refactor(ui): introduce shared list detail shells

This commit is contained in:
caoxiaozhu
2026-05-28 22:49:58 +08:00
parent b383244a29
commit 064eeb614f
17 changed files with 1163 additions and 1095 deletions

View File

@@ -0,0 +1,438 @@
.enterprise-list-page {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 16px 18px;
}
.enterprise-list-page .status-tabs {
flex: 0 0 auto;
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
overflow-x: auto;
}
.enterprise-list-page .status-tabs button {
position: relative;
min-height: 36px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.enterprise-list-page .status-tabs button.active {
color: var(--theme-primary-active);
}
.enterprise-list-page .status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 2px 2px 0 0;
background: var(--theme-primary);
}
.enterprise-list-page .list-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.enterprise-list-page .table-wrap {
flex: 1 1 auto;
min-height: 0;
}
.enterprise-list-page .toolbar-actions,
.enterprise-list-page .document-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-left: auto;
}
.enterprise-list-page .filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.enterprise-list-page .list-search {
position: relative;
width: min(280px, 100%);
}
.enterprise-list-page .list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.enterprise-list-page .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;
}
.enterprise-list-page .list-search input::placeholder {
color: #8da0b4;
}
.enterprise-list-page .list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
}
.enterprise-list-page .filter-btn {
min-width: 104px;
min-height: 38px;
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;
}
.enterprise-list-page .filter-btn:hover,
.enterprise-list-page .document-filter.open .filter-btn {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.32);
color: var(--theme-primary-active);
}
.enterprise-list-page .create-request-btn,
.enterprise-list-page .create-btn,
.enterprise-list-page .export-btn,
.enterprise-list-page .template-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 4px;
background: var(--theme-gradient-primary);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.enterprise-list-page .create-request-btn.secondary,
.enterprise-list-page .ghost-filter-btn,
.enterprise-list-page .template-btn,
.enterprise-list-page .export-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
box-shadow: none;
}
.enterprise-list-page .create-request-btn:hover:not(:disabled),
.enterprise-list-page .create-btn:hover:not(:disabled),
.enterprise-list-page .export-btn:hover:not(:disabled),
.enterprise-list-page .template-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 28px var(--theme-primary-shadow);
filter: saturate(1.02);
}
.enterprise-list-page .create-request-btn.secondary:hover:not(:disabled),
.enterprise-list-page .ghost-filter-btn:hover:not(:disabled),
.enterprise-list-page .template-btn:hover:not(:disabled),
.enterprise-list-page .export-btn:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.32);
color: var(--theme-primary-active);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.enterprise-list-page .create-request-btn:disabled,
.enterprise-list-page .create-btn:disabled,
.enterprise-list-page .export-btn:disabled,
.enterprise-list-page .template-btn:disabled {
cursor: not-allowed;
opacity: 0.72;
transform: none;
}
.enterprise-list-page .hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.enterprise-list-page .hint .mdi {
color: #64748b;
}
.enterprise-list-page .table-wrap {
min-height: 400px;
margin-top: 10px;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 4px;
background: linear-gradient(180deg, #fcfeff 0%, var(--theme-primary-light-9) 100%);
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
.enterprise-list-page .table-wrap.is-empty {
align-items: center;
justify-content: center;
}
.enterprise-list-page .table-state {
width: 100%;
min-height: 220px;
align-self: center;
}
.enterprise-list-page .table-state.error {
display: grid;
place-items: center;
gap: 10px;
padding: 32px;
text-align: center;
}
.enterprise-list-page .table-state.error > .mdi {
color: var(--danger);
font-size: 30px;
}
.enterprise-list-page .table-state.error strong {
color: var(--ink);
font-size: 15px;
}
.enterprise-list-page .table-state.error p {
max-width: 520px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.enterprise-list-page .state-action,
.enterprise-list-page .retry-btn {
min-height: 34px;
padding: 0 12px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 750;
}
.enterprise-list-page table {
width: 100%;
min-width: 1080px;
align-self: flex-start;
border-collapse: collapse;
table-layout: fixed;
}
.enterprise-list-page th,
.enterprise-list-page td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.enterprise-list-page th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
}
.enterprise-list-page tbody tr {
cursor: pointer;
}
.enterprise-list-page tbody tr:hover,
.enterprise-list-page tbody tr.spotlight {
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.03));
}
.enterprise-list-page tbody tr:last-child td {
border-bottom: 0;
}
.enterprise-list-page .doc-id {
color: var(--theme-primary-active);
font-weight: 800;
}
.enterprise-pagination {
flex: 0 0 auto;
}
.enterprise-list-page .list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.enterprise-list-page .page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.enterprise-list-page .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.enterprise-list-page .pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.enterprise-list-page .pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
}
.enterprise-list-page .pager button.active {
background: var(--theme-primary);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.enterprise-list-page .pager button:disabled {
cursor: not-allowed;
opacity: 0.45;
box-shadow: none;
}
.enterprise-list-page .page-ellipsis,
.enterprise-list-page .pager span {
color: #64748b;
font-weight: 800;
}
.enterprise-list-page .page-size-select {
width: 112px;
justify-self: end;
}
.enterprise-detail-page {
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
}
.enterprise-detail-page .detail-scroll {
min-height: 0;
overflow: auto;
}
.enterprise-detail-card .card-head {
align-items: flex-start;
}
@media (max-width: 760px) {
.enterprise-list-page {
padding: 16px;
}
.enterprise-list-page .status-tabs {
gap: 18px;
}
.enterprise-list-page .toolbar-actions,
.enterprise-list-page .document-actions {
width: 100%;
margin-left: 0;
}
.enterprise-list-page .filter-set,
.enterprise-list-page .list-search,
.enterprise-list-page .filter-btn,
.enterprise-list-page .create-request-btn,
.enterprise-list-page .create-btn,
.enterprise-list-page .export-btn,
.enterprise-list-page .template-btn,
.enterprise-list-page .page-size-select {
width: 100%;
}
.enterprise-list-page .list-toolbar,
.enterprise-list-page .list-foot {
display: grid;
grid-template-columns: 1fr;
justify-items: stretch;
}
.enterprise-list-page .pager,
.enterprise-list-page .page-size-select {
justify-self: stretch;
}
}

View File

@@ -11,9 +11,6 @@
.approval-list { .approval-list {
min-height: 0; min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden; overflow: hidden;
} }

View File

@@ -1,16 +1,16 @@
.logs-view { .logs-view {
display: grid;
grid-template-rows: minmax(0, 1fr);
height: 100%; height: 100%;
min-height: 0; min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
background: transparent; background: transparent;
} }
.logs-empty { .logs-empty {
min-height: 0;
display: grid; display: grid;
align-content: center; align-content: center;
justify-items: center; justify-items: center;
min-height: 0;
padding: 28px 20px; padding: 28px 20px;
text-align: center; text-align: center;
} }
@@ -29,9 +29,6 @@
.system-logs-list.panel { .system-logs-list.panel {
min-height: 0; min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden; overflow: hidden;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
@@ -39,84 +36,14 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, .10), 0 1px 2px rgba(0, 0, 0, .06); box-shadow: 0 1px 3px rgba(0, 0, 0, .10), 0 1px 2px rgba(0, 0, 0, .06);
} }
.system-logs-list .document-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.system-logs-list .filter-set,
.system-logs-list .document-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.system-logs-list .list-search { .system-logs-list .list-search {
position: relative;
width: 280px; width: 280px;
} }
.system-logs-list .list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.system-logs-list .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;
}
.system-logs-list .list-search input::placeholder {
color: #8da0b4;
}
.system-logs-list .list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.system-logs-list .document-filter { .system-logs-list .document-filter {
position: relative; position: relative;
} }
.system-logs-list .filter-btn {
min-width: 120px;
min-height: 38px;
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;
}
.system-logs-list .filter-btn:hover,
.system-logs-list .document-filter.open .filter-btn {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
}
.system-logs-list .status-dropdown-filter, .system-logs-list .status-dropdown-filter,
.system-logs-list .status-filter-trigger, .system-logs-list .status-filter-trigger,
.system-logs-list .status-filter-menu { .system-logs-list .status-filter-menu {
@@ -143,9 +70,9 @@
} }
.system-logs-list .document-filter-menu button { .system-logs-list .document-filter-menu button {
display: block;
width: 100%; width: 100%;
min-height: 36px; min-height: 36px;
display: block;
padding: 0 12px; padding: 0 12px;
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
@@ -159,148 +86,22 @@
.system-logs-list .document-filter-menu button:hover, .system-logs-list .document-filter-menu button:hover,
.system-logs-list .document-filter-menu button.active { .system-logs-list .document-filter-menu button.active {
background: rgba(58, 124, 165, 0.1); background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active); color: var(--theme-primary-active);
} }
.system-logs-list .create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 4px;
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.system-logs-list .create-request-btn.secondary {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
box-shadow: none;
}
.system-logs-list .create-request-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 28px var(--theme-primary-shadow);
filter: saturate(1.02);
}
.system-logs-list .create-request-btn.secondary:hover:not(:disabled) {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.system-logs-list .create-request-btn:disabled {
cursor: not-allowed;
opacity: .72;
transform: none;
}
.system-logs-list .hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin: 10px 0 0;
color: #64748b;
font-size: 13px;
}
.system-logs-list .table-wrap {
min-height: 400px;
margin-top: 10px;
display: flex;
flex-direction: column;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
}
.system-logs-list .table-wrap.is-empty {
align-items: center;
justify-content: center;
}
.system-logs-list .table-state {
width: 100%;
min-height: 260px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
text-align: center;
color: #64748b;
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
}
.system-logs-list .system-log-table { .system-logs-list .system-log-table {
width: 100%;
min-width: 1260px; min-width: 1260px;
align-self: flex-start;
border-collapse: collapse;
table-layout: fixed;
} }
.system-logs-list .col-time { width: 13%; } .system-logs-list .col-time { width: 13%; }
.system-logs-list .col-level { width: 8%; } .system-logs-list .col-level { width: 8%; }
.system-logs-list .col-event { width: 13%; } .system-logs-list .col-event { width: 13%; }
.system-logs-list .col-module { width: 15%; } .system-logs-list .col-module { width: 15%; }
.system-logs-list .col-outcome { width: 8%; } .system-logs-list .col-outcome { width: 8%; }
.system-logs-list .col-summary { width: 27%; } .system-logs-list .col-summary { width: 27%; }
.system-logs-list .col-request { width: 16%; } .system-logs-list .col-request { width: 16%; }
.system-logs-list th,
.system-logs-list td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.system-logs-list th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
}
.system-logs-list tbody tr {
cursor: pointer;
}
.system-logs-list tbody tr:hover {
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
}
.system-logs-list tbody tr:last-child td {
border-bottom: 0;
}
.system-logs-list .summary-cell { .system-logs-list .summary-cell {
text-align: left; text-align: left;
white-space: normal; white-space: normal;
@@ -342,7 +143,7 @@
justify-content: center; justify-content: center;
padding: 0 9px; padding: 0 9px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 4px;
font-size: 12px; font-size: 12px;
font-weight: 750; font-weight: 750;
white-space: nowrap; white-space: nowrap;
@@ -389,111 +190,12 @@
text-align: center; text-align: center;
} }
.system-logs-list .list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.system-logs-list .page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.system-logs-list .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.system-logs-list .pager button,
.system-logs-list .page-ellipsis {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
}
.system-logs-list .pager button {
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
}
.system-logs-list .page-ellipsis {
color: #94a3b8;
font-size: 13px;
font-weight: 800;
}
.system-logs-list .pager button:hover:not(.active):not(:disabled) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.system-logs-list .pager button.active {
background: var(--theme-primary-active);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.system-logs-list .pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
opacity: 1;
}
.system-logs-list .page-size-select {
width: 118px;
justify-self: end;
}
@media (max-width: 1200px) {
.system-logs-list .document-toolbar {
align-items: stretch;
flex-direction: column;
}
.system-logs-list .document-actions {
justify-content: flex-start;
}
.system-logs-list .list-foot {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.logs-view { .logs-view {
padding: 0; padding: 0;
} }
.system-logs-list.panel {
padding: 16px;
}
.system-logs-list .filter-set,
.system-logs-list .document-actions,
.system-logs-list .list-search,
.system-logs-list .filter-btn,
.system-logs-list .page-size-select {
width: 100%;
}
.system-logs-list .document-filter-menu { .system-logs-list .document-filter-menu {
width: 100%; width: 100%;
} }
} }

View File

@@ -4,146 +4,15 @@
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
gap: 14px; gap: 14px;
animation: fadeUp 220ms var(--ease) both;
overflow: hidden; overflow: hidden;
animation: fadeUp 220ms var(--ease) both;
} }
.travel-list { .travel-list {
min-height: 0; min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden; overflow: hidden;
} }
.list-search {
position: relative;
width: 220px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
}
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: var(--theme-primary-active);
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: var(--theme-primary);
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 10px;
background: var(--theme-gradient-primary);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(var(--theme-primary-rgb), 0.24);
filter: saturate(1.02);
}
.filter-btn {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border-radius: 8px;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.filter-btn {
min-width: 120px;
justify-content: space-between;
}
.date-range-filter { .date-range-filter {
position: relative; position: relative;
} }
@@ -153,25 +22,25 @@
} }
.date-range-label { .date-range-label {
max-width: 110px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 110px;
} }
.date-range-popover { .date-range-popover {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
left: 0; left: 0;
width: 320px;
z-index: 40; z-index: 40;
width: 320px;
display: grid; display: grid;
gap: 14px; gap: 14px;
padding: 16px; padding: 16px;
border: 1px solid #d7e0ea; border: 1px solid #d7e0ea;
border-radius: 12px; border-radius: 4px;
background: #fff; background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, .16); box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
} }
.date-range-popover header, .date-range-popover header,
@@ -193,7 +62,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
border: 0; border: 0;
border-radius: 8px; border-radius: 4px;
background: transparent; background: transparent;
color: #64748b; color: #64748b;
} }
@@ -225,7 +94,7 @@
height: 38px; height: 38px;
padding: 0 9px; padding: 0 9px;
border: 1px solid #d7e0ea; border: 1px solid #d7e0ea;
border-radius: 8px; border-radius: 4px;
color: #0f172a; color: #0f172a;
font-size: 13px; font-size: 13px;
} }
@@ -240,7 +109,7 @@
.apply-btn { .apply-btn {
height: 36px; height: 36px;
padding: 0 14px; padding: 0 14px;
border-radius: 8px; border-radius: 4px;
font-size: 13px; font-size: 13px;
font-weight: 750; font-weight: 750;
} }
@@ -262,158 +131,20 @@
background: #cbd5e1; background: #cbd5e1;
} }
.filter-btn:hover { .col-id { width: 13%; }
border-color: rgba(var(--theme-primary-rgb), .32); .col-type { width: 12%; }
color: var(--theme-primary-active); .col-title { width: 19%; }
} .col-occurred,
.col-apply { width: 13%; }
.hint { .col-amount { width: 11%; }
display: inline-flex; .col-node { width: 11%; }
align-items: center; .col-approval { width: 8%; }
gap: 7px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.hint .mdi {
color: #64748b;
}
.table-wrap {
min-height: 400px;
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, var(--theme-primary-light-9) 100%);
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
.table-wrap.is-empty {
align-items: center;
justify-content: center;
}
/* 让表格在flex容器中正常显示 */
.table-wrap table {
width: 100%;
align-self: flex-start;
}
.table-state {
width: 100%;
min-height: 260px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
text-align: center;
color: #64748b;
background: linear-gradient(180deg, #fcfffd 0%, var(--theme-primary-light-9) 100%);
align-self: center;
}
.table-state .mdi {
font-size: 28px;
color: var(--theme-primary);
}
.table-state strong {
color: #0f172a;
font-size: 15px;
}
.table-state p {
max-width: 420px;
margin: 0;
font-size: 13px;
line-height: 1.6;
}
.table-state.error {
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
}
.table-state.error .mdi {
color: #ef4444;
}
.retry-btn {
height: 36px;
padding: 0 14px;
border: 1px solid #f1c5c5;
border-radius: 8px;
background: #fff;
color: #b91c1c;
font-size: 13px;
font-weight: 750;
}
table {
width: 100%;
min-width: 1080px;
border-collapse: collapse;
table-layout: fixed;
}
colgroup col {
width: 10%;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb), .08), rgba(var(--theme-primary-rgb), .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.doc-id {
color: var(--theme-primary-active);
font-weight: 800;
}
.type-tag { .type-tag {
min-height: 26px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 26px;
padding: 0 10px; padding: 0 10px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
@@ -421,7 +152,9 @@ tbody tr:last-child td {
white-space: nowrap; white-space: nowrap;
} }
.type-tag.travel { .type-tag.travel,
.type-tag.hotel,
.type-tag.transport {
background: var(--theme-primary-light-9); background: var(--theme-primary-light-9);
color: var(--theme-primary-active); color: var(--theme-primary-active);
} }
@@ -431,12 +164,6 @@ tbody tr:last-child td {
color: #ea580c; color: #ea580c;
} }
.type-tag.hotel,
.type-tag.transport {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.type-tag.meal { .type-tag.meal {
background: #fef3c7; background: #fef3c7;
color: #b45309; color: #b45309;
@@ -464,7 +191,7 @@ tbody tr:last-child td {
align-items: center; align-items: center;
padding: 0 9px; padding: 0 9px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 4px;
font-size: 12px; font-size: 12px;
font-weight: 750; font-weight: 750;
white-space: nowrap; white-space: nowrap;
@@ -506,96 +233,13 @@ tbody tr:last-child td {
color: #475569; color: #475569;
} }
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: var(--theme-primary);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.page-nav {
color: #64748b;
}
.page-size-select {
width: 112px;
justify-self: end;
}
@media (max-width: 1200px) {
.list-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.travel-list { .date-range-filter,
padding: 16px; .date-range-trigger {
}
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-btn,
.page-size-select {
width: 100%; width: 100%;
} }
.filter-set { .date-range-popover {
width: 100%; width: min(320px, calc(100vw - 48px));
}
.list-foot {
display: grid;
justify-items: stretch;
}
.pager,
.page-size-select {
justify-self: stretch;
} }
} }

View File

@@ -0,0 +1,22 @@
<template>
<article class="detail-card panel enterprise-detail-card" :class="{ wide }">
<div v-if="title || description || $slots.actions" class="card-head">
<div>
<h3 v-if="title">{{ title }}</h3>
<p v-if="description">{{ description }}</p>
</div>
<slot name="actions"></slot>
</div>
<slot></slot>
</article>
</template>
<script setup>
defineProps({
description: { type: String, default: '' },
title: { type: String, default: '' },
wide: { type: Boolean, default: false }
})
</script>

View File

@@ -0,0 +1,71 @@
<template>
<article class="enterprise-detail-page" :class="variant">
<div class="detail-scroll">
<TableLoadingState
v-if="loading"
class="detail-loading-state panel"
variant="panel"
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
:show-skeleton="false"
/>
<section v-else-if="error" class="detail-inline-state panel error">
<slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>{{ errorTitle }}</strong>
<p>{{ error }}</p>
</div>
</slot>
</section>
<template v-else>
<section v-if="$slots.hero" class="detail-hero panel">
<slot name="hero"></slot>
</section>
<slot></slot>
<div v-if="$slots.main || $slots.side" class="detail-grid">
<section v-if="$slots.main" class="detail-main">
<slot name="main"></slot>
</section>
<aside v-if="$slots.side" class="detail-side">
<slot name="side"></slot>
</aside>
</div>
</template>
</div>
<footer v-if="backLabel || $slots.actions" class="detail-actions">
<button v-if="backLabel" class="back-action" type="button" @click="emit('back')">
<i class="mdi mdi-arrow-left"></i>
<span>{{ backLabel }}</span>
</button>
<div v-if="$slots.actions" class="detail-action-group">
<slot name="actions"></slot>
</div>
</footer>
</article>
</template>
<script setup>
import TableLoadingState from './TableLoadingState.vue'
defineProps({
backLabel: { type: String, default: '' },
error: { type: String, default: '' },
errorTitle: { type: String, default: '详情加载失败' },
loading: { type: Boolean, default: false },
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '正在加载详情' },
variant: { type: String, default: '' }
})
const emit = defineEmits(['back'])
</script>

View File

@@ -0,0 +1,197 @@
<template>
<article class="enterprise-list-page panel" :class="variant">
<slot name="before"></slot>
<nav v-if="hasTabs" class="status-tabs" :aria-label="tabsLabel">
<slot
name="tabs"
:tabs="normalizedTabs"
:active-tab="activeTab"
:select-tab="selectTab"
>
<button
v-for="tab in normalizedTabs"
:key="tab.value"
type="button"
:class="{ active: activeTab === tab.value }"
@click="selectTab(tab.value)"
>
<span>{{ tab.label }}</span>
<small v-if="tab.count !== null">{{ tab.count }}</small>
</button>
</slot>
</nav>
<div v-if="hasToolbar" class="list-toolbar">
<slot name="toolbar">
<div v-if="hasFilterRegion" class="filter-set">
<slot name="filters">
<label v-if="searchable" class="list-search">
<i class="mdi mdi-magnify"></i>
<input
:value="keyword"
type="search"
:placeholder="searchPlaceholder"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
</slot>
</div>
<div v-if="$slots.actions" class="toolbar-actions">
<slot name="actions"></slot>
</div>
</slot>
</div>
<p v-if="hasHint" class="hint">
<slot name="hint">
<i v-if="hintIcon" :class="hintIcon"></i>
<span>{{ hint }}</span>
</slot>
</p>
<div class="table-wrap" :class="{ 'is-empty': empty, 'has-error': Boolean(error) }">
<div v-if="loading" class="table-state">
<TableLoadingState
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
/>
</div>
<div v-else-if="error" class="table-state error">
<slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>{{ errorTitle }}</strong>
<p>{{ error }}</p>
<button v-if="retryLabel" class="state-action retry-btn" type="button" @click="emit('retry')">
{{ retryLabel }}
</button>
</slot>
</div>
<slot v-else-if="empty" name="empty">
<TableEmptyState
v-if="emptyState"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc || emptyState.description"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips || []"
@action="emit('empty-action')"
/>
</slot>
<slot v-else name="table"></slot>
</div>
<slot name="footer">
<EnterprisePagination
v-if="showPagination"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pages"
:show-page-size="showPageSize"
:summary="summary"
:total="total"
:total-pages="totalPages"
@update:current-page="emit('update:currentPage', $event)"
@update:page-size="emit('update:pageSize', $event)"
@page-size-change="emit('page-size-change', $event)"
/>
</slot>
</article>
</template>
<script setup>
import { computed, useSlots } from 'vue'
import EnterprisePagination from './EnterprisePagination.vue'
import TableEmptyState from './TableEmptyState.vue'
import TableLoadingState from './TableLoadingState.vue'
const props = defineProps({
activeTab: { type: [String, Number], default: '' },
currentPage: { type: Number, default: 1 },
empty: { type: Boolean, default: false },
emptyState: { type: Object, default: null },
error: { type: String, default: '' },
errorTitle: { type: String, default: '列表加载失败' },
hint: { type: String, default: '' },
hintIcon: { type: String, default: 'mdi mdi-information-outline' },
keyword: { type: String, default: '' },
loading: { type: Boolean, default: false },
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '数据同步中' },
pageSize: { type: Number, default: 10 },
pageSizeOptions: {
type: Array,
default: () => []
},
pages: {
type: Array,
default: () => []
},
retryLabel: { type: String, default: '重新加载' },
searchable: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '搜索' },
showPageSize: { type: Boolean, default: true },
showPagination: { type: Boolean, default: false },
summary: { type: String, default: '' },
tabs: {
type: Array,
default: () => []
},
tabsLabel: { type: String, default: '列表视图' },
total: { type: Number, default: 0 },
totalPages: { type: Number, default: 1 },
variant: { type: String, default: '' }
})
const emit = defineEmits([
'empty-action',
'page-size-change',
'retry',
'update:activeTab',
'update:currentPage',
'update:keyword',
'update:pageSize'
])
const slots = useSlots()
const normalizedTabs = computed(() =>
props.tabs.map((tab) => {
if (tab && typeof tab === 'object') {
const value = tab.value ?? tab.label ?? ''
return {
value,
label: String(tab.label ?? value),
count: tab.count ?? null
}
}
return {
value: tab,
label: String(tab),
count: null
}
})
)
const hasTabs = computed(() => normalizedTabs.value.length > 0 || Boolean(slots.tabs))
const hasToolbar = computed(() => props.searchable || Boolean(slots.filters || slots.actions || slots.toolbar))
const hasFilterRegion = computed(() => props.searchable || Boolean(slots.filters))
const hasHint = computed(() => Boolean(props.hint || slots.hint))
function selectTab(tab) {
emit('update:activeTab', tab)
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<footer class="list-foot enterprise-pagination">
<span class="page-summary">{{ summaryText }}</span>
<div class="pager" aria-label="分页">
<button
class="page-nav"
type="button"
:disabled="currentPage <= 1"
aria-label="上一页"
@click="setPage(currentPage - 1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<template v-for="item in pageItems" :key="String(item)">
<span v-if="item === 'ellipsis'" class="page-ellipsis" aria-hidden="true">...</span>
<button
v-else
class="page-number"
:class="{ active: currentPage === item }"
type="button"
:aria-current="currentPage === item ? 'page' : undefined"
@click="setPage(item)"
>
{{ item }}
</button>
</template>
<button
class="page-nav"
type="button"
:disabled="currentPage >= totalPages"
aria-label="下一页"
@click="setPage(currentPage + 1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-if="showPageSize"
class="page-size-select"
:model-value="pageSize"
:options="pageSizeOptions"
size="small"
@change="setPageSize"
/>
</footer>
</template>
<script setup>
import { computed } from 'vue'
import EnterpriseSelect from './EnterpriseSelect.vue'
const props = defineProps({
currentPage: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
pageSizeOptions: {
type: Array,
default: () => []
},
pages: {
type: Array,
default: () => []
},
showPageSize: { type: Boolean, default: true },
summary: { type: String, default: '' },
total: { type: Number, default: 0 },
totalPages: { type: Number, default: 1 }
})
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
const pageItems = computed(() => {
if (props.pages.length) {
return props.pages
}
return Array.from({ length: props.totalPages }, (_, index) => index + 1)
})
const summaryText = computed(() => {
if (props.summary) {
return props.summary
}
return `${props.total} 条,当前第 ${props.currentPage}`
})
function setPage(page) {
if (page === 'ellipsis') {
return
}
const nextPage = Math.min(Math.max(Number(page) || 1, 1), props.totalPages)
if (nextPage !== props.currentPage) {
emit('update:currentPage', nextPage)
}
}
function setPageSize(size) {
emit('update:pageSize', size)
emit('page-size-change', size)
}
</script>

View File

@@ -10,6 +10,7 @@ import { installThemeSkin } from './composables/useThemeSkin.js'
import { installSessionNavigation } from './composables/useSystemState.js' import { installSessionNavigation } from './composables/useSystemState.js'
import './assets/styles/element-plus-theme.css' import './assets/styles/element-plus-theme.css'
import './assets/styles/detail-page-corners.css' import './assets/styles/detail-page-corners.css'
import './assets/styles/components/enterprise-page-shell.css'
const app = createApp(App) const app = createApp(App)

View File

@@ -10,66 +10,43 @@
@request-deleted="handleDetailDeleted" @request-deleted="handleDetailDeleted"
/> />
<article v-else class="approval-list panel"> <EnterpriseListPage
<nav class="status-tabs" aria-label="审批状态"> v-else
<button v-model:active-tab="activeTab"
v-for="tab in tabs" class="approval-list"
:key="tab" :tabs="tabs"
type="button" tabs-label="审批状态"
:class="{ active: activeTab === tab }" :loading="loading"
@click="activeTab = tab" :error="error"
> :empty="showEmpty"
{{ tab }} :empty-state="approvalEmptyState"
loading-title="审批待办同步中"
loading-message="正在加载当前可见的待审批报销单据"
loading-icon="mdi mdi-clipboard-check-outline"
error-title="审批列表加载失败"
:show-pagination="false"
@retry="reload"
@empty-action="handleEmptyAction"
>
<template #filters>
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button> </button>
</nav> </template>
<div class="list-toolbar"> <template #hint>
<div class="filter-set"> <i class="mdi mdi-information-outline"></i>
<div class="list-search"> 点击单据行查看审批详情
<i class="mdi mdi-magnify"></i> </template>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn"> <template #table>
<span>{{ filter }}</span> <table>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击单据行查看审批详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="审批待办同步中"
message="正在加载当前可见的待审报销单据"
icon="mdi mdi-clipboard-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>审批列表加载失败</strong>
<p>{{ error }}</p>
<button class="state-action" type="button" @click="reload">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="approvalEmptyState.eyebrow"
:title="approvalEmptyState.title"
:description="approvalEmptyState.desc"
:icon="approvalEmptyState.icon"
:action-label="approvalEmptyState.actionLabel"
:action-icon="approvalEmptyState.actionIcon"
:tone="approvalEmptyState.tone"
:art-label="approvalEmptyState.artLabel"
:tips="approvalEmptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup> <colgroup>
<col><col><col><col><col><col><col><col><col><col><col> <col><col><col><col><col><col><col><col><col><col><col>
</colgroup> </colgroup>
@@ -113,8 +90,8 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </template>
</article> </EnterpriseListPage>
</section> </section>
</template> </template>

View File

@@ -9,91 +9,68 @@
@request-deleted="reload" @request-deleted="reload"
/> />
<article v-else class="approval-list panel"> <EnterpriseListPage
<nav class="status-tabs" aria-label="归档分类"> v-else
<button v-model:active-tab="activeTab"
v-for="tab in tabs" class="approval-list"
:key="tab" :tabs="tabs"
type="button" tabs-label="归档分类"
:class="{ active: activeTab === tab }" :loading="loading"
@click="activeTab = tab" :error="error"
:empty="showEmpty"
:empty-state="archiveEmptyState"
loading-title="归档数据同步中"
loading-message="正在加载公司已归档的报销单据"
loading-icon="mdi mdi-archive-check-outline"
error-title="归档列表加载失败"
:show-pagination="false"
@retry="reload"
@empty-action="handleEmptyAction"
>
<template #filters>
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、归档类型..." />
</div>
<el-dropdown
v-for="menu in filterMenus"
:key="menu.key"
class="archive-filter-control"
trigger="click"
placement="bottom-start"
popper-class="archive-filter-menu"
@command="selectFilterValue(menu.key, $event)"
> >
{{ tab }} <button class="filter-btn archive-filter-trigger" type="button">
</button> <span>{{ menu.label }}</span>
</nav> <i class="mdi mdi-chevron-down"></i>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="option in menu.options"
:key="`${menu.key}-${option.value}`"
:command="option.value"
:class="{ 'is-active': menu.activeValue === option.value }"
class="archive-filter-option"
:aria-current="menu.activeValue === option.value ? 'true' : undefined"
>
<i v-if="menu.activeValue === option.value" class="mdi mdi-check"></i>
<span>{{ option.label }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<div class="list-toolbar"> <template #hint>
<div class="filter-set"> <i class="mdi mdi-information-outline"></i>
<div class="list-search"> 归档中心保存公司已付款或已归档的报销数据点击单据行查看详情
<i class="mdi mdi-magnify"></i> </template>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、归档类型..." />
</div>
<el-dropdown <template #table>
v-for="menu in filterMenus" <table>
:key="menu.key"
class="archive-filter-control"
trigger="click"
placement="bottom-start"
popper-class="archive-filter-menu"
@command="selectFilterValue(menu.key, $event)"
>
<button class="filter-btn archive-filter-trigger" type="button">
<span>{{ menu.label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="option in menu.options"
:key="`${menu.key}-${option.value}`"
:command="option.value"
:class="{ 'is-active': menu.activeValue === option.value }"
class="archive-filter-option"
:aria-current="menu.activeValue === option.value ? 'true' : undefined"
>
<i v-if="menu.activeValue === option.value" class="mdi mdi-check"></i>
<span>{{ option.label }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已付款或已归档的报销数据点击单据行查看详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="归档数据同步中"
message="正在加载公司已归档的报销单据"
icon="mdi mdi-archive-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>归档列表加载失败</strong>
<p>{{ error }}</p>
<button class="state-action" type="button" @click="reload">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="archiveEmptyState.eyebrow"
:title="archiveEmptyState.title"
:description="archiveEmptyState.desc"
:icon="archiveEmptyState.icon"
:action-label="archiveEmptyState.actionLabel"
:action-icon="archiveEmptyState.actionIcon"
:tone="archiveEmptyState.tone"
:art-label="archiveEmptyState.artLabel"
:tips="archiveEmptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup> <colgroup>
<col><col><col><col><col><col><col><col><col> <col><col><col><col><col><col><col><col><col>
</colgroup> </colgroup>
@@ -129,8 +106,8 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </template>
</article> </EnterpriseListPage>
</section> </section>
</template> </template>

View File

@@ -6,112 +6,124 @@
</div> </div>
<template v-else> <template v-else>
<article class="system-logs-list panel"> <EnterpriseListPage
<div class="document-toolbar"> v-model:current-page="currentPage"
<div class="filter-set"> v-model:page-size="pageSize"
<div class="list-search"> class="system-logs-list"
<i class="mdi mdi-magnify"></i> :loading="systemLogLoading && !visibleSystemLogEntries.length"
<input :empty="!systemLogLoading && !visibleSystemLogEntries.length"
v-model.trim="systemSearchKeyword" :total="totalCount"
type="search" :total-pages="totalPages"
placeholder="搜索摘要、模块、请求路径或 Request ID" :pages="visiblePageItems"
/> :page-size-options="pageSizeOptions"
</div> :summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} 页`"
:show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0"
<div class="document-filter status-dropdown-filter" :class="{ open: openFilterKey === 'level' }"> loading-title="系统日志同步中"
<button loading-message="正在加载系统运行日志记录"
class="filter-btn status-filter-trigger" loading-icon="mdi mdi-text-box-search-outline"
type="button" @page-size-change="changePageSize"
:aria-expanded="openFilterKey === 'level'" >
@click="toggleFilter('level')" <template #filters>
> <div class="list-search">
<i class="mdi mdi-filter-variant"></i> <i class="mdi mdi-magnify"></i>
<span>{{ systemLevelFilterLabel }}</span> <input
<i class="mdi mdi-chevron-down"></i> v-model.trim="systemSearchKeyword"
</button> type="search"
<div placeholder="搜索摘要模块请求路径或 Request ID"
v-if="openFilterKey === 'level'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="日志级别"
>
<button
v-for="option in systemLevelFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemLevelFilter === option.value"
:class="{ active: systemLevelFilter === option.value }"
@click="selectLevelFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="document-filter" :class="{ open: openFilterKey === 'eventType' }">
<button
class="filter-btn"
type="button"
:aria-expanded="openFilterKey === 'eventType'"
@click="toggleFilter('eventType')"
>
<span>{{ systemEventTypeFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'eventType'"
class="document-filter-menu"
role="listbox"
aria-label="事件类型"
>
<button
v-for="option in systemEventTypeFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemEventTypeFilter === option.value"
:class="{ active: systemEventTypeFilter === option.value }"
@click="selectEventTypeFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="document-actions">
<button v-if="hasActiveFilters" class="create-request-btn secondary" type="button" @click="resetFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
type="button"
class="create-request-btn"
:disabled="systemLogLoading"
@click="loadSystemLogs(true)"
>
<i class="mdi mdi-refresh"></i>
<span>{{ systemLogLoading ? '刷新中...' : '刷新日志' }}</span>
</button>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
点击任意行可查看系统日志详情
</p>
<div class="table-wrap" :class="{ 'is-empty': !systemLogLoading && !filteredSystemLogEntries.length }">
<div v-if="systemLogLoading && !visibleSystemLogEntries.length" class="table-state">
<TableLoadingState
title="系统日志同步中"
message="正在加载系统运行日志记录"
icon="mdi mdi-text-box-search-outline"
/> />
</div> </div>
<table v-else-if="visibleSystemLogEntries.length" class="system-log-table"> <div class="document-filter status-dropdown-filter" :class="{ open: openFilterKey === 'level' }">
<button
class="filter-btn status-filter-trigger"
type="button"
:aria-expanded="openFilterKey === 'level'"
@click="toggleFilter('level')"
>
<i class="mdi mdi-filter-variant"></i>
<span>{{ systemLevelFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'level'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="日志级别"
>
<button
v-for="option in systemLevelFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemLevelFilter === option.value"
:class="{ active: systemLevelFilter === option.value }"
@click="selectLevelFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="document-filter" :class="{ open: openFilterKey === 'eventType' }">
<button
class="filter-btn"
type="button"
:aria-expanded="openFilterKey === 'eventType'"
@click="toggleFilter('eventType')"
>
<span>{{ systemEventTypeFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'eventType'"
class="document-filter-menu"
role="listbox"
aria-label="事件类型"
>
<button
v-for="option in systemEventTypeFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemEventTypeFilter === option.value"
:class="{ active: systemEventTypeFilter === option.value }"
@click="selectEventTypeFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
<template #actions>
<button v-if="hasActiveFilters" class="create-request-btn secondary" type="button" @click="resetFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
type="button"
class="create-request-btn"
:disabled="systemLogLoading"
@click="loadSystemLogs(true)"
>
<i class="mdi mdi-refresh"></i>
<span>{{ systemLogLoading ? '刷新中...' : '刷新日志' }}</span>
</button>
</template>
<template #hint>
<i class="mdi mdi-information-outline"></i>
点击任意行可查看系统日志详情
</template>
<template #empty>
<div class="inline-empty">
当前筛选条件下没有系统日志记录。
</div>
</template>
<template #table>
<table class="system-log-table">
<colgroup> <colgroup>
<col class="col-time"> <col class="col-time">
<col class="col-level"> <col class="col-level">
@@ -155,48 +167,12 @@
<strong>{{ entry.summary || entry.message }}</strong> <strong>{{ entry.summary || entry.message }}</strong>
<span>{{ formatSummary(entry.message) }}</span> <span>{{ formatSummary(entry.message) }}</span>
</td> </td>
<td class="trace-cell">{{ entry.request_id || '—' }}</td> <td class="trace-cell">{{ entry.request_id || '-' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</template>
<div v-else class="inline-empty"> </EnterpriseListPage>
当前筛选条件下没有系统日志记录
</div>
</div>
<footer v-if="!systemLogLoading && filteredSystemLogEntries.length" class="list-foot">
<span class="page-summary"> {{ totalCount }} 条系统日志目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<template v-for="page in visiblePageItems" :key="page === 'ellipsis' ? 'ellipsis' : page">
<span v-if="page === 'ellipsis'" class="page-ellipsis" aria-hidden="true">...</span>
<button
v-else
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
</template>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
/>
</footer>
</article>
</template> </template>
</section> </section>
</template> </template>

View File

@@ -1,96 +1,81 @@
<template> <template>
<section class="travel-page"> <section class="travel-page">
<article class="travel-list panel"> <EnterpriseListPage
<nav class="status-tabs" aria-label="个人报销状态"> v-model:active-tab="activeTab"
<button v-model:current-page="currentPage"
v-for="tab in tabs" v-model:page-size="pageSize"
:key="tab" class="travel-list"
type="button" :tabs="tabs"
:class="{ active: activeTab === tab }" tabs-label="个人报销状态"
@click="activeTab = tab" :loading="loading"
> :error="error"
{{ tab }} :empty="showEmpty"
</button> :empty-state="emptyState"
</nav> :total="totalCount"
:total-pages="totalPages"
<div class="list-toolbar"> :page-size-options="pageSizeOptions"
<div class="filter-set"> :show-pagination="showTable"
<div class="list-search"> loading-title="真实报销数据同步中"
<i class="mdi mdi-magnify"></i> loading-message="正在加载后端返回的个人报销单据"
<input v-model="listKeyword" type="search" placeholder="搜索单号、事由、报销类型..." /> loading-icon="mdi mdi-file-document-outline"
</div> error-title="报销列表加载失败"
@retry="emit('reload')"
<div class="date-range-filter" :class="{ open: datePopover }"> @empty-action="handleEmptyAction"
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover"> @page-size-change="changePageSize"
<span class="date-range-label">{{ dateRangeLabel }}</span> >
<i class="mdi mdi-calendar"></i> <template #filters>
</button> <div class="list-search">
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段"> <i class="mdi mdi-magnify"></i>
<header> <input v-model="listKeyword" type="search" placeholder="搜索单号、事由、报销类型..." />
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false"><i class="mdi mdi-close"></i></button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="datePopover = false">取消</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div> </div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false"><i class="mdi mdi-close"></i></button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="datePopover = false">取消</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</template>
<template #actions>
<button class="create-request-btn" type="button" @click="emit('create-request')"> <button class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i> <i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span> <span>发起报销</span>
</button> </button>
</div> </template>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意行可查看单据详情</p> <template #hint>
<i class="mdi mdi-information-outline"></i>
点击任意行可查看单据详情
</template>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }"> <template #table>
<div v-if="loading" class="table-state"> <table>
<TableLoadingState
title="真实报销数据同步中"
message="正在加载后端返回的个人报销单据"
icon="mdi mdi-file-document-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>报销列表加载失败</strong>
<p>{{ error }}</p>
<button class="retry-btn" type="button" @click="emit('reload')">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup> <colgroup>
<col class="col-id"> <col class="col-id">
<col class="col-type"> <col class="col-type">
@@ -126,24 +111,8 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </template>
</EnterpriseListPage>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--"><i class="mdi mdi-chevron-left"></i></button>
<button v-for="p in totalPages" :key="p" class="page-number" :class="{ active: currentPage === p }" type="button" :aria-current="currentPage === p ? 'page' : undefined" @click="currentPage = p">{{ p }}</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++"><i class="mdi mdi-chevron-right"></i></button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
/>
</footer>
</article>
</section> </section>
</template> </template>

View File

@@ -1,7 +1,6 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js' import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js' import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
@@ -115,9 +114,8 @@ function buildApprovalRow(request) {
export default { export default {
name: 'ApprovalCenterView', name: 'ApprovalCenterView',
components: { components: {
TravelRequestDetailView, EnterpriseListPage,
TableLoadingState, TravelRequestDetailView
TableEmptyState
}, },
setup() { setup() {
const { currentUser } = useSystemState() const { currentUser } = useSystemState()

View File

@@ -1,7 +1,6 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js' import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js' import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
import { import {
@@ -92,9 +91,8 @@ function resolveFilterLabel(options, activeValue, fallbackLabel) {
export default { export default {
name: 'ArchiveCenterView', name: 'ArchiveCenterView',
components: { components: {
TravelRequestDetailView, EnterpriseListPage,
TableLoadingState, TravelRequestDetailView
TableEmptyState
}, },
setup() { setup() {
const activeTab = ref(ARCHIVE_TAB_ALL) const activeTab = ref(ARCHIVE_TAB_ALL)

View File

@@ -1,8 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
import { fetchSystemLogEntries } from '../../services/systemLogs.js' import { fetchSystemLogEntries } from '../../services/systemLogs.js'
@@ -62,8 +61,7 @@ function resolveSystemOutcomeTone(outcome) {
export default { export default {
name: 'LogsView', name: 'LogsView',
components: { components: {
EnterpriseSelect, EnterpriseListPage
TableLoadingState
}, },
emits: ['summary-change'], emits: ['summary-change'],
setup(_, { emit }) { setup(_, { emit }) {

View File

@@ -1,8 +1,6 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js' import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
function extractRowDate(value) { function extractRowDate(value) {
@@ -13,9 +11,7 @@ function extractRowDate(value) {
export default { export default {
name: 'RequestsView', name: 'RequestsView',
components: { components: {
EnterpriseSelect, EnterpriseListPage
TableLoadingState,
TableEmptyState
}, },
props: { props: {
filteredRequests: { type: Array, required: true }, filteredRequests: { type: Array, required: true },