feat(ai): add tenant-safe hierarchical expense learning

This commit is contained in:
caoxiaozhu
2026-07-16 14:30:41 +08:00
parent 6bdf65bc24
commit ee88a36baf
65 changed files with 6909 additions and 232 deletions

View File

@@ -0,0 +1,357 @@
<template>
<div class="expense-memory-settings">
<section class="settings-card expense-memory-create-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-brain"></i>
</div>
<div>
<h4>组织费用申请记忆</h4>
<p>为企业或指定部门配置出行方式基线仅用于申请表单的智能填充</p>
</div>
</div>
</div>
<div class="expense-memory-safety-note">
<i class="mdi mdi-shield-check-outline" aria-hidden="true"></i>
<span>只保存飞机火车轮船三种低敏枚举值不保存金额事由或客户信息</span>
</div>
<div class="expense-memory-scope-switch" aria-label="选择记忆作用域">
<button
v-for="option in scopeOptions"
:key="option.value"
type="button"
:class="{ active: createForm.scopeType === option.value }"
@click="selectScope(option.value)"
>
<strong>{{ option.label }}</strong>
<span>{{ option.desc }}</span>
</button>
</div>
<div class="expense-memory-form-grid">
<label v-if="createForm.scopeType === 'department'" class="field">
<span><em>*</em> 适用部门</span>
<EnterpriseSelect
v-model="createForm.scopeId"
:options="departmentOptions"
filterable
placeholder="选择稳定部门 ID"
/>
<small>部门来自员工组织元数据不使用可变的部门名称作为关联键</small>
</label>
<label class="field">
<span><em>*</em> 默认出行方式</span>
<EnterpriseSelect v-model="createForm.value" :options="transportOptions" />
</label>
<label class="field">
<span><em>*</em> 有效天数</span>
<input v-model.number="createForm.expiresInDays" type="number" min="30" max="365" />
<small>30 365 到期后自动停止填充</small>
</label>
<label class="field field-wide">
<span><em>*</em> 操作理由</span>
<input
v-model="createForm.reason"
type="text"
maxlength="255"
placeholder="例如:统一当前差旅出行基线"
/>
<small>理由用于后端审计不会向普通员工展示</small>
</label>
</div>
<div class="expense-memory-form-actions">
<button
class="expense-memory-primary-button"
type="button"
:disabled="busyKey === 'create'"
@click="createMemory"
>
<i :class="busyKey === 'create' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-plus-circle-outline'"></i>
<span>{{ busyKey === 'create' ? '保存中...' : '新增设置' }}</span>
</button>
</div>
</section>
<section class="settings-card expense-memory-list-card">
<div class="card-head expense-memory-list-head">
<div>
<h4>当前设置与版本记录</h4>
<p>更新会创建新版本旧版本保留为可追溯记录</p>
</div>
<button class="expense-memory-refresh-button" type="button" :disabled="loading" @click="loadPanel">
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>刷新</span>
</button>
</div>
<div v-if="loading" class="expense-memory-state" role="status">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在读取组织记忆...</span>
</div>
<div v-else-if="loadError" class="expense-memory-state is-error" role="alert">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ loadError }}</span>
</div>
<div v-else-if="!memories.length" class="expense-memory-state">
<i class="mdi mdi-brain"></i>
<span>尚未配置企业或部门记忆</span>
</div>
<div v-else class="expense-memory-list">
<article v-for="memory in memories" :key="memory.id" class="expense-memory-item">
<header>
<div>
<span class="expense-memory-scope-badge" :class="`is-${memory.scopeType}`">
{{ memory.scopeType === 'enterprise' ? '企业' : '部门' }}
</span>
<strong>{{ memory.scopeLabel }}</strong>
</div>
<span class="expense-memory-status" :class="`is-${memory.status}`">
{{ resolveStatusLabel(memory.status) }}
</span>
</header>
<dl>
<div><dt>出行基线</dt><dd>{{ memory.value || '未设置' }}</dd></div>
<div><dt>版本</dt><dd> {{ memory.generation }} </dd></div>
<div><dt>有效期</dt><dd>{{ formatDateTime(memory.expiresAt) }}</dd></div>
<div><dt>维护来源</dt><dd>{{ memory.sourceLabel }}</dd></div>
</dl>
<div v-if="memory.status === 'active'" class="expense-memory-item-actions">
<button type="button" @click="startEdit(memory)">更新版本</button>
<button class="danger" type="button" @click="startRevoke(memory)">撤销</button>
</div>
<div v-if="editingId === memory.id" class="expense-memory-inline-editor">
<EnterpriseSelect v-model="editForm.value" :options="transportOptions" />
<input v-model.number="editForm.expiresInDays" type="number" min="30" max="365" aria-label="更新后有效天数" />
<input v-model="editForm.reason" type="text" maxlength="255" placeholder="请填写更新理由" />
<div>
<button type="button" :disabled="busyKey === memory.id" @click="saveUpdate(memory)">保存新版本</button>
<button type="button" @click="closeInlineActions">取消</button>
</div>
</div>
<div v-if="revokingId === memory.id" class="expense-memory-inline-editor is-danger">
<p>撤销后该作用域将停止自动填充已生成的申请不受影响</p>
<input v-model="revokeReason" type="text" maxlength="255" placeholder="请填写撤销理由" />
<div>
<button class="danger" type="button" :disabled="busyKey === memory.id" @click="confirmRevoke(memory)">确认撤销</button>
<button type="button" @click="closeInlineActions">取消</button>
</div>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import { useToast } from '../composables/useToast.js'
import { fetchEmployeeMeta } from '../services/employees.js'
import {
EXPENSE_MEMORY_TRANSPORT_OPTIONS,
createOrganizationExpenseMemory,
fetchOrganizationExpenseMemories,
revokeOrganizationExpenseMemory,
updateOrganizationExpenseMemory
} from '../services/expenseApplicationMemories.js'
const { toast } = useToast()
const scopeOptions = [
{ value: 'enterprise', label: '企业统一', desc: '对当前租户内所有员工生效' },
{ value: 'department', label: '指定部门', desc: '仅对选定组织单元生效' }
]
const transportOptions = EXPENSE_MEMORY_TRANSPORT_OPTIONS.map((value) => ({ value, label: value }))
const memories = ref([])
const departmentOptions = ref([])
const loading = ref(false)
const loadError = ref('')
const busyKey = ref('')
const editingId = ref('')
const revokingId = ref('')
const revokeReason = ref('')
const revokeRequestId = ref('')
const createForm = reactive({
scopeType: 'enterprise',
scopeId: '',
value: '火车',
expiresInDays: 180,
reason: '',
requestId: ''
})
const editForm = reactive({ value: '火车', expiresInDays: 180, reason: '', requestId: '' })
function createRequestId(prefix) {
const suffix = globalThis.crypto?.randomUUID?.()
|| `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
return `${prefix}:${suffix}`
}
watch(
() => [
createForm.scopeType,
createForm.scopeId,
createForm.value,
createForm.expiresInDays,
createForm.reason
],
() => { createForm.requestId = '' }
)
watch(
() => [editForm.value, editForm.expiresInDays, editForm.reason],
() => { editForm.requestId = '' }
)
watch(revokeReason, () => { revokeRequestId.value = '' })
function normalizeDepartmentOptions(payload = {}) {
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
return options
.filter((item) => item?.id && item?.unitType === 'department')
.map((item) => ({
value: String(item.id),
label: `${String(item.name || '未命名部门')}${String(item.code || item.id)}`
}))
}
async function loadPanel() {
loading.value = true
loadError.value = ''
const [memoryResult, employeeMetaResult] = await Promise.allSettled([
fetchOrganizationExpenseMemories(),
fetchEmployeeMeta()
])
if (memoryResult.status === 'fulfilled') {
memories.value = memoryResult.value
} else {
memories.value = []
loadError.value = memoryResult.reason?.message || '组织记忆加载失败,请稍后重试。'
}
departmentOptions.value = employeeMetaResult.status === 'fulfilled'
? normalizeDepartmentOptions(employeeMetaResult.value)
: []
loading.value = false
}
async function refreshMemories() {
memories.value = await fetchOrganizationExpenseMemories()
}
function selectScope(scopeType) {
createForm.scopeType = scopeType
if (scopeType === 'enterprise') createForm.scopeId = ''
}
async function createMemory() {
busyKey.value = 'create'
try {
createForm.requestId ||= createRequestId('organization-memory-create')
await createOrganizationExpenseMemory(createForm)
createForm.reason = ''
createForm.requestId = ''
await refreshMemories()
toast('组织记忆已保存。')
} catch (error) {
toast(error?.message || '组织记忆保存失败。')
} finally {
busyKey.value = ''
}
}
function remainingDays(expiresAt) {
const target = new Date(expiresAt).getTime()
const days = Math.ceil((target - Date.now()) / 86400000)
if (!Number.isFinite(days)) return 180
return Math.max(30, Math.min(365, days))
}
function startEdit(memory) {
editingId.value = memory.id
revokingId.value = ''
editForm.value = memory.value
editForm.expiresInDays = remainingDays(memory.expiresAt)
editForm.reason = ''
editForm.requestId = ''
}
function startRevoke(memory) {
revokingId.value = memory.id
editingId.value = ''
revokeReason.value = ''
revokeRequestId.value = ''
editForm.requestId = ''
}
function closeInlineActions() {
editingId.value = ''
revokingId.value = ''
revokeReason.value = ''
revokeRequestId.value = ''
}
async function saveUpdate(memory) {
busyKey.value = memory.id
try {
editForm.requestId ||= createRequestId(`organization-memory-update:${memory.id}`)
await updateOrganizationExpenseMemory(memory.id, {
...editForm,
expectedGeneration: memory.generation
})
closeInlineActions()
await refreshMemories()
toast('组织记忆新版本已生效。')
} catch (error) {
toast(error?.message || '组织记忆更新失败。')
await refreshMemories().catch(() => {})
} finally {
busyKey.value = ''
}
}
async function confirmRevoke(memory) {
busyKey.value = memory.id
try {
revokeRequestId.value ||= createRequestId(`organization-memory-revoke:${memory.id}`)
await revokeOrganizationExpenseMemory(memory.id, {
expectedGeneration: memory.generation,
reason: revokeReason.value,
requestId: revokeRequestId.value
})
closeInlineActions()
await refreshMemories()
toast('组织记忆已撤销。')
} catch (error) {
toast(error?.message || '组织记忆撤销失败。')
await refreshMemories().catch(() => {})
} finally {
busyKey.value = ''
}
}
function resolveStatusLabel(status) {
return { active: '生效中', suppressed: '已被新版替换', revoked: '已撤销', expired: '已过期' }[status] || '已停用'
}
function formatDateTime(value) {
const date = new Date(value)
if (!value || Number.isNaN(date.getTime())) return '未设置'
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
onMounted(loadPanel)
</script>
<style scoped src="../assets/styles/views/expense-memory-settings-panel.css"></style>