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