feat: 同步报销流程与工作台改动

This commit is contained in:
caoxiaozhu
2026-06-09 08:32:00 +00:00
parent e124e4bbcb
commit 25724c354f
64 changed files with 6518 additions and 687 deletions

View File

@@ -3,7 +3,7 @@
:model-value="visible"
append-to-body
align-center
width="min(1040px, calc(100vw - 48px))"
width="min(960px, calc(100vw - 64px))"
:show-close="false"
:lock-scroll="true"
destroy-on-close
@@ -277,6 +277,8 @@ watch(
}
:global(.expense-profile-dialog.el-dialog) {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px;
@@ -380,7 +382,8 @@ watch(
}
.profile-dialog-content {
max-height: min(660px, calc(100vh - 190px));
max-height: min(580px, calc(100vh - 176px));
max-height: min(580px, calc(100dvh - 176px));
min-height: 0;
display: grid;
gap: 12px;
@@ -469,7 +472,7 @@ watch(
.profile-analysis-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.85fr);
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
gap: 12px;
}
@@ -483,13 +486,13 @@ watch(
.profile-tags-panel {
grid-template-rows: auto minmax(0, 1fr);
align-content: stretch;
min-height: 352px;
min-height: 312px;
}
.profile-radar-panel {
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: stretch;
min-height: 352px;
min-height: 312px;
}
.profile-section-title {
@@ -554,11 +557,11 @@ watch(
}
.profile-tags-panel > .profile-panel-empty {
min-height: 284px;
min-height: 244px;
}
.profile-radar-empty {
min-height: 308px;
min-height: 268px;
}
.profile-operation-copy strong {
@@ -580,13 +583,13 @@ watch(
grid-template-columns: minmax(0, 1fr);
align-items: center;
justify-items: stretch;
min-height: 360px;
min-height: 300px;
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
}
.profile-radar-chart {
width: 100%;
height: 360px;
height: 300px;
}
.profile-behavior-tags {
@@ -703,6 +706,97 @@ watch(
justify-self: end;
}
@media (min-width: 861px) and (max-width: 1440px),
(min-width: 861px) and (max-height: 820px) {
:global(.expense-profile-dialog.el-dialog) {
width: min(900px, calc(100vw - 96px)) !important;
max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
}
.profile-dialog-header,
.profile-dialog-footer {
gap: 12px;
padding: 12px 16px;
}
.profile-dialog-header h2 {
margin: 2px 0 3px;
font-size: 17px;
}
.profile-dialog-header p,
.profile-dialog-footer span {
font-size: 11.5px;
}
.profile-dialog-content {
max-height: min(520px, calc(100vh - 152px));
max-height: min(520px, calc(100dvh - 152px));
gap: 10px;
padding: 12px;
}
.profile-summary-grid,
.profile-analysis-grid {
gap: 8px;
}
.profile-summary-item {
gap: 3px;
padding: 8px 10px;
}
.profile-summary-item strong {
font-size: 16px;
}
.profile-panel {
gap: 8px;
padding: 10px;
}
.profile-analysis-grid {
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr);
}
.profile-tags-panel,
.profile-radar-panel {
min-height: 272px;
}
.profile-tags-panel > .profile-panel-empty {
min-height: 210px;
}
.profile-radar-empty {
min-height: 220px;
}
.profile-radar-layout {
min-height: 248px;
}
.profile-radar-chart {
height: 248px;
}
.profile-behavior-tags {
gap: 6px;
min-height: 50px;
padding-top: 8px;
}
.profile-operation-list {
gap: 6px;
}
.profile-operation-row {
gap: 8px;
padding: 7px 0;
}
}
@keyframes expenseProfileDialogIn {
0% {
opacity: 0;

View File

@@ -3,7 +3,7 @@
:model-value="visible"
append-to-body
align-center
width="min(980px, calc(100vw - 48px))"
width="min(900px, calc(100vw - 64px))"
:show-close="false"
:lock-scroll="true"
destroy-on-close
@@ -256,6 +256,8 @@ function resolveTagType(tone) {
}
:global(.expense-stats-detail-dialog.el-dialog) {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px;
@@ -353,7 +355,9 @@ function resolveTagType(tone) {
}
.expense-stats-detail-content {
max-height: min(660px, calc(100vh - 190px));
max-height: min(580px, calc(100vh - 176px));
max-height: min(580px, calc(100dvh - 176px));
min-height: 0;
display: grid;
gap: 12px;
padding: 14px;
@@ -372,7 +376,7 @@ function resolveTagType(tone) {
}
.expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(360px, 1.1fr);
grid-template-columns: minmax(0, 0.92fr) minmax(320px, 1.08fr);
}
.expense-stats-summary-item,
@@ -432,7 +436,7 @@ function resolveTagType(tone) {
.expense-stats-distribution-panel,
.expense-stats-processing-panel {
min-height: 336px;
min-height: 300px;
}
.expense-stats-section-title {
@@ -483,17 +487,17 @@ function resolveTagType(tone) {
}
.expense-distribution-chart {
min-height: 286px;
min-height: 250px;
display: grid;
align-items: stretch;
}
.expense-distribution-chart-layout {
display: grid;
grid-template-columns: minmax(170px, 0.86fr) minmax(0, 1.14fr);
grid-template-columns: minmax(160px, 0.86fr) minmax(0, 1.14fr);
align-items: center;
gap: 12px;
min-height: 286px;
min-height: 250px;
}
.expense-distribution-donut {
@@ -501,7 +505,7 @@ function resolveTagType(tone) {
}
.expense-distribution-donut :deep(.donut-body) {
height: 220px;
height: 192px;
margin-top: 0;
}
@@ -619,7 +623,7 @@ function resolveTagType(tone) {
.expense-stats-empty {
margin: 0;
min-height: 180px;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
@@ -632,6 +636,97 @@ function resolveTagType(tone) {
text-align: center;
}
@media (min-width: 861px) and (max-width: 1440px),
(min-width: 861px) and (max-height: 820px) {
:global(.expense-stats-detail-dialog.el-dialog) {
width: min(860px, calc(100vw - 96px)) !important;
max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
}
.expense-stats-detail-header,
.expense-stats-detail-footer {
gap: 12px;
padding: 12px 16px;
}
.expense-stats-detail-header h2 {
margin: 2px 0 3px;
font-size: 17px;
}
.expense-stats-detail-header p,
.expense-stats-detail-footer span {
font-size: 11.5px;
}
.expense-stats-detail-content {
max-height: min(520px, calc(100vh - 152px));
max-height: min(520px, calc(100dvh - 152px));
gap: 10px;
padding: 12px;
}
.expense-stats-summary-grid,
.expense-stats-analysis-grid {
gap: 8px;
}
.expense-stats-summary-item {
gap: 3px;
padding: 8px 10px;
}
.expense-stats-summary-item strong {
font-size: 16px;
}
.expense-stats-panel {
gap: 8px;
padding: 10px;
}
.expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.86fr) minmax(300px, 1.14fr);
}
.expense-stats-distribution-panel,
.expense-stats-processing-panel {
min-height: 272px;
}
.expense-distribution-chart,
.expense-distribution-chart-layout {
min-height: 222px;
}
.expense-distribution-chart-layout {
grid-template-columns: minmax(140px, 0.8fr) minmax(0, 1.2fr);
gap: 10px;
}
.expense-distribution-donut :deep(.donut-body) {
height: 170px;
}
.expense-distribution-summary-list,
.expense-processing-list,
.expense-operation-list {
gap: 6px;
}
.expense-distribution-summary-row,
.expense-processing-row,
.expense-operation-row {
gap: 8px;
padding: 7px 0;
}
.expense-processing-row {
grid-template-columns: minmax(112px, 0.78fr) minmax(148px, 1fr) auto 54px;
}
}
@keyframes expenseStatsDialogIn {
0% {
opacity: 0;

View File

@@ -199,65 +199,10 @@
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
<div class="section-head">
<h2>费用进度</h2>
</div>
<div class="progress-list">
<button
v-for="(item, index) in visibleProgressItems"
:key="item.id"
type="button"
class="progress-row"
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
:style="{ '--item-index': index }"
@click="openWorkbenchTarget(item)"
>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
<small v-if="item.showTimeCapsule" class="time-capsule">更新时间</small>
<small v-if="item.showUpdateText">更新</small>
</span>
</span>
<span class="progress-identity">
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
<span class="progress-result">
<strong>{{ item.amount }}</strong>
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
</span>
</button>
</div>
</article>
<PersonalWorkbenchProgressPanel
:progress-items="workbenchSummary.progressItems || []"
@open-target="openWorkbenchTarget"
/>
<aside class="side-column">
<article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;">
@@ -363,6 +308,7 @@ import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -520,92 +466,8 @@ const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.e
const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {})
const currentUserProfileKey = computed(() => {
const user = currentUser.value || {}
return [
user.username,
user.email,
user.name,
user.employeeNo,
user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems
: []
const progressRows = rows.slice(0, 5).map((item) => ({
...item,
displayTime: formatProgressTime(item?.updatedAt),
isLongDuration: isLongDurationProgress(item?.updatedAt)
}))
return progressRows.map((item, index) => {
const isCompleted = item.statusTone === 'muted';
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel);
return {
...item,
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone,
showTimeCapsule: !item.isLongDuration,
showUpdateText: item.isLongDuration && !isCompleted,
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
}
})
})
const LONG_DURATION_DAYS = 10
const DAY_MS = 24 * 60 * 60 * 1000
function formatProgressTime(value) {
const text = String(value || '').trim()
if (!text) {
return '最近更新'
}
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
if (match) {
return match[4] ? `${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[2]}-${match[3]}`
}
return text
}
function parseProgressDate(value) {
const text = String(value || '').trim()
if (!text) {
return null
}
const localDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (localDateMatch) {
return new Date(
Number(localDateMatch[1]),
Number(localDateMatch[2]) - 1,
Number(localDateMatch[3])
)
}
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function isLongDurationProgress(value) {
const date = parseProgressDate(value)
if (!date) {
return false
}
return (Date.now() - date.getTime()) / DAY_MS >= LONG_DURATION_DAYS
}
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
@@ -748,7 +610,7 @@ function openWorkbenchTarget(item) {
return
}
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''}费用进度`, SESSION_TYPE_EXPENSE)
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''}单据进度`, SESSION_TYPE_EXPENSE)
}
function openCapabilityAssistant(item) {

View File

@@ -0,0 +1,211 @@
<template>
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
<div class="section-head progress-section-head">
<h2>单据进度</h2>
<div
class="progress-range-control"
@click.stop
@mousedown.stop
@pointerdown.stop
>
<EnterpriseSelect
v-model="selectedProgressRange"
class="progress-range-select"
:options="PROGRESS_RANGE_OPTIONS"
size="small"
:teleported="true"
aria-label="单据进度时间范围"
/>
</div>
</div>
<div v-if="visibleProgressItems.length" class="progress-table-shell">
<div class="progress-table-header" aria-hidden="true">
<span class="header-cell header-time">更新时间</span>
<span class="header-cell header-applicant">提单人</span>
<span class="header-cell header-identity">单据信息</span>
<span class="header-cell header-type">类型归属</span>
<span class="header-cell header-steps">办理进度</span>
<span class="header-cell header-result">涉及金额</span>
</div>
<div class="progress-list">
<button
v-for="(item, index) in visibleProgressItems"
:key="`${item.id}-${index}`"
type="button"
class="progress-row"
:style="{ '--item-index': index }"
@click="handleProgressItemClick($event, item)"
>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
</span>
</span>
<span class="progress-applicant" title="申请人">
<strong>{{ item.applicantLabel || '待补充' }}</strong>
</span>
<span class="progress-identity">
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
<span class="progress-result">
<strong>{{ item.amount }}</strong>
</span>
</button>
</div>
</div>
<div v-else class="progress-empty-state" role="status">
<span class="progress-empty-icon" aria-hidden="true">
<i class="mdi mdi-file-document-search-outline"></i>
</span>
<strong>当前范围暂无单据</strong>
<p>{{ progressRangeLabel }}内没有申请单或报销单进度</p>
</div>
</article>
</template>
<script setup>
import { computed, ref } from 'vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const PROGRESS_RANGE_OPTIONS = Object.freeze([
{ value: '10d', label: '近10日' },
{ value: '30d', label: '近30日' },
{ value: '3m', label: '近3个月' }
])
const DAY_MS = 24 * 60 * 60 * 1000
const props = defineProps({
progressItems: { type: Array, default: () => [] }
})
const emit = defineEmits(['open-target'])
const selectedProgressRange = ref('30d')
const progressRangeLabel = computed(() =>
PROGRESS_RANGE_OPTIONS.find((item) => item.value === selectedProgressRange.value)?.label || '近30日'
)
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.progressItems) ? props.progressItems : []
return rows
.filter((item) => isInSelectedProgressRange(item?.updatedAt))
.map((item) => {
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel)
return {
...item,
displayTime: formatProgressTime(item?.updatedAt),
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone
}
})
})
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
function formatProgressTime(value) {
const text = String(value || '').trim()
if (!text) {
return '最近更新'
}
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
if (match) {
return match[4] ? `${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[2]}-${match[3]}`
}
return text
}
function parseProgressDate(value) {
const text = String(value || '').trim()
if (!text) {
return null
}
const localDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (localDateMatch) {
return new Date(
Number(localDateMatch[1]),
Number(localDateMatch[2]) - 1,
Number(localDateMatch[3])
)
}
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function resolveProgressRangeStart() {
const start = new Date()
start.setHours(0, 0, 0, 0)
if (selectedProgressRange.value === '10d') {
start.setDate(start.getDate() - 10)
return start
}
if (selectedProgressRange.value === '3m') {
start.setMonth(start.getMonth() - 3)
return start
}
start.setDate(start.getDate() - 30)
return start
}
function isInSelectedProgressRange(value) {
const date = parseProgressDate(value)
if (!date) {
return true
}
return date.getTime() >= resolveProgressRangeStart().getTime()
}
function handleProgressItemClick(event, item) {
const target = event?.target
if (target?.closest?.('.progress-range-control, .enterprise-select-popper, .el-select-dropdown, .el-popper')) {
return
}
emit('open-target', item)
}
</script>
<style scoped src="../../assets/styles/components/personal-workbench-progress.css"></style>

View File

@@ -32,7 +32,7 @@ const chartColors = computed(() => ({
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}登录${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}`
`${label}在线${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}`
)).join('')
)
@@ -84,13 +84,15 @@ const chartOptions = computed(() => ({
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
fontWeight: 700,
interval: props.compact ? 2 : 1,
hideOverlap: true
}
},
yAxis: [
{
type: 'value',
name: '登录',
name: '在线',
min: 0,
axisLabel: {
color: '#64748b',
@@ -115,7 +117,7 @@ const chartOptions = computed(() => ({
],
series: [
{
name: '登录人数',
name: '在线人数',
type: 'line',
smooth: 0.42,
symbol: 'circle',