Initial commit: G3FO recurring investment schedule frontend

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
justin
2026-10-10 09:21:25 +08:00
co-authored by Cursor
commit 9ed161d93e
69 changed files with 10097 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
export function parseAmountInput(raw: string): number {
const value = Number.parseFloat(raw.replace(/,/g, '').trim())
return Number.isFinite(value) ? value : Number.NaN
}
export function formatHkd(amount: number): string {
return `HKD ${amount.toLocaleString('en-HK', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
}
+65
View File
@@ -0,0 +1,65 @@
import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale'
import type { InvestAsset } from '@/types/recurringInvestment'
export function resolveAppLocale(locale: string | undefined): AppLocale {
if (locale && isAppLocale(locale)) {
return locale
}
return DEFAULT_LOCALE
}
/** 按界面语言在本地名与英文名之间选取 */
export function pickProductBilingualName(
locale: AppLocale,
productLocalName?: string,
productEngName?: string,
): string {
const local = productLocalName?.trim() ?? ''
const eng = productEngName?.trim() ?? ''
if (locale === 'en-US') {
return eng || local
}
return local || eng
}
/** 界面展示用产品代号(优先交易所唯一码,如 1.HK) */
export function investAssetDisplayCode(asset: Pick<InvestAsset, 'code' | 'productCodeUnique'>): string {
const unique = asset.productCodeUnique?.trim()
if (unique) {
return unique
}
return asset.code.trim()
}
export function investAssetDisplayName(
asset: Pick<InvestAsset, 'name' | 'localName' | 'engName' | 'code'>,
locale?: AppLocale,
): string {
const picked = locale
? pickProductBilingualName(locale, asset.localName, asset.engName)
: asset.localName?.trim() || asset.engName?.trim() || ''
if (picked) {
return picked
}
const name = asset.name?.trim()
return name || asset.code
}
/** 计划详情等:`代号 · 名称` */
export function scheduleProductLineDisplay(
locale: AppLocale,
productCode: string,
productLocalName?: string,
productEngName?: string,
): string {
const code = productCode.trim()
const name = pickProductBilingualName(locale, productLocalName, productEngName)
if (name && name !== code) {
return `${code} · ${name}`
}
return code
}
export function investAssetOptionKey(asset: InvestAsset): string {
return asset.productKey || investAssetDisplayCode(asset)
}
+97
View File
@@ -0,0 +1,97 @@
import type { InvestAsset } from '@/types/recurringInvestment'
import type {
FrequencyType,
InvestmentType,
OrderPriceType,
ScheduleEndType,
ScheduleInvestmentCreateBody,
} from '@/types/scheduleInvestment'
import type { IntervalKey } from '@/types/recurringInvestment'
export interface ScheduleFormPayloadInput {
exchangeCode: string
asset: InvestAsset
investmentType: InvestmentType
amountPerExecution: number | null
qtyPerExecution: number | null
orderPriceType: OrderPriceType
limitPrice: number | null
frequencyType: FrequencyType
frequencyDay: string
startDate: string
endMode: 'date' | 'count'
endDate: string
noEnd: boolean
endCount: number | null
ctOffsetMinutes: number
clientId: string | null
accountId: string | null
}
export function resolveEndType(input: Pick<ScheduleFormPayloadInput, 'endMode' | 'noEnd' | 'endDate'>): ScheduleEndType {
if (input.endMode === 'count') {
return 'COUNT'
}
if (input.noEnd || !input.endDate.trim()) {
return 'NONE'
}
return 'DATE'
}
export function buildScheduleInvestmentCreateBody(
input: ScheduleFormPayloadInput,
): ScheduleInvestmentCreateBody {
const endType = resolveEndType(input)
const productCode = input.asset.productCodeUnique || input.asset.code
const body: ScheduleInvestmentCreateBody = {
accountId: input.accountId ?? '',
exchangeCode: input.exchangeCode,
productCode,
productEngName: input.asset.engName?.trim() ?? '',
productLocalName: input.asset.localName?.trim() ?? '',
clientId: input.clientId ?? '',
investmentType: input.investmentType,
orderPriceType: input.orderPriceType,
frequencyType: input.frequencyType,
startDate: input.startDate,
endType,
ctOffsetMinutes: input.ctOffsetMinutes,
}
if (input.investmentType === 'AMOUNT' && input.amountPerExecution != null) {
body.amountPerExecution = input.amountPerExecution
}
if (input.investmentType === 'QUANTITY' && input.qtyPerExecution != null) {
body.qtyPerExecution = input.qtyPerExecution
}
if (input.orderPriceType === 'LIMIT' && input.limitPrice != null && input.limitPrice > 0) {
body.limitPrice = input.limitPrice
}
if (input.frequencyType === 'WEEKLY' || input.frequencyType === 'MONTHLY') {
body.frequencyDay = input.frequencyDay
}
if (endType === 'DATE') {
body.endDate = input.endDate
} else {
body.endDate = ''
}
if (endType === 'COUNT' && input.endCount != null) {
body.totalExecutionCount = input.endCount
}
return body
}
export function frequencyTypeToInterval(frequencyType: FrequencyType): IntervalKey {
const map: Record<FrequencyType, IntervalKey> = {
DAILY: 'daily',
WEEKLY: 'weekly',
MONTHLY: 'monthly',
}
return map[frequencyType]
}
+109
View File
@@ -0,0 +1,109 @@
import type { InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment'
import type {
FrequencyType,
ScheduleEndType,
ScheduleInvestmentPlanRecord,
} from '@/types/scheduleInvestment'
import { frequencyTypeToInterval } from '@/utils/scheduleInvestmentPayload'
function parseDecimal(value: string | null | undefined): number {
if (value == null || value === '') {
return 0
}
const n = Number.parseFloat(value)
return Number.isNaN(n) ? 0 : n
}
export function mapApiPlanStatus(status: string): PlanStatus {
const normalized = status.trim().toUpperCase()
if (
normalized === 'TERMINATED' ||
normalized === 'CANCELLED' ||
normalized === 'CANCELED' ||
normalized === 'STOPPED'
) {
return 'cancelled'
}
if (normalized === 'CREATED' || normalized === 'PENDING') {
return 'created'
}
return 'active'
}
export function isPlanRunningStatus(status: string): boolean {
return mapApiPlanStatus(status) !== 'cancelled'
}
function resolveEndFields(record: ScheduleInvestmentPlanRecord): Pick<
RecurringPlan,
'endMode' | 'endDate' | 'endCount' | 'noEnd' | 'endDisplay'
> {
const endType = record.endType as ScheduleEndType
if (endType === 'COUNT' && record.totalExecutionCount != null) {
return {
endMode: 'count',
endDate: '',
endCount: record.totalExecutionCount,
noEnd: false,
endDisplay: '',
}
}
if (endType === 'NONE' || !record.endDate) {
return {
endMode: 'date',
endDate: '',
endCount: null,
noEnd: true,
endDisplay: '',
}
}
return {
endMode: 'date',
endDate: record.endDate ?? '',
endCount: null,
noEnd: false,
endDisplay: '',
}
}
export function mapSchedulePlanRecordToRecurringPlan(
record: ScheduleInvestmentPlanRecord,
): RecurringPlan {
const code = record.productCode
const resolvedName =
record.productLocalName?.trim() || record.productEngName?.trim() || ''
const name = resolvedName && resolvedName !== code ? resolvedName : ''
const asset: InvestAsset = {
code,
name,
type: 'stock',
engName: record.productEngName?.trim() ?? '',
localName: record.productLocalName?.trim() ?? '',
exchangeCode: record.exchangeCode,
productCodeUnique: record.productCode,
}
const amount =
record.investmentType === 'AMOUNT'
? parseDecimal(record.amountPerExecution)
: parseDecimal(record.qtyPerExecution)
const quantity =
record.investmentType === 'QUANTITY' ? parseDecimal(record.qtyPerExecution) : null
const endFields = resolveEndFields(record)
const frequencyType = record.frequencyType as FrequencyType
return {
id: record.id,
planNo: record.planNo,
assets: [asset],
amount,
quantity,
investmentType: record.investmentType,
frequencyType,
frequencyDay: record.frequencyDay ?? undefined,
interval: frequencyTypeToInterval(frequencyType),
startDate: record.startDate,
...endFields,
status: mapApiPlanStatus(record.status),
apiStatus: record.status,
}
}
+23
View File
@@ -0,0 +1,23 @@
const TOKEN_KEY = 'auth_token'
/** 从 URL 查询参数读取 token(例如 ?token=xxx) */
export function getTokenFromUrl(): string | null {
const urlParams = new URLSearchParams(window.location.search)
return urlParams.get('token')
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export function hasToken(): boolean {
return !!getToken()
}