feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
357
web/src/views/ExpenseMemorySettingsPanel.vue
Normal file
357
web/src/views/ExpenseMemorySettingsPanel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user