Commit 5edcb151 by yuzhenWang

Merge branch 'wyz' into 'test'

Wyz

See merge request !3
parents 5491ae58 ebee16ee
# 复制为 .env 供本地开发使用;真实 .env 不入库。
# OIDC 变量在「构建期」固化进产物,测试/生产由流水线 --build-arg 分别注入。
# IdP(Casdoor)地址
VITE_OIDC_AUTHORITY=https://center.zuihuibi.cn
# 在 IdP 为本站登记的应用 clientId
VITE_OIDC_CLIENT_ID=<测试应用-clientId>
VITE_OIDC_SCOPE=openid profile phone
# 仅本地开发使用:vite dev server 代理的后端地址。
# 不设置时指向 http://localhost:9202(本机起的 yd-csf-api),
# 需要连测试环境时再显式改成测试域名,避免本地开发误写测试库数据。
# VITE_API_TARGET=http://localhost:9202
# 置为 true 才启用接口 mock(当前仅影响首页待办的假延迟),默认关闭。
# VITE_USE_MOCK=false
# mock-only 本地核对模式:整条 OIDC 链路停用,用 localStorage 里的转介人身份直连后端。
# 只在本地 .env.development.local 里临时打开,切勿写进测试/生产构建。
# VITE_MOCK_ONLY=true
# 说明:
# redirect_uri / post_logout_redirect_uri 由代码按当前域名自动拼出,无需配置。
# 上线 https://center.zuihuibi.cn 前,需在 IdP 应用里登记:
# 允许的回调:https://center.zuihuibi.cn/auth/callback
# 登出回跳: https://center.zuihuibi.cn/logged-out
# 局域网 IP 访问开发服务器的可访问性说明
> 结论先行:**用 `http://localhost:5173` 能登录、用 `http://10.0.10.x:5173` 报 403,不是前端 bug,是登录服务(auth.anjibao.cn)网关的 Origin 白名单不含局域网地址。** 前端改不掉,必须让登录服务管理员登记,或改用 localhost 隧道。
---
## 1. 现象
从另一台电脑打开 `http://10.0.10.22:5173` 时:
```
GET https://auth.anjibao.cn/.well-known/openid-configuration
net::ERR_FAILED 403 (Forbidden)
at UserManager.signinRedirect (oidc.ts:53)
```
## 2. 实测证据
对同一个 discovery 地址,只改 `Origin` 请求头:
| Origin | 响应 |
|---|---|
| 不发送 Origin | `200` |
| `http://localhost:5173` | `200` |
| **`http://10.0.10.22:5173`** | **`403`** |
| `https://center.zuihuibi.cn` | `200` |
403 的响应头:
```
HTTP/1.1 403 Forbidden
Server: nginx
Vary: Origin
Content-Security-Policy: frame-ancestors 'self' http://localhost:5173
https://mdev.anjibao.cn https://mdev.zuihuibi.cn
```
`Vary: Origin` + 明确的白名单列表说明:**auth.anjibao.cn 前面的 nginx 按 Origin 放行**。
白名单里目前有 `http://localhost:5173`、`https://mdev.anjibao.cn`、`https://mdev.zuihuibi.cn`,**没有局域网 IP**。
oidc-client-ts 在 `signinRedirect` 时会先 fetch discovery 文档(`getMetadata`),这一步被 403,整个授权流程就中断了 —— 所以点登录/登出都没反应。
## 3. 解决方案(三选一)
### 方案 A:让登录服务管理员登记(唯一彻底的办法)
需要登记 **两项**,缺一项仍然登录不了:
1. **网关 Origin 白名单**(nginx)加入 `http://10.0.10.22:5173`
—— 解决 discovery / token 端点的 403。
2. **Casdoor 应用的回调地址白名单**加入 `http://10.0.10.22:5173/auth/callback`
—— `redirect_uri` 是代码按 `window.location.origin` 自动拼的,用 IP 访问时就是 IP 地址。
只改第 1 项不改这项,会在输完账号密码后报 `redirect_uri` 不匹配。
> 可以直接把下面这段发给管理员:
>
> 需要在登录服务 auth.anjibao.cn 登记一个开发联调用来源 `http://10.0.10.22:5173`:
> 1. nginx 的 Origin 白名单(以及 CSP frame-ancestors)加这一项;
> 2. 对应 Casdoor 应用的回调地址白名单加 `http://10.0.10.22:5173/auth/callback`。
登记完成后无需改任何代码,重启 dev server 即可。
### 方案 B:localhost 隧道(不用等管理员,立刻可用)
让**浏览器地址栏保持 `localhost:5173`**,这样 Origin 就在白名单里,请求通过隧道转发到真正跑 dev server 的机器。
- 用 VS Code 的「端口转发」把远端 5173 映射到本地 5173;
- 或 SSH:`ssh -L 5173:127.0.0.1:5173 <user>@10.0.10.22`,然后本机浏览器开 `http://localhost:5173`;
- Windows 无 SSH 时可用 `netsh interface portproxy` 做转发,但转发后浏览器仍需访问 `localhost`。
注意:隧道转发的是 dev server 端口,页面里的 `/csf/api` 仍走 vite 代理,不受影响。
### 方案 C:本地核对模式(跳过整条 OIDC)
项目已内置 `VITE_MOCK_ONLY` 开关,置为 `true` 后路由守卫直接放行、不再发起 OIDC。
在 `.env.development.local` 中配置(`VITE_MOCK_ONLY=true`),配合左下角的转介人切换面板使用。
**仅用于本地核对,切勿写进测试/生产构建。**
## 4. 附带发现并已修复的问题
### 4.1 本地开发实际连的是正式域名(危险,已修)
`vite.config.ts` 里 `loadEnv(mode, '.', 'http://10.0.10.40:9002')` 的第三个参数是**变量前缀**,不是后端地址。传成地址后:
```
当前写法读到 VITE_API_TARGET = undefined (实测只加载到 0 个变量)
正确写法(prefixes='') 读到 = "http://10.0.10.40:9002"
```
结果:`VITE_API_TARGET` 恒为 undefined,`/csf/api` 静默回退到 `https://center.zuihuibi.cn` —— **本地开发一直在连正式域名,有写库风险**,而你 `.env` / `.env.example` 里配的 `10.0.10.40:9002` 从未生效。
已改为 `loadEnv(mode, '.', '')`,并补了本地 `.env`。修复后实测:
```
GET http://127.0.0.1:5173/csf/api/c/dev/brokers
→ 200 {"code":200,"msg":"操作成功","data":[{"brokerBizId":...}]}
```
未配置 `VITE_API_TARGET` 时启动时会有醒目警告,不再静默连正式库。
### 4.2 dev 面板硬编码后端地址(已修)
`src/components/dev/DevBrokerSwitcher.vue` 里直连 `http://10.0.10.40:9002/...`,绕过了 vite 代理,从其他机器访问时该地址未必可达。已改为走 `/csf/api`。
### 4.3 动态导入失败的报错(已加固)
```
TypeError: Failed to fetch dynamically imported module:
http://10.0.10.22:5173/src/components/dev/DevBrokerSwitcher.vue
```
**已排除 Vite 的 host 校验**:用 `Host: 10.0.10.22` 请求该模块,返回 `200` + `Content-Type: text/javascript`,Vite 6 默认放行 IP 形式的 Host。
真实原因通常是 **dev server 正在重启或重新预构建依赖**(改代码触发、或外部机器首次访问触发 optimize),期间模块请求返回的是 HTML 兜底而非 JS,浏览器就报这个错。
已做的加固:
- 该 dev 面板改为异步加载失败时降级渲染为空,不再抛未捕获异常;
- `vite.config.ts` 增加 `server.warmup`,减少首次访问触发重优化的概率。
自查命令(在**跑 dev server 的那台机器**上执行):
```bash
curl -i http://127.0.0.1:5173/src/components/dev/DevBrokerSwitcher.vue
# 正常应为 200 且 Content-Type: text/javascript
# 若返回 200 但是 text/html,说明该服务跑的是 preview/dist 而不是 dev server
```
若另一台机器上跑的是 `vite preview` 或 nginx 托管的 `dist/`,`/src/...` 路径根本不存在,SPA 兜底返回 index.html,同样会报这个错 —— 这种情况属于预期,dev 面板不会出现在生产产物中。
### 4.4 登录失败现在有可读提示(已加)
原先 IdP 不可达时守卫直接跳 `/logged-out`,页面只显示「已退出登录」,看不出原因。
现在会带 `?reason=idp-unreachable`,登出页显示黄色提示条,写明「当前访问地址可能未在登录服务登记白名单」,并给出改用 localhost / 联系管理员的建议。
## 5. 快速排查清单
```bash
# 1. 确认当前 Origin 是否被登录服务放行(在被访问的那台机器上执行)
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Origin: http://10.0.10.22:5173" \
https://auth.anjibao.cn/.well-known/openid-configuration
# 200 = 已登记;403 = 未登记,走方案 A 或 B
# 2. 确认后端代理目标正确
curl -s http://127.0.0.1:5173/csf/api/c/dev/brokers | head -c 120
# 3. 确认 dev server 监听在 0.0.0.0(vite.config.ts 的 server.host)
# 启动日志应同时出现 Local 与 Network 两行地址
```
---
*最后更新:2026-11-02*
<!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
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<!-- 浏览器标签页图标 -->
<link rel="icon" type="image/png" href="/csfLogo.png" />
<!-- iOS 加到主屏幕时的图标(H5 建议带上) -->
<link rel="apple-touch-icon" href="/csfLogo.png" />
<title>CSF工作台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent, defineComponent } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import LoginBadge from '@/components/feature/LoginBadge.vue'
......@@ -8,8 +8,21 @@ const route = useRoute()
const { t } = useI18n()
// 生产构建中 import.meta.env.DEV 被替换为 false,死分支连同动态导入一起被 tree-shake
// dev 面板加载失败(vite 重启、依赖重优化、跨机器网络抖动)时降级渲染为空,
// 不再抛出「Failed to fetch dynamically imported module」的未捕获异常。
const DevFallback = defineComponent({ render: () => null })
const DevBrokerSwitcher = import.meta.env.DEV
? defineAsyncComponent(() => import('@/components/dev/DevBrokerSwitcher.vue'))
? defineAsyncComponent({
loader: () => import('@/components/dev/DevBrokerSwitcher.vue'),
timeout: 10_000,
errorComponent: DevFallback,
onError(err, _retry, fail) {
console.warn('[dev] 转介人切换面板加载失败,已跳过:', err)
// 必须调用 fail(),否则 promise 悬停,errorComponent 不会生效
fail()
}
})
: null
const tabs = [
......@@ -36,7 +49,7 @@ function isActive(path: string): boolean {
<div class="h-[3px] bg-brand-600"></div>
<header class="sticky top-0 z-20 border-b border-silver-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 text-brand-700">{{ t('app.title') }}</div>
<div class="text-[18px] font-semibold tracking-tight text-brand-700">{{ t('app.title') }}</div>
<LoginBadge />
</div>
</header>
......@@ -67,4 +80,4 @@ function isActive(path: string): boolean {
</div>
</nav>
</div>
</template>
\ No newline at end of file
</template>
......@@ -12,6 +12,12 @@ vi.mock('@/utils/oidc', () => ({
signoutRedirect: vi.fn()
}))
// 测试环境里 import.meta.env.DEV 同样为 true,App 会真的加载 dev 面板并发起请求,
// jsdom 无法连接后端地址,会产生 XHR 的 AggregateError 噪音。这里隔离掉。
vi.mock('@/components/dev/DevBrokerSwitcher.vue', () => ({
default: { render: () => null }
}))
import App from '../App.vue'
const i18n = createI18n({
......
......@@ -145,6 +145,7 @@ export interface PolicyCommissionYear {
status: CommissionStatus
/** 保留/失效原因(财务登记,可能为空) */
statusDesc?: string
stage?: string
}
export interface PipelineItem {
......
......@@ -17,23 +17,25 @@ const open = ref(false)
const keyword = ref('')
const selectedName = computed(
() => brokers.value.find((b) => b.brokerBizId === selectedBizId.value)?.brokerName ?? ''
() => brokers.value.find(b => b.brokerBizId === selectedBizId.value)?.brokerName ?? ''
)
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
const all = kw
? brokers.value.filter((b) => b.brokerName.toLowerCase().includes(kw))
? brokers.value.filter(b => b.brokerName.toLowerCase().includes(kw))
: brokers.value
// 后端已按 hasStatement 倒序返回,这里分组保持顺序
return {
withStatement: all.filter((b) => b.hasStatement),
rest: all.filter((b) => !b.hasStatement)
withStatement: all.filter(b => b.hasStatement),
rest: all.filter(b => !b.hasStatement)
}
})
onMounted(async () => {
try {
// 走 vite 代理(/csf/api)而不是硬编码后端地址:
// 硬编码时从局域网其他机器访问会绕过代理直连该地址,且后端 IP 一变就失效。
brokers.value = await get<BrokerOption[]>('/c/dev/brokers')
loaded.value = true
} catch (e) {
......@@ -72,7 +74,7 @@ function pick(broker: BrokerOption) {
type="text"
placeholder="搜索姓名…"
class="w-full border-b border-amber-200 px-2.5 py-2 text-xs outline-none placeholder:text-slate-400"
>
/>
<ul class="max-h-[50vh] overflow-y-auto py-1">
<li
class="cursor-pointer px-2.5 py-1.5 text-slate-600 hover:bg-amber-50"
......@@ -81,7 +83,10 @@ function pick(broker: BrokerOption) {
— 未选择 —
</li>
<li v-if="filtered.withStatement.length" class="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<li
v-if="filtered.withStatement.length"
class="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700"
>
有月结单 · {{ filtered.withStatement.length }}
</li>
<li
......@@ -94,7 +99,10 @@ function pick(broker: BrokerOption) {
{{ b.brokerName }}
</li>
<li v-if="filtered.rest.length" class="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
<li
v-if="filtered.rest.length"
class="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400"
>
其他有保单归属 · {{ filtered.rest.length }}
</li>
<li
......@@ -107,7 +115,12 @@ function pick(broker: BrokerOption) {
{{ b.brokerName }}
</li>
<li v-if="filtered.withStatement.length + filtered.rest.length === 0" class="px-2.5 py-2 text-slate-400">无匹配</li>
<li
v-if="filtered.withStatement.length + filtered.rest.length === 0"
class="px-2.5 py-2 text-slate-400"
>
无匹配
</li>
</ul>
</div>
......
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
......@@ -15,6 +15,18 @@ const mockOnly = import.meta.env.VITE_MOCK_ONLY === 'true'
const isHant = computed(() => preference.locale === 'zh-Hant')
// 登录/登出发起失败(IdP 不可达、origin 未登记白名单)时按钮转为「重试」,
// 避免点击后毫无反应、只有一个控制台里的 unhandled rejection
const failed = ref(false)
async function onLogin(): Promise<void> {
failed.value = !(await auth.login())
}
async function onLogout(): Promise<void> {
failed.value = !(await auth.logout())
}
function toggleLocale() {
preference.setLocale(isHant.value ? 'zh-Hans' : 'zh-Hant')
}
......@@ -36,15 +48,16 @@ function toggleLocale() {
<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()"
@click="onLogout"
>{{ t('auth.logout') }}</button>
</div>
<button
v-else-if="!mockOnly"
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()"
class="rounded-full px-4 py-1.5 text-sm font-medium text-white transition-colors"
:class="failed ? 'bg-red-500 hover:bg-red-600' : 'bg-brand-600 hover:bg-brand-700'"
@click="onLogin"
>
{{ t('auth.login') }}
{{ failed ? t('auth.retry') : t('auth.login') }}
</button>
</div>
</template>
\ No newline at end of file
......@@ -18,15 +18,24 @@ function statusClass(status: PipelineItem['status']): string {
</script>
<template>
<div class="rounded-2xl border border-silver-200 bg-white p-4 shadow-card">
<div
class="rounded-2xl border border-silver-200 bg-white p-4 shadow-card"
v-if="item.expectedAmount > 0"
>
<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 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 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>
......
......@@ -30,7 +30,9 @@ const statusTone = computed(() => (props.policy.status === 'LAPSED' ? 'neutral'
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium tracking-tight">{{ policy.policyNo }}</span>
<Badge v-if="showStatus" :tone="statusTone">{{ t(`policyStatus.${policy.status}`) }}</Badge>
<Badge v-if="showStatus" :tone="statusTone">{{
t(`policyStatus.${policy.status}`)
}}</Badge>
</div>
<div class="mt-1 truncate text-xs text-silver-600">{{ policy.productName || '—' }}</div>
<div v-if="policy.insurerName" class="mt-0.5 truncate text-xs text-silver-400">
......@@ -39,7 +41,7 @@ const statusTone = computed(() => (props.policy.status === 'LAPSED' ? 'neutral'
</div>
<div class="shrink-0 text-right">
<div class="tnum text-sm font-semibold">
{{ formatDetailMoney(policy.fullPremium, policy.currency) }}
总保费: {{ formatDetailMoney(policy.fullPremium, policy.currency) }}
</div>
<div class="mt-0.5 text-xs text-silver-400">
{{ t('policies.myRatio') }} {{ policy.myRatio ? formatPercent(policy.myRatio) : '—' }}
......
......@@ -4,7 +4,11 @@ import { useI18n } from 'vue-i18n'
import { formatDetailMoney, formatHkd } from '@/utils/money'
import { formatPercent } from '@/utils/number'
import { calcPremiumTotals } from '@/utils/premium'
import type { PaymentFrequency, StatementDetail as StatementDetailType, StatementItem } from '@/api/types'
import type {
PaymentFrequency,
StatementDetail as StatementDetailType,
StatementItem
} from '@/api/types'
const props = defineProps<{
detail: StatementDetailType
......@@ -81,10 +85,13 @@ function rateText(rate: number): string {
</div>
</div>
</div>
<p class="mt-2 text-[11px] text-silver-400">{{ t('statement.amountHint') }}</p>
<p class="mt-2 text-[12px] text-silver-400">{{ t('statement.amountHint') }}</p>
<!-- 减项:没值就不显示 -->
<dl v-if="hasMpf || hasAdjustment" class="mt-3 space-y-2 border-t border-silver-100 pt-3 text-sm">
<dl
v-if="hasMpf || hasAdjustment"
class="mt-3 space-y-2 border-t border-silver-100 pt-3 text-sm"
>
<div v-if="hasMpf">
<div class="flex items-center justify-between gap-3">
<dt class="text-silver-600">{{ t('statement.mpfItem') }}</dt>
......@@ -103,7 +110,10 @@ function rateText(rate: number): string {
</div>
</dl>
<div v-if="detail.remark" class="mt-3 rounded-xl bg-silver-50 px-3 py-2 text-xs text-silver-600">
<div
v-if="detail.remark"
class="mt-3 rounded-xl bg-silver-50 px-3 py-2 text-xs text-silver-600"
>
<span class="font-medium">{{ t('statement.remark') }}</span>
<p class="mt-1 whitespace-pre-line leading-relaxed">{{ detail.remark }}</p>
</div>
......@@ -165,7 +175,7 @@ function rateText(rate: number): string {
:key="item.id"
:class="index ? 'mt-3 border-t border-silver-100 pt-3' : ''"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start justify-between gap-3" v-if="item.originalAmount > 0">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ item.policyNo }}</div>
<div class="mt-0.5 truncate text-xs text-silver-500">
......@@ -173,10 +183,10 @@ function rateText(rate: number): string {
</div>
</div>
<div class="shrink-0 text-right">
<div class="tnum text-sm font-semibold">
<div class="tnum text-sm font-semibold">≈ {{ formatHkd(item.hkdAmount) }}</div>
<div class="tnum mt-0.5 text-xs text-silver-400">
{{ formatDetailMoney(item.originalAmount, item.originalCurrency) }}
</div>
<div class="tnum mt-0.5 text-xs text-silver-400">≈ {{ formatHkd(item.hkdAmount) }}</div>
</div>
</div>
......@@ -193,7 +203,9 @@ function rateText(rate: number): string {
</div>
<div class="flex gap-1.5">
<dt class="shrink-0 text-silver-500">{{ t('statement.ratioShare') }}</dt>
<dd class="text-silver-700">{{ item.myRatio ? formatPercent(item.myRatio) : '—' }}</dd>
<dd class="text-silver-700">
{{ item.myRatio ? formatPercent(item.myRatio) : '—' }}
</dd>
</div>
<div class="flex gap-1.5">
<dt class="shrink-0 text-silver-500">{{ t('statement.policyRate') }}</dt>
......@@ -203,6 +215,7 @@ function rateText(rate: number): string {
<dt class="shrink-0 text-silver-500">{{ t('statement.premiumTerm') }}</dt>
<dd class="tnum text-silver-700">{{ premiumText(item) }}</dd>
</div>
<div v-if="item.remark" class="col-span-2 flex gap-1.5">
<dt class="shrink-0 text-silver-500">{{ t('statement.itemRemark') }}</dt>
<dd class="text-silver-600">{{ item.remark }}</dd>
......
......@@ -25,14 +25,14 @@ const { t } = useI18n()
<div class="min-w-0">
<div class="flex items-baseline gap-2">
<span class="text-sm font-medium tracking-tight">{{ statement.month || '—' }}</span>
<span class="text-[11px] text-silver-400">{{ t('statement.salaryMonth') }}</span>
<span class="text-[14px] text-silver-400">{{ t('statement.salaryMonth') }}</span>
</div>
<div class="mt-1 truncate text-xs text-silver-500">
{{ t('statement.payDate') }} {{ statement.payDate || '—' }}
</div>
</div>
<div class="shrink-0 text-right">
<div class="text-[11px] text-silver-500">
<div class="text-[14px] text-silver-500">
{{ t('statement.gross') }}
<span class="tnum">{{ formatHkd(statement.grossAmount) }}</span>
</div>
......
......@@ -94,6 +94,7 @@ export default {
'policies.ryp': '续期',
'policies.ryc': '续期佣金',
'policies.year': '保单年度',
'policies.yugu': '预估',
'pipeline.payoutMonth': '预计出账月',
'pipeline.adjustNote': '保司来佣后发放,金额可能调整',
'pipeline.filterPolicy': '保单号',
......@@ -110,10 +111,14 @@ export default {
'auth.callbackDesc': '登录成功,正在跳转…',
'auth.callbackError': '登录失败或授权已失效,请重试。',
'auth.login': '登录',
'auth.retry': '重试',
'auth.logout': '退出',
'auth.loggedOutTitle': '已退出登录',
'auth.loggedOutDesc': '本门户登录态已清除,不影响其他系统。',
'auth.idpUnreachableTitle': '无法连接登录服务',
'auth.idpUnreachableDesc':
'常见原因:当前访问地址未在该登录服务登记白名单(例如用局域网 IP 打开)。请改用 localhost 访问,或联系管理员登记当前地址后重试。',
'lang.zhHant': '繁',
'lang.zhHans': '简',
'common.pullHint': '下拉即可重新整理'
}
\ No newline at end of file
}
......@@ -94,6 +94,7 @@ export default {
'policies.ryp': '續期',
'policies.ryc': '續期佣金',
'policies.year': '保單年度',
'policies.yugu': '預估',
'pipeline.payoutMonth': '預計出賬月',
'pipeline.adjustNote': '保司來佣後發放,金額可能調整',
'pipeline.filterPolicy': '保單號',
......@@ -110,10 +111,14 @@ export default {
'auth.callbackDesc': '登入成功,正在跳轉…',
'auth.callbackError': '登入失敗或授權已失效,請重試。',
'auth.login': '登入',
'auth.retry': '重試',
'auth.logout': '退出',
'auth.loggedOutTitle': '已登出',
'auth.loggedOutDesc': '本門戶登入態已清除,不影響其他系統。',
'auth.idpUnreachableTitle': '無法連接登入服務',
'auth.idpUnreachableDesc':
'常見原因:當前訪問地址未在該登入服務登記白名單(例如用局域網 IP 打開)。請改用 localhost 訪問,或聯絡管理員登記當前地址後重試。',
'lang.zhHant': '繁',
'lang.zhHans': '简',
'common.pullHint': '下拉即可重新整理'
}
\ No newline at end of file
}
......@@ -31,9 +31,11 @@ router.beforeEach(async (to) => {
try {
await signinRedirect(to.fullPath)
} catch {
// IdP 不可达时 signinRedirect 会 reject;不兜住会导致导航中断、整页空白
return { name: 'logged-out' }
} catch (e) {
// IdP 不可达 / 当前 origin 未登记(403)时 signinRedirect 会 reject;
// 不兜住会导致导航中断、整页空白。带上 reason 让登出页给出可读提示。
console.error('[auth] 无法发起 OIDC 授权:', e)
return { name: 'logged-out', query: { reason: 'idp-unreachable' } }
}
// 已发起整页跳转,终止本次导航
return false
......
......@@ -33,9 +33,19 @@ export const useAuthStore = defineStore('auth', () => {
user.value = toAuthUser(await userManager.getUser())
}
/** 发起 OIDC 登录;当前路径随 state 携带,回调后跳回。 */
function login(returnPath?: string): Promise<void> {
return signinRedirect(returnPath)
/**
* 发起 OIDC 登录;当前路径随 state 携带,回调后跳回。
* 返回 false 表示发起失败(如 IdP 不可达、当前 origin 未登记白名单返回 403)。
* 原先直接透传 promise,失败时是 unhandled rejection —— 界面上表现为「点了没反应」。
*/
async function login(returnPath?: string): Promise<boolean> {
try {
await signinRedirect(returnPath)
return true
} catch (e) {
console.error('[auth] 发起登录失败:', e)
return false
}
}
/** 处理授权码回调,装载登录态,返回登录前路径。 */
......@@ -45,9 +55,15 @@ export const useAuthStore = defineStore('auth', () => {
return returnPath
}
/** 登出:跳转 IdP 结束 SSO 会话,回 /logged-out。 */
function logout(): Promise<void> {
return signoutRedirect()
/** 登出:跳转 IdP 结束 SSO 会话,回 /logged-out。返回 false 表示 IdP 不可达。 */
async function logout(): Promise<boolean> {
try {
await signoutRedirect()
return true
} catch (e) {
console.error('[auth] 发起登出失败:', e)
return false
}
}
return { user, init, login, handleCallback, logout }
......
......@@ -20,12 +20,14 @@ const heroCard = computed(() =>
: null
)
const restCards = computed(() => {
type RestCard = { label: string; value: string; to?: string }
const restCards = computed<RestCard[]>(() => {
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.pipeline'), value: formatHkd(metrics.value.pipelineHkd), to: '/pipeline' },
{ label: t('metric.inforceCount'), value: String(metrics.value.inforceCount), to: '/policies' },
{ label: t('metric.firstYearFyp'), value: formatHkd(metrics.value.firstYearFypHkd) }
]
})
......@@ -63,9 +65,19 @@ onMounted(load)
<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" />
<!-- <MetricCard
v-for="card in restCards"
:key="card.label"
:label="card.label"
:value="card.value"
/> -->
<template v-for="card in restCards" :key="card.label">
<router-link v-if="card.to" :to="card.to" class="block">
<MetricCard :label="card.label" :value="card.value" />
</router-link>
<MetricCard v-else :label="card.label" :value="card.value" />
</template>
</div>
<router-link
v-if="metrics"
to="/customers"
......@@ -73,37 +85,58 @@ onMounted(load)
>
<div>
<div class="text-xs text-slate-500">{{ t('metric.myCustomers') }}</div>
<div class="tnum mt-2 text-xl font-semibold tracking-tight text-slate-900">{{ metrics.caseCount }}</div>
<div class="tnum mt-2 text-xl font-semibold tracking-tight text-slate-900">
{{ metrics.caseCount }}
</div>
</div>
<div class="flex items-center gap-1 text-xs text-slate-400">
{{ t('customer.followingHint', { count: metrics.followingCount }) }}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</router-link>
<section class="mt-7">
<!-- <section class="mt-7">
<div class="mb-3 flex items-center gap-2">
<h2 class="text-[13px] font-semibold tracking-wide text-silver-600">{{ t('dashboard.actions') }}</h2>
<h2 class="text-[13px] font-semibold tracking-wide text-silver-600">
{{ t('dashboard.actions') }}
</h2>
<span
v-if="todoDataSource === 'mock'"
class="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700"
>{{ t('todo.sample') }}</span>
>{{ t('todo.sample') }}</span
>
</div>
<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>
<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">
1111
<TodoCard v-for="todo in todos" :key="todo.id" :todo="todo" />
<div class="rounded-2xl border border-dashed border-silver-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>
</section> -->
</ApiLoader>
</div>
</template>
\ No newline at end of file
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const auth = useAuthStore()
const route = useRoute()
/** 登录失败回跳时带的 reason,用于在页面上给出可操作的排查方向 */
const unreachable = computed(() => route.query.reason === 'idp-unreachable')
// 点「登录」再次发起失败时(IdP 仍不可达)也要能看出原因
const loginFailed = ref(false)
const showHint = computed(() => unreachable.value || loginFailed.value)
async function onLogin(): Promise<void> {
loginFailed.value = !(await auth.login('/'))
}
</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>
<div
v-if="showHint"
class="mx-auto mt-5 max-w-sm rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-left"
>
<p class="text-sm font-medium text-amber-800">{{ t('auth.idpUnreachableTitle') }}</p>
<p class="mt-1 text-xs leading-relaxed text-amber-700">
{{ t('auth.idpUnreachableDesc') }}
</p>
</div>
<button
class="mt-4 rounded-lg bg-brand-600 px-4 py-2 text-sm text-white"
@click="auth.login('/')"
class="mt-4 rounded-lg px-4 py-2 text-sm text-white transition-colors"
:class="loginFailed ? 'bg-red-500 hover:bg-red-600' : 'bg-brand-600 hover:bg-brand-700'"
@click="onLogin"
>
{{ t('auth.login') }}
{{ loginFailed ? t('auth.retry') : t('auth.login') }}
</button>
</div>
</template>
......@@ -42,7 +42,11 @@ onMounted(load)
<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>
<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>
<div class="grid grid-cols-2 gap-3">
<label class="block">
......@@ -51,26 +55,58 @@ onMounted(load)
v-model="policyQuery"
type="search"
:placeholder="t('pipeline.filterPolicyPlaceholder')"
class="w-full rounded-xl border border-silver-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition-colors placeholder:text-silver-400 focus:border-brand-200"
>
class="w-full rounded-xl border border-silver-200 bg-white px-3 py-2 text-base text-slate-900 outline-none transition-colors placeholder:text-silver-400 focus:border-brand-200"
/>
</label>
<label class="block">
<!-- <label class="block">
<span class="mb-1 block text-xs text-slate-500">{{ t('pipeline.filterMonth') }}</span>
<select
v-model="selectedMonth"
class="w-full rounded-xl border border-silver-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition-colors focus:border-brand-200"
class="w-full rounded-xl border border-silver-200 bg-white px-3 py-2 text-slate-900 outline-none transition-colors focus:border-brand-200"
>
<option value="">{{ t('pipeline.allMonths') }}</option>
<option v-for="month in months" :key="month" :value="month">{{ month }}</option>
</select>
</label> -->
<label class="block">
<span class="mb-1 block text-xs text-slate-500">{{ t('pipeline.filterMonth') }}</span>
<div class="relative">
<select
v-model="selectedMonth"
class="w-full appearance-none rounded-xl border border-silver-200 bg-white px-3 py-2 pr-10 text-base text-slate-900 outline-none transition-colors focus:border-brand-200"
>
<option value="">{{ t('pipeline.allMonths') }}</option>
<option v-for="month in months" :key="month" :value="month">{{ month }}</option>
</select>
<!-- 自定义箭头:right-3 就是箭头离右边框的间隔,想大改这里 -->
<svg
class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-silver-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</label>
</div>
<ApiLoader :loading="loading" :error="error" :empty="!loading && !error && items.length === 0" @retry="load">
<ApiLoader
:loading="loading"
:error="error"
:empty="!loading && !error && items.length === 0"
@retry="load"
>
<div v-if="filtered.length" class="space-y-3">
<PipelineCard v-for="item in filtered" :key="item.id" :item="item" />
</div>
<div v-else class="py-10 text-center text-sm text-slate-400">{{ t('pipeline.noResult') }}</div>
<div v-else class="py-10 text-center text-sm text-slate-400">
{{ t('pipeline.noResult') }}
</div>
</ApiLoader>
</div>
</template>
......@@ -18,8 +18,8 @@ const modalError = ref<string | null>(null)
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'))
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
......@@ -59,19 +59,38 @@ onMounted(load)
</script>
<template>
<ApiLoader :loading="loading" :error="error" :empty="!loading && !error && policies.length === 0" @retry="load">
<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-silver-600">{{ t('policies.inforce') }}</h2>
<h2 class="mb-3 text-[13px] font-semibold tracking-wide text-silver-600">
{{ t('policies.inforce') }}
</h2>
<div class="space-y-3">
<PolicyCard v-for="policy in inforce" :key="policy.policyNo" :policy="policy" @select="openCommissions" />
<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-silver-600">{{ t('policies.inactive') }}</h2>
<h2 class="mb-3 text-[13px] font-semibold tracking-wide text-silver-600">
{{ t('policies.inactive') }}
</h2>
<div class="space-y-3">
<PolicyCard v-for="policy in inactive" :key="policy.policyNo" :policy="policy" @select="openCommissions" />
<PolicyCard
v-for="policy in inactive"
:key="policy.policyNo"
:policy="policy"
@select="openCommissions"
/>
</div>
</section>
</div>
......@@ -90,10 +109,18 @@ onMounted(load)
:key="`${item.year}-${item.status}-${index}`"
class="flex items-center justify-between gap-3 text-sm"
>
<span>{{ item.year }} · {{ item.type }}</span>
<span
>{{ item.year }} · {{ item.type }}
<span v-if="item.status == 'PIPELINE'" class="text-brand-700"
>({{ t('policies.yugu') }})</span
>
</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 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>
......
......@@ -47,6 +47,11 @@ export default {
borderRadius: {
xl: '0.75rem',
'2xl': '1rem'
},
fontSize: {
xs: ['.9375rem', { lineHeight: '1.25rem' }], // 13px + 20px 行高
sm: ['1.125rem'],
lg: ['1.25rem', { lineHeight: '1.25rem' }] // 13px + 20px 行高
}
}
},
......
......@@ -3,9 +3,21 @@ import vue from '@vitejs/plugin-vue'
import {fileURLToPath, URL} from 'node:url'
export default defineConfig(({ mode }) => {
// 第三个参数传 '' 表示不限制 VITE_ 前缀,VITE_API_TARGET 也一并读取
// 第三个参数是「变量前缀」,必须传 '' 表示不限制前缀。
// 曾误传成后端地址,导致 loadEnv 只匹配以该地址开头的 key —— VITE_* 一个都读不到(实测 0 个),
// VITE_API_TARGET 恒为 undefined,本地开发被静默代理到正式域名。
const env = loadEnv(mode, '.', '')
// 未显式配置时给出醒目警告:默认目标指向正式域名,本地开发误连会有写库风险
const apiTarget = env.VITE_API_TARGET || 'https://center.zuihuibi.cn'
if (!env.VITE_API_TARGET) {
// 用 stdout 直接写:vite 加载配置期间 console 可能被日志器接管,警告会静默丢失
process.stdout.write(
'\n[vite] 警告:未设置 VITE_API_TARGET,/csf/api 将代理到正式域名 https://center.zuihuibi.cn\n' +
' 本地开发请在 .env 中设置为本机后端,例如 VITE_API_TARGET=http://localhost:9202\n\n'
)
}
return {
plugins: [vue()],
resolve: {
......@@ -17,12 +29,20 @@ export default defineConfig(({ mode }) => {
proxy: {
// 后端 context-path 即 /csf/api,代理时不 rewrite
'/csf/api': {
// 原先硬编码测试环境域名,本地一跑 npm run dev 就直接打测试库,容易误操作数据。
// 需要连测试环境时在 .env.local 里显式设置 VITE_API_TARGET。
target: env.VITE_API_TARGET || 'https://center.zuihuibi.cn',
// 需要连测试/正式环境时在 .env 里显式设置 VITE_API_TARGET,避免本地开发误写远端库数据。
target: apiTarget,
changeOrigin: true,
secure: false
}
},
// 允许局域网其他机器通过本机 IP 访问(如 http://10.0.10.22:5173)。
// Vite 默认放行 localhost 与 IP 形式的 Host;若改用内网域名访问,需显式配置
// allowedHosts: ['your.intranet.host'](不要设 true,会关闭 host 校验带来 DNS rebinding 风险)。
host: '0.0.0.0',
port: 5173,
// 局域网联调时从其他机器首次打开可能触发依赖重优化,预热后更稳
warmup: {
clientFiles: ['./src/main.ts', './src/App.vue']
}
},
build: {
......
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