feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
@@ -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);
|
||||
|
||||
317
web/src/assets/styles/views/expense-memory-settings-panel.css
Normal file
317
web/src/assets/styles/views/expense-memory-settings-panel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
167
web/src/services/expenseApplicationMemories.js
Normal file
167
web/src/services/expenseApplicationMemories.js
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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, /已按历史偏好填入/)
|
||||
|
||||
190
web/tests/settings-expense-memory-section.test.mjs
Normal file
190
web/tests/settings-expense-memory-section.test.mjs
Normal 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/)
|
||||
})
|
||||
Reference in New Issue
Block a user