refactor: enforce 800 line source limits

This commit is contained in:
caoxiaozhu
2026-06-22 11:58:53 +08:00
parent 08a4fa3577
commit 6d33ba5742
150 changed files with 27413 additions and 23791 deletions

View File

@@ -269,624 +269,5 @@ watch(
)
</script>
<style scoped>
:global(.expense-profile-dialog-overlay) {
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.34), rgba(15, 23, 42, 0.4)),
rgba(15, 23, 42, 0.36);
}
<style scoped src="../../assets/styles/components/expense-profile-detail-modal.css"></style>
: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;
background: #ffffff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2);
}
:global(.expense-profile-dialog .el-dialog__header),
:global(.expense-profile-dialog .expense-profile-dialog-body),
:global(.expense-profile-dialog .el-dialog__footer) {
padding: 0;
margin: 0;
}
:global(.expense-profile-dialog-zoom-enter-active),
:global(.expense-profile-dialog-zoom-leave-active) {
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
}
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
transform-origin: center center;
will-change: transform, opacity;
}
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog) {
animation: expenseProfileDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both;
}
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
animation: expenseProfileDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
:global(.expense-profile-dialog-zoom-enter-from),
:global(.expense-profile-dialog-zoom-leave-to) {
opacity: 0;
}
.profile-dialog-header,
.profile-dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
background: #ffffff;
}
.profile-dialog-header {
border-bottom: 1px solid #e2e8f0;
}
.profile-dialog-footer {
justify-content: flex-start;
border-top: 1px solid #e2e8f0;
}
.profile-dialog-title-block {
min-width: 0;
}
.profile-dialog-eyebrow,
.profile-section-title small {
color: #64748b;
font-size: 10px;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
.profile-dialog-header h2 {
margin: 3px 0 4px;
color: #0f172a;
font-size: 19px;
line-height: 1.25;
font-weight: 850;
}
.profile-dialog-header p,
.profile-dialog-footer span {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
font-weight: 650;
}
.profile-dialog-close {
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
border-radius: 4px;
color: #334155;
font-size: 18px;
}
.profile-dialog-close:hover {
background: #eef4fb;
color: var(--theme-primary-active);
}
.profile-dialog-content {
max-height: min(580px, calc(100vh - 176px));
max-height: min(580px, calc(100dvh - 176px));
min-height: 0;
display: grid;
gap: 12px;
padding: 14px;
overflow: auto;
background: #f8fafc;
}
.profile-dialog-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 4px;
background: #ffffff;
color: #475569;
font-size: 12px;
font-weight: 750;
}
.profile-dialog-alert.is-error {
border-color: rgba(220, 38, 38, 0.24);
background: #fff7f7;
color: #b91c1c;
}
.profile-dialog-alert.is-empty {
border-color: rgba(245, 158, 11, 0.28);
background: #fffaf0;
color: #92400e;
}
.profile-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.profile-summary-item,
.profile-panel {
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #ffffff;
}
.profile-summary-item {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
}
.profile-summary-item span,
.profile-operation-copy span,
.profile-operation-row time {
color: #64748b;
font-size: 11.5px;
font-weight: 650;
}
.profile-summary-item strong {
color: #0f172a;
font-size: 18px;
line-height: 1.15;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
.profile-summary-item small {
margin-left: 2px;
color: #64748b;
font-size: 11px;
font-weight: 650;
}
.profile-summary-item em {
overflow: hidden;
color: #94a3b8;
font-size: 11px;
font-style: normal;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-analysis-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
gap: 12px;
}
.profile-panel {
min-width: 0;
display: grid;
gap: 10px;
padding: 12px;
}
.profile-tags-panel {
grid-template-rows: auto minmax(0, 1fr);
align-content: stretch;
min-height: 312px;
}
.profile-radar-panel {
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: stretch;
min-height: 312px;
}
.profile-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.profile-section-title > div {
min-width: 0;
display: grid;
gap: 2px;
}
.profile-section-title span {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.profile-radar-title { align-items: flex-start; }
.profile-radar-view-select {
width: 118px;
flex: 0 0 118px;
}
.profile-radar-view-select :deep(.el-select__wrapper) {
min-height: 28px;
border-radius: 4px;
box-shadow: 0 0 0 1px #cbd5e1 inset;
color: #334155;
font-size: 12px;
font-weight: 750;
}
.profile-operation-list {
display: grid;
gap: 8px;
}
.profile-panel-empty {
margin: 0;
padding: 18px 12px;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
justify-self: stretch;
box-sizing: border-box;
min-height: 100%;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
line-height: 1.5;
font-weight: 700;
text-align: center;
}
.profile-tags-panel > .profile-panel-empty {
min-height: 244px;
}
.profile-radar-empty {
min-height: 268px;
}
.profile-operation-copy strong {
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-operation-status {
border-radius: 4px;
font-weight: 800;
}
.profile-radar-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: center;
justify-items: stretch;
min-height: 300px;
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
}
.profile-radar-chart {
width: 100%;
height: 300px;
}
.profile-behavior-tags {
display: grid;
gap: 8px;
padding-top: 10px;
min-height: 59px;
border-top: 1px solid #e8eef5;
}
.profile-behavior-tags.is-empty { visibility: hidden; }
.profile-behavior-tags-title {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.profile-behavior-tag-list {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.profile-behavior-tag {
--behavior-tag-rgb: 58, 124, 165;
--behavior-tag-text: #235d7e;
max-width: 132px;
overflow: hidden;
padding: 4px 9px;
border: 1px solid rgba(var(--behavior-tag-rgb), 0.24);
border-radius: 999px;
background: rgba(var(--behavior-tag-rgb), 0.08);
color: var(--behavior-tag-text);
font-size: 11.5px;
line-height: 1.25;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
animation: profileBehaviorTagIn 260ms cubic-bezier(0.2, 0, 0, 1) both;
}
.profile-behavior-tag--risk {
--behavior-tag-rgb: 245, 158, 11;
--behavior-tag-text: #92400e;
}
.profile-behavior-tag--positive {
--behavior-tag-rgb: 16, 185, 129;
--behavior-tag-text: #047857;
}
.profile-behavior-tag--accent-0 {
--behavior-tag-rgb: 58, 124, 165;
--behavior-tag-text: #235d7e;
}
.profile-behavior-tag--accent-1 {
--behavior-tag-rgb: 15, 159, 143;
--behavior-tag-text: #0f766e;
}
.profile-behavior-tag--accent-2 {
--behavior-tag-rgb: 245, 158, 11;
--behavior-tag-text: #92400e;
}
.profile-behavior-tag--accent-3 {
--behavior-tag-rgb: 124, 58, 237;
--behavior-tag-text: #5b21b6;
}
.profile-behavior-tag--accent-4 {
--behavior-tag-rgb: 220, 38, 38;
--behavior-tag-text: #991b1b;
}
.profile-behavior-tag--accent-5 {
--behavior-tag-rgb: 37, 99, 235;
--behavior-tag-text: #1d4ed8;
}
.profile-behavior-tag--accent-6 {
--behavior-tag-rgb: 22, 163, 74;
--behavior-tag-text: #15803d;
}
.profile-behavior-tag--accent-7 {
--behavior-tag-rgb: 219, 39, 119;
--behavior-tag-text: #be185d;
}
.profile-operation-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid #e8eef5;
}
.profile-operation-row:first-child {
border-top: 0;
padding-top: 0;
}
.profile-operation-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.profile-operation-status {
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;
transform: scale3d(0.94, 0.94, 1);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes profileRadarEnter {
0% {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes profileBehaviorTagIn {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes expenseProfileDialogOut {
0% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
transform: scale3d(0.96, 0.96, 1);
}
}
@media (max-width: 860px) {
:global(.expense-profile-dialog.el-dialog) {
width: calc(100vw - 24px) !important;
}
.profile-summary-grid,
.profile-analysis-grid,
.profile-radar-layout {
grid-template-columns: 1fr;
}
.profile-dialog-content {
max-height: calc(100vh - 170px);
}
}
@media (max-width: 560px) {
.profile-dialog-header,
.profile-dialog-footer {
align-items: flex-start;
}
.profile-dialog-footer {
flex-direction: column;
}
.profile-operation-row {
grid-template-columns: 1fr;
align-items: start;
}
.profile-operation-status {
justify-self: start;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog),
.profile-radar-layout,
.profile-behavior-tag {
animation-duration: 1ms !important;
}
}
</style>

View File

@@ -0,0 +1,756 @@
<section
class="workbench-ai-mode"
:class="{ 'has-conversation': conversationStarted }"
aria-label="小财管家 AI 模式"
>
<input
ref="fileInputRef"
class="workbench-ai-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
:disabled="isAiModeInputLocked"
@change="handleAiModeFilesChange"
/>
<Transition name="workbench-ai-panel-swap" mode="out-in" appear>
<div v-if="!conversationStarted" key="welcome" class="workbench-ai-shell workbench-ai-home">
<div class="workbench-ai-orb" aria-hidden="true">
<img
:src="orbIcon"
class="workbench-ai-orb__image"
alt=""
/>
</div>
<div class="workbench-ai-copy">
<h2>嗨,{{ displayUserName }},我是您的小财管家</h2>
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
</div>
<form class="workbench-ai-composer" @submit.prevent="submitAiModePrompt">
<div class="workbench-ai-composer-field">
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'"
:disabled="isAiModeInputLocked"
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="isAiModeInputLocked"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('single')"
/>
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
:disabled="isAiModeInputLocked"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
:disabled="isAiModeInputLocked"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip" aria-label="已选择附件">
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i>
</span>
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
</span>
<button
type="button"
class="workbench-ai-file-card__remove"
:disabled="isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
<div class="workbench-ai-quick-start-section">
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
<div class="workbench-ai-action-row" aria-label="推荐主题">
<button
v-for="item in aiModeActionItems"
:key="item.label"
type="button"
class="workbench-ai-action"
:disabled="isAiModeInputLocked"
@click="runAiModeAction(item)"
>
<div class="action-icon-wrapper">
<i :class="item.icon"></i>
</div>
<div class="action-text">
<strong>{{ item.label }}</strong>
<p>{{ item.prompt }}</p>
</div>
</button>
</div>
</div>
</div>
<div v-else key="conversation" class="workbench-ai-conversation">
<div class="workbench-ai-conversation-actions" aria-label="对话操作">
<button type="button" title="回到对话顶部" aria-label="回到对话顶部" @click="scrollInlineConversationToTop">
<i class="mdi mdi-arrow-up"></i>
</button>
<button
type="button"
class="danger"
title="删除当前对话"
aria-label="删除当前对话"
:disabled="!conversationMessages.length"
@click="requestDeleteCurrentConversation"
>
<i class="mdi mdi-trash-can-outline"></i>
</button>
</div>
<div
ref="conversationScrollRef"
class="workbench-ai-thread"
aria-live="polite"
@scroll.passive="handleInlineConversationScroll"
>
<div v-if="conversationMessages.length === 0" class="workbench-ai-empty-thread">
<strong>{{ activeConversationTitle || '新对话' }}</strong>
<p>直接输入问题,小财管家会在当前页面内持续回复。</p>
</div>
<article
v-for="message in conversationMessages"
:key="message.id"
class="workbench-ai-message"
:class="`is-${message.role}`"
>
<div v-if="message.role === 'user'" class="workbench-ai-user-bubble">
{{ message.content }}
</div>
<div v-if="message.role === 'user'" class="workbench-ai-message-actions">
<button type="button" title="引用" aria-label="引用" @click="quoteInlineMessage(message)">
<i class="mdi mdi-format-quote-open"></i>
</button>
<button type="button" title="复制" aria-label="复制" @click="copyInlineMessage(message)">
<i class="mdi mdi-content-copy"></i>
</button>
<time class="workbench-ai-message-time">{{ formatMessageTime(message.createdAt) }}</time>
</div>
<template v-else>
<div
class="workbench-ai-answer-card"
:class="{ pending: message.pending, 'has-thinking': hasInlineThinking(message) }"
>
<div
v-if="hasInlineThinking(message)"
class="workbench-ai-thinking-panel"
:class="{
'is-expanded': isInlineThinkingExpanded(message),
'is-collapsed': !isInlineThinkingExpanded(message),
'is-running': message.pending
}"
>
<button
v-if="!isInlineThinkingExpanded(message)"
type="button"
class="workbench-ai-thinking-toggle"
aria-expanded="false"
@click="toggleInlineThinking(message)"
>
<span class="workbench-ai-thinking-toggle-left">
<span class="workbench-ai-thinking-dot" aria-hidden="true"></span>
<strong>小财业务思考</strong>
<small>{{ resolveInlineThinkingEvents(message).length }} 条</small>
</span>
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</button>
<div v-else class="workbench-ai-thinking-expanded">
<button
type="button"
class="workbench-ai-thinking-collapse-btn"
aria-label="折叠小财业务思考"
@click="toggleInlineThinking(message)"
>
<i class="mdi mdi-chevron-up" aria-hidden="true"></i>
</button>
<Transition name="workbench-ai-thinking-collapse" appear>
<div
class="workbench-ai-thinking-list"
aria-label="小财业务思考明细"
>
<div
v-for="event in resolveInlineThinkingEvents(message)"
:key="event.eventId || `${message.id}-${event.title}`"
class="workbench-ai-thinking-item"
:class="`is-${event.status || 'completed'}`"
>
<span class="workbench-ai-thinking-dot" aria-hidden="true"></span>
<div>
<strong>{{ event.title || '正在分析' }}</strong>
<p v-if="event.content">{{ event.content }}</p>
</div>
</div>
</div>
</Transition>
</div>
</div>
<div
v-if="hasInlineAttachmentOcrDetails(message)"
class="workbench-ai-ocr-detail-panel"
:class="{ 'is-expanded': isInlineAttachmentOcrExpanded(message) }"
>
<button
type="button"
class="workbench-ai-ocr-detail-toggle"
:aria-expanded="isInlineAttachmentOcrExpanded(message)"
@click="toggleInlineAttachmentOcrDetails(message)"
>
<span class="workbench-ai-ocr-detail-toggle-left">
<span class="workbench-ai-ocr-detail-dot" aria-hidden="true"></span>
<strong>附件识别明细</strong>
<small>{{ resolveInlineAttachmentOcrFileCount(message) }} 份</small>
</span>
<i
class="mdi"
:class="isInlineAttachmentOcrExpanded(message) ? 'mdi-chevron-up' : 'mdi-chevron-down'"
aria-hidden="true"
></i>
</button>
<Transition name="workbench-ai-thinking-collapse" appear>
<div
v-if="isInlineAttachmentOcrExpanded(message)"
class="workbench-ai-ocr-detail-body"
aria-label="附件 OCR 识别明细"
>
<article
v-for="(document, documentIndex) in resolveInlineAttachmentOcrDocuments(message)"
:key="`${message.id}-ocr-${document.filename}-${documentIndex}`"
class="workbench-ai-ocr-document"
>
<header class="workbench-ai-ocr-document__head">
<strong>{{ document.filename }}</strong>
<span>{{ document.fields.length }} 项</span>
</header>
<p v-if="document.summary" class="workbench-ai-ocr-document__summary">
{{ document.summary }}
</p>
<div v-if="document.fields.length" class="workbench-ai-ocr-document__fields">
<div
v-for="field in document.fields"
:key="`${message.id}-${document.filename}-${field.label}-${field.value}`"
class="workbench-ai-ocr-document__field"
>
<span>{{ field.label }}</span>
<strong>{{ field.value }}</strong>
</div>
</div>
</article>
</div>
</Transition>
</div>
<div
v-if="message.content"
class="workbench-ai-answer-markdown"
@click.capture="handleAiAnswerMarkdownClick($event)"
v-html="renderInlineConversationHtml(message.content)"
></div>
<Transition name="structured-card-reveal" appear>
<div
v-if="message.applicationPreview"
class="workbench-ai-application-preview application-preview-shell"
aria-label="申请信息核对结果"
>
<div
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in resolveInlineApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable,
highlight: row.highlight,
'is-disabled': isApplicationPreviewEstimatePending(message)
}"
role="row"
:tabindex="row.editable && !isApplicationPreviewEstimatePending(message) ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click.stop="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
:disabled="isApplicationPreviewEstimatePending(message)"
@click.stop
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
@blur="commitInlineApplicationPreviewEditor(message)"
/>
<select
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
autofocus
:disabled="isApplicationPreviewEstimatePending(message)"
@click.stop
@change="commitInlineApplicationPreviewEditor(message)"
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
@blur="commitInlineApplicationPreviewEditor(message)"
>
<option value="">请选择</option>
<option
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
:key="`${message.id}-${row.key}-${option}`"
:value="option"
>
{{ option }}
</option>
</select>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="isApplicationPreviewEstimatePending(message)"
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="resolveInlineApplicationPreviewMissingFields(message).length"
class="application-preview-footer application-preview-footer-missing"
aria-live="polite"
>
<span class="application-preview-missing-prefix">当前还需要补充:</span>
<span class="application-preview-missing-list">
<template
v-for="(field, index) in resolveInlineApplicationPreviewMissingFields(message)"
:key="`${message.id}-missing-${field}`"
>
<span class="application-preview-missing-chip">{{ field }}</span>
<span
v-if="index < resolveInlineApplicationPreviewMissingFields(message).length - 1"
class="application-preview-missing-separator"
></span>
</template>
</span>
<span class="application-preview-missing-suffix">。点击表格字段补齐后,费用测算会自动刷新。</span>
</div>
<div
v-else-if="buildInlineApplicationPreviewFooterText(message)"
class="application-preview-footer workbench-ai-answer-markdown"
v-html="renderInlineConversationHtml(buildInlineApplicationPreviewFooterText(message))"
></div>
</div>
</Transition>
<div
v-if="!message.content && !message.applicationPreview && message.pending && !hasInlineThinking(message)"
class="workbench-ai-pending-line"
>
小财管家正在识别任务、拆解流程并准备下一步建议...
</div>
<div v-if="canShowInlineSuggestedActions(message)" class="workbench-ai-suggested-actions">
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.label}`"
type="button"
:disabled="isInlineSuggestedActionDisabled(action, message)"
@click="handleInlineSuggestedAction(action, message)"
>
<i :class="action.icon || 'mdi mdi-arrow-right-circle-outline'"></i>
<span>{{ action.label }}</span>
</button>
</div>
</div>
<div v-if="!message.pending" class="workbench-ai-message-actions" aria-label="消息操作">
<button type="button" title="复制" aria-label="复制" @click="copyInlineMessage(message)">
<i class="mdi mdi-content-copy"></i>
</button>
<button type="button" title="有帮助" aria-label="有帮助" @click="markInlineMessageFeedback(message, 'up')">
<i class="mdi mdi-thumb-up-outline"></i>
</button>
<button type="button" title="无帮助" aria-label="无帮助" @click="markInlineMessageFeedback(message, 'down')">
<i class="mdi mdi-thumb-down-outline"></i>
</button>
<button type="button" title="重新生成" aria-label="重新生成" @click="regenerateLastReply">
<i class="mdi mdi-refresh"></i>
</button>
<time class="workbench-ai-message-time">{{ formatMessageTime(message.createdAt) }}</time>
</div>
</template>
</article>
</div>
<div class="workbench-ai-conversation-bottom">
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip inline" aria-label="已选择附件">
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i>
</span>
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
</span>
<button
type="button"
class="workbench-ai-file-card__remove"
:disabled="isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
<form class="workbench-ai-composer workbench-ai-composer--inline" @submit.prevent="submitAiModePrompt">
<div class="workbench-ai-composer-field">
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'"
:disabled="isAiModeInputLocked"
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="isAiModeInputLocked"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('single')"
/>
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
:disabled="isAiModeInputLocked"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
:disabled="isAiModeInputLocked"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p>
</div>
</div>
</Transition>
<Transition name="workbench-ai-confirm-fade">
<div v-if="deleteDialogOpen" class="workbench-ai-confirm-mask" role="presentation" @click.self="cancelDeleteConversation">
<div
class="workbench-ai-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="workbench-ai-delete-title"
>
<h3 id="workbench-ai-delete-title">删除当前对话?</h3>
<p>删除后,左侧最近对话中的这条记录也会被移除。这个操作无法恢复。</p>
<div class="workbench-ai-confirm-actions">
<button type="button" class="ghost" @click="cancelDeleteConversation">取消</button>
<button type="button" class="danger" @click="confirmDeleteConversation">删除对话</button>
</div>
</div>
</div>
</Transition>
<Transition name="workbench-ai-confirm-fade">
<div
v-if="applicationSubmitConfirmOpen"
class="workbench-ai-confirm-mask"
role="presentation"
@click.self="cancelInlineApplicationSubmitConfirm"
>
<div
class="workbench-ai-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="workbench-ai-submit-confirm-title"
>
<h3 id="workbench-ai-submit-confirm-title">确认直接提交申请?</h3>
<p>确认后系统会先查询你名下相同日期的申请单;若发现重复或重叠日期,会停止提交并列出已有单据供你查看。</p>
<p>若核查通过,申请单会直接进入审批流程。</p>
<div class="workbench-ai-confirm-actions">
<button type="button" class="ghost" @click="cancelInlineApplicationSubmitConfirm">取消</button>
<button type="button" class="primary" :disabled="sending" @click="confirmInlineApplicationSubmit">确认直接提交</button>
</div>
</div>
</div>
</Transition>
</section>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
<template>
<form
class="workbench-ai-composer"
:class="{ 'workbench-ai-composer--inline': inline }"
@submit.prevent="runtime.submitAiModePrompt"
>
<div class="workbench-ai-composer-field">
<div v-if="runtime.workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ runtime.workbenchDateTagLabel }}</span>
<button
type="button"
aria-label="移除日期"
:disabled="runtime.isAiModeInputLocked"
@click="runtime.removeWorkbenchDateTag"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
:ref="runtime.setAssistantInputRef"
v-model="runtime.assistantDraft"
maxlength="1000"
rows="3"
:placeholder="runtime.isAiModeInputLocked ? '费用测算中,请稍等...' : placeholder"
:disabled="runtime.isAiModeInputLocked"
@keydown.enter.exact.prevent="runtime.submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: runtime.workbenchDatePickerOpen || runtime.workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="runtime.workbenchDatePickerOpen"
:disabled="runtime.isAiModeInputLocked"
@click.stop="runtime.toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="runtime.workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: runtime.workbenchDateMode === 'single' }"
:disabled="runtime.isAiModeInputLocked"
@click="runtime.setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: runtime.workbenchDateMode === 'range' }"
:disabled="runtime.isAiModeInputLocked"
@click="runtime.setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="runtime.workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="runtime.workbenchSingleDate"
type="date"
:disabled="runtime.isAiModeInputLocked"
@change="runtime.handleWorkbenchDateInputChange('single')"
/>
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="runtime.workbenchRangeStartDate"
type="date"
:disabled="runtime.isAiModeInputLocked"
@change="runtime.handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="runtime.workbenchRangeEndDate"
type="date"
:disabled="runtime.isAiModeInputLocked"
@change="runtime.handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button
type="button"
class="ghost"
:disabled="runtime.isAiModeInputLocked"
@click="runtime.clearWorkbenchDateSelection"
>
清除
</button>
<button
type="button"
class="primary"
:disabled="!runtime.workbenchCanApplyDateSelection || runtime.isAiModeInputLocked"
@click="runtime.applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
:disabled="runtime.isAiModeInputLocked"
@click="runtime.triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
:disabled="runtime.isAiModeInputLocked"
@click="runtime.handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="runtime.modelSelectorTitle">
<span>{{ runtime.displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!runtime.canSubmitAiModePrompt || runtime.sending || runtime.isAiModeInputLocked"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
</template>
<script setup>
defineProps({
inline: { type: Boolean, default: false },
placeholder: { type: String, required: true },
runtime: { type: Object, required: true }
})
</script>
<style scoped src="../../../assets/styles/components/personal-workbench-ai-mode.css"></style>

View File

@@ -0,0 +1,36 @@
<template>
<div
v-if="runtime.selectedFileCards.length"
class="workbench-ai-file-strip"
:class="{ inline }"
aria-label="已选择附件"
>
<article v-for="file in runtime.selectedFileCards" :key="file.key" class="workbench-ai-file-card">
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i>
</span>
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
</span>
<button
type="button"
class="workbench-ai-file-card__remove"
:disabled="runtime.isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="runtime.removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
</template>
<script setup>
defineProps({
inline: { type: Boolean, default: false },
runtime: { type: Object, required: true }
})
</script>
<style scoped src="../../../assets/styles/components/personal-workbench-ai-mode.css"></style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="workbench-ai-shell workbench-ai-home">
<div class="workbench-ai-orb" aria-hidden="true">
<img
:src="orbIcon"
class="workbench-ai-orb__image"
alt=""
/>
</div>
<div class="workbench-ai-copy">
<h2>{{ runtime.displayUserName }}我是您的小财管家</h2>
<p>您可以直接向我提问或选择下方推荐主题快速完成费用相关事务</p>
</div>
<WorkbenchAiComposer
:runtime="runtime"
placeholder="今天我能帮您做点什么?"
/>
<WorkbenchAiFileStrip :runtime="runtime" />
<div class="workbench-ai-quick-start-section">
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
<div class="workbench-ai-action-row" aria-label="推荐主题">
<button
v-for="item in runtime.aiModeActionItems"
:key="item.label"
type="button"
class="workbench-ai-action"
:disabled="runtime.isAiModeInputLocked"
@click="runtime.runAiModeAction(item)"
>
<div class="action-icon-wrapper">
<i :class="item.icon"></i>
</div>
<div class="action-text">
<strong>{{ item.label }}</strong>
<p>{{ item.prompt }}</p>
</div>
</button>
</div>
</div>
</div>
</template>
<script setup>
import orbIcon from '../../../assets/workbench-ai-mode-orb-icon.gif'
import WorkbenchAiComposer from './WorkbenchAiComposer.vue'
import WorkbenchAiFileStrip from './WorkbenchAiFileStrip.vue'
defineProps({
runtime: { type: Object, required: true }
})
</script>
<style scoped src="../../../assets/styles/components/personal-workbench-ai-mode.css"></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
export const CHAT_KPIS = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
]
export const APPROVAL_KPIS = [
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
]
export function buildRequestKpis(summary = {}) {
const total = Number(summary.total ?? 0)
const draft = Number(summary.draft ?? 0)
const inProgress = Number(summary.inProgress ?? 0)
const completed = Number(summary.completed ?? 0)
return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
]
}
export function buildDocumentKpis(summary = {}) {
const total = Number(summary.total ?? 0)
const toSubmit = Number(summary.toSubmit ?? 0)
const toProcess = Number(summary.toProcess ?? 0)
const archived = Number(summary.archived ?? 0)
return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
]
}
export function buildDigitalEmployeeWorkRecordKpis(summary = {}) {
const total = Number(summary.total ?? 0)
const succeeded = Number(summary.succeeded ?? 0)
const failed = Number(summary.failed ?? 0)
return [
{
label: '日志总数',
value: total,
delta: '当前',
trend: 'up',
arrow: 'mdi mdi-minus',
color: 'var(--theme-primary)'
},
{
label: '成功数量',
value: succeeded,
delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
trend: 'up',
arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
color: 'var(--success)'
},
{
label: '失败数量',
value: failed,
delta: failed > 0 ? '需要关注' : '暂无失败',
trend: failed > 0 ? 'down' : 'up',
arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
color: '#ef4444'
}
]
}
export function buildKnowledgeKpis(summary = {}) {
const totalDocuments = Number(summary.totalDocuments ?? 0)
return [
{
label: '文档总数',
value: String(totalDocuments),
meta: '',
trend: 'up',
color: 'var(--theme-primary)'
}
]
}
export function buildEmployeeKpis(summary = {}) {
const total = Number(summary.total ?? 0)
const active = Number(summary.active ?? 0)
const onboarding = Number(summary.onboarding ?? 0)
const disabled = Number(summary.disabled ?? 0)
const followUp = Number(summary.followUp ?? 0)
const departments = Number(summary.departments ?? 0)
return [
{
label: '员工总数',
value: total,
unit: '人',
meta: `覆盖 ${departments} 个部门`,
trend: 'up',
color: 'var(--theme-primary)'
},
{
label: '在职账号',
value: active,
unit: '人',
meta: total ? `占比 ${Math.round((active / total) * 100)}%` : '等待数据',
trend: 'up',
color: '#3b82f6'
},
{
label: '待处理状态',
value: onboarding + disabled,
unit: '人',
meta: `试用 ${onboarding} / 停用 ${disabled}`,
trend: onboarding + disabled > 0 ? 'down' : 'up',
color: '#f59e0b'
},
{
label: '同步待处理',
value: followUp,
unit: '人',
meta: followUp > 0 ? '存在待同步账号' : '资料已同步',
trend: followUp > 0 ? 'down' : 'up',
color: '#8b5cf6'
}
]
}

View File

@@ -0,0 +1,114 @@
import { computed, ref, watch } from 'vue'
import { formatDateValue } from '../../utils/dateRangeDefaults.js'
const OVERVIEW_DASHBOARD_OPTIONS = [
{ label: '财务看板', value: 'finance' },
{ label: '风险看板', value: 'risk' },
{ label: '数字员工看板', value: 'digitalEmployee' },
{ label: '系统看板', value: 'system' }
]
export function useTopBarOverviewRange(props, emit) {
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const overviewDashboardOptions = OVERVIEW_DASHBOARD_OPTIONS
const overviewDashboardValue = computed({
get: () => props.overviewDashboard,
set: (value) => emit('update:overviewDashboard', value)
})
const rangeOptions = computed(() =>
props.ranges.map((range) => ({
value: range,
label: String(range)
}))
)
const activeOption = computed(() =>
rangeOptions.value.find((option) => option.value === props.activeRange) ?? rangeOptions.value[0]
)
const isCustomRange = computed(() => props.activeRange === 'custom')
const activeDateLabel = computed(() => {
if (isCustomRange.value) return formatRangeLabel(props.customRange.start, props.customRange.end)
return buildPresetRangeLabel(activeOption.value?.label)
})
const canApplyCustomRange = computed(() =>
Boolean(draftStart.value && draftEnd.value && draftStart.value <= draftEnd.value)
)
watch(
() => props.customRange,
(range) => {
draftStart.value = range.start
draftEnd.value = range.end
},
{ deep: true }
)
function setRange(range) {
emit('update:activeRange', range)
calendarOpen.value = false
}
function applyCustomRange() {
if (!canApplyCustomRange.value) return
emit('update:customRange', { start: draftStart.value, end: draftEnd.value })
emit('update:activeRange', 'custom')
calendarOpen.value = false
}
return {
calendarOpen,
draftStart,
draftEnd,
overviewDashboardOptions,
overviewDashboardValue,
rangeOptions,
activeOption,
isCustomRange,
activeDateLabel,
canApplyCustomRange,
setRange,
applyCustomRange
}
}
function formatRangeLabel(start, end) {
if (!start || !end) return '选择时间段'
if (start === end) return start
return `${start} ~ ${end}`
}
function buildPresetRangeLabel(label) {
const now = new Date()
const today = formatDateValue(now)
if (label === '今日') {
return today
}
if (label === '近10日') {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - 9)
return `${formatDateValue(start)} ~ ${today}`
}
if (label === '本周') {
const start = new Date(now)
const day = start.getDay() || 7
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - day + 1)
return `${formatDateValue(start)} ~ ${today}`
}
if (label === '本月') {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
return today
}

View File

@@ -332,9 +332,13 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
import { useToast } from '../../composables/useToast.js'
import {
createId,
documentHasMeaningfulText,
formatFileSize,
formatTestError,
formatTime
formatTime,
mergeRecognizedDocuments,
normalizeOcrDocuments,
toAttachmentPayload
} from './riskRuleTestDialogUtils.js'
import {
buildDocumentBrief,
@@ -716,70 +720,6 @@ function buildTraceItems(result) {
return buildTraceItemsModel(result, fields.value)
}
function toAttachmentPayload(file) {
const document = file.ocrDocument || {}
return {
id: file.id,
name: file.name,
size: file.size,
content_type: file.contentType,
note: file.error || '',
recognition_status: file.status,
ocr_text: document.text || '',
summary: document.summary || '',
document_type: document.document_type || '',
document_type_label: document.document_type_label || '',
scene_code: document.scene_code || '',
scene_label: document.scene_label || '',
avg_score: document.avg_score || 0,
document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
}
}
function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.map((item) => ({
filename: String(item?.filename || '').trim(),
summary: String(item?.summary || '').trim(),
text: String(item?.text || '').trim(),
avg_score: Number(item?.avg_score || 0),
document_type: String(item?.document_type || 'other').trim() || 'other',
document_type_label: String(item?.document_type_label || '').trim(),
scene_code: String(item?.scene_code || 'other').trim() || 'other',
scene_label: String(item?.scene_label || '').trim(),
document_fields: Array.isArray(item?.document_fields)
? item.document_fields
.map((field) => ({
key: String(field?.key || '').trim(),
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.key && field.label && field.value)
: [],
warnings: Array.isArray(item?.warnings) ? item.warnings : []
}))
}
function mergeRecognizedDocuments(current, incoming) {
const next = [...current]
incoming.forEach((document) => {
const index = next.findIndex((item) => item.filename === document.filename)
if (index >= 0) {
next.splice(index, 1, document)
} else {
next.push(document)
}
})
return next
}
function documentHasMeaningfulText(document) {
return Boolean(
String(document?.text || document?.summary || '').trim() ||
(Array.isArray(document?.document_fields) && document.document_fields.length)
)
}
function buildRecognitionStepDescription() {
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
@@ -839,4 +779,3 @@ function isActiveSession(activeSessionId) {
</script>
<style src="../../assets/styles/components/risk-rule-test-dialog.css"></style>

View File

@@ -26,3 +26,67 @@ export function formatTime() {
minute: '2-digit'
}).format(new Date())
}
export function toAttachmentPayload(file) {
const document = file.ocrDocument || {}
return {
id: file.id,
name: file.name,
size: file.size,
content_type: file.contentType,
note: file.error || '',
recognition_status: file.status,
ocr_text: document.text || '',
summary: document.summary || '',
document_type: document.document_type || '',
document_type_label: document.document_type_label || '',
scene_code: document.scene_code || '',
scene_label: document.scene_label || '',
avg_score: document.avg_score || 0,
document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
}
}
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.map((item) => ({
filename: String(item?.filename || '').trim(),
summary: String(item?.summary || '').trim(),
text: String(item?.text || '').trim(),
avg_score: Number(item?.avg_score || 0),
document_type: String(item?.document_type || 'other').trim() || 'other',
document_type_label: String(item?.document_type_label || '').trim(),
scene_code: String(item?.scene_code || 'other').trim() || 'other',
scene_label: String(item?.scene_label || '').trim(),
document_fields: Array.isArray(item?.document_fields)
? item.document_fields
.map((field) => ({
key: String(field?.key || '').trim(),
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.key && field.label && field.value)
: [],
warnings: Array.isArray(item?.warnings) ? item.warnings : []
}))
}
export function mergeRecognizedDocuments(current, incoming) {
const next = [...current]
incoming.forEach((document) => {
const index = next.findIndex((item) => item.filename === document.filename)
if (index >= 0) {
next.splice(index, 1, document)
} else {
next.push(document)
}
})
return next
}
export function documentHasMeaningfulText(document) {
return Boolean(
String(document?.text || document?.summary || '').trim() ||
(Array.isArray(document?.document_fields) && document.document_fields.length)
)
}

View File

@@ -0,0 +1,57 @@
<template>
<article class="detail-hero panel">
<div class="hero-banner">
<div class="hero-banner-main">
<div class="applicant-card">
<div class="portrait">
<img src="/assets/person.png" alt="" />
</div>
<div class="applicant-copy">
<div class="applicant-name-row">
<h2>{{ profile.name }}</h2>
<span class="identity-badge">{{ profile.identity }}</span>
</div>
<div class="applicant-profile-meta">
<div class="applicant-profile-meta__org">
<span class="applicant-meta-item">
<em>部门</em>
<strong>{{ profile.department }}</strong>
</span>
<span class="applicant-meta-item applicant-meta-item--sub">
<em>直属上司</em>
<strong>{{ profile.manager }}</strong>
</span>
</div>
<div class="applicant-profile-meta__role">
<span class="applicant-meta-item">
<em>职级</em>
<strong>{{ profile.grade }}</strong>
</span>
<span class="applicant-meta-item">
<em>岗位</em>
<strong>{{ profile.position }}</strong>
</span>
</div>
</div>
</div>
</div>
<div class="hero-fact-grid">
<div v-for="item in heroFactItems" :key="item.key" class="hero-fact">
<div class="hero-fact-label">
<i v-if="item.icon" :class="item.icon"></i>
<span>{{ item.label }}</span>
</div>
<strong :class="item.valueClass">{{ item.value }}</strong>
</div>
</div>
</div>
</div>
</article>
</template>
<script setup>
defineProps({
profile: { type: Object, required: true },
heroFactItems: { type: Array, default: () => [] }
})
</script>

View File

@@ -0,0 +1,43 @@
<template>
<article class="progress-card panel">
<div class="progress-block">
<div class="progress-head">
<h3>{{ isApplicationDocument ? '申请进度' : '报销进度' }}</h3>
</div>
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
<div
v-for="step in progressSteps"
:key="step.label"
class="progress-step"
:class="{ active: step.active, current: step.current, done: step.done }"
>
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<div class="progress-step-copy" :title="step.title || step.detail || step.time">
<strong>{{ step.label }}</strong>
<small class="progress-step-status">{{ step.time }}</small>
<em v-if="step.detail" class="progress-step-meta">{{ step.detail }}</em>
</div>
</div>
</div>
</div>
</article>
</template>
<script setup>
defineProps({
isApplicationDocument: { type: Boolean, default: false },
progressSteps: { type: Array, default: () => [] },
currentProgressRingMotion: { type: Object, required: true }
})
</script>

View File

@@ -0,0 +1,32 @@
<template>
<article v-if="!isApplicationDocument" class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>关联单据信息</h3>
<p>展示本次报销关联的前置申请便于核对申请内容天数事由和预计金额</p>
</div>
</div>
<div v-if="relatedApplicationFactItems.length" class="application-detail-facts related-application-facts">
<div
v-for="item in relatedApplicationFactItems"
:key="item.key"
class="application-detail-fact related-application-fact"
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-else class="related-application-empty">
<strong>暂未识别到关联申请单</strong>
<p>差旅报销应先关联已审批的申请单请核对本单据是否由申请单生成或已在智能录入中完成关联</p>
</div>
</article>
</template>
<script setup>
defineProps({
isApplicationDocument: { type: Boolean, default: false },
relatedApplicationFactItems: { type: Array, default: () => [] }
})
</script>