diff --git a/src/api/index.js b/src/api/index.js
index f8f6103..da99984 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -1,5 +1,11 @@
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 = {
login: (data) => http.post('/auth/login', data),
me: () => http.get('/auth/me'),
@@ -10,11 +16,32 @@ export const dashboardApi = {
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 = {
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)
+ reprocess: (id) =>
+ 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 = {
@@ -23,7 +50,14 @@ export const voucherApi = {
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`)
+ 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 = {
diff --git a/src/components/LineFeedbackDialog.vue b/src/components/LineFeedbackDialog.vue
new file mode 100644
index 0000000..bdac0e5
--- /dev/null
+++ b/src/components/LineFeedbackDialog.vue
@@ -0,0 +1,520 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 该分录已关联源费用行,不可更改
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 未找到源费用行,请先选择上方的对应费用行。
+
+
+
+ {{ conditionLabel(categoryItem) }}
+
+
+
+ {{ conditionLabel(item) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 同日相关费用(锚点日期:{{ context?.anchorDate || '-' }},共
+ {{ sameDay.total }} 条)
+
+
+
+
+
+
+ {{ row.description || '-' }}
+
+
+ {{ row.expenseCity || '-' }}
+
+
+ {{ row.invoiceDate || '-' }}
+
+
+ {{ row.crossCity ? '是' : '否' }}
+
+
+ {{ formatAmount(row.amount, '') }}
+
+
+
+ 当日费用过多,仅展示前 20 条,上下文事实仍基于全部费用计算。
+
+
+
+
+
+ 条件:{{ JSON.stringify(testResult.condition) }}
+
+
+
+ {{ check.code }}:{{ check.message }}
+
+
+
+
+
+
+
+
+ {{ row.passed ? '通过' : '失败' }}
+
+
+
+
+
+
+ 应用后将退役:
+ {{ testResult.conflicts.map((c) => c.ruleCode).join('、') }}
+
+
+
+
+
+
+
+
当前命中规则
+
+ {{ currentRule.ruleCode }}
+ {{ currentRule.name }}
+ {{ currentRule.priority }}
+
+ {{ (currentRule.matchedFields || []).join('、') || '-' }}
+
+
+ {{ JSON.stringify(currentRule.result) }}
+
+
+
+
+
上下文事实
+
+
+ {{ displayValue(item) }}
+
+
+
+
+
+ 同日费用(共 {{ sameDay.total }} 条)
+
+
+
+
+
+
+ {{ row.expenseCity || '-' }}
+
+
+ {{ row.invoiceDate || '-' }}
+
+
+ {{ formatAmount(row.amount, '') }}
+
+
+
+
+
+
+
+ 取消
+ 测试
+
+ 应用规则
+
+
+
+
+
+
diff --git a/src/components/StatusTag.vue b/src/components/StatusTag.vue
index a67e100..baf76bb 100644
--- a/src/components/StatusTag.vue
+++ b/src/components/StatusTag.vue
@@ -23,6 +23,7 @@ const LABELS = {
SUCCESS: '成功',
SUCCESS_WITH_EXISTING: '重复已确认',
FAILED: '失败',
+ RUNNING: '运行中',
ACTIVE: '启用中',
INACTIVE: '已停用',
DISABLED: '已停用',
@@ -53,6 +54,7 @@ const TYPES = {
SUCCESS: 'success',
SUCCESS_WITH_EXISTING: 'success',
FAILED: 'danger',
+ RUNNING: 'primary',
ACTIVE: 'success',
INACTIVE: 'info',
DISABLED: 'info',
diff --git a/src/composables/useBranding.js b/src/composables/useBranding.js
new file mode 100644
index 0000000..555aa94
--- /dev/null
+++ b/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 }
+}
diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue
index 1f89bbb..22ffe39 100644
--- a/src/layouts/AppLayout.vue
+++ b/src/layouts/AppLayout.vue
@@ -1,11 +1,15 @@
+
+
+
+
+
+
+
+
+
+ {{ row.jobName }}
+ {{ row.jobCode }}
+
+
+
+
+ {{ jobTypeLabel(row.jobType) }}
+
+
+
+
+ {{ row.cronExpression || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDateTime(row.lastRunAt) }}
+ · {{ formatDuration(row.lastDurationMs) }}
+
+ 尚未执行
+
+
+
+
+ {{ formatDateTime(row.nextRunAt) }}
+ 已停用
+
+
+
+
+
+ {{ row.lastMessage || '—' }}
+
+
+
+
+
+ 编辑
+
+ 立即执行
+
+ 历史
+
+
+
+
+
+
+
+
+
+
+
+
{{ editingJob.jobName }}
+
+ {{ editingJob.jobCode }} · {{ jobTypeLabel(editingJob.jobType) }}
+
+
+
+
+
+
+
+ 支持 5 段或 6 段(秒 分 时 日 月 周,0 和 7 均表示周日),5 段会自动补秒。
+ 保存后立即生效。示例:0 */5 * * * *(每 5 分钟)、
+ */10 * * * * *(每 10 秒)。
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+ {{ historyJob?.jobCode }}
+ Cron:{{ historyJob?.cronExpression }}
+ 刷新
+
+
+
+
+ {{ formatDateTime(row.startedAt) }}
+
+
+ {{ triggerLabel(row.triggerType) }}
+
+
+
+
+
+
+ {{ formatDuration(row.durationMs) }}
+
+
+
+ {{ row.message || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/SourceDocsView.vue b/src/views/SourceDocsView.vue
index 6d48bcd..294ff8d 100644
--- a/src/views/SourceDocsView.vue
+++ b/src/views/SourceDocsView.vue
@@ -120,10 +120,12 @@ async function submitManual() {
accountCode: manualForm.accountCode,
reason: manualForm.reason
})
- manualDialog.value = false
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 load()
+ manualDialog.value = false
router.push(`/vouchers/${voucher.id}`)
} finally {
manualLoading.value = false
@@ -164,9 +166,6 @@ watch(
@@ -268,6 +267,9 @@ watch(
重新生成
+
+ 正在调用大模型判定科目,请稍候…
+
diff --git a/src/views/StyleConfigView.vue b/src/views/StyleConfigView.vue
new file mode 100644
index 0000000..9ea6136
--- /dev/null
+++ b/src/views/StyleConfigView.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+ 品牌样式
+
+
+
+
+ 最多 64 个字符,保存后立即生效
+
+
+
+
+
+
![logo preview]()
+
未上传
+
+
+
+
+
+
+ 选择图片
+
+
+ 移除 Logo
+
+
支持 PNG / JPG / WebP / GIF,不超过 1MB;建议使用透明背景的正方形图片
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/SysConfigView.vue b/src/views/SysConfigView.vue
new file mode 100644
index 0000000..58ca7ed
--- /dev/null
+++ b/src/views/SysConfigView.vue
@@ -0,0 +1,345 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 重置
+
+
+
+
+
+ {{ row.configGroup }}
+
+
+
+
+ {{ row.configKey }}
+
+
+
+
+
+
+ {{ row.configured ? '••••••••' : '未配置' }}
+
+
+ 已配置
+
+
+ {{ row.configValue || '—' }}
+
+
+
+ {{ row.description || '—' }}
+
+
+ {{ formatDateTime(row.updatedAt) }}
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 可直接输入新的分组名称,例如 llm、push、oa
+
+
+
+
+ 创建后不可修改;建议使用“分组.名称”的格式
+
+
+
+
+
+
+ 出于安全考虑,已保存的令牌不会回显,也无法复制;如需清除请删除该配置项。
+
+ 最多 65535 字节
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/src/views/VoucherDetailView.vue b/src/views/VoucherDetailView.vue
index 6665297..329a309 100644
--- a/src/views/VoucherDetailView.vue
+++ b/src/views/VoucherDetailView.vue
@@ -5,6 +5,7 @@ import { masterDataApi, voucherApi } from '../api'
import StatusTag from '../components/StatusTag.vue'
import JsonBlock from '../components/JsonBlock.vue'
import OaViewDialog from '../components/OaViewDialog.vue'
+import LineFeedbackDialog from '../components/LineFeedbackDialog.vue'
import { formatAmount, formatDateTime } from '../utils/format'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -35,6 +36,33 @@ const auxIndex = ref(-1)
const auxRows = ref([])
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 canRegenerate = computed(
@@ -93,6 +121,7 @@ function auxCount(value) {
function toEditLine(line) {
return {
_key: ++rowSeq,
+ lineNo: line.lineNo,
direction: line.direction,
accountCode: line.accountCode,
amount: Number(line.amount ?? 0),
@@ -101,6 +130,7 @@ function toEditLine(line) {
taxAmount: line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount),
aux: parseAux(line.auxJson),
summary: line.summary || '',
+ sourceLineNo: line.sourceLineNo ?? null,
evidenceJson: line.evidenceJson,
accountGroup: line.accountGroup,
decisionSource: line.decisionSource,
@@ -152,16 +182,48 @@ const totals = computed(() => {
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() {
editLines.value.push({
_key: ++rowSeq,
+ lineNo: null,
direction: 'DEBIT',
accountCode: '',
amount: 0,
taxRatePercent: null,
taxAmount: null,
aux: {},
- summary: ''
+ summary: '',
+ sourceLineNo: null
})
}
@@ -285,7 +347,8 @@ async function doSave() {
taxAmount:
line.taxAmount === null || line.taxAmount === undefined ? null : Number(line.taxAmount),
aux: line.aux || {},
- summary: line.summary || ''
+ summary: line.summary || '',
+ sourceLineNo: line.sourceLineNo ?? null
}))
}
const detail = await voucherApi.updateLines(voucher.value.id, payload)
@@ -389,6 +452,7 @@ onMounted(load)
历史版本
+
{{ voucher.description }} ·
{{ voucher.voucherTypeCode }} · {{ voucher.period }} ·
{{ voucher.companyId }}/{{ voucher.bookId }} ·
版本 v{{ voucher.version }}
@@ -397,9 +461,13 @@ onMounted(load)