diff --git a/IMPACT_ANALYSIS.md b/IMPACT_ANALYSIS.md index 8c8c4c7..4d504c8 100644 --- a/IMPACT_ANALYSIS.md +++ b/IMPACT_ANALYSIS.md @@ -706,3 +706,35 @@ ### 5. 验证与测试 - `npm run dev`:终端出现 Network URL;同网段可打开页面。 + +--- + +## 补充:frequencyDay 与后端约定对齐(2026-10-10) + +### 1. 改动概览 + +- **背景与目标**:后端 `WEEKLY` 支持 `1`–`7`(ISO 周一=1)、英文全名/三字码;`MONTHLY` 支持 `1`–`31`(可带前导零);`DAILY` 不传 `frequencyDay`。前端提交已用 `MON`…`SUN` 与 `1`…`31`,需兼容接口回显。 +- **涉及模块**:`src/utils/frequencyDay.ts`(新增)、`scheduleInvestmentPlanMapper.ts`、`RecurringInvestmentView.vue`。 +- **改动类型**:缺陷修复 / 行为对齐。 + +### 2. 方法级改动分析 + +| 位置 | 差异 | +|------|------| +| `frequencyDay.ts` | `normalizeWeeklyDayToCode`、`normalizeMonthlyDay`、`formatFrequencyDayLabel`。 | +| `scheduleInvestmentPlanMapper.ts` | 列表映射时将 `frequencyDay` 规范为表单/i18n 可用值。 | +| `RecurringInvestmentView.vue` | 计划列表、详情频率、确认预览统一走 `formatFrequencyDayLabel`。 | + +### 3. 调用方与影响范围 + +- 创建/预览请求体:`DAILY` 不传 `frequencyDay`;`WEEKLY`/`MONTHLY` 经 `frequencyDayForApi` 仅提交数字字符串(周 `1`–`7`,月 `1`–`31`)。 +- **破坏性变更**:否。 + +### 4. 风险与回滚 + +- **风险级别**:低。 +- **回滚方式是否简单**:是。 + +### 5. 验证与测试 + +- `npm run build`。 diff --git a/TASK_SUMMARY.md b/TASK_SUMMARY.md index 37cb5a5..032fd19 100644 --- a/TASK_SUMMARY.md +++ b/TASK_SUMMARY.md @@ -225,3 +225,11 @@ - 创建表单在选定交易所与资产后请求 `POST /trade/schedule-investment/options/ct-time`(`productCode` 与创建计划一致:`productCodeUnique` 或 `code`)。 - 偏移字段改为 `el-select`(`displayName` / `offsetMinutes`);未选资产时禁用并提示先选交易所与资产。 - 详见 `IMPACT_ANALYSIS.md` 本节补充;验证:`npm run build`。 + +--- + +## 补充:frequencyDay 回显归一化(2026-10-10) + +- 新增 `src/utils/frequencyDay.ts`,将接口 `1`/`MONDAY`/`MON` 等 weekly 值与带前导零的 monthly 值规范后用于列表与详情展示。 +- 创建/预览提交:`WEEKLY` 为 `1`–`7`,`MONTHLY` 为 `1`–`31`;`DAILY` 不传 `frequencyDay`。界面仍显示星期中文名。 +- 验证:`npm run build`;参见 `IMPACT_ANALYSIS.md` 对应补充。 diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 511c031..ca4834e 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -20,7 +20,6 @@ declare module 'vue' { ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElInput: typeof import('element-plus/es')['ElInput'] - ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElOption: typeof import('element-plus/es')['ElOption'] ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] diff --git a/src/utils/frequencyDay.ts b/src/utils/frequencyDay.ts new file mode 100644 index 0000000..f0bc211 --- /dev/null +++ b/src/utils/frequencyDay.ts @@ -0,0 +1,145 @@ +import type { FrequencyType, WeekdayCode } from '@/types/scheduleInvestment' +import { WEEKDAY_CODES } from '@/types/scheduleInvestment' + +/** ISO:1 = 周一 … 7 = 周日(与后端 resolveWeeklyDay 一致) */ +export const WEEKLY_ISO_DAY_VALUES = ['1', '2', '3', '4', '5', '6', '7'] as const + +const ISO_WEEKDAY_TO_CODE: Record = { + 1: 'MON', + 2: 'TUE', + 3: 'WED', + 4: 'THU', + 5: 'FRI', + 6: 'SAT', + 7: 'SUN', +} + +const FULL_WEEKDAY_TO_CODE: Record = { + MONDAY: 'MON', + TUESDAY: 'TUE', + WEDNESDAY: 'WED', + THURSDAY: 'THU', + FRIDAY: 'FRI', + SATURDAY: 'SAT', + SUNDAY: 'SUN', +} + +/** + * 将接口返回的 weekly frequencyDay(1–7 / MONDAY / MON 等)规范为三字码,供表单与 i18n 使用。 + */ +export function normalizeWeeklyDayToCode(raw: string | null | undefined): WeekdayCode | undefined { + if (raw == null) { + return undefined + } + const trimmed = raw.trim() + if (!trimmed) { + return undefined + } + + const upper = trimmed.toUpperCase() + if ((WEEKDAY_CODES as readonly string[]).includes(upper)) { + return upper as WeekdayCode + } + + const fromFull = FULL_WEEKDAY_TO_CODE[upper] + if (fromFull) { + return fromFull + } + + const iso = Number.parseInt(trimmed, 10) + if (!Number.isNaN(iso) && iso >= 1 && iso <= 7) { + return ISO_WEEKDAY_TO_CODE[iso] + } + + return undefined +} + +/** 规范为 API/表单用的 weekly 数字字符串 `"1"`–`"7"`。 */ +export function isoWeekdayToCode(iso: string): WeekdayCode | undefined { + const n = Number.parseInt(iso, 10) + if (Number.isNaN(n) || n < 1 || n > 7) { + return undefined + } + return ISO_WEEKDAY_TO_CODE[n] +} + +export function normalizeWeeklyDayToIso(raw: string | null | undefined): string | undefined { + const code = normalizeWeeklyDayToCode(raw) + if (!code) { + return undefined + } + const iso = (Object.entries(ISO_WEEKDAY_TO_CODE) as [string, WeekdayCode][]).find( + ([, c]) => c === code, + )?.[0] + return iso +} + +/** 每月执行日:接口可带前导零(如 "09"),表单选项值为 "1"–"31"。 */ +export function normalizeMonthlyDay(raw: string | null | undefined): string | undefined { + if (raw == null) { + return undefined + } + const trimmed = raw.trim() + if (!trimmed) { + return undefined + } + const day = Number.parseInt(trimmed, 10) + if (Number.isNaN(day) || day < 1 || day > 31) { + return trimmed + } + return String(day) +} + +export function normalizeFrequencyDay( + frequencyType: FrequencyType, + raw: string | null | undefined, +): string | undefined { + if (raw == null || raw === '') { + return undefined + } + if (frequencyType === 'WEEKLY') { + return normalizeWeeklyDayToIso(raw) + } + if (frequencyType === 'MONTHLY') { + return normalizeMonthlyDay(raw) + } + return undefined +} + +/** 创建/预览请求体:WEEKLY、MONTHLY 仅传数字字符串。 */ +export function frequencyDayForApi( + frequencyType: FrequencyType, + raw: string | null | undefined, +): string | undefined { + if (raw == null || raw === '') { + return undefined + } + if (frequencyType === 'WEEKLY') { + return normalizeWeeklyDayToIso(raw) + } + if (frequencyType === 'MONTHLY') { + return normalizeMonthlyDay(raw) + } + return undefined +} + +type TranslateFn = (key: string, params?: Record) => string + +export function formatFrequencyDayLabel( + frequencyType: FrequencyType, + frequencyDay: string, + t: TranslateFn, +): string { + if (frequencyType === 'WEEKLY') { + const code = normalizeWeeklyDayToCode(frequencyDay) + if (code) { + return t(`recurring.weekday.${code}`) + } + return frequencyDay + } + if (frequencyType === 'MONTHLY') { + const day = normalizeMonthlyDay(frequencyDay) ?? frequencyDay + return t('recurring.monthDayOption', { day }) + } + return frequencyDay +} diff --git a/src/utils/scheduleInvestmentPayload.ts b/src/utils/scheduleInvestmentPayload.ts index 16e1dff..1373b20 100644 --- a/src/utils/scheduleInvestmentPayload.ts +++ b/src/utils/scheduleInvestmentPayload.ts @@ -7,6 +7,7 @@ import type { ScheduleInvestmentCreateBody, } from '@/types/scheduleInvestment' import type { IntervalKey } from '@/types/recurringInvestment' +import { frequencyDayForApi } from '@/utils/frequencyDay' export interface ScheduleFormPayloadInput { exchangeCode: string @@ -71,7 +72,10 @@ export function buildScheduleInvestmentCreateBody( } if (input.frequencyType === 'WEEKLY' || input.frequencyType === 'MONTHLY') { - body.frequencyDay = input.frequencyDay + const day = frequencyDayForApi(input.frequencyType, input.frequencyDay) + if (day) { + body.frequencyDay = day + } } if (endType === 'DATE') { diff --git a/src/utils/scheduleInvestmentPlanMapper.ts b/src/utils/scheduleInvestmentPlanMapper.ts index 6b6db82..04b185f 100644 --- a/src/utils/scheduleInvestmentPlanMapper.ts +++ b/src/utils/scheduleInvestmentPlanMapper.ts @@ -4,6 +4,7 @@ import type { ScheduleEndType, ScheduleInvestmentPlanRecord, } from '@/types/scheduleInvestment' +import { normalizeFrequencyDay } from '@/utils/frequencyDay' import { frequencyTypeToInterval } from '@/utils/scheduleInvestmentPayload' function parseDecimal(value: string | null | undefined): number { @@ -90,6 +91,7 @@ export function mapSchedulePlanRecordToRecurringPlan( record.investmentType === 'QUANTITY' ? parseDecimal(record.qtyPerExecution) : null const endFields = resolveEndFields(record) const frequencyType = record.frequencyType as FrequencyType + const frequencyDay = normalizeFrequencyDay(frequencyType, record.frequencyDay ?? undefined) return { id: record.id, @@ -99,7 +101,7 @@ export function mapSchedulePlanRecordToRecurringPlan( quantity, investmentType: record.investmentType, frequencyType, - frequencyDay: record.frequencyDay ?? undefined, + frequencyDay, interval: frequencyTypeToInterval(frequencyType), startDate: record.startDate, ...endFields, diff --git a/src/views/RecurringInvestmentView.vue b/src/views/RecurringInvestmentView.vue index 65ae898..96f30a3 100644 --- a/src/views/RecurringInvestmentView.vue +++ b/src/views/RecurringInvestmentView.vue @@ -33,7 +33,6 @@ import { FREQUENCY_TYPE_KEYS, INVESTMENT_TYPE_KEYS, ORDER_PRICE_TYPE_KEYS, - WEEKDAY_CODES, type FrequencyType, type InvestmentType, type OrderPriceType, @@ -53,6 +52,12 @@ import { mapApiPlanStatus, mapSchedulePlanRecordToRecurringPlan, } from '@/utils/scheduleInvestmentPlanMapper' +import { + formatFrequencyDayLabel, + frequencyDayForApi, + isoWeekdayToCode, + WEEKLY_ISO_DAY_VALUES, +} from '@/utils/frequencyDay' import { buildScheduleInvestmentCreateBody, frequencyTypeToInterval, @@ -190,10 +195,13 @@ const endValueHint = computed(() => ) const weekdayOptions = computed(() => - WEEKDAY_CODES.map((code) => ({ - value: code, - label: t(`recurring.weekday.${code}`), - })), + WEEKLY_ISO_DAY_VALUES.map((iso) => { + const code = isoWeekdayToCode(iso) + return { + value: iso, + label: code ? t(`recurring.weekday.${code}`) : iso, + } + }), ) const monthDayOptions = computed(() => @@ -228,7 +236,7 @@ watch( form.frequencyDay = '' fieldError.frequencyDay = false if (type === 'WEEKLY') { - form.frequencyDay = 'MON' + form.frequencyDay = '1' } if (type === 'MONTHLY') { form.frequencyDay = '1' @@ -434,10 +442,7 @@ function intervalTextForPlan(plan: RecurringPlan) { if (plan.frequencyType) { let text = t(`recurring.frequency.${plan.frequencyType}`) if (plan.frequencyDay) { - const dayLabel = - plan.frequencyType === 'WEEKLY' - ? t(`recurring.weekday.${plan.frequencyDay}`) - : t('recurring.monthDayOption', { day: plan.frequencyDay }) + const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t) text = `${text} · ${dayLabel}` } return text @@ -582,8 +587,9 @@ function buildNextExecutionPreviewBody() { frequencyType, ctOffsetMinutes: form.ctOffsetMinutes, } - if (showFrequencyDay.value && form.frequencyDay) { - body.frequencyDay = form.frequencyDay + const apiDay = frequencyDayForApi(frequencyType, form.frequencyDay) + if (apiDay) { + body.frequencyDay = apiDay } if (form.startDate) { body.startDate = form.startDate @@ -802,10 +808,7 @@ function formatDetailOrderPrice(plan: ScheduleInvestmentPlanDetailPlan) { function formatDetailFrequency(plan: ScheduleInvestmentPlanDetailPlan) { let text = t(`recurring.frequency.${plan.frequencyType}`) if (plan.frequencyDay) { - const dayLabel = - plan.frequencyType === 'WEEKLY' - ? t(`recurring.weekday.${plan.frequencyDay}`) - : t('recurring.monthDayOption', { day: plan.frequencyDay }) + const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t) text = `${text} · ${dayLabel}` } return text @@ -955,10 +958,7 @@ const previewInterval = computed(() => { : 'MONTHLY' let text = t(`recurring.frequency.${freqKey}`) if (draft.value.frequencyDay) { - const dayLabel = - freqKey === 'WEEKLY' - ? t(`recurring.weekday.${draft.value.frequencyDay}`) - : t('recurring.monthDayOption', { day: draft.value.frequencyDay }) + const dayLabel = formatFrequencyDayLabel(freqKey, draft.value.frequencyDay, t) text = `${text} · ${dayLabel}` } return text