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