358 lines
12 KiB
Vue
358 lines
12 KiB
Vue
|
|
<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>
|