Browse Source
Vue 3 + Element Plus console for voucher review, source document inspection, push monitoring, rule learning, and the OA no-login voucher view. Co-Authored-By: Claude Code <noreply@anthropic.com>master
commit
b11d833502
26 changed files with 6555 additions and 0 deletions
@ -0,0 +1,8 @@ |
|||||
|
node_modules/ |
||||
|
dist/ |
||||
|
*.log |
||||
|
.env.local |
||||
|
.env.*.local |
||||
|
.idea/ |
||||
|
.vscode/ |
||||
|
.DS_Store |
||||
@ -0,0 +1,12 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8" /> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
|
<title>AutoVoucher 凭证自动化</title> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div id="app"></div> |
||||
|
<script type="module" src="/src/main.js"></script> |
||||
|
</body> |
||||
|
</html> |
||||
File diff suppressed because it is too large
@ -0,0 +1,22 @@ |
|||||
|
{ |
||||
|
"name": "auto-voucher-ui", |
||||
|
"version": "0.1.0", |
||||
|
"private": true, |
||||
|
"type": "module", |
||||
|
"scripts": { |
||||
|
"dev": "vite", |
||||
|
"build": "vite build", |
||||
|
"preview": "vite preview" |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@element-plus/icons-vue": "^2.3.1", |
||||
|
"axios": "^1.7.9", |
||||
|
"element-plus": "^2.9.1", |
||||
|
"vue": "^3.5.13", |
||||
|
"vue-router": "^4.5.0" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"@vitejs/plugin-vue": "^5.2.1", |
||||
|
"vite": "^6.0.5" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
<template> |
||||
|
<router-view /> |
||||
|
</template> |
||||
@ -0,0 +1,31 @@ |
|||||
|
import axios from 'axios' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
|
||||
|
const http = axios.create({ |
||||
|
baseURL: '/api', |
||||
|
withCredentials: true, |
||||
|
timeout: 30000 |
||||
|
}) |
||||
|
|
||||
|
http.interceptors.response.use( |
||||
|
(response) => response.data, |
||||
|
(error) => { |
||||
|
const status = error.response?.status |
||||
|
const body = error.response?.data |
||||
|
const isLogin = error.config?.url?.includes('/auth/login') |
||||
|
const message = body?.message || error.message || '请求失败' |
||||
|
if (isLogin) { |
||||
|
return Promise.reject(Object.assign(new Error(message), { status, code: body?.code, body })) |
||||
|
} |
||||
|
if (status === 401) { |
||||
|
if (window.location.pathname !== '/login') { |
||||
|
window.location.replace(`/login?redirect=${encodeURIComponent(window.location.pathname)}`) |
||||
|
} |
||||
|
} else { |
||||
|
ElMessage.error(message) |
||||
|
} |
||||
|
return Promise.reject(Object.assign(new Error(message), { status, code: body?.code, body })) |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
export default http |
||||
@ -0,0 +1,73 @@ |
|||||
|
import http from './http' |
||||
|
|
||||
|
export const authApi = { |
||||
|
login: (data) => http.post('/auth/login', data), |
||||
|
me: () => http.get('/auth/me'), |
||||
|
logout: () => http.post('/auth/logout') |
||||
|
} |
||||
|
|
||||
|
export const dashboardApi = { |
||||
|
get: () => http.get('/dashboard') |
||||
|
} |
||||
|
|
||||
|
export const sourceDocApi = { |
||||
|
list: (params) => http.get('/source-docs', { params }), |
||||
|
detail: (id) => http.get(`/source-docs/${id}`), |
||||
|
reprocess: (id) => http.post(`/source-docs/${id}/reprocess`), |
||||
|
manualVoucher: (id, data) => http.post(`/source-docs/${id}/manual-voucher`, data) |
||||
|
} |
||||
|
|
||||
|
export const voucherApi = { |
||||
|
list: (params) => http.get('/vouchers', { params }), |
||||
|
detail: (id) => http.get(`/vouchers/${id}`), |
||||
|
updateLines: (id, data) => http.put(`/vouchers/${id}/lines`, data), |
||||
|
approve: (id) => http.post(`/vouchers/${id}/approve`), |
||||
|
reject: (id, data) => http.post(`/vouchers/${id}/reject`, data), |
||||
|
regenerate: (id) => http.post(`/vouchers/${id}/regenerate`) |
||||
|
} |
||||
|
|
||||
|
export const pushTaskApi = { |
||||
|
list: (params) => http.get('/push-tasks', { params }), |
||||
|
detail: (id) => http.get(`/push-tasks/${id}`), |
||||
|
push: (id, behavior) => http.post(`/push-tasks/${id}/push`, null, { params: { behavior } }), |
||||
|
retry: (id) => http.post(`/push-tasks/${id}/retry`), |
||||
|
reconcile: (id) => http.post(`/push-tasks/${id}/reconcile`) |
||||
|
} |
||||
|
|
||||
|
export const ruleApi = { |
||||
|
list: (params) => http.get('/rules', { params }), |
||||
|
detail: (id) => http.get(`/rules/${id}`), |
||||
|
create: (data) => http.post('/rules', data), |
||||
|
update: (id, data) => http.put(`/rules/${id}`, data) |
||||
|
} |
||||
|
|
||||
|
export const candidateApi = { |
||||
|
list: (params) => http.get('/rule-candidates', { params }), |
||||
|
approve: (id, data) => http.post(`/rule-candidates/${id}/approve`, data || {}), |
||||
|
reject: (id) => http.post(`/rule-candidates/${id}/reject`) |
||||
|
} |
||||
|
|
||||
|
export const masterDataApi = { |
||||
|
all: (params) => http.get('/master-data', { params }), |
||||
|
accounts: (params) => http.get('/master-data/accounts', { params }) |
||||
|
} |
||||
|
|
||||
|
export const auditLogApi = { |
||||
|
list: (params) => http.get('/audit-logs', { params }) |
||||
|
} |
||||
|
|
||||
|
export const learningApi = { |
||||
|
corrections: (params) => http.get('/learning/corrections', { params }), |
||||
|
cases: (params) => http.get('/learning/cases', { params }) |
||||
|
} |
||||
|
|
||||
|
export const devApi = { |
||||
|
oaTicket: (data) => http.post('/dev/oa/ticket', data), |
||||
|
oaEvent: (data) => http.post('/dev/oa/event', data), |
||||
|
oaExpire: (ticketId) => http.post(`/dev/oa/expire/${ticketId}`), |
||||
|
oaTickets: () => http.get('/dev/oa/tickets'), |
||||
|
oaLogs: (ticketId) => http.get('/dev/oa/logs', { params: { ticketId } }), |
||||
|
getNccBehavior: () => http.get('/dev/ncc/behavior'), |
||||
|
setNccBehavior: (behavior) => http.post('/dev/ncc/behavior', { behavior }), |
||||
|
resetNcc: () => http.post('/dev/ncc/reset') |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
<script setup> |
||||
|
import { computed } from 'vue' |
||||
|
|
||||
|
const props = defineProps({ |
||||
|
value: { type: [String, Object, Array], default: '' } |
||||
|
}) |
||||
|
|
||||
|
const text = computed(() => { |
||||
|
if (props.value === null || props.value === undefined || props.value === '') { |
||||
|
return '-' |
||||
|
} |
||||
|
if (typeof props.value === 'string') { |
||||
|
try { |
||||
|
return JSON.stringify(JSON.parse(props.value), null, 2) |
||||
|
} catch (e) { |
||||
|
return props.value |
||||
|
} |
||||
|
} |
||||
|
return JSON.stringify(props.value, null, 2) |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<pre class="json-block">{{ text }}</pre> |
||||
|
</template> |
||||
@ -0,0 +1,199 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue' |
||||
|
import { devApi } from '../api' |
||||
|
import StatusTag from './StatusTag.vue' |
||||
|
|
||||
|
const props = defineProps({ |
||||
|
modelValue: { type: Boolean, default: false }, |
||||
|
doc: { type: Object, default: null } |
||||
|
}) |
||||
|
const emit = defineEmits(['update:modelValue']) |
||||
|
|
||||
|
const operatorId = ref('') |
||||
|
const ticket = ref(null) |
||||
|
const status = ref(null) |
||||
|
const loading = ref(false) |
||||
|
const errorMessage = ref('') |
||||
|
let timer = null |
||||
|
|
||||
|
const viewSrc = computed(() => |
||||
|
ticket.value ? `/open/oa/view/${ticket.value.token}` : '' |
||||
|
) |
||||
|
|
||||
|
function visible(value) { |
||||
|
emit('update:modelValue', value) |
||||
|
} |
||||
|
|
||||
|
async function sign() { |
||||
|
if (!props.doc?.externalId) { |
||||
|
errorMessage.value = '单据缺少 externalId,无法签票' |
||||
|
return |
||||
|
} |
||||
|
stopPolling() |
||||
|
loading.value = true |
||||
|
errorMessage.value = '' |
||||
|
ticket.value = null |
||||
|
status.value = null |
||||
|
try { |
||||
|
const result = await devApi.oaTicket({ |
||||
|
sourceSystem: props.doc.sourceSystem || 'SEEYON', |
||||
|
externalId: props.doc.externalId, |
||||
|
operatorId: operatorId.value |
||||
|
}) |
||||
|
ticket.value = result.ticket |
||||
|
status.value = { |
||||
|
status: result.ticket.status, |
||||
|
message: result.ticket.message, |
||||
|
voucherId: null, |
||||
|
voucherNo: null, |
||||
|
voucherStatus: null |
||||
|
} |
||||
|
if (result.ticket.status === 'GENERATING') { |
||||
|
startPolling() |
||||
|
} else { |
||||
|
await pollStatus() |
||||
|
} |
||||
|
} catch (e) { |
||||
|
errorMessage.value = e.body?.message || e.message |
||||
|
status.value = { status: 'DENIED', message: e.code } |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function pollStatus() { |
||||
|
if (!ticket.value?.token) { |
||||
|
return |
||||
|
} |
||||
|
try { |
||||
|
const response = await fetch(`/open/oa/view/${ticket.value.token}/status`, { |
||||
|
headers: { Accept: 'application/json' }, |
||||
|
cache: 'no-store' |
||||
|
}) |
||||
|
if (response.ok) { |
||||
|
status.value = await response.json() |
||||
|
if (status.value.status !== 'GENERATING') { |
||||
|
stopPolling() |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
// transient polling failures are ignored |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function startPolling() { |
||||
|
stopPolling() |
||||
|
timer = window.setInterval(pollStatus, 1500) |
||||
|
} |
||||
|
|
||||
|
function stopPolling() { |
||||
|
if (timer) { |
||||
|
window.clearInterval(timer) |
||||
|
timer = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function expireTicket() { |
||||
|
if (!ticket.value?.ticketId) { |
||||
|
return |
||||
|
} |
||||
|
await devApi.oaExpire(ticket.value.ticketId) |
||||
|
await pollStatus() |
||||
|
} |
||||
|
|
||||
|
watch( |
||||
|
() => props.modelValue, |
||||
|
async (value) => { |
||||
|
if (value) { |
||||
|
operatorId.value = props.doc?.applicantId || '' |
||||
|
await sign() |
||||
|
} else { |
||||
|
stopPolling() |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
onBeforeUnmount(stopPolling) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<el-dialog |
||||
|
:model-value="modelValue" |
||||
|
title="OA 内嵌查看凭证(免登录)" |
||||
|
width="72%" |
||||
|
top="4vh" |
||||
|
destroy-on-close |
||||
|
@update:model-value="visible" |
||||
|
> |
||||
|
<div class="oa-toolbar"> |
||||
|
<el-input v-model="operatorId" placeholder="OA 操作人工号" style="width: 180px" /> |
||||
|
<el-button :loading="loading" type="primary" @click="sign">重新签票</el-button> |
||||
|
<el-button v-if="ticket" @click="expireTicket">模拟令牌过期</el-button> |
||||
|
<el-tag v-if="status" effect="plain"> |
||||
|
{{ status.status }}<template v-if="status.message"> · {{ status.message }}</template> |
||||
|
</el-tag> |
||||
|
<el-tag v-if="status?.voucherNo" type="success" effect="plain"> |
||||
|
{{ status.voucherNo }} · {{ status.voucherStatus }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
|
||||
|
<el-alert |
||||
|
v-if="errorMessage" |
||||
|
:title="errorMessage" |
||||
|
type="error" |
||||
|
show-icon |
||||
|
:closable="false" |
||||
|
style="margin-bottom: 12px" |
||||
|
/> |
||||
|
|
||||
|
<div class="oa-hint"> |
||||
|
此页面由 OA 服务端验签换票后打开,OA 用户无需登录 autoVoucher。 |
||||
|
演示环境直接调用 /api/dev/oa/ticket 生成签名票据。 |
||||
|
</div> |
||||
|
|
||||
|
<div v-loading="loading" class="oa-frame-wrap"> |
||||
|
<iframe |
||||
|
v-if="viewSrc" |
||||
|
:key="viewSrc" |
||||
|
:src="viewSrc" |
||||
|
class="oa-frame" |
||||
|
title="凭证查看" |
||||
|
/> |
||||
|
<el-empty v-else description="尚未取得查看令牌" /> |
||||
|
</div> |
||||
|
|
||||
|
<template #footer> |
||||
|
<el-button @click="visible(false)">关闭</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.oa-toolbar { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 8px; |
||||
|
align-items: center; |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
|
||||
|
.oa-hint { |
||||
|
margin-bottom: 10px; |
||||
|
color: #8a94a6; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.oa-frame-wrap { |
||||
|
min-height: 420px; |
||||
|
border: 1px solid #e4e7ed; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.oa-frame { |
||||
|
display: block; |
||||
|
width: 100%; |
||||
|
height: 620px; |
||||
|
border: 0; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,71 @@ |
|||||
|
<script setup> |
||||
|
import { computed } from 'vue' |
||||
|
|
||||
|
const props = defineProps({ |
||||
|
status: { type: String, default: '' } |
||||
|
}) |
||||
|
|
||||
|
const LABELS = { |
||||
|
RECEIVED: '已接入', |
||||
|
PROCESSING: '处理中', |
||||
|
IGNORED: '已忽略', |
||||
|
MANUAL_REQUIRED: '待人工判断', |
||||
|
PENDING_REVIEW: '待审核', |
||||
|
APPROVED: '已审核', |
||||
|
REJECTED: '已驳回', |
||||
|
SUPERSEDED: '已被替换', |
||||
|
PUSHED: '已推送', |
||||
|
PUSH_FAILED: '推送失败', |
||||
|
PUSH_UNKNOWN: '推送未知', |
||||
|
PENDING: '待推送', |
||||
|
RETRY_WAIT: '待重试', |
||||
|
UNKNOWN: '结果未知', |
||||
|
SUCCESS: '成功', |
||||
|
SUCCESS_WITH_EXISTING: '重复已确认', |
||||
|
FAILED: '失败', |
||||
|
ACTIVE: '启用中', |
||||
|
INACTIVE: '已停用', |
||||
|
DISABLED: '已停用', |
||||
|
DRAFT: '草稿', |
||||
|
READY: '可查看', |
||||
|
GENERATING: '生成中', |
||||
|
EXPIRED: '已过期', |
||||
|
DENIED: '无权限', |
||||
|
DEBIT: '借', |
||||
|
CREDIT: '贷' |
||||
|
} |
||||
|
|
||||
|
const TYPES = { |
||||
|
RECEIVED: 'info', |
||||
|
PROCESSING: 'primary', |
||||
|
IGNORED: 'info', |
||||
|
MANUAL_REQUIRED: 'warning', |
||||
|
PENDING_REVIEW: 'warning', |
||||
|
APPROVED: 'success', |
||||
|
REJECTED: 'danger', |
||||
|
SUPERSEDED: 'info', |
||||
|
PUSHED: 'success', |
||||
|
PUSH_FAILED: 'danger', |
||||
|
PUSH_UNKNOWN: 'warning', |
||||
|
PENDING: 'info', |
||||
|
RETRY_WAIT: 'warning', |
||||
|
UNKNOWN: 'warning', |
||||
|
SUCCESS: 'success', |
||||
|
SUCCESS_WITH_EXISTING: 'success', |
||||
|
FAILED: 'danger', |
||||
|
ACTIVE: 'success', |
||||
|
INACTIVE: 'info', |
||||
|
DISABLED: 'info', |
||||
|
READY: 'success', |
||||
|
GENERATING: 'warning', |
||||
|
EXPIRED: 'info', |
||||
|
DENIED: 'danger' |
||||
|
} |
||||
|
|
||||
|
const label = computed(() => LABELS[props.status] || props.status || '-') |
||||
|
const type = computed(() => TYPES[props.status] || 'info') |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<el-tag :type="type" disable-transitions effect="light">{{ label }}</el-tag> |
||||
|
</template> |
||||
@ -0,0 +1,39 @@ |
|||||
|
import { reactive } from 'vue' |
||||
|
import { authApi } from '../api' |
||||
|
|
||||
|
const state = reactive({ |
||||
|
user: null, |
||||
|
loaded: false |
||||
|
}) |
||||
|
|
||||
|
export function useAuth() { |
||||
|
async function ensure() { |
||||
|
if (!state.loaded) { |
||||
|
try { |
||||
|
state.user = await authApi.me() |
||||
|
} catch (e) { |
||||
|
state.user = null |
||||
|
} finally { |
||||
|
state.loaded = true |
||||
|
} |
||||
|
} |
||||
|
return state.user |
||||
|
} |
||||
|
|
||||
|
async function login(username, password) { |
||||
|
state.user = await authApi.login({ username, password }) |
||||
|
state.loaded = true |
||||
|
return state.user |
||||
|
} |
||||
|
|
||||
|
async function logout() { |
||||
|
try { |
||||
|
await authApi.logout() |
||||
|
} finally { |
||||
|
state.user = null |
||||
|
state.loaded = true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { state, ensure, login, logout } |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
<script setup> |
||||
|
import { computed } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { useAuth } from '../composables/useAuth' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
const { state, logout } = useAuth() |
||||
|
|
||||
|
const activeMenu = computed(() => { |
||||
|
if (route.path.startsWith('/vouchers')) { |
||||
|
return '/vouchers' |
||||
|
} |
||||
|
return route.path |
||||
|
}) |
||||
|
|
||||
|
async function signOut() { |
||||
|
await logout() |
||||
|
router.replace('/login') |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<el-container class="layout"> |
||||
|
<el-aside width="224px" class="sidebar"> |
||||
|
<div class="brand"> |
||||
|
<div class="brand-mark">AV</div> |
||||
|
<div> |
||||
|
<div class="brand-name">AutoVoucher</div> |
||||
|
<div class="brand-desc">费用凭证自动化</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<el-menu |
||||
|
:default-active="activeMenu" |
||||
|
router |
||||
|
class="menu" |
||||
|
background-color="#1f2d3d" |
||||
|
text-color="#c6d0de" |
||||
|
active-text-color="#ffffff" |
||||
|
> |
||||
|
<el-menu-item index="/"> |
||||
|
<el-icon><Odometer /></el-icon> |
||||
|
<span>工作台</span> |
||||
|
</el-menu-item> |
||||
|
<el-menu-item index="/source-docs"> |
||||
|
<el-icon><Document /></el-icon> |
||||
|
<span>单据接入</span> |
||||
|
</el-menu-item> |
||||
|
<el-menu-item index="/vouchers"> |
||||
|
<el-icon><Tickets /></el-icon> |
||||
|
<span>凭证审核</span> |
||||
|
</el-menu-item> |
||||
|
<el-menu-item index="/push-tasks"> |
||||
|
<el-icon><Upload /></el-icon> |
||||
|
<span>推送监控</span> |
||||
|
</el-menu-item> |
||||
|
<el-menu-item index="/rules"> |
||||
|
<el-icon><SetUp /></el-icon> |
||||
|
<span>规则与学习</span> |
||||
|
</el-menu-item> |
||||
|
<el-menu-item index="/master-data"> |
||||
|
<el-icon><Coin /></el-icon> |
||||
|
<span>基础档案</span> |
||||
|
</el-menu-item> |
||||
|
<el-menu-item index="/audit-logs"> |
||||
|
<el-icon><Memo /></el-icon> |
||||
|
<span>审计日志</span> |
||||
|
</el-menu-item> |
||||
|
</el-menu> |
||||
|
</el-aside> |
||||
|
|
||||
|
<el-container> |
||||
|
<el-header class="topbar"> |
||||
|
<div class="topbar-title">费用凭证自动化平台</div> |
||||
|
<el-dropdown trigger="click" @command="signOut"> |
||||
|
<span class="user-chip"> |
||||
|
<el-icon><UserFilled /></el-icon> |
||||
|
{{ state.user?.username || '未登录' }} |
||||
|
<el-icon><ArrowDown /></el-icon> |
||||
|
</span> |
||||
|
<template #dropdown> |
||||
|
<el-dropdown-menu> |
||||
|
<el-dropdown-item command="logout">退出登录</el-dropdown-item> |
||||
|
</el-dropdown-menu> |
||||
|
</template> |
||||
|
</el-dropdown> |
||||
|
</el-header> |
||||
|
|
||||
|
<el-main class="main"> |
||||
|
<router-view /> |
||||
|
</el-main> |
||||
|
</el-container> |
||||
|
</el-container> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.layout { |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.sidebar { |
||||
|
background: var(--av-sidebar); |
||||
|
color: #fff; |
||||
|
overflow-x: hidden; |
||||
|
} |
||||
|
|
||||
|
.brand { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
padding: 20px 18px 16px; |
||||
|
} |
||||
|
|
||||
|
.brand-mark { |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
width: 38px; |
||||
|
height: 38px; |
||||
|
border-radius: 10px; |
||||
|
background: var(--av-sidebar-active); |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
.brand-name { |
||||
|
font-size: 16px; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.brand-desc { |
||||
|
margin-top: 2px; |
||||
|
color: #8a9bb3; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.menu { |
||||
|
border-right: 0; |
||||
|
} |
||||
|
|
||||
|
.menu :deep(.el-menu-item.is-active) { |
||||
|
background: var(--av-sidebar-active); |
||||
|
} |
||||
|
|
||||
|
.topbar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
background: #fff; |
||||
|
border-bottom: 1px solid #e9edf3; |
||||
|
} |
||||
|
|
||||
|
.topbar-title { |
||||
|
font-size: 14px; |
||||
|
color: #5b6878; |
||||
|
} |
||||
|
|
||||
|
.user-chip { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 6px; |
||||
|
cursor: pointer; |
||||
|
color: #384a62; |
||||
|
font-size: 14px; |
||||
|
outline: none; |
||||
|
} |
||||
|
|
||||
|
.main { |
||||
|
padding: 0; |
||||
|
overflow-y: auto; |
||||
|
background: var(--av-bg); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,16 @@ |
|||||
|
import { createApp } from 'vue' |
||||
|
import ElementPlus from 'element-plus' |
||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn' |
||||
|
import 'element-plus/dist/index.css' |
||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue' |
||||
|
import App from './App.vue' |
||||
|
import router from './router' |
||||
|
import './styles.css' |
||||
|
|
||||
|
const app = createApp(App) |
||||
|
for (const [name, component] of Object.entries(ElementPlusIconsVue)) { |
||||
|
app.component(name, component) |
||||
|
} |
||||
|
app.use(ElementPlus, { locale: zhCn }) |
||||
|
app.use(router) |
||||
|
app.mount('#app') |
||||
@ -0,0 +1,45 @@ |
|||||
|
import { createRouter, createWebHistory } from 'vue-router' |
||||
|
import { useAuth } from '../composables/useAuth' |
||||
|
|
||||
|
const routes = [ |
||||
|
{ |
||||
|
path: '/login', |
||||
|
name: 'login', |
||||
|
component: () => import('../views/LoginView.vue'), |
||||
|
meta: { public: true } |
||||
|
}, |
||||
|
{ |
||||
|
path: '/', |
||||
|
component: () => import('../layouts/AppLayout.vue'), |
||||
|
children: [ |
||||
|
{ path: '', name: 'dashboard', component: () => import('../views/DashboardView.vue') }, |
||||
|
{ path: 'source-docs', name: 'source-docs', component: () => import('../views/SourceDocsView.vue') }, |
||||
|
{ path: 'vouchers', name: 'vouchers', component: () => import('../views/VouchersView.vue') }, |
||||
|
{ path: 'vouchers/:id', name: 'voucher-detail', component: () => import('../views/VoucherDetailView.vue') }, |
||||
|
{ path: 'push-tasks', name: 'push-tasks', component: () => import('../views/PushTasksView.vue') }, |
||||
|
{ path: 'rules', name: 'rules', component: () => import('../views/RulesView.vue') }, |
||||
|
{ path: 'master-data', name: 'master-data', component: () => import('../views/MasterDataView.vue') }, |
||||
|
{ path: 'audit-logs', name: 'audit-logs', component: () => import('../views/AuditLogsView.vue') } |
||||
|
] |
||||
|
}, |
||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' } |
||||
|
] |
||||
|
|
||||
|
const router = createRouter({ |
||||
|
history: createWebHistory(), |
||||
|
routes |
||||
|
}) |
||||
|
|
||||
|
router.beforeEach(async (to) => { |
||||
|
if (to.meta.public) { |
||||
|
return true |
||||
|
} |
||||
|
const { ensure } = useAuth() |
||||
|
const user = await ensure() |
||||
|
if (!user) { |
||||
|
return { name: 'login', query: { redirect: to.fullPath } } |
||||
|
} |
||||
|
return true |
||||
|
}) |
||||
|
|
||||
|
export default router |
||||
@ -0,0 +1,187 @@ |
|||||
|
:root { |
||||
|
--av-bg: #f4f6f9; |
||||
|
--av-sidebar: #1f2d3d; |
||||
|
--av-sidebar-active: #2f6fed; |
||||
|
--av-text-muted: #8a94a6; |
||||
|
} |
||||
|
|
||||
|
* { |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
|
||||
|
html, |
||||
|
body, |
||||
|
#app { |
||||
|
height: 100%; |
||||
|
margin: 0; |
||||
|
} |
||||
|
|
||||
|
body { |
||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', |
||||
|
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; |
||||
|
background: var(--av-bg); |
||||
|
color: #1f2d3d; |
||||
|
} |
||||
|
|
||||
|
.page { |
||||
|
padding: 18px 22px 32px; |
||||
|
} |
||||
|
|
||||
|
.page-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
gap: 16px; |
||||
|
margin-bottom: 16px; |
||||
|
} |
||||
|
|
||||
|
.page-title { |
||||
|
margin: 0; |
||||
|
font-size: 20px; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.page-subtitle { |
||||
|
margin-top: 4px; |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.filter-bar { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
margin-bottom: 14px; |
||||
|
} |
||||
|
|
||||
|
.card + .card { |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.mono { |
||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.json-block { |
||||
|
margin: 0; |
||||
|
padding: 10px 12px; |
||||
|
border-radius: 6px; |
||||
|
background: #f7f8fa; |
||||
|
color: #384a62; |
||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; |
||||
|
font-size: 12px; |
||||
|
line-height: 1.55; |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-all; |
||||
|
max-height: 360px; |
||||
|
overflow: auto; |
||||
|
} |
||||
|
|
||||
|
.muted { |
||||
|
color: var(--av-text-muted); |
||||
|
} |
||||
|
|
||||
|
.amount { |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.stat-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); |
||||
|
gap: 14px; |
||||
|
} |
||||
|
|
||||
|
.stat-card { |
||||
|
background: #fff; |
||||
|
border-radius: 10px; |
||||
|
padding: 16px 18px; |
||||
|
box-shadow: 0 1px 2px rgba(31, 45, 61, 0.06); |
||||
|
} |
||||
|
|
||||
|
.stat-label { |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.stat-value { |
||||
|
margin-top: 6px; |
||||
|
font-size: 28px; |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
.stat-hint { |
||||
|
margin-top: 6px; |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.toolbar-actions { |
||||
|
display: flex; |
||||
|
gap: 8px; |
||||
|
flex-wrap: wrap; |
||||
|
} |
||||
|
|
||||
|
.el-table .cell { |
||||
|
word-break: break-word; |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 12px; |
||||
|
font-size: 15px; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.login-page { |
||||
|
display: flex; |
||||
|
min-height: 100%; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: linear-gradient(135deg, #1f2d3d 0%, #22406b 55%, #2f6fed 100%); |
||||
|
} |
||||
|
|
||||
|
.login-card { |
||||
|
width: 380px; |
||||
|
padding: 30px 32px 26px; |
||||
|
border-radius: 14px; |
||||
|
background: #fff; |
||||
|
box-shadow: 0 18px 50px rgba(12, 24, 44, 0.35); |
||||
|
} |
||||
|
|
||||
|
.login-brand { |
||||
|
margin-bottom: 22px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.login-brand h1 { |
||||
|
margin: 0 0 6px; |
||||
|
font-size: 22px; |
||||
|
} |
||||
|
|
||||
|
.login-brand p { |
||||
|
margin: 0; |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.detail-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||
|
gap: 6px 18px; |
||||
|
} |
||||
|
|
||||
|
.detail-item { |
||||
|
padding: 5px 0; |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.detail-item .label { |
||||
|
display: inline-block; |
||||
|
min-width: 82px; |
||||
|
color: var(--av-text-muted); |
||||
|
} |
||||
@ -0,0 +1,48 @@ |
|||||
|
export function formatDateTime(value) { |
||||
|
if (!value) { |
||||
|
return '-' |
||||
|
} |
||||
|
const date = new Date(value) |
||||
|
if (Number.isNaN(date.getTime())) { |
||||
|
return String(value) |
||||
|
} |
||||
|
const pad = (n) => String(n).padStart(2, '0') |
||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( |
||||
|
date.getHours() |
||||
|
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` |
||||
|
} |
||||
|
|
||||
|
export function formatDate(value) { |
||||
|
if (!value) { |
||||
|
return '-' |
||||
|
} |
||||
|
return String(value).slice(0, 10) |
||||
|
} |
||||
|
|
||||
|
export function formatAmount(value, currency = 'CNY') { |
||||
|
if (value === null || value === undefined || value === '') { |
||||
|
return '-' |
||||
|
} |
||||
|
const number = Number(value) |
||||
|
if (Number.isNaN(number)) { |
||||
|
return String(value) |
||||
|
} |
||||
|
return `${number.toLocaleString('zh-CN', { |
||||
|
minimumFractionDigits: 2, |
||||
|
maximumFractionDigits: 2 |
||||
|
})}${currency ? ' ' + currency : ''}` |
||||
|
} |
||||
|
|
||||
|
export function pretty(value) { |
||||
|
if (value === null || value === undefined || value === '') { |
||||
|
return '' |
||||
|
} |
||||
|
if (typeof value === 'string') { |
||||
|
try { |
||||
|
return JSON.stringify(JSON.parse(value), null, 2) |
||||
|
} catch (e) { |
||||
|
return value |
||||
|
} |
||||
|
} |
||||
|
return JSON.stringify(value, null, 2) |
||||
|
} |
||||
@ -0,0 +1,291 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onMounted, reactive, ref } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { auditLogApi } from '../api' |
||||
|
import JsonBlock from '../components/JsonBlock.vue' |
||||
|
import { formatDateTime } from '../utils/format' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const logs = ref([]) |
||||
|
const filters = reactive({ |
||||
|
action: '', |
||||
|
entityType: '', |
||||
|
entityId: '', |
||||
|
keyword: '', |
||||
|
limit: 200 |
||||
|
}) |
||||
|
|
||||
|
const ACTIONS = [ |
||||
|
'VOUCHER_GENERATED', |
||||
|
'VOUCHER_MANUAL_REQUIRED', |
||||
|
'VOUCHER_LINES_UPDATED', |
||||
|
'VOUCHER_APPROVED', |
||||
|
'VOUCHER_REJECTED', |
||||
|
'MANUAL_CANDIDATE_SELECTED', |
||||
|
'PUSH_TARGET_MISSING', |
||||
|
'PUSH_SUCCESS', |
||||
|
'PUSH_DUPLICATE_ACCEPTED', |
||||
|
'PUSH_FAILED', |
||||
|
'PUSH_UNKNOWN', |
||||
|
'PUSH_RECONCILED_NOT_FOUND', |
||||
|
'RULE_CREATED', |
||||
|
'RULE_UPDATED', |
||||
|
'RULE_CANDIDATE_APPROVED', |
||||
|
'RULE_CANDIDATE_REJECTED' |
||||
|
] |
||||
|
|
||||
|
const ACTION_LABELS = { |
||||
|
VOUCHER_GENERATED: '生成凭证', |
||||
|
VOUCHER_MANUAL_REQUIRED: '转人工判断', |
||||
|
VOUCHER_LINES_UPDATED: '修改分录', |
||||
|
VOUCHER_APPROVED: '审核通过', |
||||
|
VOUCHER_REJECTED: '驳回凭证', |
||||
|
MANUAL_CANDIDATE_SELECTED: '手工选择科目', |
||||
|
PUSH_TARGET_MISSING: '缺少推送目标', |
||||
|
PUSH_SUCCESS: '推送成功', |
||||
|
PUSH_DUPLICATE_ACCEPTED: '重复凭证确认', |
||||
|
PUSH_FAILED: '推送失败', |
||||
|
PUSH_UNKNOWN: '推送结果未知', |
||||
|
PUSH_RECONCILED_NOT_FOUND: '核对无结果', |
||||
|
RULE_CREATED: '创建规则', |
||||
|
RULE_UPDATED: '更新规则', |
||||
|
RULE_CANDIDATE_APPROVED: '通过规则候选', |
||||
|
RULE_CANDIDATE_REJECTED: '拒绝规则候选' |
||||
|
} |
||||
|
|
||||
|
const ENTITY_TYPES = [ |
||||
|
'VOUCHER', |
||||
|
'SOURCE_DOC', |
||||
|
'PUSH_TASK', |
||||
|
'MAPPING_RULE', |
||||
|
'RULE_CANDIDATE' |
||||
|
] |
||||
|
|
||||
|
const ENTITY_LABELS = { |
||||
|
VOUCHER: '凭证', |
||||
|
SOURCE_DOC: '来源单据', |
||||
|
PUSH_TASK: '推送任务', |
||||
|
MAPPING_RULE: '映射规则', |
||||
|
RULE_CANDIDATE: '规则候选' |
||||
|
} |
||||
|
|
||||
|
const LIMITS = [50, 200, 500, 1000, 2000] |
||||
|
|
||||
|
const operators = computed(() => { |
||||
|
const set = new Set() |
||||
|
for (const log of logs.value) { |
||||
|
set.add(log.operatorId || 'system') |
||||
|
} |
||||
|
return Array.from(set).sort() |
||||
|
}) |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
logs.value = await auditLogApi.list({ |
||||
|
action: filters.action || undefined, |
||||
|
entityType: filters.entityType || undefined, |
||||
|
entityId: filters.entityId.trim() || undefined, |
||||
|
keyword: filters.keyword.trim() || undefined, |
||||
|
limit: filters.limit |
||||
|
}) |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function resetFilters() { |
||||
|
filters.action = '' |
||||
|
filters.entityType = '' |
||||
|
filters.entityId = '' |
||||
|
filters.keyword = '' |
||||
|
filters.limit = 200 |
||||
|
load() |
||||
|
} |
||||
|
|
||||
|
function actionLabel(action) { |
||||
|
return ACTION_LABELS[action] || action |
||||
|
} |
||||
|
|
||||
|
function entityLabel(type) { |
||||
|
return ENTITY_LABELS[type] || type || '-' |
||||
|
} |
||||
|
|
||||
|
function openEntity(row) { |
||||
|
const id = row.entityId |
||||
|
if (!id) { |
||||
|
return |
||||
|
} |
||||
|
switch (row.entityType) { |
||||
|
case 'VOUCHER': |
||||
|
router.push(`/vouchers/${id}`) |
||||
|
break |
||||
|
case 'PUSH_TASK': |
||||
|
router.push({ path: '/push-tasks', query: { taskId: id } }) |
||||
|
break |
||||
|
case 'SOURCE_DOC': |
||||
|
router.push({ path: '/source-docs', query: { docId: id } }) |
||||
|
break |
||||
|
case 'MAPPING_RULE': |
||||
|
case 'RULE_CANDIDATE': |
||||
|
router.push('/rules') |
||||
|
break |
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function detailPreview(value) { |
||||
|
if (!value) { |
||||
|
return '-' |
||||
|
} |
||||
|
try { |
||||
|
const parsed = JSON.parse(value) |
||||
|
const text = JSON.stringify(parsed) |
||||
|
return text.length > 140 ? `${text.slice(0, 140)}…` : text |
||||
|
} catch (e) { |
||||
|
return value.length > 140 ? `${value.slice(0, 140)}…` : value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(load) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">审计日志</h2> |
||||
|
<div class="page-subtitle"> |
||||
|
凭证生成、修改、审核、推送与规则变更的全链路操作留痕;日志只读,用于追溯与对账 |
||||
|
</div> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select |
||||
|
v-model="filters.action" |
||||
|
placeholder="操作类型" |
||||
|
clearable |
||||
|
filterable |
||||
|
allow-create |
||||
|
style="width: 210px" |
||||
|
@change="load" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="a in ACTIONS" |
||||
|
:key="a" |
||||
|
:label="`${actionLabel(a)} (${a})`" |
||||
|
:value="a" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<el-select |
||||
|
v-model="filters.entityType" |
||||
|
placeholder="实体类型" |
||||
|
clearable |
||||
|
style="width: 150px" |
||||
|
@change="load" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="t in ENTITY_TYPES" |
||||
|
:key="t" |
||||
|
:label="`${entityLabel(t)} (${t})`" |
||||
|
:value="t" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<el-input |
||||
|
v-model="filters.entityId" |
||||
|
placeholder="实体 ID" |
||||
|
clearable |
||||
|
style="width: 120px" |
||||
|
@keyup.enter="load" |
||||
|
@clear="load" |
||||
|
/> |
||||
|
<el-input |
||||
|
v-model="filters.keyword" |
||||
|
placeholder="关键字(操作人 / 明细内容)" |
||||
|
clearable |
||||
|
style="width: 240px" |
||||
|
@keyup.enter="load" |
||||
|
@clear="load" |
||||
|
/> |
||||
|
<el-select v-model="filters.limit" style="width: 120px" @change="load"> |
||||
|
<el-option v-for="n in LIMITS" :key="n" :label="`最近 ${n} 条`" :value="n" /> |
||||
|
</el-select> |
||||
|
<el-button type="primary" @click="load">查询</el-button> |
||||
|
<el-button @click="resetFilters">重置</el-button> |
||||
|
<span class="muted">共 {{ logs.length }} 条</span> |
||||
|
</div> |
||||
|
|
||||
|
<el-table |
||||
|
v-loading="loading" |
||||
|
:data="logs" |
||||
|
size="small" |
||||
|
empty-text="暂无审计日志" |
||||
|
max-height="680" |
||||
|
> |
||||
|
<el-table-column prop="id" label="#" width="70" /> |
||||
|
<el-table-column label="时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<div>{{ actionLabel(row.action) }}</div> |
||||
|
<div class="mono muted">{{ row.action }}</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="实体" min-width="160"> |
||||
|
<template #default="{ row }"> |
||||
|
<div>{{ entityLabel(row.entityType) }}</div> |
||||
|
<div class="mono muted"> |
||||
|
{{ row.entityType || '-' }}{{ row.entityId ? ` #${row.entityId}` : '' }} |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="operatorId" label="操作人" width="120"> |
||||
|
<template #default="{ row }">{{ row.operatorId || 'system' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="明细" min-width="300"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono muted">{{ detailPreview(row.detailJson) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="90" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
v-if="row.entityId" |
||||
|
text |
||||
|
type="primary" |
||||
|
@click="openEntity(row)" |
||||
|
> |
||||
|
查看 |
||||
|
</el-button> |
||||
|
<span v-else class="muted">-</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-body"> |
||||
|
<div class="muted">操作明细</div> |
||||
|
<JsonBlock :value="row.detailJson" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
|
||||
|
<div class="muted" style="margin-top: 10px; font-size: 12px"> |
||||
|
当前加载 {{ logs.length }} 条 · 涉及 {{ operators.length }} 个操作人:{{ operators.join('、') || '-' }} |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.expand-body { |
||||
|
padding: 8px 16px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,220 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onMounted, ref } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { dashboardApi } from '../api' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import { formatAmount, formatDateTime } from '../utils/format' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const loading = ref(false) |
||||
|
const data = ref(null) |
||||
|
const error = ref('') |
||||
|
|
||||
|
const sourceDocs = computed(() => data.value?.sourceDocs || { total: 0, byStatus: {} }) |
||||
|
const vouchers = computed(() => data.value?.vouchers || { total: 0, byStatus: {} }) |
||||
|
const pushTasks = computed(() => data.value?.pushTasks || { total: 0, byStatus: {} }) |
||||
|
|
||||
|
const pendingReview = computed(() => vouchers.value.byStatus?.PENDING_REVIEW || 0) |
||||
|
const pushPending = computed( |
||||
|
() => |
||||
|
(pushTasks.value.byStatus?.PENDING || 0) + |
||||
|
(pushTasks.value.byStatus?.RETRY_WAIT || 0) |
||||
|
) |
||||
|
const pushAbnormal = computed( |
||||
|
() => |
||||
|
(pushTasks.value.byStatus?.FAILED || 0) + |
||||
|
(pushTasks.value.byStatus?.UNKNOWN || 0) |
||||
|
) |
||||
|
|
||||
|
const docStatuses = computed(() => |
||||
|
Object.entries(sourceDocs.value.byStatus || {}).sort((a, b) => b[1] - a[1]) |
||||
|
) |
||||
|
const voucherStatuses = computed(() => |
||||
|
Object.entries(vouchers.value.byStatus || {}).sort((a, b) => b[1] - a[1]) |
||||
|
) |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
error.value = '' |
||||
|
try { |
||||
|
data.value = await dashboardApi.get() |
||||
|
} catch (e) { |
||||
|
error.value = e.body?.message || e.message |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(load) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">工作台</h2> |
||||
|
<div class="page-subtitle">OA 单据 → AI 凭证建议 → 财务审核 → NCC 推送</div> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-alert |
||||
|
v-if="error" |
||||
|
:title="error" |
||||
|
type="error" |
||||
|
show-icon |
||||
|
:closable="false" |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
|
||||
|
<div v-loading="loading"> |
||||
|
<div class="stat-grid"> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">接入单据</div> |
||||
|
<div class="stat-value">{{ sourceDocs.total || 0 }}</div> |
||||
|
<div class="stat-hint"> |
||||
|
待审核 {{ pendingReview }} · 已生成凭证 {{ vouchers.total || 0 }} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">待审核凭证</div> |
||||
|
<div class="stat-value" style="color: #e6a23c">{{ pendingReview }}</div> |
||||
|
<div class="stat-hint">财务确认后进入待推送</div> |
||||
|
</div> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">待推送 / 待重试</div> |
||||
|
<div class="stat-value" style="color: #2f6fed">{{ pushPending }}</div> |
||||
|
<div class="stat-hint">由 NCC 定时任务异步推送</div> |
||||
|
</div> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">推送异常</div> |
||||
|
<div class="stat-value" :style="{ color: pushAbnormal ? '#f56c6c' : '#67c23a' }"> |
||||
|
{{ pushAbnormal }} |
||||
|
</div> |
||||
|
<div class="stat-hint">失败或结果未知,需要人工处理</div> |
||||
|
</div> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">纠错经验</div> |
||||
|
<div class="stat-value">{{ data?.corrections || 0 }}</div> |
||||
|
<div class="stat-hint">用于规则学习与案例记忆</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<el-row :gutter="16" style="margin-top: 16px"> |
||||
|
<el-col :xs="24" :md="12"> |
||||
|
<el-card shadow="never"> |
||||
|
<div class="section-title">单据状态分布</div> |
||||
|
<div v-if="docStatuses.length" class="chip-list"> |
||||
|
<div v-for="[status, count] in docStatuses" :key="status" class="chip"> |
||||
|
<StatusTag :status="status" /> |
||||
|
<span class="chip-count">{{ count }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<el-empty v-else description="暂无单据" :image-size="70" /> |
||||
|
</el-card> |
||||
|
</el-col> |
||||
|
<el-col :xs="24" :md="12"> |
||||
|
<el-card shadow="never"> |
||||
|
<div class="section-title">凭证状态分布</div> |
||||
|
<div v-if="voucherStatuses.length" class="chip-list"> |
||||
|
<div v-for="[status, count] in voucherStatuses" :key="status" class="chip"> |
||||
|
<StatusTag :status="status" /> |
||||
|
<span class="chip-count">{{ count }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<el-empty v-else description="暂无凭证" :image-size="70" /> |
||||
|
</el-card> |
||||
|
</el-col> |
||||
|
</el-row> |
||||
|
|
||||
|
<el-card shadow="never" style="margin-top: 16px"> |
||||
|
<div class="section-title"> |
||||
|
<span>最近凭证</span> |
||||
|
<el-button text type="primary" @click="router.push('/vouchers')">全部凭证</el-button> |
||||
|
</div> |
||||
|
<el-table |
||||
|
:data="data?.recentVouchers || []" |
||||
|
size="small" |
||||
|
empty-text="暂无凭证" |
||||
|
@row-click="(row) => router.push(`/vouchers/${row.id}`)" |
||||
|
row-class-name="clickable-row" |
||||
|
> |
||||
|
<el-table-column prop="voucherNo" label="凭证号" min-width="160" /> |
||||
|
<el-table-column label="状态" width="110"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="借方合计" align="right" width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="amount">{{ formatAmount(row.totalDebit, row.currency) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="period" label="期间" width="100" /> |
||||
|
<el-table-column label="置信度" width="90"> |
||||
|
<template #default="{ row }"> |
||||
|
{{ row.confidence == null ? '-' : Math.round(row.confidence * 100) + '%' }} |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="创建时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-card shadow="never" style="margin-top: 16px"> |
||||
|
<div class="section-title"> |
||||
|
<span>最近接入单据</span> |
||||
|
<el-button text type="primary" @click="router.push('/source-docs')">全部单据</el-button> |
||||
|
</div> |
||||
|
<el-table |
||||
|
:data="data?.recentDocs || []" |
||||
|
size="small" |
||||
|
empty-text="暂无单据" |
||||
|
@row-click="(row) => router.push({ path: '/source-docs', query: { docId: row.id } })" |
||||
|
row-class-name="clickable-row" |
||||
|
> |
||||
|
<el-table-column prop="docNo" label="单据号" min-width="150" /> |
||||
|
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip /> |
||||
|
<el-table-column prop="applicantName" label="申请人" width="100" /> |
||||
|
<el-table-column label="金额" align="right" width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="amount">{{ formatAmount(row.totalAmount, row.currency) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="处理状态" width="120"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.processingStatus" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="审批时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.approvedAt) }}</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.chip-list { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 10px; |
||||
|
} |
||||
|
|
||||
|
.chip { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
padding: 6px 12px; |
||||
|
border: 1px solid #edf0f5; |
||||
|
border-radius: 8px; |
||||
|
background: #fafbfc; |
||||
|
} |
||||
|
|
||||
|
.chip-count { |
||||
|
font-size: 16px; |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
:deep(.clickable-row) { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,87 @@ |
|||||
|
<script setup> |
||||
|
import { reactive, ref } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { useAuth } from '../composables/useAuth' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const route = useRoute() |
||||
|
const { login } = useAuth() |
||||
|
|
||||
|
const form = reactive({ username: 'admin', password: 'admin123' }) |
||||
|
const loading = ref(false) |
||||
|
const error = ref('') |
||||
|
|
||||
|
async function submit() { |
||||
|
if (!form.username || !form.password) { |
||||
|
error.value = '请输入用户名和密码' |
||||
|
return |
||||
|
} |
||||
|
loading.value = true |
||||
|
error.value = '' |
||||
|
try { |
||||
|
await login(form.username, form.password) |
||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/' |
||||
|
router.replace(redirect) |
||||
|
} catch (e) { |
||||
|
error.value = e.body?.message || e.message || '登录失败' |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="login-page"> |
||||
|
<div class="login-card"> |
||||
|
<div class="login-brand"> |
||||
|
<h1>AutoVoucher</h1> |
||||
|
<p>OA 单据 · AI 凭证建议 · NCC 推送</p> |
||||
|
</div> |
||||
|
|
||||
|
<el-alert |
||||
|
v-if="error" |
||||
|
:title="error" |
||||
|
type="error" |
||||
|
show-icon |
||||
|
:closable="false" |
||||
|
style="margin-bottom: 16px" |
||||
|
/> |
||||
|
|
||||
|
<el-form label-position="top" @submit.prevent="submit"> |
||||
|
<el-form-item label="用户名"> |
||||
|
<el-input v-model="form.username" size="large" autocomplete="username" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="密码"> |
||||
|
<el-input |
||||
|
v-model="form.password" |
||||
|
size="large" |
||||
|
type="password" |
||||
|
show-password |
||||
|
autocomplete="current-password" |
||||
|
@keyup.enter="submit" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
<el-button |
||||
|
type="primary" |
||||
|
size="large" |
||||
|
style="width: 100%" |
||||
|
:loading="loading" |
||||
|
@click="submit" |
||||
|
> |
||||
|
登录 |
||||
|
</el-button> |
||||
|
</el-form> |
||||
|
|
||||
|
<p class="login-foot">演示账号 admin / admin123</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.login-foot { |
||||
|
margin: 18px 0 0; |
||||
|
color: #8a94a6; |
||||
|
font-size: 12px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,516 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onMounted, ref } from 'vue' |
||||
|
import { masterDataApi } from '../api' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import JsonBlock from '../components/JsonBlock.vue' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const activeTab = ref('accounts') |
||||
|
|
||||
|
const companies = ref([]) |
||||
|
const books = ref([]) |
||||
|
const accounts = ref([]) |
||||
|
const accountGroups = ref([]) |
||||
|
const auxItems = ref([]) |
||||
|
const auxValues = ref([]) |
||||
|
const expenseCategories = ref([]) |
||||
|
const voucherTypes = ref([]) |
||||
|
const sourceSystems = ref([]) |
||||
|
const targetSystems = ref([]) |
||||
|
|
||||
|
const companyId = ref('') |
||||
|
const bookId = ref('') |
||||
|
const accountKeyword = ref('') |
||||
|
const leafOnly = ref(true) |
||||
|
const auxType = ref('') |
||||
|
|
||||
|
const filteredBooks = computed(() => |
||||
|
companyId.value ? books.value.filter((b) => b.companyId === companyId.value) : books.value |
||||
|
) |
||||
|
|
||||
|
const filteredAccounts = computed(() => { |
||||
|
const kw = accountKeyword.value.trim().toLowerCase() |
||||
|
return accounts.value.filter((a) => { |
||||
|
if (leafOnly.value && a.isLeaf === false) { |
||||
|
return false |
||||
|
} |
||||
|
if (!kw) { |
||||
|
return true |
||||
|
} |
||||
|
return ( |
||||
|
(a.code || '').toLowerCase().includes(kw) || |
||||
|
(a.name || '').toLowerCase().includes(kw) || |
||||
|
(a.parentCode || '').toLowerCase().includes(kw) |
||||
|
) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
const filteredAuxValues = computed(() => |
||||
|
auxType.value ? auxValues.value.filter((v) => v.auxType === auxType.value) : auxValues.value |
||||
|
) |
||||
|
|
||||
|
const auxValuesByType = computed(() => { |
||||
|
const map = {} |
||||
|
for (const value of auxValues.value) { |
||||
|
map[value.auxType] = (map[value.auxType] || 0) + 1 |
||||
|
} |
||||
|
return map |
||||
|
}) |
||||
|
|
||||
|
async function loadAll() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const data = await masterDataApi.all() |
||||
|
companies.value = data.companies || [] |
||||
|
books.value = data.books || [] |
||||
|
accounts.value = data.accounts || [] |
||||
|
accountGroups.value = data.accountGroups || [] |
||||
|
auxItems.value = data.auxItems || [] |
||||
|
auxValues.value = data.auxValues || [] |
||||
|
expenseCategories.value = data.expenseCategories || [] |
||||
|
voucherTypes.value = data.voucherTypes || [] |
||||
|
sourceSystems.value = data.sourceSystems || [] |
||||
|
targetSystems.value = data.targetSystems || [] |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function reloadAccounts() { |
||||
|
if (companyId.value && bookId.value) { |
||||
|
accounts.value = await masterDataApi.accounts({ |
||||
|
companyId: companyId.value, |
||||
|
bookId: bookId.value |
||||
|
}) |
||||
|
} else { |
||||
|
accounts.value = await masterDataApi.accounts() |
||||
|
} |
||||
|
ElMessage.success(`已加载 ${accounts.value.length} 个科目`) |
||||
|
} |
||||
|
|
||||
|
function onCompanyChange() { |
||||
|
if (bookId.value && !filteredBooks.value.some((b) => b.bookId === bookId.value)) { |
||||
|
bookId.value = '' |
||||
|
} |
||||
|
reloadAccounts() |
||||
|
} |
||||
|
|
||||
|
function resetAccountFilters() { |
||||
|
companyId.value = '' |
||||
|
bookId.value = '' |
||||
|
accountKeyword.value = '' |
||||
|
leafOnly.value = true |
||||
|
reloadAccounts() |
||||
|
} |
||||
|
|
||||
|
function formatAuxRequired(value) { |
||||
|
if (!value) { |
||||
|
return '-' |
||||
|
} |
||||
|
try { |
||||
|
const parsed = JSON.parse(value) |
||||
|
if (Array.isArray(parsed)) { |
||||
|
return parsed.join('、') |
||||
|
} |
||||
|
if (typeof parsed === 'object') { |
||||
|
return Object.keys(parsed).join('、') |
||||
|
} |
||||
|
return String(parsed) |
||||
|
} catch (e) { |
||||
|
return value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function summary(value) { |
||||
|
if (!value) { |
||||
|
return '-' |
||||
|
} |
||||
|
try { |
||||
|
const parsed = JSON.parse(value) |
||||
|
if (Array.isArray(parsed)) { |
||||
|
return parsed.map((item) => (typeof item === 'object' ? JSON.stringify(item) : item)).join('、') |
||||
|
} |
||||
|
if (parsed && typeof parsed === 'object') { |
||||
|
return Object.entries(parsed) |
||||
|
.map(([key, val]) => `${key}=${typeof val === 'object' ? JSON.stringify(val) : val}`) |
||||
|
.join(' · ') |
||||
|
} |
||||
|
return String(parsed) |
||||
|
} catch (e) { |
||||
|
return value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(loadAll) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">基础档案</h2> |
||||
|
<div class="page-subtitle"> |
||||
|
科目、辅助核算、凭证类型等基础数据;当前为本地种子数据,后续可从 NCC 同步并保留外部编码映射 |
||||
|
</div> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="loadAll">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="stat-grid" style="margin-bottom: 16px"> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">会计科目</div> |
||||
|
<div class="stat-value">{{ accounts.length }}</div> |
||||
|
<div class="stat-hint">其中末级 {{ accounts.filter((a) => a.isLeaf !== false).length }} 个</div> |
||||
|
</div> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">科目映射组</div> |
||||
|
<div class="stat-value">{{ accountGroups.length }}</div> |
||||
|
<div class="stat-hint">按费用类型与部门属性定位科目</div> |
||||
|
</div> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">辅助核算档案</div> |
||||
|
<div class="stat-value">{{ auxValues.length }}</div> |
||||
|
<div class="stat-hint">共 {{ auxItems.length }} 类辅助核算</div> |
||||
|
</div> |
||||
|
<div class="stat-card"> |
||||
|
<div class="stat-label">费用类别 / 凭证类型</div> |
||||
|
<div class="stat-value">{{ expenseCategories.length }} / {{ voucherTypes.length }}</div> |
||||
|
<div class="stat-hint">凭证类型含 NCC 外部编码映射</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<el-tabs v-model="activeTab"> |
||||
|
<el-tab-pane :label="`会计科目 (${accounts.length})`" name="accounts"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select |
||||
|
v-model="companyId" |
||||
|
placeholder="公司" |
||||
|
clearable |
||||
|
style="width: 170px" |
||||
|
@change="onCompanyChange" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="c in companies" |
||||
|
:key="c.companyId" |
||||
|
:label="`${c.companyCode || ''} ${c.companyName || c.companyId}`" |
||||
|
:value="c.companyId" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<el-select |
||||
|
v-model="bookId" |
||||
|
placeholder="账簿" |
||||
|
clearable |
||||
|
style="width: 170px" |
||||
|
@change="reloadAccounts" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="b in filteredBooks" |
||||
|
:key="b.bookId" |
||||
|
:label="`${b.bookCode} ${b.bookName || ''}`" |
||||
|
:value="b.bookId" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<el-input |
||||
|
v-model="accountKeyword" |
||||
|
placeholder="科目编码 / 名称 / 上级" |
||||
|
clearable |
||||
|
style="width: 240px" |
||||
|
/> |
||||
|
<el-checkbox v-model="leafOnly">仅末级科目</el-checkbox> |
||||
|
<el-button @click="resetAccountFilters">重置</el-button> |
||||
|
<span class="muted">显示 {{ filteredAccounts.length }} 条</span> |
||||
|
</div> |
||||
|
<el-table |
||||
|
v-loading="loading" |
||||
|
:data="filteredAccounts" |
||||
|
size="small" |
||||
|
empty-text="暂无科目" |
||||
|
max-height="620" |
||||
|
> |
||||
|
<el-table-column prop="code" label="科目编码" width="140"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="科目名称" min-width="200" /> |
||||
|
<el-table-column prop="parentCode" label="上级" width="110"> |
||||
|
<template #default="{ row }">{{ row.parentCode || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="level" label="级次" width="70" /> |
||||
|
<el-table-column label="方向" width="70"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.direction" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="末级" width="70"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag v-if="row.isLeaf !== false" size="small" type="success">是</el-tag> |
||||
|
<span v-else class="muted">否</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="必用辅助核算" min-width="150"> |
||||
|
<template #default="{ row }">{{ formatAuxRequired(row.auxRequiredJson) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="公司 / 账簿" min-width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="muted">{{ row.companyId }} / {{ row.bookId }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="status" label="状态" width="100"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">辅助核算要求</div> |
||||
|
<JsonBlock :value="row.auxRequiredJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">外部编码映射</div> |
||||
|
<JsonBlock :value="row.externalCodesJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane :label="`科目组 (${accountGroups.length})`" name="groups"> |
||||
|
<el-alert |
||||
|
title="凭证生成时先按费用类别匹配到科目组,再根据申请人部门的费用属性(如销售 / 管理)取不同科目。" |
||||
|
type="info" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 12px" |
||||
|
/> |
||||
|
<el-table :data="accountGroups" size="small" empty-text="暂无科目组"> |
||||
|
<el-table-column prop="groupCode" label="科目组" width="220"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.groupCode }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="名称" min-width="180" /> |
||||
|
<el-table-column label="按部门属性映射" min-width="260"> |
||||
|
<template #default="{ row }">{{ summary(row.accountByDepartmentAttrJson) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="固定科目" width="140"> |
||||
|
<template #default="{ row }">{{ row.fixedAccount || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">部门属性 → 科目</div> |
||||
|
<JsonBlock :value="row.accountByDepartmentAttrJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane :label="`辅助核算 (${auxValues.length})`" name="aux"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select v-model="auxType" placeholder="辅助核算类型" clearable style="width: 220px"> |
||||
|
<el-option |
||||
|
v-for="item in auxItems" |
||||
|
:key="item.auxType" |
||||
|
:label="`${item.auxType} ${item.name || ''}`" |
||||
|
:value="item.auxType" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<span class="muted">显示 {{ filteredAuxValues.length }} 条档案</span> |
||||
|
</div> |
||||
|
<div class="aux-grid"> |
||||
|
<div> |
||||
|
<div class="section-title">辅助核算类型</div> |
||||
|
<el-table :data="auxItems" size="small" empty-text="暂无类型"> |
||||
|
<el-table-column prop="auxType" label="编码" width="120"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.auxType }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="名称" min-width="130" /> |
||||
|
<el-table-column label="档案数" width="90"> |
||||
|
<template #default="{ row }">{{ auxValuesByType[row.auxType] || 0 }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">外部编码映射</div> |
||||
|
<JsonBlock :value="row.externalCodesJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="section-title">辅助核算档案</div> |
||||
|
<el-table |
||||
|
:data="filteredAuxValues" |
||||
|
size="small" |
||||
|
empty-text="暂无档案" |
||||
|
max-height="520" |
||||
|
> |
||||
|
<el-table-column prop="auxType" label="类型" width="110" /> |
||||
|
<el-table-column prop="code" label="编码" width="130"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="名称" min-width="160" /> |
||||
|
<el-table-column prop="status" label="状态" width="100"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">属性</div> |
||||
|
<JsonBlock :value="row.attributesJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">外部编码映射</div> |
||||
|
<JsonBlock :value="row.externalCodesJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane :label="`费用类别 (${expenseCategories.length})`" name="categories"> |
||||
|
<el-table :data="expenseCategories" size="small" empty-text="暂无费用类别"> |
||||
|
<el-table-column prop="id" label="#" width="70" /> |
||||
|
<el-table-column prop="code" label="编码" width="180"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="名称" min-width="200" /> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane :label="`凭证类型 (${voucherTypes.length})`" name="voucherTypes"> |
||||
|
<el-table :data="voucherTypes" size="small" empty-text="暂无凭证类型"> |
||||
|
<el-table-column prop="code" label="编码" width="140"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="名称" min-width="180" /> |
||||
|
<el-table-column prop="voucherTypeId" label="NCC 类型 ID" width="160"> |
||||
|
<template #default="{ row }">{{ row.voucherTypeId || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="外部编码映射" min-width="220"> |
||||
|
<template #default="{ row }">{{ summary(row.externalCodesJson) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="note" label="说明" min-width="160" /> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">外部编码映射</div> |
||||
|
<JsonBlock :value="row.externalCodesJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="公司账簿" name="org"> |
||||
|
<div class="section-title">公司</div> |
||||
|
<el-table :data="companies" size="small" empty-text="暂无公司"> |
||||
|
<el-table-column prop="companyId" label="公司 ID" width="130"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.companyId }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="companyCode" label="编码" width="130" /> |
||||
|
<el-table-column prop="companyName" label="名称" min-width="200" /> |
||||
|
<el-table-column prop="status" label="状态" width="100"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
<div class="section-title" style="margin-top: 20px">账簿</div> |
||||
|
<el-table :data="books" size="small" empty-text="暂无账簿"> |
||||
|
<el-table-column prop="bookId" label="账簿 ID" width="130"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.bookId }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="companyId" label="公司" width="120" /> |
||||
|
<el-table-column prop="bookCode" label="编码" width="130" /> |
||||
|
<el-table-column prop="bookName" label="名称" min-width="180" /> |
||||
|
<el-table-column prop="currency" label="币种" width="80" /> |
||||
|
<el-table-column prop="currentPeriod" label="当前期间" width="100" /> |
||||
|
<el-table-column prop="targetSystem" label="目标系统" width="100" /> |
||||
|
<el-table-column prop="status" label="状态" width="100"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="系统接入" name="systems"> |
||||
|
<el-alert |
||||
|
title="当前演示环境全部使用 Mock 适配器:不调用真实致远 / NCC 接口。启用真实适配器前需完成网络、鉴权与对账验证。" |
||||
|
type="warning" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
<div class="section-title">来源系统(OA)</div> |
||||
|
<el-table :data="sourceSystems" size="small" empty-text="暂无来源系统"> |
||||
|
<el-table-column prop="sourceSystem" label="系统" width="140"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.sourceSystem }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="名称" min-width="180" /> |
||||
|
<el-table-column prop="adapterCode" label="适配器" min-width="180"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.adapterCode }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="Mock" width="80"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag v-if="row.mock" size="small" type="warning">是</el-tag> |
||||
|
<span v-else class="muted">否</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="status" label="状态" width="100"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="note" label="备注" min-width="200" /> |
||||
|
</el-table> |
||||
|
<div class="section-title" style="margin-top: 20px">目标系统(财务)</div> |
||||
|
<el-table :data="targetSystems" size="small" empty-text="暂无目标系统"> |
||||
|
<el-table-column prop="targetSystem" label="系统" width="140"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.targetSystem }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="name" label="名称" min-width="180" /> |
||||
|
<el-table-column prop="adapterCode" label="适配器" min-width="200"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.adapterCode }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="Dry Run" width="90"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag v-if="row.dryRun" size="small" type="warning">是</el-tag> |
||||
|
<span v-else class="muted">否</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="status" label="状态" width="100"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
</el-tabs> |
||||
|
</el-card> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.expand-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); |
||||
|
gap: 12px; |
||||
|
padding: 8px 16px; |
||||
|
} |
||||
|
|
||||
|
.aux-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.6fr); |
||||
|
gap: 18px; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 1100px) { |
||||
|
.aux-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,456 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { devApi, pushTaskApi } from '../api' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import JsonBlock from '../components/JsonBlock.vue' |
||||
|
import { formatAmount, formatDateTime } from '../utils/format' |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const tasks = ref([]) |
||||
|
const filters = reactive({ status: '', targetSystem: '', keyword: '' }) |
||||
|
|
||||
|
const detailVisible = ref(false) |
||||
|
const detailLoading = ref(false) |
||||
|
const detail = ref(null) |
||||
|
|
||||
|
const mockBehavior = ref('SUCCESS') |
||||
|
const savingBehavior = ref(false) |
||||
|
const actionLoading = ref('') |
||||
|
|
||||
|
const STATUSES = [ |
||||
|
'PENDING', |
||||
|
'RETRY_WAIT', |
||||
|
'UNKNOWN', |
||||
|
'SUCCESS', |
||||
|
'SUCCESS_WITH_EXISTING', |
||||
|
'FAILED' |
||||
|
] |
||||
|
const BEHAVIORS = [ |
||||
|
{ value: 'SUCCESS', label: 'SUCCESS · 正常成功' }, |
||||
|
{ value: 'DUPLICATE', label: 'DUPLICATE · 重复单据' }, |
||||
|
{ value: 'TIMEOUT', label: 'TIMEOUT · 超时结果未知' }, |
||||
|
{ value: 'ERROR', label: 'ERROR · 接口报错' } |
||||
|
] |
||||
|
|
||||
|
const canPush = computed(() => ['PENDING', 'RETRY_WAIT'].includes(detail.value?.status)) |
||||
|
const canReconcile = computed(() => detail.value?.status === 'UNKNOWN') |
||||
|
const canRetry = computed(() => |
||||
|
['FAILED', 'RETRY_WAIT', 'PENDING', 'UNKNOWN'].includes(detail.value?.status) |
||||
|
) |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
tasks.value = await pushTaskApi.list({ |
||||
|
status: filters.status || undefined, |
||||
|
targetSystem: filters.targetSystem || undefined, |
||||
|
keyword: filters.keyword || undefined |
||||
|
}) |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function loadBehavior() { |
||||
|
try { |
||||
|
const result = await devApi.getNccBehavior() |
||||
|
mockBehavior.value = result.behavior |
||||
|
} catch (e) { |
||||
|
// dev endpoints may be disabled outside demo profiles |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function saveBehavior() { |
||||
|
savingBehavior.value = true |
||||
|
try { |
||||
|
const result = await devApi.setNccBehavior(mockBehavior.value) |
||||
|
mockBehavior.value = result.behavior |
||||
|
ElMessage.success(`NCC 模拟器已切换为 ${result.behavior}`) |
||||
|
} finally { |
||||
|
savingBehavior.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function resetMock() { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
'清空模拟 NCC 中已接受的凭证,便于重复演示推送。确定继续?', |
||||
|
'重置模拟 NCC', |
||||
|
{ type: 'warning' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
await devApi.resetNcc() |
||||
|
ElMessage.success('模拟 NCC 数据已清空') |
||||
|
} |
||||
|
|
||||
|
async function openDetail(id) { |
||||
|
detailVisible.value = true |
||||
|
detailLoading.value = true |
||||
|
detail.value = null |
||||
|
try { |
||||
|
detail.value = await pushTaskApi.detail(id) |
||||
|
} finally { |
||||
|
detailLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function reloadDetail() { |
||||
|
if (detail.value?.id) { |
||||
|
detail.value = await pushTaskApi.detail(detail.value.id) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function runAction(action) { |
||||
|
if (!detail.value) { |
||||
|
return |
||||
|
} |
||||
|
const labels = { |
||||
|
push: '立即推送', |
||||
|
retry: '重试', |
||||
|
reconcile: '查询核对' |
||||
|
} |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
`确定对推送任务 #${detail.value.id}(${detail.value.voucherNo})执行“${labels[action]}”?`, |
||||
|
labels[action], |
||||
|
{ type: 'warning' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
actionLoading.value = action |
||||
|
try { |
||||
|
let task |
||||
|
if (action === 'push') { |
||||
|
task = await pushTaskApi.push(detail.value.id, mockBehavior.value) |
||||
|
} else if (action === 'retry') { |
||||
|
task = await pushTaskApi.retry(detail.value.id) |
||||
|
} else { |
||||
|
task = await pushTaskApi.reconcile(detail.value.id) |
||||
|
} |
||||
|
ElMessage.success(`操作完成,任务状态:${task.status}`) |
||||
|
await reloadDetail() |
||||
|
await load() |
||||
|
} finally { |
||||
|
actionLoading.value = '' |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function quickPush(row) { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
`立即推送凭证 ${row.voucherNo || ''} 至 ${row.targetSystem}?当前模拟行为:${mockBehavior.value}`, |
||||
|
'立即推送', |
||||
|
{ type: 'warning' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
const task = await pushTaskApi.push(row.id, mockBehavior.value) |
||||
|
ElMessage.success(`推送结果:${task.status}`) |
||||
|
await load() |
||||
|
} |
||||
|
|
||||
|
function resetFilters() { |
||||
|
filters.status = '' |
||||
|
filters.targetSystem = '' |
||||
|
filters.keyword = '' |
||||
|
load() |
||||
|
} |
||||
|
|
||||
|
watch( |
||||
|
() => route.query.taskId, |
||||
|
(value) => { |
||||
|
if (value) { |
||||
|
openDetail(Number(value)) |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
await Promise.all([load(), loadBehavior()]) |
||||
|
const taskId = Number(route.query.taskId) |
||||
|
if (taskId) { |
||||
|
await openDetail(taskId) |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">推送监控</h2> |
||||
|
<div class="page-subtitle"> |
||||
|
审核通过的凭证由定时任务推送 NCC;失败自动重试,超时结果未知必须先查询再重试 |
||||
|
</div> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never" class="mock-card"> |
||||
|
<div class="mock-bar"> |
||||
|
<div> |
||||
|
<b>NCC 模拟器</b> |
||||
|
<span class="muted">演示环境仅使用 Mock 适配器,不会调用真实 NCC</span> |
||||
|
</div> |
||||
|
<el-select v-model="mockBehavior" style="width: 220px"> |
||||
|
<el-option v-for="b in BEHAVIORS" :key="b.value" :label="b.label" :value="b.value" /> |
||||
|
</el-select> |
||||
|
<el-button :loading="savingBehavior" @click="saveBehavior">应用模拟行为</el-button> |
||||
|
<el-button @click="resetMock">清空模拟数据</el-button> |
||||
|
</div> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-card shadow="never" style="margin-top: 14px"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select |
||||
|
v-model="filters.status" |
||||
|
placeholder="任务状态" |
||||
|
clearable |
||||
|
style="width: 160px" |
||||
|
@change="load" |
||||
|
> |
||||
|
<el-option v-for="s in STATUSES" :key="s" :label="s" :value="s" /> |
||||
|
</el-select> |
||||
|
<el-input |
||||
|
v-model="filters.targetSystem" |
||||
|
placeholder="目标系统" |
||||
|
clearable |
||||
|
style="width: 140px" |
||||
|
@keyup.enter="load" |
||||
|
/> |
||||
|
<el-input |
||||
|
v-model="filters.keyword" |
||||
|
placeholder="凭证号 / 幂等键 / 外部凭证号" |
||||
|
clearable |
||||
|
style="width: 280px" |
||||
|
@keyup.enter="load" |
||||
|
@clear="load" |
||||
|
/> |
||||
|
<el-button type="primary" @click="load">查询</el-button> |
||||
|
<el-button @click="resetFilters">重置</el-button> |
||||
|
<span class="muted">共 {{ tasks.length }} 条</span> |
||||
|
</div> |
||||
|
|
||||
|
<el-table v-loading="loading" :data="tasks" size="small" empty-text="暂无推送任务"> |
||||
|
<el-table-column prop="id" label="#" width="60" /> |
||||
|
<el-table-column label="凭证" min-width="190"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
text |
||||
|
type="primary" |
||||
|
@click="router.push(`/vouchers/${row.voucherId}`)" |
||||
|
> |
||||
|
{{ row.voucherNo || `#${row.voucherId}` }} |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="任务状态" width="120"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="targetSystem" label="目标" width="90" /> |
||||
|
<el-table-column label="尝试" width="80"> |
||||
|
<template #default="{ row }">{{ row.attemptCount }}/{{ row.maxAttempts }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="externalVoucherNo" label="外部凭证号" min-width="150"> |
||||
|
<template #default="{ row }">{{ row.externalVoucherNo || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="错误" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<span v-if="row.lastErrorCode" class="mono">{{ row.lastErrorCode }}</span> |
||||
|
<span class="muted"> {{ row.lastErrorMessage || '' }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="下次重试" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.nextRetryAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="创建时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="190" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button text type="primary" @click="openDetail(row.id)">详情</el-button> |
||||
|
<el-button |
||||
|
v-if="['PENDING', 'RETRY_WAIT'].includes(row.status)" |
||||
|
text |
||||
|
@click="quickPush(row)" |
||||
|
> |
||||
|
推送 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-else-if="row.status === 'UNKNOWN'" |
||||
|
text |
||||
|
type="warning" |
||||
|
@click="openDetail(row.id)" |
||||
|
> |
||||
|
核对 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-else-if="row.status === 'FAILED'" |
||||
|
text |
||||
|
type="danger" |
||||
|
@click="openDetail(row.id)" |
||||
|
> |
||||
|
重试 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-drawer |
||||
|
v-model="detailVisible" |
||||
|
:title="detail ? `推送任务 #${detail.id}` : '推送任务'" |
||||
|
size="66%" |
||||
|
destroy-on-close |
||||
|
> |
||||
|
<div v-loading="detailLoading"> |
||||
|
<template v-if="detail"> |
||||
|
<div class="toolbar-actions" style="margin-bottom: 14px"> |
||||
|
<StatusTag :status="detail.status" /> |
||||
|
<el-button |
||||
|
v-if="canPush" |
||||
|
type="primary" |
||||
|
:loading="actionLoading === 'push'" |
||||
|
@click="runAction('push')" |
||||
|
> |
||||
|
立即推送 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="canRetry" |
||||
|
type="warning" |
||||
|
:loading="actionLoading === 'retry'" |
||||
|
@click="runAction('retry')" |
||||
|
> |
||||
|
重试 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="canReconcile" |
||||
|
type="warning" |
||||
|
plain |
||||
|
:loading="actionLoading === 'reconcile'" |
||||
|
@click="runAction('reconcile')" |
||||
|
> |
||||
|
查询核对 |
||||
|
</el-button> |
||||
|
<el-button @click="router.push(`/vouchers/${detail.voucherId}`)">查看凭证</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-descriptions :column="3" border size="small"> |
||||
|
<el-descriptions-item label="凭证号"> |
||||
|
{{ detail.voucherNo || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="凭证状态"> |
||||
|
<StatusTag :status="detail.voucherStatus" /> |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="目标系统"> |
||||
|
{{ detail.targetSystem }} / {{ detail.adapterCode }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="尝试次数"> |
||||
|
{{ detail.attemptCount }} / {{ detail.maxAttempts }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="外部凭证号"> |
||||
|
{{ detail.externalVoucherNo || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="外部凭证 ID"> |
||||
|
{{ detail.externalVoucherId || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="下次重试"> |
||||
|
{{ formatDateTime(detail.nextRetryAt) }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="创建时间"> |
||||
|
{{ formatDateTime(detail.createdAt) }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="最后错误"> |
||||
|
<span class="mono">{{ detail.lastErrorCode || '-' }}</span> |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="错误信息" :span="3"> |
||||
|
{{ detail.lastErrorMessage || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="幂等键" :span="3"> |
||||
|
<span class="mono">{{ detail.idempotencyKey }}</span> |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="凭证金额" :span="3"> |
||||
|
<template v-if="detail.voucher"> |
||||
|
借方 {{ formatAmount(detail.voucher.totalDebit, detail.voucher.currency) }} · |
||||
|
贷方 {{ formatAmount(detail.voucher.totalCredit, detail.voucher.currency) }} |
||||
|
</template> |
||||
|
<span v-else>-</span> |
||||
|
</el-descriptions-item> |
||||
|
</el-descriptions> |
||||
|
|
||||
|
<div class="section-title" style="margin-top: 18px">尝试记录</div> |
||||
|
<el-table :data="detail.attempts" size="small" empty-text="暂无尝试记录"> |
||||
|
<el-table-column prop="attemptNo" label="#" width="60" /> |
||||
|
<el-table-column label="结果" width="110"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="耗时" width="90"> |
||||
|
<template #default="{ row }"> |
||||
|
{{ row.durationMs == null ? '-' : row.durationMs + ' ms' }} |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="errorMessage" label="错误信息" min-width="200" /> |
||||
|
<el-table-column label="时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="attempt-panels"> |
||||
|
<div> |
||||
|
<div class="muted">请求</div> |
||||
|
<JsonBlock :value="row.requestJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">响应</div> |
||||
|
<JsonBlock :value="row.responseJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
|
||||
|
<div class="section-title" style="margin-top: 18px">最近请求快照</div> |
||||
|
<JsonBlock :value="detail.requestSnapshotJson" /> |
||||
|
</template> |
||||
|
</div> |
||||
|
</el-drawer> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.mock-bar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
flex-wrap: wrap; |
||||
|
} |
||||
|
|
||||
|
.mock-bar > div:first-child { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 2px; |
||||
|
} |
||||
|
|
||||
|
.attempt-panels { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1fr 1fr; |
||||
|
gap: 12px; |
||||
|
padding: 8px 16px; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 1100px) { |
||||
|
.attempt-panels { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,523 @@ |
|||||
|
<script setup> |
||||
|
import { onMounted, reactive, ref } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { candidateApi, learningApi, ruleApi } from '../api' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import JsonBlock from '../components/JsonBlock.vue' |
||||
|
import { formatDateTime } from '../utils/format' |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const activeTab = ref('rules') |
||||
|
const loading = ref(false) |
||||
|
|
||||
|
const rules = ref([]) |
||||
|
const candidates = ref([]) |
||||
|
const cases = ref([]) |
||||
|
const corrections = ref([]) |
||||
|
const ruleStatus = ref('') |
||||
|
|
||||
|
const ruleDialog = ref(false) |
||||
|
const editingRuleId = ref(null) |
||||
|
const savingRule = ref(false) |
||||
|
const ruleForm = reactive({ |
||||
|
ruleCode: '', |
||||
|
name: '', |
||||
|
priority: 50, |
||||
|
status: 'ACTIVE', |
||||
|
conditionText: '', |
||||
|
resultText: '' |
||||
|
}) |
||||
|
|
||||
|
const approveDialog = ref(false) |
||||
|
const approveCandidate = ref(null) |
||||
|
const approving = ref(false) |
||||
|
const approveForm = reactive({ ruleCode: '', name: '', priority: 50 }) |
||||
|
|
||||
|
const CONDITION_TEMPLATE = JSON.stringify( |
||||
|
{ |
||||
|
all: [ |
||||
|
{ field: 'expenseCategory', op: 'eq', value: 'TAXI' }, |
||||
|
{ field: 'businessScene', op: 'eq', value: 'OVERTIME' } |
||||
|
] |
||||
|
}, |
||||
|
null, |
||||
|
2 |
||||
|
) |
||||
|
|
||||
|
const RESULT_TEMPLATE = JSON.stringify( |
||||
|
{ |
||||
|
accountGroup: 'LOCAL_TRANSPORT', |
||||
|
aux: { DEPT: '$departmentId', EMP: '$applicantId' } |
||||
|
}, |
||||
|
null, |
||||
|
2 |
||||
|
) |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const [ruleList, candidateList, caseList, correctionList] = await Promise.all([ |
||||
|
ruleApi.list({ status: ruleStatus.value || undefined }), |
||||
|
candidateApi.list(), |
||||
|
learningApi.cases(), |
||||
|
learningApi.corrections() |
||||
|
]) |
||||
|
rules.value = ruleList |
||||
|
candidates.value = candidateList |
||||
|
cases.value = caseList |
||||
|
corrections.value = correctionList |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openCreate() { |
||||
|
editingRuleId.value = null |
||||
|
ruleForm.ruleCode = '' |
||||
|
ruleForm.name = '' |
||||
|
ruleForm.priority = 50 |
||||
|
ruleForm.status = 'ACTIVE' |
||||
|
ruleForm.conditionText = CONDITION_TEMPLATE |
||||
|
ruleForm.resultText = RESULT_TEMPLATE |
||||
|
ruleDialog.value = true |
||||
|
} |
||||
|
|
||||
|
function openEdit(rule) { |
||||
|
editingRuleId.value = rule.id |
||||
|
ruleForm.ruleCode = rule.ruleCode |
||||
|
ruleForm.name = rule.name || '' |
||||
|
ruleForm.priority = rule.priority ?? 50 |
||||
|
ruleForm.status = rule.status || 'ACTIVE' |
||||
|
ruleForm.conditionText = rule.conditionJson || '' |
||||
|
ruleForm.resultText = rule.resultJson || '' |
||||
|
ruleDialog.value = true |
||||
|
} |
||||
|
|
||||
|
function parseJson(text, label) { |
||||
|
try { |
||||
|
return JSON.parse(text) |
||||
|
} catch (e) { |
||||
|
throw new Error(`${label} 不是合法 JSON:${e.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function saveRule() { |
||||
|
if (!ruleForm.ruleCode.trim()) { |
||||
|
ElMessage.warning('规则编码不能为空') |
||||
|
return |
||||
|
} |
||||
|
let condition |
||||
|
let result |
||||
|
try { |
||||
|
condition = parseJson(ruleForm.conditionText, '条件') |
||||
|
result = parseJson(ruleForm.resultText, '结果') |
||||
|
} catch (e) { |
||||
|
ElMessage.error(e.message) |
||||
|
return |
||||
|
} |
||||
|
if (!result || typeof result !== 'object' || Array.isArray(result)) { |
||||
|
ElMessage.error('规则结果必须是 JSON 对象') |
||||
|
return |
||||
|
} |
||||
|
savingRule.value = true |
||||
|
try { |
||||
|
const payload = { |
||||
|
ruleCode: ruleForm.ruleCode.trim(), |
||||
|
name: ruleForm.name, |
||||
|
priority: Number(ruleForm.priority), |
||||
|
status: ruleForm.status, |
||||
|
condition, |
||||
|
result |
||||
|
} |
||||
|
if (editingRuleId.value) { |
||||
|
await ruleApi.update(editingRuleId.value, payload) |
||||
|
ElMessage.success('规则已更新并生成新版本') |
||||
|
} else { |
||||
|
await ruleApi.create(payload) |
||||
|
ElMessage.success('规则已创建') |
||||
|
} |
||||
|
ruleDialog.value = false |
||||
|
await load() |
||||
|
} finally { |
||||
|
savingRule.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openApprove(candidate) { |
||||
|
approveCandidate.value = candidate |
||||
|
approveForm.ruleCode = candidate.ruleCode |
||||
|
approveForm.name = candidate.name || '' |
||||
|
approveForm.priority = 50 |
||||
|
approveDialog.value = true |
||||
|
} |
||||
|
|
||||
|
async function doApprove() { |
||||
|
if (!approveForm.ruleCode.trim()) { |
||||
|
ElMessage.warning('规则编码不能为空') |
||||
|
return |
||||
|
} |
||||
|
approving.value = true |
||||
|
try { |
||||
|
const rule = await candidateApi.approve(approveCandidate.value.id, { |
||||
|
ruleCode: approveForm.ruleCode.trim(), |
||||
|
name: approveForm.name, |
||||
|
priority: Number(approveForm.priority) |
||||
|
}) |
||||
|
approveDialog.value = false |
||||
|
ElMessage.success(`已生成规则 ${rule.ruleCode} v${rule.version}`) |
||||
|
await load() |
||||
|
} finally { |
||||
|
approving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function rejectCandidate(candidate) { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
`确定拒绝候选规则 ${candidate.ruleCode}?`, |
||||
|
'拒绝候选规则', |
||||
|
{ type: 'warning' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
await candidateApi.reject(candidate.id) |
||||
|
ElMessage.success('候选规则已拒绝') |
||||
|
await load() |
||||
|
} |
||||
|
|
||||
|
function formatJson(value) { |
||||
|
if (!value) { |
||||
|
return '-' |
||||
|
} |
||||
|
try { |
||||
|
return JSON.stringify(JSON.parse(value), null, 2) |
||||
|
} catch (e) { |
||||
|
return value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(load) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">规则与学习</h2> |
||||
|
<div class="page-subtitle"> |
||||
|
规则引擎决定科目;财务纠错会沉淀为案例记忆与规则候选,审核候选后即对后续单据生效 |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="toolbar-actions"> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
<el-button type="primary" @click="openCreate">新建规则</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<el-tabs v-model="activeTab"> |
||||
|
<el-tab-pane :label="`映射规则 (${rules.length})`" name="rules"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select |
||||
|
v-model="ruleStatus" |
||||
|
placeholder="规则状态" |
||||
|
clearable |
||||
|
style="width: 140px" |
||||
|
@change="load" |
||||
|
> |
||||
|
<el-option label="ACTIVE" value="ACTIVE" /> |
||||
|
<el-option label="INACTIVE" value="INACTIVE" /> |
||||
|
<el-option label="DRAFT" value="DRAFT" /> |
||||
|
</el-select> |
||||
|
</div> |
||||
|
<el-table v-loading="loading" :data="rules" size="small" empty-text="暂无规则"> |
||||
|
<el-table-column prop="id" label="#" width="60" /> |
||||
|
<el-table-column label="规则" min-width="230"> |
||||
|
<template #default="{ row }"> |
||||
|
<div><b>{{ row.ruleCode }}</b></div> |
||||
|
<div class="muted">{{ row.name || '-' }}</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="priority" label="优先级" width="90" /> |
||||
|
<el-table-column prop="version" label="版本" width="80" /> |
||||
|
<el-table-column label="状态" width="110"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="结果" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono">{{ formatJson(row.resultJson) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="更新时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="80" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button text type="primary" @click="openEdit(row)">编辑</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">条件</div> |
||||
|
<JsonBlock :value="row.conditionJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">结果</div> |
||||
|
<JsonBlock :value="row.resultJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane :label="`规则候选 (${candidates.filter((c) => c.status === 'PENDING').length})`" name="candidates"> |
||||
|
<el-alert |
||||
|
title="候选规则来自财务修改分录后的学习沉淀。审核通过前不会影响凭证建议。" |
||||
|
type="info" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 12px" |
||||
|
/> |
||||
|
<el-table v-loading="loading" :data="candidates" size="small" empty-text="暂无候选规则"> |
||||
|
<el-table-column prop="id" label="#" width="60" /> |
||||
|
<el-table-column label="候选" min-width="230"> |
||||
|
<template #default="{ row }"> |
||||
|
<div><b>{{ row.ruleCode }}</b></div> |
||||
|
<div class="muted">{{ row.name || '-' }}</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="来源" width="100"> |
||||
|
<template #default="{ row }">{{ row.source }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="支持/冲突" width="110"> |
||||
|
<template #default="{ row }"> |
||||
|
{{ row.supportCount }} / {{ row.conflictCount }} |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="状态" width="110"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="生成时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="150" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<template v-if="row.status === 'PENDING'"> |
||||
|
<el-button text type="primary" @click="openApprove(row)">通过</el-button> |
||||
|
<el-button text type="danger" @click="rejectCandidate(row)">拒绝</el-button> |
||||
|
</template> |
||||
|
<span v-else class="muted">已处理</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">条件</div> |
||||
|
<JsonBlock :value="row.conditionJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">结果</div> |
||||
|
<JsonBlock :value="row.resultJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane :label="`案例记忆 (${cases.length})`" name="cases"> |
||||
|
<el-table v-loading="loading" :data="cases" size="small" empty-text="暂无案例"> |
||||
|
<el-table-column prop="id" label="#" width="60" /> |
||||
|
<el-table-column label="源单据" width="110"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
text |
||||
|
type="primary" |
||||
|
@click="router.push({ path: '/source-docs', query: { docId: row.sourceDocId } })" |
||||
|
> |
||||
|
#{{ row.sourceDocId }} |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="quality" label="质量" width="100" /> |
||||
|
<el-table-column label="上下文" min-width="260"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono">{{ row.contextJson }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="创建时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">上下文</div> |
||||
|
<JsonBlock :value="row.contextJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">凭证</div> |
||||
|
<JsonBlock :value="row.voucherJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">纠错差异</div> |
||||
|
<JsonBlock :value="row.correctionDiffJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane :label="`纠错记录 (${corrections.length})`" name="corrections"> |
||||
|
<el-table v-loading="loading" :data="corrections" size="small" empty-text="暂无纠错"> |
||||
|
<el-table-column prop="id" label="#" width="60" /> |
||||
|
<el-table-column label="凭证" width="120"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button text type="primary" @click="router.push(`/vouchers/${row.voucherId}`)"> |
||||
|
#{{ row.voucherId }} |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="源单据" width="110"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
text |
||||
|
type="primary" |
||||
|
@click="router.push({ path: '/source-docs', query: { docId: row.sourceDocId } })" |
||||
|
> |
||||
|
#{{ row.sourceDocId }} |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="reason" label="原因" min-width="200" /> |
||||
|
<el-table-column prop="operatorId" label="操作人" width="110" /> |
||||
|
<el-table-column prop="scope" label="范围" width="90" /> |
||||
|
<el-table-column label="时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">修改前</div> |
||||
|
<JsonBlock :value="row.originalJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">修改后</div> |
||||
|
<JsonBlock :value="row.correctedJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">差异</div> |
||||
|
<JsonBlock :value="row.diffJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
</el-tabs> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-dialog |
||||
|
v-model="ruleDialog" |
||||
|
:title="editingRuleId ? '编辑规则(将生成新版本)' : '新建映射规则'" |
||||
|
width="760px" |
||||
|
top="6vh" |
||||
|
> |
||||
|
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px"> |
||||
|
<template #title> |
||||
|
可用字段:expenseCategory、businessScene、isBusinessTrip、departmentId、amount、 |
||||
|
invoiceType 等;操作符:eq、ne、in、notIn、exists、gt、gte、lt、lte、contains。 |
||||
|
结果需指定 accountGroup 或 accountCode。 |
||||
|
</template> |
||||
|
</el-alert> |
||||
|
<el-form label-width="90px"> |
||||
|
<el-row :gutter="12"> |
||||
|
<el-col :span="10"> |
||||
|
<el-form-item label="规则编码"> |
||||
|
<el-input v-model="ruleForm.ruleCode" placeholder="如 R-TAXI-OVERTIME-002" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="8"> |
||||
|
<el-form-item label="优先级"> |
||||
|
<el-input-number v-model="ruleForm.priority" :min="0" :max="9999" style="width: 100%" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="6"> |
||||
|
<el-form-item label="状态"> |
||||
|
<el-select v-model="ruleForm.status"> |
||||
|
<el-option label="ACTIVE" value="ACTIVE" /> |
||||
|
<el-option label="DRAFT" value="DRAFT" /> |
||||
|
<el-option label="INACTIVE" value="INACTIVE" /> |
||||
|
</el-select> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
</el-row> |
||||
|
<el-form-item label="名称"> |
||||
|
<el-input v-model="ruleForm.name" placeholder="规则名称" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="条件 JSON"> |
||||
|
<el-input v-model="ruleForm.conditionText" type="textarea" :rows="10" class="code-input" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="结果 JSON"> |
||||
|
<el-input v-model="ruleForm.resultText" type="textarea" :rows="8" class="code-input" /> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
<template #footer> |
||||
|
<el-button @click="ruleDialog = false">取消</el-button> |
||||
|
<el-button type="primary" :loading="savingRule" @click="saveRule">保存规则</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<el-dialog v-model="approveDialog" title="审核候选规则" width="700px"> |
||||
|
<template v-if="approveCandidate"> |
||||
|
<el-form label-width="90px"> |
||||
|
<el-form-item label="规则编码"> |
||||
|
<el-input v-model="approveForm.ruleCode" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="名称"> |
||||
|
<el-input v-model="approveForm.name" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="优先级"> |
||||
|
<el-input-number v-model="approveForm.priority" :min="0" :max="9999" /> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
<div class="expand-grid"> |
||||
|
<div> |
||||
|
<div class="muted">候选条件</div> |
||||
|
<JsonBlock :value="approveCandidate.conditionJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">候选结果</div> |
||||
|
<JsonBlock :value="approveCandidate.resultJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
<template #footer> |
||||
|
<el-button @click="approveDialog = false">取消</el-button> |
||||
|
<el-button type="primary" :loading="approving" @click="doApprove">通过并启用</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.expand-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); |
||||
|
gap: 12px; |
||||
|
padding: 8px 16px; |
||||
|
} |
||||
|
|
||||
|
.code-input :deep(textarea) { |
||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,539 @@ |
|||||
|
<script setup> |
||||
|
import { onMounted, reactive, ref, watch } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { masterDataApi, sourceDocApi } from '../api' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import OaViewDialog from '../components/OaViewDialog.vue' |
||||
|
import JsonBlock from '../components/JsonBlock.vue' |
||||
|
import { formatAmount, formatDateTime } from '../utils/format' |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const docs = ref([]) |
||||
|
const filters = reactive({ status: '', keyword: '' }) |
||||
|
|
||||
|
const drawerVisible = ref(false) |
||||
|
const detailLoading = ref(false) |
||||
|
const detail = ref(null) |
||||
|
const activeTab = ref('lines') |
||||
|
|
||||
|
const accounts = ref([]) |
||||
|
const manualDialog = ref(false) |
||||
|
const manualForm = reactive({ accountCode: '', reason: '' }) |
||||
|
const manualLoading = ref(false) |
||||
|
|
||||
|
const reprocessing = ref(false) |
||||
|
const oaVisible = ref(false) |
||||
|
const oaDoc = ref(null) |
||||
|
|
||||
|
const DOC_STATUSES = [ |
||||
|
'RECEIVED', |
||||
|
'PROCESSING', |
||||
|
'MANUAL_REQUIRED', |
||||
|
'PENDING_REVIEW', |
||||
|
'IGNORED', |
||||
|
'REJECTED' |
||||
|
] |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
docs.value = await sourceDocApi.list({ |
||||
|
status: filters.status || undefined, |
||||
|
keyword: filters.keyword || undefined |
||||
|
}) |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function openDetail(id) { |
||||
|
drawerVisible.value = true |
||||
|
detailLoading.value = true |
||||
|
detail.value = null |
||||
|
activeTab.value = 'lines' |
||||
|
try { |
||||
|
detail.value = await sourceDocApi.detail(id) |
||||
|
} finally { |
||||
|
detailLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function reloadDetail() { |
||||
|
if (!detail.value?.doc?.id) { |
||||
|
return |
||||
|
} |
||||
|
detail.value = await sourceDocApi.detail(detail.value.doc.id) |
||||
|
} |
||||
|
|
||||
|
async function reprocess() { |
||||
|
if (!detail.value?.doc) { |
||||
|
return |
||||
|
} |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
'重新生成将基于当前单据数据再次执行凭证建议,并产生新的凭证版本。确定继续?', |
||||
|
'重新生成凭证', |
||||
|
{ type: 'warning', confirmButtonText: '继续', cancelButtonText: '取消' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
reprocessing.value = true |
||||
|
try { |
||||
|
const result = await sourceDocApi.reprocess(detail.value.doc.id) |
||||
|
if (result.status === 'GENERATED') { |
||||
|
ElMessage.success(`已生成凭证 ${result.voucherNo || ''}`) |
||||
|
} else { |
||||
|
ElMessage.warning(result.message || result.status) |
||||
|
} |
||||
|
await reloadDetail() |
||||
|
await load() |
||||
|
} finally { |
||||
|
reprocessing.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function openManualDialog() { |
||||
|
manualForm.accountCode = detail.value?.candidates?.[0]?.accountCode || '' |
||||
|
manualForm.reason = '' |
||||
|
if (!accounts.value.length) { |
||||
|
accounts.value = await masterDataApi.accounts({ |
||||
|
companyId: detail.value?.doc?.companyId, |
||||
|
bookId: detail.value?.doc?.bookId |
||||
|
}) |
||||
|
} |
||||
|
manualDialog.value = true |
||||
|
} |
||||
|
|
||||
|
async function submitManual() { |
||||
|
if (!manualForm.accountCode) { |
||||
|
ElMessage.warning('请选择费用科目') |
||||
|
return |
||||
|
} |
||||
|
manualLoading.value = true |
||||
|
try { |
||||
|
const voucher = await sourceDocApi.manualVoucher(detail.value.doc.id, { |
||||
|
accountCode: manualForm.accountCode, |
||||
|
reason: manualForm.reason |
||||
|
}) |
||||
|
manualDialog.value = false |
||||
|
ElMessage.success(`已按手工科目生成凭证 ${voucher.voucherNo}`) |
||||
|
await reloadDetail() |
||||
|
await load() |
||||
|
router.push(`/vouchers/${voucher.id}`) |
||||
|
} finally { |
||||
|
manualLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openOa(doc) { |
||||
|
oaDoc.value = doc |
||||
|
oaVisible.value = true |
||||
|
} |
||||
|
|
||||
|
function resetFilters() { |
||||
|
filters.status = '' |
||||
|
filters.keyword = '' |
||||
|
load() |
||||
|
} |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
await load() |
||||
|
const docId = Number(route.query.docId) |
||||
|
if (docId) { |
||||
|
await openDetail(docId) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
watch( |
||||
|
() => route.query.docId, |
||||
|
(value) => { |
||||
|
if (value) { |
||||
|
openDetail(Number(value)) |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">单据接入</h2> |
||||
|
<div class="page-subtitle"> |
||||
|
来自致远的费用单据,付款单据仅入库不生成凭证;点击行查看明细与凭证建议 |
||||
|
</div> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select |
||||
|
v-model="filters.status" |
||||
|
placeholder="处理状态" |
||||
|
clearable |
||||
|
style="width: 170px" |
||||
|
@change="load" |
||||
|
> |
||||
|
<el-option v-for="s in DOC_STATUSES" :key="s" :label="s" :value="s" /> |
||||
|
</el-select> |
||||
|
<el-input |
||||
|
v-model="filters.keyword" |
||||
|
placeholder="单据号 / 标题 / 申请人 / 部门" |
||||
|
clearable |
||||
|
style="width: 300px" |
||||
|
@keyup.enter="load" |
||||
|
@clear="load" |
||||
|
/> |
||||
|
<el-button type="primary" @click="load">查询</el-button> |
||||
|
<el-button @click="resetFilters">重置</el-button> |
||||
|
<span class="muted">共 {{ docs.length }} 条</span> |
||||
|
</div> |
||||
|
|
||||
|
<el-table |
||||
|
v-loading="loading" |
||||
|
:data="docs" |
||||
|
size="small" |
||||
|
empty-text="暂无单据" |
||||
|
row-class-name="clickable-row" |
||||
|
@row-click="(row) => openDetail(row.id)" |
||||
|
> |
||||
|
<el-table-column prop="docNo" label="单据号" min-width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono">{{ row.docNo }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip /> |
||||
|
<el-table-column label="申请人 / 部门" min-width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
{{ row.applicantName || '-' }} |
||||
|
<span class="muted">/ {{ row.departmentName || '-' }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="金额" align="right" width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="amount">{{ formatAmount(row.totalAmount, row.currency) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="businessScene" label="业务场景" width="130" /> |
||||
|
<el-table-column label="状态" width="120"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.processingStatus" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="当前凭证" min-width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
v-if="row.voucherId" |
||||
|
text |
||||
|
type="primary" |
||||
|
@click.stop="router.push(`/vouchers/${row.voucherId}`)" |
||||
|
> |
||||
|
{{ row.voucherNo }} |
||||
|
</el-button> |
||||
|
<span v-else class="muted">-</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="180" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button text type="primary" @click.stop="openDetail(row.id)">详情</el-button> |
||||
|
<el-button text @click.stop="openOa(row)">查看凭证</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-drawer |
||||
|
v-model="drawerVisible" |
||||
|
:title="detail?.doc?.docNo ? `单据 ${detail.doc.docNo}` : '单据详情'" |
||||
|
size="78%" |
||||
|
destroy-on-close |
||||
|
> |
||||
|
<div v-loading="detailLoading"> |
||||
|
<template v-if="detail"> |
||||
|
<div class="detail-head"> |
||||
|
<div> |
||||
|
<h3 class="detail-title">{{ detail.doc.title || '无标题' }}</h3> |
||||
|
<div class="muted"> |
||||
|
{{ detail.doc.externalId }} · {{ detail.doc.sourceSystem }} · |
||||
|
{{ formatDateTime(detail.doc.approvedAt) }} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="toolbar-actions"> |
||||
|
<StatusTag :status="detail.doc.processingStatus" /> |
||||
|
<el-button size="small" @click="openOa(detail.doc)">OA 查看凭证</el-button> |
||||
|
<el-button size="small" :loading="reprocessing" @click="reprocess"> |
||||
|
重新生成 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="detail.doc.processingStatus === 'MANUAL_REQUIRED'" |
||||
|
size="small" |
||||
|
type="primary" |
||||
|
@click="openManualDialog" |
||||
|
> |
||||
|
手工选择科目 |
||||
|
</el-button> |
||||
|
<el-button |
||||
|
v-if="detail.currentVoucher" |
||||
|
size="small" |
||||
|
type="success" |
||||
|
plain |
||||
|
@click="router.push(`/vouchers/${detail.currentVoucher.id}`)" |
||||
|
> |
||||
|
打开凭证 {{ detail.currentVoucher.voucherNo }} |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<el-descriptions :column="3" border size="small" style="margin-bottom: 14px"> |
||||
|
<el-descriptions-item label="申请人"> |
||||
|
{{ detail.doc.applicantName || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="部门"> |
||||
|
{{ detail.doc.departmentName || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="金额"> |
||||
|
<span class="amount"> |
||||
|
{{ formatAmount(detail.doc.totalAmount, detail.doc.currency) }} |
||||
|
</span> |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="业务场景"> |
||||
|
{{ detail.doc.businessScene || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="是否出差"> |
||||
|
{{ detail.doc.isBusinessTrip ? '是' : '否' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="单据类型"> |
||||
|
{{ detail.doc.docType || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="公司 / 账簿"> |
||||
|
{{ detail.doc.companyId }} / {{ detail.doc.bookId }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="审批节点"> |
||||
|
{{ detail.doc.approvalNode || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="审批时间"> |
||||
|
{{ formatDateTime(detail.doc.approvedAt) }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item v-if="detail.doc.manualReason" label="人工判断原因" :span="3"> |
||||
|
{{ detail.doc.manualReason }} |
||||
|
</el-descriptions-item> |
||||
|
</el-descriptions> |
||||
|
|
||||
|
<el-tabs v-model="activeTab"> |
||||
|
<el-tab-pane label="费用明细" name="lines"> |
||||
|
<el-table :data="detail.lines" size="small" empty-text="暂无明细"> |
||||
|
<el-table-column prop="lineNo" label="#" width="50" /> |
||||
|
<el-table-column prop="description" label="摘要" min-width="180" /> |
||||
|
<el-table-column prop="expenseCategory" label="费用类别" width="110" /> |
||||
|
<el-table-column prop="invoiceType" label="发票类型" width="100" /> |
||||
|
<el-table-column prop="invoiceNo" label="发票号" width="130" /> |
||||
|
<el-table-column label="含税金额" align="right" width="130"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="amount">{{ formatAmount(row.amount, '') }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="税率" width="80"> |
||||
|
<template #default="{ row }"> |
||||
|
{{ row.taxRate == null ? '-' : Number(row.taxRate) * 100 + '%' }} |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="税额" align="right" width="110"> |
||||
|
<template #default="{ row }">{{ formatAmount(row.taxAmount, '') }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="occurredAt" label="发生日期" width="110" /> |
||||
|
<el-table-column prop="merchantName" label="商户" width="140" /> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="候选科目" name="candidates"> |
||||
|
<el-alert |
||||
|
v-if="!detail.candidates?.length" |
||||
|
title="该单据没有候选科目(通常已自动匹配或需要人工判断)" |
||||
|
type="info" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
/> |
||||
|
<div v-else class="candidate-list"> |
||||
|
<div v-for="c in detail.candidates" :key="c.accountCode" class="candidate-item"> |
||||
|
<span class="mono candidate-code">{{ c.accountCode }}</span> |
||||
|
<span>{{ c.accountName || '未知科目' }}</span> |
||||
|
<el-tag v-if="c.direction" size="small" effect="plain"> |
||||
|
{{ c.direction === 'DEBIT' ? '借方' : '贷方' }} |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="当前凭证" name="voucher"> |
||||
|
<template v-if="detail.currentVoucher"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-tag type="success" effect="plain"> |
||||
|
{{ detail.currentVoucher.voucherNo }} |
||||
|
</el-tag> |
||||
|
<StatusTag :status="detail.currentVoucher.status" /> |
||||
|
<span class="muted"> |
||||
|
借方合计 |
||||
|
{{ formatAmount(detail.currentVoucher.totalDebit, detail.doc.currency) }} |
||||
|
</span> |
||||
|
<el-button |
||||
|
size="small" |
||||
|
type="primary" |
||||
|
plain |
||||
|
@click="router.push(`/vouchers/${detail.currentVoucher.id}`)" |
||||
|
> |
||||
|
进入审核 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
<el-table :data="detail.currentVoucher.lines" size="small"> |
||||
|
<el-table-column prop="lineNo" label="#" width="50" /> |
||||
|
<el-table-column label="方向" width="70"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.direction" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="科目" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono">{{ row.accountCode }}</span> |
||||
|
{{ row.accountName }} |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="金额" align="right" width="130"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="amount">{{ formatAmount(row.amount, '') }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="summary" label="摘要" min-width="180" /> |
||||
|
</el-table> |
||||
|
</template> |
||||
|
<el-empty v-else description="当前单据还没有凭证" :image-size="80" /> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="历史版本" name="versions"> |
||||
|
<el-table :data="detail.vouchers" size="small" empty-text="暂无版本"> |
||||
|
<el-table-column prop="voucherNo" label="凭证号" min-width="180" /> |
||||
|
<el-table-column prop="version" label="版本" width="80" /> |
||||
|
<el-table-column label="状态" width="110"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="当前" width="80"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag v-if="row.currentFlag === 1" size="small" type="success">当前</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="借方合计" align="right" width="140"> |
||||
|
<template #default="{ row }">{{ formatAmount(row.totalDebit, '') }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="创建时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="100"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button text type="primary" @click="router.push(`/vouchers/${row.id}`)"> |
||||
|
查看 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="原始报文" name="raw"> |
||||
|
<JsonBlock :value="detail.doc.rawJson" /> |
||||
|
</el-tab-pane> |
||||
|
</el-tabs> |
||||
|
</template> |
||||
|
</div> |
||||
|
</el-drawer> |
||||
|
|
||||
|
<el-dialog v-model="manualDialog" title="手工选择费用科目" width="520px"> |
||||
|
<el-alert |
||||
|
title="仅“待人工判断”状态的单据可手工指定科目;系统会同时记录纠错经验用于后续学习。" |
||||
|
type="info" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
<el-form label-width="90px"> |
||||
|
<el-form-item label="费用科目"> |
||||
|
<el-select |
||||
|
v-model="manualForm.accountCode" |
||||
|
filterable |
||||
|
placeholder="选择科目" |
||||
|
style="width: 100%" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="c in detail?.candidates || []" |
||||
|
:key="c.accountCode" |
||||
|
:label="`${c.accountCode} ${c.accountName || ''}`" |
||||
|
:value="c.accountCode" |
||||
|
/> |
||||
|
<el-option-group label="其他科目"> |
||||
|
<el-option |
||||
|
v-for="a in accounts" |
||||
|
:key="`all-${a.code}`" |
||||
|
:label="`${a.code} ${a.name || ''}`" |
||||
|
:value="a.code" |
||||
|
/> |
||||
|
</el-option-group> |
||||
|
</el-select> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="判断原因"> |
||||
|
<el-input |
||||
|
v-model="manualForm.reason" |
||||
|
type="textarea" |
||||
|
:rows="3" |
||||
|
placeholder="例如:出差期间打车,计入差旅费" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
<template #footer> |
||||
|
<el-button @click="manualDialog = false">取消</el-button> |
||||
|
<el-button type="primary" :loading="manualLoading" @click="submitManual"> |
||||
|
生成凭证 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<OaViewDialog v-model="oaVisible" :doc="oaDoc" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.clickable-row { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.detail-head { |
||||
|
display: flex; |
||||
|
align-items: flex-start; |
||||
|
justify-content: space-between; |
||||
|
gap: 16px; |
||||
|
margin-bottom: 14px; |
||||
|
} |
||||
|
|
||||
|
.detail-title { |
||||
|
margin: 0 0 4px; |
||||
|
font-size: 17px; |
||||
|
} |
||||
|
|
||||
|
.candidate-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
|
||||
|
.candidate-item { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
padding: 10px 14px; |
||||
|
border: 1px solid #edf0f5; |
||||
|
border-radius: 8px; |
||||
|
background: #fafbfc; |
||||
|
} |
||||
|
|
||||
|
.candidate-code { |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,868 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onMounted, ref, watch } from 'vue' |
||||
|
import { useRoute, useRouter } from 'vue-router' |
||||
|
import { masterDataApi, voucherApi } from '../api' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import JsonBlock from '../components/JsonBlock.vue' |
||||
|
import OaViewDialog from '../components/OaViewDialog.vue' |
||||
|
import { formatAmount, formatDateTime } from '../utils/format' |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus' |
||||
|
|
||||
|
const route = useRoute() |
||||
|
const router = useRouter() |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const voucher = ref(null) |
||||
|
const activeTab = ref('lines') |
||||
|
const accounts = ref([]) |
||||
|
const auxItems = ref([]) |
||||
|
const auxValues = ref([]) |
||||
|
const editLines = ref([]) |
||||
|
let rowSeq = 0 |
||||
|
|
||||
|
const saving = ref(false) |
||||
|
const approving = ref(false) |
||||
|
const regenerating = ref(false) |
||||
|
const rejectLoading = ref(false) |
||||
|
|
||||
|
const saveDialog = ref(false) |
||||
|
const saveReason = ref('') |
||||
|
const rejectDialog = ref(false) |
||||
|
const rejectReason = ref('') |
||||
|
|
||||
|
const auxDialog = ref(false) |
||||
|
const auxIndex = ref(-1) |
||||
|
const auxRows = ref([]) |
||||
|
|
||||
|
const oaVisible = ref(false) |
||||
|
|
||||
|
const isPending = computed(() => voucher.value?.status === 'PENDING_REVIEW') |
||||
|
const canRegenerate = computed( |
||||
|
() => voucher.value && ['PENDING_REVIEW', 'REJECTED'].includes(voucher.value.status) |
||||
|
) |
||||
|
|
||||
|
const leafAccounts = computed(() => |
||||
|
accounts.value.filter( |
||||
|
(a) => a.isLeaf !== false && (!a.status || a.status === 'ACTIVE') |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
function accountByCode(code) { |
||||
|
return accounts.value.find((a) => a.code === code) |
||||
|
} |
||||
|
|
||||
|
function parseAux(value) { |
||||
|
if (!value) { |
||||
|
return {} |
||||
|
} |
||||
|
if (typeof value === 'object') { |
||||
|
return { ...value } |
||||
|
} |
||||
|
try { |
||||
|
const parsed = JSON.parse(value) |
||||
|
return parsed && typeof parsed === 'object' ? parsed : {} |
||||
|
} catch (e) { |
||||
|
return {} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function requiredAux(account) { |
||||
|
if (!account?.auxRequiredJson) { |
||||
|
return [] |
||||
|
} |
||||
|
try { |
||||
|
const parsed = JSON.parse(account.auxRequiredJson) |
||||
|
return Array.isArray(parsed) ? parsed : [] |
||||
|
} catch (e) { |
||||
|
return [] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function auxText(value) { |
||||
|
const aux = parseAux(value) |
||||
|
const parts = Object.entries(aux) |
||||
|
.filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '') |
||||
|
.map(([k, v]) => `${k}=${v}`) |
||||
|
return parts.length ? parts.join(', ') : '-' |
||||
|
} |
||||
|
|
||||
|
function auxCount(value) { |
||||
|
return Object.keys(parseAux(value)).filter((k) => String(parseAux(value)[k] || '').trim()).length |
||||
|
} |
||||
|
|
||||
|
function toEditLine(line) { |
||||
|
return { |
||||
|
_key: ++rowSeq, |
||||
|
direction: line.direction, |
||||
|
accountCode: line.accountCode, |
||||
|
amount: Number(line.amount ?? 0), |
||||
|
taxRatePercent: |
||||
|
line.taxRate === null || line.taxRate === undefined ? null : Number(line.taxRate) * 100, |
||||
|
taxAmount: line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount), |
||||
|
aux: parseAux(line.auxJson), |
||||
|
summary: line.summary || '', |
||||
|
evidenceJson: line.evidenceJson, |
||||
|
accountGroup: line.accountGroup, |
||||
|
decisionSource: line.decisionSource, |
||||
|
confidence: line.confidence |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function applyDetail(detail) { |
||||
|
voucher.value = detail |
||||
|
editLines.value = (detail.lines || []).map(toEditLine) |
||||
|
} |
||||
|
|
||||
|
async function loadMasterData() { |
||||
|
if (!voucher.value) { |
||||
|
return |
||||
|
} |
||||
|
const data = await masterDataApi.all({ |
||||
|
companyId: voucher.value.companyId, |
||||
|
bookId: voucher.value.bookId |
||||
|
}) |
||||
|
accounts.value = data.accounts || [] |
||||
|
auxItems.value = data.auxItems || [] |
||||
|
auxValues.value = data.auxValues || [] |
||||
|
} |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const detail = await voucherApi.detail(route.params.id) |
||||
|
applyDetail(detail) |
||||
|
await loadMasterData() |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const totals = computed(() => { |
||||
|
const lines = isPending.value ? editLines.value : voucher.value?.lines || [] |
||||
|
let debit = 0 |
||||
|
let credit = 0 |
||||
|
for (const line of lines) { |
||||
|
const amount = Number(line.amount) || 0 |
||||
|
if (line.direction === 'DEBIT') { |
||||
|
debit += amount |
||||
|
} else if (line.direction === 'CREDIT') { |
||||
|
credit += amount |
||||
|
} |
||||
|
} |
||||
|
return { debit, credit, diff: Math.round((debit - credit) * 100) / 100 } |
||||
|
}) |
||||
|
|
||||
|
function addLine() { |
||||
|
editLines.value.push({ |
||||
|
_key: ++rowSeq, |
||||
|
direction: 'DEBIT', |
||||
|
accountCode: '', |
||||
|
amount: 0, |
||||
|
taxRatePercent: null, |
||||
|
taxAmount: null, |
||||
|
aux: {}, |
||||
|
summary: '' |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
function removeLine(index) { |
||||
|
if (editLines.value.length <= 2) { |
||||
|
ElMessage.warning('凭证至少保留两条分录') |
||||
|
return |
||||
|
} |
||||
|
editLines.value.splice(index, 1) |
||||
|
} |
||||
|
|
||||
|
function onAccountChange(line) { |
||||
|
const account = accountByCode(line.accountCode) |
||||
|
const keys = requiredAux(account) |
||||
|
if (!keys.length) { |
||||
|
return |
||||
|
} |
||||
|
const next = { ...line.aux } |
||||
|
for (const key of keys) { |
||||
|
if (next[key] === undefined) { |
||||
|
next[key] = '' |
||||
|
} |
||||
|
} |
||||
|
line.aux = next |
||||
|
} |
||||
|
|
||||
|
function openAux(index) { |
||||
|
auxIndex.value = index |
||||
|
const line = editLines.value[index] |
||||
|
const account = accountByCode(line.accountCode) |
||||
|
const merged = { ...line.aux } |
||||
|
for (const key of requiredAux(account)) { |
||||
|
if (merged[key] === undefined) { |
||||
|
merged[key] = '' |
||||
|
} |
||||
|
} |
||||
|
const entries = Object.entries(merged).map(([key, value]) => ({ key, value: value ?? '' })) |
||||
|
auxRows.value = entries.length ? entries : [{ key: 'DEPT', value: '' }] |
||||
|
auxDialog.value = true |
||||
|
} |
||||
|
|
||||
|
function addAuxRow() { |
||||
|
auxRows.value.push({ key: '', value: '' }) |
||||
|
} |
||||
|
|
||||
|
function removeAuxRow(index) { |
||||
|
auxRows.value.splice(index, 1) |
||||
|
} |
||||
|
|
||||
|
function valueOptions(key) { |
||||
|
return auxValues.value |
||||
|
.filter((v) => v.auxType === key) |
||||
|
.map((v) => ({ value: v.code, label: `${v.code} ${v.name || ''}` })) |
||||
|
} |
||||
|
|
||||
|
function saveAux() { |
||||
|
const aux = {} |
||||
|
for (const row of auxRows.value) { |
||||
|
if (row.key && String(row.key).trim()) { |
||||
|
aux[String(row.key).trim()] = row.value == null ? '' : String(row.value).trim() |
||||
|
} |
||||
|
} |
||||
|
editLines.value[auxIndex.value].aux = aux |
||||
|
auxDialog.value = false |
||||
|
} |
||||
|
|
||||
|
function validateBeforeSave() { |
||||
|
if (!editLines.value.length) { |
||||
|
ElMessage.warning('凭证至少需要两条分录') |
||||
|
return false |
||||
|
} |
||||
|
for (let i = 0; i < editLines.value.length; i++) { |
||||
|
const line = editLines.value[i] |
||||
|
if (!line.accountCode) { |
||||
|
ElMessage.warning(`第 ${i + 1} 行未选择科目`) |
||||
|
return false |
||||
|
} |
||||
|
if (!(Number(line.amount) > 0)) { |
||||
|
ElMessage.warning(`第 ${i + 1} 行金额必须大于 0`) |
||||
|
return false |
||||
|
} |
||||
|
const account = accountByCode(line.accountCode) |
||||
|
const missing = requiredAux(account).filter( |
||||
|
(key) => !line.aux || !String(line.aux[key] || '').trim() |
||||
|
) |
||||
|
if (missing.length) { |
||||
|
ElMessage.warning(`第 ${i + 1} 行科目 ${line.accountCode} 缺少辅助核算: ${missing.join(', ')}`) |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
if (totals.value.diff !== 0) { |
||||
|
ElMessage.warning( |
||||
|
`借贷不平衡,差额 ${formatAmount(totals.value.diff, voucher.value?.currency || '')}` |
||||
|
) |
||||
|
return false |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
function openSaveDialog() { |
||||
|
if (!validateBeforeSave()) { |
||||
|
return |
||||
|
} |
||||
|
saveReason.value = '' |
||||
|
saveDialog.value = true |
||||
|
} |
||||
|
|
||||
|
async function doSave() { |
||||
|
saving.value = true |
||||
|
try { |
||||
|
const payload = { |
||||
|
reason: saveReason.value || '财务审核调整', |
||||
|
lines: editLines.value.map((line) => ({ |
||||
|
direction: line.direction, |
||||
|
accountCode: line.accountCode, |
||||
|
amount: Number(line.amount), |
||||
|
taxRate: |
||||
|
line.taxRatePercent === null || line.taxRatePercent === undefined |
||||
|
? null |
||||
|
: Number(line.taxRatePercent) / 100, |
||||
|
taxAmount: |
||||
|
line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount), |
||||
|
aux: line.aux || {}, |
||||
|
summary: line.summary || '' |
||||
|
})) |
||||
|
} |
||||
|
const detail = await voucherApi.updateLines(voucher.value.id, payload) |
||||
|
applyDetail(detail) |
||||
|
saveDialog.value = false |
||||
|
ElMessage.success('分录已保存,并记录纠错经验') |
||||
|
} finally { |
||||
|
saving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function approve() { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
`确认审核通过凭证 ${voucher.value.voucherNo}?通过后将自动加入 ${voucher.value.targetSystem || 'NCC'} 推送队列。`, |
||||
|
'审核通过', |
||||
|
{ type: 'warning', confirmButtonText: '审核通过', cancelButtonText: '再看看' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
approving.value = true |
||||
|
try { |
||||
|
const detail = await voucherApi.approve(voucher.value.id) |
||||
|
applyDetail(detail) |
||||
|
ElMessage.success('审核通过,已加入 NCC 推送队列') |
||||
|
} finally { |
||||
|
approving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function submitReject() { |
||||
|
if (!rejectReason.value.trim()) { |
||||
|
ElMessage.warning('请填写驳回原因') |
||||
|
return |
||||
|
} |
||||
|
rejectLoading.value = true |
||||
|
try { |
||||
|
const detail = await voucherApi.reject(voucher.value.id, { reason: rejectReason.value.trim() }) |
||||
|
applyDetail(detail) |
||||
|
rejectDialog.value = false |
||||
|
ElMessage.success('凭证已驳回') |
||||
|
} finally { |
||||
|
rejectLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function regenerate() { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
'重新生成会基于源单据再执行一次 AI 建议,当前凭证将被新版本替换。确定继续?', |
||||
|
'重新生成凭证', |
||||
|
{ type: 'warning', confirmButtonText: '重新生成', cancelButtonText: '取消' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
regenerating.value = true |
||||
|
try { |
||||
|
const result = await voucherApi.regenerate(voucher.value.id) |
||||
|
if (result.voucherId) { |
||||
|
ElMessage.success(result.message || '已重新生成凭证') |
||||
|
if (String(result.voucherId) !== String(voucher.value.id)) { |
||||
|
await router.replace(`/vouchers/${result.voucherId}`) |
||||
|
} else { |
||||
|
applyDetail(result.voucher) |
||||
|
} |
||||
|
} else { |
||||
|
ElMessage.warning(result.message || `未能生成凭证: ${result.status}`) |
||||
|
await load() |
||||
|
} |
||||
|
} finally { |
||||
|
regenerating.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openOa() { |
||||
|
if (!voucher.value?.sourceDoc) { |
||||
|
ElMessage.warning('未找到源单据') |
||||
|
return |
||||
|
} |
||||
|
oaVisible.value = true |
||||
|
} |
||||
|
|
||||
|
watch( |
||||
|
() => route.params.id, |
||||
|
() => load() |
||||
|
) |
||||
|
|
||||
|
onMounted(load) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div v-loading="loading" class="page"> |
||||
|
<template v-if="voucher"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<div class="voucher-title"> |
||||
|
<h2 class="page-title">{{ voucher.voucherNo }}</h2> |
||||
|
<StatusTag :status="voucher.status" /> |
||||
|
<el-tag v-if="voucher.currentFlag !== 1" type="info" effect="plain">历史版本</el-tag> |
||||
|
</div> |
||||
|
<div class="page-subtitle"> |
||||
|
{{ voucher.voucherTypeCode }} · {{ voucher.period }} · |
||||
|
{{ voucher.companyId }}/{{ voucher.bookId }} · |
||||
|
版本 v{{ voucher.version }} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="toolbar-actions"> |
||||
|
<el-button @click="router.push('/vouchers')">返回列表</el-button> |
||||
|
<el-button @click="openOa">OA 查看凭证</el-button> |
||||
|
<el-button v-if="canRegenerate" :loading="regenerating" @click="regenerate"> |
||||
|
重新生成 |
||||
|
</el-button> |
||||
|
<template v-if="isPending"> |
||||
|
<el-button type="primary" :loading="saving" @click="openSaveDialog">保存分录</el-button> |
||||
|
<el-button type="success" :loading="approving" @click="approve">审核通过</el-button> |
||||
|
<el-button type="danger" plain @click="rejectDialog = true">驳回</el-button> |
||||
|
</template> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<el-alert |
||||
|
v-if="voucher.status === 'APPROVED'" |
||||
|
title="凭证已审核,等待 NCC 定时任务推送。" |
||||
|
type="success" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
<el-alert |
||||
|
v-else-if="voucher.status === 'PUSHED'" |
||||
|
:title="`已推送至 ${voucher.targetSystem || 'NCC'},外部凭证号 ${voucher.externalVoucherNo || '-'}。`" |
||||
|
type="success" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
<el-alert |
||||
|
v-else-if="voucher.status === 'PUSH_FAILED' || voucher.status === 'PUSH_UNKNOWN'" |
||||
|
title="推送异常,请前往推送监控执行重试或结果核对。" |
||||
|
type="error" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
> |
||||
|
<template #default> |
||||
|
<el-button text type="primary" @click="router.push('/push-tasks')">前往推送监控</el-button> |
||||
|
</template> |
||||
|
</el-alert> |
||||
|
<el-alert |
||||
|
v-else-if="voucher.status === 'REJECTED'" |
||||
|
:title="`凭证已驳回:${voucher.rejectReason || '-'}`" |
||||
|
type="warning" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<div class="detail-grid"> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">凭证日期</span>{{ voucher.voucherDate || '-' }} |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">币种</span>{{ voucher.currency || 'CNY' }} |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">借方合计</span> |
||||
|
<span class="amount">{{ formatAmount(totals.debit, voucher.currency) }}</span> |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">贷方合计</span> |
||||
|
<span class="amount">{{ formatAmount(totals.credit, voucher.currency) }}</span> |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">借贷差额</span> |
||||
|
<span class="amount" :style="{ color: totals.diff === 0 ? '#67c23a' : '#f56c6c' }"> |
||||
|
{{ formatAmount(totals.diff, voucher.currency) }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">AI 置信度</span> |
||||
|
{{ voucher.confidence == null ? '-' : Math.round(voucher.confidence * 100) + '%' }} |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">审核人</span>{{ voucher.approvedBy || '-' }} |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">审核时间</span>{{ formatDateTime(voucher.approvedAt) }} |
||||
|
</div> |
||||
|
<div class="detail-item"> |
||||
|
<span class="label">源单据</span> |
||||
|
<el-button |
||||
|
text |
||||
|
type="primary" |
||||
|
@click="router.push({ path: '/source-docs', query: { docId: voucher.sourceDocId } })" |
||||
|
> |
||||
|
#{{ voucher.sourceDocId }} {{ voucher.sourceDoc?.docNo || '' }} |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-card shadow="never" style="margin-top: 16px"> |
||||
|
<el-tabs v-model="activeTab"> |
||||
|
<el-tab-pane label="凭证分录" name="lines"> |
||||
|
<div class="lines-toolbar"> |
||||
|
<span class="muted"> |
||||
|
借贷平衡: |
||||
|
<b :style="{ color: totals.diff === 0 ? '#67c23a' : '#f56c6c' }"> |
||||
|
{{ totals.diff === 0 ? '是' : `差额 ${formatAmount(totals.diff, '')}` }} |
||||
|
</b> |
||||
|
</span> |
||||
|
<el-button v-if="isPending" size="small" @click="addLine">新增分录</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-table :data="isPending ? editLines : voucher.lines || []" size="small"> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="line-extra"> |
||||
|
<div> |
||||
|
决策来源:{{ row.decisionSource || '-' }} · 科目组: |
||||
|
{{ row.accountGroup || '-' }} · 置信度: |
||||
|
{{ row.confidence == null ? '-' : Math.round(row.confidence * 100) + '%' }} |
||||
|
</div> |
||||
|
<JsonBlock v-if="row.evidenceJson" :value="row.evidenceJson" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="lineNo" label="#" width="50" /> |
||||
|
<el-table-column label="方向" width="110"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-select v-if="isPending" v-model="row.direction" size="small"> |
||||
|
<el-option label="借" value="DEBIT" /> |
||||
|
<el-option label="贷" value="CREDIT" /> |
||||
|
</el-select> |
||||
|
<StatusTag v-else :status="row.direction" /> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="科目" min-width="230"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-select |
||||
|
v-if="isPending" |
||||
|
v-model="row.accountCode" |
||||
|
size="small" |
||||
|
filterable |
||||
|
placeholder="选择末级科目" |
||||
|
style="width: 100%" |
||||
|
@change="onAccountChange(row)" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="a in leafAccounts" |
||||
|
:key="a.code" |
||||
|
:label="`${a.code} ${a.name || ''}`" |
||||
|
:value="a.code" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<span v-else> |
||||
|
<span class="mono">{{ row.accountCode }}</span> {{ row.accountName }} |
||||
|
</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="金额" align="right" width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-input-number |
||||
|
v-if="isPending" |
||||
|
v-model="row.amount" |
||||
|
size="small" |
||||
|
:min="0" |
||||
|
:precision="2" |
||||
|
:controls="false" |
||||
|
style="width: 100%" |
||||
|
/> |
||||
|
<span v-else class="amount">{{ formatAmount(row.amount, '') }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="税率" width="110"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-input-number |
||||
|
v-if="isPending" |
||||
|
v-model="row.taxRatePercent" |
||||
|
size="small" |
||||
|
:min="0" |
||||
|
:max="100" |
||||
|
:precision="2" |
||||
|
:controls="false" |
||||
|
style="width: 100%" |
||||
|
/> |
||||
|
<span v-else> |
||||
|
{{ row.taxRate == null ? '-' : Number(row.taxRate) * 100 + '%' }} |
||||
|
</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="税额" align="right" width="130"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-input-number |
||||
|
v-if="isPending" |
||||
|
v-model="row.taxAmount" |
||||
|
size="small" |
||||
|
:min="0" |
||||
|
:precision="2" |
||||
|
:controls="false" |
||||
|
style="width: 100%" |
||||
|
/> |
||||
|
<span v-else>{{ formatAmount(row.taxAmount, '') }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="辅助核算" min-width="170"> |
||||
|
<template #default="{ row }"> |
||||
|
<template v-if="isPending"> |
||||
|
<el-button size="small" text type="primary" @click="openAux(editLines.indexOf(row))"> |
||||
|
编辑 ({{ auxCount(row.aux) }}) |
||||
|
</el-button> |
||||
|
<span class="muted aux-hint">{{ auxText(row.aux) }}</span> |
||||
|
</template> |
||||
|
<span v-else class="aux-hint">{{ auxText(row.auxJson) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="摘要" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-input v-if="isPending" v-model="row.summary" size="small" /> |
||||
|
<span v-else>{{ row.summary || '-' }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column v-if="isPending" label="操作" width="70"> |
||||
|
<template #default="{ $index }"> |
||||
|
<el-button text type="danger" @click="removeLine($index)">删除</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="AI 决策追踪" name="trace"> |
||||
|
<div class="section-title">决策过程</div> |
||||
|
<JsonBlock :value="voucher.decisionTrace" /> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="纠错记录" name="corrections"> |
||||
|
<el-table |
||||
|
:data="voucher.corrections" |
||||
|
size="small" |
||||
|
empty-text="暂无纠错记录" |
||||
|
> |
||||
|
<el-table-column label="时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="operatorId" label="操作人" width="110" /> |
||||
|
<el-table-column prop="reason" label="原因" min-width="180" /> |
||||
|
<el-table-column prop="scope" label="范围" width="90" /> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="correction-panels"> |
||||
|
<div> |
||||
|
<div class="muted">修改前</div> |
||||
|
<JsonBlock :value="row.originalJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">修改后</div> |
||||
|
<JsonBlock :value="row.correctedJson" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="muted">差异</div> |
||||
|
<JsonBlock :value="row.diffJson" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="推送任务" name="push"> |
||||
|
<el-table :data="voucher.pushTasks" size="small" empty-text="暂无推送任务"> |
||||
|
<el-table-column prop="id" label="#" width="60" /> |
||||
|
<el-table-column prop="targetSystem" label="目标系统" width="100" /> |
||||
|
<el-table-column label="状态" width="110"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="尝试" width="80"> |
||||
|
<template #default="{ row }">{{ row.attemptCount }}/{{ row.maxAttempts }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="externalVoucherNo" label="外部凭证号" min-width="150"> |
||||
|
<template #default="{ row }">{{ row.externalVoucherNo || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="错误信息" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<span v-if="row.lastErrorCode" class="mono">{{ row.lastErrorCode }}</span> |
||||
|
{{ row.lastErrorMessage || '' }} |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="下次重试" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.nextRetryAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="100"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
text |
||||
|
type="primary" |
||||
|
@click="router.push({ path: '/push-tasks', query: { taskId: row.id } })" |
||||
|
> |
||||
|
处理 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="源单据" name="source"> |
||||
|
<template v-if="voucher.sourceDoc"> |
||||
|
<el-descriptions :column="3" border size="small"> |
||||
|
<el-descriptions-item label="单据号"> |
||||
|
{{ voucher.sourceDoc.docNo }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="标题"> |
||||
|
{{ voucher.sourceDoc.title }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="申请人"> |
||||
|
{{ voucher.sourceDoc.applicantName }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="部门"> |
||||
|
{{ voucher.sourceDoc.departmentName }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="业务场景"> |
||||
|
{{ voucher.sourceDoc.businessScene || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="金额"> |
||||
|
{{ formatAmount(voucher.sourceDoc.totalAmount, voucher.sourceDoc.currency) }} |
||||
|
</el-descriptions-item> |
||||
|
</el-descriptions> |
||||
|
<el-table :data="voucher.sourceLines" size="small" style="margin-top: 12px"> |
||||
|
<el-table-column prop="lineNo" label="#" width="50" /> |
||||
|
<el-table-column prop="description" label="摘要" min-width="180" /> |
||||
|
<el-table-column prop="expenseCategory" label="类别" width="110" /> |
||||
|
<el-table-column label="金额" align="right" width="130"> |
||||
|
<template #default="{ row }">{{ formatAmount(row.amount, '') }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="invoiceType" label="发票类型" width="100" /> |
||||
|
<el-table-column prop="occurredAt" label="发生日期" width="110" /> |
||||
|
<el-table-column prop="merchantName" label="商户" width="150" /> |
||||
|
</el-table> |
||||
|
</template> |
||||
|
<el-empty v-else description="未找到源单据" /> |
||||
|
</el-tab-pane> |
||||
|
</el-tabs> |
||||
|
</el-card> |
||||
|
</template> |
||||
|
|
||||
|
<el-dialog v-model="saveDialog" title="保存分录与纠错原因" width="520px"> |
||||
|
<el-alert |
||||
|
title="保存后系统会比对修改前后的分录并记录纠错经验,用于后续规则学习。" |
||||
|
type="info" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
<el-form label-position="top"> |
||||
|
<el-form-item label="修改原因"> |
||||
|
<el-input |
||||
|
v-model="saveReason" |
||||
|
type="textarea" |
||||
|
:rows="3" |
||||
|
placeholder="例如:出差期间打车应计入差旅费,而不是市内交通费" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
<template #footer> |
||||
|
<el-button @click="saveDialog = false">取消</el-button> |
||||
|
<el-button type="primary" :loading="saving" @click="doSave">保存</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<el-dialog v-model="rejectDialog" title="驳回凭证" width="480px"> |
||||
|
<el-form label-position="top"> |
||||
|
<el-form-item label="驳回原因(必填)"> |
||||
|
<el-input v-model="rejectReason" type="textarea" :rows="3" /> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
<template #footer> |
||||
|
<el-button @click="rejectDialog = false">取消</el-button> |
||||
|
<el-button type="danger" :loading="rejectLoading" @click="submitReject">确认驳回</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<el-dialog v-model="auxDialog" title="编辑辅助核算" width="600px"> |
||||
|
<el-alert |
||||
|
v-if="editLines[auxIndex]" |
||||
|
:title="`科目 ${editLines[auxIndex].accountCode} 必需辅助核算:${ |
||||
|
requiredAux(accountByCode(editLines[auxIndex].accountCode)).join(', ') || '无' |
||||
|
}`" |
||||
|
type="info" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 12px" |
||||
|
/> |
||||
|
<div v-for="(row, index) in auxRows" :key="index" class="aux-row"> |
||||
|
<el-select |
||||
|
v-model="row.key" |
||||
|
filterable |
||||
|
allow-create |
||||
|
default-first-option |
||||
|
placeholder="辅助核算类型" |
||||
|
style="width: 180px" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="item in auxItems" |
||||
|
:key="item.auxType" |
||||
|
:label="`${item.auxType} ${item.name || ''}`" |
||||
|
:value="item.auxType" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<el-select |
||||
|
v-model="row.value" |
||||
|
filterable |
||||
|
allow-create |
||||
|
default-first-option |
||||
|
placeholder="值" |
||||
|
style="flex: 1" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="option in valueOptions(row.key)" |
||||
|
:key="option.value" |
||||
|
:label="option.label" |
||||
|
:value="option.value" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<el-button text type="danger" @click="removeAuxRow(index)">删除</el-button> |
||||
|
</div> |
||||
|
<el-button size="small" style="margin-top: 8px" @click="addAuxRow">新增辅助核算</el-button> |
||||
|
<template #footer> |
||||
|
<el-button @click="auxDialog = false">取消</el-button> |
||||
|
<el-button type="primary" @click="saveAux">确定</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<OaViewDialog v-model="oaVisible" :doc="voucher?.sourceDoc" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.voucher-title { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
} |
||||
|
|
||||
|
.lines-toolbar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
|
||||
|
.line-extra { |
||||
|
padding: 8px 16px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 6px; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.aux-hint { |
||||
|
display: inline-block; |
||||
|
margin-left: 4px; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.correction-panels { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); |
||||
|
gap: 12px; |
||||
|
padding: 8px 16px; |
||||
|
} |
||||
|
|
||||
|
.aux-row { |
||||
|
display: flex; |
||||
|
gap: 8px; |
||||
|
align-items: center; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,149 @@ |
|||||
|
<script setup> |
||||
|
import { onMounted, reactive, ref } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { voucherApi } from '../api' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import { formatAmount, formatDateTime } from '../utils/format' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const loading = ref(false) |
||||
|
const vouchers = ref([]) |
||||
|
const filters = reactive({ status: '', keyword: '' }) |
||||
|
|
||||
|
const STATUSES = [ |
||||
|
'PENDING_REVIEW', |
||||
|
'APPROVED', |
||||
|
'PUSHED', |
||||
|
'REJECTED', |
||||
|
'PUSH_FAILED', |
||||
|
'PUSH_UNKNOWN', |
||||
|
'SUPERSEDED' |
||||
|
] |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
vouchers.value = await voucherApi.list({ |
||||
|
status: filters.status || undefined, |
||||
|
keyword: filters.keyword || undefined |
||||
|
}) |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function reset() { |
||||
|
filters.status = '' |
||||
|
filters.keyword = '' |
||||
|
load() |
||||
|
} |
||||
|
|
||||
|
onMounted(load) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">凭证审核</h2> |
||||
|
<div class="page-subtitle"> |
||||
|
AI 依据规则与历史纠错推荐科目,财务可修改分录后审核;审核通过后加入 NCC 推送队列 |
||||
|
</div> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select |
||||
|
v-model="filters.status" |
||||
|
placeholder="凭证状态" |
||||
|
clearable |
||||
|
style="width: 170px" |
||||
|
@change="load" |
||||
|
> |
||||
|
<el-option v-for="s in STATUSES" :key="s" :label="s" :value="s" /> |
||||
|
</el-select> |
||||
|
<el-input |
||||
|
v-model="filters.keyword" |
||||
|
placeholder="凭证号 / 外部凭证号" |
||||
|
clearable |
||||
|
style="width: 260px" |
||||
|
@keyup.enter="load" |
||||
|
@clear="load" |
||||
|
/> |
||||
|
<el-button type="primary" @click="load">查询</el-button> |
||||
|
<el-button @click="reset">重置</el-button> |
||||
|
<span class="muted">共 {{ vouchers.length }} 张</span> |
||||
|
</div> |
||||
|
|
||||
|
<el-table |
||||
|
v-loading="loading" |
||||
|
:data="vouchers" |
||||
|
size="small" |
||||
|
empty-text="暂无凭证" |
||||
|
row-class-name="clickable-row" |
||||
|
@row-click="(row) => router.push(`/vouchers/${row.id}`)" |
||||
|
> |
||||
|
<el-table-column prop="voucherNo" label="凭证号" min-width="190"> |
||||
|
<template #default="{ row }"><span class="mono">{{ row.voucherNo }}</span></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="状态" width="110"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="借方合计" align="right" width="150"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="amount">{{ formatAmount(row.totalDebit, row.currency) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="贷方合计" align="right" width="150"> |
||||
|
<template #default="{ row }">{{ formatAmount(row.totalCredit, '') }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="period" label="期间" width="100" /> |
||||
|
<el-table-column prop="voucherTypeCode" label="类型" width="80" /> |
||||
|
<el-table-column label="版本" width="70"> |
||||
|
<template #default="{ row }"> |
||||
|
v{{ row.version }} |
||||
|
<el-tag v-if="row.currentFlag !== 1" size="small" type="info">历史</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="置信度" width="90"> |
||||
|
<template #default="{ row }"> |
||||
|
<span v-if="row.confidence != null">{{ Math.round(row.confidence * 100) }}%</span> |
||||
|
<span v-else class="muted">-</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="源单据" width="90"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
text |
||||
|
type="primary" |
||||
|
@click.stop="router.push({ path: '/source-docs', query: { docId: row.sourceDocId } })" |
||||
|
> |
||||
|
#{{ row.sourceDocId }} |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="外部凭证号" min-width="140"> |
||||
|
<template #default="{ row }">{{ row.externalVoucherNo || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="创建时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="90" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button text type="primary" @click.stop="router.push(`/vouchers/${row.id}`)"> |
||||
|
审核 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.clickable-row { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,16 @@ |
|||||
|
import { defineConfig } from 'vite' |
||||
|
import vue from '@vitejs/plugin-vue' |
||||
|
|
||||
|
const backend = process.env.VITE_BACKEND_URL || 'http://localhost:8080' |
||||
|
|
||||
|
export default defineConfig({ |
||||
|
plugins: [vue()], |
||||
|
server: { |
||||
|
host: '0.0.0.0', |
||||
|
port: 5173, |
||||
|
proxy: { |
||||
|
'/api': { target: backend, changeOrigin: true }, |
||||
|
'/open': { target: backend, changeOrigin: true } |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
Loading…
Reference in new issue