From a374bf017898eedfb845be5696a7f15d57b9eefc Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 14 Oct 2026 14:19:14 +0800 Subject: [PATCH] Enhance recurring investment features and UI components: - Implement restrictions on the start date selection in the creation form to prevent past dates. - Add order status display in the recent execution records for better visibility. - Introduce system code mapping for order statuses, frequency types, and end types, ensuring multi-language support. - Implement pagination for the investment plans list to improve performance and user experience. - Update internationalization files to include new labels for order status and other UI elements. - Document changes in `IMPACT_ANALYSIS.md` and `TASK_SUMMARY.md` for clarity on the updates. --- IMPACT_ANALYSIS.md | 150 ++++++++++++++++ TASK_SUMMARY.md | 40 +++++ src/api/systemCode.ts | 26 +++ src/composables/useSystemCode.ts | 64 +++++++ src/i18n/locales/en-US.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/i18n/locales/zh-TW.ts | 1 + src/stores/systemCode.ts | 145 +++++++++++++++ src/types/components.d.ts | 1 + src/types/recurringInvestment.ts | 1 + src/types/scheduleInvestment.ts | 6 + src/utils/scheduleInvestmentPlanMapper.ts | 26 ++- src/views/RecurringInvestmentView.vue | 205 +++++++++++++++++++--- 13 files changed, 639 insertions(+), 28 deletions(-) create mode 100644 src/api/systemCode.ts create mode 100644 src/composables/useSystemCode.ts create mode 100644 src/stores/systemCode.ts diff --git a/IMPACT_ANALYSIS.md b/IMPACT_ANALYSIS.md index 779f306..0213deb 100644 --- a/IMPACT_ANALYSIS.md +++ b/IMPACT_ANALYSIS.md @@ -1085,3 +1085,153 @@ ### 5. 验证与测试 - `npm run build`;表单三种结束类型切换与提交 payload `endType` 符合预期。 + +--- + +## 补充:开始日期禁选过去日 + 详情执行订单状态列(2026-10-14) + +- **背景与目标**:创建计划开始日期不得早于今天;计划详情最近执行列表展示后端 `orderStatus`。 +- **涉及模块**:`RecurringInvestmentView.vue`、三语言 `planDetail.colOrderStatus`。 +- **改动类型**:缺陷修复 / 功能优化(UI)。 + +### 2. 方法级改动分析 + +| 位置 | 差异 | +|------|------| +| `disableFormStartDate` / `isStartDateBeforeToday` | 按本地日历日禁用/校验开始日期。 | +| 创建表单 `el-date-picker`(startDate) | 绑定 `:disabled-date="disableFormStartDate"`。 | +| `validate()` | 开始日期为空或早于今天时 `fieldError.startDate`。 | +| 详情 `detail-exec-table` / 移动端卡片 | 末列增加 `colOrderStatus` 与 `row.orderStatus`。 | + +### 3. 调用方与影响范围 + +- 仅创建表单与计划详情弹窗展示;**破坏性变更**:否。 + +### 4. 风险与回滚 + +- **风险级别**:低。 +- **回滚方式是否简单**:是。 + +### 5. 验证与测试 + +- `npm run build`;开始日期面板不可点选昨日;详情执行表末列为订单状态。 + +--- + +## 补充:订单状态系统码展示(2026-10-14) + +- **背景与目标**:计划详情「最近执行」的 `orderStatus` 为系统码 `codeValue`(如 `12`),需通过 `POST /admin/systemCode/getByCodeTypeBatch`(`param: ['OrderStatus']`)映射 `descriptionI18nId`,并结合已有 `systemI18n` 展示当前语言文案。 +- **涉及模块**:`src/api/systemCode.ts`、`src/stores/systemCode.ts`、`src/composables/useSystemCode.ts`、`RecurringInvestmentView.vue`。 +- **改动类型**:功能优化(UI)。 + +### 2. 方法级改动分析 + +| 位置 | 差异 | +|------|------| +| `getSystemCodeByCodeTypeBatch` | 新增批量查询系统码 API 封装。 | +| `useSystemCodeStore.ensureCodeTypes` | 缓存 `OrderStatus` 等 codeType 的 value→i18nId 映射。 | +| `useSystemCode().codeLabel` | 解析 codeValue 为服务端多语言文案。 | +| `formatOrderStatus` / 详情执行表与移动端卡片 | 原样展示 `row.orderStatus` 改为 `formatOrderStatus`。 | + +### 3. 调用方与影响范围 + +- 仅定期投资计划详情弹窗执行记录「订单状态」列;**破坏性变更**:否(无匹配码时仍回退显示原 codeValue)。 + +### 4. 风险与回滚 + +- **风险级别**:低(依赖 admin 系统码与 base i18n 接口可用性)。 +- **回滚方式是否简单**:是。 + +### 5. 验证与测试 + +- `npm run build`;详情执行记录订单状态显示为 i18n 文案而非裸数字码。 + +--- + +## 补充:定投周期系统码下拉(2026-10-14) + +- **背景与目标**:创建表单「定投周期」选项来自 `getByCodeTypeBatch` 的 `ScheduleInvestmentFrequencyType`(`DAILY` / `WEEKLY` / `MONTHLY`),展示 `descriptionI18nId` 对应服务端多语言;列表/详情/确认预览的周期文案与表单一致。 +- **涉及模块**:`systemCode` store/composable、`scheduleInvestment.ts` 常量、`RecurringInvestmentView.vue`、`recurringInvestment.ts`(draft `frequencyType`)。 +- **改动类型**:功能优化(UI)。 + +### 2. 方法级改动分析 + +| 位置 | 差异 | +|------|------| +| `useSystemCodeStore` | 按 `sort` 保留系统码条目,提供 `getSortedCodeValues`。 | +| `codeSelectOptions` / `codeLabelWithFallback` | 下拉选项与带本地 i18n 回退的文案解析。 | +| `frequencyOptions` | 优先系统码列表,接口失败时回退 `FREQUENCY_TYPE_KEYS` + 静态 i18n。 | +| `frequencyTypeLabel` | 替换多处 `t('recurring.frequency.*')`。 | +| `onMounted` `ensureCodeTypes` | 增加 `ScheduleInvestmentFrequencyType`。 | + +### 3. 调用方与影响范围 + +- 创建表单周期下拉、计划列表/详情/确认弹窗周期展示;**破坏性变更**:否(选项 value 仍为 `DAILY` 等 API 枚举)。 + +### 4. 风险与回滚 + +- **风险级别**:低。 +- **回滚方式是否简单**:是。 + +### 5. 验证与测试 + +- `npm run build`;周期下拉为服务端文案;未加载系统码时仍显示静态 i18n。 + +--- + +## 补充:结束类型系统码下拉(2026-10-14) + +- **背景与目标**:创建表单「结束类型」组合框左侧选项来自 `ScheduleInvestmentEndType`(`NO_END` / `END_DATE` / `COUNT`),展示服务端 i18n;表单内部仍用 `endMode`(`none` / `date` / `count`)与提交逻辑不变。 +- **涉及模块**:`scheduleInvestment.ts` 常量、`scheduleInvestmentPlanMapper.ts`(`endMode` ↔ API 枚举)、`RecurringInvestmentView.vue`。 +- **改动类型**:功能优化(UI)。 + +### 2. 方法级改动分析 + +| 位置 | 差异 | +|------|------| +| `endModeToScheduleEndType` / `scheduleEndTypeToEndMode` | UI 与 API 结束类型互转。 | +| `endModeOptions` | 系统码驱动下拉,`codeValue` 映射为 `endMode`。 | +| `endTypeLabel` | 列表/详情/确认中 GTC 等结束类型文案与下拉一致。 | +| `ensureCodeTypes` | 增加 `ScheduleInvestmentEndType`。 | + +### 3. 调用方与影响范围 + +- 创建表单结束类型选择与相关展示;**破坏性变更**:否。 + +### 4. 风险与回滚 + +- **风险级别**:低。 +- **回滚方式是否简单**:是。 + +### 5. 验证与测试 + +- `npm run build`;结束类型下拉显示服务端文案(如 GTC 对应 `NO_END`)。 + +--- + +## 补充:我的计划列表分页(2026-10-14) + +- **背景与目标**:「我的定期投资计划」表格/卡片列表对接 `plan/page` 分页,避免一次拉取过多记录。 +- **涉及模块**:`RecurringInvestmentView.vue`。 +- **改动类型**:功能优化(UI)。 + +### 2. 方法级改动分析 + +| 位置 | 差异 | +|------|------| +| `loadPlans` | 使用 `current` / `size` 请求,写入 `planPageTotal`;支持 `loadPlans(1)` 重置页码。 | +| `el-pagination` | 默认每页 10 条,可选 10/20/50;移动端精简 layout。 | +| 空状态 | 以 `planPageTotal === 0` 判断无计划。 | + +### 3. 调用方与影响范围 + +- 计划列表展示与创建/终止后刷新;**破坏性变更**:否(`runningCount` 仍为当前页内统计,与改前单页数据范围一致)。 + +### 4. 风险与回滚 + +- **风险级别**:低。 +- **回滚方式是否简单**:是。 + +### 5. 验证与测试 + +- `npm run build`;翻页、改每页条数后列表与总数一致;新建计划后回到第 1 页。 diff --git a/TASK_SUMMARY.md b/TASK_SUMMARY.md index b37e421..94f9d62 100644 --- a/TASK_SUMMARY.md +++ b/TASK_SUMMARY.md @@ -307,3 +307,43 @@ - 创建计划与 `plan/next-execution` 请求体 `ctOffsetMinutes` 传 `null`;预览校验文案改为「请先选择交易所与定投周期」。 - 确认弹窗去掉 CT 偏移行;计划详情仍展示后端返回的偏移。 - 详见 `IMPACT_ANALYSIS.md` 本节补充。 + +--- + +## 补充:开始日期禁选过去日 + 详情执行订单状态列(2026-10-14) + +- 创建表单「开始日期」日期选择器禁用今天之前的日期;提交校验同步拦截。 +- 计划详情「最近执行」表格与移动端卡片末列展示 `orderStatus`(订单状态)。 +- 参见 `IMPACT_ANALYSIS.md` 对应补充。 + +--- + +## 补充:订单状态系统码映射(2026-10-14) + +- 调用 `POST /admin/systemCode/getByCodeTypeBatch` 预加载 `OrderStatus`;`orderStatus` 按 `codeValue` 匹配 `descriptionI18nId`,经 `useServiceI18n` 展示多语言订单状态文案。 +- 涉及 `api/systemCode.ts`、`stores/systemCode.ts`、`composables/useSystemCode.ts`、`RecurringInvestmentView.vue`。 +- 验证:`npm run build`;详情「訂單狀態」列显示文案而非裸码值。 + +--- + +## 补充:定投周期系统码下拉(2026-10-14) + +- `ScheduleInvestmentFrequencyType` 批量加载后驱动创建表单「定投周期」下拉(按 `sort` 排序);列表/详情/确认预览统一 `frequencyTypeLabel`。 +- 接口不可用时回退本地 `recurring.frequency.*` 文案。 +- 详见 `IMPACT_ANALYSIS.md` 本节补充。 + +--- + +## 补充:结束类型系统码下拉(2026-10-14) + +- `ScheduleInvestmentEndType` 驱动创建表单结束类型下拉;`NO_END`→`none`、`END_DATE`→`date`、`COUNT`→`count`。 +- 列表/详情/确认预览的 GTC 等文案经 `endTypeLabel` 与系统码一致。 +- 详见 `IMPACT_ANALYSIS.md` 本节补充。 + +--- + +## 补充:我的计划列表分页(2026-10-14) + +- `getScheduleInvestmentPlanPage` 分页参数驱动列表;表格与移动端卡片共用 `el-pagination`(默认 10 条/页)。 +- 新建计划成功后刷新并回到第 1 页。 +- 详见 `IMPACT_ANALYSIS.md` 本节补充。 diff --git a/src/api/systemCode.ts b/src/api/systemCode.ts new file mode 100644 index 0000000..fbac835 --- /dev/null +++ b/src/api/systemCode.ts @@ -0,0 +1,26 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' + +export interface SystemCodeItem { + id: string + codeType: string + codeValue: string + descriptionI18nId: string + remark?: string + sort?: number + createBy?: string + createOn?: string + updateBy?: string + updateOn?: string +} + +/** 按 codeType 批量查询系统码,返回键为 codeType、值为条目列表 */ +export type SystemCodeBatchMap = Record + +export function getSystemCodeByCodeTypeBatch( + codeTypes: string[], +): Promise> { + return http.post('/admin/systemCode/getByCodeTypeBatch', { + param: codeTypes, + }) +} diff --git a/src/composables/useSystemCode.ts b/src/composables/useSystemCode.ts new file mode 100644 index 0000000..39e1c7d --- /dev/null +++ b/src/composables/useSystemCode.ts @@ -0,0 +1,64 @@ +import { storeToRefs } from 'pinia' +import { useAppStore } from '@/stores/app' +import { useServiceI18nStore } from '@/stores/serviceI18n' +import { useSystemCodeStore } from '@/stores/systemCode' + +/** 系统码 codeValue → 当前语言展示文案(经 descriptionI18nId + 服务端 i18n) */ +export function useSystemCode() { + const appStore = useAppStore() + const systemCodeStore = useSystemCodeStore() + const { syncRevision } = storeToRefs(systemCodeStore) + const serviceI18nStore = useServiceI18nStore() + + function codeLabel(codeType: string, codeValue: string | undefined): string { + syncRevision.value + const raw = codeValue?.trim() + if (!raw) { + return '' + } + const i18nId = systemCodeStore.getDescriptionI18nId(codeType, raw) + if (i18nId) { + return serviceI18nStore.getText(i18nId, appStore.locale) + } + return raw + } + + function codeSelectOptions(codeType: string): { value: string; label: string }[] { + syncRevision.value + return systemCodeStore.getSortedCodeValues(codeType).map((value) => ({ + value, + label: codeLabel(codeType, value), + })) + } + + function hasCodeValue(codeType: string, codeValue: string | undefined): boolean { + syncRevision.value + const raw = codeValue?.trim() + if (!raw) { + return false + } + return Boolean(systemCodeStore.getDescriptionI18nId(codeType, raw)) + } + + function codeLabelWithFallback( + codeType: string, + codeValue: string | undefined, + fallback: (raw: string) => string, + ): string { + const raw = codeValue?.trim() + if (!raw) { + return '' + } + if (hasCodeValue(codeType, codeValue)) { + return codeLabel(codeType, codeValue) + } + return fallback(raw) + } + + return { + ensureCodeTypes: systemCodeStore.ensureCodeTypes, + codeLabel, + codeSelectOptions, + codeLabelWithFallback, + } +} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 9a7bbc0..fa0547c 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -51,6 +51,7 @@ export default { executionEmpty: 'No execution records', colScheduledDate: 'Scheduled date', colExecutionStatus: 'Execution', + colOrderStatus: 'Order status', colAmount: 'Amount / qty', colFilledQty: 'Filled qty', colFilledAmount: 'Filled amount', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index d5b6207..c5dac4a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -51,6 +51,7 @@ export default { executionEmpty: '暂无执行记录', colScheduledDate: '计划执行日', colExecutionStatus: '执行状态', + colOrderStatus: '订单状态', colAmount: '金额/数量', colFilledQty: '成交数量', colFilledAmount: '成交金额', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 95a4fe6..61d29d9 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -51,6 +51,7 @@ export default { executionEmpty: '暫無執行記錄', colScheduledDate: '計劃執行日', colExecutionStatus: '執行狀態', + colOrderStatus: '訂單狀態', colAmount: '金額/數量', colFilledQty: '成交數量', colFilledAmount: '成交金額', diff --git a/src/stores/systemCode.ts b/src/stores/systemCode.ts new file mode 100644 index 0000000..c1c00bb --- /dev/null +++ b/src/stores/systemCode.ts @@ -0,0 +1,145 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { + getSystemCodeByCodeTypeBatch, + type SystemCodeBatchMap, + type SystemCodeItem, +} from '@/api/systemCode' + +function sortCodeItems(items: SystemCodeItem[]): SystemCodeItem[] { + return [...items].sort((a, b) => { + const sortA = a.sort ?? 0 + const sortB = b.sort ?? 0 + if (sortA !== sortB) { + return sortA - sortB + } + return a.codeValue.localeCompare(b.codeValue) + }) +} + +function collectItemsFromBatch( + data: SystemCodeBatchMap | SystemCodeItem[] | undefined, + requestedTypes: string[], +): SystemCodeItem[] { + if (!data) { + return [] + } + const list: SystemCodeItem[] = [] + const push = (item: SystemCodeItem, fallbackType?: string) => { + const type = (item.codeType || fallbackType || '').trim() + const value = item.codeValue?.trim() + const i18nId = item.descriptionI18nId?.trim() + if (!type || !value || !i18nId) { + return + } + list.push({ ...item, codeType: type, codeValue: value, descriptionI18nId: i18nId }) + } + if (Array.isArray(data)) { + data.forEach((item) => push(item)) + return list + } + for (const type of requestedTypes) { + const rows = data[type] + if (Array.isArray(rows)) { + rows.forEach((item) => push(item, type)) + } + } + for (const [type, rows] of Object.entries(data)) { + if (Array.isArray(rows)) { + rows.forEach((item) => push(item, type)) + } + } + return list +} + +function mergeIntoItemsByType( + target: Record, + incoming: SystemCodeItem[], +) { + for (const item of incoming) { + const type = item.codeType + if (!target[type]) { + target[type] = [] + } + const index = target[type].findIndex((row) => row.codeValue === item.codeValue) + if (index >= 0) { + target[type][index] = item + } else { + target[type].push(item) + } + } + for (const type of Object.keys(target)) { + target[type] = sortCodeItems(target[type]) + } +} + +function cloneItemsByType(source: Record) { + const next: Record = {} + for (const [type, items] of Object.entries(source)) { + next[type] = [...items] + } + return next +} + +export const useSystemCodeStore = defineStore('systemCode', () => { + const itemsByType = ref>({}) + /** 系统码加载完成后递增,供视图依赖刷新 */ + const syncRevision = ref(0) + const loadedTypes = new Set() + let batchLoading: Promise | null = null + const pendingTypes: string[] = [] + + function getDescriptionI18nId(codeType: string, codeValue: string): string | undefined { + const item = itemsByType.value[codeType]?.find((row) => row.codeValue === codeValue.trim()) + return item?.descriptionI18nId + } + + function getSortedCodeValues(codeType: string): string[] { + return (itemsByType.value[codeType] ?? []).map((item) => item.codeValue) + } + + async function ensureCodeTypes(codeTypes: string[]) { + const normalized = codeTypes.map((t) => t.trim()).filter(Boolean) + const missing = normalized.filter((t) => !loadedTypes.has(t)) + if (!missing.length) { + if (batchLoading) { + await batchLoading + } + return + } + pendingTypes.push(...missing) + if (batchLoading) { + await batchLoading + return + } + batchLoading = (async () => { + while (pendingTypes.length) { + const batch = [...new Set(pendingTypes.splice(0, pendingTypes.length))] + try { + const result = await getSystemCodeByCodeTypeBatch(batch) + const merged = { ...itemsByType.value } + mergeIntoItemsByType(merged, collectItemsFromBatch(result.data, batch)) + itemsByType.value = cloneItemsByType(merged) + syncRevision.value += 1 + batch.forEach((t) => loadedTypes.add(t)) + } catch (e) { + console.error('Failed to load system codes:', e) + throw e + } + } + })() + try { + await batchLoading + } finally { + batchLoading = null + } + } + + return { + itemsByType, + syncRevision, + getDescriptionI18nId, + getSortedCodeValues, + ensureCodeTypes, + } +}) diff --git a/src/types/components.d.ts b/src/types/components.d.ts index bc29b93..735024e 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -20,6 +20,7 @@ declare module 'vue' { ElDialog: typeof import('element-plus/es')['ElDialog'] ElInput: typeof import('element-plus/es')['ElInput'] ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] diff --git a/src/types/recurringInvestment.ts b/src/types/recurringInvestment.ts index 03c3654..41ad562 100644 --- a/src/types/recurringInvestment.ts +++ b/src/types/recurringInvestment.ts @@ -68,6 +68,7 @@ export interface RecurringPlanDraft { orderPriceType?: 'MARKET' | 'LIMIT' limitPrice?: number frequencyDay?: string + frequencyType?: 'DAILY' | 'WEEKLY' | 'MONTHLY' ctOffsetMinutes?: number } diff --git a/src/types/scheduleInvestment.ts b/src/types/scheduleInvestment.ts index c186994..f01ba1c 100644 --- a/src/types/scheduleInvestment.ts +++ b/src/types/scheduleInvestment.ts @@ -12,6 +12,12 @@ export type WeekdayCode = 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT' | 'SUN' export const FREQUENCY_TYPE_KEYS: FrequencyType[] = ['DAILY', 'WEEKLY', 'MONTHLY'] +/** 定投周期系统码类型(`getByCodeTypeBatch`) */ +export const SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE = 'ScheduleInvestmentFrequencyType' + +/** 结束条件系统码类型(`getByCodeTypeBatch`) */ +export const SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE = 'ScheduleInvestmentEndType' + export const WEEKDAY_CODES: WeekdayCode[] = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] export const INVESTMENT_TYPE_KEYS: InvestmentType[] = ['AMOUNT', 'QUANTITY'] diff --git a/src/utils/scheduleInvestmentPlanMapper.ts b/src/utils/scheduleInvestmentPlanMapper.ts index 3903722..343ccae 100644 --- a/src/utils/scheduleInvestmentPlanMapper.ts +++ b/src/utils/scheduleInvestmentPlanMapper.ts @@ -1,4 +1,4 @@ -import type { InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment' +import type { EndMode, InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment' import type { FrequencyType, ScheduleEndType, @@ -49,6 +49,30 @@ export function normalizeScheduleEndType(raw: string): ScheduleEndType | null { return null } +export function endModeToScheduleEndType(mode: EndMode): ScheduleEndType { + if (mode === 'count') { + return 'COUNT' + } + if (mode === 'date') { + return 'END_DATE' + } + return 'NO_END' +} + +export function scheduleEndTypeToEndMode(raw: string): EndMode | null { + const endType = normalizeScheduleEndType(raw) + if (endType === 'COUNT') { + return 'count' + } + if (endType === 'END_DATE') { + return 'date' + } + if (endType === 'NO_END') { + return 'none' + } + return null +} + function resolveEndFields(record: ScheduleInvestmentPlanRecord): Pick< RecurringPlan, 'endMode' | 'endDate' | 'endCount' | 'noEnd' | 'endDisplay' diff --git a/src/views/RecurringInvestmentView.vue b/src/views/RecurringInvestmentView.vue index a4bb325..90883a3 100644 --- a/src/views/RecurringInvestmentView.vue +++ b/src/views/RecurringInvestmentView.vue @@ -19,6 +19,7 @@ import { } from '@/api/product' import { useBreakpoint } from '@/composables/useBreakpoint' import { useServiceI18n } from '@/composables/useServiceI18n' +import { useSystemCode } from '@/composables/useSystemCode' import { useAuthStore } from '@/stores/auth' import { useRecurringPlansStore } from '@/stores/recurringPlans' import type { @@ -31,6 +32,8 @@ import type { import { FREQUENCY_TYPE_KEYS, INVESTMENT_TYPE_KEYS, + SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE, + SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE, type FrequencyType, type InvestmentType, type OrderPriceType, @@ -47,9 +50,11 @@ import { scheduleProductLineDisplay, } from '@/utils/investAssetDisplay' import { + endModeToScheduleEndType, mapApiPlanStatus, mapSchedulePlanRecordToRecurringPlan, normalizeScheduleEndType, + scheduleEndTypeToEndMode, } from '@/utils/scheduleInvestmentPlanMapper' import { formatFrequencyDayLabel, @@ -79,6 +84,7 @@ interface FormState { const { t, locale } = useI18n() const { si18n } = useServiceI18n() +const { codeLabel, codeLabelWithFallback, codeSelectOptions, ensureCodeTypes } = useSystemCode() const { isMobile, isTablet, isDesktop } = useBreakpoint() const detailDialogWidth = computed(() => { @@ -119,7 +125,14 @@ const nextExecutionPreviewVisible = ref(false) const nextExecutionPreviewLoading = ref(false) const nextExecutionPreview = ref(null) -const PLAN_PAGE_SIZE = 50 +const PLAN_PAGE_SIZE_OPTIONS = [10, 20, 50] as const +const planPageCurrent = ref(1) +const planPageSize = ref(10) +const planPageTotal = ref(0) + +const planPaginationLayout = computed(() => + isMobile.value ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next', +) const form = reactive({ exchangeCode: '', @@ -156,12 +169,45 @@ const exchangeSelectOptions = computed(() => const gutter = computed(() => (isMobile.value ? 12 : 20)) -const frequencyOptions = computed(() => - FREQUENCY_TYPE_KEYS.map((key) => ({ +const frequencyOptions = computed(() => { + const fromSystemCode = codeSelectOptions(SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE) + if (fromSystemCode.length) { + return fromSystemCode + } + return FREQUENCY_TYPE_KEYS.map((key) => ({ value: key, label: t(`recurring.frequency.${key}`), - })), -) + })) +}) + +function frequencyTypeLabel(code: FrequencyType | string | undefined) { + return codeLabelWithFallback(SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE, code, (raw) => { + const key = raw as FrequencyType + if (FREQUENCY_TYPE_KEYS.includes(key)) { + return t(`recurring.frequency.${key}`) + } + return raw + }) +} + +function endTypeLabel(input: EndMode | string | undefined) { + const codeValue = + input === 'none' || input === 'date' || input === 'count' + ? endModeToScheduleEndType(input) + : normalizeScheduleEndType(input ?? '') ?? undefined + return codeLabelWithFallback(SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE, codeValue, (raw) => { + if (raw === 'NO_END') { + return t('recurring.endByGtc') + } + if (raw === 'END_DATE') { + return t('recurring.endByDate') + } + if (raw === 'COUNT') { + return t('recurring.endByCount') + } + return raw + }) +} const investmentTypeOptions = computed(() => INVESTMENT_TYPE_KEYS.map((key) => ({ @@ -178,11 +224,23 @@ const investmentValueHint = computed(() => form.investmentType === 'AMOUNT' ? t('recurring.amountHint') : t('recurring.quantityHint'), ) -const endModeOptions = computed(() => [ - { value: 'date' as EndMode, label: t('recurring.endByDate') }, - { value: 'none' as EndMode, label: t('recurring.endByGtc') }, - { value: 'count' as EndMode, label: t('recurring.endByCount') }, -]) +const endModeOptions = computed(() => { + const fromSystemCode = codeSelectOptions(SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE) + if (fromSystemCode.length) { + return fromSystemCode.flatMap((option) => { + const mode = scheduleEndTypeToEndMode(option.value) + if (!mode) { + return [] + } + return [{ value: mode, label: option.label }] + }) + } + return [ + { value: 'date' as EndMode, label: t('recurring.endByDate') }, + { value: 'none' as EndMode, label: t('recurring.endByGtc') }, + { value: 'count' as EndMode, label: t('recurring.endByCount') }, + ] +}) const endValueFieldError = computed(() => { if (form.endMode === 'count') { @@ -411,24 +469,50 @@ async function loadExchangeOptions() { } } -async function loadPlans() { +async function loadPlans(resetPage?: number) { + if (resetPage !== undefined) { + planPageCurrent.value = resetPage + } plansLoading.value = true try { - const res = await getScheduleInvestmentPlanPage({ current: 1, size: PLAN_PAGE_SIZE }) - const records = res.data?.records + const res = await getScheduleInvestmentPlanPage({ + current: planPageCurrent.value, + size: planPageSize.value, + }) + const data = res.data + const records = data?.records + planPageTotal.value = Number(data?.total ?? 0) + if (data?.current != null) { + planPageCurrent.value = data.current + } plansStore.setPlans( Array.isArray(records) ? records.map(mapSchedulePlanRecordToRecurringPlan) : [], ) } catch { plansStore.setPlans([]) + planPageTotal.value = 0 } finally { plansLoading.value = false } } +function onPlanPageChange() { + loadPlans() +} + +function onPlanPageSizeChange(size: number) { + planPageSize.value = size + loadPlans(1) +} + onMounted(() => { loadExchangeOptions() loadPlans() + ensureCodeTypes([ + 'OrderStatus', + SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE, + SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE, + ]).catch(() => undefined) }) function planInvestmentLabel(plan: RecurringPlan) { @@ -445,7 +529,7 @@ function planInvestmentDisplay(plan: RecurringPlan) { function intervalTextForPlan(plan: RecurringPlan) { if (plan.frequencyType) { - let text = t(`recurring.frequency.${plan.frequencyType}`) + let text = frequencyTypeLabel(plan.frequencyType) if (plan.frequencyDay) { const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t) text = `${text} · ${dayLabel}` @@ -460,7 +544,7 @@ function endTextForPlan(plan: RecurringPlan) { return t('recurring.endCountValue', { n: plan.endCount }) } if (plan.endMode === 'none' || plan.noEnd || !plan.endDate) { - return t('recurring.endByGtc') + return endTypeLabel('none') } return plan.endDate } @@ -470,11 +554,27 @@ function buildEndDisplay() { return t('recurring.endCountValue', { n: Number.parseInt(form.endCount, 10) }) } if (form.endMode === 'none') { - return t('recurring.endByGtc') + return endTypeLabel('none') } return form.endDate } +/** 创建表单开始日期:不可选今天之前(按本地日历日) */ +function disableFormStartDate(date: Date): boolean { + const startOfToday = new Date() + startOfToday.setHours(0, 0, 0, 0) + return date.getTime() < startOfToday.getTime() +} + +function isStartDateBeforeToday(isoDate: string): boolean { + const [y, m, d] = isoDate.split('-').map((part) => Number.parseInt(part, 10)) + if (!y || !m || !d) return false + const picked = new Date(y, m - 1, d) + const startOfToday = new Date() + startOfToday.setHours(0, 0, 0, 0) + return picked.getTime() < startOfToday.getTime() +} + function clearErrors() { assetInvalid.value = false fieldError.exchangeCode = false @@ -499,7 +599,7 @@ function validate(): boolean { assetInvalid.value = true ok = false } - if (!form.startDate) { + if (!form.startDate || isStartDateBeforeToday(form.startDate)) { fieldError.startDate = true ok = false } @@ -654,6 +754,7 @@ function onSubmit() { investmentType: form.investmentType, quantity, orderPriceType: 'MARKET', + frequencyType: form.frequencyType as FrequencyType, frequencyDay: showFrequencyDay.value ? form.frequencyDay : undefined, } confirmVisible.value = true @@ -703,7 +804,7 @@ async function confirmCreate() { submitting.value = true try { await createScheduleInvestmentPlan(body) - await loadPlans() + await loadPlans(1) ElMessage.success(t('recurring.createdToast')) confirmVisible.value = false draft.value = null @@ -783,7 +884,7 @@ function formatDetailOrderPrice(plan: ScheduleInvestmentPlanDetailPlan) { } function formatDetailFrequency(plan: ScheduleInvestmentPlanDetailPlan) { - let text = t(`recurring.frequency.${plan.frequencyType}`) + let text = frequencyTypeLabel(plan.frequencyType) if (plan.frequencyDay) { const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t) text = `${text} · ${dayLabel}` @@ -800,7 +901,7 @@ function formatDetailEndCondition(plan: ScheduleInvestmentPlanDetailPlan) { } } if (endType === 'NO_END' || endType === null || !plan.endDate) { - return t('recurring.endByGtc') + return endTypeLabel('NO_END') } return plan.endDate } @@ -867,6 +968,14 @@ function formatExecutionAvgFilledPrice(row: ScheduleInvestmentPlanExecutionRecor return formatHkd(parsePlanDecimal(row.avgFilledPrice)) } +function formatOrderStatus(code: string | undefined) { + if (!code?.trim()) { + return '—' + } + const label = codeLabel('OrderStatus', code) + return label || '—' +} + function closePlanDetail() { detailVisible.value = false } @@ -887,6 +996,7 @@ async function openPlanDetail(plan: RecurringPlan) { detailPlan.value = null detailExecutions.value = [] try { + await ensureCodeTypes(['OrderStatus']).catch(() => undefined) const res = await getScheduleInvestmentPlanDetail({ planNo }) detailPlan.value = res.data?.plan ?? null detailExecutions.value = Array.isArray(res.data?.recentExecutions) @@ -950,12 +1060,14 @@ const previewInterval = computed(() => { if (!draft.value) { return '' } - const freqKey = draft.value.interval === 'daily' - ? 'DAILY' - : draft.value.interval === 'weekly' - ? 'WEEKLY' - : 'MONTHLY' - let text = t(`recurring.frequency.${freqKey}`) + const freqKey = + draft.value.frequencyType ?? + (draft.value.interval === 'daily' + ? 'DAILY' + : draft.value.interval === 'weekly' + ? 'WEEKLY' + : 'MONTHLY') + let text = frequencyTypeLabel(freqKey) if (draft.value.frequencyDay) { const dayLabel = formatFrequencyDayLabel(freqKey, draft.value.frequencyDay, t) text = `${text} · ${dayLabel}` @@ -989,7 +1101,7 @@ const previewOrderPrice = computed(() => {
{{ t('recurring.planEmpty') }} @@ -1104,6 +1216,20 @@ const previewOrderPrice = computed(() => {
+ +
+ +
@@ -1279,6 +1405,7 @@ const previewOrderPrice = computed(() => { value-format="YYYY-MM-DD" :placeholder="t('recurring.startDate')" class="control-date" + :disabled-date="disableFormStartDate" @change="fieldError.startDate = false" /> @@ -1455,6 +1582,10 @@ const previewOrderPrice = computed(() => {
{{ t('recurring.planDetail.colAvgFilledPrice') }}
{{ formatExecutionAvgFilledPrice(row) }}
+
+
{{ t('recurring.planDetail.colOrderStatus') }}
+
{{ formatOrderStatus(row.orderStatus) }}
+
@@ -1471,6 +1602,7 @@ const previewOrderPrice = computed(() => { {{ t('recurring.planDetail.colFilledAmount') }} {{ t('recurring.planDetail.colAvgFilledPrice') }} {{ t('recurring.planDetail.colExecutionStatus') }} + {{ t('recurring.planDetail.colOrderStatus') }} @@ -1486,6 +1618,7 @@ const previewOrderPrice = computed(() => { {{ formatExecutionFilledAmount(row) }} {{ formatExecutionAvgFilledPrice(row) }} {{ row.executionStatus || '—' }} + {{ formatOrderStatus(row.orderStatus) }} @@ -1631,6 +1764,24 @@ const previewOrderPrice = computed(() => { font-variant-numeric: tabular-nums lining-nums; } +.plan-pagination { + display: flex; + justify-content: flex-end; + padding: 12px 18px 16px; + border-top: 1px solid var(--color-border); + + @include respond-down(md) { + justify-content: center; + padding: 12px 12px 16px; + + :deep(.el-pagination) { + flex-wrap: wrap; + justify-content: center; + row-gap: 8px; + } + } +} + .plan-table { width: 100%; min-width: 720px;