实现 CT 时间偏移选择并优化 URL 语言处理:新增获取 CT 时间偏移选项接口,表单改用下拉选择;URL 语言参数统一为 zh/tw/en 并兼容旧版;Vite 支持局域网访问,更新开发文档。
This commit is contained in:
@@ -2,6 +2,8 @@ import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type {
|
||||
ScheduleInvestmentCreateBody,
|
||||
ScheduleInvestmentCtTimeOptionsData,
|
||||
ScheduleInvestmentCtTimeOptionsRequest,
|
||||
ScheduleInvestmentExchangeItem,
|
||||
ScheduleInvestmentPlanDetailData,
|
||||
ScheduleInvestmentPlanDetailRequest,
|
||||
@@ -19,6 +21,13 @@ export function getScheduleInvestmentExchanges(): Promise<
|
||||
return http.post('/trade/schedule-investment/exchanges', {})
|
||||
}
|
||||
|
||||
/** 定期投资 CT 时间偏移选项(依赖交易所与产品代码) */
|
||||
export function getScheduleInvestmentCtTimeOptions(
|
||||
body: ScheduleInvestmentCtTimeOptionsRequest,
|
||||
): Promise<ApiResponse<ScheduleInvestmentCtTimeOptionsData>> {
|
||||
return http.post('/trade/schedule-investment/options/ct-time', body)
|
||||
}
|
||||
|
||||
/** 创建定期投资计划 */
|
||||
export function createScheduleInvestmentPlan(
|
||||
body: ScheduleInvestmentCreateBody,
|
||||
|
||||
@@ -2,8 +2,48 @@ export const APP_LOCALES = ['zh-CN', 'zh-TW', 'en-US'] as const
|
||||
|
||||
export type AppLocale = (typeof APP_LOCALES)[number]
|
||||
|
||||
/** URL `lang` 查询参数取值(写入地址栏时使用) */
|
||||
export const URL_LANG_CODES = ['zh', 'tw', 'en'] as const
|
||||
|
||||
export type UrlLangCode = (typeof URL_LANG_CODES)[number]
|
||||
|
||||
export const DEFAULT_LOCALE: AppLocale = 'zh-TW'
|
||||
|
||||
export const DEFAULT_URL_LANG: UrlLangCode = 'tw'
|
||||
|
||||
export const LOCALE_TO_URL_LANG: Record<AppLocale, UrlLangCode> = {
|
||||
'zh-CN': 'zh',
|
||||
'zh-TW': 'tw',
|
||||
'en-US': 'en',
|
||||
}
|
||||
|
||||
const URL_LANG_TO_LOCALE: Record<UrlLangCode, AppLocale> = {
|
||||
zh: 'zh-CN',
|
||||
tw: 'zh-TW',
|
||||
en: 'en-US',
|
||||
}
|
||||
|
||||
export function isAppLocale(value: string | null): value is AppLocale {
|
||||
return value !== null && (APP_LOCALES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** 解析 URL / 用户输入的语言参数为内部 `AppLocale` */
|
||||
export function resolveLocaleFromLangParam(value: string | undefined): AppLocale {
|
||||
if (!value) {
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
if ((URL_LANG_CODES as readonly string[]).includes(lower)) {
|
||||
return URL_LANG_TO_LOCALE[lower as UrlLangCode]
|
||||
}
|
||||
const normalized = trimmed.replace('_', '-')
|
||||
if (isAppLocale(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export function localeToUrlLang(locale: AppLocale): UrlLangCode {
|
||||
return LOCALE_TO_URL_LANG[locale]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale'
|
||||
import { localeToUrlLang, resolveLocaleFromLangParam, type AppLocale } from '@/config/appLocale'
|
||||
import { APP_THEMES, DEFAULT_THEME, type AppTheme } from '@/theme/types'
|
||||
|
||||
export const URL_PARAM_LANG = 'lang'
|
||||
@@ -37,14 +37,7 @@ function normalizeTheme(value: string | undefined): AppTheme {
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | undefined): AppLocale {
|
||||
if (!value) {
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
const normalized = value.replace('_', '-')
|
||||
if (isAppLocale(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
return DEFAULT_LOCALE
|
||||
return resolveLocaleFromLangParam(value)
|
||||
}
|
||||
|
||||
export function parsePreferencesFromSearch(search: string): AppPreferences {
|
||||
@@ -82,7 +75,7 @@ export function preferencesToQuery(
|
||||
}
|
||||
}
|
||||
}
|
||||
next[URL_PARAM_LANG] = preferences.locale
|
||||
next[URL_PARAM_LANG] = localeToUrlLang(preferences.locale)
|
||||
next[URL_PARAM_THEME] = preferences.theme
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ export default {
|
||||
SUN: 'Sunday',
|
||||
},
|
||||
ctOffsetLabel: 'Trading time offset (minutes)',
|
||||
ctOffsetHint: '0–240 minutes relative to market open',
|
||||
ctOffsetHint: 'Offset relative to CT market open',
|
||||
ctOffsetPickAssetHint: 'Select exchange and asset first',
|
||||
ctOffsetPreview: '{n} min',
|
||||
previewInvestmentLabel: 'Each investment',
|
||||
previewQuantity: '{n} shares',
|
||||
|
||||
@@ -105,7 +105,8 @@ export default {
|
||||
SUN: '星期日',
|
||||
},
|
||||
ctOffsetLabel: '交易时间偏移(分钟)',
|
||||
ctOffsetHint: '范围 0–240,相对开市时间的偏移',
|
||||
ctOffsetHint: '相对 CT 开市时间的偏移',
|
||||
ctOffsetPickAssetHint: '请先选择交易所与资产',
|
||||
ctOffsetPreview: '{n} 分钟',
|
||||
previewInvestmentLabel: '每次投资',
|
||||
previewQuantity: '{n} 股',
|
||||
|
||||
@@ -105,7 +105,8 @@ export default {
|
||||
SUN: '星期日',
|
||||
},
|
||||
ctOffsetLabel: '交易時間偏移(分鐘)',
|
||||
ctOffsetHint: '範圍 0–240,相對開市時間的偏移',
|
||||
ctOffsetHint: '相對 CT 開市時間的偏移',
|
||||
ctOffsetPickAssetHint: '請先選擇交易所與資產',
|
||||
ctOffsetPreview: '{n} 分鐘',
|
||||
previewInvestmentLabel: '每次投資',
|
||||
previewQuantity: '{n} 股',
|
||||
|
||||
@@ -25,6 +25,25 @@ export interface ScheduleInvestmentExchangeItem {
|
||||
tradeDate: string
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/options/ct-time` 请求体 */
|
||||
export interface ScheduleInvestmentCtTimeOptionsRequest {
|
||||
exchangeCode: string
|
||||
productCode: string
|
||||
}
|
||||
|
||||
/** CT 时间偏移单条选项 */
|
||||
export interface ScheduleInvestmentCtTimeOption {
|
||||
displayName: string
|
||||
offsetMinutes: number
|
||||
submitTime: string | null
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/options/ct-time` 响应 data */
|
||||
export interface ScheduleInvestmentCtTimeOptionsData {
|
||||
ctStartTime: string | null
|
||||
options: ScheduleInvestmentCtTimeOption[]
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/plan/page` 查询条件(筛选项可传空,仅用分页) */
|
||||
export interface ScheduleInvestmentPlanPageRequest {
|
||||
current: number
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppPage from '@/components/layout/AppPage.vue'
|
||||
import AssetChipPicker from '@/components/recurring/AssetChipPicker.vue'
|
||||
import {
|
||||
createScheduleInvestmentPlan,
|
||||
getScheduleInvestmentCtTimeOptions,
|
||||
getScheduleInvestmentExchanges,
|
||||
getScheduleInvestmentPlanDetail,
|
||||
getScheduleInvestmentPlanPage,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
type FrequencyType,
|
||||
type InvestmentType,
|
||||
type OrderPriceType,
|
||||
type ScheduleInvestmentCtTimeOption,
|
||||
type ScheduleInvestmentExchangeItem,
|
||||
type ScheduleInvestmentNextExecutionData,
|
||||
type ScheduleInvestmentPlanDetailPlan,
|
||||
@@ -101,6 +103,8 @@ const terminateSubmitting = ref(false)
|
||||
const nextExecutionPreviewVisible = ref(false)
|
||||
const nextExecutionPreviewLoading = ref(false)
|
||||
const nextExecutionPreview = ref<ScheduleInvestmentNextExecutionData | null>(null)
|
||||
const ctOffsetOptions = ref<ScheduleInvestmentCtTimeOption[]>([])
|
||||
const ctOffsetLoading = ref(false)
|
||||
|
||||
const PLAN_PAGE_SIZE = 50
|
||||
|
||||
@@ -203,6 +207,12 @@ const showFrequencyDay = computed(
|
||||
() => form.frequencyType === 'WEEKLY' || form.frequencyType === 'MONTHLY',
|
||||
)
|
||||
|
||||
const ctOffsetSelectEnabled = computed(() => ctOffsetOptions.value.length > 0)
|
||||
|
||||
const ctOffsetHintText = computed(() =>
|
||||
ctOffsetSelectEnabled.value ? t('recurring.ctOffsetHint') : t('recurring.ctOffsetPickAssetHint'),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.noEnd,
|
||||
(checked) => {
|
||||
@@ -257,6 +267,65 @@ watch(picked, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function clearCtOffsetOptions() {
|
||||
ctOffsetOptions.value = []
|
||||
}
|
||||
|
||||
function syncCtOffsetAfterOptionsLoad() {
|
||||
if (!ctOffsetOptions.value.length) {
|
||||
return
|
||||
}
|
||||
const allowed = new Set(ctOffsetOptions.value.map((item) => item.offsetMinutes))
|
||||
if (!allowed.has(form.ctOffsetMinutes)) {
|
||||
form.ctOffsetMinutes = ctOffsetOptions.value[0].offsetMinutes
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCtOffsetOptions(exchangeCode: string, productCode: string) {
|
||||
ctOffsetLoading.value = true
|
||||
try {
|
||||
const res = await getScheduleInvestmentCtTimeOptions({ exchangeCode, productCode })
|
||||
ctOffsetOptions.value = Array.isArray(res.data?.options) ? res.data.options : []
|
||||
syncCtOffsetAfterOptionsLoad()
|
||||
} catch {
|
||||
ctOffsetOptions.value = []
|
||||
} finally {
|
||||
ctOffsetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePickedProductCode(): string {
|
||||
const asset = picked.value[0]
|
||||
if (!asset) {
|
||||
return ''
|
||||
}
|
||||
return asset.productCodeUnique?.trim() || asset.code.trim()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [form.exchangeCode, resolvePickedProductCode()] as const,
|
||||
([exchangeCode, productCode]) => {
|
||||
if (!exchangeCode || !productCode) {
|
||||
clearCtOffsetOptions()
|
||||
return
|
||||
}
|
||||
loadCtOffsetOptions(exchangeCode, productCode)
|
||||
},
|
||||
)
|
||||
|
||||
function isCtOffsetValid(offset: number): boolean {
|
||||
if (ctOffsetOptions.value.length) {
|
||||
return ctOffsetOptions.value.some((item) => item.offsetMinutes === offset)
|
||||
}
|
||||
return (
|
||||
offset !== null &&
|
||||
offset !== undefined &&
|
||||
!Number.isNaN(offset) &&
|
||||
offset >= 0 &&
|
||||
offset <= 240
|
||||
)
|
||||
}
|
||||
|
||||
function mapSimpleStockToAsset(item: SimpleStockItem): InvestAsset {
|
||||
const type: AssetType = item.productType === '1' ? 'stock' : 'etf'
|
||||
const engName = item.engName?.trim() ?? ''
|
||||
@@ -458,14 +527,7 @@ function validate(): boolean {
|
||||
fieldError.frequencyDay = true
|
||||
ok = false
|
||||
}
|
||||
const offset = form.ctOffsetMinutes
|
||||
if (
|
||||
offset === null ||
|
||||
offset === undefined ||
|
||||
Number.isNaN(offset) ||
|
||||
offset < 0 ||
|
||||
offset > 240
|
||||
) {
|
||||
if (!isCtOffsetValid(form.ctOffsetMinutes)) {
|
||||
fieldError.ctOffsetMinutes = true
|
||||
ok = false
|
||||
}
|
||||
@@ -499,14 +561,7 @@ function validateNextExecutionPreview(): boolean {
|
||||
fieldError.frequencyDay = true
|
||||
ok = false
|
||||
}
|
||||
const offset = form.ctOffsetMinutes
|
||||
if (
|
||||
offset === null ||
|
||||
offset === undefined ||
|
||||
Number.isNaN(offset) ||
|
||||
offset < 0 ||
|
||||
offset > 240
|
||||
) {
|
||||
if (!isCtOffsetValid(form.ctOffsetMinutes)) {
|
||||
fieldError.ctOffsetMinutes = true
|
||||
ok = false
|
||||
}
|
||||
@@ -578,7 +633,8 @@ function resetForm() {
|
||||
form.limitPrice = ''
|
||||
form.frequencyType = ''
|
||||
form.frequencyDay = ''
|
||||
form.ctOffsetMinutes = 30
|
||||
form.ctOffsetMinutes = 0
|
||||
clearCtOffsetOptions()
|
||||
form.endMode = 'date'
|
||||
form.endDate = ''
|
||||
form.noEnd = false
|
||||
@@ -1018,7 +1074,7 @@ const previewOffset = computed(() =>
|
||||
<div class="plan-table__asset-ellipsis">
|
||||
<span class="code">{{ plan.assets[0]?.code }}</span>
|
||||
<template v-if="planAssetDisplayName(plan)">
|
||||
{{ planAssetDisplayName(plan) }}
|
||||
({{ planAssetDisplayName(plan) }})
|
||||
</template>
|
||||
<span v-if="extraAssetCount(plan)" class="microlabel">
|
||||
+{{ extraAssetCount(plan) }}
|
||||
@@ -1275,16 +1331,23 @@ const previewOffset = computed(() =>
|
||||
{{ t('recurring.ctOffsetLabel') }}
|
||||
<span class="req">*</span>
|
||||
</label>
|
||||
<el-input-number
|
||||
<el-select
|
||||
v-model="form.ctOffsetMinutes"
|
||||
class="control-input control-input-number"
|
||||
:min="0"
|
||||
:max="240"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="control-input"
|
||||
filterable
|
||||
:loading="ctOffsetLoading"
|
||||
:disabled="!ctOffsetSelectEnabled"
|
||||
:placeholder="t('recurring.ctOffsetLabel')"
|
||||
@change="fieldError.ctOffsetMinutes = false"
|
||||
/>
|
||||
<div class="hint">{{ t('recurring.ctOffsetHint') }}</div>
|
||||
>
|
||||
<el-option
|
||||
v-for="option in ctOffsetOptions"
|
||||
:key="option.offsetMinutes"
|
||||
:label="option.displayName"
|
||||
:value="option.offsetMinutes"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="hint">{{ ctOffsetHintText }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user