chore: backup workspace before list detail shell refactor

This commit is contained in:
caoxiaozhu
2026-05-28 22:33:53 +08:00
parent e384318046
commit b383244a29
18 changed files with 4005 additions and 503 deletions

2672
server/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -241,7 +241,8 @@
.main.settings-main {
grid-template-rows: minmax(0, 1fr);
}
.main.audit-detail-main {
.main.audit-detail-main,
.main.digital-employees-detail-main {
grid-template-rows: minmax(0, 1fr);
}
.workarea { min-height: 0; overflow: auto; padding: 24px; }

View File

@@ -1,69 +1,31 @@
.digital-work-records {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
display: flex;
flex-direction: column;
gap: 0;
}
.work-records-head {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.8fr);
.digital-work-records .work-records-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.work-records-head h3 {
.digital-work-records .work-records-head h3 {
margin: 0;
color: #0f172a;
font-size: 16px;
}
.work-records-head p {
.digital-work-records .work-records-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
.work-records-kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
justify-self: end;
width: min(100%, 480px);
}
.work-record-kpi {
min-height: 58px;
padding: 10px 12px;
border: 1px solid #dfe7ef;
border-radius: 4px;
background: #fff;
}
.work-record-kpi span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.work-record-kpi strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 22px;
line-height: 1;
}
.work-record-kpi.success strong {
color: var(--success-active);
}
.work-record-kpi.danger strong {
color: #dc2626;
}
.work-records-toolbar {
.digital-work-records .work-records-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
@@ -72,12 +34,129 @@
font-size: 13px;
}
.work-records-toolbar button {
min-height: 34px;
.digital-work-records .work-records-table-wrap.is-empty {
display: grid;
align-items: center;
justify-content: center;
}
.digital-work-records .work-records-table-wrap {
min-height: 0;
}
.digital-work-records .filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.digital-work-records .list-search {
position: relative;
width: 280px;
}
.digital-work-records .list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.digital-work-records .list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.digital-work-records .list-search input::placeholder {
color: #8da0b4;
}
.digital-work-records .list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.digital-work-records .filter-btn {
min-height: 38px;
min-width: 120px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
cursor: pointer;
}
.digital-work-records .filter-btn:hover {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
}
.digital-work-records .work-records-filter {
position: relative;
}
.digital-work-records .work-records-filter-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 150px;
max-height: 280px;
padding: 6px;
z-index: 40;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow-y: auto;
}
.digital-work-records .work-records-filter-menu button {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.digital-work-records .work-records-filter-menu button:hover,
.digital-work-records .work-records-filter-menu button.active {
background: rgba(58, 124, 165, 0.1);
color: var(--theme-primary-active);
}
.digital-work-records .refresh-btn {
min-height: 38px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
padding: 0 14px;
border: 1px solid #d8e1eb;
border-radius: 4px;
background: #fff;
@@ -87,45 +166,44 @@
cursor: pointer;
}
.work-records-toolbar button:disabled {
.digital-work-records .refresh-btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.work-records-table-wrap {
min-height: 400px;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
}
.work-records-table-wrap.is-empty {
display: grid;
.digital-work-records .list-foot {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-end;
padding-top: 12px;
}
.digital-work-records-table {
.digital-work-records .page-summary {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.digital-work-records .digital-work-records-table {
width: 100%;
min-width: 1180px;
table-layout: fixed;
border-collapse: collapse;
}
.digital-work-records-table .col-time { width: 14%; }
.digital-work-records .digital-work-records-table .col-time { width: 14%; }
.digital-work-records-table .col-module { width: 12%; }
.digital-work-records .digital-work-records-table .col-module { width: 12%; }
.digital-work-records-table .col-source { width: 10%; }
.digital-work-records .digital-work-records-table .col-source { width: 10%; }
.digital-work-records-table .col-status { width: 17%; }
.digital-work-records .digital-work-records-table .col-status { width: 17%; }
.digital-work-records-table .col-summary { width: 31%; }
.digital-work-records .digital-work-records-table .col-summary { width: 31%; }
.digital-work-records-table .col-trace { width: 16%; }
.digital-work-records .digital-work-records-table .col-trace { width: 16%; }
.digital-work-records-table thead th {
.digital-work-records .digital-work-records-table thead th {
position: sticky;
top: 0;
z-index: 1;
@@ -140,7 +218,7 @@
white-space: nowrap;
}
.digital-work-records-table tbody td {
.digital-work-records .digital-work-records-table tbody td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
@@ -150,56 +228,73 @@
vertical-align: middle;
}
.digital-work-records-table tbody tr {
.digital-work-records .digital-work-records-table tbody tr {
cursor: pointer;
outline: none;
}
.digital-work-records-table tbody tr:hover,
.digital-work-records-table tbody tr:focus-visible {
.digital-work-records .digital-work-records-table tbody tr:hover,
.digital-work-records .digital-work-records-table tbody tr:focus-visible {
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
}
.digital-work-records-table tbody tr:focus-visible {
.digital-work-records .digital-work-records-table tbody tr:focus-visible {
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, .28);
}
.digital-work-records-table tbody tr:last-child td {
.digital-work-records .digital-work-records-table tbody tr:last-child td {
border-bottom: 0;
}
.work-record-status-stack {
.digital-work-records .work-records-table {
min-width: 1180px;
table-layout: fixed;
}
.digital-work-records .work-records-table .col-time { width: 14%; }
.digital-work-records .work-records-table .col-module { width: 13%; }
.digital-work-records .work-records-table .col-source { width: 10%; }
.digital-work-records .work-records-table .col-status { width: 16%; }
.digital-work-records .work-records-table .col-summary { width: 31%; }
.digital-work-records .work-records-table .col-trace { width: 16%; }
.digital-work-records .work-record-status-stack {
display: grid;
gap: 5px;
justify-items: center;
}
.work-record-status-stack > span:last-child {
.digital-work-records .work-record-status-stack > span:last-child {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.work-record-summary-cell {
.digital-work-records .work-record-summary-cell {
text-align: left !important;
}
.work-record-summary-cell strong,
.work-record-summary-cell span,
.work-record-summary-cell em {
.digital-work-records .work-record-summary-cell strong,
.digital-work-records .work-record-summary-cell span,
.digital-work-records .work-record-summary-cell em {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.work-record-summary-cell strong {
.digital-work-records .work-record-summary-cell strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.work-record-summary-cell span {
.digital-work-records .work-record-summary-cell span {
margin-top: 4px;
color: #64748b;
font-size: 13px;
@@ -207,7 +302,7 @@
white-space: nowrap;
}
.work-record-summary-cell em {
.digital-work-records .work-record-summary-cell em {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
@@ -215,12 +310,12 @@
white-space: nowrap;
}
.work-record-trace-cell {
.digital-work-records .work-record-trace-cell {
color: #2563eb !important;
word-break: break-all;
}
.status-pill {
.digital-work-records .status-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
@@ -233,38 +328,38 @@
white-space: nowrap;
}
.status-pill.success {
.digital-work-records .status-pill.success {
border-color: var(--success-line);
background: var(--success-soft);
color: var(--success-active);
}
.status-pill.warning {
.digital-work-records .status-pill.warning {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-pill.danger {
.digital-work-records .status-pill.danger {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.status-pill.info {
.digital-work-records .status-pill.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-pill.muted {
.digital-work-records .status-pill.muted {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.table-state,
.work-records-empty {
.digital-work-records .table-state,
.digital-work-records .work-records-empty {
width: 100%;
min-height: 260px;
display: grid;
@@ -276,83 +371,73 @@
text-align: center;
}
.table-state.error {
.digital-work-records .table-state.error {
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
}
.table-state.error .mdi {
.digital-work-records .table-state.error .mdi {
color: #ef4444;
font-size: 28px;
}
.table-state.error strong {
.digital-work-records .table-state.error strong {
color: #0f172a;
font-size: 15px;
}
.table-state.error p {
.digital-work-records .table-state.error p {
margin: 0;
}
.work-record-detail-mask {
position: fixed;
inset: 0;
z-index: 2800;
display: flex;
justify-content: flex-end;
background: rgba(15, 23, 42, .28);
.digital-work-records.is-detail {
display: grid;
grid-template-rows: minmax(0, 1fr);
gap: 12px;
}
.work-record-detail-panel {
width: min(720px, calc(100vw - 32px));
.digital-work-records .work-record-detail-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-left: 1px solid #dfe7ef;
background: #fff;
box-shadow: -18px 0 42px rgba(15, 23, 42, .18);
gap: 12px;
}
.work-record-detail-head {
min-height: 76px;
.digital-work-records .work-record-detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
border-bottom: 1px solid #edf2f7;
min-height: 44px;
padding: 0 2px;
}
.work-record-detail-head span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.work-record-detail-head h3 {
margin: 5px 0 0;
color: #0f172a;
font-size: 17px;
line-height: 1.35;
}
.work-record-detail-head button {
width: 34px;
height: 34px;
display: grid;
flex: 0 0 auto;
place-items: center;
.digital-work-records .work-record-detail-toolbar .back-action,
.digital-work-records .work-record-detail-toolbar .refresh-btn {
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 1px solid #d8e1eb;
border-radius: 4px;
background: #fff;
color: #64748b;
color: #0f172a;
font-size: 13px;
font-weight: 750;
}
.work-record-detail-head button:hover {
.digital-work-records .work-record-detail-toolbar button:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), .34);
background: #f5fbff;
color: var(--theme-primary-active);
}
.work-record-detail-body {
.digital-work-records .work-record-detail-shell {
min-height: 0;
}
.digital-work-records .work-record-detail-body {
min-height: 0;
display: grid;
align-content: start;
@@ -362,18 +447,24 @@
background: #f8fafc;
}
.work-record-detail-section,
.work-record-detail-state {
.digital-work-records .work-record-detail-body.inline-detail {
padding: 0;
overflow: visible;
background: transparent;
}
.digital-work-records .work-record-detail-section,
.digital-work-records .work-record-detail-state {
border: 1px solid #e5edf5;
border-radius: 6px;
background: #fff;
}
.work-record-detail-section {
.digital-work-records .work-record-detail-section {
padding: 14px;
}
.work-record-detail-state {
.digital-work-records .work-record-detail-state {
min-height: 100%;
display: grid;
place-items: center;
@@ -383,20 +474,20 @@
text-align: center;
}
.work-record-detail-state.error .mdi {
.digital-work-records .work-record-detail-state.error .mdi {
color: #dc2626;
font-size: 30px;
}
.work-record-detail-state.error strong {
.digital-work-records .work-record-detail-state.error strong {
color: #0f172a;
}
.work-record-detail-state.error p {
.digital-work-records .work-record-detail-state.error p {
margin: 0;
}
.work-record-detail-state.error button {
.digital-work-records .work-record-detail-state.error button {
height: 34px;
padding: 0 12px;
border: 1px solid #fecaca;
@@ -406,7 +497,7 @@
font-weight: 750;
}
.work-record-section-head {
.digital-work-records .work-record-section-head {
display: flex;
align-items: center;
justify-content: space-between;
@@ -414,25 +505,25 @@
margin-bottom: 12px;
}
.work-record-section-head h4 {
.digital-work-records .work-record-section-head h4 {
margin: 0;
color: #0f172a;
font-size: 15px;
}
.work-record-section-head > span:not(.status-pill) {
.digital-work-records .work-record-section-head > span:not(.status-pill) {
color: #94a3b8;
font-size: 12px;
font-weight: 750;
}
.work-record-info-grid {
.digital-work-records .work-record-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.work-record-info-grid div {
.digital-work-records .work-record-info-grid div {
min-width: 0;
padding: 10px;
border: 1px solid #edf2f7;
@@ -440,14 +531,14 @@
background: #f8fafc;
}
.work-record-info-grid span {
.digital-work-records .work-record-info-grid span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.work-record-info-grid strong {
.digital-work-records .work-record-info-grid strong {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
@@ -455,16 +546,16 @@
font-size: 13px;
}
.work-record-result-text,
.work-record-error-text,
.work-record-inline-empty {
.digital-work-records .work-record-result-text,
.digital-work-records .work-record-error-text,
.digital-work-records .work-record-inline-empty {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.65;
}
.work-record-error-text {
.digital-work-records .work-record-error-text {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #fecaca;
@@ -473,12 +564,12 @@
color: #b91c1c;
}
.work-record-tool-list {
.digital-work-records .work-record-tool-list {
display: grid;
gap: 8px;
}
.work-record-tool-list article {
.digital-work-records .work-record-tool-list article {
display: flex;
align-items: center;
justify-content: space-between;
@@ -489,17 +580,17 @@
background: #f8fafc;
}
.work-record-tool-list strong {
.digital-work-records .work-record-tool-list strong {
color: #0f172a;
font-size: 13px;
}
.work-record-tool-list span {
.digital-work-records .work-record-tool-list span {
color: #64748b;
font-size: 12px;
}
.work-record-code-block {
.digital-work-records .work-record-code-block {
max-height: 320px;
margin: 0;
padding: 12px;
@@ -512,37 +603,13 @@
line-height: 1.55;
}
.work-record-detail-enter-active,
.work-record-detail-leave-active {
transition: opacity 180ms ease;
}
.work-record-detail-enter-active .work-record-detail-panel,
.work-record-detail-leave-active .work-record-detail-panel {
transition: transform 220ms ease;
}
.work-record-detail-enter-from,
.work-record-detail-leave-to {
opacity: 0;
}
.work-record-detail-enter-from .work-record-detail-panel,
.work-record-detail-leave-to .work-record-detail-panel {
transform: translateX(24px);
}
@media (max-width: 980px) {
.work-records-head {
.digital-work-records .work-records-head {
display: grid;
grid-template-columns: 1fr;
}
.work-records-kpis {
justify-self: stretch;
width: 100%;
}
.work-record-info-grid {
.digital-work-records .work-record-info-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -78,6 +78,7 @@
}
.digital-work-records-section {
flex: 1 1 auto;
min-height: 0;
}

View File

@@ -51,7 +51,7 @@
</p>
</article>
<article class="detail-card panel json-risk-flow-card digital-worker-source-card">
<article class="detail-card panel json-risk-summary-card digital-worker-source-card">
<div class="card-head">
<div>
<h3>Skills Markdown 源文件</h3>

View File

@@ -0,0 +1,337 @@
<template>
<section class="digital-employee-list-panel">
<div class="list-toolbar">
<div class="filter-set">
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input
:value="keyword"
type="search"
placeholder="搜索数字员工技能、编号、执行计划或维护人"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
<AuditPickerFilter
id="status"
title="选择资产状态"
close-label="关闭资产状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel"
:options="statusOptions"
:selected-value="selectedStatus"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('status', $event)"
/>
<AuditPickerFilter
id="enabled"
title="选择启动状态"
close-label="关闭启动状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
id="executionMode"
title="选择执行方式"
close-label="关闭执行方式选择"
:active-filter-popover="activeFilterPopover"
:label="selectedExecutionModeLabel"
:options="executionModeOptions"
:selected-value="selectedExecutionMode"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('executionMode', $event)"
/>
</div>
<div class="toolbar-actions">
<button
v-if="keyword || activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="emit('reset-filters')"
>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
class="create-btn digital-refresh-action"
type="button"
:disabled="loading"
@click="emit('load-employees')"
>
<i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
归集后台自动执行的数字员工技能可查看技能内容执行计划启动状态和最近版本
</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
<div
class="table-wrap digital-table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleEmployees.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
title="数字员工资产同步中"
message="正在加载数字员工资产"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p>
</div>
<TableEmptyState
v-else-if="!visibleEmployees.length"
eyebrow="数字员工"
title="暂无匹配的数字员工"
description="当前没有符合搜索条件的后台执行技能。"
icon="mdi mdi-account-cog-outline"
tone="theme"
art-label="STAFF"
:tips="['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']"
/>
<table v-else class="digital-employees-table">
<colgroup>
<col class="col-skill">
<col class="col-skill-type">
<col class="col-owner">
<col class="col-schedule">
<col class="col-mode">
<col class="col-status">
<col class="col-enabled">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>技能名称</th>
<th>技能类型</th>
<th>维护归口</th>
<th>执行计划</th>
<th>触发方式</th>
<th>资产状态</th>
<th>启动状态</th>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="employee in pagedEmployees"
:key="employee.id"
@click="emit('open-employee-detail', employee)"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="employee.badgeTone">{{ employee.short }}</span>
<div>
<strong>{{ employee.name }}</strong>
<span class="skill-list-subtitle">{{ employee.summary || employee.code }}</span>
</div>
</div>
</td>
<td><span class="scope-pill skill-type-pill">{{ employee.skillCategory }}</span></td>
<td>{{ employee.owner }}</td>
<td><span class="scope-pill">{{ employee.scope }}</span></td>
<td>{{ employee.executionMode }}</td>
<td>
<span :class="['status-pill', employee.statusTone]">{{ employee.status }}</span>
</td>
<td><span :class="['status-pill', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td>{{ employee.updatedAt || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && !errorMessage && visibleEmployees.length" class="list-foot digital-employee-pagination">
<span class="page-summary"> {{ visibleEmployees.length }} 目前第 {{ currentPage }} / {{ totalPages }} </span>
<div class="pager" aria-label="员工技能分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in pageNumbers"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</footer>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import TableEmptyState from '../shared/TableEmptyState.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
defineOptions({
name: 'DigitalEmployeeListPanel'
})
const props = defineProps({
keyword: { type: String, default: '' },
activeFilterPopover: { type: String, default: '' },
selectedStatus: { type: String, default: '' },
selectedStatusLabel: { type: String, default: '' },
statusOptions: { type: Array, default: () => [] },
selectedEnabledState: { type: String, default: '' },
selectedEnabledLabel: { type: String, default: '' },
enabledStateOptions: { type: Array, default: () => [] },
selectedExecutionMode: { type: String, default: '' },
selectedExecutionModeLabel: { type: String, default: '' },
executionModeOptions: { type: Array, default: () => [] },
activeFilterTokens: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
visibleEmployees: { type: Array, default: () => [] }
})
const emit = defineEmits([
'update:keyword',
'toggle-filter-popover',
'close-filter-popover',
'select-filter',
'reset-filters',
'load-employees',
'open-employee-detail'
])
const currentPage = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize)))
const pagedEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize
return props.visibleEmployees.slice(start, start + pageSize)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
watch(
() => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode],
() => {
currentPage.value = 1
}
)
watch(
() => props.visibleEmployees.length,
() => {
currentPage.value = Math.min(currentPage.value, totalPages.value)
if (currentPage.value < 1) {
currentPage.value = 1
}
},
{ immediate: true }
)
function selectFilter(type, value) {
emit('select-filter', type, value)
}
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped>
.digital-employee-list-panel {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.digital-employee-list-panel .digital-table-wrap {
flex: 1 1 0;
min-height: 0;
}
.digital-employee-list-panel .digital-employee-pagination {
flex: 0 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
}
.digital-employee-list-panel .digital-employee-pagination .page-summary {
justify-self: start;
}
.digital-employee-list-panel .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.digital-employee-list-panel .pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.digital-employee-list-panel .pager button:hover:not(.active):not(:disabled) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.digital-employee-list-panel .pager button.active {
background: var(--theme-primary-active);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.digital-employee-list-panel .pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
</style>

View File

@@ -1,36 +1,80 @@
<template>
<section class="digital-work-records">
<header class="work-records-head">
<div>
<h3>工作记录</h3>
<p>查看数字员工近期执行记录状态和结果摘要</p>
<section class="digital-employee-list-panel digital-work-records">
<Transition name="skill-view" mode="out-in">
<!-- 列表视图 -->
<div v-if="!selectedRunDetail" key="list" class="digital-work-records-list-stage">
<div class="list-toolbar">
<div class="filter-set">
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input
v-model="listKeyword"
type="search"
placeholder="搜索摘要、Run ID..."
/>
</label>
<AuditPickerFilter
id="module"
title="选择工作模块"
close-label="关闭选择"
:active-filter-popover="activeFilterPopover"
:label="activeModule === '全部' ? '工作模块' : activeModule"
:options="modulePickerOptions"
:selected-value="activeModule"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectModule"
/>
<AuditPickerFilter
id="status"
title="选择执行状态"
close-label="关闭选择"
:active-filter-popover="activeFilterPopover"
:label="activeStatus === '全部' ? '执行状态' : activeStatus"
:options="statusPickerOptions"
:selected-value="activeStatus"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectStatus"
/>
</div>
<div class="work-records-kpis" aria-label="工作记录统计">
<article class="work-record-kpi">
<span>日志总数</span>
<strong>{{ totalCount }}</strong>
</article>
<article class="work-record-kpi success">
<span>成功数量</span>
<strong>{{ successCount }}</strong>
</article>
<article class="work-record-kpi danger">
<span>失败数量</span>
<strong>{{ failedCount }}</strong>
</article>
</div>
</header>
<div class="work-records-toolbar">
<span>{{ loading ? '正在同步工作记录' : `当前展示 ${visibleRuns.length} 条记录` }}</span>
<button type="button" :disabled="loading" @click="loadWorkRecords(true)">
<div class="toolbar-actions">
<button
v-if="listKeyword || activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="resetFilters"
>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
class="create-btn digital-refresh-action"
type="button"
:disabled="loading"
@click="loadWorkRecords(true)"
>
<i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button>
</div>
</div>
<div class="table-wrap work-records-table-wrap" :class="{ 'is-empty': !loading && !runs.length }">
<p class="hint">
<i class="mdi mdi-information-outline"></i>
查看数字员工近期执行记录状态和结果摘要
</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
<div class="table-wrap digital-table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleRuns.length }">
<div v-if="loading && !runs.length" class="table-state">
<TableLoadingState
variant="panel"
@@ -42,11 +86,20 @@
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>工作记录加载失败</strong>
<p>{{ errorMessage }}</p>
</div>
<table v-else-if="runs.length" class="digital-work-records-table">
<TableEmptyState
v-else-if="!visibleRuns.length"
eyebrow="工作记录"
title="暂无匹配的工作记录"
description="当前没有符合搜索条件的数字员工工作记录。"
icon="mdi mdi-clipboard-text-clock-outline"
tone="theme"
art-label="RECORDS"
/>
<table v-else class="digital-employees-table digital-work-records-table">
<colgroup>
<col class="col-time">
<col class="col-module">
@@ -95,34 +148,60 @@
</tr>
</tbody>
</table>
<div v-else class="work-records-empty">
当前还没有数字员工工作记录
</div>
</div>
<Teleport to="body">
<Transition name="work-record-detail">
<div
v-if="detailOpen"
class="work-record-detail-mask"
role="dialog"
aria-modal="true"
aria-label="工作记录详情"
@click.self="closeWorkRecordDetail"
>
<aside class="work-record-detail-panel">
<header class="work-record-detail-head">
<div>
<span>工作记录详情</span>
<h3>{{ selectedRunDetail ? resolveWorkRecordTitle(selectedRunDetail) : '工作记录' }}</h3>
</div>
<button type="button" aria-label="关闭工作记录详情" @click="closeWorkRecordDetail">
<i class="mdi mdi-close"></i>
<footer v-if="!loading && !errorMessage && visibleRuns.length" class="list-foot digital-employee-pagination">
<span class="page-summary"> {{ filteredRuns.length }} 目前第 {{ currentPage }} / {{ totalPages }} </span>
<div class="pager" aria-label="工作记录分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in pageNumbers"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</footer>
</div>
<!-- 详情视图 (全屏样式参考 AuditJsonRiskRuleDetail) -->
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage">
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ resolveWorkRecordTitle(selectedRunDetail) }}</h2>
</div>
<p class="json-risk-head-subtitle">
执行工作流{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}
</p>
<div class="json-risk-head-meta">
<span>Run ID{{ selectedRunDetail.run_id }}</span>
<span>触发来源{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</span>
<span>开始时间{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</span>
</div>
</div>
</div>
<div
class="json-risk-score-ring"
:class="selectedRunDetail.status"
>
<strong style="font-size: 16px; font-weight: 900;">{{ resolveWorkRecordStatusLabel(selectedRunDetail) }}</strong>
<span>运行状态</span>
<em>{{ resolveWorkRecordStatusNote(selectedRunDetail) || '执行完毕' }}</em>
</div>
</header>
<div v-if="detailLoading" class="work-record-detail-state">
<div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;">
<TableLoadingState
variant="panel"
title="详情加载中"
@@ -131,76 +210,121 @@
/>
</div>
<div v-else-if="detailError" class="work-record-detail-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<div v-else-if="detailError" class="work-record-detail-state error panel" style="min-height: 200px; display: grid; place-items: center; text-align: center; border: 0; color: #dc2626;">
<i class="mdi mdi-alert-circle-outline" style="font-size: 32px; margin-bottom: 8px;"></i>
<strong>工作记录详情加载失败</strong>
<p>{{ detailError }}</p>
<button type="button" @click="reloadSelectedDetail">重新加载</button>
<button class="minor-action" type="button" @click="reloadSelectedDetail" style="margin-top: 12px;">重新加载</button>
</div>
<div v-else-if="selectedRunDetail" class="work-record-detail-body">
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>基本信息</h4>
<span class="status-pill" :class="resolveWorkRecordStatusTone(selectedRunDetail)">
{{ resolveWorkRecordStatusLabel(selectedRunDetail) }}
</span>
<div v-else class="json-risk-editor-body work-record-detail-shell">
<section class="json-risk-main-stage work-record-detail-body inline-detail">
<!-- 卡片1基本信息 -->
<article class="detail-card panel json-risk-summary-card">
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>此次运行的执行周期触发来源标识信息与最终状态</p>
</div>
<div class="work-record-info-grid">
<div><span>Run ID</span><strong>{{ selectedRunDetail.run_id }}</strong></div>
<div><span>工作模块</span><strong>{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}</strong></div>
<div><span>触发来源</span><strong>{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</strong></div>
<div><span>开始时间</span><strong>{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</strong></div>
<div><span>结束时间</span><strong>{{ formatWorkRecordDateTime(selectedRunDetail.finished_at) }}</strong></div>
<div><span>状态说明</span><strong>{{ resolveWorkRecordStatusNote(selectedRunDetail) }}</strong></div>
</div>
</section>
<div class="json-risk-meta-grid">
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">Run ID</span>
<span class="json-risk-meta-value">{{ selectedRunDetail.run_id }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">工作模块</span>
<span class="json-risk-meta-value">{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">触发来源</span>
<span class="json-risk-meta-value">{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">开始时间</span>
<span class="json-risk-meta-value">{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">结束时间</span>
<span class="json-risk-meta-value">{{ formatWorkRecordDateTime(selectedRunDetail.finished_at) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">状态说明</span>
<span class="json-risk-meta-value">{{ resolveWorkRecordStatusNote(selectedRunDetail) || '-' }}</span>
</div>
</div>
</article>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>执行摘要</h4>
<span>{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
<!-- 卡片2执行摘要 -->
<article class="detail-card panel json-risk-description-card">
<div class="card-head">
<div>
<h3>执行摘要</h3>
<p>本次数字员工工作流的执行内容与结果摘要</p>
</div>
<p class="work-record-result-text">
{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}
</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text">
<span class="edit-badge">{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
</div>
<p class="json-risk-description-text" style="padding: 0 12px 12px; margin: 0;">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text" style="margin: 0 12px 12px; padding: 10px 12px; border: 1px solid #fecaca; border-radius: 4px; background: #fef2f2; color: #b91c1c;">
{{ selectedRunDetail.error_message }}
</p>
</section>
</article>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>工具调用</h4>
<span>{{ (selectedRunDetail.tool_calls || []).length }} </span>
<!-- 卡片3工具调用 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>工具调用</h3>
<p>此任务在执行期间调用的外部系统/工具细节与执行状态</p>
</div>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id">
<strong>{{ toolCall.tool_name }}</strong>
<span>{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
<span class="edit-badge">{{ (selectedRunDetail.tool_calls || []).length }} 次调用</span>
</div>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list" style="padding: 0 12px 12px; display: grid; gap: 8px;">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #edf2f7; border-radius: 4px; background: #f8fafc;">
<strong style="color: #0f172a; font-size: 13px;">{{ toolCall.tool_name }}</strong>
<span style="color: #64748b; font-size: 12px;">{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</article>
</div>
<div v-else class="work-record-inline-empty">当前暂无工具调用明细</div>
</section>
<div v-else class="work-record-inline-empty" style="padding: 0 12px 12px; color: #94a3b8; font-size: 13px;">当前暂无工具调用明细</div>
</article>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>执行上下文</h4>
<span>JSON</span>
<!-- 卡片4执行上下文 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>执行上下文</h3>
<p>后台调度的运行时配置与状态信息JSON 格式</p>
</div>
<pre class="work-record-code-block">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</div>
<div style="padding: 0 12px 12px;">
<pre class="work-record-code-block" style="max-height: 320px; margin: 0; padding: 12px; overflow: auto; border: 1px solid #e2e8f0; border-radius: 4px; background: #0f172a; color: #e2e8f0; font-size: 12px; line-height: 1.55;">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</div>
</article>
</section>
</div>
</aside>
<footer class="detail-actions">
<button class="back-action" type="button" @click="closeWorkRecordDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回工作记录列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
</button>
</div>
</footer>
</div>
</Transition>
</Teleport>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import TableEmptyState from '../shared/TableEmptyState.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
import { useToast } from '../../composables/useToast.js'
@@ -221,7 +345,7 @@ defineOptions({
name: 'DigitalEmployeeWorkRecords'
})
const emit = defineEmits(['summary-change'])
const emit = defineEmits(['summary-change', 'detail-open-change'])
const { toast } = useToast()
const runs = ref([])
@@ -232,12 +356,120 @@ const detailLoading = ref(false)
const detailError = ref('')
const selectedRunId = ref('')
const selectedRunDetail = ref(null)
watch(detailOpen, (newVal) => {
emit('detail-open-change', newVal)
}, { immediate: true })
let pollTimer = 0
const totalCount = computed(() => runs.value.length)
const successCount = computed(() => runs.value.filter((run) => run.status === 'succeeded').length)
const failedCount = computed(() => runs.value.filter((run) => run.status === 'failed').length)
const visibleRuns = computed(() => runs.value.slice(0, 100))
const listKeyword = ref('')
const activeModule = ref('全部')
const activeStatus = ref('全部')
const activeFilterPopover = ref('')
const modulePickerOptions = computed(() => {
const set = new Set(runs.value.map((run) => resolveWorkRecordModuleLabel(run)))
return [
{ label: '全部工作模块', value: '全部' },
...Array.from(set).map(m => ({ label: m, value: m }))
]
})
const statusPickerOptions = computed(() => {
const set = new Set(runs.value.map((run) => resolveWorkRecordStatusLabel(run)))
return [
{ label: '全部执行状态', value: '全部' },
...Array.from(set).map(s => ({ label: s, value: s }))
]
})
const activeFilterTokens = computed(() => {
const tokens = []
if (activeModule.value !== '全部') {
tokens.push(activeModule.value)
}
if (activeStatus.value !== '全部') {
tokens.push(activeStatus.value)
}
return tokens
})
const filteredRuns = computed(() => {
const keyword = listKeyword.value.trim().toLowerCase()
return runs.value.filter((run) => {
const moduleLabel = resolveWorkRecordModuleLabel(run)
const statusLabel = resolveWorkRecordStatusLabel(run)
const matchesKeyword = !keyword || [run.run_id, moduleLabel, statusLabel, run.result_summary].filter(Boolean).join(' ').toLowerCase().includes(keyword)
const matchesModule = activeModule.value === '全部' || moduleLabel === activeModule.value
const matchesStatus = activeStatus.value === '全部' || statusLabel === activeStatus.value
return matchesKeyword && matchesModule && matchesStatus
})
})
const currentPage = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRuns.value.length / pageSize)))
const visibleRuns = computed(() => {
const start = (currentPage.value - 1) * pageSize
return filteredRuns.value.slice(start, start + pageSize)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
watch(
() => [listKeyword.value, activeModule.value, activeStatus.value],
() => {
currentPage.value = 1
}
)
watch(
() => filteredRuns.value.length,
() => {
currentPage.value = Math.min(currentPage.value, totalPages.value)
if (currentPage.value < 1) {
currentPage.value = 1
}
},
{ immediate: true }
)
function toggleFilterPopover(id) {
activeFilterPopover.value = activeFilterPopover.value === id ? '' : id
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function selectModule(val) {
activeModule.value = val
closeFilterPopover()
}
function selectStatus(val) {
activeStatus.value = val
closeFilterPopover()
}
function resetFilters() {
listKeyword.value = ''
activeModule.value = '全部'
activeStatus.value = '全部'
closeFilterPopover()
}
async function loadWorkRecords(showToast = false) {
loading.value = true
@@ -302,6 +534,7 @@ function reloadSelectedDetail() {
function closeWorkRecordDetail() {
detailOpen.value = false
selectedRunDetail.value = null
detailError.value = ''
}
@@ -329,4 +562,103 @@ onBeforeUnmount(() => {
})
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
<style scoped src="../../assets/styles/views/digital-employees-view.css"></style>
<style scoped src="../../assets/styles/components/digital-employee-work-records.css"></style>
<style scoped>
.digital-employee-list-panel {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.digital-employee-list-panel .digital-table-wrap {
flex: 1 1 0;
min-height: 0;
}
.digital-employee-list-panel .digital-employee-pagination {
flex: 0 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
}
.digital-employee-list-panel .digital-employee-pagination .page-summary {
justify-self: start;
}
.digital-employee-list-panel .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.digital-employee-list-panel .pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.digital-employee-list-panel .pager button:hover:not(.active):not(:disabled) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.digital-employee-list-panel .pager button.active {
background: var(--theme-primary-active);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.digital-employee-list-panel .pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.digital-work-records-list-stage {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.work-records-detail-stage {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* 风险环的成功、失败、执行中状态配色 */
.json-risk-score-ring.succeeded {
--score-ring: #16a34a;
--score-ring-bg: #f0fdf4;
}
.json-risk-score-ring.failed {
--score-ring: #dc2626;
--score-ring-bg: #fef2f2;
}
.json-risk-score-ring.running {
--score-ring: #2563eb;
--score-ring-bg: #eff6ff;
}
</style>

View File

@@ -23,7 +23,6 @@ import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
@@ -34,7 +33,6 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([() => props.items], 980)
const themeColors = useThemeColors()
const resolvedItems = computed(() => {
const fallback = themeColors.value.chartPrimary
@@ -54,18 +52,14 @@ const ariaLabel = computed(() =>
const chartMaxValue = computed(() => Math.max(...resolvedItems.value.map((item) => item.value), 1))
const chartAxisMax = computed(() => Math.ceil((chartMaxValue.value * 1.1) / 10000) * 10000)
const animatedItems = computed(() =>
resolvedItems.value.map((item) => ({
...item,
animatedValue: progress.value >= 0.999
? item.value
: Number((item.value * progress.value).toFixed(0))
}))
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 8,
right: 62,
@@ -116,9 +110,9 @@ const chartOptions = computed(() => ({
series: [
{
type: 'bar',
data: animatedItems.value.map((item) => ({
data: resolvedItems.value.map((item) => ({
name: item.name || item.shortName,
value: item.animatedValue,
value: item.value,
itemStyle: { color: item.resolvedColor }
})),
barWidth: 14,
@@ -133,6 +127,7 @@ const chartOptions = computed(() => ({
label: {
show: true,
position: 'right',
valueAnimation: true,
color: '#64748b',
fontSize: 11,
fontWeight: 800,

View File

@@ -24,7 +24,6 @@ import { TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
@@ -37,7 +36,6 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([() => props.items], 980)
const themeColors = useThemeColors()
const resolvedItems = computed(() => {
const fallback = themeColors.value.chartPrimary
@@ -55,7 +53,11 @@ const ariaLabel = computed(() =>
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
tooltip: {
trigger: 'item',
confine: true,
@@ -78,7 +80,8 @@ const chartOptions = computed(() => ({
radius: ['60%', '88%'],
center: ['50%', '50%'],
startAngle: 90,
endAngle: 90 - Math.max(progress.value, 0.0001) * 360,
animationType: 'expansion',
animationTypeUpdate: 'expansion',
avoidLabelOverlap: true,
padAngle: 1.5,
minAngle: 2,

View File

@@ -2,10 +2,6 @@
<div class="gauge-chart">
<div class="gauge-body">
<div ref="chartElement" class="gauge-canvas" role="img" :aria-label="ariaLabel"></div>
<div class="gauge-center">
<strong>{{ animatedRatio }}%</strong>
<span>已执行</span>
</div>
</div>
<div class="gauge-summary">
<div>
@@ -30,7 +26,6 @@ import { GaugeChart as EChartsGaugeChart } from 'echarts/charts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
@@ -44,15 +39,8 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([() => props.ratio], 980)
const themeColors = useThemeColors()
const normalizedRatio = computed(() => Math.max(0, Math.min(100, Math.round(Number(props.ratio) || 0))))
const animatedRatio = computed(() => {
if (progress.value >= 0.999) {
return normalizedRatio.value
}
return Math.round(normalizedRatio.value * progress.value)
})
const ariaLabel = computed(() => `预算执行率${normalizedRatio.value}%,预算总额${props.total},已执行${props.used},剩余${props.left}`)
const chartOptions = computed(() => {
@@ -60,7 +48,11 @@ const chartOptions = computed(() => {
return {
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
series: [
{
type: 'gauge',
@@ -90,8 +82,23 @@ const chartOptions = computed(() => {
splitLine: { show: false },
axisLabel: { show: false },
anchor: { show: false },
detail: { show: false },
data: [{ value: animatedRatio.value }]
detail: {
show: true,
valueAnimation: true,
offsetCenter: [0, '22%'],
formatter: '{value}%',
color: primary,
fontSize: 24,
fontWeight: 850
},
title: {
show: true,
offsetCenter: [0, '46%'],
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
data: [{ value: normalizedRatio.value, name: '已执行' }]
}
]
}
@@ -120,31 +127,6 @@ useEcharts(chartElement, chartOptions)
height: 100%;
}
.gauge-center {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
text-align: center;
pointer-events: none;
animation: gaugeCenterIn 620ms ease both;
animation-delay: 360ms;
}
.gauge-center strong {
color: var(--chart-primary);
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.gauge-center span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 11px;
}
.gauge-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -168,17 +150,6 @@ useEcharts(chartElement, chartOptions)
font-weight: 500;
}
@keyframes gaugeCenterIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes gaugeSummaryIn {
from {
opacity: 0;
@@ -191,7 +162,6 @@ useEcharts(chartElement, chartOptions)
}
@media (prefers-reduced-motion: reduce) {
.gauge-center,
.gauge-summary {
animation: none;
}

View File

@@ -16,7 +16,6 @@ import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
@@ -30,12 +29,6 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([
() => props.labels,
() => props.applications,
() => props.approved,
() => props.avgHours
], 1100)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
@@ -49,18 +42,13 @@ const ariaLabel = computed(() =>
)).join('')
)
const scaleSeries = (series, decimals = 0) =>
series.map((value) => {
const number = Number(value || 0)
if (progress.value >= 0.999) {
return number
}
return Number((number * progress.value).toFixed(decimals))
})
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 18,
right: 38,
@@ -125,7 +113,7 @@ const chartOptions = computed(() => ({
{
name: '申请量(单)',
type: 'bar',
data: scaleSeries(props.applications),
data: props.applications,
barWidth: 12,
barGap: '28%',
itemStyle: {
@@ -136,7 +124,7 @@ const chartOptions = computed(() => ({
{
name: '审批完成量(单)',
type: 'bar',
data: scaleSeries(props.approved),
data: props.approved,
barWidth: 12,
itemStyle: {
color: chartColors.value.blue,
@@ -147,7 +135,7 @@ const chartOptions = computed(() => ({
name: '平均审批时长(小时)',
type: 'line',
yAxisIndex: 1,
data: scaleSeries(props.avgHours, 1),
data: props.avgHours,
smooth: true,
symbol: 'circle',
symbolSize: 7,

View File

@@ -145,6 +145,21 @@
</div>
</template>
<template v-else-if="showDigitalEmployeeWorkRecordKpis">
<div class="kpi-chips">
<div
v-for="kpi in digitalEmployeeWorkRecordKpis"
:key="kpi.label"
class="kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small></small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div>
</div>
</template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
@@ -212,6 +227,10 @@ const props = defineProps({
type: Object,
default: () => null
},
digitalEmployeeSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
@@ -250,6 +269,7 @@ const isRequestDetail = computed(() => ['requests', 'documents'].includes(props.
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
@@ -305,6 +325,45 @@ const logsKpis = computed(() => {
]
})
const showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords'
})
const digitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
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'
}
]
})
const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },

View File

@@ -5,6 +5,12 @@ export function useEcharts(chartElement, chartOptions) {
let chartInstance = null
let resizeObserver = null
let renderFrame = 0
let initialFrame = 0
let initialTimer = 0
let initialRendered = false
let initialPending = false
let latestOption = null
const initialAnimationDelay = 180
function renderChart() {
if (!chartElement.value) {
@@ -14,7 +20,37 @@ export function useEcharts(chartElement, chartOptions) {
chartInstance = init(chartElement.value, null, { renderer: 'canvas' })
chartInstance.resize()
}
chartInstance.setOption(chartOptions.value, true)
if (!initialRendered) {
initialRendered = true
initialPending = true
latestOption = chartOptions.value
if (typeof window !== 'undefined') {
initialTimer = window.setTimeout(() => {
initialTimer = 0
initialFrame = window.requestAnimationFrame(() => {
initialFrame = 0
initialPending = false
chartInstance?.setOption(latestOption || chartOptions.value, {
notMerge: true,
lazyUpdate: false
})
latestOption = null
})
}, initialAnimationDelay)
return
}
}
if (initialPending) {
latestOption = chartOptions.value
return
}
chartInstance.setOption(chartOptions.value, {
notMerge: false,
lazyUpdate: false
})
}
function handleResize() {
@@ -63,6 +99,14 @@ export function useEcharts(chartElement, chartOptions) {
window.cancelAnimationFrame(renderFrame)
renderFrame = 0
}
if (initialFrame && typeof window !== 'undefined') {
window.cancelAnimationFrame(initialFrame)
initialFrame = 0
}
if (initialTimer && typeof window !== 'undefined') {
window.clearTimeout(initialTimer)
initialTimer = 0
}
if (chartInstance) {
chartInstance.dispose()
chartInstance = null

View File

@@ -1,4 +1,9 @@
const KNOWLEDGE_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
const KNOWLEDGE_JOB_TYPES = new Set([
'knowledge_index_sync',
'llm_wiki_sync',
'llm_wiki_rule_formation',
'finance_policy_knowledge_organize'
])
const STATUS_LABELS = {
running: '运行中',

View File

@@ -1,4 +1,9 @@
const KNOWLEDGE_INGEST_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
const KNOWLEDGE_INGEST_JOB_TYPES = new Set([
'knowledge_index_sync',
'llm_wiki_sync',
'llm_wiki_rule_formation',
'finance_policy_knowledge_organize'
])
const STATUS_META = {
queued: { label: '等待处理', tone: 'muted' },

View File

@@ -47,6 +47,7 @@
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
'digital-employees-detail-main': activeView === 'digitalEmployees' && digitalEmployeeDetailOpen,
'digital-employees-main': activeView === 'digitalEmployees',
'logs-main': activeView === 'logs',
'employees-main': activeView === 'employees',
@@ -54,7 +55,7 @@
}"
>
<TopBar
v-if="activeView !== 'settings' && !(activeView === 'audit' && auditDetailOpen)"
v-if="activeView !== 'settings' && !(activeView === 'audit' && auditDetailOpen) && !(activeView === 'digitalEmployees' && digitalEmployeeDetailOpen)"
:current-view="topBarView"
:search="search"
:active-view="activeView"
@@ -65,6 +66,7 @@
:logs-summary="logsSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="detailMode"
:log-detail-mode="logDetailMode"
@@ -144,7 +146,11 @@
/>
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<DigitalEmployeesView v-else-if="activeView === 'digitalEmployees'" />
<DigitalEmployeesView
v-else-if="activeView === 'digitalEmployees'"
@summary-change="digitalEmployeeSummary = $event"
@detail-open-change="digitalEmployeeDetailOpen = $event"
/>
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
@@ -197,7 +203,9 @@ const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const logsSummary = ref(null)
const documentSummary = ref(null)
const digitalEmployeeSummary = ref(null)
const auditDetailOpen = ref(false)
const digitalEmployeeDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(false)
const mobileSidebarOpen = ref(false)

View File

@@ -4,7 +4,7 @@
<article
v-if="selectedEmployee"
key="detail"
class="skill-detail digital-employee-detail"
class="skill-detail digital-employee-detail json-risk-skill-detail"
>
<div class="detail-scroll">
<section v-if="detailError" class="detail-inline-state panel error">
@@ -74,8 +74,13 @@
</footer>
</article>
<article v-else key="list" class="skill-list panel digital-employees-list">
<nav class="status-tabs" aria-label="数字员工页签">
<article
v-else
key="list"
class="skill-list digital-employees-list"
:class="{ 'panel': !workRecordDetailOpen }"
>
<nav v-if="!workRecordDetailOpen" class="status-tabs" aria-label="数字员工页签">
<button
type="button"
:class="{ active: activeSection === 'skills' }"
@@ -260,6 +265,8 @@
<DigitalEmployeeWorkRecords
v-else
class="digital-work-records-section"
@summary-change="emit('summary-change', $event)"
@detail-open-change="workRecordDetailOpen = $event"
/>
</article>
</Transition>
@@ -279,7 +286,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue'
import AuditPickerFilter from '../components/audit/AuditPickerFilter.vue'
@@ -322,11 +329,18 @@ import {
const { currentUser } = useSystemState()
const { toast } = useToast()
const emit = defineEmits(['summary-change', 'detail-open-change'])
const employees = ref([])
const selectedEmployee = ref(null)
const selectedEmployeeId = ref('')
const activeSection = ref('skills')
const workRecordDetailOpen = ref(false)
const isDetailOpen = computed(() => Boolean(selectedEmployee.value) || (activeSection.value === 'workRecords' && workRecordDetailOpen.value))
watch(isDetailOpen, (newVal) => {
emit('detail-open-change', newVal)
}, { immediate: true })
const keyword = ref('')
const selectedStatus = ref('')
const selectedEnabledState = ref('')

View File

@@ -14,6 +14,7 @@ const SOURCE_LABELS = {
const KNOWLEDGE_JOB_TYPES = new Set([
'knowledge_index_sync',
'llm_wiki_sync',
'llm_wiki_rule_formation',
'finance_policy_knowledge_organize'
])