修复bug

This commit is contained in:
hero920103 committed 2026-10-19 17:34:11 +08:00
1 parent 1850ac640e
commit 892ba5886d
20 files changed
+1078 -178

No files matched your search

+12 -2
View File
@@ -45,10 +45,20 @@ export function deleteReimbursement(id) {
})
}
export function submitReimbursement(id) {
export function submitReimbursement(id, comment = '') {
return request({
url: `/backend/reimbursements/${id}/submit`,
method: 'post'
method: 'post',
data: { comment }
})
}
// 撤回审批中的报销单:撤掉运行中的审批实例并回到草稿,撤回后可继续编辑、再次提交
export function withdrawReimbursement(id, comment = '') {
return request({
url: `/backend/reimbursements/${id}/withdraw`,
method: 'post',
data: { comment }
})
}
+7 -4
View File
@@ -1,4 +1,5 @@
import { createComponentLoader } from '@/utils/pathResolver';
import { pickDefaultChild } from './menuDefaults';
function computeFullPath(menuPath, parentPath) {
if (!menuPath) return parentPath || '';
@@ -105,7 +106,7 @@ function nestedMenuToRoute(menu, layoutAbsPath) {
component: () => import('@/views/layouts/EmptyLayout.vue'),
children: convertNestedMenuChildren(menu.children, childAbs),
};
const firstChild = menu.children[0];
const firstChild = pickDefaultChild(menu.children);
if (firstChild && firstChild.path) {
const firstAbs = computeFullPath(firstChild.path, childAbs);
const firstRel = toRelativeChildPath(childAbs, firstAbs);
@@ -193,9 +194,11 @@ export function convertMenusToRoutes(menus, parentPath = '') {
} else if (hasChildren) {
route.component = () => import('@/views/layouts/EmptyLayout.vue');
route.children = convertMenusToRoutes(menu.children, fullPath);
const defaultChild = menu.children.find((child) =>
child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content'
) || menu.children[0];
// CMS 内容统计是内置默认页,保持原行为;其余模块统一优先「工作台」
const defaultChild =
menu.children.find(
(child) => child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content'
) || pickDefaultChild(menu.children);
if (defaultChild && defaultChild.path) {
const childFullPath = defaultChild.path.startsWith('/')
? defaultChild.path
+48
View File
@@ -0,0 +1,48 @@
/**
* 菜单默认落地页规则。
*
* 模块根菜单本身通常是目录,点击模块(如首页的 ERP 卡片)后由这里的规则
* 决定跳到哪个子菜单。规则按「工作台优先」收敛,避免依赖后端返回顺序
* 而落到某个先创建的页面(例如 ERP 的采购订单)。
*/
/** 工作台/仪表盘入口的识别规则(标题、菜单路径、组件路径) */
const WORKBENCH_TITLE_RE = /工作台|仪表盘/;
const WORKBENCH_PATH_RE = /(^|\/)(dashboard|workbench)(\/|\.|$)/i;
export function isWorkbenchMenu(menu) {
if (!menu) return false;
return (
WORKBENCH_TITLE_RE.test(String(menu.title || '')) ||
WORKBENCH_PATH_RE.test(String(menu.path || '')) ||
WORKBENCH_PATH_RE.test(String(menu.component_path || ''))
);
}
/** 按 sort(其次 id)升序:后端菜单按 ID 返回,与侧栏按 sort 展示的顺序可能不一致 */
export function sortByMenuOrder(menus) {
return [...menus].sort((a, b) => {
const sortA = Number(a.sort) || 0;
const sortB = Number(b.sort) || 0;
if (sortA !== sortB) return sortA - sortB;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
}
/**
* 模块默认落地页:统一优先「工作台 / 仪表盘」,其次侧栏排在最前的子菜单。
*
* 直接取 children[0] 不可靠:后端按 ID 顺序返回,而侧栏是按 sort 展示的。
* 早期先创建的页面(例如 ERP 的采购订单)会因此成为模块默认页,
* 即使「工作台」已经排在 sort=1。
*/
export function pickDefaultChild(children) {
if (!children || children.length === 0) return null;
const sorted = sortByMenuOrder(children);
const workbench =
sorted.find(isWorkbenchMenu) ||
// 工作台被放成二级目录(如 ERP:工作台 -> 仪表盘)时,选这个目录,
// 由该目录自己的 redirect 落到具体工作台页面
sorted.find((child) => Array.isArray(child.children) && child.children.some(isWorkbenchMenu));
return workbench || sorted[0];
}
+20 -7
View File
@@ -3,6 +3,14 @@ import { ref, computed } from 'vue'
// import { getUserInfo } from '@/utils/auth'
import { getMenus } from '@/api/menu';
// 是否属于「登录态失效」类错误。
// request.js 已给鉴权错误打上 status=401,同时兼容历史文案与 axios 原始错误。
const isAuthError = (err) =>
err?.status === 401 ||
err?.response?.status === 401 ||
err?.isAuthError === true ||
/token已过期|token无效|无效的token/.test(err?.message || '');
export const useMenuStore = defineStore('menu', () => {
// 菜单数据
const menus = ref([]);
@@ -167,6 +175,18 @@ export const useMenuStore = defineStore('menu', () => {
throw new Error(res.message || '获取菜单失败:响应格式错误');
} catch (err) {
error.value = err.message || '获取菜单失败';
// 鉴权失败(令牌过期/无效、会话被吊销):清掉缓存并继续抛出,
// 由 request.js 静默续期或跳认证中心。
// 不能回退到缓存:否则令牌过期时会用旧菜单把登录态问题掩盖掉,
// 用户看到的是一份已经失效的权限菜单。
if (isAuthError(err)) {
clearCache();
menus.value = [];
console.warn('获取菜单失败(登录状态失效):', err.message);
throw err;
}
console.error('获取菜单失败:', err);
console.error('错误详情:', {
message: err.message,
@@ -174,13 +194,6 @@ export const useMenuStore = defineStore('menu', () => {
stack: err.stack
});
// 如果是 token 无效错误,不使用缓存,直接抛出
if (err.message === 'token无效' || err.response?.status === 401) {
clearCache();
menus.value = [];
throw err;
}
// 如果出错,尝试使用缓存数据
const cachedMenus = loadFromCache();
if (cachedMenus && cachedMenus.length > 0) {
+78 -42
View File
@@ -13,7 +13,73 @@ const service = axios.create({
timeout: 300000,
withCredentials: false // JWT 不需要 Cookie
});
// 构造鉴权失败错误:带上 status,调用方(如菜单 store)可直接按 401 判断,
// 不必依赖中文文案;否则文案一改,登录态判断就静默失效了。
function authError(message) {
const err = new Error(message || '登录状态已失效,请重新登录');
err.status = 401;
err.isAuthError = true;
return err;
}
// 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转)
function redirectToLoginOnce() {
clearTokens();
localStorage.removeItem('userInfo');
if (ssoRedirecting) return;
ssoRedirecting = true;
redirectToAuthorize().catch((err) => {
console.error('跳转统一认证失败:', err);
ssoRedirecting = false;
});
}
// 401 统一处理:
// 阶段一:并发场景下,本请求带的是旧令牌,而别的请求已换成新令牌 → 直接用最新令牌重放;
// 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次;
// 阶段三:续期失败 / 续期后仍 401 → 清登录态回认证中心。
// 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次;
// 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
async function recoverAuth(config, bizMsg) {
const latestToken = localStorage.getItem('token') || '';
const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', config?.url);
config.headers['Authorization'] = `Bearer ${latestToken}`;
config._replayedWithLatest = true;
return service.request(config);
}
if (config?._refreshed) {
// 续期拿到的令牌仍被判定过期/无效:继续留在页面只会一路报错,直接回认证中心
redirectToLoginOnce();
return Promise.reject(authError(bizMsg));
}
try {
const newToken = await refreshAccessToken();
config.headers['Authorization'] = `Bearer ${newToken}`;
config._refreshed = true;
return service.request(config);
} catch (e) {
redirectToLoginOnce();
return Promise.reject(authError('登录状态已失效,请重新登录'));
}
}
// 令牌类错误提示。后端部分接口(全局鉴权中间件为 warn 模式时控制器自行鉴权)
// 会在 HTTP 200 的 body 里返回 code:401,需要按提示词识别。
// 不能把所有 body code:401 都当令牌问题:个别接口(如新增用户时校验原密码)
// 也用 401 表示业务失败,那类响应重放没有意义,更不能因此把用户登出。
const TOKEN_ERROR_HINTS = ['token已过期', '无效的token', '未提供认证信息', '认证信息格式错误'];
function isTokenFailure(body) {
if (!body || Number(body.code) !== 401) return false;
const msg = String(body.msg || body.message || '');
return TOKEN_ERROR_HINTS.some((hint) => msg.includes(hint));
}
// 请求拦截器
service.interceptors.request.use(
config => {
@@ -38,11 +104,16 @@ service.interceptors.request.use(
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
response => {
return response.data;
const data = response.data;
// 兜住 HTTP 200 + body code:401 的令牌失效响应,让它走与真实 401 相同的续期流程
if (isTokenFailure(data)) {
return recoverAuth(response.config, data.msg || data.message);
}
return data;
},
async error => {
if (error.response) {
@@ -50,43 +121,8 @@ service.interceptors.response.use(
// 避免页面只显示 "Request failed with status code xxx"
const bizMsg = error.response.data?.msg || error.response.data?.message;
switch (error.response.status) {
case 401: {
// 阶段一:并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的
// (登录回跳、另一请求的静默续期都会改 localStorage)。
// 此时直接用最新令牌重放,免得白跑一次续期。
const latestToken = localStorage.getItem('token') || '';
const sentToken = String(error.config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
if (!error.config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', error.config?.url);
error.config.headers['Authorization'] = `Bearer ${latestToken}`;
error.config._replayedWithLatest = true;
return service.request(error.config);
}
// 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次。
// 注意「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,
// 只挡一次;否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
if (error.config?._refreshed) {
return Promise.reject(new Error(bizMsg || 'token无效,请重新登录'));
}
try {
const newToken = await refreshAccessToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
error.config._refreshed = true;
return service.request(error.config);
} catch (e) {
// 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环)
clearTokens();
localStorage.removeItem('userInfo');
if (!ssoRedirecting) {
ssoRedirecting = true;
redirectToAuthorize().catch((err) => {
console.error('跳转统一认证失败:', err);
ssoRedirecting = false;
});
}
return Promise.reject(new Error('token无效,请重新登录'));
}
}
case 401:
return recoverAuth(error.config, bizMsg);
case 404:
console.error('请求的资源不存在');
break;
@@ -108,5 +144,5 @@ service.interceptors.response.use(
return Promise.reject(error);
}
);
export default service;
export default service;
@@ -20,10 +20,19 @@
<el-button size="small" :loading="amountComparing" @click="handleCompareAmounts">
核对发票金额
</el-button>
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
<el-button type="primary" size="small" :disabled="!isEditable" @click="openAddItem">添加费用</el-button>
</div>
</div>
<el-alert
v-if="!isEditable"
class="edit-lock-tip"
type="info"
:closable="false"
show-icon
:title="editLockTip"
/>
<div v-if="amountCompare" class="amount-compare-card">
<div class="amount-compare-summary">
<div class="compare-item">
@@ -75,7 +84,7 @@
</el-table>
</div>
<div class="compare-actions">
<el-button v-if="isDraft" type="primary" size="small" :loading="amountSaving" @click="handleSaveReceiptAmounts">
<el-button v-if="isEditable" type="primary" size="small" :loading="amountSaving" @click="handleSaveReceiptAmounts">
保存发票金额
</el-button>
<el-button size="small" @click="closeAmountCompare">取消</el-button>
@@ -105,7 +114,7 @@
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
<el-table-column label="操作" width="176" fixed="right">
<template #default="{ row }">
<div class="item-actions">
<el-button
@@ -118,26 +127,38 @@
>
<i class="fa-solid fa-eye"></i>
</el-button>
<template v-if="isDraft">
<el-button
link
type="success"
title="上传发票(可多选,追加到当前费用项)"
:title="isEditable ? '上传发票(可多选,追加到当前费用项)' : '当前状态不可编辑费用明细'"
aria-label="上传发票"
:disabled="batchUploading"
:disabled="!isEditable || batchUploading"
@click="openInvoiceFilePicker(row)"
>
<i
:class="batchUploading ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
></i>
</el-button>
<el-button link type="primary" title="编辑" aria-label="编辑" @click="openEditItem(row)">
<el-button
link
type="primary"
:title="isEditable ? '编辑' : '当前状态不可编辑费用明细'"
aria-label="编辑"
:disabled="!isEditable"
@click="openEditItem(row)"
>
<i class="fa-solid fa-pen-to-square"></i>
</el-button>
<el-button link type="danger" title="删除" aria-label="删除" @click="removeItem(row)">
<el-button
link
type="danger"
:title="isEditable ? '删除' : '当前状态不可编辑费用明细'"
aria-label="删除"
:disabled="!isEditable"
@click="removeItem(row)"
>
<i class="fa-regular fa-trash-can"></i>
</el-button>
</template>
</div>
</template>
</el-table-column>
@@ -191,7 +212,7 @@
<i v-else class="fa-regular fa-file-pdf receipt-thumb-pdf"></i>
<span class="receipt-thumb-name" :title="receipt.name">{{ receipt.name }}</span>
<el-button
v-if="isDraft"
v-if="isEditable"
link
type="danger"
class="receipt-delete-btn"
@@ -227,8 +248,11 @@
:timestamp="formatDateTime(record.create_time)"
placement="top"
>
<strong>{{ actionLabel(record.action) }} · {{ record.approver_name || '-' }}</strong>
<div v-if="record.comment" class="record-comment">{{ record.comment }}</div>
<div class="record-head">
<strong>{{ actionLabel(record.action) }}</strong>
<span class="record-operator">操作人:{{ record.approver_name || '-' }}</span>
</div>
<div class="record-comment">操作理由:{{ record.comment || defaultRecordReason(record) }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无审批记录" :image-size="60" />
@@ -244,6 +268,14 @@
<template #footer>
<div class="detail-toolbar">
<el-button
v-if="isApproving"
type="warning"
:loading="withdrawing"
@click="handleWithdraw"
>
撤回并编辑
</el-button>
<el-button
v-if="isApproved"
type="success"
@@ -254,12 +286,12 @@
</el-button>
<div class="toolbar-right">
<el-button
v-if="isDraft"
v-if="isEditable"
type="primary"
:loading="submitting"
@click="handleSubmit"
>
提交审核
{{ isDraft ? "提交审核" : "重新提交" }}
</el-button>
</div>
</div>
@@ -271,7 +303,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadFile } from '@/api/file'
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus } from '@/api/reimburse'
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus, withdrawReimbursement } from '@/api/reimburse'
import { useAuthStore } from '@/stores/auth'
import {
compareReimbursementAmounts,
@@ -302,16 +334,34 @@ const statusOptions = [
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[value] || 'info'
const statusLabel = value => statusOptions.find(item => item.value === Number(value))?.label || '未知'
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[Number(value)] || 'info'
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const isDraft = computed(() => props.detail?.status === 0)
const isDraft = computed(() => Number(props.detail?.status) === 0)
// 可编辑状态:草稿、已驳回、已撤回;审批中需先撤回
const isEditable = computed(() => [0, 3, 4].includes(Number(props.detail?.status)))
// 审批中:可撤回到草稿后继续编辑
const isApproving = computed(() => Number(props.detail?.status) === 1)
// 审批通过(等待报销人确认款项到账)
const isApproved = computed(() => props.detail?.status === 2)
const isApproved = computed(() => Number(props.detail?.status) === 2)
// 非可编辑状态时说明原因,避免按钮置灰后无从判断
const editLockTip = computed(() => {
switch (Number(props.detail?.status)) {
case 1:
return '审批中不可编辑费用明细;如需修改,请点击底部「撤回并编辑」退回草稿。'
case 2:
return '审批已通过,费用明细不可编辑。'
case 5:
return '款项已打款,费用明细不可编辑。'
default:
return '当前状态不可编辑费用明细。'
}
})
const detailApplicantName = computed(() => {
const name = props.detail?.user_name
@@ -326,6 +376,7 @@ const detailApplicantName = computed(() => {
return '-'
})
const submitting = ref(false)
const withdrawing = ref(false)
const manualSaving = ref(false)
const itemVisible = ref(false)
const batchUploading = ref(false)
@@ -681,9 +732,19 @@ const handleItemsSaved = reimbursementId => {
const handleSubmit = async () => {
if (!props.detail?.id) return
try {
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
const { value: comment } = await ElMessageBox.prompt(
'提交后将进入审批流程,可填写提交说明,将记录在审批时间线中。',
'提交审核',
{
type: 'warning',
confirmButtonText: '提交',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请输入提交说明(选填)'
}
)
submitting.value = true
await submitReimbursement(props.detail.id)
await submitReimbursement(props.detail.id, comment || '')
ElMessage.success('提交成功')
emit('changed', props.detail.id)
} catch (error) {
@@ -695,6 +756,35 @@ const handleSubmit = async () => {
}
}
// 撤回审批中的单据:回到草稿后可继续编辑费用条目,再重新提交
const handleWithdraw = async () => {
if (!props.detail?.id) return
try {
const { value: comment } = await ElMessageBox.prompt(
'撤回后单据将回到草稿,可继续编辑费用条目并重新提交。',
'撤回确认',
{
type: 'warning',
confirmButtonText: '确认撤回',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请输入撤回原因',
inputValidator: value => (String(value || '').trim() ? true : '请输入撤回原因')
}
)
withdrawing.value = true
await withdrawReimbursement(props.detail.id, String(comment || '').trim())
ElMessage.success('已撤回,可继续编辑')
emit('changed', props.detail.id)
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error?.message || '撤回失败')
}
} finally {
withdrawing.value = false
}
}
// 审批通过后由报销人本人确认款项到账,单据置为「已打款」
const confirmPaid = async () => {
if (!props.detail?.id) return
@@ -899,6 +989,23 @@ const removeReceipt = async receipt => {
}
const actionLabel = value => ({ 1: '提交申请', 2: '审批通过', 3: '审批驳回', 4: '转审', 5: '撤回' })[value] || '审批操作'
// 老记录没有审批意见时,用动作补一条可读的说明,避免时间线只显示 “-”
const defaultRecordReason = record => {
switch (Number(record?.action)) {
case 1:
return '提交报销申请'
case 2:
return '审批通过'
case 3:
return '审批驳回'
case 4:
return '转交他人审批'
case 5:
return '报销人主动撤回'
default:
return '-'
}
}
const money = value => Number(value || 0).toFixed(2)
</script>
@@ -916,14 +1023,32 @@ const money = value => Number(value || 0).toFixed(2)
gap: 8px;
}
.edit-lock-tip {
margin-bottom: 12px;
}
.detail-title {
margin: 0 0 12px;
color: #303133;
}
.record-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.record-operator {
color: #909399;
font-size: 13px;
}
.record-comment {
margin-top: 6px;
color: #606266;
font-size: 13px;
white-space: pre-wrap;
}
.receipt-preview {
@@ -15,7 +15,7 @@
</el-form-item>
<el-alert
title="保存报销单后,可在详情中逐条添加报销事项。"
title="保存后为草稿,不会提交审批;可在详情中逐条添加报销事项,确认无误后再到列表操作栏点「提交审核」。"
type="info"
:closable="false"
show-icon
@@ -56,7 +56,7 @@ const cloneForm = data => ({
const form = reactive(cloneForm(props.formData))
const formRef = ref()
const submitLabel = computed(() => (props.isEdit ? '保存' : '创建'))
const submitLabel = computed(() => (props.isEdit ? '保存' : '保存草稿'))
const formRules = {
apply_date: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
}
+71 -15
View File
@@ -93,7 +93,14 @@
>详情</el-button
>
<el-button
v-if="row.status === 2"
v-if="isStatus(row, 1)"
link
type="warning"
@click="handleWithdraw(row)"
>撤回</el-button
>
<el-button
v-if="isStatus(row, 2)"
link
type="success"
@click="handleConfirmPaid(row)"
@@ -107,21 +114,21 @@
>导出</el-button
>
<el-button
v-if="row.status === 0"
v-if="isEditable(row)"
link
type="primary"
@click="openEdit(row.id)"
>编辑</el-button
>
<el-button
v-if="row.status === 0"
v-if="isEditable(row)"
link
type="success"
@click="handleSubmit(row)"
>提交</el-button
>{{ isStatus(row, 0) ? "提交审核" : "重新提交" }}</el-button
>
<el-button
v-if="row.status === 0"
v-if="isStatus(row, 0)"
link
type="danger"
@click="handleDelete(row)"
@@ -189,6 +196,7 @@ import {
submitReimbursement,
updateReimbursement,
updateReimbursementStatus,
withdrawReimbursement,
} from "@/api/reimburse";
const statusOptions = [
@@ -286,11 +294,22 @@ const openEdit = async (id) => {
const saveForm = async (payload) => {
saving.value = true;
try {
if (editingId.value) await updateReimbursement(editingId.value, payload);
else await createReimbursement(payload);
ElMessage.success("保存成功");
let createdId = 0;
if (editingId.value) {
await updateReimbursement(editingId.value, payload);
} else {
// 新建只保存为草稿,不提交审批;审核需在列表操作栏点「提交审核」
const res = await createReimbursement(payload);
createdId = Number(res?.data?.id || res?.id || 0);
}
ElMessage.success(editingId.value ? "保存成功" : "已保存为草稿,可继续添加费用明细");
formVisible.value = false;
loadList();
if (createdId) {
// 直接打开详情继续编辑,草稿状态下可反复修改
await openDetail(createdId);
} else {
loadList();
}
} catch (error) {
ElMessage.error(error?.message || "保存失败");
} finally {
@@ -300,10 +319,18 @@ const saveForm = async (payload) => {
const handleSubmit = async (row) => {
try {
await ElMessageBox.confirm("提交后将进入审批流程,确认提交吗?", "提示", {
type: "warning",
});
await submitReimbursement(row.id);
const { value: comment } = await ElMessageBox.prompt(
"提交后将进入审批流程,可填写提交说明,将记录在审批时间线中。",
"提交审核",
{
type: "warning",
confirmButtonText: "提交",
cancelButtonText: "取消",
inputType: "textarea",
inputPlaceholder: "请输入提交说明(选填)",
}
);
await submitReimbursement(row.id, comment || "");
ElMessage.success("提交成功");
loadList();
} catch (error) {
@@ -312,6 +339,31 @@ const handleSubmit = async (row) => {
}
};
// 撤回审批中的单据:回到草稿后可继续编辑费用条目,再重新提交
const handleWithdraw = async (row) => {
try {
const { value: comment } = await ElMessageBox.prompt(
"撤回后单据将回到草稿,可继续编辑费用条目并重新提交。",
"撤回确认",
{
type: "warning",
confirmButtonText: "确认撤回",
cancelButtonText: "取消",
inputType: "textarea",
inputPlaceholder: "请输入撤回原因",
inputValidator: (value) =>
String(value || "").trim() ? true : "请输入撤回原因",
}
);
await withdrawReimbursement(row.id, String(comment || "").trim());
ElMessage.success("已撤回,可继续编辑");
loadList();
} catch (error) {
if (error !== "cancel" && error !== "close")
ElMessage.error(error?.message || "撤回失败");
}
};
// 审批通过后由报销人本人确认款项到账,确认后状态变为「已打款」
const handleConfirmPaid = async (row) => {
try {
@@ -384,7 +436,11 @@ const formatDate = (value) => {
return formatDateUtil(value);
};
const statusLabel = (value) =>
statusOptions.find((item) => item.value === value)?.label || "未知";
statusOptions.find((item) => item.value === Number(value))?.label || "未知";
// 兼容后端把状态序列化为字符串的情况,避免编辑/提交按钮被误隐藏
const isStatus = (row, status) => Number(row?.status) === status;
// 可编辑状态:草稿、已驳回、已撤回;审批中需先撤回
const isEditable = (row) => [0, 3, 4].includes(Number(row?.status));
const statusType = (value) =>
({
0: "info",
@@ -393,7 +449,7 @@ const statusType = (value) =>
3: "danger",
4: "info",
5: "success",
})[value] || "info";
})[Number(value)] || "info";
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);