diff --git a/backend/src/api/reimburse.js b/backend/src/api/reimburse.js index 8ac4c74..e31f2f2 100644 --- a/backend/src/api/reimburse.js +++ b/backend/src/api/reimburse.js @@ -70,6 +70,16 @@ export function updateReimbursementStatus(id, data) { }) } +// 可挂靠发票:只返回「当前报销单」内上传、票面金额已维护且仍有余额的发票 +// reimbursement_id 必填;exclude_item_id 用于编辑明细时排除它自身的占用 +export function getAvailableInvoices(params) { + return request({ + url: '/backend/reimbursements/invoices/available', + method: 'get', + params + }) +} + export function getReimbursementRecords(id) { return request({ url: `/backend/reimbursements/${id}/records`, diff --git a/backend/src/main.js b/backend/src/main.js index e66b4b9..c4cf6b2 100644 --- a/backend/src/main.js +++ b/backend/src/main.js @@ -14,7 +14,7 @@ import router from './router' import { loadAndAddDynamicRoutes } from './router' import { createPinia } from 'pinia' import { useAuthStore } from './stores/auth' -import { redirectToAuthorize } from '@/utils/authClient' +import { redirectToAuthorize, isAuthHandoff } from '@/utils/authClient' // import { initTheme } from './utils/theme' // 导入全局组件 import UmoEditor from '@/views/components/UmoEditor.vue'; @@ -40,26 +40,32 @@ app.use(VueViewer) // 初始化主题(必须在挂载前执行) // initTheme() -// 初始化时检查认证状态 +// 初始化时检查认证状态。 +// 认证交接页(/auth/callback、/app-login、/mp-login)正在兑换令牌,此时 +// localStorage 里还是旧 token。启动期请求带旧 token 打到后端会触发 401 自动 +// 恢复,进而清掉刚换到的新令牌并重新跳回登录页。交接页一律跳过启动期的 +// 登录态请求与自动跳转,由交接页写完令牌后整页重载。 const authStore = useAuthStore() -authStore.checkAuth() +if (!isAuthHandoff()) { + authStore.checkAuth() -// 如果用户已登录,在应用启动时加载动态路由 -if (authStore.isLoggedIn) { - loadAndAddDynamicRoutes() - .catch(err => { - console.error('应用启动时加载动态路由失败:', err); - }) - .then(() => { - // 检查是否因为 token 无效而导致路由加载失败 - const token = localStorage.getItem('token'); - if (!token) { - // 本地登录已停用:直接回认证中心重新登录(不再跳 #/login) - redirectToAuthorize().catch((err) => { - console.error('跳转统一认证失败:', err); - }); - } - }); + // 如果用户已登录,在应用启动时加载动态路由 + if (authStore.isLoggedIn) { + loadAndAddDynamicRoutes() + .catch(err => { + console.error('应用启动时加载动态路由失败:', err); + }) + .then(() => { + // 检查是否因为 token 无效而导致路由加载失败 + const token = localStorage.getItem('token'); + if (!token) { + // 本地登录已停用:直接回认证中心重新登录(不再跳 #/login) + redirectToAuthorize().catch((err) => { + console.error('跳转统一认证失败:', err); + }); + } + }); + } } app.mount('#app') \ No newline at end of file diff --git a/backend/src/router/index.js b/backend/src/router/index.js index 1b83311..a732133 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -344,13 +344,15 @@ function findFirstValidRoute(routes) { return null; } -/** 跳转统一认证中心;地址未配置时给出提示,避免白屏 */ +/** 跳转统一认证中心;地址未配置/检测到循环时给出提示,避免白屏或无限跳转 */ async function gotoAuthorize() { try { await redirectToAuthorize(); + return true; } catch (e) { console.error(e); window.alert(e?.message || "统一认证跳转失败,请检查认证中心配置"); + return false; } } @@ -375,14 +377,16 @@ router.beforeEach(async (to, from, next) => { } next({ path: "/home" }); } else { - await gotoAuthorize(); + const ok = await gotoAuthorize(); + if (!ok) next(false); } return; } // 未登录:统一走认证中心(本地登录已停用) if (!token) { - await gotoAuthorize(); + const ok = await gotoAuthorize(); + if (!ok) next(false); return; } @@ -391,7 +395,14 @@ router.beforeEach(async (to, from, next) => { // 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。 const info = await ensureUserInfo(); if (!info) { - await gotoAuthorize(); + // 只有登录态确实被清空(确认令牌失效)才重新授权;userinfo 接口临时故障 + // 时保留 token 放行,避免「登录成功 → 接口抖动 → 回认证中心」的循环。 + if (localStorage.getItem("token")) { + next(); + return; + } + const ok = await gotoAuthorize(); + if (!ok) next(false); return; } diff --git a/backend/src/stores/auth.js b/backend/src/stores/auth.js index 3c8886a..29b7049 100644 --- a/backend/src/stores/auth.js +++ b/backend/src/stores/auth.js @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref, reactive } from 'vue' import { getCurrentUser } from '@/api/login' -import { logoutSSO } from '@/utils/authClient' +import { logoutSSO, isAuthHandoff } from '@/utils/authClient' // 用户信息类型 const defaultUser = { @@ -53,8 +53,9 @@ export const useAuthStore = defineStore('auth', () => { // 初始化时加载用户信息 loadUserFromCache() - // 若已登录但缺少租户名称,异步从后端补齐 - if (token.value) { + // 若已登录但缺少租户名称,异步从后端补齐; + // 认证交接页除外(此时 token 是旧的,请求会 401 并干扰换码) + if (token.value && !isAuthHandoff()) { fetchCurrentUser() } diff --git a/backend/src/utils/authClient.js b/backend/src/utils/authClient.js index 77f4ff3..d854641 100644 --- a/backend/src/utils/authClient.js +++ b/backend/src/utils/authClient.js @@ -45,6 +45,51 @@ export function hasAuthBase() { return !!AUTH_BASE; } +// 认证「交接页」:正在用 code / ticket 兑换令牌。此时 localStorage 里还是 +// 上一次的旧 token,页面启动期的请求一旦 401,自动恢复逻辑会把刚换到的新 +// token 清掉并重新跳认证中心,形成「登录成功又回登录页」的循环。 +const HANDOFF_PATHS = ["/auth/callback", "/app-login", "/mp-login"]; +const REDIRECT_LOG_KEY = "auth_authorize_redirects"; +const LOOP_WINDOW_MS = 15000; +const LOOP_MAX = 3; + +export function isAuthHandoff() { + const hash = window.location.hash || ""; + const hashPath = hash.startsWith("#") ? hash.slice(1).split("?")[0] : ""; + if (HANDOFF_PATHS.includes(hashPath)) return true; + if (window.location.pathname === "/auth-callback.html") return true; + // 兼容中转页把 code/ticket 放在 hash 之前的场景;只看 query,避免业务页面 + // 自身的 ?token= 等参数被误判成交接页。 + return /[?&](code|ticket)=/.test(window.location.search || ""); +} + +// 短时间内反复发起授权说明出现了重定向环:停下并给出可诊断的错误, +// 而不是让用户一直被弹回登录界面。 +function guardAuthorizeLoop() { + const now = Date.now(); + let times = []; + try { + times = JSON.parse(sessionStorage.getItem(REDIRECT_LOG_KEY) || "[]"); + } catch (e) { + times = []; + } + times = (Array.isArray(times) ? times : []).filter((t) => now - Number(t) < LOOP_WINDOW_MS); + if (times.length >= LOOP_MAX) { + throw new Error( + "短时间内多次跳转统一登录,已停止自动跳转以避免循环;请检查认证中心与回跳白名单配置后重试" + ); + } + times.push(now); + try { + sessionStorage.setItem(REDIRECT_LOG_KEY, JSON.stringify(times)); + } catch (e) { + // 隐私模式下 sessionStorage 不可写,忽略即可 + } +} + +// 计数按时间窗口自然过期,不在换码成功后清零:否则「换码成功 → 401 → 又跳 +// 认证中心」这类循环每次都会把计数抹掉,保护永远无法触发。 + // ---------------------------------------------------------------- PKCE function randomString(len = 64) { @@ -108,6 +153,7 @@ export async function redirectToAuthorize() { if (!AUTH_BASE) { throw new Error("统一认证地址未配置:请检查 VITE_AUTH_BASE 或 VITE_API_BASE_URL"); } + guardAuthorizeLoop(); const { verifier, challenge } = await generatePKCE(); sessionStorage.setItem("pkce_verifier", verifier); const state = randomString(24); @@ -132,6 +178,12 @@ export async function redirectToAuthorize() { /** 处理认证中心回跳:用 code 换令牌 */ export async function handleAuthorizeCallback(query = {}) { + // 交接页加载期间,启动期请求是用旧 token 发出的。先清掉旧登录态, + // 避免并发 401 续期拿旧 refresh_token 触发「重放吊销」把新会话一起废掉, + // 也避免旧 userInfo 被误当成新会话的用户信息。 + clearTokens(); + localStorage.removeItem("userInfo"); + const code = query.code; const state = query.state; const savedState = sessionStorage.getItem("oidc_state"); @@ -274,10 +326,15 @@ export async function ensureUserInfo() { localStorage.setItem('userInfo', JSON.stringify(normalized)); return normalized; } catch (e) { - // 令牌无效(例如迁移前的旧令牌):清掉本地状态,让调用方重新登录 - clearTokens(); - localStorage.removeItem('userInfo'); - return null; + // 只有确认令牌失效(401)才清空登录态。userinfo 接口网络抖动 / 404 / 5xx + // 属于临时故障,一律清 token 会把刚登录成功的用户反复送回认证中心。 + if (e?.status === 401 || e?.isAuthError) { + clearTokens(); + localStorage.removeItem('userInfo'); + return null; + } + console.warn('[auth] 获取用户信息失败,保留当前登录态:', e); + return info && info.id ? info : null; } } @@ -298,7 +355,11 @@ export async function fetchUserInfo() { // 续期失败:保持原响应,交给调用方按「未登录」处理 } } - if (!res.ok) throw new Error("获取用户信息失败"); + if (!res.ok) { + const err = new Error(`获取用户信息失败(${res.status})`); + err.status = res.status; + throw err; + } return res.json(); } diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js index 90a0de6..4c8ada3 100644 --- a/backend/src/utils/request.js +++ b/backend/src/utils/request.js @@ -1,5 +1,5 @@ import axios from 'axios'; -import { refreshAccessToken, clearTokens, redirectToAuthorize } from '@/utils/authClient'; +import { refreshAccessToken, clearTokens, redirectToAuthorize, isAuthHandoff } from '@/utils/authClient'; // 统一认证模式下 401 后正在跳转登录的标志,避免并发请求同时触发跳转造成死循环 let ssoRedirecting = false; @@ -25,6 +25,9 @@ function authError(message) { // 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转) function redirectToLoginOnce() { + // 交接页里的 401 来自旧 token,不代表刚换到的新登录态失效。 + // 在这里清 token / 跳认证中心会造成「登录成功后又回登录页」。 + if (isAuthHandoff()) return; clearTokens(); localStorage.removeItem('userInfo'); if (ssoRedirecting) return; @@ -42,6 +45,9 @@ function redirectToLoginOnce() { // 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次; // 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。 async function recoverAuth(config, bizMsg) { + // 交接页的 401 来自旧 token,不能用旧 refresh_token 续期(会触发重放吊销), + // 也不能重放或自动跳转;换码完成后整页重载即可恢复正常请求。 + if (isAuthHandoff()) return Promise.reject(authError('正在完成登录,请稍候')); const latestToken = localStorage.getItem('token') || ''; const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, ''); if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) { diff --git a/backend/src/views/apps/oa/reimburse/components/detail.vue b/backend/src/views/apps/oa/reimburse/components/detail.vue index 83969e8..b937737 100644 --- a/backend/src/views/apps/oa/reimburse/components/detail.vue +++ b/backend/src/views/apps/oa/reimburse/components/detail.vue @@ -7,9 +7,11 @@ {{ detail.apply_date || '-' }} {{ detailApplicantName }} ¥ {{ money(detail.total_amount) }} - {{ formatDetailReceiptTotal }} + + {{ formatDetailReceiptTotal }} + - {{ formatDetailReceiptDiff }} + {{ formatDetailReceiptDiff }} {{ detail.description || '-' }} @@ -53,6 +55,9 @@ + 。识别结果可修改,确认后请保存。

@@ -98,9 +103,11 @@ - + @@ -110,13 +117,36 @@ - + @@ -98,6 +125,7 @@ import { computed, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { addReimbursementItem, updateReimbursementItem } from '@/api/reimburse' +import InvoicePicker from './invoicePicker.vue' const props = defineProps({ modelValue: { type: Boolean, default: false }, @@ -115,6 +143,9 @@ const visible = computed({ const saving = ref(false) const items = ref([]) let rowKey = 0 +const pickerVisible = ref(false) +const pickerIndex = ref(-1) +const pickerItemAmount = ref(0) const today = () => new Date().toISOString().slice(0, 10) const createRow = (source = {}) => ({ @@ -124,9 +155,40 @@ const createRow = (source = {}) => ({ amount: Number(source.amount || 0), description: source.description || '', unit_price: Number(source.unit_price || 0), - receipt_urls: source.receipt_urls || '' + receipt_urls: source.receipt_urls || '', + invoice_file_id: Number(source.invoice_file_id || 0), + invoice_name: source.invoice_name || '', + invoice_use_amount: Number(source.invoice_use_amount || 0) }) +const money = value => Number(value || 0).toFixed(2) + +const openPicker = index => { + const item = items.value[index] + if (!item || Number(item.amount) <= 0) { + ElMessage.warning('请先填写本条费用明细金额') + return + } + pickerIndex.value = index + pickerItemAmount.value = Number(item.amount) + pickerVisible.value = true +} + +const handleInvoiceSelect = selected => { + const index = pickerIndex.value + if (index < 0 || !items.value[index]) return + items.value[index].invoice_file_id = selected.file_id + items.value[index].invoice_name = selected.name + items.value[index].invoice_use_amount = selected.use_amount +} + +const clearLink = index => { + if (!items.value[index]) return + items.value[index].invoice_file_id = 0 + items.value[index].invoice_name = '' + items.value[index].invoice_use_amount = 0 +} + const rules = { expense_type: [{ required: true, message: '请选择费用类型', trigger: 'change' }], expense_date: [{ required: true, message: '请选择发生日期', trigger: 'change' }], @@ -158,6 +220,15 @@ const submit = async () => { return } + const invalidLinkIndex = items.value.findIndex( + item => Number(item.invoice_file_id) > 0 && + Number(item.invoice_use_amount) > Number(item.amount) + 0.001 + ) + if (invalidLinkIndex !== -1) { + ElMessage.warning(`第 ${invalidLinkIndex + 1} 条明细的挂靠金额超过明细金额,请重新选择挂靠发票`) + return + } + saving.value = true try { if (props.editingItemId) { @@ -171,7 +242,9 @@ const submit = async () => { description: item.description || '', receipt_flag: props.editingItem?.receipt_flag || 0, receipt_file_ids: props.editingItem?.receipt_file_ids || '', - receipt_urls: props.editingItem?.receipt_urls || '' + receipt_urls: props.editingItem?.receipt_urls || '', + invoice_file_id: Number(item.invoice_file_id || 0), + invoice_use_amount: Number(item.invoice_use_amount || 0) }) } else { for (const item of items.value) { @@ -183,7 +256,9 @@ const submit = async () => { unit_price: item.unit_price || 0, description: item.description || '', receipt_flag: 0, - receipt_urls: '' + receipt_urls: '', + invoice_file_id: Number(item.invoice_file_id || 0), + invoice_use_amount: Number(item.invoice_use_amount || 0) }) } } @@ -220,6 +295,24 @@ watch( font-size: 13px; } +.invoice-link-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + border-top: 1px dashed var(--el-border-color-lighter); + padding-top: 10px; + font-size: 12px; +} + +.invoice-link-info { + color: var(--el-color-success); +} + +.invoice-link-tip { + color: var(--el-text-color-secondary); +} + .item-list { max-height: 540px; overflow-y: auto; diff --git a/backend/src/views/apps/oa/reimburse/index.vue b/backend/src/views/apps/oa/reimburse/index.vue index 55e4453..f2ba1f3 100644 --- a/backend/src/views/apps/oa/reimburse/index.vue +++ b/backend/src/views/apps/oa/reimburse/index.vue @@ -50,6 +50,11 @@
+ + + + +
@@ -207,6 +212,8 @@ const statusOptions = [ { value: 4, label: "已撤回" }, { value: 5, label: "已打款" }, ]; +// 默认进入「未报销」:待处理的单据优先展示 +const activeTab = ref("unpaid"); const filters = reactive({ keyword: "", status: undefined }); const pagination = reactive({ page: 1, pageSize: 10, total: 0 }); const list = ref([]); @@ -251,7 +258,11 @@ const loadList = async () => { loading.value = true; try { const data = responseData( - await getReimbursementList({ ...filters, ...pagination }), + await getReimbursementList({ + ...filters, + ...pagination, + reimbursed: activeTab.value === "all" ? undefined : activeTab.value, + }), ); list.value = data.list || []; pagination.total = Number(data.total || 0); @@ -262,9 +273,19 @@ const loadList = async () => { } }; +// 切换「全部 / 未报销 / 已报销」:回到第一页重新查询。 +// tab 与具体状态下拉语义有重叠,切换时清掉下拉,避免叠加出空结果。 +const handleTabChange = () => { + filters.status = undefined; + pagination.page = 1; + loadList(); +}; + const resetFilters = () => { filters.keyword = ""; filters.status = undefined; + // 重置回到默认视图「未报销」 + activeTab.value = "unpaid"; pagination.page = 1; loadList(); }; @@ -514,6 +535,17 @@ onBeforeUnmount(() => { width: 150px; } +.reimburse-tabs { + :deep(.el-tabs__header) { + margin: 0 0 16px; + } + + // 三个 pane 只作切换标签,表格共用一份,隐藏空内容区 + :deep(.el-tabs__content) { + display: none; + } +} + .table-scroll { width: 100%; } diff --git a/backend/src/views/auth/appLogin.vue b/backend/src/views/auth/appLogin.vue index 7197aa1..624219d 100644 --- a/backend/src/views/auth/appLogin.vue +++ b/backend/src/views/auth/appLogin.vue @@ -29,7 +29,7 @@