Initial commit: G3FO recurring investment schedule frontend
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+51
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getElementPlusLocale } from '@/element-plus/locale'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useServiceI18nStore } from '@/stores/serviceI18n'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const serviceI18nStore = useServiceI18nStore()
|
||||
|
||||
const elementLocale = computed(() => getElementPlusLocale(appStore.locale))
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
appStore.syncFromRouteQuery(query)
|
||||
authStore.syncContextFromQuery(query)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 首屏由 main.ts 拉取多语言;仅 URL token 变更时再 reload,避免重复请求 */
|
||||
watch(
|
||||
() => {
|
||||
const raw = route.query.token
|
||||
return Array.isArray(raw) ? raw[0] : raw
|
||||
},
|
||||
(token, prevToken) => {
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
authStore.setTokenManually(token)
|
||||
if (prevToken !== undefined && token !== prevToken) {
|
||||
serviceI18nStore.reload().catch(() => undefined)
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="elementLocale">
|
||||
<div class="app-shell">
|
||||
<div class="app-container">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
export interface SystemI18n {
|
||||
id: string
|
||||
scDescription: string
|
||||
tcDescription: string
|
||||
enDescription: string
|
||||
vnDescription?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export function getSystemI18nAllList(): Promise<ApiResponse<SystemI18n[]>> {
|
||||
return http.post('/base/systemI18n/getAllList', {})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getToken, removeToken } from '@/utils/token'
|
||||
|
||||
function resolveBaseURL(): string {
|
||||
const configured = import.meta.env.VITE_API_BASE_URL?.trim()
|
||||
if (configured) {
|
||||
return configured.replace(/\/+$/, '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: resolveBaseURL(),
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
http.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.token = token
|
||||
}
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
config.headers['Accept-Language'] = appStore.locale
|
||||
} catch {
|
||||
config.headers['Accept-Language'] = 'zh-TW'
|
||||
}
|
||||
try {
|
||||
const authStore = useAuthStore()
|
||||
const { clientId, accountId } = authStore
|
||||
if (clientId || accountId) {
|
||||
const base =
|
||||
config.data !== undefined &&
|
||||
typeof config.data === 'object' &&
|
||||
config.data !== null &&
|
||||
!(config.data instanceof FormData)
|
||||
? { ...(config.data as Record<string, unknown>) }
|
||||
: {}
|
||||
if (clientId) {
|
||||
base.clientId = clientId
|
||||
}
|
||||
if (accountId) {
|
||||
base.accountId = accountId
|
||||
}
|
||||
config.data = base
|
||||
}
|
||||
} catch {
|
||||
// pinia 未就绪时跳过
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
const { data } = response
|
||||
if (data?.code === '0' || data?.code === 0) {
|
||||
return data
|
||||
}
|
||||
if (data?.code === '401' || data?.code === 401 || data?.code === '403' || data?.code === 403) {
|
||||
removeToken()
|
||||
ElMessage.error(data?.message || '登录已过期,请重新登录')
|
||||
return Promise.reject(new Error(data?.message || 'Unauthorized'))
|
||||
}
|
||||
if (data?.message) {
|
||||
ElMessage.error(data.message)
|
||||
}
|
||||
return Promise.reject(new Error(data?.message || '请求失败'))
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
removeToken()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else if (error.response?.data?.message) {
|
||||
ElMessage.error(error.response.data.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default http
|
||||
@@ -0,0 +1,50 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
/** `getSimpleStockPage` 单条标的 */
|
||||
export interface SimpleStockItem {
|
||||
engName: string
|
||||
exchangeCode: string
|
||||
fractionalBase: number | null
|
||||
isin: string
|
||||
lastActiveDate: string
|
||||
localName: string
|
||||
productCode: string
|
||||
productCodeUnique: string
|
||||
productKey: string
|
||||
productType: string
|
||||
}
|
||||
|
||||
export interface SimpleStockPageRequest {
|
||||
current: number
|
||||
size: number
|
||||
searchName: string
|
||||
exchangeCodes: string[]
|
||||
isUpdatedToday: boolean
|
||||
}
|
||||
|
||||
export interface SimpleStockPageData {
|
||||
records: SimpleStockItem[]
|
||||
total?: number
|
||||
current?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
/** 简易股票分页(定期投资资产搜索) */
|
||||
export function getSimpleStockPage(
|
||||
params: SimpleStockPageRequest,
|
||||
): Promise<ApiResponse<SimpleStockPageData | SimpleStockItem[]>> {
|
||||
return http.post('/product/product/getSimpleStockPage', params)
|
||||
}
|
||||
|
||||
export function extractSimpleStockRecords(
|
||||
data: SimpleStockPageData | SimpleStockItem[] | null | undefined,
|
||||
): SimpleStockItem[] {
|
||||
if (!data) {
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
return Array.isArray(data.records) ? data.records : []
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type {
|
||||
ScheduleInvestmentCreateBody,
|
||||
ScheduleInvestmentExchangeItem,
|
||||
ScheduleInvestmentPlanDetailData,
|
||||
ScheduleInvestmentPlanDetailRequest,
|
||||
ScheduleInvestmentPlanPageData,
|
||||
ScheduleInvestmentNextExecutionData,
|
||||
ScheduleInvestmentNextExecutionRequest,
|
||||
ScheduleInvestmentPlanPageRequest,
|
||||
ScheduleInvestmentPlanTerminateRequest,
|
||||
} from '@/types/scheduleInvestment'
|
||||
|
||||
/** 定期投资可选交易所列表 */
|
||||
export function getScheduleInvestmentExchanges(): Promise<
|
||||
ApiResponse<ScheduleInvestmentExchangeItem[]>
|
||||
> {
|
||||
return http.post('/trade/schedule-investment/exchanges', {})
|
||||
}
|
||||
|
||||
/** 创建定期投资计划 */
|
||||
export function createScheduleInvestmentPlan(
|
||||
body: ScheduleInvestmentCreateBody,
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
return http.post('/trade/schedule-investment/plan/create', body)
|
||||
}
|
||||
|
||||
const EMPTY_PLAN_PAGE_FILTERS = {
|
||||
accountCode: '',
|
||||
exchangeCode: '',
|
||||
productCode: '',
|
||||
status: '',
|
||||
investmentType: '',
|
||||
orderPriceType: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
} as const
|
||||
|
||||
/** 定期投资计划分页列表 */
|
||||
export function getScheduleInvestmentPlanPage(
|
||||
params: Pick<ScheduleInvestmentPlanPageRequest, 'current' | 'size'> &
|
||||
Partial<Omit<ScheduleInvestmentPlanPageRequest, 'current' | 'size'>>,
|
||||
): Promise<ApiResponse<ScheduleInvestmentPlanPageData>> {
|
||||
return http.post('/trade/schedule-investment/plan/page', {
|
||||
...EMPTY_PLAN_PAGE_FILTERS,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/** 定期投资计划详情(含最近执行记录) */
|
||||
export function getScheduleInvestmentPlanDetail(
|
||||
body: ScheduleInvestmentPlanDetailRequest,
|
||||
): Promise<ApiResponse<ScheduleInvestmentPlanDetailData>> {
|
||||
return http.post('/trade/schedule-investment/plan/detail', body)
|
||||
}
|
||||
|
||||
/** 终止定期投资计划 */
|
||||
export function terminateScheduleInvestmentPlan(
|
||||
body: ScheduleInvestmentPlanTerminateRequest,
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
return http.post('/trade/schedule-investment/plan/terminate', body)
|
||||
}
|
||||
|
||||
/** 预览下次计划执行(节假日调整后的执行日与时点) */
|
||||
export function previewScheduleInvestmentNextExecution(
|
||||
body: ScheduleInvestmentNextExecutionRequest,
|
||||
): Promise<ApiResponse<ScheduleInvestmentNextExecutionData>> {
|
||||
return http.post('/trade/schedule-investment/plan/next-execution', body)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** 是否使用更窄的标题区(表单页等) */
|
||||
dense?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-page" :class="{ 'app-page--dense': dense }">
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-page--dense {
|
||||
max-width: 48rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { InvestAsset } from '@/types/recurringInvestment'
|
||||
import {
|
||||
investAssetDisplayCode,
|
||||
investAssetDisplayName,
|
||||
investAssetOptionKey,
|
||||
resolveAppLocale,
|
||||
} from '@/utils/investAssetDisplay'
|
||||
|
||||
const props = defineProps<{
|
||||
assets: InvestAsset[]
|
||||
modelValue: InvestAsset[]
|
||||
invalid?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: InvestAsset[]]
|
||||
search: [keyword: string]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
function assetDisplayName(asset: InvestAsset) {
|
||||
return investAssetDisplayName(asset, resolveAppLocale(locale.value))
|
||||
}
|
||||
const query = ref('')
|
||||
const open = ref(false)
|
||||
const wrapRef = ref<HTMLElement | null>(null)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const pickedCodes = computed(
|
||||
() => new Set(props.modelValue.map((item) => investAssetOptionKey(item))),
|
||||
)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const keyword = query.value.trim().toLowerCase()
|
||||
return props.assets.filter((item) => {
|
||||
if (pickedCodes.value.has(investAssetOptionKey(item))) {
|
||||
return false
|
||||
}
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
const code = investAssetDisplayCode(item).toLowerCase()
|
||||
const name = investAssetDisplayName(item).toLowerCase()
|
||||
const eng = item.engName?.trim().toLowerCase() ?? ''
|
||||
const raw = item.code.toLowerCase()
|
||||
return (
|
||||
code.includes(keyword) ||
|
||||
raw.includes(keyword) ||
|
||||
name.includes(keyword) ||
|
||||
(eng && eng.includes(keyword))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const hasSelection = computed(() => props.modelValue.length > 0)
|
||||
|
||||
function focusInput() {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
if (hasSelection.value) {
|
||||
open.value = true
|
||||
return
|
||||
}
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
function pick(asset: InvestAsset) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', [asset])
|
||||
query.value = ''
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
const next = props.modelValue.slice()
|
||||
next.splice(index, 1)
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function onInputFocus() {
|
||||
if (!props.disabled) {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Backspace' && !query.value && props.modelValue.length) {
|
||||
removeAt(props.modelValue.length - 1)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!wrapRef.value?.contains(event.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(query, (value) => {
|
||||
if (!props.disabled) {
|
||||
emit('search', value.trim())
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapRef" class="asset-wrap">
|
||||
<div
|
||||
class="asset-box"
|
||||
:class="{ 'asset-box--err': invalid, 'asset-box--disabled': disabled }"
|
||||
@click="focusInput"
|
||||
>
|
||||
<span v-for="(asset, index) in modelValue" :key="investAssetOptionKey(asset)" class="asset-chip">
|
||||
{{ investAssetDisplayCode(asset) }}
|
||||
<button
|
||||
type="button"
|
||||
class="asset-chip__remove"
|
||||
:aria-label="t('recurring.removeAsset', { code: investAssetDisplayCode(asset) })"
|
||||
@click.stop="removeAt(index)"
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2.5 2.5l7 7M9.5 2.5l-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="disabled && !modelValue.length"
|
||||
class="asset-placeholder"
|
||||
>
|
||||
{{ t('recurring.pleaseSelect') }}
|
||||
</span>
|
||||
<input
|
||||
v-else-if="!hasSelection"
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
class="asset-input"
|
||||
:placeholder="t('recurring.assetPlaceholder')"
|
||||
autocomplete="off"
|
||||
@focus="onInputFocus"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="open" class="asset-drop" role="listbox">
|
||||
<button
|
||||
v-for="asset in filtered"
|
||||
:key="investAssetOptionKey(asset)"
|
||||
type="button"
|
||||
class="asset-opt"
|
||||
@click="pick(asset)"
|
||||
>
|
||||
<span class="asset-opt__code">{{ investAssetDisplayCode(asset) }}</span>
|
||||
<span class="asset-opt__name">{{ assetDisplayName(asset) }}</span>
|
||||
<span class="asset-opt__type">{{ t(`recurring.assetType.${asset.type}`) }}</span>
|
||||
</button>
|
||||
<div v-if="loading" class="asset-none">{{ t('recurring.assetLoading') }}</div>
|
||||
<div v-else-if="!filtered.length" class="asset-none">{{ t('recurring.assetEmpty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.asset-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.asset-box {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: var(--el-component-size, 32px);
|
||||
padding: 1px 11px;
|
||||
border: 1px solid var(--color-control-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-control-bg);
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft);
|
||||
}
|
||||
|
||||
&--err {
|
||||
border-color: var(--color-down);
|
||||
box-shadow: 0 0 0 3px var(--color-down-soft);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
background: var(--color-control-bg);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-control-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.asset-placeholder {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.asset-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 20px;
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
[data-theme='dark'] & {
|
||||
background: var(--color-primary);
|
||||
color: #0a0f16;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-chip__remove {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 25%);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
[data-theme='dark'] & {
|
||||
background: rgb(0 0 0 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.asset-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@include respond-down(md) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-drop {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.asset-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 9px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.asset-opt__code {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.asset-opt__name {
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-opt__type {
|
||||
margin-left: auto;
|
||||
color: var(--color-primary-ink);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.asset-none {
|
||||
padding: 12px;
|
||||
color: var(--color-muted);
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/** 当前 URL 中的 clientId / accountId(路由跳转经 useRouteWithPreferences 会保留) */
|
||||
export function useAccountContext() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const clientId = computed(() => authStore.clientId)
|
||||
const accountId = computed(() => authStore.accountId)
|
||||
const hasAccountContext = computed(() => !!authStore.clientId && !!authStore.accountId)
|
||||
|
||||
return {
|
||||
clientId,
|
||||
accountId,
|
||||
hasAccountContext,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BREAKPOINTS_PX } from '@/constants/breakpoints'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
function readWidth() {
|
||||
return window.innerWidth
|
||||
}
|
||||
|
||||
export function useBreakpoint() {
|
||||
const width = ref(readWidth())
|
||||
|
||||
const update = () => {
|
||||
width.value = readWidth()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', update, { passive: true })
|
||||
update()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', update)
|
||||
})
|
||||
|
||||
const isMobile = computed(() => width.value < BREAKPOINTS_PX.md)
|
||||
const isTablet = computed(
|
||||
() => width.value >= BREAKPOINTS_PX.md && width.value < BREAKPOINTS_PX.lg,
|
||||
)
|
||||
const isDesktop = computed(() => width.value >= BREAKPOINTS_PX.lg)
|
||||
const isWide = computed(() => width.value >= BREAKPOINTS_PX.xl)
|
||||
|
||||
return {
|
||||
width,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isWide,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
/** 路由跳转时保留当前 URL 中的 lang / theme 等查询参数 */
|
||||
export function useRouteWithPreferences() {
|
||||
const route = useRoute()
|
||||
|
||||
function linkTo(path: string) {
|
||||
return { path, query: route.query }
|
||||
}
|
||||
|
||||
return { linkTo }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServiceI18nStore } from '@/stores/serviceI18n'
|
||||
|
||||
/** 服务端多语言 ID 解析(参考 Order-H5 localeStore.getI18n) */
|
||||
export function useServiceI18n() {
|
||||
const appStore = useAppStore()
|
||||
const serviceI18nStore = useServiceI18nStore()
|
||||
|
||||
function si18n(id: string | undefined) {
|
||||
return serviceI18nStore.getText(id, appStore.locale)
|
||||
}
|
||||
|
||||
return {
|
||||
si18n,
|
||||
reloadServiceI18n: serviceI18nStore.reload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { AppPreferences } from '@/config/urlPreferences'
|
||||
import type { AppLocale } from '@/i18n/locale'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AppTheme } from '@/theme/types'
|
||||
|
||||
export function useUrlPreferences() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
function replacePreferences(partial: Partial<AppPreferences>) {
|
||||
const query = appStore.buildQueryForPreferences(partial, route.query)
|
||||
router.replace({ path: route.path, hash: route.hash, query })
|
||||
}
|
||||
|
||||
function setLocale(next: AppLocale) {
|
||||
replacePreferences({ locale: next })
|
||||
}
|
||||
|
||||
function setTheme(next: AppTheme) {
|
||||
replacePreferences({ theme: next })
|
||||
}
|
||||
|
||||
return { setLocale, setTheme }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const APP_LOCALES = ['zh-CN', 'zh-TW', 'en-US'] as const
|
||||
|
||||
export type AppLocale = (typeof APP_LOCALES)[number]
|
||||
|
||||
export const DEFAULT_LOCALE: AppLocale = 'zh-TW'
|
||||
|
||||
export function isAppLocale(value: string | null): value is AppLocale {
|
||||
return value !== null && (APP_LOCALES as readonly string[]).includes(value)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
export const URL_PARAM_CLIENT_ID = 'clientId'
|
||||
export const URL_PARAM_ACCOUNT_ID = 'accountId'
|
||||
|
||||
export interface UrlAccountContext {
|
||||
clientId: string | null
|
||||
accountId: string | null
|
||||
}
|
||||
|
||||
function readQueryValue(query: LocationQuery, key: string): string | null {
|
||||
const raw = query[key]
|
||||
if (raw === undefined || raw === null) {
|
||||
return null
|
||||
}
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function readSearchParam(search: string, key: string): string | null {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
const value = params.get(key)
|
||||
return value && value.trim() !== '' ? value : null
|
||||
}
|
||||
|
||||
export function parseAccountContextFromSearch(search: string): UrlAccountContext {
|
||||
return {
|
||||
clientId: readSearchParam(search, URL_PARAM_CLIENT_ID),
|
||||
accountId: readSearchParam(search, URL_PARAM_ACCOUNT_ID),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAccountContextFromQuery(query: LocationQuery): UrlAccountContext {
|
||||
return {
|
||||
clientId: readQueryValue(query, URL_PARAM_CLIENT_ID),
|
||||
accountId: readQueryValue(query, URL_PARAM_ACCOUNT_ID),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale'
|
||||
import { APP_THEMES, DEFAULT_THEME, type AppTheme } from '@/theme/types'
|
||||
|
||||
export const URL_PARAM_LANG = 'lang'
|
||||
export const URL_PARAM_THEME = 'theme'
|
||||
|
||||
export interface AppPreferences {
|
||||
locale: AppLocale
|
||||
theme: AppTheme
|
||||
}
|
||||
|
||||
function readQueryValue(query: LocationQuery, key: string): string | undefined {
|
||||
const raw = query[key]
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined
|
||||
}
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeTheme(value: string | undefined): AppTheme {
|
||||
if (!value) {
|
||||
return DEFAULT_THEME
|
||||
}
|
||||
const lower = value.toLowerCase()
|
||||
if (lower === 'day' || lower === 'light') {
|
||||
return 'light'
|
||||
}
|
||||
if (lower === 'night' || lower === 'dark') {
|
||||
return 'dark'
|
||||
}
|
||||
return APP_THEMES.includes(lower as AppTheme) ? (lower as AppTheme) : DEFAULT_THEME
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | undefined): AppLocale {
|
||||
if (!value) {
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
const normalized = value.replace('_', '-')
|
||||
if (isAppLocale(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export function parsePreferencesFromSearch(search: string): AppPreferences {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
const lang = params.get(URL_PARAM_LANG) ?? params.get('locale')
|
||||
const theme = params.get(URL_PARAM_THEME)
|
||||
return {
|
||||
locale: normalizeLocale(lang ?? undefined),
|
||||
theme: normalizeTheme(theme ?? undefined),
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePreferencesFromQuery(query: LocationQuery): AppPreferences {
|
||||
const lang = readQueryValue(query, URL_PARAM_LANG) ?? readQueryValue(query, 'locale')
|
||||
const theme = readQueryValue(query, URL_PARAM_THEME)
|
||||
return {
|
||||
locale: normalizeLocale(lang),
|
||||
theme: normalizeTheme(theme),
|
||||
}
|
||||
}
|
||||
|
||||
export function preferencesToQuery(
|
||||
preferences: AppPreferences,
|
||||
existing: LocationQuery = {},
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(existing)) {
|
||||
if (key === URL_PARAM_LANG || key === 'locale' || key === URL_PARAM_THEME) {
|
||||
continue
|
||||
}
|
||||
if (value !== undefined && value !== null) {
|
||||
const v = Array.isArray(value) ? value[0] : value
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
next[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
next[URL_PARAM_LANG] = preferences.locale
|
||||
next[URL_PARAM_THEME] = preferences.theme
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 与 `src/styles/breakpoints.scss` 保持数值一致 */
|
||||
export const BREAKPOINTS_PX = {
|
||||
sm: 576,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
} as const
|
||||
|
||||
export type BreakpointToken = keyof typeof BREAKPOINTS_PX
|
||||
|
||||
export const LAYOUT_MAX_WIDTH_PX = 1280
|
||||
|
||||
/** 小于 md 视为移动端 */
|
||||
export const MOBILE_MAX_PX = BREAKPOINTS_PX.md - 1
|
||||
|
||||
/** 平板:md ≤ width < lg */
|
||||
export const TABLET_MIN_PX = BREAKPOINTS_PX.md
|
||||
export const TABLET_MAX_PX = BREAKPOINTS_PX.lg - 1
|
||||
|
||||
export const DESKTOP_MIN_PX = BREAKPOINTS_PX.lg
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Language } from 'element-plus/es/locale'
|
||||
import en from 'element-plus/es/locale/lang/en'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import zhTw from 'element-plus/es/locale/lang/zh-tw'
|
||||
import type { AppLocale } from '@/config/appLocale'
|
||||
|
||||
const elementPlusLocaleMap: Record<AppLocale, Language> = {
|
||||
'zh-CN': zhCn,
|
||||
'zh-TW': zhTw,
|
||||
'en-US': en,
|
||||
}
|
||||
|
||||
export function getElementPlusLocale(locale: AppLocale): Language {
|
||||
return elementPlusLocaleMap[locale]
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_API_PROXY_TARGET?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { DEFAULT_LOCALE, resolveInitialLocale } from './locale'
|
||||
import enUS from './locales/en-US'
|
||||
import zhCN from './locales/zh-CN'
|
||||
import zhTW from './locales/zh-TW'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: resolveInitialLocale(),
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
'en-US': enUS,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,7 @@
|
||||
export { APP_LOCALES, DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale'
|
||||
import { parsePreferencesFromSearch } from '@/config/urlPreferences'
|
||||
|
||||
/** 首屏 i18n 初始化:仅从 URL 读取,缺省为繁体 */
|
||||
export function resolveInitialLocale() {
|
||||
return parsePreferencesFromSearch(window.location.search).locale
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export default {
|
||||
common: {
|
||||
appName: 'Schedule Invest Plan',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
},
|
||||
recurring: {
|
||||
title: 'Recurring Investment',
|
||||
lead: 'Set up a recurring investment plan. The system will place buy orders automatically at the interval you choose.',
|
||||
myPlans: 'My recurring plans',
|
||||
runningCount: '{n} active',
|
||||
planEmpty: 'No recurring investment plans yet',
|
||||
colAsset: 'Asset',
|
||||
colAmount: 'Amount / qty',
|
||||
colInterval: 'Interval',
|
||||
colStart: 'Start date',
|
||||
colEnd: 'End date',
|
||||
colStatus: 'Status',
|
||||
colAction: 'Action',
|
||||
cancel: 'Stop',
|
||||
cancelConfirmTitle: 'Stop this recurring investment plan?',
|
||||
cancelConfirmOk: 'Confirm stop',
|
||||
cancelConfirmBack: 'Cancel',
|
||||
terminateDialogTitle: 'Stop recurring plan',
|
||||
terminateDialogLead: 'No further buys will be scheduled. Past executions are not affected.',
|
||||
terminateReasonLabel: 'Reason (optional)',
|
||||
terminateReasonPlaceholder: 'Enter a reason if you wish',
|
||||
terminateReasonHint: 'You may leave this blank and confirm.',
|
||||
planDetail: {
|
||||
detail: 'Details',
|
||||
title: 'Plan details',
|
||||
planNo: 'Plan no.',
|
||||
exchange: 'Exchange',
|
||||
product: 'Product',
|
||||
investment: 'Per execution',
|
||||
orderPrice: 'Order price',
|
||||
frequency: 'Schedule',
|
||||
ctOffset: 'Market open offset',
|
||||
startDate: 'Start date',
|
||||
endCondition: 'End condition',
|
||||
nextExecution: 'Next execution',
|
||||
progress: 'Progress',
|
||||
progressValue: '{done} of {total} executed',
|
||||
progressOpen: '{done} executed',
|
||||
status: 'Status',
|
||||
createdAt: 'Created at',
|
||||
channel: 'Channel',
|
||||
recentExecutions: 'Recent executions',
|
||||
executionEmpty: 'No execution records',
|
||||
colScheduledDate: 'Scheduled date',
|
||||
colActualDate: 'Trade date',
|
||||
colExecutionStatus: 'Execution',
|
||||
colOrderStatus: 'Order',
|
||||
colAmount: 'Amount / qty',
|
||||
missingPlanNo: 'Plan number is missing',
|
||||
},
|
||||
createTitle: 'Create recurring investment',
|
||||
createHint: 'Stocks and ETFs',
|
||||
exchange: 'Exchange',
|
||||
asset: 'Asset',
|
||||
assetPlaceholder: 'Search by code',
|
||||
assetSupport: 'Supported: stocks, ETFs',
|
||||
assetEmpty: 'No matching assets',
|
||||
assetLoading: 'Loading…',
|
||||
removeAsset: 'Remove {code}',
|
||||
startDate: 'Start date',
|
||||
amount: 'Amount',
|
||||
amountHint: 'Amount to invest on each execution (HKD)',
|
||||
quantity: 'Quantity',
|
||||
amountPlaceholder: 'e.g. 10,000',
|
||||
quantityPlaceholder: 'e.g. 100',
|
||||
quantityHint: 'Shares to buy on each execution',
|
||||
investmentTypeLabel: 'Investment type',
|
||||
investmentType: {
|
||||
AMOUNT: 'By amount',
|
||||
QUANTITY: 'By quantity',
|
||||
},
|
||||
orderPriceTypeLabel: 'Order price type',
|
||||
orderPriceType: {
|
||||
MARKET: 'Market',
|
||||
LIMIT: 'Limit',
|
||||
},
|
||||
limitPrice: 'Limit price',
|
||||
limitPricePlaceholder: 'e.g. 350.5',
|
||||
limitPriceHint: 'Required for limit orders; must be greater than 0',
|
||||
frequencyLabel: 'Frequency',
|
||||
frequency: {
|
||||
DAILY: 'Daily',
|
||||
WEEKLY: 'Weekly',
|
||||
MONTHLY: 'Monthly',
|
||||
},
|
||||
frequencyDayLabel: 'Execution day',
|
||||
frequencyDayWeeklyHint: 'Fixed weekday each week',
|
||||
frequencyDayMonthlyHint: 'Fixed calendar day each month (1–31)',
|
||||
monthDayOption: 'Day {day} of month',
|
||||
weekday: {
|
||||
MON: 'Monday',
|
||||
TUE: 'Tuesday',
|
||||
WED: 'Wednesday',
|
||||
THU: 'Thursday',
|
||||
FRI: 'Friday',
|
||||
SAT: 'Saturday',
|
||||
SUN: 'Sunday',
|
||||
},
|
||||
ctOffsetLabel: 'Trading time offset (minutes)',
|
||||
ctOffsetHint: '0–240 minutes relative to market open',
|
||||
ctOffsetPreview: '{n} min',
|
||||
previewInvestmentLabel: 'Each investment',
|
||||
previewQuantity: '{n} shares',
|
||||
intervalLabel: 'Interval',
|
||||
pleaseSelect: 'Please select',
|
||||
endModeLabel: 'End type',
|
||||
endByDate: 'Specific date',
|
||||
endByCount: 'Periods',
|
||||
endDate: 'End date',
|
||||
noEndDate: 'No end date',
|
||||
endCount: 'Periods',
|
||||
endCountPlaceholder: 'e.g. 12',
|
||||
endCountHint: 'The plan stops automatically after the specified number of executions',
|
||||
endCountValue: '{n} periods',
|
||||
notesTitle: 'Notes',
|
||||
notes: {
|
||||
n1: 'Recurring orders are placed as market orders at the open on each execution date; fills follow actual trades.',
|
||||
n2: 'Fractional shares are not supported. Quantity is rounded down to whole shares, which may leave residual cash.',
|
||||
n3: 'If the execution date is a non-trading day, the order is deferred to the next trading day.',
|
||||
n4: 'If available funds are insufficient, that period’s order will not be executed; the plan continues.',
|
||||
n5: 'You may stop the plan at any time. Executed buys cannot be cancelled.',
|
||||
},
|
||||
reset: 'Reset',
|
||||
previewNextExecution: 'Preview next run',
|
||||
previewNextExecutionTitle: 'Next execution preview',
|
||||
previewNextExecutionFill: 'Select exchange, frequency, and CT offset first',
|
||||
previewScheduledDate: 'Scheduled date',
|
||||
previewExecutionDate: 'Execution date',
|
||||
previewExecutionDateTime: 'Execution time',
|
||||
previewDeferred: 'Deferred from holiday',
|
||||
previewDeferredYes: 'Yes',
|
||||
previewDeferredNo: 'No',
|
||||
continue: 'Continue',
|
||||
confirmTitle: 'Confirm recurring plan',
|
||||
eachAmount: 'Amount each time',
|
||||
endPreview: 'End',
|
||||
confirmNote: 'Once created, funds will be deducted and shares bought on each execution date. Please keep sufficient available balance.',
|
||||
back: 'Back',
|
||||
confirmCreate: 'Confirm',
|
||||
fillRequired: 'Please complete all required fields',
|
||||
createdToast: 'Recurring plan created',
|
||||
cancelledToast: 'Plan stopped. Executed buys are not affected',
|
||||
interval: {
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
biweekly: 'Biweekly',
|
||||
monthly: 'Monthly',
|
||||
quarterly: 'Quarterly',
|
||||
semiannual: 'Semiannual',
|
||||
annual: 'Annual',
|
||||
},
|
||||
status: {
|
||||
active: 'Active',
|
||||
created: 'Created',
|
||||
cancelled: 'Stopped',
|
||||
},
|
||||
assetType: {
|
||||
etf: 'ETF',
|
||||
stock: 'Stock',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export default {
|
||||
common: {
|
||||
appName: '投资计划排期',
|
||||
language: '语言',
|
||||
theme: '主题',
|
||||
themeLight: '浅色',
|
||||
themeDark: '深色',
|
||||
},
|
||||
recurring: {
|
||||
title: '定期投资',
|
||||
lead: '设定定期定额投资计划,系统将按设定间隔自动执行买入指示。',
|
||||
myPlans: '我的定期投资计划',
|
||||
runningCount: '{n} 项进行中',
|
||||
planEmpty: '暂无定期投资计划',
|
||||
colAsset: '资产',
|
||||
colAmount: '金额/数量',
|
||||
colInterval: '间隔',
|
||||
colStart: '开始日期',
|
||||
colEnd: '结束日期',
|
||||
colStatus: '状态',
|
||||
colAction: '操作',
|
||||
cancel: '终止',
|
||||
cancelConfirmTitle: '确定要终止此定期投资计划?',
|
||||
cancelConfirmOk: '确认终止',
|
||||
cancelConfirmBack: '取消',
|
||||
terminateDialogTitle: '终止定期投资计划',
|
||||
terminateDialogLead: '终止后将不再执行新的买入指示,已执行之买入不受影响。',
|
||||
terminateReasonLabel: '终止原因(选填)',
|
||||
terminateReasonPlaceholder: '可填写终止原因',
|
||||
terminateReasonHint: '可不填,直接确认终止。',
|
||||
planDetail: {
|
||||
detail: '详情',
|
||||
title: '计划详情',
|
||||
planNo: '计划编号',
|
||||
exchange: '交易所',
|
||||
product: '产品',
|
||||
investment: '每次投资',
|
||||
orderPrice: '订单价格',
|
||||
frequency: '定投周期',
|
||||
ctOffset: '交易时间偏移',
|
||||
startDate: '开始日期',
|
||||
endCondition: '结束条件',
|
||||
nextExecution: '下次执行日',
|
||||
progress: '执行进度',
|
||||
progressValue: '已执行 {done} / 共 {total} 期',
|
||||
progressOpen: '已执行 {done} 期',
|
||||
status: '状态',
|
||||
createdAt: '创建时间',
|
||||
channel: '渠道',
|
||||
recentExecutions: '最近执行',
|
||||
executionEmpty: '暂无执行记录',
|
||||
colScheduledDate: '计划执行日',
|
||||
colActualDate: '实际交易日',
|
||||
colExecutionStatus: '执行状态',
|
||||
colOrderStatus: '委托状态',
|
||||
colAmount: '金额/数量',
|
||||
missingPlanNo: '缺少计划编号,无法查询详情',
|
||||
},
|
||||
createTitle: '创建定期投资',
|
||||
createHint: '支持股票及 ETF',
|
||||
exchange: '交易所',
|
||||
asset: '资产',
|
||||
assetPlaceholder: '输入代号搜索',
|
||||
assetSupport: '支持的: 股票、ETF',
|
||||
assetEmpty: '没有匹配的资产',
|
||||
assetLoading: '加载中…',
|
||||
removeAsset: '移除 {code}',
|
||||
startDate: '开始日期',
|
||||
amount: '金额',
|
||||
amountHint: '每次执行买入之金额(HKD)',
|
||||
quantity: '数量',
|
||||
amountPlaceholder: '例如 10,000',
|
||||
quantityPlaceholder: '例如 100',
|
||||
quantityHint: '每次执行买入之股数',
|
||||
investmentTypeLabel: '投资类型',
|
||||
investmentType: {
|
||||
AMOUNT: '按金额',
|
||||
QUANTITY: '按数量',
|
||||
},
|
||||
orderPriceTypeLabel: '订单价格类型',
|
||||
orderPriceType: {
|
||||
MARKET: '市价',
|
||||
LIMIT: '限价',
|
||||
},
|
||||
limitPrice: '限价',
|
||||
limitPricePlaceholder: '例如 350.5',
|
||||
limitPriceHint: '限价单须填写大于 0 的价格',
|
||||
frequencyLabel: '定投周期',
|
||||
frequency: {
|
||||
DAILY: '每日',
|
||||
WEEKLY: '每周',
|
||||
MONTHLY: '每月',
|
||||
},
|
||||
frequencyDayLabel: '执行日',
|
||||
frequencyDayWeeklyHint: '每周固定星期执行',
|
||||
frequencyDayMonthlyHint: '每月固定日期执行(1–31)',
|
||||
monthDayOption: '每月 {day} 日',
|
||||
weekday: {
|
||||
MON: '星期一',
|
||||
TUE: '星期二',
|
||||
WED: '星期三',
|
||||
THU: '星期四',
|
||||
FRI: '星期五',
|
||||
SAT: '星期六',
|
||||
SUN: '星期日',
|
||||
},
|
||||
ctOffsetLabel: '交易时间偏移(分钟)',
|
||||
ctOffsetHint: '范围 0–240,相对开市时间的偏移',
|
||||
ctOffsetPreview: '{n} 分钟',
|
||||
previewInvestmentLabel: '每次投资',
|
||||
previewQuantity: '{n} 股',
|
||||
intervalLabel: '间隔',
|
||||
pleaseSelect: '请选择',
|
||||
endModeLabel: '结束类型',
|
||||
endByDate: '特定日期',
|
||||
endByCount: '期数',
|
||||
endDate: '结束日期',
|
||||
noEndDate: '无结束日期',
|
||||
endCount: '期数',
|
||||
endCountPlaceholder: '例如 12',
|
||||
endCountHint: '计划执行满指定期数后自动终止',
|
||||
endCountValue: '期数 {n} 期',
|
||||
notesTitle: '注意事项',
|
||||
notes: {
|
||||
n1: '定期投资指示于执行日开市时以市价单执行,成交价以实际成交为准。',
|
||||
n2: '本功能不支持不足一股的股份。定期投资的买入股数将向下取整至整股,因此可能产生碎股(零股)。',
|
||||
n3: '如执行日为非交易日,将顺延至下一个交易日执行。',
|
||||
n4: '账户可用资金不足时,该期指示将不获执行,计划仍会继续。',
|
||||
n5: '计划可随时终止,已执行之买入不可撤回。',
|
||||
},
|
||||
reset: '重设',
|
||||
previewNextExecution: '预览下次执行',
|
||||
previewNextExecutionTitle: '下次执行预览',
|
||||
previewNextExecutionFill: '请先选择交易所、定投周期与时间偏移',
|
||||
previewScheduledDate: '周期日期',
|
||||
previewExecutionDate: '执行日期',
|
||||
previewExecutionDateTime: '执行时间',
|
||||
previewDeferred: '非交易日顺延',
|
||||
previewDeferredYes: '是',
|
||||
previewDeferredNo: '否',
|
||||
continue: '继续',
|
||||
confirmTitle: '确认定期投资计划',
|
||||
eachAmount: '每次金额',
|
||||
endPreview: '结束',
|
||||
confirmNote: '计划建立后将于每个执行日自动扣除资金并买入,请确保账户有足够可用结余。',
|
||||
back: '返回',
|
||||
confirmCreate: '确认建立',
|
||||
fillRequired: '请填妥所有必填栏位',
|
||||
createdToast: '定期投资计划已建立',
|
||||
cancelledToast: '计划已终止,已执行之买入不受影响',
|
||||
interval: {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
biweekly: '每两周',
|
||||
monthly: '月度',
|
||||
quarterly: '季度',
|
||||
semiannual: '每半年',
|
||||
annual: '每年',
|
||||
},
|
||||
status: {
|
||||
active: '进行中',
|
||||
created: '已建立',
|
||||
cancelled: '已终止',
|
||||
},
|
||||
assetType: {
|
||||
etf: 'ETF',
|
||||
stock: '股票',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export default {
|
||||
common: {
|
||||
appName: '投資計劃排期',
|
||||
language: '語言',
|
||||
theme: '主題',
|
||||
themeLight: '淺色',
|
||||
themeDark: '深色',
|
||||
},
|
||||
recurring: {
|
||||
title: '定期投資',
|
||||
lead: '設定定期定額投資計劃,系統將按設定間隔自動執行買入指示。',
|
||||
myPlans: '我的定期投資計劃',
|
||||
runningCount: '{n} 項進行中',
|
||||
planEmpty: '暫無定期投資計劃',
|
||||
colAsset: '資產',
|
||||
colAmount: '金額/數量',
|
||||
colInterval: '間隔',
|
||||
colStart: '開始日期',
|
||||
colEnd: '結束日期',
|
||||
colStatus: '狀態',
|
||||
colAction: '操作',
|
||||
cancel: '終止',
|
||||
cancelConfirmTitle: '確定要終止此定期投資計劃?',
|
||||
cancelConfirmOk: '確認終止',
|
||||
cancelConfirmBack: '取消',
|
||||
terminateDialogTitle: '終止定期投資計劃',
|
||||
terminateDialogLead: '終止後將不再執行新的買入指示,已執行之買入不受影響。',
|
||||
terminateReasonLabel: '終止原因(選填)',
|
||||
terminateReasonPlaceholder: '可填寫終止原因',
|
||||
terminateReasonHint: '可不填,直接確認終止。',
|
||||
planDetail: {
|
||||
detail: '詳情',
|
||||
title: '計劃詳情',
|
||||
planNo: '計劃編號',
|
||||
exchange: '交易所',
|
||||
product: '產品',
|
||||
investment: '每次投資',
|
||||
orderPrice: '訂單價格',
|
||||
frequency: '定投週期',
|
||||
ctOffset: '交易時間偏移',
|
||||
startDate: '開始日期',
|
||||
endCondition: '結束條件',
|
||||
nextExecution: '下次執行日',
|
||||
progress: '執行進度',
|
||||
progressValue: '已執行 {done} / 共 {total} 期',
|
||||
progressOpen: '已執行 {done} 期',
|
||||
status: '狀態',
|
||||
createdAt: '建立時間',
|
||||
channel: '渠道',
|
||||
recentExecutions: '最近執行',
|
||||
executionEmpty: '暫無執行記錄',
|
||||
colScheduledDate: '計劃執行日',
|
||||
colActualDate: '實際交易日',
|
||||
colExecutionStatus: '執行狀態',
|
||||
colOrderStatus: '委託狀態',
|
||||
colAmount: '金額/數量',
|
||||
missingPlanNo: '缺少計劃編號,無法查詢詳情',
|
||||
},
|
||||
createTitle: '創建定期投資',
|
||||
createHint: '支援股票及 ETF',
|
||||
exchange: '交易所',
|
||||
asset: '資產',
|
||||
assetPlaceholder: '輸入代號搜尋',
|
||||
assetSupport: '支持的: 股票、ETF',
|
||||
assetEmpty: '沒有匹配的資產',
|
||||
assetLoading: '載入中…',
|
||||
removeAsset: '移除 {code}',
|
||||
startDate: '開始日期',
|
||||
amount: '金額',
|
||||
amountHint: '每次執行買入之金額(HKD)',
|
||||
quantity: '數量',
|
||||
amountPlaceholder: '例如 10,000',
|
||||
quantityPlaceholder: '例如 100',
|
||||
quantityHint: '每次執行買入之股數',
|
||||
investmentTypeLabel: '投資類型',
|
||||
investmentType: {
|
||||
AMOUNT: '按金額',
|
||||
QUANTITY: '按數量',
|
||||
},
|
||||
orderPriceTypeLabel: '訂單價格類型',
|
||||
orderPriceType: {
|
||||
MARKET: '市價',
|
||||
LIMIT: '限價',
|
||||
},
|
||||
limitPrice: '限價',
|
||||
limitPricePlaceholder: '例如 350.5',
|
||||
limitPriceHint: '限價單須填寫大於 0 的價格',
|
||||
frequencyLabel: '定投週期',
|
||||
frequency: {
|
||||
DAILY: '每日',
|
||||
WEEKLY: '每周',
|
||||
MONTHLY: '每月',
|
||||
},
|
||||
frequencyDayLabel: '執行日',
|
||||
frequencyDayWeeklyHint: '每周固定星期執行',
|
||||
frequencyDayMonthlyHint: '每月固定日期執行(1–31)',
|
||||
monthDayOption: '每月 {day} 日',
|
||||
weekday: {
|
||||
MON: '星期一',
|
||||
TUE: '星期二',
|
||||
WED: '星期三',
|
||||
THU: '星期四',
|
||||
FRI: '星期五',
|
||||
SAT: '星期六',
|
||||
SUN: '星期日',
|
||||
},
|
||||
ctOffsetLabel: '交易時間偏移(分鐘)',
|
||||
ctOffsetHint: '範圍 0–240,相對開市時間的偏移',
|
||||
ctOffsetPreview: '{n} 分鐘',
|
||||
previewInvestmentLabel: '每次投資',
|
||||
previewQuantity: '{n} 股',
|
||||
intervalLabel: '間隔',
|
||||
pleaseSelect: '請選擇',
|
||||
endModeLabel: '結束類型',
|
||||
endByDate: '特定日期',
|
||||
endByCount: '期數',
|
||||
endDate: '結束日期',
|
||||
noEndDate: '無結束日期',
|
||||
endCount: '期數',
|
||||
endCountPlaceholder: '例如 12',
|
||||
endCountHint: '計劃執行滿指定期數後自動終止',
|
||||
endCountValue: '期數 {n} 期',
|
||||
notesTitle: '注意事項',
|
||||
notes: {
|
||||
n1: '定期投資指示於執行日開市時以市價單執行,成交價以實際成交為準。',
|
||||
n2: '本功能不支援不足一股的股份。定期投資的買入股數將向下取整至整股,因此可能產生碎股(零股)。',
|
||||
n3: '如執行日為非交易日,將順延至下一個交易日執行。',
|
||||
n4: '帳戶可用資金不足時,該期指示將不獲執行,計劃仍會繼續。',
|
||||
n5: '計劃可隨時終止,已執行之買入不可撤回。',
|
||||
},
|
||||
reset: '重設',
|
||||
previewNextExecution: '預覽下次執行',
|
||||
previewNextExecutionTitle: '下次執行預覽',
|
||||
previewNextExecutionFill: '請先選擇交易所、定投週期與時間偏移',
|
||||
previewScheduledDate: '週期日期',
|
||||
previewExecutionDate: '執行日期',
|
||||
previewExecutionDateTime: '執行時間',
|
||||
previewDeferred: '非交易日順延',
|
||||
previewDeferredYes: '是',
|
||||
previewDeferredNo: '否',
|
||||
continue: '繼續',
|
||||
confirmTitle: '確認定期投資計劃',
|
||||
eachAmount: '每次金額',
|
||||
endPreview: '結束',
|
||||
confirmNote: '計劃建立後將於每個執行日自動扣除資金並買入,請確保帳戶有足夠可用結餘。',
|
||||
back: '返回',
|
||||
confirmCreate: '確認建立',
|
||||
fillRequired: '請填妥所有必填欄位',
|
||||
createdToast: '定期投資計劃已建立',
|
||||
cancelledToast: '計劃已終止,已執行之買入不受影響',
|
||||
interval: {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
biweekly: '每兩周',
|
||||
monthly: '月度',
|
||||
quarterly: '季度',
|
||||
semiannual: '每半年',
|
||||
annual: '每年',
|
||||
},
|
||||
status: {
|
||||
active: '進行中',
|
||||
created: '已建立',
|
||||
cancelled: '已終止',
|
||||
},
|
||||
assetType: {
|
||||
etf: 'ETF',
|
||||
stock: '股票',
|
||||
},
|
||||
},
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { parsePreferencesFromSearch } from '@/config/urlPreferences'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useServiceI18nStore } from '@/stores/serviceI18n'
|
||||
import { hasToken } from '@/utils/token'
|
||||
import App from './App.vue'
|
||||
import i18n from './i18n'
|
||||
import router from './router'
|
||||
import { applyTheme } from './theme/applyTheme'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
import './styles/main.scss'
|
||||
|
||||
const bootstrapPreferences = parsePreferencesFromSearch(window.location.search)
|
||||
applyTheme(bootstrapPreferences.theme)
|
||||
document.documentElement.lang = bootstrapPreferences.locale
|
||||
|
||||
async function bootstrap() {
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
useAuthStore().initFromUrl()
|
||||
|
||||
if (hasToken()) {
|
||||
await useServiceI18nStore().reload().catch(() => undefined)
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'recurring-investment',
|
||||
component: () => import('@/views/RecurringInvestmentView.vue'),
|
||||
meta: { titleKey: 'recurring.title' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const appStore = useAppStore()
|
||||
appStore.syncFromRouteQuery(to.query)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import {
|
||||
parsePreferencesFromQuery,
|
||||
preferencesToQuery,
|
||||
type AppPreferences,
|
||||
} from '@/config/urlPreferences'
|
||||
import i18n from '@/i18n'
|
||||
import { APP_LOCALES, type AppLocale } from '@/config/appLocale'
|
||||
import { applyTheme } from '@/theme/applyTheme'
|
||||
import { APP_THEMES, type AppTheme } from '@/theme/types'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const initial = parsePreferencesFromQuery({})
|
||||
const locale = ref<AppLocale>(initial.locale)
|
||||
const theme = ref<AppTheme>(initial.theme)
|
||||
|
||||
function applyPreferences(preferences: AppPreferences) {
|
||||
locale.value = preferences.locale
|
||||
i18n.global.locale.value = preferences.locale
|
||||
document.documentElement.lang = preferences.locale
|
||||
|
||||
theme.value = preferences.theme
|
||||
applyTheme(preferences.theme)
|
||||
}
|
||||
|
||||
function syncFromRouteQuery(query: LocationQuery) {
|
||||
applyPreferences(parsePreferencesFromQuery(query))
|
||||
}
|
||||
|
||||
function buildQueryForPreferences(
|
||||
partial: Partial<AppPreferences>,
|
||||
existing: LocationQuery,
|
||||
): Record<string, string> {
|
||||
return preferencesToQuery(
|
||||
{
|
||||
locale: partial.locale ?? locale.value,
|
||||
theme: partial.theme ?? theme.value,
|
||||
},
|
||||
existing,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
theme,
|
||||
localeOptions: APP_LOCALES,
|
||||
themeOptions: APP_THEMES,
|
||||
applyPreferences,
|
||||
syncFromRouteQuery,
|
||||
buildQueryForPreferences,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import {
|
||||
parseAccountContextFromQuery,
|
||||
parseAccountContextFromSearch,
|
||||
type UrlAccountContext,
|
||||
} from '@/config/urlContext'
|
||||
import { getToken, getTokenFromUrl, removeToken, setToken } from '@/utils/token'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(getToken())
|
||||
const clientId = ref<string | null>(null)
|
||||
const accountId = ref<string | null>(null)
|
||||
|
||||
function applyAccountContext(context: UrlAccountContext) {
|
||||
clientId.value = context.clientId
|
||||
accountId.value = context.accountId
|
||||
}
|
||||
|
||||
/** URL 优先,其次沿用本地已存 token;账户上下文仅来自 URL */
|
||||
function initFromUrl() {
|
||||
const urlToken = getTokenFromUrl()
|
||||
if (urlToken) {
|
||||
setToken(urlToken)
|
||||
token.value = urlToken
|
||||
} else if (token.value) {
|
||||
setToken(token.value)
|
||||
}
|
||||
applyAccountContext(parseAccountContextFromSearch(window.location.search))
|
||||
}
|
||||
|
||||
function syncContextFromQuery(query: LocationQuery) {
|
||||
applyAccountContext(parseAccountContextFromQuery(query))
|
||||
}
|
||||
|
||||
function setTokenManually(value: string) {
|
||||
setToken(value)
|
||||
token.value = value
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
removeToken()
|
||||
token.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
clientId,
|
||||
accountId,
|
||||
initFromUrl,
|
||||
syncContextFromQuery,
|
||||
setTokenManually,
|
||||
clearToken,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { RecurringPlan } from '@/types/recurringInvestment'
|
||||
import { isPlanRunningStatus } from '@/utils/scheduleInvestmentPlanMapper'
|
||||
|
||||
export const useRecurringPlansStore = defineStore('recurringPlans', () => {
|
||||
const plans = ref<RecurringPlan[]>([])
|
||||
|
||||
const runningCount = computed(
|
||||
() =>
|
||||
plans.value.filter((plan) =>
|
||||
plan.apiStatus ? isPlanRunningStatus(plan.apiStatus) : plan.status !== 'cancelled',
|
||||
).length,
|
||||
)
|
||||
|
||||
function setPlans(items: RecurringPlan[]) {
|
||||
plans.value = items
|
||||
}
|
||||
|
||||
function cancelPlan(id: string) {
|
||||
const plan = plans.value.find((item) => item.id === id)
|
||||
if (!plan || plan.status === 'cancelled') {
|
||||
return false
|
||||
}
|
||||
plan.status = 'cancelled'
|
||||
plan.apiStatus = 'TERMINATED'
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
plans,
|
||||
runningCount,
|
||||
setPlans,
|
||||
cancelPlan,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getSystemI18nAllList, type SystemI18n } from '@/api/base'
|
||||
import type { AppLocale } from '@/config/appLocale'
|
||||
|
||||
type ServiceLangKey = 'sc' | 'tc' | 'en'
|
||||
|
||||
const localeToMapKey: Record<AppLocale, ServiceLangKey> = {
|
||||
'zh-CN': 'sc',
|
||||
'zh-TW': 'tc',
|
||||
'en-US': 'en',
|
||||
}
|
||||
|
||||
let reloadingPromise: Promise<void> | null = null
|
||||
const loadingIds: string[] = []
|
||||
|
||||
export const useServiceI18nStore = defineStore('serviceI18n', () => {
|
||||
const maps = ref<Record<ServiceLangKey, Map<string, string>>>({
|
||||
sc: new Map(),
|
||||
tc: new Map(),
|
||||
en: new Map(),
|
||||
})
|
||||
|
||||
function initFromList(list: SystemI18n[]) {
|
||||
list.forEach((item) => {
|
||||
maps.value.sc.set(item.id, item.scDescription)
|
||||
maps.value.tc.set(item.id, item.tcDescription)
|
||||
maps.value.en.set(item.id, item.enDescription)
|
||||
})
|
||||
}
|
||||
|
||||
function getText(id: string | undefined, locale: AppLocale): string {
|
||||
if (!id) {
|
||||
return ''
|
||||
}
|
||||
const key = localeToMapKey[locale] ?? 'en'
|
||||
const text = maps.value[key].get(id)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('No service i18n:', id)
|
||||
}
|
||||
if (!loadingIds.includes(id)) {
|
||||
loadingIds.push(id)
|
||||
reload().finally(() => {
|
||||
const index = loadingIds.indexOf(id)
|
||||
if (index > -1) {
|
||||
loadingIds.splice(index, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (reloadingPromise) {
|
||||
return reloadingPromise
|
||||
}
|
||||
reloadingPromise = (async () => {
|
||||
try {
|
||||
const result = await getSystemI18nAllList()
|
||||
if (result?.data) {
|
||||
initFromList(result.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load service i18n:', e)
|
||||
} finally {
|
||||
reloadingPromise = null
|
||||
}
|
||||
})()
|
||||
return reloadingPromise
|
||||
}
|
||||
|
||||
return {
|
||||
maps,
|
||||
getText,
|
||||
reload,
|
||||
initFromList,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
@use 'sass:map';
|
||||
|
||||
// 数值须与 src/constants/breakpoints.ts 一致
|
||||
$breakpoints: (
|
||||
sm: 576px,
|
||||
md: 768px,
|
||||
lg: 1024px,
|
||||
xl: 1280px,
|
||||
);
|
||||
|
||||
$layout-max-width: 1280px;
|
||||
|
||||
@function breakpoint($name) {
|
||||
$value: map.get($breakpoints, $name);
|
||||
@if $value == null {
|
||||
@error "Unknown breakpoint `#{$name}`.";
|
||||
}
|
||||
@return $value;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Align Element Plus form controls with app semantic tokens (incl. hover/focus borders).
|
||||
|
||||
@mixin ep-form-control-vars {
|
||||
--el-color-primary: var(--color-primary);
|
||||
--el-fill-color-blank: var(--color-control-bg);
|
||||
--el-border-color: var(--color-control-border);
|
||||
--el-border-color-hover: var(--color-primary);
|
||||
--el-text-color-regular: var(--color-text);
|
||||
--el-text-color-placeholder: var(--color-muted);
|
||||
--el-input-bg-color: var(--color-control-bg);
|
||||
--el-input-border-color: var(--color-control-border);
|
||||
--el-input-hover-border-color: var(--color-primary);
|
||||
--el-input-focus-border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
@include ep-form-control-vars;
|
||||
--el-color-primary-rgb: 14, 124, 123;
|
||||
}
|
||||
|
||||
html.dark,
|
||||
[data-theme='dark'] {
|
||||
@include ep-form-control-vars;
|
||||
--el-color-primary-rgb: 47, 191, 174;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
width: 100%;
|
||||
max-width: var(--layout-max-width);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--page-padding-x);
|
||||
padding-block: var(--page-padding-y);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-page__title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: var(--font-size-title);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.app-page__lead {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: var(--font-size-lead);
|
||||
line-height: 1.6;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.app-table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@use './variables' as *;
|
||||
@use './layout';
|
||||
@use './element-plus-theme';
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--color-bg: #f2f2f2;
|
||||
--color-text: #082d4f;
|
||||
--color-text-2: #3d546e;
|
||||
--color-border: #e2e7ec;
|
||||
--color-border-strong: #cfd8e0;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f7f9fa;
|
||||
--color-muted: #7186a0;
|
||||
--color-primary: #{$color-primary};
|
||||
--color-primary-soft: rgb(14 124 123 / 9%);
|
||||
--color-primary-ink: #0a5c5b;
|
||||
--color-up: #0f9d6e;
|
||||
--color-up-soft: rgb(15 157 110 / 10%);
|
||||
--color-down: #e03e46;
|
||||
--color-down-soft: rgb(224 62 70 / 9%);
|
||||
--color-warn: #c77414;
|
||||
--color-warn-soft: rgb(199 116 20 / 10%);
|
||||
--color-control-bg: #ffffff;
|
||||
--color-control-border: #cfd8e0;
|
||||
--radius: 4px;
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'SFMono-Regular', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--color-bg: #0a0f16;
|
||||
--color-text: #e9eef4;
|
||||
--color-text-2: #b8c5d4;
|
||||
--color-border: #1d2a3a;
|
||||
--color-border-strong: #28394d;
|
||||
--color-surface: #101824;
|
||||
--color-surface-2: #16202e;
|
||||
--color-muted: #7d8ea0;
|
||||
--color-primary: #2fbfae;
|
||||
--color-primary-soft: rgb(47 191 174 / 12%);
|
||||
--color-primary-ink: #2fbfae;
|
||||
--color-up: #34d399;
|
||||
--color-up-soft: rgb(52 211 153 / 12%);
|
||||
--color-down: #f87171;
|
||||
--color-down-soft: rgb(248 113 113 / 12%);
|
||||
--color-warn: #fbbf24;
|
||||
--color-warn-soft: rgb(251 191 36 / 12%);
|
||||
--color-control-bg: #101824;
|
||||
--color-control-border: #28394d;
|
||||
--radius: 4px;
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'SFMono-Regular', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--layout-max-width: #{$layout-max-width};
|
||||
--page-padding-x: 16px;
|
||||
--page-padding-y: 16px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-lead: 16px;
|
||||
--font-size-title: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--page-padding-x: 24px;
|
||||
--page-padding-y: 24px;
|
||||
--font-size-lead: 17px;
|
||||
--font-size-title: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--page-padding-x: 32px;
|
||||
--page-padding-y: 32px;
|
||||
--font-size-base: 15px;
|
||||
--font-size-lead: 18px;
|
||||
--font-size-title: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@use '../breakpoints' as bp;
|
||||
|
||||
@mixin respond-up($name) {
|
||||
@media (min-width: bp.breakpoint($name)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin respond-down($name) {
|
||||
$width: bp.breakpoint($name);
|
||||
@media (max-width: ($width - 0.02px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin respond-between($from, $to) {
|
||||
$min: bp.breakpoint($from);
|
||||
$max: bp.breakpoint($to);
|
||||
@media (min-width: $min) and (max-width: ($max - 0.02px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
$color-primary: #0e7c7b;
|
||||
$layout-max-width: 1280px;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AppTheme } from './types'
|
||||
|
||||
export function applyTheme(theme: AppTheme) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const APP_THEMES = ['light', 'dark'] as const
|
||||
|
||||
export type AppTheme = (typeof APP_THEMES)[number]
|
||||
|
||||
export const DEFAULT_THEME: AppTheme = 'dark'
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: string | number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
/* oxlint-disable */
|
||||
/* oxfmt-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppPage: typeof import('./../components/layout/AppPage.vue')['default']
|
||||
AssetChipPicker: typeof import('./../components/recurring/AssetChipPicker.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
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']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export type AssetType = 'etf' | 'stock'
|
||||
|
||||
export type IntervalKey =
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'biweekly'
|
||||
| 'monthly'
|
||||
| 'quarterly'
|
||||
| 'semiannual'
|
||||
| 'annual'
|
||||
|
||||
export type EndMode = 'date' | 'count'
|
||||
|
||||
export type PlanStatus = 'active' | 'created' | 'cancelled'
|
||||
|
||||
export interface InvestAsset {
|
||||
code: string
|
||||
name: string
|
||||
type: AssetType
|
||||
/** 产品英文名称(来自 getSimpleStockPage) */
|
||||
engName?: string
|
||||
/** 产品本地名称(来自 getSimpleStockPage) */
|
||||
localName?: string
|
||||
/** 对接产品接口时的主键,如 SZMK-2728 */
|
||||
productKey?: string
|
||||
exchangeCode?: string
|
||||
productCodeUnique?: string
|
||||
}
|
||||
|
||||
export interface RecurringPlan {
|
||||
id: string
|
||||
/** 接口计划编号,如 SI-2026-10-08-… */
|
||||
planNo?: string
|
||||
assets: InvestAsset[]
|
||||
amount: number
|
||||
/** 按数量定投时的每次股数 */
|
||||
quantity?: number | null
|
||||
investmentType?: 'AMOUNT' | 'QUANTITY'
|
||||
frequencyType?: 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||
frequencyDay?: string
|
||||
interval: IntervalKey
|
||||
startDate: string
|
||||
endMode: EndMode
|
||||
endDate: string
|
||||
endCount: number | null
|
||||
noEnd: boolean
|
||||
endDisplay: string
|
||||
status: PlanStatus
|
||||
/** 接口原始状态,如 ACTIVE */
|
||||
apiStatus?: string
|
||||
}
|
||||
|
||||
export interface RecurringPlanDraft {
|
||||
assets: InvestAsset[]
|
||||
amount: number
|
||||
interval: IntervalKey
|
||||
startDate: string
|
||||
endDisplay: string
|
||||
endMode: EndMode
|
||||
endDate: string
|
||||
endCount: number | null
|
||||
noEnd: boolean
|
||||
investmentType?: 'AMOUNT' | 'QUANTITY'
|
||||
quantity?: number
|
||||
orderPriceType?: 'MARKET' | 'LIMIT'
|
||||
limitPrice?: number
|
||||
frequencyDay?: string
|
||||
ctOffsetMinutes?: number
|
||||
}
|
||||
|
||||
export const INTERVAL_KEYS: IntervalKey[] = [
|
||||
'daily',
|
||||
'weekly',
|
||||
'biweekly',
|
||||
'monthly',
|
||||
'quarterly',
|
||||
'semiannual',
|
||||
'annual',
|
||||
]
|
||||
@@ -0,0 +1,205 @@
|
||||
/** 创建定期投资计划 — 与交易接口字段对齐 */
|
||||
|
||||
export type InvestmentType = 'AMOUNT' | 'QUANTITY'
|
||||
|
||||
export type OrderPriceType = 'MARKET' | 'LIMIT'
|
||||
|
||||
export type FrequencyType = 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||
|
||||
export type ScheduleEndType = 'DATE' | 'COUNT' | 'NONE'
|
||||
|
||||
export type WeekdayCode = 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT' | 'SUN'
|
||||
|
||||
export const FREQUENCY_TYPE_KEYS: FrequencyType[] = ['DAILY', 'WEEKLY', 'MONTHLY']
|
||||
|
||||
export const WEEKDAY_CODES: WeekdayCode[] = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']
|
||||
|
||||
export const INVESTMENT_TYPE_KEYS: InvestmentType[] = ['AMOUNT', 'QUANTITY']
|
||||
|
||||
export const ORDER_PRICE_TYPE_KEYS: OrderPriceType[] = ['MARKET', 'LIMIT']
|
||||
|
||||
/** `POST /trade/schedule-investment/exchanges` 单条 */
|
||||
export interface ScheduleInvestmentExchangeItem {
|
||||
exchangeCode: string
|
||||
nameI18nId: string
|
||||
tradeDate: string
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/plan/page` 查询条件(筛选项可传空,仅用分页) */
|
||||
export interface ScheduleInvestmentPlanPageRequest {
|
||||
current: number
|
||||
size: number
|
||||
accountCode?: string
|
||||
exchangeCode?: string
|
||||
productCode?: string
|
||||
status?: string
|
||||
investmentType?: string
|
||||
orderPriceType?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface ScheduleInvestmentPlanPageData {
|
||||
current: number
|
||||
maxId: string | null
|
||||
records: ScheduleInvestmentPlanRecord[]
|
||||
size: number
|
||||
total: number | string
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
/** 计划列表单条(字段以接口为准) */
|
||||
export interface ScheduleInvestmentPlanRecord {
|
||||
id: string
|
||||
planNo: string
|
||||
accountId: string
|
||||
accountCode?: string
|
||||
clientId: string
|
||||
clientCode?: string
|
||||
exchangeCode: string
|
||||
productCode: string
|
||||
productEngName?: string
|
||||
productLocalName?: string
|
||||
investmentType: InvestmentType
|
||||
amountPerExecution: string | null
|
||||
qtyPerExecution: string | null
|
||||
orderPriceType: OrderPriceType
|
||||
frequencyType: FrequencyType
|
||||
frequencyDay?: string | null
|
||||
startDate: string
|
||||
endDate?: string | null
|
||||
endType: ScheduleEndType
|
||||
totalExecutionCount?: number | null
|
||||
executedCount?: number
|
||||
ctOffsetMinutes: number
|
||||
status: string
|
||||
nextExecutionDate?: string | null
|
||||
currencyCode?: string
|
||||
channelCode?: string
|
||||
createOn?: string
|
||||
updateOn?: string
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/plan/detail` 请求体 */
|
||||
export interface ScheduleInvestmentPlanDetailRequest {
|
||||
planNo: string
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/plan/terminate` 请求体 */
|
||||
export interface ScheduleInvestmentPlanTerminateRequest {
|
||||
planNo: string
|
||||
/** 选填 */
|
||||
terminateReason?: string
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/plan/next-execution` 请求体 */
|
||||
export interface ScheduleInvestmentNextExecutionRequest {
|
||||
exchangeCode: string
|
||||
frequencyType: FrequencyType
|
||||
/** WEEKLY / MONTHLY 时必填 */
|
||||
frequencyDay?: string
|
||||
/** 空或 0 表示 CT 开市时刻;最大 240 */
|
||||
ctOffsetMinutes?: number
|
||||
/** 空表示交易所交易日 */
|
||||
startDate?: string
|
||||
}
|
||||
|
||||
/** `POST /trade/schedule-investment/plan/next-execution` 响应 data */
|
||||
export interface ScheduleInvestmentNextExecutionData {
|
||||
scheduledDate: string
|
||||
executionDate: string
|
||||
executionDateTime: string
|
||||
deferred: boolean
|
||||
ctOffsetMinutes: number
|
||||
}
|
||||
|
||||
/** 计划详情 — plan 字段(比列表项更全) */
|
||||
export interface ScheduleInvestmentPlanDetailPlan {
|
||||
id: string | number
|
||||
planNo: string
|
||||
accountId?: string | number
|
||||
accountCode?: string
|
||||
clientId?: string | number
|
||||
clientCode?: string
|
||||
exchangeCode: string
|
||||
marketId?: string | number | null
|
||||
productCode: string
|
||||
productEngName?: string
|
||||
productLocalName?: string
|
||||
currencyCode?: string
|
||||
investmentType: InvestmentType
|
||||
amountPerExecution?: string | number | null
|
||||
qtyPerExecution?: string | number | null
|
||||
orderPriceType: OrderPriceType
|
||||
limitPrice?: string | number | null
|
||||
frequencyType: FrequencyType
|
||||
frequencyDay?: string | null
|
||||
startDate: string
|
||||
endType: ScheduleEndType
|
||||
endDate?: string | null
|
||||
totalExecutionCount?: number | string | null
|
||||
executedCount?: number | string
|
||||
ctOffsetMinutes: number
|
||||
nextExecutionDate?: string | null
|
||||
status: string
|
||||
lastExecutionStatus?: string
|
||||
lastExecutionTime?: string | null
|
||||
terminateTime?: string | null
|
||||
terminateBy?: string
|
||||
terminateReason?: string
|
||||
channelCode?: string
|
||||
createBy?: string
|
||||
createOn?: string
|
||||
updateBy?: string
|
||||
updateOn?: string
|
||||
userId?: string | number
|
||||
userCode?: string
|
||||
}
|
||||
|
||||
export interface ScheduleInvestmentPlanExecutionRecord {
|
||||
id?: string | number
|
||||
executionNo?: string
|
||||
planNo?: string
|
||||
scheduledExecutionDate?: string
|
||||
actualTradeDate?: string
|
||||
scheduledSubmitTime?: string
|
||||
actualSubmitTime?: string
|
||||
investmentType?: InvestmentType
|
||||
amount?: string | number | null
|
||||
inputQty?: string | number | null
|
||||
calculatedQty?: string | number | null
|
||||
referencePrice?: string | number | null
|
||||
orderPriceType?: OrderPriceType
|
||||
limitPrice?: string | number | null
|
||||
orderStatus?: string
|
||||
executionStatus?: string
|
||||
failureCode?: string
|
||||
failureMessage?: string
|
||||
}
|
||||
|
||||
export interface ScheduleInvestmentPlanDetailData {
|
||||
plan: ScheduleInvestmentPlanDetailPlan
|
||||
recentExecutions: ScheduleInvestmentPlanExecutionRecord[]
|
||||
}
|
||||
|
||||
export interface ScheduleInvestmentCreateBody {
|
||||
accountId: number | string
|
||||
exchangeCode: string
|
||||
productCode: string
|
||||
productEngName?: string
|
||||
productLocalName?: string
|
||||
clientId: number | string
|
||||
investmentType: InvestmentType
|
||||
amountPerExecution?: number
|
||||
qtyPerExecution?: number
|
||||
orderPriceType: OrderPriceType
|
||||
/** 限价单必填,须大于 0 */
|
||||
limitPrice?: number
|
||||
frequencyType: FrequencyType
|
||||
frequencyDay?: string
|
||||
startDate: string
|
||||
endType: ScheduleEndType
|
||||
endDate?: string
|
||||
totalExecutionCount?: number
|
||||
ctOffsetMinutes: number
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user