feat: implement member stored card page and drawers

This commit is contained in:
2026-03-04 09:15:16 +08:00
parent 128ad99d8a
commit 5e1910781b
22 changed files with 2049 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { StoredCardPlanCardViewModel } from '../types';
import { Switch } from 'ant-design-vue';
import { formatCurrency } from '../composables/stored-card-page/helpers';
const props = defineProps<{
canManage: boolean;
item: StoredCardPlanCardViewModel;
}>();
const emit = defineEmits<{
(event: 'edit', row: StoredCardPlanCardViewModel): void;
(event: 'remove', row: StoredCardPlanCardViewModel): void;
(event: 'toggleStatus', row: StoredCardPlanCardViewModel): void;
}>();
function handleToggle() {
emit('toggleStatus', props.item);
}
</script>
<template>
<div class="msc-plan-card" :class="{ 'is-disabled': item.status === 'disabled' }">
<div class="msc-plan-card-head">
<div class="msc-plan-amount"> {{ formatCurrency(item.rechargeAmount) }}</div>
<div class="msc-plan-gift"> {{ formatCurrency(item.giftAmount) }}</div>
<div class="msc-plan-arrived">到账 {{ formatCurrency(item.arrivedAmount) }}</div>
</div>
<div class="msc-plan-card-body">
<div class="msc-plan-meta-item">
累计充值 <span>{{ item.rechargeCount }}</span>
</div>
<div class="msc-plan-meta-item">
合计 <span>{{ formatCurrency(item.totalRechargeAmount) }}</span>
</div>
</div>
<div class="msc-plan-card-foot">
<Switch
:checked="item.status === 'enabled'"
:disabled="!canManage"
@change="handleToggle"
/>
<div class="msc-plan-actions">
<button
type="button"
class="g-action"
:disabled="!canManage"
@click="emit('edit', item)"
>
编辑
</button>
<button
type="button"
class="g-action g-action-danger"
:disabled="!canManage"
@click="emit('remove', item)"
>
删除
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { StoredCardPlanEditorForm } from '../types';
import { Drawer, Form, InputNumber, Switch } from 'ant-design-vue';
import { resolveGiftRatioText } from '../composables/stored-card-page/helpers';
defineProps<{
canManage: boolean;
form: StoredCardPlanEditorForm;
loading: boolean;
open: boolean;
submitText: string;
submitting: boolean;
title: string;
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'setGiftAmount', value: null | number): void;
(event: 'setRechargeAmount', value: null | number): void;
(event: 'setSortOrder', value: null | number): void;
(event: 'setStatus', value: 'disabled' | 'enabled'): void;
(event: 'submit'): void;
}>();
function parseNullableNumber(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function setStatusByChecked(checked: any) {
emit('setStatus', checked === true ? 'enabled' : 'disabled');
}
</script>
<template>
<Drawer
:open="open"
:title="title"
width="480"
class="msc-plan-drawer"
@close="emit('close')"
>
<Form layout="vertical" class="msc-plan-form">
<Form.Item label="充值金额" required>
<InputNumber
:value="form.rechargeAmount ?? undefined"
:min="0.01"
:precision="2"
:disabled="!canManage || loading"
style="width: 100%"
placeholder="如100"
@update:value="
(value) => emit('setRechargeAmount', parseNullableNumber(value))
"
/>
</Form.Item>
<Form.Item label="赠送金额" required>
<InputNumber
:value="form.giftAmount ?? undefined"
:min="0"
:precision="2"
:disabled="!canManage || loading"
style="width: 100%"
placeholder="如10"
@update:value="
(value) => emit('setGiftAmount', parseNullableNumber(value))
"
/>
<div class="msc-ratio-hint">
{{ resolveGiftRatioText(form.rechargeAmount, form.giftAmount) }}
</div>
</Form.Item>
<Form.Item label="排序">
<InputNumber
:value="form.sortOrder ?? undefined"
:min="0"
:precision="0"
:disabled="!canManage || loading"
style="width: 180px"
placeholder="数字越小越靠前1"
@update:value="
(value) => emit('setSortOrder', parseNullableNumber(value))
"
/>
<div class="msc-form-hint">数字越小排序越靠前</div>
</Form.Item>
<Form.Item label="启用状态">
<div class="msc-status-row">
<Switch
:checked="form.status === 'enabled'"
:disabled="!canManage || loading"
@change="setStatusByChecked"
/>
<span class="msc-status-text">启用后会员可选择此方案充值</span>
</div>
</Form.Item>
</Form>
<template #footer>
<div class="msc-drawer-footer">
<button type="button" class="g-btn" @click="emit('close')">取消</button>
<button
type="button"
class="g-btn g-btn-primary"
:disabled="!canManage || loading || submitting"
@click="emit('submit')"
>
{{ submitText }}
</button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import type { TableColumnType } from 'ant-design-vue';
import type { MemberStoredCardRechargeRecordDto } from '#/api/member/stored-card';
import type { StoredCardRecordPager } from '../types';
import { Pagination, Table, Tag } from 'ant-design-vue';
import {
STORED_CARD_PAYMENT_METHOD_COLOR_MAP,
STORED_CARD_PAYMENT_METHOD_TEXT_MAP,
} from '../composables/stored-card-page/constants';
import { formatCurrency } from '../composables/stored-card-page/helpers';
defineProps<{
loading: boolean;
pager: StoredCardRecordPager;
}>();
const emit = defineEmits<{
(event: 'pageChange', page: number, pageSize: number): void;
}>();
const columns: TableColumnType<MemberStoredCardRechargeRecordDto>[] = [
{
title: '充值单号',
dataIndex: 'recordNo',
key: 'recordNo',
width: 220,
},
{
title: '会员',
dataIndex: 'memberName',
key: 'memberName',
width: 170,
},
{
title: '充值金额',
dataIndex: 'rechargeAmount',
key: 'rechargeAmount',
width: 130,
},
{
title: '赠送金额',
dataIndex: 'giftAmount',
key: 'giftAmount',
width: 130,
},
{
title: '到账金额',
dataIndex: 'arrivedAmount',
key: 'arrivedAmount',
width: 130,
},
{
title: '支付方式',
dataIndex: 'paymentMethod',
key: 'paymentMethod',
width: 130,
},
{
title: '充值时间',
dataIndex: 'rechargedAt',
key: 'rechargedAt',
width: 190,
},
];
function resolvePaymentMethodColor(value: string) {
return (
STORED_CARD_PAYMENT_METHOD_COLOR_MAP[
value as keyof typeof STORED_CARD_PAYMENT_METHOD_COLOR_MAP
] ?? 'default'
);
}
function resolvePaymentMethodText(record: Record<string, any>) {
if (record.paymentMethodText) {
return record.paymentMethodText;
}
return (
STORED_CARD_PAYMENT_METHOD_TEXT_MAP[
record.paymentMethod as keyof typeof STORED_CARD_PAYMENT_METHOD_TEXT_MAP
] ?? '未知'
);
}
</script>
<template>
<div class="msc-record-table-wrap">
<Table
:columns="columns"
:data-source="pager.items"
:loading="loading"
:pagination="false"
row-key="recordId"
class="msc-record-table"
:scroll="{ x: 1020 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'recordNo'">
<div class="msc-record-no">{{ record.recordNo }}</div>
</template>
<template v-else-if="column.key === 'memberName'">
<div class="msc-record-member">
<span class="msc-record-member-name">{{ record.memberName }}</span>
<span class="msc-record-member-phone">{{
record.memberMobileMasked
}}</span>
</div>
</template>
<template v-else-if="column.key === 'rechargeAmount'">
<span class="msc-record-amount">
{{ formatCurrency(record.rechargeAmount) }}
</span>
</template>
<template v-else-if="column.key === 'giftAmount'">
<span class="msc-record-amount is-gift">
{{ formatCurrency(record.giftAmount) }}
</span>
</template>
<template v-else-if="column.key === 'arrivedAmount'">
<span class="msc-record-amount">
{{ formatCurrency(record.arrivedAmount) }}
</span>
</template>
<template v-else-if="column.key === 'paymentMethod'">
<Tag :color="resolvePaymentMethodColor(record.paymentMethod)">
{{ resolvePaymentMethodText(record) }}
</Tag>
</template>
</template>
</Table>
<div class="msc-pagination">
<Pagination
:current="pager.page"
:page-size="pager.pageSize"
:total="pager.totalCount"
show-size-changer
:show-total="(value) => `${value}`"
:page-size-options="['8', '16', '24', '40']"
@change="(page, pageSize) => emit('pageChange', page, pageSize)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { StoredCardRecordFilterForm } from '../types';
import { Button, DatePicker, Input } from 'ant-design-vue';
defineProps<{
canManage: boolean;
exporting: boolean;
filters: StoredCardRecordFilterForm;
}>();
const emit = defineEmits<{
(event: 'export'): void;
(event: 'reset'): void;
(event: 'search'): void;
(event: 'setDateRange', value: null | [any, any]): void;
(event: 'setKeyword', value: string): void;
}>();
function setDateRange(value: null | [any, any]) {
emit('setDateRange', value);
}
</script>
<template>
<div class="msc-record-toolbar">
<DatePicker.RangePicker
:value="filters.dateRange ?? undefined"
class="msc-record-range"
@update:value="setDateRange"
/>
<Input
:value="filters.keyword"
class="msc-record-keyword"
allow-clear
placeholder="搜索会员姓名/手机号"
@update:value="(value) => emit('setKeyword', String(value ?? ''))"
@press-enter="emit('search')"
/>
<Button @click="emit('search')">搜索</Button>
<Button @click="emit('reset')">重置</Button>
<span class="msc-spacer"></span>
<Button
:disabled="!canManage"
:loading="exporting"
@click="emit('export')"
>
导出
</Button>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { StoredCardPlanStatsViewModel } from '../types';
import {
formatCurrency,
formatInteger,
} from '../composables/stored-card-page/helpers';
defineProps<{
stats: StoredCardPlanStatsViewModel;
}>();
</script>
<template>
<div class="msc-stats">
<div class="msc-stat-card">
<div class="msc-stat-label">储值总额</div>
<div class="msc-stat-value">
{{ formatCurrency(stats.totalRechargeAmount) }}
</div>
</div>
<div class="msc-stat-card">
<div class="msc-stat-label">赠金总额</div>
<div class="msc-stat-value is-orange">
{{ formatCurrency(stats.totalGiftAmount) }}
</div>
</div>
<div class="msc-stat-card">
<div class="msc-stat-label">本月充值</div>
<div class="msc-stat-value is-green">
{{ formatCurrency(stats.currentMonthRechargeAmount) }}
</div>
</div>
<div class="msc-stat-card">
<div class="msc-stat-label">储值用户</div>
<div class="msc-stat-value">{{ formatInteger(stats.rechargeMemberCount) }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,58 @@
import type { MemberStoredCardPaymentMethod } from '#/api/member/stored-card';
import type { StoredCardTabKey } from '#/views/member/stored-card/types';
import dayjs from 'dayjs';
/** 会员储值卡查看权限。 */
export const MEMBER_STORED_CARD_VIEW_PERMISSION =
'tenant:member:stored-card:view';
/** 会员储值卡管理权限。 */
export const MEMBER_STORED_CARD_MANAGE_PERMISSION =
'tenant:member:stored-card:manage';
/** 页面 Tab 选项。 */
export const STORED_CARD_TAB_OPTIONS: Array<{
label: string;
value: StoredCardTabKey;
}> = [
{ label: '充值方案', value: 'plans' },
{ label: '充值记录', value: 'records' },
];
/** 方案状态选项。 */
export const STORED_CARD_PLAN_STATUS_OPTIONS = [
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
/** 支付方式文案映射。 */
export const STORED_CARD_PAYMENT_METHOD_TEXT_MAP: Record<
MemberStoredCardPaymentMethod,
string
> = {
wechat: '微信支付',
alipay: '支付宝',
cash: '现金',
card: '刷卡',
balance: '余额',
unknown: '未知',
};
/** 支付方式标签颜色。 */
export const STORED_CARD_PAYMENT_METHOD_COLOR_MAP: Record<
MemberStoredCardPaymentMethod,
string
> = {
wechat: 'green',
alipay: 'blue',
cash: 'orange',
card: 'gold',
balance: 'purple',
unknown: 'default',
};
/** 默认记录日期范围(本月 1 日至今日)。 */
export function createDefaultRecordDateRange() {
return [dayjs().startOf('month'), dayjs()] as [dayjs.Dayjs, dayjs.Dayjs];
}

View File

@@ -0,0 +1,151 @@
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
import type {
StoredCardPlanCardViewModel,
StoredCardPlanStatsViewModel,
StoredCardRecordFilterForm,
StoredCardRecordPager,
} from '#/views/member/stored-card/types';
import { message } from 'ant-design-vue';
import {
getMemberStoredCardPlanListApi,
getMemberStoredCardRechargeRecordListApi,
} from '#/api/member/stored-card';
import { getStoreListApi } from '#/api/store';
import { mapRecordFilterToQuery } from './helpers';
interface CreateDataActionsOptions {
isPlanLoading: Ref<boolean>;
isRecordLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
planRows: Ref<StoredCardPlanCardViewModel[]>;
planStats: Ref<StoredCardPlanStatsViewModel>;
recordFilterForm: StoredCardRecordFilterForm;
recordPager: Ref<StoredCardRecordPager>;
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
}
export function createDataActions(options: CreateDataActionsOptions) {
function resetPlanData() {
options.planRows.value = [];
options.planStats.value = {
totalRechargeAmount: 0,
totalGiftAmount: 0,
currentMonthRechargeAmount: 0,
rechargeMemberCount: 0,
};
}
function resetRecordData() {
options.recordPager.value = {
...options.recordPager.value,
items: [],
totalCount: 0,
};
}
async function loadStores() {
options.isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
options.stores.value = result.items ?? [];
if (options.stores.value.length === 0) {
options.selectedStoreId.value = '';
resetPlanData();
resetRecordData();
return;
}
if (!options.selectedStoreId.value) {
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
return;
}
const exists = options.stores.value.some(
(item) => item.id === options.selectedStoreId.value,
);
if (!exists) {
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
options.isStoreLoading.value = false;
}
}
async function loadPlanList() {
if (!options.selectedStoreId.value) {
resetPlanData();
return;
}
options.isPlanLoading.value = true;
try {
const result = await getMemberStoredCardPlanListApi({
storeId: options.selectedStoreId.value,
});
options.planRows.value = result.items ?? [];
options.planStats.value = result.stats ?? {
totalRechargeAmount: 0,
totalGiftAmount: 0,
currentMonthRechargeAmount: 0,
rechargeMemberCount: 0,
};
} catch (error) {
console.error(error);
resetPlanData();
message.error('加载充值方案失败');
} finally {
options.isPlanLoading.value = false;
}
}
async function loadRecordList() {
if (!options.selectedStoreId.value) {
resetRecordData();
return;
}
options.isRecordLoading.value = true;
try {
const query = mapRecordFilterToQuery(options.recordFilterForm);
const result = await getMemberStoredCardRechargeRecordListApi({
storeId: options.selectedStoreId.value,
page: options.recordPager.value.page,
pageSize: options.recordPager.value.pageSize,
...query,
});
options.recordPager.value = {
items: result.items ?? [],
page: result.page,
pageSize: result.pageSize,
totalCount: result.totalCount,
};
} catch (error) {
console.error(error);
resetRecordData();
message.error('加载充值记录失败');
} finally {
options.isRecordLoading.value = false;
}
}
return {
loadPlanList,
loadRecordList,
loadStores,
};
}

View File

@@ -0,0 +1,125 @@
import type { Ref } from 'vue';
import type {
StoredCardPlanCardViewModel,
StoredCardPlanEditorForm,
} from '#/views/member/stored-card/types';
import { message } from 'ant-design-vue';
import { saveMemberStoredCardPlanApi } from '#/api/member/stored-card';
import {
mapPlanEditorFormToSavePayload,
mapPlanToEditorForm,
resetPlanEditorForm,
} from './helpers';
interface CreateDrawerActionsOptions {
canManage: Ref<boolean>;
drawerMode: Ref<'create' | 'edit'>;
form: StoredCardPlanEditorForm;
isDrawerLoading: Ref<boolean>;
isDrawerOpen: Ref<boolean>;
isDrawerSubmitting: Ref<boolean>;
loadPlanList: () => Promise<void>;
loadRecordList: () => Promise<void>;
selectedStoreId: Ref<string>;
}
export function createDrawerActions(options: CreateDrawerActionsOptions) {
function setDrawerOpen(value: boolean) {
options.isDrawerOpen.value = value;
}
function setFormRechargeAmount(value: null | number) {
options.form.rechargeAmount = value;
}
function setFormGiftAmount(value: null | number) {
options.form.giftAmount = value;
}
function setFormSortOrder(value: null | number) {
options.form.sortOrder = value;
}
function setFormStatus(value: 'disabled' | 'enabled') {
options.form.status = value;
}
function openCreateDrawer() {
if (!options.canManage.value) {
return;
}
options.drawerMode.value = 'create';
resetPlanEditorForm(options.form);
options.isDrawerOpen.value = true;
}
function openEditDrawer(row: StoredCardPlanCardViewModel) {
if (!options.canManage.value) {
return;
}
options.drawerMode.value = 'edit';
Object.assign(options.form, mapPlanToEditorForm(row));
options.isDrawerOpen.value = true;
}
async function submitDrawer() {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
const rechargeAmount = Number(options.form.rechargeAmount ?? 0);
const giftAmount = Number(options.form.giftAmount ?? 0);
const sortOrder = Number(options.form.sortOrder ?? 100);
if (!Number.isFinite(rechargeAmount) || rechargeAmount <= 0) {
message.warning('充值金额必须大于 0');
return;
}
if (!Number.isFinite(giftAmount) || giftAmount < 0) {
message.warning('赠送金额不能小于 0');
return;
}
if (!Number.isFinite(sortOrder) || sortOrder < 0 || sortOrder > 9999) {
message.warning('排序值需在 0-9999 之间');
return;
}
options.isDrawerSubmitting.value = true;
try {
await saveMemberStoredCardPlanApi(
mapPlanEditorFormToSavePayload(options.form, options.selectedStoreId.value),
);
message.success('保存成功');
options.isDrawerOpen.value = false;
await Promise.all([options.loadPlanList(), options.loadRecordList()]);
} catch (error) {
console.error(error);
message.error('保存失败');
} finally {
options.isDrawerSubmitting.value = false;
}
}
return {
openCreateDrawer,
openEditDrawer,
setDrawerOpen,
setFormGiftAmount,
setFormRechargeAmount,
setFormSortOrder,
setFormStatus,
submitDrawer,
};
}

View File

@@ -0,0 +1,127 @@
import type {
MemberStoredCardPlanDto,
SaveMemberStoredCardPlanPayload,
} from '#/api/member/stored-card';
import type { StoredCardPlanEditorForm } from '#/views/member/stored-card/types';
import {
createDefaultStoredCardPlanEditorForm,
type StoredCardRecordFilterForm,
} from '#/views/member/stored-card/types';
/** 金额格式化。 */
export function formatCurrency(value: null | number | undefined) {
const amount = Number(value ?? 0);
if (Number.isNaN(amount)) {
return '¥0';
}
if (Math.abs(amount) >= 1000) {
return `¥${amount.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}`;
}
return `¥${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`;
}
/** 数量格式化。 */
export function formatInteger(value: null | number | undefined) {
const num = Number(value ?? 0);
if (!Number.isFinite(num)) {
return '0';
}
return Math.round(num).toLocaleString('zh-CN');
}
/** 赠送比例文案。 */
export function resolveGiftRatioText(
rechargeAmount: null | number,
giftAmount: null | number,
) {
const recharge = Number(rechargeAmount ?? 0);
const gift = Number(giftAmount ?? 0);
if (recharge <= 0 || gift <= 0) {
return '赠送比例 --';
}
const ratio = ((gift / recharge) * 100).toFixed(1);
return `赠送比例 ${ratio}%`;
}
/** 表单转保存 DTO。 */
export function mapPlanEditorFormToSavePayload(
form: StoredCardPlanEditorForm,
storeId: string,
): SaveMemberStoredCardPlanPayload {
return {
storeId,
planId: form.planId || undefined,
rechargeAmount: Number(form.rechargeAmount ?? 0),
giftAmount: Number(form.giftAmount ?? 0),
sortOrder: Number(form.sortOrder ?? 100),
status: form.status,
};
}
/** 方案转编辑表单。 */
export function mapPlanToEditorForm(
plan: MemberStoredCardPlanDto,
): StoredCardPlanEditorForm {
return {
planId: plan.planId,
rechargeAmount: plan.rechargeAmount,
giftAmount: plan.giftAmount,
sortOrder: plan.sortOrder,
status: plan.status,
};
}
/** 重置编辑表单。 */
export function resetPlanEditorForm(form: StoredCardPlanEditorForm) {
const defaults = createDefaultStoredCardPlanEditorForm();
form.planId = defaults.planId;
form.rechargeAmount = defaults.rechargeAmount;
form.giftAmount = defaults.giftAmount;
form.sortOrder = defaults.sortOrder;
form.status = defaults.status;
}
/** 筛选项转查询参数。 */
export function mapRecordFilterToQuery(
filterForm: StoredCardRecordFilterForm,
): { endDate?: string; keyword?: string; startDate?: string } {
const keyword = filterForm.keyword.trim();
if (!filterForm.dateRange) {
return {
keyword: keyword || undefined,
};
}
return {
startDate: filterForm.dateRange[0].format('YYYY-MM-DD'),
endDate: filterForm.dateRange[1].format('YYYY-MM-DD'),
keyword: keyword || undefined,
};
}
/** base64 下载。 */
export function downloadBase64File(fileName: string, fileContentBase64: string) {
const binary = atob(fileContentBase64);
const length = binary.length;
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index++) {
bytes[index] = binary.codePointAt(index) ?? 0;
}
const blob = new Blob([bytes], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,81 @@
import type { Ref } from 'vue';
import type { StoredCardPlanCardViewModel } from '#/views/member/stored-card/types';
import { Modal, message } from 'ant-design-vue';
import {
changeMemberStoredCardPlanStatusApi,
deleteMemberStoredCardPlanApi,
} from '#/api/member/stored-card';
interface CreatePlanActionsOptions {
canManage: Ref<boolean>;
loadPlanList: () => Promise<void>;
loadRecordList: () => Promise<void>;
selectedStoreId: Ref<string>;
}
export function createPlanActions(options: CreatePlanActionsOptions) {
async function toggleStatus(row: StoredCardPlanCardViewModel) {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
const targetStatus = row.status === 'enabled' ? 'disabled' : 'enabled';
try {
await changeMemberStoredCardPlanStatusApi({
storeId: options.selectedStoreId.value,
planId: row.planId,
status: targetStatus,
});
message.success(targetStatus === 'enabled' ? '已启用方案' : '已停用方案');
await options.loadPlanList();
} catch (error) {
console.error(error);
message.error('状态更新失败');
}
}
function removePlan(row: StoredCardPlanCardViewModel) {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
Modal.confirm({
title: '删除充值方案',
content: '删除后不可恢复,确认继续吗?',
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await deleteMemberStoredCardPlanApi({
storeId: options.selectedStoreId.value,
planId: row.planId,
});
message.success('删除成功');
await Promise.all([options.loadPlanList(), options.loadRecordList()]);
} catch (error) {
console.error(error);
message.error('删除失败');
}
},
});
}
return {
removePlan,
toggleStatus,
};
}

View File

@@ -0,0 +1,261 @@
import type { StoreListItemDto } from '#/api/store';
import type {
StoredCardPlanCardViewModel,
StoredCardTabKey,
} from '#/views/member/stored-card/types';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useAccessStore } from '@vben/stores';
import { exportMemberStoredCardRechargeRecordApi } from '#/api/member/stored-card';
import {
createDefaultStoredCardPlanEditorForm,
createDefaultStoredCardPlanStats,
createDefaultStoredCardRecordFilterForm,
createDefaultStoredCardRecordPager,
} from '../types';
import {
createDataActions,
} from './stored-card-page/data-actions';
import {
createDrawerActions,
} from './stored-card-page/drawer-actions';
import {
MEMBER_STORED_CARD_MANAGE_PERMISSION,
MEMBER_STORED_CARD_VIEW_PERMISSION,
STORED_CARD_TAB_OPTIONS,
createDefaultRecordDateRange,
} from './stored-card-page/constants';
import {
downloadBase64File,
mapRecordFilterToQuery,
} from './stored-card-page/helpers';
import { createPlanActions } from './stored-card-page/plan-actions';
import { message } from 'ant-design-vue';
export function useMemberStoredCardPage() {
const accessStore = useAccessStore();
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const activeTab = ref<StoredCardTabKey>('plans');
const planRows = ref<StoredCardPlanCardViewModel[]>([]);
const planStats = ref(createDefaultStoredCardPlanStats());
const isPlanLoading = ref(false);
const recordFilterForm = reactive(createDefaultStoredCardRecordFilterForm());
recordFilterForm.dateRange = createDefaultRecordDateRange();
const recordPager = ref(createDefaultStoredCardRecordPager());
const isRecordLoading = ref(false);
const isExporting = ref(false);
const drawerMode = ref<'create' | 'edit'>('create');
const form = reactive(createDefaultStoredCardPlanEditorForm());
const isDrawerOpen = ref(false);
const isDrawerLoading = ref(false);
const isDrawerSubmitting = ref(false);
const accessCodeSet = computed(
() => new Set((accessStore.accessCodes ?? []).map(String)),
);
const canManage = computed(() =>
accessCodeSet.value.has(MEMBER_STORED_CARD_MANAGE_PERMISSION),
);
const canView = computed(
() =>
canManage.value ||
accessCodeSet.value.has(MEMBER_STORED_CARD_VIEW_PERMISSION),
);
const hasStore = computed(() => stores.value.length > 0);
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const drawerTitle = computed(() =>
drawerMode.value === 'create' ? '添加充值方案' : '编辑充值方案',
);
const drawerSubmitText = computed(() => '保存');
const { loadPlanList, loadRecordList, loadStores } = createDataActions({
stores,
selectedStoreId,
isStoreLoading,
isPlanLoading,
planRows,
planStats,
isRecordLoading,
recordFilterForm,
recordPager,
});
const {
openCreateDrawer,
openEditDrawer,
setDrawerOpen,
setFormGiftAmount,
setFormRechargeAmount,
setFormSortOrder,
setFormStatus,
submitDrawer,
} = createDrawerActions({
canManage,
drawerMode,
form,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
selectedStoreId,
loadPlanList,
loadRecordList,
});
const { removePlan, toggleStatus } = createPlanActions({
canManage,
selectedStoreId,
loadPlanList,
loadRecordList,
});
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setActiveTab(value: StoredCardTabKey) {
activeTab.value = value;
}
function setRecordKeyword(value: string) {
recordFilterForm.keyword = value;
}
function setRecordDateRange(value: null | [any, any]) {
if (!value) {
recordFilterForm.dateRange = null;
return;
}
if (value.length === 2) {
recordFilterForm.dateRange = value;
}
}
async function applyRecordFilters() {
recordPager.value = {
...recordPager.value,
page: 1,
};
await loadRecordList();
}
async function resetRecordFilters() {
recordFilterForm.keyword = '';
recordFilterForm.dateRange = createDefaultRecordDateRange();
recordPager.value = {
...recordPager.value,
page: 1,
};
await loadRecordList();
}
async function handleRecordPageChange(page: number, pageSize: number) {
recordPager.value = {
...recordPager.value,
page,
pageSize,
};
await loadRecordList();
}
async function exportRecords() {
if (!selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
isExporting.value = true;
try {
const query = mapRecordFilterToQuery(recordFilterForm);
const result = await exportMemberStoredCardRechargeRecordApi({
storeId: selectedStoreId.value,
...query,
});
downloadBase64File(result.fileName, result.fileContentBase64);
message.success(`导出成功,共 ${result.totalCount}`);
} catch (error) {
console.error(error);
message.error('导出失败');
} finally {
isExporting.value = false;
}
}
watch(selectedStoreId, async () => {
recordPager.value = {
...recordPager.value,
page: 1,
pageSize: 8,
};
await Promise.all([loadPlanList(), loadRecordList()]);
});
onMounted(async () => {
await loadStores();
if (selectedStoreId.value) {
await Promise.all([loadPlanList(), loadRecordList()]);
}
});
return {
activeTab,
applyRecordFilters,
canManage,
canView,
drawerMode,
drawerSubmitText,
drawerTitle,
exportRecords,
form,
handleRecordPageChange,
hasStore,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
isExporting,
isPlanLoading,
isRecordLoading,
isStoreLoading,
openCreateDrawer,
openEditDrawer,
planRows,
planStats,
recordFilterForm,
recordPager,
removePlan,
resetRecordFilters,
selectedStoreId,
setActiveTab,
setDrawerOpen,
setFormGiftAmount,
setFormRechargeAmount,
setFormSortOrder,
setFormStatus,
setRecordDateRange,
setRecordKeyword,
setSelectedStoreId,
storeOptions,
submitDrawer,
tabOptions: STORED_CARD_TAB_OPTIONS,
toggleStatus,
};
}

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { Empty, Segmented, Select, Spin } from 'ant-design-vue';
import StoredCardPlanCard from './components/StoredCardPlanCard.vue';
import StoredCardPlanEditorDrawer from './components/StoredCardPlanEditorDrawer.vue';
import StoredCardRecordTable from './components/StoredCardRecordTable.vue';
import StoredCardRecordToolbar from './components/StoredCardRecordToolbar.vue';
import StoredCardStatsCards from './components/StoredCardStatsCards.vue';
import { useMemberStoredCardPage } from './composables/useMemberStoredCardPage';
const {
activeTab,
applyRecordFilters,
canManage,
canView,
drawerSubmitText,
drawerTitle,
exportRecords,
form,
handleRecordPageChange,
hasStore,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
isExporting,
isPlanLoading,
isRecordLoading,
isStoreLoading,
openCreateDrawer,
openEditDrawer,
planRows,
planStats,
recordFilterForm,
recordPager,
removePlan,
resetRecordFilters,
selectedStoreId,
setActiveTab,
setDrawerOpen,
setFormGiftAmount,
setFormRechargeAmount,
setFormSortOrder,
setFormStatus,
setRecordDateRange,
setRecordKeyword,
setSelectedStoreId,
storeOptions,
submitDrawer,
tabOptions,
toggleStatus,
} = useMemberStoredCardPage();
</script>
<template>
<Page title="储值卡" content-class="page-member-stored-card">
<div class="msc-page">
<div class="msc-toolbar msc-toolbar-top">
<Select
class="msc-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
placeholder="请选择门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<Segmented
class="msc-segmented"
:value="activeTab"
:options="tabOptions"
@update:value="
(value) => setActiveTab((value as 'plans' | 'records') || 'plans')
"
/>
<span v-if="canView && !canManage" class="msc-readonly-tip">
当前为只读权限
</span>
</div>
<Empty v-if="!canView" description="暂无储值卡页面访问权限" />
<div v-else-if="!hasStore" class="msc-empty">
<Empty description="暂无门店,请先创建门店" />
</div>
<template v-else>
<section v-show="activeTab === 'plans'" class="msc-tab-panel">
<Spin :spinning="isPlanLoading">
<StoredCardStatsCards :stats="planStats" />
<div class="msc-section-title">充值方案</div>
<div v-if="planRows.length > 0" class="msc-plan-grid">
<StoredCardPlanCard
v-for="item in planRows"
:key="item.planId"
:item="item"
:can-manage="canManage"
@edit="openEditDrawer"
@toggle-status="toggleStatus"
@remove="removePlan"
/>
</div>
<div v-else class="msc-empty">
<Empty description="暂无充值方案" />
</div>
<button
type="button"
class="g-btn g-btn-primary msc-create-btn"
:disabled="!canManage"
@click="openCreateDrawer"
>
添加充值方案
</button>
</Spin>
</section>
<section v-show="activeTab === 'records'" class="msc-tab-panel">
<StoredCardRecordToolbar
:filters="recordFilterForm"
:can-manage="canManage"
:exporting="isExporting"
@set-keyword="setRecordKeyword"
@set-date-range="setRecordDateRange"
@search="applyRecordFilters"
@reset="resetRecordFilters"
@export="exportRecords"
/>
<StoredCardRecordTable
:loading="isRecordLoading"
:pager="recordPager"
@page-change="handleRecordPageChange"
/>
</section>
</template>
<StoredCardPlanEditorDrawer
:open="isDrawerOpen"
:title="drawerTitle"
:loading="isDrawerLoading"
:submitting="isDrawerSubmitting"
:submit-text="drawerSubmitText"
:can-manage="canManage"
:form="form"
@close="setDrawerOpen(false)"
@set-recharge-amount="setFormRechargeAmount"
@set-gift-amount="setFormGiftAmount"
@set-sort-order="setFormSortOrder"
@set-status="setFormStatus"
@submit="submitDrawer"
/>
</div>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,30 @@
.page-member-stored-card {
.msc-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.msc-tab-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.msc-empty {
border-radius: 12px;
padding: 40px 20px;
background: #fff;
box-shadow: 0 3px 10px rgb(16 24 40 / 6%);
}
.msc-readonly-tip {
margin-left: auto;
color: #6b7280;
font-size: 12px;
}
.msc-spacer {
flex: 1;
}
}

View File

@@ -0,0 +1,52 @@
.page-member-stored-card {
.msc-plan-drawer {
.ant-drawer-header {
padding: 16px 20px;
}
.ant-drawer-body {
padding: 18px 20px;
}
}
.msc-plan-form {
.ant-form-item {
margin-bottom: 16px;
}
}
.msc-ratio-hint {
display: inline-flex;
align-items: center;
border-radius: 6px;
margin-top: 8px;
padding: 4px 10px;
color: #1677ff;
font-size: 12px;
font-weight: 500;
background: rgb(22 119 255 / 10%);
}
.msc-form-hint {
margin-top: 6px;
color: #9ca3af;
font-size: 12px;
}
.msc-status-row {
display: flex;
align-items: center;
gap: 10px;
}
.msc-status-text {
color: #4b5563;
font-size: 13px;
}
.msc-drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}

View File

@@ -0,0 +1,6 @@
@import './base.less';
@import './layout.less';
@import './plan.less';
@import './record.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -0,0 +1,25 @@
.page-member-stored-card {
.msc-toolbar {
display: flex;
align-items: center;
gap: 12px;
border-radius: 12px;
padding: 12px 14px;
background: #fff;
box-shadow: 0 2px 8px rgb(15 23 42 / 7%);
}
.msc-toolbar-top {
flex-wrap: wrap;
}
.msc-store-select {
width: 220px;
min-width: 220px;
}
.msc-segmented {
--ant-segmented-item-selected-bg: #fff;
--ant-segmented-item-selected-color: #1677ff;
}
}

View File

@@ -0,0 +1,139 @@
.page-member-stored-card {
.msc-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.msc-stat-card {
border-radius: 12px;
padding: 16px 18px;
background: #fff;
box-shadow: 0 3px 10px rgb(16 24 40 / 6%);
}
.msc-stat-label {
margin-bottom: 6px;
color: #9ca3af;
font-size: 13px;
}
.msc-stat-value {
color: #0f172a;
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.msc-stat-value.is-orange {
color: #fa8c16;
}
.msc-stat-value.is-green {
color: #16a34a;
}
.msc-section-title {
border-left: 3px solid #1677ff;
padding-left: 10px;
color: #111827;
font-size: 15px;
font-weight: 600;
}
.msc-plan-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.msc-plan-card {
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
background: #fff;
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.msc-plan-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgb(15 23 42 / 12%);
}
.msc-plan-card.is-disabled {
opacity: 0.58;
}
.msc-plan-card-head {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 22px 20px 18px;
color: #fff;
background: linear-gradient(135deg, #0f66d8 0%, #4592ff 100%);
}
.msc-plan-card.is-disabled .msc-plan-card-head {
background: linear-gradient(135deg, #9ca3af 0%, #b0b8c4 100%);
}
.msc-plan-amount {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.5px;
}
.msc-plan-gift {
display: inline-flex;
align-self: flex-start;
border-radius: 999px;
padding: 3px 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
background: rgb(255 255 255 / 22%);
}
.msc-plan-arrived {
font-size: 13px;
opacity: 0.92;
}
.msc-plan-card-body {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
}
.msc-plan-meta-item {
color: #9ca3af;
font-size: 12px;
}
.msc-plan-meta-item span {
margin-left: 2px;
color: #4b5563;
font-weight: 600;
}
.msc-plan-card-foot {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #f3f4f6;
padding: 12px 20px 14px;
}
.msc-plan-actions {
display: inline-flex;
gap: 8px;
}
.msc-create-btn {
align-self: flex-start;
}
}

View File

@@ -0,0 +1,82 @@
.page-member-stored-card {
.msc-record-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
border-radius: 12px;
padding: 12px 14px;
background: #fff;
box-shadow: 0 2px 8px rgb(15 23 42 / 7%);
}
.msc-record-range {
width: 280px;
}
.msc-record-keyword {
width: 220px;
}
.msc-record-table-wrap {
border-radius: 12px;
padding: 0;
background: #fff;
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
overflow: hidden;
}
.msc-record-table {
.ant-table-thead > tr > th {
background: #f8fafc;
color: #475569;
font-size: 13px;
font-weight: 600;
}
.ant-table-tbody > tr > td {
color: #111827;
font-size: 13px;
vertical-align: middle;
}
}
.msc-record-no {
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.msc-record-member {
display: flex;
flex-direction: column;
line-height: 1.2;
gap: 2px;
}
.msc-record-member-name {
color: #111827;
font-weight: 600;
}
.msc-record-member-phone {
color: #94a3b8;
font-size: 12px;
}
.msc-record-amount {
color: #111827;
font-weight: 600;
}
.msc-record-amount.is-gift {
color: #f59e0b;
font-weight: 500;
}
.msc-pagination {
display: flex;
justify-content: flex-end;
padding: 14px 16px 16px;
}
}

View File

@@ -0,0 +1,34 @@
.page-member-stored-card {
@media (max-width: 1280px) {
.msc-plan-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 992px) {
.msc-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.msc-record-range,
.msc-record-keyword,
.msc-store-select {
width: 100%;
min-width: 0;
}
}
@media (max-width: 768px) {
.msc-plan-grid {
grid-template-columns: 1fr;
}
.msc-stats {
grid-template-columns: 1fr;
}
.msc-toolbar {
align-items: stretch;
}
}
}

View File

@@ -0,0 +1,79 @@
import type { Dayjs } from 'dayjs';
import type {
MemberStoredCardPlanDto,
MemberStoredCardPlanStatsDto,
MemberStoredCardPlanStatus,
MemberStoredCardRechargeRecordDto,
} from '#/api/member/stored-card';
/** 页面主 Tab。 */
export type StoredCardTabKey = 'plans' | 'records';
/** 方案编辑表单。 */
export interface StoredCardPlanEditorForm {
giftAmount: null | number;
planId: string;
rechargeAmount: null | number;
sortOrder: null | number;
status: MemberStoredCardPlanStatus;
}
/** 充值记录筛选表单。 */
export interface StoredCardRecordFilterForm {
dateRange: [Dayjs, Dayjs] | null;
keyword: string;
}
/** 充值记录分页。 */
export interface StoredCardRecordPager {
items: MemberStoredCardRechargeRecordDto[];
page: number;
pageSize: number;
totalCount: number;
}
/** 方案卡片视图模型。 */
export type StoredCardPlanCardViewModel = MemberStoredCardPlanDto;
/** 方案统计视图模型。 */
export type StoredCardPlanStatsViewModel = MemberStoredCardPlanStatsDto;
/** 创建默认方案编辑表单。 */
export function createDefaultStoredCardPlanEditorForm(): StoredCardPlanEditorForm {
return {
planId: '',
rechargeAmount: null,
giftAmount: null,
sortOrder: 100,
status: 'enabled',
};
}
/** 创建默认充值记录筛选表单。 */
export function createDefaultStoredCardRecordFilterForm(): StoredCardRecordFilterForm {
return {
dateRange: null,
keyword: '',
};
}
/** 创建默认充值记录分页状态。 */
export function createDefaultStoredCardRecordPager(): StoredCardRecordPager {
return {
items: [],
page: 1,
pageSize: 8,
totalCount: 0,
};
}
/** 创建默认方案统计。 */
export function createDefaultStoredCardPlanStats(): StoredCardPlanStatsViewModel {
return {
totalRechargeAmount: 0,
totalGiftAmount: 0,
currentMonthRechargeAmount: 0,
rechargeMemberCount: 0,
};
}