Browse Source

feat: add system management and scheduled task modules

- Move 审计日志 after 系统管理; add 定时任务 submenu and /scheduled-tasks route
- Add ScheduledTasksView with cron editing, manual run and run history drawer
- Add StyleConfigView and SysConfigView under system management
- Add line feedback dialog and branding composable
- Remove explanatory subtitles and mock-mode notices from pages
- Allow workspace proxy hosts in Vite dev server (allowedHosts, HMR port)

Co-Authored-By: Claude Code <noreply@anthropic.com>
master
default 3 weeks ago
parent
commit
07d414829f
  1. 40
      src/api/index.js
  2. 520
      src/components/LineFeedbackDialog.vue
  3. 2
      src/components/StatusTag.vue
  4. 41
      src/composables/useBranding.js
  5. 96
      src/layouts/AppLayout.vue
  6. 6
      src/router/index.js
  7. 12
      src/styles.css
  8. 22
      src/views/AuditLogsView.vue
  9. 2
      src/views/DashboardView.vue
  10. 18
      src/views/LoginView.vue
  11. 10
      src/views/MasterDataView.vue
  12. 17
      src/views/PushTasksView.vue
  13. 439
      src/views/ScheduledTasksView.vue
  14. 16
      src/views/SourceDocsView.vue
  15. 221
      src/views/StyleConfigView.vue
  16. 345
      src/views/SysConfigView.vue
  17. 242
      src/views/VoucherDetailView.vue
  18. 8
      src/views/VouchersView.vue
  19. 9
      vite.config.js

40
src/api/index.js

@ -1,5 +1,11 @@
import http from './http' import http from './http'
// Voucher generation may call an external LLM synchronously (up to two
// 20-second attempts plus retry backoff), so these endpoints need a much
// longer budget than the global 30-second request timeout. Aborting earlier
// would stop the UI spinner while the backend keeps processing.
const GENERATION_TIMEOUT = 180000
export const authApi = { export const authApi = {
login: (data) => http.post('/auth/login', data), login: (data) => http.post('/auth/login', data),
me: () => http.get('/auth/me'), me: () => http.get('/auth/me'),
@ -10,11 +16,32 @@ export const dashboardApi = {
get: () => http.get('/dashboard') get: () => http.get('/dashboard')
} }
export const systemConfigApi = {
get: () => http.get('/system-config'),
update: (data) => http.put('/system-config', data)
}
export const sysConfigApi = {
list: () => http.get('/sys-config'),
create: (data) => http.post('/sys-config', data),
update: (id, data) => http.put(`/sys-config/${id}`, data),
remove: (id) => http.delete(`/sys-config/${id}`)
}
export const scheduledJobApi = {
list: () => http.get('/scheduled-jobs'),
update: (id, data) => http.put(`/scheduled-jobs/${id}`, data),
run: (id) => http.post(`/scheduled-jobs/${id}/run`),
runs: (id, params) => http.get(`/scheduled-jobs/${id}/runs`, { params })
}
export const sourceDocApi = { export const sourceDocApi = {
list: (params) => http.get('/source-docs', { params }), list: (params) => http.get('/source-docs', { params }),
detail: (id) => http.get(`/source-docs/${id}`), detail: (id) => http.get(`/source-docs/${id}`),
reprocess: (id) => http.post(`/source-docs/${id}/reprocess`), reprocess: (id) =>
manualVoucher: (id, data) => http.post(`/source-docs/${id}/manual-voucher`, data) http.post(`/source-docs/${id}/reprocess`, null, { timeout: GENERATION_TIMEOUT }),
manualVoucher: (id, data) =>
http.post(`/source-docs/${id}/manual-voucher`, data, { timeout: GENERATION_TIMEOUT })
} }
export const voucherApi = { export const voucherApi = {
@ -23,7 +50,14 @@ export const voucherApi = {
updateLines: (id, data) => http.put(`/vouchers/${id}/lines`, data), updateLines: (id, data) => http.put(`/vouchers/${id}/lines`, data),
approve: (id) => http.post(`/vouchers/${id}/approve`), approve: (id) => http.post(`/vouchers/${id}/approve`),
reject: (id, data) => http.post(`/vouchers/${id}/reject`, data), reject: (id, data) => http.post(`/vouchers/${id}/reject`, data),
regenerate: (id) => http.post(`/vouchers/${id}/regenerate`) regenerate: (id) =>
http.post(`/vouchers/${id}/regenerate`, null, { timeout: GENERATION_TIMEOUT }),
feedbackContext: (id, lineNo, params) =>
http.get(`/vouchers/${id}/lines/${lineNo}/feedback/context`, { params }),
feedbackTest: (id, lineNo, data) =>
http.post(`/vouchers/${id}/lines/${lineNo}/feedback/test`, data),
feedbackApply: (id, lineNo, data) =>
http.post(`/vouchers/${id}/lines/${lineNo}/feedback/apply`, data)
} }
export const pushTaskApi = { export const pushTaskApi = {

520
src/components/LineFeedbackDialog.vue

@ -0,0 +1,520 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { voucherApi } from '../api'
import { formatAmount } from '../utils/format'
import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: { type: Boolean, default: false },
voucherId: { type: [String, Number], default: null },
line: { type: Object, default: null },
accounts: { type: Array, default: () => [] },
initialTab: { type: String, default: 'feedback' }
})
const emit = defineEmits(['update:modelValue', 'applied'])
const AUX_FACT_FIELDS = {
DEPT: 'departmentId',
CUSTOMER: 'customerId',
PROJECT: 'projectId',
COST_CENTER: 'costCenterId',
EMP: 'applicantId'
}
const tab = ref('feedback')
const loading = ref(false)
const testing = ref(false)
const applying = ref(false)
const context = ref(null)
const sourceLineNo = ref(null)
const accountCode = ref('')
const selectedFields = ref([])
const aux = ref({})
const reason = ref('')
const testResult = ref(null)
const sourceLineLocked = computed(() => props.line?.sourceLineNo != null)
const sourceLines = computed(() => context.value?.sourceLines || [])
const sameDay = computed(() => context.value?.sameDay || { total: 0, truncated: false, lines: [] })
const facts = computed(() => context.value?.facts || {})
const allowlist = computed(() => context.value?.allowlist || [])
const contextFields = computed(() =>
allowlist.value.filter((item) => item.field !== 'expenseCategory')
)
const categoryItem = computed(() =>
allowlist.value.find((item) => item.field === 'expenseCategory')
)
const selectedAccount = computed(() =>
props.accounts.find((a) => a.code === accountCode.value)
)
const requiredAuxKeys = computed(() => {
const json = selectedAccount.value?.auxRequiredJson
if (!json) {
return []
}
try {
const parsed = JSON.parse(json)
return Array.isArray(parsed) ? parsed : []
} catch (e) {
return []
}
})
const leafAccounts = computed(() =>
props.accounts.filter((a) => a.isLeaf !== false && (!a.status || a.status === 'ACTIVE'))
)
const currentRule = computed(() => context.value?.currentRule)
const warnings = computed(() => context.value?.warnings || [])
const canApply = computed(() => Boolean(testResult.value?.passed))
const failedChecks = computed(() =>
(testResult.value?.checks || []).filter((item) => !item.passed)
)
function visible(value) {
emit('update:modelValue', value)
}
function displayValue(item) {
if (item.value === null || item.value === undefined || item.value === '') {
return '无值'
}
if (item.type === 'boolean') {
return item.value ? '是' : '否'
}
return String(item.value)
}
function conditionLabel(item) {
return `${item.label}(当前值:${displayValue(item)})`
}
function sourceLineLabel(line) {
const parts = [`#${line.lineNo}`, line.expenseCategory || '未分类']
if (line.description) {
parts.push(line.description)
}
parts.push(formatAmount(line.amount, ''))
return parts.join(' · ')
}
function clearResult() {
testResult.value = null
}
async function loadContext(explicitSourceLineNo) {
if (!props.voucherId || !props.line?.lineNo) {
return
}
loading.value = true
try {
const params =
explicitSourceLineNo === null || explicitSourceLineNo === undefined
? {}
: { sourceLineNo: explicitSourceLineNo }
const data = await voucherApi.feedbackContext(props.voucherId, props.line.lineNo, params)
context.value = data
sourceLineNo.value = data.selectedSourceLineNo ?? null
accountCode.value = accountCode.value || props.line.accountCode || ''
selectedFields.value = (data.defaultFields || []).filter(
(field) => field !== 'expenseCategory'
)
syncAux()
clearResult()
} finally {
loading.value = false
}
}
function syncAux() {
const next = {}
for (const key of requiredAuxKeys.value) {
next[key] = aux.value[key] ?? ''
}
aux.value = next
}
function onSourceLineChange(value) {
accountCode.value = props.line?.accountCode || ''
loadContext(value)
}
function onAccountChange() {
syncAux()
}
function auxPlaceholder(key) {
const factField = AUX_FACT_FIELDS[key]
const value = factField ? facts.value[factField] : null
return value ? `留空自动取 ${value}` : '必填'
}
function cleanAux() {
const result = {}
for (const [key, value] of Object.entries(aux.value)) {
if (String(value ?? '').trim()) {
result[key] = String(value).trim()
}
}
return result
}
function payload() {
return {
sourceLineNo: sourceLineNo.value,
fields: ['expenseCategory', ...selectedFields.value],
accountCode: accountCode.value,
aux: cleanAux(),
reason: reason.value.trim() || null
}
}
async function test() {
if (!sourceLineNo.value) {
ElMessage.warning('请先选择该分录对应的源费用行')
return
}
if (!accountCode.value) {
ElMessage.warning('请选择正确的科目')
return
}
testing.value = true
try {
testResult.value = await voucherApi.feedbackTest(
props.voucherId,
props.line.lineNo,
payload()
)
if (testResult.value.passed) {
ElMessage.success('测试通过,可以应用规则')
} else {
ElMessage.warning('测试未通过,请根据失败项调整条件后重试')
}
} finally {
testing.value = false
}
}
async function apply() {
if (!testResult.value?.fingerprint) {
ElMessage.warning('请先测试,测试通过后再应用规则')
return
}
applying.value = true
try {
const result = await voucherApi.feedbackApply(props.voucherId, props.line.lineNo, {
...payload(),
fingerprint: testResult.value.fingerprint
})
if (!result.applied) {
testResult.value = result
ElMessage.warning(result.message || '规则应用前校验未通过,请查看检查项')
return
}
ElMessage.success(
result.created
? `规则 ${result.ruleCode} v${result.version} 已生效,请返回凭证点击「重新生成」`
: `规则 ${result.ruleCode} 已存在,无需重复创建;请返回凭证点击「重新生成」`
)
emit('applied', result)
visible(false)
} finally {
applying.value = false
}
}
function init() {
tab.value = props.initialTab || 'feedback'
context.value = null
sourceLineNo.value = null
accountCode.value = ''
selectedFields.value = []
aux.value = {}
reason.value = ''
testResult.value = null
loadContext(props.line?.sourceLineNo ?? null)
}
watch(
() => props.modelValue,
(value) => {
if (value) {
init()
}
}
)
watch([sourceLineNo, accountCode, selectedFields, aux], clearResult, { deep: true })
</script>
<template>
<el-dialog
:model-value="modelValue"
title="行反馈学习"
width="900px"
top="5vh"
destroy-on-close
@update:model-value="visible"
>
<el-tabs v-model="tab">
<el-tab-pane label="反馈学习" name="feedback">
<div v-loading="loading">
<el-alert
v-if="props.line"
:title="`凭证第 ${props.line.lineNo} 行 · ${props.line.accountCode || '-'} ${
props.line.accountName || ''
} · ${formatAmount(props.line.amount, '')} · ${props.line.summary || '无摘要'}`"
type="info"
:closable="false"
show-icon
style="margin-bottom: 14px"
/>
<el-form label-width="120px" label-position="right" size="small">
<el-form-item label="对应费用行">
<div style="display: flex; flex-direction: column; gap: 6px; width: 100%">
<el-select
v-model="sourceLineNo"
filterable
placeholder="请选择该分录对应的源费用行"
:disabled="sourceLineLocked"
style="width: 100%"
@change="onSourceLineChange"
>
<el-option
v-for="line in sourceLines"
:key="line.lineNo"
:label="sourceLineLabel(line)"
:value="line.lineNo"
/>
</el-select>
<span v-if="sourceLineLocked" class="muted">
该分录已关联源费用行,不可更改
</span>
</div>
</el-form-item>
<el-form-item label="正确科目">
<el-select
v-model="accountCode"
filterable
placeholder="选择末级科目"
style="width: 100%"
@change="onAccountChange"
>
<el-option
v-for="a in leafAccounts"
:key="a.code"
:label="`${a.code} ${a.name || ''}`"
:value="a.code"
/>
</el-select>
</el-form-item>
<el-form-item
v-for="key in requiredAuxKeys"
:key="key"
:label="`辅助核算 ${key}`"
>
<el-input v-model="aux[key]" :placeholder="auxPlaceholder(key)" style="width: 100%" />
</el-form-item>
<el-form-item label="上下文条件">
<div v-if="!categoryItem" class="muted">
未找到源费用行,请先选择上方的对应费用行。
</div>
<div v-else class="condition-list">
<el-checkbox :model-value="true" disabled>
{{ conditionLabel(categoryItem) }}
</el-checkbox>
<el-checkbox-group v-model="selectedFields" class="condition-group">
<el-checkbox
v-for="item in contextFields"
:key="item.field"
:value="item.field"
:disabled="item.value === null || item.value === undefined"
>
{{ conditionLabel(item) }}
</el-checkbox>
</el-checkbox-group>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="reason"
type="textarea"
:rows="2"
placeholder="可选,记录本次反馈原因"
/>
</el-form-item>
</el-form>
<el-alert
v-for="warning in warnings"
:key="warning"
:title="warning"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 8px"
/>
<div class="section-title">
同日相关费用(锚点日期:{{ context?.anchorDate || '-' }},共
{{ sameDay.total }} 条)
</div>
<el-table :data="sameDay.lines" size="small" empty-text="当天没有其他相关费用">
<el-table-column prop="docNo" label="单号" min-width="140" />
<el-table-column prop="lineNo" label="#" width="50" />
<el-table-column prop="expenseCategory" label="类别" width="100" />
<el-table-column prop="description" label="摘要" min-width="150">
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column prop="expenseCity" label="城市" width="90">
<template #default="{ row }">{{ row.expenseCity || '-' }}</template>
</el-table-column>
<el-table-column prop="invoiceDate" label="开票日期" width="110">
<template #default="{ row }">{{ row.invoiceDate || '-' }}</template>
</el-table-column>
<el-table-column label="跨市" width="70">
<template #default="{ row }">{{ row.crossCity ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="金额" align="right" width="110">
<template #default="{ row }">{{ formatAmount(row.amount, '') }}</template>
</el-table-column>
</el-table>
<div v-if="sameDay.truncated" class="muted" style="margin-top: 6px">
当日费用过多,仅展示前 20 条,上下文事实仍基于全部费用计算。
</div>
<template v-if="testResult">
<el-alert
:title="
testResult.passed
? `测试通过 · 规则 ${testResult.ruleCode} · 优先级 ${testResult.suggestedPriority}`
: '测试未通过,请调整条件后重试'
"
:type="testResult.passed ? 'success' : 'error'"
:closable="false"
show-icon
style="margin-top: 14px"
>
<div v-if="testResult.passed" class="muted">
条件:{{ JSON.stringify(testResult.condition) }}
</div>
<div v-else>
<div v-for="check in failedChecks" :key="check.code">
{{ check.code }}:{{ check.message }}
</div>
</div>
</el-alert>
<el-table :data="testResult.checks || []" size="small" style="margin-top: 8px">
<el-table-column prop="code" label="检查项" width="200" />
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.passed ? 'success' : 'danger'" size="small">
{{ row.passed ? '通过' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="300" />
</el-table>
<div v-if="testResult.conflicts?.length" class="muted" style="margin-top: 8px">
应用后将退役:
{{ testResult.conflicts.map((c) => c.ruleCode).join('、') }}
</div>
</template>
</div>
</el-tab-pane>
<el-tab-pane label="当前规则与上下文" name="context">
<div v-loading="loading">
<div class="section-title">当前命中规则</div>
<el-descriptions v-if="currentRule" :column="2" border size="small">
<el-descriptions-item label="规则编码">{{ currentRule.ruleCode }}</el-descriptions-item>
<el-descriptions-item label="规则名称">{{ currentRule.name }}</el-descriptions-item>
<el-descriptions-item label="优先级">{{ currentRule.priority }}</el-descriptions-item>
<el-descriptions-item label="命中条件">
{{ (currentRule.matchedFields || []).join('、') || '-' }}
</el-descriptions-item>
<el-descriptions-item label="规则结果" :span="2">
{{ JSON.stringify(currentRule.result) }}
</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="当前没有规则命中该费用行" :image-size="60" />
<div class="section-title" style="margin-top: 16px">上下文事实</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="item in allowlist"
:key="item.field"
:label="item.label"
>
{{ displayValue(item) }}
</el-descriptions-item>
</el-descriptions>
<el-empty
v-if="!allowlist.length"
description="请先在「反馈学习」选择对应费用行"
:image-size="60"
/>
<div class="section-title" style="margin-top: 16px">
同日费用(共 {{ sameDay.total }} 条)
</div>
<el-table :data="sameDay.lines" size="small" empty-text="当天没有其他相关费用">
<el-table-column prop="docNo" label="单号" min-width="140" />
<el-table-column prop="lineNo" label="#" width="50" />
<el-table-column prop="expenseCategory" label="类别" width="100" />
<el-table-column prop="expenseCity" label="城市" width="90">
<template #default="{ row }">{{ row.expenseCity || '-' }}</template>
</el-table-column>
<el-table-column prop="invoiceDate" label="开票日期" width="110">
<template #default="{ row }">{{ row.invoiceDate || '-' }}</template>
</el-table-column>
<el-table-column label="金额" align="right" width="110">
<template #default="{ row }">{{ formatAmount(row.amount, '') }}</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="visible(false)">取消</el-button>
<el-button v-if="tab === 'feedback'" :loading="testing" @click="test">测试</el-button>
<el-button
v-if="tab === 'feedback'"
type="primary"
:loading="applying"
:disabled="!canApply"
@click="apply"
>
应用规则
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.condition-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.condition-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.section-title {
margin: 14px 0 8px;
font-weight: 600;
font-size: 13px;
}
.muted {
color: #8a94a6;
font-size: 12px;
}
</style>

2
src/components/StatusTag.vue

@ -23,6 +23,7 @@ const LABELS = {
SUCCESS: '成功', SUCCESS: '成功',
SUCCESS_WITH_EXISTING: '重复已确认', SUCCESS_WITH_EXISTING: '重复已确认',
FAILED: '失败', FAILED: '失败',
RUNNING: '运行中',
ACTIVE: '启用中', ACTIVE: '启用中',
INACTIVE: '已停用', INACTIVE: '已停用',
DISABLED: '已停用', DISABLED: '已停用',
@ -53,6 +54,7 @@ const TYPES = {
SUCCESS: 'success', SUCCESS: 'success',
SUCCESS_WITH_EXISTING: 'success', SUCCESS_WITH_EXISTING: 'success',
FAILED: 'danger', FAILED: 'danger',
RUNNING: 'primary',
ACTIVE: 'success', ACTIVE: 'success',
INACTIVE: 'info', INACTIVE: 'info',
DISABLED: 'info', DISABLED: 'info',

41
src/composables/useBranding.js

@ -0,0 +1,41 @@
import { reactive } from 'vue'
import { systemConfigApi } from '../api'
const state = reactive({
platformName: 'AutoVoucher',
logo: null,
loaded: false
})
let pending = null
export function useBranding() {
async function load(force = false) {
if (state.loaded && !force) {
return state
}
if (pending) {
return pending
}
pending = systemConfigApi
.get()
.then((data) => {
apply(data || {})
state.loaded = true
return state
})
.catch(() => state)
.finally(() => {
pending = null
})
return pending
}
function apply(data) {
state.platformName = data.platformName || 'AutoVoucher'
state.logo = data.logo || null
state.loaded = true
}
return { branding: state, load, apply }
}

96
src/layouts/AppLayout.vue

@ -1,11 +1,15 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '../composables/useAuth' import { useAuth } from '../composables/useAuth'
import { useBranding } from '../composables/useBranding'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { state, logout } = useAuth() const { state, logout } = useAuth()
const { branding, load: loadBranding } = useBranding()
onMounted(() => loadBranding())
const activeMenu = computed(() => { const activeMenu = computed(() => {
if (route.path.startsWith('/vouchers')) { if (route.path.startsWith('/vouchers')) {
@ -24,20 +28,24 @@ async function signOut() {
<el-container class="layout"> <el-container class="layout">
<el-aside width="224px" class="sidebar"> <el-aside width="224px" class="sidebar">
<div class="brand"> <div class="brand">
<div class="brand-mark">AV</div> <div v-if="branding.logo" class="brand-mark brand-mark-logo">
<img :src="branding.logo" alt="logo" />
</div>
<div v-else class="brand-mark">AV</div>
<div> <div>
<div class="brand-name">AutoVoucher</div> <div class="brand-name">{{ branding.platformName }}</div>
<div class="brand-desc">费用凭证自动化</div> <div class="brand-desc">费用凭证自动化</div>
</div> </div>
</div> </div>
<el-menu <el-menu
:default-active="activeMenu" :default-active="activeMenu"
:default-openeds="['system']"
router router
class="menu" class="menu"
background-color="#1f2d3d" background-color="#ffffff"
text-color="#c6d0de" text-color="#4b5b70"
active-text-color="#ffffff" active-text-color="#2f6fed"
> >
<el-menu-item index="/"> <el-menu-item index="/">
<el-icon><Odometer /></el-icon> <el-icon><Odometer /></el-icon>
@ -63,6 +71,24 @@ async function signOut() {
<el-icon><Coin /></el-icon> <el-icon><Coin /></el-icon>
<span>基础档案</span> <span>基础档案</span>
</el-menu-item> </el-menu-item>
<el-sub-menu index="system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/style-config">
<el-icon><Brush /></el-icon>
<span>样式管理</span>
</el-menu-item>
<el-menu-item index="/sys-config">
<el-icon><Tools /></el-icon>
<span>系统配置</span>
</el-menu-item>
<el-menu-item index="/scheduled-tasks">
<el-icon><Timer /></el-icon>
<span>定时任务</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/audit-logs"> <el-menu-item index="/audit-logs">
<el-icon><Memo /></el-icon> <el-icon><Memo /></el-icon>
<span>审计日志</span> <span>审计日志</span>
@ -72,7 +98,7 @@ async function signOut() {
<el-container> <el-container>
<el-header class="topbar"> <el-header class="topbar">
<div class="topbar-title">费用凭证自动化平台</div> <div class="topbar-title">{{ branding.platformName }} · 费用凭证自动化平台</div>
<el-dropdown trigger="click" @command="signOut"> <el-dropdown trigger="click" @command="signOut">
<span class="user-chip"> <span class="user-chip">
<el-icon><UserFilled /></el-icon> <el-icon><UserFilled /></el-icon>
@ -101,7 +127,8 @@ async function signOut() {
.sidebar { .sidebar {
background: var(--av-sidebar); background: var(--av-sidebar);
color: #fff; border-right: 1px solid var(--av-sidebar-border);
color: #1f2d3d;
overflow-x: hidden; overflow-x: hidden;
} }
@ -119,9 +146,21 @@ async function signOut() {
height: 38px; height: 38px;
border-radius: 10px; border-radius: 10px;
background: var(--av-sidebar-active); background: var(--av-sidebar-active);
color: #fff;
font-weight: 700; font-weight: 700;
} }
.brand-mark-logo {
background: transparent;
overflow: hidden;
}
.brand-mark-logo img {
width: 38px;
height: 38px;
object-fit: contain;
}
.brand-name { .brand-name {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@ -129,16 +168,53 @@ async function signOut() {
.brand-desc { .brand-desc {
margin-top: 2px; margin-top: 2px;
color: #8a9bb3; color: var(--av-text-muted);
font-size: 12px; font-size: 12px;
} }
.menu { .menu {
border-right: 0; border-right: 0;
padding: 4px 10px;
}
.menu :deep(.el-menu-item) {
height: 42px;
margin-bottom: 2px;
border-radius: 8px;
}
.menu :deep(.el-menu-item:hover) {
background: #f5f8ff;
} }
.menu :deep(.el-menu-item.is-active) { .menu :deep(.el-menu-item.is-active) {
background: var(--av-sidebar-active); background: var(--av-sidebar-active-bg);
font-weight: 600;
}
.menu :deep(.el-sub-menu__title) {
height: 42px;
margin-bottom: 2px;
border-radius: 8px;
}
.menu :deep(.el-sub-menu__title:hover) {
background: #f5f8ff;
}
.menu :deep(.el-menu--inline) {
background: transparent;
}
.menu :deep(.el-sub-menu .el-menu-item) {
height: 38px;
min-width: 0;
padding-left: 52px;
}
.menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: var(--av-sidebar-active);
font-weight: 600;
} }
.topbar { .topbar {

6
src/router/index.js

@ -19,7 +19,11 @@ const routes = [
{ path: 'push-tasks', name: 'push-tasks', component: () => import('../views/PushTasksView.vue') }, { path: 'push-tasks', name: 'push-tasks', component: () => import('../views/PushTasksView.vue') },
{ path: 'rules', name: 'rules', component: () => import('../views/RulesView.vue') }, { path: 'rules', name: 'rules', component: () => import('../views/RulesView.vue') },
{ path: 'master-data', name: 'master-data', component: () => import('../views/MasterDataView.vue') }, { path: 'master-data', name: 'master-data', component: () => import('../views/MasterDataView.vue') },
{ path: 'audit-logs', name: 'audit-logs', component: () => import('../views/AuditLogsView.vue') } { path: 'audit-logs', name: 'audit-logs', component: () => import('../views/AuditLogsView.vue') },
{ path: 'style-config', name: 'style-config', component: () => import('../views/StyleConfigView.vue') },
{ path: 'sys-config', name: 'sys-config', component: () => import('../views/SysConfigView.vue') },
{ path: 'scheduled-tasks', name: 'scheduled-tasks', component: () => import('../views/ScheduledTasksView.vue') },
{ path: 'system-config', redirect: { name: 'style-config' } }
] ]
}, },
{ path: '/:pathMatch(.*)*', redirect: '/' } { path: '/:pathMatch(.*)*', redirect: '/' }

12
src/styles.css

@ -1,7 +1,9 @@
:root { :root {
--av-bg: #f4f6f9; --av-bg: #f4f6f9;
--av-sidebar: #1f2d3d; --av-sidebar: #ffffff;
--av-sidebar-border: #e9edf3;
--av-sidebar-active: #2f6fed; --av-sidebar-active: #2f6fed;
--av-sidebar-active-bg: #eaf1ff;
--av-text-muted: #8a94a6; --av-text-muted: #8a94a6;
} }
@ -159,14 +161,8 @@ body {
} }
.login-brand h1 { .login-brand h1 {
margin: 0 0 6px;
font-size: 22px;
}
.login-brand p {
margin: 0; margin: 0;
color: var(--av-text-muted); font-size: 22px;
font-size: 13px;
} }
.detail-grid { .detail-grid {

22
src/views/AuditLogsView.vue

@ -33,7 +33,12 @@ const ACTIONS = [
'RULE_CREATED', 'RULE_CREATED',
'RULE_UPDATED', 'RULE_UPDATED',
'RULE_CANDIDATE_APPROVED', 'RULE_CANDIDATE_APPROVED',
'RULE_CANDIDATE_REJECTED' 'RULE_CANDIDATE_REJECTED',
'SYS_CONFIG_CREATED',
'SYS_CONFIG_UPDATED',
'SYS_CONFIG_DELETED',
'SCHEDULED_JOB_UPDATED',
'SCHEDULED_JOB_MANUAL_TRIGGERED'
] ]
const ACTION_LABELS = { const ACTION_LABELS = {
@ -52,7 +57,12 @@ const ACTION_LABELS = {
RULE_CREATED: '创建规则', RULE_CREATED: '创建规则',
RULE_UPDATED: '更新规则', RULE_UPDATED: '更新规则',
RULE_CANDIDATE_APPROVED: '通过规则候选', RULE_CANDIDATE_APPROVED: '通过规则候选',
RULE_CANDIDATE_REJECTED: '拒绝规则候选' RULE_CANDIDATE_REJECTED: '拒绝规则候选',
SYS_CONFIG_CREATED: '新增系统配置',
SYS_CONFIG_UPDATED: '更新系统配置',
SYS_CONFIG_DELETED: '删除系统配置',
SCHEDULED_JOB_UPDATED: '更新定时任务',
SCHEDULED_JOB_MANUAL_TRIGGERED: '手动触发任务'
} }
const ENTITY_TYPES = [ const ENTITY_TYPES = [
@ -60,7 +70,9 @@ const ENTITY_TYPES = [
'SOURCE_DOC', 'SOURCE_DOC',
'PUSH_TASK', 'PUSH_TASK',
'MAPPING_RULE', 'MAPPING_RULE',
'RULE_CANDIDATE' 'RULE_CANDIDATE',
'SYS_CONFIG',
'SCHEDULED_JOB'
] ]
const ENTITY_LABELS = { const ENTITY_LABELS = {
@ -68,7 +80,9 @@ const ENTITY_LABELS = {
SOURCE_DOC: '来源单据', SOURCE_DOC: '来源单据',
PUSH_TASK: '推送任务', PUSH_TASK: '推送任务',
MAPPING_RULE: '映射规则', MAPPING_RULE: '映射规则',
RULE_CANDIDATE: '规则候选' RULE_CANDIDATE: '规则候选',
SYS_CONFIG: '系统配置',
SCHEDULED_JOB: '定时任务'
} }
const LIMITS = [50, 200, 500, 1000, 2000] const LIMITS = [50, 200, 500, 1000, 2000]

2
src/views/DashboardView.vue

@ -53,7 +53,6 @@ onMounted(load)
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">工作台</h2> <h2 class="page-title">工作台</h2>
<div class="page-subtitle">OA 单据 → AI 凭证建议 → 财务审核 → NCC 推送</div>
</div> </div>
<el-button :loading="loading" @click="load">刷新</el-button> <el-button :loading="loading" @click="load">刷新</el-button>
</div> </div>
@ -84,7 +83,6 @@ onMounted(load)
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">待推送 / 待重试</div> <div class="stat-label">待推送 / 待重试</div>
<div class="stat-value" style="color: #2f6fed">{{ pushPending }}</div> <div class="stat-value" style="color: #2f6fed">{{ pushPending }}</div>
<div class="stat-hint">由 NCC 定时任务异步推送</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">推送异常</div> <div class="stat-label">推送异常</div>

18
src/views/LoginView.vue

@ -1,11 +1,15 @@
<script setup> <script setup>
import { reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '../composables/useAuth' import { useAuth } from '../composables/useAuth'
import { useBranding } from '../composables/useBranding'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { login } = useAuth() const { login } = useAuth()
const { branding, load: loadBranding } = useBranding()
onMounted(() => loadBranding())
const form = reactive({ username: 'admin', password: 'admin123' }) const form = reactive({ username: 'admin', password: 'admin123' })
const loading = ref(false) const loading = ref(false)
@ -34,8 +38,8 @@ async function submit() {
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-card">
<div class="login-brand"> <div class="login-brand">
<h1>AutoVoucher</h1> <img v-if="branding.logo" :src="branding.logo" class="login-logo" alt="logo" />
<p>OA 单据 · AI 凭证建议 · NCC 推送</p> <h1>{{ branding.platformName }}</h1>
</div> </div>
<el-alert <el-alert
@ -78,6 +82,14 @@ async function submit() {
</template> </template>
<style scoped> <style scoped>
.login-logo {
display: block;
max-width: 140px;
max-height: 64px;
margin: 0 auto 12px;
object-fit: contain;
}
.login-foot { .login-foot {
margin: 18px 0 0; margin: 18px 0 0;
color: #8a94a6; color: #8a94a6;

10
src/views/MasterDataView.vue

@ -150,9 +150,6 @@ onMounted(loadAll)
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">基础档案</h2> <h2 class="page-title">基础档案</h2>
<div class="page-subtitle">
科目、辅助核算、凭证类型等基础数据;当前为本地种子数据,后续可从 NCC 同步并保留外部编码映射
</div>
</div> </div>
<el-button :loading="loading" @click="loadAll">刷新</el-button> <el-button :loading="loading" @click="loadAll">刷新</el-button>
</div> </div>
@ -442,13 +439,6 @@ onMounted(loadAll)
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="系统接入" name="systems"> <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> <div class="section-title">来源系统(OA)</div>
<el-table :data="sourceSystems" size="small" empty-text="暂无来源系统"> <el-table :data="sourceSystems" size="small" empty-text="暂无来源系统">
<el-table-column prop="sourceSystem" label="系统" width="140"> <el-table-column prop="sourceSystem" label="系统" width="140">

17
src/views/PushTasksView.vue

@ -70,7 +70,7 @@ async function saveBehavior() {
try { try {
const result = await devApi.setNccBehavior(mockBehavior.value) const result = await devApi.setNccBehavior(mockBehavior.value)
mockBehavior.value = result.behavior mockBehavior.value = result.behavior
ElMessage.success(`NCC 模拟器已切换为 ${result.behavior}`) ElMessage.success(`模拟行为已切换为 ${result.behavior}`)
} finally { } finally {
savingBehavior.value = false savingBehavior.value = false
} }
@ -188,19 +188,12 @@ onMounted(async () => {
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">推送监控</h2> <h2 class="page-title">推送监控</h2>
<div class="page-subtitle">
审核通过的凭证由定时任务推送 NCC;失败自动重试,超时结果未知必须先查询再重试
</div>
</div> </div>
<el-button :loading="loading" @click="load">刷新</el-button> <el-button :loading="loading" @click="load">刷新</el-button>
</div> </div>
<el-card shadow="never" class="mock-card"> <el-card shadow="never" class="mock-card">
<div class="mock-bar"> <div class="mock-bar">
<div>
<b>NCC 模拟器</b>
<span class="muted">演示环境仅使用 Mock 适配器,不会调用真实 NCC</span>
</div>
<el-select v-model="mockBehavior" style="width: 220px"> <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-option v-for="b in BEHAVIORS" :key="b.value" :label="b.label" :value="b.value" />
</el-select> </el-select>
@ -430,17 +423,11 @@ onMounted(async () => {
.mock-bar { .mock-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.mock-bar > div:first-child {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.attempt-panels { .attempt-panels {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

439
src/views/ScheduledTasksView.vue

@ -0,0 +1,439 @@
<script setup>
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { scheduledJobApi } from '../api'
import JsonBlock from '../components/JsonBlock.vue'
import StatusTag from '../components/StatusTag.vue'
import { formatDateTime } from '../utils/format'
const JOB_TYPE_LABELS = {
OA_FETCH: 'OA 单据拉取',
NCC_PUSH: 'NCC 凭证推送'
}
const TRIGGER_LABELS = {
SCHEDULED: '定时',
MANUAL: '手动'
}
const loading = ref(false)
const jobs = ref([])
const updatingId = ref(null)
const saving = ref(false)
const editVisible = ref(false)
const editingJob = ref(null)
const form = reactive({
cronExpression: '',
description: ''
})
const historyVisible = ref(false)
const historyJob = ref(null)
const runs = ref([])
const runsLoading = ref(false)
let listTimer = null
let runTimer = null
let loadingPromise = null
function jobTypeLabel(type) {
return JOB_TYPE_LABELS[type] || type || '-'
}
function triggerLabel(trigger) {
return TRIGGER_LABELS[trigger] || trigger || '-'
}
function formatDuration(ms) {
if (ms === null || ms === undefined) {
return '-'
}
if (ms < 1000) {
return `${ms} ms`
}
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`
}
function hasRunning() {
return jobs.value.some((job) => job.running || job.lastStatus === 'RUNNING')
}
function syncRunTimer() {
if (hasRunning()) {
if (!runTimer) {
runTimer = setInterval(refreshWhileRunning, 2000)
}
} else if (runTimer) {
clearInterval(runTimer)
runTimer = null
}
}
async function refreshWhileRunning() {
await load()
if (historyVisible.value) {
await loadRuns()
}
}
function load() {
if (loadingPromise) {
return loadingPromise
}
loading.value = true
loadingPromise = scheduledJobApi
.list()
.then((data) => {
jobs.value = data
syncRunTimer()
})
.finally(() => {
loading.value = false
loadingPromise = null
})
return loadingPromise
}
async function confirmToggle(row) {
const next = !row.enabled
try {
await ElMessageBox.confirm(
next
? `确定启用任务「${row.jobName}」?启用后将按 Cron 自动执行。`
: `确定停用任务「${row.jobName}」?停用后不再自动执行,仍可手动触发。`,
next ? '启用确认' : '停用确认',
{
type: 'warning',
confirmButtonText: next ? '启用' : '停用',
cancelButtonText: '取消'
}
)
} catch (e) {
return false
}
updatingId.value = row.id
try {
await scheduledJobApi.update(row.id, { enabled: next })
ElMessage.success(next ? '任务已启用' : '任务已停用')
await load()
return true
} catch (e) {
return false
} finally {
updatingId.value = null
}
}
function openEdit(row) {
editingJob.value = row
form.cronExpression = row.cronExpression || ''
form.description = row.description || ''
editVisible.value = true
}
async function saveEdit() {
const cron = form.cronExpression.trim()
if (!cron) {
ElMessage.error('Cron 表达式不能为空')
return
}
saving.value = true
try {
await scheduledJobApi.update(editingJob.value.id, {
cronExpression: cron,
// Empty string clears the description; null would mean "leave unchanged".
description: form.description.trim()
})
ElMessage.success('任务已更新,新的执行计划立即生效')
editVisible.value = false
await load()
} catch (e) {
// The HTTP layer already surfaces JOB_CRON_INVALID and similar messages.
} finally {
saving.value = false
}
}
async function runNow(row) {
try {
await ElMessageBox.confirm(
`确定立即执行任务「${row.jobName}」?任务将在后台执行,可在运行历史中查看结果。`,
'执行确认',
{ type: 'warning', confirmButtonText: '立即执行', cancelButtonText: '取消' }
)
} catch (e) {
return
}
try {
const result = await scheduledJobApi.run(row.id)
ElMessage.success(`已触发,运行编号 #${result.runId}`)
await openHistory(row)
await load()
} catch (e) {
// Conflict (409) and executor unavailable (503) messages come from the interceptor.
}
}
async function openHistory(row) {
historyJob.value = row
historyVisible.value = true
await loadRuns()
}
async function loadRuns() {
if (!historyJob.value) {
return
}
runsLoading.value = true
try {
runs.value = await scheduledJobApi.runs(historyJob.value.id, { limit: 100 })
} finally {
runsLoading.value = false
}
}
function closeHistory() {
historyJob.value = null
runs.value = []
}
onMounted(() => {
load()
listTimer = setInterval(load, 10000)
})
onUnmounted(() => {
if (listTimer) {
clearInterval(listTimer)
listTimer = null
}
if (runTimer) {
clearInterval(runTimer)
runTimer = null
}
})
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">定时任务</h2>
</div>
<el-button :loading="loading" @click="load">刷新</el-button>
</div>
<el-card shadow="never">
<el-table
v-loading="loading"
:data="jobs"
size="small"
empty-text="暂无定时任务"
>
<el-table-column label="任务" min-width="200">
<template #default="{ row }">
<div class="job-name">{{ row.jobName }}</div>
<div class="mono muted">{{ row.jobCode }}</div>
</template>
</el-table-column>
<el-table-column label="类型" width="140">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ jobTypeLabel(row.jobType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Cron" width="160">
<template #default="{ row }">
<span class="mono">{{ row.cronExpression || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
:loading="updatingId === row.id"
:before-change="() => confirmToggle(row)"
/>
</template>
</el-table-column>
<el-table-column label="上次执行" min-width="210">
<template #default="{ row }">
<template v-if="row.lastStatus">
<StatusTag :status="row.lastStatus" />
<span class="cell-time">{{ formatDateTime(row.lastRunAt) }}</span>
<span class="muted">· {{ formatDuration(row.lastDurationMs) }}</span>
</template>
<span v-else class="muted">尚未执行</span>
</template>
</el-table-column>
<el-table-column label="下次执行" width="170">
<template #default="{ row }">
<span v-if="row.enabled">{{ formatDateTime(row.nextRunAt) }}</span>
<span v-else class="muted">已停用</span>
</template>
</el-table-column>
<el-table-column label="最近信息" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span :class="row.lastStatus === 'FAILED' ? 'message-failed' : 'muted'">
{{ row.lastMessage || '—' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
link
type="primary"
:disabled="row.running"
@click="runNow(row)"
>
立即执行
</el-button>
<el-button link @click="openHistory(row)">历史</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer muted">
共 {{ jobs.length }} 个任务 · 运行中的任务每 2 秒自动刷新
</div>
</el-card>
<el-dialog
v-model="editVisible"
title="编辑定时任务"
width="560px"
@closed="editingJob = null"
>
<el-form v-if="editingJob" label-position="top" @submit.prevent="saveEdit">
<el-form-item label="任务">
<div>
<div class="job-name">{{ editingJob.jobName }}</div>
<div class="mono muted">
{{ editingJob.jobCode }} · {{ jobTypeLabel(editingJob.jobType) }}
</div>
</div>
</el-form-item>
<el-form-item label="Cron 表达式">
<el-input
v-model="form.cronExpression"
class="mono"
maxlength="128"
placeholder="例如 0 */5 * * * *"
/>
<div class="field-hint">
支持 5 段或 6 段(秒 分 时 日 月 周,0 和 7 均表示周日),5 段会自动补秒。
保存后立即生效。示例:<span class="mono">0 */5 * * * *</span>(每 5 分钟)、
<span class="mono">*/10 * * * * *</span>(每 10 秒)。
</div>
</el-form-item>
<el-form-item label="说明">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit
placeholder="选填,留空则清除说明"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<el-drawer
v-model="historyVisible"
size="780px"
:title="historyJob ? `运行历史 · ${historyJob.jobName}` : '运行历史'"
@closed="closeHistory"
>
<div class="drawer-meta muted">
<span class="mono">{{ historyJob?.jobCode }}</span>
<span>Cron:<span class="mono">{{ historyJob?.cronExpression }}</span></span>
<el-button size="small" :loading="runsLoading" @click="loadRuns">刷新</el-button>
</div>
<el-table
v-loading="runsLoading"
:data="runs"
size="small"
empty-text="暂无运行记录"
max-height="640"
>
<el-table-column label="开始时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.startedAt) }}</template>
</el-table-column>
<el-table-column label="触发" width="80">
<template #default="{ row }">{{ triggerLabel(row.triggerType) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }"><StatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">
<span class="mono">{{ formatDuration(row.durationMs) }}</span>
</template>
</el-table-column>
<el-table-column label="消息" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.message || '—' }}</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-drawer>
</div>
</template>
<style scoped>
.job-name {
font-weight: 600;
color: #1f2d3d;
}
.table-footer {
margin-top: 12px;
font-size: 12px;
text-align: right;
}
.cell-time {
margin-left: 8px;
font-size: 12px;
}
.message-failed {
color: var(--el-color-danger);
}
.field-hint {
margin-top: 6px;
color: var(--av-text-muted);
font-size: 12px;
line-height: 1.6;
}
.drawer-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
font-size: 12px;
}
.expand-body {
padding: 8px 16px;
}
</style>

16
src/views/SourceDocsView.vue

@ -120,10 +120,12 @@ async function submitManual() {
accountCode: manualForm.accountCode, accountCode: manualForm.accountCode,
reason: manualForm.reason reason: manualForm.reason
}) })
manualDialog.value = false
ElMessage.success(`已按手工科目生成凭证 ${voucher.voucherNo}`) ElMessage.success(`已按手工科目生成凭证 ${voucher.voucherNo}`)
// Keep the dialog (and its loading state) visible until the refreshed data
// is ready, then close it and navigate to the generated voucher.
await reloadDetail() await reloadDetail()
await load() await load()
manualDialog.value = false
router.push(`/vouchers/${voucher.id}`) router.push(`/vouchers/${voucher.id}`)
} finally { } finally {
manualLoading.value = false manualLoading.value = false
@ -164,9 +166,6 @@ watch(
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">单据接入</h2> <h2 class="page-title">单据接入</h2>
<div class="page-subtitle">
来自致远的费用单据,付款单据仅入库不生成凭证;点击行查看明细与凭证建议
</div>
</div> </div>
<el-button :loading="loading" @click="load">刷新</el-button> <el-button :loading="loading" @click="load">刷新</el-button>
</div> </div>
@ -268,6 +267,9 @@ watch(
<el-button size="small" :loading="reprocessing" @click="reprocess"> <el-button size="small" :loading="reprocessing" @click="reprocess">
重新生成 重新生成
</el-button> </el-button>
<span v-if="reprocessing" class="muted generating-hint">
正在调用大模型判定科目,请稍候…
</span>
<el-button <el-button
v-if="detail.doc.processingStatus === 'MANUAL_REQUIRED'" v-if="detail.doc.processingStatus === 'MANUAL_REQUIRED'"
size="small" size="small"
@ -536,4 +538,10 @@ watch(
.candidate-code { .candidate-code {
font-weight: 600; font-weight: 600;
} }
.generating-hint {
align-self: center;
font-size: 12px;
white-space: nowrap;
}
</style> </style>

221
src/views/StyleConfigView.vue

@ -0,0 +1,221 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { systemConfigApi } from '../api'
import { useBranding } from '../composables/useBranding'
const { branding, apply } = useBranding()
const loading = ref(false)
const saving = ref(false)
const platformName = ref('')
const logo = ref(null)
const MAX_LOGO_BYTES = 1024 * 1024
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
const trimmedName = computed(() => platformName.value.trim())
const nameValid = computed(() => trimmedName.value.length > 0 && trimmedName.value.length <= 64)
const dirty = computed(
() =>
trimmedName.value !== branding.platformName ||
(logo.value || null) !== (branding.logo || null)
)
async function load() {
loading.value = true
try {
const data = await systemConfigApi.get()
platformName.value = data.platformName || 'AutoVoucher'
logo.value = data.logo || null
apply(data)
} finally {
loading.value = false
}
}
function onLogoChange(uploadFile) {
const file = uploadFile.raw
if (!file) {
return
}
if (!ALLOWED_TYPES.includes(file.type)) {
ElMessage.error('Logo 仅支持 PNG、JPG、WebP 或 GIF 图片')
return
}
if (file.size > MAX_LOGO_BYTES) {
ElMessage.error('Logo 不能超过 1MB')
return
}
const reader = new FileReader()
reader.onload = () => {
logo.value = reader.result
}
reader.onerror = () => ElMessage.error('图片读取失败,请重新选择')
reader.readAsDataURL(file)
}
function removeLogo() {
logo.value = null
}
function reset() {
platformName.value = branding.platformName
logo.value = branding.logo || null
}
async function save() {
if (!nameValid.value) {
ElMessage.error('平台名称不能为空,且不能超过 64 个字符')
return
}
saving.value = true
try {
const data = await systemConfigApi.update({
platformName: trimmedName.value,
logo: logo.value || null
})
platformName.value = data.platformName
logo.value = data.logo || null
apply(data)
ElMessage.success('样式配置已保存')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">样式管理</h2>
<div class="page-subtitle">平台名称与 Logo 会同步展示在登录页、侧边栏和顶部标题</div>
</div>
<el-button :loading="loading" @click="load">刷新</el-button>
</div>
<el-card v-loading="loading" shadow="never" class="config-card">
<div class="config-section-title">品牌样式</div>
<el-form label-position="top" @submit.prevent="save">
<el-form-item label="平台名称">
<el-input
v-model="platformName"
maxlength="64"
show-word-limit
placeholder="例如:AutoVoucher"
style="max-width: 420px"
/>
<div class="field-hint">最多 64 个字符,保存后立即生效</div>
</el-form-item>
<el-form-item label="Logo">
<div class="logo-row">
<div class="logo-preview">
<img v-if="logo" :src="logo" alt="logo preview" />
<span v-else class="logo-empty">未上传</span>
</div>
<div class="logo-actions">
<div class="logo-buttons">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="image/png,image/jpeg,image/webp,image/gif"
:on-change="onLogoChange"
>
<el-button>
<el-icon><Upload /></el-icon>
<span>选择图片</span>
</el-button>
</el-upload>
<el-button v-if="logo" text type="danger" @click="removeLogo">移除 Logo</el-button>
</div>
<div class="field-hint">支持 PNG / JPG / WebP / GIF,不超过 1MB;建议使用透明背景的正方形图片</div>
</div>
</div>
</el-form-item>
</el-form>
<div class="config-footer">
<el-button :disabled="!dirty || saving" @click="reset">撤销修改</el-button>
<el-button type="primary" :loading="saving" :disabled="!nameValid || !dirty" @click="save">
保存
</el-button>
</div>
</el-card>
</div>
</template>
<style scoped>
.config-card {
max-width: 820px;
}
.config-section-title {
margin-bottom: 18px;
font-size: 15px;
font-weight: 600;
}
.field-hint {
margin-top: 6px;
color: var(--av-text-muted);
font-size: 12px;
line-height: 1.5;
}
.logo-row {
display: flex;
align-items: flex-start;
gap: 18px;
}
.logo-preview {
display: grid;
place-items: center;
width: 96px;
height: 96px;
flex: none;
border: 1px dashed #d7dee8;
border-radius: 10px;
background: #fafbfd;
overflow: hidden;
}
.logo-preview img {
max-width: 88px;
max-height: 88px;
object-fit: contain;
}
.logo-empty {
color: var(--av-text-muted);
font-size: 12px;
}
.logo-actions {
padding-top: 6px;
}
.logo-buttons {
display: flex;
align-items: center;
gap: 8px;
}
.logo-buttons :deep(.el-upload) {
display: inline-flex;
}
.config-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding-top: 18px;
border-top: 1px solid #eef1f6;
}
</style>

345
src/views/SysConfigView.vue

@ -0,0 +1,345 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { sysConfigApi } from '../api'
import { formatDateTime } from '../utils/format'
const MAX_GROUP_LENGTH = 64
const MAX_KEY_LENGTH = 128
const MAX_VALUE_BYTES = 65535
const MAX_DESCRIPTION_LENGTH = 255
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
const loading = ref(false)
const saving = ref(false)
const entries = ref([])
const dialogVisible = ref(false)
const editingId = ref(null)
const editingSecret = ref(false)
const filters = reactive({
group: '',
keyword: ''
})
const form = reactive({
configGroup: '',
configKey: '',
configValue: '',
description: ''
})
const groups = computed(() => {
const set = new Set(entries.value.map((entry) => entry.configGroup))
return Array.from(set).sort((a, b) => a.localeCompare(b))
})
const filteredEntries = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return entries.value.filter((entry) => {
if (filters.group && entry.configGroup !== filters.group) {
return false
}
if (!keyword) {
return true
}
return [entry.configGroup, entry.configKey, entry.description]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(keyword))
})
})
async function load() {
loading.value = true
try {
entries.value = await sysConfigApi.list()
if (filters.group && !groups.value.includes(filters.group)) {
filters.group = ''
}
} finally {
loading.value = false
}
}
function resetFilters() {
filters.group = ''
filters.keyword = ''
}
function resetForm() {
editingId.value = null
editingSecret.value = false
form.configGroup = ''
form.configKey = ''
form.configValue = ''
form.description = ''
}
function openCreate() {
resetForm()
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
editingSecret.value = Boolean(row.secret)
form.configGroup = row.configGroup
form.configKey = row.configKey
form.configValue = row.secret ? '' : (row.configValue || '')
form.description = row.description || ''
dialogVisible.value = true
}
function validate() {
const group = form.configGroup.trim()
const key = form.configKey.trim()
const description = form.description.trim()
if (!group) {
ElMessage.error('配置分组不能为空')
return false
}
if (group.length > MAX_GROUP_LENGTH) {
ElMessage.error(`配置分组不能超过 ${MAX_GROUP_LENGTH} 个字符`)
return false
}
if (!editingId.value) {
if (!key) {
ElMessage.error('配置键不能为空')
return false
}
if (key.length > MAX_KEY_LENGTH) {
ElMessage.error(`配置键不能超过 ${MAX_KEY_LENGTH} 个字符`)
return false
}
if (!KEY_PATTERN.test(key)) {
ElMessage.error('配置键只能包含字母、数字、点、下划线和短横线,且需以字母或数字开头')
return false
}
}
if (new TextEncoder().encode(form.configValue).length > MAX_VALUE_BYTES) {
ElMessage.error('配置值不能超过 65535 字节')
return false
}
if (description.length > MAX_DESCRIPTION_LENGTH) {
ElMessage.error(`说明不能超过 ${MAX_DESCRIPTION_LENGTH} 个字符`)
return false
}
return true
}
async function save() {
if (!validate()) {
return
}
saving.value = true
try {
const secretEdit = Boolean(editingId.value) && editingSecret.value
const payload = {
configGroup: form.configGroup.trim(),
configKey: form.configKey.trim(),
// Masked secrets are never sent to the browser, so blank means keep the stored value.
configValue: secretEdit && form.configValue === '' ? null : form.configValue,
description: form.description.trim() || null
}
if (editingId.value) {
await sysConfigApi.update(editingId.value, payload)
ElMessage.success('配置项已更新')
} else {
await sysConfigApi.create(payload)
ElMessage.success('配置项已新增')
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(
`确定删除配置项「${row.configKey}」?该操作不可恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch (e) {
return
}
await sysConfigApi.remove(row.id)
ElMessage.success('配置项已删除')
await load()
}
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">
<div class="filter-bar">
<el-select
v-model="filters.group"
clearable
placeholder="全部分组"
style="width: 180px"
>
<el-option v-for="group in groups" :key="group" :label="group" :value="group" />
</el-select>
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索配置键 / 说明"
style="max-width: 280px"
/>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-table
v-loading="loading"
:data="filteredEntries"
size="small"
empty-text="暂无配置项"
>
<el-table-column prop="configGroup" label="分组" width="140">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.configGroup }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="configKey" label="配置键" min-width="200">
<template #default="{ row }">
<span class="mono">{{ row.configKey }}</span>
</template>
</el-table-column>
<el-table-column prop="configValue" label="配置值" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row.secret">
<span class="mono secret-value">
{{ row.configured ? '••••••••' : '未配置' }}
</span>
<el-tag v-if="row.configured" size="small" type="success" effect="plain">
已配置
</el-tag>
</template>
<span v-else class="mono">{{ row.configValue || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer muted">共 {{ filteredEntries.length }} 项</div>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑配置' : '新增配置'"
width="560px"
@closed="resetForm"
>
<el-form label-position="top" @submit.prevent="save">
<el-form-item label="配置分组">
<el-select
v-model="form.configGroup"
filterable
allow-create
default-first-option
placeholder="选择或输入分组"
style="width: 100%"
>
<el-option v-for="group in groups" :key="group" :label="group" :value="group" />
</el-select>
<div class="field-hint">可直接输入新的分组名称,例如 llm、push、oa</div>
</el-form-item>
<el-form-item label="配置键">
<el-input
v-model="form.configKey"
:disabled="Boolean(editingId)"
maxlength="128"
placeholder="例如 llm.endpoint"
/>
<div class="field-hint">创建后不可修改;建议使用“分组.名称”的格式</div>
</el-form-item>
<el-form-item label="配置值">
<el-input
v-model="form.configValue"
:type="editingSecret ? 'password' : 'textarea'"
:show-password="editingSecret"
:rows="editingSecret ? 1 : 4"
:autocomplete="editingSecret ? 'new-password' : 'off'"
:placeholder="editingSecret
? '已配置,留空表示不修改'
: '请输入配置值,留空表示清除'"
/>
<div class="field-hint">
<template v-if="editingSecret">
出于安全考虑,已保存的令牌不会回显,也无法复制;如需清除请删除该配置项。
</template>
<template v-else>最多 65535 字节</template>
</div>
</el-form-item>
<el-form-item label="说明">
<el-input
v-model="form.description"
maxlength="255"
show-word-limit
placeholder="选填,描述该配置项的用途"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.table-footer {
margin-top: 12px;
font-size: 12px;
text-align: right;
}
.field-hint {
margin-top: 6px;
color: var(--av-text-muted);
font-size: 12px;
line-height: 1.5;
}
.secret-value {
margin-right: 8px;
letter-spacing: 2px;
user-select: none;
-webkit-user-select: none;
}
</style>

242
src/views/VoucherDetailView.vue

@ -5,6 +5,7 @@ import { masterDataApi, voucherApi } from '../api'
import StatusTag from '../components/StatusTag.vue' import StatusTag from '../components/StatusTag.vue'
import JsonBlock from '../components/JsonBlock.vue' import JsonBlock from '../components/JsonBlock.vue'
import OaViewDialog from '../components/OaViewDialog.vue' import OaViewDialog from '../components/OaViewDialog.vue'
import LineFeedbackDialog from '../components/LineFeedbackDialog.vue'
import { formatAmount, formatDateTime } from '../utils/format' import { formatAmount, formatDateTime } from '../utils/format'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@ -35,6 +36,33 @@ const auxIndex = ref(-1)
const auxRows = ref([]) const auxRows = ref([])
const oaVisible = ref(false) const oaVisible = ref(false)
const previewVisible = ref(false)
const feedbackVisible = ref(false)
const feedbackLine = ref(null)
const feedbackTab = ref('feedback')
const SCOPE_LABELS = { LINE_FEEDBACK: '行反馈规则' }
function scopeLabel(scope) {
return SCOPE_LABELS[scope] || scope || '-'
}
function canFeedback(row) {
return (
row.lineNo != null &&
row.direction === 'DEBIT' &&
row.accountGroup !== 'INPUT_VAT' &&
canRegenerate.value &&
voucher.value?.currentFlag === 1
)
}
function openFeedback(row, tab) {
feedbackLine.value = row
feedbackTab.value = tab
feedbackVisible.value = true
}
const isPending = computed(() => voucher.value?.status === 'PENDING_REVIEW') const isPending = computed(() => voucher.value?.status === 'PENDING_REVIEW')
const canRegenerate = computed( const canRegenerate = computed(
@ -93,6 +121,7 @@ function auxCount(value) {
function toEditLine(line) { function toEditLine(line) {
return { return {
_key: ++rowSeq, _key: ++rowSeq,
lineNo: line.lineNo,
direction: line.direction, direction: line.direction,
accountCode: line.accountCode, accountCode: line.accountCode,
amount: Number(line.amount ?? 0), amount: Number(line.amount ?? 0),
@ -101,6 +130,7 @@ function toEditLine(line) {
taxAmount: line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount), taxAmount: line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount),
aux: parseAux(line.auxJson), aux: parseAux(line.auxJson),
summary: line.summary || '', summary: line.summary || '',
sourceLineNo: line.sourceLineNo ?? null,
evidenceJson: line.evidenceJson, evidenceJson: line.evidenceJson,
accountGroup: line.accountGroup, accountGroup: line.accountGroup,
decisionSource: line.decisionSource, decisionSource: line.decisionSource,
@ -152,16 +182,48 @@ const totals = computed(() => {
return { debit, credit, diff: Math.round((debit - credit) * 100) / 100 } return { debit, credit, diff: Math.round((debit - credit) * 100) / 100 }
}) })
// Preview follows the current edit state, so finance can check the voucher shape
// before saving changes.
const previewLines = computed(() => {
const lines = isPending.value ? editLines.value : voucher.value?.lines || []
return lines.map((line) => ({
lineNo: line.lineNo,
summary: line.summary || '',
accountCode: line.accountCode,
accountName: line.accountName || accountByCode(line.accountCode)?.name || '',
aux: auxText(line.auxJson ?? line.aux),
debit: line.direction === 'DEBIT' ? Number(line.amount) || 0 : 0,
credit: line.direction === 'CREDIT' ? Number(line.amount) || 0 : 0
}))
})
function previewSummary({ columns }) {
return columns.map((column, index) => {
if (index === 0) {
return '合计'
}
if (column.label === '借方金额') {
return formatAmount(totals.value.debit, '')
}
if (column.label === '贷方金额') {
return formatAmount(totals.value.credit, '')
}
return ''
})
}
function addLine() { function addLine() {
editLines.value.push({ editLines.value.push({
_key: ++rowSeq, _key: ++rowSeq,
lineNo: null,
direction: 'DEBIT', direction: 'DEBIT',
accountCode: '', accountCode: '',
amount: 0, amount: 0,
taxRatePercent: null, taxRatePercent: null,
taxAmount: null, taxAmount: null,
aux: {}, aux: {},
summary: '' summary: '',
sourceLineNo: null
}) })
} }
@ -285,7 +347,8 @@ async function doSave() {
taxAmount: taxAmount:
line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount), line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount),
aux: line.aux || {}, aux: line.aux || {},
summary: line.summary || '' summary: line.summary || '',
sourceLineNo: line.sourceLineNo ?? null
})) }))
} }
const detail = await voucherApi.updateLines(voucher.value.id, payload) const detail = await voucherApi.updateLines(voucher.value.id, payload)
@ -389,6 +452,7 @@ onMounted(load)
<el-tag v-if="voucher.currentFlag !== 1" type="info" effect="plain">历史版本</el-tag> <el-tag v-if="voucher.currentFlag !== 1" type="info" effect="plain">历史版本</el-tag>
</div> </div>
<div class="page-subtitle"> <div class="page-subtitle">
<template v-if="voucher.description">{{ voucher.description }} · </template>
{{ voucher.voucherTypeCode }} · {{ voucher.period }} · {{ voucher.voucherTypeCode }} · {{ voucher.period }} ·
{{ voucher.companyId }}/{{ voucher.bookId }} · {{ voucher.companyId }}/{{ voucher.bookId }} ·
版本 v{{ voucher.version }} 版本 v{{ voucher.version }}
@ -397,9 +461,13 @@ onMounted(load)
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button @click="router.push('/vouchers')">返回列表</el-button> <el-button @click="router.push('/vouchers')">返回列表</el-button>
<el-button @click="openOa">OA 查看凭证</el-button> <el-button @click="openOa">OA 查看凭证</el-button>
<el-button @click="previewVisible = true">凭证预览</el-button>
<el-button v-if="canRegenerate" :loading="regenerating" @click="regenerate"> <el-button v-if="canRegenerate" :loading="regenerating" @click="regenerate">
重新生成 重新生成
</el-button> </el-button>
<span v-if="regenerating" class="muted generating-hint">
正在调用大模型判定科目,请稍候…
</span>
<template v-if="isPending"> <template v-if="isPending">
<el-button type="primary" :loading="saving" @click="openSaveDialog">保存分录</el-button> <el-button type="primary" :loading="saving" @click="openSaveDialog">保存分录</el-button>
<el-button type="success" :loading="approving" @click="approve">审核通过</el-button> <el-button type="success" :loading="approving" @click="approve">审核通过</el-button>
@ -494,12 +562,20 @@ onMounted(load)
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="凭证分录" name="lines"> <el-tab-pane label="凭证分录" name="lines">
<div class="lines-toolbar"> <div class="lines-toolbar">
<div class="lines-summary">
<span class="muted"> <span class="muted">
借贷平衡: 借贷平衡:
<b :style="{ color: totals.diff === 0 ? '#67c23a' : '#f56c6c' }"> <b :style="{ color: totals.diff === 0 ? '#67c23a' : '#f56c6c' }">
{{ totals.diff === 0 ? '是' : `差额 ${formatAmount(totals.diff, '')}` }} {{ totals.diff === 0 ? '是' : `差额 ${formatAmount(totals.diff, '')}` }}
</b> </b>
</span> </span>
<span class="summary-item">
借方:<b class="amount">{{ formatAmount(totals.debit, voucher.currency) }}</b>
</span>
<span class="summary-item">
贷方:<b class="amount">{{ formatAmount(totals.credit, voucher.currency) }}</b>
</span>
</div>
<el-button v-if="isPending" size="small" @click="addLine">新增分录</el-button> <el-button v-if="isPending" size="small" @click="addLine">新增分录</el-button>
</div> </div>
@ -549,49 +625,32 @@ onMounted(load)
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="金额" align="right" width="150"> <el-table-column v-if="canRegenerate" label="反馈" width="72">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-button
v-if="isPending" v-if="canFeedback(row)"
v-model="row.amount" link
size="small" type="primary"
: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" size="small"
:min="0" @click="openFeedback(row, 'feedback')"
:max="100" >
:precision="2" 反馈
:controls="false" </el-button>
style="width: 100%" <span v-else class="muted">-</span>
/>
<span v-else>
{{ row.taxRate == null ? '-' : Number(row.taxRate) * 100 + '%' }}
</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="税额" align="right" width="130"> <el-table-column label="金额" align="right" width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number
v-if="isPending" v-if="isPending"
v-model="row.taxAmount" v-model="row.amount"
size="small" size="small"
:min="0" :min="0"
:precision="2" :precision="2"
:controls="false" :controls="false"
style="width: 100%" style="width: 100%"
/> />
<span v-else>{{ formatAmount(row.taxAmount, '') }}</span> <span v-else class="amount">{{ formatAmount(row.amount, '') }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="辅助核算" min-width="170"> <el-table-column label="辅助核算" min-width="170">
@ -635,7 +694,9 @@ onMounted(load)
</el-table-column> </el-table-column>
<el-table-column prop="operatorId" label="操作人" width="110" /> <el-table-column prop="operatorId" label="操作人" width="110" />
<el-table-column prop="reason" label="原因" min-width="180" /> <el-table-column prop="reason" label="原因" min-width="180" />
<el-table-column prop="scope" label="范围" width="90" /> <el-table-column label="范围" width="100">
<template #default="{ row }">{{ scopeLabel(row.scope) }}</template>
</el-table-column>
<el-table-column type="expand"> <el-table-column type="expand">
<template #default="{ row }"> <template #default="{ row }">
<div class="correction-panels"> <div class="correction-panels">
@ -821,6 +882,71 @@ onMounted(load)
</el-dialog> </el-dialog>
<OaViewDialog v-model="oaVisible" :doc="voucher?.sourceDoc" /> <OaViewDialog v-model="oaVisible" :doc="voucher?.sourceDoc" />
<el-dialog v-model="previewVisible" title="凭证预览" width="880px" top="6vh">
<div class="voucher-preview">
<div class="preview-title">记 账 凭 证</div>
<div class="preview-meta">
<span>凭证号:{{ voucher?.voucherNo }}</span>
<span>凭证日期:{{ voucher?.voucherDate || '-' }}</span>
<span>期间:{{ voucher?.period }}</span>
<span>币种:{{ voucher?.currency || 'CNY' }}</span>
<span>公司/账簿:{{ voucher?.companyId }}/{{ voucher?.bookId }}</span>
<span>源单据:{{ voucher?.sourceDoc?.docNo || voucher?.sourceDocId }}</span>
</div>
<el-table
:data="previewLines"
size="small"
border
show-summary
:summary-method="previewSummary"
empty-text="暂无分录"
>
<el-table-column prop="summary" label="摘要" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.summary || '-' }}</template>
</el-table-column>
<el-table-column label="科目" min-width="230">
<template #default="{ row }">
<span class="mono">{{ row.accountCode }}</span> {{ row.accountName }}
</template>
</el-table-column>
<el-table-column label="辅助核算" min-width="150">
<template #default="{ row }">{{ row.aux }}</template>
</el-table-column>
<el-table-column label="借方金额" align="right" width="140">
<template #default="{ row }">
{{ row.debit ? formatAmount(row.debit, '') : '' }}
</template>
</el-table-column>
<el-table-column label="贷方金额" align="right" width="140">
<template #default="{ row }">
{{ row.credit ? formatAmount(row.credit, '') : '' }}
</template>
</el-table-column>
</el-table>
<div class="preview-foot">
<span>
借贷平衡:
<b :style="{ color: totals.diff === 0 ? '#67c23a' : '#f56c6c' }">
{{ totals.diff === 0 ? '是' : `差额 ${formatAmount(totals.diff, '')}` }}
</b>
</span>
<span>审核人:{{ voucher?.approvedBy || '-' }}</span>
<span>审核时间:{{ voucher?.approvedAt ? formatDateTime(voucher.approvedAt) : '-' }}</span>
</div>
</div>
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
<LineFeedbackDialog
v-model="feedbackVisible"
:voucher-id="voucher?.id"
:line="feedbackLine"
:accounts="accounts"
:initial-tab="feedbackTab"
/>
</div> </div>
</template> </template>
@ -838,6 +964,50 @@ onMounted(load)
margin-bottom: 10px; margin-bottom: 10px;
} }
.lines-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 22px;
}
.summary-item {
font-size: 13px;
}
.summary-item .amount {
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.voucher-preview {
padding: 4px 6px;
}
.preview-title {
text-align: center;
font-size: 22px;
font-weight: 600;
letter-spacing: 8px;
margin-bottom: 16px;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 28px;
font-size: 13px;
margin-bottom: 12px;
}
.preview-foot {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 12px;
font-size: 13px;
}
.line-extra { .line-extra {
padding: 8px 16px; padding: 8px 16px;
display: flex; display: flex;
@ -865,4 +1035,10 @@ onMounted(load)
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
} }
.generating-hint {
align-self: center;
font-size: 12px;
white-space: nowrap;
}
</style> </style>

8
src/views/VouchersView.vue

@ -46,9 +46,6 @@ onMounted(load)
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">凭证审核</h2> <h2 class="page-title">凭证审核</h2>
<div class="page-subtitle">
AI 依据规则与历史纠错推荐科目,财务可修改分录后审核;审核通过后加入 NCC 推送队列
</div>
</div> </div>
<el-button :loading="loading" @click="load">刷新</el-button> <el-button :loading="loading" @click="load">刷新</el-button>
</div> </div>
@ -66,7 +63,7 @@ onMounted(load)
</el-select> </el-select>
<el-input <el-input
v-model="filters.keyword" v-model="filters.keyword"
placeholder="凭证号 / 外部凭证号" placeholder="凭证号 / 外部凭证号 / 描述"
clearable clearable
style="width: 260px" style="width: 260px"
@keyup.enter="load" @keyup.enter="load"
@ -88,6 +85,9 @@ onMounted(load)
<el-table-column prop="voucherNo" label="凭证号" min-width="190"> <el-table-column prop="voucherNo" label="凭证号" min-width="190">
<template #default="{ row }"><span class="mono">{{ row.voucherNo }}</span></template> <template #default="{ row }"><span class="mono">{{ row.voucherNo }}</span></template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="描述" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="110"> <el-table-column label="状态" width="110">
<template #default="{ row }"><StatusTag :status="row.status" /></template> <template #default="{ row }"><StatusTag :status="row.status" /></template>
</el-table-column> </el-table-column>

9
vite.config.js

@ -2,12 +2,21 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
const backend = process.env.VITE_BACKEND_URL || 'http://localhost:8080' const backend = process.env.VITE_BACKEND_URL || 'http://localhost:8080'
// When the workspace is served through the HTTPS proxy, HMR must connect to
// the proxy port instead of the Vite port.
const hmrClientPort = process.env.VITE_HMR_CLIENT_PORT
? Number(process.env.VITE_HMR_CLIENT_PORT)
: undefined
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
// The workspace is reachable through a per-user proxy domain such as
// 5174--main--zjx-code--zhangjianxue.code.connectconcur.com.
allowedHosts: ['.code.connectconcur.com'],
hmr: hmrClientPort ? { clientPort: hmrClientPort } : undefined,
proxy: { proxy: {
'/api': { target: backend, changeOrigin: true }, '/api': { target: backend, changeOrigin: true },
'/open': { target: backend, changeOrigin: true } '/open': { target: backend, changeOrigin: true }

Loading…
Cancel
Save