feat: 增强员工管理与报销单全流程功能
- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点 - 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力 - 报销单提交补充身份回填、部门信息透传及预审结果展示优化 - 认证流程增加部门信息(departmentName)并在schema中同步扩展 - 用户Agent服务增加部门关联与报销单回填逻辑 - 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能 - 前端审批中心、审计、差旅报销等视图交互与样式优化 - 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
|
||||
@@ -195,7 +199,6 @@ function buildFlowItems(request) {
|
||||
|
||||
function canCurrentUserProcessRequest(request, currentUser) {
|
||||
const node = String(request?.workflowNode || '').trim()
|
||||
const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : []
|
||||
const currentName = String(currentUser?.name || '').trim()
|
||||
const applicantName = String(request?.person || request?.employeeName || '').trim()
|
||||
|
||||
@@ -203,8 +206,8 @@ function canCurrentUserProcessRequest(request, currentUser) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentUser?.isAdmin || roleCodes.includes('finance')) {
|
||||
return node.includes('财务')
|
||||
if (canManageExpenseClaims(currentUser)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -251,10 +254,13 @@ function buildApprovalRow(request) {
|
||||
export default {
|
||||
name: 'ApprovalCenterView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const activeTab = ref('全部待审')
|
||||
const selectedClaimId = ref('')
|
||||
const expandedExpenseId = ref(null)
|
||||
@@ -262,6 +268,9 @@ export default {
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
|
||||
const selectedRow = computed({
|
||||
get() {
|
||||
@@ -303,6 +312,7 @@ export default {
|
||||
})
|
||||
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const approvalEmptyState = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return {
|
||||
@@ -381,6 +391,76 @@ export default {
|
||||
activeTab.value = '全部待审'
|
||||
}
|
||||
|
||||
function handleReturnSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (!actionBusy.value) {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (!actionBusy.value) {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(row.claimId, {
|
||||
reason: '审批中心退回,请申请人补充后重新提交。'
|
||||
})
|
||||
toast(`${row.id} 已退回待补充。`)
|
||||
returnDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(row.claimId)
|
||||
toast(payload?.message || `${row.id} 报销单已删除。`)
|
||||
deleteDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -420,8 +500,15 @@ export default {
|
||||
visibleRows,
|
||||
showTable,
|
||||
showEmpty,
|
||||
actionBusy,
|
||||
approvalEmptyState,
|
||||
approvalSteps,
|
||||
canManageClaims,
|
||||
closeDeleteDialog,
|
||||
closeReturnDialog,
|
||||
confirmDeleteSelected,
|
||||
confirmReturnSelected,
|
||||
deleteDialogOpen,
|
||||
summaryItems,
|
||||
heroSummaryItems,
|
||||
currentProgressRingMotion,
|
||||
@@ -434,8 +521,11 @@ export default {
|
||||
riskItems,
|
||||
flowItems,
|
||||
handleEmptyAction,
|
||||
handleDeleteSelected,
|
||||
handleReturnSelected,
|
||||
loading,
|
||||
error,
|
||||
returnDialogOpen,
|
||||
reload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { fetchEmployees } from '../../services/employees.js'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -1130,7 +1131,6 @@ function buildListItem(asset) {
|
||||
changeCount,
|
||||
updatedAt: formatDateTime(asset.updated_at),
|
||||
badgeTone: tabMeta.badgeTone,
|
||||
spotlight: asset.status === 'active',
|
||||
domainValue: asset.domain
|
||||
}
|
||||
}
|
||||
@@ -1653,6 +1653,7 @@ export default {
|
||||
name: 'AuditView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
emits: ['detail-open-change'],
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
disableEmployee,
|
||||
downloadEmployeeImportTemplate,
|
||||
enableEmployee,
|
||||
exportEmployees,
|
||||
fetchEmployeeDetail,
|
||||
fetchEmployeeMeta,
|
||||
fetchEmployees,
|
||||
importEmployees,
|
||||
updateEmployee
|
||||
} from '../../services/employees.js'
|
||||
|
||||
@@ -56,6 +61,7 @@ function createEmployeeForm() {
|
||||
name: '',
|
||||
employeeNo: '',
|
||||
gender: '',
|
||||
age: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
@@ -64,7 +70,9 @@ function createEmployeeForm() {
|
||||
position: '',
|
||||
grade: '',
|
||||
department: '',
|
||||
organizationUnitCode: '',
|
||||
manager: '',
|
||||
managerEmployeeNo: '',
|
||||
financeOwner: '',
|
||||
costCenter: '',
|
||||
roleCodes: [],
|
||||
@@ -72,24 +80,120 @@ function createEmployeeForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmployeeForm(employee) {
|
||||
function isPlaceholderManagerName(name) {
|
||||
const normalized = normalizeText(name)
|
||||
return !normalized || normalized === 'CEO' || normalized === '无'
|
||||
}
|
||||
|
||||
function resolveManagerEmployeeNo(employee, roster = []) {
|
||||
const fromApi = normalizeText(employee?.managerEmployeeNo)
|
||||
if (fromApi) {
|
||||
return fromApi
|
||||
}
|
||||
|
||||
const managerName = normalizeText(employee?.manager)
|
||||
if (isPlaceholderManagerName(managerName)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const matches = roster.filter((item) => normalizeText(item.name) === managerName)
|
||||
if (matches.length === 1) {
|
||||
return matches[0].employeeNo
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function enrichEmployeeRecord(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return employee
|
||||
}
|
||||
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) {
|
||||
return employee
|
||||
}
|
||||
|
||||
return {
|
||||
...employee,
|
||||
managerEmployeeNo
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEmployeeRecords(listItem, detailItem, roster = []) {
|
||||
if (!listItem && !detailItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!listItem) {
|
||||
return enrichEmployeeRecord(detailItem, roster)
|
||||
}
|
||||
|
||||
if (!detailItem) {
|
||||
return enrichEmployeeRecord(listItem, roster)
|
||||
}
|
||||
|
||||
const managerEmployeeNo =
|
||||
normalizeText(detailItem.managerEmployeeNo) ||
|
||||
normalizeText(listItem.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(detailItem, roster) ||
|
||||
resolveManagerEmployeeNo(listItem, roster)
|
||||
|
||||
const history =
|
||||
Array.isArray(detailItem.history) && detailItem.history.length
|
||||
? detailItem.history
|
||||
: listItem.history || []
|
||||
|
||||
const permissions =
|
||||
Array.isArray(detailItem.permissions) && detailItem.permissions.length
|
||||
? detailItem.permissions
|
||||
: listItem.permissions || []
|
||||
|
||||
return enrichEmployeeRecord(
|
||||
{
|
||||
...listItem,
|
||||
...detailItem,
|
||||
manager: detailItem.manager || listItem.manager,
|
||||
managerEmployeeNo: managerEmployeeNo || null,
|
||||
history,
|
||||
permissions,
|
||||
roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes,
|
||||
roles: detailItem.roles?.length ? detailItem.roles : listItem.roles,
|
||||
organization: detailItem.organization || listItem.organization,
|
||||
department: detailItem.department || listItem.department
|
||||
},
|
||||
roster
|
||||
)
|
||||
}
|
||||
|
||||
function buildEmployeeForm(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return createEmployeeForm()
|
||||
}
|
||||
|
||||
const birthDate = employee.birthDate || ''
|
||||
const managerName = employee.manager || ''
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
|
||||
return {
|
||||
name: employee.name || '',
|
||||
employeeNo: employee.employeeNo || '',
|
||||
gender: employee.gender || '',
|
||||
birthDate: employee.birthDate || '',
|
||||
age:
|
||||
employee.age !== null && employee.age !== undefined && employee.age !== ''
|
||||
? String(employee.age)
|
||||
: calculateAgeFromDate(birthDate),
|
||||
birthDate,
|
||||
phone: employee.phone || '',
|
||||
email: employee.email || '',
|
||||
joinDate: employee.joinDate || '',
|
||||
location: employee.location || '',
|
||||
position: employee.position || '',
|
||||
grade: employee.grade || '',
|
||||
department: employee.department || '',
|
||||
manager: employee.manager || '',
|
||||
department: resolveOrganizationUnitName(employee),
|
||||
organizationUnitCode: resolveOrganizationUnitCode(employee),
|
||||
manager: managerName,
|
||||
managerEmployeeNo,
|
||||
financeOwner: employee.financeOwner || '',
|
||||
costCenter: employee.costCenter || '',
|
||||
roleCodes: [...(employee.roleCodes || [])],
|
||||
@@ -154,6 +258,60 @@ function sameValues(left, right) {
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function formatEmployeeHistoryTime(value) {
|
||||
const raw = normalizeText(value)
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const matched = raw.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
|
||||
)
|
||||
if (!matched) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const year = Number.parseInt(matched[1], 10)
|
||||
const month = Number.parseInt(matched[2], 10)
|
||||
const day = Number.parseInt(matched[3], 10)
|
||||
const hour = Number.parseInt(matched[4] || '0', 10)
|
||||
const minute = Number.parseInt(matched[5] || '0', 10)
|
||||
const second = Number.parseInt(matched[6] || '0', 10)
|
||||
|
||||
return `${year}年${month}月${day}日${hour}时${minute}分${second}秒`
|
||||
}
|
||||
|
||||
function resolveOrganizationUnitCode(employee) {
|
||||
return normalizeText(employee?.organization?.code)
|
||||
}
|
||||
|
||||
function resolveOrganizationUnitName(employee) {
|
||||
return normalizeText(employee?.department) || normalizeText(employee?.organization?.name)
|
||||
}
|
||||
|
||||
function captureEmployeeDetailSnapshot(form) {
|
||||
return {
|
||||
roleCodes: [...(form.roleCodes || [])].sort(),
|
||||
organizationUnitCode: normalizeText(form.organizationUnitCode) || ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOrganizationOptions(metaOrganizations) {
|
||||
if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return metaOrganizations
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
unitType: item.unitType,
|
||||
label: `${item.name}(${item.code})`
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
}
|
||||
|
||||
function calculateAgeFromDate(dateString) {
|
||||
if (!dateString) {
|
||||
return ''
|
||||
@@ -177,6 +335,33 @@ function calculateAgeFromDate(dateString) {
|
||||
return age >= 0 ? String(age) : ''
|
||||
}
|
||||
|
||||
function calculateBirthDateFromAge(ageValue, existingBirthDate = '') {
|
||||
const age = Number.parseInt(String(ageValue ?? '').trim(), 10)
|
||||
if (Number.isNaN(age) || age < 0 || age > 120) {
|
||||
return existingBirthDate || ''
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
let month = '01'
|
||||
let day = '01'
|
||||
|
||||
if (existingBirthDate && isValidIsoDate(existingBirthDate)) {
|
||||
const [, monthText, dayText] = existingBirthDate.split('-')
|
||||
month = monthText
|
||||
day = dayText
|
||||
}
|
||||
|
||||
let birthYear = today.getFullYear() - age
|
||||
let candidate = `${birthYear}-${month}-${day}`
|
||||
|
||||
if (Number(calculateAgeFromDate(candidate)) > age) {
|
||||
birthYear -= 1
|
||||
candidate = `${birthYear}-${month}-${day}`
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function matchKeyword(employee, keyword) {
|
||||
if (!keyword) {
|
||||
return true
|
||||
@@ -249,6 +434,7 @@ export default {
|
||||
name: 'EmployeeManagementView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
emits: ['overview-change'],
|
||||
@@ -272,17 +458,37 @@ export default {
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const disableDialogOpen = ref(false)
|
||||
const importFileInput = ref(null)
|
||||
const pendingImportFile = ref(null)
|
||||
const importConfirmDialogOpen = ref(false)
|
||||
const importErrorDialogOpen = ref(false)
|
||||
const importErrors = ref([])
|
||||
const importResultMessage = ref('')
|
||||
const managerPickerOpen = ref(false)
|
||||
const managerSearchKeyword = ref('')
|
||||
const departmentPickerOpen = ref(false)
|
||||
const departmentSearchKeyword = ref('')
|
||||
const organizationUnitOptions = ref([])
|
||||
const employeeDetailSnapshot = ref(null)
|
||||
|
||||
const tabs = computed(() => buildStatusTabs(employees.value))
|
||||
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
|
||||
const detailAge = computed(() => calculateAgeFromDate(employeeForm.value.birthDate))
|
||||
const roleCount = computed(() => employeeForm.value.roleCodes.length)
|
||||
const selectedRoleLabels = computed(() =>
|
||||
roleOptions.value
|
||||
.filter((role) => employeeForm.value.roleCodes.includes(role.code))
|
||||
.map((role) => role.label)
|
||||
)
|
||||
const actionBusy = computed(() => actionState.value === 'save' || actionState.value === 'disable')
|
||||
const actionBusy = computed(
|
||||
() =>
|
||||
actionState.value === 'save' ||
|
||||
actionState.value === 'disable' ||
|
||||
actionState.value === 'import' ||
|
||||
actionState.value === 'export'
|
||||
)
|
||||
const importExportBusy = computed(
|
||||
() => actionState.value === 'import' || actionState.value === 'export'
|
||||
)
|
||||
const disableActionDisabled = computed(() => actionBusy.value || !selectedEmployee.value)
|
||||
const selectedEmployeeDisabled = computed(() => selectedEmployee.value?.status === '停用')
|
||||
const statusActionCopy = computed(() => {
|
||||
@@ -333,6 +539,94 @@ export default {
|
||||
)
|
||||
)
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const currentId = selectedEmployee.value?.id
|
||||
return employees.value.filter((item) => item.id !== currentId)
|
||||
})
|
||||
|
||||
const filteredManagerOptions = computed(() => {
|
||||
const keyword = managerSearchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return managerOptions.value.slice(0, 20)
|
||||
}
|
||||
|
||||
return managerOptions.value
|
||||
.filter((item) => {
|
||||
const haystack = [
|
||||
item.name,
|
||||
item.employeeNo,
|
||||
item.department,
|
||||
item.position,
|
||||
item.email
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
})
|
||||
.slice(0, 20)
|
||||
})
|
||||
|
||||
const managerDisplayLabel = computed(() => {
|
||||
const managerNo = normalizeText(employeeForm.value.managerEmployeeNo)
|
||||
const managerName = normalizeText(employeeForm.value.manager)
|
||||
|
||||
if (managerNo) {
|
||||
const matched =
|
||||
managerOptions.value.find((item) => item.employeeNo === managerNo) ||
|
||||
employees.value.find((item) => item.employeeNo === managerNo)
|
||||
|
||||
if (matched) {
|
||||
return `${matched.name}(${matched.employeeNo})`
|
||||
}
|
||||
|
||||
return managerName ? `${managerName}(${managerNo})` : managerNo
|
||||
}
|
||||
|
||||
if (!isPlaceholderManagerName(managerName)) {
|
||||
return managerName
|
||||
}
|
||||
|
||||
return '未设置直属上级'
|
||||
})
|
||||
|
||||
const filteredDepartmentOptions = computed(() => {
|
||||
const keyword = departmentSearchKeyword.value.trim().toLowerCase()
|
||||
const options = organizationUnitOptions.value
|
||||
|
||||
if (!keyword) {
|
||||
return options.slice(0, 20)
|
||||
}
|
||||
|
||||
return options
|
||||
.filter((item) => {
|
||||
const haystack = [item.name, item.code, item.unitType, item.label]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
})
|
||||
.slice(0, 20)
|
||||
})
|
||||
|
||||
const departmentDisplayLabel = computed(() => {
|
||||
const code = normalizeText(employeeForm.value.organizationUnitCode)
|
||||
const name = normalizeText(employeeForm.value.department)
|
||||
|
||||
if (code) {
|
||||
const matched = organizationUnitOptions.value.find((item) => item.code === code)
|
||||
if (matched) {
|
||||
return matched.label
|
||||
}
|
||||
|
||||
return name ? `${name}(${code})` : code
|
||||
}
|
||||
|
||||
return name || '请选择所属部门'
|
||||
})
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
@@ -431,14 +725,66 @@ export default {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function syncFormFromEmployee(employee) {
|
||||
if (!employee) {
|
||||
employeeForm.value = createEmployeeForm()
|
||||
employeeDetailSnapshot.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const preservedPassword = employeeForm.value.password
|
||||
employeeForm.value = buildEmployeeForm(employee, employees.value)
|
||||
employeeForm.value.password = preservedPassword
|
||||
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedEmployee,
|
||||
(employee) => {
|
||||
employeeForm.value = buildEmployeeForm(employee)
|
||||
() => selectedEmployee.value?.id ?? null,
|
||||
(employeeId, previousId) => {
|
||||
if (!employeeId) {
|
||||
syncFormFromEmployee(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (employeeId === previousId) {
|
||||
return
|
||||
}
|
||||
|
||||
syncFormFromEmployee(selectedEmployee.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(employees, () => {
|
||||
if (!selectedEmployee.value?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const preserved = selectedEmployee.value
|
||||
const fromList = employees.value.find((item) => item.id === preserved.id)
|
||||
if (!fromList) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
|
||||
})
|
||||
|
||||
const hasManagerAssignment = computed(() => {
|
||||
return (
|
||||
Boolean(normalizeText(employeeForm.value.managerEmployeeNo)) ||
|
||||
!isPlaceholderManagerName(employeeForm.value.manager)
|
||||
)
|
||||
})
|
||||
|
||||
const recentEmployeeHistory = computed(() => {
|
||||
const history = selectedEmployee.value?.history
|
||||
if (!Array.isArray(history)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return history.slice(0, 5)
|
||||
})
|
||||
|
||||
watch(filteredEmployees, () => {
|
||||
currentPage.value = 1
|
||||
pageSizeOpen.value = false
|
||||
@@ -510,15 +856,105 @@ export default {
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
if (!target.closest('.manager-picker')) {
|
||||
closeManagerPicker()
|
||||
}
|
||||
|
||||
if (!target.closest('.department-picker')) {
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
if (!target.closest('.page-size-wrap')) {
|
||||
pageSizeOpen.value = false
|
||||
}
|
||||
|
||||
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
|
||||
if (
|
||||
target.closest('.picker-filter') ||
|
||||
target.closest('.page-size-wrap') ||
|
||||
target.closest('.manager-picker') ||
|
||||
target.closest('.department-picker')
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDepartmentPicker() {
|
||||
departmentPickerOpen.value = !departmentPickerOpen.value
|
||||
if (!departmentPickerOpen.value) {
|
||||
departmentSearchKeyword.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeDepartmentPicker() {
|
||||
departmentPickerOpen.value = false
|
||||
departmentSearchKeyword.value = ''
|
||||
}
|
||||
|
||||
function selectDepartment(option) {
|
||||
if (!option) {
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.organizationUnitCode = option.code
|
||||
employeeForm.value.department = option.name
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
function resolveDepartmentSelectionFromKeyword() {
|
||||
const keyword = normalizeText(departmentSearchKeyword.value)
|
||||
if (!keyword || normalizeText(employeeForm.value.organizationUnitCode)) {
|
||||
return
|
||||
}
|
||||
|
||||
const exactMatches = organizationUnitOptions.value.filter(
|
||||
(item) => item.code === keyword || item.name === keyword
|
||||
)
|
||||
|
||||
if (exactMatches.length === 1) {
|
||||
selectDepartment(exactMatches[0])
|
||||
}
|
||||
}
|
||||
|
||||
function toggleManagerPicker() {
|
||||
managerPickerOpen.value = !managerPickerOpen.value
|
||||
if (!managerPickerOpen.value) {
|
||||
managerSearchKeyword.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeManagerPicker() {
|
||||
managerPickerOpen.value = false
|
||||
managerSearchKeyword.value = ''
|
||||
}
|
||||
|
||||
function selectManager(option) {
|
||||
if (!option) {
|
||||
employeeForm.value.managerEmployeeNo = ''
|
||||
employeeForm.value.manager = ''
|
||||
closeManagerPicker()
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.managerEmployeeNo = option.employeeNo
|
||||
employeeForm.value.manager = option.name
|
||||
closeManagerPicker()
|
||||
}
|
||||
|
||||
function resolveManagerSelectionFromKeyword() {
|
||||
const keyword = normalizeText(managerSearchKeyword.value)
|
||||
if (!keyword || normalizeText(employeeForm.value.managerEmployeeNo)) {
|
||||
return
|
||||
}
|
||||
|
||||
const exactMatches = managerOptions.value.filter(
|
||||
(item) => item.employeeNo === keyword || item.name === keyword
|
||||
)
|
||||
|
||||
if (exactMatches.length === 1) {
|
||||
selectManager(exactMatches[0])
|
||||
}
|
||||
}
|
||||
|
||||
function openEmployeeDetail(employee) {
|
||||
selectedEmployee.value = employee
|
||||
}
|
||||
@@ -527,6 +963,24 @@ export default {
|
||||
selectedEmployee.value = null
|
||||
employeeForm.value = createEmployeeForm()
|
||||
actionState.value = ''
|
||||
closeManagerPicker()
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
function syncAgeFromBirthDate() {
|
||||
employeeForm.value.age = calculateAgeFromDate(employeeForm.value.birthDate)
|
||||
}
|
||||
|
||||
function syncBirthDateFromAge() {
|
||||
const ageText = normalizeText(employeeForm.value.age)
|
||||
if (!ageText) {
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.birthDate = calculateBirthDateFromAge(
|
||||
ageText,
|
||||
employeeForm.value.birthDate
|
||||
)
|
||||
}
|
||||
|
||||
function buildUpdatePayload() {
|
||||
@@ -583,6 +1037,15 @@ export default {
|
||||
payload.grade = nextGrade
|
||||
}
|
||||
|
||||
const nextOrganizationCode = normalizeText(form.organizationUnitCode)
|
||||
const currentOrganizationCode =
|
||||
normalizeText(employeeDetailSnapshot.value?.organizationUnitCode) ||
|
||||
resolveOrganizationUnitCode(current) ||
|
||||
''
|
||||
if (nextOrganizationCode !== currentOrganizationCode) {
|
||||
payload.organization_unit_code = nextOrganizationCode
|
||||
}
|
||||
|
||||
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
|
||||
if (nextFinanceOwner !== (current.financeOwner || null)) {
|
||||
payload.finance_owner_name = nextFinanceOwner
|
||||
@@ -593,10 +1056,19 @@ export default {
|
||||
payload.cost_center = nextCostCenter
|
||||
}
|
||||
|
||||
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
|
||||
const currentManagerEmployeeNo =
|
||||
normalizeNullableText(current.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(current, employees.value) ||
|
||||
null
|
||||
if (nextManagerEmployeeNo !== currentManagerEmployeeNo) {
|
||||
payload.manager_employee_no = nextManagerEmployeeNo || ''
|
||||
}
|
||||
|
||||
const nextRoleCodes = [...form.roleCodes].sort()
|
||||
const currentRoleCodes = [...(current.roleCodes || [])].sort()
|
||||
const currentRoleCodes = [...(employeeDetailSnapshot.value?.roleCodes || current.roleCodes || [])].sort()
|
||||
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
|
||||
payload.role_codes = form.roleCodes
|
||||
payload.role_codes = [...form.roleCodes]
|
||||
}
|
||||
|
||||
const nextPassword = normalizeText(form.password)
|
||||
@@ -637,6 +1109,16 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const ageText = normalizeText(employeeForm.value.age)
|
||||
if (ageText) {
|
||||
const age = Number.parseInt(ageText, 10)
|
||||
if (Number.isNaN(age) || age < 0 || age > 120) {
|
||||
toast('年龄请输入 0 到 120 之间的整数。')
|
||||
return
|
||||
}
|
||||
syncBirthDateFromAge()
|
||||
}
|
||||
|
||||
const birthDate = normalizeNullableText(employeeForm.value.birthDate)
|
||||
if (birthDate && !isValidIsoDate(birthDate)) {
|
||||
toast('出生日期格式不正确,请使用 YYYY-MM-DD。')
|
||||
@@ -654,18 +1136,56 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
resolveManagerSelectionFromKeyword()
|
||||
resolveDepartmentSelectionFromKeyword()
|
||||
|
||||
if (!normalizeText(employeeForm.value.organizationUnitCode)) {
|
||||
toast('请选择所属部门。')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
toast('未检测到需要保存的变更。')
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizeText(employeeForm.value.managerEmployeeNo) === selectedEmployee.value.employeeNo) {
|
||||
toast('直属上级不能设置为员工本人。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'save'
|
||||
|
||||
try {
|
||||
const updated = await updateEmployee(selectedEmployee.value.id, payload)
|
||||
const employeeId = selectedEmployee.value.id
|
||||
const updated = await updateEmployee(employeeId, payload)
|
||||
selectedEmployee.value = updated
|
||||
await loadEmployees()
|
||||
|
||||
let refreshed = updated
|
||||
try {
|
||||
refreshed = await fetchEmployeeDetail(employeeId)
|
||||
} catch {
|
||||
refreshed = updated
|
||||
}
|
||||
|
||||
const fromList = employees.value.find((item) => item.id === employeeId)
|
||||
const merged = mergeEmployeeRecords(fromList, refreshed, employees.value)
|
||||
selectedEmployee.value = merged
|
||||
|
||||
const listIndex = employees.value.findIndex((item) => item.id === employeeId)
|
||||
if (listIndex >= 0) {
|
||||
employees.value[listIndex] = {
|
||||
...employees.value[listIndex],
|
||||
...merged
|
||||
}
|
||||
}
|
||||
|
||||
closeManagerPicker()
|
||||
closeDepartmentPicker()
|
||||
syncFormFromEmployee(selectedEmployee.value)
|
||||
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
|
||||
toast('员工信息已保存并生效。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工信息保存失败,请稍后重试。')
|
||||
@@ -723,6 +1243,95 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openImportFilePicker() {
|
||||
importFileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleImportFileChange(event) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingImportFile.value = file
|
||||
importConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeImportConfirmDialog() {
|
||||
if (actionState.value === 'import') {
|
||||
return
|
||||
}
|
||||
|
||||
importConfirmDialogOpen.value = false
|
||||
pendingImportFile.value = null
|
||||
}
|
||||
|
||||
function closeImportErrorDialog() {
|
||||
importErrorDialogOpen.value = false
|
||||
importErrors.value = []
|
||||
importResultMessage.value = ''
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await downloadEmployeeImportTemplate()
|
||||
toast('员工导入模板已开始下载。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '模板下载失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportEmployees() {
|
||||
actionState.value = 'export'
|
||||
|
||||
try {
|
||||
await exportEmployees({
|
||||
status: activeTab.value,
|
||||
keyword: searchKeyword.value.trim()
|
||||
})
|
||||
toast('员工目录已开始导出。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工导出失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImportEmployees() {
|
||||
const file = pendingImportFile.value
|
||||
if (!file) {
|
||||
closeImportConfirmDialog()
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'import'
|
||||
|
||||
try {
|
||||
const result = await importEmployees(file)
|
||||
|
||||
if (!result?.success) {
|
||||
importErrors.value = Array.isArray(result?.errors) ? result.errors : []
|
||||
importResultMessage.value =
|
||||
result?.message || '导入未执行,请根据下方错误提示修正 Excel 后重试。'
|
||||
importConfirmDialogOpen.value = false
|
||||
importErrorDialogOpen.value = true
|
||||
pendingImportFile.value = null
|
||||
return
|
||||
}
|
||||
|
||||
importConfirmDialogOpen.value = false
|
||||
pendingImportFile.value = null
|
||||
await loadEmployees()
|
||||
toast(result.message || '员工导入成功。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工导入失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -735,6 +1344,7 @@ export default {
|
||||
if (employeesResult.status !== 'fulfilled') {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
organizationUnitOptions.value = []
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value =
|
||||
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
|
||||
@@ -742,12 +1352,17 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
const roster = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
employees.value = roster.map((item) => enrichEmployeeRecord(item, roster))
|
||||
|
||||
if (metaResult.status === 'fulfilled') {
|
||||
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
|
||||
organizationUnitOptions.value = resolveOrganizationOptions(
|
||||
metaResult.value?.organizationOptions
|
||||
)
|
||||
} else {
|
||||
roleOptions.value = resolveRoleOptions([], employees.value)
|
||||
organizationUnitOptions.value = []
|
||||
}
|
||||
|
||||
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
|
||||
@@ -755,8 +1370,9 @@ export default {
|
||||
}
|
||||
|
||||
if (selectedEmployee.value) {
|
||||
selectedEmployee.value =
|
||||
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
|
||||
const preserved = selectedEmployee.value
|
||||
const fromList = employees.value.find((item) => item.id === preserved.id) || null
|
||||
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
@@ -767,6 +1383,7 @@ export default {
|
||||
loadEmployees().catch((error) => {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
organizationUnitOptions.value = []
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
|
||||
loading.value = false
|
||||
@@ -781,13 +1398,27 @@ export default {
|
||||
tabs,
|
||||
activeTab,
|
||||
employeeForm,
|
||||
detailAge,
|
||||
roleCount,
|
||||
syncAgeFromBirthDate,
|
||||
syncBirthDateFromAge,
|
||||
selectedRoleLabels,
|
||||
selectedEmployeeDisabled,
|
||||
statusActionCopy,
|
||||
actionState,
|
||||
actionBusy,
|
||||
importExportBusy,
|
||||
importFileInput,
|
||||
importConfirmDialogOpen,
|
||||
importErrorDialogOpen,
|
||||
importErrors,
|
||||
importResultMessage,
|
||||
openImportFilePicker,
|
||||
handleImportFileChange,
|
||||
closeImportConfirmDialog,
|
||||
closeImportErrorDialog,
|
||||
handleDownloadTemplate,
|
||||
handleExportEmployees,
|
||||
confirmImportEmployees,
|
||||
disableActionDisabled,
|
||||
selectedEmployee,
|
||||
roleOptions,
|
||||
@@ -806,6 +1437,25 @@ export default {
|
||||
departmentOptions,
|
||||
gradeOptions,
|
||||
roleFilterOptions,
|
||||
managerPickerOpen,
|
||||
managerSearchKeyword,
|
||||
managerDisplayLabel,
|
||||
hasManagerAssignment,
|
||||
departmentPickerOpen,
|
||||
departmentSearchKeyword,
|
||||
departmentDisplayLabel,
|
||||
filteredDepartmentOptions,
|
||||
toggleDepartmentPicker,
|
||||
closeDepartmentPicker,
|
||||
selectDepartment,
|
||||
resolveDepartmentSelectionFromKeyword,
|
||||
recentEmployeeHistory,
|
||||
formatEmployeeHistoryTime,
|
||||
filteredManagerOptions,
|
||||
toggleManagerPicker,
|
||||
closeManagerPicker,
|
||||
selectManager,
|
||||
resolveManagerSelectionFromKeyword,
|
||||
activeFilterTokens,
|
||||
hasActiveFilters,
|
||||
hasEmployeeFilters,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
|
||||
import DonutChart from '../../components/charts/DonutChart.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
@@ -217,10 +218,11 @@ function buildTrendSeries(runs) {
|
||||
|
||||
export default {
|
||||
name: 'LogsView',
|
||||
components: {
|
||||
LogTrendChart,
|
||||
DonutChart
|
||||
},
|
||||
components: {
|
||||
LogTrendChart,
|
||||
DonutChart,
|
||||
TableLoadingState
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
@@ -79,10 +80,11 @@ function setBodyScrollLocked(isLocked) {
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const { currentUser } = useSystemState()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
@@ -11,6 +12,7 @@ function extractRowDate(value) {
|
||||
export default {
|
||||
name: 'RequestsView',
|
||||
components: {
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
props: {
|
||||
|
||||
@@ -43,6 +43,24 @@ const INTENT_LABELS = {
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
const REVIEW_RISK_LEVEL_META = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
||||
},
|
||||
warning: {
|
||||
label: '需关注',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||
},
|
||||
info: {
|
||||
label: '提示',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
|
||||
}
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
travel_ticket: '行程单/机票/车票',
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
@@ -1503,7 +1521,7 @@ function buildDraftSavedPayload({
|
||||
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
|
||||
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
|
||||
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
|
||||
riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
attachmentSummary,
|
||||
expenseTableSummary: documents.length
|
||||
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
|
||||
@@ -2451,16 +2469,43 @@ function buildReviewRiskSummary(reviewPayload) {
|
||||
return '当前版本暂未生成风险评分结果。'
|
||||
}
|
||||
|
||||
function normalizeReviewRiskLevel(level) {
|
||||
const normalized = String(level || '').trim().toLowerCase()
|
||||
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
|
||||
if (normalized === 'warn' || normalized === 'medium') return 'warning'
|
||||
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function buildReviewRiskItems(reviewPayload) {
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief) => {
|
||||
.map((brief, index) => {
|
||||
const title = String(brief?.title || '').trim()
|
||||
const content = String(brief?.content || '').trim()
|
||||
if (title && content) return `${title}:${content}`
|
||||
return content || title
|
||||
const detail = String(brief?.detail || '').trim()
|
||||
const suggestion = String(brief?.suggestion || '').trim()
|
||||
const level = normalizeReviewRiskLevel(brief?.level)
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
|
||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
||||
const normalizedTitle = title || fallbackTitle
|
||||
const summary = content || normalizedTitle
|
||||
|
||||
if (!normalizedTitle && !summary) return null
|
||||
|
||||
return {
|
||||
key: `${level}-${normalizedTitle}-${index}`,
|
||||
title: normalizedTitle,
|
||||
summary,
|
||||
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
|
||||
level,
|
||||
levelLabel: meta.label,
|
||||
icon: meta.icon,
|
||||
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
|
||||
suggestion: suggestion || meta.suggestion
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.slice(0, 6)
|
||||
}
|
||||
|
||||
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
|
||||
@@ -2904,6 +2949,7 @@ export default {
|
||||
const composerRangeStartDate = ref(formatDateInputValue())
|
||||
const composerRangeEndDate = ref(formatDateInputValue())
|
||||
const composerBusinessTimeTags = ref([])
|
||||
const composerBusinessTimeDraftTouched = ref(false)
|
||||
const attachedFiles = ref([])
|
||||
const composerFilesExpanded = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -2947,6 +2993,10 @@ export default {
|
||||
const activeReviewDocumentIndex = ref(0)
|
||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const reviewRiskDetailDialog = ref({
|
||||
open: false,
|
||||
item: null
|
||||
})
|
||||
const documentPreviewDialog = ref({
|
||||
open: false,
|
||||
filename: '',
|
||||
@@ -3107,7 +3157,6 @@ export default {
|
||||
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
|
||||
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
|
||||
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
|
||||
const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0)
|
||||
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
|
||||
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
@@ -3932,6 +3981,91 @@ export default {
|
||||
return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
function hasComposerBusinessTimeSelection() {
|
||||
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
|
||||
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
|
||||
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayValue = mode === 'range' && startDate !== endDate
|
||||
? `${startDate} 至 ${endDate}`
|
||||
: startDate
|
||||
return {
|
||||
mode,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
occurred_date: startDate,
|
||||
time_range: displayValue,
|
||||
business_time: displayValue,
|
||||
time_range_raw: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
|
||||
if (!businessTimeContext) {
|
||||
return extraContext
|
||||
}
|
||||
|
||||
const baseReviewFormValues =
|
||||
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||||
? extraContext.review_form_values
|
||||
: {}
|
||||
|
||||
return {
|
||||
...extraContext,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
business_time: businessTimeContext.business_time,
|
||||
business_time_context: {
|
||||
mode: businessTimeContext.mode,
|
||||
start_date: businessTimeContext.start_date,
|
||||
end_date: businessTimeContext.end_date,
|
||||
display_value: businessTimeContext.business_time
|
||||
},
|
||||
review_form_values: {
|
||||
...baseReviewFormValues,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
time_range: businessTimeContext.time_range,
|
||||
business_time: businessTimeContext.business_time,
|
||||
time_range_raw: businessTimeContext.time_range_raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
|
||||
if (!businessTimeContext || !activeReviewPayload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextInlineState = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: businessTimeContext.occurred_date
|
||||
}
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
|
||||
reviewInlineForm.value = nextInlineState
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveComposerSubmitText(explicitRawText) {
|
||||
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
|
||||
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',')
|
||||
@@ -3956,8 +4090,16 @@ export default {
|
||||
composerDateMode.value = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange() {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
}
|
||||
|
||||
function removeComposerBusinessTimeTag(tagId) {
|
||||
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
|
||||
if (!composerBusinessTimeTags.value.length) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerDatePickerOutside(event) {
|
||||
@@ -3975,12 +4117,14 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
@@ -4432,13 +4576,19 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
function explainCurrentReviewRisk() {
|
||||
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
||||
submitComposer({
|
||||
rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
|
||||
userText: '查看全部风险项',
|
||||
systemGenerated: true
|
||||
})
|
||||
function openReviewRiskDetail(item) {
|
||||
if (!item) return
|
||||
reviewRiskDetailDialog.value = {
|
||||
open: true,
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
function closeReviewRiskDetail() {
|
||||
reviewRiskDetailDialog.value = {
|
||||
...reviewRiskDetailDialog.value,
|
||||
open: false
|
||||
}
|
||||
}
|
||||
|
||||
function goReviewDocument(direction) {
|
||||
@@ -4642,9 +4792,13 @@ export default {
|
||||
}
|
||||
if (!rawText && !files.length) return
|
||||
|
||||
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
@@ -4699,6 +4853,7 @@ export default {
|
||||
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
@@ -4769,6 +4924,12 @@ export default {
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
employee_no: user.employeeNo || user.employee_no || '',
|
||||
manager_name: user.managerName || user.manager_name || '',
|
||||
employee_location: user.location || '',
|
||||
cost_center: user.costCenter || user.cost_center || '',
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
@@ -4802,16 +4963,6 @@ export default {
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。')
|
||||
}
|
||||
}
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
@@ -4832,6 +4983,14 @@ export default {
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
syncComposerFilesToDraft(resolvedDraftClaimId, files).catch((error) => {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
@@ -5144,6 +5303,7 @@ export default {
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
handleComposerDateInputChange,
|
||||
removeComposerBusinessTimeTag,
|
||||
flowSteps,
|
||||
flowRunId,
|
||||
@@ -5213,7 +5373,7 @@ export default {
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewRiskActionAvailable,
|
||||
reviewRiskDetailDialog,
|
||||
recognizedNarratives,
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
@@ -5298,7 +5458,8 @@ export default {
|
||||
selectReviewCategory,
|
||||
selectReviewOtherCategory,
|
||||
queryDraftByClaimNo,
|
||||
explainCurrentReviewRisk,
|
||||
openReviewRiskDetail,
|
||||
closeReviewRiskDetail,
|
||||
goReviewDocument,
|
||||
openActiveReviewDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import {
|
||||
@@ -9,10 +10,12 @@ import {
|
||||
deleteExpenseClaim,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimItemAttachmentPreview,
|
||||
returnExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
@@ -380,6 +383,7 @@ export default {
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
setup(props, { emit }) {
|
||||
const { toast } = useToast()
|
||||
const { currentUser } = useSystemState()
|
||||
const editingExpenseId = ref('')
|
||||
const savingExpenseId = ref('')
|
||||
const creatingExpense = ref(false)
|
||||
@@ -390,6 +394,8 @@ export default {
|
||||
const submitBusy = ref(false)
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const returnBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const expenseUploadInput = ref(null)
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
@@ -448,10 +454,25 @@ export default {
|
||||
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const canDeleteRequest = computed(() => isDraftRequest.value || canManageCurrentClaim.value)
|
||||
const canReturnRequest = computed(() =>
|
||||
canManageCurrentClaim.value
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
isDraftRequest.value
|
||||
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
||||
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
|
||||
)
|
||||
const actionBusy = computed(() =>
|
||||
Boolean(savingExpenseId.value)
|
||||
|| submitBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| creatingExpense.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
@@ -1105,9 +1126,14 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDraft() {
|
||||
async function handleDeleteRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canDeleteRequest.value) {
|
||||
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1122,9 +1148,9 @@ export default {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmDeleteDraft() {
|
||||
async function confirmDeleteRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1132,15 +1158,58 @@ export default {
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(request.value.claimId)
|
||||
deleteDialogOpen.value = false
|
||||
toast(payload?.message || `${request.value.id} 草稿已删除。`)
|
||||
toast(payload?.message || `${request.value.id} 报销单已删除。`)
|
||||
emit('request-deleted', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '删除草稿失败,请稍后重试。')
|
||||
toast(error?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
deleteBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReturnRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canReturnRequest.value) {
|
||||
toast('当前状态不支持退回。')
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (returnBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmReturnRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
}
|
||||
|
||||
returnBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(request.value.claimId, {
|
||||
reason: '详情页退回,请申请人补充后重新提交。'
|
||||
})
|
||||
returnDialogOpen.value = false
|
||||
toast(`${request.value.id} 已退回待补充。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
returnBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
@@ -1164,14 +1233,22 @@ export default {
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
confirmDeleteDraft,
|
||||
closeReturnDialog,
|
||||
confirmDeleteRequest,
|
||||
confirmReturnRequest,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
@@ -1186,8 +1263,9 @@ export default {
|
||||
expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleDeleteDraft,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
hasExpenseRiskColumn,
|
||||
heroFactItems,
|
||||
@@ -1205,6 +1283,8 @@ export default {
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
|
||||
Reference in New Issue
Block a user