From 61343b72b9bb01aadf781d25abbf99d6b41239f7 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 4 Mar 2026 11:03:37 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E4=BA=A4=E6=98=93=E6=B5=81=E6=B0=B4=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8E=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/finance/index.ts | 159 ++++++++++++ .../components/RelatedOrderDrawer.vue | 181 ++++++++++++++ .../components/TransactionDetailDrawer.vue | 206 +++++++++++++++ .../components/TransactionExportModal.vue | 127 ++++++++++ .../components/TransactionFilterBar.vue | 186 ++++++++++++++ .../components/TransactionStatsBar.vue | 50 ++++ .../components/TransactionTableCard.vue | 177 +++++++++++++ .../composables/transaction-page/constants.ts | 89 +++++++ .../transaction-page/data-actions.ts | 126 ++++++++++ .../transaction-page/drawer-actions.ts | 78 ++++++ .../transaction-page/export-actions.ts | 67 +++++ .../transaction-page/filter-actions.ts | 89 +++++++ .../composables/transaction-page/helpers.ts | 197 +++++++++++++++ .../composables/useFinanceTransactionPage.ts | 236 ++++++++++++++++++ .../src/views/finance/transaction/index.vue | 127 ++++++++++ .../finance/transaction/styles/base.less | 18 ++ .../finance/transaction/styles/drawer.less | 193 ++++++++++++++ .../finance/transaction/styles/index.less | 9 + .../finance/transaction/styles/layout.less | 134 ++++++++++ .../finance/transaction/styles/modal.less | 51 ++++ .../transaction/styles/responsive.less | 59 +++++ .../finance/transaction/styles/table.less | 74 ++++++ .../src/views/finance/transaction/types.ts | 70 ++++++ 23 files changed, 2703 insertions(+) create mode 100644 apps/web-antd/src/api/finance/index.ts create mode 100644 apps/web-antd/src/views/finance/transaction/components/RelatedOrderDrawer.vue create mode 100644 apps/web-antd/src/views/finance/transaction/components/TransactionDetailDrawer.vue create mode 100644 apps/web-antd/src/views/finance/transaction/components/TransactionExportModal.vue create mode 100644 apps/web-antd/src/views/finance/transaction/components/TransactionFilterBar.vue create mode 100644 apps/web-antd/src/views/finance/transaction/components/TransactionStatsBar.vue create mode 100644 apps/web-antd/src/views/finance/transaction/components/TransactionTableCard.vue create mode 100644 apps/web-antd/src/views/finance/transaction/composables/transaction-page/constants.ts create mode 100644 apps/web-antd/src/views/finance/transaction/composables/transaction-page/data-actions.ts create mode 100644 apps/web-antd/src/views/finance/transaction/composables/transaction-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/finance/transaction/composables/transaction-page/export-actions.ts create mode 100644 apps/web-antd/src/views/finance/transaction/composables/transaction-page/filter-actions.ts create mode 100644 apps/web-antd/src/views/finance/transaction/composables/transaction-page/helpers.ts create mode 100644 apps/web-antd/src/views/finance/transaction/composables/useFinanceTransactionPage.ts create mode 100644 apps/web-antd/src/views/finance/transaction/index.vue create mode 100644 apps/web-antd/src/views/finance/transaction/styles/base.less create mode 100644 apps/web-antd/src/views/finance/transaction/styles/drawer.less create mode 100644 apps/web-antd/src/views/finance/transaction/styles/index.less create mode 100644 apps/web-antd/src/views/finance/transaction/styles/layout.less create mode 100644 apps/web-antd/src/views/finance/transaction/styles/modal.less create mode 100644 apps/web-antd/src/views/finance/transaction/styles/responsive.less create mode 100644 apps/web-antd/src/views/finance/transaction/styles/table.less create mode 100644 apps/web-antd/src/views/finance/transaction/types.ts diff --git a/apps/web-antd/src/api/finance/index.ts b/apps/web-antd/src/api/finance/index.ts new file mode 100644 index 0000000..44f9611 --- /dev/null +++ b/apps/web-antd/src/api/finance/index.ts @@ -0,0 +1,159 @@ +/** + * 文件职责:财务中心交易流水 API 契约与请求封装。 + */ +import { requestClient } from '#/api/request'; + +/** 交易类型筛选值。 */ +export type FinanceTransactionTypeFilter = + | 'all' + | 'income' + | 'point_redeem' + | 'refund' + | 'stored_card_recharge'; + +/** 交易渠道筛选值。 */ +export type FinanceTransactionChannelFilter = + | 'all' + | 'delivery' + | 'dine_in' + | 'pickup'; + +/** 交易支付方式筛选值。 */ +export type FinanceTransactionPaymentFilter = + | 'alipay' + | 'all' + | 'balance' + | 'card' + | 'cash' + | 'wechat'; + +/** 交易流水筛选参数。 */ +export interface FinanceTransactionFilterQuery { + channel?: FinanceTransactionChannelFilter; + endDate?: string; + keyword?: string; + paymentMethod?: FinanceTransactionPaymentFilter; + startDate?: string; + storeId: string; + type?: FinanceTransactionTypeFilter; +} + +/** 交易流水列表查询参数。 */ +export interface FinanceTransactionListQuery extends FinanceTransactionFilterQuery { + page: number; + pageSize: number; +} + +/** 交易流水列表行。 */ +export interface FinanceTransactionListItemDto { + amount: number; + channel: string; + isIncome: boolean; + occurredAt: string; + orderNo?: string; + paymentMethod: string; + remark: string; + transactionId: string; + transactionNo: string; + type: string; + typeText: string; +} + +/** 交易流水列表结果。 */ +export interface FinanceTransactionListResultDto { + items: FinanceTransactionListItemDto[]; + page: number; + pageIncomeAmount: number; + pageRefundAmount: number; + pageSize: number; + total: number; +} + +/** 交易流水统计结果。 */ +export interface FinanceTransactionStatsDto { + totalCount: number; + totalIncome: number; + totalRefund: number; +} + +/** 交易流水详情。 */ +export interface FinanceTransactionDetailDto { + amount: number; + arrivedAmount?: number; + channel: string; + customerName: string; + customerPhone: string; + giftAmount?: number; + memberMobileMasked?: string; + memberName?: string; + occurredAt: string; + orderNo?: string; + paymentMethod: string; + pointBalanceAfterChange?: number; + pointChangeAmount?: number; + rechargeAmount?: number; + refundNo?: string; + refundReason?: string; + remark: string; + storeId: string; + transactionId: string; + transactionNo: string; + type: string; + typeText: string; +} + +/** 交易流水导出结果。 */ +export interface FinanceTransactionExportDto { + fileContentBase64: string; + fileName: string; + totalCount: number; +} + +/** 查询交易流水列表。 */ +export async function getFinanceTransactionListApi( + params: FinanceTransactionListQuery, +) { + return requestClient.get( + '/finance/transaction/list', + { + params, + }, + ); +} + +/** 查询交易流水统计。 */ +export async function getFinanceTransactionStatsApi( + params: FinanceTransactionFilterQuery, +) { + return requestClient.get( + '/finance/transaction/stats', + { + params, + }, + ); +} + +/** 查询交易流水详情。 */ +export async function getFinanceTransactionDetailApi(params: { + storeId: string; + transactionId: string; +}) { + return requestClient.get( + '/finance/transaction/detail', + { + params, + }, + ); +} + +/** 导出交易流水 CSV。 */ +export async function exportFinanceTransactionCsvApi( + params: FinanceTransactionFilterQuery, +) { + return requestClient.get( + '/finance/transaction/export', + { + params, + }, + ); +} diff --git a/apps/web-antd/src/views/finance/transaction/components/RelatedOrderDrawer.vue b/apps/web-antd/src/views/finance/transaction/components/RelatedOrderDrawer.vue new file mode 100644 index 0000000..97eb2c3 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/components/RelatedOrderDrawer.vue @@ -0,0 +1,181 @@ + + + diff --git a/apps/web-antd/src/views/finance/transaction/components/TransactionDetailDrawer.vue b/apps/web-antd/src/views/finance/transaction/components/TransactionDetailDrawer.vue new file mode 100644 index 0000000..c6d1a15 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/components/TransactionDetailDrawer.vue @@ -0,0 +1,206 @@ + + + diff --git a/apps/web-antd/src/views/finance/transaction/components/TransactionExportModal.vue b/apps/web-antd/src/views/finance/transaction/components/TransactionExportModal.vue new file mode 100644 index 0000000..b63b179 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/components/TransactionExportModal.vue @@ -0,0 +1,127 @@ + + + diff --git a/apps/web-antd/src/views/finance/transaction/components/TransactionFilterBar.vue b/apps/web-antd/src/views/finance/transaction/components/TransactionFilterBar.vue new file mode 100644 index 0000000..d1aa384 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/components/TransactionFilterBar.vue @@ -0,0 +1,186 @@ + + + diff --git a/apps/web-antd/src/views/finance/transaction/components/TransactionStatsBar.vue b/apps/web-antd/src/views/finance/transaction/components/TransactionStatsBar.vue new file mode 100644 index 0000000..5eb0930 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/components/TransactionStatsBar.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/web-antd/src/views/finance/transaction/components/TransactionTableCard.vue b/apps/web-antd/src/views/finance/transaction/components/TransactionTableCard.vue new file mode 100644 index 0000000..37795fb --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/components/TransactionTableCard.vue @@ -0,0 +1,177 @@ + + + diff --git a/apps/web-antd/src/views/finance/transaction/composables/transaction-page/constants.ts b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/constants.ts new file mode 100644 index 0000000..23c61a8 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/constants.ts @@ -0,0 +1,89 @@ +import type { + FinanceTransactionFilterState, + FinanceTransactionPageSummaryState, + OptionItem, + QuickRangeOption, +} from '../../types'; + +/** + * 文件职责:交易流水页面常量与默认状态定义。 + */ +import type { + FinanceTransactionChannelFilter, + FinanceTransactionPaymentFilter, + FinanceTransactionStatsDto, + FinanceTransactionTypeFilter, +} from '#/api/finance'; + +import { getTodayDateString } from './helpers'; + +/** 交易流水查看权限。 */ +export const FINANCE_TRANSACTION_VIEW_PERMISSION = + 'tenant:finance:transaction:view'; + +/** 交易流水导出权限。 */ +export const FINANCE_TRANSACTION_EXPORT_PERMISSION = + 'tenant:finance:transaction:export'; + +/** 交易类型筛选项。 */ +export const TRANSACTION_TYPE_OPTIONS: OptionItem[] = [ + { label: '全部类型', value: 'all' }, + { label: '收入', value: 'income' }, + { label: '退款', value: 'refund' }, + { label: '储值充值', value: 'stored_card_recharge' }, + { label: '积分抵扣', value: 'point_redeem' }, +]; + +/** 交易渠道筛选项。 */ +export const TRANSACTION_CHANNEL_OPTIONS: OptionItem[] = [ + { label: '全部渠道', value: 'all' }, + { label: '外卖', value: 'delivery' }, + { label: '自提', value: 'pickup' }, + { label: '堂食', value: 'dine_in' }, +]; + +/** 支付方式筛选项。 */ +export const TRANSACTION_PAYMENT_OPTIONS: OptionItem[] = [ + { label: '全部支付方式', value: 'all' }, + { label: '微信', value: 'wechat' }, + { label: '支付宝', value: 'alipay' }, + { label: '现金', value: 'cash' }, + { label: '刷卡', value: 'card' }, + { label: '储值余额', value: 'balance' }, +]; + +/** 快捷日期选项。 */ +export const QUICK_RANGE_OPTIONS: QuickRangeOption[] = [ + { label: '今天', value: 'today' }, + { label: '昨天', value: 'yesterday' }, + { label: '近7天', value: '7d' }, + { label: '近30天', value: '30d' }, + { label: '本月', value: 'month' }, +]; + +/** 默认筛选状态。 */ +export function createDefaultFilters(): FinanceTransactionFilterState { + const today = getTodayDateString(); + return { + type: 'all' as FinanceTransactionTypeFilter, + channel: 'all' as FinanceTransactionChannelFilter, + paymentMethod: 'all' as FinanceTransactionPaymentFilter, + keyword: '', + startDate: today, + endDate: today, + quickRange: 'today', + }; +} + +/** 默认统计数据。 */ +export const DEFAULT_STATS: FinanceTransactionStatsDto = { + totalIncome: 0, + totalRefund: 0, + totalCount: 0, +}; + +/** 默认本页汇总数据。 */ +export const DEFAULT_PAGE_SUMMARY: FinanceTransactionPageSummaryState = { + pageIncomeAmount: 0, + pageRefundAmount: 0, +}; diff --git a/apps/web-antd/src/views/finance/transaction/composables/transaction-page/data-actions.ts b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/data-actions.ts new file mode 100644 index 0000000..b7d0ce7 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/data-actions.ts @@ -0,0 +1,126 @@ +import type { + FinanceTransactionFilterState, + FinanceTransactionPageSummaryState, + FinanceTransactionPaginationState, +} from '../../types'; + +/** + * 文件职责:交易流水页面数据加载动作。 + */ +import type { + FinanceTransactionListItemDto, + FinanceTransactionStatsDto, +} from '#/api/finance'; +import type { StoreListItemDto } from '#/api/store'; + +import { + getFinanceTransactionListApi, + getFinanceTransactionStatsApi, +} from '#/api/finance'; +import { getStoreListApi } from '#/api/store'; + +import { buildFilterQueryPayload, buildListQueryPayload } from './helpers'; + +interface DataActionOptions { + filters: FinanceTransactionFilterState; + isListLoading: { value: boolean }; + isStatsLoading: { value: boolean }; + isStoreLoading: { value: boolean }; + pageSummary: FinanceTransactionPageSummaryState; + pagination: FinanceTransactionPaginationState; + rows: { value: FinanceTransactionListItemDto[] }; + selectedStoreId: { value: string }; + stats: FinanceTransactionStatsDto; + stores: { value: StoreListItemDto[] }; +} + +/** 创建数据相关动作。 */ +export function createDataActions(options: DataActionOptions) { + function resetStats() { + options.stats.totalIncome = 0; + options.stats.totalRefund = 0; + options.stats.totalCount = 0; + } + + function resetPageSummary() { + options.pageSummary.pageIncomeAmount = 0; + options.pageSummary.pageRefundAmount = 0; + } + + function clearPageData() { + options.rows.value = []; + options.pagination.total = 0; + resetPageSummary(); + resetStats(); + } + + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ page: 1, pageSize: 200 }); + options.stores.value = result.items; + + if (result.items.length === 0) { + options.selectedStoreId.value = ''; + clearPageData(); + return; + } + + const matched = result.items.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!matched) { + options.selectedStoreId.value = result.items[0]?.id ?? ''; + } + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadPageData() { + if (!options.selectedStoreId.value) { + clearPageData(); + return; + } + + const listPayload = buildListQueryPayload( + options.selectedStoreId.value, + options.filters, + options.pagination.page, + options.pagination.pageSize, + ); + const filterPayload = buildFilterQueryPayload( + options.selectedStoreId.value, + options.filters, + ); + + options.isListLoading.value = true; + options.isStatsLoading.value = true; + try { + const [listResult, statsResult] = await Promise.all([ + getFinanceTransactionListApi(listPayload), + getFinanceTransactionStatsApi(filterPayload), + ]); + + options.rows.value = listResult.items; + options.pagination.total = listResult.total; + options.pagination.page = listResult.page; + options.pagination.pageSize = listResult.pageSize; + options.pageSummary.pageIncomeAmount = listResult.pageIncomeAmount; + options.pageSummary.pageRefundAmount = listResult.pageRefundAmount; + + options.stats.totalIncome = statsResult.totalIncome; + options.stats.totalRefund = statsResult.totalRefund; + options.stats.totalCount = statsResult.totalCount; + } finally { + options.isListLoading.value = false; + options.isStatsLoading.value = false; + } + } + + return { + clearPageData, + loadPageData, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/finance/transaction/composables/transaction-page/drawer-actions.ts b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/drawer-actions.ts new file mode 100644 index 0000000..e9a35c3 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/drawer-actions.ts @@ -0,0 +1,78 @@ +/** + * 文件职责:交易流水详情与关联订单抽屉动作。 + */ +import type { FinanceTransactionDetailDto } from '#/api/finance'; +import type { OrderAllDetailDto } from '#/api/order'; + +import { getFinanceTransactionDetailApi } from '#/api/finance'; +import { getOrderAllDetailApi } from '#/api/order'; + +interface DrawerActionOptions { + isRelatedOrderDrawerOpen: { value: boolean }; + isRelatedOrderLoading: { value: boolean }; + isTransactionDetailLoading: { value: boolean }; + isTransactionDrawerOpen: { value: boolean }; + relatedOrderDetail: { value: null | OrderAllDetailDto }; + selectedStoreId: { value: string }; + transactionDetail: { value: FinanceTransactionDetailDto | null }; +} + +/** 创建抽屉动作。 */ +export function createDrawerActions(options: DrawerActionOptions) { + function setTransactionDrawerOpen(value: boolean) { + options.isTransactionDrawerOpen.value = value; + if (!value) { + options.transactionDetail.value = null; + } + } + + function setRelatedOrderDrawerOpen(value: boolean) { + options.isRelatedOrderDrawerOpen.value = value; + if (!value) { + options.relatedOrderDetail.value = null; + } + } + + async function openTransactionDetail(transactionId: string) { + if (!options.selectedStoreId.value || !transactionId) { + return; + } + + options.isTransactionDrawerOpen.value = true; + options.transactionDetail.value = null; + options.isTransactionDetailLoading.value = true; + try { + options.transactionDetail.value = await getFinanceTransactionDetailApi({ + storeId: options.selectedStoreId.value, + transactionId, + }); + } finally { + options.isTransactionDetailLoading.value = false; + } + } + + async function openRelatedOrder(orderNo: string) { + if (!options.selectedStoreId.value || !orderNo) { + return; + } + + options.isRelatedOrderDrawerOpen.value = true; + options.relatedOrderDetail.value = null; + options.isRelatedOrderLoading.value = true; + try { + options.relatedOrderDetail.value = await getOrderAllDetailApi({ + storeId: options.selectedStoreId.value, + orderNo, + }); + } finally { + options.isRelatedOrderLoading.value = false; + } + } + + return { + openRelatedOrder, + openTransactionDetail, + setRelatedOrderDrawerOpen, + setTransactionDrawerOpen, + }; +} diff --git a/apps/web-antd/src/views/finance/transaction/composables/transaction-page/export-actions.ts b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/export-actions.ts new file mode 100644 index 0000000..d0a4453 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/export-actions.ts @@ -0,0 +1,67 @@ +import type { FinanceTransactionFilterState } from '../../types'; + +/** + * 文件职责:交易流水导出弹窗与导出动作。 + */ +import { message } from 'ant-design-vue'; + +import { exportFinanceTransactionCsvApi } from '#/api/finance'; + +import { + buildFilterQueryPayload, + downloadBase64File, + isDateRangeInvalid, +} from './helpers'; + +interface ExportActionOptions { + canExport: { value: boolean }; + filters: FinanceTransactionFilterState; + isExportModalOpen: { value: boolean }; + isExporting: { value: boolean }; + selectedStoreId: { value: string }; +} + +/** 创建导出动作。 */ +export function createExportActions(options: ExportActionOptions) { + function setExportModalOpen(value: boolean) { + options.isExportModalOpen.value = value; + } + + function openExportModal() { + if (!options.canExport.value || !options.selectedStoreId.value) { + return; + } + options.isExportModalOpen.value = true; + } + + async function handleConfirmExport() { + if (!options.canExport.value || !options.selectedStoreId.value) { + return; + } + + if (isDateRangeInvalid(options.filters)) { + message.warning('开始日期不能晚于结束日期'); + return; + } + + options.isExporting.value = true; + try { + const payload = buildFilterQueryPayload( + options.selectedStoreId.value, + options.filters, + ); + const result = await exportFinanceTransactionCsvApi(payload); + downloadBase64File(result.fileName, result.fileContentBase64); + message.success(`导出成功,共 ${result.totalCount} 条记录`); + setExportModalOpen(false); + } finally { + options.isExporting.value = false; + } + } + + return { + handleConfirmExport, + openExportModal, + setExportModalOpen, + }; +} diff --git a/apps/web-antd/src/views/finance/transaction/composables/transaction-page/filter-actions.ts b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/filter-actions.ts new file mode 100644 index 0000000..6e4815f --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/filter-actions.ts @@ -0,0 +1,89 @@ +import type { + FinanceTransactionFilterState, + FinanceTransactionPaginationState, + QuickDateRangeKey, +} from '../../types'; + +/** + * 文件职责:交易流水页面筛选与分页行为。 + */ +import { message } from 'ant-design-vue'; + +import { isDateRangeInvalid, resolveQuickRangeDateRange } from './helpers'; + +interface FilterActionOptions { + filters: FinanceTransactionFilterState; + loadPageData: () => Promise; + pagination: FinanceTransactionPaginationState; +} + +/** 创建筛选行为。 */ +export function createFilterActions(options: FilterActionOptions) { + function setType(value: string) { + options.filters.type = (value || + 'all') as FinanceTransactionFilterState['type']; + } + + function setChannel(value: string) { + options.filters.channel = (value || + 'all') as FinanceTransactionFilterState['channel']; + } + + function setPaymentMethod(value: string) { + options.filters.paymentMethod = (value || + 'all') as FinanceTransactionFilterState['paymentMethod']; + } + + function setQuickRange(value: QuickDateRangeKey) { + options.filters.quickRange = value; + if (!value) { + return; + } + + const { startDate, endDate } = resolveQuickRangeDateRange(value); + options.filters.startDate = startDate; + options.filters.endDate = endDate; + } + + function setStartDate(value: string) { + options.filters.startDate = value; + options.filters.quickRange = ''; + } + + function setEndDate(value: string) { + options.filters.endDate = value; + options.filters.quickRange = ''; + } + + function setKeyword(value: string) { + options.filters.keyword = value; + } + + async function handleSearch() { + if (isDateRangeInvalid(options.filters)) { + message.warning('开始日期不能晚于结束日期'); + return; + } + + options.pagination.page = 1; + await options.loadPageData(); + } + + async function handlePageChange(page: number, pageSize: number) { + options.pagination.page = page; + options.pagination.pageSize = pageSize; + await options.loadPageData(); + } + + return { + handlePageChange, + handleSearch, + setChannel, + setEndDate, + setKeyword, + setPaymentMethod, + setQuickRange, + setStartDate, + setType, + }; +} diff --git a/apps/web-antd/src/views/finance/transaction/composables/transaction-page/helpers.ts b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/helpers.ts new file mode 100644 index 0000000..429ea50 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/composables/transaction-page/helpers.ts @@ -0,0 +1,197 @@ +import type { + FinanceTransactionFilterState, + FinanceTransactionListQueryPayload, + FinanceTransactionQueryPayload, + QuickDateRangeKey, +} from '../../types'; + +/** + * 文件职责:交易流水页面纯函数与数据转换工具。 + */ +import type { + FinanceTransactionChannelFilter, + FinanceTransactionPaymentFilter, + FinanceTransactionTypeFilter, +} from '#/api/finance'; + +function formatDate(date: Date) { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function toDateOnly(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function shiftDate(date: Date, dayOffset: number) { + const next = new Date(date); + next.setDate(next.getDate() + dayOffset); + return next; +} + +function normalizeType( + value: FinanceTransactionTypeFilter, +): FinanceTransactionTypeFilter | undefined { + return value === 'all' ? undefined : value; +} + +function normalizeChannel( + value: FinanceTransactionChannelFilter, +): FinanceTransactionChannelFilter | undefined { + return value === 'all' ? undefined : value; +} + +function normalizePayment( + value: FinanceTransactionPaymentFilter, +): FinanceTransactionPaymentFilter | undefined { + return value === 'all' ? undefined : value; +} + +/** 获取今天日期字符串(yyyy-MM-dd)。 */ +export function getTodayDateString() { + return formatDate(new Date()); +} + +/** 根据快捷日期键计算筛选起止日期。 */ +export function resolveQuickRangeDateRange(value: QuickDateRangeKey) { + const today = toDateOnly(new Date()); + let start = today; + let end = today; + + switch (value) { + case '7d': { + start = shiftDate(today, -6); + + break; + } + case '30d': { + start = shiftDate(today, -29); + + break; + } + case 'month': { + start = new Date(today.getFullYear(), today.getMonth(), 1); + + break; + } + case 'yesterday': { + start = shiftDate(today, -1); + end = start; + + break; + } + // No default + } + + return { + startDate: formatDate(start), + endDate: formatDate(end), + }; +} + +/** 构建交易流水筛选请求。 */ +export function buildFilterQueryPayload( + storeId: string, + filters: FinanceTransactionFilterState, +): FinanceTransactionQueryPayload { + return { + storeId, + startDate: filters.startDate || undefined, + endDate: filters.endDate || undefined, + type: normalizeType(filters.type), + channel: normalizeChannel(filters.channel), + paymentMethod: normalizePayment(filters.paymentMethod), + keyword: filters.keyword.trim() || undefined, + }; +} + +/** 构建交易流水列表请求。 */ +export function buildListQueryPayload( + storeId: string, + filters: FinanceTransactionFilterState, + page: number, + pageSize: number, +): FinanceTransactionListQueryPayload { + return { + ...buildFilterQueryPayload(storeId, filters), + page, + pageSize, + }; +} + +/** 判断日期范围是否合法。 */ +export function isDateRangeInvalid(filters: FinanceTransactionFilterState) { + if (!filters.startDate || !filters.endDate) { + return false; + } + return filters.startDate > filters.endDate; +} + +/** 货币格式化(人民币)。 */ +export function formatCurrency(value: number) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number.isFinite(value) ? value : 0); +} + +/** 交易金额(带符号)格式化。 */ +export function formatSignedAmount(value: number, isIncome: boolean) { + const normalized = Number.isFinite(value) ? value : 0; + if (normalized === 0) { + return formatCurrency(0); + } + + if (normalized > 0 || isIncome) { + return `+${formatCurrency(Math.abs(normalized))}`; + } + + return `-${formatCurrency(Math.abs(normalized))}`; +} + +/** 金额视觉色调。 */ +export function resolveAmountToneClass(value: number, isIncome: boolean) { + if (value > 0 || isIncome) { + return 'income'; + } + if (value < 0) { + return 'expense'; + } + return 'neutral'; +} + +/** 交易类型标签颜色。 */ +export function resolveTransactionTypeTagColor(type: string) { + if (type === 'income') return 'green'; + if (type === 'refund') return 'red'; + if (type === 'stored_card_recharge') return 'blue'; + if (type === 'point_redeem') return 'orange'; + return 'default'; +} + +function decodeBase64ToBlob(base64: string) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.codePointAt(index) ?? 0; + } + return new Blob([bytes], { type: 'text/csv;charset=utf-8;' }); +} + +/** 下载 Base64 编码文件。 */ +export function downloadBase64File( + fileName: string, + fileContentBase64: string, +) { + const blob = decodeBase64ToBlob(fileContentBase64); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/web-antd/src/views/finance/transaction/composables/useFinanceTransactionPage.ts b/apps/web-antd/src/views/finance/transaction/composables/useFinanceTransactionPage.ts new file mode 100644 index 0000000..c51aadd --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/composables/useFinanceTransactionPage.ts @@ -0,0 +1,236 @@ +/** + * 文件职责:交易流水页面状态与动作编排。 + */ +import type { + FinanceTransactionDetailDto, + FinanceTransactionListItemDto, + FinanceTransactionStatsDto, +} from '#/api/finance'; +import type { OrderAllDetailDto } from '#/api/order'; +import type { StoreListItemDto } from '#/api/store'; + +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { + createDefaultFilters, + DEFAULT_PAGE_SUMMARY, + DEFAULT_STATS, + FINANCE_TRANSACTION_EXPORT_PERMISSION, + FINANCE_TRANSACTION_VIEW_PERMISSION, +} from './transaction-page/constants'; +import { createDataActions } from './transaction-page/data-actions'; +import { createDrawerActions } from './transaction-page/drawer-actions'; +import { createExportActions } from './transaction-page/export-actions'; +import { createFilterActions } from './transaction-page/filter-actions'; + +/** 创建交易流水页面组合状态。 */ +export function useFinanceTransactionPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const filters = reactive(createDefaultFilters()); + const rows = ref([]); + const pagination = reactive({ + page: 1, + pageSize: 20, + total: 0, + }); + const pageSummary = reactive({ ...DEFAULT_PAGE_SUMMARY }); + + const stats = reactive({ ...DEFAULT_STATS }); + const isListLoading = ref(false); + const isStatsLoading = ref(false); + + const transactionDetail = ref(null); + const isTransactionDrawerOpen = ref(false); + const isTransactionDetailLoading = ref(false); + + const relatedOrderDetail = ref(null); + const isRelatedOrderDrawerOpen = ref(false); + const isRelatedOrderLoading = ref(false); + + const isExportModalOpen = ref(false); + const isExporting = ref(false); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const selectedStoreName = computed( + () => + storeOptions.value.find((item) => item.value === selectedStoreId.value) + ?.label ?? '--', + ); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + + const canView = computed(() => + accessCodeSet.value.has(FINANCE_TRANSACTION_VIEW_PERMISSION), + ); + + const canExport = computed(() => + accessCodeSet.value.has(FINANCE_TRANSACTION_EXPORT_PERMISSION), + ); + + const { clearPageData, loadPageData, loadStores } = createDataActions({ + stores, + selectedStoreId, + filters, + rows, + pagination, + pageSummary, + stats, + isStoreLoading, + isListLoading, + isStatsLoading, + }); + + const { + handlePageChange, + handleSearch, + setChannel, + setEndDate, + setKeyword, + setPaymentMethod, + setQuickRange, + setStartDate, + setType, + } = createFilterActions({ + filters, + pagination, + loadPageData, + }); + + const { + openRelatedOrder, + openTransactionDetail, + setRelatedOrderDrawerOpen, + setTransactionDrawerOpen, + } = createDrawerActions({ + selectedStoreId, + transactionDetail, + isTransactionDrawerOpen, + isTransactionDetailLoading, + relatedOrderDetail, + isRelatedOrderDrawerOpen, + isRelatedOrderLoading, + }); + + const { handleConfirmExport, openExportModal, setExportModalOpen } = + createExportActions({ + canExport, + selectedStoreId, + filters, + isExportModalOpen, + isExporting, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function clearByPermission() { + stores.value = []; + selectedStoreId.value = ''; + clearPageData(); + setTransactionDrawerOpen(false); + setRelatedOrderDrawerOpen(false); + setExportModalOpen(false); + } + + watch(selectedStoreId, async (storeId) => { + setTransactionDrawerOpen(false); + setRelatedOrderDrawerOpen(false); + + if (!storeId) { + clearPageData(); + return; + } + + pagination.page = 1; + await loadPageData(); + }); + + watch(canView, async (value, oldValue) => { + if (value === oldValue) { + return; + } + + if (!value) { + clearByPermission(); + return; + } + + await loadStores(); + }); + + onMounted(async () => { + if (!canView.value) { + clearByPermission(); + return; + } + + await loadStores(); + }); + + onActivated(() => { + if (!canView.value) { + return; + } + + if (stores.value.length === 0 || !selectedStoreId.value) { + void loadStores(); + } + }); + + return { + canExport, + canView, + filters, + handleConfirmExport, + handlePageChange, + handleSearch, + isExportModalOpen, + isExporting, + isListLoading, + isRelatedOrderDrawerOpen, + isRelatedOrderLoading, + isStatsLoading, + isStoreLoading, + isTransactionDetailLoading, + isTransactionDrawerOpen, + openExportModal, + openRelatedOrder, + openTransactionDetail, + pageSummary, + pagination, + relatedOrderDetail, + rows, + selectedStoreId, + selectedStoreName, + setChannel, + setEndDate, + setExportModalOpen, + setKeyword, + setPaymentMethod, + setQuickRange, + setRelatedOrderDrawerOpen, + setSelectedStoreId, + setStartDate, + setTransactionDrawerOpen, + setType, + stats, + storeOptions, + transactionDetail, + }; +} diff --git a/apps/web-antd/src/views/finance/transaction/index.vue b/apps/web-antd/src/views/finance/transaction/index.vue new file mode 100644 index 0000000..df9ee6d --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/index.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/apps/web-antd/src/views/finance/transaction/styles/base.less b/apps/web-antd/src/views/finance/transaction/styles/base.less new file mode 100644 index 0000000..00f96ca --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/styles/base.less @@ -0,0 +1,18 @@ +/** + * 文件职责:交易流水页面基础容器样式。 + */ +.page-finance-transaction { + .ant-card { + border-radius: 10px; + } +} + +.ft-page { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ft-mono { + font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace; +} diff --git a/apps/web-antd/src/views/finance/transaction/styles/drawer.less b/apps/web-antd/src/views/finance/transaction/styles/drawer.less new file mode 100644 index 0000000..c6500a5 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/styles/drawer.less @@ -0,0 +1,193 @@ +/** + * 文件职责:交易详情与关联订单抽屉样式。 + */ +.page-finance-transaction { + .ant-drawer { + .ant-drawer-header { + padding: 14px 18px; + border-bottom: 1px solid #f0f0f0; + } + + .ant-drawer-body { + padding: 16px 20px; + } + + .ant-drawer-footer { + padding: 12px 20px; + border-top: 1px solid #f0f0f0; + } + } +} + +.ft-section { + margin-bottom: 20px; + + .ft-section-title { + padding-left: 10px; + margin-bottom: 14px; + font-size: 14px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-left: 3px solid #1677ff; + } +} + +.ft-info-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 20px; + font-size: 13px; + + .label { + color: rgb(0 0 0 / 45%); + } + + .full { + grid-column: 1 / -1; + } +} + +.ft-order-link { + padding-inline: 0; +} + +.ft-detail-amount { + font-weight: 600; + color: rgb(0 0 0 / 88%); + + &.income { + color: #52c41a; + } + + &.expense { + color: #ff4d4f; + } +} + +.ft-detail-table-wrap { + overflow-x: auto; +} + +.ft-detail-table { + width: 100%; + min-width: 520px; + font-size: 13px; + border-collapse: collapse; + + th { + padding: 8px 10px; + font-size: 12px; + font-weight: 500; + color: rgb(0 0 0 / 45%); + text-align: left; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; + + &.right { + text-align: right; + } + } + + td { + padding: 8px 10px; + color: rgb(0 0 0 / 88%); + border-bottom: 1px solid #f5f5f5; + + &.right { + text-align: right; + } + } +} + +.ft-amount-summary { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 10px 0; + font-size: 13px; + + > div { + display: flex; + justify-content: space-between; + color: rgb(0 0 0 / 65%); + } + + .discount { + color: #ff4d4f; + } + + .total { + padding-top: 8px; + margin-top: 4px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-top: 1px solid #f0f0f0; + + span:last-child { + font-size: 15px; + color: #1677ff; + } + } +} + +.ft-timeline { + position: relative; + padding-left: 22px; + + .ft-timeline-item { + position: relative; + display: flex; + gap: 8px; + align-items: center; + padding-bottom: 18px; + font-size: 13px; + + &::before { + position: absolute; + top: 5px; + left: -22px; + width: 10px; + height: 10px; + content: ''; + background: #1677ff; + border: 2px solid #d6e4ff; + border-radius: 50%; + } + + &::after { + position: absolute; + top: 17px; + left: -18px; + width: 2px; + height: calc(100% - 12px); + content: ''; + background: #e8e8e8; + } + + &:last-child { + padding-bottom: 0; + + &::after { + display: none; + } + } + + .text { + font-weight: 500; + color: rgb(0 0 0 / 88%); + } + + .time { + color: rgb(0 0 0 / 45%); + } + } +} + +.ft-remark-box { + padding: 10px 14px; + font-size: 13px; + color: rgb(0 0 0 / 65%); + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 8px; +} diff --git a/apps/web-antd/src/views/finance/transaction/styles/index.less b/apps/web-antd/src/views/finance/transaction/styles/index.less new file mode 100644 index 0000000..ee1dbe6 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/styles/index.less @@ -0,0 +1,9 @@ +/** + * 文件职责:交易流水页面样式聚合入口。 + */ +@import './base.less'; +@import './layout.less'; +@import './table.less'; +@import './drawer.less'; +@import './modal.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/finance/transaction/styles/layout.less b/apps/web-antd/src/views/finance/transaction/styles/layout.less new file mode 100644 index 0000000..3f36f03 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/styles/layout.less @@ -0,0 +1,134 @@ +/** + * 文件职责:交易流水页面布局与筛选区域样式。 + */ +.ft-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.ft-stat-card { + padding: 18px 20px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 6px 14px rgb(15 23 42 / 10%); + transform: translateY(-1px); + } +} + +.ft-stat-label { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 6px; + font-size: 13px; + color: rgb(0 0 0 / 45%); +} + +.ft-stat-icon { + width: 16px; + height: 16px; +} + +.ft-stat-value { + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: rgb(0 0 0 / 88%); + + &.is-green { + color: #52c41a; + } + + &.is-red { + color: #ff4d4f; + } +} + +.ft-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 14px 18px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + + .ft-store-select { + width: 220px; + } + + .ft-date-input { + width: 145px; + } + + .ft-type-select { + width: 120px; + } + + .ft-channel-select { + width: 110px; + } + + .ft-payment-select { + width: 130px; + } + + .ft-search { + width: 190px; + } + + .ft-date-sep { + font-size: 13px; + line-height: 32px; + color: rgb(0 0 0 / 45%); + } + + .ft-toolbar-right { + margin-left: auto; + } + + .ft-search-icon { + width: 14px; + height: 14px; + color: rgb(0 0 0 / 45%); + } + + .ft-export-btn { + display: inline-flex; + gap: 4px; + align-items: center; + height: 32px; + } + + .ant-select-selector, + .ant-input, + .ant-input-affix-wrapper { + height: 32px; + font-size: 13px; + } + + .ant-input-affix-wrapper .ant-input { + height: 100%; + } +} + +.ft-quick-dates { + display: flex; + gap: 4px; + + .ant-btn { + min-width: 56px; + height: 28px; + padding: 0 10px; + font-size: 12px; + border-radius: 4px; + } +} diff --git a/apps/web-antd/src/views/finance/transaction/styles/modal.less b/apps/web-antd/src/views/finance/transaction/styles/modal.less new file mode 100644 index 0000000..13b4477 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/styles/modal.less @@ -0,0 +1,51 @@ +/** + * 文件职责:交易流水导出弹窗样式。 + */ +.ft-export-modal { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ft-export-desc { + margin: 0; + font-size: 13px; + color: rgb(0 0 0 / 65%); +} + +.ft-export-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 8px; +} + +.ft-export-line { + display: flex; + gap: 16px; + justify-content: space-between; + font-size: 13px; + + .ft-export-label { + color: rgb(0 0 0 / 45%); + } + + .ft-export-value { + color: rgb(0 0 0 / 88%); + text-align: right; + } + + &.is-total { + padding-top: 8px; + margin-top: 4px; + font-weight: 600; + border-top: 1px solid #f0f0f0; + + .ft-export-value { + color: #1677ff; + } + } +} diff --git a/apps/web-antd/src/views/finance/transaction/styles/responsive.less b/apps/web-antd/src/views/finance/transaction/styles/responsive.less new file mode 100644 index 0000000..581d0c0 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/styles/responsive.less @@ -0,0 +1,59 @@ +/** + * 文件职责:交易流水页面响应式样式。 + */ +@media (max-width: 1600px) { + .ft-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .ft-stats { + grid-template-columns: 1fr; + } + + .ft-toolbar { + padding: 14px 12px; + + .ft-store-select, + .ft-date-input, + .ft-type-select, + .ft-channel-select, + .ft-payment-select, + .ft-search { + width: 100%; + } + + .ft-date-sep { + display: none; + } + + .ft-quick-dates { + flex-wrap: wrap; + width: 100%; + + .ant-btn { + flex: 1; + min-width: 68px; + } + } + + .ft-toolbar-right { + width: 100%; + margin-left: 0; + } + + .ft-export-btn { + justify-content: center; + width: 100%; + } + } + + .ft-info-grid { + grid-template-columns: 1fr; + + .full { + grid-column: auto; + } + } +} diff --git a/apps/web-antd/src/views/finance/transaction/styles/table.less b/apps/web-antd/src/views/finance/transaction/styles/table.less new file mode 100644 index 0000000..b0b6f94 --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/styles/table.less @@ -0,0 +1,74 @@ +/** + * 文件职责:交易流水表格区域样式。 + */ +.ft-table-card { + overflow: hidden; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + + .ant-table-wrapper { + .ant-table-thead > tr > th { + font-size: 13px; + white-space: nowrap; + } + + .ant-table-tbody > tr > td { + font-size: 13px; + vertical-align: middle; + } + } + + .ant-pagination { + margin: 14px 16px; + } +} + +.ft-link-action { + padding-inline: 0; +} + +.ft-amount { + font-weight: 600; + white-space: nowrap; + + &.income { + color: #52c41a; + } + + &.expense { + color: #ff4d4f; + } + + &.neutral { + color: rgb(0 0 0 / 88%); + } +} + +.ft-time { + font-size: 12px; + color: rgb(0 0 0 / 45%); + white-space: nowrap; +} + +.ft-remark { + display: inline-block; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: rgb(0 0 0 / 45%); + white-space: nowrap; +} + +.ft-summary-row > td { + font-size: 13px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + background: #f8f9fb !important; + border-top: 2px solid #e5e7eb; +} + +.ft-summary-text { + white-space: nowrap; +} diff --git a/apps/web-antd/src/views/finance/transaction/types.ts b/apps/web-antd/src/views/finance/transaction/types.ts new file mode 100644 index 0000000..a3b417b --- /dev/null +++ b/apps/web-antd/src/views/finance/transaction/types.ts @@ -0,0 +1,70 @@ +/** + * 文件职责:交易流水页面本地状态类型定义。 + */ +import type { + FinanceTransactionChannelFilter, + FinanceTransactionPaymentFilter, + FinanceTransactionTypeFilter, +} from '#/api/finance'; + +/** 快捷日期范围键。 */ +export type QuickDateRangeKey = + | '7d' + | '30d' + | '' + | 'month' + | 'today' + | 'yesterday'; + +/** 交易流水筛选状态。 */ +export interface FinanceTransactionFilterState { + channel: FinanceTransactionChannelFilter; + endDate: string; + keyword: string; + paymentMethod: FinanceTransactionPaymentFilter; + quickRange: QuickDateRangeKey; + startDate: string; + type: FinanceTransactionTypeFilter; +} + +/** 交易流水分页状态。 */ +export interface FinanceTransactionPaginationState { + page: number; + pageSize: number; + total: number; +} + +/** 交易流水本页汇总状态。 */ +export interface FinanceTransactionPageSummaryState { + pageIncomeAmount: number; + pageRefundAmount: number; +} + +/** 通用选项项。 */ +export interface OptionItem { + label: string; + value: string; +} + +/** 快捷日期选项。 */ +export interface QuickRangeOption { + label: string; + value: QuickDateRangeKey; +} + +/** 交易流水筛选请求负载。 */ +export interface FinanceTransactionQueryPayload { + channel?: FinanceTransactionChannelFilter; + endDate?: string; + keyword?: string; + paymentMethod?: FinanceTransactionPaymentFilter; + startDate?: string; + storeId: string; + type?: FinanceTransactionTypeFilter; +} + +/** 交易流水列表请求负载。 */ +export interface FinanceTransactionListQueryPayload extends FinanceTransactionQueryPayload { + page: number; + pageSize: number; +} From 0a19610d927693bccb246f27f47b73389437ad03 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 4 Mar 2026 11:36:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E6=B6=88=E6=81=AF=E8=A7=A6=E8=BE=BE=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8E=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/member/index.ts | 1 + apps/web-antd/src/api/member/message-reach.ts | 283 +++++++++++++ .../components/MessageReachDetailDrawer.vue | 273 ++++++++++++ .../components/MessageReachEditorDrawer.vue | 209 +++++++++ .../components/MessageReachFilterBar.vue | 70 ++++ .../components/MessageReachStatsCards.vue | 51 +++ .../components/MessageReachTableCard.vue | 221 ++++++++++ .../components/MessageTemplateCardGrid.vue | 101 +++++ .../components/MessageTemplateEditorModal.vue | 76 ++++ .../components/MessageTemplateToolbar.vue | 54 +++ .../message-reach-page/constants.ts | 106 +++++ .../message-reach-page/data-actions.ts | 160 +++++++ .../composables/message-reach-page/helpers.ts | 264 ++++++++++++ .../message-reach-page/message-actions.ts | 264 ++++++++++++ .../message-reach-page/template-actions.ts | 144 +++++++ .../composables/useMemberMessageReachPage.ts | 396 ++++++++++++++++++ .../src/views/member/message-reach/index.vue | 197 +++++++++ .../member/message-reach/styles/base.less | 27 ++ .../member/message-reach/styles/drawer.less | 163 +++++++ .../member/message-reach/styles/index.less | 7 + .../member/message-reach/styles/layout.less | 78 ++++ .../member/message-reach/styles/list.less | 53 +++ .../member/message-reach/styles/modal.less | 7 + .../message-reach/styles/responsive.less | 48 +++ .../member/message-reach/styles/template.less | 85 ++++ .../src/views/member/message-reach/types.ts | 147 +++++++ 26 files changed, 3485 insertions(+) create mode 100644 apps/web-antd/src/api/member/message-reach.ts create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageReachDetailDrawer.vue create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageReachEditorDrawer.vue create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageReachFilterBar.vue create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageReachStatsCards.vue create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageReachTableCard.vue create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageTemplateCardGrid.vue create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageTemplateEditorModal.vue create mode 100644 apps/web-antd/src/views/member/message-reach/components/MessageTemplateToolbar.vue create mode 100644 apps/web-antd/src/views/member/message-reach/composables/message-reach-page/constants.ts create mode 100644 apps/web-antd/src/views/member/message-reach/composables/message-reach-page/data-actions.ts create mode 100644 apps/web-antd/src/views/member/message-reach/composables/message-reach-page/helpers.ts create mode 100644 apps/web-antd/src/views/member/message-reach/composables/message-reach-page/message-actions.ts create mode 100644 apps/web-antd/src/views/member/message-reach/composables/message-reach-page/template-actions.ts create mode 100644 apps/web-antd/src/views/member/message-reach/composables/useMemberMessageReachPage.ts create mode 100644 apps/web-antd/src/views/member/message-reach/index.vue create mode 100644 apps/web-antd/src/views/member/message-reach/styles/base.less create mode 100644 apps/web-antd/src/views/member/message-reach/styles/drawer.less create mode 100644 apps/web-antd/src/views/member/message-reach/styles/index.less create mode 100644 apps/web-antd/src/views/member/message-reach/styles/layout.less create mode 100644 apps/web-antd/src/views/member/message-reach/styles/list.less create mode 100644 apps/web-antd/src/views/member/message-reach/styles/modal.less create mode 100644 apps/web-antd/src/views/member/message-reach/styles/responsive.less create mode 100644 apps/web-antd/src/views/member/message-reach/styles/template.less create mode 100644 apps/web-antd/src/views/member/message-reach/types.ts diff --git a/apps/web-antd/src/api/member/index.ts b/apps/web-antd/src/api/member/index.ts index c8798aa..74ea27d 100644 --- a/apps/web-antd/src/api/member/index.ts +++ b/apps/web-antd/src/api/member/index.ts @@ -282,4 +282,5 @@ export async function getMemberCouponPickerApi(params: { ); } +export * from './message-reach'; export * from './stored-card'; diff --git a/apps/web-antd/src/api/member/message-reach.ts b/apps/web-antd/src/api/member/message-reach.ts new file mode 100644 index 0000000..50e13f0 --- /dev/null +++ b/apps/web-antd/src/api/member/message-reach.ts @@ -0,0 +1,283 @@ +/** + * 文件职责:会员消息触达模块 API 契约定义。 + */ +import { requestClient } from '#/api/request'; + +/** 消息状态。 */ +export type MemberMessageReachStatus = + | 'draft' + | 'failed' + | 'pending' + | 'sending' + | 'sent'; + +/** 消息渠道。 */ +export type MemberMessageReachChannel = 'inapp' | 'sms' | 'wechat-mini'; + +/** 目标人群类型。 */ +export type MemberMessageAudienceType = 'all' | 'tag'; + +/** 发送时间类型。 */ +export type MemberMessageScheduleType = 'immediate' | 'scheduled'; + +/** 模板分类。 */ +export type MemberMessageTemplateCategory = 'marketing' | 'notice' | 'recall'; + +/** 页面统计。 */ +export interface MemberMessageReachStatsDto { + conversionRate: number; + monthlySentCount: number; + openRate: number; + reachMemberCount: number; +} + +/** 消息列表查询。 */ +export interface MemberMessageReachListQuery { + channel?: MemberMessageReachChannel; + keyword?: string; + page: number; + pageSize: number; + status?: MemberMessageReachStatus; +} + +/** 消息列表项。 */ +export interface MemberMessageReachListItemDto { + audienceText: string; + channels: MemberMessageReachChannel[]; + conversionRate: number; + estimatedReachCount: number; + messageId: string; + openRate: number; + scheduledAt?: string; + sentAt?: string; + status: MemberMessageReachStatus; + title: string; +} + +/** 消息列表结果。 */ +export interface MemberMessageReachListResultDto { + items: MemberMessageReachListItemDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 收件明细。 */ +export interface MemberMessageReachRecipientDto { + channel: MemberMessageReachChannel; + convertedAt?: string; + errorMessage?: string; + memberId: string; + mobile?: string; + openId?: string; + readAt?: string; + sentAt?: string; + status: 'failed' | 'pending' | 'sent'; +} + +/** 消息详情。 */ +export interface MemberMessageReachDetailDto { + audienceTags: string[]; + audienceText: string; + audienceType: MemberMessageAudienceType; + channels: MemberMessageReachChannel[]; + content: string; + conversionRate: number; + convertedCount: number; + estimatedReachCount: number; + lastError?: string; + messageId: string; + openRate: number; + readCount: number; + recipients: MemberMessageReachRecipientDto[]; + scheduleType: MemberMessageScheduleType; + scheduledAt?: string; + sentAt?: string; + sentCount: number; + status: MemberMessageReachStatus; + templateId?: string; + title: string; +} + +/** 保存消息请求。 */ +export interface SaveMemberMessageReachPayload { + audienceTags: string[]; + audienceType: MemberMessageAudienceType; + channels: MemberMessageReachChannel[]; + content: string; + messageId?: string; + scheduleType: MemberMessageScheduleType; + scheduledAt?: string; + storeId?: string; + submitAction: 'draft' | 'send'; + templateId?: string; + title: string; +} + +/** 删除消息请求。 */ +export interface DeleteMemberMessageReachPayload { + messageId: string; +} + +/** 调度元信息。 */ +export interface MemberMessageDispatchMetaDto { + hangfireJobId?: string; + messageId: string; + scheduleType: MemberMessageScheduleType; + scheduledAt?: string; + status: MemberMessageReachStatus; +} + +/** 目标估算请求。 */ +export interface EstimateMemberMessageAudiencePayload { + audienceType: MemberMessageAudienceType; + tags: string[]; +} + +/** 目标估算响应。 */ +export interface MemberMessageAudienceEstimateDto { + reachCount: number; +} + +/** 模板列表查询。 */ +export interface MemberMessageTemplateListQuery { + category?: MemberMessageTemplateCategory; + keyword?: string; + page: number; + pageSize: number; +} + +/** 模板 DTO。 */ +export interface MemberMessageTemplateDto { + category: MemberMessageTemplateCategory; + content: string; + lastUsedAt?: string; + name: string; + templateId: string; + usageCount: number; +} + +/** 模板列表结果。 */ +export interface MemberMessageTemplateListResultDto { + items: MemberMessageTemplateDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 保存模板请求。 */ +export interface SaveMemberMessageTemplatePayload { + category: MemberMessageTemplateCategory; + content: string; + name: string; + templateId?: string; +} + +/** 删除模板请求。 */ +export interface DeleteMemberMessageTemplatePayload { + templateId: string; +} + +/** 查询页面统计。 */ +export async function getMemberMessageReachStatsApi(params?: { + storeId?: string; +}) { + return requestClient.get( + '/member/message-reach/stats', + { + params, + }, + ); +} + +/** 查询消息列表。 */ +export async function getMemberMessageReachListApi( + params: MemberMessageReachListQuery, +) { + return requestClient.get( + '/member/message-reach/list', + { + params, + }, + ); +} + +/** 查询消息详情。 */ +export async function getMemberMessageReachDetailApi(params: { + messageId: string; +}) { + return requestClient.get( + '/member/message-reach/detail', + { + params, + }, + ); +} + +/** 保存消息。 */ +export async function saveMemberMessageReachApi( + payload: SaveMemberMessageReachPayload, +) { + return requestClient.post( + '/member/message-reach/save', + payload, + ); +} + +/** 删除消息。 */ +export async function deleteMemberMessageReachApi( + payload: DeleteMemberMessageReachPayload, +) { + return requestClient.post('/member/message-reach/delete', payload); +} + +/** 估算触达人数。 */ +export async function estimateMemberMessageAudienceApi( + payload: EstimateMemberMessageAudiencePayload, +) { + return requestClient.post( + '/member/message-reach/audience/estimate', + payload, + ); +} + +/** 查询模板列表。 */ +export async function getMemberMessageTemplateListApi( + params: MemberMessageTemplateListQuery, +) { + return requestClient.get( + '/member/message-reach/template/list', + { + params, + }, + ); +} + +/** 查询模板详情。 */ +export async function getMemberMessageTemplateDetailApi(params: { + templateId: string; +}) { + return requestClient.get( + '/member/message-reach/template/detail', + { + params, + }, + ); +} + +/** 保存模板。 */ +export async function saveMemberMessageTemplateApi( + payload: SaveMemberMessageTemplatePayload, +) { + return requestClient.post( + '/member/message-reach/template/save', + payload, + ); +} + +/** 删除模板。 */ +export async function deleteMemberMessageTemplateApi( + payload: DeleteMemberMessageTemplatePayload, +) { + return requestClient.post('/member/message-reach/template/delete', payload); +} diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachDetailDrawer.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachDetailDrawer.vue new file mode 100644 index 0000000..1e66f4b --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachDetailDrawer.vue @@ -0,0 +1,273 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachEditorDrawer.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachEditorDrawer.vue new file mode 100644 index 0000000..8c5b3af --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachEditorDrawer.vue @@ -0,0 +1,209 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachFilterBar.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachFilterBar.vue new file mode 100644 index 0000000..f0c798e --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachFilterBar.vue @@ -0,0 +1,70 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachStatsCards.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachStatsCards.vue new file mode 100644 index 0000000..891e682 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachStatsCards.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageReachTableCard.vue b/apps/web-antd/src/views/member/message-reach/components/MessageReachTableCard.vue new file mode 100644 index 0000000..074c5b6 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageReachTableCard.vue @@ -0,0 +1,221 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageTemplateCardGrid.vue b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateCardGrid.vue new file mode 100644 index 0000000..e1a9c20 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateCardGrid.vue @@ -0,0 +1,101 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageTemplateEditorModal.vue b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateEditorModal.vue new file mode 100644 index 0000000..bad8583 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateEditorModal.vue @@ -0,0 +1,76 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/components/MessageTemplateToolbar.vue b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateToolbar.vue new file mode 100644 index 0000000..b15c291 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/components/MessageTemplateToolbar.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/constants.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/constants.ts new file mode 100644 index 0000000..61db97b --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/constants.ts @@ -0,0 +1,106 @@ +import type { + MemberMessageReachChannel, + MemberMessageReachStatus, + MemberMessageTemplateCategory, +} from '#/api/member/message-reach'; +import type { MemberMessageReachTabKey } from '#/views/member/message-reach/types'; + +/** 消息触达查看权限。 */ +export const MEMBER_MESSAGE_REACH_VIEW_PERMISSION = + 'tenant:member:message-reach:view'; + +/** 消息触达管理权限。 */ +export const MEMBER_MESSAGE_REACH_MANAGE_PERMISSION = + 'tenant:member:message-reach:manage'; + +/** 页面 Tab 选项。 */ +export const MESSAGE_REACH_TAB_OPTIONS: Array<{ + label: string; + value: MemberMessageReachTabKey; +}> = [ + { label: '消息列表', value: 'list' }, + { label: '消息模板', value: 'template' }, +]; + +/** 状态筛选选项。 */ +export const MESSAGE_REACH_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberMessageReachStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '草稿', value: 'draft' }, + { label: '待发送', value: 'pending' }, + { label: '发送中', value: 'sending' }, + { label: '已发送', value: 'sent' }, + { label: '发送失败', value: 'failed' }, +]; + +/** 渠道筛选选项。 */ +export const MESSAGE_REACH_CHANNEL_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberMessageReachChannel; +}> = [ + { label: '全部渠道', value: '' }, + { label: '站内信', value: 'inapp' }, + { label: '短信', value: 'sms' }, + { label: '微信模板', value: 'wechat-mini' }, +]; + +/** 模板分类筛选选项。 */ +export const MESSAGE_TEMPLATE_CATEGORY_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MemberMessageTemplateCategory; +}> = [ + { label: '全部分类', value: '' }, + { label: '通知', value: 'notice' }, + { label: '营销', value: 'marketing' }, + { label: '召回', value: 'recall' }, +]; + +/** 模板分类编辑选项。 */ +export const MESSAGE_TEMPLATE_CATEGORY_OPTIONS: Array<{ + label: string; + value: MemberMessageTemplateCategory; +}> = [ + { label: '通知', value: 'notice' }, + { label: '营销', value: 'marketing' }, + { label: '召回', value: 'recall' }, +]; + +/** 抽屉渠道选项。 */ +export const MESSAGE_REACH_CHANNEL_OPTIONS: Array<{ + label: string; + value: MemberMessageReachChannel; +}> = [ + { label: '站内信', value: 'inapp' }, + { label: '短信', value: 'sms' }, + { label: '微信模板消息', value: 'wechat-mini' }, +]; + +/** 固定标签选项。 */ +export const MESSAGE_AUDIENCE_TAG_OPTIONS = [ + { label: '高频客户', value: '高频客户' }, + { label: '新客', value: '新客' }, + { label: '沉睡客户', value: '沉睡客户' }, + { label: '流失客户', value: '流失客户' }, + { label: '午餐常客', value: '午餐常客' }, + { label: '大额消费', value: '大额消费' }, +] as const; + +/** 目标人群选项。 */ +export const MESSAGE_AUDIENCE_TYPE_OPTIONS = [ + { label: '全部会员', value: 'all' }, + { label: '按标签筛选', value: 'tag' }, +] as const; + +/** 发送时间选项。 */ +export const MESSAGE_SCHEDULE_TYPE_OPTIONS = [ + { label: '立即发送', value: 'immediate' }, + { label: '定时发送', value: 'scheduled' }, +] as const; + +/** 表格分页尺寸选项。 */ +export const MESSAGE_LIST_PAGE_SIZE_OPTIONS = ['10', '20', '50']; + +/** 模板分页尺寸选项。 */ +export const MESSAGE_TEMPLATE_PAGE_SIZE_OPTIONS = ['12', '24', '48']; diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/data-actions.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/data-actions.ts new file mode 100644 index 0000000..11e781b --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/data-actions.ts @@ -0,0 +1,160 @@ +import type { Ref } from 'vue'; + +import type { + MemberMessageReachDetailDto, + MemberMessageReachStatsDto, +} from '#/api/member/message-reach'; +import type { + MessageReachFilterForm, + MessageReachPager, + MessageTemplateFilterForm, + MessageTemplatePager, +} from '#/views/member/message-reach/types'; + +import { message } from 'ant-design-vue'; + +import { + estimateMemberMessageAudienceApi, + getMemberMessageReachDetailApi, + getMemberMessageReachListApi, + getMemberMessageReachStatsApi, + getMemberMessageTemplateListApi, +} from '#/api/member/message-reach'; + +import { mapMessageFilterToQuery, mapTemplateFilterToQuery } from './helpers'; + +interface CreateDataActionsOptions { + audienceEstimateCount: Ref; + detail: Ref; + isDetailLoading: Ref; + isEstimatingAudience: Ref; + isMessageLoading: Ref; + isStatsLoading: Ref; + isTemplateLoading: Ref; + messageFilterForm: MessageReachFilterForm; + messagePager: Ref; + stats: Ref; + templateFilterForm: MessageTemplateFilterForm; + templatePager: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + function resetMessagePager() { + options.messagePager.value = { + ...options.messagePager.value, + items: [], + totalCount: 0, + }; + } + + function resetTemplatePager() { + options.templatePager.value = { + ...options.templatePager.value, + items: [], + totalCount: 0, + }; + } + + async function loadStats() { + options.isStatsLoading.value = true; + try { + options.stats.value = await getMemberMessageReachStatsApi(); + } catch (error) { + console.error(error); + message.error('加载消息统计失败'); + } finally { + options.isStatsLoading.value = false; + } + } + + async function loadMessageList() { + options.isMessageLoading.value = true; + try { + const query = mapMessageFilterToQuery(options.messageFilterForm); + const result = await getMemberMessageReachListApi({ + page: options.messagePager.value.page, + pageSize: options.messagePager.value.pageSize, + ...query, + }); + options.messagePager.value = { + items: result.items ?? [], + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + }; + } catch (error) { + console.error(error); + resetMessagePager(); + message.error('加载消息列表失败'); + } finally { + options.isMessageLoading.value = false; + } + } + + async function loadTemplateList() { + options.isTemplateLoading.value = true; + try { + const query = mapTemplateFilterToQuery(options.templateFilterForm); + const result = await getMemberMessageTemplateListApi({ + page: options.templatePager.value.page, + pageSize: options.templatePager.value.pageSize, + ...query, + }); + options.templatePager.value = { + items: result.items ?? [], + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + }; + } catch (error) { + console.error(error); + resetTemplatePager(); + message.error('加载模板列表失败'); + } finally { + options.isTemplateLoading.value = false; + } + } + + async function loadMessageDetail(messageId: string) { + options.isDetailLoading.value = true; + try { + const result = await getMemberMessageReachDetailApi({ messageId }); + options.detail.value = result; + return result; + } catch (error) { + console.error(error); + options.detail.value = null; + message.error('加载消息详情失败'); + return null; + } finally { + options.isDetailLoading.value = false; + } + } + + async function estimateAudience(audienceType: 'all' | 'tag', tags: string[]) { + options.isEstimatingAudience.value = true; + try { + const result = await estimateMemberMessageAudienceApi({ + audienceType, + tags, + }); + options.audienceEstimateCount.value = result.reachCount ?? 0; + return options.audienceEstimateCount.value; + } catch (error) { + console.error(error); + options.audienceEstimateCount.value = 0; + message.error('估算触达人数失败'); + return 0; + } finally { + options.isEstimatingAudience.value = false; + } + } + + return { + estimateAudience, + loadMessageDetail, + loadMessageList, + loadStats, + loadTemplateList, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/helpers.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/helpers.ts new file mode 100644 index 0000000..c00c3b4 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/helpers.ts @@ -0,0 +1,264 @@ +import type { + MemberMessageReachChannel, + MemberMessageReachDetailDto, + MemberMessageReachStatus, + MemberMessageScheduleType, + MemberMessageTemplateCategory, + SaveMemberMessageReachPayload, + SaveMemberMessageTemplatePayload, +} from '#/api/member/message-reach'; +import type { + MessageReachEditorForm, + MessageReachFilterForm, + MessageTemplateEditorForm, + MessageTemplateFilterForm, +} from '#/views/member/message-reach/types'; + +import dayjs from 'dayjs'; + +/** 状态文案。 */ +export const MESSAGE_STATUS_TEXT_MAP: Record = + { + draft: '草稿', + pending: '待发送', + sending: '发送中', + sent: '已发送', + failed: '发送失败', + }; + +/** 状态标签色。 */ +export const MESSAGE_STATUS_COLOR_MAP: Record< + MemberMessageReachStatus, + string +> = { + draft: 'default', + pending: 'processing', + sending: 'warning', + sent: 'success', + failed: 'error', +}; + +/** 渠道文案。 */ +export const MESSAGE_CHANNEL_TEXT_MAP: Record< + MemberMessageReachChannel, + string +> = { + inapp: '站内信', + sms: '短信', + 'wechat-mini': '微信模板', +}; + +/** 渠道标签色。 */ +export const MESSAGE_CHANNEL_COLOR_MAP: Record< + MemberMessageReachChannel, + string +> = { + inapp: 'blue', + sms: 'green', + 'wechat-mini': 'orange', +}; + +/** 模板分类文案。 */ +export const MESSAGE_TEMPLATE_CATEGORY_TEXT_MAP: Record< + MemberMessageTemplateCategory, + string +> = { + marketing: '营销', + notice: '通知', + recall: '召回', +}; + +/** 模板分类颜色。 */ +export const MESSAGE_TEMPLATE_CATEGORY_COLOR_MAP: Record< + MemberMessageTemplateCategory, + string +> = { + marketing: 'magenta', + notice: 'blue', + recall: 'red', +}; + +/** 收件状态文案。 */ +export const MESSAGE_RECIPIENT_STATUS_TEXT_MAP: Record< + 'failed' | 'pending' | 'sent', + string +> = { + pending: '待发送', + sent: '已发送', + failed: '发送失败', +}; + +/** 收件状态颜色。 */ +export const MESSAGE_RECIPIENT_STATUS_COLOR_MAP: Record< + 'failed' | 'pending' | 'sent', + string +> = { + pending: 'processing', + sent: 'success', + failed: 'error', +}; + +/** 格式化百分比。 */ +export function formatPercent(value: null | number | undefined) { + const amount = Number(value ?? 0); + if (!Number.isFinite(amount)) { + return '0%'; + } + + const digits = amount % 1 === 0 ? 0 : 1; + return `${amount.toFixed(digits)}%`; +} + +/** 格式化数量。 */ +export function formatInteger(value: null | number | undefined) { + const amount = Number(value ?? 0); + if (!Number.isFinite(amount)) { + return '0'; + } + return Math.round(amount).toLocaleString('zh-CN'); +} + +/** 格式化时间。 */ +export function formatDateTime(value?: string) { + if (!value) { + return '—'; + } + + const parsed = dayjs(value); + if (!parsed.isValid()) { + return value; + } + return parsed.format('YYYY-MM-DD HH:mm'); +} + +/** 解析消息发送时间。 */ +export function resolveMessageTime( + sentAt?: string, + scheduledAt?: string, +): string { + if (sentAt) { + return formatDateTime(sentAt); + } + if (scheduledAt) { + return formatDateTime(scheduledAt); + } + return '—'; +} + +/** 将列表筛选映射为查询参数。 */ +export function mapMessageFilterToQuery(form: MessageReachFilterForm) { + const keyword = form.keyword.trim(); + return { + status: form.status || undefined, + channel: form.channel || undefined, + keyword: keyword || undefined, + }; +} + +/** 将模板筛选映射为查询参数。 */ +export function mapTemplateFilterToQuery(form: MessageTemplateFilterForm) { + const keyword = form.keyword.trim(); + return { + category: form.category || undefined, + keyword: keyword || undefined, + }; +} + +/** 将详情映射为编辑表单。 */ +export function mapDetailToEditorForm( + detail: MemberMessageReachDetailDto, + form: MessageReachEditorForm, +) { + form.messageId = detail.messageId; + form.templateId = detail.templateId; + form.title = detail.title; + form.content = detail.content; + form.channels = [...detail.channels]; + form.audienceType = detail.audienceType; + form.audienceTags = [...detail.audienceTags]; + form.scheduleType = detail.scheduleType; + form.scheduledAt = + detail.scheduleType === 'scheduled' && detail.scheduledAt + ? dayjs(detail.scheduledAt) + : null; +} + +/** 重置消息编辑表单。 */ +export function resetMessageEditorForm(form: MessageReachEditorForm) { + form.messageId = ''; + form.templateId = undefined; + form.title = ''; + form.content = ''; + form.channels = ['inapp']; + form.audienceType = 'all'; + form.audienceTags = []; + form.scheduleType = 'immediate'; + form.scheduledAt = null; +} + +/** 重置模板编辑表单。 */ +export function resetTemplateEditorForm(form: MessageTemplateEditorForm) { + form.templateId = ''; + form.name = ''; + form.category = 'notice'; + form.content = ''; +} + +/** 消息编辑表单转保存请求。 */ +export function mapMessageEditorFormToSavePayload( + form: MessageReachEditorForm, + submitAction: 'draft' | 'send', +): SaveMemberMessageReachPayload { + const payload: SaveMemberMessageReachPayload = { + messageId: form.messageId || undefined, + templateId: form.templateId || undefined, + title: form.title.trim(), + content: form.content.trim(), + channels: [...form.channels], + audienceType: form.audienceType, + audienceTags: [...form.audienceTags], + scheduleType: form.scheduleType, + scheduledAt: + form.scheduleType === 'scheduled' && form.scheduledAt + ? form.scheduledAt.toISOString() + : undefined, + submitAction, + }; + return payload; +} + +/** 模板编辑表单转保存请求。 */ +export function mapTemplateEditorFormToSavePayload( + form: MessageTemplateEditorForm, +): SaveMemberMessageTemplatePayload { + return { + templateId: form.templateId || undefined, + name: form.name.trim(), + category: form.category, + content: form.content.trim(), + }; +} + +/** 切换渠道。 */ +export function toggleChannel( + channels: MemberMessageReachChannel[], + channel: MemberMessageReachChannel, +) { + if (channels.includes(channel)) { + return channels.filter((item) => item !== channel); + } + return [...channels, channel]; +} + +/** 切换标签。 */ +export function toggleTag(tags: string[], tag: string) { + if (tags.includes(tag)) { + return tags.filter((item) => item !== tag); + } + return [...tags, tag]; +} + +/** 解析时间类型。 */ +export function resolveScheduleType(value: string): MemberMessageScheduleType { + return value === 'scheduled' ? 'scheduled' : 'immediate'; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/message-actions.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/message-actions.ts new file mode 100644 index 0000000..e74a05c --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/message-actions.ts @@ -0,0 +1,264 @@ +import type { Dayjs } from 'dayjs'; + +import type { Ref } from 'vue'; + +import type { + MemberMessageTemplateDto, + SaveMemberMessageReachPayload, +} from '#/api/member/message-reach'; +import type { + MemberMessageReachTabKey, + MessageReachDetailViewModel, + MessageReachEditorForm, +} from '#/views/member/message-reach/types'; + +import { message, Modal } from 'ant-design-vue'; + +import { + deleteMemberMessageReachApi, + saveMemberMessageReachApi, +} from '#/api/member/message-reach'; + +import { + mapDetailToEditorForm, + mapMessageEditorFormToSavePayload, + resetMessageEditorForm, + toggleTag, +} from './helpers'; + +interface CreateMessageActionsOptions { + activeTab: Ref; + audienceEstimateCount: Ref; + canManage: Ref; + detail: Ref; + detailDrawerMessageId: Ref; + form: MessageReachEditorForm; + isDetailDrawerOpen: Ref; + isMessageDrawerOpen: Ref; + isMessageSubmitting: Ref; + messageDrawerMode: Ref<'create' | 'edit'>; + loadMessageDetail: ( + messageId: string, + ) => Promise; + loadMessageList: () => Promise; + loadStats: () => Promise; +} + +export function createMessageActions(options: CreateMessageActionsOptions) { + function setMessageDrawerOpen(value: boolean) { + options.isMessageDrawerOpen.value = value; + } + + function setMessageTitle(value: string) { + options.form.title = value; + } + + function setMessageContent(value: string) { + options.form.content = value; + } + + function setMessageChannel(channel: 'inapp' | 'sms' | 'wechat-mini') { + if ( + options.form.channels.length === 1 && + options.form.channels[0] === channel + ) { + return; + } + options.form.channels = [channel]; + } + + function setAudienceType(value: 'all' | 'tag') { + options.form.audienceType = value; + if (value === 'all') { + options.form.audienceTags = []; + } + } + + function toggleAudienceTag(value: string) { + options.form.audienceTags = toggleTag(options.form.audienceTags, value); + } + + function setScheduleType(value: 'immediate' | 'scheduled') { + options.form.scheduleType = value; + if (value === 'immediate') { + options.form.scheduledAt = null; + } + } + + function setScheduledAt(value: Dayjs | null) { + options.form.scheduledAt = value; + } + + function switchToTemplateTab() { + options.activeTab.value = 'template'; + options.isMessageDrawerOpen.value = false; + } + + async function openCreateMessageDrawer() { + if (!options.canManage.value) { + return; + } + + resetMessageEditorForm(options.form); + options.audienceEstimateCount.value = 0; + options.messageDrawerMode.value = 'create'; + options.isMessageDrawerOpen.value = true; + } + + async function openEditMessageDrawer(messageId: string) { + if (!options.canManage.value) { + return; + } + + const detail = await options.loadMessageDetail(messageId); + if (!detail) { + return; + } + + mapDetailToEditorForm(detail, options.form); + options.audienceEstimateCount.value = detail.estimatedReachCount; + options.messageDrawerMode.value = 'edit'; + options.isMessageDrawerOpen.value = true; + } + + async function openDetailDrawer(messageId: string) { + options.detailDrawerMessageId.value = messageId; + options.isDetailDrawerOpen.value = true; + await options.loadMessageDetail(messageId); + } + + async function refreshDetailIfNeeded(messageId: string) { + if (!options.isDetailDrawerOpen.value) { + return; + } + if (options.detailDrawerMessageId.value !== messageId) { + return; + } + await options.loadMessageDetail(messageId); + } + + function useTemplateToCreateMessage(template: MemberMessageTemplateDto) { + if (!options.canManage.value) { + return; + } + + resetMessageEditorForm(options.form); + options.form.templateId = template.templateId; + options.form.title = template.name; + options.form.content = template.content; + options.audienceEstimateCount.value = 0; + options.messageDrawerMode.value = 'create'; + options.isMessageDrawerOpen.value = true; + options.activeTab.value = 'list'; + } + + function validateMessagePayload(payload: SaveMemberMessageReachPayload) { + if (!payload.title) { + message.warning('请输入消息标题'); + return false; + } + if (!payload.content) { + message.warning('请输入消息内容'); + return false; + } + if (payload.channels.length === 0) { + message.warning('请至少选择一个推送渠道'); + return false; + } + if (payload.audienceType === 'tag' && payload.audienceTags.length === 0) { + message.warning('请选择目标标签'); + return false; + } + if (payload.scheduleType === 'scheduled' && !payload.scheduledAt) { + message.warning('请选择定时发送时间'); + return false; + } + return true; + } + + async function submitMessage(submitAction: 'draft' | 'send') { + if (!options.canManage.value) { + return; + } + + const payload = mapMessageEditorFormToSavePayload( + options.form, + submitAction, + ); + if (!validateMessagePayload(payload)) { + return; + } + + options.isMessageSubmitting.value = true; + try { + const result = await saveMemberMessageReachApi(payload); + message.success( + submitAction === 'send' ? '发送任务已提交' : '草稿已保存', + ); + options.isMessageDrawerOpen.value = false; + await Promise.all([options.loadStats(), options.loadMessageList()]); + await refreshDetailIfNeeded(result.messageId); + } catch (error) { + console.error(error); + message.error(submitAction === 'send' ? '发送失败' : '保存草稿失败'); + } finally { + options.isMessageSubmitting.value = false; + } + } + + async function removeMessage(messageId: string) { + if (!options.canManage.value) { + return; + } + + Modal.confirm({ + title: '确认删除消息?', + content: '删除后无法恢复,且会取消未执行的发送任务。', + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + await deleteMemberMessageReachApi({ messageId }); + message.success('删除成功'); + if (options.detailDrawerMessageId.value === messageId) { + options.isDetailDrawerOpen.value = false; + options.detailDrawerMessageId.value = ''; + options.detail.value = null; + } + await Promise.all([options.loadStats(), options.loadMessageList()]); + } catch (error) { + console.error(error); + message.error('删除失败'); + } + }, + }); + } + + function setDetailDrawerOpen(value: boolean) { + options.isDetailDrawerOpen.value = value; + if (!value) { + options.detailDrawerMessageId.value = ''; + options.detail.value = null; + } + } + + return { + openCreateMessageDrawer, + openDetailDrawer, + openEditMessageDrawer, + removeMessage, + setAudienceType, + setDetailDrawerOpen, + setMessageContent, + setMessageDrawerOpen, + setMessageChannel, + setMessageTitle, + setScheduleType, + setScheduledAt, + submitMessage, + switchToTemplateTab, + toggleAudienceTag, + useTemplateToCreateMessage, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/template-actions.ts b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/template-actions.ts new file mode 100644 index 0000000..982f467 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/message-reach-page/template-actions.ts @@ -0,0 +1,144 @@ +import type { Ref } from 'vue'; + +import type { MessageTemplateEditorForm } from '#/views/member/message-reach/types'; + +import { message, Modal } from 'ant-design-vue'; + +import { + deleteMemberMessageTemplateApi, + getMemberMessageTemplateDetailApi, + saveMemberMessageTemplateApi, +} from '#/api/member/message-reach'; + +import { + mapTemplateEditorFormToSavePayload, + resetTemplateEditorForm, +} from './helpers'; + +interface CreateTemplateActionsOptions { + canManage: Ref; + form: MessageTemplateEditorForm; + isTemplateEditorLoading: Ref; + isTemplateEditorOpen: Ref; + isTemplateSubmitting: Ref; + loadTemplateList: () => Promise; + mode: Ref<'create' | 'edit'>; +} + +export function createTemplateActions(options: CreateTemplateActionsOptions) { + function setTemplateEditorOpen(value: boolean) { + options.isTemplateEditorOpen.value = value; + } + + function setTemplateName(value: string) { + options.form.name = value; + } + + function setTemplateCategory(value: 'marketing' | 'notice' | 'recall') { + options.form.category = value; + } + + function setTemplateContent(value: string) { + options.form.content = value; + } + + function openCreateTemplateModal() { + if (!options.canManage.value) { + return; + } + + options.mode.value = 'create'; + resetTemplateEditorForm(options.form); + options.isTemplateEditorOpen.value = true; + } + + async function openEditTemplateModal(templateId: string) { + if (!options.canManage.value) { + return; + } + + options.isTemplateEditorLoading.value = true; + try { + const detail = await getMemberMessageTemplateDetailApi({ templateId }); + options.mode.value = 'edit'; + options.form.templateId = detail.templateId; + options.form.name = detail.name; + options.form.category = detail.category; + options.form.content = detail.content; + options.isTemplateEditorOpen.value = true; + } catch (error) { + console.error(error); + message.error('加载模板详情失败'); + } finally { + options.isTemplateEditorLoading.value = false; + } + } + + async function submitTemplate() { + if (!options.canManage.value) { + return; + } + + const payload = mapTemplateEditorFormToSavePayload(options.form); + if (!payload.name) { + message.warning('请输入模板名称'); + return; + } + if (!payload.content) { + message.warning('请输入模板内容'); + return; + } + + options.isTemplateSubmitting.value = true; + try { + await saveMemberMessageTemplateApi(payload); + message.success( + options.mode.value === 'create' ? '模板创建成功' : '模板保存成功', + ); + options.isTemplateEditorOpen.value = false; + await options.loadTemplateList(); + } catch (error) { + console.error(error); + message.error( + options.mode.value === 'create' ? '模板创建失败' : '模板保存失败', + ); + } finally { + options.isTemplateSubmitting.value = false; + } + } + + function removeTemplate(templateId: string) { + if (!options.canManage.value) { + return; + } + + Modal.confirm({ + title: '确认删除模板?', + content: '删除后不可恢复,且不影响已发送消息。', + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + await deleteMemberMessageTemplateApi({ templateId }); + message.success('删除成功'); + await options.loadTemplateList(); + } catch (error) { + console.error(error); + message.error('删除失败'); + } + }, + }); + } + + return { + openCreateTemplateModal, + openEditTemplateModal, + removeTemplate, + setTemplateCategory, + setTemplateContent, + setTemplateEditorOpen, + setTemplateName, + submitTemplate, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/composables/useMemberMessageReachPage.ts b/apps/web-antd/src/views/member/message-reach/composables/useMemberMessageReachPage.ts new file mode 100644 index 0000000..136ff0a --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/composables/useMemberMessageReachPage.ts @@ -0,0 +1,396 @@ +import type { MemberMessageReachTabKey } from '../types'; + +import type { + MemberMessageReachChannel, + MemberMessageReachDetailDto, + MemberMessageReachStatus, + MemberMessageTemplateCategory, +} from '#/api/member/message-reach'; + +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { + createDefaultMessageReachEditorForm, + createDefaultMessageReachFilterForm, + createDefaultMessageReachPager, + createDefaultMessageReachStats, + createDefaultMessageTemplateEditorForm, + createDefaultMessageTemplateFilterForm, + createDefaultMessageTemplatePager, +} from '../types'; +import { + MEMBER_MESSAGE_REACH_MANAGE_PERMISSION, + MEMBER_MESSAGE_REACH_VIEW_PERMISSION, + MESSAGE_REACH_TAB_OPTIONS, +} from './message-reach-page/constants'; +import { createDataActions } from './message-reach-page/data-actions'; +import { + resetMessageEditorForm, + resetTemplateEditorForm, +} from './message-reach-page/helpers'; +import { createMessageActions } from './message-reach-page/message-actions'; +import { createTemplateActions } from './message-reach-page/template-actions'; + +export function useMemberMessageReachPage() { + const accessStore = useAccessStore(); + + const activeTab = ref('list'); + + const stats = ref(createDefaultMessageReachStats()); + const isStatsLoading = ref(false); + + const messageFilterForm = reactive(createDefaultMessageReachFilterForm()); + const messagePager = ref(createDefaultMessageReachPager()); + const isMessageLoading = ref(false); + + const templateFilterForm = reactive(createDefaultMessageTemplateFilterForm()); + const templatePager = ref(createDefaultMessageTemplatePager()); + const isTemplateLoading = ref(false); + + const detail = ref(null); + const isDetailLoading = ref(false); + const isDetailDrawerOpen = ref(false); + const detailDrawerMessageId = ref(''); + + const messageDrawerMode = ref<'create' | 'edit'>('create'); + const form = reactive(createDefaultMessageReachEditorForm()); + const isMessageDrawerOpen = ref(false); + const isMessageSubmitting = ref(false); + const audienceEstimateCount = ref(0); + const isEstimatingAudience = ref(false); + + const templateEditorMode = ref<'create' | 'edit'>('create'); + const templateForm = reactive(createDefaultMessageTemplateEditorForm()); + const isTemplateEditorOpen = ref(false); + const isTemplateEditorLoading = ref(false); + const isTemplateSubmitting = ref(false); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(MEMBER_MESSAGE_REACH_MANAGE_PERMISSION), + ); + const canView = computed( + () => + canManage.value || + accessCodeSet.value.has(MEMBER_MESSAGE_REACH_VIEW_PERMISSION), + ); + + const messageDrawerTitle = computed(() => + messageDrawerMode.value === 'create' ? '创建消息' : '编辑消息', + ); + + const templateEditorTitle = computed(() => + templateEditorMode.value === 'create' ? '新建模板' : '编辑模板', + ); + + const templateEditorSubmitText = computed(() => + templateEditorMode.value === 'create' ? '创建' : '保存', + ); + + const { + estimateAudience, + loadMessageDetail, + loadMessageList, + loadStats, + loadTemplateList, + } = createDataActions({ + audienceEstimateCount, + detail, + isDetailLoading, + isEstimatingAudience, + isMessageLoading, + isStatsLoading, + isTemplateLoading, + messageFilterForm, + messagePager, + stats, + templateFilterForm, + templatePager, + }); + + const { + openCreateMessageDrawer, + openDetailDrawer, + openEditMessageDrawer, + removeMessage, + setAudienceType, + setDetailDrawerOpen, + setMessageContent, + setMessageDrawerOpen, + setMessageChannel, + setMessageTitle, + setScheduleType, + setScheduledAt, + submitMessage, + switchToTemplateTab, + toggleAudienceTag, + useTemplateToCreateMessage, + } = createMessageActions({ + activeTab, + audienceEstimateCount, + canManage, + detail, + detailDrawerMessageId, + form, + isDetailDrawerOpen, + isMessageDrawerOpen, + isMessageSubmitting, + messageDrawerMode, + loadMessageDetail, + loadMessageList, + loadStats, + }); + + const { + openCreateTemplateModal, + openEditTemplateModal, + removeTemplate, + setTemplateCategory, + setTemplateContent, + setTemplateEditorOpen, + setTemplateName, + submitTemplate, + } = createTemplateActions({ + canManage, + form: templateForm, + isTemplateEditorLoading, + isTemplateEditorOpen, + isTemplateSubmitting, + loadTemplateList, + mode: templateEditorMode, + }); + + function setActiveTab(value: MemberMessageReachTabKey) { + activeTab.value = value; + } + + function setMessageStatusFilter(value: string) { + messageFilterForm.status = (value || undefined) as + | MemberMessageReachStatus + | undefined; + } + + function setMessageChannelFilter(value: string) { + messageFilterForm.channel = (value || undefined) as + | MemberMessageReachChannel + | undefined; + } + + function setMessageKeyword(value: string) { + messageFilterForm.keyword = value; + } + + async function applyMessageFilters() { + messagePager.value = { + ...messagePager.value, + page: 1, + }; + await loadMessageList(); + } + + async function resetMessageFilters() { + messageFilterForm.status = undefined; + messageFilterForm.channel = undefined; + messageFilterForm.keyword = ''; + messagePager.value = { + ...messagePager.value, + page: 1, + }; + await loadMessageList(); + } + + async function handleMessagePageChange(page: number, pageSize: number) { + messagePager.value = { + ...messagePager.value, + page, + pageSize, + }; + await loadMessageList(); + } + + function setTemplateCategoryFilter(value: string) { + templateFilterForm.category = (value || undefined) as + | MemberMessageTemplateCategory + | undefined; + } + + function setTemplateKeyword(value: string) { + templateFilterForm.keyword = value; + } + + async function applyTemplateFilters() { + templatePager.value = { + ...templatePager.value, + page: 1, + }; + await loadTemplateList(); + } + + async function resetTemplateFilters() { + templateFilterForm.category = undefined; + templateFilterForm.keyword = ''; + templatePager.value = { + ...templatePager.value, + page: 1, + }; + await loadTemplateList(); + } + + async function handleTemplatePageChange(page: number, pageSize: number) { + templatePager.value = { + ...templatePager.value, + page, + pageSize, + }; + await loadTemplateList(); + } + + async function setAudienceTypeAndEstimate(value: 'all' | 'tag') { + setAudienceType(value); + if (value === 'all') { + audienceEstimateCount.value = 0; + return; + } + await estimateAudience('tag', form.audienceTags); + } + + async function toggleAudienceTagAndEstimate(tag: string) { + toggleAudienceTag(tag); + if (form.audienceType !== 'tag') { + return; + } + await estimateAudience('tag', form.audienceTags); + } + + async function submitDraftMessage() { + await submitMessage('draft'); + } + + async function submitSendMessage() { + await submitMessage('send'); + } + + function clearByPermission() { + stats.value = createDefaultMessageReachStats(); + messagePager.value = createDefaultMessageReachPager(); + templatePager.value = createDefaultMessageTemplatePager(); + detail.value = null; + isDetailDrawerOpen.value = false; + detailDrawerMessageId.value = ''; + resetMessageEditorForm(form); + audienceEstimateCount.value = 0; + isMessageDrawerOpen.value = false; + resetTemplateEditorForm(templateForm); + isTemplateEditorOpen.value = false; + } + + async function bootstrapPageData() { + await Promise.all([loadStats(), loadMessageList(), loadTemplateList()]); + } + + watch(canView, async (value, oldValue) => { + if (value === oldValue) { + return; + } + + if (!value) { + clearByPermission(); + return; + } + + await bootstrapPageData(); + }); + + onMounted(async () => { + if (!canView.value) { + clearByPermission(); + return; + } + + await bootstrapPageData(); + }); + + onActivated(() => { + if (!canView.value) { + return; + } + + if ( + messagePager.value.totalCount === 0 && + templatePager.value.totalCount === 0 + ) { + void bootstrapPageData(); + } + }); + + return { + activeTab, + applyMessageFilters, + applyTemplateFilters, + audienceEstimateCount, + canManage, + canView, + detail, + form, + handleMessagePageChange, + handleTemplatePageChange, + isDetailDrawerOpen, + isDetailLoading, + isEstimatingAudience, + isMessageDrawerOpen, + isMessageLoading, + isMessageSubmitting, + isStatsLoading, + isTemplateEditorLoading, + isTemplateEditorOpen, + isTemplateLoading, + isTemplateSubmitting, + messageDrawerTitle, + messageFilterForm, + messagePager, + openCreateMessageDrawer, + openCreateTemplateModal, + openDetailDrawer, + openEditMessageDrawer, + openEditTemplateModal, + removeMessage, + removeTemplate, + resetMessageFilters, + resetTemplateFilters, + setActiveTab, + setAudienceTypeAndEstimate, + setDetailDrawerOpen, + setMessageChannel, + setMessageChannelFilter, + setMessageContent, + setMessageDrawerOpen, + setMessageKeyword, + setMessageStatusFilter, + setMessageTitle, + setScheduleType, + setScheduledAt, + setTemplateCategory, + setTemplateCategoryFilter, + setTemplateContent, + setTemplateEditorOpen, + setTemplateKeyword, + setTemplateName, + stats, + submitDraftMessage, + submitSendMessage, + submitTemplate, + switchToTemplateTab, + tabOptions: MESSAGE_REACH_TAB_OPTIONS, + templateEditorSubmitText, + templateEditorTitle, + templateFilterForm, + templateForm, + templatePager, + toggleAudienceTagAndEstimate, + useTemplateToCreateMessage, + }; +} diff --git a/apps/web-antd/src/views/member/message-reach/index.vue b/apps/web-antd/src/views/member/message-reach/index.vue new file mode 100644 index 0000000..ae07192 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/index.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/apps/web-antd/src/views/member/message-reach/styles/base.less b/apps/web-antd/src/views/member/message-reach/styles/base.less new file mode 100644 index 0000000..5732945 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/base.less @@ -0,0 +1,27 @@ +.page-member-message-reach { + .mmr-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mmr-tab-panel { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mmr-spacer { + flex: 1; + } + + .mmr-time-text { + font-size: 12px; + color: #6b7280; + white-space: nowrap; + } + + .mmr-action-link { + padding-inline: 0; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/drawer.less b/apps/web-antd/src/views/member/message-reach/styles/drawer.less new file mode 100644 index 0000000..2fb863d --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/drawer.less @@ -0,0 +1,163 @@ +.page-member-message-reach { + .mmr-editor-form { + .ant-form-item { + margin-bottom: 16px; + } + } + + .mmr-pill-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .mmr-pill { + padding: 4px 12px; + margin-right: 0; + font-size: 13px; + line-height: 22px; + border-radius: 999px; + } + + .mmr-pill.ant-tag-checkable-checked { + color: #fff; + background: #1677ff; + } + + .mmr-tag-panel { + margin-top: 10px; + } + + .mmr-reach-row { + margin-top: 10px; + font-size: 12px; + font-weight: 500; + color: #1677ff; + } + + .mmr-form-hint { + margin-top: 6px; + font-size: 12px; + color: #9ca3af; + } + + .mmr-scheduled-picker { + width: 260px; + margin-top: 10px; + } + + .mmr-template-link-wrap { + margin-bottom: 0; + } + + .mmr-template-link { + padding-left: 0; + } + + .mmr-drawer-footer { + display: flex; + gap: 10px; + justify-content: flex-end; + } + + .mmr-detail-section { + margin-bottom: 20px; + } + + .mmr-detail-section-title { + margin-bottom: 12px; + font-size: 14px; + font-weight: 600; + color: #111827; + } + + .mmr-detail-title-row { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 12px; + } + + .mmr-detail-title { + font-size: 18px; + font-weight: 700; + color: #111827; + } + + .mmr-detail-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + font-size: 13px; + + .label { + color: #6b7280; + } + + .channels { + display: inline-flex; + gap: 4px; + align-items: center; + } + } + + .mmr-detail-stat-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .mmr-detail-stat-item { + padding: 10px 12px; + background: #f8fafc; + border-radius: 8px; + + .label { + margin-bottom: 4px; + font-size: 12px; + color: #6b7280; + } + + .value { + font-size: 18px; + font-weight: 700; + color: #0f172a; + } + } + + .mmr-detail-content { + padding: 10px 12px; + font-size: 13px; + line-height: 1.65; + color: #374151; + white-space: pre-wrap; + background: #f8fafc; + border: 1px solid #f1f5f9; + border-radius: 8px; + } + + .mmr-detail-error { + margin-top: 10px; + font-size: 12px; + color: #dc2626; + } + + .mmr-recipient-table { + .ant-table-thead > tr > th { + font-size: 12px; + background: #f8fafc; + } + + .ant-table-tbody > tr > td { + font-size: 12px; + } + } + + .mmr-error-text { + display: inline-block; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/index.less b/apps/web-antd/src/views/member/message-reach/styles/index.less new file mode 100644 index 0000000..2a408a3 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/index.less @@ -0,0 +1,7 @@ +@import './base.less'; +@import './layout.less'; +@import './list.less'; +@import './template.less'; +@import './drawer.less'; +@import './modal.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/member/message-reach/styles/layout.less b/apps/web-antd/src/views/member/message-reach/styles/layout.less new file mode 100644 index 0000000..d7929cc --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/layout.less @@ -0,0 +1,78 @@ +.page-member-message-reach { + .mmr-topbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + .mmr-tab-switch { + --ant-segmented-item-selected-bg: #fff; + --ant-segmented-item-selected-color: #1677ff; + } + + .mmr-readonly-tip { + font-size: 12px; + color: #6b7280; + } + + .mmr-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 7%); + } + + .mmr-select { + width: 120px; + } + + .mmr-keyword-input { + width: 200px; + } + + .mmr-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + + .mmr-stat-card { + padding: 16px 18px; + background: #fff; + border-radius: 10px; + box-shadow: 0 3px 10px rgb(15 23 42 / 7%); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + } + + .mmr-stat-card:hover { + box-shadow: 0 10px 20px rgb(15 23 42 / 11%); + transform: translateY(-1px); + } + + .mmr-stat-label { + margin-bottom: 6px; + font-size: 12px; + color: #9ca3af; + } + + .mmr-stat-value { + font-size: 24px; + font-weight: 700; + line-height: 1.1; + color: #111827; + } + + .mmr-stat-unit { + margin-left: 2px; + font-size: 13px; + font-weight: 500; + color: #6b7280; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/list.less b/apps/web-antd/src/views/member/message-reach/styles/list.less new file mode 100644 index 0000000..432d8f8 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/list.less @@ -0,0 +1,53 @@ +.page-member-message-reach { + .mmr-table-wrap { + overflow: hidden; + background: #fff; + border-radius: 10px; + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + } + + .mmr-table { + .ant-table-thead > tr > th { + font-size: 13px; + font-weight: 600; + color: #475569; + background: #f8fafc; + white-space: nowrap; + } + + .ant-table-tbody > tr > td { + font-size: 13px; + vertical-align: middle; + color: #0f172a; + } + } + + .mmr-message-title { + font-weight: 600; + color: #0f172a; + } + + .mmr-message-metric { + margin-top: 2px; + font-size: 12px; + color: #6b7280; + } + + .mmr-channel-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .mmr-table-actions { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .mmr-pagination { + display: flex; + justify-content: flex-end; + padding: 14px 16px 16px; + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/modal.less b/apps/web-antd/src/views/member/message-reach/styles/modal.less new file mode 100644 index 0000000..1cc7010 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/modal.less @@ -0,0 +1,7 @@ +.page-member-message-reach { + .mmr-template-modal-form { + .ant-form-item { + margin-bottom: 14px; + } + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/responsive.less b/apps/web-antd/src/views/member/message-reach/styles/responsive.less new file mode 100644 index 0000000..f419235 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/responsive.less @@ -0,0 +1,48 @@ +.page-member-message-reach { + @media (max-width: 1200px) { + .mmr-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mmr-template-grid { + grid-template-columns: 1fr; + } + + .mmr-detail-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 768px) { + .mmr-toolbar { + align-items: stretch; + padding: 12px; + } + + .mmr-topbar { + flex-direction: column; + align-items: flex-start; + } + + .mmr-select, + .mmr-keyword-input { + width: 100%; + } + + .mmr-stats { + grid-template-columns: 1fr; + } + + .mmr-detail-meta { + grid-template-columns: 1fr; + } + + .mmr-detail-stat-grid { + grid-template-columns: 1fr; + } + + .mmr-scheduled-picker { + width: 100%; + } + } +} diff --git a/apps/web-antd/src/views/member/message-reach/styles/template.less b/apps/web-antd/src/views/member/message-reach/styles/template.less new file mode 100644 index 0000000..6524c26 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/styles/template.less @@ -0,0 +1,85 @@ +.page-member-message-reach { + .mmr-template-wrap { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mmr-template-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .mmr-template-card { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 172px; + padding: 18px 20px; + background: #fff; + border-radius: 10px; + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + } + + .mmr-template-card:hover { + box-shadow: 0 10px 20px rgb(15 23 42 / 12%); + transform: translateY(-1px); + } + + .mmr-template-head { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + } + + .mmr-template-name { + flex: 1; + overflow: hidden; + font-size: 14px; + font-weight: 600; + color: #0f172a; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mmr-template-preview { + display: -webkit-box; + overflow: hidden; + font-size: 12px; + line-height: 1.65; + color: #6b7280; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + min-height: 40px; + } + + .mmr-template-foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + } + + .mmr-template-usage { + font-size: 12px; + color: #9ca3af; + } + + .mmr-template-actions { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .mmr-template-empty { + padding: 36px 0; + background: #fff; + border-radius: 10px; + box-shadow: 0 3px 10px rgb(15 23 42 / 7%); + } +} diff --git a/apps/web-antd/src/views/member/message-reach/types.ts b/apps/web-antd/src/views/member/message-reach/types.ts new file mode 100644 index 0000000..f17b9f9 --- /dev/null +++ b/apps/web-antd/src/views/member/message-reach/types.ts @@ -0,0 +1,147 @@ +/** + * 文件职责:会员消息触达页面类型定义与默认值工厂。 + */ +import type { Dayjs } from 'dayjs'; + +import type { + MemberMessageAudienceType, + MemberMessageReachChannel, + MemberMessageReachDetailDto, + MemberMessageReachListItemDto, + MemberMessageReachStatsDto, + MemberMessageReachStatus, + MemberMessageScheduleType, + MemberMessageTemplateCategory, + MemberMessageTemplateDto, +} from '#/api/member/message-reach'; + +/** 页面主 Tab。 */ +export type MemberMessageReachTabKey = 'list' | 'template'; + +/** 消息列表筛选表单。 */ +export interface MessageReachFilterForm { + channel?: MemberMessageReachChannel; + keyword: string; + status?: MemberMessageReachStatus; +} + +/** 模板列表筛选表单。 */ +export interface MessageTemplateFilterForm { + category?: MemberMessageTemplateCategory; + keyword: string; +} + +/** 消息列表分页状态。 */ +export interface MessageReachPager { + items: MemberMessageReachListItemDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 模板列表分页状态。 */ +export interface MessageTemplatePager { + items: MemberMessageTemplateDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 消息编辑表单。 */ +export interface MessageReachEditorForm { + audienceTags: string[]; + audienceType: MemberMessageAudienceType; + channels: MemberMessageReachChannel[]; + content: string; + messageId: string; + scheduleType: MemberMessageScheduleType; + scheduledAt: Dayjs | null; + templateId?: string; + title: string; +} + +/** 模板编辑表单。 */ +export interface MessageTemplateEditorForm { + category: MemberMessageTemplateCategory; + content: string; + name: string; + templateId: string; +} + +/** 页面统计视图模型。 */ +export type MessageReachStatsViewModel = MemberMessageReachStatsDto; + +/** 消息详情视图模型。 */ +export type MessageReachDetailViewModel = MemberMessageReachDetailDto; + +/** 默认消息列表筛选。 */ +export function createDefaultMessageReachFilterForm(): MessageReachFilterForm { + return { + status: undefined, + channel: undefined, + keyword: '', + }; +} + +/** 默认模板列表筛选。 */ +export function createDefaultMessageTemplateFilterForm(): MessageTemplateFilterForm { + return { + category: undefined, + keyword: '', + }; +} + +/** 默认消息分页。 */ +export function createDefaultMessageReachPager(): MessageReachPager { + return { + items: [], + page: 1, + pageSize: 10, + totalCount: 0, + }; +} + +/** 默认模板分页。 */ +export function createDefaultMessageTemplatePager(): MessageTemplatePager { + return { + items: [], + page: 1, + pageSize: 12, + totalCount: 0, + }; +} + +/** 默认消息编辑表单。 */ +export function createDefaultMessageReachEditorForm(): MessageReachEditorForm { + return { + messageId: '', + templateId: undefined, + title: '', + content: '', + channels: ['inapp'], + audienceType: 'all', + audienceTags: [], + scheduleType: 'immediate', + scheduledAt: null, + }; +} + +/** 默认模板编辑表单。 */ +export function createDefaultMessageTemplateEditorForm(): MessageTemplateEditorForm { + return { + templateId: '', + name: '', + category: 'notice', + content: '', + }; +} + +/** 默认统计数据。 */ +export function createDefaultMessageReachStats(): MessageReachStatsViewModel { + return { + monthlySentCount: 0, + reachMemberCount: 0, + openRate: 0, + conversionRate: 0, + }; +} From 4e32bf21e150b4400a4db03b9c416ec777e45edc Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 4 Mar 2026 11:38:01 +0800 Subject: [PATCH 3/3] =?UTF-8?q?style:=20=E8=B0=83=E6=95=B4=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=A7=A6=E8=BE=BE=E6=A0=B7=E5=BC=8F=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/member/message-reach/styles/list.less | 2 +- .../src/views/member/message-reach/styles/template.less | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web-antd/src/views/member/message-reach/styles/list.less b/apps/web-antd/src/views/member/message-reach/styles/list.less index 432d8f8..ffced99 100644 --- a/apps/web-antd/src/views/member/message-reach/styles/list.less +++ b/apps/web-antd/src/views/member/message-reach/styles/list.less @@ -11,8 +11,8 @@ font-size: 13px; font-weight: 600; color: #475569; - background: #f8fafc; white-space: nowrap; + background: #f8fafc; } .ant-table-tbody > tr > td { diff --git a/apps/web-antd/src/views/member/message-reach/styles/template.less b/apps/web-antd/src/views/member/message-reach/styles/template.less index 6524c26..9045529 100644 --- a/apps/web-antd/src/views/member/message-reach/styles/template.less +++ b/apps/web-antd/src/views/member/message-reach/styles/template.less @@ -40,22 +40,22 @@ .mmr-template-name { flex: 1; overflow: hidden; + text-overflow: ellipsis; font-size: 14px; font-weight: 600; color: #0f172a; - text-overflow: ellipsis; white-space: nowrap; } .mmr-template-preview { display: -webkit-box; + min-height: 40px; overflow: hidden; + -webkit-line-clamp: 2; font-size: 12px; line-height: 1.65; color: #6b7280; - -webkit-line-clamp: 2; -webkit-box-orient: vertical; - min-height: 40px; } .mmr-template-foot {