feat: implement member stored card page and drawers
This commit is contained in:
@@ -281,3 +281,5 @@ export async function getMemberCouponPickerApi(params: {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * from './stored-card';
|
||||||
|
|||||||
205
apps/web-antd/src/api/member/stored-card.ts
Normal file
205
apps/web-antd/src/api/member/stored-card.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:会员中心储值卡 API 与 DTO 定义。
|
||||||
|
*/
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/** 储值卡方案状态。 */
|
||||||
|
export type MemberStoredCardPlanStatus = 'disabled' | 'enabled';
|
||||||
|
|
||||||
|
/** 储值卡充值支付方式。 */
|
||||||
|
export type MemberStoredCardPaymentMethod =
|
||||||
|
| 'alipay'
|
||||||
|
| 'balance'
|
||||||
|
| 'card'
|
||||||
|
| 'cash'
|
||||||
|
| 'unknown'
|
||||||
|
| 'wechat';
|
||||||
|
|
||||||
|
/** 储值卡方案列表查询。 */
|
||||||
|
export interface MemberStoredCardPlanListQuery {
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 储值卡方案统计。 */
|
||||||
|
export interface MemberStoredCardPlanStatsDto {
|
||||||
|
currentMonthRechargeAmount: number;
|
||||||
|
rechargeMemberCount: number;
|
||||||
|
totalGiftAmount: number;
|
||||||
|
totalRechargeAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 储值卡方案项。 */
|
||||||
|
export interface MemberStoredCardPlanDto {
|
||||||
|
arrivedAmount: number;
|
||||||
|
giftAmount: number;
|
||||||
|
planId: string;
|
||||||
|
rechargeAmount: number;
|
||||||
|
rechargeCount: number;
|
||||||
|
sortOrder: number;
|
||||||
|
status: MemberStoredCardPlanStatus;
|
||||||
|
totalRechargeAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 储值卡方案列表结果。 */
|
||||||
|
export interface MemberStoredCardPlanListResultDto {
|
||||||
|
items: MemberStoredCardPlanDto[];
|
||||||
|
stats: MemberStoredCardPlanStatsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存储值卡方案请求。 */
|
||||||
|
export interface SaveMemberStoredCardPlanPayload {
|
||||||
|
giftAmount: number;
|
||||||
|
planId?: string;
|
||||||
|
rechargeAmount: number;
|
||||||
|
sortOrder: number;
|
||||||
|
status: MemberStoredCardPlanStatus;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改储值卡方案状态请求。 */
|
||||||
|
export interface ChangeMemberStoredCardPlanStatusPayload {
|
||||||
|
planId: string;
|
||||||
|
status: MemberStoredCardPlanStatus;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除储值卡方案请求。 */
|
||||||
|
export interface DeleteMemberStoredCardPlanPayload {
|
||||||
|
planId: string;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 充值记录列表查询。 */
|
||||||
|
export interface MemberStoredCardRechargeRecordListQuery {
|
||||||
|
endDate?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
startDate?: string;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 储值卡充值记录项。 */
|
||||||
|
export interface MemberStoredCardRechargeRecordDto {
|
||||||
|
arrivedAmount: number;
|
||||||
|
giftAmount: number;
|
||||||
|
memberId: string;
|
||||||
|
memberMobileMasked: string;
|
||||||
|
memberName: string;
|
||||||
|
paymentMethod: MemberStoredCardPaymentMethod;
|
||||||
|
paymentMethodText: string;
|
||||||
|
planId?: string;
|
||||||
|
rechargedAt: string;
|
||||||
|
rechargeAmount: number;
|
||||||
|
recordId: string;
|
||||||
|
recordNo: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 储值卡充值记录分页结果。 */
|
||||||
|
export interface MemberStoredCardRechargeRecordListResultDto {
|
||||||
|
items: MemberStoredCardRechargeRecordDto[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 储值卡充值记录导出查询。 */
|
||||||
|
export interface ExportMemberStoredCardRechargeRecordQuery {
|
||||||
|
endDate?: string;
|
||||||
|
keyword?: string;
|
||||||
|
startDate?: string;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 储值卡充值记录导出结果。 */
|
||||||
|
export interface MemberStoredCardRechargeRecordExportDto {
|
||||||
|
fileContentBase64: string;
|
||||||
|
fileName: string;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入储值卡充值记录请求。 */
|
||||||
|
export interface WriteMemberStoredCardRechargeRecordPayload {
|
||||||
|
giftAmount: number;
|
||||||
|
memberId: string;
|
||||||
|
paymentMethod: Exclude<MemberStoredCardPaymentMethod, 'unknown'>;
|
||||||
|
planId?: string;
|
||||||
|
rechargedAt?: string;
|
||||||
|
rechargeAmount: number;
|
||||||
|
remark?: string;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询储值卡方案列表。 */
|
||||||
|
export async function getMemberStoredCardPlanListApi(
|
||||||
|
params: MemberStoredCardPlanListQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<MemberStoredCardPlanListResultDto>(
|
||||||
|
'/member/stored-card/plan/list',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存储值卡方案。 */
|
||||||
|
export async function saveMemberStoredCardPlanApi(
|
||||||
|
payload: SaveMemberStoredCardPlanPayload,
|
||||||
|
) {
|
||||||
|
return requestClient.post<MemberStoredCardPlanDto>(
|
||||||
|
'/member/stored-card/plan/save',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改储值卡方案状态。 */
|
||||||
|
export async function changeMemberStoredCardPlanStatusApi(
|
||||||
|
payload: ChangeMemberStoredCardPlanStatusPayload,
|
||||||
|
) {
|
||||||
|
return requestClient.post<MemberStoredCardPlanDto>(
|
||||||
|
'/member/stored-card/plan/status',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除储值卡方案。 */
|
||||||
|
export async function deleteMemberStoredCardPlanApi(
|
||||||
|
payload: DeleteMemberStoredCardPlanPayload,
|
||||||
|
) {
|
||||||
|
return requestClient.post('/member/stored-card/plan/delete', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询储值卡充值记录。 */
|
||||||
|
export async function getMemberStoredCardRechargeRecordListApi(
|
||||||
|
params: MemberStoredCardRechargeRecordListQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<MemberStoredCardRechargeRecordListResultDto>(
|
||||||
|
'/member/stored-card/record/list',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出储值卡充值记录。 */
|
||||||
|
export async function exportMemberStoredCardRechargeRecordApi(
|
||||||
|
params: ExportMemberStoredCardRechargeRecordQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<MemberStoredCardRechargeRecordExportDto>(
|
||||||
|
'/member/stored-card/record/export',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入储值卡充值记录。 */
|
||||||
|
export async function writeMemberStoredCardRechargeRecordApi(
|
||||||
|
payload: WriteMemberStoredCardRechargeRecordPayload,
|
||||||
|
) {
|
||||||
|
return requestClient.post<MemberStoredCardRechargeRecordDto>(
|
||||||
|
'/member/stored-card/record/write',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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