优化报销管理增加挂靠

This commit is contained in:
hero920103 committed 2026-11-06 01:13:28 +08:00
1 parent 7b1c94b5ff
commit ee49ef15b5
24 files changed
+1267 -70

No files matched your search

+10
View File
@@ -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`,
+25 -19
View File
@@ -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')
+15 -4
View File
@@ -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;
}
+4 -3
View File
@@ -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()
}
+66 -5
View File
@@ -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();
}
+7 -1
View File
@@ -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) {
@@ -7,9 +7,11 @@
<el-descriptions-item label="申请日期">{{ detail.apply_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ detailApplicantName }}</el-descriptions-item>
<el-descriptions-item label="报销金额">¥ {{ money(detail.total_amount) }}</el-descriptions-item>
<el-descriptions-item label="发票金额">{{ formatDetailReceiptTotal }}</el-descriptions-item>
<el-descriptions-item label="发票金额">
<span title="本单实际上传票据的票面合计;挂靠只是把同一张票分给多条明细,不重复计入">{{ formatDetailReceiptTotal }}</span>
</el-descriptions-item>
<el-descriptions-item label="发票差额">
<span :class="detailReceiptDiffClass">{{ formatDetailReceiptDiff }}</span>
<span :class="detailReceiptDiffClass" title="发票金额 − 报销金额(挂靠不重复计入)">{{ formatDetailReceiptDiff }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注说明" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
</el-descriptions>
@@ -53,6 +55,9 @@
<template v-if="compareSummary.unmatchedCount > 0">
,{{ compareSummary.unmatchedCount }} 张待填写
</template>
<template v-if="compareLinkedTotal > 0">
,另有发票挂靠 ¥ {{ money(compareLinkedTotal) }}(同一张票不重复计入票据总额)
</template>
。识别结果可修改,确认后请保存。
</p>
<div class="table-scroll">
@@ -98,9 +103,11 @@
<el-table-column prop="amount" label="金额" width="110">
<template #default="{ row }">¥ {{ money(row.amount) }}</template>
</el-table-column>
<el-table-column prop="receipt_flag" label="发票" width="90">
<el-table-column prop="receipt_flag" label="发票" width="100">
<template #default="{ row }">
{{ itemHasReceipt(row) ? '有' : '无' }}({{ getItemReceiptFiles(row).length }})
<span :class="{ 'receipt-status-linked': itemReceiptStatus(row) === '挂靠' }">
{{ itemReceiptStatus(row) }}({{ itemReceiptCount(row) }})
</span>
</template>
</el-table-column>
<el-table-column label="发票金额" width="110">
@@ -110,13 +117,36 @@
</el-table-column>
<el-table-column label="发票差额" width="110">
<template #default="{ row }">
<span :class="itemReceiptDiffClass(row)">{{ formatItemReceiptDiff(row) }}</span>
<span :class="itemReceiptDiffClass(row)" :title="itemReceiptDiffTip(row)">
{{ formatItemReceiptDiff(row) }}
</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="176" fixed="right">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="item-actions">
<el-button
link
type="warning"
:title="isEditable ? '挂靠有余额的发票' : '当前状态不可编辑费用明细'"
aria-label="挂靠发票"
:disabled="!isEditable"
@click="openInvoicePicker(row)"
>
<i class="fa-solid fa-link"></i>
</el-button>
<el-button
v-if="row.invoice_file_id"
link
type="info"
title="取消挂靠"
aria-label="取消挂靠"
:disabled="!isEditable"
@click="clearInvoiceLink(row)"
>
<i class="fa-solid fa-link-slash"></i>
</el-button>
<el-button
v-if="itemHasReceipt(row)"
link
@@ -266,6 +296,14 @@
@saved="handleItemsSaved"
/>
<InvoicePicker
v-model="pickerVisible"
:reimbursement-id="detail?.id"
:item-amount="pickerItemAmount"
:exclude-item-id="pickerRow?.id || 0"
@select="handleInvoiceSelect"
/>
<template #footer>
<div class="detail-toolbar">
<el-button
@@ -312,6 +350,7 @@ import {
summarizeReceiptAmountRows
} from '../utils/invoiceAmount'
import ItemForm from './itemForm.vue'
import InvoicePicker from './invoicePicker.vue'
// 时间显示统一为:YYYY-MM-DD HH:mm:ss
import { formatDateTime } from '@/utils/datetime'
@@ -379,6 +418,9 @@ const submitting = ref(false)
const withdrawing = ref(false)
const manualSaving = ref(false)
const itemVisible = ref(false)
const pickerVisible = ref(false)
const pickerRow = ref(null)
const pickerItemAmount = ref(0)
const batchUploading = ref(false)
const invoiceFileInputRef = ref(null)
const pendingUploadRow = ref(null)
@@ -406,6 +448,14 @@ onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile)
})
// 本单挂靠发票的已使用额度:仅用于面板提示,不重复计入整单票据金额
const compareLinkedTotal = computed(() =>
(props.detail?.items || []).reduce(
(sum, item) => sum + Number(item.invoice_use_amount || 0),
0
)
)
const compareSummary = computed(() => {
if (!amountCompare.value?.details) {
return {
@@ -421,6 +471,7 @@ const compareSummary = computed(() => {
(sum, item) => sum + Number(item.amount || 0),
0
)
// 与顶部基础信息保持同一口径:挂靠不增加整单票据总额
return summarizeReceiptAmountRows(amountCompare.value.details, claimTotal)
})
@@ -571,6 +622,19 @@ const getItemReceiptFiles = item => {
const itemHasReceipt = item => getItemReceiptFiles(item).length > 0
// 「发票」列三态:有(本行上传发票)/ 挂靠(用本单其它明细上传的发票)/ 无。
// 挂靠优先展示;数量为本行关联的发票凭证张数(上传张数 + 挂靠张数)。
const itemLinkedInvoiceCount = item =>
Number(item?.invoice_file_id || 0) > 0 && Number(item?.invoice_use_amount || 0) > 0 ? 1 : 0
const itemReceiptStatus = item => {
if (itemLinkedInvoiceCount(item) > 0) return '挂靠'
return itemHasReceipt(item) ? '有' : '无'
}
const itemReceiptCount = item =>
getItemReceiptFiles(item).length + itemLinkedInvoiceCount(item)
const getItemReceiptAmountTotal = item => {
const amounts = getItemReceiptFiles(item)
.map(file => file.amount)
@@ -579,14 +643,49 @@ const getItemReceiptAmountTotal = item => {
return amounts.reduce((sum, amount) => sum + Number(amount), 0)
}
// 本条明细挂靠本单其它明细上传的发票时,本次分到的额度
const getItemLinkedAmount = item => Number(item?.invoice_use_amount || 0)
// 按发票文件 ID 统计「被挂靠走」的额度。一张票的票面只在上传它的那条
// 明细计入一次,挂靠出去的部分要从宿主明细划出,否则同一张票会被重复计算。
const linkedOutByFile = computed(() => {
const map = {}
for (const item of props.detail?.items || []) {
const fileId = Number(item?.invoice_file_id || 0)
const used = getItemLinkedAmount(item)
if (fileId > 0 && used > 0) {
map[fileId] = (map[fileId] || 0) + used
}
}
return map
})
const getItemLinkedOutAmount = item =>
getItemReceiptFiles(item).reduce(
(sum, file) => sum + Number(linkedOutByFile.value[Number(file.file_id)] || 0),
0
)
// 本行可用发票金额 = 上传票面(扣除已划出给挂靠明细的部分)+ 本行挂靠分到的额度
const getItemInvoiceAmount = item => {
const uploaded = getItemReceiptAmountTotal(item)
const linked = getItemLinkedAmount(item)
if (uploaded == null && linked <= 0) return null
const linkedOut = uploaded == null ? 0 : getItemLinkedOutAmount(item)
const own = Math.max(0, (uploaded || 0) - linkedOut)
return own + linked
}
// 统一口径:本行发票金额 − 本行明细金额。所有明细、顶部整单完全一致,
// 明细行差额之和就等于顶部整单差额。
const getItemReceiptDiff = item => {
const receiptTotal = getItemReceiptAmountTotal(item)
if (receiptTotal == null) return null
return receiptTotal - Number(item?.amount || 0)
const invoiceTotal = getItemInvoiceAmount(item)
if (invoiceTotal == null) return null
return invoiceTotal - Number(item?.amount || 0)
}
const formatItemReceiptTotal = item => {
const total = getItemReceiptAmountTotal(item)
const total = getItemInvoiceAmount(item)
return total == null ? '-' : `¥ ${money(total)}`
}
@@ -604,11 +703,27 @@ const itemReceiptDiffClass = item => {
return diff > 0 ? 'receipt-diff-over' : 'receipt-diff-short'
}
// 悬停说明差额构成:票面、划出的挂靠额度、分入的挂靠额度
const itemReceiptDiffTip = item => {
const uploaded = getItemReceiptAmountTotal(item)
const linked = getItemLinkedAmount(item)
if (uploaded == null && linked <= 0) return '本条明细暂无发票'
const linkedOut = uploaded == null ? 0 : getItemLinkedOutAmount(item)
const parts = []
if (uploaded != null) parts.push(`上传票面 ¥${money(uploaded)}`)
if (linkedOut > 0) parts.push(`划出挂靠 ¥${money(linkedOut)}`)
if (linked > 0) parts.push(`挂靠分入 ¥${money(linked)}`)
parts.push(`本行可用发票 ¥${money(getItemInvoiceAmount(item) || 0)} − 明细 ¥${money(item?.amount || 0)}`)
return parts.join(';')
}
// 顶部整单 = 各明细可用发票金额之和。挂靠额度只是同一张票在明细之间划转,
// 「划出」与「分入」相互抵消,因此总额等于实际上传票据的票面合计,不重复计算。
const detailReceiptTotal = computed(() => {
const items = props.detail?.items || []
let hasAny = false
const total = items.reduce((sum, item) => {
const itemTotal = getItemReceiptAmountTotal(item)
const itemTotal = getItemInvoiceAmount(item)
if (itemTotal != null) {
hasAny = true
return sum + itemTotal
@@ -729,6 +844,48 @@ const handleItemsSaved = reimbursementId => {
emit('changed', reimbursementId)
}
// 发票挂靠:对已有明细直接挂靠 / 更换 / 解挂,复用明细更新接口,
// 请求里带上原发票附件字段,避免更新时把已上传的票据清掉。
const saveInvoiceLink = async (row, fileId, useAmount) => {
try {
await updateReimbursementItem(props.detail?.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: row.receipt_flag || 0,
receipt_file_ids: row.receipt_file_ids || '',
receipt_urls: row.receipt_urls || '',
invoice_file_id: fileId,
invoice_use_amount: useAmount
})
ElMessage.success(fileId ? '发票挂靠成功' : '已取消挂靠')
emit('changed', props.detail?.id)
} catch (error) {
ElMessage.error(error?.message || '保存挂靠失败')
}
}
const openInvoicePicker = row => {
pickerRow.value = row
pickerItemAmount.value = Number(row.amount || 0)
pickerVisible.value = true
}
const handleInvoiceSelect = selected => {
const row = pickerRow.value
if (!row) return
saveInvoiceLink(row, selected.file_id, selected.use_amount)
}
const clearInvoiceLink = row => {
ElMessageBox.confirm('确认取消这条明细的发票挂靠吗?', '取消挂靠', { type: 'warning' })
.then(() => saveInvoiceLink(row, 0, 0))
.catch(() => {})
}
const handleSubmit = async () => {
if (!props.detail?.id) return
try {
@@ -1146,6 +1303,11 @@ const money = value => Number(value || 0).toFixed(2)
border: 0;
}
.receipt-status-linked {
color: var(--el-color-warning);
font-weight: 600;
}
.item-actions {
display: flex;
align-items: center;
@@ -0,0 +1,193 @@
<template>
<el-dialog
v-model="visible"
title="选择挂靠发票"
width="780px"
class="invoice-picker-dialog"
append-to-body
destroy-on-close
>
<el-alert
type="info"
:closable="false"
show-icon
class="picker-tip"
title="一张发票可以分多次报销。这里只列出当前报销单内上传、且仍有余额的发票;本次使用金额不能超过发票余额,也不能超过本条费用明细金额。"
/>
<el-table
v-loading="loading"
:data="list"
border
size="small"
highlight-current-row
max-height="320"
@current-change="handleCurrentChange"
>
<el-table-column prop="name" label="发票文件" min-width="170" show-overflow-tooltip />
<el-table-column label="票面金额" width="110">
<template #default="{ row }">¥ {{ money(row.amount) }}</template>
</el-table-column>
<el-table-column label="已用" width="100">
<template #default="{ row }">¥ {{ money(row.used_amount) }}</template>
</el-table-column>
<el-table-column label="剩余可挂靠" width="120">
<template #default="{ row }">
<span class="remain">¥ {{ money(row.remain_amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="expense_date" label="上传日期" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleSelect(row)">
{{ selectedId === row.file_id ? '已选' : '选择' }}
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="当前报销单暂无有余额的发票:请先在本单费用明细上传发票并填写票面金额" :image-size="70" />
</template>
</el-table>
<div v-if="selected" class="picker-use">
<span>本次使用金额</span>
<el-input-number
v-model="useAmount"
:min="0.01"
:max="maxUse"
:precision="2"
:step="10"
controls-position="right"
size="small"
/>
<span class="picker-hint">最多 ¥ {{ money(maxUse) }}</span>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="confirm">确认挂靠</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getAvailableInvoices } from '@/api/reimburse'
const props = defineProps({
modelValue: { type: Boolean, default: false },
// 当前报销单 ID:发票挂靠只允许在本单范围内进行
reimbursementId: { type: [Number, String], default: 0 },
// 本条费用明细金额,用于限制本次使用金额上限
itemAmount: { type: Number, default: 0 },
// 编辑已有明细时传入该明细 ID,排除它自身占用的额度
excludeItemId: { type: [Number, String], default: 0 }
})
const emit = defineEmits(['update:modelValue', 'select'])
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const loading = ref(false)
const list = ref([])
const selectedId = ref(0)
const selected = ref(null)
const useAmount = ref(0)
const money = value => Number(value || 0).toFixed(2)
const maxUse = computed(() =>
Number(
Math.min(
Number(props.itemAmount) || 0,
Number(selected.value?.remain_amount) || 0
).toFixed(2)
)
)
const load = async () => {
if (!props.reimbursementId) {
list.value = []
ElMessage.warning('缺少报销单信息,无法加载可挂靠发票')
return
}
loading.value = true
try {
const res = await getAvailableInvoices({
reimbursement_id: props.reimbursementId,
exclude_item_id: props.excludeItemId || undefined
})
const payload = res?.data ?? {}
list.value = Array.isArray(payload.list)
? payload.list
: (Array.isArray(res?.list) ? res.list : [])
} catch (error) {
ElMessage.error(error?.message || '加载可挂靠发票失败')
list.value = []
} finally {
loading.value = false
}
}
const handleSelect = row => {
if (!row) return
selectedId.value = row.file_id
selected.value = row
useAmount.value = maxUse.value
}
const handleCurrentChange = row => {
if (row) handleSelect(row)
}
const confirm = () => {
if (!selected.value) return
const amount = Number(useAmount.value) || 0
if (amount <= 0 || amount > maxUse.value + 0.001) {
ElMessage.warning(`本次使用金额需在 0.01 ~ ${money(maxUse.value)} 之间`)
return
}
emit('select', {
file_id: selected.value.file_id,
name: selected.value.name || '',
remain_amount: selected.value.remain_amount,
use_amount: Number(amount.toFixed(2))
})
visible.value = false
}
watch(
() => props.modelValue,
async isVisible => {
if (!isVisible) return
selectedId.value = 0
selected.value = null
useAmount.value = 0
await load()
}
)
</script>
<style lang="less" scoped>
.picker-tip {
margin-bottom: 12px;
}
.remain {
color: var(--el-color-success);
font-weight: 600;
}
.picker-use {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
font-size: 13px;
}
.picker-hint {
color: var(--el-text-color-secondary);
}
</style>
@@ -79,6 +79,25 @@
show-word-limit
/>
</el-form-item>
<div class="invoice-link-row">
<el-button
link
type="primary"
:disabled="Number(item.amount) <= 0"
@click="openPicker(index)"
>
<i class="fa-solid fa-link"></i>
{{ item.invoice_file_id ? '更换挂靠发票' : '挂靠有余额的发票' }}
</el-button>
<template v-if="item.invoice_file_id">
<span class="invoice-link-info">
{{ item.invoice_name || ('发票 #' + item.invoice_file_id) }}
· 本次 ¥{{ money(item.invoice_use_amount) }}
</span>
<el-button link type="danger" @click="clearLink(index)">取消挂靠</el-button>
</template>
<span v-else class="invoice-link-tip">可选:一张发票分多次报销</span>
</div>
</el-form>
</el-card>
</div>
@@ -91,6 +110,14 @@
{{ editingItemId ? '保存' : `保存 ${items.length} 条` }}
</el-button>
</template>
<InvoicePicker
v-model="pickerVisible"
:reimbursement-id="props.reimbursementId"
:item-amount="pickerItemAmount"
:exclude-item-id="props.editingItemId"
@select="handleInvoiceSelect"
/>
</el-dialog>
</template>
@@ -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;
+33 -1
View File
@@ -50,6 +50,11 @@
</div>
<div class="table-card">
<el-tabs v-model="activeTab" class="reimburse-tabs" @tab-change="handleTabChange">
<el-tab-pane label="全部" name="all" />
<el-tab-pane label="未报销" name="unpaid" />
<el-tab-pane label="已报销" name="paid" />
</el-tabs>
<div class="table-scroll">
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="status" label="状态" width="80">
@@ -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%;
}
+4 -1
View File
@@ -29,7 +29,7 @@
<script setup>
import { onMounted, ref } from "vue";
import request from "@/utils/request";
import { setTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
import { setTokens, clearTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
/**
* 移动端免登录兑换页(/app-login)
@@ -102,6 +102,9 @@ async function chooseTenant(t) {
}
async function finishLogin(tokens) {
// 清掉上一轮的旧登录态,避免旧 refresh_token 被并发续期逻辑误用
clearTokens();
localStorage.removeItem("userInfo");
setTokens(tokens);
await saveUserInfo();
// 与 /auth/callback 一致:replaceState 改写 hash 后整页重载,
+4 -1
View File
@@ -29,7 +29,7 @@
<script setup>
import { onMounted, ref } from "vue";
import request from "@/utils/request";
import { setTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
import { setTokens, clearTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
const error = ref("");
const choosing = ref(false);
@@ -73,6 +73,9 @@ async function finish() {
}
async function finishLogin(tokens) {
// 清掉上一轮的旧登录态,避免旧 refresh_token 被并发续期逻辑误用
clearTokens();
localStorage.removeItem("userInfo");
setTokens(tokens);
await saveUserInfo();
// 与 /auth/callback 一致:replaceState 改写 hash 后整页重载,避免旧令牌请求 401