refactor: enforce 800 line source limits
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
173
web/src/components/business/workbench-ai/WorkbenchAiComposer.vue
Normal file
173
web/src/components/business/workbench-ai/WorkbenchAiComposer.vue
Normal 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>
|
||||
@@ -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>
|
||||
56
web/src/components/business/workbench-ai/WorkbenchAiHome.vue
Normal file
56
web/src/components/business/workbench-ai/WorkbenchAiHome.vue
Normal 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
132
web/src/components/layout/topBarKpis.js
Normal file
132
web/src/components/layout/topBarKpis.js
Normal 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
114
web/src/components/layout/useTopBarOverviewRange.js
Normal file
114
web/src/components/layout/useTopBarOverviewRange.js
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
57
web/src/components/travel/TravelRequestDetailHero.vue
Normal file
57
web/src/components/travel/TravelRequestDetailHero.vue
Normal 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>
|
||||
43
web/src/components/travel/TravelRequestProgressCard.vue
Normal file
43
web/src/components/travel/TravelRequestProgressCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user