Initial commit: OneOS prototype workspace baseline.
Include weekly report and other prototype sources with project tooling config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
964
src/common/DesignMdBatchShowcase/base.css
Normal file
964
src/common/DesignMdBatchShowcase/base.css
Normal file
@@ -0,0 +1,964 @@
|
||||
.dmb-page {
|
||||
min-height: 100vh;
|
||||
background: var(--dmb-bg, #ffffff);
|
||||
color: var(--dmb-ink, #111318);
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
}
|
||||
|
||||
.dmb-layout {
|
||||
width: min(1320px, calc(100vw - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 42px 0 76px;
|
||||
}
|
||||
|
||||
.dmb-sheet {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dmb-sheet-head {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--dmb-border, #e1e2e7) 72%, transparent);
|
||||
}
|
||||
|
||||
.dmb-sheet-head-main {
|
||||
min-width: 0;
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.dmb-sheet-actions {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
max-width: min(100%, 640px);
|
||||
}
|
||||
|
||||
.dmb-sheet-head p {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dmb-ink-subtle, #686c76);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dmb-sheet-head h1 {
|
||||
margin: 0;
|
||||
color: var(--dmb-ink, #121319);
|
||||
font-family: var(--dmb-font-display, var(--dmb-font-body, var(--dmb-font-sans)));
|
||||
font-size: 54px;
|
||||
line-height: 1.08;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.dmb-sheet-links {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.dmb-sheet-links a {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 36px;
|
||||
block-size: 36px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dmb-ink-subtle, #64748b);
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
transition: transform 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.dmb-sheet-links a:hover {
|
||||
color: var(--dmb-ink, #111318);
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dmb-sheet-links a:focus-visible {
|
||||
color: var(--dmb-ink, #111318);
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.dmb-sheet-links svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.dmb-sheet-links a::after {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 4;
|
||||
min-width: max-content;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
color: #ffffff;
|
||||
padding: 6px 8px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.dmb-sheet-links a:hover::after,
|
||||
.dmb-sheet-links a:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.dmb-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
border: 1px solid #e1e2e7;
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
background: #f7f5ef;
|
||||
}
|
||||
|
||||
.dmb-tabs button {
|
||||
min-height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--dmb-radius-control, 0);
|
||||
background: transparent;
|
||||
color: #5f6370;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dmb-tabs button[aria-selected="true"] {
|
||||
border-color: color-mix(in srgb, var(--dmb-accent) 38%, #d9dbe2);
|
||||
background: #ffffff;
|
||||
color: #111318;
|
||||
}
|
||||
|
||||
.dmb-tab-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dmb-preview {
|
||||
width: 100%;
|
||||
height: 620px;
|
||||
border: 0;
|
||||
border-radius: var(--dmb-radius-preview, 0);
|
||||
background: color-mix(in srgb, var(--dmb-accent) 4%, #f6f7fa);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.dmb-preview::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
border: 1px solid color-mix(in srgb, var(--dmb-border, #dfe1e7) 80%, transparent);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.dmb-preview-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--dmb-radius-preview, 0);
|
||||
}
|
||||
|
||||
.dmb-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dmb-preview iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dmb-preview-label {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: var(--dmb-radius-pill, 0);
|
||||
background: rgba(17, 19, 24, 0.7);
|
||||
color: #ffffff;
|
||||
padding: 5px 9px;
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dmb-preview-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: default;
|
||||
color: #8a8c95;
|
||||
}
|
||||
|
||||
.dmb-preview-empty strong,
|
||||
.dmb-preview-empty span {
|
||||
display: block;
|
||||
position: static;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dmb-preview-empty strong {
|
||||
color: #1b1d24;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.dmb-description {
|
||||
max-width: 1040px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dmb-description p {
|
||||
margin: 0;
|
||||
color: var(--dmb-ink-muted, #3f424b);
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.dmb-description p + p {
|
||||
margin-top: 4px;
|
||||
color: var(--dmb-ink-subtle, #6c707a);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dmb-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dmb-overview-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 18px 28px;
|
||||
align-items: start;
|
||||
margin-top: 22px;
|
||||
padding: 18px 0 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--dmb-border, #e1e2e7) 64%, transparent);
|
||||
}
|
||||
|
||||
.dmb-overview-meta .dmb-tags {
|
||||
justify-content: flex-end;
|
||||
margin-top: 2px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.dmb-tags span,
|
||||
.dmb-muted {
|
||||
border: 1px solid #e2e3e8;
|
||||
border-radius: var(--dmb-radius-pill, 0);
|
||||
background: #f4f1ea;
|
||||
color: #545964;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dmb-token-card,
|
||||
.dmb-block {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.dmb-token-card {
|
||||
border: 1px solid #e1e2e7;
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
background: color-mix(in srgb, var(--dmb-accent) 9%, #ffffff);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 190px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dmb-token-rail,
|
||||
.dmb-token-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dmb-token-chip {
|
||||
min-height: 58px;
|
||||
border-radius: var(--dmb-radius-control, 0);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dmb-token-chip strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dmb-token-chip code {
|
||||
font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-size: 10px;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.dmb-token-grid {
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(164px, 0.82fr) minmax(150px, 0.76fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dmb-token-grid > div {
|
||||
min-height: 90px;
|
||||
border-radius: var(--dmb-radius-control, 0);
|
||||
background: color-mix(in srgb, var(--dmb-accent) 7%, #ffffff);
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dmb-token-grid small {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8a8c95;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dmb-token-grid strong {
|
||||
font-family: var(--dmb-font-display, var(--dmb-font-body, var(--dmb-font-sans)));
|
||||
font-size: 48px;
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.dmb-token-grid button,
|
||||
.dmb-component-strip button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--dmb-accent);
|
||||
border-radius: var(--dmb-radius-control, 0);
|
||||
background: var(--dmb-accent);
|
||||
color: var(--dmb-accent-contrast);
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
margin: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.dmb-token-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dmb-token-buttons button {
|
||||
width: 118px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dmb-token-grid button span {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.dmb-token-grid .dmb-text-button {
|
||||
background: transparent;
|
||||
color: var(--dmb-link);
|
||||
}
|
||||
|
||||
.dmb-outline-button {
|
||||
background: #ffffff !important;
|
||||
color: var(--dmb-link) !important;
|
||||
border-color: #d9d9df !important;
|
||||
}
|
||||
|
||||
.dmb-token-grid input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: 1px solid #ededf0;
|
||||
border-radius: var(--dmb-radius-control, 0);
|
||||
background: #ffffff;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
}
|
||||
|
||||
.dmb-lines {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.dmb-lines span {
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: var(--dmb-accent);
|
||||
}
|
||||
|
||||
.dmb-lines span:nth-child(2) {
|
||||
width: 84%;
|
||||
}
|
||||
|
||||
.dmb-lines span:nth-child(3) {
|
||||
width: 68%;
|
||||
}
|
||||
|
||||
.dmb-lines span:nth-child(4) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.dmb-block h2 {
|
||||
margin: 0 0 14px;
|
||||
color: var(--dmb-ink, #111318);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.dmb-block h2 span {
|
||||
margin-left: 8px;
|
||||
color: var(--dmb-ink-subtle, var(--dmb-link));
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.dmb-palette {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dmb-palette article {
|
||||
min-height: 122px;
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dmb-palette strong,
|
||||
.dmb-palette code,
|
||||
.dmb-palette p {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dmb-palette strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dmb-palette code {
|
||||
font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dmb-palette p {
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.dmb-type-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.dmb-type-list article,
|
||||
.dmb-component-strip,
|
||||
.dmb-component-grid,
|
||||
.dmb-spacing-scale {
|
||||
border: 1px solid #ededf0;
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
background: #f0f0f1;
|
||||
}
|
||||
|
||||
.dmb-type-list article {
|
||||
min-height: 58px;
|
||||
padding: 14px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dmb-type-list div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dmb-type-list strong {
|
||||
color: #111318;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dmb-type-list span {
|
||||
color: #7d808a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dmb-type-list p {
|
||||
margin: 0;
|
||||
color: #111318;
|
||||
font-size: var(--dmb-type-size, 18px);
|
||||
line-height: 1.04;
|
||||
font-weight: var(--dmb-type-weight, 600);
|
||||
}
|
||||
|
||||
.dmb-type-wide {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 224px;
|
||||
}
|
||||
|
||||
.dmb-type-wide .dmb-type-sample {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dmb-type-list .dmb-type-sample-zh,
|
||||
.dmb-type-list .dmb-type-sample-en {
|
||||
display: block;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
font-style: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dmb-type-list .dmb-type-sample-en {
|
||||
margin-top: 0.18em;
|
||||
color: color-mix(in srgb, var(--dmb-link) 72%, #111318);
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.dmb-type-wide .dmb-type-sample-en {
|
||||
margin-top: 0.2em;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.dmb-type-sample-fit {
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dmb-type-list .dmb-type-sample-fit-inner {
|
||||
display: inline-block;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
max-width: none;
|
||||
white-space: inherit;
|
||||
transform: scale(var(--dmb-type-fit-scale, 1));
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.dmb-type-display p {
|
||||
font-family: var(--dmb-font-display, var(--dmb-font-body, var(--dmb-font-sans)));
|
||||
}
|
||||
|
||||
.dmb-type-body p {
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.dmb-type-mono p {
|
||||
font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dmb-spacing-scale {
|
||||
height: 116px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dmb-spacing-scale div {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
color: #777a84;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dmb-spacing-scale i {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 18px;
|
||||
border-left: 1px solid #999ca6;
|
||||
border-right: 1px solid #999ca6;
|
||||
border-bottom: 1px solid #999ca6;
|
||||
}
|
||||
|
||||
.dmb-radius-grid,
|
||||
.dmb-shadow-grid,
|
||||
.dmb-border-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dmb-shadow-grid,
|
||||
.dmb-border-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dmb-radius-grid article,
|
||||
.dmb-shadow-grid article,
|
||||
.dmb-border-grid article {
|
||||
min-height: 154px;
|
||||
border: 1px solid #ededf0;
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dmb-radius-sample {
|
||||
height: 58px;
|
||||
border-radius: var(--dmb-radius-demo);
|
||||
background: var(--dmb-surface);
|
||||
border: 1px solid var(--dmb-border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dmb-radius-grid strong,
|
||||
.dmb-shadow-grid strong,
|
||||
.dmb-border-grid strong {
|
||||
display: block;
|
||||
color: #151720;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.dmb-radius-grid code {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--dmb-link);
|
||||
font-family: var(--dmb-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dmb-radius-grid p,
|
||||
.dmb-shadow-grid p,
|
||||
.dmb-border-grid p {
|
||||
margin: 8px 0 0;
|
||||
color: #676b76;
|
||||
font-size: 11px;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.dmb-shadow-grid article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dmb-border-sample {
|
||||
display: block;
|
||||
height: 58px;
|
||||
margin: 14px 0 12px;
|
||||
border: 1px solid #dfe1e7;
|
||||
border-radius: 0;
|
||||
background: var(--dmb-border-sample-bg, var(--dmb-surface, var(--dmb-bg, #ffffff)));
|
||||
}
|
||||
|
||||
.dmb-component-strip {
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dmb-component-strip .dmb-button-hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.dmb-component-grid {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dmb-component-grid article {
|
||||
min-height: 122px;
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5e9;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dmb-component-grid small {
|
||||
color: var(--dmb-link);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dmb-component-grid h3 {
|
||||
margin: 12px 0 8px;
|
||||
color: #151720;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dmb-component-grid p {
|
||||
margin: 0;
|
||||
color: #666a75;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dmb-dos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dmb-dos-grid article {
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
padding: 16px;
|
||||
min-height: 142px;
|
||||
}
|
||||
|
||||
.dmb-do {
|
||||
border: 1px solid #bcebd8;
|
||||
background: #effbf6;
|
||||
color: #0b6b43;
|
||||
}
|
||||
|
||||
.dmb-dont {
|
||||
border: 1px solid #f4c7c7;
|
||||
background: #fff1f1;
|
||||
color: #b32626;
|
||||
}
|
||||
|
||||
.dmb-dos-grid strong {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dmb-dos-grid p {
|
||||
margin: 0 0 9px;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dmb-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(8, 10, 14, 0.88);
|
||||
padding: 54px 24px 28px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dmb-lightbox > button {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
height: 34px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--dmb-radius-control, 0);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dmb-lightbox-scroll {
|
||||
width: min(1040px, calc(100vw - 48px));
|
||||
max-width: calc(100vw - 48px);
|
||||
height: auto;
|
||||
max-height: calc(100vh - 86px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-radius: var(--dmb-radius-preview, 0);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dmb-lightbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-radius: var(--dmb-radius-preview, 0);
|
||||
}
|
||||
|
||||
.dmb-lightbox iframe {
|
||||
width: 100%;
|
||||
height: calc(100vh - 86px);
|
||||
min-height: calc(100vh - 86px);
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #ffffff;
|
||||
border-radius: var(--dmb-radius-preview, 0);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.dmb-layout {
|
||||
width: min(100% - 32px, 1320px);
|
||||
}
|
||||
|
||||
.dmb-token-grid,
|
||||
.dmb-type-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dmb-radius-grid,
|
||||
.dmb-shadow-grid,
|
||||
.dmb-border-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dmb-layout {
|
||||
width: min(100% - 20px, 1320px);
|
||||
padding: 20px 0 44px;
|
||||
}
|
||||
|
||||
.dmb-sheet {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dmb-sheet-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dmb-sheet-actions,
|
||||
.dmb-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dmb-sheet-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dmb-sheet-links {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.dmb-tabs button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.dmb-preview {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.dmb-overview-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dmb-overview-meta .dmb-tags {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.dmb-lightbox {
|
||||
padding: 52px 12px 16px;
|
||||
}
|
||||
|
||||
.dmb-lightbox-scroll {
|
||||
width: min(720px, calc(100vw - 24px));
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100vh - 68px);
|
||||
}
|
||||
|
||||
.dmb-token-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dmb-token-grid,
|
||||
.dmb-type-list,
|
||||
.dmb-palette,
|
||||
.dmb-radius-grid,
|
||||
.dmb-shadow-grid,
|
||||
.dmb-border-grid,
|
||||
.dmb-component-grid,
|
||||
.dmb-dos-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dmb-sheet-head h1 {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.dmb-sheet-head span {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
28
src/common/DesignMdBatchShowcase/headerLinks.ts
Normal file
28
src/common/DesignMdBatchShowcase/headerLinks.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type BatchShowcaseSourceLinks = {
|
||||
sourceName?: string;
|
||||
originalDetailUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
|
||||
export type HeaderSourceLink = {
|
||||
ariaLabel: string;
|
||||
href: string;
|
||||
kind: 'website' | 'source';
|
||||
label: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function isExternalUrl(value?: string) {
|
||||
return Boolean(value && /^https?:\/\//i.test(value.trim()));
|
||||
}
|
||||
|
||||
export function getHeaderLinks(source?: BatchShowcaseSourceLinks): HeaderSourceLink[] {
|
||||
return [
|
||||
source?.websiteUrl && isExternalUrl(source.websiteUrl)
|
||||
? { ariaLabel: '打开品牌官网', href: source.websiteUrl, kind: 'website', label: '官网', title: '品牌官网' }
|
||||
: null,
|
||||
source?.originalDetailUrl && isExternalUrl(source.originalDetailUrl)
|
||||
? { ariaLabel: '打开采集来源', href: source.originalDetailUrl, kind: 'source', label: '来源', title: '主题来源' }
|
||||
: null,
|
||||
].filter((item): item is HeaderSourceLink => Boolean(item));
|
||||
}
|
||||
802
src/common/DesignMdBatchShowcase/index.tsx
Normal file
802
src/common/DesignMdBatchShowcase/index.tsx
Normal file
@@ -0,0 +1,802 @@
|
||||
import './base.css';
|
||||
import { FileSearch, Globe2 } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getHeaderLinks, type BatchShowcaseSourceLinks } from './headerLinks';
|
||||
|
||||
type TypographyFontRole = 'display' | 'body' | 'mono';
|
||||
|
||||
export type BatchPreviewImage = {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type BatchPaletteSwatch = {
|
||||
color: string;
|
||||
labelZh: string;
|
||||
labelEn: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
export type BatchStyleSystemItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
cssValue?: string;
|
||||
};
|
||||
|
||||
export type BatchShowcaseConfig = {
|
||||
brand: string;
|
||||
brandAlias?: string;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
source?: BatchShowcaseSourceLinks;
|
||||
variant: 'saas-devtool' | 'dashboard' | 'consumer-commerce' | 'editorial-agency' | 'dark-experimental';
|
||||
distributionTags: string[];
|
||||
fontStylesheets?: string[];
|
||||
palette: Array<string | BatchPaletteSwatch>;
|
||||
radius?: {
|
||||
control?: string;
|
||||
card?: string;
|
||||
preview?: string;
|
||||
pill?: string;
|
||||
source?: string;
|
||||
};
|
||||
spacing?: Record<string, string>;
|
||||
shadows?: BatchStyleSystemItem[];
|
||||
borders?: BatchStyleSystemItem[];
|
||||
typography: string[];
|
||||
previewImages: BatchPreviewImage[];
|
||||
panels: {
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
body: string;
|
||||
}[];
|
||||
usageGuidance?: {
|
||||
do: string[];
|
||||
dont: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type BatchShowcaseTab = {
|
||||
id: string;
|
||||
label: string;
|
||||
content: React.ReactNode;
|
||||
};
|
||||
|
||||
export type DesignMdBatchShowcaseProps = {
|
||||
config: BatchShowcaseConfig;
|
||||
tabs?: BatchShowcaseTab[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const variantLabels: Record<BatchShowcaseConfig['variant'], string> = {
|
||||
'saas-devtool': 'SaaS 开发工具 - SaaS / Devtool',
|
||||
dashboard: '数据仪表盘 - Dashboard',
|
||||
'consumer-commerce': '消费与商业 - Consumer / Commerce',
|
||||
'editorial-agency': '编辑与机构 - Editorial / Agency',
|
||||
'dark-experimental': '暗色实验 - Dark / Experimental',
|
||||
};
|
||||
|
||||
const NON_SELECTION_TAGS = new Set([
|
||||
'DESIGN.md',
|
||||
'设计 Token',
|
||||
'品牌指南',
|
||||
'GitHub Raw',
|
||||
'Tailwind v4',
|
||||
'CSS 变量',
|
||||
'图片资产',
|
||||
'浏览器复制',
|
||||
'目录来源',
|
||||
]);
|
||||
|
||||
const typographyRows = [
|
||||
{
|
||||
name: '展示标题 - Display',
|
||||
fontRole: 'display',
|
||||
layout: 'wide',
|
||||
size: '72px',
|
||||
weight: 'bold',
|
||||
cssWeight: 800,
|
||||
zh: '主题字形展示',
|
||||
en: 'Design System Display',
|
||||
},
|
||||
{
|
||||
name: '页面标题 - Heading',
|
||||
fontRole: 'display',
|
||||
layout: 'wide',
|
||||
size: '48px',
|
||||
weight: 'bold',
|
||||
cssWeight: 800,
|
||||
zh: '标题层级',
|
||||
en: 'Product Experience',
|
||||
},
|
||||
{
|
||||
name: '分区标题 - Section Heading',
|
||||
fontRole: 'display',
|
||||
layout: 'compact',
|
||||
size: '28px',
|
||||
weight: 'bold',
|
||||
cssWeight: 800,
|
||||
zh: '视觉系统',
|
||||
en: 'Visual Language',
|
||||
},
|
||||
{
|
||||
name: '副标题 - Subhead',
|
||||
fontRole: 'body',
|
||||
layout: 'compact',
|
||||
size: '20px',
|
||||
weight: 'semibold',
|
||||
cssWeight: 600,
|
||||
zh: '清晰的信息节奏',
|
||||
en: 'A clear rhythm for product stories.',
|
||||
},
|
||||
{
|
||||
name: '正文 - Body',
|
||||
fontRole: 'body',
|
||||
layout: 'compact',
|
||||
size: '15px',
|
||||
weight: 'regular',
|
||||
cssWeight: 400,
|
||||
zh: '每一种字体都让主题拥有不同的声音。',
|
||||
en: 'Typography gives every theme its own voice.',
|
||||
},
|
||||
{
|
||||
name: '小字 - Small',
|
||||
fontRole: 'body',
|
||||
layout: 'compact',
|
||||
size: '12px',
|
||||
weight: 'regular',
|
||||
cssWeight: 400,
|
||||
zh: '辅助说明与状态信息',
|
||||
en: 'Helper text and interface states',
|
||||
},
|
||||
{
|
||||
name: '注释 - Caption',
|
||||
fontRole: 'mono',
|
||||
layout: 'compact',
|
||||
size: '11px',
|
||||
weight: 'regular',
|
||||
cssWeight: 400,
|
||||
zh: '来源注释与细节标记',
|
||||
en: 'Source notes and fine details',
|
||||
},
|
||||
] satisfies Array<{
|
||||
name: string;
|
||||
fontRole: TypographyFontRole;
|
||||
layout: 'wide' | 'compact';
|
||||
size: string;
|
||||
weight: string;
|
||||
cssWeight: number;
|
||||
zh: string;
|
||||
en: string;
|
||||
}>;
|
||||
|
||||
function unique(items: string[]) {
|
||||
return Array.from(new Set(items.filter(Boolean)));
|
||||
}
|
||||
|
||||
function themeSelectionTags(tags: string[]) {
|
||||
return unique(tags.map(tag => tag.trim()).filter(tag => !NON_SELECTION_TAGS.has(tag)));
|
||||
}
|
||||
|
||||
function themeSelectionDescription(description: string) {
|
||||
const text = Array.from(NON_SELECTION_TAGS).reduce(
|
||||
(current, tag) => current.replace(new RegExp(`、?${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'), ''),
|
||||
description,
|
||||
);
|
||||
return text
|
||||
.replace(/围绕、/g, '围绕')
|
||||
.replace(/围绕组织页面/g, '围绕品牌视觉、产品场景组织页面')
|
||||
.replace(/、、+/g, '、');
|
||||
}
|
||||
|
||||
function contrastFor(color: string) {
|
||||
const value = color.replace('#', '').slice(0, 6);
|
||||
if (value.length !== 6) return '#171717';
|
||||
const r = parseInt(value.slice(0, 2), 16);
|
||||
const g = parseInt(value.slice(2, 4), 16);
|
||||
const b = parseInt(value.slice(4, 6), 16);
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#171717' : '#ffffff';
|
||||
}
|
||||
|
||||
function normalizePalette(palette: Array<string | BatchPaletteSwatch>): BatchPaletteSwatch[] {
|
||||
const labels = [
|
||||
['主色', 'Primary'],
|
||||
['主色悬停', 'Primary Hover'],
|
||||
['辅助色', 'Secondary'],
|
||||
['强调色', 'Accent'],
|
||||
['背景色', 'Background'],
|
||||
['表面色', 'Surface'],
|
||||
['文本色', 'Text'],
|
||||
['边框色', 'Border'],
|
||||
];
|
||||
return palette.slice(0, 12).map((item, index) => {
|
||||
if (typeof item !== 'string') return item;
|
||||
const label = labels[index] || [`颜色 ${index + 1}`, `Color ${index + 1}`];
|
||||
return {
|
||||
color: item,
|
||||
labelZh: label[0],
|
||||
labelEn: label[1],
|
||||
textColor: contrastFor(item),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function uniquePaletteByColor(palette: BatchPaletteSwatch[]): BatchPaletteSwatch[] {
|
||||
const seen = new Set<string>();
|
||||
return palette.filter(swatch => {
|
||||
const key = swatch.color.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function useThemeFontStylesheets(urls: string[] = []) {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || urls.length === 0) return;
|
||||
|
||||
const links = urls.map(url => {
|
||||
const existing = document.querySelector<HTMLLinkElement>(`link[data-dmb-font="${url}"]`);
|
||||
if (existing) return existing;
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.dataset.dmbFont = url;
|
||||
document.head.appendChild(link);
|
||||
return link;
|
||||
});
|
||||
|
||||
return () => {
|
||||
links.forEach(link => {
|
||||
if (document.querySelectorAll(`link[data-dmb-font="${link.dataset.dmbFont}"]`).length === 1) {
|
||||
link.remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [urls.join('|')]);
|
||||
}
|
||||
|
||||
function FittedSampleText({
|
||||
children,
|
||||
className,
|
||||
lang,
|
||||
}: {
|
||||
children: string;
|
||||
className: string;
|
||||
lang: string;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [fitScale, setFitScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
let active = true;
|
||||
let frame = 0;
|
||||
const updateFit = () => {
|
||||
if (!active) return;
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
if (!active) return;
|
||||
const availableWidth = container.clientWidth;
|
||||
const textWidth = text.scrollWidth;
|
||||
const nextScale = availableWidth > 0 && textWidth > 0
|
||||
? Math.min(1, availableWidth / textWidth)
|
||||
: 1;
|
||||
setFitScale(current => (Math.abs(current - nextScale) < 0.005 ? current : nextScale));
|
||||
});
|
||||
};
|
||||
|
||||
updateFit();
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(updateFit);
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(text);
|
||||
}
|
||||
|
||||
document.fonts?.ready.then(updateFit).catch(() => {});
|
||||
window.addEventListener('resize', updateFit);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateFit);
|
||||
};
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={`${className} dmb-type-sample-fit`}
|
||||
lang={lang}
|
||||
style={{ '--dmb-type-fit-scale': fitScale } as React.CSSProperties}
|
||||
>
|
||||
<span ref={textRef} className="dmb-type-sample-fit-inner">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({ tags }: { tags: string[] }) {
|
||||
if (tags.length === 0) return <span className="dmb-muted">暂无标签</span>;
|
||||
return (
|
||||
<div className="dmb-tags">
|
||||
{tags.map(tag => <span key={tag}>{tag}</span>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewFigure({ config, onOpen }: { config: BatchShowcaseConfig; onOpen: (url: string) => void }) {
|
||||
const primaryImage = config.previewImages[0];
|
||||
const imageLabel = config.brandAlias || config.brand;
|
||||
|
||||
if (!primaryImage) {
|
||||
return (
|
||||
<button className="dmb-preview dmb-preview-empty" type="button" aria-label={`${imageLabel} preview pending`}>
|
||||
<strong>{config.brand}</strong>
|
||||
<span>预览暂未提供</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryImage.type === 'source-preview-html') {
|
||||
return (
|
||||
<button className="dmb-preview dmb-preview-iframe" type="button" onClick={() => onOpen(primaryImage.url)} aria-label={`Open ${imageLabel} source preview`}>
|
||||
<span className="dmb-preview-canvas">
|
||||
<iframe src={primaryImage.url} title={`${imageLabel} source preview`} loading="lazy" sandbox="allow-same-origin" />
|
||||
</span>
|
||||
<span className="dmb-preview-label">源站预览 - Source Preview</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="dmb-preview" type="button" onClick={() => onOpen(primaryImage.url)} aria-label={`Open ${imageLabel} preview`}>
|
||||
<span className="dmb-preview-canvas">
|
||||
<img src={primaryImage.url} alt={`${imageLabel} ${primaryImage.type} preview`} loading="lazy" />
|
||||
</span>
|
||||
<span className="dmb-preview-label">{primaryImage.type}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteSystem({ palette }: { palette: BatchPaletteSwatch[] }) {
|
||||
const colors = uniquePaletteByColor(palette).slice(0, 12);
|
||||
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>颜色系统 - Color Palette</h2>
|
||||
<div className="dmb-palette">
|
||||
{colors.map((swatch, index) => (
|
||||
<article key={`${swatch.color}-${index}`} style={{ backgroundColor: swatch.color, color: swatch.textColor }}>
|
||||
<strong>{swatch.labelZh} - {swatch.labelEn}</strong>
|
||||
<code>{swatch.color}</code>
|
||||
<p>{index < 4 ? '用于主题预览和生成组件的核心色彩。' : '从来源元数据中提取的辅助色彩。'}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenOverview({ config }: { config: BatchShowcaseConfig }) {
|
||||
const uniquePalette = uniquePaletteByColor(normalizePalette(config.palette));
|
||||
const primary = uniquePalette[0] || { labelZh: '主色', labelEn: 'Primary', color: 'var(--dmb-accent)', textColor: 'var(--dmb-accent-contrast)' };
|
||||
const secondary = uniquePalette.find(swatch => swatch.color.toLowerCase() !== primary.color.toLowerCase()) || { labelZh: '辅助色', labelEn: 'Secondary', color: 'var(--dmb-link)', textColor: '#ffffff' };
|
||||
const neutral = uniquePalette.find(swatch => ['#000000', '#ffffff'].includes(swatch.color.toLowerCase())
|
||||
&& ![primary.color.toLowerCase(), secondary.color.toLowerCase()].includes(swatch.color.toLowerCase()));
|
||||
const background = uniquePalette.find(swatch => ['#fafafa', '#ffffff', '#fbfbfd', '#fffefb'].includes(swatch.color.toLowerCase())
|
||||
&& ![primary.color.toLowerCase(), secondary.color.toLowerCase(), neutral?.color.toLowerCase()].filter(Boolean).includes(swatch.color.toLowerCase()));
|
||||
const selectedSummarySwatches = [primary, secondary, neutral, background]
|
||||
.filter((swatch): swatch is BatchPaletteSwatch => Boolean(swatch));
|
||||
const summarySwatches = uniquePaletteByColor(selectedSummarySwatches);
|
||||
|
||||
return (
|
||||
<section className="dmb-token-card">
|
||||
<div className="dmb-token-rail">
|
||||
{summarySwatches.map(swatch => (
|
||||
<div key={`${swatch.labelEn}-${swatch.color}`} className="dmb-token-chip" style={{ backgroundColor: swatch.color, color: swatch.textColor }}>
|
||||
<strong>{swatch.labelZh} - {swatch.labelEn}</strong>
|
||||
<code>{swatch.color}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="dmb-token-grid">
|
||||
<div>
|
||||
<small>标题 - Headline</small>
|
||||
<strong>Aa</strong>
|
||||
</div>
|
||||
<div className="dmb-token-buttons">
|
||||
<button type="button">主按钮<br /><span>Primary</span></button>
|
||||
<button type="button" className="dmb-text-button">次按钮<br /><span>Secondary</span></button>
|
||||
<button type="button" className="dmb-outline-button">描边<br /><span>Outline</span></button>
|
||||
</div>
|
||||
<div>
|
||||
<input aria-label="搜索示例" placeholder="搜索 / Search" />
|
||||
</div>
|
||||
<div>
|
||||
<small>正文 - Body</small>
|
||||
<strong>Aa</strong>
|
||||
</div>
|
||||
<div className="dmb-lines">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TypographySystem({ typography }: { typography: string[] }) {
|
||||
const fontNames = {
|
||||
display: typography[0] || 'System Sans',
|
||||
body: typography[1] || typography[0] || 'System Sans',
|
||||
mono: typography[2] || 'ui-monospace',
|
||||
};
|
||||
const fontNameForRole = (role: TypographyFontRole) => {
|
||||
if (role === 'body') return fontNames.body;
|
||||
if (role === 'mono') return fontNames.mono;
|
||||
return fontNames.display;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>字体系统 - Typography</h2>
|
||||
<div className="dmb-type-list">
|
||||
{typographyRows.map(row => (
|
||||
<article
|
||||
key={row.name}
|
||||
className={`dmb-type-${row.fontRole} dmb-type-${row.layout}`}
|
||||
style={{
|
||||
'--dmb-type-size': row.size,
|
||||
'--dmb-type-weight': row.cssWeight,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div>
|
||||
<strong>{row.name}</strong>
|
||||
<span>{fontNameForRole(row.fontRole)} · {row.size} · {row.weight}</span>
|
||||
</div>
|
||||
<p className="dmb-type-sample">
|
||||
<span className="dmb-type-sample-zh" lang="zh-CN">{row.zh}</span>
|
||||
{row.layout === 'wide' ? (
|
||||
<FittedSampleText className="dmb-type-sample-en" lang="en">{row.en}</FittedSampleText>
|
||||
) : (
|
||||
<span className="dmb-type-sample-en" lang="en">{row.en}</span>
|
||||
)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SpacingSystem() {
|
||||
const marks = ['4px', '8px', '12px', '16px', '24px', '32px', '48px', '64px', '80px'];
|
||||
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>间距系统 - Spacing <span>基准 · 4px</span></h2>
|
||||
<div className="dmb-spacing-scale">
|
||||
{marks.map(mark => (
|
||||
<div key={mark}>
|
||||
<i />
|
||||
<span>{mark}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RadiusSystem({ radius }: { radius?: BatchShowcaseConfig['radius'] }) {
|
||||
if (radius?.source !== 'design-md') return null;
|
||||
|
||||
const radiusItems = [
|
||||
radius?.control ? ['控件 - Control', radius.control, '按钮、输入框、分段控件'] : null,
|
||||
radius?.card ? ['卡片 - Card', radius.card, '内容容器、面板、浮层'] : null,
|
||||
radius?.preview ? ['预览 - Preview', radius.preview, '截图、画布、媒体框'] : null,
|
||||
radius?.pill ? ['胶囊 - Pill', radius.pill, '标签、状态点、短操作'] : null,
|
||||
].filter((item): item is [string, string, string] => Boolean(item));
|
||||
|
||||
if (radiusItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>圆角系统 - Radius</h2>
|
||||
<div className="dmb-radius-grid">
|
||||
{radiusItems.map(([label, value, usage]) => (
|
||||
<article key={label} style={{ '--dmb-radius-demo': value } as React.CSSProperties}>
|
||||
<div className="dmb-radius-sample" />
|
||||
<strong>{label}</strong>
|
||||
<code>{value}</code>
|
||||
<p>{usage}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ShadowSystem({ shadows }: { shadows?: BatchStyleSystemItem[] }) {
|
||||
if (!shadows?.length) return null;
|
||||
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>阴影规则 - Shadow</h2>
|
||||
<div className="dmb-shadow-grid">
|
||||
{shadows.map(item => (
|
||||
<article key={`${item.label}-${item.value}`} style={item.cssValue ? { boxShadow: item.cssValue } : undefined}>
|
||||
<strong>{item.label}</strong>
|
||||
<code>{item.value}</code>
|
||||
{item.description ? <p>{item.description}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BorderSystem({ borders }: { borders?: BatchStyleSystemItem[] }) {
|
||||
if (!borders?.length) return null;
|
||||
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>边框系统 - Border</h2>
|
||||
<div className="dmb-border-grid">
|
||||
{borders.map(item => (
|
||||
<article key={`${item.label}-${item.value}`}>
|
||||
<strong>{item.label}</strong>
|
||||
<span
|
||||
className="dmb-border-sample"
|
||||
style={item.cssValue ? { borderColor: item.cssValue } : undefined}
|
||||
/>
|
||||
<code>{item.value}</code>
|
||||
{item.description ? <p>{item.description}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentsSystem({ panels }: { panels: BatchShowcaseConfig['panels'] }) {
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>组件片段 - Components</h2>
|
||||
<div className="dmb-component-strip">
|
||||
<button type="button">默认</button>
|
||||
<button type="button" className="dmb-button-hover">悬停</button>
|
||||
<button type="button" className="dmb-outline-button">描边</button>
|
||||
</div>
|
||||
<div className="dmb-component-grid">
|
||||
{panels.map(panel => (
|
||||
<article key={panel.title}>
|
||||
<small>{panel.eyebrow}</small>
|
||||
<h3>{panel.title}</h3>
|
||||
<p>{panel.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DosDonts({ config }: { config: BatchShowcaseConfig }) {
|
||||
const usageGuidance = config.usageGuidance;
|
||||
if (!usageGuidance?.do?.length || !usageGuidance?.dont?.length) return null;
|
||||
|
||||
return (
|
||||
<section className="dmb-block">
|
||||
<h2>使用建议 - Do's & Don'ts</h2>
|
||||
<div className="dmb-dos-grid">
|
||||
<article className="dmb-do">
|
||||
<strong>建议 - Do</strong>
|
||||
{usageGuidance.do.map(item => <p key={item}>{item}</p>)}
|
||||
</article>
|
||||
<article className="dmb-dont">
|
||||
<strong>避免 - Don't</strong>
|
||||
{usageGuidance.dont.map(item => <p key={item}>{item}</p>)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceTabs({
|
||||
activeTabId,
|
||||
tabs,
|
||||
onSelect,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
tabs: BatchShowcaseTab[];
|
||||
onSelect: (tabId: string) => void;
|
||||
}) {
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="dmb-tabs" role="tablist" aria-label="Theme resource sections">
|
||||
<button
|
||||
id="dmb-tab-design-md"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTabId === 'design-md'}
|
||||
aria-controls="dmb-panel-design-md"
|
||||
onClick={() => onSelect('design-md')}
|
||||
>
|
||||
规范
|
||||
</button>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
id={`dmb-tab-${tab.id}`}
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTabId === tab.id}
|
||||
aria-controls={`dmb-panel-${tab.id}`}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderLinks({ source }: { source?: BatchShowcaseSourceLinks }) {
|
||||
const links = getHeaderLinks(source);
|
||||
|
||||
if (links.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav className="dmb-sheet-links" aria-label="主题来源链接">
|
||||
{links.map(link => {
|
||||
const Icon = link.kind === 'website' ? Globe2 : FileSearch;
|
||||
return (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={link.ariaLabel}
|
||||
data-label={link.title}
|
||||
>
|
||||
<Icon aria-hidden="true" focusable="false" strokeWidth={1.9} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderActions({
|
||||
activeTabId,
|
||||
onSelect,
|
||||
source,
|
||||
tabs,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
onSelect: (tabId: string) => void;
|
||||
source?: BatchShowcaseSourceLinks;
|
||||
tabs: BatchShowcaseTab[];
|
||||
}) {
|
||||
const links = getHeaderLinks(source);
|
||||
const hasTabs = tabs.length > 0;
|
||||
const hasActions = Boolean(links.length || hasTabs);
|
||||
|
||||
if (!hasActions) return null;
|
||||
|
||||
return (
|
||||
<div className="dmb-sheet-actions">
|
||||
{links.length > 0 ? <HeaderLinks source={source} /> : null}
|
||||
{hasTabs ? <ResourceTabs activeTabId={activeTabId} tabs={tabs} onSelect={onSelect} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignMdBatchShowcase({ config, tabs = [], className = '' }: DesignMdBatchShowcaseProps) {
|
||||
const [zoomImage, setZoomImage] = useState<string | null>(null);
|
||||
const [activeTabId, setActiveTabId] = useState('design-md');
|
||||
useThemeFontStylesheets(config.fontStylesheets);
|
||||
const tags = themeSelectionTags(config.distributionTags).slice(0, 14);
|
||||
const description = themeSelectionDescription(config.description);
|
||||
const imageLabel = config.brandAlias || config.brand;
|
||||
const headerTitle = config.brandAlias || config.brand.replace(/\s*主题$/, '');
|
||||
const palette = normalizePalette(config.palette);
|
||||
const hasResourceTabs = tabs.length > 0;
|
||||
const activeTab = tabs.find(tab => activeTabId === tab.id);
|
||||
|
||||
const sheetHead = (
|
||||
<div className="dmb-sheet-head">
|
||||
<div className="dmb-sheet-head-main">
|
||||
<p>{variantLabels[config.variant]}</p>
|
||||
<h1>{headerTitle}</h1>
|
||||
</div>
|
||||
<HeaderActions
|
||||
activeTabId={activeTabId}
|
||||
source={config.source}
|
||||
tabs={tabs}
|
||||
onSelect={setActiveTabId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const overviewContent = (
|
||||
<>
|
||||
<PreviewFigure config={config} onOpen={setZoomImage} />
|
||||
|
||||
<div className="dmb-overview-meta">
|
||||
<div className="dmb-description">
|
||||
<p>{description || `${imageLabel} 的 Design.md 主题展示。`}</p>
|
||||
{config.descriptionEn ? <p lang="en">{config.descriptionEn}</p> : null}
|
||||
</div>
|
||||
<TagList tags={tags} />
|
||||
</div>
|
||||
|
||||
<TokenOverview config={config} />
|
||||
<PaletteSystem palette={palette} />
|
||||
<TypographySystem typography={config.typography} />
|
||||
<SpacingSystem />
|
||||
<RadiusSystem radius={config.radius} />
|
||||
<ShadowSystem shadows={config.shadows} />
|
||||
<BorderSystem borders={config.borders} />
|
||||
<ComponentsSystem panels={config.panels} />
|
||||
<DosDonts config={config} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className={['dmb-page', `dmb-variant-${config.variant}`, className].filter(Boolean).join(' ')}>
|
||||
<div className="dmb-layout">
|
||||
<article className="dmb-sheet">
|
||||
{sheetHead}
|
||||
|
||||
{hasResourceTabs && activeTab ? (
|
||||
<div
|
||||
id={`dmb-panel-${activeTab.id}`}
|
||||
className="dmb-tab-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`dmb-tab-${activeTab.id}`}
|
||||
>
|
||||
{activeTab.content}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id="dmb-panel-design-md"
|
||||
className={hasResourceTabs ? 'dmb-tab-panel' : undefined}
|
||||
role={hasResourceTabs ? 'tabpanel' : undefined}
|
||||
aria-labelledby={hasResourceTabs ? 'dmb-tab-design-md' : undefined}
|
||||
>
|
||||
{overviewContent}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{zoomImage ? (
|
||||
<div className="dmb-lightbox" role="dialog" aria-modal="true" aria-label={`${imageLabel} enlarged preview`}>
|
||||
<button type="button" onClick={() => setZoomImage(null)} aria-label="Close preview">关闭 - Close</button>
|
||||
<div className="dmb-lightbox-scroll">
|
||||
{zoomImage.endsWith('.html') ? (
|
||||
<iframe src={zoomImage} title={`${imageLabel} enlarged source preview`} sandbox="allow-same-origin" />
|
||||
) : (
|
||||
<img src={zoomImage} alt={`${imageLabel} enlarged preview`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesignMdBatchShowcase;
|
||||
36
src/common/ThemeShell/Icons.tsx
Normal file
36
src/common/ThemeShell/Icons.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* ThemeShell 图标组件
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const Icons = {
|
||||
Menu: () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Collapse: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="11 17 6 12 11 7" />
|
||||
<polyline points="18 17 13 12 18 7" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Expand: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="13 17 18 12 13 7" />
|
||||
<polyline points="6 17 11 12 6 7" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Close: () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
130
src/common/ThemeShell/MarkdownViewer.tsx
Normal file
130
src/common/ThemeShell/MarkdownViewer.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* MarkdownViewer - 轻量级 Markdown 渲染组件
|
||||
*
|
||||
* 零依赖,支持基础 Markdown 语法:
|
||||
* - 标题 (h1-h3)
|
||||
* - 列表 (有序/无序)
|
||||
* - 引用
|
||||
* - 行内代码
|
||||
* - 加粗
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface MarkdownViewerProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content, className }) => {
|
||||
// 解析行内样式
|
||||
const parseInlineStyles = (text: string): React.ReactNode => {
|
||||
// 处理行内代码 `code`
|
||||
const parts = text.split(/(`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return (
|
||||
<code
|
||||
key={i}
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-bg-tertiary, rgba(0, 0, 0, 0.04))',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '0.9em',
|
||||
fontFamily: 'Monaco, Consolas, monospace',
|
||||
color: 'var(--theme-text-primary, #1a1a1a)',
|
||||
}}
|
||||
>
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
// 处理加粗 **text**
|
||||
const boldParts = part.split(/(\*\*[^*]+\*\*)/g);
|
||||
return boldParts.map((bp, j) => {
|
||||
if (bp.startsWith('**') && bp.endsWith('**')) {
|
||||
return <strong key={`${i}-${j}`}>{bp.slice(2, -2)}</strong>;
|
||||
}
|
||||
return bp;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const parseLine = (line: string, index: number): React.ReactNode => {
|
||||
// H3
|
||||
if (line.startsWith('### ')) {
|
||||
return (
|
||||
<h3 key={index} style={{ fontSize: '18px', fontWeight: 600, marginTop: '24px', marginBottom: '12px', color: 'var(--theme-text-primary, #1a1a1a)' }}>
|
||||
{parseInlineStyles(line.slice(4))}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
// H2
|
||||
if (line.startsWith('## ')) {
|
||||
return (
|
||||
<h2 key={index} style={{ fontSize: '22px', fontWeight: 600, marginTop: '32px', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid var(--theme-border-light, rgba(0,0,0,0.06))', color: 'var(--theme-text-primary, #1a1a1a)' }}>
|
||||
{parseInlineStyles(line.slice(3))}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
// H1
|
||||
if (line.startsWith('# ')) {
|
||||
return (
|
||||
<h1 key={index} style={{ fontSize: '28px', fontWeight: 600, marginBottom: '20px', color: 'var(--theme-text-primary, #1a1a1a)' }}>
|
||||
{parseInlineStyles(line.slice(2))}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
// 无序列表
|
||||
if (line.startsWith('- ')) {
|
||||
return (
|
||||
<li key={index} style={{ marginLeft: '20px', marginBottom: '8px', listStyle: 'disc', color: 'var(--theme-text-secondary, rgba(0,0,0,0.65))' }}>
|
||||
{parseInlineStyles(line.slice(2))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
// 有序列表
|
||||
if (/^\d+\.\s/.test(line)) {
|
||||
const match = line.match(/^\d+\.\s(.*)$/);
|
||||
if (match) {
|
||||
return (
|
||||
<li key={index} style={{ marginLeft: '20px', marginBottom: '8px', listStyle: 'decimal', color: 'var(--theme-text-secondary, rgba(0,0,0,0.65))' }}>
|
||||
{parseInlineStyles(match[1])}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
// 引用
|
||||
if (line.startsWith('> ')) {
|
||||
return (
|
||||
<blockquote key={index} style={{ marginLeft: 0, paddingLeft: '16px', borderLeft: '3px solid var(--theme-border, rgba(0,0,0,0.1))', color: 'var(--theme-text-tertiary, rgba(0,0,0,0.55))', fontStyle: 'italic', margin: '16px 0' }}>
|
||||
{parseInlineStyles(line.slice(2))}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
// 分隔线
|
||||
if (line.trim() === '---') {
|
||||
return <hr key={index} style={{ border: 'none', borderTop: '1px solid var(--theme-border-light, rgba(0,0,0,0.06))', margin: '24px 0' }} />;
|
||||
}
|
||||
// 空行
|
||||
if (line.trim() === '') {
|
||||
return <div key={index} style={{ height: '12px' }} />;
|
||||
}
|
||||
// 普通段落
|
||||
return (
|
||||
<p key={index} style={{ color: 'var(--theme-text-secondary, rgba(0,0,0,0.65))', lineHeight: 1.7, marginBottom: '12px' }}>
|
||||
{parseInlineStyles(line)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className={className} style={{ fontFamily: 'inherit' }}>
|
||||
{lines.map((line, i) => parseLine(line, i))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownViewer;
|
||||
330
src/common/ThemeShell/ThemeShell.tsx
Normal file
330
src/common/ThemeShell/ThemeShell.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* ThemeShell - 去主题化的设计系统展示组件
|
||||
*
|
||||
* 核心特性:
|
||||
* - 零视觉干扰:使用中性色,不抢占内容焦点
|
||||
* - 完全可配置:标题、分组、导航项均通过 props 配置
|
||||
* - 响应式支持:桌面端侧边栏 + 移动端抽屉式导航
|
||||
* - 可折叠侧边栏:支持展开/收起
|
||||
* - 主题支持:支持深色/浅色主题配置
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, CSSProperties } from 'react';
|
||||
import { ThemeShellProps, NavGroup, NavItem, ThemeColors } from './types';
|
||||
import { createShellStyles, getThemeColors } from './styles';
|
||||
import { Icons } from './Icons';
|
||||
|
||||
// 单个导航项组件 - 使用独立的 hover 状态
|
||||
const NavItemButton: React.FC<{
|
||||
item: NavItem;
|
||||
isActive: boolean;
|
||||
onNavigate: (id: string) => void;
|
||||
styles: Record<string, CSSProperties>;
|
||||
colors: ThemeColors;
|
||||
}> = ({ item, isActive, onNavigate, styles, colors }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// 点击后清除 hover 状态
|
||||
const handleClick = () => {
|
||||
setIsHovered(false);
|
||||
onNavigate(item.id);
|
||||
};
|
||||
|
||||
// 只有当前激活项显示激活样式,其他项只在 hover 时显示 hover 样式
|
||||
const style: CSSProperties = {
|
||||
...styles.navItem,
|
||||
...(isActive ? styles.navItemActive : {}),
|
||||
...(!isActive && isHovered ? styles.navItemHover : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={style}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{item.icon && <span style={{ marginRight: 8 }}>{item.icon}</span>}
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThemeShell: React.FC<ThemeShellProps> = ({
|
||||
brand,
|
||||
groups,
|
||||
items,
|
||||
activeId,
|
||||
onNavigate,
|
||||
sidebar,
|
||||
theme,
|
||||
children,
|
||||
header,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(sidebar?.defaultOpen ?? true);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const sidebarWidth = sidebar?.width ?? 256;
|
||||
const collapsible = sidebar?.collapsible ?? true;
|
||||
|
||||
// 计算主题颜色和样式
|
||||
const themeColors = useMemo(() =>
|
||||
getThemeColors(theme?.mode ?? 'light', theme?.colors),
|
||||
[theme?.mode, theme?.colors]
|
||||
);
|
||||
|
||||
const STYLES = useMemo(() =>
|
||||
createShellStyles(themeColors),
|
||||
[themeColors]
|
||||
);
|
||||
|
||||
// 按分组组织导航项
|
||||
const sortedGroups = [...groups].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const getItemsByGroup = useCallback(
|
||||
(groupId: string): NavItem[] => items.filter(item => item.groupId === groupId),
|
||||
[items]
|
||||
);
|
||||
|
||||
// 处理导航点击
|
||||
const handleNavigate = useCallback(
|
||||
(id: string) => {
|
||||
onNavigate(id);
|
||||
setMobileMenuOpen(false);
|
||||
},
|
||||
[onNavigate]
|
||||
);
|
||||
|
||||
// 渲染单个导航项
|
||||
const renderNavItem = (item: NavItem) => {
|
||||
const isActive = item.id === activeId;
|
||||
return (
|
||||
<NavItemButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
onNavigate={handleNavigate}
|
||||
styles={STYLES}
|
||||
colors={themeColors}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染导航分组
|
||||
const renderNavGroup = (group: NavGroup) => {
|
||||
const groupItems = getItemsByGroup(group.id);
|
||||
if (groupItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.id} style={STYLES.navGroup}>
|
||||
<div style={STYLES.navGroupTitle}>{group.title}</div>
|
||||
<ul style={STYLES.navList}>
|
||||
{groupItems.map(renderNavItem)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染品牌区域
|
||||
const renderBrand = () => {
|
||||
if (!brand) return null;
|
||||
|
||||
return (
|
||||
<div style={STYLES.brandArea}>
|
||||
<div style={STYLES.brandContent as CSSProperties}>
|
||||
<div style={STYLES.brandText as CSSProperties}>
|
||||
<span style={STYLES.brandName}>{brand.name}</span>
|
||||
{brand.subtitle && (
|
||||
<span style={STYLES.brandSubtitle}>{brand.subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
style={STYLES.collapseBtn}
|
||||
title="收起侧边栏"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = themeColors.bgHover;
|
||||
e.currentTarget.style.color = themeColors.textPrimary;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = themeColors.textTertiary;
|
||||
}}
|
||||
>
|
||||
<Icons.Collapse />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染侧边栏内容
|
||||
const renderSidebarContent = () => (
|
||||
<>
|
||||
{renderBrand()}
|
||||
<nav className="theme-shell-scroll" style={STYLES.navArea as CSSProperties}>
|
||||
{sortedGroups.map(renderNavGroup)}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
// 桌面端侧边栏样式
|
||||
const desktopSidebarStyle: CSSProperties = {
|
||||
...STYLES.sidebar as CSSProperties,
|
||||
...(sidebarOpen
|
||||
? { ...STYLES.sidebarOpen, width: sidebarWidth }
|
||||
: STYLES.sidebarClosed),
|
||||
};
|
||||
|
||||
// 移动端侧边栏样式
|
||||
const mobileSidebarStyle: CSSProperties = {
|
||||
...STYLES.mobileSidebar as CSSProperties,
|
||||
...(mobileMenuOpen ? STYLES.mobileSidebarOpen : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...STYLES.root as CSSProperties,
|
||||
'--theme-text-primary': themeColors.textPrimary,
|
||||
'--theme-text-secondary': themeColors.textSecondary,
|
||||
'--theme-text-tertiary': themeColors.textTertiary,
|
||||
'--theme-text-muted': themeColors.textMuted,
|
||||
'--theme-border': themeColors.border,
|
||||
'--theme-border-light': themeColors.borderLight,
|
||||
'--theme-bg-secondary': themeColors.bgSecondary,
|
||||
'--theme-bg-tertiary': themeColors.bgTertiary,
|
||||
...style,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<style>{`
|
||||
.theme-shell-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.theme-shell-scroll::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.theme-shell-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.theme-shell-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
.theme-shell-scroll:hover::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
`}</style>
|
||||
{/* 移动端顶栏 */}
|
||||
<div
|
||||
className="theme-shell-mobile-topbar"
|
||||
style={{
|
||||
...STYLES.mobileTopbar as CSSProperties,
|
||||
display: 'none', // 默认隐藏,CSS 媒体查询控制
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{brand && <span style={{ fontWeight: 600, fontSize: 15 }}>{brand.name}</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 8,
|
||||
cursor: 'pointer',
|
||||
color: themeColors.textSecondary,
|
||||
}}
|
||||
>
|
||||
<Icons.Menu />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 移动端遮罩 */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
style={STYLES.mobileOverlay as CSSProperties}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
<aside className="theme-shell-mobile-sidebar" style={mobileSidebarStyle}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ ...STYLES.brandArea as CSSProperties, justifyContent: 'space-between' }}>
|
||||
{brand && (
|
||||
<div style={STYLES.brandContent as CSSProperties}>
|
||||
<div style={STYLES.brandText as CSSProperties}>
|
||||
<span style={STYLES.brandName}>{brand.name}</span>
|
||||
{brand.subtitle && (
|
||||
<span style={STYLES.brandSubtitle}>{brand.subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 4,
|
||||
cursor: 'pointer',
|
||||
color: themeColors.textTertiary,
|
||||
}}
|
||||
>
|
||||
<Icons.Close />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="theme-shell-scroll" style={STYLES.navArea as CSSProperties}>
|
||||
{sortedGroups.map(renderNavGroup)}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 桌面端侧边栏 */}
|
||||
<aside className="theme-shell-sidebar" style={desktopSidebarStyle}>
|
||||
{renderSidebarContent()}
|
||||
</aside>
|
||||
|
||||
{/* 展开按钮(侧边栏关闭时) */}
|
||||
{!sidebarOpen && collapsible && (
|
||||
<button
|
||||
className="theme-shell-expand-btn"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
style={STYLES.expandBtn as CSSProperties}
|
||||
title="展开侧边栏"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = themeColors.bgHover;
|
||||
e.currentTarget.style.color = themeColors.textPrimary;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = themeColors.bgPrimary;
|
||||
e.currentTarget.style.color = themeColors.textSecondary;
|
||||
}}
|
||||
>
|
||||
<Icons.Expand />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main style={STYLES.main as CSSProperties}>
|
||||
{/* 可选头部区域 */}
|
||||
{header && <div style={STYLES.header as CSSProperties}>{header}</div>}
|
||||
|
||||
{/* 内容区 */}
|
||||
<div style={STYLES.content as CSSProperties}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeShell;
|
||||
10
src/common/ThemeShell/index.ts
Normal file
10
src/common/ThemeShell/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* ThemeShell - 去主题化的设计系统展示组件
|
||||
*
|
||||
* 导出模块
|
||||
*/
|
||||
|
||||
export { ThemeShell, default } from './ThemeShell';
|
||||
export * from './types';
|
||||
export { MarkdownViewer } from './MarkdownViewer';
|
||||
export type { MarkdownViewerProps } from './MarkdownViewer';
|
||||
314
src/common/ThemeShell/styles.ts
Normal file
314
src/common/ThemeShell/styles.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* ThemeShell 样式定义
|
||||
*
|
||||
* 支持深色/浅色主题 - 使用中性色,减少视觉干扰
|
||||
*/
|
||||
|
||||
import { CSSProperties } from 'react';
|
||||
import { ThemeColors, ThemeMode } from './types';
|
||||
|
||||
// 浅色主题色板
|
||||
export const LIGHT_COLORS: ThemeColors = {
|
||||
// 文字
|
||||
textPrimary: '#1a1a1a',
|
||||
textSecondary: '#666666',
|
||||
textTertiary: '#999999',
|
||||
textMuted: '#b3b3b3',
|
||||
|
||||
// 背景
|
||||
bgPrimary: '#ffffff',
|
||||
bgSecondary: '#fafafa',
|
||||
bgTertiary: '#f5f5f5',
|
||||
bgHover: '#f0f0f0',
|
||||
bgActive: '#e8e8e8',
|
||||
|
||||
// 边框
|
||||
border: '#e5e5e5',
|
||||
borderLight: '#f0f0f0',
|
||||
|
||||
// 状态指示
|
||||
activeIndicator: '#1a1a1a',
|
||||
};
|
||||
|
||||
// 深色主题色板
|
||||
export const DARK_COLORS: ThemeColors = {
|
||||
// 文字
|
||||
textPrimary: '#f5f5f5',
|
||||
textSecondary: '#a0a0a0',
|
||||
textTertiary: '#707070',
|
||||
textMuted: '#505050',
|
||||
|
||||
// 背景
|
||||
bgPrimary: '#1a1a1a',
|
||||
bgSecondary: '#141414',
|
||||
bgTertiary: '#242424',
|
||||
bgHover: '#2a2a2a',
|
||||
bgActive: '#333333',
|
||||
|
||||
// 边框
|
||||
border: '#333333',
|
||||
borderLight: '#2a2a2a',
|
||||
|
||||
// 状态指示
|
||||
activeIndicator: '#f5f5f5',
|
||||
};
|
||||
|
||||
// 获取主题颜色
|
||||
export function getThemeColors(mode: ThemeMode = 'light', customColors?: Partial<ThemeColors>): ThemeColors {
|
||||
const baseColors = mode === 'dark' ? DARK_COLORS : LIGHT_COLORS;
|
||||
return customColors ? { ...baseColors, ...customColors } : baseColors;
|
||||
}
|
||||
|
||||
// 生成样式
|
||||
export function createShellStyles(colors: ThemeColors): Record<string, CSSProperties> {
|
||||
return {
|
||||
// 根容器
|
||||
root: {
|
||||
display: 'flex',
|
||||
minHeight: '100vh',
|
||||
height: '100vh',
|
||||
backgroundColor: colors.bgSecondary,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
color: colors.textPrimary,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
// 侧边栏
|
||||
sidebar: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: colors.bgPrimary,
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
transition: 'width 0.2s ease, opacity 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
sidebarOpen: {
|
||||
width: 256,
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
sidebarClosed: {
|
||||
width: 0,
|
||||
opacity: 0,
|
||||
borderRight: 'none',
|
||||
},
|
||||
|
||||
// 品牌区域
|
||||
brandArea: {
|
||||
padding: '20px 16px',
|
||||
borderBottom: `1px solid ${colors.borderLight}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minWidth: 224,
|
||||
},
|
||||
|
||||
brandContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
|
||||
brandLogo: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
brandText: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
},
|
||||
|
||||
brandName: {
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
|
||||
brandSubtitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: '0.02em',
|
||||
},
|
||||
|
||||
// 折叠按钮
|
||||
collapseBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: colors.textTertiary,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
collapseBtnHover: {
|
||||
backgroundColor: colors.bgHover,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
|
||||
// 导航区域
|
||||
navArea: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 0',
|
||||
minWidth: 224,
|
||||
},
|
||||
|
||||
// 分组
|
||||
navGroup: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
|
||||
navGroupTitle: {
|
||||
padding: '0 16px',
|
||||
marginBottom: 8,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: colors.textMuted,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
navList: {
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
|
||||
// 导航项
|
||||
navItem: {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
},
|
||||
|
||||
navItemHover: {
|
||||
backgroundColor: colors.bgHover,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
|
||||
navItemActive: {
|
||||
backgroundColor: colors.bgTertiary,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: 500,
|
||||
},
|
||||
|
||||
// 主内容区
|
||||
main: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
},
|
||||
|
||||
// 头部区域(可选)
|
||||
header: {
|
||||
flexShrink: 0,
|
||||
padding: '16px 24px',
|
||||
backgroundColor: colors.bgPrimary,
|
||||
borderBottom: `1px solid ${colors.borderLight}`,
|
||||
},
|
||||
|
||||
// 内容区域
|
||||
content: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
padding: '24px 32px',
|
||||
},
|
||||
|
||||
// 展开按钮(侧边栏关闭时)
|
||||
expandBtn: {
|
||||
position: 'fixed',
|
||||
top: 20,
|
||||
left: 20,
|
||||
zIndex: 100,
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgPrimary,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
color: colors.textSecondary,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
transition: 'all 0.15s ease',
|
||||
},
|
||||
|
||||
expandBtnHover: {
|
||||
backgroundColor: colors.bgHover,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
|
||||
// 移动端顶栏
|
||||
mobileTopbar: {
|
||||
display: 'none', // 默认隐藏,通过媒体查询显示
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
height: 56,
|
||||
padding: '0 16px',
|
||||
backgroundColor: colors.bgPrimary,
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
|
||||
// 移动端遮罩
|
||||
mobileOverlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 99,
|
||||
},
|
||||
|
||||
// 移动端侧边栏
|
||||
mobileSidebar: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: 280,
|
||||
zIndex: 100,
|
||||
backgroundColor: colors.bgPrimary,
|
||||
boxShadow: '4px 0 24px rgba(0,0,0,0.12)',
|
||||
transform: 'translateX(-100%)',
|
||||
transition: 'transform 0.3s ease',
|
||||
},
|
||||
|
||||
mobileSidebarOpen: {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 保持向后兼容 - 默认使用浅色主题
|
||||
export const SHELL_STYLES = createShellStyles(LIGHT_COLORS);
|
||||
118
src/common/ThemeShell/types.ts
Normal file
118
src/common/ThemeShell/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* ThemeShell 类型定义
|
||||
*
|
||||
* 去主题化的设计系统展示组件类型
|
||||
*/
|
||||
|
||||
/** 主题模式 */
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
/** 主题颜色配置 */
|
||||
export interface ThemeColors {
|
||||
/** 主要文字色 */
|
||||
textPrimary: string;
|
||||
/** 次要文字色 */
|
||||
textSecondary: string;
|
||||
/** 三级文字色 */
|
||||
textTertiary: string;
|
||||
/** 静音文字色 */
|
||||
textMuted: string;
|
||||
/** 主背景色 */
|
||||
bgPrimary: string;
|
||||
/** 次要背景色 */
|
||||
bgSecondary: string;
|
||||
/** 三级背景色 */
|
||||
bgTertiary: string;
|
||||
/** 悬浮背景色 */
|
||||
bgHover: string;
|
||||
/** 激活背景色 */
|
||||
bgActive: string;
|
||||
/** 边框色 */
|
||||
border: string;
|
||||
/** 浅边框色 */
|
||||
borderLight: string;
|
||||
/** 激活指示器色 */
|
||||
activeIndicator: string;
|
||||
}
|
||||
|
||||
/** 主题配置 */
|
||||
export interface ThemeConfig {
|
||||
/** 主题模式 */
|
||||
mode?: ThemeMode;
|
||||
/** 自定义颜色(覆盖默认值) */
|
||||
colors?: Partial<ThemeColors>;
|
||||
}
|
||||
|
||||
/** 导航项 */
|
||||
export interface NavItem {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
/** 显示标签 */
|
||||
label: string;
|
||||
/** 所属分组 ID */
|
||||
groupId: string;
|
||||
/** 可选描述 */
|
||||
description?: string;
|
||||
/** 可选图标(React 节点) */
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 导航分组 */
|
||||
export interface NavGroup {
|
||||
/** 分组唯一标识 */
|
||||
id: string;
|
||||
/** 分组标题 */
|
||||
title: string;
|
||||
/** 分组排序权重(越小越靠前) */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** 品牌配置 */
|
||||
export interface BrandConfig {
|
||||
/** 品牌名称 */
|
||||
name: string;
|
||||
/** 副标题 */
|
||||
subtitle?: string;
|
||||
/** Logo 图标(React 节点) */
|
||||
logo?: React.ReactNode;
|
||||
/** Logo 背景色 */
|
||||
logoBgColor?: string;
|
||||
/** Logo 文字色 */
|
||||
logoTextColor?: string;
|
||||
}
|
||||
|
||||
/** 侧边栏配置 */
|
||||
export interface SidebarConfig {
|
||||
/** 默认宽度 */
|
||||
width?: number;
|
||||
/** 是否默认展开 */
|
||||
defaultOpen?: boolean;
|
||||
/** 是否允许折叠 */
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
/** ThemeShell 组件属性 */
|
||||
export interface ThemeShellProps {
|
||||
/** 品牌配置 */
|
||||
brand?: BrandConfig;
|
||||
/** 导航分组列表 */
|
||||
groups: NavGroup[];
|
||||
/** 导航项列表 */
|
||||
items: NavItem[];
|
||||
/** 当前激活项 ID */
|
||||
activeId: string;
|
||||
/** 导航切换回调 */
|
||||
onNavigate: (id: string) => void;
|
||||
/** 侧边栏配置 */
|
||||
sidebar?: SidebarConfig;
|
||||
/** 主题配置 */
|
||||
theme?: ThemeConfig;
|
||||
/** 内容区域 */
|
||||
children: React.ReactNode;
|
||||
/** 顶部额外内容(如比选面板) */
|
||||
header?: React.ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 自定义样式 */
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
863
src/common/VariantSwitcher.tsx
Normal file
863
src/common/VariantSwitcher.tsx
Normal file
@@ -0,0 +1,863 @@
|
||||
/**
|
||||
*本项目Variant Switcher 组件 (重构版)
|
||||
*
|
||||
* 核心特性:
|
||||
* - 零依赖:不依赖任何外部 CSS 框架或图标库
|
||||
* - 轻量化 UI:使用图标入口替代重型控制条
|
||||
* - 丰富信息:支持标题和描述
|
||||
* - 全局面板:支持页面级统一管理和跳转
|
||||
* - 隐形控制:支持快捷键显隐入口
|
||||
* - 自动全局入口:当有比选组件时自动显示全局入口按钮
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, CSSProperties, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// --- 类型定义 ---
|
||||
|
||||
export interface VariantItem {
|
||||
/** 唯一标识,若不提供则使用索引 */
|
||||
key?: string;
|
||||
/** 渲染内容 */
|
||||
content: React.ReactNode;
|
||||
/** 方案标题 */
|
||||
title: string;
|
||||
/** 方案一句话描述 */
|
||||
description: string;
|
||||
/** 方案详细说明文档(Markdown 格式) */
|
||||
markdown?: string;
|
||||
}
|
||||
|
||||
export interface VariantAPI {
|
||||
id: string;
|
||||
/** 比选方案的中文名称,用于在全局面板中显示 */
|
||||
name: string;
|
||||
currentIndex: number;
|
||||
totalVariants: number;
|
||||
isDecided: boolean;
|
||||
variants: VariantItem[]; // 暴露方案详情供全局面板使用
|
||||
select: (index: number) => void;
|
||||
confirm: () => void;
|
||||
reset: () => void;
|
||||
focus: () => void; // 聚焦到该组件(滚动)
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AXHUB_VARIANT_MANAGER?: VariantManager;
|
||||
}
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export interface VariantManager {
|
||||
register: (id: string, api: VariantAPI) => void;
|
||||
unregister: (id: string) => void;
|
||||
instances: Record<string, VariantAPI>;
|
||||
subscribe: (listener: Listener) => () => void;
|
||||
notify: () => void;
|
||||
setVisibility: (visible: boolean) => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export interface VariantSwitcherProps {
|
||||
id?: string;
|
||||
/** 比选方案的中文名称,显示在全局面板中(如"头部设计"、"登录页布局") */
|
||||
name?: string;
|
||||
/** 方案列表 */
|
||||
variants: VariantItem[];
|
||||
defaultIndex?: number;
|
||||
onConfirm?: (index: number, item: VariantItem) => void;
|
||||
onReset?: () => void;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// --- 图标定义 ---
|
||||
|
||||
const Icons = {
|
||||
Switcher: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
),
|
||||
Check: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
Close: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
),
|
||||
// 比选图标:两个重叠的卡片,表示多个方案比选
|
||||
VariantCompare: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* 底层卡片 */}
|
||||
<rect x="4" y="6" width="12" height="10" rx="2" opacity="0.4" />
|
||||
{/* 顶层卡片(偏移) */}
|
||||
<rect x="8" y="4" width="12" height="10" rx="2" />
|
||||
</svg>
|
||||
),
|
||||
Target: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="6"></circle>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
</svg>
|
||||
),
|
||||
Exit: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
),
|
||||
Doc: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10 9 9 9 8 9"></polyline>
|
||||
</svg>
|
||||
),
|
||||
Back: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
// --- 主题配置 ---
|
||||
const THEME_COLOR = '#008F5D';
|
||||
const THEME_COLOR_BG = 'rgba(0, 143, 93, 0.1)';
|
||||
|
||||
// --- 内置 Markdown 渲染器(零依赖) ---
|
||||
|
||||
const MarkdownViewer: React.FC<{ content: string }> = ({ content }) => {
|
||||
const parseInlineStyles = (text: string): React.ReactNode => {
|
||||
// 处理行内代码 `code`
|
||||
const parts = text.split(/(`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return (
|
||||
<code key={i} style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'Monaco, Consolas, monospace',
|
||||
color: '#e83e8c'
|
||||
}}>
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
// 处理加粗 **text**
|
||||
const boldParts = part.split(/(\*\*[^*]+\*\*)/g);
|
||||
return boldParts.map((bp, j) => {
|
||||
if (bp.startsWith('**') && bp.endsWith('**')) {
|
||||
return <strong key={`${i}-${j}`}>{bp.slice(2, -2)}</strong>;
|
||||
}
|
||||
return bp;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const parseLine = (line: string, index: number): React.ReactNode => {
|
||||
// 标题
|
||||
if (line.startsWith('### ')) {
|
||||
return <h3 key={index} style={{ fontSize: '16px', fontWeight: 600, color: '#333', marginTop: '20px', marginBottom: '8px' }}>{parseInlineStyles(line.slice(4))}</h3>;
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
return <h2 key={index} style={{ fontSize: '18px', fontWeight: 600, color: '#333', marginTop: '24px', marginBottom: '12px', paddingBottom: '8px', borderBottom: '1px solid #eee' }}>{parseInlineStyles(line.slice(3))}</h2>;
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
return <h1 key={index} style={{ fontSize: '22px', fontWeight: 600, color: '#333', marginBottom: '16px' }}>{parseInlineStyles(line.slice(2))}</h1>;
|
||||
}
|
||||
// 列表
|
||||
if (line.startsWith('- ')) {
|
||||
return (
|
||||
<li key={index} style={{ marginLeft: '16px', paddingLeft: '8px', color: '#555', marginBottom: '6px', listStyle: 'disc' }}>
|
||||
{parseInlineStyles(line.slice(2))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (/^\d+\.\s/.test(line)) {
|
||||
const match = line.match(/^(\d+)\.\s(.*)$/);
|
||||
if (match) {
|
||||
return (
|
||||
<li key={index} style={{ marginLeft: '16px', paddingLeft: '8px', color: '#555', marginBottom: '6px', listStyle: 'decimal' }}>
|
||||
{parseInlineStyles(match[2])}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
// 引用
|
||||
if (line.startsWith('> ')) {
|
||||
return (
|
||||
<blockquote key={index} style={{ marginLeft: 0, paddingLeft: '12px', borderLeft: `3px solid ${THEME_COLOR}`, color: '#666', fontStyle: 'italic', margin: '12px 0' }}>
|
||||
{parseInlineStyles(line.slice(2))}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
// 空行
|
||||
if (line.trim() === '') {
|
||||
return <div key={index} style={{ height: '12px' }} />;
|
||||
}
|
||||
// 普通段落
|
||||
return <p key={index} style={{ color: '#555', lineHeight: 1.7, marginBottom: '12px' }}>{parseInlineStyles(line)}</p>;
|
||||
};
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontSize: '14px' }}>
|
||||
{lines.map((line, i) => parseLine(line, i))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- 全局管理器实现 ---
|
||||
|
||||
const listeners: Listener[] = [];
|
||||
let globalVisible = true;
|
||||
|
||||
function initGlobalManager(): VariantManager {
|
||||
if (!window.AXHUB_VARIANT_MANAGER) {
|
||||
window.AXHUB_VARIANT_MANAGER = {
|
||||
instances: {},
|
||||
isVisible: true,
|
||||
register(id, api) {
|
||||
this.instances[id] = api;
|
||||
this.notify();
|
||||
},
|
||||
unregister(id) {
|
||||
delete this.instances[id];
|
||||
this.notify();
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx > -1) listeners.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
notify() {
|
||||
this.isVisible = globalVisible;
|
||||
listeners.forEach(fn => fn());
|
||||
},
|
||||
setVisibility(visible) {
|
||||
globalVisible = visible;
|
||||
this.notify();
|
||||
}
|
||||
};
|
||||
}
|
||||
return window.AXHUB_VARIANT_MANAGER;
|
||||
}
|
||||
|
||||
// --- Hooks ---
|
||||
|
||||
/** 获取所有注册的实例及全局可见性 */
|
||||
function useVariantManager() {
|
||||
const [state, setState] = useState<{
|
||||
instances: Record<string, VariantAPI>;
|
||||
isVisible: boolean;
|
||||
}>({ instances: {}, isVisible: true });
|
||||
|
||||
useEffect(() => {
|
||||
const manager = initGlobalManager();
|
||||
const update = () => setState({
|
||||
instances: { ...manager.instances },
|
||||
isVisible: manager.isVisible
|
||||
});
|
||||
update();
|
||||
return manager.subscribe(update);
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// --- 样式定义 ---
|
||||
|
||||
const STYLES = {
|
||||
container: {
|
||||
position: 'relative' as const,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
triggerBtn: {
|
||||
position: 'absolute' as const,
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
zIndex: 9001,
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
borderRadius: '2px',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
popover: {
|
||||
position: 'absolute' as const,
|
||||
top: '32px',
|
||||
right: '0px',
|
||||
width: '260px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '2px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
padding: '4px',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '2px',
|
||||
opacity: 0,
|
||||
transform: 'translateY(-4px)',
|
||||
pointerEvents: 'none' as const,
|
||||
transition: 'all 0.15s ease-out',
|
||||
},
|
||||
popoverVisible: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
pointerEvents: 'auto' as const,
|
||||
},
|
||||
variantCard: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
padding: '8px 10px',
|
||||
borderRadius: '0',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'background 0.2s',
|
||||
textAlign: 'left' as const,
|
||||
background: 'transparent',
|
||||
},
|
||||
variantCardActive: {
|
||||
background: THEME_COLOR_BG,
|
||||
border: 'none',
|
||||
},
|
||||
variantTitle: {
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
marginBottom: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
variantDesc: {
|
||||
fontSize: '12px',
|
||||
color: '#888',
|
||||
lineHeight: '1.4',
|
||||
},
|
||||
globalTrigger: {
|
||||
position: 'fixed' as const,
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
zIndex: 99999,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: '#fff',
|
||||
color: '#555',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid rgba(0,0,0,0.05)',
|
||||
transition: 'transform 0.2s, opacity 0.2s',
|
||||
},
|
||||
globalPanel: {
|
||||
position: 'fixed' as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '300px',
|
||||
backgroundColor: '#fff',
|
||||
boxShadow: '-4px 0 24px rgba(0,0,0,0.08)',
|
||||
zIndex: 100000,
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
transform: 'translateX(100%)',
|
||||
transition: 'transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)',
|
||||
},
|
||||
globalPanelVisible: {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
globalPanelHeader: {
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
globalPanelContent: {
|
||||
flex: 1,
|
||||
overflowY: 'auto' as const,
|
||||
padding: '16px',
|
||||
},
|
||||
globalPanelFooter: {
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #f5f5f5',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
nodeGroup: {
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #eee',
|
||||
borderRadius: '0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
nodeHeader: {
|
||||
padding: '6px 10px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderBottom: '1px solid #eee',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
color: '#666',
|
||||
},
|
||||
exitBtn: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#999',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '8px',
|
||||
borderRadius: '0',
|
||||
transition: 'all 0.2s',
|
||||
}
|
||||
};
|
||||
|
||||
// --- 全局入口组件(单例) ---
|
||||
|
||||
let globalControlMountRef = { current: false };
|
||||
|
||||
/** 全局入口控制组件 */
|
||||
const GlobalVariantControl: React.FC = () => {
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [docView, setDocView] = useState<{ title: string; content: string } | null>(null);
|
||||
const { instances: allInstances, isVisible } = useVariantManager();
|
||||
|
||||
// 键盘快捷键监听
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '.') {
|
||||
e.preventDefault();
|
||||
initGlobalManager().setVisibility(!isVisible);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isVisible]);
|
||||
|
||||
const instancesList = Object.values(allInstances);
|
||||
|
||||
// 按 id 字母顺序排序,保持稳定的显示顺序
|
||||
const sortedInstances = [...instancesList].sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
// 如果没有实例或不可见,则不渲染
|
||||
if (!isVisible || sortedInstances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 全局悬浮球 */}
|
||||
<button
|
||||
style={STYLES.globalTrigger}
|
||||
onClick={() => setIsPanelOpen(true)}
|
||||
title="方案比选 (Ctrl + .)"
|
||||
>
|
||||
<Icons.VariantCompare />
|
||||
</button>
|
||||
|
||||
{/* 全局侧边栏面板 */}
|
||||
<div style={{
|
||||
...STYLES.globalPanel,
|
||||
...(isPanelOpen ? STYLES.globalPanelVisible : {})
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.globalPanelHeader}>
|
||||
{docView ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setDocView(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: '#666', display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||
>
|
||||
<Icons.Back />
|
||||
<span style={{ fontSize: '14px' }}>返回</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPanelOpen(false)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: '#999' }}
|
||||
>
|
||||
<Icons.Close />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>方案比选</span>
|
||||
<button
|
||||
onClick={() => setIsPanelOpen(false)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: '#999' }}
|
||||
>
|
||||
<Icons.Close />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={STYLES.globalPanelContent}>
|
||||
{docView ? (
|
||||
/* 文档视图 - 简洁无风格 */
|
||||
<div style={{ padding: '0 4px' }}>
|
||||
<div style={{ fontSize: '13px', color: '#999', marginBottom: '16px' }}>{docView.title}</div>
|
||||
<MarkdownViewer content={docView.content} />
|
||||
</div>
|
||||
) : (
|
||||
/* 方案列表视图 */
|
||||
sortedInstances.map(inst => (
|
||||
<div key={inst.id} style={STYLES.nodeGroup}>
|
||||
{/* Node Header */}
|
||||
<div style={STYLES.nodeHeader}>
|
||||
<span>{inst.name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
inst.focus();
|
||||
setIsPanelOpen(false);
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: THEME_COLOR, display: 'flex', alignItems: 'center', fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<Icons.Target />
|
||||
<span style={{ marginLeft: 4 }}>定位</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Variants List */}
|
||||
<div>
|
||||
{inst.variants.map((v, idx) => {
|
||||
const isActive = inst.currentIndex === idx;
|
||||
return (
|
||||
<div
|
||||
key={v.key || idx}
|
||||
style={{
|
||||
...STYLES.variantCard,
|
||||
...(isActive ? { background: THEME_COLOR_BG } : {}),
|
||||
borderBottom: '1px solid #f9f9f9',
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => inst.select(idx)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div style={STYLES.variantTitle}>
|
||||
<span>{v.title}</span>
|
||||
{isActive && <span style={{color: THEME_COLOR, fontSize: '12px'}}>当前</span>}
|
||||
</div>
|
||||
<div style={STYLES.variantDesc}>{v.description}</div>
|
||||
</div>
|
||||
{/* 文档按钮 */}
|
||||
{v.markdown && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDocView({ title: v.title, content: v.markdown! });
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#999', display: 'flex', alignItems: 'center',
|
||||
fontSize: '12px', marginTop: '6px', padding: 0
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = THEME_COLOR}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#999'}
|
||||
>
|
||||
<Icons.Doc />
|
||||
<span style={{ marginLeft: 4 }}>查看文档</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Exit Button - 仅在列表视图显示 */}
|
||||
{!docView && (
|
||||
<div style={STYLES.globalPanelFooter}>
|
||||
<button
|
||||
style={STYLES.exitBtn}
|
||||
onClick={() => {
|
||||
initGlobalManager().setVisibility(false);
|
||||
setIsPanelOpen(false);
|
||||
}}
|
||||
title="隐藏比选入口 (Ctrl + . 重新开启)"
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#333'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#999'}
|
||||
>
|
||||
<Icons.Exit />
|
||||
<span>退出比选</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 遮罩层 */}
|
||||
{isPanelOpen && (
|
||||
<div
|
||||
onClick={() => setIsPanelOpen(false)}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)', zIndex: 99999
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- 主组件 ---
|
||||
|
||||
export const VariantSwitcher: React.FC<VariantSwitcherProps> = ({
|
||||
id: propId,
|
||||
name: propName,
|
||||
variants = [],
|
||||
defaultIndex = 0,
|
||||
onConfirm,
|
||||
onReset,
|
||||
style,
|
||||
className,
|
||||
}) => {
|
||||
const [instanceId] = useState(() =>
|
||||
propId || `axhub_vs_${Math.random().toString(36).substr(2, 9)}`
|
||||
);
|
||||
|
||||
// 如果没有提供 name,使用 id 作为显示名称
|
||||
const displayName = propName || instanceId;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(defaultIndex);
|
||||
const [isDecided, setIsDecided] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const { isVisible: globalVisible } = useVariantManager();
|
||||
|
||||
// 标记当前组件负责渲染全局入口(单例,只渲染一次)
|
||||
const [isGlobalControlOwner, setIsGlobalControlOwner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果还没有组件负责渲染全局入口,则当前组件负责
|
||||
if (!globalControlMountRef.current) {
|
||||
globalControlMountRef.current = true;
|
||||
setIsGlobalControlOwner(true);
|
||||
}
|
||||
|
||||
// 组件卸载时,如果当前组件是全局入口的拥有者,则释放
|
||||
return () => {
|
||||
if (isGlobalControlOwner) {
|
||||
globalControlMountRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [isGlobalControlOwner]);
|
||||
|
||||
// --- API Methods ---
|
||||
const select = useCallback((index: number) => {
|
||||
if (index >= 0 && index < variants.length) {
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
}, [variants.length]);
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
setIsDecided(true);
|
||||
setIsPopoverOpen(false);
|
||||
if (variants[currentIndex]) {
|
||||
onConfirm?.(currentIndex, variants[currentIndex]);
|
||||
}
|
||||
}, [currentIndex, variants, onConfirm]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setIsDecided(false);
|
||||
onReset?.();
|
||||
}, [onReset]);
|
||||
|
||||
const focus = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setIsHovered(true);
|
||||
setTimeout(() => setIsHovered(false), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- 注册到全局 ---
|
||||
useEffect(() => {
|
||||
if (variants.length > 0) {
|
||||
const manager = initGlobalManager();
|
||||
const api: VariantAPI = {
|
||||
id: instanceId,
|
||||
name: displayName,
|
||||
currentIndex,
|
||||
totalVariants: variants.length,
|
||||
isDecided,
|
||||
variants,
|
||||
select,
|
||||
confirm,
|
||||
reset,
|
||||
focus,
|
||||
};
|
||||
|
||||
manager.register(instanceId, api);
|
||||
return () => manager.unregister(instanceId);
|
||||
}
|
||||
}, [instanceId, displayName, currentIndex, variants, isDecided, select, confirm, reset, focus]);
|
||||
|
||||
// --- 点击外部关闭弹窗 ---
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsPopoverOpen(false);
|
||||
}
|
||||
};
|
||||
if (isPopoverOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
if (variants.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 全局入口(单例,通过 Portal 渲染到 body,只由第一个组件渲染) */}
|
||||
{isGlobalControlOwner && typeof document !== 'undefined' && createPortal(
|
||||
<GlobalVariantControl />,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ ...STYLES.container, ...style }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
data-axhub-variant-id={instanceId}
|
||||
>
|
||||
{/* 渲染当前方案内容 */}
|
||||
{variants[currentIndex]?.content}
|
||||
|
||||
{/* 触发器图标 (轻量化,悬停显示) */}
|
||||
{globalVisible && (
|
||||
<button
|
||||
style={{
|
||||
...STYLES.triggerBtn,
|
||||
opacity: (isHovered || isPopoverOpen) ? 1 : 0,
|
||||
pointerEvents: (isHovered || isPopoverOpen) ? 'auto' : 'none',
|
||||
backgroundColor: isPopoverOpen ? THEME_COLOR : '#fff',
|
||||
color: isPopoverOpen ? '#fff' : '#666',
|
||||
borderColor: isPopoverOpen ? THEME_COLOR : 'rgba(0,0,0,0.06)'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsPopoverOpen(!isPopoverOpen);
|
||||
}}
|
||||
title="切换方案"
|
||||
>
|
||||
<Icons.Switcher />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 下拉选择面板 */}
|
||||
{globalVisible && (
|
||||
<div style={{
|
||||
...STYLES.popover,
|
||||
...(isPopoverOpen ? STYLES.popoverVisible : {})
|
||||
}}>
|
||||
<div style={{ padding: '4px 8px', fontSize: '12px', color: '#999', fontWeight: 600 }}>
|
||||
选择方案
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{variants.map((variant, index) => {
|
||||
const isActive = index === currentIndex;
|
||||
return (
|
||||
<div
|
||||
key={variant.key || index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
select(index);
|
||||
}}
|
||||
style={{
|
||||
...STYLES.variantCard,
|
||||
...(isActive ? STYLES.variantCardActive : {}),
|
||||
}}
|
||||
>
|
||||
<div style={STYLES.variantTitle}>
|
||||
{variant.title}
|
||||
{isActive && <Icons.Check />}
|
||||
</div>
|
||||
<div style={STYLES.variantDesc}>
|
||||
{variant.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid #f5f5f5', marginTop: '4px', paddingTop: '8px', display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
{isDecided ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); reset(); }}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #eee', borderRadius: '4px',
|
||||
padding: '4px 10px', fontSize: '12px', cursor: 'pointer', color: '#666'
|
||||
}}
|
||||
>
|
||||
重选
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); confirm(); }}
|
||||
style={{
|
||||
background: THEME_COLOR, border: 'none', borderRadius: '4px',
|
||||
padding: '4px 10px', fontSize: '12px', cursor: 'pointer', color: '#fff'
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariantSwitcher;
|
||||
237
src/common/axure-types.ts
Normal file
237
src/common/axure-types.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
*Axue渲染引擎共类型定义
|
||||
* 包含所有组件和页面共用的类型、接口和工具函数
|
||||
*/
|
||||
|
||||
// ============ 基础类型定义 ============
|
||||
|
||||
// 小写字母 a-z
|
||||
type Lower =
|
||||
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g'
|
||||
| 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n'
|
||||
| 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u'
|
||||
| 'v' | 'w' | 'x' | 'y' | 'z';
|
||||
|
||||
// 数字 0-9
|
||||
type Digit =
|
||||
| '0' | '1' | '2' | '3' | '4' | '5'
|
||||
| '6' | '7' | '8' | '9';
|
||||
|
||||
// 允许的字符:小写字母 + 数字 + 下划线
|
||||
type AllowedChar = Lower | Digit | '_';
|
||||
|
||||
// snake_case 字符串类型(仅用于文档说明,TypeScript 无法完全约束)
|
||||
// 正确示例: 'user_name', 'item_count', 'is_active'
|
||||
// 错误示例: 'userName', 'ItemCount', 'Is-Active'
|
||||
type SnakeCaseString = string;
|
||||
|
||||
// name 字段规则:
|
||||
// - 必须由小写字母、数字、下划线组成(a-z、0-9、_)
|
||||
// - 长度 >= 1(不能为空)
|
||||
// - 建议使用 snake_case 命名风格
|
||||
//
|
||||
// ⚠️ 注意:TypeScript 类型系统无法在编译时完全约束字符串内容
|
||||
// 请手动遵守命名规范,或使用下面的运行时验证函数
|
||||
export type KeyDesc = {
|
||||
name: SnakeCaseString;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证 name 是否符合规范(运行时检查)
|
||||
* @param name 要验证的名称
|
||||
* @returns 是否合法
|
||||
*/
|
||||
export function isValidKeyName(name: string): boolean {
|
||||
return /^[a-z0-9_]+$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言 name 符合规范,不符合则抛出错误
|
||||
* @param name 要验证的名称
|
||||
* @param context 上下文信息(用于错误提示)
|
||||
*/
|
||||
export function assertValidKeyName(name: string, context = 'KeyDesc'): asserts name is SnakeCaseString {
|
||||
if (!isValidKeyName(name)) {
|
||||
throw new Error(
|
||||
`[${context}] Invalid name "${name}". Name must only contain lowercase letters, digits, and underscores (a-z, 0-9, _)`
|
||||
);
|
||||
}
|
||||
}
|
||||
export type DataKey = { name: string; desc: string };
|
||||
export type DataDesc = { name: string; desc: string; keys: DataKey[] };
|
||||
|
||||
/**
|
||||
* 配置项定义
|
||||
* Configuration item definition
|
||||
* 参考 AttributeComponentProps 结构
|
||||
*/
|
||||
export type ConfigItem = {
|
||||
/** 组件类型 Component type */
|
||||
type?:
|
||||
| 'group' // 分组
|
||||
| 'input' // 文本输入框
|
||||
| 'inputNumber' // 数字输入框
|
||||
| 'checkbox' // 复选框
|
||||
| 'slider' // 滑块
|
||||
| 'select' // 下拉选择框
|
||||
| 'autoComplete' // 自动完成
|
||||
| 'colorPicker' // 颜色选择器
|
||||
| 'arrayData' // 数组数据编辑器
|
||||
| 'table' // 表格数据编辑器
|
||||
| 'map' // 键值对数据编辑器
|
||||
| 'fontSetting' // 字体设置
|
||||
| 'lineSetting' // 线条设置
|
||||
| 'pointSetting' // 端点设置
|
||||
| 'collapse'; // 折叠面板
|
||||
|
||||
/** 属性唯一标识符 Unique attribute identifier (supports dot notation like 'style.fontSize') */
|
||||
attributeId?: string;
|
||||
|
||||
/** 显示名称 Display name shown in UI */
|
||||
displayName?: string;
|
||||
|
||||
/** 描述信息(提示文本) Description or tooltip text */
|
||||
info?: string;
|
||||
|
||||
/** 默认值 Initial/default value */
|
||||
initialValue?: any;
|
||||
|
||||
/** 子配置项 Child configuration items (for nested structures) */
|
||||
children?: ConfigItem[];
|
||||
|
||||
/** 是否显示 Whether to show this item (default: true) */
|
||||
show?: boolean;
|
||||
|
||||
/** 组件特定配置 Component-specific configuration properties */
|
||||
[k: string]: any;
|
||||
};
|
||||
|
||||
export type Action = { name: string; desc: string; params?: string };
|
||||
export type EventItem = { name: string; desc: string; payload?: string };
|
||||
|
||||
export type CSSProperties = Record<string, string | number>;
|
||||
export type AnyFunction = (...args: any[]) => any;
|
||||
export type Ref<T> = { current: T | null } | ((instance: T | null) => void) | null;
|
||||
export type ForwardRefRenderFunction<T, P> = (props: P, ref: Ref<T>) => any;
|
||||
|
||||
|
||||
// ============Axure组件接口 ============
|
||||
|
||||
/**
|
||||
* Axure 组件的 Props 接口
|
||||
* 所有 Axure 组件都应该接受这些属性
|
||||
*/
|
||||
export interface AxureProps {
|
||||
/** 数据源,用于传递组件需要的数据 */
|
||||
data?: Record<string, any>;
|
||||
/** 配置项,用于配置组件的行为和样式 */
|
||||
config?: Record<string, any>;
|
||||
/** 事件处理函数,组件触发事件时调用
|
||||
* ⚠️ 强制规则:payload 必须是字符串类型
|
||||
*/
|
||||
onEvent?: (name: string, payload?: string) => void;
|
||||
/** 容器元素,用于挂载组件 */
|
||||
container?: HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Axure 组件的 Handle 接口
|
||||
* 通过 ref 暴露给外部的方法和属性
|
||||
*/
|
||||
export interface AxureHandle {
|
||||
/** 获取组件内部变量 */
|
||||
getVar: (name: string) => any;
|
||||
/** 触发组件动作
|
||||
* ⚠️ 强制规则:params 必须是字符串类型
|
||||
*/
|
||||
fireAction: (name: string, params?: string) => void;
|
||||
/** 组件支持的事件列表 */
|
||||
eventList: EventItem[];
|
||||
/** 组件支持的动作列表 */
|
||||
actionList: Action[];
|
||||
/** 组件暴露的变量列表 */
|
||||
varList: KeyDesc[];
|
||||
/** 组件的配置项列表 */
|
||||
configList: ConfigItem[];
|
||||
/** 组件的数据项列表 */
|
||||
dataList: DataDesc[];
|
||||
}
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/**
|
||||
* 安全地触发事件
|
||||
* @param handler 事件处理函数
|
||||
* @param eventName 事件名称
|
||||
* @param payload 事件数据(⚠️ 强制规则:必须是字符串类型)
|
||||
*/
|
||||
export function safeEmitEvent(
|
||||
handler: ((name: string, payload?: string) => void) | undefined,
|
||||
eventName: string,
|
||||
payload?: string
|
||||
): void {
|
||||
if (typeof handler === 'function') {
|
||||
try {
|
||||
handler(eventName, payload);
|
||||
} catch (error) {
|
||||
console.warn(`事件 ${eventName} 调用失败:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建事件发射器
|
||||
* @param onEventHandler 事件处理函数
|
||||
* @returns 事件发射函数(⚠️ 强制规则:payload 必须是字符串类型)
|
||||
*/
|
||||
export function createEventEmitter(onEventHandler?: (name: string, payload?: string) => void) {
|
||||
return function emitEvent(eventName: string, payload?: string) {
|
||||
safeEmitEvent(onEventHandler, eventName, payload);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并样式对象
|
||||
* @param styles 样式对象数组
|
||||
* @returns 合并后的样式对象
|
||||
*/
|
||||
export function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties {
|
||||
return Object.assign({}, ...styles.filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置值,如果不存在则返回默认值
|
||||
* @param config 配置对象
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
* @returns 配置值或默认值
|
||||
*/
|
||||
export function getConfigValue<T>(
|
||||
config: Record<string, any> | undefined,
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): T {
|
||||
if (!config || config[key] === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
return config[key] as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据值,如果不存在则返回默认值
|
||||
* @param data 数据对象
|
||||
* @param key 数据键
|
||||
* @param defaultValue 默认值
|
||||
* @returns 数据值或默认值
|
||||
*/
|
||||
export function getDataValue<T>(
|
||||
data: Record<string, any> | undefined,
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): T {
|
||||
if (!data || data[key] === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
return data[key] as T;
|
||||
}
|
||||
501
src/common/config-panel-types.ts
Normal file
501
src/common/config-panel-types.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* ConfigPanel API Type Definitions
|
||||
* 配置面板 API 类型定义
|
||||
*
|
||||
* @version 2.0
|
||||
* @author Lintendo
|
||||
* @description 可扩展的属性配置系统,支持第三方集成使用
|
||||
*
|
||||
* 核心特性:
|
||||
* - 声明式配置
|
||||
* - 树形结构组织
|
||||
* - 动态显示/隐藏
|
||||
* - 丰富的组件类型
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 核心类型定义 Core Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 配置项基础属性
|
||||
* Base properties for all configuration items
|
||||
*/
|
||||
export interface AttributeComponentProps {
|
||||
/** 组件类型 Component type (e.g., 'input', 'select', 'colorPicker') */
|
||||
type?: string;
|
||||
|
||||
/** 属性唯一标识符 Unique attribute identifier (supports dot notation like 'style.fontSize') */
|
||||
attributeId?: string;
|
||||
|
||||
/** 显示名称 Display name shown in UI */
|
||||
displayName?: string;
|
||||
|
||||
/** 描述信息(提示文本) Description or tooltip text */
|
||||
info?: string;
|
||||
|
||||
/** 默认值 Initial/default value */
|
||||
initialValue?: any;
|
||||
|
||||
/** 子配置项 Child configuration items (for nested structures) */
|
||||
children?: AttributeComponentProps[];
|
||||
|
||||
/** 是否显示 Whether to show this item (default: true) */
|
||||
show?: boolean;
|
||||
|
||||
/** 组件特定配置 Component-specific configuration properties */
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整配置对象
|
||||
* Complete configuration object
|
||||
*/
|
||||
export interface AttributesConfig {
|
||||
/** 配置树 Configuration tree */
|
||||
config: AttributeComponentProps;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 布局组件 Layout Components
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 分组配置
|
||||
* Group configuration
|
||||
*
|
||||
* @description 用于在面板内部进行逻辑分组
|
||||
* Used for logical grouping within panels
|
||||
*/
|
||||
export interface GroupConfig extends AttributeComponentProps {
|
||||
type: 'group';
|
||||
|
||||
/** 分组显示名称 Group display name */
|
||||
displayName: string;
|
||||
|
||||
/** 显示类型 Display type ('inline' for inline display) */
|
||||
displayType?: 'inline' | 'default';
|
||||
|
||||
/** 分组内的配置项 Configuration items within the group */
|
||||
children: AttributeComponentProps[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 基础输入组件 Basic Input Components
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 文本输入框配置
|
||||
* Text input configuration
|
||||
*/
|
||||
export interface InputConfig extends AttributeComponentProps {
|
||||
type: 'input';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 占位符文本 Placeholder text */
|
||||
placeholder?: string;
|
||||
|
||||
/** 输入框宽度 Input width (default: '40%') */
|
||||
width?: string;
|
||||
|
||||
/** 是否禁用 Whether disabled */
|
||||
disabled?: boolean;
|
||||
|
||||
/** 初始值 Initial value */
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字输入框配置
|
||||
* Number input configuration
|
||||
*/
|
||||
export interface InputNumberConfig extends AttributeComponentProps {
|
||||
type: 'inputNumber';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 最小值 Minimum value */
|
||||
min?: number;
|
||||
|
||||
/** 最大值 Maximum value */
|
||||
max?: number;
|
||||
|
||||
/** 步长 Step increment (default: 1) */
|
||||
step?: number;
|
||||
|
||||
/** 占位符文本 Placeholder text */
|
||||
placeholder?: string;
|
||||
|
||||
/** 初始值 Initial value */
|
||||
initialValue?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复选框配置
|
||||
* Checkbox configuration
|
||||
*/
|
||||
export interface CheckboxConfig extends AttributeComponentProps {
|
||||
type: 'checkbox';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 是否禁用 Whether disabled */
|
||||
disabled?: boolean;
|
||||
|
||||
/** 初始值 Initial value */
|
||||
initialValue?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑块配置
|
||||
* Slider configuration
|
||||
*/
|
||||
export interface SliderConfig extends AttributeComponentProps {
|
||||
type: 'slider';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 最小值 Minimum value */
|
||||
min: number;
|
||||
|
||||
/** 最大值 Maximum value */
|
||||
max: number;
|
||||
|
||||
/** 步长 Step increment */
|
||||
step?: number;
|
||||
|
||||
/** 是否显示数字输入框 Whether to show number input alongside slider */
|
||||
showInputNumber?: boolean;
|
||||
|
||||
/** 初始值 Initial value */
|
||||
initialValue?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 选择组件 Selection Components
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 选项定义
|
||||
* Option definition for select components
|
||||
*/
|
||||
export interface SelectOption {
|
||||
/** 显示标签 Display label */
|
||||
label: string;
|
||||
|
||||
/** 选项值 Option value */
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉选择框配置
|
||||
* Select dropdown configuration
|
||||
*/
|
||||
export interface SelectConfig extends AttributeComponentProps {
|
||||
type: 'select';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 选项数组 Array of options */
|
||||
options: SelectOption[];
|
||||
|
||||
/** 选择模式 Selection mode ('multiple' for multi-select) */
|
||||
mode?: 'multiple';
|
||||
|
||||
/** 下拉框宽度 Dropdown width (default: 120) */
|
||||
dropdownMatchSelectWidth?: number;
|
||||
|
||||
/** 初始值 Initial value */
|
||||
initialValue?: string | number | string[] | number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动完成配置
|
||||
* AutoComplete configuration
|
||||
*/
|
||||
export interface AutoCompleteConfig extends AttributeComponentProps {
|
||||
type: 'autoComplete';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 建议选项数组 Array of suggestion options */
|
||||
options: SelectOption[];
|
||||
|
||||
/** 弹出框宽度 Popup width (default: 200) */
|
||||
popupMatchSelectWidth?: number;
|
||||
|
||||
/** 初始值 Initial value */
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 颜色组件 Color Components
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 颜色选择器配置
|
||||
* Color picker configuration
|
||||
*/
|
||||
export interface ColorPickerConfig extends AttributeComponentProps {
|
||||
type: 'colorPicker';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 选择器类型 Picker type */
|
||||
picker?: 'common' | 'lite';
|
||||
|
||||
/** 初始值 Initial color value (hex format) */
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 复杂数据组件 Complex Data Components
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 数组数据编辑器配置
|
||||
* Array data editor configuration
|
||||
*
|
||||
* @description 弹窗编辑,每行一个数据项
|
||||
* Modal editor, one item per line
|
||||
*/
|
||||
export interface ArrayDataConfig extends AttributeComponentProps {
|
||||
type: 'arrayData';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 初始值 Initial array value */
|
||||
initialValue?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格列定义
|
||||
* Table column definition
|
||||
*/
|
||||
export interface TableColumn {
|
||||
/** 字段名 Field name */
|
||||
name: string;
|
||||
|
||||
/** 列显示名 Column display name */
|
||||
colName: string;
|
||||
|
||||
/** 输入类型 Input type */
|
||||
type: 'text' | 'select' | 'color' | 'number' | 'icon';
|
||||
|
||||
/** select 类型的选项 Options for select type */
|
||||
options?: SelectOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格数据编辑器配置
|
||||
* Table data editor configuration
|
||||
*
|
||||
* @description 支持添加、删除、复制、排序行
|
||||
* Supports add, delete, copy, and reorder rows
|
||||
*/
|
||||
export interface TableConfig extends AttributeComponentProps {
|
||||
type: 'table';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 是否为 Map 结构(以第一列值为 key) Whether to use Map structure (first column as key) */
|
||||
isMap?: boolean;
|
||||
|
||||
/** 列定义数组 Array of column definitions */
|
||||
columns: TableColumn[];
|
||||
|
||||
/** 初始值 Initial table data */
|
||||
initialValue?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map 列定义
|
||||
* Map column definition
|
||||
*/
|
||||
export interface MapColumn {
|
||||
/** 键名 Key name */
|
||||
key: string;
|
||||
|
||||
/** 列显示名 Column display name */
|
||||
colName: string;
|
||||
|
||||
/** 输入类型 Input type */
|
||||
type: 'text' | 'select' | 'color' | 'number' | 'icon';
|
||||
|
||||
/** 属性 ID(支持 {key} 占位符) Attribute ID (supports {key} placeholder) */
|
||||
attributeId: string;
|
||||
|
||||
/** select 类型的选项 Options for select type */
|
||||
options?: SelectOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 键值对数据编辑器配置
|
||||
* Key-value pair data editor configuration
|
||||
*
|
||||
* @description 固定键名,编辑值,支持嵌套属性更新
|
||||
* Fixed keys, editable values, supports nested property updates
|
||||
*/
|
||||
export interface MapConfig extends AttributeComponentProps {
|
||||
type: 'map';
|
||||
attributeId: string;
|
||||
displayName: string;
|
||||
|
||||
/** 列定义数组 Array of column definitions */
|
||||
columns: MapColumn[];
|
||||
|
||||
/** 初始值 Initial map value */
|
||||
initialValue?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组合组件 Composite Components
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 字体设置配置
|
||||
* Font setting configuration
|
||||
*
|
||||
* @description 组合了颜色、字号、字重、透明度等设置
|
||||
* Combines color, size, weight, opacity settings
|
||||
*/
|
||||
export interface FontSettingConfig extends AttributeComponentProps {
|
||||
type: 'fontSetting';
|
||||
displayName: string;
|
||||
|
||||
/** 属性 ID 映射 Attribute ID mapping */
|
||||
attributeIdMap: {
|
||||
fontColor?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
fontFamily?: string;
|
||||
textAlign?: string;
|
||||
opacity?: string;
|
||||
};
|
||||
|
||||
/** 初始值 Initial values */
|
||||
initialValue?: {
|
||||
fontColor?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string;
|
||||
fontFamily?: string;
|
||||
textAlign?: string;
|
||||
opacity?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 线条设置配置
|
||||
* Line setting configuration
|
||||
*
|
||||
* @description 组合了线条颜色、线宽、虚线样式、长度等设置
|
||||
* Combines line color, width, dash style, length settings
|
||||
*/
|
||||
export interface LineSettingConfig extends AttributeComponentProps {
|
||||
type: 'lineSetting';
|
||||
displayName: string;
|
||||
|
||||
/** 属性 ID 映射 Attribute ID mapping */
|
||||
attributeIdMap: {
|
||||
lineWidth?: string;
|
||||
lineColor?: string;
|
||||
lineDash?: string;
|
||||
length?: string;
|
||||
};
|
||||
|
||||
/** 初始值 Initial values */
|
||||
initialValue?: {
|
||||
lineWidth?: number;
|
||||
lineColor?: string;
|
||||
lineDash?: number[];
|
||||
length?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 端点设置配置
|
||||
* Point setting configuration
|
||||
*
|
||||
* @description 用于设置线条端点样式
|
||||
* Used for setting line endpoint styles
|
||||
*/
|
||||
export interface PointSettingConfig extends AttributeComponentProps {
|
||||
type: 'pointSetting';
|
||||
displayName: string;
|
||||
attributeIdMap?: Record<string, string>;
|
||||
initialValue?: any;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 使用示例 Usage Examples
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 完整配置示例
|
||||
* Complete configuration example
|
||||
*
|
||||
* @example
|
||||
* const config: AttributesConfig = {
|
||||
* config: {
|
||||
* type: 'collapse',
|
||||
* children: [
|
||||
* {
|
||||
* displayName: '基础设置',
|
||||
* show: true,
|
||||
* children: [
|
||||
* {
|
||||
* type: 'input',
|
||||
* attributeId: 'title',
|
||||
* displayName: '标题',
|
||||
* initialValue: '默认标题',
|
||||
* placeholder: '请输入标题'
|
||||
* },
|
||||
* {
|
||||
* type: 'checkbox',
|
||||
* attributeId: 'showBorder',
|
||||
* displayName: '显示边框',
|
||||
* initialValue: true
|
||||
* },
|
||||
* {
|
||||
* type: 'colorPicker',
|
||||
* attributeId: 'borderColor',
|
||||
* displayName: '边框颜色',
|
||||
* initialValue: '#000000'
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 最佳实践 Best Practices
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 最佳实践指南
|
||||
* Best Practices Guide
|
||||
*
|
||||
* 1. attributeId 命名规范 Naming Convention:
|
||||
* - 使用点分隔的路径 Use dot-separated paths: 'style.fontSize'
|
||||
* - 保持一致性和可读性 Maintain consistency and readability
|
||||
* - 避免使用特殊字符 Avoid special characters
|
||||
*
|
||||
* 2. 初始值设置 Initial Values:
|
||||
* - 始终提供合理的 initialValue Always provide reasonable initialValue
|
||||
* - 确保初始值类型与组件匹配 Ensure type matches component
|
||||
*
|
||||
* 3. 分组组织 Grouping:
|
||||
* - 使用 Collapse 组织大量配置项 Use Collapse for many items
|
||||
* - 使用 Group 进行逻辑分组 Use Group for logical grouping
|
||||
* - 相关配置项放在一起 Keep related items together
|
||||
*
|
||||
* 4. 性能优化 Performance:
|
||||
* - 避免过深的嵌套层级 Avoid deep nesting
|
||||
* - 合理使用 show 属性预先隐藏不需要的项 Use show to hide unnecessary items
|
||||
* - 大量数据使用 Table 或 Map 组件 Use Table/Map for large datasets
|
||||
*/
|
||||
24
src/common/react-dom-shim.js
vendored
Normal file
24
src/common/react-dom-shim.js
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
// react-dom-shim.js
|
||||
const RD = window.ReactDOM;
|
||||
|
||||
export default RD;
|
||||
|
||||
// ReactDOM 18+ (createRoot / hydrateRoot)
|
||||
export const {
|
||||
createRoot,
|
||||
hydrateRoot,
|
||||
|
||||
// ReactDOM 17 兼容 API(一些环境仍然可能需要)
|
||||
render,
|
||||
hydrate,
|
||||
unmountComponentAtNode,
|
||||
findDOMNode,
|
||||
|
||||
// Server side features (如果 CDN 提供)
|
||||
createPortal,
|
||||
|
||||
// React 18 Transition API(可能存在)
|
||||
flushSync,
|
||||
unstable_batchedUpdates,
|
||||
unstable_renderSubtreeIntoContainer,
|
||||
} = RD;
|
||||
47
src/common/react-shim.js
vendored
Normal file
47
src/common/react-shim.js
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
const R = window.React;
|
||||
const RJSXRuntime = window.ReactJSXRuntime || {};
|
||||
|
||||
export default R;
|
||||
|
||||
export const {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useReducer,
|
||||
useLayoutEffect,
|
||||
useImperativeHandle,
|
||||
useDebugValue,
|
||||
useDeferredValue,
|
||||
useTransition,
|
||||
useId,
|
||||
useSyncExternalStore,
|
||||
useInsertionEffect,
|
||||
|
||||
forwardRef,
|
||||
memo,
|
||||
createElement,
|
||||
Fragment,
|
||||
Component,
|
||||
PureComponent,
|
||||
createContext,
|
||||
createRef,
|
||||
lazy,
|
||||
Suspense,
|
||||
StrictMode,
|
||||
Profiler,
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
createFactory,
|
||||
startTransition,
|
||||
act,
|
||||
version,
|
||||
} = R;
|
||||
|
||||
// JSX Runtime exports for modern React
|
||||
export const jsx = RJSXRuntime.jsx || createElement;
|
||||
export const jsxs = RJSXRuntime.jsxs || createElement;
|
||||
export const jsxDEV = RJSXRuntime.jsxDEV || createElement;
|
||||
237
src/common/side-menu/index.tsx
Normal file
237
src/common/side-menu/index.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @name 侧边菜单
|
||||
*
|
||||
* 参考资料:
|
||||
* - /rules/development-guide.md
|
||||
* - /rules/axure-api-guide.md
|
||||
* - /docs/设计规范.UIGuidelines.md
|
||||
*/
|
||||
|
||||
import './style.css';
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LayoutDashboard,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings,
|
||||
ShoppingBag,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { getNewlyOpenedSubmenuKey } from './side-menu-utils';
|
||||
|
||||
type MenuItemInput = {
|
||||
key?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
children?: MenuItemInput[];
|
||||
};
|
||||
|
||||
type SideMenuProps = {
|
||||
title?: string;
|
||||
width?: number;
|
||||
collapsible?: boolean;
|
||||
defaultCollapsed?: boolean;
|
||||
defaultSelectedKey?: string;
|
||||
defaultOpenKeys?: string[];
|
||||
items?: MenuItemInput[];
|
||||
onMenuSelect?: (key: string) => void;
|
||||
onCollapseChange?: (collapsed: boolean) => void;
|
||||
};
|
||||
|
||||
function resolveIcon(name?: string): LucideIcon | null {
|
||||
switch (name) {
|
||||
case 'dashboard':
|
||||
return LayoutDashboard;
|
||||
case 'shop':
|
||||
return ShoppingBag;
|
||||
case 'user':
|
||||
return User;
|
||||
case 'setting':
|
||||
return Settings;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeItems(items: any): MenuItemInput[] {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
return items.map(function (it: any) {
|
||||
return {
|
||||
key: typeof it?.key === 'string' && it.key ? it.key : String(it?.key ?? it?.label ?? ''),
|
||||
label: typeof it?.label === 'string' ? it.label : String(it?.label ?? ''),
|
||||
icon: typeof it?.icon === 'string' ? it.icon : undefined,
|
||||
disabled: it?.disabled === true,
|
||||
children: Array.isArray(it?.children) ? normalizeItems(it.children) : undefined
|
||||
};
|
||||
}).filter(function (it: MenuItemInput) { return !!it.key; });
|
||||
}
|
||||
|
||||
const DEFAULT_ITEMS: MenuItemInput[] = [
|
||||
{ key: 'dashboard', label: '仪表盘', icon: 'dashboard' },
|
||||
{
|
||||
key: 'orders',
|
||||
label: '订单管理',
|
||||
icon: 'shop',
|
||||
children: [
|
||||
{ key: 'orders_list', label: '订单列表' },
|
||||
{ key: 'orders_refund', label: '退款管理' }
|
||||
]
|
||||
},
|
||||
{ key: 'users', label: '用户管理', icon: 'user' },
|
||||
{ key: 'settings', label: '系统设置', icon: 'setting' }
|
||||
];
|
||||
|
||||
const Component = function SideMenu(props: SideMenuProps) {
|
||||
const title = typeof props.title === 'string' && props.title ? props.title : 'Axhub';
|
||||
const width = typeof props.width === 'number' && props.width > 0 ? props.width : 240;
|
||||
const collapsible = props.collapsible !== false;
|
||||
const defaultCollapsed = props.defaultCollapsed === true;
|
||||
const defaultSelectedKey = typeof props.defaultSelectedKey === 'string' && props.defaultSelectedKey
|
||||
? props.defaultSelectedKey
|
||||
: 'dashboard';
|
||||
const defaultOpenKeys = Array.isArray(props.defaultOpenKeys)
|
||||
? props.defaultOpenKeys.map(String).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const normalizedItems = useMemo(function () {
|
||||
const fromProps = normalizeItems(props.items);
|
||||
return fromProps.length > 0 ? fromProps : DEFAULT_ITEMS;
|
||||
}, [props.items]);
|
||||
|
||||
const collapsedState = useState<boolean>(defaultCollapsed);
|
||||
const collapsed = collapsedState[0];
|
||||
const setCollapsed = collapsedState[1];
|
||||
|
||||
const selectedKeyState = useState<string>(defaultSelectedKey);
|
||||
const selectedKey = selectedKeyState[0];
|
||||
const setSelectedKey = selectedKeyState[1];
|
||||
|
||||
const openKeysState = useState<string[]>(defaultOpenKeys);
|
||||
const openKeys = openKeysState[0];
|
||||
const setOpenKeys = openKeysState[1];
|
||||
|
||||
useEffect(function syncSelectedKey() {
|
||||
setSelectedKey(defaultSelectedKey);
|
||||
}, [defaultSelectedKey]);
|
||||
|
||||
useEffect(function syncOpenKeys() {
|
||||
setOpenKeys(defaultOpenKeys);
|
||||
}, [defaultOpenKeys]);
|
||||
|
||||
const handleToggleCollapsed = useCallback(function () {
|
||||
setCollapsed(function (prev) {
|
||||
const next = !prev;
|
||||
if (typeof props.onCollapseChange === 'function') {
|
||||
props.onCollapseChange(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [props]);
|
||||
|
||||
const handleMenuClick = useCallback(function (info: any) {
|
||||
const key = typeof info?.key === 'string' ? info.key : String(info?.key ?? '');
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
setSelectedKey(key);
|
||||
if (typeof props.onMenuSelect === 'function') {
|
||||
props.onMenuSelect(key);
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
const handleOpenChange = useCallback(function (keys: any) {
|
||||
const next = Array.isArray(keys) ? keys.map(String) : [];
|
||||
const openedKey = getNewlyOpenedSubmenuKey(openKeys, next);
|
||||
setOpenKeys(next);
|
||||
if (openedKey && typeof props.onMenuSelect === 'function') {
|
||||
props.onMenuSelect(openedKey);
|
||||
}
|
||||
}, [openKeys, props]);
|
||||
|
||||
const renderMenuItems = useCallback(function renderMenuItems(items: MenuItemInput[], level = 0): React.ReactNode {
|
||||
return (
|
||||
<ul className={level === 0 ? 'axhub-side-menu__list' : 'axhub-side-menu__sublist'}>
|
||||
{items.map(function (item) {
|
||||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||
const Icon = resolveIcon(item.icon);
|
||||
const isOpen = item.key ? openKeys.includes(item.key) : false;
|
||||
const isSelected = item.key === selectedKey;
|
||||
const itemClassName = [
|
||||
'axhub-side-menu__item',
|
||||
isSelected ? 'axhub-side-menu__item--selected' : '',
|
||||
item.disabled ? 'axhub-side-menu__item--disabled' : '',
|
||||
hasChildren ? 'axhub-side-menu__item--parent' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<li key={item.key} className={itemClassName}>
|
||||
<button
|
||||
type="button"
|
||||
className="axhub-side-menu__item-button"
|
||||
disabled={item.disabled}
|
||||
title={collapsed ? item.label : undefined}
|
||||
onClick={function () {
|
||||
if (hasChildren) {
|
||||
handleOpenChange(isOpen
|
||||
? openKeys.filter(function (key) { return key !== item.key; })
|
||||
: [...openKeys, String(item.key)]);
|
||||
return;
|
||||
}
|
||||
handleMenuClick({ key: item.key });
|
||||
}}
|
||||
>
|
||||
<span className="axhub-side-menu__item-main">
|
||||
{Icon && <Icon className="axhub-side-menu__icon" aria-hidden="true" size={18} />}
|
||||
{!collapsed && <span className="axhub-side-menu__label">{item.label}</span>}
|
||||
</span>
|
||||
{!collapsed && hasChildren && (
|
||||
isOpen
|
||||
? <ChevronDown className="axhub-side-menu__chevron" aria-hidden="true" size={16} />
|
||||
: <ChevronRight className="axhub-side-menu__chevron" aria-hidden="true" size={16} />
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && hasChildren && isOpen && renderMenuItems(item.children || [], level + 1)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}, [collapsed, handleMenuClick, handleOpenChange, openKeys, selectedKey]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="axhub-side-menu"
|
||||
style={{
|
||||
width: collapsed ? 64 : width
|
||||
}}
|
||||
>
|
||||
<div className={'axhub-side-menu__header' + (collapsed ? ' axhub-side-menu__header--collapsed' : '')}>
|
||||
{!collapsed && <div className="axhub-side-menu__title">{title}</div>}
|
||||
{collapsible && (
|
||||
<button
|
||||
className="axhub-side-menu__collapse"
|
||||
type="button"
|
||||
aria-label={collapsed ? '展开侧边菜单' : '折叠侧边菜单'}
|
||||
onClick={handleToggleCollapsed}
|
||||
>
|
||||
{collapsed
|
||||
? <PanelLeftOpen aria-hidden="true" size={18} />
|
||||
: <PanelLeftClose aria-hidden="true" size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className="axhub-side-menu__nav" aria-label={title}>
|
||||
{renderMenuItems(normalizedItems)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Component;
|
||||
9
src/common/side-menu/side-menu-utils.ts
Normal file
9
src/common/side-menu/side-menu-utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function getNewlyOpenedSubmenuKey(previousKeys: string[], nextKeys: string[]): string | null {
|
||||
for (const key of nextKeys) {
|
||||
if (!previousKeys.includes(key)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
138
src/common/side-menu/spec.md
Normal file
138
src/common/side-menu/spec.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 侧边菜单
|
||||
|
||||
## 📋 业务与功能
|
||||
|
||||
### 1.1 核心目标
|
||||
|
||||
该组件提供后台场景常见的侧边导航能力,支持分级菜单与折叠交互。
|
||||
|
||||
该元素强调结构清晰、信息密度适中,适配后台场景。
|
||||
|
||||
### 1.2 功能清单
|
||||
|
||||
- **顶部标题区域**:显示应用或系统名称
|
||||
- **菜单项区域**:支持一级/二级菜单项、图标与禁用状态
|
||||
- **折叠按钮**:控制侧边栏折叠/展开
|
||||
|
||||
### 1.3 交互要点
|
||||
|
||||
- 点击菜单项:选中菜单项并触发 `onMenuSelect`
|
||||
- 点击折叠按钮:切换折叠状态并触发 `onCollapseChange`
|
||||
- 子菜单展开/收起:点击父级菜单项控制
|
||||
- 悬停菜单项:高亮反馈
|
||||
|
||||
---
|
||||
|
||||
## 📊 内容规划
|
||||
|
||||
### 2.1 信息架构
|
||||
|
||||
```
|
||||
侧边菜单
|
||||
├── 顶部标题
|
||||
│ └── 折叠按钮
|
||||
└── 菜单列表
|
||||
├── 一级菜单
|
||||
└── 二级子菜单(可选)
|
||||
```
|
||||
|
||||
### 2.2 数据来源
|
||||
|
||||
- **数据类型**:菜单项列表(`items`)
|
||||
- **数据源**:用户提供(props)/ 内置默认菜单
|
||||
- **关键字段**:
|
||||
- `key`: 唯一标识
|
||||
- `label`: 显示文本
|
||||
- `icon`: 图标名称(dashboard / shop / user / setting)
|
||||
- `disabled`: 是否禁用
|
||||
- `children`: 子菜单数组
|
||||
|
||||
### 2.3 内容示例
|
||||
|
||||
**默认菜单示例**:
|
||||
|
||||
- 仪表盘 / 订单管理(含订单列表、退款管理)/ 用户管理 / 系统设置
|
||||
|
||||
---
|
||||
|
||||
## 🎨 布局与结构
|
||||
|
||||
### 3.1 整体布局
|
||||
|
||||
- **布局模式**:垂直侧边栏
|
||||
- **容器宽度**:固定
|
||||
- **关键尺寸**:
|
||||
- 展开宽度:`240px`
|
||||
- 折叠宽度:`64px`
|
||||
- 顶部标题区高度:`48px`
|
||||
|
||||
### 3.2 响应式适配
|
||||
|
||||
- **桌面端(≥1200px)**:默认展开显示
|
||||
- **平板端(768-1199px)**:建议默认折叠或在主区保留更大空间
|
||||
- **移动端(<768px)**:建议折叠或使用抽屉式替代
|
||||
|
||||
---
|
||||
|
||||
## 🎨 视觉规范
|
||||
|
||||
### 4.1 设计规范来源
|
||||
|
||||
**设计依据**:
|
||||
|
||||
- [x] 用户提供的设计规范:`/docs/设计规范.UIGuidelines.md`
|
||||
- [x] 主题设计系统:`/src/themes/antd-new/`(DESIGN.md + designToken.json + globals.css)
|
||||
|
||||
### 4.2 自定义设计要点
|
||||
|
||||
**自定义色彩**(如有):
|
||||
|
||||
- 无
|
||||
|
||||
**自定义尺寸**(如有):
|
||||
|
||||
- 无
|
||||
|
||||
**其他自定义规范**(如有):
|
||||
- 无
|
||||
|
||||
### 4.3 组件状态
|
||||
|
||||
- **默认态(default)**:菜单项正常显示
|
||||
- **悬停态(hover)**:菜单项高亮背景
|
||||
- **选中态(selected)**:当前菜单项高亮
|
||||
- **禁用态(disabled)**:菜单项灰化不可点击
|
||||
- **折叠态(collapsed)**:仅展示图标,标题隐藏
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Axure API 说明
|
||||
|
||||
### 5.1 事件列表(eventList)
|
||||
|
||||
### 5.2 动作列表(actionList)
|
||||
|
||||
### 5.3 变量列表(varList)
|
||||
|
||||
### 5.4 配置项列表(configList)
|
||||
|
||||
### 5.5 数据项列表(dataList)
|
||||
|
||||
**数据结构**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
items: Array<{
|
||||
key: string; // 菜单项唯一标识
|
||||
label: string; // 显示文本
|
||||
icon?: string; // 图标名称
|
||||
disabled?: boolean; // 是否禁用
|
||||
children?: Array<{ // 子菜单项
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
131
src/common/side-menu/style.css
Normal file
131
src/common/side-menu/style.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.axhub-side-menu {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.axhub-side-menu__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
height: 48px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.axhub-side-menu__header--collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.axhub-side-menu__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.axhub-side-menu__collapse {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: #475569;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.axhub-side-menu__collapse:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.axhub-side-menu__nav {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.axhub-side-menu__list,
|
||||
.axhub-side-menu__sublist {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.axhub-side-menu__sublist {
|
||||
padding-left: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.axhub-side-menu__item {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.axhub-side-menu__item-button {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.axhub-side-menu__item-button:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.axhub-side-menu__item--selected > .axhub-side-menu__item-button {
|
||||
color: #0f172a;
|
||||
background: #e8f0ff;
|
||||
}
|
||||
|
||||
.axhub-side-menu__item--disabled > .axhub-side-menu__item-button {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.axhub-side-menu__item--disabled > .axhub-side-menu__item-button:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.axhub-side-menu__item-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.axhub-side-menu__icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.axhub-side-menu__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.axhub-side-menu__chevron {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
153
src/common/useHashPage.ts
Normal file
153
src/common/useHashPage.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 多页面原型的轻量 hash 路由 hook。
|
||||
*
|
||||
* URL 格式: #page=<pageId>
|
||||
* pageId 仅允许小写字母、数字、连字符(a-z 0-9 -)。
|
||||
*
|
||||
* 用法:
|
||||
* const { page, setPage } = useHashPage('home');
|
||||
* const route = defineHashPageRoute([
|
||||
* { id: 'dashboard', title: '工作台' },
|
||||
* ], { defaultPageId: 'dashboard' });
|
||||
* const { page, setPage, pages } = useHashPage(route);
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface HashPageRoutePage {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface HashPageRoute {
|
||||
pages: HashPageRoutePage[];
|
||||
defaultPageId: string;
|
||||
}
|
||||
|
||||
const PAGE_ID_RE = /^[a-z0-9-]+$/u;
|
||||
|
||||
function normalizePageId(value: unknown): string {
|
||||
const id = typeof value === 'string' ? value.trim() : '';
|
||||
return PAGE_ID_RE.test(id) ? id : '';
|
||||
}
|
||||
|
||||
function normalizeRoutePages(pages: HashPageRoutePage[]): HashPageRoutePage[] {
|
||||
if (!Array.isArray(pages)) {
|
||||
return [];
|
||||
}
|
||||
return pages
|
||||
.map((page) => {
|
||||
const id = normalizePageId(page?.id);
|
||||
const title = typeof page?.title === 'string' ? page.title.trim() : '';
|
||||
return id && title ? { id, title } : null;
|
||||
})
|
||||
.filter((page): page is HashPageRoutePage => Boolean(page));
|
||||
}
|
||||
|
||||
export function parseHashPage(hash: string): string | null {
|
||||
const rawHash = String(hash || '').replace(/^#/, '');
|
||||
const pageId = new URLSearchParams(rawHash).get('page');
|
||||
return normalizePageId(pageId) || null;
|
||||
}
|
||||
|
||||
export function parseSearchPage(search: string): string | null {
|
||||
const rawSearch = String(search || '').replace(/^\?/, '');
|
||||
const pageId = new URLSearchParams(rawSearch).get('page');
|
||||
return normalizePageId(pageId) || null;
|
||||
}
|
||||
|
||||
export function defineHashPageRoute(
|
||||
pages: HashPageRoutePage[],
|
||||
options?: { defaultPageId?: string },
|
||||
): HashPageRoute {
|
||||
const normalizedPages = normalizeRoutePages(pages);
|
||||
const defaultPageId = normalizePageId(options?.defaultPageId);
|
||||
return {
|
||||
pages: normalizedPages,
|
||||
defaultPageId: normalizedPages.some((page) => page.id === defaultPageId)
|
||||
? defaultPageId
|
||||
: normalizedPages[0]?.id || 'home',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRouteInput(routeOrDefault?: HashPageRoute | string): HashPageRoute {
|
||||
if (routeOrDefault && typeof routeOrDefault === 'object') {
|
||||
return defineHashPageRoute(routeOrDefault.pages, { defaultPageId: routeOrDefault.defaultPageId });
|
||||
}
|
||||
const defaultPageId = normalizePageId(routeOrDefault) || 'home';
|
||||
return {
|
||||
pages: [],
|
||||
defaultPageId,
|
||||
};
|
||||
}
|
||||
|
||||
function notifyHostPrototypePageChange(pageId: string) {
|
||||
if (typeof window === 'undefined' || window.parent === window) {
|
||||
return;
|
||||
}
|
||||
window.parent.postMessage({
|
||||
type: 'AXHUB_PROTOTYPE_PAGE_CHANGE',
|
||||
pageId,
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function notifyHostPrototypeRouteInfo(
|
||||
pages: HashPageRoutePage[],
|
||||
defaultPageId: string,
|
||||
activePageId: string,
|
||||
) {
|
||||
if (
|
||||
pages.length === 0
|
||||
|| typeof window === 'undefined'
|
||||
|| window.parent === window
|
||||
) {
|
||||
return;
|
||||
}
|
||||
window.parent.postMessage({
|
||||
type: 'AXHUB_PROTOTYPE_ROUTE_INFO',
|
||||
pages,
|
||||
defaultPageId,
|
||||
activePageId,
|
||||
}, '*');
|
||||
}
|
||||
|
||||
export function useHashPage(routeOrDefault: HashPageRoute | string = 'home') {
|
||||
const route = normalizeRouteInput(routeOrDefault);
|
||||
const { pages, defaultPageId } = route;
|
||||
const routeSignature = `${defaultPageId}:${pages.map((routePage) => `${routePage.id}=${routePage.title}`).join('|')}`;
|
||||
const [page, setPageState] = useState<string>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultPageId;
|
||||
}
|
||||
return parseHashPage(window.location.hash) ?? parseSearchPage(window.location.search) ?? defaultPageId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
notifyHostPrototypeRouteInfo(pages, defaultPageId, page);
|
||||
}, [routeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onHashChange = () => {
|
||||
const next = parseHashPage(window.location.hash) ?? parseSearchPage(window.location.search);
|
||||
const nextPageId = next ?? defaultPageId;
|
||||
setPageState(nextPageId);
|
||||
notifyHostPrototypePageChange(nextPageId);
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, [defaultPageId]);
|
||||
|
||||
const setPage = useCallback((pageId: string) => {
|
||||
const nextPageId = normalizePageId(pageId);
|
||||
if (!nextPageId || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.location.hash = `page=${nextPageId}`;
|
||||
}, []);
|
||||
|
||||
return { page, setPage, pages, defaultPageId };
|
||||
}
|
||||
177
src/index.html
Normal file
177
src/index.html
Normal file
@@ -0,0 +1,177 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Axhub Make Project</title>
|
||||
<style>
|
||||
:root {
|
||||
color: #20242a;
|
||||
background: #f7f8fa;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 680px);
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #22262d;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #20242a;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
min-height: 40px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #5f6673;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-top: 12px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p>可以通过 npx @axhub/make 启动管理页面。</p>
|
||||
<div class="actions">
|
||||
<button id="start-button" type="button" hidden>启动管理服务</button>
|
||||
</div>
|
||||
<div id="status" class="status">正在检查管理服务...</div>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var statusElement = document.getElementById('status');
|
||||
var startButton = document.getElementById('start-button');
|
||||
var pollTimer = 0;
|
||||
|
||||
function setStatus(text) {
|
||||
if (statusElement) {
|
||||
statusElement.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWhenReady(payload) {
|
||||
if (payload && payload.ready && payload.adminUrl) {
|
||||
location.replace(payload.adminUrl);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function showStartButton() {
|
||||
if (startButton) {
|
||||
startButton.hidden = false;
|
||||
startButton.disabled = false;
|
||||
startButton.textContent = '启动管理服务';
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
window.clearTimeout(pollTimer);
|
||||
pollTimer = window.setTimeout(checkStatus, 1200);
|
||||
}
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
var response = await fetch('/__axhub/make-server/status', { headers: { accept: 'application/json' } });
|
||||
var payload = await response.json();
|
||||
if (redirectWhenReady(payload)) {
|
||||
return;
|
||||
}
|
||||
if (payload && payload.starting) {
|
||||
setStatus('管理服务正在启动...');
|
||||
schedulePoll();
|
||||
return;
|
||||
}
|
||||
setStatus(payload && payload.error ? payload.error : '管理服务尚未启动。');
|
||||
showStartButton();
|
||||
} catch (error) {
|
||||
setStatus('管理服务尚未启动。');
|
||||
showStartButton();
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
if (!startButton || startButton.disabled) {
|
||||
return;
|
||||
}
|
||||
startButton.disabled = true;
|
||||
startButton.textContent = '启动中...';
|
||||
setStatus('正在执行 npx @axhub/make...');
|
||||
try {
|
||||
var response = await fetch('/__axhub/make-server/start', { method: 'POST', headers: { accept: 'application/json' } });
|
||||
var payload = await response.json();
|
||||
if (redirectWhenReady(payload)) {
|
||||
return;
|
||||
}
|
||||
setStatus(payload && payload.error ? payload.error : '管理服务正在启动...');
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
setStatus('启动失败,请稍后重试。');
|
||||
showStartButton();
|
||||
}
|
||||
}
|
||||
|
||||
if (startButton) {
|
||||
startButton.addEventListener('click', startServer);
|
||||
}
|
||||
checkStatus();
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
src/preview-templates/dev-template.html
Normal file
22
src/preview-templates/dev-template.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
{{PREVIEW_LOADER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
40
src/preview-templates/spec-template.html
Normal file
40
src/preview-templates/spec-template.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
body {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #172033;
|
||||
background: #f7f8fb;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
nav a {
|
||||
color: #1760bf;
|
||||
text-decoration: none;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.65;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>{{DOC_TABS}}</nav>
|
||||
<pre>{{MARKDOWN}}</pre>
|
||||
</body>
|
||||
</html>
|
||||
3375
src/prototypes/contract-template-management/ContractTemplate.jsx
Normal file
3375
src/prototypes/contract-template-management/ContractTemplate.jsx
Normal file
File diff suppressed because one or more lines are too long
324
src/prototypes/contract-template-management/contract-template-vars.js
vendored
Normal file
324
src/prototypes/contract-template-management/contract-template-vars.js
vendored
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 合同模板占位符变量 — 供合同模板管理与租赁合同创建页共用
|
||||
*/
|
||||
|
||||
export var DEFAULT_LESSOR_COMPANIES = [
|
||||
{
|
||||
id: 'jx',
|
||||
shortName: '嘉兴羚牛',
|
||||
enabled: true,
|
||||
legalName: '浙江羚牛氢能科技有限公司',
|
||||
creditCode: '91330481MA2JXXXXX',
|
||||
address: '浙江省嘉兴市平湖市乍浦镇杭州湾大道1号',
|
||||
contact: '法务部',
|
||||
phone: '0573-88888888',
|
||||
email: 'legal@lingniu-jx.com',
|
||||
accountName: '浙江羚牛氢能科技有限公司',
|
||||
bankName: '招商银行嘉兴分行',
|
||||
bankAccount: '120924165110301',
|
||||
hotline: '400-021-1773',
|
||||
},
|
||||
{
|
||||
id: 'sh',
|
||||
shortName: '上海羚牛',
|
||||
enabled: true,
|
||||
legalName: '羚牛氢能科技(上海)有限公司',
|
||||
creditCode: '91310115MA1HXXXXX',
|
||||
address: '上海市浦东新区张江高科技园区科苑路88号',
|
||||
contact: '法务部',
|
||||
phone: '021-88888888',
|
||||
email: 'legal@lingniu-sh.com',
|
||||
accountName: '羚牛氢能科技(上海)有限公司',
|
||||
bankName: '招商银行上海张江支行',
|
||||
bankAccount: '120924165110401',
|
||||
hotline: '400-021-1773',
|
||||
},
|
||||
{
|
||||
id: 'gd',
|
||||
shortName: '广东羚牛',
|
||||
enabled: true,
|
||||
legalName: '羚牛氢能科技(广东)有限公司',
|
||||
creditCode: '91440101MA5CXXXXX',
|
||||
address: '广东省广州市黄埔区科学城开源大道11号',
|
||||
contact: '法务部',
|
||||
phone: '020-88888888',
|
||||
email: 'legal@lingniu-gd.com',
|
||||
accountName: '羚牛氢能科技(广东)有限公司',
|
||||
bankName: '招商银行广州萝岗支行',
|
||||
bankAccount: '120924165110201',
|
||||
hotline: '400-021-1773',
|
||||
},
|
||||
];
|
||||
|
||||
export var MOCK_LEASE_CUSTOMERS = [
|
||||
{
|
||||
id: '1',
|
||||
name: '嘉兴某某物流有限公司',
|
||||
creditCode: '91330400MA2XXXXX1',
|
||||
address: '浙江省嘉兴市南湖区科技大道1号',
|
||||
contact: '张三',
|
||||
phone: '13800138001',
|
||||
email: 'zhangsan@example.com',
|
||||
companyName: '嘉兴某某物流有限公司',
|
||||
companyPhone: '0573-88888888',
|
||||
mailingAddress: '浙江省嘉兴市南湖区科技大道1号',
|
||||
bank: '中国工商银行嘉兴分行',
|
||||
bankAccount: '6222021234567890123',
|
||||
taxId: '91330400MA2XXXXX1',
|
||||
attachments: {
|
||||
businessLicense: { name: '嘉兴某某物流-营业执照.pdf' },
|
||||
legalIdFront: { name: '法人身份证-正面.jpg' },
|
||||
legalIdBack: { name: '法人身份证-反面.jpg' },
|
||||
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '上海某某运输公司',
|
||||
creditCode: '91310000MA2XXXXX2',
|
||||
address: '上海市浦东新区张江高科技园区',
|
||||
contact: '李四',
|
||||
phone: '13800138002',
|
||||
email: 'lisi@example.com',
|
||||
companyName: '上海某某运输公司',
|
||||
companyPhone: '021-66666666',
|
||||
mailingAddress: '上海市浦东新区张江高科技园区碧波路88号',
|
||||
bank: '中国建设银行上海分行',
|
||||
bankAccount: '6217001234567890123',
|
||||
taxId: '91310000MA2XXXXX2',
|
||||
attachments: {
|
||||
businessLicense: { name: '上海某某运输-营业执照.pdf' },
|
||||
legalIdFront: { name: '法人身份证-正面.jpg' },
|
||||
legalIdBack: { name: '法人身份证-反面.jpg' },
|
||||
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '杭州某某租赁有限公司',
|
||||
creditCode: '91330100MA2XXXXX3',
|
||||
address: '浙江省杭州市余杭区未来科技城',
|
||||
contact: '王五',
|
||||
phone: '13800138003',
|
||||
email: 'wangwu@example.com',
|
||||
companyName: '杭州某某租赁有限公司',
|
||||
companyPhone: '0571-99999999',
|
||||
mailingAddress: '浙江省杭州市余杭区文一西路998号',
|
||||
bank: '中国农业银行杭州分行',
|
||||
bankAccount: '6228481234567890123',
|
||||
taxId: '91330100MA2XXXXX3',
|
||||
attachments: {
|
||||
businessLicense: { name: '杭州某某租赁-营业执照.pdf' },
|
||||
legalIdFront: { name: '法人身份证-正面.jpg' },
|
||||
legalIdBack: { name: '法人身份证-反面.jpg' },
|
||||
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export var CUSTOMER_CREDENTIAL_ITEMS = [
|
||||
{ key: 'businessLicense', label: '客户营业执照' },
|
||||
{ key: 'legalIdFront', label: '法人身份证(正面)' },
|
||||
{ key: 'legalIdBack', label: '法人身份证(反面)' },
|
||||
{ key: 'roadTransportPermit', label: '道路运输许可证' },
|
||||
];
|
||||
|
||||
var ATTACHMENT_PREVIEW_COLORS = {
|
||||
businessLicense: { bg: '#ecfdf5', accent: '#32a06e' },
|
||||
legalIdFront: { bg: '#eff6ff', accent: '#3b82f6' },
|
||||
legalIdBack: { bg: '#eef2ff', accent: '#6366f1' },
|
||||
roadTransportPermit: { bg: '#fff7ed', accent: '#ea580c' },
|
||||
};
|
||||
|
||||
function escapeSvgText(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 客户表证照预览图(原型用 SVG 占位,模拟客户档案附件) */
|
||||
export function getCustomerAttachmentPreviewUrl(customer, key, label) {
|
||||
if (!customer) return '';
|
||||
var file = customer.attachments && customer.attachments[key];
|
||||
if (file && file.previewUrl) return file.previewUrl;
|
||||
var colors = ATTACHMENT_PREVIEW_COLORS[key] || { bg: '#f8fafc', accent: '#64748b' };
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480">'
|
||||
+ '<rect width="640" height="480" fill="' + colors.bg + '"/>'
|
||||
+ '<rect x="32" y="32" width="576" height="416" rx="12" fill="#ffffff" stroke="' + colors.accent + '" stroke-width="2"/>'
|
||||
+ '<text x="320" y="232" text-anchor="middle" font-family="PingFang SC,Microsoft YaHei,sans-serif" font-size="22" fill="#18181b">'
|
||||
+ escapeSvgText(label)
|
||||
+ '</text>'
|
||||
+ '<text x="320" y="272" text-anchor="middle" font-family="PingFang SC,Microsoft YaHei,sans-serif" font-size="14" fill="#64748b">'
|
||||
+ escapeSvgText(customer.name || '')
|
||||
+ '</text>'
|
||||
+ '</svg>';
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
export var DEFAULT_MILEAGE_STANDARD = {
|
||||
hasRequirement: false,
|
||||
period: 'month',
|
||||
targetKm: 6000,
|
||||
reductionYuan: 2000,
|
||||
validUntil: '2026-12-31',
|
||||
};
|
||||
|
||||
export var MILEAGE_PERIOD_OPTIONS = [
|
||||
{ value: 'month', labelBefore: '每月完成', labelAfter: '公里' },
|
||||
{ value: 'quarter', labelBefore: '每季度完成', labelAfter: '公里' },
|
||||
{ value: 'year', labelBefore: '每年度完成', labelAfter: '公里' },
|
||||
];
|
||||
|
||||
export var CONTRACT_CODE_PREFIX = 'LNZLHT';
|
||||
|
||||
export var DEFAULT_FEE_INFO = {
|
||||
paymentPeriod: 1,
|
||||
hydrogenPaymentMethod: 'self',
|
||||
prepayAmount: null,
|
||||
payAheadWorkdays: 5,
|
||||
returnHydrogenDiffUnitPrice: null,
|
||||
};
|
||||
|
||||
export var PAYMENT_PERIOD_OPTIONS = [
|
||||
{ value: 1, label: '1个月' },
|
||||
{ value: 3, label: '3个月' },
|
||||
{ value: 6, label: '6个月' },
|
||||
{ value: 12, label: '12个月' },
|
||||
];
|
||||
|
||||
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'self', label: '自行解决' },
|
||||
{ value: 'prepay', label: '预付款' },
|
||||
];
|
||||
|
||||
export function getLessorCompanies() {
|
||||
if (typeof window !== 'undefined' && window.ONEOS_LESSOR_COMPANIES && window.ONEOS_LESSOR_COMPANIES.length) {
|
||||
return window.ONEOS_LESSOR_COMPANIES;
|
||||
}
|
||||
return DEFAULT_LESSOR_COMPANIES;
|
||||
}
|
||||
|
||||
export function getLessorCompanyById(id) {
|
||||
if (!id) return null;
|
||||
var list = getLessorCompanies();
|
||||
return list.find(function (c) { return c.id === id; })
|
||||
|| list.find(function (c) { return c.shortName === id; })
|
||||
|| null;
|
||||
}
|
||||
|
||||
export function getLeaseCustomerById(id) {
|
||||
if (!id) return null;
|
||||
return MOCK_LEASE_CUSTOMERS.find(function (c) { return c.id === id; }) || null;
|
||||
}
|
||||
|
||||
export function buildLessorVars(company) {
|
||||
if (!company) return {};
|
||||
return {
|
||||
lessorName: company.legalName || '',
|
||||
lessorShortName: company.shortName || '',
|
||||
lessorAddress: company.address || '',
|
||||
lessorContact: company.contact || '',
|
||||
lessorPhone: company.phone || '',
|
||||
lessorEmail: company.email || '',
|
||||
lessorAccountName: company.accountName || company.legalName || '',
|
||||
lessorBankName: company.bankName || '',
|
||||
lessorBankAccount: company.bankAccount || '',
|
||||
lessorCreditCode: company.creditCode || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTemplateVars(html, vars) {
|
||||
var out = String(html || '');
|
||||
Object.keys(vars || {}).forEach(function (key) {
|
||||
var val = vars[key] != null ? String(vars[key]) : '';
|
||||
out = out.split('{{' + key + '}}').join(val);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 将左侧表单值映射为 V26.6 合同 HTML 占位符 */
|
||||
export function buildLeasePreviewVars(form) {
|
||||
var lessor = getLessorCompanyById(form && form.lessorId);
|
||||
var customer = getLeaseCustomerById(form && form.customerId);
|
||||
var vars = {
|
||||
contractCode: (form && form.contractCode) ? String(form.contractCode) : '',
|
||||
customerName: customer ? customer.name : '',
|
||||
customerAddress: customer ? customer.address : '',
|
||||
customerContact: customer ? customer.contact : '',
|
||||
customerPhone: customer ? customer.phone : '',
|
||||
creditCode: customer ? customer.creditCode : '',
|
||||
};
|
||||
return Object.assign(vars, buildLessorVars(lessor));
|
||||
}
|
||||
|
||||
export function getCustomerInvoicePreview(customer) {
|
||||
if (!customer) {
|
||||
return {
|
||||
companyName: '',
|
||||
bank: '',
|
||||
bankAccount: '',
|
||||
taxId: '',
|
||||
mailingAddress: '',
|
||||
companyPhone: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
companyName: customer.companyName || customer.name || '',
|
||||
bank: customer.bank || '',
|
||||
bankAccount: customer.bankAccount || '',
|
||||
taxId: customer.taxId || customer.creditCode || '',
|
||||
mailingAddress: customer.mailingAddress || customer.address || '',
|
||||
companyPhone: customer.companyPhone || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getLessorAccountPreview(company) {
|
||||
if (!company) {
|
||||
return {
|
||||
accountName: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountName: company.accountName || company.legalName || '',
|
||||
bankName: company.bankName || '',
|
||||
bankAccount: company.bankAccount || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getLessorContactPreview(company) {
|
||||
if (!company) {
|
||||
return {
|
||||
mailAddress: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
email: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mailAddress: company.address || '',
|
||||
contactName: company.contact || '',
|
||||
contactPhone: company.phone || '',
|
||||
email: company.email || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomerContactPreview(customer) {
|
||||
if (!customer) {
|
||||
return {
|
||||
mailAddress: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
email: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mailAddress: customer.address || customer.mailingAddress || '',
|
||||
contactName: customer.contact || '',
|
||||
contactPhone: customer.phone || '',
|
||||
email: customer.email || '',
|
||||
};
|
||||
}
|
||||
63
src/prototypes/contract-template-management/index.tsx
Normal file
63
src/prototypes/contract-template-management/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @name 合同模板管理
|
||||
*/
|
||||
import React from 'react';
|
||||
import * as antd from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import ContractTemplateApp from './ContractTemplate.jsx';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
React: typeof React;
|
||||
antd: typeof antd;
|
||||
}
|
||||
}
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
|
||||
/** 与 vehicle-management / Linear 主题一致 */
|
||||
const vmTheme = {
|
||||
token: {
|
||||
colorPrimary: '#32a06e',
|
||||
colorLink: '#32a06e',
|
||||
colorLinkHover: '#3fb87c',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
colorText: '#18181b',
|
||||
colorTextSecondary: '#52525b',
|
||||
colorBorder: '#e5e7eb',
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#f6f7f7',
|
||||
headerColor: '#71717a',
|
||||
rowHoverBg: '#f6f7f7',
|
||||
borderColor: '#e5e7eb',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
Tabs: {
|
||||
inkBarColor: '#32a06e',
|
||||
itemActiveColor: '#32a06e',
|
||||
itemSelectedColor: '#32a06e',
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContractTemplateManagementPage() {
|
||||
return React.createElement(
|
||||
ConfigProvider,
|
||||
{ theme: vmTheme },
|
||||
React.createElement(ContractTemplateApp),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 合同模板管理 — 模块样式(Linear 令牌与 .vm-* 由 vehicle-management/style.css 提供)
|
||||
*/
|
||||
@import url('./contract-template.css');
|
||||
792
src/prototypes/contract-template-management/styles/oneos-app.css
Normal file
792
src/prototypes/contract-template-management/styles/oneos-app.css
Normal file
@@ -0,0 +1,792 @@
|
||||
/**
|
||||
* ONE-OS 全局应用中后台样式
|
||||
* 来源:web端/styles/vehicle-management.css(车辆管理)
|
||||
* 页面根节点: .oneos-page(新页面)或 .vm-page(向后兼容)
|
||||
*
|
||||
* 依赖: Ant Design v5
|
||||
* 建议顺序: antd.css → oneos-tokens.css → oneos-app.css
|
||||
*/
|
||||
|
||||
@import url('./oneos-tokens.css');
|
||||
|
||||
/* 页面根:oneos-page 与 vm-page 等价 */
|
||||
.oneos-page,
|
||||
.vm-page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: var(--oneos-page-padding-y) var(--oneos-page-padding-x) var(--oneos-page-padding-bottom);
|
||||
font-family: var(--oneos-font-family);
|
||||
font-size: var(--oneos-font-size-base);
|
||||
line-height: var(--oneos-line-height-normal);
|
||||
color: var(--oneos-color-text);
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
var(--oneos-color-bg-page-alt) 0%,
|
||||
var(--oneos-color-bg-page) 50%,
|
||||
var(--oneos-color-bg-page-alt) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.oneos-page *,
|
||||
.oneos-page *::before,
|
||||
.oneos-page *::after,
|
||||
.vm-page *,
|
||||
.vm-page *::before,
|
||||
.vm-page *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.oneos-page.oneos-detail-shell,
|
||||
.oneos-page.vm-detail-shell,
|
||||
.vm-page.oneos-detail-shell,
|
||||
.vm-page.vm-detail-shell {
|
||||
padding: var(--oneos-page-padding-y) var(--oneos-page-padding-x);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Page header
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .oneos-page-header,
|
||||
.vm-page .vm-page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--oneos-space-3);
|
||||
margin-bottom: var(--oneos-space-4);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-page-header .ant-breadcrumb,
|
||||
.vm-page .vm-page-header .ant-breadcrumb {
|
||||
font-size: var(--oneos-font-size-base);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-page-header .ant-breadcrumb a,
|
||||
.oneos-page .oneos-page-header .ant-breadcrumb button,
|
||||
.vm-page .vm-page-header .ant-breadcrumb a,
|
||||
.vm-page .vm-page-header .ant-breadcrumb button {
|
||||
color: var(--oneos-color-primary);
|
||||
cursor: pointer;
|
||||
transition: color var(--oneos-duration-fast) var(--oneos-ease-default);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-page-header .ant-breadcrumb a:hover,
|
||||
.oneos-page .oneos-page-header .ant-breadcrumb button:hover,
|
||||
.vm-page .vm-page-header .ant-breadcrumb a:hover,
|
||||
.vm-page .vm-page-header .ant-breadcrumb button:hover {
|
||||
color: var(--oneos-color-primary-hover);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Filter card(oneos-filter-* / lc-filter-* / vm-filter-* 等价)
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .oneos-filter-card.ant-card,
|
||||
.oneos-page .lc-filter-card.ant-card,
|
||||
.oneos-page .vm-filter-card.ant-card,
|
||||
.vm-page .oneos-filter-card.ant-card,
|
||||
.vm-page .lc-filter-card.ant-card,
|
||||
.vm-page .vm-filter-card.ant-card {
|
||||
margin-bottom: var(--oneos-space-4);
|
||||
border: 1px solid var(--oneos-color-border) !important;
|
||||
border-radius: var(--oneos-radius-2xl) !important;
|
||||
box-shadow: var(--oneos-shadow-card) !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-card > .ant-card-head,
|
||||
.oneos-page .lc-filter-card > .ant-card-head,
|
||||
.oneos-page .vm-filter-card > .ant-card-head,
|
||||
.vm-page .oneos-filter-card > .ant-card-head,
|
||||
.vm-page .lc-filter-card > .ant-card-head,
|
||||
.vm-page .vm-filter-card > .ant-card-head {
|
||||
min-height: auto;
|
||||
padding: var(--oneos-space-3) var(--oneos-space-5) !important;
|
||||
border-bottom: 1px solid var(--oneos-color-border-light) !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-card > .ant-card-head .ant-card-head-title,
|
||||
.oneos-page .lc-filter-card > .ant-card-head .ant-card-head-title,
|
||||
.oneos-page .vm-filter-card > .ant-card-head .ant-card-head-title,
|
||||
.vm-page .oneos-filter-card > .ant-card-head .ant-card-head-title,
|
||||
.vm-page .lc-filter-card > .ant-card-head .ant-card-head-title,
|
||||
.vm-page .vm-filter-card > .ant-card-head .ant-card-head-title {
|
||||
padding: 0 !important;
|
||||
font-size: var(--oneos-font-size-md) !important;
|
||||
font-weight: var(--oneos-font-weight-bold) !important;
|
||||
color: var(--oneos-color-text) !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-card > .ant-card-body,
|
||||
.oneos-page .lc-filter-card > .ant-card-body,
|
||||
.oneos-page .vm-filter-card > .ant-card-body,
|
||||
.vm-page .oneos-filter-card > .ant-card-body,
|
||||
.vm-page .lc-filter-card > .ant-card-body,
|
||||
.vm-page .vm-filter-card > .ant-card-body {
|
||||
padding: var(--oneos-space-4) var(--oneos-space-5) var(--oneos-space-5) !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-grid,
|
||||
.oneos-page .lc-filter-grid,
|
||||
.oneos-page .vm-filter-grid,
|
||||
.vm-page .oneos-filter-grid,
|
||||
.vm-page .lc-filter-grid,
|
||||
.vm-page .vm-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--oneos-filter-columns), minmax(0, 1fr));
|
||||
gap: 14px var(--oneos-space-5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-field,
|
||||
.oneos-page .lc-filter-field,
|
||||
.oneos-page .vm-filter-field,
|
||||
.vm-page .oneos-filter-field,
|
||||
.vm-page .lc-filter-field,
|
||||
.vm-page .vm-filter-field {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-field-label,
|
||||
.oneos-page .lc-filter-field-label,
|
||||
.oneos-page .vm-filter-field-label,
|
||||
.vm-page .oneos-filter-field-label,
|
||||
.vm-page .lc-filter-field-label,
|
||||
.vm-page .vm-filter-field-label {
|
||||
flex: 0 0 var(--oneos-filter-label-width);
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
font-weight: var(--oneos-font-weight-medium);
|
||||
line-height: var(--oneos-line-height-tight);
|
||||
color: var(--oneos-color-text-secondary);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-field-control,
|
||||
.oneos-page .lc-filter-field-control,
|
||||
.oneos-page .vm-filter-field-control,
|
||||
.vm-page .oneos-filter-field-control,
|
||||
.vm-page .lc-filter-field-control,
|
||||
.vm-page .vm-filter-field-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-field-control .ant-input,
|
||||
.oneos-page .oneos-filter-field-control .ant-input-affix-wrapper,
|
||||
.oneos-page .oneos-filter-field-control .ant-select,
|
||||
.oneos-page .oneos-filter-field-control .ant-select-selector,
|
||||
.oneos-page .oneos-filter-field-control .ant-cascader,
|
||||
.oneos-page .oneos-filter-field-control .ant-picker,
|
||||
.oneos-page .lc-filter-field-control .ant-input,
|
||||
.oneos-page .lc-filter-field-control .ant-input-affix-wrapper,
|
||||
.oneos-page .lc-filter-field-control .ant-select .ant-select-selector,
|
||||
.oneos-page .lc-filter-field-control .ant-cascader .ant-select-selector,
|
||||
.oneos-page .vm-filter-field-control .ant-input,
|
||||
.oneos-page .vm-filter-field-control .ant-input-affix-wrapper,
|
||||
.oneos-page .vm-filter-field-control .ant-select .ant-select-selector,
|
||||
.oneos-page .vm-filter-field-control .ant-cascader .ant-select-selector,
|
||||
.vm-page .oneos-filter-field-control .ant-input,
|
||||
.vm-page .oneos-filter-field-control .ant-input-affix-wrapper,
|
||||
.vm-page .oneos-filter-field-control .ant-select .ant-select-selector,
|
||||
.vm-page .lc-filter-field-control .ant-input,
|
||||
.vm-page .lc-filter-field-control .ant-input-affix-wrapper,
|
||||
.vm-page .lc-filter-field-control .ant-select .ant-select-selector,
|
||||
.vm-page .vm-filter-field-control .ant-input,
|
||||
.vm-page .vm-filter-field-control .ant-input-affix-wrapper,
|
||||
.vm-page .vm-filter-field-control .ant-select .ant-select-selector {
|
||||
width: 100%;
|
||||
border-radius: var(--oneos-radius-md) !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-actions,
|
||||
.oneos-page .lc-filter-actions,
|
||||
.oneos-page .vm-filter-actions,
|
||||
.vm-page .oneos-filter-actions,
|
||||
.vm-page .lc-filter-actions,
|
||||
.vm-page .vm-filter-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--oneos-space-2);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--oneos-space-4);
|
||||
padding-top: var(--oneos-space-4);
|
||||
border-top: 1px solid var(--oneos-color-border-light);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
KPI row
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .oneos-kpi-row,
|
||||
.oneos-page .lc-alert-stats-row,
|
||||
.vm-page .oneos-kpi-row,
|
||||
.vm-page .lc-alert-stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: var(--oneos-space-3);
|
||||
margin-bottom: var(--oneos-space-4);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card,
|
||||
.oneos-page .lc-alert-card,
|
||||
.vm-page .oneos-kpi-card,
|
||||
.vm-page .lc-alert-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 30px 14px 14px;
|
||||
min-height: 76px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--oneos-color-bg-card);
|
||||
border: 1px solid var(--oneos-color-border);
|
||||
border-radius: var(--oneos-radius-xl);
|
||||
box-shadow: var(--oneos-shadow-kpi);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card-icon,
|
||||
.oneos-page .lc-alert-card-icon,
|
||||
.vm-page .oneos-kpi-card-icon,
|
||||
.vm-page .lc-alert-card-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--oneos-color-primary-gradient-start) 0%,
|
||||
var(--oneos-color-primary-gradient-end) 100%
|
||||
);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--oneos-shadow-kpi-icon);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card-val,
|
||||
.oneos-page .lc-alert-card-val,
|
||||
.vm-page .oneos-kpi-card-val,
|
||||
.vm-page .lc-alert-card-val {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--oneos-font-size-kpi);
|
||||
font-weight: var(--oneos-font-weight-extrabold);
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--oneos-color-text);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card-title,
|
||||
.oneos-page .lc-alert-card-title,
|
||||
.vm-page .oneos-kpi-card-title,
|
||||
.vm-page .lc-alert-card-title {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
font-weight: var(--oneos-font-weight-semibold);
|
||||
line-height: var(--oneos-line-height-tight);
|
||||
color: var(--oneos-color-text-muted);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card-clickable,
|
||||
.oneos-page .lc-alert-card-clickable,
|
||||
.vm-page .oneos-kpi-card-clickable,
|
||||
.vm-page .lc-alert-card-clickable {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow var(--oneos-duration-normal) var(--oneos-ease-default),
|
||||
border-color var(--oneos-duration-normal) var(--oneos-ease-default);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card-clickable:hover,
|
||||
.oneos-page .lc-alert-card-clickable:hover,
|
||||
.vm-page .oneos-kpi-card-clickable:hover,
|
||||
.vm-page .lc-alert-card-clickable:hover {
|
||||
box-shadow: var(--oneos-shadow-card-hover);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card-clickable:focus-visible,
|
||||
.oneos-page .lc-alert-card-clickable:focus-visible,
|
||||
.vm-page .oneos-kpi-card-clickable:focus-visible,
|
||||
.vm-page .lc-alert-card-clickable:focus-visible {
|
||||
outline: var(--oneos-focus-ring);
|
||||
outline-offset: var(--oneos-focus-offset);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kpi-card-active,
|
||||
.oneos-page .lc-alert-card-active,
|
||||
.vm-page .oneos-kpi-card-active,
|
||||
.vm-page .lc-alert-card-active {
|
||||
border-color: var(--oneos-color-primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25) !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Table section
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .oneos-table-section,
|
||||
.oneos-page .lc-table-section,
|
||||
.oneos-page .vm-table-section,
|
||||
.vm-page .oneos-table-section,
|
||||
.vm-page .lc-table-section,
|
||||
.vm-page .vm-table-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-table-toolbar,
|
||||
.oneos-page .lc-table-toolbar,
|
||||
.oneos-page .vm-table-toolbar,
|
||||
.vm-page .oneos-table-toolbar,
|
||||
.vm-page .lc-table-toolbar,
|
||||
.vm-page .vm-table-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px var(--oneos-space-4);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-table-toolbar-meta,
|
||||
.oneos-page .lc-table-toolbar-meta,
|
||||
.oneos-page .vm-table-toolbar-meta,
|
||||
.vm-page .oneos-table-toolbar-meta,
|
||||
.vm-page .lc-table-toolbar-meta,
|
||||
.vm-page .vm-table-toolbar-meta {
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
color: var(--oneos-color-text-muted);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-table-toolbar-meta strong,
|
||||
.oneos-page .lc-table-toolbar-meta strong,
|
||||
.oneos-page .vm-table-toolbar-meta strong,
|
||||
.vm-page .oneos-table-toolbar-meta strong,
|
||||
.vm-page .lc-table-toolbar-meta strong,
|
||||
.vm-page .vm-table-toolbar-meta strong {
|
||||
font-weight: var(--oneos-font-weight-bold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--oneos-color-text);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-table-toolbar-actions,
|
||||
.oneos-page .lc-table-toolbar-actions,
|
||||
.oneos-page .vm-table-toolbar-actions,
|
||||
.vm-page .oneos-table-toolbar-actions,
|
||||
.vm-page .lc-table-toolbar-actions,
|
||||
.vm-page .vm-table-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--oneos-space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-table-card,
|
||||
.oneos-page .lc-table-card,
|
||||
.oneos-page .vm-table-card,
|
||||
.vm-page .oneos-table-card,
|
||||
.vm-page .lc-table-card,
|
||||
.vm-page .vm-table-card {
|
||||
overflow: hidden;
|
||||
background: var(--oneos-color-bg-card);
|
||||
border: 1px solid var(--oneos-color-border);
|
||||
border-radius: var(--oneos-radius-2xl);
|
||||
box-shadow: var(--oneos-shadow-card);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-list-table .ant-table-thead > tr > th,
|
||||
.oneos-page .lc-list-table .ant-table-thead > tr > th,
|
||||
.oneos-page .vm-list-table .ant-table-thead > tr > th,
|
||||
.vm-page .oneos-list-table .ant-table-thead > tr > th,
|
||||
.vm-page .lc-list-table .ant-table-thead > tr > th,
|
||||
.vm-page .vm-list-table .ant-table-thead > tr > th {
|
||||
padding: var(--oneos-space-3) var(--oneos-space-4) !important;
|
||||
font-size: var(--oneos-font-size-sm) !important;
|
||||
font-weight: var(--oneos-font-weight-bold) !important;
|
||||
color: var(--oneos-color-text-secondary) !important;
|
||||
background: var(--oneos-color-bg-muted) !important;
|
||||
border-bottom: 1px solid var(--oneos-color-border) !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.oneos-page .lc-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.oneos-page .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.vm-page .oneos-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.vm-page .lc-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.vm-page .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
|
||||
padding: var(--oneos-space-3) var(--oneos-space-4) !important;
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-list-table .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td,
|
||||
.oneos-page .lc-list-table .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td,
|
||||
.oneos-page .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td,
|
||||
.vm-page .oneos-list-table .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td,
|
||||
.vm-page .lc-list-table .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td,
|
||||
.vm-page .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td {
|
||||
background: var(--oneos-color-bg-muted) !important;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-list-table .ant-pagination,
|
||||
.oneos-page .lc-list-table .ant-pagination,
|
||||
.oneos-page .vm-list-table .ant-pagination,
|
||||
.vm-page .oneos-list-table .ant-pagination,
|
||||
.vm-page .lc-list-table .ant-pagination,
|
||||
.vm-page .vm-list-table .ant-pagination {
|
||||
margin: 0 !important;
|
||||
padding: var(--oneos-space-3) var(--oneos-space-4) !important;
|
||||
border-top: 1px solid var(--oneos-color-border-light);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Actions & links
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .oneos-action-btn,
|
||||
.oneos-page .lc-action-btn,
|
||||
.oneos-page .vm-action-btn,
|
||||
.vm-page .oneos-action-btn,
|
||||
.vm-page .lc-action-btn,
|
||||
.vm-page .vm-action-btn {
|
||||
min-height: var(--oneos-control-height-lg);
|
||||
padding: 0 var(--oneos-space-1) !important;
|
||||
font-weight: var(--oneos-font-weight-semibold) !important;
|
||||
color: var(--oneos-color-primary) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-cell-link,
|
||||
.oneos-page .vm-plate-link,
|
||||
.vm-page .oneos-cell-link,
|
||||
.vm-page .vm-plate-link {
|
||||
padding: 0;
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
font-weight: var(--oneos-font-weight-bold);
|
||||
color: var(--oneos-color-text);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-cell-link:hover,
|
||||
.oneos-page .vm-plate-link:hover,
|
||||
.vm-page .oneos-cell-link:hover,
|
||||
.vm-page .vm-plate-link:hover {
|
||||
color: var(--oneos-color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-cell-link:focus-visible,
|
||||
.oneos-page .vm-plate-link:focus-visible,
|
||||
.vm-page .oneos-cell-link:focus-visible,
|
||||
.vm-page .vm-plate-link:focus-visible {
|
||||
border-radius: var(--oneos-space-1);
|
||||
outline: var(--oneos-focus-ring);
|
||||
outline-offset: var(--oneos-focus-offset);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-mono-text,
|
||||
.oneos-page .vm-vin-text,
|
||||
.vm-page .oneos-mono-text,
|
||||
.vm-page .vm-vin-text {
|
||||
font-family: var(--oneos-font-mono);
|
||||
font-size: var(--oneos-font-size-xs);
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--oneos-color-text-secondary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Detail page
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .oneos-detail-card,
|
||||
.oneos-page .vm-detail-card,
|
||||
.vm-page .oneos-detail-card,
|
||||
.vm-page .vm-detail-card {
|
||||
margin-bottom: var(--oneos-space-4);
|
||||
padding: var(--oneos-space-6);
|
||||
background: var(--oneos-color-bg-card);
|
||||
border: 1px solid var(--oneos-color-border);
|
||||
border-radius: var(--oneos-radius-2xl);
|
||||
box-shadow: var(--oneos-shadow-card);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-section-title,
|
||||
.oneos-page .vm-section-title,
|
||||
.vm-page .oneos-section-title,
|
||||
.vm-page .vm-section-title {
|
||||
display: flex;
|
||||
gap: var(--oneos-space-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--oneos-space-4);
|
||||
font-size: var(--oneos-font-size-md);
|
||||
font-weight: var(--oneos-font-weight-bold);
|
||||
color: var(--oneos-color-text);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-section-title::before,
|
||||
.oneos-page .vm-section-title::before,
|
||||
.vm-page .oneos-section-title::before,
|
||||
.vm-page .vm-section-title::before {
|
||||
flex-shrink: 0;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
content: '';
|
||||
background: linear-gradient(180deg, var(--oneos-color-primary), #60a5fa);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kv-label,
|
||||
.oneos-page .vm-kv-label,
|
||||
.vm-page .oneos-kv-label,
|
||||
.vm-page .vm-kv-label {
|
||||
flex-shrink: 0;
|
||||
min-width: var(--oneos-kv-label-width);
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
color: var(--oneos-color-text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-kv-value,
|
||||
.oneos-page .vm-kv-value,
|
||||
.vm-page .oneos-kv-value,
|
||||
.vm-page .vm-kv-value {
|
||||
min-width: 0;
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
color: var(--oneos-color-text);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Blocks: hint / banner / fail
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .oneos-modal-hint,
|
||||
.oneos-page .vm-modal-hint,
|
||||
.oneos-modal-hint,
|
||||
.vm-modal-hint {
|
||||
margin-bottom: var(--oneos-space-4);
|
||||
padding: 10px 14px;
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
line-height: var(--oneos-line-height-relaxed);
|
||||
color: var(--oneos-color-text-secondary);
|
||||
background: var(--oneos-color-primary-light);
|
||||
border: 1px solid var(--oneos-color-primary-border);
|
||||
border-radius: var(--oneos-radius-lg);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-import-fail,
|
||||
.oneos-page .vm-import-fail,
|
||||
.oneos-import-fail,
|
||||
.vm-import-fail {
|
||||
margin-top: var(--oneos-space-4);
|
||||
padding: var(--oneos-space-3) 14px;
|
||||
font-size: var(--oneos-font-size-sm);
|
||||
background: var(--oneos-color-error-bg);
|
||||
border: 1px solid var(--oneos-color-error-border);
|
||||
border-radius: var(--oneos-radius-lg);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Ant Design overrides
|
||||
========================================================================== */
|
||||
|
||||
.oneos-page .ant-btn,
|
||||
.vm-page .ant-btn {
|
||||
border-radius: var(--oneos-radius-md);
|
||||
transition:
|
||||
transform var(--oneos-duration-fast) var(--oneos-ease-default),
|
||||
box-shadow var(--oneos-duration-fast) var(--oneos-ease-default),
|
||||
background var(--oneos-duration-fast) var(--oneos-ease-default),
|
||||
border-color var(--oneos-duration-fast) var(--oneos-ease-default);
|
||||
}
|
||||
|
||||
.oneos-page .ant-btn-primary:not(:disabled),
|
||||
.vm-page .ant-btn-primary:not(:disabled) {
|
||||
background: var(--oneos-color-primary) !important;
|
||||
border-color: var(--oneos-color-primary) !important;
|
||||
box-shadow: var(--oneos-shadow-btn-primary);
|
||||
}
|
||||
|
||||
.oneos-page .ant-btn-primary:not(:disabled):hover,
|
||||
.vm-page .ant-btn-primary:not(:disabled):hover {
|
||||
background: var(--oneos-color-primary-hover) !important;
|
||||
border-color: var(--oneos-color-primary-hover) !important;
|
||||
}
|
||||
|
||||
.oneos-page .ant-input:focus,
|
||||
.oneos-page .ant-input-focused,
|
||||
.oneos-page .ant-input-affix-wrapper-focused,
|
||||
.oneos-page .ant-select-focused .ant-select-selector,
|
||||
.oneos-page .ant-picker-focused,
|
||||
.vm-page .ant-input:focus,
|
||||
.vm-page .ant-input-focused,
|
||||
.vm-page .ant-input-affix-wrapper-focused,
|
||||
.vm-page .ant-select-focused .ant-select-selector,
|
||||
.vm-page .ant-picker-focused {
|
||||
border-color: var(--oneos-color-primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15) !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Modal portal
|
||||
========================================================================== */
|
||||
|
||||
.oneos-modal-wrap .ant-modal-content,
|
||||
.vm-modal-wrap .ant-modal-content {
|
||||
border-radius: var(--oneos-radius-xl);
|
||||
}
|
||||
|
||||
.oneos-modal-wrap .ant-modal-header,
|
||||
.vm-modal-wrap .ant-modal-header {
|
||||
border-bottom: 1px solid var(--oneos-color-border-light);
|
||||
}
|
||||
|
||||
.oneos-modal-wrap .ant-modal-footer,
|
||||
.vm-modal-wrap .ant-modal-footer {
|
||||
border-top: 1px solid var(--oneos-color-border-light);
|
||||
}
|
||||
|
||||
.oneos-modal-wrap .ant-btn-primary:not(:disabled),
|
||||
.vm-modal-wrap .ant-btn-primary:not(:disabled) {
|
||||
background: var(--oneos-color-primary);
|
||||
border-color: var(--oneos-color-primary);
|
||||
box-shadow: var(--oneos-shadow-btn-primary);
|
||||
}
|
||||
|
||||
.oneos-modal-wrap .ant-btn-primary:not(:disabled):hover,
|
||||
.vm-modal-wrap .ant-btn-primary:not(:disabled):hover {
|
||||
background: var(--oneos-color-primary-hover);
|
||||
border-color: var(--oneos-color-primary-hover);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Utilities
|
||||
========================================================================== */
|
||||
|
||||
.oneos-u-sr-only,
|
||||
.vm-u-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.oneos-u-tabular,
|
||||
.vm-u-tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 1600px) {
|
||||
.oneos-page .oneos-kpi-row,
|
||||
.oneos-page .lc-alert-stats-row,
|
||||
.vm-page .oneos-kpi-row,
|
||||
.vm-page .lc-alert-stats-row {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.oneos-page .oneos-filter-grid,
|
||||
.oneos-page .lc-filter-grid,
|
||||
.oneos-page .vm-filter-grid,
|
||||
.vm-page .oneos-filter-grid,
|
||||
.vm-page .lc-filter-grid,
|
||||
.vm-page .vm-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.oneos-page .oneos-kpi-row,
|
||||
.oneos-page .lc-alert-stats-row,
|
||||
.vm-page .oneos-kpi-row,
|
||||
.vm-page .lc-alert-stats-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.oneos-page,
|
||||
.vm-page {
|
||||
padding: var(--oneos-space-4) var(--oneos-space-4) var(--oneos-space-6);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-field,
|
||||
.oneos-page .lc-filter-field,
|
||||
.oneos-page .vm-filter-field,
|
||||
.vm-page .oneos-filter-field,
|
||||
.vm-page .lc-filter-field,
|
||||
.vm-page .vm-filter-field {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--oneos-space-2);
|
||||
}
|
||||
|
||||
.oneos-page .oneos-filter-field-label,
|
||||
.oneos-page .lc-filter-field-label,
|
||||
.oneos-page .vm-filter-field-label,
|
||||
.vm-page .oneos-filter-field-label,
|
||||
.vm-page .lc-filter-field-label,
|
||||
.vm-page .vm-filter-field-label {
|
||||
flex: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.oneos-page .oneos-table-toolbar,
|
||||
.oneos-page .lc-table-toolbar,
|
||||
.oneos-page .vm-table-toolbar,
|
||||
.vm-page .oneos-table-toolbar,
|
||||
.vm-page .lc-table-toolbar,
|
||||
.vm-page .vm-table-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.oneos-page .oneos-filter-grid,
|
||||
.oneos-page .lc-filter-grid,
|
||||
.oneos-page .vm-filter-grid,
|
||||
.vm-page .oneos-filter-grid,
|
||||
.vm-page .lc-filter-grid,
|
||||
.vm-page .vm-filter-grid,
|
||||
.oneos-page .oneos-kpi-row,
|
||||
.oneos-page .lc-alert-stats-row,
|
||||
.vm-page .oneos-kpi-row,
|
||||
.vm-page .lc-alert-stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.oneos-page .ant-btn,
|
||||
.oneos-page .oneos-kpi-card-clickable,
|
||||
.oneos-page .lc-alert-card-clickable,
|
||||
.oneos-page .oneos-cell-link,
|
||||
.vm-page .ant-btn,
|
||||
.vm-page .oneos-kpi-card-clickable,
|
||||
.vm-page .lc-alert-card-clickable,
|
||||
.vm-page .vm-plate-link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* ONE-OS 全局设计令牌
|
||||
* 来源:web端/车辆管理.jsx + web端/styles/vm-tokens.css
|
||||
* 所有中后台模块应优先引用本文件,而非页面内硬编码色值
|
||||
*/
|
||||
:root {
|
||||
/* Brand — Emerald 主色(与车辆管理、加氢站站点信息一致) */
|
||||
--oneos-color-primary: #10b981;
|
||||
--oneos-color-primary-hover: #059669;
|
||||
--oneos-color-primary-light: #ecfdf5;
|
||||
--oneos-color-primary-border: #bbf7d0;
|
||||
--oneos-color-primary-shadow: rgba(16, 185, 129, 0.45);
|
||||
--oneos-color-primary-gradient-start: #0d7a63;
|
||||
--oneos-color-primary-gradient-end: #065f4a;
|
||||
|
||||
/* Semantic */
|
||||
--oneos-color-success: #10b981;
|
||||
--oneos-color-success-bg: #ecfdf5;
|
||||
--oneos-color-success-text: #047857;
|
||||
--oneos-color-warning: #f59e0b;
|
||||
--oneos-color-error: #ef4444;
|
||||
--oneos-color-error-bg: #fef2f2;
|
||||
--oneos-color-error-border: #fecaca;
|
||||
--oneos-color-info: #2563eb;
|
||||
|
||||
/* Business dimension colors(车辆状态说明等业务维度,非 UI 主色) */
|
||||
--oneos-dim-ops: #0f766e;
|
||||
--oneos-dim-vehicle: #2563eb;
|
||||
--oneos-dim-outbound: #7c3aed;
|
||||
--oneos-dim-license: #d97706;
|
||||
--oneos-dim-insurance: #e11d48;
|
||||
|
||||
/* Neutral */
|
||||
--oneos-color-bg-page: #f8fafc;
|
||||
--oneos-color-bg-page-alt: #f1f5f9;
|
||||
--oneos-color-bg-card: #ffffff;
|
||||
--oneos-color-bg-muted: #f8fafc;
|
||||
--oneos-color-bg-hover: #f1f5f9;
|
||||
--oneos-color-border: #e2e8f0;
|
||||
--oneos-color-border-light: #f1f5f9;
|
||||
--oneos-color-border-strong: #cbd5e1;
|
||||
--oneos-color-text: #0f172a;
|
||||
--oneos-color-text-secondary: #475569;
|
||||
--oneos-color-text-muted: #64748b;
|
||||
--oneos-color-text-placeholder: #94a3b8;
|
||||
|
||||
/* Typography */
|
||||
--oneos-font-family: Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--oneos-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--oneos-font-size-xs: 12px;
|
||||
--oneos-font-size-sm: 13px;
|
||||
--oneos-font-size-base: 14px;
|
||||
--oneos-font-size-md: 15px;
|
||||
--oneos-font-size-lg: 16px;
|
||||
--oneos-font-size-xl: 22px;
|
||||
--oneos-font-size-kpi: 24px;
|
||||
--oneos-font-weight-medium: 500;
|
||||
--oneos-font-weight-semibold: 600;
|
||||
--oneos-font-weight-bold: 700;
|
||||
--oneos-font-weight-extrabold: 800;
|
||||
--oneos-line-height-tight: 1.35;
|
||||
--oneos-line-height-normal: 1.5;
|
||||
--oneos-line-height-relaxed: 1.65;
|
||||
|
||||
/* Spacing(4px 基准网格) */
|
||||
--oneos-space-1: 4px;
|
||||
--oneos-space-2: 8px;
|
||||
--oneos-space-3: 12px;
|
||||
--oneos-space-4: 16px;
|
||||
--oneos-space-5: 20px;
|
||||
--oneos-space-6: 24px;
|
||||
--oneos-space-8: 32px;
|
||||
|
||||
/* Radius */
|
||||
--oneos-radius-sm: 6px;
|
||||
--oneos-radius-md: 8px;
|
||||
--oneos-radius-lg: 10px;
|
||||
--oneos-radius-xl: 12px;
|
||||
--oneos-radius-2xl: 16px;
|
||||
--oneos-radius-pill: 999px;
|
||||
|
||||
/* Shadow */
|
||||
--oneos-shadow-card: 0 4px 20px -4px rgba(15, 23, 42, 0.04);
|
||||
--oneos-shadow-card-hover: 0 8px 28px -6px rgba(15, 23, 42, 0.08);
|
||||
--oneos-shadow-kpi: 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||
--oneos-shadow-btn-primary: 0 2px 8px -2px var(--oneos-color-primary-shadow);
|
||||
--oneos-shadow-kpi-icon: 0 4px 12px rgba(6, 95, 74, 0.28);
|
||||
--oneos-shadow-popover: 0 8px 28px rgba(15, 23, 42, 0.12);
|
||||
|
||||
/* Layout */
|
||||
--oneos-page-padding-x: 24px;
|
||||
--oneos-page-padding-y: 24px;
|
||||
--oneos-page-padding-bottom: 32px;
|
||||
--oneos-filter-label-width: 88px;
|
||||
--oneos-filter-columns: 4;
|
||||
--oneos-control-height: 32px;
|
||||
--oneos-control-height-lg: 44px;
|
||||
--oneos-table-toolbar-search-width: 240px;
|
||||
--oneos-kv-label-width: 100px;
|
||||
|
||||
/* Motion */
|
||||
--oneos-duration-fast: 150ms;
|
||||
--oneos-duration-normal: 200ms;
|
||||
--oneos-ease-default: ease;
|
||||
|
||||
/* Z-index */
|
||||
--oneos-z-dropdown: 1050;
|
||||
--oneos-z-modal: 1100;
|
||||
|
||||
/* Focus ring(全局统一) */
|
||||
--oneos-focus-ring: 2px solid var(--oneos-color-primary);
|
||||
--oneos-focus-offset: 2px;
|
||||
}
|
||||
|
||||
/* 车辆管理模块向后兼容别名 */
|
||||
:root {
|
||||
--vm-color-primary: var(--oneos-color-primary);
|
||||
--vm-color-primary-hover: var(--oneos-color-primary-hover);
|
||||
--vm-color-primary-light: var(--oneos-color-primary-light);
|
||||
--vm-color-primary-border: var(--oneos-color-primary-border);
|
||||
--vm-color-primary-shadow: var(--oneos-color-primary-shadow);
|
||||
--vm-color-success: var(--oneos-color-success);
|
||||
--vm-color-success-bg: var(--oneos-color-success-bg);
|
||||
--vm-color-success-text: var(--oneos-color-success-text);
|
||||
--vm-color-warning: var(--oneos-color-warning);
|
||||
--vm-color-error: var(--oneos-color-error);
|
||||
--vm-color-error-bg: var(--oneos-color-error-bg);
|
||||
--vm-color-error-border: var(--oneos-color-error-border);
|
||||
--vm-color-bg-page: var(--oneos-color-bg-page);
|
||||
--vm-color-bg-page-alt: var(--oneos-color-bg-page-alt);
|
||||
--vm-color-bg-card: var(--oneos-color-bg-card);
|
||||
--vm-color-bg-muted: var(--oneos-color-bg-muted);
|
||||
--vm-color-bg-hover: var(--oneos-color-bg-hover);
|
||||
--vm-color-border: var(--oneos-color-border);
|
||||
--vm-color-border-light: var(--oneos-color-border-light);
|
||||
--vm-color-text: var(--oneos-color-text);
|
||||
--vm-color-text-secondary: var(--oneos-color-text-secondary);
|
||||
--vm-color-text-muted: var(--oneos-color-text-muted);
|
||||
--vm-color-text-placeholder: var(--oneos-color-text-placeholder);
|
||||
--vm-font-family: var(--oneos-font-family);
|
||||
--vm-font-mono: var(--oneos-font-mono);
|
||||
--vm-font-size-xs: var(--oneos-font-size-xs);
|
||||
--vm-font-size-sm: var(--oneos-font-size-sm);
|
||||
--vm-font-size-base: var(--oneos-font-size-base);
|
||||
--vm-font-size-md: var(--oneos-font-size-md);
|
||||
--vm-font-size-lg: var(--oneos-font-size-lg);
|
||||
--vm-font-size-xl: var(--oneos-font-size-xl);
|
||||
--vm-font-weight-medium: var(--oneos-font-weight-medium);
|
||||
--vm-font-weight-semibold: var(--oneos-font-weight-semibold);
|
||||
--vm-font-weight-bold: var(--oneos-font-weight-bold);
|
||||
--vm-font-weight-extrabold: var(--oneos-font-weight-extrabold);
|
||||
--vm-line-height-tight: var(--oneos-line-height-tight);
|
||||
--vm-line-height-normal: var(--oneos-line-height-normal);
|
||||
--vm-line-height-relaxed: var(--oneos-line-height-relaxed);
|
||||
--vm-space-1: var(--oneos-space-1);
|
||||
--vm-space-2: var(--oneos-space-2);
|
||||
--vm-space-3: var(--oneos-space-3);
|
||||
--vm-space-4: var(--oneos-space-4);
|
||||
--vm-space-5: var(--oneos-space-5);
|
||||
--vm-space-6: var(--oneos-space-6);
|
||||
--vm-space-8: var(--oneos-space-8);
|
||||
--vm-radius-sm: var(--oneos-radius-sm);
|
||||
--vm-radius-md: var(--oneos-radius-md);
|
||||
--vm-radius-lg: var(--oneos-radius-lg);
|
||||
--vm-radius-xl: var(--oneos-radius-xl);
|
||||
--vm-radius-2xl: var(--oneos-radius-2xl);
|
||||
--vm-radius-pill: var(--oneos-radius-pill);
|
||||
--vm-shadow-card: var(--oneos-shadow-card);
|
||||
--vm-shadow-card-hover: var(--oneos-shadow-card-hover);
|
||||
--vm-shadow-btn-primary: var(--oneos-shadow-btn-primary);
|
||||
--vm-page-padding-x: var(--oneos-page-padding-x);
|
||||
--vm-page-padding-y: var(--oneos-page-padding-y);
|
||||
--vm-page-padding-bottom: var(--oneos-page-padding-bottom);
|
||||
--vm-filter-label-width: var(--oneos-filter-label-width);
|
||||
--vm-filter-columns: var(--oneos-filter-columns);
|
||||
--vm-table-toolbar-search-width: var(--oneos-table-toolbar-search-width);
|
||||
--vm-action-btn-min-height: var(--oneos-control-height);
|
||||
--vm-action-more-size: var(--oneos-control-height);
|
||||
--vm-kv-label-width: var(--oneos-kv-label-width);
|
||||
--vm-duration-fast: var(--oneos-duration-fast);
|
||||
--vm-duration-normal: var(--oneos-duration-normal);
|
||||
--vm-ease-default: var(--oneos-ease-default);
|
||||
--vm-z-dropdown: var(--oneos-z-dropdown);
|
||||
--vm-z-modal: var(--oneos-z-modal);
|
||||
}
|
||||
2
src/prototypes/contract-template-management/v266-lease-document.js
vendored
Normal file
2
src/prototypes/contract-template-management/v266-lease-document.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8701
src/prototypes/insurance-procurement/InsuranceProcurement.jsx
Normal file
8701
src/prototypes/insurance-procurement/InsuranceProcurement.jsx
Normal file
File diff suppressed because it is too large
Load Diff
45
src/prototypes/insurance-procurement/index.tsx
Normal file
45
src/prototypes/insurance-procurement/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @name 保险采购
|
||||
* 自 ONE-OS web端/业务管理/保险采购.jsx 复原
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
import InsuranceProcurement from './InsuranceProcurement.jsx';
|
||||
|
||||
const ipcTheme = {
|
||||
token: {
|
||||
colorPrimary: '#10b981',
|
||||
colorLink: '#10b981',
|
||||
colorLinkHover: '#059669',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
colorText: '#0f172a',
|
||||
colorTextSecondary: '#64748b',
|
||||
colorBorder: '#e2e8f0',
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#f8fafc',
|
||||
headerColor: '#64748b',
|
||||
rowHoverBg: '#f8fafc',
|
||||
borderColor: '#e2e8f0',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 16,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function InsuranceProcurementPage() {
|
||||
return React.createElement(
|
||||
ConfigProvider,
|
||||
{ theme: ipcTheme },
|
||||
React.createElement(InsuranceProcurement),
|
||||
);
|
||||
}
|
||||
201
src/prototypes/lease-contract-management/LeaseContractCreate.jsx
Normal file
201
src/prototypes/lease-contract-management/LeaseContractCreate.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Row, Col, message } from 'antd';
|
||||
import { DEFAULT_MILEAGE_STANDARD, DEFAULT_FEE_INFO } from '../contract-template-management/contract-template-vars.js';
|
||||
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState } from './lease-order-vars.js';
|
||||
import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js';
|
||||
import {
|
||||
isMainContractFormComplete,
|
||||
isLeaseOrderFormComplete,
|
||||
isPowerOfAttorneyFormComplete,
|
||||
} from './lease-contract-form-validation.js';
|
||||
import LeaseContractPreviewPanel from './LeaseContractPreviewPanel.jsx';
|
||||
import LeaseContractEditorForm, {
|
||||
LeaseContractLeaseOrderSection,
|
||||
LeaseContractPowerOfAttorneySection,
|
||||
} from './LeaseContractEditorForm.jsx';
|
||||
|
||||
export default function LeaseContractCreate({ onBack }) {
|
||||
var workspaceZoomState = useState(100);
|
||||
var workspaceZoom = workspaceZoomState[0];
|
||||
var setWorkspaceZoom = workspaceZoomState[1];
|
||||
var lessorIdState = useState('');
|
||||
var customerIdState = useState('');
|
||||
var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD));
|
||||
var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO));
|
||||
var contractCodeState = useState('');
|
||||
var leaseOrderState = useState(function () {
|
||||
var initial = createDefaultLeaseOrderState();
|
||||
return {
|
||||
insuredVehicleCount: initial.insuredVehicleCount,
|
||||
thirdPartyLiabilityMillion: initial.thirdPartyLiabilityMillion,
|
||||
deliveryRegion: initial.deliveryRegion.slice(),
|
||||
deliveryDate: initial.deliveryDate,
|
||||
rows: initial.rows.map(function (row) {
|
||||
return Object.assign({}, row, { brandModel: row.brandModel ? row.brandModel.slice() : [] });
|
||||
}),
|
||||
};
|
||||
});
|
||||
var lessorId = lessorIdState[0];
|
||||
var setLessorId = lessorIdState[1];
|
||||
var customerId = customerIdState[0];
|
||||
var setCustomerId = customerIdState[1];
|
||||
var mileage = mileageState[0];
|
||||
var setMileage = mileageState[1];
|
||||
var feeInfo = feeInfoState[0];
|
||||
var setFeeInfo = feeInfoState[1];
|
||||
var contractCode = contractCodeState[0];
|
||||
var setContractCode = contractCodeState[1];
|
||||
var leaseOrder = leaseOrderState[0];
|
||||
var setLeaseOrder = leaseOrderState[1];
|
||||
var powerOfAttorneyState = useState(function () {
|
||||
var initial = createDefaultPowerOfAttorneyState();
|
||||
return {
|
||||
delegates: initial.delegates.map(function (row) {
|
||||
return Object.assign({}, row);
|
||||
}),
|
||||
};
|
||||
});
|
||||
var powerOfAttorney = powerOfAttorneyState[0];
|
||||
var setPowerOfAttorney = powerOfAttorneyState[1];
|
||||
|
||||
var previewHtml = useMemo(function () {
|
||||
return buildLeaseContractPreviewHtml({
|
||||
lessorId: lessorId,
|
||||
customerId: customerId,
|
||||
mileage: mileage,
|
||||
feeInfo: feeInfo,
|
||||
contractCode: contractCode,
|
||||
leaseOrder: leaseOrder,
|
||||
});
|
||||
}, [lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder]);
|
||||
|
||||
var isMainContractComplete = useMemo(function () {
|
||||
return isMainContractFormComplete({
|
||||
lessorId: lessorId,
|
||||
customerId: customerId,
|
||||
contractCode: contractCode,
|
||||
mileage: mileage,
|
||||
feeInfo: feeInfo,
|
||||
});
|
||||
}, [lessorId, customerId, contractCode, mileage, feeInfo]);
|
||||
|
||||
var isLeaseOrderComplete = useMemo(function () {
|
||||
return isLeaseOrderFormComplete({ leaseOrder: leaseOrder });
|
||||
}, [leaseOrder]);
|
||||
|
||||
var isPowerOfAttorneyComplete = useMemo(function () {
|
||||
return isPowerOfAttorneyFormComplete({ powerOfAttorney: powerOfAttorney });
|
||||
}, [powerOfAttorney]);
|
||||
|
||||
return React.createElement('div', { className: 'vm-page lc-page lc-create-page ct-page' },
|
||||
React.createElement('div', { className: 'lc-create-topbar' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-back',
|
||||
onClick: onBack,
|
||||
}, '返回列表'),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-editor-page lc-create-editor-page' },
|
||||
React.createElement('div', { className: 'ct-editor-workspace' },
|
||||
React.createElement(Row, { gutter: [16, 16] },
|
||||
React.createElement(Col, { xs: 24, lg: 12 },
|
||||
React.createElement('div', { className: 'lc-create-editor-column' },
|
||||
React.createElement('section', { className: 'ct-editor-column-card', 'aria-label': '主体合同信息' },
|
||||
React.createElement('div', { className: 'ct-panel-head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '主体合同信息'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-completion-badge ' + (isMainContractComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, isMainContractComplete ? '已完成' : '未完成'),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel' },
|
||||
React.createElement(LeaseContractEditorForm, {
|
||||
lessorId: lessorId,
|
||||
customerId: customerId,
|
||||
contractCode: contractCode,
|
||||
mileage: mileage,
|
||||
feeInfo: feeInfo,
|
||||
onLessorChange: setLessorId,
|
||||
onCustomerChange: setCustomerId,
|
||||
onContractCodeChange: setContractCode,
|
||||
onMileageChange: setMileage,
|
||||
onFeeInfoChange: setFeeInfo,
|
||||
}),
|
||||
),
|
||||
),
|
||||
React.createElement('section', { className: 'ct-editor-column-card', 'aria-label': '附件1:租赁订单' },
|
||||
React.createElement('div', { className: 'ct-panel-head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '附件1:租赁订单'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-completion-badge ' + (isLeaseOrderComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, isLeaseOrderComplete ? '已完成' : '未完成'),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel' },
|
||||
React.createElement(LeaseContractLeaseOrderSection, {
|
||||
leaseOrder: leaseOrder,
|
||||
onLeaseOrderChange: setLeaseOrder,
|
||||
}),
|
||||
),
|
||||
),
|
||||
React.createElement('section', { className: 'ct-editor-column-card', 'aria-label': '授权委托书' },
|
||||
React.createElement('div', { className: 'ct-panel-head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '授权委托书'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-completion-badge ' + (isPowerOfAttorneyComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, isPowerOfAttorneyComplete ? '已完成' : '未完成'),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel' },
|
||||
React.createElement(LeaseContractPowerOfAttorneySection, {
|
||||
powerOfAttorney: powerOfAttorney,
|
||||
onPowerOfAttorneyChange: setPowerOfAttorney,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
React.createElement(Col, { xs: 24, lg: 12 },
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card ct-editor-column-card--preview',
|
||||
'aria-label': '实时预览',
|
||||
},
|
||||
React.createElement('div', { className: 'ct-panel-head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '实时预览',
|
||||
React.createElement('span', { className: 'ct-panel-head__badge' }, 'V26.6 完整合同'),
|
||||
),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-preview-panel ct-preview-panel--word' },
|
||||
React.createElement(LeaseContractPreviewPanel, {
|
||||
html: previewHtml,
|
||||
zoom: workspaceZoom,
|
||||
onZoomChange: setWorkspaceZoom,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
React.createElement('div', { className: 'lc-create-footer' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost',
|
||||
onClick: onBack,
|
||||
}, '取消'),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary',
|
||||
onClick: function () { message.success('已保存草稿(原型)'); },
|
||||
}, '保存草稿'),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-primary',
|
||||
onClick: function () { message.info('提交审核(原型)'); },
|
||||
}, '提交审核'),
|
||||
),
|
||||
);
|
||||
}
|
||||
1043
src/prototypes/lease-contract-management/LeaseContractEditorForm.jsx
Normal file
1043
src/prototypes/lease-contract-management/LeaseContractEditorForm.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1799
src/prototypes/lease-contract-management/LeaseContractManagement.jsx
Normal file
1799
src/prototypes/lease-contract-management/LeaseContractManagement.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { paginateWordHtml } from './contract-word-preview.js';
|
||||
|
||||
var PREVIEW_ZOOM_STEPS = [50, 75, 90, 100, 110, 125, 150, 175, 200];
|
||||
|
||||
function previewPagerIcon(name) {
|
||||
var common = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
};
|
||||
if (name === 'zoomOut') {
|
||||
return React.createElement('svg', common, React.createElement('path', { d: 'M5 12h14' }));
|
||||
}
|
||||
if (name === 'zoomIn') {
|
||||
return React.createElement('svg', common,
|
||||
React.createElement('path', { d: 'M5 12h14' }),
|
||||
React.createElement('path', { d: 'M12 5v14' }),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LeaseContractPreviewPanel({ html, zoom, onZoomChange }) {
|
||||
var pagesState = useState([]);
|
||||
var pageIdxState = useState(0);
|
||||
var viewModeState = useState('scroll');
|
||||
var internalZoomState = useState(100);
|
||||
var pages = pagesState[0];
|
||||
var pageIdx = pageIdxState[0];
|
||||
var setPageIdx = pageIdxState[1];
|
||||
var viewMode = viewModeState[0];
|
||||
var setViewMode = viewModeState[1];
|
||||
var previewZoom = zoom != null ? zoom : internalZoomState[0];
|
||||
var setPreviewZoom = onZoomChange || internalZoomState[1];
|
||||
|
||||
useEffect(function () {
|
||||
if (!html) {
|
||||
pagesState[1]([]);
|
||||
return;
|
||||
}
|
||||
var timer = window.setTimeout(function () {
|
||||
pagesState[1](paginateWordHtml(html));
|
||||
pageIdxState[1](0);
|
||||
}, 0);
|
||||
return function () { window.clearTimeout(timer); };
|
||||
}, [html]);
|
||||
|
||||
function stepPreviewZoom(direction) {
|
||||
setPreviewZoom(function (current) {
|
||||
var idx = PREVIEW_ZOOM_STEPS.indexOf(current);
|
||||
if (idx < 0) {
|
||||
if (direction > 0) {
|
||||
return PREVIEW_ZOOM_STEPS.find(function (value) { return value > current; })
|
||||
|| PREVIEW_ZOOM_STEPS[PREVIEW_ZOOM_STEPS.length - 1];
|
||||
}
|
||||
var reversed = PREVIEW_ZOOM_STEPS.slice().reverse();
|
||||
return reversed.find(function (value) { return value < current; }) || PREVIEW_ZOOM_STEPS[0];
|
||||
}
|
||||
var nextIdx = idx + direction;
|
||||
if (nextIdx < 0) return PREVIEW_ZOOM_STEPS[0];
|
||||
if (nextIdx >= PREVIEW_ZOOM_STEPS.length) return PREVIEW_ZOOM_STEPS[PREVIEW_ZOOM_STEPS.length - 1];
|
||||
return PREVIEW_ZOOM_STEPS[nextIdx];
|
||||
});
|
||||
}
|
||||
|
||||
if (!html || !String(html).trim()) {
|
||||
return React.createElement('div', { className: 'ct-preview-empty', role: 'status' },
|
||||
React.createElement('p', { className: 'ct-preview-empty__text' }, '暂无合同内容可预览'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!pages.length) {
|
||||
return React.createElement('div', { className: 'ct-preview-doc ct-word-doc ct-word-paginated' },
|
||||
React.createElement('div', { className: 'ct-preview-body-shell' },
|
||||
React.createElement('div', { className: 'ct-preview-pages-wrap' },
|
||||
React.createElement('div', { className: 'ct-preview-pages' },
|
||||
React.createElement('div', { className: 'ct-preview-page is-loading' }, '合同分页排版计算中…'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
var previewToolbar = React.createElement('div', { className: 'ct-preview-toolbar' },
|
||||
React.createElement('div', { className: 'ct-preview-toolbar__row ct-preview-toolbar__row--primary', role: 'toolbar', 'aria-label': '预览缩放' },
|
||||
React.createElement('div', { className: 'ct-preview-pager__group', role: 'group', 'aria-label': '缩放' },
|
||||
React.createElement('div', { className: 'ct-preview-pager__group-body' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'ct-preview-pager__icon-btn',
|
||||
'aria-label': '缩小',
|
||||
disabled: previewZoom <= PREVIEW_ZOOM_STEPS[0],
|
||||
onClick: function () { stepPreviewZoom(-1); },
|
||||
}, previewPagerIcon('zoomOut')),
|
||||
React.createElement(Select, {
|
||||
className: 'ct-preview-zoom-select vm-focus-border',
|
||||
size: 'small',
|
||||
value: previewZoom,
|
||||
onChange: setPreviewZoom,
|
||||
'aria-label': '预览缩放比例',
|
||||
options: PREVIEW_ZOOM_STEPS.map(function (value) {
|
||||
return { value: value, label: value + '%' };
|
||||
}),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'ct-preview-pager__icon-btn',
|
||||
'aria-label': '放大',
|
||||
disabled: previewZoom >= PREVIEW_ZOOM_STEPS[PREVIEW_ZOOM_STEPS.length - 1],
|
||||
onClick: function () { stepPreviewZoom(1); },
|
||||
}, previewPagerIcon('zoomIn')),
|
||||
),
|
||||
),
|
||||
),
|
||||
React.createElement('div', {
|
||||
className: 'ct-preview-toolbar__row ct-preview-toolbar__row--secondary ct-preview-pager',
|
||||
role: 'toolbar',
|
||||
'aria-label': '合同预览工具栏',
|
||||
},
|
||||
React.createElement('div', { className: 'ct-preview-pager__group', role: 'group', 'aria-label': '翻页' },
|
||||
React.createElement('span', { className: 'ct-preview-pager__group-label' }, '翻页'),
|
||||
React.createElement('div', { className: 'ct-preview-pager__group-body' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
|
||||
disabled: pageIdx <= 0,
|
||||
onClick: function () { setPageIdx(Math.max(0, pageIdx - 1)); },
|
||||
}, '上一页'),
|
||||
React.createElement('span', { className: 'ct-preview-pager__page', 'aria-live': 'polite' },
|
||||
React.createElement('span', { className: 'ct-preview-pager__page-current' }, pageIdx + 1),
|
||||
' / ',
|
||||
React.createElement('span', { className: 'ct-preview-pager__page-total' }, pages.length),
|
||||
),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
|
||||
disabled: pageIdx >= pages.length - 1,
|
||||
onClick: function () { setPageIdx(Math.min(pages.length - 1, pageIdx + 1)); },
|
||||
}, '下一页'),
|
||||
),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-preview-pager__group ct-preview-pager__group--view', role: 'group', 'aria-label': '视图' },
|
||||
React.createElement('span', { className: 'ct-preview-pager__group-label' }, '视图'),
|
||||
React.createElement('div', { className: 'ct-preview-pager__segmented', role: 'group', 'aria-label': '预览视图模式' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'ct-preview-pager__segment' + (viewMode === 'scroll' ? ' is-active' : ''),
|
||||
'aria-pressed': viewMode === 'scroll',
|
||||
onClick: function () { setViewMode('scroll'); },
|
||||
}, '连续预览'),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'ct-preview-pager__segment' + (viewMode === 'single' ? ' is-active' : ''),
|
||||
'aria-pressed': viewMode === 'single',
|
||||
onClick: function () { setViewMode('single'); },
|
||||
}, '单页预览'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
var pagesContent = viewMode === 'single'
|
||||
? React.createElement('div', { className: 'ct-preview-page', key: pageIdx },
|
||||
React.createElement('div', {
|
||||
className: 'ct-preview-page__body ct-word-doc',
|
||||
dangerouslySetInnerHTML: { __html: pages[pageIdx] },
|
||||
}),
|
||||
React.createElement('div', { className: 'ct-preview-page__footer' }, '— ', pageIdx + 1, ' —'),
|
||||
)
|
||||
: pages.map(function (pageHtml, i) {
|
||||
return React.createElement('div', { className: 'ct-preview-page', key: 'p-' + i },
|
||||
React.createElement('div', {
|
||||
className: 'ct-preview-page__body ct-word-doc',
|
||||
dangerouslySetInnerHTML: { __html: pageHtml },
|
||||
}),
|
||||
React.createElement('div', { className: 'ct-preview-page__footer' }, '— ', i + 1, ' —'),
|
||||
);
|
||||
});
|
||||
|
||||
return React.createElement('div', { className: 'ct-preview-doc ct-word-doc ct-word-paginated' },
|
||||
previewToolbar,
|
||||
React.createElement('div', { className: 'ct-preview-body-shell' },
|
||||
React.createElement('div', { className: 'ct-preview-pages-wrap' },
|
||||
React.createElement('div', {
|
||||
className: 'ct-preview-pages',
|
||||
style: { zoom: previewZoom / 100 },
|
||||
}, pagesContent),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
183
src/prototypes/lease-contract-management/contract-word-preview.js
vendored
Normal file
183
src/prototypes/lease-contract-management/contract-word-preview.js
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Word 合同预览分页(与合同模板管理共用逻辑)
|
||||
*/
|
||||
import { V266_LEASE_DOCUMENT_HTML } from '../contract-template-management/v266-lease-document.js';
|
||||
|
||||
export function isWordFormattedDoc(html) {
|
||||
var source = String(html || '');
|
||||
return /ct-word-doc|ct-word-title|ct-word-doc--v266/.test(source)
|
||||
|| /class="p\d+"/.test(source)
|
||||
|| /font:\s*\d+(?:\.\d+)?px\s+Times/i.test(source);
|
||||
}
|
||||
|
||||
function stripRiskRedlinePreviewFooters(html) {
|
||||
if (!html || typeof document === 'undefined') return String(html || '');
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
div.querySelectorAll('.ct-risk-redline__footer').forEach(function (el) {
|
||||
el.parentNode.removeChild(el);
|
||||
});
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function stripEmptyRiskRedlines(html) {
|
||||
if (!html || typeof document === 'undefined') return String(html || '');
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
div.querySelectorAll('.ct-risk-redline[data-risk-redline="1"]').forEach(function (el) {
|
||||
var inner = String(el.innerHTML || '')
|
||||
.replace(/<br\s*\/?>/gi, '')
|
||||
.replace(/ /gi, '')
|
||||
.replace(/\u200b/g, '')
|
||||
.trim();
|
||||
var text = String(el.textContent || '').replace(/\s/g, '').trim();
|
||||
if (!inner && !text) {
|
||||
var parent = el.parentNode;
|
||||
if (parent) parent.removeChild(el);
|
||||
}
|
||||
});
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function fixWordPreviewHtml(html) {
|
||||
if (!html || typeof document === 'undefined') return String(html || '');
|
||||
var div = document.createElement('div');
|
||||
var cleaned = String(html)
|
||||
.replace(/<\/p><td[^>]*>[\s\S]*?<\/tr><\/tbody><\/table>/g, function (frag) {
|
||||
return frag.indexOf('<table') >= 0 ? frag : '</p>';
|
||||
});
|
||||
div.innerHTML = cleaned;
|
||||
div.querySelectorAll('table.ct-word-table').forEach(function (table) {
|
||||
var maxCols = 0;
|
||||
table.querySelectorAll('tr').forEach(function (tr) {
|
||||
var cols = 0;
|
||||
tr.querySelectorAll('td, th').forEach(function (td) {
|
||||
cols += parseInt(td.getAttribute('colspan') || '1', 10);
|
||||
});
|
||||
if (cols > maxCols) maxCols = cols;
|
||||
});
|
||||
if (maxCols <= 1) return;
|
||||
table.querySelectorAll('tr').forEach(function (tr) {
|
||||
var tds = tr.querySelectorAll('td, th');
|
||||
var cols = 0;
|
||||
tds.forEach(function (td) { cols += parseInt(td.getAttribute('colspan') || '1', 10); });
|
||||
if (tds.length === 1 && cols === 1) {
|
||||
tds[0].setAttribute('colspan', String(maxCols));
|
||||
tds[0].classList.add('ct-word-td-full');
|
||||
}
|
||||
});
|
||||
});
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function ensureV266WordStyles(html) {
|
||||
var content = String(html || '');
|
||||
if (!isWordFormattedDoc(content) || typeof document === 'undefined') return content;
|
||||
|
||||
var cssText = '';
|
||||
var match = content.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
|
||||
if (match) {
|
||||
cssText = match[1];
|
||||
content = content.replace(/<style[^>]*>[\s\S]*?<\/style>/i, '');
|
||||
} else if (V266_LEASE_DOCUMENT_HTML) {
|
||||
var fullMatch = V266_LEASE_DOCUMENT_HTML.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
|
||||
if (fullMatch) cssText = fullMatch[1];
|
||||
}
|
||||
if (!cssText) return content;
|
||||
|
||||
var styleEl = document.getElementById('ct-v266-word-styles');
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'ct-v266-word-styles';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
if (styleEl.textContent !== cssText) styleEl.textContent = cssText;
|
||||
|
||||
if (content.indexOf('ct-word-doc--v266') < 0) {
|
||||
content = '<div class="ct-word-doc ct-word-doc--v266">' + content + '</div>';
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function prepareWordHtml(html) {
|
||||
var content = String(html || '');
|
||||
if (!content) return '';
|
||||
if (isWordFormattedDoc(content)) {
|
||||
content = ensureV266WordStyles(content);
|
||||
content = fixWordPreviewHtml(content);
|
||||
content = stripEmptyRiskRedlines(content);
|
||||
content = stripRiskRedlinePreviewFooters(content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function isPageBreakNode(node) {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
if (node.nodeName === 'HR' && (
|
||||
(node.classList && node.classList.contains('ct-page-break')) ||
|
||||
node.getAttribute('data-page-break')
|
||||
)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function paginateWordHtml(html) {
|
||||
var MM = 96 / 25.4;
|
||||
var contentH = Math.floor((297 - 25.4 * 2) * MM);
|
||||
var contentW = Math.floor((210 - 31.8 * 2) * MM);
|
||||
var fixed = prepareWordHtml(html);
|
||||
if (isWordFormattedDoc(html)) ensureV266WordStyles(html);
|
||||
var sandbox = document.createElement('div');
|
||||
sandbox.style.cssText = 'position:fixed;left:-20000px;top:0;visibility:hidden;pointer-events:none;';
|
||||
document.body.appendChild(sandbox);
|
||||
|
||||
var measure = document.createElement('div');
|
||||
measure.className = 'ct-word-doc ct-word-doc--v266 ct-word-measure';
|
||||
measure.style.width = contentW + 'px';
|
||||
measure.style.boxSizing = 'border-box';
|
||||
measure.innerHTML = fixed;
|
||||
sandbox.appendChild(measure);
|
||||
|
||||
var root = measure.querySelector('.ct-word-doc') || measure;
|
||||
var nodes = [];
|
||||
root.childNodes.forEach(function (n) {
|
||||
if (n.nodeType === 1) nodes.push(n);
|
||||
else if (n.nodeType === 3 && String(n.textContent || '').trim()) {
|
||||
var p = document.createElement('p');
|
||||
p.className = 'ct-word-body';
|
||||
p.textContent = n.textContent;
|
||||
nodes.push(p);
|
||||
}
|
||||
});
|
||||
|
||||
var pageMeasure = document.createElement('div');
|
||||
pageMeasure.className = 'ct-word-doc ct-word-doc--v266 ct-word-measure';
|
||||
pageMeasure.style.width = contentW + 'px';
|
||||
pageMeasure.style.boxSizing = 'border-box';
|
||||
sandbox.appendChild(pageMeasure);
|
||||
|
||||
var pages = [];
|
||||
nodes.forEach(function (node) {
|
||||
if (isPageBreakNode(node)) {
|
||||
if (pageMeasure.innerHTML.trim()) pages.push(pageMeasure.innerHTML);
|
||||
pageMeasure.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var clone = node.cloneNode(true);
|
||||
pageMeasure.appendChild(clone);
|
||||
if (pageMeasure.scrollHeight > contentH + 4) {
|
||||
pageMeasure.removeChild(clone);
|
||||
if (pageMeasure.innerHTML.trim()) pages.push(pageMeasure.innerHTML);
|
||||
pageMeasure.innerHTML = '';
|
||||
pageMeasure.appendChild(clone);
|
||||
if (pageMeasure.scrollHeight > contentH + 4) {
|
||||
pages.push(pageMeasure.innerHTML);
|
||||
pageMeasure.innerHTML = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
if (pageMeasure.innerHTML.trim()) pages.push(pageMeasure.innerHTML);
|
||||
if (!pages.length) pages.push(fixed);
|
||||
|
||||
document.body.removeChild(sandbox);
|
||||
return pages;
|
||||
}
|
||||
61
src/prototypes/lease-contract-management/index.tsx
Normal file
61
src/prototypes/lease-contract-management/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @name 租赁合同管理
|
||||
*/
|
||||
import React from 'react';
|
||||
import * as antd from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import LeaseContractApp from './LeaseContractManagement.jsx';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
React: typeof React;
|
||||
antd: typeof antd;
|
||||
}
|
||||
}
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
|
||||
/** 与 vehicle-management / ONE-OS 主题一致 */
|
||||
const vmTheme = {
|
||||
token: {
|
||||
colorPrimary: '#32a06e',
|
||||
colorLink: '#32a06e',
|
||||
colorLinkHover: '#3fb87c',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
colorText: '#18181b',
|
||||
colorTextSecondary: '#52525b',
|
||||
colorBorder: '#e5e7eb',
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#f6f7f7',
|
||||
headerColor: '#71717a',
|
||||
rowHoverBg: '#f6f7f7',
|
||||
borderColor: '#e5e7eb',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
InputNumber: {
|
||||
handleVisible: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function LeaseContractManagementPage() {
|
||||
return React.createElement(
|
||||
ConfigProvider,
|
||||
{ theme: vmTheme },
|
||||
React.createElement(LeaseContractApp),
|
||||
);
|
||||
}
|
||||
59
src/prototypes/lease-contract-management/lease-contract-form-validation.js
vendored
Normal file
59
src/prototypes/lease-contract-management/lease-contract-form-validation.js
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
function isMainContractFieldsComplete(form) {
|
||||
var contractCode = (form.contractCode || '').trim();
|
||||
if (!contractCode) return false;
|
||||
if (!form.lessorId) return false;
|
||||
if (!form.customerId) return false;
|
||||
|
||||
var mileage = form.mileage || {};
|
||||
if (mileage.hasRequirement) {
|
||||
if (!mileage.period) return false;
|
||||
if (mileage.targetKm == null || mileage.targetKm === '') return false;
|
||||
}
|
||||
|
||||
var feeInfo = form.feeInfo || {};
|
||||
if (feeInfo.paymentPeriod == null || feeInfo.paymentPeriod === '') return false;
|
||||
if (!feeInfo.hydrogenPaymentMethod) return false;
|
||||
|
||||
if (feeInfo.hydrogenPaymentMethod === 'prepay') {
|
||||
if (feeInfo.prepayAmount == null || feeInfo.prepayAmount === '') return false;
|
||||
if (feeInfo.payAheadWorkdays == null || feeInfo.payAheadWorkdays === '') return false;
|
||||
}
|
||||
|
||||
if (feeInfo.returnHydrogenDiffUnitPrice == null || feeInfo.returnHydrogenDiffUnitPrice === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 主体合同信息卡片完成度 */
|
||||
export function isMainContractFormComplete(form) {
|
||||
return isMainContractFieldsComplete(form);
|
||||
}
|
||||
|
||||
/** 附件1:租赁订单卡片完成度 */
|
||||
export function isLeaseOrderFormComplete(form) {
|
||||
var leaseOrder = form.leaseOrder || {};
|
||||
if (leaseOrder.thirdPartyLiabilityMillion == null || leaseOrder.thirdPartyLiabilityMillion === '') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 授权委托书卡片完成度 */
|
||||
export function isPowerOfAttorneyFormComplete(form) {
|
||||
var delegates = (form.powerOfAttorney && form.powerOfAttorney.delegates) || [];
|
||||
if (!delegates.length) return false;
|
||||
return delegates.every(function (row) {
|
||||
return Boolean((row.name || '').trim())
|
||||
&& Boolean((row.contact || '').trim())
|
||||
&& Boolean((row.idNumber || '').trim());
|
||||
});
|
||||
}
|
||||
|
||||
/** 判断新增租赁合同左侧表单必填项是否均已填写 */
|
||||
export function isLeaseContractFormComplete(form) {
|
||||
return isMainContractFieldsComplete(form)
|
||||
&& isLeaseOrderFormComplete(form)
|
||||
&& isPowerOfAttorneyFormComplete(form);
|
||||
}
|
||||
322
src/prototypes/lease-contract-management/lease-contract-preview-build.js
vendored
Normal file
322
src/prototypes/lease-contract-management/lease-contract-preview-build.js
vendored
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 租赁合同预览 HTML 拼装:占位符替换 + 2.6.1 开票信息 + 里程标准 + 客户资质附件
|
||||
*/
|
||||
import { V266_LEASE_DOCUMENT_HTML } from '../contract-template-management/v266-lease-document.js';
|
||||
import {
|
||||
applyTemplateVars,
|
||||
buildLeasePreviewVars,
|
||||
getLeaseCustomerById,
|
||||
getLessorCompanyById,
|
||||
getCustomerAttachmentPreviewUrl,
|
||||
CUSTOMER_CREDENTIAL_ITEMS,
|
||||
CONTRACT_CODE_PREFIX,
|
||||
} from '../contract-template-management/contract-template-vars.js';
|
||||
import { calcRentServiceSubtotal, formatExtraServicesDisplay, formatLeasePeriod, normalizeExtraServices, sumExtraServiceFees } from './lease-order-vars.js';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value != null ? value : '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fillLabelInSlice(slice, label, value) {
|
||||
var safe = escapeHtml(value);
|
||||
var pattern = new RegExp('(' + label + ':)([\\s\\u00a0]*)(</p>)');
|
||||
return slice.replace(pattern, '$1 ' + safe + '$3');
|
||||
}
|
||||
|
||||
function getContractCodeParts(form) {
|
||||
var code = form && form.contractCode ? String(form.contractCode).trim() : '';
|
||||
if (!code) return { full: '', suffix: '' };
|
||||
var prefix = CONTRACT_CODE_PREFIX || 'LNZLHT';
|
||||
if (code.indexOf(prefix) === 0) {
|
||||
return { full: code, suffix: code.slice(prefix.length) };
|
||||
}
|
||||
return { full: prefix + code, suffix: code };
|
||||
}
|
||||
|
||||
/** 合同首页签章区乙方名称(模板该处无 {{customerName}} 占位符) */
|
||||
export function applyCustomerPartyPatch(html, customer) {
|
||||
if (!html || !customer || !customer.name) return html;
|
||||
var safe = escapeHtml(customer.name);
|
||||
var pattern = /(<b>乙方<\/b><span class="s3" style="letter-spacing: 0\.1px"><b>(承租方):<\/b><\/span>)([\s\u00a0]*)((盖章))/;
|
||||
return html.replace(pattern, '$1' + safe + '$3');
|
||||
}
|
||||
|
||||
/** 2.6.1 乙方开票信息 */
|
||||
export function applyInvoiceInfoPatch(html, customer) {
|
||||
if (!html || !customer) return html;
|
||||
var marker = '2.6.1 乙方开票信息如下';
|
||||
var start = html.indexOf(marker);
|
||||
if (start < 0) return html;
|
||||
var end = html.indexOf('2.6.2', start);
|
||||
if (end < 0) end = start + 1800;
|
||||
var head = html.slice(0, start);
|
||||
var slice = html.slice(start, end);
|
||||
var tail = html.slice(end);
|
||||
slice = fillLabelInSlice(slice, '企业名称', customer.companyName || customer.name || '');
|
||||
slice = fillLabelInSlice(slice, '开户银行', customer.bank || '');
|
||||
slice = fillLabelInSlice(slice, '银行账号', customer.bankAccount || '');
|
||||
slice = fillLabelInSlice(slice, '纳税人识别号', customer.taxId || customer.creditCode || '');
|
||||
slice = fillLabelInSlice(slice, '企业地址', customer.mailingAddress || customer.address || '');
|
||||
slice = fillLabelInSlice(slice, '企业电话', customer.companyPhone || '');
|
||||
return head + slice + tail;
|
||||
}
|
||||
|
||||
function formatValidUntilLabel(value) {
|
||||
if (!value) return '2026年12月31日';
|
||||
var text = String(value).trim();
|
||||
if (/年.*月.*日/.test(text)) return text;
|
||||
var parts = text.split('-');
|
||||
if (parts.length === 3) {
|
||||
return parts[0] + '年' + parseInt(parts[1], 10) + '月' + parseInt(parts[2], 10) + '日';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 1.4.1–1.4.3 里程要求条款 */
|
||||
export function applyMileageClausePatch(html, mileage) {
|
||||
if (!html || !mileage || !mileage.hasRequirement) return html;
|
||||
var km = mileage.targetKm != null ? mileage.targetKm : '';
|
||||
var period = mileage.period || 'month';
|
||||
html = fillMileageBracket(html, '1\\.4\\.1', '每月完成', period === 'month' ? km : '');
|
||||
html = fillMileageBracket(html, '1\\.4\\.2', '每季度完成', period === 'quarter' ? km : '');
|
||||
html = fillMileageBracket(html, '1\\.4\\.3', '每年度完成', period === 'year' ? km : '');
|
||||
return html;
|
||||
}
|
||||
|
||||
function fillMileageBracket(html, clauseId, labelText, km) {
|
||||
var safe = escapeHtml(km);
|
||||
var pattern = new RegExp('(' + clauseId + '[\\s\\S]*?' + labelText + '【)\\s*([\\s\\u00a0]*)(】)');
|
||||
return html.replace(pattern, '$1' + safe + '$3');
|
||||
}
|
||||
|
||||
/** 2.1.5 里程减免标准 */
|
||||
export function applyMileageStandardPatch(html, mileage) {
|
||||
if (!html || !mileage) return html;
|
||||
var reduction = mileage.reductionYuan != null ? String(mileage.reductionYuan) : '2000';
|
||||
var validUntil = formatValidUntilLabel(mileage.validUntil);
|
||||
var threshold = mileage.hasRequirement
|
||||
? String(mileage.targetKm != null ? mileage.targetKm : 6000)
|
||||
: null;
|
||||
var start = html.indexOf('2.1.5');
|
||||
if (start < 0) return html;
|
||||
var end = html.indexOf('2.2', start);
|
||||
if (end < 0) end = start + 3500;
|
||||
var head = html.slice(0, start);
|
||||
var slice = html.slice(start, end);
|
||||
var tail = html.slice(end);
|
||||
if (threshold != null) {
|
||||
slice = slice.replace(/6000/g, threshold);
|
||||
}
|
||||
slice = slice.replace(/2000元\/辆/g, reduction + '元/辆');
|
||||
slice = slice.replace(/有效期至[^))]+[))]/, '有效期至' + validUntil + '止)');
|
||||
return head + slice + tail;
|
||||
}
|
||||
|
||||
/** 2.1.1 付款周期:每【x】个自然月 */
|
||||
export function applyPaymentPeriodPatch(html, feeInfo) {
|
||||
if (!html || !feeInfo) return html;
|
||||
var months = feeInfo.paymentPeriod != null ? String(feeInfo.paymentPeriod) : '';
|
||||
var start = html.indexOf('2.1.1');
|
||||
if (start < 0) return html;
|
||||
var end = html.indexOf('2.1.2', start);
|
||||
if (end < 0) end = start + 800;
|
||||
var head = html.slice(0, start);
|
||||
var slice = html.slice(start, end);
|
||||
var tail = html.slice(end);
|
||||
slice = slice.replace(/(每【)\s*([\s\u00a0]*)(】个自然月为一个付款周期)/, '$1' + escapeHtml(months) + '$3');
|
||||
return head + slice + tail;
|
||||
}
|
||||
|
||||
/** 2.2.1 氢费支付:提前 x 个工作日 */
|
||||
export function applyFeeInfoPatch(html, feeInfo) {
|
||||
if (!html || !feeInfo) return html;
|
||||
var days = feeInfo.payAheadWorkdays != null ? String(feeInfo.payAheadWorkdays) : '';
|
||||
var start = html.indexOf('2.2.1');
|
||||
if (start < 0) return html;
|
||||
var end = html.indexOf('2.2.2', start);
|
||||
if (end < 0) end = html.indexOf('2.2.3', start);
|
||||
if (end < 0) end = start + 2200;
|
||||
var head = html.slice(0, start);
|
||||
var slice = html.slice(start, end);
|
||||
var tail = html.slice(end);
|
||||
slice = slice.replace(/(提前【)\s*([\s\u00a0]*)(】个工作日内)/, '$1' + escapeHtml(days) + '$3');
|
||||
return head + slice + tail;
|
||||
}
|
||||
|
||||
/** 附件1:租赁订单编号、甲乙方与车辆列表反写 */
|
||||
export function applyLeaseOrderAttachmentPatch(html, form, customer, lessor) {
|
||||
if (!html) return html;
|
||||
var attachStart = html.indexOf('1:租赁订单');
|
||||
if (attachStart < 0) attachStart = html.indexOf('订单编号');
|
||||
if (attachStart < 0) return html;
|
||||
var attachEnd = html.indexOf('附件2', attachStart);
|
||||
if (attachEnd < 0) attachEnd = html.length;
|
||||
|
||||
var head = html.slice(0, attachStart);
|
||||
var slice = html.slice(attachStart, attachEnd);
|
||||
var tail = html.slice(attachEnd);
|
||||
|
||||
var codeParts = getContractCodeParts(form);
|
||||
var fullContractCode = escapeHtml(codeParts.full);
|
||||
var contractCodeSuffix = escapeHtml(codeParts.suffix);
|
||||
|
||||
if (contractCodeSuffix) {
|
||||
slice = slice.replace(
|
||||
/(<span class="s13"[^>]*>LNZLHT<\/span><span class="s14"[^>]*>)\s*([\s\u00a0]*)(<\/span>)/,
|
||||
'$1' + contractCodeSuffix + '$3',
|
||||
);
|
||||
}
|
||||
|
||||
if (fullContractCode) {
|
||||
slice = slice.replace(
|
||||
/(合同编号<span class="s2"[^>]*>)\s*([\s\u00a0]*)(<\/span>)/,
|
||||
'$1' + fullContractCode + '$3',
|
||||
);
|
||||
}
|
||||
|
||||
if (customer && customer.name) {
|
||||
var customerName = escapeHtml(customer.name);
|
||||
slice = slice.replace(
|
||||
/(<b>乙方\(承租方\):[\s\u00a0]*<\/b><\/span><span class="s16"[^>]*><b>)\s*([\s\u00a0]*)(<\/b><\/span>)/,
|
||||
'$1' + customerName + '$3',
|
||||
);
|
||||
}
|
||||
|
||||
var sigMarker = slice.indexOf('甲方(签章');
|
||||
if (sigMarker >= 0) {
|
||||
var sigSlice = slice.slice(sigMarker);
|
||||
if (lessor && lessor.legalName) {
|
||||
var lessorName = escapeHtml(lessor.legalName);
|
||||
sigSlice = sigSlice.replace(
|
||||
/(<td class="td12"[^>]*>\s*<p class="p(?:37|168)"[^>]*>)\s*<br\/>\s*(<\/p>)/,
|
||||
'$1' + lessorName + '$2',
|
||||
);
|
||||
}
|
||||
if (customer && customer.name) {
|
||||
var partyBName = escapeHtml(customer.name);
|
||||
sigSlice = sigSlice.replace(
|
||||
/(<td class="td13"[^>]*>\s*<p[^>]*>[\s\S]*?<b>:<\/b>)(<\/p>)/,
|
||||
'$1' + partyBName + '$2',
|
||||
);
|
||||
}
|
||||
slice = slice.slice(0, sigMarker) + sigSlice;
|
||||
}
|
||||
|
||||
var orders = (form && form.leaseOrder && form.leaseOrder.rows)
|
||||
|| (form && form.leaseOrders)
|
||||
|| [];
|
||||
if (orders.length > 0) {
|
||||
var rowsHtml = orders.map(buildLeaseOrderPreviewRow).join('');
|
||||
var marker = '一、租赁车辆信息';
|
||||
var tableMarkerPos = slice.indexOf(marker);
|
||||
if (tableMarkerPos >= 0) {
|
||||
var tbodyEnd = slice.indexOf('</tbody>', tableMarkerPos);
|
||||
if (tbodyEnd >= 0) {
|
||||
slice = slice.slice(0, tbodyEnd) + rowsHtml + slice.slice(tbodyEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return head + slice + tail;
|
||||
}
|
||||
|
||||
function buildLeaseOrderPreviewRow(row) {
|
||||
var brand = row.brandModel && row.brandModel[0] ? escapeHtml(row.brandModel[0]) : '';
|
||||
var model = row.brandModel && row.brandModel[1] ? escapeHtml(row.brandModel[1]) : '';
|
||||
var plate = escapeHtml(row.plateNo || '');
|
||||
var extraServices = normalizeExtraServices(row);
|
||||
var subtotal = row.rentServiceSubtotal != null && row.rentServiceSubtotal !== ''
|
||||
? escapeHtml(row.rentServiceSubtotal)
|
||||
: (function () {
|
||||
var computed = calcRentServiceSubtotal(row.rent, extraServices);
|
||||
return computed != null ? escapeHtml(computed) : '';
|
||||
}());
|
||||
var rent = row.rent != null && row.rent !== '' ? escapeHtml(row.rent) : '';
|
||||
var serviceFeeTotal = sumExtraServiceFees(extraServices);
|
||||
var serviceFee = serviceFeeTotal > 0 ? escapeHtml(serviceFeeTotal) : '';
|
||||
var deposit = row.deposit != null && row.deposit !== '' ? escapeHtml(row.deposit) : '';
|
||||
var extra = escapeHtml(formatExtraServicesDisplay(extraServices));
|
||||
var leasePeriod = escapeHtml(formatLeasePeriod(row));
|
||||
var border = 'border:1px solid #bfbfbf;padding:0 5px';
|
||||
return '<tr data-lc-lease-order-row="1">'
|
||||
+ '<td style="min-width:36px;' + border + '" valign="top">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times"><b>' + brand + '</b></p>'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + model + '</p></td>'
|
||||
+ '<td style="min-width:51px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + plate + '</p></td>'
|
||||
+ '<td style="min-width:84px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + subtotal + '</p></td>'
|
||||
+ '<td style="min-width:72px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + rent + '</p></td>'
|
||||
+ '<td style="min-width:56px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + serviceFee + '</p></td>'
|
||||
+ '<td style="min-width:88px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + extra + '</p></td>'
|
||||
+ '<td style="min-width:41px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + deposit + '</p></td>'
|
||||
+ '<td style="min-width:94px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + leasePeriod + '</p></td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
|
||||
function attachmentPlaceholderBlock(title, fileName, imageUrl) {
|
||||
if (imageUrl) {
|
||||
return '<div class="lc-preview-attach">'
|
||||
+ '<p class="p12" style="margin:12px 0 6px;text-align:center;font:10.5px Times;font-weight:700">' + escapeHtml(title) + '</p>'
|
||||
+ '<div class="lc-preview-attach__frame lc-preview-attach__frame--image">'
|
||||
+ '<img class="lc-preview-attach__image" src="' + imageUrl + '" alt="' + escapeHtml(title) + '" />'
|
||||
+ '</div></div>';
|
||||
}
|
||||
return '<div class="lc-preview-attach">'
|
||||
+ '<p class="p12" style="margin:12px 0 6px;text-align:center;font:10.5px Times;font-weight:700">' + escapeHtml(title) + '</p>'
|
||||
+ '<div class="lc-preview-attach__frame" role="img" aria-label="' + escapeHtml(title) + '">'
|
||||
+ '<span class="lc-preview-attach__file">' + escapeHtml(fileName || '附件') + '</span>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
/** 合同尾页附上客户资质附件 */
|
||||
export function appendCustomerCredentialAttachments(html, customer) {
|
||||
if (!html || !customer || !customer.attachments) return html;
|
||||
var blocks = [];
|
||||
CUSTOMER_CREDENTIAL_ITEMS.forEach(function (item, index) {
|
||||
if (index > 0 && index % 2 === 0) {
|
||||
blocks.push('<hr class="ct-page-break" data-page-break="1" />');
|
||||
}
|
||||
var file = customer.attachments[item.key];
|
||||
var previewUrl = getCustomerAttachmentPreviewUrl(customer, item.key, item.label);
|
||||
blocks.push(attachmentPlaceholderBlock(item.label, file && file.name, previewUrl));
|
||||
});
|
||||
var section = '<div class="lc-preview-credentials">'
|
||||
+ '<hr class="ct-page-break" data-page-break="1" />'
|
||||
+ '<p class="p12" style="margin:0 0 12px;text-align:center;font:12pt Times;font-weight:700">客户资质附件(乙方)</p>'
|
||||
+ blocks.join('')
|
||||
+ '</div>';
|
||||
var closeIdx = html.lastIndexOf('</div>');
|
||||
if (closeIdx < 0) return html + section;
|
||||
return html.slice(0, closeIdx) + section + html.slice(closeIdx);
|
||||
}
|
||||
|
||||
function stripUnresolvedPlaceholders(html) {
|
||||
return String(html || '').replace(/\{\{[^{}]+\}\}/g, '');
|
||||
}
|
||||
|
||||
export function buildLeaseContractPreviewHtml(form) {
|
||||
var base = V266_LEASE_DOCUMENT_HTML || '';
|
||||
var vars = buildLeasePreviewVars(form || {});
|
||||
var html = stripUnresolvedPlaceholders(applyTemplateVars(base, vars));
|
||||
var customer = getLeaseCustomerById(form && form.customerId);
|
||||
var lessor = getLessorCompanyById(form && form.lessorId);
|
||||
html = applyCustomerPartyPatch(html, customer);
|
||||
html = applyInvoiceInfoPatch(html, customer);
|
||||
html = applyMileageClausePatch(html, form && form.mileage);
|
||||
html = applyMileageStandardPatch(html, form && form.mileage);
|
||||
html = applyPaymentPeriodPatch(html, form && form.feeInfo);
|
||||
html = applyFeeInfoPatch(html, form && form.feeInfo);
|
||||
html = applyLeaseOrderAttachmentPatch(html, form, customer, lessor);
|
||||
html = appendCustomerCredentialAttachments(html, customer);
|
||||
return html;
|
||||
}
|
||||
267
src/prototypes/lease-contract-management/lease-order-vars.js
vendored
Normal file
267
src/prototypes/lease-contract-management/lease-order-vars.js
vendored
Normal file
@@ -0,0 +1,267 @@
|
||||
/** 附件1 租赁订单 — 品牌型号级联与行数据 */
|
||||
|
||||
import vehiclesData from '../vehicle-management/data/vehicles.json';
|
||||
|
||||
export var READY_VEHICLE_STATUS = '已备车';
|
||||
|
||||
/** 车辆库中暂无「已备车」时的原型兜底数据 */
|
||||
export var LEASE_READY_VEHICLE_FALLBACK = [
|
||||
{ plateNo: '粤AGR8556', brand: '现代', model: '帕力安牌4.5吨冷链车', vehicleStatus: '已备车' },
|
||||
{ plateNo: '粤BHY6688', brand: '现代', model: '18吨氢燃料电池车', vehicleStatus: '已备车' },
|
||||
{ plateNo: '粤CSL9901', brand: '苏龙', model: '9.6米氢燃料电池车', vehicleStatus: '已备车' },
|
||||
{ plateNo: '粤DFC7722', brand: '跃进', model: '4.2米冷链车', vehicleStatus: '已备车' },
|
||||
{ plateNo: '粤EGT5533', brand: '宇通', model: '氢燃料电池客车', vehicleStatus: '已备车' },
|
||||
];
|
||||
|
||||
export function getReadyVehicles() {
|
||||
var ready = vehiclesData.filter(function (vehicle) {
|
||||
return vehicle.vehicleStatus === READY_VEHICLE_STATUS && vehicle.plateNo;
|
||||
});
|
||||
return ready.length > 0 ? ready : LEASE_READY_VEHICLE_FALLBACK;
|
||||
}
|
||||
|
||||
export function getReadyVehiclePlateOptions(brandModel) {
|
||||
var vehicles = getReadyVehicles();
|
||||
if (brandModel && brandModel.length >= 2) {
|
||||
vehicles = vehicles.filter(function (vehicle) {
|
||||
return vehicle.brand === brandModel[0] && vehicle.model === brandModel[1];
|
||||
});
|
||||
}
|
||||
return vehicles.map(function (vehicle) {
|
||||
return {
|
||||
value: vehicle.plateNo,
|
||||
label: vehicle.plateNo,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export var LEASE_VEHICLE_BRAND_MODEL_CATALOG = [
|
||||
{ brand: '现代', models: ['帕力安牌4.5吨冷链车', '18吨氢燃料电池车', '9.6米厢式货车'] },
|
||||
{ brand: '苏龙', models: ['9.6米氢燃料电池车'] },
|
||||
{ brand: '飞驰', models: ['集卡头', '半挂车'] },
|
||||
{ brand: '跃进', models: ['4.2米冷链车'] },
|
||||
{ brand: '宇通', models: ['氢燃料电池客车'] },
|
||||
];
|
||||
|
||||
export var PROVINCE_CITY_CASCADER_OPTIONS = [
|
||||
{
|
||||
value: '浙江省',
|
||||
label: '浙江省',
|
||||
children: [
|
||||
{ value: '嘉兴市', label: '嘉兴市' },
|
||||
{ value: '杭州市', label: '杭州市' },
|
||||
{ value: '宁波市', label: '宁波市' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: '上海市',
|
||||
label: '上海市',
|
||||
children: [{ value: '上海市', label: '上海市' }],
|
||||
},
|
||||
{
|
||||
value: '广东省',
|
||||
label: '广东省',
|
||||
children: [
|
||||
{ value: '广州市', label: '广州市' },
|
||||
{ value: '深圳市', label: '深圳市' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: '江苏省',
|
||||
label: '江苏省',
|
||||
children: [
|
||||
{ value: '南京市', label: '南京市' },
|
||||
{ value: '苏州市', label: '苏州市' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function buildBrandModelCascaderOptions() {
|
||||
return LEASE_VEHICLE_BRAND_MODEL_CATALOG.map(function (item) {
|
||||
return {
|
||||
value: item.brand,
|
||||
label: item.brand,
|
||||
children: item.models.map(function (model) {
|
||||
return { value: model, label: model };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmptyLeaseOrderRow() {
|
||||
return {
|
||||
id: 'lo-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
brandModel: [],
|
||||
plateNo: '',
|
||||
rentServiceSubtotal: null,
|
||||
rent: null,
|
||||
deposit: null,
|
||||
extraServices: [],
|
||||
leasePeriodStart: null,
|
||||
leasePeriodEnd: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatLeasePeriod(row) {
|
||||
if (!row) return '';
|
||||
if (row.leasePeriodStart && row.leasePeriodEnd) {
|
||||
return row.leasePeriodStart + ' ~ ' + row.leasePeriodEnd;
|
||||
}
|
||||
return row.leasePeriodStart || row.leasePeriodEnd || '';
|
||||
}
|
||||
|
||||
/** 租赁订单附加服务(服务项 + 月费) */
|
||||
export var LEASE_EXTRA_SERVICE_CATALOG = [
|
||||
{ value: 'door-delivery', name: '送车服务费', fee: 300 },
|
||||
{ value: 'door-pickup', name: '接车服务费', fee: 300 },
|
||||
{ value: 'carefree-pack', name: '无忧包', fee: 500 },
|
||||
{ value: 'tire-insurance', name: '轮胎保', fee: 200 },
|
||||
{ value: 'maintenance-insurance', name: '养护保', fee: 180 },
|
||||
{ value: 'tailgate', name: '尾板', fee: 150 },
|
||||
];
|
||||
|
||||
export var LEASE_EXTRA_SERVICE_OPTIONS = LEASE_EXTRA_SERVICE_CATALOG.map(function (item) {
|
||||
return {
|
||||
value: item.value,
|
||||
label: item.name + ' ¥' + item.fee + '/月',
|
||||
};
|
||||
});
|
||||
|
||||
export function getExtraServiceFeeByValue(value) {
|
||||
for (var i = 0; i < LEASE_EXTRA_SERVICE_CATALOG.length; i++) {
|
||||
if (LEASE_EXTRA_SERVICE_CATALOG[i].value === value) {
|
||||
return LEASE_EXTRA_SERVICE_CATALOG[i].fee;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getExtraServiceByValue(value) {
|
||||
for (var i = 0; i < LEASE_EXTRA_SERVICE_CATALOG.length; i++) {
|
||||
if (LEASE_EXTRA_SERVICE_CATALOG[i].value === value) {
|
||||
return LEASE_EXTRA_SERVICE_CATALOG[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getExtraServiceNames(selectedValues) {
|
||||
if (!selectedValues || !selectedValues.length) return [];
|
||||
return selectedValues.map(function (value) {
|
||||
var item = getExtraServiceByValue(value);
|
||||
return item ? item.name : value;
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeExtraServices(row) {
|
||||
if (!row) return [];
|
||||
if (Array.isArray(row.extraServices)) return row.extraServices;
|
||||
if (row.extraService) return [row.extraService];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function sumExtraServiceFees(selectedValues) {
|
||||
if (!selectedValues || !selectedValues.length) return 0;
|
||||
var total = 0;
|
||||
selectedValues.forEach(function (value) {
|
||||
total += getExtraServiceFeeByValue(value);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
export function formatExtraServicesDisplay(selectedValues) {
|
||||
if (!selectedValues || !selectedValues.length) return '';
|
||||
return selectedValues.map(function (value) {
|
||||
for (var i = 0; i < LEASE_EXTRA_SERVICE_CATALOG.length; i++) {
|
||||
var item = LEASE_EXTRA_SERVICE_CATALOG[i];
|
||||
if (item.value === value) {
|
||||
return item.name + ' ¥' + item.fee;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}).join('、');
|
||||
}
|
||||
|
||||
export function calcRentServiceSubtotal(rent, extraServices) {
|
||||
var serviceFeeTotal = sumExtraServiceFees(extraServices);
|
||||
var hasRent = rent != null && rent !== '';
|
||||
if (!hasRent && serviceFeeTotal <= 0) return null;
|
||||
var rentVal = hasRent ? Number(rent) : 0;
|
||||
if (!Number.isFinite(rentVal)) return null;
|
||||
return Math.round((rentVal + serviceFeeTotal) * 100) / 100;
|
||||
}
|
||||
|
||||
export function calcLeaseOrderKpis(rows) {
|
||||
var rentServiceTaxTotal = 0;
|
||||
var depositTotal = 0;
|
||||
var hasSubtotal = false;
|
||||
var hasDeposit = false;
|
||||
(rows || []).forEach(function (row) {
|
||||
var subtotal = calcRentServiceSubtotal(row.rent, normalizeExtraServices(row));
|
||||
if (subtotal != null) {
|
||||
rentServiceTaxTotal += subtotal;
|
||||
hasSubtotal = true;
|
||||
}
|
||||
if (row.deposit != null && row.deposit !== '') {
|
||||
var depositVal = Number(row.deposit);
|
||||
if (Number.isFinite(depositVal)) {
|
||||
depositTotal += depositVal;
|
||||
hasDeposit = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
function roundMoney(amount) {
|
||||
return Math.round(amount * 100) / 100;
|
||||
}
|
||||
var monthlyTotal = hasSubtotal ? roundMoney(rentServiceTaxTotal) : null;
|
||||
return {
|
||||
rentServiceTaxTotal: monthlyTotal,
|
||||
depositTotal: hasDeposit ? roundMoney(depositTotal) : null,
|
||||
firstPeriodRentService: monthlyTotal,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatKpiAmount(value) {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return '0元';
|
||||
}
|
||||
return '¥' + value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function countLeaseOrderRows(rows) {
|
||||
if (!rows || !rows.length) return 0;
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export function createDefaultLeaseOrderState() {
|
||||
return {
|
||||
insuredVehicleCount: null,
|
||||
thirdPartyLiabilityMillion: null,
|
||||
deliveryRegion: [],
|
||||
deliveryDate: null,
|
||||
rows: [createEmptyLeaseOrderRow()],
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 使用 createDefaultLeaseOrderState */
|
||||
export var DEFAULT_LEASE_ORDERS = createDefaultLeaseOrderState().rows;
|
||||
|
||||
/** 授权委托书 — 委托人行数据 */
|
||||
|
||||
export function createEmptyDelegateRow() {
|
||||
return {
|
||||
id: 'del-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
name: '',
|
||||
contact: '',
|
||||
idNumber: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultPowerOfAttorneyState() {
|
||||
return {
|
||||
delegates: [createEmptyDelegateRow()],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 租赁合同管理 — 模块样式(基于 vehicle-management vm-*)
|
||||
*/
|
||||
@import url('../../contract-template-management/styles/contract-template.css');
|
||||
@import url('./lease-contract.css');
|
||||
@import url('./lease-contract-create.css');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 租赁合同管理 — Ant Design 控件与 vm 布局对齐
|
||||
*/
|
||||
|
||||
/* 筛选区:Ant Select / DatePicker 与 vm-input 同高 */
|
||||
.vm-page.lc-page .vm-filter-field .ant-select,
|
||||
.vm-page.lc-page .vm-filter-field .ant-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-filter-field .ant-select .ant-select-selector,
|
||||
.vm-page.lc-page .vm-filter-field .ant-picker {
|
||||
min-height: var(--vm-control-height) !important;
|
||||
border-radius: var(--ln-radius-control) !important;
|
||||
border-color: var(--ln-hairline-strong) !important;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-filter-field .ant-select .ant-select-selector {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-filter-field .ant-select-multiple .ant-select-selector {
|
||||
min-height: var(--vm-control-height) !important;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
/* 列表工具栏:上下间距一致(以下方 16px 为准) */
|
||||
.vm-page.lc-page .vm-filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-table-section .vm-table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-height: var(--vm-btn-height);
|
||||
padding: 0 0 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 列表区:Ant Table 嵌入 vm-table-card */
|
||||
.vm-page.lc-page .vm-table-card .vm-list-table.ant-table-wrapper,
|
||||
.vm-page.lc-page .vm-table-card .vm-list-table .ant-table-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-kpi-row {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-table-wrap.vm-list-table {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 640px);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.vm-page.lc-page .vm-kpi-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.vm-page.lc-page .vm-kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vm-page.lc-page .vm-kpi-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .ant-table-thead > tr > th {
|
||||
background: var(--ln-canvas-soft) !important;
|
||||
color: var(--ln-muted) !important;
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 500 !important;
|
||||
padding: 8px 12px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
|
||||
padding: 8px 12px !important;
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td {
|
||||
background: var(--ln-canvas-soft) !important;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .ant-table-cell-fix-left,
|
||||
.vm-page.lc-page .vm-list-table .ant-table-cell-fix-right {
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||
.vm-page.lc-page .vm-list-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||
background: var(--ln-canvas-soft) !important;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .oneos-cell-link,
|
||||
.vm-page.lc-page .vm-list-table .vm-link {
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-primary);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .oneos-cell-link:hover,
|
||||
.vm-page.lc-page .vm-list-table .vm-link:hover {
|
||||
color: var(--ln-primary-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .ant-btn-link {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
padding: 0 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--ln-primary) !important;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-list-table .ant-btn-link:hover {
|
||||
color: var(--ln-primary-hover) !important;
|
||||
}
|
||||
|
||||
/* 审批流 Popover 标签 */
|
||||
.vm-page.lc-page .lc-approval-tag {
|
||||
margin: 0;
|
||||
color: var(--ln-primary);
|
||||
background: var(--ln-primary-soft);
|
||||
border-color: color-mix(in srgb, var(--ln-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* 面包屑与 vm 页头 */
|
||||
.vm-page.lc-page .vm-page-header .ant-breadcrumb {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .vm-page-header .ant-breadcrumb li:last-child {
|
||||
color: var(--ln-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 项目信息:三行堆叠,与车辆管理所属项目一致 */
|
||||
.vm-page.lc-page .vm-list-table .lc-project-info-col {
|
||||
white-space: normal !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-project-info-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--ln-radius-xs);
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-project-info-btn:hover .vm-stack .primary,
|
||||
.vm-page.lc-page .lc-project-info-btn:hover .vm-stack .code {
|
||||
color: var(--ln-primary-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-project-info-btn:focus-visible {
|
||||
outline: var(--vm-focus-border-color, var(--ln-primary)) solid 2px;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-project-info-btn .vm-stack .primary {
|
||||
font-weight: 500;
|
||||
color: var(--ln-link);
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-project-info-btn .vm-stack .code {
|
||||
font-family: var(--vm-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-link);
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-project-info-btn .vm-stack .sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-body);
|
||||
}
|
||||
767
src/prototypes/oneos-weekly-report/WeeklyReportPage.tsx
Normal file
767
src/prototypes/oneos-weekly-report/WeeklyReportPage.tsx
Normal file
@@ -0,0 +1,767 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
Download,
|
||||
Flag,
|
||||
FlaskConical,
|
||||
Layers,
|
||||
MessageSquareQuote,
|
||||
Scale,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
EXECUTIVE_DECISIONS,
|
||||
EXECUTIVE_KPIS,
|
||||
EXECUTIVE_SUMMARY,
|
||||
KEY_ISSUES,
|
||||
MILESTONES,
|
||||
MINOR_ISSUES,
|
||||
NEXT_WEEK_PLAN,
|
||||
REPORT_META,
|
||||
TRACK_PROGRESS,
|
||||
type ExecutiveDecision,
|
||||
type ExecutiveHealth,
|
||||
type ExecutiveKpi,
|
||||
type IssueStatus,
|
||||
type PhaseWorkItem,
|
||||
type RequirementItem,
|
||||
type RequirementPriority,
|
||||
type TrackKey,
|
||||
type TrackPhase,
|
||||
type WeeklyIssue,
|
||||
} from './weekly-report-data';
|
||||
|
||||
const STATUS_LABEL: Record<IssueStatus, string> = {
|
||||
open: '待处理',
|
||||
in_progress: '处理中',
|
||||
resolved: '已闭环',
|
||||
pending_feedback: '待反馈',
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<IssueStatus, string> = {
|
||||
open: 'wr-badge--gray',
|
||||
in_progress: 'wr-badge--blue',
|
||||
resolved: 'wr-badge--green',
|
||||
pending_feedback: 'wr-badge--amber',
|
||||
};
|
||||
|
||||
const TRACK_ICON: Record<TrackKey, React.ReactNode> = {
|
||||
product: <Layers size={20} strokeWidth={2.25} aria-hidden />,
|
||||
dev: <Code2 size={20} strokeWidth={2.25} aria-hidden />,
|
||||
qa: <FlaskConical size={20} strokeWidth={2.25} aria-hidden />,
|
||||
};
|
||||
|
||||
const MILESTONE_STATUS: Record<string, { label: string; tone: string }> = {
|
||||
on_track: { label: '正常', tone: 'wr-badge--green' },
|
||||
at_risk: { label: '有风险', tone: 'wr-badge--amber' },
|
||||
done: { label: '已完成', tone: 'wr-badge--green' },
|
||||
delayed: { label: '延期', tone: 'wr-badge--red' },
|
||||
};
|
||||
|
||||
function WorkReportCard(props: { items: PhaseWorkItem[]; title?: string; emptyText?: string }) {
|
||||
if (!props.items.length && !props.emptyText) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="wr-work-report">
|
||||
<h5 className="wr-work-report__title">{props.title || '工作汇报'}</h5>
|
||||
{props.items.length ? (
|
||||
<ul className="wr-work-report__list">
|
||||
{props.items.map(function (item) {
|
||||
return (
|
||||
<li key={item.id} className={'wr-work-report__item' + (item.done ? ' wr-work-report__item--done' : '')}>
|
||||
<span className="wr-work-report__icon" aria-hidden>
|
||||
{item.done ? <CheckCircle2 size={16} strokeWidth={2.25} /> : <Circle size={16} strokeWidth={2.25} />}
|
||||
</span>
|
||||
<div className="wr-work-report__body">
|
||||
<span className="wr-work-report__text">{item.title}</span>
|
||||
{item.note ? <span className="wr-work-report__note">{item.note}</span> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="wr-work-report__empty">{props.emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function requirementPriorityRank(priority: RequirementPriority): number {
|
||||
if (priority === '高优先级') {
|
||||
return 0;
|
||||
}
|
||||
if (priority === '中优先级') {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
function requirementPriorityTone(priority: RequirementPriority): string {
|
||||
if (priority === '高优先级') {
|
||||
return 'wr-req-tag--high';
|
||||
}
|
||||
if (priority === '中优先级') {
|
||||
return 'wr-req-tag--mid';
|
||||
}
|
||||
return 'wr-req-tag--low';
|
||||
}
|
||||
|
||||
function CircularProgressRing(props: { value: number; label: string }) {
|
||||
var size = 36;
|
||||
var stroke = 3;
|
||||
var radius = (size - stroke) / 2;
|
||||
var circumference = 2 * Math.PI * radius;
|
||||
var clamped = Math.max(0, Math.min(100, props.value));
|
||||
var offset = circumference - (clamped / 100) * circumference;
|
||||
var center = size / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="wr-req-progress"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={'0 0 ' + size + ' ' + size}
|
||||
role="img"
|
||||
aria-label={props.label + ',完成 ' + clamped + '%'}
|
||||
>
|
||||
<circle
|
||||
className="wr-req-progress__track"
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
className={'wr-req-progress__fill' + (clamped >= 100 ? ' wr-req-progress__fill--done' : '')}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={'rotate(-90 ' + center + ' ' + center + ')'}
|
||||
/>
|
||||
<text
|
||||
className="wr-req-progress__text tabular-nums"
|
||||
x={center}
|
||||
y={center}
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{clamped}%
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementWorkReportCard(props: { title: string; items: RequirementItem[] }) {
|
||||
if (!props.items.length) {
|
||||
return null;
|
||||
}
|
||||
var sortedItems = [...props.items].sort(function (a, b) {
|
||||
return requirementPriorityRank(a.priority) - requirementPriorityRank(b.priority);
|
||||
});
|
||||
return (
|
||||
<div className="wr-work-report">
|
||||
<h5 className="wr-work-report__title">{props.title}</h5>
|
||||
<ul className="wr-req-list">
|
||||
{sortedItems.map(function (item) {
|
||||
return (
|
||||
<li key={item.id} className="wr-req-item">
|
||||
<CircularProgressRing value={item.progress} label={item.title} />
|
||||
<div className="wr-req-item__body">
|
||||
<div className="wr-req-item__tags">
|
||||
<span className="wr-req-tag wr-req-tag--stage">{item.stage}</span>
|
||||
<span className={'wr-req-tag wr-req-tag--priority ' + requirementPriorityTone(item.priority)}>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="wr-req-item__text">{item.title}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EXEC_HEALTH_TONE: Record<ExecutiveHealth, string> = {
|
||||
green: 'wr-exec-health--green',
|
||||
amber: 'wr-exec-health--amber',
|
||||
red: 'wr-exec-health--red',
|
||||
};
|
||||
|
||||
function ExecutiveKpiCard(props: { kpi: ExecutiveKpi }) {
|
||||
var kpi = props.kpi;
|
||||
return (
|
||||
<article className={'wr-kpi-card wr-kpi-card--' + (kpi.tone || 'default')}>
|
||||
<div className="wr-kpi-card__body">
|
||||
<div className="wr-kpi-card__value tabular-nums">{kpi.value}</div>
|
||||
<div className="wr-kpi-card__title">{kpi.label}</div>
|
||||
{kpi.hint ? <div className="wr-kpi-card__desc">{kpi.hint}</div> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutiveSummarySection() {
|
||||
var summary = EXECUTIVE_SUMMARY;
|
||||
return (
|
||||
<section className="wr-exec-summary" aria-labelledby="wr-exec-summary-title">
|
||||
<div className="wr-exec-summary__head">
|
||||
<div>
|
||||
<h2 id="wr-exec-summary-title" className="wr-section__title">本周执行结论</h2>
|
||||
<p className="wr-section__desc">面向管理层的一页摘要,可直接用于周会汇报</p>
|
||||
</div>
|
||||
<span className={'wr-exec-health ' + EXEC_HEALTH_TONE[summary.health]}>{summary.healthLabel}</span>
|
||||
</div>
|
||||
<p className="wr-exec-summary__headline">{summary.headline}</p>
|
||||
<div className="wr-exec-summary__grid">
|
||||
<div className="wr-exec-summary__block">
|
||||
<h3 className="wr-exec-summary__block-title">
|
||||
<CheckCircle2 size={16} strokeWidth={2.25} aria-hidden />
|
||||
本周成效
|
||||
</h3>
|
||||
<ul className="wr-exec-summary__list">
|
||||
{summary.achievements.map(function (item, index) {
|
||||
return <li key={index}>{item}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="wr-exec-summary__block wr-exec-summary__block--risk">
|
||||
<h3 className="wr-exec-summary__block-title">
|
||||
<AlertTriangle size={16} strokeWidth={2.25} aria-hidden />
|
||||
主要风险
|
||||
</h3>
|
||||
<ul className="wr-exec-summary__list">
|
||||
{summary.risks.map(function (item, index) {
|
||||
return <li key={index}>{item}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutiveDecisionCard(props: { decision: ExecutiveDecision }) {
|
||||
var decision = props.decision;
|
||||
return (
|
||||
<article className={'wr-exec-decision' + (decision.urgency === 'high' ? ' wr-exec-decision--urgent' : '')}>
|
||||
<header className="wr-exec-decision__head">
|
||||
<h3 className="wr-exec-decision__title">{decision.title}</h3>
|
||||
<span className={'wr-badge ' + (decision.urgency === 'high' ? 'wr-badge--red' : 'wr-badge--amber')}>
|
||||
{decision.urgency === 'high' ? '紧急' : '一般'}
|
||||
</span>
|
||||
</header>
|
||||
<dl className="wr-exec-decision__meta">
|
||||
<div>
|
||||
<dt>业务影响</dt>
|
||||
<dd>{decision.impact}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>建议决策</dt>
|
||||
<dd>{decision.recommendation}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>期望时限</dt>
|
||||
<dd className="tabular-nums">{decision.deadline}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function NextWeekPanel() {
|
||||
var goals = NEXT_WEEK_PLAN.filter(function (item) { return item.type === 'goal'; });
|
||||
var risks = NEXT_WEEK_PLAN.filter(function (item) { return item.type === 'risk'; });
|
||||
return (
|
||||
<div className="wr-exec-next">
|
||||
<div className="wr-exec-next__col">
|
||||
<h3 className="wr-exec-next__title">
|
||||
<Flag size={16} strokeWidth={2.25} aria-hidden />
|
||||
下周必达
|
||||
</h3>
|
||||
<ul className="wr-exec-next__list">
|
||||
{goals.map(function (item) {
|
||||
return (
|
||||
<li key={item.id} className="wr-exec-next__item">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.detail}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="wr-exec-next__col wr-exec-next__col--risk">
|
||||
<h3 className="wr-exec-next__title">
|
||||
<AlertTriangle size={16} strokeWidth={2.25} aria-hidden />
|
||||
下周风险
|
||||
</h3>
|
||||
<ul className="wr-exec-next__list">
|
||||
{risks.map(function (item) {
|
||||
return (
|
||||
<li key={item.id} className="wr-exec-next__item">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.detail}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar(props: { value: number; label: string }) {
|
||||
return (
|
||||
<div className="wr-progress" role="group" aria-label={props.label}>
|
||||
<div className="wr-progress__head">
|
||||
<span className="wr-progress__label">{props.label}</span>
|
||||
<span className="wr-progress__pct tabular-nums">{props.value}%</span>
|
||||
</div>
|
||||
<div className="wr-progress__track" aria-hidden>
|
||||
<div className="wr-progress__fill" style={{ width: props.value + '%' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FourRGrid(props: { fourR: WeeklyIssue['fourR'] }) {
|
||||
return (
|
||||
<dl className="wr-four-r-grid">
|
||||
<div className="wr-four-r-item">
|
||||
<dt><Target size={15} strokeWidth={2.25} aria-hidden /> 原因</dt>
|
||||
<dd>{props.fourR.reason}</dd>
|
||||
</div>
|
||||
<div className="wr-four-r-item">
|
||||
<dt><ClipboardList size={15} strokeWidth={2.25} aria-hidden /> 措施</dt>
|
||||
<dd>{props.fourR.measure}</dd>
|
||||
</div>
|
||||
<div className="wr-four-r-item">
|
||||
<dt><CheckCircle2 size={15} strokeWidth={2.25} aria-hidden /> 结果</dt>
|
||||
<dd>{props.fourR.result}</dd>
|
||||
</div>
|
||||
<div className="wr-four-r-item wr-four-r-item--feedback">
|
||||
<dt><MessageSquareQuote size={15} strokeWidth={2.25} aria-hidden /> 反馈</dt>
|
||||
<dd>{props.fourR.feedback}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseCard(props: { phase: TrackPhase }) {
|
||||
var phase = props.phase;
|
||||
var tabs = phase.tabs || [];
|
||||
var tabState = useState(tabs[0] ? tabs[0].key : '');
|
||||
var activeTabKey = tabState[0];
|
||||
var setActiveTabKey = tabState[1];
|
||||
|
||||
var activeTab = useMemo(function () {
|
||||
return tabs.find(function (t) { return t.key === activeTabKey; }) || tabs[0];
|
||||
}, [tabs, activeTabKey]);
|
||||
|
||||
if (!tabs.length) {
|
||||
return (
|
||||
<article className="wr-phase-card" role="listitem">
|
||||
<h4 className="wr-phase-card__title">{phase.label}</h4>
|
||||
<div className="wr-phase-card__stats tabular-nums">
|
||||
<span><strong>{phase.done}</strong> / {phase.count}</span>
|
||||
</div>
|
||||
{phase.note ? <p className="wr-phase-card__note">{phase.note}</p> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="wr-phase-card" role="listitem">
|
||||
<h4 className="wr-phase-card__title">{phase.label}</h4>
|
||||
<div className="wr-phase-tabs" role="tablist" aria-label={phase.label + '分类'}>
|
||||
{tabs.map(function (tab) {
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTabKey === tab.key}
|
||||
className={'wr-phase-tab' + (activeTabKey === tab.key ? ' wr-phase-tab--active' : '')}
|
||||
onClick={function () { setActiveTabKey(tab.key); }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{activeTab ? (
|
||||
<div className="wr-phase-panel" role="tabpanel">
|
||||
{phase.key === 'prototype' && activeTab.requirementSections ? (
|
||||
activeTab.requirementSections.map(function (section) {
|
||||
return (
|
||||
<RequirementWorkReportCard
|
||||
key={section.title}
|
||||
title={section.title}
|
||||
items={section.items}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : phase.key === 'prototype' ? (
|
||||
<>
|
||||
<WorkReportCard
|
||||
items={activeTab.workReport || []}
|
||||
title="PC"
|
||||
emptyText={activeTab.emptyText ?? (!(activeTab.workReport && activeTab.workReport.length) ? '无' : undefined)}
|
||||
/>
|
||||
<WorkReportCard
|
||||
items={activeTab.miniProgramWorkReport || []}
|
||||
title="小程序"
|
||||
emptyText={activeTab.miniProgramEmptyText ?? '无'}
|
||||
/>
|
||||
</>
|
||||
) : (activeTab.workReport?.length || activeTab.emptyText) ? (
|
||||
<WorkReportCard
|
||||
items={activeTab.workReport || []}
|
||||
title={phase.workReportTitle}
|
||||
emptyText={activeTab.emptyText}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseModuleCards(props: { phases: TrackPhase[] }) {
|
||||
return (
|
||||
<div className="wr-phase-grid" role="list" aria-label="产品阶段模块">
|
||||
{props.phases.map(function (phase) {
|
||||
return <PhaseCard key={phase.key} phase={phase} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackSummaryCard(props: {
|
||||
track: (typeof TRACK_PROGRESS)[number];
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
var track = props.track;
|
||||
var subLabel = track.lines ? 'OneOS · BI' : track.phases ? track.planned + ' 项需求' : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={props.active}
|
||||
aria-controls={'wr-track-panel-' + track.key}
|
||||
id={'wr-track-tab-' + track.key}
|
||||
className={'wr-track-tab' + (props.active ? ' wr-track-tab--active' : '')}
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<span className="wr-track-tab__icon" aria-hidden>{TRACK_ICON[track.key]}</span>
|
||||
<span className="wr-track-tab__body">
|
||||
<span className="wr-track-tab__title">{track.label}</span>
|
||||
<span className="wr-track-tab__stats tabular-nums">
|
||||
计划 {track.planned} · 完成 {track.done} · 阻塞 {track.blocked}
|
||||
</span>
|
||||
{subLabel ? <span className="wr-track-tab__hint">{subLabel}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackDetailPanel(props: { track: (typeof TRACK_PROGRESS)[number] }) {
|
||||
var track = props.track;
|
||||
if (track.phases) {
|
||||
return <PhaseModuleCards phases={track.phases} />;
|
||||
}
|
||||
return (
|
||||
<div className="wr-track-detail">
|
||||
<div className="wr-track-detail__head">
|
||||
<h3 className="wr-track-detail__title">{track.label} · 详细进展</h3>
|
||||
<ProgressBar value={track.progress} label={track.label + '完成度'} />
|
||||
</div>
|
||||
{track.lines ? (
|
||||
<div className="wr-track-lines">
|
||||
{track.lines.map(function (line) {
|
||||
return (
|
||||
<div key={line.key} className="wr-track-line">
|
||||
<h4 className="wr-track-line__title">{line.label}</h4>
|
||||
<ProgressBar value={line.progress} label={line.label + '完成度'} />
|
||||
<ul className="wr-track-stats">
|
||||
<li><span>计划</span><strong className="tabular-nums">{line.planned}</strong></li>
|
||||
<li><span>完成</span><strong className="tabular-nums">{line.done}</strong></li>
|
||||
<li><span>阻塞</span><strong className={'tabular-nums' + (line.blocked > 0 ? ' wr-text-warning' : '')}>{line.blocked}</strong></li>
|
||||
</ul>
|
||||
<p className="wr-track-card__highlight">{line.highlight}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="wr-track-stats">
|
||||
<li><span>计划</span><strong className="tabular-nums">{track.planned}</strong></li>
|
||||
<li><span>完成</span><strong className="tabular-nums">{track.done}</strong></li>
|
||||
<li><span>阻塞</span><strong className={'tabular-nums' + (track.blocked > 0 ? ' wr-text-warning' : '')}>{track.blocked}</strong></li>
|
||||
</ul>
|
||||
{track.highlight ? <p className="wr-track-card__highlight">{track.highlight}</p> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyIssueCard(props: { issue: WeeklyIssue }) {
|
||||
var issue = props.issue;
|
||||
return (
|
||||
<article className={'wr-issue-card wr-issue-card--' + issue.priority.toLowerCase()}>
|
||||
<header className="wr-issue-card__head">
|
||||
<div className="wr-issue-card__tags">
|
||||
<span className={'wr-badge wr-badge--priority-' + issue.priority.toLowerCase()}>{issue.priority}</span>
|
||||
<span className={'wr-badge ' + STATUS_TONE[issue.status]}>{STATUS_LABEL[issue.status]}</span>
|
||||
<span className="wr-badge wr-badge--track">{issue.track === 'product' ? '产品' : issue.track === 'dev' ? '开发' : '测试'}</span>
|
||||
</div>
|
||||
<h3 className="wr-issue-card__title">{issue.title}</h3>
|
||||
<p className="wr-issue-card__meta">
|
||||
<span>{issue.module}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{issue.owner}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>更新 {issue.updatedAt}</span>
|
||||
</p>
|
||||
</header>
|
||||
<FourRGrid fourR={issue.fourR} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WeeklyReportPage() {
|
||||
var minorOpenState = useState(false);
|
||||
var minorOpen = minorOpenState[0];
|
||||
var setMinorOpen = minorOpenState[1];
|
||||
|
||||
var activeTrackState = useState<TrackKey>('product');
|
||||
var activeTrack = activeTrackState[0];
|
||||
var setActiveTrack = activeTrackState[1];
|
||||
|
||||
var activeTrackData = useMemo(function () {
|
||||
return TRACK_PROGRESS.find(function (t) { return t.key === activeTrack; }) || TRACK_PROGRESS[0];
|
||||
}, [activeTrack]);
|
||||
|
||||
return (
|
||||
<div className="wr-page">
|
||||
<header className="wr-hero">
|
||||
<div className="wr-hero__main">
|
||||
<p className="wr-hero__eyebrow">{REPORT_META.department} · OneOS</p>
|
||||
<h1 className="wr-hero__title">每周工作汇报</h1>
|
||||
<p className="wr-hero__sub">
|
||||
{REPORT_META.weekLabel}
|
||||
<span aria-hidden> · </span>
|
||||
{REPORT_META.weekRange}
|
||||
</p>
|
||||
</div>
|
||||
<div className="wr-hero__aside">
|
||||
<div className="wr-hero__meta">
|
||||
<span>汇报:{REPORT_META.reporter}</span>
|
||||
<span>生成:{REPORT_META.reportDate}</span>
|
||||
</div>
|
||||
<button type="button" className="wr-btn wr-btn--primary" onClick={function () { window.print(); }}>
|
||||
<Download size={16} strokeWidth={2.25} aria-hidden />
|
||||
导出 PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="wr-kpi-row wr-kpi-row--exec" aria-label="关键指标">
|
||||
{EXECUTIVE_KPIS.map(function (item) {
|
||||
return <ExecutiveKpiCard key={item.key} kpi={item} />;
|
||||
})}
|
||||
</section>
|
||||
|
||||
<ExecutiveSummarySection />
|
||||
|
||||
<section className="wr-section" aria-labelledby="wr-track-title">
|
||||
<div className="wr-section__head">
|
||||
<h2 id="wr-track-title" className="wr-section__title">各条线进度</h2>
|
||||
</div>
|
||||
<div className="wr-track-switcher" role="tablist" aria-label="产品、开发、测试进度切换">
|
||||
{TRACK_PROGRESS.map(function (track) {
|
||||
return (
|
||||
<TrackSummaryCard
|
||||
key={track.key}
|
||||
track={track}
|
||||
active={activeTrack === track.key}
|
||||
onSelect={function () { setActiveTrack(track.key); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={'wr-track-panel-' + activeTrack}
|
||||
aria-labelledby={'wr-track-tab-' + activeTrack}
|
||||
className={'wr-track-detail-wrap' + (activeTrackData.phases ? ' wr-track-detail-wrap--flat' : '')}
|
||||
>
|
||||
<TrackDetailPanel track={activeTrackData} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="wr-section" aria-labelledby="wr-exec-decision-title">
|
||||
<div className="wr-section__head">
|
||||
<h2 id="wr-exec-decision-title" className="wr-section__title">
|
||||
<Scale size={20} strokeWidth={2.25} aria-hidden className="wr-section__title-icon" />
|
||||
需董事长 / 高管决策
|
||||
</h2>
|
||||
<p className="wr-section__desc">需在本周期内明确方向或授权的事项,附建议方案与时限</p>
|
||||
</div>
|
||||
<div className="wr-exec-decision-list">
|
||||
{EXECUTIVE_DECISIONS.map(function (decision) {
|
||||
return <ExecutiveDecisionCard key={decision.id} decision={decision} />;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="wr-section" aria-labelledby="wr-exec-next-title">
|
||||
<div className="wr-section__head">
|
||||
<h2 id="wr-exec-next-title" className="wr-section__title">
|
||||
<CalendarClock size={20} strokeWidth={2.25} aria-hidden className="wr-section__title-icon" />
|
||||
下周工作重点与风险
|
||||
</h2>
|
||||
<p className="wr-section__desc">项目经理视角的下周执行焦点</p>
|
||||
</div>
|
||||
<NextWeekPanel />
|
||||
</section>
|
||||
|
||||
<section className="wr-section" aria-labelledby="wr-key-issues-title">
|
||||
<div className="wr-section__head">
|
||||
<h2 id="wr-key-issues-title" className="wr-section__title">重点问题(P0 / P1)</h2>
|
||||
</div>
|
||||
<div className="wr-issue-list">
|
||||
{KEY_ISSUES.map(function (issue) {
|
||||
return <KeyIssueCard key={issue.id} issue={issue} />;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="wr-section" aria-labelledby="wr-milestone-title">
|
||||
<div className="wr-section__head">
|
||||
<h2 id="wr-milestone-title" className="wr-section__title">里程碑进度</h2>
|
||||
<p className="wr-section__desc">OneOS 各模块关键节点与完成度</p>
|
||||
</div>
|
||||
<div className="wr-table-wrap">
|
||||
<table className="wr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">模块</th>
|
||||
<th scope="col">里程碑</th>
|
||||
<th scope="col">负责人</th>
|
||||
<th scope="col">计划完成</th>
|
||||
<th scope="col">实际完成</th>
|
||||
<th scope="col">进度</th>
|
||||
<th scope="col">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MILESTONES.map(function (row) {
|
||||
var st = MILESTONE_STATUS[row.status];
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>{row.module}</td>
|
||||
<td>{row.milestone}</td>
|
||||
<td>{row.owner}</td>
|
||||
<td className="tabular-nums">{row.planDate}</td>
|
||||
<td className="tabular-nums">{row.actualDate || '—'}</td>
|
||||
<td>
|
||||
<div className="wr-table-progress">
|
||||
<div className="wr-progress__track wr-progress__track--sm" aria-hidden>
|
||||
<div className="wr-progress__fill" style={{ width: row.progress + '%' }} />
|
||||
</div>
|
||||
<span className="tabular-nums">{row.progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={'wr-badge ' + st.tone}>{st.label}</span></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="wr-section wr-section--minor" aria-labelledby="wr-minor-title">
|
||||
<button
|
||||
type="button"
|
||||
className="wr-minor-toggle"
|
||||
aria-expanded={minorOpen}
|
||||
aria-controls="wr-minor-panel"
|
||||
onClick={function () { setMinorOpen(!minorOpen); }}
|
||||
>
|
||||
<div className="wr-section__head wr-section__head--inline">
|
||||
<h2 id="wr-minor-title" className="wr-section__title">次要问题(P2)</h2>
|
||||
<p className="wr-section__desc">共 {MINOR_ISSUES.length} 项 · 仅统计与简要列表,不展开四要素</p>
|
||||
</div>
|
||||
{minorOpen ? <ChevronUp size={22} strokeWidth={2.25} aria-hidden /> : <ChevronDown size={22} strokeWidth={2.25} aria-hidden />}
|
||||
</button>
|
||||
{minorOpen ? (
|
||||
<div id="wr-minor-panel" className="wr-table-wrap">
|
||||
<table className="wr-table wr-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">编号</th>
|
||||
<th scope="col">模块</th>
|
||||
<th scope="col">问题</th>
|
||||
<th scope="col">条线</th>
|
||||
<th scope="col">负责人</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MINOR_ISSUES.map(function (issue) {
|
||||
return (
|
||||
<tr key={issue.id}>
|
||||
<td className="tabular-nums">{issue.id}</td>
|
||||
<td>{issue.module}</td>
|
||||
<td>{issue.title}</td>
|
||||
<td>{issue.track === 'product' ? '产品' : issue.track === 'dev' ? '开发' : '测试'}</td>
|
||||
<td>{issue.owner}</td>
|
||||
<td><span className={'wr-badge ' + STATUS_TONE[issue.status]}>{STATUS_LABEL[issue.status]}</span></td>
|
||||
<td className="tabular-nums">{issue.updatedAt}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div id="wr-minor-panel" className="wr-minor-summary" aria-live="polite">
|
||||
{MINOR_ISSUES.map(function (issue) {
|
||||
return (
|
||||
<span key={issue.id} className="wr-minor-chip">
|
||||
{issue.module}:{issue.title.slice(0, 12)}{issue.title.length > 12 ? '…' : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="wr-footer">
|
||||
<p>OneOS 数智部 · 每周汇报 · 数据为原型演示,正式环境对接项目管理与缺陷平台</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/prototypes/oneos-weekly-report/index.tsx
Normal file
7
src/prototypes/oneos-weekly-report/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @name 数智部周报
|
||||
*/
|
||||
import './styles/weekly-report.css';
|
||||
import WeeklyReportPage from './WeeklyReportPage';
|
||||
|
||||
export default WeeklyReportPage;
|
||||
83
src/prototypes/oneos-weekly-report/styles/linear-theme.css
Normal file
83
src/prototypes/oneos-weekly-report/styles/linear-theme.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Linear design tokens — scoped to .wr-page only.
|
||||
* Source: src/themes/linear/DESIGN.md + assets/tokens.json
|
||||
*/
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
.wr-page {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Brand */
|
||||
--wr-ln-primary: #5e6ad2;
|
||||
--wr-ln-primary-hover: #828fff;
|
||||
--wr-ln-primary-focus: #5e69d1;
|
||||
--wr-ln-on-primary: #ffffff;
|
||||
--wr-ln-brand-secure: #7a7fad;
|
||||
|
||||
/* Surfaces */
|
||||
--wr-ln-canvas: #010102;
|
||||
--wr-ln-surface-1: #0f1011;
|
||||
--wr-ln-surface-2: #141516;
|
||||
--wr-ln-surface-3: #18191a;
|
||||
--wr-ln-surface-4: #191a1b;
|
||||
|
||||
/* Ink — bumped for readability on dark surfaces */
|
||||
--wr-ln-ink: #f7f8f8;
|
||||
--wr-ln-ink-muted: #dce1e8;
|
||||
--wr-ln-ink-subtle: #adb3bf;
|
||||
--wr-ln-ink-tertiary: #7a808c;
|
||||
|
||||
/* Hairlines — slightly stronger separation */
|
||||
--wr-ln-hairline: #2c2f36;
|
||||
--wr-ln-hairline-strong: #3f434c;
|
||||
--wr-ln-hairline-tertiary: #4a4e58;
|
||||
|
||||
/* Semantic */
|
||||
--wr-ln-success: #3ecf5f;
|
||||
--wr-ln-warning: #f0b45a;
|
||||
--wr-ln-error: #ff7b82;
|
||||
--wr-ln-icon: #9499e8;
|
||||
--wr-ln-icon-active: #a8afff;
|
||||
|
||||
/* Radius */
|
||||
--wr-ln-radius-xs: 4px;
|
||||
--wr-ln-radius-sm: 6px;
|
||||
--wr-ln-radius-md: 8px;
|
||||
--wr-ln-radius-lg: 12px;
|
||||
--wr-ln-radius-xl: 16px;
|
||||
--wr-ln-radius-pill: 9999px;
|
||||
|
||||
/* Typography */
|
||||
--wr-ln-font: Inter, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--wr-ln-font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
|
||||
--wr-ln-font-size-base: 14px;
|
||||
|
||||
/* Component aliases (weekly report) */
|
||||
--wr-ln-font-family: var(--wr-ln-font);
|
||||
--wr-ln-color-primary: var(--wr-ln-primary);
|
||||
--wr-ln-color-primary-hover: var(--wr-ln-primary-hover);
|
||||
--wr-ln-color-primary-light: rgba(94, 106, 210, 0.2);
|
||||
--wr-ln-color-primary-border: rgba(130, 143, 255, 0.45);
|
||||
--wr-ln-color-primary-shadow: rgba(94, 106, 210, 0.45);
|
||||
--wr-ln-color-text: var(--wr-ln-ink);
|
||||
--wr-ln-color-text-muted: var(--wr-ln-ink-subtle);
|
||||
--wr-ln-color-text-secondary: var(--wr-ln-ink-muted);
|
||||
--wr-ln-color-icon: var(--wr-ln-icon);
|
||||
--wr-ln-color-icon-active: var(--wr-ln-icon-active);
|
||||
--wr-ln-color-bg-page: var(--wr-ln-canvas);
|
||||
--wr-ln-color-bg-card: var(--wr-ln-surface-1);
|
||||
--wr-ln-color-bg-muted: var(--wr-ln-surface-2);
|
||||
--wr-ln-color-bg-hover: var(--wr-ln-surface-3);
|
||||
--wr-ln-color-bg-page-alt: var(--wr-ln-surface-2);
|
||||
--wr-ln-color-border: var(--wr-ln-hairline);
|
||||
--wr-ln-color-border-light: var(--wr-ln-hairline);
|
||||
--wr-ln-color-border-strong: var(--wr-ln-hairline-strong);
|
||||
--wr-ln-color-success: var(--wr-ln-success);
|
||||
--wr-ln-color-success-bg: rgba(62, 207, 95, 0.18);
|
||||
--wr-ln-color-success-text: #84eb9a;
|
||||
--wr-ln-color-warning: var(--wr-ln-warning);
|
||||
--wr-ln-color-error: var(--wr-ln-error);
|
||||
--wr-ln-color-error-bg: rgba(255, 99, 105, 0.14);
|
||||
--wr-ln-color-error-border: rgba(255, 99, 105, 0.35);
|
||||
--wr-ln-color-info: var(--wr-ln-primary);
|
||||
}
|
||||
1397
src/prototypes/oneos-weekly-report/styles/weekly-report.css
Normal file
1397
src/prototypes/oneos-weekly-report/styles/weekly-report.css
Normal file
File diff suppressed because it is too large
Load Diff
707
src/prototypes/oneos-weekly-report/weekly-report-data.ts
Normal file
707
src/prototypes/oneos-weekly-report/weekly-report-data.ts
Normal file
@@ -0,0 +1,707 @@
|
||||
export type IssuePriority = 'P0' | 'P1' | 'P2';
|
||||
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'pending_feedback';
|
||||
export type TrackKey = 'product' | 'dev' | 'qa';
|
||||
|
||||
export interface FourRBlock {
|
||||
reason: string;
|
||||
measure: string;
|
||||
result: string;
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
export interface WeeklyIssue {
|
||||
id: string;
|
||||
priority: IssuePriority;
|
||||
status: IssueStatus;
|
||||
track: TrackKey;
|
||||
module: string;
|
||||
title: string;
|
||||
owner: string;
|
||||
updatedAt: string;
|
||||
fourR: FourRBlock;
|
||||
}
|
||||
|
||||
export interface ProductRequirementItem {
|
||||
id: string;
|
||||
priority: IssuePriority | 'V1.0';
|
||||
title: string;
|
||||
note?: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface ProductRequirementGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
items: ProductRequirementItem[];
|
||||
}
|
||||
|
||||
export interface TrackLine {
|
||||
key: string;
|
||||
label: string;
|
||||
progress: number;
|
||||
planned: number;
|
||||
done: number;
|
||||
blocked: number;
|
||||
highlight: string;
|
||||
}
|
||||
|
||||
export interface PhaseWorkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
done: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export type RequirementStage = '原型设计' | '需求确认' | '需求跟踪' | '需求规划' | '无需求开发';
|
||||
export type RequirementPriority = '高优先级' | '中优先级' | '低优先级';
|
||||
|
||||
export interface RequirementItem {
|
||||
id: string;
|
||||
stage: RequirementStage;
|
||||
priority: RequirementPriority;
|
||||
title: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface RequirementSection {
|
||||
title: string;
|
||||
items: RequirementItem[];
|
||||
}
|
||||
|
||||
export interface PhaseTab {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
done: number;
|
||||
summary?: string;
|
||||
points?: string[];
|
||||
workReport?: PhaseWorkItem[];
|
||||
miniProgramWorkReport?: PhaseWorkItem[];
|
||||
miniProgramEmptyText?: string;
|
||||
emptyText?: string;
|
||||
requirementSections?: RequirementSection[];
|
||||
}
|
||||
|
||||
export interface TrackPhase {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
done: number;
|
||||
note?: string;
|
||||
tabs?: PhaseTab[];
|
||||
workReportTitle?: string;
|
||||
}
|
||||
|
||||
export interface TrackProgress {
|
||||
key: TrackKey;
|
||||
label: string;
|
||||
progress: number;
|
||||
planned: number;
|
||||
done: number;
|
||||
blocked: number;
|
||||
highlight?: string;
|
||||
lines?: TrackLine[];
|
||||
requirementGroups?: ProductRequirementGroup[];
|
||||
phases?: TrackPhase[];
|
||||
}
|
||||
|
||||
export interface MilestoneRow {
|
||||
id: string;
|
||||
module: string;
|
||||
milestone: string;
|
||||
owner: string;
|
||||
planDate: string;
|
||||
actualDate: string | null;
|
||||
progress: number;
|
||||
status: 'on_track' | 'at_risk' | 'done' | 'delayed';
|
||||
}
|
||||
|
||||
export interface WeeklyReportMeta {
|
||||
weekLabel: string;
|
||||
weekRange: string;
|
||||
reportDate: string;
|
||||
reporter: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
export const REPORT_META: WeeklyReportMeta = {
|
||||
weekLabel: '2026 年第 25 周',
|
||||
weekRange: '2026-06-16 ~ 2026-06-22',
|
||||
reportDate: '2026-06-23',
|
||||
reporter: '数智部 · 产品组',
|
||||
department: '数智部',
|
||||
};
|
||||
|
||||
export const TRACK_PROGRESS: TrackProgress[] = [
|
||||
{
|
||||
key: 'product',
|
||||
label: '产品',
|
||||
progress: 39,
|
||||
planned: 18,
|
||||
done: 7,
|
||||
blocked: 0,
|
||||
phases: [
|
||||
{
|
||||
key: 'plan',
|
||||
label: '规划',
|
||||
count: 5,
|
||||
done: 1,
|
||||
workReportTitle: '版本规划',
|
||||
tabs: [
|
||||
{
|
||||
key: 'v12',
|
||||
label: 'V1.2',
|
||||
count: 2,
|
||||
done: 0,
|
||||
workReport: [
|
||||
{ id: 'WR-P-V12-1', title: 'V1.2版本需求管理矩阵', done: false },
|
||||
{ id: 'WR-P-V12-2', title: 'V1.2版本WBS', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'v13',
|
||||
label: 'V1.3',
|
||||
count: 4,
|
||||
done: 0,
|
||||
workReport: [
|
||||
{ id: 'WR-P-V13-P1', title: '资产链条全闭环', done: false },
|
||||
{ id: 'WR-P-V13-P2', title: '合同合规性改造', done: false },
|
||||
{ id: 'WR-P-V13-P3', title: '业财采运侧统计分析上线', done: false },
|
||||
{ id: 'WR-P-V13-P4', title: '移动端商城发布', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'v14',
|
||||
label: 'V1.4',
|
||||
count: 3,
|
||||
done: 0,
|
||||
workReport: [
|
||||
{ id: 'WR-P-V14-P1', title: '移动化', done: false },
|
||||
{ id: 'WR-P-V14-P2', title: '外部用户业务引入', done: false },
|
||||
{ id: 'WR-P-V14-P3', title: '业财一体化打通', done: false },
|
||||
{ id: 'WR-P-V14-1', title: '在线商城需求预研', done: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'interview',
|
||||
label: '访谈',
|
||||
count: 4,
|
||||
done: 2,
|
||||
workReportTitle: '访谈结果',
|
||||
tabs: [
|
||||
{
|
||||
key: 'biz',
|
||||
label: '业务',
|
||||
count: 1,
|
||||
done: 0,
|
||||
workReport: [
|
||||
{ id: 'WR-I-BIZ-1', title: '部分业务部合同代提审人员权限确认', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ops-mgmt',
|
||||
label: '业管',
|
||||
count: 3,
|
||||
done: 2,
|
||||
workReport: [
|
||||
{ id: 'WR-I-OM-1', title: '租赁、自营台账需求逻辑确认和演示,进度反馈', done: true },
|
||||
{ id: 'WR-I-OM-3', title: '在线合同部分业务逻辑确认和优化方向讨论', done: true },
|
||||
{ id: 'WR-I-OM-4', title: '提车应收款的生成逻辑讨论和确认', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'procurement',
|
||||
label: '采购',
|
||||
count: 1,
|
||||
done: 1,
|
||||
workReport: [
|
||||
{ id: 'WR-I-PR-2', title: '保险采购功能使用培训,用户实操反馈确认,优化意见采集', done: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'maint',
|
||||
label: '运维',
|
||||
count: 1,
|
||||
done: 0,
|
||||
workReport: [
|
||||
{ id: 'WR-I-MT-1', title: '年审/保养台账原型演示和确认', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
label: '财务',
|
||||
count: 0,
|
||||
done: 0,
|
||||
workReport: [],
|
||||
emptyText: '无',
|
||||
},
|
||||
{
|
||||
key: 'legal',
|
||||
label: '法务',
|
||||
count: 3,
|
||||
done: 3,
|
||||
workReport: [
|
||||
{ id: 'WR-I-LG-1', title: '标准合同不同车型条款逻辑确认', done: true },
|
||||
{ id: 'WR-I-LG-2', title: '标准合同里程约束和操作逻辑确认', done: true },
|
||||
{ id: 'WR-I-LG-3', title: '标准合同多车型操作逻辑确认', done: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ceo',
|
||||
label: 'CEO',
|
||||
count: 1,
|
||||
done: 0,
|
||||
summary: 'V1.0-V1.4 版本基调与 WBS 汇报',
|
||||
workReport: [{ id: 'WR-I-CEO-1', title: '版本基调口头对齐', done: true, note: 'WBS 文档整理中' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'prototype',
|
||||
label: '需求',
|
||||
count: 20,
|
||||
done: 8,
|
||||
tabs: [
|
||||
{
|
||||
key: 'new-feature',
|
||||
label: '新增功能',
|
||||
count: 16,
|
||||
done: 6,
|
||||
requirementSections: [
|
||||
{
|
||||
title: 'PC',
|
||||
items: [
|
||||
{ id: 'WR-NF-W-1', stage: '原型设计', priority: '高优先级', title: '标准合同管理功能原型设计', progress: 90 },
|
||||
{ id: 'WR-NF-W-2', stage: '原型设计', priority: '高优先级', title: '新合同体系功能原型设计', progress: 70 },
|
||||
{ id: 'WR-NF-W-3', stage: '需求确认', priority: '高优先级', title: '客户评级体系功能及原型设计,有方案待与法务确认', progress: 25 },
|
||||
{ id: 'WR-NF-W-4', stage: '原型设计', priority: '高优先级', title: '维修保养台账功能,待确认', progress: 100 },
|
||||
{ id: 'WR-NF-W-5', stage: '原型设计', priority: '中优先级', title: '还车应结款运维处理人转交功能,未开始', progress: 0 },
|
||||
{ id: 'WR-NF-W-6', stage: '原型设计', priority: '高优先级', title: '合同里程减免功能,合并到新合同体系,现版本考虑手工', progress: 30 },
|
||||
{ id: 'WR-NF-W-7', stage: '原型设计', priority: '中优先级', title: '三方退租功能,择期评审', progress: 100 },
|
||||
{ id: 'WR-NF-W-8', stage: '需求跟踪', priority: '高优先级', title: '保险采购功能,已上线,部分体验优化已提', progress: 100 },
|
||||
{ id: 'WR-NF-W-9', stage: '需求跟踪', priority: '高优先级', title: '加氢站管理功能,已评审开发中', progress: 50 },
|
||||
{ id: 'WR-NF-W-10', stage: '原型设计', priority: '中优先级', title: '加氢站登录和加氢订单记录功能,等待加氢站管理上线推进', progress: 100 },
|
||||
{ id: 'WR-NF-W-11', stage: '原型设计', priority: '高优先级', title: '租赁台账功能,部分细节需求需要进一步确认', progress: 95 },
|
||||
{ id: 'WR-NF-W-12', stage: '原型设计', priority: '高优先级', title: '租赁业务盈亏表', progress: 80 },
|
||||
{ id: 'WR-NF-W-13', stage: '原型设计', priority: '高优先级', title: '自营业务盈亏表', progress: 40 },
|
||||
{ id: 'WR-NF-W-14', stage: '原型设计', priority: '中优先级', title: '总体业务盈亏表', progress: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '小程序',
|
||||
items: [
|
||||
{ id: 'WR-NF-M-1', stage: '需求跟踪', priority: '中优先级', title: '审批中心「提车应收款、还车应结款」功能,测试发现部分问题,开发重新修改', progress: 65 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '小羚羚',
|
||||
items: [
|
||||
{ id: 'WR-NF-X-1', stage: '需求规划', priority: '高优先级', title: '外部用户注册登录、在线商城,依赖web新合同功能,有完整规划', progress: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'optimize',
|
||||
label: '功能优化',
|
||||
count: 5,
|
||||
done: 1,
|
||||
requirementSections: [
|
||||
{
|
||||
title: 'PC',
|
||||
items: [
|
||||
{ id: 'WR-OP-W-1', stage: '需求确认', priority: '中优先级', title: '提车应收款功能、UI、交互全面优化,有方案待与业管确认', progress: 30 },
|
||||
{ id: 'WR-OP-W-2', stage: '原型设计', priority: '中优先级', title: '车辆氢费明细抹零、客户核对功能', progress: 0 },
|
||||
{ id: 'WR-OP-W-3', stage: '需求确认', priority: '低优先级', title: '运维特殊权限体系设计,待确认', progress: 20 },
|
||||
{ id: 'WR-OP-W-4', stage: '无需求开发', priority: '高优先级', title: '合同续签/转正式/转三方/新增车辆等逻辑修复规则确认,开发中', progress: 60 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '小程序',
|
||||
items: [
|
||||
{ id: 'WR-OP-M-1', stage: '需求跟踪', priority: '中优先级', title: '交车管理功能优化,测试中', progress: 85 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
label: '评审',
|
||||
count: 4,
|
||||
done: 2,
|
||||
workReportTitle: '评审结果',
|
||||
tabs: [
|
||||
{
|
||||
key: 'reviewed',
|
||||
label: '已评审',
|
||||
count: 2,
|
||||
done: 2,
|
||||
workReport: [
|
||||
{ id: 'WR-R-DONE-1', title: '「加氢站管理」站点管理/充值单/对账单/余额/营业状况/成本价维护评审完成', done: true },
|
||||
{ id: 'WR-R-DONE-2', title: '「租赁合同」续签/转正式/转三方/新增车辆功能缺失逻辑评审确认', done: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待评审',
|
||||
count: 2,
|
||||
done: 0,
|
||||
workReport: [
|
||||
{ id: 'WR-R-PND-1', title: '「租赁台账」功能需求评审,待部分细节和实现逻辑与业管确认后评审', done: false },
|
||||
{ id: 'WR-R-PND-2', title: '「自营台账」功能需求评审,部分依赖模块需求完善后评审,下周一评审', done: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'dev',
|
||||
label: '开发',
|
||||
progress: 72,
|
||||
planned: 11,
|
||||
done: 8,
|
||||
blocked: 2,
|
||||
lines: [
|
||||
{
|
||||
key: 'oneos',
|
||||
label: 'OneOS',
|
||||
progress: 78,
|
||||
planned: 7,
|
||||
done: 6,
|
||||
blocked: 1,
|
||||
highlight: '车辆管理联调完成 85%;保险采购模块已迁入 oneos1.2 原型库',
|
||||
},
|
||||
{
|
||||
key: 'bi',
|
||||
label: 'BI',
|
||||
progress: 64,
|
||||
planned: 4,
|
||||
done: 2,
|
||||
blocked: 1,
|
||||
highlight: '经营看板 v2 接口对接中;车辆油耗报表 SQL 优化待 DBA 评审',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'qa',
|
||||
label: '测试',
|
||||
progress: 81,
|
||||
planned: 8,
|
||||
done: 6,
|
||||
blocked: 0,
|
||||
highlight: '租赁合同创建页 E2E 用例 12/15 通过;性能基线回归无新增 P0',
|
||||
},
|
||||
];
|
||||
|
||||
export const KEY_ISSUES: WeeklyIssue[] = [
|
||||
{
|
||||
id: 'ISS-001',
|
||||
priority: 'P0',
|
||||
status: 'in_progress',
|
||||
track: 'dev',
|
||||
module: '租赁合同管理',
|
||||
title: '新增页左侧多卡片布局在窄屏下偶发错位',
|
||||
owner: '前端 · 王磊',
|
||||
updatedAt: '2026-06-21',
|
||||
fourR: {
|
||||
reason: 'Ant Design 5 Select 与 DatePicker 默认高度不一致,叠加 flex 嵌套导致子卡片被压缩为 0 高度。',
|
||||
measure: '统一 36px 控件高度链;左侧列改为纵向滚动 + 各卡片 height:auto;补充交付区与表格列专项样式。',
|
||||
result: '桌面端三卡片布局已对齐;表格内租金/保证金输入框与车牌号选择器高度一致。移动端仍需 992px 以下回归。',
|
||||
feedback: '待总监确认:是否接受移动端整页纵向滚动方案(不再左右分栏固定高度)。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ISS-002',
|
||||
priority: 'P0',
|
||||
status: 'pending_feedback',
|
||||
track: 'qa',
|
||||
module: '车辆管理',
|
||||
title: '保险状态与保险采购台账偶发不一致',
|
||||
owner: '测试 · 陈悦',
|
||||
updatedAt: '2026-06-20',
|
||||
fourR: {
|
||||
reason: '车辆列表取 insurance-purchases.json 最新一条,详情页缓存未刷新;localStorage 脏数据未隔离。',
|
||||
measure: '统一 insurance.ts 聚合逻辑;详情 Tab 进入时强制 re-fetch;测试环境增加 storage 前缀隔离。',
|
||||
result: '复现路径已稳定;修复 PR #428 合并 staging,回归 48 台样例车全部一致。',
|
||||
feedback: '请总监确认:正式环境是否允许用户手动清缓存,或仅后台运维可触发全量重算。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ISS-003',
|
||||
priority: 'P1',
|
||||
status: 'resolved',
|
||||
track: 'product',
|
||||
module: '保险采购',
|
||||
title: '比价单与保单台账业务边界需书面确认',
|
||||
owner: '产品 · 李敏',
|
||||
updatedAt: '2026-06-19',
|
||||
fourR: {
|
||||
reason: '业务方对「比价单审批中是否占用车辆险种」理解不一致,影响 KPI 统计口径。',
|
||||
measure: '输出双业务线流程图 + 状态机说明;与法务、运营联合评审 1 次。',
|
||||
result: '已发布《保险采购模块方案书》PDF v1.2;原型按「不关联」实现获业务签字。',
|
||||
feedback: '总监已批示「按方案书执行」,无追加意见。',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const MINOR_ISSUES: WeeklyIssue[] = [
|
||||
{
|
||||
id: 'ISS-101',
|
||||
priority: 'P2',
|
||||
status: 'open',
|
||||
track: 'dev',
|
||||
module: '合同模板',
|
||||
title: '预览分页与编辑区分页测量偶差 1px',
|
||||
owner: '王磊',
|
||||
updatedAt: '2026-06-18',
|
||||
fourR: { reason: '—', measure: '—', result: '—', feedback: '—' },
|
||||
},
|
||||
{
|
||||
id: 'ISS-102',
|
||||
priority: 'P2',
|
||||
status: 'in_progress',
|
||||
track: 'qa',
|
||||
module: '全局',
|
||||
title: 'Safari 下 DatePicker 焦点环被裁剪',
|
||||
owner: '陈悦',
|
||||
updatedAt: '2026-06-17',
|
||||
fourR: { reason: '—', measure: '—', result: '—', feedback: '—' },
|
||||
},
|
||||
{
|
||||
id: 'ISS-103',
|
||||
priority: 'P2',
|
||||
status: 'resolved',
|
||||
track: 'product',
|
||||
module: '车辆管理',
|
||||
title: 'PRD 导出文件名编码在 Windows 乱码',
|
||||
owner: '李敏',
|
||||
updatedAt: '2026-06-16',
|
||||
fourR: { reason: '—', measure: '—', result: '—', feedback: '—' },
|
||||
},
|
||||
{
|
||||
id: 'ISS-104',
|
||||
priority: 'P2',
|
||||
status: 'open',
|
||||
track: 'dev',
|
||||
module: '租赁订单',
|
||||
title: '服务项 Modal 在 1280px 下横向滚动条样式不统一',
|
||||
owner: '赵航',
|
||||
updatedAt: '2026-06-15',
|
||||
fourR: { reason: '—', measure: '—', result: '—', feedback: '—' },
|
||||
},
|
||||
{
|
||||
id: 'ISS-105',
|
||||
priority: 'P2',
|
||||
status: 'in_progress',
|
||||
track: 'qa',
|
||||
module: '保险采购',
|
||||
title: 'OCR 识别任务 mock 进度条动画在 CI 不稳定',
|
||||
owner: '陈悦',
|
||||
updatedAt: '2026-06-14',
|
||||
fourR: { reason: '—', measure: '—', result: '—', feedback: '—' },
|
||||
},
|
||||
];
|
||||
|
||||
export const MILESTONES: MilestoneRow[] = [
|
||||
{
|
||||
id: 'MS-1',
|
||||
module: '租赁合同管理',
|
||||
milestone: '新增合同 · 主体/订单/委托书分卡',
|
||||
owner: '王磊',
|
||||
planDate: '2026-06-20',
|
||||
actualDate: '2026-06-21',
|
||||
progress: 100,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: 'MS-2',
|
||||
module: '保险采购',
|
||||
milestone: '原型迁入 oneos1.2 + 构建通过',
|
||||
owner: '赵航',
|
||||
planDate: '2026-06-22',
|
||||
actualDate: '2026-06-22',
|
||||
progress: 100,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: 'MS-3',
|
||||
module: '车辆管理',
|
||||
milestone: '保险跳转保险采购联调',
|
||||
owner: '王磊',
|
||||
planDate: '2026-06-25',
|
||||
actualDate: null,
|
||||
progress: 65,
|
||||
status: 'at_risk',
|
||||
},
|
||||
{
|
||||
id: 'MS-4',
|
||||
module: '合同模板 V26.6',
|
||||
milestone: '预览与编辑分页一致性',
|
||||
owner: '王磊',
|
||||
planDate: '2026-06-28',
|
||||
actualDate: null,
|
||||
progress: 40,
|
||||
status: 'on_track',
|
||||
},
|
||||
];
|
||||
|
||||
export function countIssuesByPriority(issues: WeeklyIssue[], priority: IssuePriority) {
|
||||
return issues.filter((i) => i.priority === priority).length;
|
||||
}
|
||||
|
||||
export function countIssuesByStatus(issues: WeeklyIssue[], status: IssueStatus) {
|
||||
return issues.filter((i) => i.status === status).length;
|
||||
}
|
||||
|
||||
export type ExecutiveHealth = 'green' | 'amber' | 'red';
|
||||
|
||||
export interface ExecutiveKpi {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
tone?: 'default' | 'primary' | 'warning' | 'success' | 'danger';
|
||||
}
|
||||
|
||||
export interface ExecutiveSummary {
|
||||
health: ExecutiveHealth;
|
||||
healthLabel: string;
|
||||
headline: string;
|
||||
achievements: string[];
|
||||
risks: string[];
|
||||
}
|
||||
|
||||
export interface ExecutiveDecision {
|
||||
id: string;
|
||||
title: string;
|
||||
impact: string;
|
||||
recommendation: string;
|
||||
deadline: string;
|
||||
urgency: 'high' | 'medium';
|
||||
}
|
||||
|
||||
export interface NextWeekItem {
|
||||
id: string;
|
||||
type: 'goal' | 'risk';
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const EXECUTIVE_KPIS: ExecutiveKpi[] = [
|
||||
{
|
||||
key: 'v12-progress',
|
||||
label: 'V1.2 版本整体进度',
|
||||
value: '64%',
|
||||
hint: '较上周 +4%',
|
||||
tone: 'primary',
|
||||
},
|
||||
{
|
||||
key: 'milestone',
|
||||
label: '里程碑按时达成',
|
||||
value: '2 / 4',
|
||||
hint: '本季节点 50%',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'delivery',
|
||||
label: '本周价值交付',
|
||||
value: '5 项',
|
||||
hint: '上线 1 · 评审 4',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
key: 'decision',
|
||||
label: '待高层决策',
|
||||
value: '2 项',
|
||||
hint: '需在本周内闭环',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'blocker',
|
||||
label: '阻塞/高风险',
|
||||
value: '2 项',
|
||||
hint: '开发阻塞 2',
|
||||
tone: 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
export const EXECUTIVE_SUMMARY: ExecutiveSummary = {
|
||||
health: 'amber',
|
||||
healthLabel: '总体可控 · 局部需加速',
|
||||
headline:
|
||||
'本周 OneOS V1.2 建设按节奏推进:标准合同与新合同体系原型接近收尾,保险采购已上线运行,加氢站管理与租赁合同关键逻辑完成评审。租赁/自营台账进入评审前最后一轮需求确认,提车应收款口径仍待跨部门拍板。开发联调整体正常,车辆管理与合同模板各 1 项技术阻塞需资源协调。',
|
||||
achievements: [
|
||||
'保险采购功能正式上线,业管完成使用培训并收集优化意见',
|
||||
'加氢站管理、租赁合同续签/转正式等 4 项需求完成评审或规则确认',
|
||||
'跨部门访谈覆盖业务、业管、采购、法务等 7 条战线,需求矩阵与 WBS 框架明确',
|
||||
],
|
||||
risks: [
|
||||
'租赁台账、自营台账评审时点依赖业管细节确认,存在挤压 V1.2 里程碑窗口风险',
|
||||
'提车应收款生成逻辑未最终确认,可能影响审批中心小程序联调与财务对账口径',
|
||||
],
|
||||
};
|
||||
|
||||
export const EXECUTIVE_DECISIONS: ExecutiveDecision[] = [
|
||||
{
|
||||
id: 'DEC-1',
|
||||
title: '租赁/自营台账正式评审排期',
|
||||
impact: '决定 V1.2 资产链条与台账能力能否按期进入开发与 UAT',
|
||||
recommendation: '建议责成业管本周内完成细节确认,下周一启动自营台账评审,租赁台账紧随其后',
|
||||
deadline: '2026-06-27',
|
||||
urgency: 'high',
|
||||
},
|
||||
{
|
||||
id: 'DEC-2',
|
||||
title: '提车应收款生成逻辑与财务口径',
|
||||
impact: '影响审批中心小程序、财务对账及后续业财一体化数据一致性',
|
||||
recommendation: '建议由董事长协调业管与财务联合拍板,明确规则后 3 日内冻结需求',
|
||||
deadline: '2026-06-25',
|
||||
urgency: 'high',
|
||||
},
|
||||
{
|
||||
id: 'DEC-3',
|
||||
title: '合同里程减免 V1.2 实现策略',
|
||||
impact: '决定本期是否投入研发,或维持手工过渡方案',
|
||||
recommendation: '建议确认「合并至新合同体系、本期手工处理」策略,避免重复建设',
|
||||
deadline: '2026-06-30',
|
||||
urgency: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
export const NEXT_WEEK_PLAN: NextWeekItem[] = [
|
||||
{
|
||||
id: 'NW-1',
|
||||
type: 'goal',
|
||||
title: '自营台账需求评审',
|
||||
detail: '下周一组织正式评审,会前补齐依赖模块需求说明',
|
||||
},
|
||||
{
|
||||
id: 'NW-2',
|
||||
type: 'goal',
|
||||
title: 'V1.2 版本 WBS 与需求矩阵定稿',
|
||||
detail: '输出可执行 WBS,作为开发与测试排期基线',
|
||||
},
|
||||
{
|
||||
id: 'NW-3',
|
||||
type: 'goal',
|
||||
title: '车辆管理保险联调闭环',
|
||||
detail: '推动保险状态一致性修复上线,消除 P0 质量风险',
|
||||
},
|
||||
{
|
||||
id: 'NW-4',
|
||||
type: 'risk',
|
||||
title: 'BI 数据源迁移外部依赖',
|
||||
detail: '若数据平台排期延后,将直接影响 V1.2 统计分析上线节点',
|
||||
},
|
||||
{
|
||||
id: 'NW-5',
|
||||
type: 'risk',
|
||||
title: '开发人力被合同模板专项占用',
|
||||
detail: '分页一致性专项与租赁合同新增页问题并行,需评估是否临时增援',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,289 @@
|
||||
# 车辆管理 · 详情页产品需求说明(PRD)
|
||||
|
||||
> 本页为单车档案详情。标注「原型演示」的跨模块跳转仅 Toast 提示,不做真实路由。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 车辆管理 — 详情页(查看页) |
|
||||
| 入口 | 列表页点击车牌或「查看」 |
|
||||
| 核心任务 | 查看单车档案、证照、保险、租赁及各类业务记录,维护可编辑字段 |
|
||||
| 页面结构 | 顶栏返回 + 车辆概要 + 统计条 + Tab 内容区;右上角工具栏:「查看需求说明」「PRD文档MD文件下载」「下载测试用例」 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 页面框架
|
||||
|
||||
### 2.1 顶栏
|
||||
|
||||
| 元素 | 行为 |
|
||||
|---|---|
|
||||
| 返回列表 | 回到列表页,保留列表筛选/KPI/分页状态 |
|
||||
| 查看需求说明 | 打开详情页 PRD(本文档) |
|
||||
| PRD文档MD文件下载 | 下载列表页、详情页两份 Markdown PRD |
|
||||
| 下载测试用例 | 下载 Excel,含列表页与详情页测试用例两个 Sheet |
|
||||
|
||||
### 2.2 车辆概要区
|
||||
|
||||
- 车牌号、品牌·型号、VIN
|
||||
- 在线状态(在线/离线圆点)
|
||||
- 运营城市文案、GPS 时间
|
||||
|
||||
### 2.3 统计条(4 项)
|
||||
|
||||
| 字段 | 可编辑 | 行为 |
|
||||
|---|---|---|
|
||||
| 运营城市 | 仅来源为「人工」时可改 | 点击 → 运营城市修改弹窗;展示车机/GPS/人工来源标签 |
|
||||
| 里程数 | 否 | 在线时展示车机标签 |
|
||||
| 停放区域 | 否 | 只读 |
|
||||
| 运维负责人 | 有运维权限时可改 | 点击铅笔图标 → 运维负责人弹窗 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Tab 总览
|
||||
|
||||
| 序号 | Tab | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | 基本信息 | 档案、项目、证照摘要等,部分字段行内可编辑 |
|
||||
| 2 | 型号参数 | 车型参数与保养项目表 |
|
||||
| 3 | 证照信息 | 行驶证、营运证等分组展示与照片预览 |
|
||||
| 4 | 保险记录 | 险种概览卡片 + 采购历史表 |
|
||||
| 5 | 租赁记录 | 合同/交还车历史 |
|
||||
| 6 | 事故记录 | 筛选 + 事故列表 |
|
||||
| 7 | 故障记录 | 筛选 + 故障列表 |
|
||||
| 8 | 违章记录 | 筛选 + 违章列表 |
|
||||
| 9 | 异动记录 | 筛选 + 异动列表 |
|
||||
| 10 | 调拨记录 | 筛选 + 调拨列表 |
|
||||
| 11 | 年审记录 | 筛选 + 已完成年审历史(对齐年审管理历史列表) |
|
||||
|
||||
各业务记录 Tab 通用规则:
|
||||
|
||||
- 数据按当前车辆 `plateNo` 从对应 JSON 种子文件过滤
|
||||
- 筛选区:**查询**生效、**重置**清空,修改未查询不影响列表
|
||||
- 日期范围使用双月历弹层组件
|
||||
- 表格左对齐;金额/数字列使用等宽数字
|
||||
- 底栏:共 N 条 + 紧凑分页(默认 10 条/页)
|
||||
|
||||
---
|
||||
|
||||
## 4. 基本信息 Tab
|
||||
|
||||
### 4.1 档案信息
|
||||
|
||||
| 字段 | 可编辑 |
|
||||
|---|---|
|
||||
| 出厂年份 | 是(年份选择) |
|
||||
| 采购入库日期 | 是(日期选择) |
|
||||
| 运营状态、车辆来源、停车场等 | 只读展示 |
|
||||
|
||||
保存后 Toast「车辆信息已更新」,写回内存台账。
|
||||
|
||||
### 4.2 项目信息
|
||||
|
||||
租赁/自营车辆展示项目名称、合同编码、客户名称;否则显示「无」。
|
||||
|
||||
### 4.3 证照摘要
|
||||
|
||||
- 检验有效期:展示到期日与剩余/过期标签
|
||||
- 未上传时可点击 → Toast 跳转证照管理编辑(原型演示)
|
||||
|
||||
---
|
||||
|
||||
## 5. 型号参数 Tab
|
||||
|
||||
- 展示车辆型号技术参数(只读)
|
||||
- 保养项目表:养护项目、保养公里/时间周期、工时费、材料费、合计
|
||||
|
||||
---
|
||||
|
||||
## 6. 证照信息 Tab
|
||||
|
||||
- 分组:行驶证、道路运输证、上海牌专项证件等(按车牌规则显隐)
|
||||
- 每证展示:证件号、有效期、余额(如有)、照片网格
|
||||
- 照片可点击全屏预览;证件状态色点图例
|
||||
- 数据来自 `utils/vehicleLicenses` 与车辆台账关联
|
||||
|
||||
---
|
||||
|
||||
## 7. 保险记录 Tab
|
||||
|
||||
### 7.1 险种概览卡片
|
||||
|
||||
交强险、商业险、承运人责任险等;点击切换下方历史表。
|
||||
|
||||
- 未购买险种卡片可点击 → Toast 跳转保险采购(原型演示)
|
||||
- 展示状态 Tag 与到期剩余天数
|
||||
|
||||
### 7.2 采购历史表
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 保险导入时间、操作人、类型、保单号 | 只读 |
|
||||
| 保险状态 | Tag |
|
||||
| 保险公司、付款时间、到期日期 | 只读 |
|
||||
| 保单文件 | 有附件时「预览」「下载」可点击 → Toast 演示 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 租赁记录 Tab
|
||||
|
||||
### 8.1 筛选
|
||||
|
||||
合同编码、项目名称、客户名称、业务类型。
|
||||
|
||||
### 8.2 表格与跳转
|
||||
|
||||
| 列 | 可点击 | 跳转目标(原型 Toast) |
|
||||
|---|---|---|
|
||||
| 合同编码 | 是 | 租赁合同详情 |
|
||||
| 项目名称 | 有值时 | 租赁合同详情 |
|
||||
| 交车日期 | 有值时 | 交车管理详情 |
|
||||
| 还车日期 | 有值时 | 还车管理详情 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 事故记录 Tab
|
||||
|
||||
### 9.1 筛选
|
||||
|
||||
事故时间(日期范围)、客户名称、事故等级、事故状态。
|
||||
|
||||
### 9.2 表格
|
||||
|
||||
| 列 | 可点击 | 跳转 |
|
||||
|---|---|---|
|
||||
| 事故编码 | 是 | 事故管理详情页 |
|
||||
| 其余列 | 否 | — |
|
||||
|
||||
---
|
||||
|
||||
## 10. 故障记录 Tab
|
||||
|
||||
### 10.1 筛选
|
||||
|
||||
上报时间(日期范围)、故障编号、故障等级、故障类型、解决情况、AI 匹配状态。
|
||||
|
||||
### 10.2 表格
|
||||
|
||||
| 列/操作 | 可点击 | 行为 |
|
||||
|---|---|---|
|
||||
| 故障编号 | 是 | Toast 跳转故障管理详情 |
|
||||
| 操作「查看」 | 是 | 同上 |
|
||||
| 操作「编辑」 | 是 | Toast 编辑故障记录演示 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 违章记录 Tab
|
||||
|
||||
### 11.1 筛选
|
||||
|
||||
违法时间、客户名称、缴费状态、是否处理。
|
||||
|
||||
### 11.2 表格
|
||||
|
||||
| 列 | 可点击 | 跳转 |
|
||||
|---|---|---|
|
||||
| 违法行为 | 是 | 违章管理详情页(按违法时间标识) |
|
||||
| 其余列 | 否 | — |
|
||||
|
||||
---
|
||||
|
||||
## 12. 异动记录 Tab
|
||||
|
||||
### 12.1 筛选
|
||||
|
||||
异动开始日期、异动目的地、异动类型。
|
||||
|
||||
### 12.2 表格
|
||||
|
||||
宽表展示异动起止里程/电量/氢量等;**异动开始日期**可点击 → Toast 跳转异动管理详情。
|
||||
|
||||
---
|
||||
|
||||
## 13. 调拨记录 Tab
|
||||
|
||||
### 13.1 筛选
|
||||
|
||||
调拨日期、调出人、接收人。
|
||||
|
||||
### 13.2 表格
|
||||
|
||||
**调拨日期**可点击 → Toast 跳转车辆调拨详情。
|
||||
|
||||
---
|
||||
|
||||
## 14. 年审记录 Tab
|
||||
|
||||
对齐「年审管理」历史列表字段(不含车牌/品牌/型号,因已在详情页上下文)。
|
||||
|
||||
### 14.1 筛选
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| 完成时间 | 按办结日期范围 |
|
||||
| 检验有效期 | 按检验有效期至范围 |
|
||||
| 办理人 | 下拉选择 |
|
||||
|
||||
### 14.2 表格列
|
||||
|
||||
| 列 | 可点击 | 说明 |
|
||||
|---|---|---|
|
||||
| 检验有效期至 | **是** | Toast 跳转年审管理详情(原型演示) |
|
||||
| 检测服务站名称 | 否 | 无数据显示「—」 |
|
||||
| 检测费用 | 否 | 金额两位小数,无数据显示「—」 |
|
||||
| 二保服务站名称 | 否 | 同上 |
|
||||
| 二保费用 | 否 | 同上 |
|
||||
| 整备服务站名称 | 否 | 同上 |
|
||||
| 整备费用 | 否 | 同上 |
|
||||
| 办理人 | 否 | — |
|
||||
| 完成时间 | 否 | 办结时间,默认倒序 |
|
||||
|
||||
### 14.3 数据
|
||||
|
||||
- 来源:`data/annual-review-records.json`,按 `plateNo` 过滤
|
||||
- 样例车 `粤AGP5156` 含 3 条已完成年审记录
|
||||
|
||||
---
|
||||
|
||||
## 15. 跨模块跳转 Toast 汇总
|
||||
|
||||
| 场景 | 文案模板 |
|
||||
|---|---|
|
||||
| 证照编辑 | 即将跳转至证照管理编辑页(原型演示):{车牌} |
|
||||
| 保险采购 | 即将跳转至保险采购编辑页(原型演示):{车牌} · {险种} |
|
||||
| 保单预览 | 预览保单文件(原型演示):{文件名} |
|
||||
| 保单下载 | 已开始下载:{文件名}(原型演示) |
|
||||
| 租赁合同 | 即将跳转至租赁合同详情(原型演示):{车牌} · {合同编码} |
|
||||
| 交车记录 | 即将跳转至交车管理详情(原型演示):{车牌} · {合同} · {交车日期} |
|
||||
| 还车记录 | 即将跳转至还车管理详情(原型演示):{车牌} · {合同} · {还车日期} |
|
||||
| 事故详情 | 即将跳转至事故管理详情页(原型演示):{事故编码} |
|
||||
| 故障详情 | 即将跳转至故障管理详情页(原型演示):{故障编号} |
|
||||
| 故障编辑 | 编辑故障记录(原型演示):{车牌} · {故障编号} |
|
||||
| 违章详情 | 即将跳转至违章管理详情页(原型演示):{违法时间} |
|
||||
| 异动详情 | 即将跳转至异动管理详情页(原型演示):{异动开始日期} |
|
||||
| 调拨详情 | 即将跳转至车辆调拨详情页(原型演示):{调拨日期} |
|
||||
| 年审详情 | 即将跳转至年审管理详情页(原型演示):{检验有效期至} |
|
||||
| 字段保存 | 车辆信息已更新 / 运维负责人已更新 / 运营城市已更新 |
|
||||
|
||||
---
|
||||
|
||||
## 16. 原型局限
|
||||
|
||||
1. 各业务记录为前端 JSON 种子,不与外部年审/事故等模块真实联动。
|
||||
2. 详情 Tab 间切换不保留各 Tab 内筛选/分页状态(刷新组件即重置)。
|
||||
3. 行内编辑与弹窗保存仅更新内存,刷新页面后丢失。
|
||||
|
||||
---
|
||||
|
||||
## 17. 验收要点
|
||||
|
||||
- [ ] 返回列表保留列表态
|
||||
- [ ] 11 个 Tab 均可切换且内容正确
|
||||
- [ ] 各记录 Tab 筛选查询/重置/分页正常
|
||||
- [ ] 所有标注可点击字段触发对应 Toast
|
||||
- [ ] 年审记录含检测服务站/检测费用列,检验有效期至可点击
|
||||
- [ ] 右上角「查看需求说明」展示本文档(详情页 PRD),非列表页 PRD
|
||||
- [ ] 「PRD文档MD文件下载」「下载测试用例」在详情页可用且行为正确
|
||||
255
src/prototypes/vehicle-management/.spec/requirements-prd-list.md
Normal file
255
src/prototypes/vehicle-management/.spec/requirements-prd-list.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 车辆管理 · 列表页产品需求说明(PRD)
|
||||
|
||||
> 本页为车辆台账列表。标注「原型演示」的跳转、导入、导出仅 Toast 提示,不做真实页面跳转。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 车辆管理 — 列表页 |
|
||||
| 目标用户 | 运营、业务、运维、管理员 |
|
||||
| 核心任务 | 按多维度筛选车辆台账,查看证照/保险/交还车状态,维护运维负责人,进入车辆详情 |
|
||||
| 页面结构 | 右上角工具栏:「查看需求说明」「PRD文档MD文件下载」「下载测试用例」;筛选区 + KPI + 表格 + 分页 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限与角色(原型常量)
|
||||
|
||||
| 常量 | 原型取值 | 影响 |
|
||||
|---|---|---|
|
||||
| ADMIN | `true` | 操作列「更多」菜单显示「编辑」 |
|
||||
| 运维主管 | `true` | 可点击运维负责人、操作列「修改运维负责人」 |
|
||||
|
||||
生产环境应由登录用户权限接口下发,原型写死便于演示。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据来源总览
|
||||
|
||||
| 数据 | 来源文件/规则 | 用途 |
|
||||
|---|---|---|
|
||||
| 车辆台账 | `data/vehicles.json`(100 条样例) | 列表、筛选选项、详情、编辑回写 |
|
||||
| 保险采购 | `data/insurance-purchases.json` | 按车辆 + 险种取**最新一条**采购记录的到期日 |
|
||||
| 停车场→区域 | `PARKING_REGION_MAP` 常量 | 运维负责人候选区域、运营城市辅助 |
|
||||
| 运维人员 | `OPS_STAFF` 常量 | 按车辆区域过滤可选运维负责人 |
|
||||
| 运营城市种子 | `OPERATE_CITY_SEED` + 台账 `location` | 运营城市筛选/修改选项 |
|
||||
| GPS 坐标 | 优先 `gpsLat/gpsLng`;否则按 `location` 城市基准点 + 车辆 id 偏移模拟 | 地图弹窗 |
|
||||
| GPS 详细地址 | `locationAddress` 优先,否则 `location` 省市区 | 当前位置列 |
|
||||
| 所属项目三元组 | `projectName` / `contractNo` / `customer` | 仅运营状态为「租赁」「自营」时展示 |
|
||||
| 三方租赁公司 | `leaseCompany` | 仅 `vehicleSource === '外租'` 时展示 |
|
||||
| 交还车记录 | `lastDelivery*` / `lastReturn*` 字段 | 上次交车/还车情况列 |
|
||||
|
||||
**空值约定**:台账中 `-`、空字符串视为无数据;列表列默认显示空白(非「—」)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 筛选区
|
||||
|
||||
### 4.1 字段清单
|
||||
|
||||
默认展示前 4 项,点击「展开更多筛选项」显示全部。
|
||||
|
||||
| 字段 | 控件 | 选项来源 | 匹配规则 |
|
||||
|---|---|---|---|
|
||||
| 车牌号 | 多行弹层输入 | 用户输入 | 确认后精确匹配(归一化:去空格、大写) |
|
||||
| 运营城市 | 省-市级联多选 | 种子 + 停车场映射 + 台账 location | 与车辆运营城市(省-市格式)包含关系 |
|
||||
| 品牌/型号/客户/部门/项目/合同/所有权/运营公司/租赁公司/停车场 | 可搜索单选 | 当前台账去重排序 | 精确相等 |
|
||||
| 项目类型 | 可搜索单选 | 租赁、自营 | 与创建合同时合同类型相等 |
|
||||
| 运营状态 | 可搜索单选 | 租赁、自营、可运营、待运营、退出运营 | 精确相等 |
|
||||
| 车辆来源 | 可搜索单选 | 自有、外租、挂靠 | 精确相等 |
|
||||
|
||||
### 4.2 操作逻辑
|
||||
|
||||
| 操作 | 行为 |
|
||||
|---|---|
|
||||
| **查询** | 将当前筛选项写入「已应用条件」,列表与 KPI 计数基于全量台账;页码重置为 1 |
|
||||
| **重置** | 清空待应用与已应用条件,页码重置为 1 |
|
||||
| 修改筛选项未点查询 | 不影响当前列表 |
|
||||
|
||||
筛选与 KPI Tab **叠加**:先按 KPI 分类过滤,再按筛选条件过滤。
|
||||
|
||||
---
|
||||
|
||||
## 5. KPI 分类卡片
|
||||
|
||||
点击卡片切换列表分类,并回到第 1 页。卡片右上角「?」展示口径说明。
|
||||
|
||||
| KPI | 统计口径 |
|
||||
|---|---|
|
||||
| 所有营运车辆 | 非「非运营车辆」且非「退出运营」 |
|
||||
| 租赁车辆 | `operateStatus === '租赁'` |
|
||||
| 自营车辆 | `operateStatus === '自营'` |
|
||||
| 库存车辆 | `operateStatus` 为「可运营」或「待运营」 |
|
||||
| 非运营车辆 | `vehicleLedgerType === '非运营车辆'` |
|
||||
| 退出运营车辆 | `operateStatus === '退出运营'` |
|
||||
| 证照异常车辆 | 检验有效期已过期(`inspectExpire` 早于今天) |
|
||||
| 保险异常车辆 | `insuranceStatus === '异常'` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 列表工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 导出 | Toast:`导出功能(原型演示):当前列表共 N 条` |
|
||||
| 批量导入 | 打开导入弹窗 |
|
||||
|
||||
### 6.1 批量导入弹窗
|
||||
|
||||
1. 下载 CSV 模板(含必填字段说明行)
|
||||
2. 上传文件 → Toast:`已接收文件 {文件名},导入校验为原型演示`
|
||||
|
||||
原型不做真实校验与落库。
|
||||
|
||||
---
|
||||
|
||||
## 7. 列表字段说明
|
||||
|
||||
### 7.1 车辆信息(冻结列)
|
||||
|
||||
- 车牌:链接色,点击进入**车辆详情页**
|
||||
- VIN、品牌·型号:只读展示
|
||||
|
||||
### 7.2 运营城市
|
||||
|
||||
- 展示:省-市(由 `location` 解析)
|
||||
- 标签:车机 / GPS / 人工(来源 `operateCitySource` 或推导)
|
||||
- **可编辑**:仅来源为「人工」时可点击修改 → 运营城市修改弹窗
|
||||
- 车机/GPS 来源:不可改,hover 提示原因
|
||||
|
||||
### 7.3 所属项目
|
||||
|
||||
仅**租赁/自营**车辆展示项目名称、合同编码、客户名称;否则三行均为空。
|
||||
|
||||
- 项目名称、合同编码:有值时可点击 → Toast 跳转合同详情(原型演示)
|
||||
|
||||
### 7.4 里程数
|
||||
|
||||
- 在线车辆:显示里程 + 「车机」标签,里程按车机数据格式化
|
||||
- 离线:仅里程或空
|
||||
|
||||
### 7.5 证照 / 保险
|
||||
|
||||
| 列 | 有数据 | 无数据/异常 |
|
||||
|---|---|---|
|
||||
| 检验有效期 | 到期日 + 剩余/已过期天数标签 | 「未上传」可点击 + 「禁止交车」标签 |
|
||||
| 保险状态 | 正常/异常 Tag;异常时 hover 展示具体到期险种 | — |
|
||||
| 交强险/商业险到期 | 到期日 + 剩余天数 | 「未购买」可点击 + 「禁止交车」 |
|
||||
|
||||
未上传/未购买点击 → Toast 跳转证照管理或保险采购(原型演示)。
|
||||
|
||||
### 7.6 上次交车 / 还车情况
|
||||
|
||||
三行纯数值(无行内标题):里程(km)、人员、时间(至分钟)。**有数据时**可点击。
|
||||
|
||||
| 列 | 点击行为 |
|
||||
|---|---|
|
||||
| 上次交车情况 | Toast:`即将跳转至交车管理(原型演示):{车牌}` |
|
||||
| 上次还车情况 | Toast:`即将跳转至还车管理(原型演示):{车牌}` |
|
||||
|
||||
### 7.7 三方租赁公司
|
||||
|
||||
仅外租车辆显示 `leaseCompany`,否则空白。
|
||||
|
||||
### 7.8 当前位置
|
||||
|
||||
- 在线状态圆点 + 文案
|
||||
- 详细地址:有 GPS 坐标时可点击 → 地图弹窗(OpenStreetMap 嵌入)
|
||||
- GPS 时间:精确到秒
|
||||
|
||||
### 7.9 运维负责人
|
||||
|
||||
可编辑时显示为链接;无负责人显示「设置负责人」→ 运维负责人弹窗。
|
||||
|
||||
### 7.10 操作列(冻结)
|
||||
|
||||
| 操作 | 条件 | 行为 |
|
||||
|---|---|---|
|
||||
| 查看 | 始终 | 进入车辆详情页 |
|
||||
| 更多 | 管理员或有运维权限 | 下拉:编辑(管理员)、修改运维负责人 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 弹窗交互
|
||||
|
||||
### 8.1 设置运维负责人
|
||||
|
||||
- 标题下胶囊:车牌 | 运维区域(省-市)
|
||||
- 多选搜索选择器,候选人按车辆区域过滤
|
||||
- **保存**:写回 `opsManagers`,Toast「运维负责人已更新」
|
||||
- **取消**:不保存
|
||||
|
||||
### 8.2 修改运营城市
|
||||
|
||||
- 省-市选择,保存后 `location` 更新且 `operateCitySource` 置为「人工」
|
||||
- Toast「运营城市已更新」
|
||||
|
||||
### 8.3 编辑车辆(管理员)
|
||||
|
||||
- 可搜索选择器字段与筛选区一致规范
|
||||
- 保存时运营状态、车辆状态仍为必填(按钮禁用),但标签无红色 *
|
||||
- Toast「车辆信息已更新」
|
||||
|
||||
### 8.4 地图
|
||||
|
||||
展示坐标点与 GPS 时间;关闭不保存。
|
||||
|
||||
---
|
||||
|
||||
## 9. 进入详情页
|
||||
|
||||
- 入口:列表车牌链接或操作列「查看」
|
||||
- 详情页右上角同样有「查看需求说明」,展示**详情页 PRD**(与本文档分离)
|
||||
- 从详情返回列表时,保留筛选/KPI/分页状态(内存态)
|
||||
|
||||
---
|
||||
|
||||
## 10. 提示信息汇总(Toast)
|
||||
|
||||
| 触发场景 | 文案模板 |
|
||||
|---|---|
|
||||
| 导出 | 导出功能(原型演示):当前列表共 {n} 条 |
|
||||
| 导入成功 | 已接收文件 {name},导入校验为原型演示 |
|
||||
| 运维负责人保存 | 运维负责人已更新 |
|
||||
| 运营城市保存 | 运营城市已更新 |
|
||||
| 车辆编辑保存 | 车辆信息已更新 |
|
||||
| 合同跳转 | 即将跳转至合同详情(原型演示):{合同编码} |
|
||||
| 证照跳转 | 即将跳转至证照管理编辑页(原型演示):{车牌} |
|
||||
| 保险跳转 | 即将跳转至保险采购编辑页(原型演示):{车牌} · {险种} |
|
||||
| 交车跳转 | 即将跳转至交车管理(原型演示):{车牌} |
|
||||
| 还车跳转 | 即将跳转至还车管理(原型演示):{车牌} |
|
||||
|
||||
Toast 约 3 秒后自动消失。
|
||||
|
||||
---
|
||||
|
||||
## 11. 分页
|
||||
|
||||
- 默认每页 10 条,可选 10 / 20 / 50 / 100
|
||||
- 切换每页条数回到第 1 页
|
||||
- 总条数为筛选后的数量
|
||||
|
||||
---
|
||||
|
||||
## 12. 原型局限
|
||||
|
||||
1. 数据为前端 JSON,刷新页面后编辑结果丢失(内存态)。
|
||||
2. 导入、导出、外链跳转均为演示提示。
|
||||
3. 地图依赖外网 OpenStreetMap。
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收要点
|
||||
|
||||
- [ ] 筛选查询/重置与 KPI 叠加正确
|
||||
- [ ] 空值列显示空白
|
||||
- [ ] 人工运营城市可改,车机/GPS 不可改
|
||||
- [ ] 未上传/未购买可点击并提示跳转
|
||||
- [ ] 交还车有数据可点击、无数据不可点
|
||||
- [ ] 运维负责人、编辑弹窗保存后列表即时更新
|
||||
- [ ] 管理员「更多」菜单与权限开关联动
|
||||
- [ ] 右上角「查看需求说明」展示本文档(列表页 PRD)
|
||||
- [ ] 「PRD文档MD文件下载」可下载列表页、详情页两份 .md
|
||||
- [ ] 「下载测试用例」可下载含列表页/详情页 Sheet 的 Excel
|
||||
93
src/prototypes/vehicle-management/.spec/ui-review.md
Normal file
93
src/prototypes/vehicle-management/.spec/ui-review.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# UI Review
|
||||
|
||||
- 审查目标:`src/prototypes/vehicle-management`
|
||||
- 使用设计依据:**(缺失)** 原型目录与项目均未关联 `DESIGN.md`;按 Axhub 规范,规范性设计一致性评审需先确认设计基底
|
||||
- 生成时间:2026-06-24
|
||||
|
||||
> **评审模式说明**:本次为 **degraded** 独立评估。在缺少正式 `DESIGN.md` 的前提下,结合源码走查、Impeccable `detect.mjs` 静态扫描与通用 B2B 运营后台可用性准则完成预审;**无法对照设计规范判定品牌/版式一致性**。建议从 `src/themes/` 中选定主题(如 `operate` 运营仪表盘类)或补充 ONE-OS 专属 `DESIGN.md` 后复评。
|
||||
|
||||
## 总体点评
|
||||
|
||||
车辆管理原型整体完成了「筛选 → KPI 分群 → 宽表浏览 → 详情下钻 → 导入/运维负责人弹窗」的主链路,信息架构与参考实现意图基本一致,视觉采用 ONE-OS 绿色主色与 Data-Dense 布局,KPI 可点击筛选、表格左右冻结列、状态色标签与到期提醒等关键业务表达已落地,作为可演示原型具备较高完成度。
|
||||
|
||||
主要风险集中在 **高密度表格的可读性**、**部分筛选控件的原生交互陷阱** 以及 **弹层/辅助说明的可访问性**。页面在桌面宽屏下表现尚可,但笔记本常见宽度下 17 列表格横向滚动严重,证照/保险等异常字段容易离开首屏;筛选区「运营城市」使用原生 `multiple` 多选下拉,对非技术用户几乎不可发现操作方式,会直接阻断按城市筛车的核心任务。模态框缺少焦点陷阱与 Esc 关闭策略,点击遮罩即关闭,运维负责人多选场景存在误操作丢数据风险。
|
||||
|
||||
亮点方面:触控主按钮普遍达到 44px 最小高度;主按钮、链接、KPI 卡片具备 `:focus-visible` 环;表格空态、分页、Toast 反馈、详情页 Tab 语义角色等基础状态较完整;`prefers-reduced-motion` 已做降级处理。Impeccable CLI 扫描未检出静态规则违规项。
|
||||
|
||||
## P0-P3 优先级问题
|
||||
|
||||
### P0 - 运营城市多选控件对普通用户不可用
|
||||
|
||||
- 证据:`FilterPanel.tsx` 中「运营城市」为原生 `<select multiple>`,无操作提示、无标签化多选 UI;桌面端需 Ctrl/⌘ 点击,移动端行为更不可预期。
|
||||
- 影响:按运营城市筛选是台账高频任务;用户会误以为单选或无法完成多城筛选,直接阻断查询链路。
|
||||
- 修复方向:改为可搜索的多选标签组件(如输入框 + 下拉勾选 + 已选 Tag),并在字段旁增加一句操作说明;保留键盘可达与 `aria-label`。
|
||||
|
||||
### P1 - 宽表信息密度过高,关键异常字段易离开首屏
|
||||
|
||||
- 证据:`VehicleTable.tsx` 共 17 列,字号 `0.8125rem`(约 13px);`style.css` 中 `.vm-table-wrap` 横向滚动,仅冻结「车辆信息」「操作」两列,证照状态、检验有效期、保险状态位于中后段。
|
||||
- 影响:运维/车管需在列表中快速发现证照、保险异常车辆;在 1280px 及以下屏宽时,用户需大量横向拖动才能看到异常列,显著增加任务耗时与漏检风险。
|
||||
- 修复方向:提供列显示配置或「异常摘要」紧凑列;默认将证照/保险合并为「合规状态」前置列;或允许按 KPI(证照异常/保险异常)切换精简列集。
|
||||
|
||||
### P1 - 占位与辅助文字对比度未达 WCAG AA
|
||||
|
||||
- 证据:`style.css` 中 `.vm-input .placeholder` 为 `#94a3b8`,`.vm-muted` 为 `#64748b`,用于筛选项占位、表头辅助、KPI 标题等;在白色背景上前者约 2.8:1、后者边界偏低。
|
||||
- 影响:低视力用户与强光环境下阅读困难,不符合 AA 级正文/占位对比要求,影响表单可完成性。
|
||||
- 修复方向:占位文字提升至 ≥ `#64748b` 或更深;辅助标签改用 `#475569` 并抽样用对比度工具复核 4.5:1。
|
||||
|
||||
### P1 - 模态框缺少焦点管理与误关保护
|
||||
|
||||
- 证据:`ImportModal.tsx`、`OpsManagerModal.tsx` 使用 `role="presentation"` 遮罩 + `onClick={onClose}`;无焦点陷阱、无 `Escape` 关闭、打开时未将焦点移入对话框;`OpsManagerModal` 未设置 `aria-labelledby`。
|
||||
- 影响:键盘用户 Tab 可能穿透到背景;运维负责人多选时误点遮罩会丢失已选内容;屏幕阅读器对弹窗标题关联不完整。
|
||||
- 修复方向:使用 `<dialog>` 或实现 focus trap;遮罩点击改为需确认或仅允许显式「取消」关闭;打开时 `focus()` 到标题或首控件,关闭后还原触发按钮焦点。
|
||||
|
||||
### P2 - KPI 指标口径说明对键盘与触屏不可达
|
||||
|
||||
- 证据:`KpiCards.tsx` 中说明文案仅放在 `.vm-kpi-tip` 的 `title` 属性上,依赖鼠标悬停;图标 `CircleHelp` 非可聚焦控件。
|
||||
- 影响:触屏与键盘用户无法理解「库存」「证照异常」等指标统计口径,可能误用 KPI 筛选导致结果偏差。
|
||||
- 修复方向:将帮助图标改为 `button` 并绑定 `aria-describedby`;或提供可聚焦的「指标说明」抽屉/Popover,支持 Esc 关闭。
|
||||
|
||||
## 核心元件
|
||||
|
||||
### 页面标题区(Header)
|
||||
|
||||
标题「车辆管理」与副标题层级清晰,字重对比足够。副标题使用 muted 色,在强光下略淡,建议复核对比度。区块未占用过多垂直空间,符合数据密集型后台习惯。**保留**简洁标题结构;**调整**副标题色阶以达 AA。
|
||||
|
||||
### 筛选面板(FilterPanel)
|
||||
|
||||
四列栅格 +「展开更多」有效降低首屏认知负荷,重置/查询按钮位置符合右对齐操作习惯。车牌号弹层输入多车牌的体验优于纯下拉。**问题**:运营城市 `multiple` select(见 P0);车牌 Popover 无点击外部关闭与 Esc 处理;展开后 14+ 字段同时暴露,决策点偏多。**建议**对高频筛选项(车牌、城市、运营状态、停车场)做「常用/高级」分组。
|
||||
|
||||
### KPI 卡片行(KpiCards)
|
||||
|
||||
七张 KPI 可点击切换列表,激活态描边与 `aria-pressed` 语义正确,数字使用等宽数字利于扫读。圆形渐变图标 + 大数字布局略偏通用 SaaS 仪表盘模板感,但业务语义(营运/库存/异常)表达清楚。**保留**可点击筛选交互;**调整**指标说明可达性(见 P2);窄屏下改为 2 列/1 列的响应式已具备。
|
||||
|
||||
### 数据表格(VehicleTable)
|
||||
|
||||
车辆信息列堆叠车牌/VIN/品牌型号,信息密度合理;状态 Tag、到期组件 `ExpireDate`、在线圆点等视觉编码一致。左右冻结列在横向滚动时可用。**问题**:列过多导致异常字段不可见(见 P1);「更多操作」图标按钮无菜单实现,属可发现但不可用的死胡同;`.vm-icon-btn` 为 36×36px,低于 44px 触控建议。**建议**实现更多操作菜单或暂时隐藏;放大行内图标触控区。
|
||||
|
||||
### 详情页(DetailPage)
|
||||
|
||||
返回列表、车牌 Hero、四格摘要、Tab 分区结构清楚;基本信息/资产状况字段卡片化便于扫读。非「基本信息」Tab 仅为占位文案,切换后信息架构断层,评审时应标注为原型限制。**保留** Hero + 摘要 + Tab 骨架;**调整**占位 Tab 增加「即将上线」视觉区分,避免与已完成 Tab 同等权重。
|
||||
|
||||
### 弹窗(ImportModal / OpsManagerModal)
|
||||
|
||||
导入两步流程(模板下载 → 上传)符合业务心智,步骤编号清晰。上传区文案写「拖拽」但未实现 `onDrop`,与文案不符。运维负责人多选列表结构清楚,checkbox 标签可点区域够大。**调整**补齐拖拽或改文案;落实模态焦点与误关策略(见 P1)。
|
||||
|
||||
## 响应式与可访问性
|
||||
|
||||
- **响应式**:`style.css` 在 1600/1280/960/640px 断点下对 KPI、筛选、详情栅格有降级;宽表仍依赖横向滚动,移动端可用性有限,符合后台桌面优先场景,但需在评审说明中标注「移动端仅只读浏览,不建议完整运维操作」。
|
||||
- **键盘**:主按钮、Tab、KPI 卡片具备 focus 样式;模态与车牌 Popover 焦点管理不足。
|
||||
- **语义**:表格使用原生 `<table>` 结构良好;详情 Tab 有 `role="tablist"` / `aria-selected`,但缺少 `tabpanel` 与 `aria-controls` 关联。
|
||||
- **对比度**:主文字 `#0f172a` 达标;占位与部分 muted 文案需加深(见 P1)。
|
||||
- **动效**:已提供 `prefers-reduced-motion` 全局关闭 transition。
|
||||
|
||||
## 证据与评估说明
|
||||
|
||||
- **浏览器/截图**:本次未启动本地预览与浏览器截图,结论主要来自源码与样式走查。
|
||||
- **Scanner**:已运行 `node .claude/skills/impeccable/scripts/detect.mjs --json src/prototypes/vehicle-management`,返回 `[]`(无静态检出项)。
|
||||
- **独立评估**:`degraded` — 缺少 `DESIGN.md`,未完成设计规范一致性评估;可用性与实现风险评估基于源码与通用准则。
|
||||
|
||||
### 后续建议
|
||||
|
||||
1. 请从 `src/themes/<theme-id>/DESIGN.md` 指定设计基底,或于 `src/prototypes/vehicle-management/` 补充专属 `DESIGN.md`。
|
||||
2. 提供设计基底后,可复评品牌色、字体、间距、组件形态与 DESIGN 强制规则的一致性。
|
||||
3. 修复 P0/P1 后建议在 Make Client 预览中做一次桌面(1440px / 1280px)与触屏宽度验收,并附截图存档。
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
addMonths,
|
||||
buildMonthCells,
|
||||
formatISODate,
|
||||
formatMonthTitle,
|
||||
isDateInRange,
|
||||
isRangeEnd,
|
||||
isRangeStart,
|
||||
normalizeDateRange,
|
||||
parseISODate,
|
||||
sameCalendarDay,
|
||||
startOfMonth,
|
||||
WEEKDAY_LABELS,
|
||||
} from '../utils/dateRangeCalendar';
|
||||
import type { DateRangeValue } from '../utils/dateRangeCalendar';
|
||||
|
||||
interface DateRangeCalendarPanelProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onChange: (range: DateRangeValue) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
function MonthPanel({
|
||||
monthDate,
|
||||
start,
|
||||
end,
|
||||
hover,
|
||||
onDayClick,
|
||||
onDayHover,
|
||||
}: {
|
||||
monthDate: Date;
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
hover: Date | null;
|
||||
onDayClick: (date: Date) => void;
|
||||
onDayHover: (date: Date | null) => void;
|
||||
}) {
|
||||
const cells = useMemo(() => buildMonthCells(monthDate), [monthDate]);
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
return (
|
||||
<div className="vm-date-range-month">
|
||||
<div className="vm-date-range-month-title">{formatMonthTitle(monthDate)}</div>
|
||||
<div className="vm-date-range-weekdays" aria-hidden>
|
||||
{WEEKDAY_LABELS.map((label) => (
|
||||
<span key={label} className="vm-date-range-weekday">{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="vm-date-range-days" role="grid">
|
||||
{cells.map((cell) => {
|
||||
const key = formatISODate(cell.date);
|
||||
const selectedStart = isRangeStart(cell.date, start, end, hover);
|
||||
const selectedEnd = isRangeEnd(cell.date, start, end, hover);
|
||||
const inRange = isDateInRange(cell.date, start, end, hover);
|
||||
const isToday = sameCalendarDay(cell.date, today);
|
||||
const classNames = [
|
||||
'vm-date-range-day',
|
||||
!cell.inMonth ? 'is-outside' : '',
|
||||
inRange ? 'is-in-range' : '',
|
||||
selectedStart ? 'is-range-start' : '',
|
||||
selectedEnd ? 'is-range-end' : '',
|
||||
isToday ? 'is-today' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={classNames}
|
||||
aria-label={key}
|
||||
aria-pressed={selectedStart || selectedEnd}
|
||||
onMouseEnter={() => onDayHover(cell.date)}
|
||||
onMouseLeave={() => onDayHover(null)}
|
||||
onClick={() => onDayClick(cell.date)}
|
||||
>
|
||||
<span>{cell.date.getDate()}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateRangeCalendarPanel({
|
||||
startDate,
|
||||
endDate,
|
||||
onChange,
|
||||
onComplete,
|
||||
}: DateRangeCalendarPanelProps) {
|
||||
const start = parseISODate(startDate);
|
||||
const end = parseISODate(endDate);
|
||||
const [viewMonth, setViewMonth] = useState(() => startOfMonth(start || end || new Date()));
|
||||
const [hoverDate, setHoverDate] = useState<Date | null>(null);
|
||||
|
||||
const leftMonth = viewMonth;
|
||||
const rightMonth = addMonths(viewMonth, 1);
|
||||
|
||||
const handleDayClick = (date: Date) => {
|
||||
const iso = formatISODate(date);
|
||||
if (!startDate || (startDate && endDate)) {
|
||||
onChange({ startDate: iso, endDate: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const next = normalizeDateRange(startDate, iso);
|
||||
onChange(next);
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-date-range-calendars">
|
||||
<div className="vm-date-range-calendars-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-date-range-nav-btn"
|
||||
aria-label="上一月"
|
||||
onClick={() => setViewMonth((prev) => addMonths(prev, -1))}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-date-range-nav-btn"
|
||||
aria-label="下一月"
|
||||
onClick={() => setViewMonth((prev) => addMonths(prev, 1))}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="vm-date-range-calendars-body">
|
||||
<MonthPanel
|
||||
monthDate={leftMonth}
|
||||
start={start}
|
||||
end={end}
|
||||
hover={hoverDate}
|
||||
onDayClick={handleDayClick}
|
||||
onDayHover={setHoverDate}
|
||||
/>
|
||||
<MonthPanel
|
||||
monthDate={rightMonth}
|
||||
start={start}
|
||||
end={end}
|
||||
hover={hoverDate}
|
||||
onDayClick={handleDayClick}
|
||||
onDayHover={setHoverDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Calendar, X } from 'lucide-react';
|
||||
import { DateRangeCalendarPanel } from './DateRangeCalendarPanel';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
import type { DateRangeValue } from '../utils/dateRangeCalendar';
|
||||
|
||||
export type { DateRangeValue };
|
||||
|
||||
interface DateRangeFilterFieldProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onChange: (range: DateRangeValue) => void;
|
||||
ariaLabel?: string;
|
||||
startPlaceholder?: string;
|
||||
endPlaceholder?: string;
|
||||
}
|
||||
|
||||
function formatDisplayText(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startPlaceholder: string,
|
||||
endPlaceholder: string,
|
||||
): string {
|
||||
if (startDate && endDate) return `${startDate} ~ ${endDate}`;
|
||||
if (startDate) return `${startDate} ~ ${endPlaceholder}`;
|
||||
if (endDate) return `${startPlaceholder} ~ ${endDate}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function DateRangeFilterField({
|
||||
startDate,
|
||||
endDate,
|
||||
onChange,
|
||||
ariaLabel = '日期范围',
|
||||
startPlaceholder = '开始日期',
|
||||
endPlaceholder = '结束日期',
|
||||
}: DateRangeFilterFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
const hasValue = Boolean(startDate || endDate);
|
||||
const displayText = formatDisplayText(startDate, endDate, startPlaceholder, endPlaceholder);
|
||||
const placeholder = `${startPlaceholder} ~ ${endPlaceholder}`;
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
useDismissOnFocusOutside(open, fieldRef, dismiss);
|
||||
|
||||
const openPicker = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const clear = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onChange({ startDate: '', endDate: '' });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-date-range-field vm-filter-picker-field" ref={fieldRef}>
|
||||
<div
|
||||
className={`vm-filter-picker-control vm-date-range-trigger ${open ? 'open' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
className="vm-filter-picker-input"
|
||||
value={displayText}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
onFocus={openPicker}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openPicker();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{hasValue ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-filter-picker-clear"
|
||||
onClick={clear}
|
||||
aria-label={`清空${ariaLabel}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
<Calendar size={14} className="vm-date-range-icon" aria-hidden />
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="vm-date-range-popover" role="dialog" aria-label={ariaLabel}>
|
||||
<DateRangeCalendarPanel
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={onChange}
|
||||
onComplete={dismiss}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { DateRangeFilterField } from './DateRangeFilterField';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { StatusTag } from './StatusTag';
|
||||
import { TablePagination } from './TablePagination';
|
||||
import {
|
||||
ACCIDENT_LEVEL_OPTIONS,
|
||||
ACCIDENT_STATUS_OPTIONS,
|
||||
applyAccidentRecordFilters,
|
||||
buildAccidentRecordOptions,
|
||||
EMPTY_ACCIDENT_RECORD_FILTERS,
|
||||
formatAccidentAmount,
|
||||
formatAccidentCell,
|
||||
accidentStatusTone,
|
||||
resolveVehicleAccidentRecords,
|
||||
type AccidentRecordFilters,
|
||||
type AccidentRecordRow,
|
||||
} from '../utils/accidentRecords';
|
||||
|
||||
interface DetailAccidentRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenAccidentDetail?: (record: VehicleRecord, row: AccidentRecordRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function DetailAccidentRecordsTab({
|
||||
record,
|
||||
onOpenAccidentDetail,
|
||||
}: DetailAccidentRecordsTabProps) {
|
||||
const allRows = useMemo(() => resolveVehicleAccidentRecords(record), [record]);
|
||||
const [pendingFilters, setPendingFilters] = useState<AccidentRecordFilters>(EMPTY_ACCIDENT_RECORD_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<AccidentRecordFilters>(EMPTY_ACCIDENT_RECORD_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
const customerOptions = useMemo(
|
||||
() => buildAccidentRecordOptions(allRows, 'customerName'),
|
||||
[allRows],
|
||||
);
|
||||
const levelOptions = useMemo(() => {
|
||||
const fromData = buildAccidentRecordOptions(allRows, 'accidentLevel');
|
||||
return fromData.length ? fromData : [...ACCIDENT_LEVEL_OPTIONS];
|
||||
}, [allRows]);
|
||||
const statusOptions = useMemo(() => {
|
||||
const fromData = buildAccidentRecordOptions(allRows, 'status');
|
||||
return fromData.length ? fromData : [...ACCIDENT_STATUS_OPTIONS];
|
||||
}, [allRows]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyAccidentRecordFilters(allRows, appliedFilters),
|
||||
[allRows, appliedFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
const updatePending = (patch: Partial<AccidentRecordFilters>) => {
|
||||
setPendingFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_ACCIDENT_RECORD_FILTERS);
|
||||
setAppliedFilters(EMPTY_ACCIDENT_RECORD_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-accident-records-tab">
|
||||
<div className="vm-detail-tab-filter">
|
||||
<div className="vm-detail-tab-filter-grid vm-detail-tab-filter-grid--accident">
|
||||
<label className="vm-filter-field">
|
||||
<span>事故时间</span>
|
||||
<DateRangeFilterField
|
||||
startDate={pendingFilters.startDate}
|
||||
endDate={pendingFilters.endDate}
|
||||
ariaLabel="事故时间"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={(range) => updatePending(range)}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>客户名称</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.customerName}
|
||||
options={customerOptions}
|
||||
placeholder="请输入或选择客户名称"
|
||||
ariaLabel="客户名称"
|
||||
onChange={(value) => updatePending({ customerName: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>事故等级</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.accidentLevel}
|
||||
options={levelOptions}
|
||||
placeholder="请选择事故等级"
|
||||
ariaLabel="事故等级"
|
||||
onChange={(value) => updatePending({ accidentLevel: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>事故状态</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.accidentStatus}
|
||||
options={statusOptions}
|
||||
placeholder="请选择事故状态"
|
||||
ariaLabel="事故状态"
|
||||
onChange={(value) => updatePending({ accidentStatus: value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-detail-tab-filter-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab">
|
||||
<table className="vm-table vm-table--accident">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事故编码</th>
|
||||
<th>事故时间</th>
|
||||
<th>事故地点</th>
|
||||
<th>事故类型</th>
|
||||
<th>客户名称</th>
|
||||
<th>我方定损金额</th>
|
||||
<th>对方定损金额</th>
|
||||
<th>责任划分</th>
|
||||
<th>事故状态</th>
|
||||
<th>结案时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="vm-empty-cell">暂无数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link code"
|
||||
onClick={() => onOpenAccidentDetail?.(record, row)}
|
||||
>
|
||||
{row.accidentCode}
|
||||
</button>
|
||||
</td>
|
||||
<td>{row.accidentTime}</td>
|
||||
<td className="ellipsis" title={row.accidentLocation}>{row.accidentLocation}</td>
|
||||
<td>{row.accidentType}</td>
|
||||
<td className="ellipsis" title={row.customerName}>{row.customerName}</td>
|
||||
<td className="vm-table-num">{formatAccidentAmount(row.ourDamageAmount)}</td>
|
||||
<td className="vm-table-num">{formatAccidentAmount(row.theirDamageAmount)}</td>
|
||||
<td>{row.responsibility}</td>
|
||||
<td>
|
||||
<StatusTag label={row.status} tone={accidentStatusTone(row.status)} />
|
||||
</td>
|
||||
<td>{formatAccidentCell(row.closedTime)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||
<span className="vm-table-total">共 {filtered.length} 条</span>
|
||||
{filtered.length > 0 && (
|
||||
<TablePagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { DateRangeFilterField } from './DateRangeFilterField';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { TablePagination } from './TablePagination';
|
||||
import {
|
||||
applyAnnualReviewRecordFilters,
|
||||
buildAnnualReviewRecordOptions,
|
||||
EMPTY_ANNUAL_REVIEW_RECORD_FILTERS,
|
||||
formatAnnualReviewCell,
|
||||
formatAnnualReviewMoney,
|
||||
resolveVehicleAnnualReviewRecords,
|
||||
type AnnualReviewRecordFilters,
|
||||
type AnnualReviewRecordRow,
|
||||
} from '../utils/annualReviewRecords';
|
||||
|
||||
interface DetailAnnualReviewRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenAnnualReviewDetail?: (record: VehicleRecord, row: AnnualReviewRecordRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function DetailAnnualReviewRecordsTab({
|
||||
record,
|
||||
onOpenAnnualReviewDetail,
|
||||
}: DetailAnnualReviewRecordsTabProps) {
|
||||
const allRows = useMemo(() => resolveVehicleAnnualReviewRecords(record), [record]);
|
||||
const [pendingFilters, setPendingFilters] = useState<AnnualReviewRecordFilters>(
|
||||
EMPTY_ANNUAL_REVIEW_RECORD_FILTERS,
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<AnnualReviewRecordFilters>(
|
||||
EMPTY_ANNUAL_REVIEW_RECORD_FILTERS,
|
||||
);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
const executorOptions = useMemo(
|
||||
() => buildAnnualReviewRecordOptions(allRows, 'executor'),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyAnnualReviewRecordFilters(allRows, appliedFilters),
|
||||
[allRows, appliedFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
const updatePending = (patch: Partial<AnnualReviewRecordFilters>) => {
|
||||
setPendingFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_ANNUAL_REVIEW_RECORD_FILTERS);
|
||||
setAppliedFilters(EMPTY_ANNUAL_REVIEW_RECORD_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-annual-review-records-tab">
|
||||
<div className="vm-detail-tab-filter">
|
||||
<div className="vm-detail-tab-filter-grid vm-detail-tab-filter-grid--annual-review">
|
||||
<label className="vm-filter-field">
|
||||
<span>完成时间</span>
|
||||
<DateRangeFilterField
|
||||
startDate={pendingFilters.executeStartDate}
|
||||
endDate={pendingFilters.executeEndDate}
|
||||
ariaLabel="完成时间"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={(range) => updatePending({
|
||||
executeStartDate: range.startDate,
|
||||
executeEndDate: range.endDate,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>检验有效期</span>
|
||||
<DateRangeFilterField
|
||||
startDate={pendingFilters.validStartDate}
|
||||
endDate={pendingFilters.validEndDate}
|
||||
ariaLabel="检验有效期"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={(range) => updatePending({
|
||||
validStartDate: range.startDate,
|
||||
validEndDate: range.endDate,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>办理人</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.executor}
|
||||
options={executorOptions}
|
||||
placeholder="请选择办理人"
|
||||
ariaLabel="办理人"
|
||||
onChange={(value) => updatePending({ executor: value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-detail-tab-filter-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab vm-table-wrap--wide">
|
||||
<table className="vm-table vm-table--annual-review">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>检验有效期至</th>
|
||||
<th>检测服务站名称</th>
|
||||
<th>检测费用</th>
|
||||
<th>二保服务站名称</th>
|
||||
<th>二保费用</th>
|
||||
<th>整备服务站名称</th>
|
||||
<th>整备费用</th>
|
||||
<th>办理人</th>
|
||||
<th>完成时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="vm-empty-cell">暂无数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onOpenAnnualReviewDetail?.(record, row)}
|
||||
>
|
||||
{row.inspectionValidUntil}
|
||||
</button>
|
||||
</td>
|
||||
<td className="ellipsis" title={row.inspectionStation || undefined}>
|
||||
{formatAnnualReviewCell(row.inspectionStation)}
|
||||
</td>
|
||||
<td className="vm-table-num">{formatAnnualReviewMoney(row.inspectionCost)}</td>
|
||||
<td className="ellipsis" title={row.m2Station || undefined}>
|
||||
{formatAnnualReviewCell(row.m2Station)}
|
||||
</td>
|
||||
<td className="vm-table-num">{formatAnnualReviewMoney(row.m2Cost)}</td>
|
||||
<td className="ellipsis" title={row.zbStation || undefined}>
|
||||
{formatAnnualReviewCell(row.zbStation)}
|
||||
</td>
|
||||
<td className="vm-table-num">{formatAnnualReviewMoney(row.zbCost)}</td>
|
||||
<td>{row.executor}</td>
|
||||
<td>{row.executeTime}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||
<span className="vm-table-total">共 {filtered.length} 条</span>
|
||||
{filtered.length > 0 && (
|
||||
<TablePagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { displayUILabel, isShanghaiPlate, resolveProjectDelivery } from '../utils/vehicle';
|
||||
import { DetailExpireField } from './DetailExpireField';
|
||||
import { DetailInlineEditField } from './DetailInlineEditField';
|
||||
import { DetailParamField, DetailParamSection } from './DetailParamLayout';
|
||||
import { InspectExpireCell } from './InspectExpireCell';
|
||||
import { StatusTag } from './StatusTag';
|
||||
|
||||
interface DetailBasicInfoTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenLicenseEdit?: (record: VehicleRecord) => void;
|
||||
onSaveField?: (recordId: string, patch: Partial<VehicleRecord>) => void;
|
||||
}
|
||||
|
||||
export function DetailBasicInfoTab({
|
||||
record,
|
||||
onOpenLicenseEdit,
|
||||
onSaveField,
|
||||
}: DetailBasicInfoTabProps) {
|
||||
const project = resolveProjectDelivery(record, '无');
|
||||
const shanghaiPlate = isShanghaiPlate(record.plateNo);
|
||||
const handleLicenseEdit = () => onOpenLicenseEdit?.(record);
|
||||
|
||||
return (
|
||||
<div className="vm-model-param-tab vm-model-param-tab--readonly">
|
||||
<DetailParamSection title="档案信息">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField
|
||||
label="出厂年份"
|
||||
actionable
|
||||
value={(
|
||||
<DetailInlineEditField
|
||||
value={record.year}
|
||||
editType="year"
|
||||
onSave={(year) => onSaveField?.(record.id, { year })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="采购入库日期"
|
||||
actionable
|
||||
value={(
|
||||
<DetailInlineEditField
|
||||
value={record.purchaseDate}
|
||||
editType="date"
|
||||
onSave={(purchaseDate) => onSaveField?.(record.id, { purchaseDate })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="强制报废日期"
|
||||
value={<DetailExpireField date={record.scrapDate} onClickNotUploaded={handleLicenseEdit} />}
|
||||
/>
|
||||
{shanghaiPlate && (
|
||||
<DetailParamField
|
||||
label="下次等级评定时间"
|
||||
value={<DetailExpireField date={record.ratingTime} onClickNotUploaded={handleLicenseEdit} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="业务信息">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField
|
||||
label="运营状态"
|
||||
value={<StatusTag label={record.operateStatus} tone="green" />}
|
||||
/>
|
||||
<DetailParamField label="项目名称" value={project.projectName} empty={project.projectName === '无'} />
|
||||
<DetailParamField label="合同编码" value={project.contractNo} empty={project.contractNo === '无'} />
|
||||
<DetailParamField label="企业名称" value={project.companyName} empty={project.companyName === '无'} />
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="证照信息">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField
|
||||
label="车辆状态"
|
||||
value={<StatusTag label={record.vehicleStatus} tone="blue" />}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="证照状态"
|
||||
value={(
|
||||
<StatusTag
|
||||
label={record.licenseStatus}
|
||||
tone={record.licenseStatus === '异常' ? 'red' : 'green'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="行驶证有效期"
|
||||
value={(
|
||||
<InspectExpireCell
|
||||
expireDate={record.inspectExpire}
|
||||
onClickNotUploaded={handleLicenseEdit}
|
||||
alwaysShowRemainder
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="保险与出库">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField
|
||||
label="保险状态"
|
||||
value={(
|
||||
<StatusTag
|
||||
label={record.insuranceStatus}
|
||||
tone={record.insuranceStatus === '异常' ? 'red' : 'green'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="出库状态"
|
||||
value={displayUILabel(record.outStatus)}
|
||||
empty={displayUILabel(record.outStatus) === '无'}
|
||||
/>
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="资产状况">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField
|
||||
label="车辆来源"
|
||||
value={<StatusTag label={record.vehicleSource} tone="blue" />}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="登记所有权"
|
||||
value={displayUILabel(record.ownership)}
|
||||
empty={displayUILabel(record.ownership) === '无'}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="运营公司"
|
||||
value={displayUILabel(record.operateCompany)}
|
||||
empty={displayUILabel(record.operateCompany) === '无'}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="租赁公司"
|
||||
value={displayUILabel(record.leaseCompany)}
|
||||
empty={displayUILabel(record.leaseCompany) === '无'}
|
||||
/>
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { isEmpty } from '../utils/vehicle';
|
||||
import { ExpireDate } from './ExpireDate';
|
||||
|
||||
interface DetailExpireFieldProps {
|
||||
date: string;
|
||||
onClickNotUploaded?: () => void;
|
||||
}
|
||||
|
||||
/** 详情证照类日期:空为可点「未上传」,有值展示日期与剩余/已过期天数 */
|
||||
export function DetailExpireField({ date, onClickNotUploaded }: DetailExpireFieldProps) {
|
||||
if (isEmpty(date)) {
|
||||
if (onClickNotUploaded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={onClickNotUploaded}
|
||||
title="前往证照管理维护"
|
||||
>
|
||||
未上传
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span>未上传</span>;
|
||||
}
|
||||
return <ExpireDate date={date} alwaysShowRemainder />;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { DateRangeFilterField } from './DateRangeFilterField';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { StatusTag } from './StatusTag';
|
||||
import { TablePagination } from './TablePagination';
|
||||
import {
|
||||
FAULT_AI_MATCH_OPTIONS,
|
||||
FAULT_LEVEL_OPTIONS,
|
||||
FAULT_RESOLUTION_OPTIONS,
|
||||
applyFaultRecordFilters,
|
||||
buildFaultRecordOptions,
|
||||
EMPTY_FAULT_RECORD_FILTERS,
|
||||
faultLevelTone,
|
||||
formatAiMatched,
|
||||
formatFaultCell,
|
||||
resolveVehicleFaultRecords,
|
||||
type FaultRecordFilters,
|
||||
type FaultRecordRow,
|
||||
} from '../utils/faultRecords';
|
||||
|
||||
interface DetailFaultRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onViewFaultDetail?: (record: VehicleRecord, row: FaultRecordRow) => void;
|
||||
onEditFaultRecord?: (record: VehicleRecord, row: FaultRecordRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function DetailFaultRecordsTab({
|
||||
record,
|
||||
onViewFaultDetail,
|
||||
onEditFaultRecord,
|
||||
}: DetailFaultRecordsTabProps) {
|
||||
const allRows = useMemo(() => resolveVehicleFaultRecords(record), [record]);
|
||||
const [pendingFilters, setPendingFilters] = useState<FaultRecordFilters>(EMPTY_FAULT_RECORD_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<FaultRecordFilters>(EMPTY_FAULT_RECORD_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
const faultNoOptions = useMemo(
|
||||
() => buildFaultRecordOptions(allRows, 'faultNo'),
|
||||
[allRows],
|
||||
);
|
||||
const levelOptions = useMemo(() => {
|
||||
const fromData = buildFaultRecordOptions(allRows, 'faultLevel');
|
||||
return fromData.length ? fromData : [...FAULT_LEVEL_OPTIONS];
|
||||
}, [allRows]);
|
||||
const typeOptions = useMemo(
|
||||
() => buildFaultRecordOptions(allRows, 'faultType'),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyFaultRecordFilters(allRows, appliedFilters),
|
||||
[allRows, appliedFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
const updatePending = (patch: Partial<FaultRecordFilters>) => {
|
||||
setPendingFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_FAULT_RECORD_FILTERS);
|
||||
setAppliedFilters(EMPTY_FAULT_RECORD_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-fault-records-tab">
|
||||
<div className="vm-detail-tab-filter">
|
||||
<div className="vm-detail-tab-filter-grid vm-detail-tab-filter-grid--fault">
|
||||
<label className="vm-filter-field">
|
||||
<span>上报时间</span>
|
||||
<DateRangeFilterField
|
||||
startDate={pendingFilters.startDate}
|
||||
endDate={pendingFilters.endDate}
|
||||
ariaLabel="上报时间"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={(range) => updatePending(range)}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>故障编号</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.faultNo}
|
||||
options={faultNoOptions}
|
||||
placeholder="请输入或选择故障编号"
|
||||
ariaLabel="故障编号"
|
||||
onChange={(value) => updatePending({ faultNo: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>故障等级</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.faultLevel}
|
||||
options={levelOptions}
|
||||
placeholder="请选择故障等级"
|
||||
ariaLabel="故障等级"
|
||||
onChange={(value) => updatePending({ faultLevel: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>故障类型</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.faultType}
|
||||
options={typeOptions}
|
||||
placeholder="请选择故障类型"
|
||||
ariaLabel="故障类型"
|
||||
onChange={(value) => updatePending({ faultType: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>解决情况</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.resolutionStatus}
|
||||
options={[...FAULT_RESOLUTION_OPTIONS]}
|
||||
placeholder="请选择解决情况"
|
||||
ariaLabel="解决情况"
|
||||
onChange={(value) => updatePending({ resolutionStatus: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>AI匹配状态</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.aiMatched}
|
||||
options={[...FAULT_AI_MATCH_OPTIONS]}
|
||||
placeholder="请选择AI匹配状态"
|
||||
ariaLabel="AI匹配状态"
|
||||
onChange={(value) => updatePending({ aiMatched: value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-detail-tab-filter-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab vm-table-wrap--wide">
|
||||
<table className="vm-table vm-table--fault">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>故障编号</th>
|
||||
<th>解决情况</th>
|
||||
<th>车牌号</th>
|
||||
<th>车辆品牌</th>
|
||||
<th>车辆型号</th>
|
||||
<th>运营公司</th>
|
||||
<th>故障等级</th>
|
||||
<th>故障类型</th>
|
||||
<th>故障描述</th>
|
||||
<th>上报时间</th>
|
||||
<th>解决时间</th>
|
||||
<th>AI匹配状态</th>
|
||||
<th>最后操作人</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={14} className="vm-empty-cell">暂无数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link code"
|
||||
onClick={() => onViewFaultDetail?.(record, row)}
|
||||
>
|
||||
{row.faultNo}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{row.resolutionStatus === '已解决' ? (
|
||||
<StatusTag label="已解决" tone="green" />
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>{row.plateNo}</td>
|
||||
<td>{row.brand}</td>
|
||||
<td className="ellipsis" title={row.model}>{row.model}</td>
|
||||
<td className="ellipsis" title={row.operateCompany || undefined}>
|
||||
{formatFaultCell(row.operateCompany)}
|
||||
</td>
|
||||
<td>
|
||||
<StatusTag label={row.faultLevel} tone={faultLevelTone(row.faultLevel)} />
|
||||
</td>
|
||||
<td>{row.faultType}</td>
|
||||
<td className="ellipsis" title={row.faultDescription}>{row.faultDescription}</td>
|
||||
<td>{row.reportedAt}</td>
|
||||
<td>{formatFaultCell(row.resolvedAt)}</td>
|
||||
<td>
|
||||
<span className={row.aiMatched ? 'vm-fault-ai-yes' : undefined}>
|
||||
{formatAiMatched(row.aiMatched)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.lastOperator}</td>
|
||||
<td>
|
||||
<div className="vm-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onViewFaultDetail?.(record, row)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onEditFaultRecord?.(record, row)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||
<span className="vm-table-total">共 {filtered.length} 条</span>
|
||||
{filtered.length > 0 && (
|
||||
<TablePagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useId, useRef, useState } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { buildYearOptions, formatDateYmd, isEmpty } from '../utils/vehicle';
|
||||
|
||||
interface DetailInlineEditFieldProps {
|
||||
value: string;
|
||||
editType: 'year' | 'date';
|
||||
onSave: (value: string) => void;
|
||||
}
|
||||
|
||||
/** 详情内联编辑:空值显示「未上传」,编辑图标由字段卡片右侧展示 */
|
||||
export function DetailInlineEditField({ value, editType, onSave }: DetailInlineEditFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | HTMLSelectElement>(null);
|
||||
const labelId = useId();
|
||||
|
||||
const display = editType === 'date' ? formatDateYmd(value) || value : value;
|
||||
const empty = isEmpty(value);
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const commit = (next: string) => {
|
||||
setEditing(false);
|
||||
if (!isEmpty(next) && next !== value) onSave(next);
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
if (editType === 'year') {
|
||||
return (
|
||||
<select
|
||||
ref={inputRef as React.RefObject<HTMLSelectElement>}
|
||||
className="vm-detail-inline-input"
|
||||
defaultValue={isEmpty(value) ? '' : value}
|
||||
aria-labelledby={labelId}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
onBlur={() => setEditing(false)}
|
||||
>
|
||||
<option value="">请选择年份</option>
|
||||
{buildYearOptions().map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
type="date"
|
||||
className="vm-detail-inline-input"
|
||||
defaultValue={isEmpty(value) ? '' : formatDateYmd(value)}
|
||||
aria-labelledby={labelId}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
onBlur={() => setEditing(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<>
|
||||
<span id={labelId}>未上传</span>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-detail-edit-btn vm-detail-field-action"
|
||||
onClick={startEdit}
|
||||
aria-label={editType === 'year' ? '选择出厂年份' : '选择采购入库日期'}
|
||||
title={editType === 'year' ? '选择出厂年份' : '选择采购入库日期'}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{display}</span>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { ExpireDate } from './ExpireDate';
|
||||
import { StatusTag } from './StatusTag';
|
||||
import {
|
||||
insuranceStatusTone,
|
||||
purchaseTypeTone,
|
||||
resolveVehicleInsuranceOverview,
|
||||
type InsuranceHistoryRow,
|
||||
type InsuranceTypeCoverage,
|
||||
type VehicleInsuranceTypeKey,
|
||||
} from '../utils/vehicleInsuranceRecords';
|
||||
|
||||
interface DetailInsuranceRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenInsurancePurchase?: (record: VehicleRecord, insuranceLabel: string) => void;
|
||||
onPreviewPolicyFile?: (fileName: string) => void;
|
||||
onDownloadPolicyFile?: (fileName: string) => void;
|
||||
}
|
||||
|
||||
function InsuranceTypeSummaryCard({
|
||||
coverage,
|
||||
active,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
coverage: InsuranceTypeCoverage;
|
||||
active: boolean;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isNotPurchased = coverage.status === 'not_purchased';
|
||||
const showDateMeta = !isNotPurchased && Boolean(coverage.expireDate);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`vm-ins-type-card${active ? ' is-active' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="vm-ins-type-card-head">
|
||||
<span className="vm-ins-type-card-title">{coverage.label}</span>
|
||||
{count > 0 ? <span className="vm-ins-type-card-count">{count}</span> : null}
|
||||
</div>
|
||||
<div className="vm-ins-type-card-status-row">
|
||||
<StatusTag
|
||||
label={coverage.statusLabel}
|
||||
tone={insuranceStatusTone(coverage.status)}
|
||||
/>
|
||||
{showDateMeta ? (
|
||||
<span className="vm-ins-type-card-date">
|
||||
<ExpireDate date={coverage.expireDate} alwaysShowRemainder />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyFileCell({
|
||||
row,
|
||||
onPreview,
|
||||
onDownload,
|
||||
}: {
|
||||
row: InsuranceHistoryRow;
|
||||
onPreview?: (fileName: string) => void;
|
||||
onDownload?: (fileName: string) => void;
|
||||
}) {
|
||||
if (!row.policyFileName) {
|
||||
return <span className="vm-ins-policy-file-empty">无附件</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vm-ins-policy-file-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onPreview?.(row.policyFileName!)}
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<span className="vm-ins-policy-file-divider" aria-hidden>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onDownload?.(row.policyFileName!)}
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailInsuranceRecordsTab({
|
||||
record,
|
||||
onOpenInsurancePurchase,
|
||||
onPreviewPolicyFile,
|
||||
onDownloadPolicyFile,
|
||||
}: DetailInsuranceRecordsTabProps) {
|
||||
const overview = useMemo(() => resolveVehicleInsuranceOverview(record), [record]);
|
||||
const [activeType, setActiveType] = useState<VehicleInsuranceTypeKey>('compulsory');
|
||||
|
||||
const activeCoverage = overview.types.find((item) => item.key === activeType) ?? overview.types[0];
|
||||
const allInsuranceNotPurchased = overview.types.every((item) => item.status === 'not_purchased');
|
||||
|
||||
return (
|
||||
<div className="vm-ins-records-tab">
|
||||
<div className="vm-ins-records-hero">
|
||||
<div>
|
||||
<div className="vm-ins-records-hero-title">车辆保险档案</div>
|
||||
</div>
|
||||
<StatusTag
|
||||
label={`保险状态 · ${overview.overallLabel}`}
|
||||
tone={overview.overallTone}
|
||||
title={overview.overallTip}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vm-ins-type-grid">
|
||||
{overview.types.map((coverage) => (
|
||||
<InsuranceTypeSummaryCard
|
||||
key={coverage.key}
|
||||
coverage={coverage}
|
||||
active={activeType === coverage.key}
|
||||
count={coverage.history.length}
|
||||
onClick={() => setActiveType(coverage.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vm-ins-records-panel">
|
||||
<div className="vm-ins-records-panel-head">
|
||||
<h4 className="vm-ins-records-panel-title">{activeCoverage.label}</h4>
|
||||
</div>
|
||||
|
||||
{activeCoverage.history.length === 0 ? (
|
||||
<div className="vm-ins-records-empty">
|
||||
<div className="vm-ins-records-empty-title">{activeCoverage.label}暂无记录</div>
|
||||
{!allInsuranceNotPurchased ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-primary vm-ins-records-empty-upload"
|
||||
onClick={() => onOpenInsurancePurchase?.(record, activeCoverage.label)}
|
||||
>
|
||||
去上传
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="vm-model-param-grid vm-ins-records-summary">
|
||||
<div className="vm-model-param-item">
|
||||
<span className="vm-model-param-label">保单号</span>
|
||||
<div className="vm-model-param-value">{activeCoverage.policyNo || '—'}</div>
|
||||
</div>
|
||||
<div className="vm-model-param-item">
|
||||
<span className="vm-model-param-label">保险公司</span>
|
||||
<div className="vm-model-param-value">{activeCoverage.company || '—'}</div>
|
||||
</div>
|
||||
<div className="vm-model-param-item">
|
||||
<span className="vm-model-param-label">到期日期</span>
|
||||
<div className="vm-model-param-value">
|
||||
{activeCoverage.expireDate
|
||||
? <ExpireDate date={activeCoverage.expireDate} alwaysShowRemainder />
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vm-model-param-item">
|
||||
<span className="vm-model-param-label">保费(元)</span>
|
||||
<div className="vm-model-param-value">{activeCoverage.premium || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab">
|
||||
<table className="vm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>保险导入时间</th>
|
||||
<th>操作人</th>
|
||||
<th>类型</th>
|
||||
<th>保单号</th>
|
||||
<th>保险状态</th>
|
||||
<th>保险公司</th>
|
||||
<th>付款时间</th>
|
||||
<th>到期日期</th>
|
||||
<th>保单文件</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeCoverage.history.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.purchaseTime || '—'}</td>
|
||||
<td>{row.operator || '—'}</td>
|
||||
<td>
|
||||
<StatusTag label={row.purchaseType} tone={purchaseTypeTone(row.purchaseType)} />
|
||||
</td>
|
||||
<td><span className="code">{row.policyNo || '—'}</span></td>
|
||||
<td>
|
||||
<StatusTag
|
||||
label={row.policyStatus}
|
||||
tone={row.policyStatus === '已停保' ? 'amber' : row.policyStatus === '已退保' ? 'gray' : 'green'}
|
||||
/>
|
||||
</td>
|
||||
<td className="ellipsis" title={row.company !== '—' ? row.company : undefined}>
|
||||
{row.company || '—'}
|
||||
</td>
|
||||
<td>{row.payTime || '—'}</td>
|
||||
<td>
|
||||
{row.expireDate ? (
|
||||
<ExpireDate date={row.expireDate} alwaysShowRemainder />
|
||||
) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<PolicyFileCell
|
||||
row={row}
|
||||
onPreview={onPreviewPolicyFile}
|
||||
onDownload={onDownloadPolicyFile}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { TablePagination } from './TablePagination';
|
||||
import {
|
||||
applyLeaseRecordFilters,
|
||||
buildLeaseRecordOptions,
|
||||
EMPTY_LEASE_RECORD_FILTERS,
|
||||
formatLeaseRecordCell,
|
||||
isLeaseRecordLinkable,
|
||||
resolveVehicleLeaseRecords,
|
||||
type LeaseRecordFilters,
|
||||
type LeaseRecordRow,
|
||||
} from '../utils/leaseRecords';
|
||||
|
||||
interface DetailLeaseRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenLeaseContract?: (record: VehicleRecord, contractNo: string) => void;
|
||||
onOpenDeliveryRecord?: (record: VehicleRecord, row: LeaseRecordRow) => void;
|
||||
onOpenReturnRecord?: (record: VehicleRecord, row: LeaseRecordRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function DetailLeaseRecordsTab({
|
||||
record,
|
||||
onOpenLeaseContract,
|
||||
onOpenDeliveryRecord,
|
||||
onOpenReturnRecord,
|
||||
}: DetailLeaseRecordsTabProps) {
|
||||
const allRows = useMemo(() => resolveVehicleLeaseRecords(record), [record]);
|
||||
const [pendingFilters, setPendingFilters] = useState<LeaseRecordFilters>(EMPTY_LEASE_RECORD_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<LeaseRecordFilters>(EMPTY_LEASE_RECORD_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
const contractOptions = useMemo(
|
||||
() => buildLeaseRecordOptions(allRows, 'contractNo'),
|
||||
[allRows],
|
||||
);
|
||||
const projectOptions = useMemo(
|
||||
() => buildLeaseRecordOptions(allRows, 'projectName'),
|
||||
[allRows],
|
||||
);
|
||||
const customerOptions = useMemo(
|
||||
() => buildLeaseRecordOptions(allRows, 'customerName'),
|
||||
[allRows],
|
||||
);
|
||||
const businessTypeOptions = useMemo(
|
||||
() => buildLeaseRecordOptions(allRows, 'businessType'),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyLeaseRecordFilters(allRows, appliedFilters),
|
||||
[allRows, appliedFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
const updatePending = (patch: Partial<LeaseRecordFilters>) => {
|
||||
setPendingFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_LEASE_RECORD_FILTERS);
|
||||
setAppliedFilters(EMPTY_LEASE_RECORD_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openContract = (contractNo: string) => {
|
||||
if (!isLeaseRecordLinkable(contractNo)) return;
|
||||
onOpenLeaseContract?.(record, contractNo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-lease-records-tab">
|
||||
<div className="vm-detail-tab-filter">
|
||||
<div className="vm-detail-tab-filter-grid">
|
||||
<label className="vm-filter-field">
|
||||
<span>合同编码</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.contractNo}
|
||||
options={contractOptions}
|
||||
placeholder="请输入或选择合同编码"
|
||||
ariaLabel="合同编码"
|
||||
onChange={(value) => updatePending({ contractNo: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>项目名称</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.projectName}
|
||||
options={projectOptions}
|
||||
placeholder="请输入或选择项目名称"
|
||||
ariaLabel="项目名称"
|
||||
onChange={(value) => updatePending({ projectName: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>客户名称</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.customerName}
|
||||
options={customerOptions}
|
||||
placeholder="请输入或选择客户名称"
|
||||
ariaLabel="客户名称"
|
||||
onChange={(value) => updatePending({ customerName: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>业务类型</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.businessType}
|
||||
options={businessTypeOptions}
|
||||
placeholder="请输入或选择业务类型"
|
||||
ariaLabel="业务类型"
|
||||
onChange={(value) => updatePending({ businessType: value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-detail-tab-filter-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab">
|
||||
<table className="vm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合同编码</th>
|
||||
<th>项目名称</th>
|
||||
<th>客户名称</th>
|
||||
<th>业务类型</th>
|
||||
<th>交车日期</th>
|
||||
<th>交车人</th>
|
||||
<th>还车日期</th>
|
||||
<th>还车人</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="vm-empty-cell">暂无租赁记录</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
{isLeaseRecordLinkable(row.contractNo) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link code"
|
||||
onClick={() => openContract(row.contractNo)}
|
||||
>
|
||||
{row.contractNo}
|
||||
</button>
|
||||
) : (
|
||||
<span className="code">{formatLeaseRecordCell(row.contractNo)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="ellipsis" title={row.projectName || undefined}>
|
||||
{isLeaseRecordLinkable(row.projectName) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link primary"
|
||||
onClick={() => openContract(row.contractNo)}
|
||||
>
|
||||
{row.projectName}
|
||||
</button>
|
||||
) : (
|
||||
formatLeaseRecordCell(row.projectName)
|
||||
)}
|
||||
</td>
|
||||
<td className="ellipsis" title={row.customerName || undefined}>
|
||||
{formatLeaseRecordCell(row.customerName)}
|
||||
</td>
|
||||
<td>{row.businessType}</td>
|
||||
<td>
|
||||
{isLeaseRecordLinkable(row.deliveryDate) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onOpenDeliveryRecord?.(record, row)}
|
||||
>
|
||||
{row.deliveryDate}
|
||||
</button>
|
||||
) : (
|
||||
formatLeaseRecordCell(row.deliveryDate)
|
||||
)}
|
||||
</td>
|
||||
<td>{formatLeaseRecordCell(row.deliveryPerson)}</td>
|
||||
<td>
|
||||
{isLeaseRecordLinkable(row.returnDate) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onOpenReturnRecord?.(record, row)}
|
||||
>
|
||||
{row.returnDate}
|
||||
</button>
|
||||
) : (
|
||||
formatLeaseRecordCell(row.returnDate)
|
||||
)}
|
||||
</td>
|
||||
<td>{formatLeaseRecordCell(row.returnPerson)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||
<span className="vm-table-total">共 {filtered.length} 条</span>
|
||||
{filtered.length > 0 && (
|
||||
<TablePagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { DetailParamField, DetailParamSection } from './DetailParamLayout';
|
||||
import { isShanghaiPlate } from '../utils/vehicle';
|
||||
import {
|
||||
certPhotoStatus,
|
||||
formatLicenseBalance,
|
||||
formatLicenseDisplay,
|
||||
getLicenseCertBadgeStatus,
|
||||
LICENSE_CERT_NAV,
|
||||
LICENSE_CERT_STATUS_LEGEND,
|
||||
resolveVehicleLicenseBundle,
|
||||
type LicenseCertBadgeStatus,
|
||||
type VehicleLicenseBundle,
|
||||
} from '../utils/vehicleLicenses';
|
||||
|
||||
interface DetailLicenseInfoTabProps {
|
||||
record: VehicleRecord;
|
||||
}
|
||||
|
||||
interface LicensePhotoPreviewState {
|
||||
url: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const LicensePhotoPreviewContext = React.createContext<((url: string, alt: string) => void) | null>(null);
|
||||
|
||||
function useLicensePhotoPreview() {
|
||||
return React.useContext(LicensePhotoPreviewContext);
|
||||
}
|
||||
|
||||
function LicenseStatusDot({ status }: { status: LicenseCertBadgeStatus }) {
|
||||
return <span className={`vm-license-status-dot vm-license-status-dot--${status}`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function LicenseCertLegend() {
|
||||
return (
|
||||
<div className="vm-license-legend">
|
||||
<span className="vm-license-legend-label">证件状态图示</span>
|
||||
<div className="vm-license-legend-items">
|
||||
{LICENSE_CERT_STATUS_LEGEND.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
className={`vm-license-legend-item${item.title ? ' vm-license-legend-item--help' : ''}`}
|
||||
title={item.title}
|
||||
>
|
||||
<LicenseStatusDot status={item.status} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LicensePhotoPreview({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vm-license-preview"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="照片预览"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-license-preview-close"
|
||||
aria-label="关闭预览"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="vm-license-preview-body" onClick={(event) => event.stopPropagation()}>
|
||||
<img src={src} alt={alt} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LicensePhotoGrid({ photos, alt }: { photos: string[]; alt: string }) {
|
||||
const onPreview = useLicensePhotoPreview();
|
||||
if (!photos.length) {
|
||||
return <div className="vm-license-photos-empty">未上传</div>;
|
||||
}
|
||||
return (
|
||||
<div className="vm-license-photo-grid">
|
||||
{photos.map((url, index) => {
|
||||
const imageAlt = `${alt}${index + 1}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-license-photo"
|
||||
key={`${url}-${index}`}
|
||||
aria-label={`预览${imageAlt}`}
|
||||
onClick={() => onPreview?.(url, imageAlt)}
|
||||
>
|
||||
<img src={url} alt={imageAlt} loading="lazy" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CertStatusBadge({ status }: { status: 'uploaded' | 'empty' }) {
|
||||
return (
|
||||
<span className={`vm-license-status vm-license-status--${status}`}>
|
||||
{status === 'uploaded' ? '已上传' : '未上传'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseCertCard({
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
status?: 'uploaded' | 'empty';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="vm-license-cert" id={id}>
|
||||
<div className="vm-license-cert-head">
|
||||
<h4 className="vm-license-cert-title">{title}</h4>
|
||||
{status && <CertStatusBadge status={status} />}
|
||||
</div>
|
||||
<div className="vm-license-cert-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaFields({ items }: { items: Array<{ label: string; value: string }> }) {
|
||||
const visible = items.filter((item) => item.value && item.value !== '未上传');
|
||||
if (!visible.length) return null;
|
||||
return (
|
||||
<div className="vm-model-param-grid vm-license-meta-grid">
|
||||
{visible.map((item) => (
|
||||
<DetailParamField key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DriverLicenseSection({
|
||||
bundle,
|
||||
shanghaiPlate,
|
||||
}: {
|
||||
bundle: VehicleLicenseBundle['driverLicense'];
|
||||
shanghaiPlate: boolean;
|
||||
}) {
|
||||
return (
|
||||
<LicenseCertCard
|
||||
id="driverLicense"
|
||||
title="行驶证"
|
||||
status={certPhotoStatus(bundle)}
|
||||
>
|
||||
<LicensePhotoGrid photos={bundle.photos} alt="行驶证" />
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField label="注册日期" value={formatLicenseDisplay(bundle.regDate)} empty={!bundle.regDate} />
|
||||
<DetailParamField label="发证日期" value={formatLicenseDisplay(bundle.issueDate)} empty={!bundle.issueDate} />
|
||||
<DetailParamField label="强制报废日期" value={formatLicenseDisplay(bundle.scrapDate)} empty={!bundle.scrapDate} />
|
||||
<DetailParamField label="检验有效期至" value={formatLicenseDisplay(bundle.expireDate)} empty={!bundle.expireDate} />
|
||||
</div>
|
||||
{shanghaiPlate && (
|
||||
<div className="vm-license-sh-panel">
|
||||
<div className="vm-license-sh-badge">沪牌车辆等级评定</div>
|
||||
<div className="vm-model-param-grid vm-model-param-grid--1">
|
||||
<DetailParamField
|
||||
label="下次等级评定时间"
|
||||
value={formatLicenseDisplay(bundle.shNextEvaluation)}
|
||||
empty={!bundle.shNextEvaluation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MetaFields items={[
|
||||
{ label: '更新方式', value: formatLicenseDisplay(bundle.updateType) },
|
||||
{ label: '更新时间', value: formatLicenseDisplay(bundle.updateTime) },
|
||||
{ label: '更新人', value: formatLicenseDisplay(bundle.updateUser) },
|
||||
]}
|
||||
/>
|
||||
</LicenseCertCard>
|
||||
);
|
||||
}
|
||||
|
||||
function TransportLicenseSection({ bundle }: { bundle: VehicleLicenseBundle['transportLicense'] }) {
|
||||
return (
|
||||
<LicenseCertCard id="transportLicense" title="道路运输证" status={certPhotoStatus(bundle)}>
|
||||
<LicensePhotoGrid photos={bundle.photos} alt="道路运输证" />
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField label="经营许可证号" value={formatLicenseDisplay(bundle.licenseNo)} empty={!bundle.licenseNo} />
|
||||
<DetailParamField label="核发时间" value={formatLicenseDisplay(bundle.issueDate)} empty={!bundle.issueDate} />
|
||||
<DetailParamField label="证件有效期" value={formatLicenseDisplay(bundle.expireDate)} empty={!bundle.expireDate} />
|
||||
<DetailParamField label="审验有效期" value={formatLicenseDisplay(bundle.inspectValidUntil)} empty={!bundle.inspectValidUntil} />
|
||||
</div>
|
||||
<MetaFields items={[
|
||||
{ label: '更新时间', value: formatLicenseDisplay(bundle.updateTime) },
|
||||
{ label: '更新人', value: formatLicenseDisplay(bundle.updateUser) },
|
||||
]}
|
||||
/>
|
||||
</LicenseCertCard>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoOnlyCertSection({
|
||||
id,
|
||||
title,
|
||||
photos,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
photos: string[];
|
||||
}) {
|
||||
return (
|
||||
<LicenseCertCard id={id} title={title} status={certPhotoStatus({ photos })}>
|
||||
<LicensePhotoGrid photos={photos} alt={title} />
|
||||
</LicenseCertCard>
|
||||
);
|
||||
}
|
||||
|
||||
function HydrogenCardSection({ bundle }: { bundle: VehicleLicenseBundle['hydrogenCard'] }) {
|
||||
const hasCard = Boolean(bundle.cardNo);
|
||||
if (!hasCard) {
|
||||
return (
|
||||
<LicenseCertCard id="hydrogenCard" title="加氢卡" status="empty">
|
||||
<div className="vm-license-h2-empty">
|
||||
<div className="vm-license-h2-empty-icon" aria-hidden="true">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="vm-license-h2-empty-title">无加氢卡</div>
|
||||
<p className="vm-license-h2-empty-desc">该车辆尚未绑定加氢卡</p>
|
||||
</div>
|
||||
</LicenseCertCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LicenseCertCard id="hydrogenCard" title="加氢卡" status="uploaded">
|
||||
<div className="vm-license-h2-card">
|
||||
<div className="vm-license-h2-card-no">{bundle.cardNo}</div>
|
||||
<div className="vm-license-h2-card-balance">
|
||||
¥ {formatLicenseBalance(bundle.balance)}
|
||||
</div>
|
||||
<div className="vm-license-h2-card-meta">
|
||||
<span>{bundle.cardType || '中石化加氢卡'}</span>
|
||||
<span>{bundle.issueUser || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField label="加氢卡号" value={bundle.cardNo} />
|
||||
<DetailParamField label="实时余额" value={`¥ ${formatLicenseBalance(bundle.balance)}`} />
|
||||
<DetailParamField label="配发时间" value={formatLicenseDisplay(bundle.issueDate)} empty={!bundle.issueDate} />
|
||||
<DetailParamField label="配发经办人" value={formatLicenseDisplay(bundle.issueUser)} empty={!bundle.issueUser} />
|
||||
</div>
|
||||
</LicenseCertCard>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectCertSection({
|
||||
id,
|
||||
title,
|
||||
bundle,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
bundle: VehicleLicenseBundle['safetyValve'];
|
||||
}) {
|
||||
return (
|
||||
<LicenseCertCard id={id} title={title} status={certPhotoStatus(bundle)}>
|
||||
<LicensePhotoGrid photos={bundle.photos} alt={title} />
|
||||
<div className="vm-model-param-grid vm-model-param-grid--2">
|
||||
<DetailParamField label="本次检验日期" value={formatLicenseDisplay(bundle.inspectDate)} empty={!bundle.inspectDate} />
|
||||
<DetailParamField
|
||||
label={id === 'pressureGauge' ? '下次检验日期' : '下次检测日期'}
|
||||
value={formatLicenseDisplay(bundle.nextInspectDate)}
|
||||
empty={!bundle.nextInspectDate}
|
||||
/>
|
||||
</div>
|
||||
</LicenseCertCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailLicenseInfoTab({ record }: DetailLicenseInfoTabProps) {
|
||||
const [activeNav, setActiveNav] = useState<string>('driverLicense');
|
||||
const [preview, setPreview] = useState<LicensePhotoPreviewState | null>(null);
|
||||
const licenses = resolveVehicleLicenseBundle(record);
|
||||
const shanghaiPlate = isShanghaiPlate(record.plateNo);
|
||||
|
||||
const openPreview = useCallback((url: string, alt: string) => {
|
||||
setPreview({ url, alt });
|
||||
}, []);
|
||||
|
||||
const closePreview = useCallback(() => {
|
||||
setPreview(null);
|
||||
}, []);
|
||||
|
||||
const scrollToCert = (key: string) => {
|
||||
setActiveNav(key);
|
||||
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<LicensePhotoPreviewContext.Provider value={openPreview}>
|
||||
<div className="vm-license-tab vm-model-param-tab--readonly">
|
||||
<DetailParamSection title="证照索引">
|
||||
<LicenseCertLegend />
|
||||
<nav className="vm-license-nav" aria-label="证照分类">
|
||||
{LICENSE_CERT_NAV.map((item) => {
|
||||
const badgeStatus = getLicenseCertBadgeStatus(licenses, item.key);
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`vm-license-nav-item${activeNav === item.key ? ' is-active' : ''}`}
|
||||
onClick={() => scrollToCert(item.key)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<LicenseStatusDot status={badgeStatus} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</DetailParamSection>
|
||||
|
||||
<div className="vm-license-cert-list">
|
||||
<DriverLicenseSection bundle={licenses.driverLicense} shanghaiPlate={shanghaiPlate} />
|
||||
<TransportLicenseSection bundle={licenses.transportLicense} />
|
||||
<PhotoOnlyCertSection
|
||||
id="registrationCert"
|
||||
title="登记证"
|
||||
photos={licenses.registrationCert.photos}
|
||||
/>
|
||||
<PhotoOnlyCertSection
|
||||
id="specialEquipCert"
|
||||
title="特种设备使用登记证"
|
||||
photos={licenses.specialEquipCert.photos}
|
||||
/>
|
||||
<LicenseCertCard id="specialEquipDecal" title="特种设备使用标识" status={certPhotoStatus(licenses.specialEquipDecal)}>
|
||||
<LicensePhotoGrid photos={licenses.specialEquipDecal.photos} alt="特种设备使用标识" />
|
||||
<div className="vm-model-param-grid vm-model-param-grid--1">
|
||||
<DetailParamField
|
||||
label="下次检验日期"
|
||||
value={formatLicenseDisplay(licenses.specialEquipDecal.nextInspectDate)}
|
||||
empty={!licenses.specialEquipDecal.nextInspectDate}
|
||||
/>
|
||||
</div>
|
||||
<MetaFields items={[
|
||||
{ label: '更新时间', value: formatLicenseDisplay(licenses.specialEquipDecal.updateTime) },
|
||||
{ label: '更新人', value: formatLicenseDisplay(licenses.specialEquipDecal.updateUser) },
|
||||
]}
|
||||
/>
|
||||
</LicenseCertCard>
|
||||
<HydrogenCardSection bundle={licenses.hydrogenCard} />
|
||||
<InspectCertSection id="safetyValve" title="安全阀" bundle={licenses.safetyValve} />
|
||||
<InspectCertSection id="pressureGauge" title="压力表" bundle={licenses.pressureGauge} />
|
||||
</div>
|
||||
{preview && (
|
||||
<LicensePhotoPreview src={preview.url} alt={preview.alt} onClose={closePreview} />
|
||||
)}
|
||||
</div>
|
||||
</LicensePhotoPreviewContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import {
|
||||
resolveVehicleModelParams,
|
||||
type VehicleModelParams,
|
||||
} from '../utils/modelParams';
|
||||
import { DetailParamField, DetailParamSection } from './DetailParamLayout';
|
||||
|
||||
interface DetailModelParamsTabProps {
|
||||
record: VehicleRecord;
|
||||
}
|
||||
|
||||
function MaintenanceTable({ rows }: { rows: VehicleModelParams['maintenanceItems'] }) {
|
||||
return (
|
||||
<div className="vm-model-param-table-wrap">
|
||||
<table className="vm-model-param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>养护项目</th>
|
||||
<th>保养公里周期(KM)</th>
|
||||
<th>保养时间周期(月)</th>
|
||||
<th>工时费(元)</th>
|
||||
<th>材料费(元)</th>
|
||||
<th>合计(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.item}</td>
|
||||
<td>{row.kmCycle}</td>
|
||||
<td>{row.monthCycle}</td>
|
||||
<td>{row.labor}</td>
|
||||
<td>{row.material}</td>
|
||||
<td>{row.total}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyValue(value: string): boolean {
|
||||
return value === '无' || value === '—' || value === '-';
|
||||
}
|
||||
|
||||
export function DetailModelParamsTab({ record }: DetailModelParamsTabProps) {
|
||||
const params = resolveVehicleModelParams(record);
|
||||
|
||||
return (
|
||||
<div className="vm-model-param-tab vm-model-param-tab--readonly" aria-readonly="true">
|
||||
<DetailParamSection title="基本情况">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField label="品牌" value={params.brand} empty={isEmptyValue(params.brand)} />
|
||||
<DetailParamField label="型号" value={params.model} empty={isEmptyValue(params.model)} />
|
||||
<DetailParamField label="车型" value={params.vehicleModel} empty={isEmptyValue(params.vehicleModel)} />
|
||||
<DetailParamField label="公告型号" value={params.announcementModel} empty={isEmptyValue(params.announcementModel)} />
|
||||
<DetailParamField label="燃料种类" value={params.fuelType} />
|
||||
<DetailParamField label="车辆类型" value={params.vehicleType} empty={isEmptyValue(params.vehicleType)} />
|
||||
<DetailParamField label="车辆长度(mm)" value={params.lengthMm} />
|
||||
<DetailParamField label="车辆宽度(mm)" value={params.widthMm} />
|
||||
<DetailParamField label="车辆高度(mm)" value={params.heightMm} />
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="轮轴情况">
|
||||
<div className="vm-model-param-grid vm-model-param-grid--2">
|
||||
<DetailParamField label="轮胎数量" value={params.tireCount} />
|
||||
<DetailParamField label="轮胎磨损费用" value={params.tireWearFee} />
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="电气情况">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField label="电池类型" value={params.batteryType} />
|
||||
<DetailParamField label="电池厂家" value={params.batteryVendor} />
|
||||
<DetailParamField label="总电量" value={params.totalPower} />
|
||||
<DetailParamField label="电池续航里程(KM)" value={params.batteryRangeKm} wide />
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="供氢系统情况">
|
||||
<div className="vm-model-param-grid">
|
||||
<DetailParamField label="氢瓶容量" value={params.hydrogenCapacity} empty={isEmptyValue(params.hydrogenCapacity)} />
|
||||
<DetailParamField label="仪表盘显示模式" value={params.gaugeMode} empty={isEmptyValue(params.gaugeMode)} />
|
||||
<DetailParamField label="氢续航里程" value={params.hydrogenRangeKm} empty={isEmptyValue(params.hydrogenRangeKm)} />
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="其他系统情况">
|
||||
<div className="vm-model-param-grid vm-model-param-grid--1">
|
||||
<DetailParamField label="冷机厂家" value={params.coldUnitVendor} wide />
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
<DetailParamSection title="保养规则">
|
||||
<MaintenanceTable rows={params.maintenanceItems} />
|
||||
</DetailParamSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { DateRangeFilterField } from './DateRangeFilterField';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { StatusTag } from './StatusTag';
|
||||
import { TablePagination } from './TablePagination';
|
||||
import {
|
||||
applyMovementRecordFilters,
|
||||
buildMovementRecordOptions,
|
||||
EMPTY_MOVEMENT_RECORD_FILTERS,
|
||||
formatMovementNumber,
|
||||
formatMovementPercent,
|
||||
movementStatusTone,
|
||||
resolveVehicleMovementRecords,
|
||||
type MovementRecordFilters,
|
||||
type MovementRecordRow,
|
||||
} from '../utils/movementRecords';
|
||||
|
||||
interface DetailMovementRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenMovementDetail?: (record: VehicleRecord, row: MovementRecordRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function DetailMovementRecordsTab({
|
||||
record,
|
||||
onOpenMovementDetail,
|
||||
}: DetailMovementRecordsTabProps) {
|
||||
const allRows = useMemo(() => resolveVehicleMovementRecords(record), [record]);
|
||||
const [pendingFilters, setPendingFilters] = useState<MovementRecordFilters>(EMPTY_MOVEMENT_RECORD_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<MovementRecordFilters>(EMPTY_MOVEMENT_RECORD_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
const destinationOptions = useMemo(
|
||||
() => buildMovementRecordOptions(allRows, 'destinationType'),
|
||||
[allRows],
|
||||
);
|
||||
const movementTypeOptions = useMemo(
|
||||
() => buildMovementRecordOptions(allRows, 'movementType'),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyMovementRecordFilters(allRows, appliedFilters),
|
||||
[allRows, appliedFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
const updatePending = (patch: Partial<MovementRecordFilters>) => {
|
||||
setPendingFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_MOVEMENT_RECORD_FILTERS);
|
||||
setAppliedFilters(EMPTY_MOVEMENT_RECORD_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-movement-records-tab">
|
||||
<div className="vm-detail-tab-filter">
|
||||
<div className="vm-detail-tab-filter-grid vm-detail-tab-filter-grid--movement">
|
||||
<label className="vm-filter-field">
|
||||
<span>异动开始日期</span>
|
||||
<DateRangeFilterField
|
||||
startDate={pendingFilters.startDate}
|
||||
endDate={pendingFilters.endDate}
|
||||
ariaLabel="异动开始日期"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={(range) => updatePending(range)}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>异动目的地</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.destinationType}
|
||||
options={destinationOptions}
|
||||
placeholder="请选择异动目的地"
|
||||
ariaLabel="异动目的地"
|
||||
onChange={(value) => updatePending({ destinationType: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>异动类型</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.movementType}
|
||||
options={movementTypeOptions}
|
||||
placeholder="请选择异动类型"
|
||||
ariaLabel="异动类型"
|
||||
onChange={(value) => updatePending({ movementType: value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-detail-tab-filter-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab vm-table-wrap--wide">
|
||||
<table className="vm-table vm-table--movement">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>异动开始日期</th>
|
||||
<th>异动预计结束日期</th>
|
||||
<th>异动状态</th>
|
||||
<th>异动目的地</th>
|
||||
<th>目的地名称</th>
|
||||
<th>异动类型</th>
|
||||
<th>预计异动里程 (km)</th>
|
||||
<th>异动开始里程 (km)</th>
|
||||
<th>异动开始电量 (kWh)</th>
|
||||
<th>异动开始氢量</th>
|
||||
<th>异动结束里程 (km)</th>
|
||||
<th>异动结束电量 (kWh)</th>
|
||||
<th>异动结束氢量</th>
|
||||
<th>创建人</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={15} className="vm-empty-cell">暂无数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onOpenMovementDetail?.(record, row)}
|
||||
>
|
||||
{row.startDate}
|
||||
</button>
|
||||
</td>
|
||||
<td>{row.estimatedEndDate}</td>
|
||||
<td>
|
||||
<StatusTag label={row.status} tone={movementStatusTone(row.status)} />
|
||||
</td>
|
||||
<td>{row.destinationType}</td>
|
||||
<td className="ellipsis" title={row.destinationName}>{row.destinationName}</td>
|
||||
<td>{row.movementType}</td>
|
||||
<td className="vm-table-num">{formatMovementNumber(row.estimatedMileageKm)}</td>
|
||||
<td className="vm-table-num">{formatMovementNumber(row.startMileageKm)}</td>
|
||||
<td className="vm-table-num">{formatMovementNumber(row.startBatteryKwh, 1)}</td>
|
||||
<td className="vm-table-num">{formatMovementPercent(row.startHydrogenPct)}</td>
|
||||
<td className="vm-table-num">{formatMovementNumber(row.endMileageKm)}</td>
|
||||
<td className="vm-table-num">{formatMovementNumber(row.endBatteryKwh, 1)}</td>
|
||||
<td className="vm-table-num">{formatMovementPercent(row.endHydrogenPct)}</td>
|
||||
<td>{row.createdBy}</td>
|
||||
<td>{row.createdAt}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||
<span className="vm-table-total">共 {filtered.length} 条</span>
|
||||
{filtered.length > 0 && (
|
||||
<TablePagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
export function DetailParamSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="vm-model-param-section">
|
||||
<h3 className="vm-model-param-section-title">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailParamField({
|
||||
label,
|
||||
value,
|
||||
wide = false,
|
||||
empty = false,
|
||||
actionable = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
wide?: boolean;
|
||||
empty?: boolean;
|
||||
actionable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'vm-model-param-item',
|
||||
wide ? 'vm-model-param-item--wide' : '',
|
||||
actionable ? 'vm-model-param-item--actionable' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className="vm-model-param-label">{label}</span>
|
||||
<div className={`vm-model-param-value${empty ? ' is-empty' : ''}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { DateRangeFilterField } from './DateRangeFilterField';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { TablePagination } from './TablePagination';
|
||||
import {
|
||||
applyTransferRecordFilters,
|
||||
buildTransferRecordOptions,
|
||||
EMPTY_TRANSFER_RECORD_FILTERS,
|
||||
formatTransferCell,
|
||||
resolveVehicleTransferRecords,
|
||||
type TransferRecordFilters,
|
||||
type TransferRecordRow,
|
||||
} from '../utils/transferRecords';
|
||||
|
||||
interface DetailTransferRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenTransferDetail?: (record: VehicleRecord, row: TransferRecordRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function DetailTransferRecordsTab({
|
||||
record,
|
||||
onOpenTransferDetail,
|
||||
}: DetailTransferRecordsTabProps) {
|
||||
const allRows = useMemo(() => resolveVehicleTransferRecords(record), [record]);
|
||||
const [pendingFilters, setPendingFilters] = useState<TransferRecordFilters>(EMPTY_TRANSFER_RECORD_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<TransferRecordFilters>(EMPTY_TRANSFER_RECORD_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
const transferOutOptions = useMemo(
|
||||
() => buildTransferRecordOptions(allRows, 'transferOutPerson'),
|
||||
[allRows],
|
||||
);
|
||||
const recipientOptions = useMemo(
|
||||
() => buildTransferRecordOptions(allRows, 'recipientPerson'),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyTransferRecordFilters(allRows, appliedFilters),
|
||||
[allRows, appliedFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
const updatePending = (patch: Partial<TransferRecordFilters>) => {
|
||||
setPendingFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_TRANSFER_RECORD_FILTERS);
|
||||
setAppliedFilters(EMPTY_TRANSFER_RECORD_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-transfer-records-tab">
|
||||
<div className="vm-detail-tab-filter">
|
||||
<div className="vm-detail-tab-filter-grid vm-detail-tab-filter-grid--transfer">
|
||||
<label className="vm-filter-field">
|
||||
<span>调拨日期</span>
|
||||
<DateRangeFilterField
|
||||
startDate={pendingFilters.startDate}
|
||||
endDate={pendingFilters.endDate}
|
||||
ariaLabel="调拨日期"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={(range) => updatePending(range)}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>调出人</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.transferOutPerson}
|
||||
options={transferOutOptions}
|
||||
placeholder="请选择调出人"
|
||||
ariaLabel="调出人"
|
||||
onChange={(value) => updatePending({ transferOutPerson: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>接收人</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.recipientPerson}
|
||||
options={recipientOptions}
|
||||
placeholder="请选择接收人"
|
||||
ariaLabel="接收人"
|
||||
onChange={(value) => updatePending({ recipientPerson: value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-detail-tab-filter-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab">
|
||||
<table className="vm-table vm-table--transfer">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>调拨日期</th>
|
||||
<th>调出人</th>
|
||||
<th>接收人</th>
|
||||
<th>出发区域</th>
|
||||
<th>接收区域</th>
|
||||
<th>调拨方式</th>
|
||||
<th>出发停车场</th>
|
||||
<th>接收停车场</th>
|
||||
<th>接收日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="vm-empty-cell">暂无数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onOpenTransferDetail?.(record, row)}
|
||||
>
|
||||
{row.transferDate}
|
||||
</button>
|
||||
</td>
|
||||
<td>{row.transferOutPerson}</td>
|
||||
<td>{row.recipientPerson}</td>
|
||||
<td>{row.departureArea}</td>
|
||||
<td>{row.receivingArea}</td>
|
||||
<td>{row.transferMethod}</td>
|
||||
<td className="ellipsis" title={row.departureParking}>{formatTransferCell(row.departureParking)}</td>
|
||||
<td className="ellipsis" title={row.receivingParking}>{formatTransferCell(row.receivingParking)}</td>
|
||||
<td>{formatTransferCell(row.receiveDate)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||
<span className="vm-table-total">共 {filtered.length} 条</span>
|
||||
{filtered.length > 0 && (
|
||||
<TablePagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { DateRangeFilterField } from './DateRangeFilterField';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { StatusTag } from './StatusTag';
|
||||
import { TablePagination } from './TablePagination';
|
||||
import {
|
||||
applyViolationRecordFilters,
|
||||
buildViolationRecordOptions,
|
||||
EMPTY_VIOLATION_RECORD_FILTERS,
|
||||
formatViolationFine,
|
||||
formatViolationPoints,
|
||||
resolveVehicleViolationRecords,
|
||||
VIOLATION_PAYMENT_STATUS_OPTIONS,
|
||||
VIOLATION_PROCESSED_OPTIONS,
|
||||
violationPaymentTone,
|
||||
violationProcessedTone,
|
||||
type ViolationRecordFilters,
|
||||
type ViolationRecordRow,
|
||||
} from '../utils/violationRecords';
|
||||
|
||||
interface DetailViolationRecordsTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenViolationDetail?: (record: VehicleRecord, row: ViolationRecordRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function DetailViolationRecordsTab({
|
||||
record,
|
||||
onOpenViolationDetail,
|
||||
}: DetailViolationRecordsTabProps) {
|
||||
const allRows = useMemo(() => resolveVehicleViolationRecords(record), [record]);
|
||||
const [pendingFilters, setPendingFilters] = useState<ViolationRecordFilters>(EMPTY_VIOLATION_RECORD_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<ViolationRecordFilters>(EMPTY_VIOLATION_RECORD_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
const customerOptions = useMemo(
|
||||
() => buildViolationRecordOptions(allRows, 'customerName'),
|
||||
[allRows],
|
||||
);
|
||||
const paymentOptions = useMemo(() => {
|
||||
const fromData = buildViolationRecordOptions(allRows, 'paymentStatus');
|
||||
return fromData.length ? fromData : [...VIOLATION_PAYMENT_STATUS_OPTIONS];
|
||||
}, [allRows]);
|
||||
const processedOptions = useMemo(() => {
|
||||
const fromData = buildViolationRecordOptions(allRows, 'processed');
|
||||
return fromData.length ? fromData : [...VIOLATION_PROCESSED_OPTIONS];
|
||||
}, [allRows]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyViolationRecordFilters(allRows, appliedFilters),
|
||||
[allRows, appliedFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
const updatePending = (patch: Partial<ViolationRecordFilters>) => {
|
||||
setPendingFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_VIOLATION_RECORD_FILTERS);
|
||||
setAppliedFilters(EMPTY_VIOLATION_RECORD_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-violation-records-tab">
|
||||
<div className="vm-detail-tab-filter">
|
||||
<div className="vm-detail-tab-filter-grid vm-detail-tab-filter-grid--violation">
|
||||
<label className="vm-filter-field">
|
||||
<span>违法时间</span>
|
||||
<DateRangeFilterField
|
||||
startDate={pendingFilters.startDate}
|
||||
endDate={pendingFilters.endDate}
|
||||
ariaLabel="违法时间"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={(range) => updatePending(range)}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>客户名称</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.customerName}
|
||||
options={customerOptions}
|
||||
placeholder="请输入或选择客户名称"
|
||||
ariaLabel="客户名称"
|
||||
onChange={(value) => updatePending({ customerName: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>缴费状态</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.paymentStatus}
|
||||
options={paymentOptions}
|
||||
placeholder="请选择缴费状态"
|
||||
ariaLabel="缴费状态"
|
||||
onChange={(value) => updatePending({ paymentStatus: value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>是否处理</span>
|
||||
<FilterPickerField
|
||||
value={pendingFilters.processed}
|
||||
options={processedOptions}
|
||||
placeholder="请选择是否处理"
|
||||
ariaLabel="是否处理"
|
||||
onChange={(value) => updatePending({ processed: value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-detail-tab-filter-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<div className="vm-table-wrap vm-table-wrap--detail-tab">
|
||||
<table className="vm-table vm-table--violation">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>违法时间</th>
|
||||
<th>违法地点</th>
|
||||
<th>违法行为</th>
|
||||
<th>扣分</th>
|
||||
<th>罚款金额</th>
|
||||
<th>缴费状态</th>
|
||||
<th>是否处理</th>
|
||||
<th>采集单位</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="vm-empty-cell">暂无数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.violationTime}</td>
|
||||
<td className="ellipsis" title={row.violationLocation}>{row.violationLocation}</td>
|
||||
<td className="ellipsis" title={row.violationBehavior}>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onOpenViolationDetail?.(record, row)}
|
||||
>
|
||||
{row.violationBehavior}
|
||||
</button>
|
||||
</td>
|
||||
<td>{formatViolationPoints(row.pointsDeducted)}</td>
|
||||
<td className="vm-table-num">{formatViolationFine(row.fineAmount)}</td>
|
||||
<td>
|
||||
<StatusTag label={row.paymentStatus} tone={violationPaymentTone(row.paymentStatus)} />
|
||||
</td>
|
||||
<td>
|
||||
<StatusTag label={row.processed} tone={violationProcessedTone(row.processed)} />
|
||||
</td>
|
||||
<td className="ellipsis" title={row.collectionUnit}>{row.collectionUnit}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||
<span className="vm-table-total">共 {filtered.length} 条</span>
|
||||
{filtered.length > 0 && (
|
||||
<TablePagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/prototypes/vehicle-management/components/ExpireDate.tsx
Normal file
35
src/prototypes/vehicle-management/components/ExpireDate.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { daysUntilExpire, formatDateYmd, isEmpty } from '../utils/vehicle';
|
||||
|
||||
interface ExpireDateProps {
|
||||
date: string;
|
||||
emptyLabel?: string;
|
||||
/** 始终展示剩余/已过期天数(保险到期列) */
|
||||
alwaysShowRemainder?: boolean;
|
||||
}
|
||||
|
||||
export function ExpireDate({ date, emptyLabel = '—', alwaysShowRemainder = false }: ExpireDateProps) {
|
||||
if (isEmpty(date)) return <span>{emptyLabel}</span>;
|
||||
const text = formatDateYmd(date);
|
||||
const days = daysUntilExpire(date);
|
||||
let tag = '';
|
||||
let tagClass = 'vm-expire-tag';
|
||||
if (days !== null) {
|
||||
if (days < 0) {
|
||||
tag = `已过期${Math.abs(days)}天`;
|
||||
tagClass += ' danger';
|
||||
} else if (alwaysShowRemainder) {
|
||||
tag = `剩余${days}天`;
|
||||
if (days <= 30) tagClass += ' warning';
|
||||
} else if (days <= 30) {
|
||||
tag = `剩余${days}天`;
|
||||
tagClass += ' warning';
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span className="vm-expire">
|
||||
<span className="vm-expire-date">{text}</span>
|
||||
{tag && <span className={tagClass}>{tag}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
202
src/prototypes/vehicle-management/components/FilterPanel.tsx
Normal file
202
src/prototypes/vehicle-management/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import type { VehicleFilters, VehicleRecord } from '../types';
|
||||
import { buildOptions } from '../utils/filters';
|
||||
import { OperateCityField } from './OperateCityField';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
import { parseMultiPlates } from '../utils/vehicle';
|
||||
import { OPERATE_STATUS_OPTIONS, PROJECT_TYPE_OPTIONS, VEHICLE_SOURCE_OPTIONS } from '../utils/vehicle';
|
||||
|
||||
interface FilterPanelProps {
|
||||
records: VehicleRecord[];
|
||||
filters: VehicleFilters;
|
||||
onChange: (patch: Partial<VehicleFilters>) => void;
|
||||
onReset: () => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export function FilterPanel({
|
||||
records,
|
||||
filters,
|
||||
onChange,
|
||||
onReset,
|
||||
onSearch,
|
||||
}: FilterPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [plateOpen, setPlateOpen] = useState(false);
|
||||
const [plateDraft, setPlateDraft] = useState('');
|
||||
const plateFieldRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismissPlate = useCallback(() => {
|
||||
setPlateOpen(false);
|
||||
}, []);
|
||||
|
||||
useDismissOnFocusOutside(plateOpen, plateFieldRef, dismissPlate);
|
||||
|
||||
const brands = useMemo(() => buildOptions(records, 'brand'), [records]);
|
||||
const models = useMemo(() => buildOptions(records, 'model'), [records]);
|
||||
const customers = useMemo(() => buildOptions(records, 'customer'), [records]);
|
||||
const departments = useMemo(() => buildOptions(records, 'department'), [records]);
|
||||
const projects = useMemo(() => buildOptions(records, 'projectName'), [records]);
|
||||
const contracts = useMemo(() => buildOptions(records, 'contractNo'), [records]);
|
||||
const ownerships = useMemo(() => buildOptions(records, 'ownership'), [records]);
|
||||
const companies = useMemo(() => buildOptions(records, 'operateCompany'), [records]);
|
||||
const leases = useMemo(() => buildOptions(records, 'leaseCompany'), [records]);
|
||||
const parkings = useMemo(() => buildOptions(records, 'parking'), [records]);
|
||||
|
||||
const plateSummary = filters.plateNos.length <= 2
|
||||
? filters.plateNos.join('、')
|
||||
: filters.plateNos.length > 0
|
||||
? `已选 ${filters.plateNos.length} 个车牌`
|
||||
: '';
|
||||
|
||||
const renderPicker = (
|
||||
fieldKey: keyof VehicleFilters,
|
||||
label: string,
|
||||
options: string[],
|
||||
placeholder: string,
|
||||
) => (
|
||||
<FilterPickerField
|
||||
value={String(filters[fieldKey] ?? '')}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
ariaLabel={label}
|
||||
onChange={(value) => onChange({ [fieldKey]: value } as Partial<VehicleFilters>)}
|
||||
/>
|
||||
);
|
||||
|
||||
const fields = [
|
||||
{
|
||||
label: '车牌号',
|
||||
control: (
|
||||
<div className="vm-plate-field" ref={plateFieldRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-input vm-plate-trigger"
|
||||
onClick={() => { setPlateDraft(filters.plateNos.join('\n')); setPlateOpen(true); }}
|
||||
>
|
||||
{plateSummary || <span className="placeholder">请选择或输入车牌号</span>}
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
</button>
|
||||
{plateOpen && (
|
||||
<div className="vm-plate-popover">
|
||||
<textarea
|
||||
className="vm-input vm-plate-textarea"
|
||||
value={plateDraft}
|
||||
onChange={(e) => setPlateDraft(e.target.value)}
|
||||
placeholder="请输入车牌号,多个车牌跨行输入"
|
||||
rows={5}
|
||||
/>
|
||||
<div className="vm-plate-popover-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setPlateOpen(false)}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-primary"
|
||||
onClick={() => {
|
||||
onChange({ plateNos: parseMultiPlates(plateDraft) });
|
||||
setPlateOpen(false);
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '运营城市',
|
||||
control: (
|
||||
<OperateCityField
|
||||
records={records}
|
||||
value={filters.operateCities}
|
||||
onChange={(operateCities) => onChange({ operateCities })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '品牌',
|
||||
control: renderPicker('brand', '品牌', brands, '请选择品牌'),
|
||||
},
|
||||
{
|
||||
label: '型号',
|
||||
control: renderPicker('model', '型号', models, '请选择型号'),
|
||||
},
|
||||
{
|
||||
label: '客户名称',
|
||||
control: renderPicker('customer', '客户名称', customers, '请选择或输入客户名称'),
|
||||
},
|
||||
{
|
||||
label: '归属业务部门',
|
||||
control: renderPicker('department', '归属业务部门', departments, '请选择归属业务部门'),
|
||||
},
|
||||
{
|
||||
label: '项目名称',
|
||||
control: renderPicker('projectName', '项目名称', projects, '请选择或输入项目名称'),
|
||||
},
|
||||
{
|
||||
label: '项目类型',
|
||||
control: renderPicker('projectType', '项目类型', PROJECT_TYPE_OPTIONS, '请选择项目类型'),
|
||||
},
|
||||
{
|
||||
label: '合同编码',
|
||||
control: renderPicker('contractNo', '合同编码', contracts, '请选择或输入合同编码'),
|
||||
},
|
||||
{
|
||||
label: '登记所有权',
|
||||
control: renderPicker('ownership', '登记所有权', ownerships, '请选择登记所有权'),
|
||||
},
|
||||
{
|
||||
label: '运营公司',
|
||||
control: renderPicker('operateCompany', '运营公司', companies, '请选择或输入运营公司'),
|
||||
},
|
||||
{
|
||||
label: '运营状态',
|
||||
control: renderPicker('operateStatus', '运营状态', OPERATE_STATUS_OPTIONS, '请选择运营状态'),
|
||||
},
|
||||
{
|
||||
label: '车辆来源',
|
||||
control: renderPicker('vehicleSource', '车辆来源', VEHICLE_SOURCE_OPTIONS, '请选择车辆来源'),
|
||||
},
|
||||
{
|
||||
label: '租赁公司',
|
||||
control: renderPicker('leaseCompany', '租赁公司', leases, '请选择或搜索租赁公司'),
|
||||
},
|
||||
{
|
||||
label: '停车场',
|
||||
control: renderPicker('parking', '停车场', parkings, '请选择或输入停车场'),
|
||||
},
|
||||
];
|
||||
|
||||
const visible = expanded ? fields : fields.slice(0, 4);
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card">
|
||||
<header className="vm-filter-header">
|
||||
<h2 className="vm-filter-title">筛选条件</h2>
|
||||
</header>
|
||||
<div className="vm-filter-grid">
|
||||
{visible.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="vm-filter-actions">
|
||||
{fields.length > 4 && (
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? '收起' : '展开更多筛选项'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
|
||||
interface FilterPickerFieldProps {
|
||||
value: string;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function FilterPickerField({
|
||||
value,
|
||||
options,
|
||||
placeholder = '请选择',
|
||||
ariaLabel = '筛选选择',
|
||||
onChange,
|
||||
}: FilterPickerFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setQuery(value);
|
||||
}, [value, editing]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
setQuery(value);
|
||||
}, [value]);
|
||||
|
||||
useDismissOnFocusOutside(open, fieldRef, dismiss);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim();
|
||||
if (!q) return options;
|
||||
return options.filter((item) => item.includes(q));
|
||||
}, [options, query]);
|
||||
|
||||
const pick = (next: string) => {
|
||||
onChange(next);
|
||||
setQuery(next);
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const clear = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onChange('');
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
setOpen(true);
|
||||
setQuery(value);
|
||||
window.requestAnimationFrame(() => inputRef.current?.select());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-filter-picker-field" ref={fieldRef}>
|
||||
<div className={`vm-filter-picker-control ${open ? 'open' : ''}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="vm-filter-picker-input"
|
||||
value={editing ? query : value}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
readOnly={!editing}
|
||||
onFocus={startEdit}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
/>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-filter-picker-clear"
|
||||
onClick={clear}
|
||||
aria-label={`清空${ariaLabel}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown size={14} className="vm-filter-picker-chevron" aria-hidden />
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="vm-filter-picker-popover" role="listbox" aria-label={ariaLabel}>
|
||||
<div className="vm-filter-picker-options">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="vm-filter-picker-empty">暂无匹配项</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === item}
|
||||
className={`vm-filter-picker-option ${value === item ? 'checked' : ''}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => pick(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import {
|
||||
displayText,
|
||||
formatHandoverDateTimeMinute,
|
||||
formatHandoverMileage,
|
||||
} from '../utils/vehicle';
|
||||
|
||||
interface HandoverSituationCellProps {
|
||||
kind: 'delivery' | 'return';
|
||||
record: VehicleRecord;
|
||||
emptyLabel?: string;
|
||||
onClick?: (record: VehicleRecord) => void;
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
delivery: {
|
||||
mile: '交车里程',
|
||||
person: '交车人',
|
||||
time: '交车时间',
|
||||
},
|
||||
return: {
|
||||
mile: '还车里程',
|
||||
person: '还车人',
|
||||
time: '还车时间',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function HandoverSituationCell({
|
||||
kind,
|
||||
record,
|
||||
emptyLabel = '',
|
||||
onClick,
|
||||
}: HandoverSituationCellProps) {
|
||||
const labels = LABELS[kind];
|
||||
const mile =
|
||||
kind === 'delivery' ? record.lastDeliveryMile : record.lastReturnMile;
|
||||
const person =
|
||||
kind === 'delivery' ? record.lastDeliveryPerson : record.lastReturnPerson;
|
||||
const time =
|
||||
kind === 'delivery' ? record.lastDeliveryTime : record.lastReturnTime;
|
||||
|
||||
const mileText = formatHandoverMileage(mile, emptyLabel);
|
||||
const personText = displayText(person, emptyLabel);
|
||||
const timeText = formatHandoverDateTimeMinute(time, emptyLabel);
|
||||
|
||||
const line1 = mileText;
|
||||
const line2 = personText;
|
||||
const line3 = timeText;
|
||||
|
||||
if (!line1 && !line2 && !line3) return <>{emptyLabel}</>;
|
||||
|
||||
const title = [
|
||||
mileText && `${labels.mile} ${mileText}`,
|
||||
personText && `${labels.person} ${personText}`,
|
||||
timeText && `${labels.time} ${timeText}`,
|
||||
].filter(Boolean).join(' / ');
|
||||
|
||||
const jumpHint = kind === 'delivery' ? '点击查看交车管理' : '点击查看还车管理';
|
||||
const cellTitle = onClick ? `${title} · ${jumpHint}` : title;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{line1 ? (
|
||||
<div className="vm-handover-situation-line vm-handover-situation-line--mile" title={line1}>
|
||||
{line1}
|
||||
</div>
|
||||
) : null}
|
||||
{line2 ? (
|
||||
<div className="vm-handover-situation-line vm-handover-situation-line--person" title={line2}>
|
||||
{line2}
|
||||
</div>
|
||||
) : null}
|
||||
{line3 ? (
|
||||
<div className="vm-handover-situation-line vm-handover-situation-line--time" title={line3}>
|
||||
{line3}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-handover-situation-cell vm-handover-situation-cell--clickable"
|
||||
title={cellTitle || undefined}
|
||||
onClick={() => onClick(record)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vm-handover-situation-cell" title={title || undefined}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/prototypes/vehicle-management/components/ImportModal.tsx
Normal file
79
src/prototypes/vehicle-management/components/ImportModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Download, Upload, X } from 'lucide-react';
|
||||
|
||||
interface ImportModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function ImportModal({ open, onClose, onImport }: ImportModalProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
if (!open) return null;
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = ['车辆类型', '车牌号', '*车辆识别代码', '*品牌', '*型号', '*停放区域', '行驶公里数', '*登记所有权', '*运营公司', '*车辆来源'];
|
||||
const sample = ['运营车辆', '粤A99999F', 'LNXNEGRR0SR399999', '现代', '帕力安牌4.5吨冷链车', '平湖停车场', '12580.50', '羚牛氢能科技有限公司', '羚牛氢能科技有限公司', '自有'];
|
||||
const csv = `\uFEFF非运营车辆不做必填项校验\n${headers.join(',')}\n${sample.join(',')}`;
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '车辆导入模板.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div className="vm-modal vm-modal-sm" role="dialog" aria-modal="true" aria-labelledby="import-title" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="import-title">批量导入车辆</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-import-body">
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">1</span>
|
||||
<span>下载导入模板</span>
|
||||
</div>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={downloadTemplate}>
|
||||
<Download size={16} aria-hidden />
|
||||
下载车辆导入模板
|
||||
</button>
|
||||
</section>
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">2</span>
|
||||
<span>上传填写好的文件</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-upload-zone"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload size={28} aria-hidden />
|
||||
<p>点击或拖拽文件到此处上传</p>
|
||||
<p className="hint">支持 .csv、.xls、.xlsx</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,.xls,.xlsx"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onImport(file);
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { isEmpty } from '../utils/vehicle';
|
||||
import { ExpireDate } from './ExpireDate';
|
||||
|
||||
const BLOCK_DELIVERY_TITLE = '未维护行驶证检验有效期,禁止交车';
|
||||
|
||||
interface InspectExpireCellProps {
|
||||
expireDate: string;
|
||||
emptyLabel?: string;
|
||||
onClickNotUploaded?: () => void;
|
||||
alwaysShowRemainder?: boolean;
|
||||
}
|
||||
|
||||
export function InspectExpireCell({
|
||||
expireDate,
|
||||
emptyLabel = '—',
|
||||
onClickNotUploaded,
|
||||
alwaysShowRemainder = false,
|
||||
}: InspectExpireCellProps) {
|
||||
if (isEmpty(expireDate)) {
|
||||
return (
|
||||
<span className="vm-expire">
|
||||
{onClickNotUploaded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link vm-expire-date"
|
||||
onClick={onClickNotUploaded}
|
||||
title="前往证照管理维护行驶证检验有效期"
|
||||
>
|
||||
未上传
|
||||
</button>
|
||||
) : (
|
||||
<span className="vm-expire-date">未上传</span>
|
||||
)}
|
||||
<span className="vm-expire-tag danger" title={BLOCK_DELIVERY_TITLE}>
|
||||
禁止交车
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ExpireDate
|
||||
date={expireDate}
|
||||
emptyLabel={emptyLabel}
|
||||
alwaysShowRemainder={alwaysShowRemainder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import type { InsuranceType } from '../types';
|
||||
import { ExpireDate } from './ExpireDate';
|
||||
import { getInsuranceCoverageState } from '../utils/insurance';
|
||||
|
||||
interface InsuranceExpireCellProps {
|
||||
insuranceType: InsuranceType;
|
||||
expireDate?: string;
|
||||
emptyLabel?: string;
|
||||
onClickNotPurchased?: () => void;
|
||||
}
|
||||
|
||||
export function InsuranceExpireCell({
|
||||
insuranceType,
|
||||
expireDate,
|
||||
emptyLabel = '—',
|
||||
onClickNotPurchased,
|
||||
}: InsuranceExpireCellProps) {
|
||||
const state = getInsuranceCoverageState(expireDate);
|
||||
if (state === 'not_purchased') {
|
||||
return (
|
||||
<span className="vm-expire">
|
||||
{onClickNotPurchased ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link vm-expire-date"
|
||||
onClick={onClickNotPurchased}
|
||||
title={`前往保险采购维护${insuranceType}`}
|
||||
>
|
||||
未购买
|
||||
</button>
|
||||
) : (
|
||||
<span className="vm-expire-date">未购买</span>
|
||||
)}
|
||||
<span
|
||||
className="vm-expire-tag danger"
|
||||
title={`未购买${insuranceType}的车辆禁止交车`}
|
||||
>
|
||||
禁止交车
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <ExpireDate date={expireDate!} emptyLabel={emptyLabel} alwaysShowRemainder />;
|
||||
}
|
||||
64
src/prototypes/vehicle-management/components/KpiCards.tsx
Normal file
64
src/prototypes/vehicle-management/components/KpiCards.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CircleHelp,
|
||||
Truck,
|
||||
KeyRound,
|
||||
Store,
|
||||
Warehouse,
|
||||
BusFront,
|
||||
CircleOff,
|
||||
FileWarning,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import type { KpiKey } from '../types';
|
||||
import { KPI_CARDS } from '../utils/vehicle';
|
||||
|
||||
const ICONS: Record<KpiKey, React.ReactNode> = {
|
||||
all: <Truck size={18} aria-hidden />,
|
||||
lease: <KeyRound size={18} aria-hidden />,
|
||||
self: <Store size={18} aria-hidden />,
|
||||
stock: <Warehouse size={18} aria-hidden />,
|
||||
nonOperating: <BusFront size={18} aria-hidden />,
|
||||
exit: <CircleOff size={18} aria-hidden />,
|
||||
licenseAbnormal: <FileWarning size={18} aria-hidden />,
|
||||
insuranceAbnormal: <ShieldAlert size={18} aria-hidden />,
|
||||
};
|
||||
|
||||
interface KpiCardsProps {
|
||||
counts: Record<KpiKey, number>;
|
||||
active: KpiKey;
|
||||
onChange: (key: KpiKey) => void;
|
||||
}
|
||||
|
||||
export function KpiCards({ counts, active, onChange }: KpiCardsProps) {
|
||||
return (
|
||||
<div className="vm-kpi-row">
|
||||
{KPI_CARDS.map((card) => (
|
||||
<button
|
||||
key={card.key}
|
||||
type="button"
|
||||
className={`vm-kpi-card ${active === card.key ? 'active' : ''}`}
|
||||
onClick={() => onChange(card.key)}
|
||||
aria-pressed={active === card.key}
|
||||
>
|
||||
<span
|
||||
className="vm-kpi-tip"
|
||||
tabIndex={0}
|
||||
role="note"
|
||||
aria-label={`${card.title}说明`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden />
|
||||
<span className="vm-kpi-tooltip" role="tooltip">{card.desc}</span>
|
||||
</span>
|
||||
<span className="vm-kpi-icon" aria-hidden>{ICONS[card.key]}</span>
|
||||
<span className="vm-kpi-main">
|
||||
<span className="vm-kpi-eyebrow">{card.title}</span>
|
||||
<span className="vm-kpi-val">{counts[card.key]}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import {
|
||||
buildMapEmbedUrl,
|
||||
formatGpsTime,
|
||||
resolveGpsLocationAddress,
|
||||
resolveVehicleGpsCoords,
|
||||
} from '../utils/vehicle';
|
||||
|
||||
interface LocationMapModalProps {
|
||||
open: boolean;
|
||||
record: VehicleRecord | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LocationMapModal({ open, record, onClose }: LocationMapModalProps) {
|
||||
if (!open || !record) return null;
|
||||
|
||||
const coords = resolveVehicleGpsCoords(record);
|
||||
const address = resolveGpsLocationAddress(record);
|
||||
if (!coords) return null;
|
||||
|
||||
const mapUrl = buildMapEmbedUrl(coords.lat, coords.lng);
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-map"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="vm-location-map-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<div>
|
||||
<h2 id="vm-location-map-title">车辆定位</h2>
|
||||
<p>{record.plateNo} · {address}</p>
|
||||
</div>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-location-map-body">
|
||||
<div className="vm-location-map-frame-wrap">
|
||||
<iframe
|
||||
className="vm-location-map-frame"
|
||||
title={`${record.plateNo} 当前定位地图`}
|
||||
src={mapUrl}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<span className="vm-location-map-marker" aria-hidden />
|
||||
</div>
|
||||
<p className="vm-location-map-gps-time">
|
||||
GPS最后更新时间:{formatGpsTime(record.gpsTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import {
|
||||
buildOperateCityTree,
|
||||
operateCityValueToLocation,
|
||||
toOperateCityValue,
|
||||
} from '../utils/filters';
|
||||
import { formatOperateCity, parseRegionParts } from '../utils/vehicle';
|
||||
|
||||
interface OperateCityEditModalProps {
|
||||
open: boolean;
|
||||
record: VehicleRecord | null;
|
||||
records: VehicleRecord[];
|
||||
onClose: () => void;
|
||||
onSave: (location: string) => void;
|
||||
}
|
||||
|
||||
export function OperateCityEditModal({
|
||||
open,
|
||||
record,
|
||||
records,
|
||||
onClose,
|
||||
onSave,
|
||||
}: OperateCityEditModalProps) {
|
||||
const tree = useMemo(() => buildOperateCityTree(records), [records]);
|
||||
const [province, setProvince] = useState('');
|
||||
const [selected, setSelected] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !record) return;
|
||||
const current = formatOperateCity(record.location);
|
||||
const { province: currentProvince } = parseRegionParts(current);
|
||||
const fallbackProvince = tree.find((node) => node.province === currentProvince)?.province
|
||||
|| tree[0]?.province
|
||||
|| '';
|
||||
setProvince(currentProvince || fallbackProvince);
|
||||
setSelected(current);
|
||||
}, [open, record, tree]);
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const activeProvince = province || tree[0]?.province || '';
|
||||
const cityOptions = tree.find((node) => node.province === activeProvince)?.cities ?? [];
|
||||
const selectedInProvince = parseRegionParts(selected).province === activeProvince ? selected : '';
|
||||
|
||||
const pickCity = (city: string) => {
|
||||
setSelected(toOperateCityValue(activeProvince, city));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selected) return;
|
||||
onSave(operateCityValueToLocation(selected));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div className="vm-modal vm-modal-sm" role="dialog" aria-modal="true" aria-labelledby="vm-operate-city-edit-title" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="vm-modal-header">
|
||||
<div>
|
||||
<h2 id="vm-operate-city-edit-title">修改运营城市</h2>
|
||||
<p>{record.plateNo} · 当前 {formatOperateCity(record.location)}</p>
|
||||
</div>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-operate-city-edit-body">
|
||||
<label className="vm-operate-city-step vm-operate-city-step-single">
|
||||
<span>省份</span>
|
||||
<select
|
||||
className="vm-input"
|
||||
value={activeProvince}
|
||||
onChange={(event) => setProvince(event.target.value)}
|
||||
aria-label="选择省份"
|
||||
>
|
||||
{tree.map((node) => (
|
||||
<option key={node.province} value={node.province}>{node.province}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="vm-operate-city-edit-cities" role="listbox" aria-label="城市列表">
|
||||
{cityOptions.length === 0 ? (
|
||||
<p className="vm-operate-city-empty">暂无可选城市</p>
|
||||
) : (
|
||||
cityOptions.map((city) => {
|
||||
const full = toOperateCityValue(activeProvince, city);
|
||||
const checked = selectedInProvince === full || selected === full;
|
||||
return (
|
||||
<button
|
||||
key={full}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
className={`vm-operate-city-edit-option ${checked ? 'checked' : ''}`}
|
||||
onClick={() => pickCity(city)}
|
||||
>
|
||||
{full}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSave} disabled={!selected}>保存</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { buildOperateCityTree, toOperateCityValue } from '../utils/filters';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
|
||||
interface OperateCityFieldProps {
|
||||
records: VehicleRecord[];
|
||||
value: string[];
|
||||
onChange: (cities: string[]) => void;
|
||||
}
|
||||
|
||||
export function OperateCityField({ records, value, onChange }: OperateCityFieldProps) {
|
||||
const tree = useMemo(() => buildOperateCityTree(records), [records]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [province, setProvince] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
setQuery('');
|
||||
}, []);
|
||||
|
||||
useDismissOnFocusOutside(open, fieldRef, dismiss);
|
||||
|
||||
const activeProvince = province || tree[0]?.province || '';
|
||||
|
||||
const summary = value.length <= 2
|
||||
? value.join('、')
|
||||
: value.length > 0
|
||||
? `已选 ${value.length} 个城市`
|
||||
: '';
|
||||
|
||||
const flatCities = useMemo(
|
||||
() => tree.flatMap((node) => node.cities.map((city) => toOperateCityValue(node.province, city))),
|
||||
[tree],
|
||||
);
|
||||
|
||||
const cityOptions = useMemo(() => {
|
||||
const q = query.trim();
|
||||
if (q) return flatCities.filter((item) => item.includes(q));
|
||||
const node = tree.find((n) => n.province === activeProvince);
|
||||
if (!node) return [];
|
||||
return node.cities.map((city) => toOperateCityValue(activeProvince, city));
|
||||
}, [query, tree, activeProvince, flatCities]);
|
||||
|
||||
const toggleCity = (full: string) => {
|
||||
if (value.includes(full)) {
|
||||
onChange(value.filter((v) => v !== full));
|
||||
} else {
|
||||
onChange([...value, full]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onChange([]);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
setOpen(true);
|
||||
setQuery('');
|
||||
window.requestAnimationFrame(() => inputRef.current?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-operate-city-field" ref={fieldRef}>
|
||||
<div className={`vm-filter-picker-control ${open ? 'open' : ''}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="vm-filter-picker-input"
|
||||
value={editing ? query : summary}
|
||||
placeholder="请选择省-市"
|
||||
aria-label="运营城市"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
readOnly={!editing}
|
||||
onFocus={startEdit}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
/>
|
||||
{value.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-filter-picker-clear"
|
||||
onClick={clearAll}
|
||||
aria-label="清空运营城市"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown size={14} className="vm-filter-picker-chevron" aria-hidden />
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="vm-operate-city-popover" role="dialog" aria-label="运营城市选择">
|
||||
{!query.trim() && (
|
||||
<label className="vm-operate-city-step vm-operate-city-step-single">
|
||||
<span>省份</span>
|
||||
<select
|
||||
className="vm-input"
|
||||
value={activeProvince}
|
||||
onChange={(e) => {
|
||||
setProvince(e.target.value);
|
||||
}}
|
||||
aria-label="选择省份"
|
||||
>
|
||||
{tree.map((node) => (
|
||||
<option key={node.province} value={node.province}>{node.province}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="vm-operate-city-options" role="listbox" aria-label="城市列表" aria-multiselectable="true">
|
||||
{cityOptions.length === 0 ? (
|
||||
<p className="vm-operate-city-empty">暂无匹配城市</p>
|
||||
) : (
|
||||
cityOptions.map((full) => {
|
||||
const checked = value.includes(full);
|
||||
return (
|
||||
<label
|
||||
key={full}
|
||||
className={`vm-operate-city-option ${checked ? 'checked' : ''}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleCity(full)}
|
||||
/>
|
||||
<span>{full}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="vm-operate-city-tags">
|
||||
{value.map((city) => (
|
||||
<span key={city} className="vm-operate-city-tag">
|
||||
{city}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除${city}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onChange(value.filter((v) => v !== city))}
|
||||
>
|
||||
<X size={12} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vm-operate-city-actions">
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={dismiss}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { resolveOpsStaffCandidates, resolveVehicleRegion } from '../utils/vehicle';
|
||||
import { OpsManagerMultiSelect } from './OpsManagerMultiSelect';
|
||||
|
||||
interface OpsManagerModalProps {
|
||||
open: boolean;
|
||||
record: VehicleRecord | null;
|
||||
onClose: () => void;
|
||||
onSave: (managers: string[]) => void;
|
||||
}
|
||||
|
||||
export function OpsManagerModal({ open, record, onClose, onSave }: OpsManagerModalProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && record) {
|
||||
setSelected(record.opsManagers || []);
|
||||
}
|
||||
}, [open, record]);
|
||||
|
||||
const candidates = useMemo(
|
||||
() => (record ? resolveOpsStaffCandidates(record) : []),
|
||||
[record],
|
||||
);
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const regionLabel = resolveVehicleRegion(record);
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div className="vm-modal vm-modal-sm" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="vm-modal-header">
|
||||
<div>
|
||||
<h2>设置运维负责人</h2>
|
||||
<div className="vm-ops-vehicle-capsule" aria-label={`${record.plateNo} ${regionLabel}`}>
|
||||
<span className="vm-ops-vehicle-plate">{record.plateNo}</span>
|
||||
<span className="vm-ops-vehicle-divider" aria-hidden />
|
||||
<span className="vm-ops-vehicle-region">{regionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-ops-body">
|
||||
<OpsManagerMultiSelect
|
||||
value={selected}
|
||||
options={candidates}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={() => onSave(selected)}>保存</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
|
||||
export interface OpsStaffOption {
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface OpsManagerMultiSelectProps {
|
||||
value: string[];
|
||||
options: OpsStaffOption[];
|
||||
onChange: (names: string[]) => void;
|
||||
}
|
||||
|
||||
interface PopoverPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function OpsManagerMultiSelect({ value, options, onChange }: OpsManagerMultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0, width: 0 });
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
setQuery('');
|
||||
}, []);
|
||||
|
||||
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 6,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
const summary = value.length <= 2
|
||||
? value.join('、')
|
||||
: value.length > 0
|
||||
? `已选 ${value.length} 人`
|
||||
: '';
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim();
|
||||
if (!q) return options;
|
||||
return options.filter((item) => item.name.includes(q) || item.role.includes(q));
|
||||
}, [options, query]);
|
||||
|
||||
const toggle = (name: string) => {
|
||||
if (value.includes(name)) {
|
||||
onChange(value.filter((item) => item !== name));
|
||||
} else {
|
||||
onChange([...value, name]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onChange([]);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const openPicker = () => {
|
||||
setEditing(true);
|
||||
setOpen(true);
|
||||
setQuery('');
|
||||
window.requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const popover =
|
||||
open && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="vm-filter-picker-popover vm-ops-picker-popover vm-ops-picker-popover--portal"
|
||||
role="listbox"
|
||||
aria-label="运维负责人列表"
|
||||
aria-multiselectable="true"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
}}
|
||||
>
|
||||
<div className="vm-filter-picker-options vm-ops-picker-options">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="vm-filter-picker-empty">暂无匹配人员</p>
|
||||
) : (
|
||||
filtered.map((staff) => {
|
||||
const checked = value.includes(staff.name);
|
||||
return (
|
||||
<label
|
||||
key={staff.name}
|
||||
className={`vm-ops-picker-option ${checked ? 'checked' : ''}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(staff.name)}
|
||||
/>
|
||||
<span className="vm-ops-picker-name">{staff.name}</span>
|
||||
<span className="vm-ops-picker-role">{staff.role}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="vm-ops-picker-tags">
|
||||
{value.map((name) => (
|
||||
<span key={name} className="vm-ops-picker-tag">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除${name}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onChange(value.filter((item) => item !== name))}
|
||||
>
|
||||
<X size={12} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="vm-ops-picker">
|
||||
<div className="vm-ops-picker-control" ref={anchorRef}>
|
||||
<div className={`vm-filter-picker-control ${open ? 'open' : ''}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="vm-filter-picker-input"
|
||||
value={editing ? query : summary}
|
||||
placeholder="请输入姓名搜索并选择"
|
||||
aria-label="运维负责人"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
readOnly={!editing}
|
||||
onFocus={openPicker}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
window.requestAnimationFrame(updatePosition);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
/>
|
||||
{value.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-filter-picker-clear"
|
||||
onClick={clearAll}
|
||||
aria-label="清空运维负责人"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown size={14} className="vm-filter-picker-chevron" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
{popover}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Download, FileSpreadsheet, FileText } from 'lucide-react';
|
||||
|
||||
interface PageDocToolbarProps {
|
||||
onOpenRequirements: () => void;
|
||||
onDownloadPrd: () => void;
|
||||
onDownloadTestCases: () => void;
|
||||
}
|
||||
|
||||
export function PageDocToolbar({
|
||||
onOpenRequirements,
|
||||
onDownloadPrd,
|
||||
onDownloadTestCases,
|
||||
}: PageDocToolbarProps) {
|
||||
return (
|
||||
<div className="vm-doc-toolbar" role="toolbar" aria-label="文档与测试用例">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-doc-toolbar-btn"
|
||||
onClick={onOpenRequirements}
|
||||
aria-label="查看需求说明"
|
||||
>
|
||||
<FileText size={16} aria-hidden />
|
||||
查看需求说明
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-doc-toolbar-btn"
|
||||
onClick={onDownloadPrd}
|
||||
aria-label="PRD文档MD文件下载"
|
||||
>
|
||||
<Download size={16} aria-hidden />
|
||||
PRD文档MD文件下载
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-doc-toolbar-btn"
|
||||
onClick={onDownloadTestCases}
|
||||
aria-label="下载测试用例"
|
||||
>
|
||||
<FileSpreadsheet size={16} aria-hidden />
|
||||
下载测试用例
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import listPrdMarkdown from '../.spec/requirements-prd-list.md?raw';
|
||||
import detailPrdMarkdown from '../.spec/requirements-prd-detail.md?raw';
|
||||
|
||||
export type RequirementsPrdVariant = 'list' | 'detail';
|
||||
|
||||
interface RequirementsModalProps {
|
||||
open: boolean;
|
||||
variant: RequirementsPrdVariant;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PRD_BY_VARIANT: Record<RequirementsPrdVariant, string> = {
|
||||
list: listPrdMarkdown,
|
||||
detail: detailPrdMarkdown,
|
||||
};
|
||||
|
||||
function renderInline(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/g);
|
||||
return parts.map((part, index) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={index}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): React.ReactNode[] {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let listItems: string[] = [];
|
||||
let tableRows: string[][] = [];
|
||||
let inTable = false;
|
||||
let key = 0;
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length === 0) return;
|
||||
nodes.push(
|
||||
<ul key={`ul-${key += 1}`} className="vm-prd-list">
|
||||
{listItems.map((item) => (
|
||||
<li key={item}>{renderInline(item)}</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
listItems = [];
|
||||
};
|
||||
|
||||
const flushTable = () => {
|
||||
if (tableRows.length < 2) {
|
||||
tableRows = [];
|
||||
inTable = false;
|
||||
return;
|
||||
}
|
||||
const [header, , ...body] = tableRows;
|
||||
nodes.push(
|
||||
<div key={`table-${key += 1}`} className="vm-prd-table-wrap">
|
||||
<table className="vm-prd-table">
|
||||
<thead>
|
||||
<tr>{header.map((cell) => <th key={cell}>{renderInline(cell.trim())}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row) => (
|
||||
<tr key={row.join('|')}>
|
||||
{row.map((cell) => <td key={cell}>{renderInline(cell.trim())}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>,
|
||||
);
|
||||
tableRows = [];
|
||||
inTable = false;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('|')) {
|
||||
flushList();
|
||||
inTable = true;
|
||||
tableRows.push(
|
||||
trimmed
|
||||
.slice(1, trimmed.endsWith('|') ? -1 : undefined)
|
||||
.split('|')
|
||||
.map((cell) => cell.trim()),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (inTable) flushTable();
|
||||
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === '---') {
|
||||
flushList();
|
||||
nodes.push(<hr key={`hr-${key += 1}`} className="vm-prd-hr" />);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('# ')) {
|
||||
flushList();
|
||||
nodes.push(<h1 key={`h1-${key += 1}`} className="vm-prd-h1">{trimmed.slice(2)}</h1>);
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
nodes.push(<h2 key={`h2-${key += 1}`} className="vm-prd-h2">{trimmed.slice(3)}</h2>);
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
nodes.push(<h3 key={`h3-${key += 1}`} className="vm-prd-h3">{trimmed.slice(4)}</h3>);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('> ')) {
|
||||
flushList();
|
||||
nodes.push(
|
||||
<blockquote key={`quote-${key += 1}`} className="vm-prd-quote">
|
||||
{renderInline(trimmed.slice(2))}
|
||||
</blockquote>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('- ')) {
|
||||
listItems.push(trimmed.slice(2));
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
nodes.push(<p key={`p-${key += 1}`} className="vm-prd-p">{renderInline(trimmed)}</p>);
|
||||
}
|
||||
|
||||
flushList();
|
||||
flushTable();
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function RequirementsModal({ open, variant, onClose }: RequirementsModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const prdMarkdown = PRD_BY_VARIANT[variant];
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-prd"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="需求说明"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header vm-prd-header vm-prd-header--compact">
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<article className="vm-prd-body">{renderMarkdown(prdMarkdown)}</article>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose}>关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/prototypes/vehicle-management/components/RowActionsMenu.tsx
Normal file
135
src/prototypes/vehicle-management/components/RowActionsMenu.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
|
||||
interface RowActionsMenuProps {
|
||||
record: VehicleRecord;
|
||||
isAdmin: boolean;
|
||||
canEditOpsManager: boolean;
|
||||
onEdit: (record: VehicleRecord) => void;
|
||||
onEditOpsManager: (record: VehicleRecord) => void;
|
||||
}
|
||||
|
||||
interface PopoverPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export function RowActionsMenu({
|
||||
record,
|
||||
isAdmin,
|
||||
canEditOpsManager,
|
||||
onEdit,
|
||||
onEditOpsManager,
|
||||
}: RowActionsMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismiss = useCallback(() => setOpen(false), []);
|
||||
|
||||
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.right,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const menu: Array<{ key: string; label: string; onClick: () => void }> = [];
|
||||
if (isAdmin) {
|
||||
menu.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
onClick: () => onEdit(record),
|
||||
});
|
||||
}
|
||||
if (canEditOpsManager) {
|
||||
menu.push({
|
||||
key: 'opsManager',
|
||||
label: '修改运维负责人',
|
||||
onClick: () => onEditOpsManager(record),
|
||||
});
|
||||
}
|
||||
return menu;
|
||||
}, [canEditOpsManager, isAdmin, onEdit, onEditOpsManager, record]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const runAction = (action: () => void) => {
|
||||
setOpen(false);
|
||||
action();
|
||||
};
|
||||
|
||||
const toggleOpen = () => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
window.requestAnimationFrame(updatePosition);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const popover =
|
||||
open && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="vm-action-menu-popover vm-action-menu-popover--portal"
|
||||
role="menu"
|
||||
aria-label="更多操作"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="vm-action-menu-item"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => runAction(item.onClick)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="vm-action-menu" ref={anchorRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-icon-btn"
|
||||
aria-label="更多操作"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<MoreHorizontal size={16} aria-hidden />
|
||||
</button>
|
||||
{popover}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/prototypes/vehicle-management/components/StatusTag.tsx
Normal file
17
src/prototypes/vehicle-management/components/StatusTag.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatusTagProps {
|
||||
label: string;
|
||||
tone: 'green' | 'blue' | 'amber' | 'red' | 'gray';
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
export function StatusTag({ label, tone, title, emptyLabel = '—' }: StatusTagProps) {
|
||||
if (!label && emptyLabel === '') return <span />;
|
||||
return (
|
||||
<span className={`vm-status vm-status-${tone}`} title={title}>
|
||||
{label || emptyLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
101
src/prototypes/vehicle-management/components/TablePagination.tsx
Normal file
101
src/prototypes/vehicle-management/components/TablePagination.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
interface TablePaginationProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
}
|
||||
|
||||
function buildPageItems(current: number, totalPages: number): Array<number | 'ellipsis'> {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
const items: Array<number | 'ellipsis'> = [1];
|
||||
if (current > 3) items.push('ellipsis');
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(totalPages - 1, current + 1);
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
items.push(page);
|
||||
}
|
||||
|
||||
if (current < totalPages - 2) items.push('ellipsis');
|
||||
if (totalPages > 1) items.push(totalPages);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: TablePaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]);
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="vm-pagination" role="navigation" aria-label="表格分页">
|
||||
<label className="vm-pagination-size">
|
||||
<span className="vm-pagination-size-label">每页</span>
|
||||
<select
|
||||
className="vm-pagination-select"
|
||||
value={pageSize}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
aria-label="每页显示条数"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="vm-pagination-size-label">条</span>
|
||||
</label>
|
||||
|
||||
<div className="vm-pagination-pages">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-pagination-nav"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden />
|
||||
</button>
|
||||
|
||||
{pageItems.map((item, index) => (
|
||||
item === 'ellipsis' ? (
|
||||
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden>…</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
|
||||
onClick={() => onPageChange(item)}
|
||||
aria-current={page === item ? 'page' : undefined}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="vm-pagination-nav"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { buildOptions } from '../utils/filters';
|
||||
import {
|
||||
OPERATE_STATUS_OPTIONS,
|
||||
VEHICLE_SOURCE_OPTIONS,
|
||||
VEHICLE_STATUS_OPTIONS,
|
||||
applyVehicleEditForm,
|
||||
buildYearOptions,
|
||||
createVehicleEditForm,
|
||||
displayText,
|
||||
type VehicleEditForm,
|
||||
} from '../utils/vehicle';
|
||||
import { FilterPickerField } from './FilterPickerField';
|
||||
|
||||
interface VehicleEditModalProps {
|
||||
open: boolean;
|
||||
record: VehicleRecord | null;
|
||||
records: VehicleRecord[];
|
||||
onClose: () => void;
|
||||
onSave: (record: VehicleRecord) => void;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="vm-filter-field vm-form-field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function VehicleEditModal({
|
||||
open,
|
||||
record,
|
||||
records,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VehicleEditModalProps) {
|
||||
const [form, setForm] = useState<VehicleEditForm>(createVehicleEditForm({} as VehicleRecord));
|
||||
const parkings = useMemo(() => buildOptions(records, 'parking'), [records]);
|
||||
const operateCompanies = useMemo(() => buildOptions(records, 'operateCompany'), [records]);
|
||||
const ownerships = useMemo(() => buildOptions(records, 'ownership'), [records]);
|
||||
const leaseCompanies = useMemo(() => buildOptions(records, 'leaseCompany'), [records]);
|
||||
const yearOptions = useMemo(() => buildYearOptions(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && record) {
|
||||
setForm(createVehicleEditForm(record));
|
||||
}
|
||||
}, [open, record]);
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const update = <K extends keyof VehicleEditForm>(key: K, value: VehicleEditForm[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.operateStatus || !form.vehicleStatus) return;
|
||||
onSave(applyVehicleEditForm(record, form));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="vm-edit-vehicle-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<div>
|
||||
<h2 id="vm-edit-vehicle-title">编辑车辆信息</h2>
|
||||
</div>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-edit-body">
|
||||
<div className="vm-edit-banner">
|
||||
车牌号:<strong>{displayText(record.plateNo)}</strong>
|
||||
{' '}识别代码:<span>{displayText(record.vin)}</span>
|
||||
</div>
|
||||
<div className="vm-form-grid">
|
||||
<FormField label="车辆归属停车场">
|
||||
<FilterPickerField
|
||||
value={form.parking}
|
||||
options={parkings}
|
||||
placeholder="请选择或输入停车场"
|
||||
ariaLabel="车辆归属停车场"
|
||||
onChange={(value) => update('parking', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="运营状态">
|
||||
<FilterPickerField
|
||||
value={form.operateStatus}
|
||||
options={OPERATE_STATUS_OPTIONS}
|
||||
placeholder="请选择运营状态"
|
||||
ariaLabel="运营状态"
|
||||
onChange={(value) => update('operateStatus', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="车辆状态">
|
||||
<FilterPickerField
|
||||
value={form.vehicleStatus}
|
||||
options={VEHICLE_STATUS_OPTIONS}
|
||||
placeholder="请选择车辆状态"
|
||||
ariaLabel="车辆状态"
|
||||
onChange={(value) => update('vehicleStatus', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="登记所有权">
|
||||
<FilterPickerField
|
||||
value={form.ownership}
|
||||
options={ownerships}
|
||||
placeholder="请选择登记所有权"
|
||||
ariaLabel="登记所有权"
|
||||
onChange={(value) => update('ownership', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="运营公司">
|
||||
<FilterPickerField
|
||||
value={form.operateCompany}
|
||||
options={operateCompanies}
|
||||
placeholder="请选择运营公司"
|
||||
ariaLabel="运营公司"
|
||||
onChange={(value) => update('operateCompany', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="车辆来源">
|
||||
<FilterPickerField
|
||||
value={form.vehicleSource}
|
||||
options={VEHICLE_SOURCE_OPTIONS}
|
||||
placeholder="请选择车辆来源"
|
||||
ariaLabel="车辆来源"
|
||||
onChange={(value) => update('vehicleSource', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="租赁公司">
|
||||
<FilterPickerField
|
||||
value={form.leaseCompany}
|
||||
options={leaseCompanies}
|
||||
placeholder="请选择或搜索租赁公司"
|
||||
ariaLabel="租赁公司"
|
||||
onChange={(value) => update('leaseCompany', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="出厂年份">
|
||||
<FilterPickerField
|
||||
value={form.year}
|
||||
options={yearOptions}
|
||||
placeholder="请选择出厂年份"
|
||||
ariaLabel="出厂年份"
|
||||
onChange={(value) => update('year', value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="采购入库时间">
|
||||
<input
|
||||
className="vm-input vm-form-input"
|
||||
value={form.purchaseDate}
|
||||
onChange={(e) => update('purchaseDate', e.target.value)}
|
||||
placeholder="请输入采购入库时间"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={!form.operateStatus || !form.vehicleStatus}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
src/prototypes/vehicle-management/components/VehicleTable.tsx
Normal file
356
src/prototypes/vehicle-management/components/VehicleTable.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React from 'react';
|
||||
import { CircleHelp, Eye } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import {
|
||||
displayText,
|
||||
formatGpsTime,
|
||||
formatMileage,
|
||||
formatOperateCity,
|
||||
operateCitySourceTagClass,
|
||||
canEditOperateCity,
|
||||
canOpenLocationMap,
|
||||
resolveOperateCitySource,
|
||||
resolveOpsManagers,
|
||||
resolveProjectDelivery,
|
||||
resolveGpsLocationAddress,
|
||||
resolveThirdPartyLeaseCompany,
|
||||
} from '../utils/vehicle';
|
||||
import type { VehicleInsuranceExpire } from '../utils/insurance';
|
||||
import { buildInsuranceStatusTooltip, resolveVehicleInsuranceExpire } from '../utils/insurance';
|
||||
import { HandoverSituationCell } from './HandoverSituationCell';
|
||||
import { InspectExpireCell } from './InspectExpireCell';
|
||||
import { InsuranceExpireCell } from './InsuranceExpireCell';
|
||||
import { RowActionsMenu } from './RowActionsMenu';
|
||||
import { StatusTag } from './StatusTag';
|
||||
|
||||
interface VehicleTableProps {
|
||||
records: VehicleRecord[];
|
||||
insuranceExpireMap: Map<string, VehicleInsuranceExpire>;
|
||||
canEditOpsManager: boolean;
|
||||
isAdmin: boolean;
|
||||
onView: (record: VehicleRecord) => void;
|
||||
onEdit: (record: VehicleRecord) => void;
|
||||
onEditOpsManager: (record: VehicleRecord) => void;
|
||||
onEditOperateCity: (record: VehicleRecord) => void;
|
||||
onOpenContract: (record: VehicleRecord, contractNo: string) => void;
|
||||
onOpenLicenseEdit: (record: VehicleRecord) => void;
|
||||
onOpenInsurancePurchase: (record: VehicleRecord, insuranceType: '交强险' | '商业险') => void;
|
||||
onOpenLocationMap: (record: VehicleRecord) => void;
|
||||
onOpenDeliveryManagement: (record: VehicleRecord) => void;
|
||||
onOpenReturnManagement: (record: VehicleRecord) => void;
|
||||
}
|
||||
|
||||
const EMPTY = '';
|
||||
|
||||
function isClickableProjectField(value: string): boolean {
|
||||
return Boolean(value && value !== EMPTY);
|
||||
}
|
||||
|
||||
function formatBrandModel(brand: string, model: string): string {
|
||||
const brandText = displayText(brand, EMPTY);
|
||||
const modelText = displayText(model, EMPTY);
|
||||
if (brandText && modelText) return `${brandText} · ${modelText}`;
|
||||
return brandText || modelText;
|
||||
}
|
||||
|
||||
function operateStatusTone(status: string): 'green' | 'blue' | 'amber' | 'gray' {
|
||||
if (status === '租赁' || status === '自营') return 'green';
|
||||
if (status === '可运营' || status === '待运营') return 'amber';
|
||||
if (status === '退出运营') return 'gray';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
function alertTone(status: string): 'green' | 'red' | 'gray' {
|
||||
if (status === '正常') return 'green';
|
||||
if (status === '异常') return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
export function VehicleTable({
|
||||
records,
|
||||
insuranceExpireMap,
|
||||
canEditOpsManager,
|
||||
isAdmin,
|
||||
onView,
|
||||
onEdit,
|
||||
onEditOpsManager,
|
||||
onEditOperateCity,
|
||||
onOpenContract,
|
||||
onOpenLicenseEdit,
|
||||
onOpenInsurancePurchase,
|
||||
onOpenLocationMap,
|
||||
onOpenDeliveryManagement,
|
||||
onOpenReturnManagement,
|
||||
}: VehicleTableProps) {
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="vm-empty">
|
||||
<p>暂无符合条件的车辆</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vm-table-wrap">
|
||||
<table className="vm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky-col">车辆信息</th>
|
||||
<th>运营城市</th>
|
||||
<th>运营状态</th>
|
||||
<th>所属项目</th>
|
||||
<th>业务员</th>
|
||||
<th>停放区域</th>
|
||||
<th>里程数</th>
|
||||
<th>车辆状态</th>
|
||||
<th>证照状态</th>
|
||||
<th>检验有效期</th>
|
||||
<th>保险状态</th>
|
||||
<th>交强险到期时间</th>
|
||||
<th>商业险到期时间</th>
|
||||
<th>上次交车情况</th>
|
||||
<th>上次还车情况</th>
|
||||
<th>登记所有权</th>
|
||||
<th>
|
||||
<span className="vm-th-label">
|
||||
运营公司
|
||||
<span
|
||||
className="vm-th-tip"
|
||||
tabIndex={0}
|
||||
role="note"
|
||||
aria-label="运营公司说明"
|
||||
>
|
||||
<CircleHelp size={13} aria-hidden />
|
||||
<span className="vm-th-tooltip" role="tooltip">该车辆由羚牛哪个公司主体运营</span>
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
<th>车辆来源</th>
|
||||
<th>三方租赁公司</th>
|
||||
<th>当前位置</th>
|
||||
<th>运维负责人</th>
|
||||
<th className="sticky-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const ops = resolveOpsManagers(record);
|
||||
const online = record.onlineStatus === '在线';
|
||||
const citySource = resolveOperateCitySource(record);
|
||||
const cityText = formatOperateCity(record.location, EMPTY);
|
||||
const project = resolveProjectDelivery(record, EMPTY);
|
||||
const insurance = resolveVehicleInsuranceExpire(record.id, insuranceExpireMap);
|
||||
const thirdPartyLeaseCompany = resolveThirdPartyLeaseCompany(record, EMPTY);
|
||||
const cityEditable = canEditOperateCity(record);
|
||||
const locationAddress = resolveGpsLocationAddress(record, EMPTY);
|
||||
const locationMapOpenable = canOpenLocationMap(record);
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="sticky-col">
|
||||
<div className="vm-identity">
|
||||
<button type="button" className="vm-link" onClick={() => onView(record)}>
|
||||
{record.plateNo}
|
||||
</button>
|
||||
<button type="button" className="vm-link vm-vin" onClick={() => onView(record)}>
|
||||
{record.vin}
|
||||
</button>
|
||||
<span className="vm-brand">{formatBrandModel(record.brand, record.model)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="vm-cell-tags">
|
||||
{cityEditable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={() => onEditOperateCity(record)}
|
||||
title="点击修改运营城市"
|
||||
>
|
||||
{cityText}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
title={
|
||||
citySource === '车机'
|
||||
? '车机定位的运营城市不可修改'
|
||||
: citySource === 'GPS'
|
||||
? 'GPS定位的运营城市不可修改'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{cityText}
|
||||
</span>
|
||||
)}
|
||||
{citySource && (
|
||||
<span className={operateCitySourceTagClass(citySource)}>{citySource}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<StatusTag
|
||||
label={record.operateStatus}
|
||||
tone={operateStatusTone(record.operateStatus)}
|
||||
emptyLabel={EMPTY}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="vm-stack">
|
||||
{isClickableProjectField(project.projectName) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link primary"
|
||||
onClick={() => onOpenContract(record, project.contractNo)}
|
||||
>
|
||||
{project.projectName}
|
||||
</button>
|
||||
) : (
|
||||
<span className="primary">{project.projectName}</span>
|
||||
)}
|
||||
{isClickableProjectField(project.contractNo) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link code"
|
||||
onClick={() => onOpenContract(record, project.contractNo)}
|
||||
>
|
||||
{project.contractNo}
|
||||
</button>
|
||||
) : (
|
||||
<span className="code">{project.contractNo}</span>
|
||||
)}
|
||||
<span className="sub">{project.companyName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="vm-stack">
|
||||
<span className="sub">{displayText(record.department, EMPTY)}</span>
|
||||
<span className="primary">{displayText(record.manager, EMPTY)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{displayText(record.parking, EMPTY)}</td>
|
||||
<td>
|
||||
<div className="vm-cell-tags">
|
||||
<span className="vm-mileage">{formatMileage(record.mileage, online, EMPTY)}</span>
|
||||
{online && <span className="vm-tag vm-tag-teal">车机</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<StatusTag label={record.vehicleStatus} tone="blue" emptyLabel={EMPTY} />
|
||||
</td>
|
||||
<td>
|
||||
<StatusTag
|
||||
label={record.licenseStatus}
|
||||
tone={alertTone(record.licenseStatus)}
|
||||
title={record.licenseStatus === '异常' ? '行驶证检验有效期已过期' : undefined}
|
||||
emptyLabel={EMPTY}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<InspectExpireCell
|
||||
expireDate={record.inspectExpire}
|
||||
emptyLabel={EMPTY}
|
||||
onClickNotUploaded={() => onOpenLicenseEdit(record)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<StatusTag
|
||||
label={record.insuranceStatus}
|
||||
tone={alertTone(record.insuranceStatus)}
|
||||
title={buildInsuranceStatusTooltip(insurance)}
|
||||
emptyLabel={EMPTY}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<InsuranceExpireCell
|
||||
insuranceType="交强险"
|
||||
expireDate={insurance.compulsory}
|
||||
emptyLabel={EMPTY}
|
||||
onClickNotPurchased={() => onOpenInsurancePurchase(record, '交强险')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<InsuranceExpireCell
|
||||
insuranceType="商业险"
|
||||
expireDate={insurance.commercial}
|
||||
emptyLabel={EMPTY}
|
||||
onClickNotPurchased={() => onOpenInsurancePurchase(record, '商业险')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HandoverSituationCell
|
||||
kind="delivery"
|
||||
record={record}
|
||||
emptyLabel={EMPTY}
|
||||
onClick={onOpenDeliveryManagement}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HandoverSituationCell
|
||||
kind="return"
|
||||
record={record}
|
||||
emptyLabel={EMPTY}
|
||||
onClick={onOpenReturnManagement}
|
||||
/>
|
||||
</td>
|
||||
<td className="ellipsis" title={record.ownership}>{displayText(record.ownership, EMPTY)}</td>
|
||||
<td className="ellipsis" title={record.operateCompany}>{displayText(record.operateCompany, EMPTY)}</td>
|
||||
<td>
|
||||
<StatusTag label={record.vehicleSource} tone="blue" emptyLabel={EMPTY} />
|
||||
</td>
|
||||
<td className="ellipsis" title={thirdPartyLeaseCompany || undefined}>
|
||||
{thirdPartyLeaseCompany}
|
||||
</td>
|
||||
<td>
|
||||
<div className="vm-location">
|
||||
<span className={`vm-online ${online ? 'on' : 'off'}`}>
|
||||
<span className="dot" aria-hidden />
|
||||
{record.onlineStatus || '离线'}
|
||||
</span>
|
||||
{locationMapOpenable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link vm-loc-text"
|
||||
onClick={() => onOpenLocationMap(record)}
|
||||
title={locationAddress}
|
||||
>
|
||||
{locationAddress}
|
||||
</button>
|
||||
) : (
|
||||
<span className="vm-loc-text" title={locationAddress || undefined}>
|
||||
{locationAddress}
|
||||
</span>
|
||||
)}
|
||||
<span className="vm-loc-time">{formatGpsTime(record.gpsTime, EMPTY)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{canEditOpsManager ? (
|
||||
<button type="button" className="vm-link" onClick={() => onEditOpsManager(record)}>
|
||||
{ops.length ? ops.join(',') : '设置负责人'}
|
||||
</button>
|
||||
) : (
|
||||
<span>{ops.length ? ops.join(',') : EMPTY}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="sticky-right">
|
||||
<div className="vm-row-actions">
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onView(record)}>
|
||||
<Eye size={15} aria-hidden />
|
||||
查看
|
||||
</button>
|
||||
<RowActionsMenu
|
||||
record={record}
|
||||
isAdmin={isAdmin}
|
||||
canEditOpsManager={canEditOpsManager}
|
||||
onEdit={onEdit}
|
||||
onEditOpsManager={onEditOpsManager}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
function isInsideContainers(
|
||||
target: EventTarget | null,
|
||||
containers: Array<RefObject<HTMLElement | null>>,
|
||||
): boolean {
|
||||
const node = target as Node | null;
|
||||
if (!node) return false;
|
||||
return containers.some((ref) => ref.current?.contains(node));
|
||||
}
|
||||
|
||||
export function useDismissOnFocusOutside(
|
||||
open: boolean,
|
||||
containerRef: RefObject<HTMLElement | null> | Array<RefObject<HTMLElement | null>>,
|
||||
onDismiss: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const containers = Array.isArray(containerRef) ? containerRef : [containerRef];
|
||||
|
||||
const closeIfOutside = (target: EventTarget | null) => {
|
||||
if (!isInsideContainers(target, containers)) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
closeIfOutside(event.target);
|
||||
};
|
||||
|
||||
const handleFocusIn = () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
closeIfOutside(document.activeElement);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('focusin', handleFocusIn);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('focusin', handleFocusIn);
|
||||
};
|
||||
}, [open, onDismiss, containerRef]);
|
||||
}
|
||||
47
src/prototypes/vehicle-management/data/accident-records.json
Normal file
47
src/prototypes/vehicle-management/data/accident-records.json
Normal file
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": "acc-3-1",
|
||||
"plateNo": "粤AGP5156",
|
||||
"accidentCode": "ACC20231027001",
|
||||
"accidentTime": "2023-10-27 10:30",
|
||||
"accidentLocation": "北京市朝阳区建国门外大街",
|
||||
"accidentType": "追尾",
|
||||
"customerName": "张三",
|
||||
"accidentLevel": "一般",
|
||||
"ourDamageAmount": 2000,
|
||||
"theirDamageAmount": 1500,
|
||||
"responsibility": "我方全责",
|
||||
"status": "已结案",
|
||||
"closedTime": "2023-11-05 14:00"
|
||||
},
|
||||
{
|
||||
"id": "acc-3-2",
|
||||
"plateNo": "粤AGP5156",
|
||||
"accidentCode": "ACC20231115002",
|
||||
"accidentTime": "2023-11-15 16:45",
|
||||
"accidentLocation": "上海市浦东新区世纪大道",
|
||||
"accidentType": "侧碰",
|
||||
"customerName": "李四",
|
||||
"accidentLevel": "较大",
|
||||
"ourDamageAmount": 5500,
|
||||
"theirDamageAmount": 3200,
|
||||
"responsibility": "对方全责",
|
||||
"status": "处理中",
|
||||
"closedTime": ""
|
||||
},
|
||||
{
|
||||
"id": "acc-3-3",
|
||||
"plateNo": "粤AGP5156",
|
||||
"accidentCode": "ACC20231201003",
|
||||
"accidentTime": "2023-12-01 09:15",
|
||||
"accidentLocation": "广州市天河区天河路",
|
||||
"accidentType": "刮擦",
|
||||
"customerName": "王五",
|
||||
"accidentLevel": "轻微",
|
||||
"ourDamageAmount": 800,
|
||||
"theirDamageAmount": 0,
|
||||
"responsibility": "同等责任",
|
||||
"status": "已结案",
|
||||
"closedTime": "2023-12-10 11:30"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"id": "ar-3-1",
|
||||
"plateNo": "粤AGP5156",
|
||||
"inspectionValidUntil": "2025-06-30",
|
||||
"inspectionStation": "汇通检测站",
|
||||
"inspectionCost": 280,
|
||||
"m2Station": "汇通检测站",
|
||||
"m2Cost": 180,
|
||||
"zbStation": "",
|
||||
"zbCost": null,
|
||||
"executor": "张明辉",
|
||||
"executeTime": "2025-06-28 11:20"
|
||||
},
|
||||
{
|
||||
"id": "ar-3-2",
|
||||
"plateNo": "粤AGP5156",
|
||||
"inspectionValidUntil": "2024-06-15",
|
||||
"inspectionStation": "平湖检测站",
|
||||
"inspectionCost": 320,
|
||||
"m2Station": "平湖检测站",
|
||||
"m2Cost": 320,
|
||||
"zbStation": "广州天河维修站",
|
||||
"zbCost": 520,
|
||||
"executor": "李晓彤",
|
||||
"executeTime": "2024-06-12 09:45"
|
||||
},
|
||||
{
|
||||
"id": "ar-3-3",
|
||||
"plateNo": "粤AGP5156",
|
||||
"inspectionValidUntil": "2023-05-20",
|
||||
"inspectionStation": "汇通检测站",
|
||||
"inspectionCost": 280,
|
||||
"m2Station": "",
|
||||
"m2Cost": null,
|
||||
"zbStation": "",
|
||||
"zbCost": null,
|
||||
"executor": "王建国",
|
||||
"executeTime": "2023-05-18 16:30"
|
||||
},
|
||||
{
|
||||
"id": "ar-1-1",
|
||||
"plateNo": "粤B58888F",
|
||||
"inspectionValidUntil": "2025-03-10",
|
||||
"inspectionStation": "汇通检测站",
|
||||
"inspectionCost": 320,
|
||||
"m2Station": "汇通检测站",
|
||||
"m2Cost": 180,
|
||||
"zbStation": "",
|
||||
"zbCost": null,
|
||||
"executor": "张明辉",
|
||||
"executeTime": "2025-03-08 14:20"
|
||||
},
|
||||
{
|
||||
"id": "ar-2-1",
|
||||
"plateNo": "沪A03561F",
|
||||
"inspectionValidUntil": "2024-07-31",
|
||||
"inspectionStation": "平湖检测站",
|
||||
"inspectionCost": 320,
|
||||
"m2Station": "平湖检测站",
|
||||
"m2Cost": 320,
|
||||
"zbStation": "",
|
||||
"zbCost": null,
|
||||
"executor": "李晓彤",
|
||||
"executeTime": "2024-07-28 09:15"
|
||||
}
|
||||
]
|
||||
82
src/prototypes/vehicle-management/data/fault-records.json
Normal file
82
src/prototypes/vehicle-management/data/fault-records.json
Normal file
@@ -0,0 +1,82 @@
|
||||
[
|
||||
{
|
||||
"id": "fault-3-1",
|
||||
"plateNo": "粤AGP5156",
|
||||
"faultNo": "GZ20260624111101",
|
||||
"resolutionStatus": "已解决",
|
||||
"brand": "现代",
|
||||
"model": "帕力安牌4.5吨冷链车",
|
||||
"operateCompany": "羚牛氢能科技(广东)有限公司",
|
||||
"faultLevel": "L4-提示",
|
||||
"faultType": "冷机故障",
|
||||
"faultDescription": "冷机压缩机异响,制冷效果下降",
|
||||
"reportedAt": "2026-06-24 11:11:08",
|
||||
"resolvedAt": "2026-06-24 15:32:40",
|
||||
"aiMatched": true,
|
||||
"lastOperator": "AI"
|
||||
},
|
||||
{
|
||||
"id": "fault-3-2",
|
||||
"plateNo": "粤AGP5156",
|
||||
"faultNo": "GZ20260623100502",
|
||||
"resolutionStatus": "",
|
||||
"brand": "现代",
|
||||
"model": "帕力安牌4.5吨冷链车",
|
||||
"operateCompany": "羚牛氢能科技(广东)有限公司",
|
||||
"faultLevel": "L2-紧急",
|
||||
"faultType": "供氢系统故障",
|
||||
"faultDescription": "加氢口密封圈老化,存在轻微漏气",
|
||||
"reportedAt": "2026-06-23 10:05:16",
|
||||
"resolvedAt": "",
|
||||
"aiMatched": false,
|
||||
"lastOperator": "魏山"
|
||||
},
|
||||
{
|
||||
"id": "fault-3-3",
|
||||
"plateNo": "粤AGP5156",
|
||||
"faultNo": "GZ20260622140803",
|
||||
"resolutionStatus": "已解决",
|
||||
"brand": "现代",
|
||||
"model": "帕力安牌4.5吨冷链车",
|
||||
"operateCompany": "羚牛氢能科技(广东)有限公司",
|
||||
"faultLevel": "L1-特急",
|
||||
"faultType": "三电故障",
|
||||
"faultDescription": "电机控制器过温报警,需立即检修",
|
||||
"reportedAt": "2026-06-22 14:08:22",
|
||||
"resolvedAt": "2026-06-22 18:45:10",
|
||||
"aiMatched": true,
|
||||
"lastOperator": "AI"
|
||||
},
|
||||
{
|
||||
"id": "fault-3-4",
|
||||
"plateNo": "粤AGP5156",
|
||||
"faultNo": "GZ20260620153204",
|
||||
"resolutionStatus": "",
|
||||
"brand": "现代",
|
||||
"model": "帕力安牌4.5吨冷链车",
|
||||
"operateCompany": "羚牛氢能科技(广东)有限公司",
|
||||
"faultLevel": "L2-紧急",
|
||||
"faultType": "底盘故障",
|
||||
"faultDescription": "前桥制动片磨损超限",
|
||||
"reportedAt": "2026-06-20 15:32:04",
|
||||
"resolvedAt": "",
|
||||
"aiMatched": false,
|
||||
"lastOperator": "沈扣"
|
||||
},
|
||||
{
|
||||
"id": "fault-3-5",
|
||||
"plateNo": "粤AGP5156",
|
||||
"faultNo": "GZ20260618102105",
|
||||
"resolutionStatus": "已解决",
|
||||
"brand": "现代",
|
||||
"model": "帕力安牌4.5吨冷链车",
|
||||
"operateCompany": "羚牛氢能科技(广东)有限公司",
|
||||
"faultLevel": "L3-一般",
|
||||
"faultType": "燃料系统",
|
||||
"faultDescription": "氢气压力传感器读数波动",
|
||||
"reportedAt": "2026-06-18 10:21:05",
|
||||
"resolvedAt": "2026-06-19 09:15:00",
|
||||
"aiMatched": true,
|
||||
"lastOperator": "AI"
|
||||
}
|
||||
]
|
||||
2648
src/prototypes/vehicle-management/data/insurance-purchases.json
Normal file
2648
src/prototypes/vehicle-management/data/insurance-purchases.json
Normal file
File diff suppressed because it is too large
Load Diff
14
src/prototypes/vehicle-management/data/lease-records.json
Normal file
14
src/prototypes/vehicle-management/data/lease-records.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": "lr-42-1",
|
||||
"plateNo": "沪BH391挂",
|
||||
"contractNo": "2026010101",
|
||||
"projectName": "嘉兴玲利租赁挂板*1",
|
||||
"customerName": "嘉兴玲利供应链科技有限公司",
|
||||
"businessType": "租赁",
|
||||
"deliveryDate": "2026-02-01",
|
||||
"deliveryPerson": "赵小隆",
|
||||
"returnDate": "",
|
||||
"returnPerson": ""
|
||||
}
|
||||
]
|
||||
78
src/prototypes/vehicle-management/data/movement-records.json
Normal file
78
src/prototypes/vehicle-management/data/movement-records.json
Normal file
@@ -0,0 +1,78 @@
|
||||
[
|
||||
{
|
||||
"id": "move-3-1",
|
||||
"plateNo": "粤AGP5156",
|
||||
"startDate": "2024-03-15",
|
||||
"estimatedEndDate": "2024-03-16",
|
||||
"status": "已完成",
|
||||
"destinationType": "维修站",
|
||||
"destinationName": "汇通检测站停车场",
|
||||
"movementType": "年审",
|
||||
"estimatedMileageKm": 42,
|
||||
"startMileageKm": 128560,
|
||||
"startBatteryKwh": 68.5,
|
||||
"startHydrogenPct": 71,
|
||||
"endMileageKm": 128602,
|
||||
"endBatteryKwh": 65.2,
|
||||
"endHydrogenPct": 68.5,
|
||||
"createdBy": "张华",
|
||||
"createdAt": "2024-03-15 09:12:08"
|
||||
},
|
||||
{
|
||||
"id": "move-3-2",
|
||||
"plateNo": "粤AGP5156",
|
||||
"startDate": "2024-05-22",
|
||||
"estimatedEndDate": "2024-05-22",
|
||||
"status": "已完成",
|
||||
"destinationType": "加氢站",
|
||||
"destinationName": "萧山加氢站",
|
||||
"movementType": "其他",
|
||||
"estimatedMileageKm": 18,
|
||||
"startMileageKm": 131240,
|
||||
"startBatteryKwh": 72.0,
|
||||
"startHydrogenPct": 22.5,
|
||||
"endMileageKm": 131258,
|
||||
"endBatteryKwh": 71.4,
|
||||
"endHydrogenPct": 85.0,
|
||||
"createdBy": "张华",
|
||||
"createdAt": "2024-05-22 14:36:21"
|
||||
},
|
||||
{
|
||||
"id": "move-3-3",
|
||||
"plateNo": "粤AGP5156",
|
||||
"startDate": "2024-08-03",
|
||||
"estimatedEndDate": "2024-08-05",
|
||||
"status": "已完成",
|
||||
"destinationType": "停车场",
|
||||
"destinationName": "滨江运营停车场",
|
||||
"movementType": "维修",
|
||||
"estimatedMileageKm": 26,
|
||||
"startMileageKm": 135880,
|
||||
"startBatteryKwh": 80.2,
|
||||
"startHydrogenPct": 56.0,
|
||||
"endMileageKm": 135906,
|
||||
"endBatteryKwh": 78.6,
|
||||
"endHydrogenPct": 54.2,
|
||||
"createdBy": "李明",
|
||||
"createdAt": "2024-08-03 08:05:44"
|
||||
},
|
||||
{
|
||||
"id": "move-3-4",
|
||||
"plateNo": "粤AGP5156",
|
||||
"startDate": "2024-11-18",
|
||||
"estimatedEndDate": "2024-11-19",
|
||||
"status": "已完成",
|
||||
"destinationType": "维修站",
|
||||
"destinationName": "羚牛售后服务中心",
|
||||
"movementType": "其他",
|
||||
"estimatedMileageKm": 35,
|
||||
"startMileageKm": 139420,
|
||||
"startBatteryKwh": 63.8,
|
||||
"startHydrogenPct": 48.0,
|
||||
"endMileageKm": 139455,
|
||||
"endBatteryKwh": 61.5,
|
||||
"endHydrogenPct": 46.5,
|
||||
"createdBy": "张华",
|
||||
"createdAt": "2024-11-18 16:28:33"
|
||||
}
|
||||
]
|
||||
54
src/prototypes/vehicle-management/data/transfer-records.json
Normal file
54
src/prototypes/vehicle-management/data/transfer-records.json
Normal file
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"id": "xfer-3-1",
|
||||
"plateNo": "粤AGP5156",
|
||||
"transferDate": "2024-02-08",
|
||||
"transferOutPerson": "张华",
|
||||
"recipientPerson": "李明",
|
||||
"departureArea": "滨江区",
|
||||
"receivingArea": "萧山区",
|
||||
"transferMethod": "自驾",
|
||||
"departureParking": "滨江运营停车场",
|
||||
"receivingParking": "萧山南部停车场",
|
||||
"receiveDate": "2024-02-08"
|
||||
},
|
||||
{
|
||||
"id": "xfer-3-2",
|
||||
"plateNo": "粤AGP5156",
|
||||
"transferDate": "2024-06-19",
|
||||
"transferOutPerson": "王强",
|
||||
"recipientPerson": "张华",
|
||||
"departureArea": "萧山区",
|
||||
"receivingArea": "余杭区",
|
||||
"transferMethod": "板车运输",
|
||||
"departureParking": "萧山南部停车场",
|
||||
"receivingParking": "余杭未来科技城停车场",
|
||||
"receiveDate": "2024-06-20"
|
||||
},
|
||||
{
|
||||
"id": "xfer-3-3",
|
||||
"plateNo": "粤AGP5156",
|
||||
"transferDate": "2024-09-05",
|
||||
"transferOutPerson": "李明",
|
||||
"recipientPerson": "赵敏",
|
||||
"departureArea": "余杭区",
|
||||
"receivingArea": "拱墅区",
|
||||
"transferMethod": "自驾",
|
||||
"departureParking": "余杭未来科技城停车场",
|
||||
"receivingParking": "拱墅康桥停车场",
|
||||
"receiveDate": "2024-09-05"
|
||||
},
|
||||
{
|
||||
"id": "xfer-3-4",
|
||||
"plateNo": "粤AGP5156",
|
||||
"transferDate": "2024-12-11",
|
||||
"transferOutPerson": "张华",
|
||||
"recipientPerson": "王强",
|
||||
"departureArea": "拱墅区",
|
||||
"receivingArea": "滨江区",
|
||||
"transferMethod": "板车运输",
|
||||
"departureParking": "拱墅康桥停车场",
|
||||
"receivingParking": "滨江运营停车场",
|
||||
"receiveDate": "2024-12-12"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"1": {
|
||||
"compulsory": {
|
||||
"policyNo": "PDAA2025440100B00001234",
|
||||
"company": "中国人民财产保险股份有限公司",
|
||||
"premium": "950.00"
|
||||
},
|
||||
"commercial": {
|
||||
"policyNo": "PDAA2025440100B00005678",
|
||||
"company": "中国平安财产保险股份有限公司",
|
||||
"premium": "12800.50"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"commercial": {
|
||||
"policyNo": "PDAA2025440100B00009876",
|
||||
"company": "中华联合财产保险股份有限公司",
|
||||
"premium": "11200.00"
|
||||
}
|
||||
},
|
||||
"42": {
|
||||
"compulsory": {
|
||||
"policyNo": "PDAA2025310100B00003210",
|
||||
"company": "中国人寿财产保险股份有限公司",
|
||||
"premium": "880.00"
|
||||
},
|
||||
"commercial": {
|
||||
"policyNo": "PDAA2025310100B00008765",
|
||||
"company": "太平洋财产保险股份有限公司",
|
||||
"premium": "9850.00"
|
||||
},
|
||||
"excess": {
|
||||
"policyNo": "PDAA2025310100B00009999",
|
||||
"company": "中国人民财产保险股份有限公司",
|
||||
"premium": "3200.00",
|
||||
"expireDate": "2026-06-28",
|
||||
"purchasedAt": "2025-06-28",
|
||||
"operator": "李专员"
|
||||
}
|
||||
}
|
||||
}
|
||||
4402
src/prototypes/vehicle-management/data/vehicles.json
Normal file
4402
src/prototypes/vehicle-management/data/vehicles.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"id": "vio-3-1",
|
||||
"plateNo": "粤AGP5156",
|
||||
"customerName": "杭州羚牛物流有限公司",
|
||||
"violationTime": "2023-11-14",
|
||||
"violationLocation": "萧山-风情大道小白线口",
|
||||
"violationBehavior": "驾驶机动车违反禁令标志指示的",
|
||||
"pointsDeducted": 1,
|
||||
"fineAmount": 100,
|
||||
"paymentStatus": "已缴费",
|
||||
"processed": "已处理",
|
||||
"collectionUnit": "杭州市公安局萧山区分局交通警察大队"
|
||||
}
|
||||
]
|
||||
378
src/prototypes/vehicle-management/index.tsx
Normal file
378
src/prototypes/vehicle-management/index.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @name 车辆管理
|
||||
*/
|
||||
|
||||
import './style.css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Download, FileUp } from 'lucide-react';
|
||||
import seedVehicles from './data/vehicles.json';
|
||||
import seedInsurancePurchases from './data/insurance-purchases.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { ImportModal } from './components/ImportModal';
|
||||
import { KpiCards } from './components/KpiCards';
|
||||
import { OpsManagerModal } from './components/OpsManagerModal';
|
||||
import { OperateCityEditModal } from './components/OperateCityEditModal';
|
||||
import { LocationMapModal } from './components/LocationMapModal';
|
||||
import { VehicleEditModal } from './components/VehicleEditModal';
|
||||
import { RequirementsModal } from './components/RequirementsModal';
|
||||
import { PageDocToolbar } from './components/PageDocToolbar';
|
||||
import { VehicleTable } from './components/VehicleTable';
|
||||
import { TablePagination } from './components/TablePagination';
|
||||
import { DetailPage } from './pages/DetailPage';
|
||||
import {
|
||||
EMPTY_FILTERS,
|
||||
type KpiKey,
|
||||
type VehicleFilters,
|
||||
type VehicleRecord,
|
||||
} from './types';
|
||||
import type { InsurancePurchaseRecord } from './types';
|
||||
import { applyFilters } from './utils/filters';
|
||||
import { buildInsuranceExpireMap } from './utils/insurance';
|
||||
import type { LeaseRecordRow } from './utils/leaseRecords';
|
||||
import type { AccidentRecordRow } from './utils/accidentRecords';
|
||||
import type { ViolationRecordRow } from './utils/violationRecords';
|
||||
import type { MovementRecordRow } from './utils/movementRecords';
|
||||
import type { TransferRecordRow } from './utils/transferRecords';
|
||||
import type { AnnualReviewRecordRow } from './utils/annualReviewRecords';
|
||||
import { downloadVehiclePrdMarkdownFiles } from './utils/prdDownloads';
|
||||
import { downloadVehicleTestCasesExcel } from './utils/testCaseExport';
|
||||
import { countKpi } from './utils/vehicle';
|
||||
|
||||
const CAN_EDIT_OPS_MANAGER = true;
|
||||
const IS_ADMIN = true;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export default function VehicleManagementApp() {
|
||||
const [records, setRecords] = useState<VehicleRecord[]>(seedVehicles as VehicleRecord[]);
|
||||
const [pendingFilters, setPendingFilters] = useState<VehicleFilters>(EMPTY_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<VehicleFilters>(EMPTY_FILTERS);
|
||||
const [kpiTab, setKpiTab] = useState<KpiKey>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [detailRecord, setDetailRecord] = useState<VehicleRecord | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [opsRecord, setOpsRecord] = useState<VehicleRecord | null>(null);
|
||||
const [operateCityRecord, setOperateCityRecord] = useState<VehicleRecord | null>(null);
|
||||
const [locationMapRecord, setLocationMapRecord] = useState<VehicleRecord | null>(null);
|
||||
const [editRecord, setEditRecord] = useState<VehicleRecord | null>(null);
|
||||
const [requirementsOpen, setRequirementsOpen] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 3000);
|
||||
}, []);
|
||||
|
||||
const insuranceExpireMap = useMemo(
|
||||
() => buildInsuranceExpireMap(seedInsurancePurchases as InsurancePurchaseRecord[]),
|
||||
[],
|
||||
);
|
||||
|
||||
const kpiCounts = useMemo(() => ({
|
||||
all: countKpi(records, 'all'),
|
||||
lease: countKpi(records, 'lease'),
|
||||
self: countKpi(records, 'self'),
|
||||
stock: countKpi(records, 'stock'),
|
||||
nonOperating: countKpi(records, 'nonOperating'),
|
||||
exit: countKpi(records, 'exit'),
|
||||
licenseAbnormal: countKpi(records, 'licenseAbnormal'),
|
||||
insuranceAbnormal: countKpi(records, 'insuranceAbnormal'),
|
||||
}), [records]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(records, appliedFilters, kpiTab),
|
||||
[records, appliedFilters, kpiTab],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, page, pageSize]);
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_FILTERS);
|
||||
setAppliedFilters(EMPTY_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
showToast(`导出功能(原型演示):当前列表共 ${filtered.length} 条`);
|
||||
};
|
||||
|
||||
const handleImport = (file: File) => {
|
||||
showToast(`已接收文件 ${file.name},导入校验为原型演示`);
|
||||
setImportOpen(false);
|
||||
};
|
||||
|
||||
const handleSaveOps = (managers: string[]) => {
|
||||
if (!opsRecord) return;
|
||||
setRecords((prev) => prev.map((r) => (
|
||||
r.id === opsRecord.id ? { ...r, opsManagers: managers } : r
|
||||
)));
|
||||
setOpsRecord(null);
|
||||
showToast('运维负责人已更新');
|
||||
};
|
||||
|
||||
const handleSaveOperateCity = (location: string) => {
|
||||
if (!operateCityRecord) return;
|
||||
setRecords((prev) => prev.map((r) => (
|
||||
r.id === operateCityRecord.id
|
||||
? { ...r, location, operateCitySource: '人工' as const }
|
||||
: r
|
||||
)));
|
||||
setOperateCityRecord(null);
|
||||
showToast('运营城市已更新');
|
||||
};
|
||||
|
||||
const handleOpenContract = (_record: VehicleRecord, contractNo: string) => {
|
||||
showToast(`即将跳转至合同详情(原型演示):${contractNo}`);
|
||||
};
|
||||
|
||||
const handleOpenLeaseContract = (record: VehicleRecord, contractNo: string) => {
|
||||
showToast(`即将跳转至租赁合同详情(原型演示):${record.plateNo} · ${contractNo}`);
|
||||
};
|
||||
|
||||
const handleOpenDeliveryRecord = (record: VehicleRecord, row: LeaseRecordRow) => {
|
||||
showToast(`即将跳转至交车管理详情(原型演示):${record.plateNo} · ${row.contractNo} · ${row.deliveryDate}`);
|
||||
};
|
||||
|
||||
const handleOpenReturnRecord = (record: VehicleRecord, row: LeaseRecordRow) => {
|
||||
showToast(`即将跳转至还车管理详情(原型演示):${record.plateNo} · ${row.contractNo} · ${row.returnDate}`);
|
||||
};
|
||||
|
||||
const handleOpenLicenseEdit = (record: VehicleRecord) => {
|
||||
showToast(`即将跳转至证照管理编辑页(原型演示):${record.plateNo}`);
|
||||
};
|
||||
|
||||
const handleOpenInsurancePurchase = (record: VehicleRecord, insuranceLabel: string) => {
|
||||
showToast(`即将跳转至保险采购编辑页(原型演示):${record.plateNo} · ${insuranceLabel}`);
|
||||
};
|
||||
|
||||
const handleViewFaultDetail = (record: VehicleRecord, row: { faultNo: string }) => {
|
||||
showToast(`即将跳转至故障管理详情页(原型演示):${row.faultNo}`);
|
||||
};
|
||||
|
||||
const handleEditFaultRecord = (record: VehicleRecord, row: { faultNo: string }) => {
|
||||
showToast(`编辑故障记录(原型演示):${record.plateNo} · ${row.faultNo}`);
|
||||
};
|
||||
|
||||
const handleOpenAccidentDetail = (record: VehicleRecord, row: AccidentRecordRow) => {
|
||||
showToast(`即将跳转至事故管理详情页(原型演示):${row.accidentCode}`);
|
||||
};
|
||||
|
||||
const handleOpenViolationDetail = (_record: VehicleRecord, row: ViolationRecordRow) => {
|
||||
showToast(`即将跳转至违章管理详情页(原型演示):${row.violationTime}`);
|
||||
};
|
||||
|
||||
const handleOpenMovementDetail = (_record: VehicleRecord, row: MovementRecordRow) => {
|
||||
showToast(`即将跳转至异动管理详情页(原型演示):${row.startDate}`);
|
||||
};
|
||||
|
||||
const handleOpenTransferDetail = (_record: VehicleRecord, row: TransferRecordRow) => {
|
||||
showToast(`即将跳转至车辆调拨详情页(原型演示):${row.transferDate}`);
|
||||
};
|
||||
|
||||
const handleOpenAnnualReviewDetail = (_record: VehicleRecord, row: AnnualReviewRecordRow) => {
|
||||
showToast(`即将跳转至年审管理详情页(原型演示):${row.inspectionValidUntil}`);
|
||||
};
|
||||
|
||||
const handlePreviewPolicyFile = (fileName: string) => {
|
||||
showToast(`预览保单文件(原型演示):${fileName}`);
|
||||
};
|
||||
|
||||
const handleDownloadPolicyFile = (fileName: string) => {
|
||||
showToast(`已开始下载:${fileName}(原型演示)`);
|
||||
};
|
||||
|
||||
const handleOpenDeliveryManagement = (record: VehicleRecord) => {
|
||||
showToast(`即将跳转至交车管理(原型演示):${record.plateNo}`);
|
||||
};
|
||||
|
||||
const handleOpenReturnManagement = (record: VehicleRecord) => {
|
||||
showToast(`即将跳转至还车管理(原型演示):${record.plateNo}`);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (record: VehicleRecord) => {
|
||||
setRecords((prev) => prev.map((r) => (r.id === record.id ? record : r)));
|
||||
setEditRecord(null);
|
||||
showToast('车辆信息已更新');
|
||||
};
|
||||
|
||||
const handleDetailFieldSave = (recordId: string, patch: Partial<VehicleRecord>) => {
|
||||
setRecords((prev) => prev.map((r) => (r.id === recordId ? { ...r, ...patch } : r)));
|
||||
showToast('车辆信息已更新');
|
||||
};
|
||||
|
||||
const handleDownloadPrd = () => {
|
||||
downloadVehiclePrdMarkdownFiles(showToast);
|
||||
};
|
||||
|
||||
const handleDownloadTestCases = () => {
|
||||
downloadVehicleTestCasesExcel(showToast);
|
||||
};
|
||||
|
||||
const docToolbar = (
|
||||
<PageDocToolbar
|
||||
onOpenRequirements={() => setRequirementsOpen(true)}
|
||||
onDownloadPrd={handleDownloadPrd}
|
||||
onDownloadTestCases={handleDownloadTestCases}
|
||||
/>
|
||||
);
|
||||
|
||||
const activeDetail = detailRecord
|
||||
? records.find((r) => r.id === detailRecord.id) ?? detailRecord
|
||||
: null;
|
||||
|
||||
if (activeDetail) {
|
||||
return (
|
||||
<div className="vm-page">
|
||||
{docToolbar}
|
||||
<DetailPage
|
||||
record={activeDetail}
|
||||
onBack={() => setDetailRecord(null)}
|
||||
canEditOpsManager={CAN_EDIT_OPS_MANAGER}
|
||||
onEditOpsManager={setOpsRecord}
|
||||
onEditOperateCity={setOperateCityRecord}
|
||||
onOpenLicenseEdit={handleOpenLicenseEdit}
|
||||
onSaveField={handleDetailFieldSave}
|
||||
onOpenLeaseContract={handleOpenLeaseContract}
|
||||
onOpenDeliveryRecord={handleOpenDeliveryRecord}
|
||||
onOpenReturnRecord={handleOpenReturnRecord}
|
||||
onOpenInsurancePurchase={handleOpenInsurancePurchase}
|
||||
onViewFaultDetail={handleViewFaultDetail}
|
||||
onEditFaultRecord={handleEditFaultRecord}
|
||||
onOpenAccidentDetail={handleOpenAccidentDetail}
|
||||
onOpenViolationDetail={handleOpenViolationDetail}
|
||||
onOpenMovementDetail={handleOpenMovementDetail}
|
||||
onOpenTransferDetail={handleOpenTransferDetail}
|
||||
onOpenAnnualReviewDetail={handleOpenAnnualReviewDetail}
|
||||
onPreviewPolicyFile={handlePreviewPolicyFile}
|
||||
onDownloadPolicyFile={handleDownloadPolicyFile}
|
||||
/>
|
||||
<OpsManagerModal
|
||||
open={!!opsRecord}
|
||||
record={opsRecord}
|
||||
onClose={() => setOpsRecord(null)}
|
||||
onSave={handleSaveOps}
|
||||
/>
|
||||
<OperateCityEditModal
|
||||
open={!!operateCityRecord}
|
||||
record={operateCityRecord}
|
||||
records={records}
|
||||
onClose={() => setOperateCityRecord(null)}
|
||||
onSave={handleSaveOperateCity}
|
||||
/>
|
||||
<RequirementsModal
|
||||
open={requirementsOpen}
|
||||
variant="detail"
|
||||
onClose={() => setRequirementsOpen(false)}
|
||||
/>
|
||||
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vm-page">
|
||||
{docToolbar}
|
||||
<FilterPanel
|
||||
records={records}
|
||||
filters={pendingFilters}
|
||||
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
|
||||
onReset={handleReset}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
|
||||
<KpiCards counts={kpiCounts} active={kpiTab} onChange={(key) => { setKpiTab(key); setPage(1); }} />
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar">
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={handleExport}>
|
||||
<Download size={16} aria-hidden />
|
||||
导出
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => setImportOpen(true)}>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<VehicleTable
|
||||
records={paged}
|
||||
insuranceExpireMap={insuranceExpireMap}
|
||||
canEditOpsManager={CAN_EDIT_OPS_MANAGER}
|
||||
isAdmin={IS_ADMIN}
|
||||
onView={setDetailRecord}
|
||||
onEdit={setEditRecord}
|
||||
onEditOpsManager={setOpsRecord}
|
||||
onEditOperateCity={setOperateCityRecord}
|
||||
onOpenContract={handleOpenContract}
|
||||
onOpenLicenseEdit={handleOpenLicenseEdit}
|
||||
onOpenInsurancePurchase={handleOpenInsurancePurchase}
|
||||
onOpenLocationMap={setLocationMapRecord}
|
||||
onOpenDeliveryManagement={handleOpenDeliveryManagement}
|
||||
onOpenReturnManagement={handleOpenReturnManagement}
|
||||
/>
|
||||
<div className="vm-table-footer">
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ImportModal open={importOpen} onClose={() => setImportOpen(false)} onImport={handleImport} />
|
||||
<OpsManagerModal
|
||||
open={!!opsRecord}
|
||||
record={opsRecord}
|
||||
onClose={() => setOpsRecord(null)}
|
||||
onSave={handleSaveOps}
|
||||
/>
|
||||
<OperateCityEditModal
|
||||
open={!!operateCityRecord}
|
||||
record={operateCityRecord}
|
||||
records={records}
|
||||
onClose={() => setOperateCityRecord(null)}
|
||||
onSave={handleSaveOperateCity}
|
||||
/>
|
||||
<LocationMapModal
|
||||
open={!!locationMapRecord}
|
||||
record={locationMapRecord}
|
||||
onClose={() => setLocationMapRecord(null)}
|
||||
/>
|
||||
<VehicleEditModal
|
||||
open={!!editRecord}
|
||||
record={editRecord}
|
||||
records={records}
|
||||
onClose={() => setEditRecord(null)}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
|
||||
<RequirementsModal
|
||||
open={requirementsOpen}
|
||||
variant="list"
|
||||
onClose={() => setRequirementsOpen(false)}
|
||||
/>
|
||||
|
||||
{toast && <div className="vm-toast" role="status" aria-live="polite">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user