Files
X-Financial/web/src/views/ExpenseMemorySettingsPanel.vue

358 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>