Browse Source

Initial commit: autoVoucher console frontend

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
default 3 weeks ago
commit
b11d833502
  1. 8
      .gitignore
  2. 12
      index.html
  3. 1939
      package-lock.json
  4. 22
      package.json
  5. 3
      src/App.vue
  6. 31
      src/api/http.js
  7. 73
      src/api/index.js
  8. 25
      src/components/JsonBlock.vue
  9. 199
      src/components/OaViewDialog.vue
  10. 71
      src/components/StatusTag.vue
  11. 39
      src/composables/useAuth.js
  12. 172
      src/layouts/AppLayout.vue
  13. 16
      src/main.js
  14. 45
      src/router/index.js
  15. 187
      src/styles.css
  16. 48
      src/utils/format.js
  17. 291
      src/views/AuditLogsView.vue
  18. 220
      src/views/DashboardView.vue
  19. 87
      src/views/LoginView.vue
  20. 516
      src/views/MasterDataView.vue
  21. 456
      src/views/PushTasksView.vue
  22. 523
      src/views/RulesView.vue
  23. 539
      src/views/SourceDocsView.vue
  24. 868
      src/views/VoucherDetailView.vue
  25. 149
      src/views/VouchersView.vue
  26. 16
      vite.config.js

8
.gitignore

@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.env.local
.env.*.local
.idea/
.vscode/
.DS_Store

12
index.html

@ -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>

1939
package-lock.json

File diff suppressed because it is too large

22
package.json

@ -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"
}
}

3
src/App.vue

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

31
src/api/http.js

@ -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

73
src/api/index.js

@ -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')
}

25
src/components/JsonBlock.vue

@ -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>

199
src/components/OaViewDialog.vue

@ -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>

71
src/components/StatusTag.vue

@ -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>

39
src/composables/useAuth.js

@ -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 }
}

172
src/layouts/AppLayout.vue

@ -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>

16
src/main.js

@ -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')

45
src/router/index.js

@ -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

187
src/styles.css

@ -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);
}

48
src/utils/format.js

@ -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)
}

291
src/views/AuditLogsView.vue

@ -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>

220
src/views/DashboardView.vue

@ -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>

87
src/views/LoginView.vue

@ -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>

516
src/views/MasterDataView.vue

@ -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>

456
src/views/PushTasksView.vue

@ -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>

523
src/views/RulesView.vue

@ -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>

539
src/views/SourceDocsView.vue

@ -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>

868
src/views/VoucherDetailView.vue

@ -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>

149
src/views/VouchersView.vue

@ -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>

16
vite.config.js

@ -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…
Cancel
Save