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.
This commit is contained in:
justin
2026-10-14 14:19:14 +08:00
parent dcb008d381
commit a374bf0178
13 changed files with 639 additions and 28 deletions
+150
View File
@@ -1085,3 +1085,153 @@
### 5. 验证与测试 ### 5. 验证与测试
- `npm run build`;表单三种结束类型切换与提交 payload `endType` 符合预期。 - `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 页。
+40
View File
@@ -307,3 +307,43 @@
- 创建计划与 `plan/next-execution` 请求体 `ctOffsetMinutes` 传 `null`;预览校验文案改为「请先选择交易所与定投周期」。 - 创建计划与 `plan/next-execution` 请求体 `ctOffsetMinutes` 传 `null`;预览校验文案改为「请先选择交易所与定投周期」。
- 确认弹窗去掉 CT 偏移行;计划详情仍展示后端返回的偏移。 - 确认弹窗去掉 CT 偏移行;计划详情仍展示后端返回的偏移。
- 详见 `IMPACT_ANALYSIS.md` 本节补充。 - 详见 `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` 本节补充。
+26
View File
@@ -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<string, SystemCodeItem[]>
export function getSystemCodeByCodeTypeBatch(
codeTypes: string[],
): Promise<ApiResponse<SystemCodeBatchMap | SystemCodeItem[]>> {
return http.post('/admin/systemCode/getByCodeTypeBatch', {
param: codeTypes,
})
}
+64
View File
@@ -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,
}
}
+1
View File
@@ -51,6 +51,7 @@ export default {
executionEmpty: 'No execution records', executionEmpty: 'No execution records',
colScheduledDate: 'Scheduled date', colScheduledDate: 'Scheduled date',
colExecutionStatus: 'Execution', colExecutionStatus: 'Execution',
colOrderStatus: 'Order status',
colAmount: 'Amount / qty', colAmount: 'Amount / qty',
colFilledQty: 'Filled qty', colFilledQty: 'Filled qty',
colFilledAmount: 'Filled amount', colFilledAmount: 'Filled amount',
+1
View File
@@ -51,6 +51,7 @@ export default {
executionEmpty: '暂无执行记录', executionEmpty: '暂无执行记录',
colScheduledDate: '计划执行日', colScheduledDate: '计划执行日',
colExecutionStatus: '执行状态', colExecutionStatus: '执行状态',
colOrderStatus: '订单状态',
colAmount: '金额/数量', colAmount: '金额/数量',
colFilledQty: '成交数量', colFilledQty: '成交数量',
colFilledAmount: '成交金额', colFilledAmount: '成交金额',
+1
View File
@@ -51,6 +51,7 @@ export default {
executionEmpty: '暫無執行記錄', executionEmpty: '暫無執行記錄',
colScheduledDate: '計劃執行日', colScheduledDate: '計劃執行日',
colExecutionStatus: '執行狀態', colExecutionStatus: '執行狀態',
colOrderStatus: '訂單狀態',
colAmount: '金額/數量', colAmount: '金額/數量',
colFilledQty: '成交數量', colFilledQty: '成交數量',
colFilledAmount: '成交金額', colFilledAmount: '成交金額',
+145
View File
@@ -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<string, SystemCodeItem[]>,
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<string, SystemCodeItem[]>) {
const next: Record<string, SystemCodeItem[]> = {}
for (const [type, items] of Object.entries(source)) {
next[type] = [...items]
}
return next
}
export const useSystemCodeStore = defineStore('systemCode', () => {
const itemsByType = ref<Record<string, SystemCodeItem[]>>({})
/** 系统码加载完成后递增,供视图依赖刷新 */
const syncRevision = ref(0)
const loadedTypes = new Set<string>()
let batchLoading: Promise<void> | 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,
}
})
+1
View File
@@ -20,6 +20,7 @@ declare module 'vue' {
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
+1
View File
@@ -68,6 +68,7 @@ export interface RecurringPlanDraft {
orderPriceType?: 'MARKET' | 'LIMIT' orderPriceType?: 'MARKET' | 'LIMIT'
limitPrice?: number limitPrice?: number
frequencyDay?: string frequencyDay?: string
frequencyType?: 'DAILY' | 'WEEKLY' | 'MONTHLY'
ctOffsetMinutes?: number ctOffsetMinutes?: number
} }
+6
View File
@@ -12,6 +12,12 @@ export type WeekdayCode = 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT' | 'SUN'
export const FREQUENCY_TYPE_KEYS: FrequencyType[] = ['DAILY', 'WEEKLY', 'MONTHLY'] 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 WEEKDAY_CODES: WeekdayCode[] = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']
export const INVESTMENT_TYPE_KEYS: InvestmentType[] = ['AMOUNT', 'QUANTITY'] export const INVESTMENT_TYPE_KEYS: InvestmentType[] = ['AMOUNT', 'QUANTITY']
+25 -1
View File
@@ -1,4 +1,4 @@
import type { InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment' import type { EndMode, InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment'
import type { import type {
FrequencyType, FrequencyType,
ScheduleEndType, ScheduleEndType,
@@ -49,6 +49,30 @@ export function normalizeScheduleEndType(raw: string): ScheduleEndType | null {
return 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< function resolveEndFields(record: ScheduleInvestmentPlanRecord): Pick<
RecurringPlan, RecurringPlan,
'endMode' | 'endDate' | 'endCount' | 'noEnd' | 'endDisplay' 'endMode' | 'endDate' | 'endCount' | 'noEnd' | 'endDisplay'
+172 -21
View File
@@ -19,6 +19,7 @@ import {
} from '@/api/product' } from '@/api/product'
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint'
import { useServiceI18n } from '@/composables/useServiceI18n' import { useServiceI18n } from '@/composables/useServiceI18n'
import { useSystemCode } from '@/composables/useSystemCode'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRecurringPlansStore } from '@/stores/recurringPlans' import { useRecurringPlansStore } from '@/stores/recurringPlans'
import type { import type {
@@ -31,6 +32,8 @@ import type {
import { import {
FREQUENCY_TYPE_KEYS, FREQUENCY_TYPE_KEYS,
INVESTMENT_TYPE_KEYS, INVESTMENT_TYPE_KEYS,
SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE,
SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE,
type FrequencyType, type FrequencyType,
type InvestmentType, type InvestmentType,
type OrderPriceType, type OrderPriceType,
@@ -47,9 +50,11 @@ import {
scheduleProductLineDisplay, scheduleProductLineDisplay,
} from '@/utils/investAssetDisplay' } from '@/utils/investAssetDisplay'
import { import {
endModeToScheduleEndType,
mapApiPlanStatus, mapApiPlanStatus,
mapSchedulePlanRecordToRecurringPlan, mapSchedulePlanRecordToRecurringPlan,
normalizeScheduleEndType, normalizeScheduleEndType,
scheduleEndTypeToEndMode,
} from '@/utils/scheduleInvestmentPlanMapper' } from '@/utils/scheduleInvestmentPlanMapper'
import { import {
formatFrequencyDayLabel, formatFrequencyDayLabel,
@@ -79,6 +84,7 @@ interface FormState {
const { t, locale } = useI18n() const { t, locale } = useI18n()
const { si18n } = useServiceI18n() const { si18n } = useServiceI18n()
const { codeLabel, codeLabelWithFallback, codeSelectOptions, ensureCodeTypes } = useSystemCode()
const { isMobile, isTablet, isDesktop } = useBreakpoint() const { isMobile, isTablet, isDesktop } = useBreakpoint()
const detailDialogWidth = computed(() => { const detailDialogWidth = computed(() => {
@@ -119,7 +125,14 @@ const nextExecutionPreviewVisible = ref(false)
const nextExecutionPreviewLoading = ref(false) const nextExecutionPreviewLoading = ref(false)
const nextExecutionPreview = ref<ScheduleInvestmentNextExecutionData | null>(null) const nextExecutionPreview = ref<ScheduleInvestmentNextExecutionData | null>(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<FormState>({ const form = reactive<FormState>({
exchangeCode: '', exchangeCode: '',
@@ -156,12 +169,45 @@ const exchangeSelectOptions = computed(() =>
const gutter = computed(() => (isMobile.value ? 12 : 20)) const gutter = computed(() => (isMobile.value ? 12 : 20))
const frequencyOptions = computed(() => const frequencyOptions = computed(() => {
FREQUENCY_TYPE_KEYS.map((key) => ({ const fromSystemCode = codeSelectOptions(SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE)
if (fromSystemCode.length) {
return fromSystemCode
}
return FREQUENCY_TYPE_KEYS.map((key) => ({
value: key, value: key,
label: t(`recurring.frequency.${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(() => const investmentTypeOptions = computed(() =>
INVESTMENT_TYPE_KEYS.map((key) => ({ INVESTMENT_TYPE_KEYS.map((key) => ({
@@ -178,11 +224,23 @@ const investmentValueHint = computed(() =>
form.investmentType === 'AMOUNT' ? t('recurring.amountHint') : t('recurring.quantityHint'), form.investmentType === 'AMOUNT' ? t('recurring.amountHint') : t('recurring.quantityHint'),
) )
const endModeOptions = computed(() => [ 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: 'date' as EndMode, label: t('recurring.endByDate') },
{ value: 'none' as EndMode, label: t('recurring.endByGtc') }, { value: 'none' as EndMode, label: t('recurring.endByGtc') },
{ value: 'count' as EndMode, label: t('recurring.endByCount') }, { value: 'count' as EndMode, label: t('recurring.endByCount') },
]) ]
})
const endValueFieldError = computed(() => { const endValueFieldError = computed(() => {
if (form.endMode === 'count') { 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 plansLoading.value = true
try { try {
const res = await getScheduleInvestmentPlanPage({ current: 1, size: PLAN_PAGE_SIZE }) const res = await getScheduleInvestmentPlanPage({
const records = res.data?.records 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( plansStore.setPlans(
Array.isArray(records) ? records.map(mapSchedulePlanRecordToRecurringPlan) : [], Array.isArray(records) ? records.map(mapSchedulePlanRecordToRecurringPlan) : [],
) )
} catch { } catch {
plansStore.setPlans([]) plansStore.setPlans([])
planPageTotal.value = 0
} finally { } finally {
plansLoading.value = false plansLoading.value = false
} }
} }
function onPlanPageChange() {
loadPlans()
}
function onPlanPageSizeChange(size: number) {
planPageSize.value = size
loadPlans(1)
}
onMounted(() => { onMounted(() => {
loadExchangeOptions() loadExchangeOptions()
loadPlans() loadPlans()
ensureCodeTypes([
'OrderStatus',
SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE,
SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE,
]).catch(() => undefined)
}) })
function planInvestmentLabel(plan: RecurringPlan) { function planInvestmentLabel(plan: RecurringPlan) {
@@ -445,7 +529,7 @@ function planInvestmentDisplay(plan: RecurringPlan) {
function intervalTextForPlan(plan: RecurringPlan) { function intervalTextForPlan(plan: RecurringPlan) {
if (plan.frequencyType) { if (plan.frequencyType) {
let text = t(`recurring.frequency.${plan.frequencyType}`) let text = frequencyTypeLabel(plan.frequencyType)
if (plan.frequencyDay) { if (plan.frequencyDay) {
const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t) const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t)
text = `${text} · ${dayLabel}` text = `${text} · ${dayLabel}`
@@ -460,7 +544,7 @@ function endTextForPlan(plan: RecurringPlan) {
return t('recurring.endCountValue', { n: plan.endCount }) return t('recurring.endCountValue', { n: plan.endCount })
} }
if (plan.endMode === 'none' || plan.noEnd || !plan.endDate) { if (plan.endMode === 'none' || plan.noEnd || !plan.endDate) {
return t('recurring.endByGtc') return endTypeLabel('none')
} }
return plan.endDate return plan.endDate
} }
@@ -470,11 +554,27 @@ function buildEndDisplay() {
return t('recurring.endCountValue', { n: Number.parseInt(form.endCount, 10) }) return t('recurring.endCountValue', { n: Number.parseInt(form.endCount, 10) })
} }
if (form.endMode === 'none') { if (form.endMode === 'none') {
return t('recurring.endByGtc') return endTypeLabel('none')
} }
return form.endDate 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() { function clearErrors() {
assetInvalid.value = false assetInvalid.value = false
fieldError.exchangeCode = false fieldError.exchangeCode = false
@@ -499,7 +599,7 @@ function validate(): boolean {
assetInvalid.value = true assetInvalid.value = true
ok = false ok = false
} }
if (!form.startDate) { if (!form.startDate || isStartDateBeforeToday(form.startDate)) {
fieldError.startDate = true fieldError.startDate = true
ok = false ok = false
} }
@@ -654,6 +754,7 @@ function onSubmit() {
investmentType: form.investmentType, investmentType: form.investmentType,
quantity, quantity,
orderPriceType: 'MARKET', orderPriceType: 'MARKET',
frequencyType: form.frequencyType as FrequencyType,
frequencyDay: showFrequencyDay.value ? form.frequencyDay : undefined, frequencyDay: showFrequencyDay.value ? form.frequencyDay : undefined,
} }
confirmVisible.value = true confirmVisible.value = true
@@ -703,7 +804,7 @@ async function confirmCreate() {
submitting.value = true submitting.value = true
try { try {
await createScheduleInvestmentPlan(body) await createScheduleInvestmentPlan(body)
await loadPlans() await loadPlans(1)
ElMessage.success(t('recurring.createdToast')) ElMessage.success(t('recurring.createdToast'))
confirmVisible.value = false confirmVisible.value = false
draft.value = null draft.value = null
@@ -783,7 +884,7 @@ function formatDetailOrderPrice(plan: ScheduleInvestmentPlanDetailPlan) {
} }
function formatDetailFrequency(plan: ScheduleInvestmentPlanDetailPlan) { function formatDetailFrequency(plan: ScheduleInvestmentPlanDetailPlan) {
let text = t(`recurring.frequency.${plan.frequencyType}`) let text = frequencyTypeLabel(plan.frequencyType)
if (plan.frequencyDay) { if (plan.frequencyDay) {
const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t) const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t)
text = `${text} · ${dayLabel}` text = `${text} · ${dayLabel}`
@@ -800,7 +901,7 @@ function formatDetailEndCondition(plan: ScheduleInvestmentPlanDetailPlan) {
} }
} }
if (endType === 'NO_END' || endType === null || !plan.endDate) { if (endType === 'NO_END' || endType === null || !plan.endDate) {
return t('recurring.endByGtc') return endTypeLabel('NO_END')
} }
return plan.endDate return plan.endDate
} }
@@ -867,6 +968,14 @@ function formatExecutionAvgFilledPrice(row: ScheduleInvestmentPlanExecutionRecor
return formatHkd(parsePlanDecimal(row.avgFilledPrice)) return formatHkd(parsePlanDecimal(row.avgFilledPrice))
} }
function formatOrderStatus(code: string | undefined) {
if (!code?.trim()) {
return '—'
}
const label = codeLabel('OrderStatus', code)
return label || '—'
}
function closePlanDetail() { function closePlanDetail() {
detailVisible.value = false detailVisible.value = false
} }
@@ -887,6 +996,7 @@ async function openPlanDetail(plan: RecurringPlan) {
detailPlan.value = null detailPlan.value = null
detailExecutions.value = [] detailExecutions.value = []
try { try {
await ensureCodeTypes(['OrderStatus']).catch(() => undefined)
const res = await getScheduleInvestmentPlanDetail({ planNo }) const res = await getScheduleInvestmentPlanDetail({ planNo })
detailPlan.value = res.data?.plan ?? null detailPlan.value = res.data?.plan ?? null
detailExecutions.value = Array.isArray(res.data?.recentExecutions) detailExecutions.value = Array.isArray(res.data?.recentExecutions)
@@ -950,12 +1060,14 @@ const previewInterval = computed(() => {
if (!draft.value) { if (!draft.value) {
return '' return ''
} }
const freqKey = draft.value.interval === 'daily' const freqKey =
draft.value.frequencyType ??
(draft.value.interval === 'daily'
? 'DAILY' ? 'DAILY'
: draft.value.interval === 'weekly' : draft.value.interval === 'weekly'
? 'WEEKLY' ? 'WEEKLY'
: 'MONTHLY' : 'MONTHLY')
let text = t(`recurring.frequency.${freqKey}`) let text = frequencyTypeLabel(freqKey)
if (draft.value.frequencyDay) { if (draft.value.frequencyDay) {
const dayLabel = formatFrequencyDayLabel(freqKey, draft.value.frequencyDay, t) const dayLabel = formatFrequencyDayLabel(freqKey, draft.value.frequencyDay, t)
text = `${text} · ${dayLabel}` text = `${text} · ${dayLabel}`
@@ -989,7 +1101,7 @@ const previewOrderPrice = computed(() => {
</div> </div>
<div <div
v-if="!plansLoading && !plansStore.plans.length" v-if="!plansLoading && planPageTotal === 0"
class="plan-empty" class="plan-empty"
> >
{{ t('recurring.planEmpty') }} {{ t('recurring.planEmpty') }}
@@ -1104,6 +1216,20 @@ const previewOrderPrice = computed(() => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="planPageTotal > 0" class="plan-pagination">
<el-pagination
v-model:current-page="planPageCurrent"
v-model:page-size="planPageSize"
:page-sizes="[...PLAN_PAGE_SIZE_OPTIONS]"
:total="planPageTotal"
:layout="planPaginationLayout"
:disabled="plansLoading"
background
@current-change="onPlanPageChange"
@size-change="onPlanPageSizeChange"
/>
</div>
</section> </section>
<form class="panel" novalidate @submit.prevent="onSubmit" @reset.prevent="resetForm"> <form class="panel" novalidate @submit.prevent="onSubmit" @reset.prevent="resetForm">
@@ -1279,6 +1405,7 @@ const previewOrderPrice = computed(() => {
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="t('recurring.startDate')" :placeholder="t('recurring.startDate')"
class="control-date" class="control-date"
:disabled-date="disableFormStartDate"
@change="fieldError.startDate = false" @change="fieldError.startDate = false"
/> />
</div> </div>
@@ -1455,6 +1582,10 @@ const previewOrderPrice = computed(() => {
<dt>{{ t('recurring.planDetail.colAvgFilledPrice') }}</dt> <dt>{{ t('recurring.planDetail.colAvgFilledPrice') }}</dt>
<dd class="num">{{ formatExecutionAvgFilledPrice(row) }}</dd> <dd class="num">{{ formatExecutionAvgFilledPrice(row) }}</dd>
</div> </div>
<div>
<dt>{{ t('recurring.planDetail.colOrderStatus') }}</dt>
<dd>{{ formatOrderStatus(row.orderStatus) }}</dd>
</div>
</dl> </dl>
</article> </article>
</div> </div>
@@ -1471,6 +1602,7 @@ const previewOrderPrice = computed(() => {
<th>{{ t('recurring.planDetail.colFilledAmount') }}</th> <th>{{ t('recurring.planDetail.colFilledAmount') }}</th>
<th>{{ t('recurring.planDetail.colAvgFilledPrice') }}</th> <th>{{ t('recurring.planDetail.colAvgFilledPrice') }}</th>
<th class="l">{{ t('recurring.planDetail.colExecutionStatus') }}</th> <th class="l">{{ t('recurring.planDetail.colExecutionStatus') }}</th>
<th class="l">{{ t('recurring.planDetail.colOrderStatus') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1486,6 +1618,7 @@ const previewOrderPrice = computed(() => {
<td class="num">{{ formatExecutionFilledAmount(row) }}</td> <td class="num">{{ formatExecutionFilledAmount(row) }}</td>
<td class="num">{{ formatExecutionAvgFilledPrice(row) }}</td> <td class="num">{{ formatExecutionAvgFilledPrice(row) }}</td>
<td class="l">{{ row.executionStatus || '—' }}</td> <td class="l">{{ row.executionStatus || '—' }}</td>
<td class="l">{{ formatOrderStatus(row.orderStatus) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -1631,6 +1764,24 @@ const previewOrderPrice = computed(() => {
font-variant-numeric: tabular-nums lining-nums; 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 { .plan-table {
width: 100%; width: 100%;
min-width: 720px; min-width: 720px;