feat: implement member stored card page and drawers
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
164
apps/web-antd/src/views/member/stored-card/index.vue
Normal file
164
apps/web-antd/src/views/member/stored-card/index.vue
Normal 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>
|
||||
30
apps/web-antd/src/views/member/stored-card/styles/base.less
Normal file
30
apps/web-antd/src/views/member/stored-card/styles/base.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import './base.less';
|
||||
@import './layout.less';
|
||||
@import './plan.less';
|
||||
@import './record.less';
|
||||
@import './drawer.less';
|
||||
@import './responsive.less';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
139
apps/web-antd/src/views/member/stored-card/styles/plan.less
Normal file
139
apps/web-antd/src/views/member/stored-card/styles/plan.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/web-antd/src/views/member/stored-card/types.ts
Normal file
79
apps/web-antd/src/views/member/stored-card/types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user