You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

523 lines
18 KiB

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