Initial commit: G3FO recurring investment schedule frontend
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
})}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user