Files
X-Financial/web/src/views/scripts/BudgetCenterView.js
caoxiaozhu ca691f3ee0 feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
2026-06-02 14:01:51 +08:00

459 lines
15 KiB
JavaScript

import { computed, onMounted, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
import {
canEditBudgetCenter,
canSwitchBudgetDepartments,
isBudgetMonitorUser,
isExecutiveUser
} from '../../utils/accessControl.js'
import {
BUDGET_QUARTER_OPTIONS,
BUDGET_YEAR_OPTIONS
} from '../../utils/budgetOntology.js'
import {
BUDGET_PAGE_SIZE_OPTIONS,
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
buildBudgetRows,
buildBudgetScopeTabs,
buildBudgetUsageData,
getBudgetStatusOptions,
matchesBudgetKeyword
} from './budgetCenterListModel.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
{ code: 'FINANCE-DEPT', name: '财务部', costCenter: 'CC-2100' },
{ code: 'TECH-DEPT', name: '技术部', costCenter: 'CC-6100' },
{ code: 'HR-DEPT', name: '人力资源部', costCenter: 'CC-3200' },
{ code: 'PRODUCTION-DEPT', name: '生产部', costCenter: 'CC-7200' },
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
function mapOptions(values, suffix = '') {
return values.map((value) => ({
label: suffix ? `${value}${suffix}` : value,
value
}))
}
function resolveBudgetUpdatedAt(row) {
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
}
function resolveBudgetCompiler(row) {
return row?.compiler || row?.owner || '-'
}
function buildBudgetDetailKpis(row) {
return [
{
label: '编制人',
value: resolveBudgetCompiler(row),
unit: '',
meta: row.scope === BUDGET_SCOPE_REVIEW ? '提交草案' : '预算编制',
color: 'var(--theme-primary)'
},
{
label: '审核人',
value: row.reviewer || '-',
unit: '',
meta: '高级财务审核',
color: '#3b82f6'
},
{
label: '版本',
value: row.version || '-',
unit: '',
meta: row.periodType || '预算版本',
color: '#64748b'
},
{
label: '更新时间',
value: resolveBudgetUpdatedAt(row),
unit: '',
meta: '最近同步',
color: '#f59e0b'
}
]
}
export default {
name: 'BudgetCenterView',
props: {
currentUser: {
type: Object,
default: () => ({})
}
},
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
TableEmptyState,
TableLoadingState,
ElButton
},
setup(props, { emit }) {
const departments = ref(FALLBACK_DEPARTMENTS)
const activeBudgetScope = ref(BUDGET_SCOPE_ALL)
const budgetKeyword = ref('')
const budgetPage = ref(1)
const budgetPageSize = ref(8)
const budgetLoading = ref(true)
const budgetError = ref('')
const selectedBudgetId = ref('')
const filters = ref({
year: '2026',
quarter: 'Q1',
status: '全部'
})
const canEditBudget = computed(() =>
canEditBudgetCenter(props.currentUser) || isBudgetMonitorUser(props.currentUser)
)
const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser))
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
const isDepartmentBudgetMonitor = computed(
() => isBudgetMonitorUser(props.currentUser) && !canSwitchDepartments.value && !isExecutiveUser(props.currentUser)
)
const currentUserDepartmentName = computed(() =>
String(props.currentUser?.departmentName || props.currentUser?.department || '').trim()
)
const currentUserCostCenter = computed(() =>
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
)
const yearOptions = mapOptions(BUDGET_YEAR_OPTIONS, '年度')
const quarterOptions = mapOptions(BUDGET_QUARTER_OPTIONS)
const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size }))
const budgetRowsByScope = computed(() =>
buildBudgetRows({
departments: departments.value,
year: filters.value.year,
quarter: filters.value.quarter
})
)
const budgetScopeTabs = computed(() =>
buildBudgetScopeTabs(budgetRowsByScope.value)
.filter((tab) => canAuditBudgetDrafts.value || tab.value !== BUDGET_SCOPE_REVIEW)
)
const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || [])
const activeScopeLabel = computed(
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
)
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
const filteredBudgetRows = computed(() =>
activeScopeRows.value
.filter((row) => filters.value.status === '全部' || row.statusLabel === filters.value.status)
.filter((row) => matchesBudgetKeyword(row, budgetKeyword.value))
)
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
const totalBudgetPages = computed(() =>
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 8)))
)
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
)
const budgetPageNumbers = computed(() =>
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 8)
const start = (currentBudgetPage.value - 1) * pageSize
return filteredBudgetRows.value.slice(start, start + pageSize)
})
const selectedBudget = computed(() =>
activeScopeRows.value.find((row) => row.id === selectedBudgetId.value) || null
)
const detailMode = computed(() => Boolean(selectedBudget.value))
const selectedBudgetUsageData = computed(() => buildBudgetUsageData(selectedBudget.value))
const budgetDetailTopBarPayload = computed(() => {
const row = selectedBudget.value
if (!row) return null
return {
view: {
eyebrow: '预算详情',
title: `${row.departmentName} · ${row.periodLabel}`,
desc: `${row.budgetNo} / ${row.version} · 仅覆盖差旅、通信、招待费、办公用品`
},
alerts: [],
kpis: buildBudgetDetailKpis(row)
}
})
const selectedBudgetStatusNotes = computed(() => {
const row = selectedBudget.value
if (!row) return []
return [
{
label: '预算状态',
value: row.statusLabel,
tone: row.statusTone || 'ok',
desc: row.auditSummary || '当前预算状态已完成同步,可在预算中心继续追踪。'
},
{
label: '风险状态',
value: row.riskLabel,
tone: row.riskTone || 'ok',
desc: `当前已发生与已占用合计使用率为 ${row.usageRateLabel},系统按四类费用的提醒、告警和风险阈值综合判断。`
}
]
})
const showTable = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length > 0)
const showEmpty = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length === 0)
const emptyState = computed(() => ({
eyebrow: activeScopeLabel.value,
title: `暂无${activeScopeLabel.value}`,
desc: '当前筛选条件下没有匹配的预算记录。',
icon: 'mdi mdi-database-search-outline',
tone: 'blue',
artLabel: '预算列表为空',
tips: ['可以调整年度、季度、状态或关键词后重试。']
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
function buildBudgetAssistantContext(row, mode = 'edit') {
if (!row) return null
return {
mode,
budgetNo: row.budgetNo,
departmentCode: row.departmentCode,
departmentName: row.departmentName,
costCenter: row.costCenter,
periodLabel: row.periodLabel,
periodType: row.periodType,
budgetYear: row.budgetYear,
budgetQuarter: row.budgetQuarter,
version: row.version,
compiler: row.compiler || row.owner,
reviewer: row.reviewer,
submittedAt: row.submittedAt,
requestedAmount: row.requestedAmount || row.quarterAmount,
previousAmount: row.quarterAmount,
categoryRows: Array.isArray(row.categoryRows)
? row.categoryRows.map((item) => ({ ...item }))
: []
}
}
function resolveEditableBudgetRow() {
const allRows = budgetRowsByScope.value[BUDGET_SCOPE_ALL] || []
if (isDepartmentBudgetMonitor.value) {
return allRows.find((row) => (
row.scope === BUDGET_SCOPE_ALL &&
(
(currentUserCostCenter.value && row.costCenter === currentUserCostCenter.value) ||
(currentUserDepartmentName.value && row.departmentName === currentUserDepartmentName.value)
)
)) || allRows[0] || null
}
return allRows.find((row) => row.scope === BUDGET_SCOPE_ALL) || allRows[0] || null
}
function openBudgetAssistant(prompt = '', budgetContext = null) {
if (!canEditBudget.value) return
const context = budgetContext || buildBudgetAssistantContext(resolveEditableBudgetRow(), 'edit')
emit('openAssistant', {
source: 'budget',
sessionType: 'budget',
prompt: prompt || (
context?.departmentName
? `编辑${context.departmentName}${context.periodLabel || ''}预算`
: '编辑本部门预算'
),
files: [],
conversation: null,
budgetContext: context
})
}
function openBudgetReviewAssistant(row) {
if (!row || !canAuditBudgetDrafts.value) {
openBudgetDetail(row)
return
}
openBudgetAssistant(
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`,
buildBudgetAssistantContext(row, 'review')
)
}
function openBudgetDetail(row) {
if (!row?.id) return
selectedBudgetId.value = row.id
}
function backToList() {
selectedBudgetId.value = ''
}
function handleRowAction(row) {
if (activeBudgetScope.value === BUDGET_SCOPE_REVIEW && canAuditBudgetDrafts.value) {
openBudgetReviewAssistant(row)
return
}
openBudgetDetail(row)
}
function goToBudgetPage(page) {
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
}
function changeBudgetPageSize(size) {
budgetPageSize.value = Number(size) || 8
budgetPage.value = 1
}
function resolveScopedDepartments(options) {
if (!isDepartmentBudgetMonitor.value) return options
const userDepartment = currentUserDepartmentName.value
const userCostCenter = currentUserCostCenter.value
const scoped = options.filter((item) => {
if (userCostCenter && item.costCenter === userCostCenter) return true
return userDepartment && item.name === userDepartment
})
if (scoped.length) return scoped
return [
{
id: '',
code: userCostCenter || userDepartment || 'CURRENT-DEPARTMENT',
name: userDepartment || '当前部门',
costCenter: userCostCenter
}
]
}
async function loadDepartments() {
budgetLoading.value = true
budgetError.value = ''
try {
const payload = await fetchEmployeeMeta()
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
const nextDepartments = options
.filter((item) => item?.code && item?.name)
.map((item) => ({
id: String(item.id || ''),
code: String(item.code),
name: String(item.name),
costCenter: String(item.costCenter || '')
}))
const scopedDepartments = resolveScopedDepartments(nextDepartments)
if (scopedDepartments.length) {
departments.value = scopedDepartments
}
} catch (error) {
console.warn('Failed to load budget departments from employee meta:', error)
} finally {
budgetLoading.value = false
}
}
onMounted(() => {
void loadDepartments()
})
watch(
() => activeBudgetScope.value,
() => {
filters.value.status = '全部'
budgetPage.value = 1
selectedBudgetId.value = ''
}
)
watch(canAuditBudgetDrafts, (allowed) => {
if (!allowed && activeBudgetScope.value === BUDGET_SCOPE_REVIEW) {
activeBudgetScope.value = BUDGET_SCOPE_ALL
}
}, { immediate: true })
watch(
[
budgetPageSize,
budgetKeyword,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.status
],
() => {
budgetPage.value = 1
}
)
watch(totalBudgetPages, (pages) => {
if (budgetPage.value > pages) {
budgetPage.value = pages
}
})
watch(detailMode, (value) => {
emit('detail-open-change', value)
}, { immediate: true })
watch(budgetDetailTopBarPayload, (payload) => {
emit('detail-topbar-change', payload)
}, { immediate: true, deep: true })
return {
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
activeBudgetScope,
budgetError,
budgetKeyword,
budgetLoading,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions,
budgetScopeTabs,
backToList,
canAuditBudgetDrafts,
canEditBudget,
changeBudgetPageSize,
detailMode,
emptyState,
filters,
goToBudgetPage,
handleRowAction,
openBudgetAssistant,
openBudgetDetail,
openBudgetReviewAssistant,
pageSummary,
quarterOptions,
selectedBudget,
selectedBudgetStatusNotes,
selectedBudgetUsageData,
showEmpty,
showTable,
statusOptions,
totalBudgetPages,
totalBudgetRows,
visibleBudgetRows,
yearOptions
}
}
}