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:
王冕
2026-06-26 15:36:52 +08:00
commit 7ef8852919
1310 changed files with 282074 additions and 0 deletions

View 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;
}
}

View 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));
}

View 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;

View 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>
),
};

View 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;

View 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;

View File

@@ -0,0 +1,10 @@
/**
* ThemeShell - 去主题化的设计系统展示组件
*
* 导出模块
*/
export { ThemeShell, default } from './ThemeShell';
export * from './types';
export { MarkdownViewer } from './MarkdownViewer';
export type { MarkdownViewerProps } from './MarkdownViewer';

View 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);

View 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;
}

View 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
View 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;
}

View 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
View 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
View 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;

View 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;

View 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;
}

View 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;
}>;
}>;
}
```

View 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
View 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
View 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>

View 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>

View 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>

File diff suppressed because one or more lines are too long

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 客户表证照预览图(原型用 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 || '',
};
}

View 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

View File

@@ -0,0 +1,4 @@
/**
* 合同模板管理 — 模块样式Linear 令牌与 .vm-* 由 vehicle-management/style.css 提供)
*/
@import url('./contract-template.css');

View 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 cardoneos-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;
}
}

View File

@@ -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;
/* Spacing4px 基准网格) */
--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);
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View 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),
);
}

View 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('提交审核(原型)'); },
}, '提交审核'),
),
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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),
),
),
);
}

View 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(/&nbsp;/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;
}

View 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),
);
}

View 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);
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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.11.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;
}

View 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()],
};
}

View File

@@ -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

View File

@@ -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);
}

View 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>
);
}

View File

@@ -0,0 +1,7 @@
/**
* @name 数智部周报
*/
import './styles/weekly-report.css';
import WeeklyReportPage from './WeeklyReportPage';
export default WeeklyReportPage;

View 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);
}

File diff suppressed because it is too large Load Diff

View 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: '分页一致性专项与租赁合同新增页问题并行,需评估是否临时增援',
},
];

View File

@@ -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文件下载」「下载测试用例」在详情页可用且行为正确

View 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

View 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与触屏宽度验收并附截图存档。

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 />;
}

View File

@@ -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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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 />;
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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]);
}

View 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"
}
]

View File

@@ -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"
}
]

View 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"
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[
{
"id": "lr-42-1",
"plateNo": "沪BH391挂",
"contractNo": "2026010101",
"projectName": "嘉兴玲利租赁挂板*1",
"customerName": "嘉兴玲利供应链科技有限公司",
"businessType": "租赁",
"deliveryDate": "2026-02-01",
"deliveryPerson": "赵小隆",
"returnDate": "",
"returnPerson": ""
}
]

View 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"
}
]

View 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"
}
]

View File

@@ -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": "李专员"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
[
{
"id": "vio-3-1",
"plateNo": "粤AGP5156",
"customerName": "杭州羚牛物流有限公司",
"violationTime": "2023-11-14",
"violationLocation": "萧山-风情大道小白线口",
"violationBehavior": "驾驶机动车违反禁令标志指示的",
"pointsDeducted": 1,
"fineAmount": 100,
"paymentStatus": "已缴费",
"processed": "已处理",
"collectionUnit": "杭州市公安局萧山区分局交通警察大队"
}
]

View 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