Commit ebee16ee by yuzhenWang

优化,合并代码解决冲突

parents ce3e433a a7ad962a
# 复制为 .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> <!doctype html>
<html lang="zh-Hant"> <html lang="zh-Hant">
<head>
<meta charset="UTF-8" /> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" /> <meta charset="UTF-8" />
<title>转介人工作台</title> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
</head> <!-- 浏览器标签页图标 -->
<body> <link rel="icon" type="image/png" href="/csfLogo.png" />
<div id="app"></div> <!-- iOS 加到主屏幕时的图标(H5 建议带上) -->
<script type="module" src="/src/main.ts"></script> <link rel="apple-touch-icon" href="/csfLogo.png" />
</body> <title>CSF工作台</title>
</html> </head>
\ No newline at end of file
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue' import { computed, defineAsyncComponent, defineComponent } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import LoginBadge from '@/components/feature/LoginBadge.vue' import LoginBadge from '@/components/feature/LoginBadge.vue'
...@@ -8,8 +8,21 @@ const route = useRoute() ...@@ -8,8 +8,21 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
// 生产构建中 import.meta.env.DEV 被替换为 false,死分支连同动态导入一起被 tree-shake // 生产构建中 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 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 : null
const tabs = [ const tabs = [
......
...@@ -12,6 +12,12 @@ vi.mock('@/utils/oidc', () => ({ ...@@ -12,6 +12,12 @@ vi.mock('@/utils/oidc', () => ({
signoutRedirect: vi.fn() 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' import App from '../App.vue'
const i18n = createI18n({ const i18n = createI18n({
......
...@@ -33,12 +33,14 @@ const filtered = computed(() => { ...@@ -33,12 +33,14 @@ const filtered = computed(() => {
}) })
onMounted(async () => { onMounted(async () => {
// try { try {
// brokers.value = await get<BrokerOption[]>('') // 走 vite 代理(/csf/api)而不是硬编码后端地址:
// loaded.value = true // 硬编码时从局域网其他机器访问会绕过代理直连该地址,且后端 IP 一变就失效。
// } catch (e) { brokers.value = await get<BrokerOption[]>('/c/dev/brokers')
// error.value = e instanceof Error ? e.message : 'error' loaded.value = true
// } } catch (e) {
error.value = e instanceof Error ? e.message : 'error'
}
}) })
function toggle() { function toggle() {
......
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
...@@ -15,6 +15,18 @@ const mockOnly = import.meta.env.VITE_MOCK_ONLY === 'true' ...@@ -15,6 +15,18 @@ const mockOnly = import.meta.env.VITE_MOCK_ONLY === 'true'
const isHant = computed(() => preference.locale === 'zh-Hant') 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() { function toggleLocale() {
preference.setLocale(isHant.value ? 'zh-Hans' : 'zh-Hant') preference.setLocale(isHant.value ? 'zh-Hans' : 'zh-Hant')
} }
...@@ -36,15 +48,16 @@ function toggleLocale() { ...@@ -36,15 +48,16 @@ function toggleLocale() {
<span class="max-w-[7rem] truncate text-sm font-medium">{{ user.name }}</span> <span class="max-w-[7rem] truncate text-sm font-medium">{{ user.name }}</span>
<button <button
class="shrink-0 text-xs text-slate-400 transition-colors hover:text-slate-600" class="shrink-0 text-xs text-slate-400 transition-colors hover:text-slate-600"
@click="auth.logout()" @click="onLogout"
>{{ t('auth.logout') }}</button> >{{ t('auth.logout') }}</button>
</div> </div>
<button <button
v-else-if="!mockOnly" 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" class="rounded-full px-4 py-1.5 text-sm font-medium text-white transition-colors"
@click="auth.login()" :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> </button>
</div> </div>
</template> </template>
\ No newline at end of file
...@@ -94,6 +94,7 @@ export default { ...@@ -94,6 +94,7 @@ export default {
'policies.ryp': '续期', 'policies.ryp': '续期',
'policies.ryc': '续期佣金', 'policies.ryc': '续期佣金',
'policies.year': '保单年度', 'policies.year': '保单年度',
'policies.yugu': '预估',
'pipeline.payoutMonth': '预计出账月', 'pipeline.payoutMonth': '预计出账月',
'pipeline.adjustNote': '保司来佣后发放,金额可能调整', 'pipeline.adjustNote': '保司来佣后发放,金额可能调整',
'pipeline.filterPolicy': '保单号', 'pipeline.filterPolicy': '保单号',
...@@ -110,10 +111,14 @@ export default { ...@@ -110,10 +111,14 @@ export default {
'auth.callbackDesc': '登录成功,正在跳转…', 'auth.callbackDesc': '登录成功,正在跳转…',
'auth.callbackError': '登录失败或授权已失效,请重试。', 'auth.callbackError': '登录失败或授权已失效,请重试。',
'auth.login': '登录', 'auth.login': '登录',
'auth.retry': '重试',
'auth.logout': '退出', 'auth.logout': '退出',
'auth.loggedOutTitle': '已退出登录', 'auth.loggedOutTitle': '已退出登录',
'auth.loggedOutDesc': '本门户登录态已清除,不影响其他系统。', 'auth.loggedOutDesc': '本门户登录态已清除,不影响其他系统。',
'auth.idpUnreachableTitle': '无法连接登录服务',
'auth.idpUnreachableDesc':
'常见原因:当前访问地址未在该登录服务登记白名单(例如用局域网 IP 打开)。请改用 localhost 访问,或联系管理员登记当前地址后重试。',
'lang.zhHant': '繁', 'lang.zhHant': '繁',
'lang.zhHans': '简', 'lang.zhHans': '简',
'common.pullHint': '下拉即可重新整理' 'common.pullHint': '下拉即可重新整理'
} }
\ No newline at end of file
...@@ -94,6 +94,7 @@ export default { ...@@ -94,6 +94,7 @@ export default {
'policies.ryp': '續期', 'policies.ryp': '續期',
'policies.ryc': '續期佣金', 'policies.ryc': '續期佣金',
'policies.year': '保單年度', 'policies.year': '保單年度',
'policies.yugu': '預估',
'pipeline.payoutMonth': '預計出賬月', 'pipeline.payoutMonth': '預計出賬月',
'pipeline.adjustNote': '保司來佣後發放,金額可能調整', 'pipeline.adjustNote': '保司來佣後發放,金額可能調整',
'pipeline.filterPolicy': '保單號', 'pipeline.filterPolicy': '保單號',
...@@ -110,10 +111,14 @@ export default { ...@@ -110,10 +111,14 @@ export default {
'auth.callbackDesc': '登入成功,正在跳轉…', 'auth.callbackDesc': '登入成功,正在跳轉…',
'auth.callbackError': '登入失敗或授權已失效,請重試。', 'auth.callbackError': '登入失敗或授權已失效,請重試。',
'auth.login': '登入', 'auth.login': '登入',
'auth.retry': '重試',
'auth.logout': '退出', 'auth.logout': '退出',
'auth.loggedOutTitle': '已登出', 'auth.loggedOutTitle': '已登出',
'auth.loggedOutDesc': '本門戶登入態已清除,不影響其他系統。', 'auth.loggedOutDesc': '本門戶登入態已清除,不影響其他系統。',
'auth.idpUnreachableTitle': '無法連接登入服務',
'auth.idpUnreachableDesc':
'常見原因:當前訪問地址未在該登入服務登記白名單(例如用局域網 IP 打開)。請改用 localhost 訪問,或聯絡管理員登記當前地址後重試。',
'lang.zhHant': '繁', 'lang.zhHant': '繁',
'lang.zhHans': '简', 'lang.zhHans': '简',
'common.pullHint': '下拉即可重新整理' 'common.pullHint': '下拉即可重新整理'
} }
\ No newline at end of file
...@@ -31,9 +31,11 @@ router.beforeEach(async (to) => { ...@@ -31,9 +31,11 @@ router.beforeEach(async (to) => {
try { try {
await signinRedirect(to.fullPath) await signinRedirect(to.fullPath)
} catch { } catch (e) {
// IdP 不可达时 signinRedirect 会 reject;不兜住会导致导航中断、整页空白 // IdP 不可达 / 当前 origin 未登记(403)时 signinRedirect 会 reject;
return { name: 'logged-out' } // 不兜住会导致导航中断、整页空白。带上 reason 让登出页给出可读提示。
console.error('[auth] 无法发起 OIDC 授权:', e)
return { name: 'logged-out', query: { reason: 'idp-unreachable' } }
} }
// 已发起整页跳转,终止本次导航 // 已发起整页跳转,终止本次导航
return false return false
......
...@@ -33,9 +33,19 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -33,9 +33,19 @@ export const useAuthStore = defineStore('auth', () => {
user.value = toAuthUser(await userManager.getUser()) user.value = toAuthUser(await userManager.getUser())
} }
/** 发起 OIDC 登录;当前路径随 state 携带,回调后跳回。 */ /**
function login(returnPath?: string): Promise<void> { * 发起 OIDC 登录;当前路径随 state 携带,回调后跳回。
return signinRedirect(returnPath) * 返回 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', () => { ...@@ -45,9 +55,15 @@ export const useAuthStore = defineStore('auth', () => {
return returnPath return returnPath
} }
/** 登出:跳转 IdP 结束 SSO 会话,回 /logged-out。 */ /** 登出:跳转 IdP 结束 SSO 会话,回 /logged-out。返回 false 表示 IdP 不可达。 */
function logout(): Promise<void> { async function logout(): Promise<boolean> {
return signoutRedirect() try {
await signoutRedirect()
return true
} catch (e) {
console.error('[auth] 发起登出失败:', e)
return false
}
} }
return { user, init, login, handleCallback, logout } return { user, init, login, handleCallback, logout }
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const { t } = useI18n() const { t } = useI18n()
const auth = useAuthStore() 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> </script>
<template> <template>
<div class="py-16 text-center"> <div class="py-16 text-center">
<h1 class="text-lg font-semibold">{{ t('auth.loggedOutTitle') }}</h1> <h1 class="text-lg font-semibold">{{ t('auth.loggedOutTitle') }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ t('auth.loggedOutDesc') }}</p> <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 <button
class="mt-4 rounded-lg bg-brand-600 px-4 py-2 text-sm text-white" class="mt-4 rounded-lg px-4 py-2 text-sm text-white transition-colors"
@click="auth.login('/')" :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> </button>
</div> </div>
</template> </template>
...@@ -111,7 +111,9 @@ onMounted(load) ...@@ -111,7 +111,9 @@ onMounted(load)
> >
<span <span
>{{ item.year }} · {{ item.type }} >{{ item.year }} · {{ item.type }}
<span v-if="item.status == 'PIPELINE'">(预估)</span> <span v-if="item.status == 'PIPELINE'" class="text-brand-700"
>({{ t('policies.yugu') }})</span
>
</span> </span>
<span class="text-right"> <span class="text-right">
<span class="tnum">{{ formatDetailMoney(item.amount, item.currency) }}</span> · <span class="tnum">{{ formatDetailMoney(item.amount, item.currency) }}</span> ·
......
...@@ -3,9 +3,21 @@ import vue from '@vitejs/plugin-vue' ...@@ -3,9 +3,21 @@ import vue from '@vitejs/plugin-vue'
import {fileURLToPath, URL} from 'node:url' import {fileURLToPath, URL} from 'node:url'
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
// 第三个参数传 '' 表示不限制 VITE_ 前缀,VITE_API_TARGET 也一并读取 // 第三个参数是「变量前缀」,必须传 '' 表示不限制前缀。
// 曾误传成后端地址,导致 loadEnv 只匹配以该地址开头的 key —— VITE_* 一个都读不到(实测 0 个),
// VITE_API_TARGET 恒为 undefined,本地开发被静默代理到正式域名。
const env = loadEnv(mode, '.', '') 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 { return {
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
...@@ -17,12 +29,20 @@ export default defineConfig(({ mode }) => { ...@@ -17,12 +29,20 @@ export default defineConfig(({ mode }) => {
proxy: { proxy: {
// 后端 context-path 即 /csf/api,代理时不 rewrite // 后端 context-path 即 /csf/api,代理时不 rewrite
'/csf/api': { '/csf/api': {
// 原先硬编码测试环境域名,本地一跑 npm run dev 就直接打测试库,容易误操作数据。 // 需要连测试/正式环境时在 .env 里显式设置 VITE_API_TARGET,避免本地开发误写远端库数据。
// 需要连测试环境时在 .env.local 里显式设置 VITE_API_TARGET。 target: apiTarget,
target: env.VITE_API_TARGET || 'https://center.zuihuibi.cn',
changeOrigin: true, changeOrigin: true,
secure: false 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: { 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