Commit 0cac32e9 by jianan

feat(核对环境): mock-only 本地核对模式 + DEV 转介人选人器

本地生产数据核对环境(vite --host 供同事经办公网访问):

- VITE_MOCK_ONLY=true 时整条 OIDC 链路停用:路由守卫放行、
  auth store 跳过登录态装载、请求不附 token、401 不跳登录、
  LoginBadge 隐藏——零外联 Casdoor
- DevBrokerSwitcher:左下角悬浮选人器,搜索/分组(有月结单置顶),
  经 X-Mock-Broker-Biz-Id 请求头切换身份;切人强制回首页避免
  旧详情页触发归属校验
- 仅 import.meta.env.DEV 引用(defineAsyncComponent 动态导入),
  生产构建 tree-shake 零残留
- TodoCard「通知」按钮 DEV 下隐藏(核对库只读,写入必被拒)
parent 6c1d2f6d
<script setup lang="ts">
import { computed } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import LoginBadge from '@/components/feature/LoginBadge.vue'
......@@ -7,6 +7,11 @@ import LoginBadge from '@/components/feature/LoginBadge.vue'
const route = useRoute()
const { t } = useI18n()
// 生产构建中 import.meta.env.DEV 被替换为 false,死分支连同动态导入一起被 tree-shake
const DevBrokerSwitcher = import.meta.env.DEV
? defineAsyncComponent(() => import('@/components/dev/DevBrokerSwitcher.vue'))
: null
const tabs = [
{ name: 'home', path: '/', labelKey: 'nav.home' },
{ name: 'statement', path: '/statement', labelKey: 'nav.statement' },
......@@ -31,6 +36,8 @@ const hideTabBar = computed(() => route.path === '/auth/callback')
<RouterView />
</main>
<DevBrokerSwitcher v-if="DevBrokerSwitcher" />
<nav
v-if="!hideTabBar"
class="fixed inset-x-0 bottom-0 z-20 border-t border-silver-200/70 bg-white/90 pb-safe backdrop-blur-lg"
......
......@@ -3,6 +3,7 @@
// 仅 Spring Security 层未认证会返回真实 401。
import axios from 'axios'
import { userManager, signinRedirect } from '@/utils/oidc'
import { getMockBrokerBizId, isMockOnly } from '@/utils/devMock'
interface ApiResult<T> {
code: number
......@@ -10,9 +11,23 @@ interface ApiResult<T> {
data: T
}
const HEADER_MOCK_BROKER = 'X-Mock-Broker-Biz-Id'
// mock-only:本地生产核对环境(.env.development.local),整条 OIDC 链路停用
const mockOnly = isMockOnly
const instance = axios.create({ baseURL: '/csf/api' })
instance.interceptors.request.use(async (config) => {
// DEV 模拟身份优先于 OIDC(仅在 dev 构建中可能有值)
const mockBrokerBizId = import.meta.env.DEV ? getMockBrokerBizId() : ''
if (mockBrokerBizId) {
config.headers[HEADER_MOCK_BROKER] = mockBrokerBizId
return config
}
if (mockOnly) {
return config
}
const user = await userManager.getUser()
if (user && !user.expired) {
config.headers.Authorization = `Bearer ${user.access_token}`
......@@ -26,7 +41,9 @@ instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
if (!redirecting && window.location.pathname !== '/auth/callback') {
// mock-only / 已选模拟身份时 401 交给页面处理,不跳 OIDC
const mockActive = mockOnly || (import.meta.env.DEV && !!getMockBrokerBizId())
if (!mockActive && !redirecting && window.location.pathname !== '/auth/callback') {
redirecting = true
void signinRedirect(window.location.pathname + window.location.search)
}
......
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { get } from '@/api/http'
import { getMockBrokerBizId, setMockBrokerBizId } from '@/utils/devMock'
interface BrokerOption {
brokerBizId: string
brokerName: string
hasStatement: boolean
}
const brokers = ref<BrokerOption[]>([])
const selectedBizId = ref(getMockBrokerBizId())
const loaded = ref(false)
const error = ref('')
const open = ref(false)
const keyword = ref('')
const selectedName = computed(
() => 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
// 后端已按 hasStatement 倒序返回,这里分组保持顺序
return {
withStatement: all.filter((b) => b.hasStatement),
rest: all.filter((b) => !b.hasStatement)
}
})
onMounted(async () => {
try {
brokers.value = await get<BrokerOption[]>('/c/dev/brokers')
loaded.value = true
} catch (e) {
error.value = e instanceof Error ? e.message : 'error'
}
})
function toggle() {
open.value = !open.value
keyword.value = ''
}
function pick(broker: BrokerOption) {
open.value = false
if (broker.brokerBizId === selectedBizId.value) return
selectedBizId.value = broker.brokerBizId
setMockBrokerBizId(broker.brokerBizId)
// 回首页再以新身份加载:避免停留在上一人的详情/列表 URL 触发归属校验
if (window.location.pathname === '/') {
window.location.reload()
} else {
window.location.href = '/'
}
}
</script>
<template>
<div class="fixed bottom-20 left-3 z-30 w-56 text-xs">
<!-- 下拉面板:向上展开,自带滚动,不被 tab 栏遮挡 -->
<div
v-if="open"
class="absolute bottom-12 left-0 w-full overflow-hidden rounded-xl border border-amber-300 bg-white shadow-lg"
>
<input
v-model="keyword"
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"
@click="pick({ brokerBizId: '', brokerName: '', hasStatement: false })"
>
— 未选择 —
</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">
有月结单 · {{ filtered.withStatement.length }}
</li>
<li
v-for="b in filtered.withStatement"
:key="b.brokerBizId"
class="cursor-pointer px-2.5 py-1.5 hover:bg-amber-50"
:class="b.brokerBizId === selectedBizId ? 'font-medium text-amber-800' : 'text-slate-700'"
@click="pick(b)"
>
{{ 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">
其他有保单归属 · {{ filtered.rest.length }}
</li>
<li
v-for="b in filtered.rest"
:key="b.brokerBizId"
class="cursor-pointer px-2.5 py-1.5 hover:bg-amber-50"
:class="b.brokerBizId === selectedBizId ? 'font-medium text-amber-800' : 'text-slate-700'"
@click="pick(b)"
>
{{ b.brokerName }}
</li>
<li v-if="filtered.withStatement.length + filtered.rest.length === 0" class="px-2.5 py-2 text-slate-400">无匹配</li>
</ul>
</div>
<button
class="flex w-full items-center justify-between rounded-xl border border-amber-300 bg-amber-50/95 px-2.5 py-2 font-medium text-amber-800 shadow-lg backdrop-blur"
@click="toggle"
>
<span>DEV · {{ selectedName || '模拟转介人' }}</span>
<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-3.5 w-3.5 transition-transform"
:class="open ? 'rotate-180' : ''"
>
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<div v-if="!loaded && !error" class="mt-1 text-amber-700">加载中…</div>
<div v-if="error" class="mt-1 text-red-600">{{ error }}</div>
</div>
</template>
......@@ -10,6 +10,9 @@ const preference = usePreferenceStore()
const { user } = storeToRefs(auth)
const { t } = useI18n()
// mock-only 核对环境不显示登录/用户区(语言切换保留)
const mockOnly = import.meta.env.VITE_MOCK_ONLY === 'true'
const isHant = computed(() => preference.locale === 'zh-Hant')
function toggleLocale() {
......@@ -26,7 +29,7 @@ function toggleLocale() {
{{ isHant ? t('lang.zhHans') : t('lang.zhHant') }}
</button>
<div v-if="user" class="flex items-center gap-2">
<div v-if="user && !mockOnly" 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>
......@@ -37,7 +40,7 @@ function toggleLocale() {
>{{ t('auth.logout') }}</button>
</div>
<button
v-else
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()"
>
......
......@@ -18,6 +18,9 @@ const typeLabel = computed(() => {
return t(map[props.todo.type])
})
// DEV 核对环境下隐藏假的「标记完成」按钮(仅本地状态、不落库,避免干扰核对)
const isDev = import.meta.env.DEV
const done = ref(false)
const doneDate = ref('')
......@@ -39,6 +42,7 @@ function markDone() {
<div class="mt-1 text-xs text-slate-500">{{ todo.customerName }} · {{ todo.policyNo }}</div>
</div>
<button
v-if="!isDev"
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"
......
/// <reference types="vite/client" />
\ No newline at end of file
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** mock-only 模式(本地生产数据核对):禁用 OIDC 跳转与 Bearer 附加 */
readonly VITE_MOCK_ONLY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
\ No newline at end of file
import { createRouter, createWebHistory } from 'vue-router'
import { userManager, signinRedirect } from '@/utils/oidc'
import { isMockOnly } from '@/utils/devMock'
const router = createRouter({
history: createWebHistory(),
......@@ -16,7 +17,9 @@ const router = createRouter({
})
// 认证守卫:除回调/已退出页外,无有效登录态即发起 OIDC 授权(当前路径随授权 state 携带,回跳后还原)。
// mock-only(本地生产核对)下整条 OIDC 链路停用,守卫直接放行。
router.beforeEach(async (to) => {
if (isMockOnly) return true
if (to.name === 'auth-callback' || to.name === 'logged-out') return true
const user = await userManager.getUser()
if (!user || user.expired) {
......
......@@ -7,6 +7,7 @@ import {
handleRedirectCallback,
signoutRedirect
} from '@/utils/oidc'
import { isMockOnly } from '@/utils/devMock'
export interface AuthUser {
name: string
......@@ -26,6 +27,8 @@ export const useAuthStore = defineStore('auth', () => {
/** 应用启动时从内存登录态同步到 store。 */
async function init(): Promise<void> {
// mock-only(本地生产核对):OIDC 链路停用,跳过登录态装载
if (isMockOnly) return
user.value = toAuthUser(await userManager.getUser())
}
......
// DEV 专用:模拟转介人身份。仅被 import.meta.env.DEV 守卫的代码引用,生产构建不打包。
const STORAGE_KEY = 'dev.mockBrokerBizId'
/** mock-only:本地生产核对环境(.env.development.local),整条 OIDC 链路停用。 */
export const isMockOnly = import.meta.env.VITE_MOCK_ONLY === 'true'
export function getMockBrokerBizId(): string {
return localStorage.getItem(STORAGE_KEY) ?? ''
}
export function setMockBrokerBizId(brokerBizId: string): void {
if (brokerBizId) {
localStorage.setItem(STORAGE_KEY, brokerBizId)
} else {
localStorage.removeItem(STORAGE_KEY)
}
}
import { defineConfig } from 'vite'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
import {fileURLToPath, URL} from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
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
}
}
}
},
server: {
proxy: {
// 后端 context-path 即 /csf/api,代理时不 rewrite
'/csf/api': {
target: 'http://center.zuihuibi.cn',
changeOrigin: true,
secure: false,
}
}
}
})
\ 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