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

@@ -3,6 +3,7 @@
}
.application-memory-panel,
.application-memory-notice-panel,
.application-learning-receipt-panel {
margin-top: 14px;
padding: 12px;
@@ -12,6 +13,7 @@
}
.application-memory-panel-head,
.application-memory-notice-panel > header,
.application-learning-receipt-panel > header {
display: flex;
align-items: flex-start;
@@ -37,6 +39,7 @@
}
.application-memory-panel-head strong,
.application-memory-notice-panel > header strong,
.application-learning-receipt-panel > header strong {
color: #17324a;
font-size: 13px;
@@ -103,6 +106,10 @@
color: #708196;
}
.application-memory-conflict-note {
color: #8a5a12 !important;
}
.application-memory-forget-btn {
min-height: 44px;
display: inline-flex;
@@ -143,6 +150,43 @@
background: #f8fbff;
}
.application-memory-notice-panel {
display: grid;
gap: 8px;
border-color: #ead7a2;
background: #fffaf0;
}
.application-memory-notice-panel > header {
align-items: center;
color: #8a5a12;
}
.application-memory-notice-panel > header i {
font-size: 17px;
}
.application-memory-notice {
display: grid;
gap: 3px;
padding: 8px 10px;
border-left: 3px solid #d6a12a;
border-radius: 4px;
background: #ffffff;
}
.application-memory-notice strong {
color: #17324a;
font-size: 12px;
font-weight: 850;
}
.application-memory-notice span {
color: #52677a;
font-size: 11px;
line-height: 1.5;
}
.application-learning-receipt-panel > header {
align-items: center;
color: var(--theme-primary-active, #255b7d);

View File

@@ -0,0 +1,317 @@
.expense-memory-settings {
display: grid;
gap: 20px;
}
.expense-memory-safety-note {
display: flex;
align-items: flex-start;
gap: 9px;
margin: 18px 0;
padding: 11px 12px;
border: 1px solid #cfe3da;
border-radius: 8px;
background: #f3faf7;
color: #315f52;
font-size: 12px;
line-height: 1.6;
}
.expense-memory-safety-note i {
flex: 0 0 auto;
margin-top: 1px;
font-size: 17px;
}
.expense-memory-scope-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 18px;
}
.expense-memory-scope-switch button {
min-width: 0;
display: grid;
gap: 4px;
padding: 13px 14px;
border: 1px solid #d8e2ec;
border-radius: 8px;
background: #ffffff;
color: #52677a;
text-align: left;
cursor: pointer;
}
.expense-memory-scope-switch button:hover,
.expense-memory-scope-switch button.active {
border-color: var(--theme-primary, #3a7ca5);
background: color-mix(in srgb, var(--theme-primary-soft, #eaf4fa) 45%, #ffffff);
}
.expense-memory-scope-switch strong {
color: #17324a;
font-size: 13px;
}
.expense-memory-scope-switch span {
font-size: 11px;
line-height: 1.5;
}
.expense-memory-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.expense-memory-form-grid .field-wide {
grid-column: 1 / -1;
}
.expense-memory-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
.expense-memory-primary-button,
.expense-memory-refresh-button,
.expense-memory-item-actions button,
.expense-memory-inline-editor button {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 14px;
border: 1px solid #d6e1eb;
border-radius: 7px;
background: #ffffff;
color: #31556f;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.expense-memory-primary-button {
border-color: var(--theme-primary, #3a7ca5);
background: var(--theme-primary, #3a7ca5);
color: #ffffff;
}
.expense-memory-primary-button:disabled,
.expense-memory-refresh-button:disabled,
.expense-memory-inline-editor button:disabled {
cursor: wait;
opacity: 0.58;
}
.expense-memory-list-head {
align-items: center;
}
.expense-memory-list-head > div:first-child {
min-width: 0;
}
.expense-memory-list-head h4,
.expense-memory-list-head p {
margin: 0;
}
.expense-memory-list-head p {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.expense-memory-state {
min-height: 110px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #64748b;
font-size: 13px;
}
.expense-memory-state.is-error {
color: #b42318;
}
.expense-memory-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.expense-memory-item {
padding: 14px;
border: 1px solid #dce6ef;
border-radius: 9px;
background: #ffffff;
}
.expense-memory-item > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.expense-memory-item > header > div {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.expense-memory-item > header strong {
color: #17324a;
font-size: 13px;
}
.expense-memory-scope-badge,
.expense-memory-status {
flex: 0 0 auto;
padding: 3px 7px;
border-radius: 999px;
background: #edf4f8;
color: #31556f;
font-size: 10px;
font-weight: 850;
}
.expense-memory-scope-badge.is-enterprise {
background: #e8f1ff;
color: #245596;
}
.expense-memory-status.is-active {
background: #e8f7ef;
color: #287653;
}
.expense-memory-status.is-revoked,
.expense-memory-status.is-expired,
.expense-memory-status.is-suppressed {
background: #f1f3f5;
color: #65727e;
}
.expense-memory-item dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 13px 0 0;
}
.expense-memory-item dl > div {
min-width: 0;
display: grid;
gap: 3px;
}
.expense-memory-item dt,
.expense-memory-item dd {
margin: 0;
}
.expense-memory-item dt {
color: #8190a0;
font-size: 10px;
}
.expense-memory-item dd {
color: #334155;
font-size: 11px;
font-weight: 750;
overflow-wrap: anywhere;
}
.expense-memory-item-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 13px;
}
.expense-memory-item-actions button.danger,
.expense-memory-inline-editor button.danger {
border-color: #efc7c3;
color: #b42318;
}
.expense-memory-inline-editor {
display: grid;
grid-template-columns: minmax(140px, 0.6fr) minmax(120px, 0.4fr) minmax(220px, 1fr);
gap: 9px;
margin-top: 13px;
padding: 12px;
border: 1px solid #cfe0ee;
border-radius: 8px;
background: #f7fbfe;
}
.expense-memory-inline-editor.is-danger {
grid-template-columns: 1fr;
border-color: #efc7c3;
background: #fff8f7;
}
.expense-memory-inline-editor p {
margin: 0;
color: #7a3b35;
font-size: 11px;
line-height: 1.55;
}
.expense-memory-inline-editor input {
min-width: 0;
min-height: 38px;
padding: 0 10px;
border: 1px solid #d5e0ea;
border-radius: 7px;
color: #26394a;
font-size: 12px;
}
.expense-memory-inline-editor > div {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 900px) {
.expense-memory-item dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.expense-memory-inline-editor {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.expense-memory-scope-switch,
.expense-memory-form-grid,
.expense-memory-item dl {
grid-template-columns: 1fr;
}
.expense-memory-item > header {
align-items: flex-start;
}
.expense-memory-form-actions,
.expense-memory-item-actions {
justify-content: stretch;
}
.expense-memory-primary-button,
.expense-memory-item-actions button {
flex: 1;
}
}

View File

@@ -1,17 +1,20 @@
<template>
<div v-if="appliedMemories.length || learningReceipts.length" class="application-memory-stack">
<div
v-if="appliedMemories.length || memoryNotices.length || learningReceipts.length"
class="application-memory-stack"
>
<section
v-if="appliedMemories.length"
class="application-memory-panel"
aria-label="本次申请使用的历史偏好"
aria-label="本次申请使用的智能记忆"
>
<header class="application-memory-panel-head">
<span class="application-memory-panel-icon" aria-hidden="true">
<i class="mdi mdi-history"></i>
</span>
<span>
<strong>个性化填充</strong>
<small>以下内容来自已确认的历史偏好您仍可直接修改上方字段</small>
<strong>智能记忆填充</strong>
<small>企业制度部门基线和个人偏好会按优先级应用您仍可直接修改上方字段</small>
</span>
</header>
<div class="application-memory-list">
@@ -23,13 +26,17 @@
<div class="application-memory-copy">
<span class="application-memory-status">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
已按历史偏好填入
{{ resolveMemorySourceTitle(memory) }}
</span>
<strong>{{ memory.fieldLabel }}{{ memory.value }}</strong>
<small v-if="memory.message">{{ memory.message }}</small>
<em>{{ ui.resolveApplicationMemoryEvidenceText(memory) }}</em>
<small v-if="memory.conflicts.length" class="application-memory-conflict-note">
已按适用优先级选择当前设置其他层级设置未自动采用
</small>
<em>{{ resolveMemorySupportingText(memory) }}</em>
</div>
<button
v-if="canForgetMemory(memory)"
type="button"
class="application-memory-forget-btn"
:disabled="ui.isForgettingApplicationMemory(memory)"
@@ -46,6 +53,27 @@
</div>
</section>
<section
v-if="memoryNotices.length"
class="application-memory-notice-panel"
role="status"
aria-live="polite"
aria-label="本次未自动应用的记忆说明"
>
<header>
<i class="mdi mdi-information-outline" aria-hidden="true"></i>
<strong>需要您确认的设置</strong>
</header>
<article
v-for="memory in memoryNotices"
:key="`${message.id}-memory-notice-${memory.memoryId}`"
class="application-memory-notice"
>
<strong>{{ memory.fieldLabel }}</strong>
<span>{{ resolveMemoryNoticeText(memory) }}</span>
</article>
</section>
<section
v-if="learningReceipts.length"
class="application-learning-receipt-panel"
@@ -75,6 +103,16 @@
<script setup>
import { computed } from 'vue'
import { resolveAiApplicationPreviewMemories } from '../../services/aiApplicationPreviewActions.js'
const MEMORY_NOTICE_STATUSES = new Set([
'conflict',
'conflicted',
'not_applied',
'overridden',
'skipped',
'suppressed'
])
const props = defineProps({
message: {
@@ -88,7 +126,44 @@ const props = defineProps({
})
const appliedMemories = computed(() => props.ui.resolveAppliedApplicationPreviewMemories(props.message))
const normalizedMemories = computed(() => (
resolveAiApplicationPreviewMemories(props.message?.applicationPreview || {})
))
const memoryNotices = computed(() => normalizedMemories.value.filter((memory) => (
memory.status !== 'applied'
&& (memory.conflicts.length > 0 || MEMORY_NOTICE_STATUSES.has(memory.status))
)))
const learningReceipts = computed(() => props.ui.resolveVisibleApplicationLearningReceipts(props.message))
function resolveMemorySourceTitle(memory = {}) {
if (memory.scopeType === 'enterprise') return '企业制度填充'
if (memory.scopeType === 'department') return '部门基线填充'
return '个性化填充'
}
function resolveMemorySupportingText(memory = {}) {
if (memory.scopeType === 'enterprise') {
return '由企业管理员维护,仅影响自动填充。'
}
if (memory.scopeType === 'department') {
return '由当前部门的适用设置提供,仅影响自动填充。'
}
return props.ui.resolveApplicationMemoryEvidenceText(memory)
}
function canForgetMemory(memory = {}) {
return memory.scopeType === 'user' && memory.canRevoke === true
}
function resolveMemoryNoticeText(memory = {}) {
if (memory.conflicts.length > 0 || ['conflict', 'conflicted'].includes(memory.status)) {
return '检测到同层级设置冲突,本次未自动填入,请按实际情况确认。'
}
if (['overridden', 'suppressed'].includes(memory.status)) {
return '存在更高优先级的适用设置,本次未使用该项。'
}
return '该项本次未自动填入,请按实际情况确认。'
}
</script>
<style scoped src="../../assets/styles/components/travel-reimbursement-message-memory.css"></style>

View File

@@ -19,6 +19,43 @@ const AI_APPLICATION_MEMORY_FIELD_LABELS = {
transport_mode: '出行方式',
transportMode: '出行方式'
}
const AI_APPLICATION_MEMORY_SCOPE_DEFAULTS = {
enterprise: {
label: '企业制度',
priority: 300,
source: 'admin_managed_org'
},
department: {
label: '部门基线',
priority: 200,
source: 'admin_managed_org'
},
user: {
label: '个人偏好',
priority: 100,
source: 'verified_user_history'
}
}
function normalizeOptionalNumber(value) {
if (value === '' || value === null || value === undefined) return null
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : null
}
function normalizeBoolean(value, fallback = false) {
if (value === true || value === 1 || value === 'true' || value === '1') return true
if (value === false || value === 0 || value === 'false' || value === '0') return false
return fallback
}
function normalizeMemoryConflicts(value) {
if (!Array.isArray(value)) return []
return value.filter((item) => {
if (item && typeof item === 'object') return true
return Boolean(normalizeText(item))
})
}
export function normalizeAiApplicationMemory(item = {}) {
if (!item || typeof item !== 'object') return null
@@ -26,6 +63,12 @@ export function normalizeAiApplicationMemory(item = {}) {
const fieldKey = normalizeText(item.fieldKey || item.field_key)
const rawStatus = normalizeText(item.status).toLowerCase()
const status = rawStatus === 'active' ? 'applied' : rawStatus
const scopeType = normalizeText(item.scopeType || item.scope_type).toLowerCase() || 'user'
const scopeDefaults = AI_APPLICATION_MEMORY_SCOPE_DEFAULTS[scopeType]
|| AI_APPLICATION_MEMORY_SCOPE_DEFAULTS.user
const explicitCanRevoke = Object.prototype.hasOwnProperty.call(item, 'canRevoke')
? item.canRevoke
: item.can_revoke
if (!memoryId || !fieldKey || !status) return null
return {
memoryId,
@@ -39,6 +82,17 @@ export function normalizeAiApplicationMemory(item = {}) {
evidenceCount: Number(item.evidenceCount ?? item.evidence_count ?? 0) || 0,
approvedEvidenceCount:
Number(item.approvedEvidenceCount ?? item.approved_evidence_count ?? 0) || 0,
scopeType,
scopeId: normalizeText(item.scopeId || item.scope_id),
scopeLabel: normalizeText(item.scopeLabel || item.scope_label) || scopeDefaults.label,
source: normalizeText(item.source) || scopeDefaults.source,
priority:
normalizeOptionalNumber(item.priority) ?? scopeDefaults.priority,
effectiveConfidence: normalizeOptionalNumber(
item.effectiveConfidence ?? item.effective_confidence
),
conflicts: normalizeMemoryConflicts(item.conflicts),
canRevoke: normalizeBoolean(explicitCanRevoke, scopeType === 'user'),
message: normalizeText(item.message)
}
}

View File

@@ -0,0 +1,167 @@
import { apiRequest } from './api.js'
export const EXPENSE_MEMORY_TRANSPORT_OPTIONS = ['飞机', '火车', '轮船']
export const EXPENSE_MEMORY_MIN_DAYS = 30
export const EXPENSE_MEMORY_MAX_DAYS = 365
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeInteger(value, fallback = 0) {
const normalized = Number.parseInt(String(value ?? ''), 10)
return Number.isFinite(normalized) ? normalized : fallback
}
function requireReason(value) {
const reason = normalizeText(value)
if (!reason) throw new Error('请填写操作理由。')
return reason
}
function requireTransportValue(value) {
const normalized = normalizeText(value)
if (!EXPENSE_MEMORY_TRANSPORT_OPTIONS.includes(normalized)) {
throw new Error('出行方式仅支持飞机、火车或轮船。')
}
return normalized
}
function requireExpiresInDays(value) {
const normalized = normalizeInteger(value)
if (normalized < EXPENSE_MEMORY_MIN_DAYS || normalized > EXPENSE_MEMORY_MAX_DAYS) {
throw new Error('有效期必须在 30 到 365 天之间。')
}
return normalized
}
function requireExpectedGeneration(value) {
const normalized = normalizeInteger(value)
if (normalized < 1) throw new Error('缺少有效的记忆版本。')
return normalized
}
function requireRequestId(value) {
const normalized = normalizeText(value)
if (normalized.length < 8) throw new Error('缺少有效的幂等请求标识。')
return normalized
}
export function normalizeOrganizationExpenseMemory(item = {}) {
if (!item || typeof item !== 'object') return null
const id = normalizeText(item.id)
const scopeType = normalizeText(item.scopeType || item.scope_type).toLowerCase()
if (!id || !['enterprise', 'department'].includes(scopeType)) return null
const originType = normalizeText(item.originType || item.origin_type)
return {
id,
fieldKey: normalizeText(item.fieldKey || item.field_key) || 'transport_mode',
value: normalizeText(item.value),
status: normalizeText(item.status).toLowerCase(),
scopeType,
scopeId: normalizeText(item.scopeId || item.scope_id),
scopeLabel: normalizeText(item.scopeLabel || item.scope_label)
|| (scopeType === 'enterprise' ? '企业统一规则' : '部门规则'),
source: normalizeText(item.source),
originType,
sourceLabel: originType === 'admin_managed' ? '管理员显式维护' : '组织规则',
generation: Math.max(1, normalizeInteger(item.generation, 1)),
policyVersion: normalizeText(item.policyVersion || item.policy_version),
validFrom: normalizeText(item.validFrom || item.valid_from),
expiresAt: normalizeText(item.expiresAt || item.expires_at),
managedAt: normalizeText(item.managedAt || item.managed_at),
canRevoke: Boolean(item.canRevoke ?? item.can_revoke),
createdAt: normalizeText(item.createdAt || item.created_at),
updatedAt: normalizeText(item.updatedAt || item.updated_at)
}
}
export function normalizeOrganizationExpenseMemories(payload = {}) {
const items = Array.isArray(payload) ? payload : payload?.items
return (Array.isArray(items) ? items : [])
.map((item) => normalizeOrganizationExpenseMemory(item))
.filter(Boolean)
}
export function buildOrganizationExpenseMemoryCreatePayload(input = {}) {
const scopeType = normalizeText(input.scopeType || input.scope_type).toLowerCase()
if (!['enterprise', 'department'].includes(scopeType)) {
throw new Error('请选择企业或部门作用域。')
}
const payload = {
scope_type: scopeType,
value: requireTransportValue(input.value),
expires_in_days: requireExpiresInDays(input.expiresInDays ?? input.expires_in_days),
reason: requireReason(input.reason),
request_id: requireRequestId(input.requestId || input.request_id)
}
if (scopeType === 'department') {
const scopeId = normalizeText(input.scopeId || input.scope_id)
if (!scopeId) throw new Error('请选择要适用的部门。')
payload.scope_id = scopeId
}
return payload
}
export function buildOrganizationExpenseMemoryUpdatePayload(input = {}) {
return {
value: requireTransportValue(input.value),
expires_in_days: requireExpiresInDays(input.expiresInDays ?? input.expires_in_days),
expected_generation: requireExpectedGeneration(
input.expectedGeneration ?? input.expected_generation
),
reason: requireReason(input.reason),
request_id: requireRequestId(input.requestId || input.request_id)
}
}
export function buildOrganizationExpenseMemoryRevokePayload(input = {}) {
return {
expected_generation: requireExpectedGeneration(
input.expectedGeneration ?? input.expected_generation
),
reason: requireReason(input.reason),
request_id: requireRequestId(input.requestId || input.request_id)
}
}
export async function fetchOrganizationExpenseMemories(options = {}) {
const payload = await apiRequest('/expense-application-memories/organization', options)
return normalizeOrganizationExpenseMemories(payload)
}
export async function createOrganizationExpenseMemory(input = {}, options = {}) {
const payload = await apiRequest('/expense-application-memories/organization', {
method: 'POST',
body: JSON.stringify(buildOrganizationExpenseMemoryCreatePayload(input)),
...options
})
return normalizeOrganizationExpenseMemory(payload)
}
export async function updateOrganizationExpenseMemory(memoryId, input = {}, options = {}) {
const id = normalizeText(memoryId)
if (!id) throw new Error('缺少要更新的记忆标识。')
const payload = await apiRequest(
`/expense-application-memories/organization/${encodeURIComponent(id)}`,
{
method: 'PUT',
body: JSON.stringify(buildOrganizationExpenseMemoryUpdatePayload(input)),
...options
}
)
return normalizeOrganizationExpenseMemory(payload)
}
export function revokeOrganizationExpenseMemory(memoryId, input = {}, options = {}) {
const id = normalizeText(memoryId)
if (!id) return Promise.reject(new Error('缺少要撤销的记忆标识。'))
return apiRequest(
`/expense-application-memories/organization/${encodeURIComponent(id)}/revoke`,
{
method: 'POST',
body: JSON.stringify(buildOrganizationExpenseMemoryRevokePayload(input)),
...options
}
)
}

View File

@@ -43,6 +43,14 @@ export const SECTION_DEFINITIONS = [
longDesc: '统一配置智能体会话的保留天数。超过保留期的历史会话会在后端清理,避免上下文和管理成本无限增长。',
actionLabel: '保存会话设置'
},
{
id: 'aiMemory',
label: 'AI 记忆',
title: 'AI 记忆策略',
desc: '企业与部门智能填充',
longDesc: '显式维护企业和部门的费用申请记忆,统一控制作用域、版本与有效期。',
actionLabel: ''
},
{
id: 'hermes',
label: '数字员工设置',
@@ -607,6 +615,7 @@ export function computeSectionStatus(state) {
Number(state.sessionForm.conversationRetentionDays) >= 1 &&
Number(state.sessionForm.conversationRetentionDays) <= 10
),
aiMemory: true,
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
llm: (() => {
const rows = normalizeLlmModelRows(state.llmForm.models)

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>

View File

@@ -324,7 +324,7 @@
</section>
</template>
<template v-else-if="activeSection === 'hermes'">
<template v-else-if="activeSection === 'hermes'">
<HermesEmployeeSettingsPanel
:hermes-form="pageState.hermesForm"
@toggle-master="toggleHermesMaster"
@@ -332,9 +332,13 @@
@toggle-task="toggleHermesTask"
@update-task-time="updateHermesTaskTime"
/>
</template>
<template v-else-if="activeSection === 'llm'">
</template>
<template v-else-if="activeSection === 'aiMemory'">
<ExpenseMemorySettingsPanel />
</template>
<template v-else-if="activeSection === 'llm'">
<LlmSettingsPanel :llm-form="pageState.llmForm" :provider-options="providerOptions" />
</template>

View File

@@ -1,3 +1,4 @@
import ExpenseMemorySettingsPanel from '../ExpenseMemorySettingsPanel.vue'
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
import LogDetailView from '../LogDetailView.vue'
@@ -9,6 +10,7 @@ import { useSettings } from '../../composables/useSettings.js'
export default {
name: 'SettingsView',
components: {
ExpenseMemorySettingsPanel,
HermesEmployeeSettingsPanel,
EnterpriseSelect,
LlmSettingsPanel,

View File

@@ -151,6 +151,10 @@ export function useTravelReimbursementApplicationPreviewActions({
async function forgetApplicationPreviewMemory(message, memory = {}) {
const normalizedMemory = normalizeAiApplicationMemory(memory)
if (!message?.applicationPreview || !normalizedMemory) return false
if (normalizedMemory.scopeType !== 'user' || normalizedMemory.canRevoke !== true) {
toast('企业或部门设置由管理员统一维护,当前申请仍可手动修改。')
return false
}
if (isForgettingApplicationMemory(normalizedMemory)) return false
if (!(await requestForgetMemoryConfirmation(normalizedMemory))) return false

View File

@@ -4,6 +4,7 @@ import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
forgetAiApplicationMemory,
normalizeAiApplicationMemory,
registerAiApplicationPreviewDecision,
resolveAiApplicationLearningReceipts,
runAiApplicationPreviewAction
@@ -148,6 +149,14 @@ async function testRegistrationUsesServerCanonicalPreview() {
status: 'active',
evidenceCount: 4,
approvedEvidenceCount: 3,
scope_type: 'department',
scope_id: 'department-shanghai-delivery',
scope_label: '上海交付部',
source: 'admin_managed_org',
priority: 200,
effective_confidence: 0.92,
conflicts: [{ scope_type: 'user', reason: 'lower_priority' }],
can_revoke: false,
message: '根据历史申请偏好填入'
}]
}
@@ -188,6 +197,14 @@ async function testRegistrationUsesServerCanonicalPreview() {
status: 'applied',
evidenceCount: 4,
approvedEvidenceCount: 3,
scopeType: 'department',
scopeId: 'department-shanghai-delivery',
scopeLabel: '上海交付部',
source: 'admin_managed_org',
priority: 200,
effectiveConfidence: 0.92,
conflicts: [{ scope_type: 'user', reason: 'lower_priority' }],
canRevoke: false,
message: '根据历史申请偏好填入'
}])
}
@@ -215,6 +232,14 @@ async function testLearningReceiptNormalizationAndForgetEndpoint() {
status: 'candidate',
evidenceCount: 2,
approvedEvidenceCount: 1,
scopeType: 'user',
scopeId: '',
scopeLabel: '个人偏好',
source: 'verified_user_history',
priority: 100,
effectiveConfidence: null,
conflicts: [],
canRevoke: true,
message: '再确认一次后可形成稳定偏好'
}])
@@ -238,6 +263,52 @@ async function testLearningReceiptNormalizationAndForgetEndpoint() {
assert.equal(capturedOptions.method, 'DELETE')
}
function testHierarchicalMemoryNormalizationKeepsCompatibilityAndPermissions() {
const enterpriseMemory = normalizeAiApplicationMemory({
memory_id: 'memory-enterprise-transport',
field_key: 'transport_mode',
value: '火车',
status: 'applied',
scope_type: 'enterprise',
scope_id: 'tenant-acme',
scope_label: '企业统一制度',
source: 'admin_managed_org',
priority: '300',
effective_confidence: '0.88',
conflicts: [{ scope_type: 'department', reason: 'lower_priority' }],
can_revoke: false
})
assert.deepEqual(enterpriseMemory, {
memoryId: 'memory-enterprise-transport',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
evidenceCount: 0,
approvedEvidenceCount: 0,
scopeType: 'enterprise',
scopeId: 'tenant-acme',
scopeLabel: '企业统一制度',
source: 'admin_managed_org',
priority: 300,
effectiveConfidence: 0.88,
conflicts: [{ scope_type: 'department', reason: 'lower_priority' }],
canRevoke: false,
message: ''
})
const legacyPersonalMemory = normalizeAiApplicationMemory({
memoryId: 'memory-legacy-user',
fieldKey: 'transport_mode',
value: '飞机',
status: 'active'
})
assert.equal(legacyPersonalMemory.scopeType, 'user')
assert.equal(legacyPersonalMemory.source, 'verified_user_history')
assert.equal(legacyPersonalMemory.canRevoke, true)
assert.equal(legacyPersonalMemory.status, 'applied')
}
async function testEditDraftActionCarriesClaimAndEditableFields() {
let capturedOptions = null
@@ -327,6 +398,7 @@ async function run() {
await testSaveDraftActionUsesFastPreviewEndpoint()
await testRegistrationUsesServerCanonicalPreview()
await testLearningReceiptNormalizationAndForgetEndpoint()
testHierarchicalMemoryNormalizationKeepsCompatibilityAndPermissions()
await testEditDraftActionCarriesClaimAndEditableFields()
await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults()
console.log('ai-application-preview-actions tests passed')

View File

@@ -407,13 +407,48 @@ test('忘记偏好失败时不移除 memory也不改当前字段', async () =
assert.match(toasts.at(-1), /偏好服务暂不可用/)
})
test('申请预览 UI 解释已应用偏好并提供可访问的忘记口', () => {
test('组织记忆不允许普通用户调用忘记口', async () => {
const organizationMemory = {
memoryId: 'memory-enterprise-policy',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
scopeType: 'enterprise',
canRevoke: false
}
const message = {
id: 'preview-enterprise-memory',
applicationPreview: createPreview({ memoryApplications: [organizationMemory] })
}
const { actions, toasts } = createActions()
let requestCount = 0
global.fetch = async () => {
requestCount += 1
throw new Error('不应发起组织记忆删除请求')
}
const forgotten = await actions.forgetApplicationPreviewMemory(message, organizationMemory)
assert.equal(forgotten, false)
assert.equal(requestCount, 0)
assert.equal(message.applicationPreview.memoryApplications.length, 1)
assert.match(toasts.at(-1), /管理员统一维护/)
})
test('申请预览 UI 解释分层记忆,仅个人记忆提供忘记入口', () => {
assert.match(messageItemTemplate, /<TravelReimbursementMemoryPanel :message="message" :ui="ui" \/>/)
assert.match(memoryPanelTemplate, /resolveAppliedApplicationPreviewMemories\(props\.message\)/)
assert.match(memoryPanelTemplate, /已按历史偏好填入/)
assert.match(memoryPanelTemplate, /企业制度填充/)
assert.match(memoryPanelTemplate, /部门基线填充/)
assert.match(memoryPanelTemplate, /个性化填充/)
assert.match(memoryPanelTemplate, /您仍可直接修改上方字段/)
assert.match(memoryPanelTemplate, /忘记此偏好/)
assert.match(memoryPanelTemplate, /v-if="canForgetMemory\(memory\)"/)
assert.match(memoryPanelTemplate, /memory\.scopeType === 'user' && memory\.canRevoke === true/)
assert.match(memoryPanelTemplate, /:aria-busy="ui\.isForgettingApplicationMemory\(memory\)"/)
assert.match(memoryPanelTemplate, /检测到同层级设置冲突/)
assert.doesNotMatch(memoryPanelTemplate, /JSON\.stringify\(memory\.conflicts/)
assert.match(memoryPanelTemplate, /resolveVisibleApplicationLearningReceipts\(props\.message\)/)
assert.match(memoryPanelTemplate, /ui\.resolveApplicationLearningReceiptTitle\(receipt\)/)
assert.doesNotMatch(messageItemTemplate, /已按历史偏好填入/)

View File

@@ -0,0 +1,190 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
buildOrganizationExpenseMemoryCreatePayload,
buildOrganizationExpenseMemoryRevokePayload,
buildOrganizationExpenseMemoryUpdatePayload,
createOrganizationExpenseMemory,
fetchOrganizationExpenseMemories,
normalizeOrganizationExpenseMemory,
revokeOrganizationExpenseMemory,
updateOrganizationExpenseMemory
} from '../src/services/expenseApplicationMemories.js'
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
const memoryPanel = readFileSync(new URL('../src/views/ExpenseMemorySettingsPanel.vue', import.meta.url), 'utf8')
test('组织记忆 payload 限制低敏枚举、有效期和必填理由', () => {
assert.deepEqual(buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise',
scopeId: 'must-not-be-sent',
value: '火车',
expiresInDays: 180,
reason: '统一企业基线',
requestId: 'create-enterprise-1'
}), {
scope_type: 'enterprise',
value: '火车',
expires_in_days: 180,
reason: '统一企业基线',
request_id: 'create-enterprise-1'
})
assert.deepEqual(buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'department',
scopeId: 'organization-unit-stable-id',
value: '飞机',
expiresInDays: 90,
reason: '部门差旅基线',
requestId: 'create-department-1'
}), {
scope_type: 'department',
scope_id: 'organization-unit-stable-id',
value: '飞机',
expires_in_days: 90,
reason: '部门差旅基线',
request_id: 'create-department-1'
})
assert.deepEqual(buildOrganizationExpenseMemoryUpdatePayload({
value: '轮船',
expiresInDays: 120,
expectedGeneration: 3,
reason: '更新制度',
requestId: 'update-memory-3'
}), {
value: '轮船',
expires_in_days: 120,
expected_generation: 3,
reason: '更新制度',
request_id: 'update-memory-3'
})
assert.deepEqual(buildOrganizationExpenseMemoryRevokePayload({
expectedGeneration: 3,
reason: '制度已停用',
requestId: 'revoke-memory-3'
}), {
expected_generation: 3,
reason: '制度已停用',
request_id: 'revoke-memory-3'
})
assert.throws(
() => buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise', value: '网约车', expiresInDays: 180, reason: '不应通过', requestId: 'invalid-value-1'
}),
/仅支持飞机、火车或轮船/
)
assert.throws(
() => buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise', value: '火车', expiresInDays: 29, reason: '不应通过', requestId: 'invalid-days-1'
}),
/30 到 365 天/
)
assert.throws(
() => buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise', value: '火车', expiresInDays: 180, reason: '', requestId: 'invalid-reason-1'
}),
/操作理由/
)
})
test('组织记忆 service 使用管理员接口并保留作用域和版本解释', async () => {
const requests = []
const originalFetch = globalThis.fetch
const responseMemory = {
id: 'memory/enterprise 1',
field_key: 'transport_mode',
value: '火车',
status: 'active',
scope_type: 'enterprise',
scope_id: 'tenant-1',
scope_label: '企业统一规则',
source: 'enterprise_policy_memory',
origin_type: 'admin_managed',
generation: 2,
policy_version: 'expense_application_transport_org_memory.v1',
expires_at: '2026-12-31T00:00:00Z',
managed_by: 'private-admin-identifier',
can_revoke: true
}
globalThis.fetch = async (url, options = {}) => {
requests.push({ url: String(url), options })
return {
ok: true,
status: 200,
async json() {
return requests.length === 1 ? { items: [responseMemory] } : responseMemory
}
}
}
try {
const items = await fetchOrganizationExpenseMemories()
await createOrganizationExpenseMemory({
scopeType: 'enterprise', value: '火车', expiresInDays: 180, reason: '创建企业记忆', requestId: 'create-memory-1'
})
await updateOrganizationExpenseMemory('memory/enterprise 1', {
value: '轮船', expiresInDays: 120, expectedGeneration: 2, reason: '更新记忆', requestId: 'update-memory-1'
})
await revokeOrganizationExpenseMemory('memory/enterprise 1', {
expectedGeneration: 2, reason: '撤销记忆', requestId: 'revoke-memory-1'
})
assert.equal(items[0].scopeType, 'enterprise')
assert.equal(items[0].generation, 2)
assert.equal(items[0].sourceLabel, '管理员显式维护')
assert.equal('managedBy' in items[0], false)
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requests[0].url, '/api/v1/expense-application-memories/organization')
assert.equal(requests[1].options.method, 'POST')
assert.equal(
requests[2].url,
'/api/v1/expense-application-memories/organization/memory%2Fenterprise%201'
)
assert.equal(requests[2].options.method, 'PUT')
assert.equal(
requests[3].url,
'/api/v1/expense-application-memories/organization/memory%2Fenterprise%201/revoke'
)
assert.equal(requests[3].options.method, 'POST')
})
test('组织记忆规范化不将管理员标识带入展示模型', () => {
const memory = normalizeOrganizationExpenseMemory({
id: 'memory-department-1',
scope_type: 'department',
scope_id: 'organization-unit-1',
scope_label: '部门规则(财务部)',
status: 'active',
value: '飞机',
origin_type: 'admin_managed',
managed_by: 'sensitive-user-id'
})
assert.equal(memory.scopeId, 'organization-unit-1')
assert.equal(memory.scopeLabel, '部门规则(财务部)')
assert.equal(memory.sourceLabel, '管理员显式维护')
assert.equal('managedBy' in memory, false)
})
test('设置页接入无顶部保存按钮的 AI 记忆独立面板', () => {
assert.match(settingsModel, /id:\s*'aiMemory'[\s\S]*label:\s*'AI 记忆'[\s\S]*actionLabel:\s*''/)
assert.match(settingsModel, /aiMemory:\s*true/)
assert.match(settingsScript, /import ExpenseMemorySettingsPanel/)
assert.match(settingsScript, /ExpenseMemorySettingsPanel,/)
assert.match(settingsView, /activeSection === 'aiMemory'[\s\S]*<ExpenseMemorySettingsPanel \/>/)
assert.match(memoryPanel, /fetchEmployeeMeta\(\)/)
assert.match(memoryPanel, /item\?\.id && item\?\.unitType === 'department'/)
assert.match(memoryPanel, /value:\s*String\(item\.id\)/)
assert.match(memoryPanel, /飞机[、\s\S]*火车[、\s\S]*轮船/)
assert.match(memoryPanel, /min="30" max="365"/)
assert.match(memoryPanel, /第 \{\{ memory\.generation \}\} 版/)
assert.match(memoryPanel, /memory\.sourceLabel/)
assert.match(memoryPanel, /organization-memory-revoke:/)
assert.match(memoryPanel, /requestId:\s*revokeRequestId\.value/)
assert.doesNotMatch(memoryPanel, /managedBy|managed_by|managementReason|management_reason/)
})