Commit 9cd2bb16 by jianan

init: 港险转介人工作台 H5(OIDC 登录 + 四大功能模块)

Vue3 + Vite + Tailwind,对接 /c/** 真实接口:首页指标、月结单、我的保单、在途佣金。
parents
# dependencies
node_modules/
# build output
dist/
dist-ssr/
*.local
# env(OIDC clientId/issuer 等按环境注入,不入库)
.env
.env.*
!.env.example
# logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# editor / os
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
Thumbs.db
# misc
tmpclaude-*-cwd
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<title>转介人工作台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "workbench-h5",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"oidc-client-ts": "^3.1.0",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-i18n": "^10.0.5",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
\ No newline at end of file
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
\ No newline at end of file
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import LoginBadge from '@/components/feature/LoginBadge.vue'
const route = useRoute()
const { t } = useI18n()
const tabs = [
{ name: 'home', path: '/', labelKey: 'nav.home' },
{ name: 'statement', path: '/statement', labelKey: 'nav.statement' },
{ name: 'policies', path: '/policies', labelKey: 'nav.policies' },
{ name: 'pipeline', path: '/pipeline', labelKey: 'nav.pipeline' }
]
const hideTabBar = computed(() => route.path === '/auth/callback')
</script>
<template>
<div class="min-h-screen bg-slate-50 text-slate-900">
<header class="sticky top-0 z-20 border-b border-slate-200/70 bg-white/80 backdrop-blur-lg">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<div class="text-[15px] font-semibold tracking-tight">{{ t('app.title') }}</div>
<LoginBadge />
</div>
</header>
<main class="mx-auto max-w-3xl px-4 pb-28 pt-5">
<RouterView />
</main>
<nav
v-if="!hideTabBar"
class="fixed inset-x-0 bottom-0 z-20 border-t border-slate-200/70 bg-white/90 pb-safe backdrop-blur-lg"
>
<div class="mx-auto grid max-w-3xl grid-cols-4 px-2 py-1.5">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="tab.path"
class="flex flex-col items-center rounded-xl py-2 text-xs font-medium transition-colors"
:class="route.path === tab.path ? 'text-brand-700' : 'text-slate-500 hover:text-slate-700'"
>
<span
class="rounded-full px-3 py-1 transition-colors"
:class="route.path === tab.path ? 'bg-brand-50' : 'bg-transparent'"
>{{ t(tab.labelKey) }}</span>
</RouterLink>
</div>
</nav>
</div>
</template>
\ No newline at end of file
// 统一 HTTP client(axios)。
// 后端约定:业务返回体固定为 { code, msg, data },成功 code=200;
// 仅 Spring Security 层未认证会返回真实 401。
import axios from 'axios'
import { userManager, signinRedirect } from '@/utils/oidc'
interface ApiResult<T> {
code: number
msg: string
data: T
}
const instance = axios.create({ baseURL: '/csf/api' })
instance.interceptors.request.use(async (config) => {
const user = await userManager.getUser()
if (user && !user.expired) {
config.headers.Authorization = `Bearer ${user.access_token}`
}
return config
})
// 防止多个并发请求同时 401 触发多次跳转
let redirecting = false
function redirectToLogin(): void {
if (redirecting) return
// 已在回调页则不再发起,避免循环
if (window.location.pathname === '/auth/callback') return
redirecting = true
void signinRedirect(window.location.pathname + window.location.search)
}
instance.interceptors.response.use(
(response) => {
const body = response.data as ApiResult<unknown>
if (body && body.code !== 200) {
throw new Error(body.msg || '请求失败')
}
// 拦截器直接解包 data,业务侧只拿到 T
return body.data
},
(error) => {
if (error.response?.status === 401) {
// token 缺失/失效:重新走授权,IdP 有 SSO 会话时无感跳回
redirectToLogin()
throw new Error('未登录或登录已过期')
}
throw new Error(error.message || '网络请求失败')
}
)
// 拦截器已在运行时把响应解包为 data,这里用类型断言表达该事实
export function get<T>(path: string): Promise<T> {
return instance.get(path) as unknown as Promise<T>
}
export function post<T>(path: string, body?: unknown): Promise<T> {
return instance.post(path, body) as unknown as Promise<T>
}
import type { TodoItem } from '../types'
// 待办后端当前仅有留痕写入接口,无列表查询,暂保留 mock
export const mockTodos: TodoItem[] = [
{ id: 'TODO-1', policyNo: 'POL-10001', type: 'renewal', customerName: '陳大文' },
{ id: 'TODO-2', policyNo: 'POL-10002', type: 'lapse', customerName: '李婉婷' },
{ id: 'TODO-3', policyNo: 'POL-10001', type: 'birthday', customerName: '陳大文' }
]
export const useMock = import.meta.env.VITE_USE_MOCK !== 'false'
export function mockApiDelay(ms = 300): Promise<void> {
if (!useMock) return Promise.resolve()
return new Promise((resolve) => setTimeout(resolve, ms))
}
\ No newline at end of file
import type { MetricSummary, TodoItem } from '../types'
import { get } from '../http'
import { mockApiDelay } from '../mock'
import { mockTodos } from '../mock/data'
// 后端 /c/dashboard/summary 返回体(com.yd.csf.service.vo.ReferrerDashboardVO)
interface DashboardSummaryDTO {
monthPaidCommission: number
taxYearPaidCommission: number
pipelineCommission: number
effectivePolicyCount: number
firstYearFyp: number
}
export async function fetchMetricSummary(): Promise<MetricSummary> {
const dto = await get<DashboardSummaryDTO>('/c/dashboard/summary')
// 后端契约 -> 前端展示模型
return {
thisMonthPaidHkd: dto.monthPaidCommission,
taxYearPaidHkd: dto.taxYearPaidCommission,
pipelineHkd: dto.pipelineCommission,
inforceCount: dto.effectivePolicyCount,
firstYearFypHkd: dto.firstYearFyp
}
}
// 待办后端当前仅有留痕写入接口,无列表查询,暂保留 mock
export async function fetchTodos(): Promise<TodoItem[]> {
await mockApiDelay()
return mockTodos
}
import type { PipelineItem, CommissionStatus } from '../types'
import { get } from '../http'
// 后端 /c/pipeline 返回体(com.yd.csf.service.vo.ReferrerPipelineVO)
interface PipelineDTO {
expectedFortuneBizId: string
policyNo: string
fortunePeriod: number
fortuneName: string
payoutDate?: string | null // yyyy-MM-dd
stage: CommissionStatus // D4.1 四态码
hkdAmount: number // 剩余待发折合港币
}
export async function fetchPipeline(): Promise<PipelineItem[]> {
const list = await get<PipelineDTO[]>('/c/pipeline/list')
return (list ?? []).map((dto) => ({
id: dto.expectedFortuneBizId,
policyNo: dto.policyNo,
payoutMonth: dto.payoutDate ? dto.payoutDate.slice(0, 7) : '',
expectedAmount: dto.hkdAmount,
currency: 'HKD',
status: dto.stage
}))
}
import type { Policy, PolicyCommissionYear, CommissionStatus, CommissionType } from '../types'
import { get } from '../http'
// 后端 /c/policy 返回体(com.yd.csf.service.vo.ReferrerPolicy*)
interface PolicyDTO {
policyNo: string
productName: string
currency: string
fullPremium: number
myRatio: string | null
status: string
}
interface PolicyListDTO {
effectivePolicies: PolicyDTO[]
inactivePolicies: PolicyDTO[]
}
interface CommissionSegmentDTO {
stage: CommissionStatus // 后端已按 D4.1 输出的四态码
fortunePeriod: number
periodLabel: string // 首年 / 第2年 ...
fortuneName: string
statusDesc?: string | null
amount: number // 该段 HKD 金额
}
interface PolicyCommissionDTO {
policyNo: string
items: CommissionSegmentDTO[]
}
export async function fetchPolicies(): Promise<Policy[]> {
const dto = await get<PolicyListDTO>('/c/policy/list')
const all = [...(dto.effectivePolicies ?? []), ...(dto.inactivePolicies ?? [])]
return all.map((p) => ({
policyNo: p.policyNo,
status: p.status as Policy['status'],
productName: p.productName,
fullPremium: p.fullPremium,
currency: p.currency,
myRatio: p.myRatio ? Number(p.myRatio) : 0
}))
}
export async function fetchPolicyCommissions(policyNo: string): Promise<PolicyCommissionYear[]> {
if (!policyNo) return []
const dto = await get<PolicyCommissionDTO>(`/c/policy/${policyNo}/commission`)
return (dto.items ?? []).map((it) => {
const type: CommissionType = it.fortunePeriod === 1 ? 'FYC' : 'RYC'
return {
year: it.periodLabel || `Y${it.fortunePeriod}`,
type,
amount: it.amount,
currency: 'HKD',
status: it.stage,
statusDesc: it.statusDesc ?? undefined
}
})
}
import type { Statement, StatementDetail, StatementItem } from '../types'
import { get } from '../http'
// 后端 /c/statement 返回体(com.yd.csf.service.vo.ReferrerStatement*)
interface StatementDTO {
salaryBizId: string
month: string // yyyyMM
salaryNo: string
grossAmount: number
mpfAmount: number
otherAmount: number
paidAmount: number
payoutDate?: string | null
}
interface StatementItemDTO {
policyNo: string
policyHolder: string
insuranceCompany: string
productName: string
fortunePeriod: number
fortuneName: string
brokerRatio: string | null
originalCurrency: string
originalAmount: number
originalToHkdRate: number
hkdAmount: number
}
interface StatementDetailDTO extends StatementDTO {
bankName?: string | null
accountEndNo?: string | null
items: StatementItemDTO[]
}
/** yyyyMM -> yyyy-MM */
function formatMonth(month: string): string {
return month?.length === 6 ? `${month.slice(0, 4)}-${month.slice(4, 6)}` : month
}
export async function fetchStatements(): Promise<Statement[]> {
const list = await get<StatementDTO[]>('/c/statement/list')
return list.map((dto) => ({
id: dto.salaryBizId,
month: formatMonth(dto.month),
grossAmount: dto.grossAmount,
currency: 'HKD',
netAmount: dto.paidAmount,
payDate: dto.payoutDate ?? '',
bankTail: ''
}))
}
export async function fetchStatementDetail(id: string): Promise<StatementDetail> {
const dto = await get<StatementDetailDTO>(`/c/statement/${id}`)
const items: StatementItem[] = dto.items.map((it) => ({
id: `${it.policyNo}-${it.fortunePeriod}`,
customerName: it.policyHolder,
policyNo: it.policyNo,
companyName: it.insuranceCompany,
productName: it.productName,
commissionYearType: it.fortuneName || `Y${it.fortunePeriod}`,
originalAmount: it.originalAmount,
originalCurrency: it.originalCurrency,
exchangeRate: it.originalToHkdRate,
hkdAmount: it.hkdAmount,
myRatio: it.brokerRatio ? Number(it.brokerRatio) : 0
}))
return {
id: dto.salaryBizId,
month: formatMonth(dto.month),
grossAmount: dto.grossAmount,
currency: 'HKD',
netAmount: dto.paidAmount,
payDate: dto.payoutDate ?? '',
bankTail: dto.accountEndNo ?? '',
mpfAmount: dto.mpfAmount,
adjustmentAmount: dto.otherAmount,
items
}
}
export type Currency = string
export interface MetricSummary {
thisMonthPaidHkd: number
taxYearPaidHkd: number
pipelineHkd: number
inforceCount: number
firstYearFypHkd: number
}
export interface Statement {
id: string
month: string
grossAmount: number
currency: Currency
netAmount: number
payDate: string
bankTail: string
}
export interface StatementItem {
id: string
customerName: string
policyNo: string
companyName: string
productName: string
commissionYearType: string
originalAmount: number
originalCurrency: Currency
exchangeRate: number
hkdAmount: number
myRatio: number
}
export interface StatementDetail extends Statement {
mpfAmount: number
adjustmentAmount: number
items: StatementItem[]
}
export type PolicyStatus = 'INFORCE' | 'LAPSED' | 'SURRENDERED' | 'REJECTED'
export interface Policy {
policyNo: string
status: PolicyStatus
productName: string
fullPremium: number
currency: Currency
myRatio: number
}
export type CommissionType = 'FYC' | 'RYC'
/** D4.1 四态语义码,由后端输出,前端只按码查文案、不再翻译内部状态。 */
export type CommissionStatus = 'PAID' | 'PIPELINE' | 'RETAINED' | 'LAPSED'
export interface PolicyCommissionYear {
year: string
type: CommissionType
amount: number
currency: Currency
status: CommissionStatus
/** 保留/失效原因(财务登记,可能为空) */
statusDesc?: string
}
export interface PipelineItem {
id: string
policyNo: string
payoutMonth: string
expectedAmount: number
currency: Currency
/** D4.1 语义码:PIPELINE / RETAINED / LAPSED */
status: CommissionStatus
}
export type TodoType = 'renewal' | 'lapse' | 'birthday'
export interface TodoItem {
id: string
policyNo: string
type: TodoType
customerName: string
}
\ No newline at end of file
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Skeleton from './Skeleton.vue'
import Empty from './Empty.vue'
import Button from './Button.vue'
defineProps<{
loading: boolean
error?: string | null
empty?: boolean
}>()
const emit = defineEmits<{
(e: 'retry'): void
}>()
const { t } = useI18n()
</script>
<template>
<div>
<Skeleton v-if="loading" />
<div v-else-if="error" class="rounded-xl bg-red-50 p-4 text-sm text-red-700">
<p>{{ t('common.error') }}: {{ error }}</p>
<Button variant="secondary" class="mt-3" @click="emit('retry')">{{ t('common.retry') }}</Button>
</div>
<Empty v-else-if="empty" />
<slot v-else />
</div>
</template>
\ No newline at end of file
<script setup lang="ts">
defineProps<{
tone?: 'brand' | 'success' | 'warning' | 'danger' | 'neutral'
}>()
</script>
<template>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="{
'bg-brand-50 text-brand-700': tone === 'brand' || !tone,
'bg-emerald-50 text-emerald-700': tone === 'success',
'bg-amber-50 text-amber-700': tone === 'warning',
'bg-red-50 text-red-700': tone === 'danger',
'bg-slate-100 text-slate-600': tone === 'neutral'
}"
>
<slot />
</span>
</template>
\ No newline at end of file
<script setup lang="ts">
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost'
disabled?: boolean
}>()
</script>
<template>
<button
class="rounded-full px-4 py-2 text-sm font-medium transition-all hover:brightness-[0.98] active:scale-[0.98] disabled:opacity-50"
:class="{
'bg-brand-600 text-white shadow-sm': variant === 'primary',
'bg-slate-100 text-slate-700 hover:bg-slate-200': variant === 'secondary' || !variant,
'text-brand-600': variant === 'ghost'
}"
:disabled="disabled"
>
<slot />
</button>
</template>
\ No newline at end of file
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div class="py-10 text-center text-sm text-slate-400">{{ t('common.empty') }}</div>
</template>
\ No newline at end of file
<script setup lang="ts">
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
defineProps<{
open: boolean
title?: string
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
</script>
<template>
<Dialog :open="open" class="relative z-50" @close="emit('close')">
<div class="fixed inset-0 bg-black/30" aria-hidden="true"></div>
<div class="fixed inset-0 flex items-end justify-center p-4 sm:items-center">
<DialogPanel class="w-full max-w-md rounded-3xl bg-white p-5 shadow-soft sm:rounded-2xl">
<DialogTitle v-if="title" class="tnum mb-3 text-lg font-semibold tracking-tight">{{ title }}</DialogTitle>
<slot />
</DialogPanel>
</div>
</Dialog>
</template>
\ No newline at end of file
<template>
<div class="space-y-3 animate-pulse">
<div class="h-24 rounded-2xl border border-slate-100 bg-slate-100/80"></div>
<div class="h-[72px] rounded-2xl border border-slate-100 bg-slate-100/80"></div>
<div class="h-[72px] rounded-2xl border border-slate-100 bg-slate-100/80"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { usePreferenceStore } from '@/stores/preference'
const auth = useAuthStore()
const preference = usePreferenceStore()
const { user } = storeToRefs(auth)
const { t } = useI18n()
const isHant = computed(() => preference.locale === 'zh-Hant')
function toggleLocale() {
preference.setLocale(isHant.value ? 'zh-Hans' : 'zh-Hant')
}
</script>
<template>
<div class="flex items-center gap-2">
<button
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
@click="toggleLocale"
>
{{ isHant ? t('lang.zhHans') : t('lang.zhHant') }}
</button>
<div v-if="user" class="flex items-center gap-2">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-600 text-sm font-medium text-white">
{{ user.name.slice(0, 1) }}
</div>
<span class="max-w-[7rem] truncate text-sm font-medium">{{ user.name }}</span>
<button
class="shrink-0 text-xs text-slate-400 transition-colors hover:text-slate-600"
@click="auth.logout()"
>{{ t('auth.logout') }}</button>
</div>
<button
v-else
class="rounded-full bg-brand-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-brand-700"
@click="auth.login()"
>
{{ t('auth.login') }}
</button>
</div>
</template>
\ No newline at end of file
<script setup lang="ts">
defineProps<{
label: string
value: string
hint?: string
feature?: boolean
}>()
</script>
<template>
<div
class="rounded-2xl border border-slate-200/80 bg-white shadow-card transition-shadow hover:shadow-soft"
:class="feature ? 'p-5' : 'p-4'"
>
<div class="text-xs text-slate-500">{{ label }}</div>
<div
class="tnum mt-2 font-semibold tracking-tight text-slate-900"
:class="feature ? 'text-[28px] leading-tight' : 'text-xl'"
>{{ value }}</div>
<div v-if="hint" class="mt-1 text-xs text-slate-400">{{ hint }}</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { formatDetailMoney } from '@/utils/money'
import type { PipelineItem } from '@/api/types'
defineProps<{
item: PipelineItem
}>()
const { t } = useI18n()
/** 失效/保留不应当作待发金额:配色按语义状态分流 */
function statusClass(status: PipelineItem['status']): string {
if (status === 'LAPSED') return 'text-rose-600'
if (status === 'RETAINED') return 'text-slate-500'
return 'text-amber-600'
}
</script>
<template>
<div class="rounded-2xl border border-slate-200/80 bg-white p-4 shadow-card">
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium tracking-tight">{{ item.policyNo }}</div>
<div class="mt-1 text-xs text-slate-500">{{ t('pipeline.payoutMonth') }} {{ item.payoutMonth }}</div>
</div>
<div class="text-right">
<div class="tnum text-sm font-semibold">{{ formatDetailMoney(item.expectedAmount, item.currency) }}</div>
<div class="mt-1 text-xs font-medium" :class="statusClass(item.status)">{{ t(`stage.${item.status}`) }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { formatDetailMoney } from '@/utils/money'
import type { Policy } from '@/api/types'
defineProps<{
policy: Policy
}>()
const emit = defineEmits<{
(e: 'select', policyNo: string): void
}>()
const { t } = useI18n()
</script>
<template>
<button
class="flex w-full items-center justify-between rounded-2xl border border-slate-200/80 bg-white p-4 text-left shadow-card transition-all hover:border-slate-300 hover:shadow-soft active:scale-[0.99]"
@click="emit('select', policy.policyNo)"
>
<div>
<div class="text-sm font-medium tracking-tight">{{ policy.policyNo }}</div>
<div class="mt-1 text-xs text-slate-500">{{ policy.productName }}</div>
</div>
<div class="text-right">
<div class="tnum text-sm font-medium">{{ formatDetailMoney(policy.fullPremium, policy.currency) }}</div>
<div class="mt-1 text-xs text-slate-400">{{ t('policies.myRatio') }} {{ policy.myRatio }}</div>
</div>
</button>
</template>
\ No newline at end of file
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { formatDetailMoney, formatHkd } from '@/utils/money'
import type { StatementDetail as StatementDetailType } from '@/api/types'
defineProps<{
detail: StatementDetailType
}>()
const { t } = useI18n()
</script>
<template>
<div class="space-y-4">
<section class="rounded-2xl border border-slate-200/80 bg-white p-4 shadow-card">
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs text-slate-500">{{ t('statement.gross') }}</div>
<div class="tnum mt-1 font-medium">{{ formatHkd(detail.grossAmount) }}</div>
</div>
<div>
<div class="text-xs text-slate-500">{{ t('statement.mpf') }}</div>
<div class="tnum mt-1 font-medium">{{ formatHkd(detail.mpfAmount) }}</div>
</div>
<div>
<div class="text-xs text-slate-500">{{ t('statement.adjustment') }}</div>
<div class="tnum mt-1 font-medium">{{ formatHkd(detail.adjustmentAmount) }}</div>
</div>
<div>
<div class="text-xs text-slate-500">{{ t('statement.net') }}</div>
<div class="tnum mt-1 font-semibold">{{ formatHkd(detail.netAmount) }}</div>
</div>
<div>
<div class="text-xs text-slate-500">{{ t('statement.payDate') }}</div>
<div class="mt-1">{{ detail.payDate }}</div>
</div>
<div>
<div class="text-xs text-slate-500">{{ t('statement.bankTail') }}</div>
<div class="mt-1">{{ detail.bankTail }}</div>
</div>
</div>
</section>
<section class="rounded-2xl border border-slate-200/80 bg-white p-4 shadow-card">
<div class="mb-3 text-sm font-medium">{{ t('statement.amount') }}</div>
<div v-for="item in detail.items" :key="item.id" class="border-b border-slate-100 py-3 last:border-0">
<div class="flex items-center justify-between text-sm">
<span>{{ item.customerName }}</span>
<span class="tnum font-medium">{{ formatDetailMoney(item.originalAmount, item.originalCurrency) }}</span>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>{{ t('statement.policy') }} {{ item.policyNo }}</span>
<span>{{ item.companyName }}</span>
<span>{{ item.productName }}</span>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
<span>{{ item.commissionYearType }}</span>
<span>{{ t('statement.ratio') }} {{ item.myRatio }}</span>
<span>@{{ item.exchangeRate }}</span>
<span class="tnum">{{ formatHkd(item.hkdAmount) }}</span>
</div>
</div>
</section>
</div>
</template>
\ No newline at end of file
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { formatHkd } from '@/utils/money'
import type { Statement } from '@/api/types'
defineProps<{
statements: Statement[]
}>()
const emit = defineEmits<{
(e: 'select', id: string): void
}>()
const { t } = useI18n()
</script>
<template>
<div class="space-y-3">
<button
v-for="statement in statements"
:key="statement.id"
class="flex w-full items-center justify-between rounded-2xl border border-slate-200/80 bg-white p-4 text-left shadow-card transition-all hover:border-slate-300 hover:shadow-soft active:scale-[0.99]"
@click="emit('select', statement.id)"
>
<div>
<div class="text-sm font-medium tracking-tight">{{ statement.month }}</div>
<div class="mt-1 text-xs text-slate-500">{{ t('statement.payDate') }} {{ statement.payDate }}</div>
</div>
<div class="text-right">
<div class="tnum text-sm font-semibold">{{ formatHkd(statement.netAmount) }}</div>
<div class="mt-1 text-xs text-slate-400">{{ t('statement.net') }}</div>
</div>
</button>
</div>
</template>
\ No newline at end of file
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TodoItem, TodoType } from '@/api/types'
const props = defineProps<{
todo: TodoItem
}>()
const { t } = useI18n()
const typeLabel = computed(() => {
const map: Record<TodoType, string> = {
renewal: 'todo.renewal',
lapse: 'todo.lapse',
birthday: 'todo.birthday'
}
return t(map[props.todo.type])
})
const done = ref(false)
const doneDate = ref('')
const actionLabel = computed(() =>
done.value ? t('todo.doneAt', { date: doneDate.value }) : t('todo.markDone')
)
function markDone() {
if (done.value) return
done.value = true
doneDate.value = new Date().toISOString().slice(0, 10)
}
</script>
<template>
<div class="flex items-center justify-between rounded-2xl border border-slate-200/80 bg-white p-4 shadow-card">
<div>
<div class="text-sm font-medium tracking-tight">{{ typeLabel }}</div>
<div class="mt-1 text-xs text-slate-500">{{ todo.customerName }} · {{ todo.policyNo }}</div>
</div>
<button
class="rounded-full px-3.5 py-1.5 text-xs font-medium transition-all active:scale-95"
:class="done ? 'bg-emerald-50 text-emerald-700' : 'bg-brand-600 text-white hover:bg-brand-700 shadow-sm'"
@click="markDone"
>
{{ actionLabel }}
</button>
</div>
</template>
\ No newline at end of file
/// <reference types="vite/client" />
\ No newline at end of file
import { createI18n } from 'vue-i18n'
import zhHant from './zh-Hant'
import zhHans from './zh-Hans'
const saved = localStorage.getItem('wb-locale')
const locale = saved && ['zh-Hant', 'zh-Hans'].includes(saved) ? saved : 'zh-Hant'
export const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: 'zh-Hant',
messages: {
'zh-Hant': zhHant,
'zh-Hans': zhHans
}
})
\ No newline at end of file
export default {
'app.title': '转介人工作台',
'nav.home': '首页',
'nav.statement': '月结单',
'nav.policies': '我的保单',
'nav.pipeline': '在途佣金',
'common.loading': '加载中…',
'common.error': '加载失败',
'common.retry': '重试',
'common.empty': '暂无数据',
'metric.thisMonthPaid': '本月已发佣金',
'metric.taxYearPaid': '本课税年度累计已发',
'metric.pipeline': '在途佣金',
'metric.inforceCount': '生效件数',
'metric.firstYearFyp': '首年业绩 FYP',
'dashboard.actions': '智能行动看板',
'todo.renewal': '续期缴费',
'todo.lapse': '失效预警',
'todo.birthday': '生日关怀',
'todo.claim': '理赔协助',
'todo.claimPlaceholder': '系统暂无理赔模块,二期或人工录入',
'todo.markDone': '已通知',
'todo.doneAt': '已通知 · {date}',
'statement.gross': '应发',
'statement.mpf': 'MPF',
'statement.adjustment': '其他调整',
'statement.net': '实发',
'statement.payDate': '发放日',
'statement.bankTail': '银行尾号',
'statement.amount': '金额',
'statement.customer': '客户',
'statement.policy': '保单',
'statement.company': '保司',
'statement.product': '产品',
'statement.yearType': '佣金年度/类型',
'statement.ratio': '我的分成比例',
'policies.inforce': '生效保单',
'policies.inactive': '未生效/已失效',
'policies.fullPremium': '全额保费',
'policies.myRatio': '我的比例',
'policies.fyp': '首年',
'policies.fyc': '首年佣金',
'policies.ryp': '续期',
'policies.ryc': '续期佣金',
'policies.year': '保单年度',
'pipeline.payoutMonth': '预计出账月',
'pipeline.adjustNote': '保司来佣后发放,金额可能调整',
'stage.PAID': '已发',
'stage.PIPELINE': '在途',
'stage.RETAINED': '保留',
'stage.LAPSED': '失效',
'auth.callbackTitle': '认证回调',
'auth.callbackDesc': '登录成功,正在跳转…',
'auth.callbackError': '登录失败或授权已失效,请重试。',
'auth.login': '登录',
'auth.logout': '退出',
'auth.loggedOutTitle': '已退出登录',
'auth.loggedOutDesc': '本门户登录态已清除,不影响其他系统。',
'lang.zhHant': '繁',
'lang.zhHans': '简',
'common.pullHint': '下拉即可重新整理'
}
\ No newline at end of file
export default {
'app.title': '轉介人工作台',
'nav.home': '首頁',
'nav.statement': '月結單',
'nav.policies': '我的保單',
'nav.pipeline': '在途佣金',
'common.loading': '載入中…',
'common.error': '載入失敗',
'common.retry': '重試',
'common.empty': '暫無數據',
'metric.thisMonthPaid': '本月已發佣金',
'metric.taxYearPaid': '本課稅年度累計已發',
'metric.pipeline': '在途佣金',
'metric.inforceCount': '生效件數',
'metric.firstYearFyp': '首年業績 FYP',
'dashboard.actions': '智能行動看板',
'todo.renewal': '續期繳費',
'todo.lapse': '失效預警',
'todo.birthday': '生日關懷',
'todo.claim': '理賠協助',
'todo.claimPlaceholder': '系統暫無理賠模組,二期或人工錄入',
'todo.markDone': '已通知',
'todo.doneAt': '已通知 · {date}',
'statement.gross': '應發',
'statement.mpf': 'MPF',
'statement.adjustment': '其他調整',
'statement.net': '實發',
'statement.payDate': '發放日',
'statement.bankTail': '銀行尾號',
'statement.amount': '金額',
'statement.customer': '客戶',
'statement.policy': '保單',
'statement.company': '保司',
'statement.product': '產品',
'statement.yearType': '佣金年度/類型',
'statement.ratio': '我的分成比例',
'policies.inforce': '生效保單',
'policies.inactive': '未生效/已失效',
'policies.fullPremium': '全額保費',
'policies.myRatio': '我的比例',
'policies.fyp': '首年',
'policies.fyc': '首年佣金',
'policies.ryp': '續期',
'policies.ryc': '續期佣金',
'policies.year': '保單年度',
'pipeline.payoutMonth': '預計出賬月',
'pipeline.adjustNote': '保司來佣後發放,金額可能調整',
'stage.PAID': '已發',
'stage.PIPELINE': '在途',
'stage.RETAINED': '保留',
'stage.LAPSED': '失效',
'auth.callbackTitle': '認證回調',
'auth.callbackDesc': '登入成功,正在跳轉…',
'auth.callbackError': '登入失敗或授權已失效,請重試。',
'auth.login': '登入',
'auth.logout': '退出',
'auth.loggedOutTitle': '已登出',
'auth.loggedOutDesc': '本門戶登入態已清除,不影響其他系統。',
'lang.zhHant': '繁',
'lang.zhHans': '简',
'common.pullHint': '下拉即可重新整理'
}
\ No newline at end of file
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { i18n } from './locales'
import { useAuthStore } from './stores/auth'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
// 挂载前从内存登录态同步用户信息,供 LoginBadge 等组件渲染
await useAuthStore().init()
app.mount('#app')
\ No newline at end of file
import { createRouter, createWebHistory } from 'vue-router'
import { userManager, signinRedirect } from '@/utils/oidc'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: () => import('@/views/HomeView.vue') },
{ path: '/statement', name: 'statement', component: () => import('@/views/StatementView.vue') },
{ path: '/statement/:id', name: 'statement-detail', component: () => import('@/views/StatementDetailView.vue') },
{ path: '/policies', name: 'policies', component: () => import('@/views/PoliciesView.vue') },
{ path: '/pipeline', name: 'pipeline', component: () => import('@/views/PipelineView.vue') },
{ path: '/auth/callback', name: 'auth-callback', component: () => import('@/views/AuthCallbackView.vue') },
{ path: '/logged-out', name: 'logged-out', component: () => import('@/views/LoggedOutView.vue') }
]
})
// 认证守卫:除回调/已退出页外,无有效登录态即发起 OIDC 授权(当前路径随授权 state 携带,回跳后还原)。
router.beforeEach(async (to) => {
if (to.name === 'auth-callback' || to.name === 'logged-out') return true
const user = await userManager.getUser()
if (!user || user.expired) {
await signinRedirect(to.fullPath)
return false
}
return true
})
export default router
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { User } from 'oidc-client-ts'
import {
userManager,
signinRedirect,
handleRedirectCallback,
signoutRedirect
} from '@/utils/oidc'
export interface AuthUser {
name: string
phone: string
}
function toAuthUser(u: User | null): AuthUser | null {
if (!u || u.expired) return null
const p = u.profile
const name = p.name ?? p.preferred_username ?? ''
return { name, phone: p.phone_number ?? '' }
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null)
/** 应用启动时从内存登录态同步到 store。 */
async function init(): Promise<void> {
user.value = toAuthUser(await userManager.getUser())
}
/** 发起 OIDC 登录;当前路径随 state 携带,回调后跳回。 */
function login(returnPath?: string): Promise<void> {
return signinRedirect(returnPath)
}
/** 处理授权码回调,装载登录态,返回登录前路径。 */
async function handleCallback(): Promise<string> {
const returnPath = await handleRedirectCallback()
user.value = toAuthUser(await userManager.getUser())
return returnPath
}
/** 登出:跳转 IdP 结束 SSO 会话,回 /logged-out。 */
function logout(): Promise<void> {
return signoutRedirect()
}
return { user, init, login, handleCallback, logout }
})
import { defineStore } from 'pinia'
import { i18n } from '@/locales'
export type Locale = 'zh-Hant' | 'zh-Hans'
export const usePreferenceStore = defineStore('preference', {
state: () => ({
locale: String(i18n.global.locale.value) as Locale
}),
actions: {
setLocale(locale: Locale) {
this.locale = locale
i18n.global.locale.value = locale
localStorage.setItem('wb-locale', locale)
}
}
})
\ No newline at end of file
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
-webkit-tap-highlight-color: transparent;
}
body {
@apply antialiased;
text-rendering: optimizeLegibility;
}
/* 键盘可达:仅在键盘聚焦时显示焦点环,触摸点击不残留黑框 */
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid rgb(99 102 241 / 0.55);
outline-offset: 2px;
}
}
@layer utilities {
/* 金额等数据列:等宽数字,避免位数跳动 */
.tnum {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
/* iPhone 底部安全区 */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
}
import dayjs from 'dayjs'
// 香港课税年度:4/1–3/31
export function taxYearOf(date: Date): string {
const y = date.getFullYear()
const m = date.getMonth() + 1
const startYear = m >= 4 ? y : y - 1
return `${startYear}-${startYear + 1}`
}
export function formatDate(date: string | Date, template = 'YYYY-MM-DD'): string {
return dayjs(date).format(template)
}
\ No newline at end of file
export function roundHalfUp(value: number, decimals = 2): number {
const factor = 10 ** decimals
return Math.round((value + Number.EPSILON) * factor) / factor
}
export function formatMoney(value: number, currency: string, decimals = 2): string {
return new Intl.NumberFormat('zh-HK', {
style: 'currency',
currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
}).format(value)
}
// 明细金额保留 4 位 HALF_UP;HKD 汇总展示 2 位
export function formatDetailMoney(value: number, currency: string): string {
const decimals = currency === 'HKD' ? 2 : 4
return formatMoney(roundHalfUp(value, decimals), currency, decimals)
}
export function formatHkd(value: number): string {
return formatMoney(roundHalfUp(value, 2), 'HKD', 2)
}
\ No newline at end of file
import { UserManager, WebStorageStateStore, type IStore } from 'oidc-client-ts'
/**
* 内存存储:登录后的 User(含 access_token)只存于此,不写入 localStorage/sessionStorage。
* 刷新页面或关闭标签即丢失,符合 D8「access_token 仅存内存」。
*/
class MemoryStore implements IStore {
private readonly map = new Map<string, string>()
getItem(key: string): string | null {
return this.map.has(key) ? this.map.get(key)! : null
}
setItem(key: string, value: string): void {
this.map.set(key, value)
}
removeItem(key: string): void {
this.map.delete(key)
}
}
const authority = import.meta.env.VITE_OIDC_AUTHORITY ?? 'https://auth.anjibao.cn'
const clientId = import.meta.env.VITE_OIDC_CLIENT_ID ?? '6db48077896a73e99c4a'
const scope = import.meta.env.VITE_OIDC_SCOPE ?? 'openid profile phone'
export const userManager = new UserManager({
authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
post_logout_redirect_uri: `${window.location.origin}/logged-out`,
response_type: 'code',
scope,
// 授权码流程跨页面跳转所需的临时 state(code_verifier/nonce),同标签页内保留
stateStore: new WebStorageStateStore({ store: window.sessionStorage }),
// 登录态(含 token)仅内存
userStore: new WebStorageStateStore({ store: new MemoryStore() }),
// 第一版不开隐藏 iframe 自动续期:过期/失效由 401 触发一次重新授权(同站 SSO 会话在则无感跳回)
automaticSilentRenew: false,
// 令牌足以解身份,无需额外再拉 userinfo
loadUserInfo: false
})
/** 发起登录(整页跳到 IdP;在 iframe 内即 iframe 内跳转)。 */
export function signinRedirect(returnPath?: string): Promise<void> {
return userManager.signinRedirect({ state: { returnPath } })
}
/** 处理授权码回调,交换并装载登录态,返回发起登录前的路径。 */
export async function handleRedirectCallback(): Promise<string> {
const user = await userManager.signinRedirectCallback()
return (user.state as { returnPath?: string } | undefined)?.returnPath ?? '/'
}
/**
* 登出:走 OIDC RP-Initiated Logout,跳转 IdP 结束 SSO 会话后回到 /logged-out。
* 转介人在 Casdoor 仅消费本应用,结束全局会话无「连带退出其他系统」副作用。
*/
export function signoutRedirect(): Promise<void> {
return userManager.signoutRedirect()
}
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const router = useRouter()
const auth = useAuthStore()
const error = ref(false)
onMounted(async () => {
try {
const returnPath = await auth.handleCallback()
await router.replace(returnPath)
} catch (e) {
error.value = true
}
})
function retry() {
void auth.login('/')
}
</script>
<template>
<div class="py-16 text-center">
<template v-if="!error">
<h1 class="text-lg font-semibold">{{ t('auth.callbackTitle') }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ t('auth.callbackDesc') }}</p>
</template>
<template v-else>
<h1 class="text-lg font-semibold">{{ t('auth.callbackTitle') }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ t('auth.callbackError') }}</p>
<button
class="mt-4 rounded-lg bg-brand-600 px-4 py-2 text-sm text-white"
@click="retry"
>
{{ t('auth.login') }}
</button>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ApiLoader from '@/components/common/ApiLoader.vue'
import MetricCard from '@/components/feature/MetricCard.vue'
import TodoCard from '@/components/feature/TodoCard.vue'
import { fetchMetricSummary, fetchTodos } from '@/api/modules/dashboard'
import type { MetricSummary, TodoItem } from '@/api/types'
import { formatHkd } from '@/utils/money'
const { t } = useI18n()
const loading = ref(true)
const error = ref<string | null>(null)
const metrics = ref<MetricSummary | null>(null)
const todos = ref<TodoItem[]>([])
const heroCard = computed(() =>
metrics.value
? { label: t('metric.thisMonthPaid'), value: formatHkd(metrics.value.thisMonthPaidHkd) }
: null
)
const restCards = computed(() => {
if (!metrics.value) return []
return [
{ label: t('metric.taxYearPaid'), value: formatHkd(metrics.value.taxYearPaidHkd) },
{ label: t('metric.pipeline'), value: formatHkd(metrics.value.pipelineHkd) },
{ label: t('metric.inforceCount'), value: String(metrics.value.inforceCount) },
{ label: t('metric.firstYearFyp'), value: formatHkd(metrics.value.firstYearFypHkd) }
]
})
const countByType = computed(() => ({
renewal: todos.value.filter((x) => x.type === 'renewal').length,
lapse: todos.value.filter((x) => x.type === 'lapse').length,
birthday: todos.value.filter((x) => x.type === 'birthday').length
}))
async function load() {
loading.value = true
error.value = null
try {
const [summary, todoList] = await Promise.all([fetchMetricSummary(), fetchTodos()])
metrics.value = summary
todos.value = todoList
} catch (e) {
error.value = e instanceof Error ? e.message : 'error'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="space-y-7">
<ApiLoader :loading="loading" :error="error" @retry="load">
<MetricCard v-if="heroCard" feature :label="heroCard.label" :value="heroCard.value" />
<div class="mt-3 grid grid-cols-2 gap-3">
<MetricCard v-for="card in restCards" :key="card.label" :label="card.label" :value="card.value" />
</div>
<section class="mt-7">
<h2 class="mb-3 text-[13px] font-semibold tracking-wide text-slate-700">{{ t('dashboard.actions') }}</h2>
<div class="mb-3 flex flex-wrap gap-2 text-xs text-slate-500">
<span class="rounded-full bg-slate-100 px-2.5 py-1">{{ t('todo.renewal') }} {{ countByType.renewal }}</span>
<span class="rounded-full bg-slate-100 px-2.5 py-1">{{ t('todo.lapse') }} {{ countByType.lapse }}</span>
<span class="rounded-full bg-slate-100 px-2.5 py-1">{{ t('todo.birthday') }} {{ countByType.birthday }}</span>
</div>
<div class="space-y-3">
<TodoCard v-for="todo in todos" :key="todo.id" :todo="todo" />
<div class="rounded-2xl border border-dashed border-slate-200 bg-white/60 p-4">
<div class="text-sm font-medium">{{ t('todo.claim') }}</div>
<div class="mt-1 text-xs text-slate-400">{{ t('todo.claimPlaceholder') }}</div>
</div>
</div>
</section>
</ApiLoader>
</div>
</template>
\ No newline at end of file
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const auth = useAuthStore()
</script>
<template>
<div class="py-16 text-center">
<h1 class="text-lg font-semibold">{{ t('auth.loggedOutTitle') }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ t('auth.loggedOutDesc') }}</p>
<button
class="mt-4 rounded-lg bg-brand-600 px-4 py-2 text-sm text-white"
@click="auth.login('/')"
>
{{ t('auth.login') }}
</button>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ApiLoader from '@/components/common/ApiLoader.vue'
import PipelineCard from '@/components/feature/PipelineCard.vue'
import { fetchPipeline } from '@/api/modules/pipeline'
import type { PipelineItem } from '@/api/types'
const { t } = useI18n()
const loading = ref(true)
const error = ref<string | null>(null)
const items = ref<PipelineItem[]>([])
async function load() {
loading.value = true
error.value = null
try {
items.value = await fetchPipeline()
} catch (e) {
error.value = e instanceof Error ? e.message : 'error'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="space-y-4">
<div class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">{{ t('pipeline.adjustNote') }}</div>
<ApiLoader :loading="loading" :error="error" :empty="!loading && !error && items.length === 0" @retry="load">
<div class="space-y-3">
<PipelineCard v-for="item in items" :key="item.id" :item="item" />
</div>
</ApiLoader>
</div>
</template>
\ No newline at end of file
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ApiLoader from '@/components/common/ApiLoader.vue'
import Modal from '@/components/common/Modal.vue'
import PolicyCard from '@/components/feature/PolicyCard.vue'
import { fetchPolicies, fetchPolicyCommissions } from '@/api/modules/policy'
import type { Policy, PolicyCommissionYear } from '@/api/types'
import { formatDetailMoney } from '@/utils/money'
const { t } = useI18n()
const loading = ref(true)
const error = ref<string | null>(null)
const policies = ref<Policy[]>([])
const modalOpen = ref(false)
const commissions = ref<PolicyCommissionYear[]>([])
const selectedPolicyNo = ref('')
const inforce = computed(() => policies.value.filter((p) => p.status === 'INFORCE'))
const inactive = computed(() => policies.value.filter((p) => p.status !== 'INFORCE'))
async function load() {
loading.value = true
error.value = null
try {
policies.value = await fetchPolicies()
} catch (e) {
error.value = e instanceof Error ? e.message : 'error'
} finally {
loading.value = false
}
}
async function openCommissions(policyNo: string) {
selectedPolicyNo.value = policyNo
commissions.value = await fetchPolicyCommissions(policyNo)
modalOpen.value = true
}
onMounted(load)
</script>
<template>
<ApiLoader :loading="loading" :error="error" :empty="!loading && !error && policies.length === 0" @retry="load">
<div class="space-y-6">
<section>
<h2 class="mb-3 text-[13px] font-semibold tracking-wide text-slate-700">{{ t('policies.inforce') }}</h2>
<div class="space-y-3">
<PolicyCard v-for="policy in inforce" :key="policy.policyNo" :policy="policy" @select="openCommissions" />
</div>
</section>
<section v-if="inactive.length">
<h2 class="mb-3 text-[13px] font-semibold tracking-wide text-slate-700">{{ t('policies.inactive') }}</h2>
<div class="space-y-3">
<PolicyCard v-for="policy in inactive" :key="policy.policyNo" :policy="policy" @select="openCommissions" />
</div>
</section>
</div>
</ApiLoader>
<Modal :open="modalOpen" :title="selectedPolicyNo" @close="modalOpen = false">
<div class="space-y-2">
<div v-for="item in commissions" :key="`${item.year}-${item.status}`" class="flex items-center justify-between gap-3 text-sm">
<span>{{ item.year }} · {{ item.type }}</span>
<span class="text-right">
<span class="tnum">{{ formatDetailMoney(item.amount, item.currency) }}</span> · {{ t(`stage.${item.status}`) }}
<span v-if="item.statusDesc" class="block text-xs text-slate-400">{{ item.statusDesc }}</span>
</span>
</div>
</div>
</Modal>
</template>
\ No newline at end of file
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import ApiLoader from '@/components/common/ApiLoader.vue'
import StatementDetail from '@/components/feature/StatementDetail.vue'
import { fetchStatementDetail } from '@/api/modules/statement'
import type { StatementDetail as StatementDetailType } from '@/api/types'
const route = useRoute()
const loading = ref(true)
const error = ref<string | null>(null)
const detail = ref<StatementDetailType | null>(null)
async function load() {
loading.value = true
error.value = null
try {
detail.value = await fetchStatementDetail(String(route.params.id))
} catch (e) {
error.value = e instanceof Error ? e.message : 'error'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<ApiLoader :loading="loading" :error="error" @retry="load">
<StatementDetail v-if="detail" :detail="detail" />
</ApiLoader>
</template>
\ No newline at end of file
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ApiLoader from '@/components/common/ApiLoader.vue'
import StatementList from '@/components/feature/StatementList.vue'
import { fetchStatements } from '@/api/modules/statement'
import type { Statement } from '@/api/types'
const router = useRouter()
const loading = ref(true)
const error = ref<string | null>(null)
const statements = ref<Statement[]>([])
async function load() {
loading.value = true
error.value = null
try {
statements.value = await fetchStatements()
} catch (e) {
error.value = e instanceof Error ? e.message : 'error'
} finally {
loading.value = false
}
}
function goDetail(id: string) {
router.push(`/statement/${id}`)
}
onMounted(load)
</script>
<template>
<ApiLoader :loading="loading" :error="error" :empty="!loading && !error && statements.length === 0" @retry="load">
<StatementList :statements="statements" @select="goDetail" />
</ApiLoader>
</template>
\ No newline at end of file
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'"SF Pro Text"',
'"Segoe UI"',
'"PingFang HK"',
'"PingFang TC"',
'"Microsoft JhengHei"',
'"Helvetica Neue"',
'Arial',
'sans-serif'
]
},
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca'
},
success: '#059669',
warning: '#d97706',
danger: '#dc2626'
},
boxShadow: {
card: '0 1px 2px rgba(16,24,40,0.04), 0 1px 3px rgba(16,24,40,0.05)',
soft: '0 8px 28px -12px rgba(16,24,40,0.18), 0 2px 6px -2px rgba(16,24,40,0.06)'
},
borderRadius: {
xl: '0.75rem',
'2xl': '1rem'
}
}
},
plugins: []
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
\ No newline at end of file
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
// 后端 context-path 即 /csf/api,代理时不 rewrite
'/csf/api': {
target: 'http://localhost:9202',
changeOrigin: true
}
}
}
})
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment