feat: 增强员工管理与报销单全流程功能

- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
caoxiaozhu
2026-05-20 14:21:56 +08:00
parent 57957d11a0
commit d7e98a58b9
46 changed files with 4022 additions and 305 deletions

View File

@@ -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
}
}

View File

@@ -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'],

View File

@@ -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,

View File

@@ -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()

View File

@@ -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()

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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,