Sync OneOS workspace with new prototypes, annotations, and Gitea remote fix.
Add vehicle-h2-fee-ledger, customer-management, lease and self-operated ledgers, annotation sources, agent skills, and vite annotation runtime support. Update vehicle management, contract templates, and lease contract flows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -149,9 +149,9 @@
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
border: 1px solid #e1e2e7;
|
||||
border: 1px solid var(--dmb-tab-border, #e1e2e7);
|
||||
border-radius: var(--dmb-radius-card, 0);
|
||||
background: #f7f5ef;
|
||||
background: var(--dmb-tab-bg, #f7f5ef);
|
||||
}
|
||||
|
||||
.dmb-tabs button {
|
||||
@@ -159,7 +159,7 @@
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--dmb-radius-control, 0);
|
||||
background: transparent;
|
||||
color: #5f6370;
|
||||
color: var(--dmb-tab-color, #5f6370);
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-family: var(--dmb-font-body, var(--dmb-font-sans));
|
||||
@@ -169,9 +169,9 @@
|
||||
}
|
||||
|
||||
.dmb-tabs button[aria-selected="true"] {
|
||||
border-color: color-mix(in srgb, var(--dmb-accent) 38%, #d9dbe2);
|
||||
background: #ffffff;
|
||||
color: #111318;
|
||||
border-color: var(--dmb-tab-active-border, color-mix(in srgb, var(--dmb-accent) 38%, #d9dbe2));
|
||||
background: var(--dmb-tab-active-bg, #ffffff);
|
||||
color: var(--dmb-tab-active-color, #111318);
|
||||
}
|
||||
|
||||
.dmb-tab-panel {
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p>可以通过 npx @axhub/make 启动管理页面。</p>
|
||||
<p>可以通过 npx -y @axhub/make@latest 启动管理页面。</p>
|
||||
<div class="actions">
|
||||
<button id="start-button" type="button" hidden>启动管理服务</button>
|
||||
</div>
|
||||
@@ -106,6 +106,9 @@
|
||||
}
|
||||
|
||||
function redirectWhenReady(payload) {
|
||||
if (!shouldRedirectToAdmin()) {
|
||||
return false;
|
||||
}
|
||||
if (payload && payload.ready && payload.adminUrl) {
|
||||
location.replace(payload.adminUrl);
|
||||
return true;
|
||||
@@ -113,6 +116,11 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldRedirectToAdmin() {
|
||||
var pathname = window.location.pathname;
|
||||
return pathname === '/' || pathname === '/index.html';
|
||||
}
|
||||
|
||||
function showStartButton() {
|
||||
if (startButton) {
|
||||
startButton.hidden = false;
|
||||
@@ -152,7 +160,7 @@
|
||||
}
|
||||
startButton.disabled = true;
|
||||
startButton.textContent = '启动中...';
|
||||
setStatus('正在执行 npx @axhub/make...');
|
||||
setStatus('正在执行 npx -y @axhub/make@latest...');
|
||||
try {
|
||||
var response = await fetch('/__axhub/make-server/start', { method: 'POST', headers: { accept: 'application/json' } });
|
||||
var payload = await response.json();
|
||||
@@ -170,6 +178,10 @@
|
||||
if (startButton) {
|
||||
startButton.addEventListener('click', startServer);
|
||||
}
|
||||
if (!shouldRedirectToAdmin()) {
|
||||
setStatus('当前路径不是管理入口。');
|
||||
return;
|
||||
}
|
||||
checkStatus();
|
||||
}());
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "axhub-make",
|
||||
"elements": [],
|
||||
"appState": {
|
||||
"gridSize": 20,
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* @name 合同模板管理
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import * as antd from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import 'antd/dist/reset.css';
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import ContractTemplateApp from './ContractTemplate.jsx';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
type ContractPageId = 'list' | 'editor';
|
||||
|
||||
type ContractNavigationRef = {
|
||||
openCreate?: () => void;
|
||||
backToList?: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -55,9 +69,43 @@ const vmTheme = {
|
||||
};
|
||||
|
||||
export default function ContractTemplateManagementPage() {
|
||||
return React.createElement(
|
||||
ConfigProvider,
|
||||
{ theme: vmTheme },
|
||||
React.createElement(ContractTemplateApp),
|
||||
const [annotationPageId, setAnnotationPageId] = useState<ContractPageId>('list');
|
||||
const navigationRef = useRef<ContractNavigationRef>({});
|
||||
|
||||
const handleDirectoryRoute = useCallback((node: AnnotationDirectoryRouteNode) => {
|
||||
const payload = node.payload as { pageId?: string } | undefined;
|
||||
const pageId: ContractPageId = payload?.pageId === 'editor' ? 'editor' : 'list';
|
||||
if (pageId === 'editor') {
|
||||
navigationRef.current.openCreate?.();
|
||||
} else {
|
||||
navigationRef.current.backToList?.();
|
||||
}
|
||||
setAnnotationPageId(pageId);
|
||||
}, []);
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: annotationPageId,
|
||||
onDirectoryRoute: handleDirectoryRoute,
|
||||
}),
|
||||
[annotationPageId, handleDirectoryRoute],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={vmTheme}>
|
||||
<ContractTemplateApp
|
||||
navigationRef={navigationRef}
|
||||
onPageViewChange={setAnnotationPageId}
|
||||
/>
|
||||
<AnnotationViewer
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,114 @@
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-tbody > tr.ant-table-measure-row {
|
||||
display: table-row !important;
|
||||
height: 0 !important;
|
||||
line-height: 0 !important;
|
||||
font-size: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-tbody > tr.ant-table-measure-row > td,
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-tbody > tr.ant-table-measure-row > th {
|
||||
display: table-cell !important;
|
||||
height: 0 !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: 0 !important;
|
||||
overflow: hidden !important;
|
||||
visibility: hidden !important;
|
||||
line-height: 0 !important;
|
||||
font-size: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-content > table,
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-header table,
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-body table {
|
||||
table-layout: fixed !important;
|
||||
width: max(100%, var(--ct-list-table-width, 1232px)) !important;
|
||||
min-width: var(--ct-list-table-width, 1232px) !important;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-data-table--nested {
|
||||
--ct-list-table-width: 1232px;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-thead > tr > th.ant-table-row-expand-icon-cell,
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-tbody > tr > td.ant-table-row-expand-icon-cell,
|
||||
.vm-page.ct-page .ct-data-table--nested col.ant-table-expand-icon-col {
|
||||
width: 48px !important;
|
||||
min-width: 48px !important;
|
||||
max-width: 48px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-data-table--nested .ant-table-expanded-row > .ant-table-cell {
|
||||
padding: 10px 12px 14px 60px;
|
||||
background: var(--ln-canvas-soft, #f8fafc);
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-native-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
background: var(--ln-surface-card, #fff);
|
||||
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||
border-radius: var(--ln-radius-control, 8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-native-table th,
|
||||
.vm-page.ct-page .ct-chapter-native-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--ln-hairline, #e2e8f0);
|
||||
font-size: 13px;
|
||||
color: var(--ln-body, #334155);
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-native-table thead th {
|
||||
background: var(--ln-surface-strong, #f1f5f9);
|
||||
font-weight: 600;
|
||||
color: var(--ln-muted, #64748b);
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-native-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-native-table__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-native-table__num,
|
||||
.vm-page.ct-page .ct-chapter-native-table th.ct-chapter-native-table__num {
|
||||
width: 108px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-chapter-native-table__status,
|
||||
.vm-page.ct-page .ct-chapter-native-table th.ct-chapter-native-table__status {
|
||||
width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-clause-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .vm-filter-card[aria-label="筛选条件"] {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -60,6 +168,11 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-list-section-tabs {
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-list-section-tabs .ct-section-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
@@ -598,8 +711,9 @@
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-editor-page__sections {
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px 0;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-section-tabs {
|
||||
@@ -677,6 +791,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-page.ct-page.ct-editor-shell {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-editor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1122,15 +1240,9 @@
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
color: #000;
|
||||
font-family: Times, "Times New Roman", FangSong, STFangsong, "仿宋", SimSun, "宋体", serif;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 p,
|
||||
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 span,
|
||||
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 td,
|
||||
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 th {
|
||||
font-family: inherit;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 table.ct-word-table,
|
||||
@@ -1201,8 +1313,9 @@
|
||||
|
||||
.vm-page.ct-page .ct-editor-workspace .ct-preview-page__body {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
overflow: visible;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-editor-workspace .ct-preview-page__footer {
|
||||
@@ -1260,6 +1373,17 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ct-main-clause-tip {
|
||||
margin-bottom: 12px;
|
||||
border-left: 3px solid #7c3aed;
|
||||
}
|
||||
|
||||
.ct-preview-doc .ct-vehicle-clause,
|
||||
.ct-rich-editor .ct-vehicle-clause {
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-preview-doc .ct-word-doc--v266 .ct-risk-redline,
|
||||
.vm-page.ct-page .ct-preview-page__body.ct-word-doc .ct-risk-redline {
|
||||
color: inherit;
|
||||
@@ -1284,12 +1408,23 @@
|
||||
width: 100%;
|
||||
min-height: calc(297mm - 50.8mm);
|
||||
outline: none;
|
||||
word-break: break-word;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-word-measure-shell {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 25.4mm 31.8mm;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-word-a4-page__body:focus {
|
||||
@@ -1718,3 +1853,338 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 条件条款弹窗(挂载到 body,勿加 .vm-page 前缀) */
|
||||
.ct-vc-modal-wrap .ant-modal-content {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.ct-vc-modal-wrap .ant-modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ct-vc-modal-wrap .ant-modal-body {
|
||||
padding: 0 24px 8px;
|
||||
}
|
||||
|
||||
.ct-vc-modal-wrap .ant-modal-footer {
|
||||
padding: 12px 24px 20px;
|
||||
border-top: 1px solid var(--ln-hairline, #e2e8f0);
|
||||
}
|
||||
|
||||
.ct-vc-modal__head-title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--ln-ink, #0f172a);
|
||||
}
|
||||
|
||||
.ct-vc-modal__head-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ln-muted, #64748b);
|
||||
}
|
||||
|
||||
.ct-vc-modal__hero {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
|
||||
border: 1px solid #ddd6fe;
|
||||
}
|
||||
|
||||
.ct-vc-modal__hero-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ct-vc-modal__hero-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.ct-vc-modal__hero-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1em;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: #4c1d95;
|
||||
}
|
||||
|
||||
.ct-vc-modal__hero-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ct-vc-modal__form-card {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
background: var(--ln-surface-strong, #f8fafc);
|
||||
}
|
||||
|
||||
.ct-vc-modal__form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(108px, 0.9fr) minmax(0, 1.6fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.ct-vc-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ct-vc-modal__field--grow {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ct-vc-modal__field--action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ct-vc-modal__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink, #0f172a);
|
||||
}
|
||||
|
||||
.ct-vc-modal__label--sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ct-vc-modal__select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ct-vc-modal__add-btn {
|
||||
min-width: 72px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ct-vc-modal__form-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ln-muted, #64748b);
|
||||
}
|
||||
|
||||
.ct-vc-modal__selected {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ct-vc-modal__selected-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ct-vc-modal__selected-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink, #0f172a);
|
||||
}
|
||||
|
||||
.ct-vc-modal__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.ct-vc-modal__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-height: 56px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed #c4b5fd;
|
||||
background: #faf5ff;
|
||||
}
|
||||
|
||||
.ct-vc-modal__chips.is-empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ct-vc-modal__empty-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #7c3aed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ct-vc-modal__chip.ant-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ct-vc-modal__chip-brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ct-vc-modal__chip-sep {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ct-vc-modal__chip-model {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.ct-vc-modal__form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ct-vc-modal__add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Word 分页测量容器(挂到 body,与编辑器 A4 页同尺寸、同样式) */
|
||||
.ct-word-measure-host {
|
||||
position: fixed;
|
||||
left: -20000px;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.ct-word-measure-host .ct-word-a4-page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 25.4mm 31.8mm;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ct-word-measure-host .ct-word-a4-page__body {
|
||||
width: 100%;
|
||||
min-height: 0 !important;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
overflow: visible;
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
color: #000;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.ct-word-measure-host .ct-rich-editor--word {
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
color: unset;
|
||||
padding: 20px 24px;
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.ct-word-measure-host .ct-word-doc--v266 {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 !important;
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
color: #000;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ct-word-measure-shell {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 25.4mm 31.8mm;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ct-word-measure-shell .ct-word-a4-page__body {
|
||||
width: 100%;
|
||||
min-height: 0 !important;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
overflow: visible;
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
color: #000;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.ct-word-measure-inner {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ct-word-measure-shell .ct-word-doc--v266 {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 !important;
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
color: #000;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ct-vc-modal-wrap .ant-modal-content {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
2
src/prototypes/contract-template-management/trial-agreement-18t-document.js
vendored
Normal file
2
src/prototypes/contract-template-management/trial-agreement-18t-document.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
src/prototypes/contract-template-management/trial-agreement-document.js
vendored
Normal file
2
src/prototypes/contract-template-management/trial-agreement-document.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
||||
# 客户管理 · 新增页产品需求说明(PRD)
|
||||
|
||||
> 从列表页「新增客户」进入。保存为原型 Toast,不对接真实接口。
|
||||
|
||||
---
|
||||
|
||||
## 1. 页面框架
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 面包屑 | 业务管理 / 客户管理 / 新增 |
|
||||
| 布局 | 三组卡片表单 + 底部 sticky 操作栏 |
|
||||
| 操作 | 重置(清空表单)、提交(校验后 Toast 成功并返回列表) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 客户信息
|
||||
|
||||
| 字段 | 必填 | 控件 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |
|
||||
| 客户分级 | 否 | 下拉 | A / B / C |
|
||||
| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |
|
||||
| 客户简称 | 否 | 文本 | 选填 |
|
||||
| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |
|
||||
| 通讯地址 | 否 | 文本 | |
|
||||
| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |
|
||||
| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |
|
||||
| 备注 | 否 | 多行文本 | |
|
||||
|
||||
---
|
||||
|
||||
## 3. 联系人信息
|
||||
|
||||
| 字段 | 必填 | 控件 |
|
||||
|---|---|---|
|
||||
| 姓名 | 否 | 文本 |
|
||||
| 手机号 | 否 | 文本 |
|
||||
| 职位 | 否 | 文本 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 付款及开票信息
|
||||
|
||||
| 字段 | 必填 | 控件 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |
|
||||
| 纳税人识别号 | 是 | 文本 | |
|
||||
| 注册地址 | 否 | 文本 | |
|
||||
| 注册电话 | 否 | 文本 | |
|
||||
| 开户银行 | 是 | 文本 | |
|
||||
| 账号 | 否 | 文本 | |
|
||||
|
||||
---
|
||||
|
||||
## 5. 校验与提交
|
||||
|
||||
- 提交前校验所有必填项
|
||||
- 通过后 Toast「客户已保存(原型演示)」并返回列表
|
||||
- 重置清空全部字段,客户类型默认「企业」
|
||||
@@ -0,0 +1,96 @@
|
||||
# 客户管理 · 列表页产品需求说明(PRD)
|
||||
|
||||
> 本页为客户主数据台账列表。标注「原型演示」的跳转、导入、导出仅 Toast 提示。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 客户管理 — 列表页 |
|
||||
| 目标用户 | 销售、业务负责人、运营、风控 |
|
||||
| 核心任务 | 按多维度筛选客户台账,查看标签与状态,新增客户或进入详情/编辑 |
|
||||
| 页面结构 | 面包屑 + 筛选区 + 工具栏 + 表格 + 分页 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 客户编号规则(集团多子公司)
|
||||
|
||||
- 总长度 7,仅含大写字母 A–Z 与数字 0–9
|
||||
- 结构:第 1 位 = 签约/归属子公司代码;第 2–7 位 = 6 位数字流水号(000001–999999)
|
||||
- 流水号在「同一子公司代码」下递增
|
||||
- 校验正则:`/^[A-Z][0-9]{6}$/`
|
||||
- 示例:A000001(华东)、B000001(华南)、C000001(华北)等
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据来源
|
||||
|
||||
| 数据 | 来源 | 用途 |
|
||||
|---|---|---|
|
||||
| 客户台账 | `data/customers.json`(20 条样例) | 列表、筛选选项 |
|
||||
| 客户标签 | `CUSTOMER_LABEL_OPTIONS` 常量 | 筛选与列表展示 |
|
||||
| 部门-负责人 | 由台账聚合 + `DEPT_MANAGER_MAP` | 筛选联动 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 筛选区
|
||||
|
||||
默认展示前 4 项(一行),点击「更多筛选」显示全部。
|
||||
|
||||
| 字段 | 控件 | 匹配规则 |
|
||||
|---|---|---|
|
||||
| 客户编号 | 可搜索单选 | 精确匹配 `code` |
|
||||
| 客户名称 | 可搜索单选 | 精确匹配 `name` |
|
||||
| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |
|
||||
| 区域 | 可搜索单选 | 精确匹配 `region` |
|
||||
| 客户状态 | 多选 | `status` 在选中集合内 |
|
||||
| 业务部门 | 多选 | `department` 在选中集合内 |
|
||||
| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |
|
||||
| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |
|
||||
|
||||
### 4.1 操作逻辑
|
||||
|
||||
| 操作 | 行为 |
|
||||
|---|---|
|
||||
| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |
|
||||
| **重置** | 清空待应用与已应用条件,页码重置为 1 |
|
||||
| 修改筛选项未点查询 | 不影响当前列表 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 列表工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 新增客户 | 进入新增客户页(分组表单) |
|
||||
| 批量导入 | Toast:批量导入(原型演示) |
|
||||
| 批量导出 | Toast:导出当前列表条数 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 列表字段说明
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 客户编号 | 左固定列 |
|
||||
| 客户名称 | 超长省略 |
|
||||
| 标签 | 仅展示第一个标签;多于 1 个显示「+N」,悬浮展示全部 |
|
||||
| 客户区域 | 大区名称 |
|
||||
| 客户状态 | 绿点「正常」/ 红点「黑名单」 |
|
||||
| 业务部门 / 业务负责人 | 文本 |
|
||||
| 客户类型 / 所属行业 | 文本 |
|
||||
| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |
|
||||
| 详细地址 / 创建时间 | 文本;创建时间不换行 |
|
||||
| 操作 | 详情、编辑、删除(原型 Toast) |
|
||||
|
||||
支持行多选(checkbox);横向滚动;操作列右固定。
|
||||
|
||||
---
|
||||
|
||||
## 7. 客户标签枚举
|
||||
|
||||
逾期预警、严重拖欠、频繁改账期、资质存疑、涉诉/仲裁、制裁/高风险地区、频繁争议、车辆/资产高风险、信息不实、开票异常、多头签约。
|
||||
|
||||
标签样式:白底 + 橙色描边(`#f77234`)。
|
||||
283
src/prototypes/customer-management/annotation-source.json
Normal file
283
src/prototypes/customer-management/annotation-source.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "customer-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1782697342692,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "cm-list-filter",
|
||||
"index": 1,
|
||||
"title": "筛选条件",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"cm-list-filter\"]"
|
||||
],
|
||||
"fingerprint": "cm-list-filter",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "列表筛选区字段与查询逻辑。",
|
||||
"annotationText": "默认展示 4 项(一行),点击「更多筛选」显示全部。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "cm-list-toolbar",
|
||||
"index": 2,
|
||||
"title": "列表工具栏",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"cm-list-toolbar\"]"
|
||||
],
|
||||
"fingerprint": "cm-list-toolbar",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "新增、导入、导出入口。",
|
||||
"annotationText": "「新增客户」进入分组表单页。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "cm-list-table",
|
||||
"index": 3,
|
||||
"title": "客户台账列表",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"cm-list-table\"]"
|
||||
],
|
||||
"fingerprint": "cm-list-table",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "客户台账表格列与行操作。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "cm-create-base",
|
||||
"index": 4,
|
||||
"title": "客户信息",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"cm-create-base\"]"
|
||||
],
|
||||
"fingerprint": "cm-create-base",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "客户类型、分级、全称、城市、部门负责人等。",
|
||||
"annotationText": "客户全称变更时同步发票抬头。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "cm-create-contact",
|
||||
"index": 5,
|
||||
"title": "联系人信息",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"cm-create-contact\"]"
|
||||
],
|
||||
"fingerprint": "cm-create-contact",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "联系人姓名、手机、职位。",
|
||||
"annotationText": "均为选填。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#64748b",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "cm-create-invoice",
|
||||
"index": 6,
|
||||
"title": "付款及开票信息",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"cm-create-invoice\"]"
|
||||
],
|
||||
"fingerprint": "cm-create-invoice",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "发票抬头、税号、银行账号等。",
|
||||
"annotationText": "发票抬头只读,与客户全称一致。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#d97706",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "cm-create-footer",
|
||||
"index": 7,
|
||||
"title": "底部操作栏",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"cm-create-footer\"]"
|
||||
],
|
||||
"fingerprint": "cm-create-footer",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "重置与提交。",
|
||||
"annotationText": "提交校验必填项后 Toast 并返回列表。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
}
|
||||
]
|
||||
},
|
||||
"markdownMap": {
|
||||
"cm-list-filter": "## 4. 筛选区\n\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\n\n| 字段 | 控件 | 匹配规则 |\n|---|---|---|\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |\n| 区域 | 可搜索单选 | 精确匹配 `region` |\n| 客户状态 | 多选 | `status` 在选中集合内 |\n| 业务部门 | 多选 | `department` 在选中集合内 |\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\n\n### 4.1 操作逻辑\n\n| 操作 | 行为 |\n|---|---|\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |\n| **重置** | 清空待应用与已应用条件,页码重置为 1 |\n| 修改筛选项未点查询 | 不影响当前列表 |\n\n---",
|
||||
"cm-list-toolbar": "## 5. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增客户 | 进入新增客户页(分组表单) |\n| 批量导入 | Toast:批量导入(原型演示) |\n| 批量导出 | Toast:导出当前列表条数 |\n\n---",
|
||||
"cm-list-table": "## 6. 列表字段说明\n\n| 列 | 说明 |\n|---|---|\n| 客户编号 | 左固定列 |\n| 客户名称 | 超长省略 |\n| 标签 | 仅展示第一个标签;多于 1 个显示「+N」,悬浮展示全部 |\n| 客户区域 | 大区名称 |\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\n| 业务部门 / 业务负责人 | 文本 |\n| 客户类型 / 所属行业 | 文本 |\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\n| 操作 | 详情、编辑、删除(原型 Toast) |\n\n支持行多选(checkbox);横向滚动;操作列右固定。\n\n---",
|
||||
"cm-create-base": "## 2. 客户信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\n| 客户分级 | 否 | 下拉 | A / B / C |\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\n| 客户简称 | 否 | 文本 | 选填 |\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\n| 通讯地址 | 否 | 文本 | |\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\n| 备注 | 否 | 多行文本 | |\n\n---",
|
||||
"cm-create-contact": "## 3. 联系人信息\n\n| 字段 | 必填 | 控件 |\n|---|---|---|\n| 姓名 | 否 | 文本 |\n| 手机号 | 否 | 文本 |\n| 职位 | 否 | 文本 |\n\n---",
|
||||
"cm-create-invoice": "## 4. 付款及开票信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\n| 纳税人识别号 | 是 | 文本 | |\n| 注册地址 | 否 | 文本 | |\n| 注册电话 | 否 | 文本 | |\n| 开户银行 | 是 | 文本 | |\n| 账号 | 否 | 文本 | |\n\n---",
|
||||
"cm-create-footer": "## 5. 校验与提交\n\n- 提交前校验所有必填项\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\n- 重置清空全部字段,客户类型默认「企业」"
|
||||
},
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "cm-doc-root",
|
||||
"title": "客户管理说明",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-overview",
|
||||
"title": "模块总览",
|
||||
"markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 客户管理 — 列表页 |\n| 目标用户 | 销售、业务负责人、运营、风控 |\n| 核心任务 | 按多维度筛选客户台账,查看标签与状态,新增客户或进入详情/编辑 |\n| 页面结构 | 面包屑 + 筛选区 + 工具栏 + 表格 + 分页 |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "cm-link-self",
|
||||
"title": "客户管理原型",
|
||||
"href": "/prototypes/customer-management",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "cm-doc-prd",
|
||||
"title": "PRD 全文",
|
||||
"defaultExpanded": false,
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-prd-list",
|
||||
"title": "列表页 PRD",
|
||||
"markdown": "# 客户管理 · 列表页产品需求说明(PRD)\n\n> 本页为客户主数据台账列表。标注「原型演示」的跳转、导入、导出仅 Toast 提示。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 客户管理 — 列表页 |\n| 目标用户 | 销售、业务负责人、运营、风控 |\n| 核心任务 | 按多维度筛选客户台账,查看标签与状态,新增客户或进入详情/编辑 |\n| 页面结构 | 面包屑 + 筛选区 + 工具栏 + 表格 + 分页 |\n\n---\n\n## 2. 客户编号规则(集团多子公司)\n\n- 总长度 7,仅含大写字母 A–Z 与数字 0–9\n- 结构:第 1 位 = 签约/归属子公司代码;第 2–7 位 = 6 位数字流水号(000001–999999)\n- 流水号在「同一子公司代码」下递增\n- 校验正则:`/^[A-Z][0-9]{6}$/`\n- 示例:A000001(华东)、B000001(华南)、C000001(华北)等\n\n---\n\n## 3. 数据来源\n\n| 数据 | 来源 | 用途 |\n|---|---|---|\n| 客户台账 | `data/customers.json`(20 条样例) | 列表、筛选选项 |\n| 客户标签 | `CUSTOMER_LABEL_OPTIONS` 常量 | 筛选与列表展示 |\n| 部门-负责人 | 由台账聚合 + `DEPT_MANAGER_MAP` | 筛选联动 |\n\n---\n\n## 4. 筛选区\n\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\n\n| 字段 | 控件 | 匹配规则 |\n|---|---|---|\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |\n| 区域 | 可搜索单选 | 精确匹配 `region` |\n| 客户状态 | 多选 | `status` 在选中集合内 |\n| 业务部门 | 多选 | `department` 在选中集合内 |\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\n\n### 4.1 操作逻辑\n\n| 操作 | 行为 |\n|---|---|\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |\n| **重置** | 清空待应用与已应用条件,页码重置为 1 |\n| 修改筛选项未点查询 | 不影响当前列表 |\n\n---\n\n## 5. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增客户 | 进入新增客户页(分组表单) |\n| 批量导入 | Toast:批量导入(原型演示) |\n| 批量导出 | Toast:导出当前列表条数 |\n\n---\n\n## 6. 列表字段说明\n\n| 列 | 说明 |\n|---|---|\n| 客户编号 | 左固定列 |\n| 客户名称 | 超长省略 |\n| 标签 | 仅展示第一个标签;多于 1 个显示「+N」,悬浮展示全部 |\n| 客户区域 | 大区名称 |\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\n| 业务部门 / 业务负责人 | 文本 |\n| 客户类型 / 所属行业 | 文本 |\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\n| 操作 | 详情、编辑、删除(原型 Toast) |\n\n支持行多选(checkbox);横向滚动;操作列右固定。\n\n---\n\n## 7. 客户标签枚举\n\n逾期预警、严重拖欠、频繁改账期、资质存疑、涉诉/仲裁、制裁/高风险地区、频繁争议、车辆/资产高风险、信息不实、开票异常、多头签约。\n\n标签样式:白底 + 橙色描边(`#f77234`)。\n"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-prd-create",
|
||||
"title": "新增页 PRD",
|
||||
"markdown": "# 客户管理 · 新增页产品需求说明(PRD)\n\n> 从列表页「新增客户」进入。保存为原型 Toast,不对接真实接口。\n\n---\n\n## 1. 页面框架\n\n| 项 | 说明 |\n|---|---|\n| 面包屑 | 业务管理 / 客户管理 / 新增 |\n| 布局 | 三组卡片表单 + 底部 sticky 操作栏 |\n| 操作 | 重置(清空表单)、提交(校验后 Toast 成功并返回列表) |\n\n---\n\n## 2. 客户信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\n| 客户分级 | 否 | 下拉 | A / B / C |\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\n| 客户简称 | 否 | 文本 | 选填 |\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\n| 通讯地址 | 否 | 文本 | |\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\n| 备注 | 否 | 多行文本 | |\n\n---\n\n## 3. 联系人信息\n\n| 字段 | 必填 | 控件 |\n|---|---|---|\n| 姓名 | 否 | 文本 |\n| 手机号 | 否 | 文本 |\n| 职位 | 否 | 文本 |\n\n---\n\n## 4. 付款及开票信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\n| 纳税人识别号 | 是 | 文本 | |\n| 注册地址 | 否 | 文本 | |\n| 注册电话 | 否 | 文本 | |\n| 开户银行 | 是 | 文本 | |\n| 账号 | 否 | 文本 | |\n\n---\n\n## 5. 校验与提交\n\n- 提交前校验所有必填项\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\n- 重置清空全部字段,客户类型默认「企业」\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "cm-doc-list",
|
||||
"title": "列表页模块",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "route",
|
||||
"id": "cm-route-list",
|
||||
"title": "打开列表页",
|
||||
"route": "customer-management:list",
|
||||
"payload": {
|
||||
"pageId": "list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-node-cm-list-filter",
|
||||
"title": "筛选条件",
|
||||
"markdown": "## 4. 筛选区\n\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\n\n| 字段 | 控件 | 匹配规则 |\n|---|---|---|\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |\n| 区域 | 可搜索单选 | 精确匹配 `region` |\n| 客户状态 | 多选 | `status` 在选中集合内 |\n| 业务部门 | 多选 | `department` 在选中集合内 |\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\n\n### 4.1 操作逻辑\n\n| 操作 | 行为 |\n|---|---|\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |\n| **重置** | 清空待应用与已应用条件,页码重置为 1 |\n| 修改筛选项未点查询 | 不影响当前列表 |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-node-cm-list-toolbar",
|
||||
"title": "列表工具栏",
|
||||
"markdown": "## 5. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增客户 | 进入新增客户页(分组表单) |\n| 批量导入 | Toast:批量导入(原型演示) |\n| 批量导出 | Toast:导出当前列表条数 |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-node-cm-list-table",
|
||||
"title": "客户台账列表",
|
||||
"markdown": "## 6. 列表字段说明\n\n| 列 | 说明 |\n|---|---|\n| 客户编号 | 左固定列 |\n| 客户名称 | 超长省略 |\n| 标签 | 仅展示第一个标签;多于 1 个显示「+N」,悬浮展示全部 |\n| 客户区域 | 大区名称 |\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\n| 业务部门 / 业务负责人 | 文本 |\n| 客户类型 / 所属行业 | 文本 |\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\n| 操作 | 详情、编辑、删除(原型 Toast) |\n\n支持行多选(checkbox);横向滚动;操作列右固定。\n\n---"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "cm-doc-create",
|
||||
"title": "新增页模块",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "route",
|
||||
"id": "cm-route-create",
|
||||
"title": "打开新增页",
|
||||
"route": "customer-management:create",
|
||||
"payload": {
|
||||
"pageId": "create"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-node-cm-create-base",
|
||||
"title": "客户信息",
|
||||
"markdown": "## 2. 客户信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\n| 客户分级 | 否 | 下拉 | A / B / C |\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\n| 客户简称 | 否 | 文本 | 选填 |\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\n| 通讯地址 | 否 | 文本 | |\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\n| 备注 | 否 | 多行文本 | |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-node-cm-create-contact",
|
||||
"title": "联系人信息",
|
||||
"markdown": "## 3. 联系人信息\n\n| 字段 | 必填 | 控件 |\n|---|---|---|\n| 姓名 | 否 | 文本 |\n| 手机号 | 否 | 文本 |\n| 职位 | 否 | 文本 |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-node-cm-create-invoice",
|
||||
"title": "付款及开票信息",
|
||||
"markdown": "## 4. 付款及开票信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\n| 纳税人识别号 | 是 | 文本 | |\n| 注册地址 | 否 | 文本 | |\n| 注册电话 | 否 | 文本 | |\n| 开户银行 | 是 | 文本 | |\n| 账号 | 否 | 文本 | |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "cm-doc-node-cm-create-footer",
|
||||
"title": "底部操作栏",
|
||||
"markdown": "## 5. 校验与提交\n\n- 提交前校验所有必填项\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\n- 重置清空全部字段,客户类型默认「企业」"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
113
src/prototypes/customer-management/components/CustomerTable.tsx
Normal file
113
src/prototypes/customer-management/components/CustomerTable.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { CustomerRecord } from '../types';
|
||||
import { LabelTags } from './LabelTags';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface CustomerTableProps {
|
||||
records: CustomerRecord[];
|
||||
selectedIds: Set<string>;
|
||||
onToggleRow: (id: string, checked: boolean) => void;
|
||||
onToggleAll: (checked: boolean) => void;
|
||||
onView: (record: CustomerRecord) => void;
|
||||
onEdit: (record: CustomerRecord) => void;
|
||||
onDelete: (record: CustomerRecord) => void;
|
||||
}
|
||||
|
||||
export function CustomerTable({
|
||||
records,
|
||||
selectedIds,
|
||||
onToggleRow,
|
||||
onToggleAll,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CustomerTableProps) {
|
||||
const allSelected = records.length > 0 && records.every((record) => selectedIds.has(record.id));
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="vm-empty" data-annotation-id="cm-list-table">
|
||||
<p>暂无符合条件的客户</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vm-table-wrap cm-table-wrap" data-annotation-id="cm-list-table">
|
||||
<table className="vm-table cm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cm-col-check sticky-col-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(event) => onToggleAll(event.target.checked)}
|
||||
aria-label="全选当前页"
|
||||
/>
|
||||
</th>
|
||||
<th className="sticky-col-left cm-col-code">客户编号</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 className="cm-col-time">创建时间</th>
|
||||
<th className="sticky-col-right cm-col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => (
|
||||
<tr
|
||||
key={record.id}
|
||||
onMouseEnter={() => setHoverId(record.id)}
|
||||
onMouseLeave={() => setHoverId(null)}
|
||||
className={hoverId === record.id ? 'is-hovered' : undefined}
|
||||
>
|
||||
<td className="cm-col-check sticky-col-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(record.id)}
|
||||
onChange={(event) => onToggleRow(record.id, event.target.checked)}
|
||||
aria-label={`选择 ${record.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="sticky-col-left cm-col-code tabular-nums">{record.code}</td>
|
||||
<td className="cm-col-name" title={record.name}>{record.name}</td>
|
||||
<td className="cm-col-labels"><LabelTags tags={record.labels} /></td>
|
||||
<td>{record.region}</td>
|
||||
<td><StatusBadge status={record.status} /></td>
|
||||
<td>{record.department}</td>
|
||||
<td>{record.manager}</td>
|
||||
<td>{record.customerType}</td>
|
||||
<td>{record.industry}</td>
|
||||
<td className="cm-col-credit tabular-nums" title={record.creditCode}>{record.creditCode}</td>
|
||||
<td>{record.legalPerson}</td>
|
||||
<td>{record.regCapital}</td>
|
||||
<td>{record.operateStatus}</td>
|
||||
<td className="cm-col-address" title={record.address}>{record.address}</td>
|
||||
<td className="cm-col-time tabular-nums">{record.createTime}</td>
|
||||
<td className="sticky-col-right cm-col-actions">
|
||||
<div className="cm-row-actions">
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onView(record)}>详情</button>
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(record)}>编辑</button>
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link cm-link-danger" onClick={() => onDelete(record)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
src/prototypes/customer-management/components/FilterPanel.tsx
Normal file
211
src/prototypes/customer-management/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import type { CustomerFilters, CustomerRecord } from '../types';
|
||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||
import {
|
||||
CUSTOMER_LABEL_OPTIONS,
|
||||
CUSTOMER_STATUS_OPTIONS,
|
||||
REGION_OPTIONS,
|
||||
} from '../utils/constants';
|
||||
import {
|
||||
buildCodeOptions,
|
||||
buildDeptManagerMap,
|
||||
buildOptions,
|
||||
getManagerOptions,
|
||||
} from '../utils/filters';
|
||||
import { MultiSelectField } from './MultiSelectField';
|
||||
|
||||
const DEFAULT_VISIBLE_COUNT = 4;
|
||||
|
||||
interface FilterPanelProps {
|
||||
records: CustomerRecord[];
|
||||
filters: CustomerFilters;
|
||||
onChange: (patch: Partial<CustomerFilters>) => void;
|
||||
onReset: () => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export function FilterPanel({
|
||||
records,
|
||||
filters,
|
||||
onChange,
|
||||
onReset,
|
||||
onSearch,
|
||||
}: FilterPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const codeOptions = useMemo(() => buildCodeOptions(records), [records]);
|
||||
const nameOptions = useMemo(() => buildOptions(records, 'name'), [records]);
|
||||
const departmentOptions = useMemo(() => buildOptions(records, 'department'), [records]);
|
||||
const deptManagerMap = useMemo(() => buildDeptManagerMap(records), [records]);
|
||||
const managerOptions = useMemo(
|
||||
() => getManagerOptions(deptManagerMap, filters.departments),
|
||||
[deptManagerMap, filters.departments],
|
||||
);
|
||||
|
||||
const codePickerOptions = codeOptions.map((item) => item.label);
|
||||
const codeLabelToValue = useMemo(
|
||||
() => Object.fromEntries(codeOptions.map((item) => [item.label, item.value])),
|
||||
[codeOptions],
|
||||
);
|
||||
const codeValueToLabel = useMemo(
|
||||
() => Object.fromEntries(codeOptions.map((item) => [item.value, item.label])),
|
||||
[codeOptions],
|
||||
);
|
||||
|
||||
const handleDeptChange = (departments: string[]) => {
|
||||
const allowed = getManagerOptions(deptManagerMap, departments);
|
||||
onChange({
|
||||
departments,
|
||||
managers: filters.managers.filter((manager) => allowed.includes(manager)),
|
||||
});
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{
|
||||
label: '客户编号',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.code ? (codeValueToLabel[filters.code] ?? '') : ''}
|
||||
options={codePickerOptions}
|
||||
placeholder="请选择或搜索客户编号"
|
||||
ariaLabel="客户编号"
|
||||
onChange={(label) => onChange({ code: codeLabelToValue[label] ?? '' })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '客户名称',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.name}
|
||||
options={nameOptions}
|
||||
placeholder="请选择或搜索客户名称"
|
||||
ariaLabel="客户名称"
|
||||
onChange={(name) => onChange({ name })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '标签',
|
||||
control: (
|
||||
<MultiSelectField
|
||||
values={filters.labels}
|
||||
options={[...CUSTOMER_LABEL_OPTIONS]}
|
||||
placeholder="请选择标签"
|
||||
ariaLabel="标签"
|
||||
onChange={(labels) => onChange({ labels })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '区域',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.region}
|
||||
options={[...REGION_OPTIONS]}
|
||||
placeholder="请选择客户区域"
|
||||
ariaLabel="区域"
|
||||
onChange={(region) => onChange({ region })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '客户状态',
|
||||
control: (
|
||||
<MultiSelectField
|
||||
values={filters.statuses}
|
||||
options={[...CUSTOMER_STATUS_OPTIONS]}
|
||||
placeholder="请选择客户状态"
|
||||
ariaLabel="客户状态"
|
||||
onChange={(statuses) => onChange({ statuses: statuses as CustomerFilters['statuses'] })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '业务部门',
|
||||
control: (
|
||||
<MultiSelectField
|
||||
values={filters.departments}
|
||||
options={departmentOptions}
|
||||
placeholder="请选择业务部门"
|
||||
ariaLabel="业务部门"
|
||||
onChange={handleDeptChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '业务负责人',
|
||||
control: (
|
||||
<MultiSelectField
|
||||
values={filters.managers}
|
||||
options={managerOptions}
|
||||
placeholder="请选择业务负责人"
|
||||
ariaLabel="业务负责人"
|
||||
onChange={(managers) => onChange({ managers })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
control: (
|
||||
<div className="cm-date-range">
|
||||
<input
|
||||
type="date"
|
||||
className="vm-input cm-date-input"
|
||||
value={filters.createFrom}
|
||||
onChange={(event) => onChange({ createFrom: event.target.value })}
|
||||
aria-label="创建开始日期"
|
||||
/>
|
||||
<span className="cm-date-sep" aria-hidden>—</span>
|
||||
<input
|
||||
type="date"
|
||||
className="vm-input cm-date-input"
|
||||
value={filters.createTo}
|
||||
onChange={(event) => onChange({ createTo: event.target.value })}
|
||||
aria-label="创建结束日期"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleFields = expanded ? fields : fields.slice(0, DEFAULT_VISIBLE_COUNT);
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card cm-filter-card" data-annotation-id="cm-list-filter">
|
||||
<header className="vm-filter-header">
|
||||
<h1 className="cm-page-title">客户管理</h1>
|
||||
</header>
|
||||
<div className="vm-filter-grid">
|
||||
{visibleFields.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="vm-filter-actions cm-filter-actions">
|
||||
{fields.length > DEFAULT_VISIBLE_COUNT && (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
63
src/prototypes/customer-management/components/LabelTags.tsx
Normal file
63
src/prototypes/customer-management/components/LabelTags.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface LabelTagsProps {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 7px',
|
||||
lineHeight: '20px',
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
border: '1px solid #f77234',
|
||||
color: '#1d2129',
|
||||
fontSize: 12,
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const countStyle: React.CSSProperties = {
|
||||
...labelStyle,
|
||||
borderColor: '#c9cdd4',
|
||||
color: '#4e5969',
|
||||
cursor: 'default',
|
||||
};
|
||||
|
||||
export function LabelTags({ tags }: LabelTagsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!tags.length) {
|
||||
return <span className="cm-muted">—</span>;
|
||||
}
|
||||
|
||||
if (tags.length === 1) {
|
||||
return <span style={labelStyle}>{tags[0]}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="cm-label-tags">
|
||||
<span style={labelStyle}>{tags[0]}</span>
|
||||
<span
|
||||
className="cm-label-count"
|
||||
style={countStyle}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`还有 ${tags.length - 1} 个标签`}
|
||||
>
|
||||
+{tags.length - 1}
|
||||
{open && (
|
||||
<span className="cm-label-tooltip" role="tooltip">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} style={labelStyle}>{tag}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { useDismissOnFocusOutside } from '../../vehicle-management/components/useDismissOnFocusOutside';
|
||||
|
||||
interface MultiSelectFieldProps {
|
||||
values: string[];
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
ariaLabel: string;
|
||||
onChange: (values: string[]) => void;
|
||||
}
|
||||
|
||||
export function MultiSelectField({
|
||||
values,
|
||||
options,
|
||||
placeholder = '请选择',
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: MultiSelectFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismiss = useCallback(() => setOpen(false), []);
|
||||
useDismissOnFocusOutside(open, fieldRef, dismiss);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (values.length === 0) return '';
|
||||
if (values.length <= 2) return values.join('、');
|
||||
return `已选 ${values.length} 项`;
|
||||
}, [values]);
|
||||
|
||||
const toggle = (option: string) => {
|
||||
if (values.includes(option)) {
|
||||
onChange(values.filter((item) => item !== option));
|
||||
} else {
|
||||
onChange([...values, option]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cm-multi-field" ref={fieldRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-input cm-multi-trigger"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{summary || <span className="placeholder">{placeholder}</span>}
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
</button>
|
||||
{values.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="cm-multi-clear"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange([]);
|
||||
}}
|
||||
aria-label={`清空${ariaLabel}`}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div className="cm-multi-popover" role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((option) => (
|
||||
<label key={option} className="cm-multi-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.includes(option)}
|
||||
onChange={() => toggle(option)}
|
||||
/>
|
||||
<span>{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import type { CustomerStatus } from '../types';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: CustomerStatus;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: StatusBadgeProps) {
|
||||
const dotColor = status === '正常' ? '#00b42a' : status === '黑名单' ? '#f53f3f' : '#86909c';
|
||||
return (
|
||||
<span className="cm-status-badge">
|
||||
<span className="cm-status-dot" style={{ backgroundColor: dotColor }} aria-hidden />
|
||||
<span>{status}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
22
src/prototypes/customer-management/data/customers.json
Normal file
22
src/prototypes/customer-management/data/customers.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "id": "1", "code": "C000001", "name": "北京华宇科技有限公司", "labels": ["资质存疑"], "region": "华北地区", "status": "正常", "department": "销售一部", "manager": "张伟", "customerType": "企业客户", "industry": "互联网", "creditCode": "91110108MA01D2XY89", "legalPerson": "李明", "regCapital": "1000万元", "operateStatus": "在业", "address": "北京市海淀区中关村大街18号科创大厦8层", "createTime": "2023-10-25 14:30:00" },
|
||||
{ "id": "2", "code": "A000001", "name": "上海诚新贸易有限公司", "labels": ["频繁改账期"], "region": "华东地区", "status": "正常", "department": "销售一部", "manager": "王芳", "customerType": "企业客户", "industry": "零售", "creditCode": "91310115MA1K3NBC12", "legalPerson": "陈强", "regCapital": "500万元", "operateStatus": "在业", "address": "上海市浦东新区张江路1288号科技园3号楼", "createTime": "2023-11-02 09:15:00" },
|
||||
{ "id": "3", "code": "B000001", "name": "深圳智联信息技术有限公司", "labels": [], "region": "华南地区", "status": "正常", "department": "市场开拓部", "manager": "刘洋", "customerType": "企业客户", "industry": "互联网", "creditCode": "91440300MA5F8QWE45", "legalPerson": "赵敏", "regCapital": "2000万元", "operateStatus": "在业", "address": "广东省深圳市南山区科技园南区科苑路15号", "createTime": "2023-11-18 16:20:00" },
|
||||
{ "id": "4", "code": "A000002", "name": "杭州云帆智能制造股份有限公司", "labels": ["逾期预警"], "region": "华东地区", "status": "正常", "department": "大客户部", "manager": "李强", "customerType": "企业客户", "industry": "制造业", "creditCode": "91330106MA2B7HJK01", "legalPerson": "周杰", "regCapital": "5000万元", "operateStatus": "存续", "address": "浙江省杭州市滨江区物联网街451号", "createTime": "2023-12-05 11:00:00" },
|
||||
{ "id": "5", "code": "D000001", "name": "成都天府医疗健康集团", "labels": ["信息不实"], "region": "西南地区", "status": "正常", "department": "大客户部", "manager": "陈静", "customerType": "企业客户", "industry": "医疗保健", "creditCode": "91510100MA6C9RST78", "legalPerson": "吴磊", "regCapital": "3000万元", "operateStatus": "在业", "address": "四川省成都市高新区天府大道北段966号", "createTime": "2024-01-08 10:45:00" },
|
||||
{ "id": "6", "code": "B000002", "name": "广州粤海金融服务有限公司", "labels": ["严重拖欠", "多头签约"], "region": "华南地区", "status": "黑名单", "department": "销售二部", "manager": "孙丽", "customerType": "企业客户", "industry": "金融", "creditCode": "91440101MA5D1UVW23", "legalPerson": "郑华", "regCapital": "8000万元", "operateStatus": "在业", "address": "广东省广州市天河区珠江东路30号金融中心16层", "createTime": "2024-01-22 13:10:00" },
|
||||
{ "id": "7", "code": "E000001", "name": "武汉长江物流供应链有限公司", "labels": [], "region": "华中地区", "status": "正常", "department": "销售一部", "manager": "张伟", "customerType": "企业客户", "industry": "物流", "creditCode": "91420100MA4K2PQR56", "legalPerson": "冯涛", "regCapital": "1500万元", "operateStatus": "在业", "address": "湖北省武汉市东西湖区物流大道88号", "createTime": "2024-02-01 08:50:00" },
|
||||
{ "id": "8", "code": "A000003", "name": "南京金陵软件科技有限公司", "labels": [], "region": "华东地区", "status": "正常", "department": "市场开拓部", "manager": "王芳", "customerType": "企业客户", "industry": "互联网", "creditCode": "91320105MA1P5XYZ99", "legalPerson": "韩雪", "regCapital": "800万元", "operateStatus": "在业", "address": "江苏省南京市雨花台区软件大道101号", "createTime": "2024-02-14 15:25:00" },
|
||||
{ "id": "9", "code": "C000002", "name": "天津市滨海新区港口设备制造厂", "labels": ["涉诉/仲裁"], "region": "华北地区", "status": "正常", "department": "销售二部", "manager": "刘洋", "customerType": "企业客户", "industry": "制造业", "creditCode": "91120116MA07NMLM34", "legalPerson": "曹阳", "regCapital": "2200万元", "operateStatus": "存续", "address": "天津市滨海新区临港经济区渤海路36号", "createTime": "2024-03-03 09:40:00" },
|
||||
{ "id": "10", "code": "B000003", "name": "厦门海峡跨境电商有限公司", "labels": [], "region": "华南地区", "status": "正常", "department": "市场开拓部", "manager": "赵敏", "customerType": "企业客户", "industry": "零售", "creditCode": "91350200MA8F3CDE77", "legalPerson": "许晴", "regCapital": "600万元", "operateStatus": "在业", "address": "福建省厦门市湖里区象屿路97号国际航运中心", "createTime": "2024-03-20 14:05:00" },
|
||||
{ "id": "11", "code": "F000001", "name": "西安市教育局机关服务中心", "labels": [], "region": "西北地区", "status": "正常", "department": "大客户部", "manager": "李强", "customerType": "政府机构", "industry": "公共服务", "creditCode": "91610103MB1T2ABC88", "legalPerson": "何伟", "regCapital": "—", "operateStatus": "在业", "address": "陕西省西安市碑林区盐店街28号", "createTime": "2024-04-02 11:30:00" },
|
||||
{ "id": "12", "code": "A000004", "name": "青岛海洋生物医药研究院有限公司", "labels": ["开票异常"], "region": "华东地区", "status": "正常", "department": "销售一部", "manager": "陈静", "customerType": "企业客户", "industry": "医疗保健", "creditCode": "91370212MA3U5FGH22", "legalPerson": "罗斌", "regCapital": "3500万元", "operateStatus": "在业", "address": "山东省青岛市崂山区松岭路238号", "createTime": "2024-04-18 16:00:00" },
|
||||
{ "id": "13", "code": "E000002", "name": "郑州中原新能源汽车销售有限公司", "labels": ["严重拖欠", "涉诉/仲裁", "多头签约"], "region": "华中地区", "status": "黑名单", "department": "销售二部", "manager": "孙丽", "customerType": "企业客户", "industry": "制造业", "creditCode": "91410105MA9L6NOP44", "legalPerson": "丁凯", "regCapital": "1200万元", "operateStatus": "注销", "address": "河南省郑州市金水区经三路66号(已迁出)", "createTime": "2024-05-06 10:15:00" },
|
||||
{ "id": "14", "code": "A000005", "name": "个体工商户·林晓峰五金经营部", "labels": [], "region": "华东地区", "status": "正常", "department": "销售一部", "manager": "周杰", "customerType": "个人客户", "industry": "零售", "creditCode": "92330102MA2HY67890", "legalPerson": "林晓峰", "regCapital": "50万元", "operateStatus": "在业", "address": "浙江省温州市鹿城区车站大道789号", "createTime": "2024-05-22 09:20:00" },
|
||||
{ "id": "15", "code": "D000002", "name": "重庆山城火锅餐饮管理有限公司", "labels": [], "region": "西南地区", "status": "正常", "department": "市场开拓部", "manager": "吴磊", "customerType": "企业客户", "industry": "零售", "creditCode": "91500105MA60QRS111", "legalPerson": "黄丽", "regCapital": "300万元", "operateStatus": "在业", "address": "重庆市渝中区解放碑民族路188号", "createTime": "2024-06-01 12:45:00" },
|
||||
{ "id": "16", "code": "A000006", "name": "苏州工业园区精密模具厂", "labels": ["车辆/资产高风险", "制裁/高风险地区"], "region": "华东地区", "status": "黑名单", "department": "大客户部", "manager": "郑华", "customerType": "企业客户", "industry": "制造业", "creditCode": "91320594MA1W2TUV55", "legalPerson": "马军", "regCapital": "1800万元", "operateStatus": "在业", "address": "江苏省苏州市工业园区星龙街428号", "createTime": "2024-06-15 15:50:00" },
|
||||
{ "id": "17", "code": "E000003", "name": "长沙湘江科创投资基金(有限合伙)", "labels": ["频繁争议"], "region": "华中地区", "status": "正常", "department": "大客户部", "manager": "冯涛", "customerType": "企业客户", "industry": "金融", "creditCode": "91430100MA4L8XYZ66", "legalPerson": "谢文", "regCapital": "10000万元", "operateStatus": "在业", "address": "湖南省长沙市岳麓区麓谷大道658号", "createTime": "2024-07-03 08:30:00" },
|
||||
{ "id": "18", "code": "C000003", "name": "石家庄市桥西区智慧城市运营中心", "labels": [], "region": "华北地区", "status": "正常", "department": "市场开拓部", "manager": "韩雪", "customerType": "政府机构", "industry": "互联网", "creditCode": "91130104MB0X9DEF12", "legalPerson": "邓伟", "regCapital": "—", "operateStatus": "在业", "address": "河北省石家庄市桥西区中华南大街172号", "createTime": "2024-07-20 14:10:00" },
|
||||
{ "id": "19", "code": "A000007", "name": "合肥徽风电子元器件有限公司", "labels": ["频繁改账期"], "region": "华东地区", "status": "正常", "department": "销售二部", "manager": "曹阳", "customerType": "企业客户", "industry": "制造业", "creditCode": "91340100MA2U1BCD33", "legalPerson": "蒋欣", "regCapital": "900万元", "operateStatus": "在业", "address": "安徽省合肥市高新区望江西路800号", "createTime": "2024-08-08 11:55:00" },
|
||||
{ "id": "20", "code": "D000003", "name": "昆明滇池生态旅游开发有限公司", "labels": ["制裁/高风险地区", "多头签约", "开票异常"], "region": "西南地区", "status": "正常", "department": "销售一部", "manager": "许晴", "customerType": "企业客户", "industry": "公共服务", "creditCode": "91530100MA6N7KLM88", "legalPerson": "潘越", "regCapital": "4500万元", "operateStatus": "存续", "address": "云南省昆明市西山区滇池路1318号", "createTime": "2024-08-25 17:00:00" }
|
||||
]
|
||||
243
src/prototypes/customer-management/index.tsx
Normal file
243
src/prototypes/customer-management/index.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @name 客户管理
|
||||
*/
|
||||
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Download, FileUp, Plus } from 'lucide-react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import seedCustomers from './data/customers.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { CustomerTable } from './components/CustomerTable';
|
||||
import { CreatePage } from './pages/CreatePage';
|
||||
import { TablePagination } from '../vehicle-management/components/TablePagination';
|
||||
import {
|
||||
EMPTY_CREATE_FORM,
|
||||
EMPTY_FILTERS,
|
||||
type CustomerCreateForm,
|
||||
type CustomerFilters,
|
||||
type CustomerPageId,
|
||||
type CustomerRecord,
|
||||
} from './types';
|
||||
import { applyFilters } from './utils/filters';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
type NavigationRef = {
|
||||
openCreate?: () => void;
|
||||
backToList?: () => void;
|
||||
};
|
||||
|
||||
function validateCreateForm(form: CustomerCreateForm): string | null {
|
||||
if (!form.customerType) return '请选择客户类型';
|
||||
if (!form.customerName.trim()) return '请输入客户全称';
|
||||
if (!form.cityProvince || !form.cityName) return '请选择所属城市';
|
||||
if (!form.department) return '请选择业务负责部门';
|
||||
if (!form.manager) return '请选择业务负责人员';
|
||||
if (!form.invoiceTitle.trim()) return '请填写发票抬头';
|
||||
if (!form.taxId.trim()) return '请输入纳税人识别号';
|
||||
if (!form.bankName.trim()) return '请输入开户银行';
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CustomerManagementApp() {
|
||||
const [records] = useState<CustomerRecord[]>(seedCustomers as CustomerRecord[]);
|
||||
const [view, setView] = useState<CustomerPageId>('list');
|
||||
const [pendingFilters, setPendingFilters] = useState<CustomerFilters>(EMPTY_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<CustomerFilters>(EMPTY_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [createForm, setCreateForm] = useState<CustomerCreateForm>(EMPTY_CREATE_FORM);
|
||||
const [toast, setToast] = useState('');
|
||||
const navigationRef = useRef<NavigationRef>({});
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 3000);
|
||||
}, []);
|
||||
|
||||
navigationRef.current = {
|
||||
openCreate: () => {
|
||||
setCreateForm(EMPTY_CREATE_FORM);
|
||||
setView('create');
|
||||
},
|
||||
backToList: () => setView('list'),
|
||||
};
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(records, appliedFilters),
|
||||
[records, appliedFilters],
|
||||
);
|
||||
|
||||
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 handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_FILTERS);
|
||||
setAppliedFilters(EMPTY_FILTERS);
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleToggleRow = (id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setSelectedIds(new Set());
|
||||
return;
|
||||
}
|
||||
setSelectedIds(new Set(paged.map((record) => record.id)));
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setCreateForm(EMPTY_CREATE_FORM);
|
||||
setView('create');
|
||||
};
|
||||
|
||||
const handleSubmitCreate = () => {
|
||||
const error = validateCreateForm(createForm);
|
||||
if (error) {
|
||||
showToast(error);
|
||||
return;
|
||||
}
|
||||
showToast('客户已保存(原型演示)');
|
||||
setView('list');
|
||||
};
|
||||
|
||||
const handleDirectoryRoute = useCallback((node: AnnotationDirectoryRouteNode) => {
|
||||
const payload = node.payload as { pageId?: string } | undefined;
|
||||
const pageId: CustomerPageId = payload?.pageId === 'create' ? 'create' : 'list';
|
||||
if (pageId === 'create') {
|
||||
navigationRef.current.openCreate?.();
|
||||
} else {
|
||||
navigationRef.current.backToList?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: view,
|
||||
onDirectoryRoute: handleDirectoryRoute,
|
||||
}),
|
||||
[view, handleDirectoryRoute],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vm-page cm-page">
|
||||
{view === 'create' ? (
|
||||
<CreatePage
|
||||
form={createForm}
|
||||
onChange={(patch) => setCreateForm((prev) => ({ ...prev, ...patch }))}
|
||||
onReset={() => setCreateForm(EMPTY_CREATE_FORM)}
|
||||
onSubmit={handleSubmitCreate}
|
||||
onBack={() => setView('list')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<nav className="cm-breadcrumb cm-breadcrumb-list" aria-label="面包屑">
|
||||
<span className="cm-breadcrumb-muted">业务管理</span>
|
||||
</nav>
|
||||
|
||||
<FilterPanel
|
||||
records={records}
|
||||
filters={pendingFilters}
|
||||
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
|
||||
onReset={handleReset}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar" data-annotation-id="cm-list-toolbar">
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleOpenCreate}>
|
||||
<Plus size={16} aria-hidden />
|
||||
新增客户
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary"
|
||||
onClick={() => showToast('批量导入(原型演示)')}
|
||||
>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost"
|
||||
onClick={() => showToast(`批量导出(原型演示):当前列表共 ${filtered.length} 条`)}
|
||||
>
|
||||
<Download size={16} aria-hidden />
|
||||
批量导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<CustomerTable
|
||||
records={paged}
|
||||
selectedIds={selectedIds}
|
||||
onToggleRow={handleToggleRow}
|
||||
onToggleAll={handleToggleAll}
|
||||
onView={(record) => showToast(`详情:${record.name}(原型演示)`)}
|
||||
onEdit={(record) => showToast(`编辑:${record.name}(原型演示)`)}
|
||||
onDelete={(record) => showToast(`删除:${record.name}(原型演示)`)}
|
||||
/>
|
||||
<div className="vm-table-footer">
|
||||
<div className="cm-table-total">共 {filtered.length} 条</div>
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{toast && <div className="vm-toast" role="status" aria-live="polite">{toast}</div>}
|
||||
</div>
|
||||
<AnnotationViewer
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
279
src/prototypes/customer-management/pages/CreatePage.tsx
Normal file
279
src/prototypes/customer-management/pages/CreatePage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { CustomerCreateForm } from '../types';
|
||||
import {
|
||||
CUSTOMER_LEVEL_OPTIONS,
|
||||
CUSTOMER_TYPE_OPTIONS,
|
||||
DEPT_MANAGER_MAP,
|
||||
REGION_TREE,
|
||||
} from '../utils/constants';
|
||||
|
||||
interface CreatePageProps {
|
||||
form: CustomerCreateForm;
|
||||
onChange: (patch: Partial<CustomerCreateForm>) => void;
|
||||
onReset: () => void;
|
||||
onSubmit: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="cm-form-field">
|
||||
<span className="cm-form-label">
|
||||
{label}
|
||||
{required && <span className="cm-required" aria-hidden>*</span>}
|
||||
</span>
|
||||
{children}
|
||||
{hint && <span className="cm-form-hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreatePage({
|
||||
form,
|
||||
onChange,
|
||||
onReset,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: CreatePageProps) {
|
||||
const cityOptions = useMemo(() => {
|
||||
const node = REGION_TREE.find((item) => item.province === form.cityProvince);
|
||||
return node?.cities ?? [];
|
||||
}, [form.cityProvince]);
|
||||
|
||||
const managerOptions = form.department ? (DEPT_MANAGER_MAP[form.department] ?? []) : [];
|
||||
|
||||
const handleNameChange = (customerName: string) => {
|
||||
onChange({ customerName, invoiceTitle: customerName });
|
||||
};
|
||||
|
||||
const handleDepartmentChange = (department: string) => {
|
||||
onChange({ department, manager: '' });
|
||||
};
|
||||
|
||||
const handleProvinceChange = (cityProvince: string) => {
|
||||
onChange({ cityProvince, cityName: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cm-create-page">
|
||||
<nav className="cm-breadcrumb" aria-label="面包屑">
|
||||
<button type="button" className="cm-breadcrumb-link" onClick={onBack}>业务管理</button>
|
||||
<span aria-hidden>/</span>
|
||||
<button type="button" className="cm-breadcrumb-link" onClick={onBack}>客户管理</button>
|
||||
<span aria-hidden>/</span>
|
||||
<span className="cm-breadcrumb-current">新增</span>
|
||||
</nav>
|
||||
|
||||
<section className="cm-form-card" data-annotation-id="cm-create-base">
|
||||
<h2 className="cm-form-card-title">客户信息</h2>
|
||||
<div className="cm-form-grid">
|
||||
<FormField label="客户类型" required>
|
||||
<select
|
||||
className="vm-input"
|
||||
value={form.customerType}
|
||||
onChange={(event) => onChange({ customerType: event.target.value })}
|
||||
>
|
||||
{CUSTOMER_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="客户分级">
|
||||
<select
|
||||
className="vm-input"
|
||||
value={form.customerLevel}
|
||||
onChange={(event) => onChange({ customerLevel: event.target.value })}
|
||||
>
|
||||
{CUSTOMER_LEVEL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="客户全称" required>
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.customerName}
|
||||
placeholder="与客户证照一致"
|
||||
onChange={(event) => handleNameChange(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="客户简称">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.customerShortName}
|
||||
placeholder="选填"
|
||||
onChange={(event) => onChange({ customerShortName: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="所属城市" required>
|
||||
<div className="cm-city-row">
|
||||
<select
|
||||
className="vm-input"
|
||||
value={form.cityProvince}
|
||||
onChange={(event) => handleProvinceChange(event.target.value)}
|
||||
>
|
||||
<option value="">请选择省份</option>
|
||||
{REGION_TREE.map((item) => (
|
||||
<option key={item.province} value={item.province}>{item.province}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="vm-input"
|
||||
value={form.cityName}
|
||||
disabled={!form.cityProvince}
|
||||
onChange={(event) => onChange({ cityName: event.target.value })}
|
||||
>
|
||||
<option value="">请选择城市</option>
|
||||
{cityOptions.map((city) => (
|
||||
<option key={city} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="通讯地址">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.address}
|
||||
placeholder="请输入通讯地址"
|
||||
onChange={(event) => onChange({ address: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="业务负责部门" required>
|
||||
<select
|
||||
className="vm-input"
|
||||
value={form.department}
|
||||
onChange={(event) => handleDepartmentChange(event.target.value)}
|
||||
>
|
||||
<option value="">请选择业务负责部门</option>
|
||||
{Object.keys(DEPT_MANAGER_MAP).map((dept) => (
|
||||
<option key={dept} value={dept}>{dept}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="业务负责人员" required>
|
||||
<select
|
||||
className="vm-input"
|
||||
value={form.manager}
|
||||
disabled={!form.department}
|
||||
onChange={(event) => onChange({ manager: event.target.value })}
|
||||
>
|
||||
<option value="">请选择业务负责人员</option>
|
||||
{managerOptions.map((manager) => (
|
||||
<option key={manager} value={manager}>{manager}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="备注" hint="">
|
||||
<textarea
|
||||
className="vm-input cm-textarea"
|
||||
rows={3}
|
||||
value={form.remark}
|
||||
placeholder="请输入备注"
|
||||
onChange={(event) => onChange({ remark: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="cm-form-card" data-annotation-id="cm-create-contact">
|
||||
<h2 className="cm-form-card-title">联系人信息</h2>
|
||||
<div className="cm-form-grid">
|
||||
<FormField label="姓名">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.contactName}
|
||||
placeholder="请输入姓名"
|
||||
onChange={(event) => onChange({ contactName: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="手机号">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.contactPhone}
|
||||
placeholder="请输入手机号"
|
||||
onChange={(event) => onChange({ contactPhone: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="职位">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.contactPosition}
|
||||
placeholder="请输入职位"
|
||||
onChange={(event) => onChange({ contactPosition: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="cm-form-card" data-annotation-id="cm-create-invoice">
|
||||
<h2 className="cm-form-card-title">付款及开票信息</h2>
|
||||
<div className="cm-form-grid">
|
||||
<FormField label="发票抬头" required hint="自动带入客户全称">
|
||||
<input
|
||||
className="vm-input cm-input-disabled"
|
||||
value={form.invoiceTitle}
|
||||
placeholder="自动带入客户全称"
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="纳税人识别号" required>
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.taxId}
|
||||
placeholder="请输入纳税人识别号"
|
||||
onChange={(event) => onChange({ taxId: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="注册地址">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.regAddress}
|
||||
placeholder="请输入注册地址"
|
||||
onChange={(event) => onChange({ regAddress: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="注册电话">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.regPhone}
|
||||
placeholder="请输入注册电话"
|
||||
onChange={(event) => onChange({ regPhone: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="开户银行" required>
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.bankName}
|
||||
placeholder="请输入开户银行"
|
||||
onChange={(event) => onChange({ bankName: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="账号">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.bankAccount}
|
||||
placeholder="请输入账号"
|
||||
onChange={(event) => onChange({ bankAccount: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="cm-create-footer" data-annotation-id="cm-create-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit}>提交</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const protoRoot = path.resolve(__dirname, '..');
|
||||
const listPrd = fs.readFileSync(path.join(protoRoot, '.spec/requirements-prd-list.md'), 'utf8');
|
||||
const createPrd = fs.readFileSync(path.join(protoRoot, '.spec/requirements-prd-create.md'), 'utf8');
|
||||
|
||||
const NODE_DEFS = [
|
||||
{ id: 'cm-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项(一行),点击「更多筛选」显示全部。' },
|
||||
{ id: 'cm-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '新增、导入、导出入口。', annotationText: '「新增客户」进入分组表单页。' },
|
||||
{ id: 'cm-list-table', title: '客户台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '客户台账表格列与行操作。', annotationText: '' },
|
||||
{ id: 'cm-create-base', title: '客户信息', pageId: 'create', color: '#2563eb', aiPrompt: '客户类型、分级、全称、城市、部门负责人等。', annotationText: '客户全称变更时同步发票抬头。' },
|
||||
{ id: 'cm-create-contact', title: '联系人信息', pageId: 'create', color: '#64748b', aiPrompt: '联系人姓名、手机、职位。', annotationText: '均为选填。' },
|
||||
{ id: 'cm-create-invoice', title: '付款及开票信息', pageId: 'create', color: '#d97706', aiPrompt: '发票抬头、税号、银行账号等。', annotationText: '发票抬头只读,与客户全称一致。' },
|
||||
{ id: 'cm-create-footer', title: '底部操作栏', pageId: 'create', color: '#0f766e', aiPrompt: '重置与提交。', annotationText: '提交校验必填项后 Toast 并返回列表。' },
|
||||
];
|
||||
|
||||
const prdByKey = { list: listPrd, create: createPrd };
|
||||
|
||||
function extractSection(md, startHeading) {
|
||||
const lines = md.split('\n');
|
||||
let start = lines.findIndex((line) => line.trim() === startHeading || line.startsWith(startHeading));
|
||||
if (start < 0) return null;
|
||||
const startLevel = (lines[start].match(/^(#+)/)?.[1] ?? '##').length;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^#{1,6}\s/.test(lines[i])) {
|
||||
const level = lines[i].match(/^(#+)/)?.[1].length ?? 2;
|
||||
if (level <= startLevel) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.slice(start, end).join('\n').trim();
|
||||
}
|
||||
|
||||
function extractFromPrd({ prd, heading, sub }) {
|
||||
const md = prdByKey[prd];
|
||||
if (!md) return null;
|
||||
const base = extractSection(md, heading);
|
||||
if (!base) return null;
|
||||
if (!sub) return base;
|
||||
return extractSection(base, sub) ?? base;
|
||||
}
|
||||
|
||||
const NODE_PRD_MAP = {
|
||||
'cm-list-filter': { prd: 'list', heading: '## 4. 筛选区' },
|
||||
'cm-list-toolbar': { prd: 'list', heading: '## 5. 列表工具栏' },
|
||||
'cm-list-table': { prd: 'list', heading: '## 6. 列表字段说明' },
|
||||
'cm-create-base': { prd: 'create', heading: '## 2. 客户信息' },
|
||||
'cm-create-contact': { prd: 'create', heading: '## 3. 联系人信息' },
|
||||
'cm-create-invoice': { prd: 'create', heading: '## 4. 付款及开票信息' },
|
||||
'cm-create-footer': { prd: 'create', heading: '## 5. 校验与提交' },
|
||||
};
|
||||
|
||||
function buildFallbackMarkdown(node) {
|
||||
const parts = [`# ${node.title}`, ''];
|
||||
if (node.aiPrompt) parts.push(node.aiPrompt, '');
|
||||
if (node.annotationText?.trim()) parts.push('## 说明', '', node.annotationText.trim());
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
|
||||
function buildNodeMarkdown(node) {
|
||||
const mapping = NODE_PRD_MAP[node.id];
|
||||
if (mapping) {
|
||||
const prdBody = extractFromPrd(mapping);
|
||||
if (prdBody) return prdBody;
|
||||
}
|
||||
return buildFallbackMarkdown(node);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const nodes = NODE_DEFS.map((def, index) => ({
|
||||
id: def.id,
|
||||
index: index + 1,
|
||||
title: def.title,
|
||||
pageId: def.pageId,
|
||||
locator: {
|
||||
selectors: [`[data-annotation-id="${def.id}"]`],
|
||||
fingerprint: def.id,
|
||||
path: [],
|
||||
},
|
||||
aiPrompt: def.aiPrompt,
|
||||
annotationText: def.annotationText,
|
||||
hasMarkdown: true,
|
||||
color: def.color,
|
||||
images: [],
|
||||
createdAt: 1740787200000,
|
||||
updatedAt: 1740787200000,
|
||||
}));
|
||||
|
||||
const markdownMap = Object.fromEntries(nodes.map((node) => [node.id, buildNodeMarkdown(node)]));
|
||||
|
||||
const listPageNodes = nodes.filter((n) => n.pageId === 'list');
|
||||
const createPageNodes = nodes.filter((n) => n.pageId === 'create');
|
||||
|
||||
const source = {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: {
|
||||
version: 2,
|
||||
prototypeName: 'customer-management',
|
||||
pageId: 'list',
|
||||
updatedAt: now,
|
||||
nodes,
|
||||
},
|
||||
markdownMap,
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'cm-doc-root',
|
||||
title: '客户管理说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'cm-doc-overview',
|
||||
title: '模块总览',
|
||||
markdown: extractSection(listPrd, '## 1. 模块定位') ?? '# 客户管理',
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'cm-link-self',
|
||||
title: '客户管理原型',
|
||||
href: '/prototypes/customer-management',
|
||||
target: 'self',
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'cm-doc-prd',
|
||||
title: 'PRD 全文',
|
||||
defaultExpanded: false,
|
||||
children: [
|
||||
{ type: 'markdown', id: 'cm-doc-prd-list', title: '列表页 PRD', markdown: listPrd },
|
||||
{ type: 'markdown', id: 'cm-doc-prd-create', title: '新增页 PRD', markdown: createPrd },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'cm-doc-list',
|
||||
title: '列表页模块',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'cm-route-list',
|
||||
title: '打开列表页',
|
||||
route: 'customer-management:list',
|
||||
payload: { pageId: 'list' },
|
||||
},
|
||||
...listPageNodes.map((node) => ({
|
||||
type: 'markdown',
|
||||
id: `cm-doc-node-${node.id}`,
|
||||
title: node.title,
|
||||
markdown: markdownMap[node.id],
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'cm-doc-create',
|
||||
title: '新增页模块',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'cm-route-create',
|
||||
title: '打开新增页',
|
||||
route: 'customer-management:create',
|
||||
payload: { pageId: 'create' },
|
||||
},
|
||||
...createPageNodes.map((node) => ({
|
||||
type: 'markdown',
|
||||
id: `cm-doc-node-${node.id}`,
|
||||
title: node.title,
|
||||
markdown: markdownMap[node.id],
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(protoRoot, 'annotation-source.json'),
|
||||
`${JSON.stringify(source, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
console.log(`Built annotation-source.json with ${nodes.length} nodes.`);
|
||||
374
src/prototypes/customer-management/styles/index.css
Normal file
374
src/prototypes/customer-management/styles/index.css
Normal file
@@ -0,0 +1,374 @@
|
||||
/* 客户管理 — 模块样式(基于 vehicle-management vm-*) */
|
||||
|
||||
.cm-page {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.cm-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.cm-breadcrumb-list {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cm-breadcrumb-muted {
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.cm-breadcrumb-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--ln-body);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.cm-breadcrumb-link:hover {
|
||||
color: var(--ln-primary);
|
||||
}
|
||||
|
||||
.cm-breadcrumb-current {
|
||||
color: var(--ln-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cm-page-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.cm-filter-card .vm-filter-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cm-filter-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cm-date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-date-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cm-date-sep {
|
||||
color: var(--ln-muted-soft);
|
||||
}
|
||||
|
||||
.cm-multi-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-multi-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cm-multi-clear {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ln-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.cm-multi-popover {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
box-shadow: var(--ln-shadow-hover);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.cm-multi-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--ln-radius-xs);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cm-multi-option:hover {
|
||||
background: var(--ln-surface-pearl);
|
||||
}
|
||||
|
||||
.cm-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cm-table {
|
||||
min-width: 2240px;
|
||||
}
|
||||
|
||||
.cm-col-check {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.cm-col-code {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.cm-col-name {
|
||||
min-width: 200px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-col-labels {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.cm-col-credit {
|
||||
min-width: 200px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-col-address {
|
||||
min-width: 240px;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-col-time {
|
||||
min-width: 176px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-col-actions {
|
||||
min-width: 168px;
|
||||
}
|
||||
|
||||
.sticky-col-left {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.sticky-col-right {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.cm-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-action-sep {
|
||||
color: var(--ln-hairline);
|
||||
}
|
||||
|
||||
.cm-link-danger {
|
||||
color: var(--ln-error) !important;
|
||||
}
|
||||
|
||||
.cm-muted {
|
||||
color: var(--ln-muted-soft);
|
||||
}
|
||||
|
||||
.cm-label-tags {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cm-label-count {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-label-tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
box-shadow: var(--ln-shadow-hover);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.cm-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cm-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cm-table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cm-table-total {
|
||||
color: var(--ln-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cm-create-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.cm-form-card {
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
box-shadow: var(--ln-shadow-soft);
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.cm-form-card-title {
|
||||
margin: 0 0 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.cm-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px 24px;
|
||||
}
|
||||
|
||||
.cm-form-grid .cm-form-field:last-child:nth-child(3n + 1) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.cm-form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cm-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cm-form-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-body);
|
||||
}
|
||||
|
||||
.cm-required {
|
||||
color: var(--ln-error);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.cm-form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.cm-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.cm-input-disabled {
|
||||
background: var(--ln-surface-pearl) !important;
|
||||
color: var(--ln-muted) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cm-city-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cm-create-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
margin: 0 -32px -32px;
|
||||
background: var(--ln-surface-card);
|
||||
border-top: 1px solid var(--ln-hairline);
|
||||
box-shadow: 0 -2px 10px rgba(24, 24, 27, 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cm-multi-trigger,
|
||||
.cm-label-count {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
90
src/prototypes/customer-management/types.ts
Normal file
90
src/prototypes/customer-management/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export type CustomerStatus = '正常' | '黑名单';
|
||||
|
||||
export interface CustomerRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
labels: string[];
|
||||
region: string;
|
||||
status: CustomerStatus;
|
||||
department: string;
|
||||
manager: string;
|
||||
customerType: string;
|
||||
industry: string;
|
||||
creditCode: string;
|
||||
legalPerson: string;
|
||||
regCapital: string;
|
||||
operateStatus: string;
|
||||
address: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface CustomerFilters {
|
||||
code: string;
|
||||
name: string;
|
||||
labels: string[];
|
||||
region: string;
|
||||
statuses: CustomerStatus[];
|
||||
departments: string[];
|
||||
managers: string[];
|
||||
createFrom: string;
|
||||
createTo: string;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTERS: CustomerFilters = {
|
||||
code: '',
|
||||
name: '',
|
||||
labels: [],
|
||||
region: '',
|
||||
statuses: [],
|
||||
departments: [],
|
||||
managers: [],
|
||||
createFrom: '',
|
||||
createTo: '',
|
||||
};
|
||||
|
||||
export interface CustomerCreateForm {
|
||||
customerType: string;
|
||||
customerLevel: string;
|
||||
customerName: string;
|
||||
customerShortName: string;
|
||||
cityProvince: string;
|
||||
cityName: string;
|
||||
address: string;
|
||||
department: string;
|
||||
manager: string;
|
||||
remark: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
contactPosition: string;
|
||||
invoiceTitle: string;
|
||||
taxId: string;
|
||||
regAddress: string;
|
||||
regPhone: string;
|
||||
bankName: string;
|
||||
bankAccount: string;
|
||||
}
|
||||
|
||||
export const EMPTY_CREATE_FORM: CustomerCreateForm = {
|
||||
customerType: '企业',
|
||||
customerLevel: 'B',
|
||||
customerName: '',
|
||||
customerShortName: '',
|
||||
cityProvince: '',
|
||||
cityName: '',
|
||||
address: '',
|
||||
department: '',
|
||||
manager: '',
|
||||
remark: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactPosition: '',
|
||||
invoiceTitle: '',
|
||||
taxId: '',
|
||||
regAddress: '',
|
||||
regPhone: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
};
|
||||
|
||||
export type CustomerPageId = 'list' | 'create';
|
||||
42
src/prototypes/customer-management/utils/constants.ts
Normal file
42
src/prototypes/customer-management/utils/constants.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export const CUSTOMER_LABEL_OPTIONS = [
|
||||
'逾期预警',
|
||||
'严重拖欠',
|
||||
'频繁改账期',
|
||||
'资质存疑',
|
||||
'涉诉/仲裁',
|
||||
'制裁/高风险地区',
|
||||
'频繁争议',
|
||||
'车辆/资产高风险',
|
||||
'信息不实',
|
||||
'开票异常',
|
||||
'多头签约',
|
||||
] as const;
|
||||
|
||||
export const CUSTOMER_STATUS_OPTIONS = ['正常', '黑名单'] as const;
|
||||
|
||||
export const CUSTOMER_TYPE_OPTIONS = ['企业', '个人', '事业单位'] as const;
|
||||
|
||||
export const CUSTOMER_LEVEL_OPTIONS = ['A', 'B', 'C'] as const;
|
||||
|
||||
export const REGION_OPTIONS = [
|
||||
'华北地区',
|
||||
'华东地区',
|
||||
'华南地区',
|
||||
'华中地区',
|
||||
'西南地区',
|
||||
'西北地区',
|
||||
] as const;
|
||||
|
||||
export const REGION_TREE: Array<{ province: string; cities: string[] }> = [
|
||||
{ province: '浙江省', cities: ['杭州市', '嘉兴市', '宁波市'] },
|
||||
{ province: '上海市', cities: ['上海市'] },
|
||||
{ province: '江苏省', cities: ['南京市', '苏州市'] },
|
||||
{ province: '广东省', cities: ['广州市', '深圳市'] },
|
||||
];
|
||||
|
||||
export const DEPT_MANAGER_MAP: Record<string, string[]> = {
|
||||
销售一部: ['张伟', '王芳', '周杰', '陈静', '许晴'],
|
||||
销售二部: ['刘洋', '孙丽', '曹阳'],
|
||||
市场开拓部: ['吴磊', '赵敏', '韩雪'],
|
||||
大客户部: ['李强', '郑华', '冯涛'],
|
||||
};
|
||||
84
src/prototypes/customer-management/utils/filters.ts
Normal file
84
src/prototypes/customer-management/utils/filters.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { CustomerFilters, CustomerRecord } from '../types';
|
||||
|
||||
function parseDate(value: string): number | null {
|
||||
if (!value) return null;
|
||||
const ts = Date.parse(value.replace(' ', 'T'));
|
||||
return Number.isNaN(ts) ? null : ts;
|
||||
}
|
||||
|
||||
function inDateRange(createTime: string, from: string, to: string): boolean {
|
||||
const recordTs = parseDate(createTime);
|
||||
if (recordTs == null) return true;
|
||||
const fromTs = from ? parseDate(`${from}T00:00:00`) : null;
|
||||
const toTs = to ? parseDate(`${to}T23:59:59`) : null;
|
||||
if (fromTs != null && recordTs < fromTs) return false;
|
||||
if (toTs != null && recordTs > toTs) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyFilters(records: CustomerRecord[], filters: CustomerFilters): CustomerRecord[] {
|
||||
return records.filter((record) => {
|
||||
if (filters.code && record.code !== filters.code) return false;
|
||||
if (filters.name && record.name !== filters.name) return false;
|
||||
if (filters.region && record.region !== filters.region) return false;
|
||||
if (filters.labels.length > 0 && !filters.labels.some((tag) => record.labels.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
if (filters.statuses.length > 0 && !filters.statuses.includes(record.status)) return false;
|
||||
if (filters.departments.length > 0 && !filters.departments.includes(record.department)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.managers.length > 0 && !filters.managers.includes(record.manager)) return false;
|
||||
if (!inDateRange(record.createTime, filters.createFrom, filters.createTo)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOptions(records: CustomerRecord[], key: keyof CustomerRecord): string[] {
|
||||
const set = new Set<string>();
|
||||
records.forEach((record) => {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim()) set.add(value);
|
||||
});
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function buildCodeOptions(records: CustomerRecord[]): Array<{ value: string; label: string }> {
|
||||
return records.map((record) => ({
|
||||
value: record.code,
|
||||
label: `${record.code} · ${record.name}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildDeptManagerMap(records: CustomerRecord[]): Record<string, string[]> {
|
||||
const map: Record<string, string[]> = {};
|
||||
records.forEach((record) => {
|
||||
if (!record.department) return;
|
||||
if (!map[record.department]) map[record.department] = [];
|
||||
if (record.manager && !map[record.department].includes(record.manager)) {
|
||||
map[record.department].push(record.manager);
|
||||
}
|
||||
});
|
||||
Object.keys(map).forEach((dept) => {
|
||||
map[dept].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getManagerOptions(
|
||||
deptManagerMap: Record<string, string[]>,
|
||||
departments: string[],
|
||||
): string[] {
|
||||
if (departments.length === 0) {
|
||||
const all = new Set<string>();
|
||||
Object.values(deptManagerMap).forEach((managers) => {
|
||||
managers.forEach((manager) => all.add(manager));
|
||||
});
|
||||
return Array.from(all).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
const pick = new Set<string>();
|
||||
departments.forEach((dept) => {
|
||||
(deptManagerMap[dept] ?? []).forEach((manager) => pick.add(manager));
|
||||
});
|
||||
return Array.from(pick).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
@@ -2909,7 +2909,7 @@ const PAGE_STYLE = `
|
||||
.lc-filter-card > .ant-card-head { border-bottom: 1px solid #f1f5f9 !important; min-height: auto; padding: 12px 20px !important; }
|
||||
.lc-filter-card > .ant-card-head .ant-card-head-title { font-size: 15px !important; font-weight: 700 !important; color: #0f172a !important; padding: 0 !important; }
|
||||
.lc-filter-card > .ant-card-body { padding: 16px 20px 20px !important; }
|
||||
.lc-filter-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px 24px; }
|
||||
.lc-filter-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px 24px; }
|
||||
@media (max-width: 1100px) { .lc-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) { .lc-filter-grid { grid-template-columns: 1fr; } }
|
||||
.lc-filter-field { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
@@ -3337,6 +3337,7 @@ const Component = function () {
|
||||
};
|
||||
const [listFilters, setListFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS }));
|
||||
const [appliedFilters, setAppliedFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS }));
|
||||
const [listFilterExpanded, setListFilterExpanded] = useState(false);
|
||||
const [multiPlateOpen, setMultiPlateOpen] = useState(false);
|
||||
const [multiPlateDraft, setMultiPlateDraft] = useState('');
|
||||
const [kpiFilter, setKpiFilter] = useState('total');
|
||||
@@ -6582,6 +6583,145 @@ const Component = function () {
|
||||
},
|
||||
];
|
||||
|
||||
const listFilterFieldNodes = [
|
||||
renderFilterField('车牌号', (
|
||||
<Input
|
||||
placeholder={appliedMultiPlates.length ? '已启用多车牌筛选' : '请输入车牌号'}
|
||||
allowClear
|
||||
disabled={appliedMultiPlates.length > 0}
|
||||
value={listFilters.plateNo}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('多车牌', (
|
||||
<Popover
|
||||
open={multiPlateOpen}
|
||||
onOpenChange={handleMultiPlateOpenChange}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
content={(
|
||||
<div className="lc-multi-plate-pop">
|
||||
<div className="lc-multi-plate-pop-hint">每行一个车牌,或同一行内用逗号分隔</div>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={multiPlateDraft}
|
||||
onChange={(e) => setMultiPlateDraft(e.target.value)}
|
||||
placeholder={'沪A03561F\n粤B58888F'}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
<div className="lc-multi-plate-pop-actions">
|
||||
<Button size="small" onClick={() => setMultiPlateOpen(false)}>取消</Button>
|
||||
<Button size="small" type="primary" onClick={handleListFilterQuery}>应用</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
className="lc-multi-plate-trigger"
|
||||
readOnly
|
||||
placeholder="点击输入多个车牌"
|
||||
value={appliedMultiPlates.length ? `已选 ${appliedMultiPlates.length} 个车牌` : ''}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
</Popover>
|
||||
)),
|
||||
renderFilterField('VIN码', (
|
||||
<Input
|
||||
placeholder="请输入车辆识别代码"
|
||||
allowClear
|
||||
value={listFilters.vin}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, vin: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('品牌', (
|
||||
<Select
|
||||
placeholder="请选择或输入品牌"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.brand || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, brand: val || '' }))}
|
||||
options={brandOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('型号', (
|
||||
<Select
|
||||
placeholder="请选择或输入型号"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.model || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, model: val || '' }))}
|
||||
options={modelOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('运营状态', (
|
||||
<Select
|
||||
value={listFilters.operateStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, operateStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="租赁">租赁</Select.Option>
|
||||
<Select.Option value="自营">自营</Select.Option>
|
||||
<Select.Option value="库存">库存</Select.Option>
|
||||
<Select.Option value="退出运营">退出运营</Select.Option>
|
||||
</Select>
|
||||
)),
|
||||
renderFilterField('保险状态', (
|
||||
<Select
|
||||
value={listFilters.insuranceStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="正常">正常/临期</Select.Option>
|
||||
<Select.Option value="异常">异常</Select.Option>
|
||||
</Select>
|
||||
)),
|
||||
renderFilterField('保险类型', (
|
||||
<Select
|
||||
placeholder="请选择险种"
|
||||
allowClear
|
||||
value={listFilters.insuranceType || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({
|
||||
...prev,
|
||||
insuranceType: val || '',
|
||||
endDateRange: val ? prev.endDateRange : null,
|
||||
}))}
|
||||
options={INSURANCE_TYPE_ITEMS.map((item) => ({
|
||||
label: item.fullLabel,
|
||||
value: item.fullLabel,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)),
|
||||
renderFilterField('到期时间', (
|
||||
<Tooltip title={listFilters.insuranceType ? '' : '须先选择保险类型'}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={listFilters.endDateRange}
|
||||
disabled={!listFilters.insuranceType}
|
||||
onChange={(range) => {
|
||||
if (!listFilters.insuranceType) {
|
||||
message.warning('请先选择保险类型');
|
||||
return;
|
||||
}
|
||||
setListFilters((prev) => ({ ...prev, endDateRange: range }));
|
||||
}}
|
||||
placeholder={listFilters.insuranceType ? ['开始日期', '结束日期'] : ['请先选择保险类型', '']}
|
||||
allowClear
|
||||
/>
|
||||
</Tooltip>
|
||||
)),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lc-edit-page"
|
||||
@@ -6598,7 +6738,7 @@ const Component = function () {
|
||||
<style>{PAGE_STYLE}</style>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<div className="lc-page-header" style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div className="lc-page-header" data-annotation-id="ipc-header" style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800, color: '#0f172a', lineHeight: 1.3 }}>保险采购</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>保单管理(一车一档)· 比价单独立管理,互不关联</div>
|
||||
@@ -6613,146 +6753,16 @@ const Component = function () {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="lc-filter-card" title="保单管理 · 筛选条件" bordered={false}>
|
||||
<Card className="lc-filter-card" data-annotation-id="ipc-filter" title="保单管理 · 筛选条件" bordered={false}>
|
||||
<div className="lc-filter-grid">
|
||||
{renderFilterField('车牌号', (
|
||||
<Input
|
||||
placeholder={appliedMultiPlates.length ? '已启用多车牌筛选' : '请输入车牌号'}
|
||||
allowClear
|
||||
disabled={appliedMultiPlates.length > 0}
|
||||
value={listFilters.plateNo}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
))}
|
||||
{renderFilterField('多车牌', (
|
||||
<Popover
|
||||
open={multiPlateOpen}
|
||||
onOpenChange={handleMultiPlateOpenChange}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
content={(
|
||||
<div className="lc-multi-plate-pop">
|
||||
<div className="lc-multi-plate-pop-hint">每行一个车牌,或同一行内用逗号分隔</div>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={multiPlateDraft}
|
||||
onChange={(e) => setMultiPlateDraft(e.target.value)}
|
||||
placeholder={'沪A03561F\n粤B58888F'}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
<div className="lc-multi-plate-pop-actions">
|
||||
<Button size="small" onClick={() => setMultiPlateOpen(false)}>取消</Button>
|
||||
<Button size="small" type="primary" onClick={handleListFilterQuery}>应用</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
className="lc-multi-plate-trigger"
|
||||
readOnly
|
||||
placeholder="点击输入多个车牌"
|
||||
value={appliedMultiPlates.length ? `已选 ${appliedMultiPlates.length} 个车牌` : ''}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
</Popover>
|
||||
))}
|
||||
{renderFilterField('VIN码', (
|
||||
<Input
|
||||
placeholder="请输入车辆识别代码"
|
||||
allowClear
|
||||
value={listFilters.vin}
|
||||
onChange={(e) => setListFilters((prev) => ({ ...prev, vin: e.target.value }))}
|
||||
onPressEnter={handleListFilterQuery}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
))}
|
||||
{renderFilterField('品牌', (
|
||||
<Select
|
||||
placeholder="请选择或输入品牌"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.brand || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, brand: val || '' }))}
|
||||
options={brandOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
))}
|
||||
{renderFilterField('型号', (
|
||||
<Select
|
||||
placeholder="请选择或输入型号"
|
||||
allowClear
|
||||
showSearch
|
||||
value={listFilters.model || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, model: val || '' }))}
|
||||
options={modelOptions}
|
||||
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
))}
|
||||
{renderFilterField('运营状态', (
|
||||
<Select
|
||||
value={listFilters.operateStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, operateStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="租赁">租赁</Select.Option>
|
||||
<Select.Option value="自营">自营</Select.Option>
|
||||
<Select.Option value="库存">库存</Select.Option>
|
||||
<Select.Option value="退出运营">退出运营</Select.Option>
|
||||
</Select>
|
||||
))}
|
||||
{renderFilterField('保险状态', (
|
||||
<Select
|
||||
value={listFilters.insuranceStatus}
|
||||
onChange={(val) => setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value="全部">全部</Select.Option>
|
||||
<Select.Option value="正常">正常/临期</Select.Option>
|
||||
<Select.Option value="异常">异常</Select.Option>
|
||||
</Select>
|
||||
))}
|
||||
{renderFilterField('保险类型', (
|
||||
<Select
|
||||
placeholder="请选择险种"
|
||||
allowClear
|
||||
value={listFilters.insuranceType || undefined}
|
||||
onChange={(val) => setListFilters((prev) => ({
|
||||
...prev,
|
||||
insuranceType: val || '',
|
||||
endDateRange: val ? prev.endDateRange : null,
|
||||
}))}
|
||||
options={INSURANCE_TYPE_ITEMS.map((item) => ({
|
||||
label: item.fullLabel,
|
||||
value: item.fullLabel,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
))}
|
||||
{renderFilterField('到期时间', (
|
||||
<Tooltip title={listFilters.insuranceType ? '' : '须先选择保险类型'}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={listFilters.endDateRange}
|
||||
disabled={!listFilters.insuranceType}
|
||||
onChange={(range) => {
|
||||
if (!listFilters.insuranceType) {
|
||||
message.warning('请先选择保险类型');
|
||||
return;
|
||||
}
|
||||
setListFilters((prev) => ({ ...prev, endDateRange: range }));
|
||||
}}
|
||||
placeholder={listFilters.insuranceType ? ['开始日期', '结束日期'] : ['请先选择保险类型', '']}
|
||||
allowClear
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{(listFilterExpanded ? listFilterFieldNodes : listFilterFieldNodes.slice(0, 4))}
|
||||
</div>
|
||||
<div className="lc-filter-actions">
|
||||
{listFilterFieldNodes.length > 4 ? (
|
||||
<Button type="link" className="lc-filter-expand-btn" onClick={() => setListFilterExpanded((prev) => !prev)}>
|
||||
{listFilterExpanded ? '收起' : '更多筛选'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={handleListFilterReset}>重置</Button>
|
||||
<Button type="primary" onClick={handleListFilterQuery}>查询</Button>
|
||||
</div>
|
||||
@@ -6768,6 +6778,7 @@ const Component = function () {
|
||||
].map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
data-annotation-id={`ipc-kpi-${card.key}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`lc-alert-card lc-alert-card--${card.type} lc-alert-card-clickable${
|
||||
@@ -6822,6 +6833,7 @@ const Component = function () {
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#475569' }}>保单录入</span>
|
||||
<div className="lc-table-toolbar-actions lc-policy-toolbar">
|
||||
<Button
|
||||
data-annotation-id="ipc-btn-compare-mgmt"
|
||||
type="primary"
|
||||
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', border: 'none' }}
|
||||
onClick={openCompareMgmtModal}
|
||||
@@ -6829,6 +6841,7 @@ const Component = function () {
|
||||
比价单管理
|
||||
</Button>
|
||||
<Button
|
||||
data-annotation-id="ipc-btn-policy-ocr"
|
||||
type="default"
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: '#10b981', color: '#059669' }}
|
||||
onClick={() => openPolicyRecogn('ocr', 'policy')}
|
||||
@@ -6836,18 +6849,21 @@ const Component = function () {
|
||||
保单批量识别
|
||||
</Button>
|
||||
<Button
|
||||
data-annotation-id="ipc-btn-recogn-tasks"
|
||||
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||
onClick={openPolicyRecognTasksModal}
|
||||
>
|
||||
识别任务记录
|
||||
</Button>
|
||||
<Button
|
||||
data-annotation-id="ipc-btn-batch-import"
|
||||
style={{ borderRadius: 8, fontWeight: 600, borderColor: '#10b981', color: '#059669' }}
|
||||
onClick={() => openPolicyRecogn('import', 'policy')}
|
||||
>
|
||||
批量导入
|
||||
</Button>
|
||||
<Button
|
||||
data-annotation-id="ipc-btn-policy-add"
|
||||
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||
onClick={() => {
|
||||
setPolicyAddDraft({ ...EMPTY_POLICY_DETAIL, bizType: 'policy' });
|
||||
@@ -6859,7 +6875,7 @@ const Component = function () {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lc-table-card" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="lc-table-card" data-annotation-id="ipc-vehicle-table" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<Table
|
||||
className="lc-list-table"
|
||||
columns={listColumns}
|
||||
@@ -6903,7 +6919,7 @@ const Component = function () {
|
||||
? `lc-vehicle-ins-mgmt-status-pill--${insStatus.color}`
|
||||
: 'lc-vehicle-ins-mgmt-status-pill--error';
|
||||
return (
|
||||
<div className="lc-vehicle-ins-mgmt-shell">
|
||||
<div className="lc-vehicle-ins-mgmt-shell" data-annotation-id="ipc-vehicle-mgmt">
|
||||
<div className="lc-vehicle-ins-mgmt-hero">
|
||||
<div className="lc-vehicle-ins-mgmt-hero-top">
|
||||
<div>
|
||||
@@ -6946,7 +6962,7 @@ const Component = function () {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lc-vehicle-ins-mgmt-body">
|
||||
<div className="lc-vehicle-ins-mgmt-body" data-annotation-id="ipc-vehicle-mgmt-tabs">
|
||||
<div className="lc-vehicle-ins-mgmt-filter">
|
||||
{renderFilterField('保单号', (
|
||||
<Input
|
||||
@@ -7041,7 +7057,7 @@ const Component = function () {
|
||||
<div className="lc-policy-biz-summary-item-val">{policyBizModalRecord.endDate || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lc-policy-biz-form">
|
||||
<div className="lc-policy-biz-form" data-annotation-id="ipc-policy-biz">
|
||||
{policyBizModalMode === 'suspend' ? (
|
||||
<>
|
||||
{renderFilterField('中止时间', (
|
||||
@@ -7575,7 +7591,7 @@ const Component = function () {
|
||||
footer={null}
|
||||
onCancel={() => setCompareMgmtOpen(false)}
|
||||
>
|
||||
<div className="lc-compare-mgmt-filter">
|
||||
<div className="lc-compare-mgmt-filter" data-annotation-id="ipc-compare-mgmt">
|
||||
{renderFilterField('创建时间', (
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
@@ -7600,7 +7616,7 @@ const Component = function () {
|
||||
<Button onClick={handleCompareMgmtReset}>重置</Button>
|
||||
<Button type="primary" onClick={handleCompareMgmtQuery}>查询</Button>
|
||||
</div>
|
||||
<div className="lc-compare-pay-alert-row">
|
||||
<div className="lc-compare-pay-alert-row" data-annotation-id="ipc-compare-pay-alerts">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -7795,6 +7811,7 @@ const Component = function () {
|
||||
保存比价单
|
||||
</Button>
|
||||
<Button
|
||||
data-annotation-id="ipc-compare-procurement"
|
||||
type="primary"
|
||||
style={{ fontWeight: 600, background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', border: 'none' }}
|
||||
onClick={handleSubmitProcurement}
|
||||
@@ -7806,7 +7823,7 @@ const Component = function () {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="lc-compare-editor-filter">
|
||||
<div className="lc-compare-editor-filter" data-annotation-id="ipc-compare-editor">
|
||||
{renderFilterField('车辆', (
|
||||
<Popover
|
||||
open={compareVehicleFilterOpen}
|
||||
@@ -8072,7 +8089,7 @@ const Component = function () {
|
||||
footer={null}
|
||||
onCancel={() => setPolicyRecognTasksOpen(false)}
|
||||
>
|
||||
<div className="lc-policy-recogn-tasks-filter">
|
||||
<div className="lc-policy-recogn-tasks-filter" data-annotation-id="ipc-recogn-tasks">
|
||||
{renderFilterField('创建时间', (
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
@@ -8247,6 +8264,7 @@ const Component = function () {
|
||||
onCancel={closePolicyRecogn}
|
||||
destroyOnClose
|
||||
>
|
||||
<div data-annotation-id="ipc-policy-recogn">
|
||||
{policyRecognPhase === 'upload' ? (
|
||||
<>
|
||||
{policyRecognEntry === 'ocr' ? (
|
||||
@@ -8522,6 +8540,7 @@ const Component = function () {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -8567,6 +8586,7 @@ const Component = function () {
|
||||
style={{ marginBottom: 12, borderRadius: 8 }}
|
||||
message="仅支持录入五类险种保单;停保、复驶、退保请在列表「管理」中通过保单记录「更多」操作办理"
|
||||
/>
|
||||
<div data-annotation-id="ipc-policy-add">
|
||||
{renderPolicyDetailForm(policyAddDraft, setPolicyAddDraft, { showBizType: false, policyEntryMode: true })}
|
||||
<div className="lc-compare-attach-field" style={{ marginTop: 16 }}>
|
||||
<span className="lc-compare-attach-label">保单附件</span>
|
||||
@@ -8589,6 +8609,7 @@ const Component = function () {
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -8611,7 +8632,7 @@ const Component = function () {
|
||||
)}
|
||||
onCancel={() => setInsuranceAlertOpen(false)}
|
||||
>
|
||||
<div className="lc-expiring-warn-filter">
|
||||
<div className="lc-expiring-warn-filter" data-annotation-id="ipc-insurance-alert">
|
||||
<div className="lc-expiring-warn-filter-label">
|
||||
{insuranceAlertMode === 'coreExpired'
|
||||
? '逾期险种筛选(交强险、商业险)'
|
||||
|
||||
422
src/prototypes/insurance-procurement/annotation-source.json
Normal file
422
src/prototypes/insurance-procurement/annotation-source.json
Normal file
@@ -0,0 +1,422 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "insurance-procurement",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1740009600000,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ipc-header",
|
||||
"index": 1,
|
||||
"title": "页面标题与模块边界",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-header\"]"], "fingerprint": "header|ipc", "path": [] },
|
||||
"aiPrompt": "说明保险采购模块双业务线边界。",
|
||||
"annotationText": "保单管理与比价单相互独立,不自动双向同步。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-filter",
|
||||
"index": 2,
|
||||
"title": "保单管理 · 筛选条件",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-filter\"]"], "fingerprint": "card|filter", "path": [] },
|
||||
"aiPrompt": "列表筛选字段与查询逻辑。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-kpi-total",
|
||||
"index": 3,
|
||||
"title": "台账车辆总数",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-kpi-total\"]"], "fingerprint": "kpi|total", "path": [] },
|
||||
"aiPrompt": "KPI 点击筛选行为。",
|
||||
"annotationText": "纳入保险采购台账管理的车辆总数(一车一档)。点击可筛选列表。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#64748b",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-kpi-normal",
|
||||
"index": 4,
|
||||
"title": "保险状态正常",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-kpi-normal\"]"], "fingerprint": "kpi|normal", "path": [] },
|
||||
"aiPrompt": "与车辆管理保险状态联动口径。",
|
||||
"annotationText": "交强险、商业险均已购买且在有效期内,与车辆管理「保险状态=正常」一致。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#059669",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-kpi-warning",
|
||||
"index": 5,
|
||||
"title": "险种临期预警",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-kpi-warning\"]"], "fingerprint": "kpi|warning", "path": [] },
|
||||
"aiPrompt": "临期预警弹窗入口。",
|
||||
"annotationText": "任一类险种止期 ≤ 30 天。点击打开临期预警弹窗,可按险种筛选。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#d97706",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-kpi-expired",
|
||||
"index": 6,
|
||||
"title": "核心险种逾期",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-kpi-expired\"]"], "fingerprint": "kpi|expired", "path": [] },
|
||||
"aiPrompt": "交车合规禁止规则。",
|
||||
"annotationText": "交强险或商业险已到期 → 车辆管理保险状态异常,禁止交车。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#dc2626",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-kpi-unuploaded",
|
||||
"index": 7,
|
||||
"title": "核心险种待购",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-kpi-unuploaded\"]"], "fingerprint": "kpi|unuploaded", "path": [] },
|
||||
"aiPrompt": "未购买核心险种规则。",
|
||||
"annotationText": "交强险或商业险未录入/未购买 → 保险状态异常,禁止交车。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#7c3aed",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-btn-compare-mgmt",
|
||||
"index": 8,
|
||||
"title": "比价单管理",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-btn-compare-mgmt\"]"], "fingerprint": "btn|compare-mgmt", "path": [] },
|
||||
"aiPrompt": "比价单独立业务线入口。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-btn-policy-ocr",
|
||||
"index": 9,
|
||||
"title": "保单批量识别",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-btn-policy-ocr\"]"], "fingerprint": "btn|ocr", "path": [] },
|
||||
"aiPrompt": "OCR 录入通道与确认规则。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0891b2",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-btn-recogn-tasks",
|
||||
"index": 10,
|
||||
"title": "识别任务记录",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-btn-recogn-tasks\"]"], "fingerprint": "btn|tasks", "path": [] },
|
||||
"aiPrompt": "识别任务列表与确认进度。",
|
||||
"annotationText": "查看 OCR/导入任务状态、识别进度与「确认识别结果」入口。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#64748b",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-btn-batch-import",
|
||||
"index": 11,
|
||||
"title": "批量导入",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-btn-batch-import\"]"], "fingerprint": "btn|import", "path": [] },
|
||||
"aiPrompt": "Excel 批量导入规则。",
|
||||
"annotationText": "仅支持新保/续保类业务行;停保/复驶/退保行须自动跳过。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#059669",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-btn-policy-add",
|
||||
"index": 12,
|
||||
"title": "新增保单",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-btn-policy-add\"]"], "fingerprint": "btn|add", "path": [] },
|
||||
"aiPrompt": "手工逐条录入入口。",
|
||||
"annotationText": "手工新增单条保单记录,支持承保险种明细等完整字段(OCR 确认页不展示部分字段)。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#059669",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-vehicle-table",
|
||||
"index": 13,
|
||||
"title": "保险台账列表",
|
||||
"pageId": "list",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-vehicle-table\"]"], "fingerprint": "table|ledger", "path": [] },
|
||||
"aiPrompt": "列表列含义与操作列。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#334155",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-vehicle-mgmt",
|
||||
"index": 14,
|
||||
"title": "车辆保险档案",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-vehicle-mgmt\"]"], "fingerprint": "modal|vehicle-mgmt", "path": [] },
|
||||
"aiPrompt": "管理弹窗结构与 Tab。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-vehicle-mgmt-tabs",
|
||||
"index": 15,
|
||||
"title": "险种 Tab 与全周期记录",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-vehicle-mgmt-tabs\"]"], "fingerprint": "tabs|vehicle-mgmt", "path": [] },
|
||||
"aiPrompt": "全周期时间轴与分险种历史表。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-compare-mgmt",
|
||||
"index": 16,
|
||||
"title": "比价单管理弹窗",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-compare-mgmt\"]"], "fingerprint": "modal|compare-mgmt", "path": [] },
|
||||
"aiPrompt": "比价单批次列表与付费预警。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-compare-pay-alerts",
|
||||
"index": 17,
|
||||
"title": "最晚付费临期/超期",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-compare-pay-alerts\"]"], "fingerprint": "compare|pay-alert", "path": [] },
|
||||
"aiPrompt": "最晚付费日预警规则。",
|
||||
"annotationText": "最晚付费日期 ≤ 3 天视为临期;已过期为超期。用于采购前提醒,不写入保单台账。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#d97706",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-compare-editor",
|
||||
"index": 18,
|
||||
"title": "比价单编辑",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-compare-editor\"]"], "fingerprint": "modal|compare-editor", "path": [] },
|
||||
"aiPrompt": "报价、最终比价、采购审批提交流程。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#1d4ed8",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-compare-procurement",
|
||||
"index": 19,
|
||||
"title": "提交采购申请",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-compare-procurement\"]"], "fingerprint": "btn|procurement", "path": [] },
|
||||
"aiPrompt": "采购状态与工作流回写。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#1d4ed8",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-policy-recogn",
|
||||
"index": 20,
|
||||
"title": "保单识别/导入",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-policy-recogn\"]"], "fingerprint": "modal|recogn", "path": [] },
|
||||
"aiPrompt": "OCR 与导入确认页字段差异。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0891b2",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-policy-add",
|
||||
"index": 21,
|
||||
"title": "新增保单表单",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-policy-add\"]"], "fingerprint": "modal|policy-add", "path": [] },
|
||||
"aiPrompt": "手工录入字段与附件。",
|
||||
"annotationText": "支持完整保单要素、承保险种明细与附件;落库后更新对应险种台账。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#059669",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-insurance-alert",
|
||||
"index": 22,
|
||||
"title": "险种临期/逾期预警弹窗",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-insurance-alert\"]"], "fingerprint": "modal|alert", "path": [] },
|
||||
"aiPrompt": "预警列表与比价采购状态展示。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#d97706",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-policy-biz",
|
||||
"index": 23,
|
||||
"title": "停保 / 复驶 / 退保",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-policy-biz\"]"], "fingerprint": "modal|policy-biz", "path": [] },
|
||||
"aiPrompt": "业务变更表单与附件要求。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#ea580c",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
},
|
||||
{
|
||||
"id": "ipc-recogn-tasks",
|
||||
"index": 24,
|
||||
"title": "识别任务列表",
|
||||
"locator": { "selectors": ["[data-annotation-id=\"ipc-recogn-tasks\"]"], "fingerprint": "modal|recogn-tasks", "path": [] },
|
||||
"aiPrompt": "任务状态机与确认进度。",
|
||||
"annotationText": "任务含识别中/已完成;确认识别结果后写入台账。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#64748b",
|
||||
"images": [],
|
||||
"createdAt": 1740009600000,
|
||||
"updatedAt": 1740009600000
|
||||
}
|
||||
]
|
||||
},
|
||||
"markdownMap": {
|
||||
"ipc-header": "# 模块边界\n\n保险采购包含两条**相互独立**的业务线:\n\n1. **保单管理**:一车一档台账,OCR/导入/手工录入,停保/复驶/退保留痕,与车辆管理交车合规联动。\n2. **比价单**:采购前多方报价、批次管理、最晚付费预警、采购审批;**审批通过不自动写入保单台账**。\n\n右上角「查看需求说明」含完整研发 PRD。",
|
||||
"ipc-filter": "# 筛选条件\n\n| 字段 | 说明 |\n|------|------|\n| 车牌号 | 单车模糊匹配;与多车牌互斥 |\n| 多车牌 | 每行一个或逗号分隔 |\n| VIN | 车辆识别代码 |\n| 品牌/型号 | 下拉可搜 |\n| 运营状态 | 租赁/自营/库存/退出运营 |\n| 保险状态 | 正常/临期 或 异常 |\n| 保险类型 + 到期时间 | 须先选险种再选日期范围 |\n\n点击「查询」应用条件;「重置」清空。",
|
||||
"ipc-btn-compare-mgmt": "# 比价单管理\n\n独立业务线入口。支持:\n\n- 按创建时间、车牌筛选批次\n- 最晚付费临期/超期 KPI 筛选\n- 新建/编辑比价单\n- 查看采购状态(审批中/通过/驳回/撤回)\n\n**不自动同步**至保单台账。",
|
||||
"ipc-btn-policy-ocr": "# 保单批量识别(OCR)\n\n- 业务类型:保单录入、停保、复驶、退保\n- 自动识别车牌、保单号并匹配台账\n- 识别结果须**人工确认**后落库\n- OCR 确认页**不展示**承保险种明细、分项保费等(手工新增保留)",
|
||||
"ipc-vehicle-table": "# 台账列表\n\n| 列 | 说明 |\n|----|------|\n| 车牌/VIN/品牌型号 | 车辆标识 |\n| 运营状态 | 退出运营行灰显 |\n| 保险状态 | 与 KPI 口径一致 |\n| 五类险种到期日 | 副文案:临期天数/停保/退保标签 |\n| 操作·管理 | 打开车辆保险档案 |",
|
||||
"ipc-vehicle-mgmt": "# 车辆保险档案\n\n展示车辆元信息、保险状态胶囊、客户/产权方等。\n\n**保险状态**:交强险+商业险均在有效期内为正常,否则异常(禁止交车)。",
|
||||
"ipc-vehicle-mgmt-tabs": "# Tab 结构\n\n- **全周期记录**:时间轴,左新保/续保,右停保/复驶/退保\n- **分险种 Tab**:交强险、商业险、超赔、驾意、货物\n\n历史表含:导入时间、类型、保单号、保险状态、保司、日期、金额、操作。\n\n当前有效保单可办理停保/复驶/退保;归档记录只读。",
|
||||
"ipc-compare-mgmt": "# 比价单批次\n\n列表展示创建时间、创建人、购买记录数、确认金额、备注等。\n\n操作:编辑、删除(未提交审批时)、查看附件。",
|
||||
"ipc-compare-editor": "# 编辑比价单\n\n每行购买记录包含:车辆、险种、投保方式、报价列表、最终比价、最晚付费日。\n\n须完成:新增报价 → 设为最终比价 → 填写最晚付费日 → 勾选行 → 提交采购申请。",
|
||||
"ipc-compare-procurement": "# 采购审批\n\n- 状态:未提交 / 审批中 / 审批通过 / 撤回 / 审批驳回\n- 本页**只读**展示当前审批人(正式环境由工作流回写)\n- 审批通过后运营须在保单管理侧**另行录入**正式保单",
|
||||
"ipc-policy-recogn": "# 识别/导入确认\n\n上传阶段选择业务类型与险种(保单录入时)。\n\n确认页逐条核对 OCR 字段,确认后写入对应车辆险种台账并记录操作日志。",
|
||||
"ipc-insurance-alert": "# 预警弹窗\n\n- **临期**:任一类险种 ≤30 天,可按险种多选筛选\n- **核心逾期**:交强或商业已过期\n\n列表可展示关联比价单的采购状态标签。",
|
||||
"ipc-policy-biz": "# 停保 / 复驶 / 退保\n\n| 业务 | 关键字段 |\n|------|----------|\n| 停保 | 中止时间、恢复时间、新到期日、停保单附件 |\n| 复驶 | 恢复时间、新到期日、复驶单附件 |\n| 退保 | 退保时间、退还保费、退保单附件 |\n\n提交后更新险种 `policyTag` 与操作日志。"
|
||||
},
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "ipc-doc-root",
|
||||
"title": "保险采购说明",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "ipc-doc-overview",
|
||||
"title": "模块总览",
|
||||
"markdown": "# 保险采购 · 模块总览\n\n面向车队保险采购与台账管理。核心约束:**保单台账与比价单不自动双向同步**。\n\n## 与车辆管理联动\n\n- 交强 + 商业均有效 → 保险状态正常\n- 任一核心险种缺失或逾期 → 异常,**禁止交车**\n- 列表到期日取同车同险种**最新一条**记录\n\n## 验收重点\n\n1. 筛选与 KPI 口径一致\n2. 五类险种独立台账\n3. OCR 确认后落库\n4. 比价审批不回写台账\n5. 停保/复驶/退保留痕"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "ipc-link-vehicle",
|
||||
"title": "车辆管理原型",
|
||||
"href": "/prototypes/vehicle-management",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "ipc-doc-policy",
|
||||
"title": "保单管理",
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "ipc-doc-policy-entry",
|
||||
"title": "录入通道",
|
||||
"markdown": "# 录入通道\n\n1. **新增**:手工完整字段\n2. **保单批量识别**:OCR + 人工确认\n3. **批量导入**:Excel,仅新保/续保\n4. **识别任务记录**:异步任务进度与确认\n\n打开对应弹窗后,页面会出现相应标注点。"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "ipc-doc-policy-status",
|
||||
"title": "险种状态规则",
|
||||
"markdown": "# 单险种状态优先级\n\n1. 已退保\n2. 无保单号 → 未购买\n3. 停保标记 → 已停保\n4. 到期日逾期 → 逾期\n5. ≤30 天 → 临期\n6. 否则 → 正常\n\n车辆级状态仅看**交强险 + 商业险**。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "ipc-doc-compare",
|
||||
"title": "比价单",
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "ipc-doc-compare-flow",
|
||||
"title": "采购流程",
|
||||
"markdown": "# 比价采购流程\n\n1. 比价单管理 → 新建比价单\n2. 添加车辆与险种购买记录\n3. 录入多方报价并设最终比价\n4. 填写最晚付费日\n5. 保存比价单\n6. 勾选记录 → 提交采购申请\n7. 审批通过后,在保单管理录入正式保单"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "ipc-doc-compare-pay",
|
||||
"title": "付费预警",
|
||||
"markdown": "# 最晚付费日预警\n\n- **临期**:≤ 3 个自然日\n- **超期**:已过最晚付费日\n\n在比价单管理弹窗顶部 KPI 筛选对应批次。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "ipc-doc-review-tip",
|
||||
"title": "标注查看提示",
|
||||
"markdown": "# 如何查看全部标注\n\n- **列表页**:打开即可看到 KPI、筛选、工具栏、表格等标注点\n- **弹窗类**:请先点击对应按钮打开弹窗(如「管理」「比价单管理」「保单批量识别」),标注点会出现在弹窗内\n- 右侧标注工具栏可切换主题、按颜色筛选标注"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,16 @@
|
||||
* @name 保险采购
|
||||
* 自 ONE-OS web端/业务管理/保险采购.jsx 复原
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import 'antd/dist/reset.css';
|
||||
import InsuranceProcurement from './InsuranceProcurement.jsx';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const ipcTheme = {
|
||||
token: {
|
||||
@@ -37,9 +43,29 @@ const ipcTheme = {
|
||||
};
|
||||
|
||||
export default function InsuranceProcurementPage() {
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'list',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return React.createElement(
|
||||
ConfigProvider,
|
||||
{ theme: ipcTheme },
|
||||
React.createElement(InsuranceProcurement),
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(
|
||||
ConfigProvider,
|
||||
{ theme: ipcTheme },
|
||||
React.createElement(InsuranceProcurement),
|
||||
),
|
||||
React.createElement(AnnotationViewer, {
|
||||
source: annotationSourceDocument as AnnotationSourceDocument,
|
||||
options: annotationOptions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
209
src/prototypes/lease-business-ledger/.spec/requirements-prd.md
Normal file
209
src/prototypes/lease-business-ledger/.spec/requirements-prd.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 租赁业务台账 · 产品需求说明(PRD)
|
||||
|
||||
> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 台账数据 — 租赁业务台账 |
|
||||
| 目标用户 | 租赁业务客服、业务服务组主管 |
|
||||
| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 |
|
||||
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 筛选区
|
||||
|
||||
默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。
|
||||
|
||||
| 筛选项 | 说明 |
|
||||
|---|---|
|
||||
| 年份 / 月份 | 账单所属年月 |
|
||||
| 业务部门 / 业务员 | 精确匹配 |
|
||||
| 车牌号码 | 精确匹配 |
|
||||
| 客户名称 | 模糊包含 |
|
||||
| 明细状态 | 草稿 / 已确认 / 部分收款 / 已收款 |
|
||||
| 付款日期 | 日期区间 |
|
||||
|
||||
---
|
||||
|
||||
## 3. KPI 统计卡片(6 张)
|
||||
|
||||
台账条数、应收总计、实收总计、未收总计、成本总计、盈亏总计。
|
||||
|
||||
- **台账条数**:员工仅统计本人录入;主管统计全组。点击切换列表为显示全部。
|
||||
- **未收总计**:点击筛选下方列表为未收款数据。
|
||||
|
||||
### 台账条数 KPI
|
||||
|
||||
业务服务组员工只能看到自己录入的数据条数以及金额;业务服务组主管可以查看所有业务服务组人员录入的数据总和。点击可切换列表显示全部数据。
|
||||
|
||||
### 未收总计 KPI
|
||||
|
||||
点击未收总计卡片,下方列表仅展示未收款数据(状态不等于已收款)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表工具栏
|
||||
|
||||
左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 批量导入 | 下载模板并上传 Excel(原型 Toast) |
|
||||
| 导出 | 导出当前筛选结果 |
|
||||
| 标记为已确认 | 勾选草稿行后二次确认 |
|
||||
| 提交收款审批 | 勾选行提交审批 |
|
||||
|
||||
添加行已移至表格底部;列表采用下拉加载更多,不做分页。
|
||||
|
||||
### 标记为已确认
|
||||
|
||||
勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改。
|
||||
|
||||
### 添加行
|
||||
|
||||
位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制。
|
||||
|
||||
---
|
||||
|
||||
## 5. 列表字段
|
||||
|
||||
对齐 Excel「租赁车辆收入明细表」全量字段:年份、月份、业务部门(含业务员)、付款日期、车牌(含车型)、客户(含项目)、增值服务、享免政策、提车/合同/起止日期、平均天数、押金、合同标的租金、应收与收入拆分、减免、实收/未收、开票与付款信息、成本拆分、盈亏、GPS里程、减免明细、资产归属、签约公司、备注。
|
||||
|
||||
宽表支持横向滚动;序号与操作列固定。
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态流转
|
||||
|
||||
1. 新增/导入 → **草稿**(业务服务组可删改)
|
||||
2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)
|
||||
3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款**
|
||||
|
||||
---
|
||||
|
||||
## 7. 字段说明
|
||||
|
||||
### 业务部门列
|
||||
|
||||
业务部门与业务负责人合并为一列:第一行显示为业务部门,第二行显示为业务负责人。
|
||||
|
||||
### 车牌号码列
|
||||
|
||||
车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。
|
||||
|
||||
### 客户名称列
|
||||
|
||||
客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。
|
||||
|
||||
### 提车日期
|
||||
|
||||
等于交车管理中该车辆运维 E 签宝签字完成的时间。
|
||||
|
||||
### 保证金
|
||||
|
||||
与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示。
|
||||
|
||||
### 合同标的租金
|
||||
|
||||
等于该车辆在签订该合同时合同中的租金(月租金标准)。
|
||||
|
||||
### 应收租金
|
||||
|
||||
按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。
|
||||
|
||||
### 月度收入
|
||||
|
||||
租金 + 服务费 − 里程减免金额 − 其他减免金额(后两项为减项)。
|
||||
|
||||
### 月度租金
|
||||
|
||||
根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。
|
||||
|
||||
### 维保包干收入
|
||||
|
||||
合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。
|
||||
|
||||
### 保险上浮费
|
||||
|
||||
事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。
|
||||
|
||||
### 运维费(收入侧)
|
||||
|
||||
维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。
|
||||
|
||||
### 其他收入
|
||||
|
||||
手工填写,预留作为特殊情况记录使用。
|
||||
|
||||
### 实收金额
|
||||
|
||||
根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配。
|
||||
|
||||
### 未收金额
|
||||
|
||||
未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。
|
||||
|
||||
### 开票日期
|
||||
|
||||
账单开票后由财务维护,自动反写,只读。
|
||||
|
||||
### 实际付款日期
|
||||
|
||||
关联收款记录时取对应到账记录的付款日期;未关联时为空。
|
||||
|
||||
### 付款方式
|
||||
|
||||
由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。
|
||||
|
||||
### 车辆标准成本
|
||||
|
||||
固定成本,按车型明细维护,只读。
|
||||
|
||||
### 车辆实际成本
|
||||
|
||||
车辆标准成本 ×(实际天数 / 30),自动计算。
|
||||
|
||||
### 保险费
|
||||
|
||||
暂保留,不可编辑和导入;后续可能自动计算。
|
||||
|
||||
### 氢费
|
||||
|
||||
表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账)。
|
||||
|
||||
### 运维费(成本侧)
|
||||
|
||||
与收入侧运维费平进平出,自动同步。
|
||||
|
||||
### 居间费
|
||||
|
||||
手动输入。
|
||||
|
||||
### 其他(成本侧)
|
||||
|
||||
前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。
|
||||
|
||||
### GPS里程
|
||||
|
||||
从 BI 里程取账单时间段的数据,自动展示。
|
||||
|
||||
### 其他减免金额明细内容
|
||||
|
||||
手动输入,备注明细内容。
|
||||
|
||||
### 资产归属
|
||||
|
||||
显示车辆实际归属公司名称,按车辆档案自动获取。
|
||||
|
||||
### 签约公司
|
||||
|
||||
租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。
|
||||
|
||||
### 备注
|
||||
|
||||
手动输入。
|
||||
1171
src/prototypes/lease-business-ledger/annotation-source.json
Normal file
1171
src/prototypes/lease-business-ledger/annotation-source.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { AccidentRecord, LeaseLedgerRow } from '../types';
|
||||
import { formatMoney } from '../utils/ledger';
|
||||
|
||||
interface AccidentLinkModalProps {
|
||||
open: boolean;
|
||||
record: LeaseLedgerRow | null;
|
||||
accidents: AccidentRecord[];
|
||||
onClose: () => void;
|
||||
onConfirm: (recordId: string, selectedIds: string[], linkedAmount: number) => void;
|
||||
}
|
||||
|
||||
export function AccidentLinkModal({
|
||||
open,
|
||||
record,
|
||||
accidents,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: AccidentLinkModalProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredAccidents = useMemo(() => {
|
||||
if (!record) return [];
|
||||
return accidents.filter((item) => item.plateNo === record.plateNo);
|
||||
}, [record, accidents]);
|
||||
|
||||
const linkedAmount = useMemo(
|
||||
() => filteredAccidents
|
||||
.filter((item) => selected.has(item.id))
|
||||
.reduce((sum, item) => sum + item.insuranceSurcharge, 0),
|
||||
[filteredAccidents, selected],
|
||||
);
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected.size === 0) return;
|
||||
onConfirm(record.id, Array.from(selected), linkedAmount);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal ldb-receipt-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="accident-link-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="accident-link-title" className="vm-modal-title">关联事故单据</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-modal-body">
|
||||
<p className="ldb-confirm-meta">
|
||||
车牌 {record.plateNo} · 关联后自动汇总保险上浮费
|
||||
</p>
|
||||
<div className="ldb-receipt-table-wrap">
|
||||
<table className="vm-table ldb-receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ldb-col-check" aria-label="选择" />
|
||||
<th>事故单号</th>
|
||||
<th>发生日期</th>
|
||||
<th className="ldb-col-money">保险上浮费</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAccidents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="ldb-receipt-empty">暂无待关联的事故单据</td>
|
||||
</tr>
|
||||
) : filteredAccidents.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="ldb-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
aria-label={`选择 ${item.accidentNo}`}
|
||||
/>
|
||||
</td>
|
||||
<td>{item.accidentNo}</td>
|
||||
<td className="tabular-nums">{item.occurredAt}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(item.insuranceSurcharge)}</td>
|
||||
<td>{item.remark}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="ldb-link-preview">
|
||||
本次关联保险上浮费合计:<strong className="tabular-nums">{formatMoney(linkedAmount)}</strong>
|
||||
</p>
|
||||
</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" disabled={selected.size === 0} onClick={handleConfirm}>
|
||||
确认关联
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ConfirmMarkModalProps {
|
||||
open: boolean;
|
||||
count: number;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmMarkModal({ open, count, onCancel, onConfirm }: ConfirmMarkModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onCancel}>
|
||||
<div
|
||||
className="vm-modal vm-modal-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-mark-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="confirm-mark-title" className="vm-modal-title">标记为已确认</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onCancel} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-modal-body">
|
||||
<p className="ldb-confirm-text">
|
||||
确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?
|
||||
</p>
|
||||
<p className="ldb-confirm-meta">已选择 {count} 条草稿记录</p>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}>确认</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import type { LeaseLedgerFilters, LeaseLedgerRow } from '../types';
|
||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||
import { buildLeaseOptions } from '../utils/ledger';
|
||||
|
||||
const STATUS_OPTIONS = ['草稿', '已确认', '部分收款', '已收款'];
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
|
||||
interface FilterPanelProps {
|
||||
records: LeaseLedgerRow[];
|
||||
filters: LeaseLedgerFilters;
|
||||
onChange: (patch: Partial<LeaseLedgerFilters>) => void;
|
||||
onReset: () => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const yearOptions = useMemo(() => buildLeaseOptions(records, 'year'), [records]);
|
||||
const monthOptions = useMemo(() => buildLeaseOptions(records, 'month'), [records]);
|
||||
const deptOptions = useMemo(() => buildLeaseOptions(records, 'dept'), [records]);
|
||||
const salesmanOptions = useMemo(() => buildLeaseOptions(records, 'salesman'), [records]);
|
||||
const plateOptions = useMemo(() => buildLeaseOptions(records, 'plateNo'), [records]);
|
||||
|
||||
const fields = [
|
||||
{ label: '年份', control: <FilterPickerField value={filters.year} options={yearOptions} placeholder="全部年份" ariaLabel="年份" onChange={(year) => onChange({ year })} /> },
|
||||
{ label: '月份', control: <FilterPickerField value={filters.month} options={monthOptions} placeholder="全部月份" ariaLabel="月份" onChange={(month) => onChange({ month })} /> },
|
||||
{ label: '业务部门', control: <FilterPickerField value={filters.dept} options={deptOptions} placeholder="全部部门" ariaLabel="业务部门" onChange={(dept) => onChange({ dept })} /> },
|
||||
{ label: '业务员', control: <FilterPickerField value={filters.salesman} options={salesmanOptions} placeholder="全部业务员" ariaLabel="业务员" onChange={(salesman) => onChange({ salesman })} /> },
|
||||
{ label: '车牌号码', control: <FilterPickerField value={filters.plateNo} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNo) => onChange({ plateNo })} /> },
|
||||
{ label: '客户名称', control: <input className="vm-input" value={filters.customerName} placeholder="输入客户名称" aria-label="客户名称" onChange={(e) => onChange({ customerName: e.target.value })} /> },
|
||||
{ label: '明细状态', control: <FilterPickerField value={filters.status} options={STATUS_OPTIONS} placeholder="全部状态" ariaLabel="明细状态" onChange={(status) => onChange({ status })} /> },
|
||||
{ label: '付款日期', control: (
|
||||
<div className="ldb-date-range">
|
||||
<input type="date" className="vm-input" value={filters.paymentFrom} aria-label="付款开始日期" onChange={(e) => onChange({ paymentFrom: e.target.value })} />
|
||||
<span aria-hidden>—</span>
|
||||
<input type="date" className="vm-input" value={filters.paymentTo} aria-label="付款结束日期" onChange={(e) => onChange({ paymentTo: e.target.value })} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const visible = expanded ? fields : fields.slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card" data-annotation-id="lbl-filter">
|
||||
<header className="vm-filter-header">
|
||||
<h1 className="ldb-page-title">租赁业务台账</h1>
|
||||
</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 > DEFAULT_VISIBLE && (
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
<ChevronDown size={14} aria-hidden style={{ transform: expanded ? 'rotate(180deg)' : undefined }} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, CircleHelp } from 'lucide-react';
|
||||
import type { AccidentRecord, LeaseLedgerRow, MaintenanceOrder } from '../types';
|
||||
import {
|
||||
calcMonthlyIncome,
|
||||
calcUnreceived,
|
||||
canStaffDeleteRow,
|
||||
canStaffEditRow,
|
||||
formatMoney,
|
||||
needsAccidentLinkWarning,
|
||||
needsMaintenanceLinkWarning,
|
||||
} from '../utils/ledger';
|
||||
import { LEASE_TABLE_COLUMNS, STATUS_CLASS } from './tableColumns';
|
||||
|
||||
interface LeaseLedgerTableProps {
|
||||
records: LeaseLedgerRow[];
|
||||
selectedIds: Set<string>;
|
||||
maintenanceOrders: MaintenanceOrder[];
|
||||
accidentRecords: AccidentRecord[];
|
||||
onToggleRow: (id: string, checked: boolean) => void;
|
||||
onToggleAll: (checked: boolean) => void;
|
||||
onEdit: (record: LeaseLedgerRow) => void;
|
||||
onDelete: (record: LeaseLedgerRow) => void;
|
||||
onLinkReceipt: (record: LeaseLedgerRow) => void;
|
||||
onLinkMaintenance: (record: LeaseLedgerRow) => void;
|
||||
onLinkAccident: (record: LeaseLedgerRow) => void;
|
||||
onOtherIncomeChange: (recordId: string, value: number) => void;
|
||||
onOtherCostChange: (recordId: string, value: number) => void;
|
||||
onBrokerageFeeChange: (recordId: string, value: number) => void;
|
||||
onRemarkChange: (recordId: string, value: string) => void;
|
||||
onDeductionDetailChange: (recordId: string, value: string) => void;
|
||||
}
|
||||
|
||||
function IncomeWarning({ title, onClick }: { title: string; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="ldb-income-warn" title={title} aria-label={title} onClick={onClick}>
|
||||
<AlertTriangle size={14} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderWithTip({ title, tip }: { title: string; tip?: string }) {
|
||||
if (!tip) return <>{title}</>;
|
||||
return (
|
||||
<span className="ldb-th-with-tip">
|
||||
{title}
|
||||
<span className="ldb-th-tip" tabIndex={0} aria-label={`${title}说明`}>
|
||||
<CircleHelp size={14} aria-hidden />
|
||||
<span className="ldb-kpi-tooltip" role="tooltip">{tip}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MoneyInput({
|
||||
value,
|
||||
placeholder,
|
||||
required,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
ariaLabel: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className={`vm-input ldb-income-input tabular-nums ${required && !value ? 'ldb-input-required' : ''}`}
|
||||
value={value || ''}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-required={required}
|
||||
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
value,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
ariaLabel: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="vm-input ldb-text-input"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCell(
|
||||
row: LeaseLedgerRow,
|
||||
col: (typeof LEASE_TABLE_COLUMNS)[number],
|
||||
index: number,
|
||||
ctx: {
|
||||
maintenanceOrders: MaintenanceOrder[];
|
||||
accidentRecords: AccidentRecord[];
|
||||
canEdit: boolean;
|
||||
onLinkMaintenance: (record: LeaseLedgerRow) => void;
|
||||
onLinkAccident: (record: LeaseLedgerRow) => void;
|
||||
onOtherIncomeChange: (recordId: string, value: number) => void;
|
||||
onOtherCostChange: (recordId: string, value: number) => void;
|
||||
onBrokerageFeeChange: (recordId: string, value: number) => void;
|
||||
onRemarkChange: (recordId: string, value: string) => void;
|
||||
onDeductionDetailChange: (recordId: string, value: string) => void;
|
||||
},
|
||||
) {
|
||||
switch (col.render) {
|
||||
case 'status':
|
||||
return <span className={`ldb-status-tag ${STATUS_CLASS[row.status]}`}>{row.status}</span>;
|
||||
case 'stack-dept':
|
||||
return (
|
||||
<div className="ldb-cell-stack">
|
||||
<span className="ldb-cell-primary">{row.dept}</span>
|
||||
<span className="ldb-cell-secondary">{row.salesman}</span>
|
||||
</div>
|
||||
);
|
||||
case 'stack-plate': {
|
||||
const vehicleLine = row.vehicleType || [row.vehicleBrand, row.vehicleModel].filter(Boolean).join('');
|
||||
return (
|
||||
<div className="ldb-cell-stack">
|
||||
<span className="ldb-cell-primary tabular-nums">{row.plateNo}</span>
|
||||
<span className="ldb-cell-secondary" title={vehicleLine}>{vehicleLine}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'stack-customer':
|
||||
return (
|
||||
<div className="ldb-cell-stack">
|
||||
<span className="ldb-cell-primary" title={row.customerName}>{row.customerName}</span>
|
||||
<span className="ldb-cell-secondary" title={row.projectName}>{row.projectName}</span>
|
||||
</div>
|
||||
);
|
||||
case 'deposit':
|
||||
if (row.deposit === '/' || row.deposit === '') return '—';
|
||||
return typeof row.deposit === 'number' ? formatMoney(row.deposit) : String(row.deposit);
|
||||
case 'asset-owner':
|
||||
return <span title={row.assetOwner}>{row.assetOwner || '—'}</span>;
|
||||
case 'signing-company':
|
||||
return <span className="ldb-cell-readonly" title="租赁合同甲方自动获取">{row.signingCompany || '—'}</span>;
|
||||
case 'monthly-income':
|
||||
return (
|
||||
<span className="ldb-computed-val" title="租金 + 服务费 − 里程减免 − 其他减免">
|
||||
{formatMoney(calcMonthlyIncome(row))}
|
||||
</span>
|
||||
);
|
||||
case 'maintenance-income':
|
||||
return (
|
||||
<span
|
||||
className={row.maintenancePackageEnabled ? 'ldb-computed-val' : 'ldb-cell-readonly'}
|
||||
title={
|
||||
row.maintenancePackageEnabled
|
||||
? `单价 ${row.maintenanceUnitPricePerKm} 元/km × ${row.mileage} km`
|
||||
: '合同未勾选维保包干,默认 0 不可编辑'
|
||||
}
|
||||
>
|
||||
{formatMoney(row.maintenanceIncome)}
|
||||
</span>
|
||||
);
|
||||
case 'insurance-surcharge': {
|
||||
const warn = needsAccidentLinkWarning(row, ctx.accidentRecords);
|
||||
return (
|
||||
<div className="ldb-income-cell">
|
||||
{warn && (
|
||||
<IncomeWarning title="有未关联的事故单据,请点击关联" onClick={() => ctx.onLinkAccident(row)} />
|
||||
)}
|
||||
<span className={warn ? 'ldb-income-pending' : ''}>{formatMoney(row.insuranceSurcharge)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'ops-fee-income': {
|
||||
const warn = needsMaintenanceLinkWarning(row, ctx.maintenanceOrders);
|
||||
return (
|
||||
<div className="ldb-income-cell">
|
||||
{warn && (
|
||||
<IncomeWarning title="维修/保养为客户承担,请关联维修保养单" onClick={() => ctx.onLinkMaintenance(row)} />
|
||||
)}
|
||||
<span className={warn ? 'ldb-income-pending' : ''}>{formatMoney(row.opsFeeIncome)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'ops-fee-cost':
|
||||
return (
|
||||
<span className="ldb-computed-val" title="与收入侧运维费平进平出">
|
||||
{formatMoney(row.opsFeeCost)}
|
||||
</span>
|
||||
);
|
||||
case 'other-income':
|
||||
if (!ctx.canEdit) return row.otherIncome ? formatMoney(row.otherIncome) : '—';
|
||||
return (
|
||||
<MoneyInput
|
||||
value={row.otherIncome}
|
||||
placeholder="手工填写"
|
||||
ariaLabel={`${row.plateNo} 其他收入`}
|
||||
onChange={(v) => ctx.onOtherIncomeChange(row.id, v)}
|
||||
/>
|
||||
);
|
||||
case 'other-cost':
|
||||
if (!ctx.canEdit) return row.otherCost ? formatMoney(row.otherCost) : '—';
|
||||
return (
|
||||
<MoneyInput
|
||||
value={row.otherCost}
|
||||
placeholder={row.otherIncome > 0 ? '必填' : '成本侧其他'}
|
||||
required={row.otherIncome > 0}
|
||||
ariaLabel={`${row.plateNo} 其他成本`}
|
||||
onChange={(v) => ctx.onOtherCostChange(row.id, v)}
|
||||
/>
|
||||
);
|
||||
case 'brokerage-fee':
|
||||
if (!ctx.canEdit) return row.brokerageFee ? formatMoney(row.brokerageFee) : '—';
|
||||
return (
|
||||
<MoneyInput
|
||||
value={row.brokerageFee}
|
||||
placeholder="手工填写"
|
||||
ariaLabel={`${row.plateNo} 居间费`}
|
||||
onChange={(v) => ctx.onBrokerageFeeChange(row.id, v)}
|
||||
/>
|
||||
);
|
||||
case 'received-amount':
|
||||
return (
|
||||
<span title={row.linkedReceiptIds?.length ? `已关联 ${row.linkedReceiptIds.length} 条收款明细` : '关联收款记录后自动归集'}>
|
||||
{formatMoney(row.receivedAmount)}
|
||||
{row.linkedReceiptIds?.length > 0 && (
|
||||
<span className="ldb-linked-badge">{row.linkedReceiptIds.length}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
case 'invoice-date':
|
||||
return <span className="ldb-cell-readonly tabular-nums">{row.invoiceDate || '—'}</span>;
|
||||
case 'vehicle-standard-cost':
|
||||
return (
|
||||
<span className="ldb-cell-readonly" title="车型明细维护的固定标准成本">
|
||||
{formatMoney(row.vehicleStandardCost)}
|
||||
</span>
|
||||
);
|
||||
case 'vehicle-actual-cost':
|
||||
return (
|
||||
<span className="ldb-computed-val" title={`标准成本 × (${row.avgDays}/30)`}>
|
||||
{formatMoney(row.vehicleActualCost)}
|
||||
</span>
|
||||
);
|
||||
case 'insurance-readonly':
|
||||
return (
|
||||
<span className="ldb-cell-readonly" title="暂不可编辑与导入,后续可能自动计算">
|
||||
{row.insuranceFee ? formatMoney(row.insuranceFee) : '—'}
|
||||
</span>
|
||||
);
|
||||
case 'hydrogen-fee':
|
||||
return (
|
||||
<span
|
||||
className={row.hydrogenBorneBy === '我司承担' ? 'ldb-computed-val' : 'ldb-cell-readonly'}
|
||||
title={row.hydrogenBorneBy === '我司承担' ? '我司承担氢费账单期汇总' : '客户承担或无氢费'}
|
||||
>
|
||||
{row.hydrogenFee ? formatMoney(row.hydrogenFee) : '—'}
|
||||
</span>
|
||||
);
|
||||
case 'gps-mileage':
|
||||
return (
|
||||
<span className="ldb-computed-val tabular-nums" title="BI 里程 · 账单时间段">
|
||||
{row.gpsMileage || '—'}
|
||||
</span>
|
||||
);
|
||||
case 'deduction-detail':
|
||||
if (!ctx.canEdit) return row.otherDeductionDetail || '—';
|
||||
return (
|
||||
<TextInput
|
||||
value={row.otherDeductionDetail}
|
||||
placeholder="减免明细"
|
||||
ariaLabel={`${row.plateNo} 其他减免明细`}
|
||||
onChange={(v) => ctx.onDeductionDetailChange(row.id, v)}
|
||||
/>
|
||||
);
|
||||
case 'remark-text':
|
||||
if (!ctx.canEdit) return row.remark || '—';
|
||||
return (
|
||||
<TextInput
|
||||
value={row.remark}
|
||||
placeholder="备注"
|
||||
ariaLabel={`${row.plateNo} 备注`}
|
||||
onChange={(v) => ctx.onRemarkChange(row.id, v)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (col.key === 'unreceived') return formatMoney(calcUnreceived(row));
|
||||
|
||||
if (col.key === 'profitLoss') {
|
||||
const val = row.profitLoss;
|
||||
return (
|
||||
<span className={val >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss'}>
|
||||
{formatMoney(val)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const raw = col.key === '_index' ? index + 1 : row[col.key as keyof LeaseLedgerRow];
|
||||
if (col.money && typeof raw === 'number') return formatMoney(raw);
|
||||
if (raw === '' || raw === null || raw === undefined) return '—';
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
export function LeaseLedgerTable({
|
||||
records,
|
||||
selectedIds,
|
||||
maintenanceOrders,
|
||||
accidentRecords,
|
||||
onToggleRow,
|
||||
onToggleAll,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onLinkReceipt,
|
||||
onLinkMaintenance,
|
||||
onLinkAccident,
|
||||
onOtherIncomeChange,
|
||||
onOtherCostChange,
|
||||
onBrokerageFeeChange,
|
||||
onRemarkChange,
|
||||
onDeductionDetailChange,
|
||||
}: LeaseLedgerTableProps) {
|
||||
const allSelected = records.length > 0 && records.every((r) => selectedIds.has(r.id));
|
||||
|
||||
if (!records.length) {
|
||||
return <div className="vm-empty" data-annotation-id="lbl-table"><p>暂无符合条件的台账记录</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vm-table-wrap ldb-table-wrap" data-annotation-id="lbl-table">
|
||||
<table className="vm-table ldb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ldb-col-check sticky-col-left">
|
||||
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
||||
</th>
|
||||
{LEASE_TABLE_COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
col.money ? 'ldb-col-money' : '',
|
||||
col.className,
|
||||
col.sticky === 'left' ? 'sticky-col-left' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
data-annotation-id={col.annotationId}
|
||||
title={
|
||||
col.key === 'opsFeeIncome'
|
||||
? '收入侧运维费'
|
||||
: col.key === 'opsFeeCost'
|
||||
? '成本侧运维费,与收入侧平进平出'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<HeaderWithTip title={col.title} tip={col.headerTip} />
|
||||
</th>
|
||||
))}
|
||||
<th className="sticky-col-right ldb-col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((row, index) => {
|
||||
const canEdit = canStaffEditRow(row);
|
||||
const canDelete = canStaffDeleteRow(row);
|
||||
const showReceiptLink = row.status === '已确认' || row.status === '部分收款';
|
||||
const cellCtx = {
|
||||
maintenanceOrders,
|
||||
accidentRecords,
|
||||
canEdit,
|
||||
onLinkMaintenance,
|
||||
onLinkAccident,
|
||||
onOtherIncomeChange,
|
||||
onOtherCostChange,
|
||||
onBrokerageFeeChange,
|
||||
onRemarkChange,
|
||||
onDeductionDetailChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td className="ldb-col-check sticky-col-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(row.id)}
|
||||
onChange={(e) => onToggleRow(row.id, e.target.checked)}
|
||||
aria-label={`选择第 ${index + 1} 行`}
|
||||
/>
|
||||
</td>
|
||||
{LEASE_TABLE_COLUMNS.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={[
|
||||
col.money ? 'ldb-col-money tabular-nums' : '',
|
||||
col.className,
|
||||
col.sticky === 'left' ? 'sticky-col-left' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{renderCell(row, col, index, cellCtx)}
|
||||
</td>
|
||||
))}
|
||||
<td className="sticky-col-right ldb-col-actions">
|
||||
{showReceiptLink && (
|
||||
<>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onLinkReceipt(row)}>
|
||||
关联收款记录
|
||||
</button>
|
||||
<span className="ldb-action-sep" aria-hidden>|</span>
|
||||
</>
|
||||
)}
|
||||
{needsMaintenanceLinkWarning(row, maintenanceOrders) && (
|
||||
<>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onLinkMaintenance(row)}>
|
||||
关联维保
|
||||
</button>
|
||||
<span className="ldb-action-sep" aria-hidden>|</span>
|
||||
</>
|
||||
)}
|
||||
{needsAccidentLinkWarning(row, accidentRecords) && (
|
||||
<>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onLinkAccident(row)}>
|
||||
关联事故
|
||||
</button>
|
||||
<span className="ldb-action-sep" aria-hidden>|</span>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="vm-btn vm-btn-link" disabled={!canEdit} onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</button>
|
||||
<span className="ldb-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link ldb-link-danger" disabled={!canDelete} onClick={() => onDelete(row)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
import type { LeaseKpiSummary, ListViewMode } from '../types';
|
||||
import { formatMoney } from '../utils/ledger';
|
||||
|
||||
const CARDS: Array<{
|
||||
key: keyof LeaseKpiSummary;
|
||||
title: string;
|
||||
desc: string;
|
||||
money?: boolean;
|
||||
clickable?: boolean;
|
||||
viewMode?: ListViewMode;
|
||||
}> = [
|
||||
{
|
||||
key: 'count',
|
||||
title: '台账条数',
|
||||
desc: '业务服务组员工仅统计本人录入;主管统计全组汇总。点击可切换列表为显示全部数据。',
|
||||
clickable: true,
|
||||
viewMode: 'all',
|
||||
},
|
||||
{ key: 'receivableTotal', title: '应收总计', desc: '当前筛选范围内应收合计之和', money: true },
|
||||
{ key: 'receivedAmount', title: '实收总计', desc: '当前筛选范围内实收金额之和', money: true },
|
||||
{
|
||||
key: 'unreceived',
|
||||
title: '未收总计',
|
||||
desc: '当前筛选范围内未收金额之和。点击可筛选下方列表仅显示未收款数据。',
|
||||
money: true,
|
||||
clickable: true,
|
||||
viewMode: 'unpaid',
|
||||
},
|
||||
{ key: 'totalCost', title: '成本总计', desc: '当前筛选范围内总成本之和', money: true },
|
||||
{ key: 'profitLoss', title: '盈亏总计', desc: '当前筛选范围内盈亏之和', money: true },
|
||||
];
|
||||
|
||||
interface LedgerKpiRowProps {
|
||||
summary: LeaseKpiSummary;
|
||||
listViewMode: ListViewMode;
|
||||
onListViewChange: (mode: ListViewMode) => void;
|
||||
}
|
||||
|
||||
export function LedgerKpiRow({ summary, listViewMode, onListViewChange }: LedgerKpiRowProps) {
|
||||
return (
|
||||
<div className="ldb-kpi-row" data-annotation-id="lbl-kpi">
|
||||
{CARDS.map((card) => {
|
||||
const raw = summary[card.key];
|
||||
const value = card.money ? formatMoney(raw) : String(raw);
|
||||
const profitClass = card.key === 'profitLoss'
|
||||
? (summary.profitLoss >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss')
|
||||
: '';
|
||||
const isActive = card.clickable && card.viewMode === listViewMode;
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<span className="ldb-kpi-tip" tabIndex={0} aria-label={`${card.title}说明`}>
|
||||
<CircleHelp size={14} aria-hidden />
|
||||
<span className="ldb-kpi-tooltip" role="tooltip">{card.desc}</span>
|
||||
</span>
|
||||
<span className="ldb-kpi-label">{card.title}</span>
|
||||
<span className={`ldb-kpi-val tabular-nums ${profitClass}`}>{value}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (card.clickable && card.viewMode) {
|
||||
return (
|
||||
<button
|
||||
key={card.key}
|
||||
type="button"
|
||||
className={`ldb-kpi-card ldb-kpi-card--clickable ${isActive ? 'ldb-kpi-card--active' : ''}`}
|
||||
data-annotation-id={card.key === 'count' ? 'lbl-kpi-count' : 'lbl-kpi-unreceived'}
|
||||
onClick={() => onListViewChange(card.viewMode!)}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={card.key} className="ldb-kpi-card">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { LeaseLedgerRow, MaintenanceOrder } from '../types';
|
||||
import { formatMoney } from '../utils/ledger';
|
||||
|
||||
interface MaintenanceLinkModalProps {
|
||||
open: boolean;
|
||||
record: LeaseLedgerRow | null;
|
||||
orders: MaintenanceOrder[];
|
||||
onClose: () => void;
|
||||
onConfirm: (recordId: string, selectedIds: string[], linkedAmount: number) => void;
|
||||
}
|
||||
|
||||
export function MaintenanceLinkModal({
|
||||
open,
|
||||
record,
|
||||
orders,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MaintenanceLinkModalProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (!record) return [];
|
||||
return orders.filter((item) => item.plateNo === record.plateNo && item.bearBy === '客户承担');
|
||||
}, [record, orders]);
|
||||
|
||||
const linkedAmount = useMemo(
|
||||
() => filteredOrders.filter((item) => selected.has(item.id)).reduce((sum, item) => sum + item.amount, 0),
|
||||
[filteredOrders, selected],
|
||||
);
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected.size === 0) return;
|
||||
onConfirm(record.id, Array.from(selected), linkedAmount);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal ldb-receipt-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="maintenance-link-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="maintenance-link-title" className="vm-modal-title">关联维修/保养记录</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-modal-body">
|
||||
<p className="ldb-confirm-meta">
|
||||
车牌 {record.plateNo} · 承担方为客户承担的单据,关联后自动计入运维费收入
|
||||
</p>
|
||||
<div className="ldb-receipt-table-wrap">
|
||||
<table className="vm-table ldb-receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ldb-col-check" aria-label="选择" />
|
||||
<th>单号</th>
|
||||
<th>类型</th>
|
||||
<th>完成日期</th>
|
||||
<th className="ldb-col-money">金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="ldb-receipt-empty">暂无待关联的维修/保养单</td>
|
||||
</tr>
|
||||
) : filteredOrders.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="ldb-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
aria-label={`选择 ${item.orderNo}`}
|
||||
/>
|
||||
</td>
|
||||
<td>{item.orderNo}</td>
|
||||
<td>{item.type}</td>
|
||||
<td className="tabular-nums">{item.completedAt}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(item.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="ldb-link-preview">
|
||||
本次关联运维费合计:<strong className="tabular-nums">{formatMoney(linkedAmount)}</strong>
|
||||
</p>
|
||||
</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" disabled={selected.size === 0} onClick={handleConfirm}>
|
||||
确认关联
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { LeaseLedgerRow, ReceiptRecord } from '../types';
|
||||
import { calcUnreceived, formatMoney } from '../utils/ledger';
|
||||
|
||||
interface ReceiptLinkModalProps {
|
||||
open: boolean;
|
||||
record: LeaseLedgerRow | null;
|
||||
receipts: ReceiptRecord[];
|
||||
onClose: () => void;
|
||||
onConfirm: (recordId: string, selectedIds: string[], linkedAmount: number, allocations: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
export function ReceiptLinkModal({
|
||||
open,
|
||||
record,
|
||||
receipts,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ReceiptLinkModalProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [allocations, setAllocations] = useState<Record<string, number>>({});
|
||||
|
||||
const filteredReceipts = useMemo(() => {
|
||||
if (!record) return [];
|
||||
return receipts.filter((item) => item.customerName === record.customerName);
|
||||
}, [record, receipts]);
|
||||
|
||||
const linkedAmount = useMemo(
|
||||
() => Object.values(allocations).reduce((sum, v) => sum + v, 0),
|
||||
[allocations],
|
||||
);
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const toggle = (id: string, amount: number) => {
|
||||
if (selected.has(id)) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
setAllocations((prev) => {
|
||||
const copy = { ...prev };
|
||||
delete copy[id];
|
||||
return copy;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelected((prev) => new Set(prev).add(id));
|
||||
setAllocations((prev) => {
|
||||
const used = Object.values(prev).reduce((sum, v) => sum + v, 0);
|
||||
const remaining = Math.max(0, record.receivableTotal - used);
|
||||
return { ...prev, [id]: Math.min(amount, remaining) };
|
||||
});
|
||||
};
|
||||
|
||||
const updateAllocation = (id: string, value: number, max: number) => {
|
||||
setAllocations((prev) => ({ ...prev, [id]: Math.min(Math.max(0, value), max) }));
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected.size === 0) return;
|
||||
onConfirm(record.id, Array.from(selected), linkedAmount, allocations);
|
||||
setSelected(new Set());
|
||||
setAllocations({});
|
||||
};
|
||||
|
||||
const unreceived = calcUnreceived(record);
|
||||
const overflow = Math.max(0, Math.round((linkedAmount - record.receivableTotal) * 100) / 100);
|
||||
const previewStatus = linkedAmount >= record.receivableTotal
|
||||
? '已收款'
|
||||
: linkedAmount > 0
|
||||
? '部分收款'
|
||||
: '已确认';
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal ldb-receipt-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="receipt-link-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="receipt-link-title" className="vm-modal-title">关联收款记录</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-modal-body">
|
||||
<div className="ldb-receipt-summary">
|
||||
<span>应收合计 <strong className="tabular-nums">{formatMoney(record.receivableTotal)}</strong></span>
|
||||
<span>当前未收 <strong className="tabular-nums">{formatMoney(unreceived)}</strong></span>
|
||||
<span>计入本账单 <strong className="tabular-nums">{formatMoney(Math.min(linkedAmount, record.receivableTotal))}</strong></span>
|
||||
<span>预计状态 <strong>{previewStatus}</strong></span>
|
||||
</div>
|
||||
{overflow > 0 && (
|
||||
<p className="ldb-overflow-hint">
|
||||
收款合计超出应收 {formatMoney(overflow)},多出部分显示在对应收款单据下,请自行分配计入金额。
|
||||
</p>
|
||||
)}
|
||||
<div className="ldb-receipt-table-wrap">
|
||||
<table className="vm-table ldb-receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ldb-col-check" aria-label="选择" />
|
||||
<th>到账时间</th>
|
||||
<th>客户名称</th>
|
||||
<th className="ldb-col-money">到账金额</th>
|
||||
<th className="ldb-col-money">计入本账单</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredReceipts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="ldb-receipt-empty">暂无匹配的到账记录</td>
|
||||
</tr>
|
||||
) : filteredReceipts.map((item) => {
|
||||
const checked = selected.has(item.id);
|
||||
const alloc = allocations[item.id] ?? 0;
|
||||
const overflowAmt = checked ? Math.max(0, item.amount - alloc) : 0;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td className="ldb-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(item.id, item.amount)}
|
||||
aria-label={`选择到账记录 ${item.arrivalTime}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="tabular-nums">{item.arrivalTime}</td>
|
||||
<td>{item.customerName}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(item.amount)}</td>
|
||||
<td className="ldb-col-money">
|
||||
{checked ? (
|
||||
<input
|
||||
type="number"
|
||||
className="vm-input ldb-alloc-input tabular-nums"
|
||||
value={alloc || ''}
|
||||
min={0}
|
||||
max={item.amount}
|
||||
step={0.01}
|
||||
aria-label={`${item.arrivalTime} 计入金额`}
|
||||
onChange={(e) => updateAllocation(item.id, Number(e.target.value) || 0, item.amount)}
|
||||
/>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{item.remark}
|
||||
{overflowAmt > 0 && (
|
||||
<span className="ldb-overflow-tag">余 {formatMoney(overflowAmt)} 待分配</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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" disabled={selected.size === 0} onClick={handleConfirm}>
|
||||
确认关联
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { LeaseDetailStatus } from './types';
|
||||
|
||||
/** 宽表列定义(对齐 Excel「租赁车辆收入明细表」) */
|
||||
export interface LeaseTableColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
money?: boolean;
|
||||
date?: boolean;
|
||||
sticky?: 'left' | 'right';
|
||||
className?: string;
|
||||
annotationId?: string;
|
||||
headerTip?: string;
|
||||
render?:
|
||||
| 'stack-dept' | 'stack-plate' | 'stack-customer' | 'status' | 'deposit'
|
||||
| 'asset-owner' | 'signing-company'
|
||||
| 'monthly-income' | 'maintenance-income' | 'insurance-surcharge' | 'ops-fee-income' | 'other-income'
|
||||
| 'received-amount' | 'invoice-date' | 'vehicle-standard-cost' | 'vehicle-actual-cost'
|
||||
| 'insurance-readonly' | 'hydrogen-fee' | 'ops-fee-cost'
|
||||
| 'brokerage-fee' | 'other-cost' | 'gps-mileage'
|
||||
| 'deduction-detail' | 'remark-text' | 'text';
|
||||
}
|
||||
|
||||
export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [
|
||||
{ key: '_index', title: '序号', sticky: 'left', className: 'tabular-nums' },
|
||||
{ key: 'status', title: '状态', render: 'status', annotationId: 'lbl-col-status' },
|
||||
{ key: 'maintainerName', title: '维护人' },
|
||||
{ key: 'year', title: '年份', className: 'tabular-nums' },
|
||||
{ key: 'month', title: '月份', className: 'tabular-nums' },
|
||||
{ key: 'dept', title: '业务部门', render: 'stack-dept', annotationId: 'lbl-col-dept' },
|
||||
{ key: 'paymentDate', title: '付款日期', date: true },
|
||||
{ key: 'plateNo', title: '车牌号码', render: 'stack-plate', annotationId: 'lbl-col-plate' },
|
||||
{ key: 'customerName', title: '客户名称', render: 'stack-customer', annotationId: 'lbl-col-customer' },
|
||||
{ key: 'valueAddedService', title: '增值服务' },
|
||||
{ key: 'exemptionPolicy', title: '享免政策' },
|
||||
{ key: 'pickupDate', title: '提车日期', date: true, annotationId: 'lbl-col-pickup-date' },
|
||||
{ key: 'contractStartDate', title: '合同生效日期', date: true },
|
||||
{ key: 'contractEndDate', title: '合同到期日期', date: true },
|
||||
{ key: 'periodStartDate', title: '起始日期', date: true },
|
||||
{ key: 'returnDate', title: '退车日期', date: true },
|
||||
{ key: 'avgDays', title: '平均天数', className: 'tabular-nums' },
|
||||
{ key: 'deposit', title: '保证金', render: 'deposit', annotationId: 'lbl-col-deposit' },
|
||||
{ key: 'contractRent', title: '合同标的租金', money: true, annotationId: 'lbl-col-contract-rent' },
|
||||
{ key: 'receivableTotal', title: '应收合计', money: true },
|
||||
{ key: 'receivableRent', title: '应收租金', money: true, annotationId: 'lbl-col-receivable-rent' },
|
||||
{ key: 'monthlyIncome', title: '月度收入', money: true, render: 'monthly-income', annotationId: 'lbl-col-monthly-income' },
|
||||
{ key: 'monthlyRent', title: '月度租金', money: true, annotationId: 'lbl-col-monthly-rent' },
|
||||
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, render: 'maintenance-income', annotationId: 'lbl-col-maintenance-income' },
|
||||
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, render: 'insurance-surcharge', annotationId: 'lbl-col-insurance-surcharge' },
|
||||
{ key: 'opsFeeIncome', title: '运维费', money: true, className: 'ldb-col-ops-income', render: 'ops-fee-income', annotationId: 'lbl-col-ops-income' },
|
||||
{ key: 'otherIncome', title: '其他收入', money: true, render: 'other-income', annotationId: 'lbl-col-other-income' },
|
||||
{ key: 'mileageDeduction', title: '里程减免金额', money: true },
|
||||
{ key: 'otherDeduction', title: '其他减免金额', money: true },
|
||||
{ key: 'receivedAmount', title: '实收金额', money: true, render: 'received-amount', annotationId: 'lbl-col-received-amount' },
|
||||
{ key: 'unreceived', title: '未收金额', money: true, annotationId: 'lbl-col-unreceived' },
|
||||
{ key: 'invoiceDate', title: '开票日期', date: true, render: 'invoice-date', annotationId: 'lbl-col-invoice-date' },
|
||||
{ key: 'actualPaymentDate', title: '实际付款日期', date: true, annotationId: 'lbl-col-actual-payment' },
|
||||
{ key: 'paymentMethod', title: '付款方式', annotationId: 'lbl-col-payment-method' },
|
||||
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, render: 'vehicle-standard-cost', annotationId: 'lbl-col-vehicle-standard-cost' },
|
||||
{ key: 'vehicleActualCost', title: '车辆实际成本', money: true, render: 'vehicle-actual-cost', annotationId: 'lbl-col-vehicle-actual-cost' },
|
||||
{ key: 'insuranceFee', title: '保险费', money: true, render: 'insurance-readonly', annotationId: 'lbl-col-insurance-fee' },
|
||||
{
|
||||
key: 'hydrogenFee',
|
||||
title: '氢费',
|
||||
money: true,
|
||||
render: 'hydrogen-fee',
|
||||
annotationId: 'lbl-col-hydrogen-fee',
|
||||
headerTip: '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)',
|
||||
},
|
||||
{ key: 'opsFeeCost', title: '运维费', money: true, className: 'ldb-col-ops-cost', render: 'ops-fee-cost', annotationId: 'lbl-col-ops-cost' },
|
||||
{ key: 'brokerageFee', title: '居间费', money: true, render: 'brokerage-fee', annotationId: 'lbl-col-brokerage-fee' },
|
||||
{ key: 'otherCost', title: '其他', money: true, render: 'other-cost', annotationId: 'lbl-col-other-cost' },
|
||||
{ key: 'totalCost', title: '总成本', money: true },
|
||||
{ key: 'profitLoss', title: '盈亏', money: true },
|
||||
{ key: 'gpsMileage', title: 'GPS里程', render: 'gps-mileage', annotationId: 'lbl-col-gps-mileage' },
|
||||
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', render: 'deduction-detail', annotationId: 'lbl-col-deduction-detail' },
|
||||
{ key: 'assetOwner', title: '资产归属', render: 'asset-owner', annotationId: 'lbl-col-asset-ownership' },
|
||||
{ key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', render: 'signing-company', annotationId: 'lbl-col-signing-company' },
|
||||
{ key: 'remark', title: '备注', className: 'ldb-col-remark', render: 'remark-text', annotationId: 'lbl-col-remark' },
|
||||
];
|
||||
|
||||
export const STATUS_CLASS: Record<LeaseDetailStatus, string> = {
|
||||
草稿: 'ldb-status--draft',
|
||||
已确认: 'ldb-status--confirmed',
|
||||
部分收款: 'ldb-status--partial',
|
||||
已收款: 'ldb-status--paid',
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "id": "ac1", "plateNo": "沪A65889F", "accidentNo": "SG202601003", "insuranceSurcharge": 680, "occurredAt": "2026-01-08", "remark": "追尾事故保险上浮" },
|
||||
{ "id": "ac2", "plateNo": "沪A65889F", "accidentNo": "SG202601009", "insuranceSurcharge": 320, "occurredAt": "2026-01-15", "remark": "剐蹭事故保险上浮" },
|
||||
{ "id": "ac3", "plateNo": "浙F08991F", "accidentNo": "SG202512018", "insuranceSurcharge": 450, "occurredAt": "2025-12-28", "remark": "单方事故" }
|
||||
]
|
||||
10
src/prototypes/lease-business-ledger/data/bi-mileage.json
Normal file
10
src/prototypes/lease-business-ledger/data/bi-mileage.json
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{ "plateNo": "沪A32896F", "year": 2026, "month": 1, "mileageKm": 3240 },
|
||||
{ "plateNo": "沪A61121F", "year": 2026, "month": 1, "mileageKm": 2980 },
|
||||
{ "plateNo": "沪A69695F", "year": 2026, "month": 1, "mileageKm": 3156 },
|
||||
{ "plateNo": "粤AGF4535", "year": 2026, "month": 1, "mileageKm": 1820 },
|
||||
{ "plateNo": "沪A65889F", "year": 2026, "month": 1, "mileageKm": 2765 },
|
||||
{ "plateNo": "浙F08991F", "year": 2026, "month": 1, "mileageKm": 2540 },
|
||||
{ "plateNo": "浙F05580F", "year": 2026, "month": 1, "mileageKm": 3012 },
|
||||
{ "plateNo": "沪A50990F", "year": 2026, "month": 1, "mileageKm": 2890 }
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "plateNo": "沪A32896F", "year": 2026, "month": 1, "amount": 1280.5, "status": "已对账" },
|
||||
{ "plateNo": "沪A65889F", "year": 2026, "month": 1, "amount": 960, "status": "未对账" },
|
||||
{ "plateNo": "浙F08991F", "year": 2026, "month": 1, "amount": 720, "status": "已对账" }
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{ "id": "mo1", "plateNo": "浙F05580F", "orderNo": "WX202601001", "type": "维修", "bearBy": "客户承担", "amount": 860, "completedAt": "2026-01-12" },
|
||||
{ "id": "mo2", "plateNo": "浙F05580F", "orderNo": "BY202601008", "type": "保养", "bearBy": "客户承担", "amount": 420, "completedAt": "2026-01-18" },
|
||||
{ "id": "mo3", "plateNo": "沪A50990F", "orderNo": "WX202601015", "type": "维修", "bearBy": "客户承担", "amount": 1500, "completedAt": "2026-01-20" },
|
||||
{ "id": "mo4", "plateNo": "沪A65889F", "orderNo": "BY202512020", "type": "保养", "bearBy": "公司承担", "amount": 380, "completedAt": "2025-12-20" }
|
||||
]
|
||||
8
src/prototypes/lease-business-ledger/data/receipts.json
Normal file
8
src/prototypes/lease-business-ledger/data/receipts.json
Normal file
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{ "id": "rc1", "arrivalTime": "2026-01-19 14:30", "customerName": "安徽驰远供应链管理有限公司", "amount": 4500, "remark": "银行转账" },
|
||||
{ "id": "rc2", "arrivalTime": "2026-02-02 10:00", "customerName": "嘉兴港区韵达快递有限公司", "amount": 2000, "remark": "第一笔到账" },
|
||||
{ "id": "rc3", "arrivalTime": "2026-02-02 16:20", "customerName": "嘉兴港区韵达快递有限公司", "amount": 1500, "remark": "第二笔到账" },
|
||||
{ "id": "rc4", "arrivalTime": "2025-12-25 09:15", "customerName": "上海朔宇物流有限公司", "amount": 1500, "remark": "部分到账" },
|
||||
{ "id": "rc5", "arrivalTime": "2025-12-25 11:00", "customerName": "上海朔宇物流有限公司", "amount": 1000, "remark": "第二笔部分到账" },
|
||||
{ "id": "rc6", "arrivalTime": "2025-12-26 15:40", "customerName": "上海利合供应链管理有限公司", "amount": 4000, "remark": "全额到账" }
|
||||
]
|
||||
532
src/prototypes/lease-business-ledger/data/rows.json
Normal file
532
src/prototypes/lease-business-ledger/data/rows.json
Normal file
@@ -0,0 +1,532 @@
|
||||
[
|
||||
{
|
||||
"id": "l1",
|
||||
"status": "已收款",
|
||||
"maintainerName": "刘念念",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "沪A32896F",
|
||||
"vehicleBrand": "苏龙",
|
||||
"vehicleModel": "18T",
|
||||
"vehicleType": "苏龙18T",
|
||||
"customerName": "安徽驰远供应链管理有限公司",
|
||||
"projectName": "安徽驰远供应链管租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2024-11-30",
|
||||
"contractStartDate": "2025-12-01",
|
||||
"contractEndDate": "2026-11-30",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 4500.0,
|
||||
"receivableTotal": 4500.0,
|
||||
"receivableRent": 4500.0,
|
||||
"monthlyIncome": 4500.0,
|
||||
"monthlyRent": 4500.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 0.0,
|
||||
"mileageDeduction": 0.0,
|
||||
"otherDeduction": 0.0,
|
||||
"receivedAmount": 4500.0,
|
||||
"invoiceDate": "2026-01-05",
|
||||
"actualPaymentDate": "2026-01-19",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 3900.0,
|
||||
"vehicleActualCost": 3900.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 500.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 4400.0,
|
||||
"profitLoss": 100.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "安吉天地物流科技有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 1,
|
||||
"assetOwnership": "租赁",
|
||||
"serviceFee": 0,
|
||||
"maintenancePackageEnabled": false,
|
||||
"maintenanceUnitPricePerKm": 0,
|
||||
"mileage": 0,
|
||||
"customerBearsMaintenance": false,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": false,
|
||||
"linkedReceiptIds": [
|
||||
"rc1"
|
||||
],
|
||||
"hydrogenBorneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"status": "已收款",
|
||||
"maintainerName": "刘念念",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "沪A61121F",
|
||||
"vehicleBrand": "苏龙",
|
||||
"vehicleModel": "18T",
|
||||
"vehicleType": "苏龙18T",
|
||||
"customerName": "安徽驰远供应链管理有限公司",
|
||||
"projectName": "安徽驰远供应链管租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2024-11-30",
|
||||
"contractStartDate": "2025-12-01",
|
||||
"contractEndDate": "2026-11-30",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 4500.0,
|
||||
"receivableTotal": 4500.0,
|
||||
"receivableRent": 4500.0,
|
||||
"monthlyIncome": 4500.0,
|
||||
"monthlyRent": 4500.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 0.0,
|
||||
"mileageDeduction": 50,
|
||||
"otherDeduction": 30,
|
||||
"receivedAmount": 4500.0,
|
||||
"invoiceDate": "2026-01-05",
|
||||
"actualPaymentDate": "2026-01-19",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 3900.0,
|
||||
"vehicleActualCost": 3900.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 500.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 4400.0,
|
||||
"profitLoss": 100.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "安吉天地物流科技有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 1,
|
||||
"assetOwnership": "租赁",
|
||||
"serviceFee": 200,
|
||||
"maintenancePackageEnabled": true,
|
||||
"maintenanceUnitPricePerKm": 0.8,
|
||||
"mileage": 1250,
|
||||
"customerBearsMaintenance": false,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": false,
|
||||
"linkedReceiptIds": [],
|
||||
"hydrogenBorneBy": "客户承担"
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"status": "已收款",
|
||||
"maintainerName": "刘念念",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "沪A69695F",
|
||||
"vehicleBrand": "苏龙",
|
||||
"vehicleModel": "18T",
|
||||
"vehicleType": "苏龙18T",
|
||||
"customerName": "安徽驰远供应链管理有限公司",
|
||||
"projectName": "安徽驰远供应链管租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2024-08-20",
|
||||
"contractStartDate": "2025-09-01",
|
||||
"contractEndDate": "2026-08-31",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 4500.0,
|
||||
"receivableTotal": 4500.0,
|
||||
"receivableRent": 4500.0,
|
||||
"monthlyIncome": 4500.0,
|
||||
"monthlyRent": 4500.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 0.0,
|
||||
"mileageDeduction": 0.0,
|
||||
"otherDeduction": 0.0,
|
||||
"receivedAmount": 4500.0,
|
||||
"invoiceDate": "2026-01-05",
|
||||
"actualPaymentDate": "2026-01-19",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 3900.0,
|
||||
"vehicleActualCost": 3900.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 500.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 4400.0,
|
||||
"profitLoss": 100.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "安吉天地物流科技有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 1,
|
||||
"assetOwnership": "租赁",
|
||||
"serviceFee": 0,
|
||||
"maintenancePackageEnabled": false,
|
||||
"maintenanceUnitPricePerKm": 0,
|
||||
"mileage": 0,
|
||||
"customerBearsMaintenance": false,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": false,
|
||||
"linkedReceiptIds": [],
|
||||
"hydrogenBorneBy": "客户承担"
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"status": "已收款",
|
||||
"maintainerName": "刘念念",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "粤AGF4535",
|
||||
"vehicleBrand": "现代",
|
||||
"vehicleModel": "4.5T/交投",
|
||||
"vehicleType": "现代4.5T/交投",
|
||||
"customerName": "嘉兴港区韵达快递有限公司",
|
||||
"projectName": "嘉兴港区韵达快递租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2024-10-29",
|
||||
"contractStartDate": "2025-11-01",
|
||||
"contractEndDate": "2026-10-31",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 2000.0,
|
||||
"receivableTotal": 2000.0,
|
||||
"receivableRent": 2000.0,
|
||||
"monthlyIncome": 2000.0,
|
||||
"monthlyRent": 2000.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 0.0,
|
||||
"mileageDeduction": 0.0,
|
||||
"otherDeduction": 0.0,
|
||||
"receivedAmount": 2000.0,
|
||||
"invoiceDate": "2026-01-27",
|
||||
"actualPaymentDate": "2026-02-02",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 1500.0,
|
||||
"vehicleActualCost": 1500.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 0.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 1500.0,
|
||||
"profitLoss": 500.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "广州开发区交投氢能运营管理有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 1,
|
||||
"assetOwnership": "租赁",
|
||||
"serviceFee": 150,
|
||||
"maintenancePackageEnabled": false,
|
||||
"maintenanceUnitPricePerKm": 0,
|
||||
"mileage": 0,
|
||||
"customerBearsMaintenance": false,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": false,
|
||||
"linkedReceiptIds": [],
|
||||
"hydrogenBorneBy": "客户承担"
|
||||
},
|
||||
{
|
||||
"id": "l5",
|
||||
"status": "已收款",
|
||||
"maintainerName": "刘念念",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "沪A65889F",
|
||||
"vehicleBrand": "苏龙",
|
||||
"vehicleModel": "18T",
|
||||
"vehicleType": "苏龙18T",
|
||||
"customerName": "嘉兴港区韵达快递有限公司",
|
||||
"projectName": "嘉兴港区韵达快递租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2024-12-03",
|
||||
"contractStartDate": "2025-12-03",
|
||||
"contractEndDate": "2026-12-02",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 3500.0,
|
||||
"receivableTotal": 3500.0,
|
||||
"receivableRent": 3500.0,
|
||||
"monthlyIncome": 3500.0,
|
||||
"monthlyRent": 3500.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 0.0,
|
||||
"mileageDeduction": 0.0,
|
||||
"otherDeduction": 0.0,
|
||||
"receivedAmount": 3500.0,
|
||||
"invoiceDate": "2026-01-27",
|
||||
"actualPaymentDate": "2026-02-02",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 3900.0,
|
||||
"vehicleActualCost": 3900.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 0.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 3900.0,
|
||||
"profitLoss": -400.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "羚牛新能源科技(上海)有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 1,
|
||||
"assetOwnership": "自有",
|
||||
"serviceFee": 100,
|
||||
"maintenancePackageEnabled": false,
|
||||
"maintenanceUnitPricePerKm": 0,
|
||||
"mileage": 0,
|
||||
"customerBearsMaintenance": false,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": true,
|
||||
"linkedReceiptIds": [],
|
||||
"hydrogenBorneBy": "客户承担"
|
||||
},
|
||||
{
|
||||
"id": "l6",
|
||||
"status": "已确认",
|
||||
"maintainerName": "刘念念",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "浙F08991F",
|
||||
"vehicleBrand": "飞驰",
|
||||
"vehicleModel": "18T",
|
||||
"vehicleType": "飞驰18T",
|
||||
"customerName": "上海利合供应链管理有限公司",
|
||||
"projectName": "上海利合供应链管租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2024-03-01",
|
||||
"contractStartDate": "2025-03-01",
|
||||
"contractEndDate": "2026-02-28",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "2026-02-04",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 4000.0,
|
||||
"receivableTotal": 4000.0,
|
||||
"receivableRent": 4000.0,
|
||||
"monthlyIncome": 4000.0,
|
||||
"monthlyRent": 4000.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 0.0,
|
||||
"mileageDeduction": 0.0,
|
||||
"otherDeduction": 0.0,
|
||||
"receivedAmount": 0,
|
||||
"invoiceDate": "2025-12-29",
|
||||
"actualPaymentDate": "",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 3000.0,
|
||||
"vehicleActualCost": 3000.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 0.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 3000.0,
|
||||
"profitLoss": 1000.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "浙江羚牛氢能科技有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 3,
|
||||
"assetOwnership": "自有",
|
||||
"serviceFee": 0,
|
||||
"maintenancePackageEnabled": true,
|
||||
"maintenanceUnitPricePerKm": 1.2,
|
||||
"mileage": 980,
|
||||
"customerBearsMaintenance": false,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": true,
|
||||
"linkedReceiptIds": [],
|
||||
"hydrogenBorneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"id": "l7",
|
||||
"status": "部分收款",
|
||||
"maintainerName": "刘念念",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "浙F05580F",
|
||||
"vehicleBrand": "苏龙",
|
||||
"vehicleModel": "18T",
|
||||
"vehicleType": "苏龙18T",
|
||||
"customerName": "上海朔宇物流有限公司",
|
||||
"projectName": "上海朔宇物流租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2025-09-26",
|
||||
"contractStartDate": "2025-10-01",
|
||||
"contractEndDate": "2026-09-30",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 4000.0,
|
||||
"receivableTotal": 4000.0,
|
||||
"receivableRent": 4000.0,
|
||||
"monthlyIncome": 4000.0,
|
||||
"monthlyRent": 4000.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 0.0,
|
||||
"mileageDeduction": 0,
|
||||
"otherDeduction": 0,
|
||||
"receivedAmount": 2500.0,
|
||||
"invoiceDate": "2025-12-29",
|
||||
"actualPaymentDate": "2025-12-25",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 3900.0,
|
||||
"vehicleActualCost": 3900.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 0.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 3900.0,
|
||||
"profitLoss": 100.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "上海宇速物流有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 1,
|
||||
"assetOwnership": "挂靠",
|
||||
"serviceFee": 80,
|
||||
"maintenancePackageEnabled": false,
|
||||
"maintenanceUnitPricePerKm": 0,
|
||||
"mileage": 0,
|
||||
"customerBearsMaintenance": true,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": true,
|
||||
"linkedReceiptIds": [],
|
||||
"hydrogenBorneBy": "客户承担"
|
||||
},
|
||||
{
|
||||
"id": "l8",
|
||||
"status": "草稿",
|
||||
"maintainerName": "王芳",
|
||||
"year": 2026,
|
||||
"month": 1,
|
||||
"dept": "业务二部",
|
||||
"salesman": "刘念念",
|
||||
"paymentDate": "2026-01-01",
|
||||
"plateNo": "沪A50990F",
|
||||
"vehicleBrand": "苏龙",
|
||||
"vehicleModel": "18T",
|
||||
"vehicleType": "苏龙18T",
|
||||
"customerName": "苏州望亭远方物流有限公司",
|
||||
"projectName": "苏州望亭远方物流租赁项目",
|
||||
"valueAddedService": "",
|
||||
"exemptionPolicy": "",
|
||||
"pickupDate": "2025-08-21",
|
||||
"contractStartDate": "2025-09-01",
|
||||
"contractEndDate": "2026-08-31",
|
||||
"periodStartDate": "",
|
||||
"returnDate": "",
|
||||
"avgDays": 1,
|
||||
"deposit": "/",
|
||||
"contractRent": 5500.0,
|
||||
"receivableTotal": 5500.0,
|
||||
"receivableRent": 5500.0,
|
||||
"monthlyIncome": 5500.0,
|
||||
"monthlyRent": 5500.0,
|
||||
"maintenanceIncome": 0.0,
|
||||
"insuranceSurcharge": 0.0,
|
||||
"opsFeeIncome": 0.0,
|
||||
"otherIncome": 120,
|
||||
"mileageDeduction": 0.0,
|
||||
"otherDeduction": 0.0,
|
||||
"receivedAmount": 0,
|
||||
"invoiceDate": "2025-12-02",
|
||||
"actualPaymentDate": "",
|
||||
"paymentMethod": "月度预付",
|
||||
"vehicleStandardCost": 3900.0,
|
||||
"vehicleActualCost": 3900.0,
|
||||
"insuranceFee": 0.0,
|
||||
"hydrogenFee": 0,
|
||||
"opsFeeCost": 0.0,
|
||||
"brokerageFee": 1500.0,
|
||||
"otherCost": 0.0,
|
||||
"totalCost": 5400.0,
|
||||
"profitLoss": 100.0,
|
||||
"gpsMileage": "",
|
||||
"otherDeductionDetail": "",
|
||||
"assetOwner": "上海宇速物流有限公司",
|
||||
"signingCompany": "浙江羚牛氢能科技有限公司",
|
||||
"remark": "",
|
||||
"paymentCycleMonths": 6,
|
||||
"assetOwnership": "自有",
|
||||
"serviceFee": 0,
|
||||
"maintenancePackageEnabled": false,
|
||||
"maintenanceUnitPricePerKm": 0,
|
||||
"mileage": 0,
|
||||
"customerBearsMaintenance": true,
|
||||
"linkedMaintenanceIds": [],
|
||||
"linkedAccidentIds": [],
|
||||
"isLatestBill": true,
|
||||
"linkedReceiptIds": [],
|
||||
"hydrogenBorneBy": "客户承担"
|
||||
}
|
||||
]
|
||||
410
src/prototypes/lease-business-ledger/index.tsx
Normal file
410
src/prototypes/lease-business-ledger/index.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* @name 租赁业务台账
|
||||
*/
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, Download, FileUp, Plus, Send } from 'lucide-react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import seedRows from './data/rows.json';
|
||||
import seedReceipts from './data/receipts.json';
|
||||
import seedMaintenance from './data/maintenance-orders.json';
|
||||
import seedAccidents from './data/accident-records.json';
|
||||
import seedBiMileage from './data/bi-mileage.json';
|
||||
import seedHydrogenFees from './data/hydrogen-fees.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { LedgerKpiRow } from './components/LedgerKpiRow';
|
||||
import { LeaseLedgerTable } from './components/LeaseLedgerTable';
|
||||
import { ConfirmMarkModal } from './components/ConfirmMarkModal';
|
||||
import { ReceiptLinkModal } from './components/ReceiptLinkModal';
|
||||
import { MaintenanceLinkModal } from './components/MaintenanceLinkModal';
|
||||
import { AccidentLinkModal } from './components/AccidentLinkModal';
|
||||
import {
|
||||
EMPTY_LEASE_FILTERS,
|
||||
type AccidentRecord,
|
||||
type BiMileageRecord,
|
||||
type HydrogenFeeRecord,
|
||||
type LeaseLedgerFilters,
|
||||
type LeaseLedgerRow,
|
||||
type ListViewMode,
|
||||
type MaintenanceOrder,
|
||||
type ReceiptRecord,
|
||||
} from './types';
|
||||
import {
|
||||
applyLeaseFilters,
|
||||
applyListViewFilter,
|
||||
canStaffDeleteRow,
|
||||
canStaffEditRow,
|
||||
enrichLeaseRow,
|
||||
filterRecordsByRole,
|
||||
latestReceiptPaymentDate,
|
||||
LOAD_BATCH_SIZE,
|
||||
resolveStatusAfterReceipt,
|
||||
sumLeaseKpi,
|
||||
} from './utils/ledger';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const MAINTENANCE_ORDERS = seedMaintenance as MaintenanceOrder[];
|
||||
const ACCIDENT_RECORDS = seedAccidents as AccidentRecord[];
|
||||
const BI_MILEAGE = seedBiMileage as BiMileageRecord[];
|
||||
const HYDROGEN_FEES = seedHydrogenFees as HydrogenFeeRecord[];
|
||||
|
||||
function mapRows(rows: LeaseLedgerRow[]): LeaseLedgerRow[] {
|
||||
return rows.map((row) => enrichLeaseRow(row, {
|
||||
maintenanceOrders: MAINTENANCE_ORDERS,
|
||||
accidentRecords: ACCIDENT_RECORDS,
|
||||
biMileage: BI_MILEAGE,
|
||||
hydrogenFees: HYDROGEN_FEES,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function LeaseBusinessLedgerApp() {
|
||||
const [records, setRecords] = useState<LeaseLedgerRow[]>(() => mapRows(seedRows as LeaseLedgerRow[]));
|
||||
const [receipts] = useState<ReceiptRecord[]>(seedReceipts as ReceiptRecord[]);
|
||||
const [pendingFilters, setPendingFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [toast, setToast] = useState('');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [receiptRow, setReceiptRow] = useState<LeaseLedgerRow | null>(null);
|
||||
const [maintenanceRow, setMaintenanceRow] = useState<LeaseLedgerRow | null>(null);
|
||||
const [accidentRow, setAccidentRow] = useState<LeaseLedgerRow | null>(null);
|
||||
const [showUnpaidOnly, setShowUnpaidOnly] = useState(false);
|
||||
const [listViewMode, setListViewMode] = useState<ListViewMode>('all');
|
||||
const [visibleCount, setVisibleCount] = useState(LOAD_BATCH_SIZE);
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 3000);
|
||||
}, []);
|
||||
|
||||
const roleScopedRecords = useMemo(() => filterRecordsByRole(records), [records]);
|
||||
const filtered = useMemo(
|
||||
() => applyLeaseFilters(records, appliedFilters),
|
||||
[records, appliedFilters],
|
||||
);
|
||||
const listFiltered = useMemo(
|
||||
() => applyListViewFilter(filtered, { showUnpaidOnly, listViewMode }),
|
||||
[filtered, showUnpaidOnly, listViewMode],
|
||||
);
|
||||
const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]);
|
||||
const visibleRecords = useMemo(
|
||||
() => listFiltered.slice(0, visibleCount),
|
||||
[listFiltered, visibleCount],
|
||||
);
|
||||
const hasMore = visibleCount < listFiltered.length;
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(LOAD_BATCH_SIZE);
|
||||
}, [appliedFilters, showUnpaidOnly, listViewMode]);
|
||||
|
||||
const draftSelectedCount = useMemo(
|
||||
() => Array.from(selectedIds).filter((id) => records.find((r) => r.id === id)?.status === '草稿').length,
|
||||
[selectedIds, records],
|
||||
);
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_LEASE_FILTERS);
|
||||
setAppliedFilters(EMPTY_LEASE_FILTERS);
|
||||
setSelectedIds(new Set());
|
||||
setShowUnpaidOnly(false);
|
||||
setListViewMode('all');
|
||||
};
|
||||
|
||||
const handleMarkConfirmedClick = () => {
|
||||
if (draftSelectedCount === 0) {
|
||||
showToast('请先勾选草稿状态的记录');
|
||||
return;
|
||||
}
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmMark = () => {
|
||||
const draftIds = new Set(
|
||||
Array.from(selectedIds).filter((id) => records.find((r) => r.id === id)?.status === '草稿'),
|
||||
);
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (draftIds.has(row.id) ? { ...row, status: '已确认' as const } : row)),
|
||||
));
|
||||
setConfirmOpen(false);
|
||||
setSelectedIds(new Set());
|
||||
showToast(`已确认 ${draftIds.size} 条记录,业务服务组人员不可再删改`);
|
||||
};
|
||||
|
||||
const handleReceiptConfirm = (
|
||||
recordId: string,
|
||||
selectedReceiptIds: string[],
|
||||
linkedAmount: number,
|
||||
_allocations: Record<string, number>,
|
||||
) => {
|
||||
const arrivalTimes = selectedReceiptIds
|
||||
.map((id) => receipts.find((r) => r.id === id)?.arrivalTime)
|
||||
.filter((v): v is string => Boolean(v));
|
||||
const paymentDate = latestReceiptPaymentDate(arrivalTimes);
|
||||
const billAmount = Math.min(linkedAmount, records.find((r) => r.id === recordId)?.receivableTotal ?? linkedAmount);
|
||||
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => {
|
||||
if (row.id !== recordId) return row;
|
||||
const receivedAmount = Math.round((row.receivedAmount + billAmount) * 100) / 100;
|
||||
return {
|
||||
...row,
|
||||
receivedAmount,
|
||||
linkedReceiptIds: [...new Set([...(row.linkedReceiptIds ?? []), ...selectedReceiptIds])],
|
||||
actualPaymentDate: paymentDate,
|
||||
status: resolveStatusAfterReceipt(row.receivableTotal, receivedAmount),
|
||||
};
|
||||
}),
|
||||
));
|
||||
setReceiptRow(null);
|
||||
showToast(linkedAmount > billAmount ? '收款关联成功,多出部分请在单据下自行分配' : '收款记录关联成功');
|
||||
};
|
||||
|
||||
const handleMaintenanceConfirm = (recordId: string, selectedIds: string[]) => {
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (
|
||||
row.id === recordId
|
||||
? { ...row, linkedMaintenanceIds: [...new Set([...row.linkedMaintenanceIds, ...selectedIds])] }
|
||||
: row
|
||||
)),
|
||||
));
|
||||
setMaintenanceRow(null);
|
||||
showToast('维修/保养记录关联成功,运维费已自动计算');
|
||||
};
|
||||
|
||||
const handleAccidentConfirm = (recordId: string, selectedIds: string[]) => {
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (
|
||||
row.id === recordId
|
||||
? { ...row, linkedAccidentIds: [...new Set([...row.linkedAccidentIds, ...selectedIds])] }
|
||||
: row
|
||||
)),
|
||||
));
|
||||
setAccidentRow(null);
|
||||
showToast('事故单据关联成功,保险上浮费已自动汇总');
|
||||
};
|
||||
|
||||
const handleOtherIncomeChange = (recordId: string, value: number) => {
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (row.id === recordId ? { ...row, otherIncome: value } : row)),
|
||||
));
|
||||
};
|
||||
|
||||
const handleOtherCostChange = (recordId: string, value: number) => {
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (row.id === recordId ? { ...row, otherCost: value } : row)),
|
||||
));
|
||||
};
|
||||
|
||||
const handleBrokerageFeeChange = (recordId: string, value: number) => {
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (row.id === recordId ? { ...row, brokerageFee: value } : row)),
|
||||
));
|
||||
};
|
||||
|
||||
const handleRemarkChange = (recordId: string, value: string) => {
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (row.id === recordId ? { ...row, remark: value } : row)),
|
||||
));
|
||||
};
|
||||
|
||||
const handleDeductionDetailChange = (recordId: string, value: string) => {
|
||||
setRecords((prev) => mapRows(
|
||||
prev.map((row) => (row.id === recordId ? { ...row, otherDeductionDetail: value } : row)),
|
||||
));
|
||||
};
|
||||
|
||||
const handleListViewChange = (mode: ListViewMode) => {
|
||||
setListViewMode(mode);
|
||||
if (mode === 'unpaid') setShowUnpaidOnly(true);
|
||||
if (mode === 'all') setShowUnpaidOnly(false);
|
||||
};
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'list',
|
||||
onDirectoryRoute: (_node: AnnotationDirectoryRouteNode) => {},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vm-page ldb-page">
|
||||
<FilterPanel
|
||||
records={records}
|
||||
filters={pendingFilters}
|
||||
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
|
||||
onReset={handleReset}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
|
||||
<LedgerKpiRow
|
||||
summary={kpi}
|
||||
listViewMode={listViewMode}
|
||||
onListViewChange={handleListViewChange}
|
||||
/>
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="lbl-toolbar">
|
||||
<label className="ldb-toolbar-unpaid-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnpaidOnly}
|
||||
onChange={(e) => {
|
||||
setShowUnpaidOnly(e.target.checked);
|
||||
setListViewMode(e.target.checked ? 'unpaid' : 'all');
|
||||
}}
|
||||
/>
|
||||
仅显示未收款数据
|
||||
</label>
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => showToast('批量导入(原型演示)')}>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => showToast(`导出 ${listFiltered.length} 条(原型演示)`)}>
|
||||
<Download size={16} aria-hidden />
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost"
|
||||
data-annotation-id="lbl-btn-mark-confirmed"
|
||||
onClick={handleMarkConfirmedClick}
|
||||
>
|
||||
<CheckCircle2 size={16} aria-hidden />
|
||||
标记为已确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-primary"
|
||||
onClick={() => showToast(`提交收款审批:已选 ${selectedIds.size} 条(原型演示)`)}
|
||||
>
|
||||
<Send size={16} aria-hidden />
|
||||
提交收款审批
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<LeaseLedgerTable
|
||||
records={visibleRecords}
|
||||
maintenanceOrders={MAINTENANCE_ORDERS}
|
||||
accidentRecords={ACCIDENT_RECORDS}
|
||||
selectedIds={selectedIds}
|
||||
onToggleRow={(id, checked) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onToggleAll={(checked) => {
|
||||
setSelectedIds(checked ? new Set(listFiltered.map((r) => r.id)) : new Set());
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
if (!canStaffEditRow(row)) {
|
||||
showToast('已确认记录仅业务服务组主管可修改');
|
||||
return;
|
||||
}
|
||||
showToast(`行内编辑:${row.plateNo}(原型演示)`);
|
||||
}}
|
||||
onDelete={(row) => {
|
||||
if (!canStaffDeleteRow(row)) {
|
||||
showToast('已确认记录仅业务服务组主管可删除');
|
||||
return;
|
||||
}
|
||||
showToast(`删除:${row.plateNo}(原型演示)`);
|
||||
}}
|
||||
onLinkReceipt={(row) => setReceiptRow(row)}
|
||||
onLinkMaintenance={(row) => setMaintenanceRow(row)}
|
||||
onLinkAccident={(row) => setAccidentRow(row)}
|
||||
onOtherIncomeChange={handleOtherIncomeChange}
|
||||
onOtherCostChange={handleOtherCostChange}
|
||||
onBrokerageFeeChange={handleBrokerageFeeChange}
|
||||
onRemarkChange={handleRemarkChange}
|
||||
onDeductionDetailChange={handleDeductionDetailChange}
|
||||
/>
|
||||
<div className="vm-table-footer ldb-table-footer">
|
||||
<span className="ldb-table-total">
|
||||
已显示 {visibleRecords.length} / 共 {listFiltered.length} 条
|
||||
</span>
|
||||
<div className="ldb-table-footer-actions">
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost"
|
||||
onClick={() => setVisibleCount((n) => n + LOAD_BATCH_SIZE)}
|
||||
>
|
||||
下拉显示更多
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary"
|
||||
data-annotation-id="lbl-btn-add-row"
|
||||
onClick={() => showToast('表格底部新增行(原型演示)')}
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
添加行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
||||
</div>
|
||||
|
||||
<ConfirmMarkModal
|
||||
open={confirmOpen}
|
||||
count={draftSelectedCount}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={handleConfirmMark}
|
||||
/>
|
||||
<ReceiptLinkModal
|
||||
key={receiptRow?.id ?? 'closed'}
|
||||
open={Boolean(receiptRow)}
|
||||
record={receiptRow}
|
||||
receipts={receipts}
|
||||
onClose={() => setReceiptRow(null)}
|
||||
onConfirm={handleReceiptConfirm}
|
||||
/>
|
||||
<MaintenanceLinkModal
|
||||
key={maintenanceRow?.id ?? 'closed'}
|
||||
open={Boolean(maintenanceRow)}
|
||||
record={maintenanceRow}
|
||||
orders={MAINTENANCE_ORDERS}
|
||||
onClose={() => setMaintenanceRow(null)}
|
||||
onConfirm={handleMaintenanceConfirm}
|
||||
/>
|
||||
<AccidentLinkModal
|
||||
key={accidentRow?.id ?? 'closed'}
|
||||
open={Boolean(accidentRow)}
|
||||
record={accidentRow}
|
||||
accidents={ACCIDENT_RECORDS}
|
||||
onClose={() => setAccidentRow(null)}
|
||||
onConfirm={handleAccidentConfirm}
|
||||
/>
|
||||
|
||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const protoRoot = path.resolve(__dirname, '..');
|
||||
const prdPath = path.join(protoRoot, '.spec/requirements-prd.md');
|
||||
const sourcePath = path.join(protoRoot, 'annotation-source.json');
|
||||
const columnsPath = path.join(protoRoot, 'components/tableColumns.ts');
|
||||
|
||||
const legacyPrd = fs.existsSync(prdPath) ? fs.readFileSync(prdPath, 'utf8') : '';
|
||||
const existing = fs.existsSync(sourcePath)
|
||||
? JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
|
||||
: null;
|
||||
|
||||
/** 保留批注工具里已写入的 markdown,避免被 PRD 摘要覆盖 */
|
||||
const markdownMap = { ...(existing?.markdownMap ?? {}) };
|
||||
|
||||
const columnsSource = fs.readFileSync(columnsPath, 'utf8');
|
||||
const columnAnnotationIds = [...columnsSource.matchAll(/annotationId:\s*'(lbl-[^']+)'/g)].map((m) => m[1]);
|
||||
|
||||
const NODE_META = {
|
||||
'lbl-filter': { title: '筛选条件', color: '#2563eb', aiPrompt: '年份、月份、部门、业务员等筛选。' },
|
||||
'lbl-kpi': { title: 'KPI 统计', color: '#64748b', aiPrompt: '六张统计卡片口径。' },
|
||||
'lbl-kpi-count': { title: '台账条数', color: '#64748b', aiPrompt: '台账条数 KPI 点击切换列表。' },
|
||||
'lbl-kpi-unreceived': { title: '未收总计', color: '#64748b', aiPrompt: '未收总计 KPI 筛选未收款。' },
|
||||
'lbl-toolbar': { title: '列表工具栏', color: '#0f766e', aiPrompt: '导入、导出、标记确认、提交审批。' },
|
||||
'lbl-btn-mark-confirmed': { title: '标记为已确认', color: '#0f766e', aiPrompt: '草稿批量确认与权限收口。' },
|
||||
'lbl-btn-add-row': { title: '添加行', color: '#0f766e', aiPrompt: '表格底部新增行。' },
|
||||
'lbl-table': { title: '租赁台账宽表', color: '#2563eb', aiPrompt: '运营明细台账列表与行操作。' },
|
||||
};
|
||||
|
||||
function extractSection(md, heading) {
|
||||
const lines = md.split('\n');
|
||||
const start = lines.findIndex((line) => line.startsWith(heading));
|
||||
if (start < 0) return null;
|
||||
const level = heading.match(/^#+/)?.[0].length ?? 2;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^#{1,6}\s/.test(lines[i])) {
|
||||
const l = lines[i].match(/^#+/)?.[0].length ?? 2;
|
||||
if (l <= level) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.slice(start, end).join('\n').trim();
|
||||
}
|
||||
|
||||
function titleFromMarkdown(md) {
|
||||
const match = md.match(/^##\s+(.+)/m);
|
||||
if (!match) return null;
|
||||
return match[1].replace(/列$/u, '').trim();
|
||||
}
|
||||
|
||||
function resolveMeta(id) {
|
||||
if (NODE_META[id]) return NODE_META[id];
|
||||
const md = markdownMap[id];
|
||||
const fromMd = md ? titleFromMarkdown(md) : null;
|
||||
if (fromMd) {
|
||||
return { title: fromMd, color: '#64748b', aiPrompt: `${fromMd}字段说明。` };
|
||||
}
|
||||
return { title: id, color: '#64748b', aiPrompt: id };
|
||||
}
|
||||
|
||||
function trimDoc(md) {
|
||||
return (md ?? '').replace(/\n---\s*$/g, '').trim();
|
||||
}
|
||||
|
||||
/** 将标注文档中的 ## 降为 ###,用于 PRD 子节 */
|
||||
function asSubsection(md) {
|
||||
return trimDoc(md).replace(/^## /gm, '### ');
|
||||
}
|
||||
|
||||
function prdSection(title, body) {
|
||||
const content = trimDoc(body);
|
||||
return content ? `## ${title}\n\n${content}` : `## ${title}`;
|
||||
}
|
||||
|
||||
function buildFullPrd(map, columnIds) {
|
||||
const fieldSections = columnIds
|
||||
.filter((id) => id !== 'lbl-col-status')
|
||||
.map((id) => asSubsection(map[id]))
|
||||
.filter(Boolean);
|
||||
|
||||
return [
|
||||
'# 租赁业务台账 · 产品需求说明(PRD)',
|
||||
'',
|
||||
'> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
prdSection(
|
||||
'1. 模块定位',
|
||||
[
|
||||
'| 项 | 说明 |',
|
||||
'|---|---|',
|
||||
'| 模块名称 | 台账数据 — 租赁业务台账 |',
|
||||
'| 目标用户 | 租赁业务客服、业务服务组主管 |',
|
||||
'| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 |',
|
||||
'| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页) |',
|
||||
].join('\n'),
|
||||
),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
trimDoc(map['lbl-filter']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
trimDoc(map['lbl-kpi']),
|
||||
'',
|
||||
asSubsection(map['lbl-kpi-count']),
|
||||
'',
|
||||
asSubsection(map['lbl-kpi-unreceived']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
trimDoc(map['lbl-toolbar']),
|
||||
'',
|
||||
asSubsection(map['lbl-btn-mark-confirmed']),
|
||||
'',
|
||||
asSubsection(map['lbl-btn-add-row']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
trimDoc(map['lbl-table']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
(() => {
|
||||
const body = trimDoc(map['lbl-col-status']).replace(/^##\s+状态流转\s*\n+/, '');
|
||||
return prdSection('6. 状态流转', body);
|
||||
})(),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
prdSection('7. 字段说明', fieldSections.join('\n\n')),
|
||||
]
|
||||
.filter((block) => block.trim())
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function ensureMarkdown(id, meta) {
|
||||
if (markdownMap[id]?.trim()) return markdownMap[id];
|
||||
const prdFallback = {
|
||||
'lbl-filter': '## 2. 筛选区',
|
||||
'lbl-kpi': '## 3. KPI 统计卡片(6 张)',
|
||||
'lbl-toolbar': '## 4. 列表工具栏',
|
||||
'lbl-table': '## 5. 列表字段',
|
||||
};
|
||||
if (prdFallback[id] && legacyPrd) {
|
||||
return extractSection(legacyPrd, prdFallback[id]) ?? `# ${meta.title}\n\n${meta.aiPrompt}`;
|
||||
}
|
||||
return `# ${meta.title}\n\n${meta.aiPrompt}`;
|
||||
}
|
||||
|
||||
const pageNodeIds = [
|
||||
'lbl-filter',
|
||||
'lbl-kpi',
|
||||
'lbl-kpi-count',
|
||||
'lbl-kpi-unreceived',
|
||||
'lbl-toolbar',
|
||||
'lbl-btn-mark-confirmed',
|
||||
'lbl-btn-add-row',
|
||||
'lbl-table',
|
||||
];
|
||||
|
||||
const uniqueOrdered = [...pageNodeIds];
|
||||
for (const id of columnAnnotationIds) {
|
||||
if (!uniqueOrdered.includes(id)) uniqueOrdered.push(id);
|
||||
}
|
||||
for (const id of Object.keys(markdownMap)) {
|
||||
if (!uniqueOrdered.includes(id)) uniqueOrdered.push(id);
|
||||
}
|
||||
|
||||
const existingNodeById = Object.fromEntries((existing?.data?.nodes ?? []).map((n) => [n.id, n]));
|
||||
const now = Date.now();
|
||||
|
||||
const nodes = uniqueOrdered.map((id, index) => {
|
||||
const meta = resolveMeta(id);
|
||||
const markdown = ensureMarkdown(id, meta);
|
||||
markdownMap[id] = markdown;
|
||||
const prev = existingNodeById[id];
|
||||
return {
|
||||
id,
|
||||
index: index + 1,
|
||||
title: meta.title,
|
||||
pageId: 'list',
|
||||
locator: {
|
||||
selectors: [`[data-annotation-id="${id}"]`],
|
||||
fingerprint: id,
|
||||
path: [],
|
||||
},
|
||||
aiPrompt: meta.aiPrompt,
|
||||
annotationText: markdown,
|
||||
hasMarkdown: true,
|
||||
color: meta.color,
|
||||
images: prev?.images ?? [],
|
||||
createdAt: prev?.createdAt ?? 1740787200000,
|
||||
updatedAt: prev?.updatedAt ?? now,
|
||||
controls: prev?.controls ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
function docMarkdown(nodeId) {
|
||||
return markdownMap[nodeId] ?? '';
|
||||
}
|
||||
|
||||
function docNode(nodeId) {
|
||||
const meta = resolveMeta(nodeId);
|
||||
return {
|
||||
type: 'markdown',
|
||||
id: `lbl-doc-${nodeId}`,
|
||||
title: meta.title,
|
||||
markdown: docMarkdown(nodeId),
|
||||
};
|
||||
}
|
||||
|
||||
const tableColumnDocIds = columnAnnotationIds;
|
||||
|
||||
const fullPrd = buildFullPrd(markdownMap, columnAnnotationIds);
|
||||
fs.writeFileSync(prdPath, `${fullPrd.trim()}\n`, 'utf8');
|
||||
|
||||
const overviewMd = extractSection(fullPrd, '## 1. 模块定位') ?? fullPrd;
|
||||
|
||||
const source = {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: {
|
||||
version: 2,
|
||||
prototypeName: 'lease-business-ledger',
|
||||
pageId: 'list',
|
||||
updatedAt: now,
|
||||
nodes,
|
||||
},
|
||||
markdownMap,
|
||||
assetMap: existing?.assetMap ?? {},
|
||||
directory: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lbl-doc-root',
|
||||
title: '租赁业务台账说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lbl-doc-overview',
|
||||
title: '模块总览',
|
||||
markdown: overviewMd,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'lbl-link-self',
|
||||
title: '租赁业务台账原型',
|
||||
href: '/prototypes/lease-business-ledger',
|
||||
target: 'self',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lbl-doc-prd',
|
||||
title: 'PRD 全文',
|
||||
markdown: fullPrd,
|
||||
markdownPath: 'src/prototypes/lease-business-ledger/.spec/requirements-prd.md',
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lbl-doc-page-annotations',
|
||||
title: '页面标注',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
docNode('lbl-filter'),
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lbl-doc-folder-kpi',
|
||||
title: 'KPI 统计',
|
||||
defaultExpanded: false,
|
||||
children: [
|
||||
docNode('lbl-kpi'),
|
||||
docNode('lbl-kpi-count'),
|
||||
docNode('lbl-kpi-unreceived'),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lbl-doc-folder-toolbar',
|
||||
title: '列表工具栏',
|
||||
defaultExpanded: false,
|
||||
children: [
|
||||
docNode('lbl-toolbar'),
|
||||
docNode('lbl-btn-mark-confirmed'),
|
||||
docNode('lbl-btn-add-row'),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lbl-doc-folder-table',
|
||||
title: '租赁台账宽表',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
docNode('lbl-table'),
|
||||
...tableColumnDocIds.map((id) => docNode(id)),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(sourcePath, `${JSON.stringify(source, null, 2)}\n`, 'utf8');
|
||||
console.log(`Built requirements-prd.md and annotation-source.json with ${nodes.length} nodes.`);
|
||||
411
src/prototypes/lease-business-ledger/styles/index.css
Normal file
411
src/prototypes/lease-business-ledger/styles/index.css
Normal file
@@ -0,0 +1,411 @@
|
||||
/* 租赁业务台账 — 模块样式(基于 vehicle-management vm-*) */
|
||||
|
||||
.ldb-page {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.ldb-page-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.ldb-date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ldb-date-range input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ldb-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.ldb-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ldb-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.ldb-kpi-card {
|
||||
position: relative;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--ln-radius-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
background: var(--ln-surface-card);
|
||||
box-shadow: var(--ln-shadow-soft);
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.ldb-kpi-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--ln-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ldb-kpi-val {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--ln-ink);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ldb-kpi-val--profit { color: var(--ln-success); }
|
||||
.ldb-kpi-val--loss { color: var(--ln-error); }
|
||||
|
||||
.ldb-kpi-tip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: var(--ln-muted-soft);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.ldb-kpi-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 10;
|
||||
width: max-content;
|
||||
max-width: 240px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: var(--ln-ink);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
box-shadow: var(--ln-shadow-hover);
|
||||
}
|
||||
|
||||
.ldb-kpi-tip:hover .ldb-kpi-tooltip,
|
||||
.ldb-kpi-tip:focus-within .ldb-kpi-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ldb-table-toolbar {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ldb-toolbar-unpaid-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-ink);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ldb-toolbar-unpaid-filter input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ldb-kpi-card--clickable {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
background: var(--ln-surface-card);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.ldb-kpi-card--clickable:hover {
|
||||
border-color: var(--ln-primary);
|
||||
box-shadow: var(--ln-shadow-soft);
|
||||
}
|
||||
|
||||
.ldb-kpi-card--clickable.ldb-kpi-card--active {
|
||||
border-color: var(--ln-primary);
|
||||
box-shadow: 0 0 0 1px var(--ln-primary);
|
||||
}
|
||||
|
||||
.ldb-asset-tag {
|
||||
display: inline-flex;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--ln-radius-pill);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.ldb-asset--owned { background: #eff6ff; color: #1d4ed8; }
|
||||
.ldb-asset--leased { background: #fff7ed; color: #c2410c; }
|
||||
.ldb-asset--affiliated { background: #f5f3ff; color: #6d28d9; }
|
||||
|
||||
.ldb-table-footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ldb-table-wrap { overflow: auto; }
|
||||
.ldb-table { min-width: 4200px; }
|
||||
|
||||
.ldb-cell-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ldb-cell-primary {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-ink);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ldb-cell-secondary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ldb-col-check { width: 48px; }
|
||||
.ldb-col-money { text-align: right; white-space: nowrap; }
|
||||
.ldb-col-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ldb-col-remark { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ldb-col-actions { min-width: 200px; white-space: nowrap; }
|
||||
|
||||
.sticky-col-left {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.sticky-col-right {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.ldb-status-tag {
|
||||
display: inline-flex;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--ln-radius-pill);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.ldb-status--draft { background: #ecfdf5; color: #047857; }
|
||||
.ldb-status--confirmed { background: #eff6ff; color: #1d4ed8; }
|
||||
.ldb-status--partial { background: #fff7ed; color: #c2410c; }
|
||||
.ldb-status--paid { background: #f0fdf4; color: #15803d; }
|
||||
|
||||
.ldb-confirm-text {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.ldb-confirm-meta {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.ldb-receipt-modal { max-width: 720px; width: calc(100% - 32px); }
|
||||
|
||||
.ldb-receipt-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: var(--ln-surface-strong);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.ldb-receipt-summary strong {
|
||||
color: var(--ln-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ldb-receipt-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 320px;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
}
|
||||
|
||||
.ldb-receipt-table { margin: 0; }
|
||||
|
||||
.ldb-receipt-empty {
|
||||
text-align: center;
|
||||
color: var(--ln-muted);
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
.vm-btn-link:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ldb-action-sep { color: var(--ln-hairline); margin: 0 2px; }
|
||||
.ldb-link-danger { color: var(--ln-error) !important; }
|
||||
|
||||
.ldb-table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ldb-table-total {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.ldb-table .vm-table thead th.ldb-col-money,
|
||||
.ldb-table .vm-table tbody td.ldb-col-money {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ldb-link-preview {
|
||||
margin: 12px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.ldb-link-preview strong {
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.ldb-income-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ldb-income-warn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ldb-income-warn:hover {
|
||||
background: #ffedd5;
|
||||
}
|
||||
|
||||
.ldb-income-pending {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.ldb-income-input {
|
||||
width: 100%;
|
||||
min-width: 88px;
|
||||
max-width: 120px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8125rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ldb-computed-val {
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.ldb-cell-readonly {
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.ldb-th-with-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ldb-th-tip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
color: var(--ln-muted);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.ldb-text-input {
|
||||
width: 100%;
|
||||
min-width: 100px;
|
||||
max-width: 140px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ldb-input-required {
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.ldb-linked-badge {
|
||||
display: inline-flex;
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ldb-overflow-hint {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ldb-overflow-tag {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.ldb-alloc-input {
|
||||
width: 88px;
|
||||
padding: 4px 6px;
|
||||
font-size: 0.8125rem;
|
||||
text-align: right;
|
||||
}
|
||||
145
src/prototypes/lease-business-ledger/types.ts
Normal file
145
src/prototypes/lease-business-ledger/types.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
export type LeaseDetailStatus = '草稿' | '已确认' | '部分收款' | '已收款';
|
||||
export type AssetOwnership = '自有' | '租赁' | '挂靠';
|
||||
export type ListViewMode = 'all' | 'unpaid';
|
||||
|
||||
export interface LeaseLedgerRow {
|
||||
id: string;
|
||||
status: LeaseDetailStatus;
|
||||
maintainerName: string;
|
||||
year: number;
|
||||
month: number;
|
||||
dept: string;
|
||||
salesman: string;
|
||||
paymentDate: string;
|
||||
plateNo: string;
|
||||
vehicleBrand: string;
|
||||
vehicleModel: string;
|
||||
vehicleType?: string;
|
||||
customerName: string;
|
||||
projectName: string;
|
||||
valueAddedService: string;
|
||||
exemptionPolicy: string;
|
||||
pickupDate: string;
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
periodStartDate: string;
|
||||
returnDate: string;
|
||||
avgDays: number;
|
||||
deposit: string | number;
|
||||
contractRent: number;
|
||||
paymentCycleMonths: number;
|
||||
serviceFee: number;
|
||||
maintenancePackageEnabled: boolean;
|
||||
maintenanceUnitPricePerKm: number;
|
||||
mileage: number;
|
||||
customerBearsMaintenance: boolean;
|
||||
linkedMaintenanceIds: string[];
|
||||
linkedAccidentIds: string[];
|
||||
linkedReceiptIds: string[];
|
||||
hydrogenBorneBy: '我司承担' | '客户承担' | '';
|
||||
isLatestBill: boolean;
|
||||
receivableTotal: number;
|
||||
receivableRent: number;
|
||||
monthlyIncome: number;
|
||||
monthlyRent: number;
|
||||
maintenanceIncome: number;
|
||||
insuranceSurcharge: number;
|
||||
opsFeeIncome: number;
|
||||
otherIncome: number;
|
||||
mileageDeduction: number;
|
||||
otherDeduction: number;
|
||||
receivedAmount: number;
|
||||
invoiceDate: string;
|
||||
actualPaymentDate: string;
|
||||
paymentMethod: string;
|
||||
vehicleStandardCost: number;
|
||||
vehicleActualCost: number;
|
||||
insuranceFee: number;
|
||||
hydrogenFee: number;
|
||||
opsFeeCost: number;
|
||||
brokerageFee: number;
|
||||
otherCost: number;
|
||||
totalCost: number;
|
||||
profitLoss: number;
|
||||
gpsMileage: string;
|
||||
otherDeductionDetail: string;
|
||||
assetOwnership: AssetOwnership;
|
||||
assetOwner?: string;
|
||||
signingCompany: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface LeaseLedgerFilters {
|
||||
year: string;
|
||||
month: string;
|
||||
dept: string;
|
||||
salesman: string;
|
||||
plateNo: string;
|
||||
customerName: string;
|
||||
status: string;
|
||||
paymentFrom: string;
|
||||
paymentTo: string;
|
||||
}
|
||||
|
||||
export const EMPTY_LEASE_FILTERS: LeaseLedgerFilters = {
|
||||
year: '',
|
||||
month: '',
|
||||
dept: '',
|
||||
salesman: '',
|
||||
plateNo: '',
|
||||
customerName: '',
|
||||
status: '',
|
||||
paymentFrom: '',
|
||||
paymentTo: '',
|
||||
};
|
||||
|
||||
export interface LeaseKpiSummary {
|
||||
count: number;
|
||||
receivableTotal: number;
|
||||
receivedAmount: number;
|
||||
unreceived: number;
|
||||
totalCost: number;
|
||||
profitLoss: number;
|
||||
}
|
||||
|
||||
export interface ReceiptRecord {
|
||||
id: string;
|
||||
arrivalTime: string;
|
||||
customerName: string;
|
||||
amount: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface BiMileageRecord {
|
||||
plateNo: string;
|
||||
year: number;
|
||||
month: number;
|
||||
mileageKm: number;
|
||||
}
|
||||
|
||||
export interface HydrogenFeeRecord {
|
||||
plateNo: string;
|
||||
year: number;
|
||||
month: number;
|
||||
amount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceOrder {
|
||||
id: string;
|
||||
plateNo: string;
|
||||
orderNo: string;
|
||||
type: string;
|
||||
bearBy: string;
|
||||
amount: number;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface AccidentRecord {
|
||||
id: string;
|
||||
plateNo: string;
|
||||
accidentNo: string;
|
||||
insuranceSurcharge: number;
|
||||
occurredAt: string;
|
||||
remark: string;
|
||||
}
|
||||
370
src/prototypes/lease-business-ledger/utils/ledger.ts
Normal file
370
src/prototypes/lease-business-ledger/utils/ledger.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import type {
|
||||
AccidentRecord,
|
||||
AssetOwnership,
|
||||
BiMileageRecord,
|
||||
HydrogenFeeRecord,
|
||||
LeaseDetailStatus,
|
||||
LeaseLedgerFilters,
|
||||
LeaseLedgerRow,
|
||||
LeaseKpiSummary,
|
||||
ListViewMode,
|
||||
MaintenanceOrder,
|
||||
} from '../types';
|
||||
|
||||
/** 原型:车牌主数据(导入/新增时按车牌自动匹配品牌型号) */
|
||||
export const PLATE_VEHICLE_MAP: Record<string, { brand: string; model: string }> = {
|
||||
沪A32896F: { brand: '苏龙', model: '18T' },
|
||||
沪A61121F: { brand: '苏龙', model: '18T' },
|
||||
沪A69695F: { brand: '苏龙', model: '18T' },
|
||||
粤AGF4535: { brand: '现代', model: '4.5T/交投' },
|
||||
沪A65889F: { brand: '苏龙', model: '18T' },
|
||||
浙F08991F: { brand: '飞驰', model: '18T' },
|
||||
浙F05580F: { brand: '苏龙', model: '18T' },
|
||||
沪A50990F: { brand: '苏龙', model: '18T' },
|
||||
};
|
||||
|
||||
/** 原型:按车牌匹配客户与项目 */
|
||||
export const PLATE_CONTRACT_MAP: Record<string, { customerName: string; projectName: string }> = {
|
||||
沪A32896F: { customerName: '安徽驰远供应链管理有限公司', projectName: '安徽驰远供应链管租赁项目' },
|
||||
沪A61121F: { customerName: '安徽驰远供应链管理有限公司', projectName: '安徽驰远供应链管租赁项目' },
|
||||
沪A69695F: { customerName: '安徽驰远供应链管理有限公司', projectName: '安徽驰远供应链管租赁项目' },
|
||||
粤AGF4535: { customerName: '嘉兴港区韵达快递有限公司', projectName: '嘉兴港区韵达租赁项目' },
|
||||
沪A65889F: { customerName: '嘉兴港区韵达快递有限公司', projectName: '嘉兴港区韵达租赁项目' },
|
||||
浙F08991F: { customerName: '上海利合供应链管理有限公司', projectName: '上海利合供应链租赁项目' },
|
||||
浙F05580F: { customerName: '上海朔宇物流有限公司', projectName: '上海朔宇物流租赁项目' },
|
||||
沪A50990F: { customerName: '苏州望亭远方物流有限公司', projectName: '苏州望亭远方物流租赁项目' },
|
||||
};
|
||||
|
||||
/** 原型:租赁合同甲方(签约公司) */
|
||||
export const PLATE_SIGNING_COMPANY: Record<string, string> = {
|
||||
沪A32896F: '浙江羚牛氢能科技有限公司',
|
||||
沪A61121F: '浙江羚牛氢能科技有限公司',
|
||||
沪A69695F: '浙江羚牛氢能科技有限公司',
|
||||
粤AGF4535: '浙江羚牛氢能科技有限公司',
|
||||
沪A65889F: '浙江羚牛氢能科技有限公司',
|
||||
浙F08991F: '浙江羚牛氢能科技有限公司',
|
||||
浙F05580F: '浙江羚牛氢能科技有限公司',
|
||||
沪A50990F: '浙江羚牛氢能科技有限公司',
|
||||
};
|
||||
|
||||
/** 原型:资产实际归属公司 */
|
||||
export const PLATE_ASSET_OWNER: Record<string, string> = {
|
||||
沪A32896F: '安吉天地物流科技有限公司',
|
||||
沪A61121F: '安吉天地物流科技有限公司',
|
||||
沪A69695F: '安吉天地物流科技有限公司',
|
||||
粤AGF4535: '广州开发区交投氢能运营管理有限公司',
|
||||
沪A65889F: '羚牛新能源科技(上海)有限公司',
|
||||
浙F08991F: '浙江羚牛氢能科技有限公司',
|
||||
浙F05580F: '上海宇速物流有限公司',
|
||||
沪A50990F: '上海宇速物流有限公司',
|
||||
};
|
||||
|
||||
/** 原型:车型标准成本(固定成本) */
|
||||
export const PLATE_STANDARD_COST: Record<string, number> = {
|
||||
沪A32896F: 3900,
|
||||
沪A61121F: 3900,
|
||||
沪A69695F: 3900,
|
||||
粤AGF4535: 1500,
|
||||
沪A65889F: 3900,
|
||||
浙F08991F: 3000,
|
||||
浙F05580F: 3900,
|
||||
沪A50990F: 3900,
|
||||
};
|
||||
|
||||
export const HYDROGEN_FEE_HEADER_TIP = '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)';
|
||||
|
||||
/** 资产归属类型(内部) */
|
||||
export const PLATE_ASSET_OWNERSHIP: Record<string, AssetOwnership> = {
|
||||
沪A32896F: '租赁',
|
||||
沪A61121F: '租赁',
|
||||
沪A69695F: '租赁',
|
||||
粤AGF4535: '租赁',
|
||||
沪A65889F: '自有',
|
||||
浙F08991F: '自有',
|
||||
浙F05580F: '挂靠',
|
||||
沪A50990F: '自有',
|
||||
};
|
||||
|
||||
export const IS_SUPERVISOR = false;
|
||||
export const CURRENT_STAFF_USER = '刘念念';
|
||||
|
||||
export const LOAD_BATCH_SIZE = 20;
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function calcUnreceived(row: Pick<LeaseLedgerRow, 'receivableTotal' | 'receivedAmount'>): number {
|
||||
return Math.round((row.receivableTotal - row.receivedAmount) * 100) / 100;
|
||||
}
|
||||
|
||||
export function isUnpaidRow(row: LeaseLedgerRow): boolean {
|
||||
return row.status !== '已收款';
|
||||
}
|
||||
|
||||
export function resolvePaymentMethod(cycleMonths: number): string {
|
||||
const map: Record<number, string> = {
|
||||
1: '月度预付',
|
||||
3: '季度预付',
|
||||
6: '半年预付',
|
||||
12: '年度预付',
|
||||
};
|
||||
return map[cycleMonths] ?? `每${cycleMonths}个月预付`;
|
||||
}
|
||||
|
||||
export function calcMonthlyRent(contractRent: number): number {
|
||||
return contractRent;
|
||||
}
|
||||
|
||||
export function calcReceivableRent(contractRent: number, cycleMonths: number): number {
|
||||
return Math.round(contractRent * cycleMonths * 100) / 100;
|
||||
}
|
||||
|
||||
export function resolveStatusAfterReceipt(
|
||||
receivableTotal: number,
|
||||
receivedAmount: number,
|
||||
): LeaseDetailStatus {
|
||||
if (receivedAmount >= receivableTotal) return '已收款';
|
||||
if (receivedAmount > 0) return '部分收款';
|
||||
return '已确认';
|
||||
}
|
||||
|
||||
export function filterRecordsByRole(rows: LeaseLedgerRow[]): LeaseLedgerRow[] {
|
||||
if (IS_SUPERVISOR) return rows;
|
||||
return rows.filter((row) => row.maintainerName === CURRENT_STAFF_USER);
|
||||
}
|
||||
|
||||
export function calcVehicleActualCost(standardCost: number, actualDays: number): number {
|
||||
return Math.round(standardCost * (actualDays / 30) * 100) / 100;
|
||||
}
|
||||
|
||||
export function lookupBiMileage(
|
||||
plateNo: string,
|
||||
year: number,
|
||||
month: number,
|
||||
records: BiMileageRecord[],
|
||||
): string {
|
||||
const hit = records.find((item) => item.plateNo === plateNo && item.year === year && item.month === month);
|
||||
return hit ? String(hit.mileageKm) : '';
|
||||
}
|
||||
|
||||
export function sumHydrogenFeeForBill(
|
||||
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month' | 'hydrogenBorneBy'>,
|
||||
records: HydrogenFeeRecord[],
|
||||
): number {
|
||||
if (row.hydrogenBorneBy !== '我司承担') return 0;
|
||||
return records
|
||||
.filter((item) => item.plateNo === row.plateNo && item.year === row.year && item.month === row.month)
|
||||
.reduce((sum, item) => sum + item.amount, 0);
|
||||
}
|
||||
|
||||
export function calcReceivedAmountFromReceipts(
|
||||
linkedIds: string[],
|
||||
receipts: { id: string; amount: number }[],
|
||||
receivableTotal: number,
|
||||
): number {
|
||||
const total = receipts
|
||||
.filter((item) => linkedIds.includes(item.id))
|
||||
.reduce((sum, item) => sum + item.amount, 0);
|
||||
return Math.min(Math.round(total * 100) / 100, receivableTotal);
|
||||
}
|
||||
|
||||
export function calcMaintenanceIncome(row: Pick<LeaseLedgerRow, 'maintenancePackageEnabled' | 'maintenanceUnitPricePerKm' | 'mileage'>): number {
|
||||
if (!row.maintenancePackageEnabled) return 0;
|
||||
return Math.round(row.maintenanceUnitPricePerKm * row.mileage * 100) / 100;
|
||||
}
|
||||
|
||||
export function calcMonthlyIncome(
|
||||
row: Pick<LeaseLedgerRow, 'monthlyRent' | 'serviceFee' | 'mileageDeduction' | 'otherDeduction'>,
|
||||
): number {
|
||||
return Math.round((row.monthlyRent + row.serviceFee - row.mileageDeduction - row.otherDeduction) * 100) / 100;
|
||||
}
|
||||
|
||||
export function sumLinkedMaintenanceAmount(
|
||||
linkedIds: string[],
|
||||
orders: MaintenanceOrder[],
|
||||
): number {
|
||||
return orders
|
||||
.filter((item) => linkedIds.includes(item.id) && item.bearBy === '客户承担')
|
||||
.reduce((sum, item) => sum + item.amount, 0);
|
||||
}
|
||||
|
||||
export function sumLinkedAccidentSurcharge(
|
||||
linkedIds: string[],
|
||||
accidents: AccidentRecord[],
|
||||
): number {
|
||||
return accidents
|
||||
.filter((item) => linkedIds.includes(item.id))
|
||||
.reduce((sum, item) => sum + item.insuranceSurcharge, 0);
|
||||
}
|
||||
|
||||
export function needsMaintenanceLinkWarning(
|
||||
row: LeaseLedgerRow,
|
||||
orders: MaintenanceOrder[],
|
||||
): boolean {
|
||||
if (!row.isLatestBill || !row.customerBearsMaintenance) return false;
|
||||
if (row.linkedMaintenanceIds.length > 0) return false;
|
||||
return orders.some((item) => item.plateNo === row.plateNo && item.bearBy === '客户承担');
|
||||
}
|
||||
|
||||
export function needsAccidentLinkWarning(
|
||||
row: LeaseLedgerRow,
|
||||
accidents: AccidentRecord[],
|
||||
): boolean {
|
||||
if (!row.isLatestBill) return false;
|
||||
if (row.linkedAccidentIds.length > 0) return false;
|
||||
return accidents.some((item) => item.plateNo === row.plateNo);
|
||||
}
|
||||
|
||||
export function enrichLeaseRow(
|
||||
row: LeaseLedgerRow,
|
||||
options?: {
|
||||
maintenanceOrders?: MaintenanceOrder[];
|
||||
accidentRecords?: AccidentRecord[];
|
||||
biMileage?: BiMileageRecord[];
|
||||
hydrogenFees?: HydrogenFeeRecord[];
|
||||
},
|
||||
): LeaseLedgerRow {
|
||||
const vehicle = PLATE_VEHICLE_MAP[row.plateNo];
|
||||
const contract = PLATE_CONTRACT_MAP[row.plateNo];
|
||||
const typeLine = row.vehicleType || '';
|
||||
let brand = row.vehicleBrand;
|
||||
let model = row.vehicleModel;
|
||||
if (!brand && typeLine) {
|
||||
const m = typeLine.match(/^([^\d/]+)(.*)$/);
|
||||
if (m?.[2]) {
|
||||
brand = m[1];
|
||||
model = m[2];
|
||||
} else {
|
||||
brand = typeLine;
|
||||
model = typeLine;
|
||||
}
|
||||
}
|
||||
|
||||
const cycleMonths = row.paymentCycleMonths || 1;
|
||||
const contractRent = row.contractRent;
|
||||
const monthlyRent = calcMonthlyRent(contractRent);
|
||||
const receivableRent = calcReceivableRent(contractRent, cycleMonths);
|
||||
const hasReceipt = row.receivedAmount > 0 && row.actualPaymentDate;
|
||||
const maintenanceOrders = options?.maintenanceOrders ?? [];
|
||||
const accidentRecords = options?.accidentRecords ?? [];
|
||||
const biMileage = options?.biMileage ?? [];
|
||||
const hydrogenFees = options?.hydrogenFees ?? [];
|
||||
const linkedMaintenanceIds = row.linkedMaintenanceIds ?? [];
|
||||
const linkedAccidentIds = row.linkedAccidentIds ?? [];
|
||||
const linkedReceiptIds = row.linkedReceiptIds ?? [];
|
||||
const maintenanceIncome = calcMaintenanceIncome(row);
|
||||
const opsFeeIncome = sumLinkedMaintenanceAmount(linkedMaintenanceIds, maintenanceOrders);
|
||||
const opsFeeCost = opsFeeIncome;
|
||||
const insuranceSurcharge = linkedAccidentIds.length > 0
|
||||
? sumLinkedAccidentSurcharge(linkedAccidentIds, accidentRecords)
|
||||
: row.insuranceSurcharge;
|
||||
const serviceFee = row.serviceFee ?? 0;
|
||||
const monthlyIncome = calcMonthlyIncome({
|
||||
monthlyRent,
|
||||
serviceFee,
|
||||
mileageDeduction: row.mileageDeduction,
|
||||
otherDeduction: row.otherDeduction,
|
||||
});
|
||||
const vehicleStandardCost = PLATE_STANDARD_COST[row.plateNo] ?? row.vehicleStandardCost;
|
||||
const vehicleActualCost = calcVehicleActualCost(vehicleStandardCost, row.avgDays || 30);
|
||||
const gpsMileage = lookupBiMileage(row.plateNo, row.year, row.month, biMileage) || row.gpsMileage;
|
||||
const hydrogenFeeSum = sumHydrogenFeeForBill(row, hydrogenFees);
|
||||
const hydrogenFee = row.hydrogenBorneBy === '我司承担' ? hydrogenFeeSum : row.hydrogenFee;
|
||||
const signingCompany = PLATE_SIGNING_COMPANY[row.plateNo] || row.signingCompany;
|
||||
const assetOwner = PLATE_ASSET_OWNER[row.plateNo] || row.assetOwner || '';
|
||||
|
||||
return {
|
||||
...row,
|
||||
vehicleBrand: brand || vehicle?.brand || '',
|
||||
vehicleModel: model || vehicle?.model || '',
|
||||
customerName: row.customerName || contract?.customerName || '',
|
||||
projectName: row.projectName || contract?.projectName || '',
|
||||
paymentCycleMonths: cycleMonths,
|
||||
paymentMethod: resolvePaymentMethod(cycleMonths),
|
||||
monthlyRent,
|
||||
receivableRent,
|
||||
serviceFee,
|
||||
linkedMaintenanceIds,
|
||||
linkedAccidentIds,
|
||||
linkedReceiptIds,
|
||||
maintenanceIncome,
|
||||
opsFeeIncome,
|
||||
opsFeeCost,
|
||||
insuranceSurcharge,
|
||||
monthlyIncome,
|
||||
vehicleStandardCost,
|
||||
vehicleActualCost,
|
||||
gpsMileage,
|
||||
hydrogenFee,
|
||||
signingCompany,
|
||||
assetOwner,
|
||||
assetOwnership: row.assetOwnership || PLATE_ASSET_OWNERSHIP[row.plateNo] || '自有',
|
||||
actualPaymentDate: hasReceipt ? row.actualPaymentDate : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function canStaffEditRow(row: LeaseLedgerRow): boolean {
|
||||
if (row.status === '草稿') return true;
|
||||
return IS_SUPERVISOR;
|
||||
}
|
||||
|
||||
export function canStaffDeleteRow(row: LeaseLedgerRow): boolean {
|
||||
return canStaffEditRow(row);
|
||||
}
|
||||
|
||||
export function sumLeaseKpi(rows: LeaseLedgerRow[]): LeaseKpiSummary {
|
||||
return rows.reduce(
|
||||
(acc, row) => ({
|
||||
count: acc.count + 1,
|
||||
receivableTotal: acc.receivableTotal + row.receivableTotal,
|
||||
receivedAmount: acc.receivedAmount + row.receivedAmount,
|
||||
unreceived: acc.unreceived + calcUnreceived(row),
|
||||
totalCost: acc.totalCost + row.totalCost,
|
||||
profitLoss: acc.profitLoss + row.profitLoss,
|
||||
}),
|
||||
{ count: 0, receivableTotal: 0, receivedAmount: 0, unreceived: 0, totalCost: 0, profitLoss: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export function applyLeaseFilters(rows: LeaseLedgerRow[], filters: LeaseLedgerFilters): LeaseLedgerRow[] {
|
||||
return rows.filter((row) => {
|
||||
if (filters.year && String(row.year) !== filters.year) return false;
|
||||
if (filters.month && String(row.month) !== filters.month) return false;
|
||||
if (filters.dept && row.dept !== filters.dept) return false;
|
||||
if (filters.salesman && row.salesman !== filters.salesman) return false;
|
||||
if (filters.plateNo && row.plateNo !== filters.plateNo) return false;
|
||||
if (filters.status && row.status !== filters.status) return false;
|
||||
if (filters.customerName && !row.customerName.includes(filters.customerName)) return false;
|
||||
if (filters.paymentFrom && row.paymentDate < filters.paymentFrom) return false;
|
||||
if (filters.paymentTo && row.paymentDate > filters.paymentTo) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyListViewFilter(
|
||||
rows: LeaseLedgerRow[],
|
||||
options: { showUnpaidOnly: boolean; listViewMode: ListViewMode },
|
||||
): LeaseLedgerRow[] {
|
||||
if (!options.showUnpaidOnly && options.listViewMode === 'all') return rows;
|
||||
return rows.filter(isUnpaidRow);
|
||||
}
|
||||
|
||||
export function buildLeaseOptions(rows: LeaseLedgerRow[], key: keyof LeaseLedgerRow): string[] {
|
||||
const set = new Set<string>();
|
||||
rows.forEach((row) => {
|
||||
const value = row[key];
|
||||
if (typeof value === 'string' && value.trim()) set.add(value);
|
||||
if (typeof value === 'number') set.add(String(value));
|
||||
});
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function extractReceiptPaymentDate(arrivalTime: string): string {
|
||||
return arrivalTime.split(' ')[0] || '';
|
||||
}
|
||||
|
||||
export function latestReceiptPaymentDate(arrivalTimes: string[]): string {
|
||||
const dates = arrivalTimes.map(extractReceiptPaymentDate).filter(Boolean).sort();
|
||||
return dates[dates.length - 1] ?? '';
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# 租赁合同管理 · 新增/编辑页 PRD
|
||||
|
||||
## 1. 页面结构
|
||||
|
||||
左右分栏工作区:
|
||||
|
||||
```text
|
||||
新增租赁合同
|
||||
├── 顶栏:返回列表
|
||||
├── 左侧编辑列(可滚动)
|
||||
│ ├── 主体合同信息(完成度徽章)
|
||||
│ ├── 附件1:租赁订单
|
||||
│ └── 授权委托书
|
||||
├── 右侧:实时预览(V26.6 完整合同 + 审批类型徽章)
|
||||
└── 底栏:取消 / 保存草稿 / 提交审核
|
||||
```
|
||||
|
||||
编辑页与新增页表单结构一致;编辑时回填已保存数据。
|
||||
|
||||
---
|
||||
|
||||
## 2. 主体合同信息
|
||||
|
||||
卡片内三个可折叠区块,标题行右侧展示区块说明(展开时显示在标题后方)。
|
||||
|
||||
### 2.1 签约信息
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|:---:|
|
||||
| 合同编号 | 是 | 前缀 `HT-ZL-` + 用户输入 |
|
||||
| 甲方 | 是 | 出租方签约主体,来自组织机构 |
|
||||
| 乙方 | 是 | 客户选择器;仅 A/B 级客户可选 |
|
||||
|
||||
选择甲方/乙方后,下方只读面板自动带出档案:
|
||||
|
||||
**甲方**:户名、开户行、账号、通讯地址、联系人、电话、邮箱。
|
||||
|
||||
**乙方**:企业名称、开户银行、银行账号、纳税人识别号、企业地址、企业电话、通讯地址、联系人、电话、邮箱;资质证照缩略图可点击放大。
|
||||
|
||||
未选双方时展示空状态引导。
|
||||
|
||||
### 2.2 里程标准
|
||||
|
||||
对应合同正文最低里程与租金减免条款。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| 是否有最低里程要求 | 开关 |
|
||||
| 统计周期 | 有要求时必选:月 / 季 / 年 |
|
||||
| 目标里程 | 有要求时必填(公里) |
|
||||
| 里程类型 | 多选标签:实际里程 / 仪表里程 等 |
|
||||
|
||||
### 2.3 费用信息
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|:---:|
|
||||
| 付款周期 | 是 | 如月付、季付 |
|
||||
| 氢费支付方式 | 是 | 后付 / 预付 |
|
||||
| 预付金额 | 条件 | 预付时必填 |
|
||||
| 提前付款工作日 | 条件 | 预付时必填 |
|
||||
| 还车氢量差单价 | 是 | 元 |
|
||||
|
||||
**完成度**:上述必填项均已填则卡片显示「已完成」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 附件1:租赁订单
|
||||
|
||||
| 区域 | 说明 |
|
||||
|---|---|
|
||||
| 投保车辆数 / 三者责任险(百万) | 三者责任险必填 |
|
||||
| 交车区域 | 省市区级联 |
|
||||
| 计划交车日期 | 日期 |
|
||||
| 订单车辆表 | 品牌型号级联、车牌、租期、租金、氢费、增值服务等 |
|
||||
|
||||
支持新增行、复制行、删除行;表头 KPI 展示合计。
|
||||
|
||||
**完成度**:三者责任险已填即视为完成(原型简化口径)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 授权委托书
|
||||
|
||||
被委托人表格:姓名、联系方式、身份证号。
|
||||
|
||||
支持新增行、删除行。
|
||||
|
||||
**完成度**:至少一行且三项均非空。
|
||||
|
||||
---
|
||||
|
||||
## 5. 实时预览
|
||||
|
||||
- 基于标准合同模板 V26.6 拼装 HTML,A4 分页展示。
|
||||
- 支持缩放(50%—150%)、连续预览 / 单页预览切换。
|
||||
- **可编辑预览**:合同正文 `contentEditable`;用户可直接改字。
|
||||
- 顶栏展示当前 **审批类型** 徽章。
|
||||
|
||||
### 5.1 占位符替换
|
||||
|
||||
左侧表单变更时,预览内 `{{contractCode}}`、`{{customerName}}` 等变量实时替换。
|
||||
|
||||
### 5.2 条件条款与附件章节
|
||||
|
||||
按租赁订单车辆品牌型号,过滤模板中条件条款与附件章节绑定(与合同模板管理规则一致)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 审批类型判定
|
||||
|
||||
| 类型 | 触发条件 |
|
||||
|---|---|
|
||||
| 标准合同审批 | 默认可编辑预览相对基线未改动风控红线条款 |
|
||||
| 非标准合同审批 | 预览中 `.ct-risk-redline[data-risk-redline="1"]` 正文相对基线有增删改 |
|
||||
|
||||
检测逻辑:`lease-contract-risk-detect.js` 对比基线 HTML 与当前合并页 HTML。
|
||||
|
||||
保存草稿 / 提交审核时 Toast 展示将走的审批类型(原型)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 底栏操作
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 取消 / 返回列表 | 不保存,回列表 |
|
||||
| 保存草稿 | 保存为草稿,审批类型随检测结果记录 |
|
||||
| 提交审核 | 校验必填后发起审批流程 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 与合同模板的关系
|
||||
|
||||
- 创建时默认引用 **标准合同模板**(合同模板管理 `isDefault=true` 的已发布版本)。
|
||||
- 模板内风控红线条款、条件条款、锁定区标记在预览中保留。
|
||||
- 业务侧仅编辑表单字段与允许编辑的预览正文;锁定区不可改(正式环境由编辑器约束)。
|
||||
@@ -0,0 +1,156 @@
|
||||
# 租赁合同管理 · 列表页 PRD
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 租赁合同管理 |
|
||||
| 所属系统 | ONE-OS 业务管理 |
|
||||
| 面包屑 | 业务管理 → 租赁合同管理 |
|
||||
| 目标用户 | 业务人员(主)、法务(盖章上传)、客服/运营(查看) |
|
||||
| 核心任务 | 筛选与查看租赁合同台账,发起新增/变更/续签等流程 |
|
||||
| 交互原型 | `/prototypes/lease-contract-management` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 筛选区
|
||||
|
||||
默认展示 **第一行 4 个筛选项**(3 列栅格),点击「展开更多筛选项」显示全部 12 项;支持「重置」「查询」。
|
||||
|
||||
| 字段 | 控件 | 说明 |
|
||||
|---|---|---|
|
||||
| 合同编码 | 可搜索下拉 | 模糊匹配合同编码 |
|
||||
| 项目名称 | 可搜索下拉 | 模糊匹配项目名称 |
|
||||
| 客户名称 | 可搜索下拉 | 模糊匹配客户名称 |
|
||||
| 签约公司 | 下拉 | 租赁合同签约公司枚举 |
|
||||
| 审批状态 | 多选下拉 | 全部 / 待审批 / 审批中 / 审批通过 / 审批驳回 / 未提交 |
|
||||
| 合同状态 | 多选下拉 | 全部 / 草稿 / 变更 / 合同进行中 / 到期合同 / 已提交审批 / 已结束 |
|
||||
| 业务部门 | 多选下拉 | 业务相关部门 |
|
||||
| 业务负责人 | 多选下拉 | 业务组人员 |
|
||||
| 合同类型 | 多选下拉 | 全部 / 正式合同 / 试用合同 |
|
||||
| 合同审批类型 | 多选下拉 | 全部 / 标准合同审批 / 非标准合同审批 |
|
||||
| 创建人 | 多选下拉 | 业务相关人员 |
|
||||
| 合同结束日期 | 日期范围 | 开始—结束,精确至日 |
|
||||
|
||||
**交互**:
|
||||
|
||||
- **查询**:将当前筛选条件写入「已应用筛选」,列表与 KPI 统计联动。
|
||||
- **重置**:清空为默认(多选类默认「全部」),并刷新列表。
|
||||
|
||||
---
|
||||
|
||||
## 5. KPI 统计卡片
|
||||
|
||||
共 5 张卡片,点击切换列表筛选(与筛选区叠加生效)。
|
||||
|
||||
| 卡片 | 口径 |
|
||||
|---|---|
|
||||
| 全部合同 | 不做 KPI 维度过滤 |
|
||||
| 进行中 | 合同状态为「合同进行中」且结束日期 ≥ 今日 |
|
||||
| 已终止 | 合同状态为「已终止」或「已结束」 |
|
||||
| 临期合同 | 距离合同结束日期 ≤ 30 天且未过期 |
|
||||
| 过期合同 | 合同状态为「已过期」或「到期合同」 |
|
||||
|
||||
卡片右上角 `?` 悬停展示口径说明。
|
||||
|
||||
---
|
||||
|
||||
## 6. 列表工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 租赁费用模板 | 跳转或打开费用模板配置(本期原型 Toast) |
|
||||
| 导出 | 按当前筛选结果导出(本期原型 Toast) |
|
||||
| 新增 | 进入「新增租赁合同」页 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 列表字段说明
|
||||
|
||||
### 7.1 项目信息(合并列)
|
||||
|
||||
一列展示三行:
|
||||
|
||||
1. 项目名称(主文案)
|
||||
2. 合同编码(次级)
|
||||
3. 客户名称(次级)
|
||||
|
||||
点击整格跳转「查看」页(原型 Toast)。
|
||||
|
||||
### 7.2 车辆与交车
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 车辆数 | 租赁订单车辆总数;点击弹出车辆明细表 |
|
||||
| 已交车 | 已有实际交车日期的车辆数;点击弹出已交车列表 |
|
||||
|
||||
车辆明细字段:车辆类型、品牌、型号、车牌号、实际交车日期、交车人。
|
||||
|
||||
### 7.3 状态与业务字段
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 审批状态 | 待审批 / 审批中 / 审批通过 / 审批驳回 / 未提交;「审批中」悬停展示审批流节点 |
|
||||
| 合同状态 | 草稿 / 变更 / 合同进行中 / 到期合同 / 已提交审批 / 已结束 |
|
||||
| 签约公司 | 创建时所选甲方签约主体简称 |
|
||||
| 业务部门 / 业务负责人 | 创建时选取 |
|
||||
| 合同类型 | 正式合同 / 试用合同 |
|
||||
| 合同审批类型 | 标准合同审批 / 非标准合同审批 |
|
||||
| 合同结束日期 | YYYY-MM-DD |
|
||||
| 客户联系人 / 联系电话 | 来自客户档案 |
|
||||
| 创建人 / 创建时间 | 精确至分钟 |
|
||||
| 更新人 / 最后更新时间 | 无则显示 `-` |
|
||||
| 备注 | 超长省略 |
|
||||
|
||||
**分页**:每页 10 / 20 / 50 条可选。
|
||||
|
||||
---
|
||||
|
||||
## 8. 操作与弹窗
|
||||
|
||||
### 8.1 操作列(主按钮 + 更多)
|
||||
|
||||
| 操作 | 显示条件 | 行为 |
|
||||
|---|---|---|
|
||||
| 查看 | 始终 | 跳转合同查看页 |
|
||||
| 编辑 | 草稿;或审批驳回 | 进入编辑页 |
|
||||
| 删除合同 | 草稿 | 二次确认后删除 |
|
||||
| 撤回合同 | 已提交审批且非驳回 | 二次确认后撤回 |
|
||||
| 新增车辆 | 合同进行中 | 租赁订单内增车并重新审批 |
|
||||
| 续签合同 | 进行中 / 到期 | 跳转续签页 |
|
||||
| 添加被授权人 | 合同进行中 | 弹窗编辑被授权人列表 |
|
||||
| 附加费用 | 合同进行中 | 弹窗按车辆录入附加费用 |
|
||||
| 变更为三方合同 | 合同进行中 | 跳转变更页 |
|
||||
| 转正式合同 | 试用合同 | 跳转转正式页 |
|
||||
| 终止合同 | 合同进行中 | 二次确认,提交终止审批 |
|
||||
| 上传盖章合同 | 审批通过且法务未上传 | 仅法务部可见;多文件上传后关闭入口 |
|
||||
|
||||
### 8.2 审批状态口径
|
||||
|
||||
| 状态 | 定义 |
|
||||
|---|---|
|
||||
| 未提交 | 仅保存草稿 |
|
||||
| 待审批 | 已提交,尚无节点通过 |
|
||||
| 审批中 | 已有节点通过,未完成终审 |
|
||||
| 审批通过 | 终审通过 |
|
||||
| 审批驳回 | 任一节点的驳回;可编辑重提 |
|
||||
|
||||
### 8.3 合同状态口径
|
||||
|
||||
| 状态 | 定义 |
|
||||
|---|---|
|
||||
| 草稿 | 未提交审批 |
|
||||
| 已提交审批 | 初次提交,终审未完成 |
|
||||
| 变更 | 已通过基础上变更并重新审批中 |
|
||||
| 合同进行中 | 终审通过且在有效期内 |
|
||||
| 到期合同 | 已过结束日期 |
|
||||
| 已结束 | 终止或自然结束 |
|
||||
|
||||
### 8.4 弹窗摘要
|
||||
|
||||
- **删除草稿**:「是否确认删除该合同草稿」
|
||||
- **撤回**:「是否确认撤回该合同」
|
||||
- **终止**:「是否确认终止合同」→ 提交审核
|
||||
- **添加被授权人**:姓名、电话、身份证;支持增删行
|
||||
- **附加费用**:按车辆展示类型/品牌/型号/车牌,可填服务项目、费用(元,2 位小数)、生效日期
|
||||
- **上传盖章合同**:拖拽多文件;确认后标记已上传
|
||||
@@ -1,12 +1,18 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, 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 {
|
||||
resolveContractApprovalType,
|
||||
STANDARD_CONTRACT_APPROVAL,
|
||||
NONSTANDARD_CONTRACT_APPROVAL,
|
||||
} from './lease-contract-risk-detect.js';
|
||||
import {
|
||||
isMainContractFormComplete,
|
||||
isLeaseOrderFormComplete,
|
||||
isPowerOfAttorneyFormComplete,
|
||||
isPowerOfAttorneyOptionalEmpty,
|
||||
} from './lease-contract-form-validation.js';
|
||||
import LeaseContractPreviewPanel from './LeaseContractPreviewPanel.jsx';
|
||||
import LeaseContractEditorForm, {
|
||||
@@ -31,7 +37,10 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
deliveryRegion: initial.deliveryRegion.slice(),
|
||||
deliveryDate: initial.deliveryDate,
|
||||
rows: initial.rows.map(function (row) {
|
||||
return Object.assign({}, row, { brandModel: row.brandModel ? row.brandModel.slice() : [] });
|
||||
return Object.assign({}, row, {
|
||||
brandModels: row.brandModels ? row.brandModels.slice() : [],
|
||||
plateNos: row.plateNos ? row.plateNos.slice() : undefined,
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -57,6 +66,9 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
});
|
||||
var powerOfAttorney = powerOfAttorneyState[0];
|
||||
var setPowerOfAttorney = powerOfAttorneyState[1];
|
||||
var contractApprovalTypeState = useState(STANDARD_CONTRACT_APPROVAL);
|
||||
var contractApprovalType = contractApprovalTypeState[0];
|
||||
var setContractApprovalType = contractApprovalTypeState[1];
|
||||
|
||||
var previewHtml = useMemo(function () {
|
||||
return buildLeaseContractPreviewHtml({
|
||||
@@ -66,8 +78,19 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
feeInfo: feeInfo,
|
||||
contractCode: contractCode,
|
||||
leaseOrder: leaseOrder,
|
||||
powerOfAttorney: powerOfAttorney,
|
||||
});
|
||||
}, [lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder]);
|
||||
}, [lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney]);
|
||||
|
||||
var handlePreviewContentChange = useCallback(function (mergedHtml) {
|
||||
setContractApprovalType(resolveContractApprovalType(previewHtml, mergedHtml));
|
||||
}, [previewHtml]);
|
||||
|
||||
useEffect(function () {
|
||||
setContractApprovalType(STANDARD_CONTRACT_APPROVAL);
|
||||
}, [previewHtml]);
|
||||
|
||||
var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL;
|
||||
|
||||
var isMainContractComplete = useMemo(function () {
|
||||
return isMainContractFormComplete({
|
||||
@@ -87,6 +110,10 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
return isPowerOfAttorneyFormComplete({ powerOfAttorney: powerOfAttorney });
|
||||
}, [powerOfAttorney]);
|
||||
|
||||
var isPowerOfAttorneyEmpty = useMemo(function () {
|
||||
return isPowerOfAttorneyOptionalEmpty({ 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', {
|
||||
@@ -100,8 +127,8 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
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('section', { className: 'ct-editor-column-card lc-main-contract-card', 'aria-label': '主体合同信息', 'data-annotation-id': 'lc-create-main-contract' },
|
||||
React.createElement('div', { className: 'ct-panel-head lc-main-contract-card__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'),
|
||||
@@ -109,7 +136,7 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
'aria-live': 'polite',
|
||||
}, isMainContractComplete ? '已完成' : '未完成'),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel' },
|
||||
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel lc-main-contract-panel' },
|
||||
React.createElement(LeaseContractEditorForm, {
|
||||
lessorId: lessorId,
|
||||
customerId: customerId,
|
||||
@@ -124,7 +151,7 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
}),
|
||||
),
|
||||
),
|
||||
React.createElement('section', { className: 'ct-editor-column-card', 'aria-label': '附件1:租赁订单' },
|
||||
React.createElement('section', { className: 'ct-editor-column-card', 'aria-label': '附件1:租赁订单', 'data-annotation-id': 'lc-create-lease-order' },
|
||||
React.createElement('div', { className: 'ct-panel-head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '附件1:租赁订单'),
|
||||
React.createElement('span', {
|
||||
@@ -140,14 +167,17 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
}),
|
||||
),
|
||||
),
|
||||
React.createElement('section', { className: 'ct-editor-column-card', 'aria-label': '授权委托书' },
|
||||
React.createElement('section', { className: 'ct-editor-column-card', 'aria-label': '授权委托书', 'data-annotation-id': 'lc-create-poa' },
|
||||
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'),
|
||||
className: 'lc-form-completion-badge '
|
||||
+ (isPowerOfAttorneyEmpty
|
||||
? 'lc-form-completion-badge--optional'
|
||||
: (isPowerOfAttorneyComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending')),
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, isPowerOfAttorneyComplete ? '已完成' : '未完成'),
|
||||
}, isPowerOfAttorneyEmpty ? '选填' : (isPowerOfAttorneyComplete ? '已完成' : '未完成')),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel' },
|
||||
React.createElement(LeaseContractPowerOfAttorneySection, {
|
||||
@@ -162,17 +192,28 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card ct-editor-column-card--preview',
|
||||
'aria-label': '实时预览',
|
||||
'data-annotation-id': 'lc-create-preview',
|
||||
},
|
||||
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-panel-head__actions' },
|
||||
React.createElement('span', {
|
||||
className: 'lc-approval-type-badge'
|
||||
+ (isNonStandardApproval ? ' lc-approval-type-badge--nonstd' : ' lc-approval-type-badge--standard'),
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, '审批:' + contractApprovalType),
|
||||
),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-preview-panel ct-preview-panel--word' },
|
||||
React.createElement(LeaseContractPreviewPanel, {
|
||||
html: previewHtml,
|
||||
zoom: workspaceZoom,
|
||||
onZoomChange: setWorkspaceZoom,
|
||||
editable: true,
|
||||
onContentChange: handlePreviewContentChange,
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -180,7 +221,7 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
),
|
||||
),
|
||||
),
|
||||
React.createElement('div', { className: 'lc-create-footer' },
|
||||
React.createElement('div', { className: 'lc-create-footer', 'data-annotation-id': 'lc-create-footer' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost',
|
||||
@@ -189,12 +230,16 @@ export default function LeaseContractCreate({ onBack }) {
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary',
|
||||
onClick: function () { message.success('已保存草稿(原型)'); },
|
||||
onClick: function () {
|
||||
message.success('已保存草稿(' + contractApprovalType + ',原型)');
|
||||
},
|
||||
}, '保存草稿'),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-primary',
|
||||
onClick: function () { message.info('提交审核(原型)'); },
|
||||
onClick: function () {
|
||||
message.info('提交审核:' + contractApprovalType + '(原型)');
|
||||
},
|
||||
}, '提交审核'),
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,14 @@
|
||||
// 设计规范:oneos-app.css · 样式前缀 lc-*
|
||||
|
||||
import LeaseContractCreate from './LeaseContractCreate.jsx';
|
||||
import {
|
||||
LEASE_CONTRACT_LIST_RECORDS,
|
||||
formatMileageSummary,
|
||||
formatDeliveryRegion,
|
||||
} from './lease-contract-list-data.js';
|
||||
|
||||
const Component = function() {
|
||||
const Component = function(props) {
|
||||
var useEffect = React.useEffect;
|
||||
var ONEOS_ANT_TABLE_GLOBAL_FIX = [
|
||||
'.ant-table-container .ant-table-header { margin-bottom: 0 !important; }',
|
||||
'.ant-table-container .ant-table-body { margin-top: 0 !important; }',
|
||||
@@ -88,6 +94,21 @@ const Component = function() {
|
||||
// 上传盖章合同完成后,按合同 id 记录已上传(原型:与列表 legalStampedContractUploaded 合并判断)
|
||||
var _stampedUploadedOverride = useState({});
|
||||
|
||||
useEffect(function() {
|
||||
if (props && props.onPageViewChange) {
|
||||
props.onPageViewChange(_view[0] === 'create' ? 'create' : 'list');
|
||||
}
|
||||
}, [_view[0]]);
|
||||
|
||||
useEffect(function() {
|
||||
if (props && props.navigationRef) {
|
||||
props.navigationRef.current = {
|
||||
openCreate: function() { _view[1]('create'); },
|
||||
backToList: function() { _view[1]('list'); },
|
||||
};
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
// 联调时:当前登录用户所属部门为法务部时为 true(原型默认 true 便于演示「上传盖章合同」)
|
||||
var isLegalDeptUser = true;
|
||||
|
||||
@@ -161,296 +182,8 @@ const Component = function() {
|
||||
{ value: '钱专员', label: '钱专员' }
|
||||
];
|
||||
|
||||
// 模拟列表数据:按审批状态 × 合同状态组合生成样例
|
||||
// 合同状态:草稿、变更、合同进行中、到期合同、已提交审批、已结束
|
||||
var rawList = [
|
||||
// 1. 未提交 + 草稿(仅保存未提交)
|
||||
{
|
||||
id: '1',
|
||||
contractCode: 'HT-ZL-2025-001',
|
||||
projectName: '嘉兴氢能示范项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '4.5吨冷链车-轻型厢式货车', brand: '品牌A', model: '型号A1', plateNo: '浙A12345', actualDelivery: '2025-01-10 09:00', deliveryPerson: '张运维' },
|
||||
{ vehicleType: '18吨厢式货车-重型厢式货车', brand: '品牌B', model: '型号B1', plateNo: '-', actualDelivery: '2025-01-12 14:30', deliveryPerson: '李运维' }
|
||||
],
|
||||
approvalStatus: '未提交',
|
||||
contractStatus: '草稿',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
contractEndDate: '2026-02-16',
|
||||
contactName: '张三',
|
||||
contactPhone: '13800138001',
|
||||
creator: '张经理',
|
||||
createTime: '2025-01-05 10:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '草稿待完善',
|
||||
legalStampedContractUploaded: undefined
|
||||
},
|
||||
// 2. 未提交 + 草稿(试用合同)
|
||||
{
|
||||
id: '2',
|
||||
contractCode: 'HT-ZL-2025-002',
|
||||
projectName: '上海物流租赁项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '公务用车/小客车-小型普通客车', brand: '品牌C', model: '型号C1', plateNo: '沪D66666', actualDelivery: '2025-02-01 11:00', deliveryPerson: '王运维' }
|
||||
],
|
||||
approvalStatus: '未提交',
|
||||
contractStatus: '草稿',
|
||||
customerName: '上海某某运输公司',
|
||||
signingCompany: '上海羚牛',
|
||||
businessDept: '业务2部',
|
||||
businessOwner: '李专员',
|
||||
contractType: '试用合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
contractEndDate: '2025-08-01',
|
||||
contactName: '李四',
|
||||
contactPhone: '13800138002',
|
||||
creator: '李专员',
|
||||
createTime: '2025-02-10 09:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '试用期 3 个月',
|
||||
legalStampedContractUploaded: undefined
|
||||
},
|
||||
// 3. 待审批 + 已提交审批(初次提交,尚未有任何节点审批)
|
||||
{
|
||||
id: '3',
|
||||
contractCode: 'HT-ZL-2025-003',
|
||||
projectName: '杭州城配租赁项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '4.5吨货车-轻型厢式货车', brand: '品牌A', model: '型号A2', plateNo: '浙B20002', actualDelivery: '2025-02-15 08:30', deliveryPerson: '赵运维' }
|
||||
],
|
||||
approvalStatus: '待审批',
|
||||
contractStatus: '已提交审批',
|
||||
customerName: '杭州某某租赁有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务3部',
|
||||
businessOwner: '王专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
contractEndDate: '2026-06-30',
|
||||
contactName: '王五',
|
||||
contactPhone: '13800138003',
|
||||
creator: '王专员',
|
||||
createTime: '2025-02-12 11:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: undefined
|
||||
},
|
||||
// 4. 审批中 + 已提交审批(已有节点审批,未走完)
|
||||
{
|
||||
id: '4',
|
||||
contractCode: 'HT-ZL-2025-004',
|
||||
projectName: '宁波冷链运输项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '18吨双飞翼货车-重型厢式货车', brand: '品牌B', model: '型号B2', plateNo: '-', actualDelivery: '2025-02-16 10:00', deliveryPerson: '钱运维' },
|
||||
{ vehicleType: '49吨牵引车头-重型半挂牵引车', brand: '品牌D', model: '型号D1', plateNo: '浙C30003', actualDelivery: '2025-02-18 14:00', deliveryPerson: '孙运维' }
|
||||
],
|
||||
approvalStatus: '审批中',
|
||||
contractStatus: '已提交审批',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
contractEndDate: '2026-03-01',
|
||||
contactName: '赵六',
|
||||
contactPhone: '13900139001',
|
||||
creator: '张经理',
|
||||
createTime: '2025-02-14 09:00',
|
||||
updater: '李专员',
|
||||
updateTime: '2025-02-15 16:00',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: undefined,
|
||||
approvalFlowNodes: [
|
||||
{ nodeTitle: '业务服务主管审批', result: 'passed', operatorName: '姚守涛', operatorTime: '2026-04-29 17:57:15' },
|
||||
{ nodeTitle: '发起审批', result: 'passed', operatorName: '超级用户', operatorTime: '2026-04-28 17:44:45' },
|
||||
{ nodeTitle: '业务负责人审批', result: 'pending', pendingApprovers: ['超级用户', '金可鹏'] }
|
||||
]
|
||||
},
|
||||
// 5. 审批中 + 变更(已通过审批后做了变更并重新提交)
|
||||
{
|
||||
id: '5',
|
||||
contractCode: 'HT-ZL-2025-005',
|
||||
projectName: '苏州城配试点项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '重型平板半挂车-重型平板半挂车', brand: '品牌D', model: '型号D2', plateNo: '苏E50005', actualDelivery: '2025-02-20 09:00', deliveryPerson: '周运维' }
|
||||
],
|
||||
approvalStatus: '审批中',
|
||||
contractStatus: '变更',
|
||||
customerName: '上海某某运输公司',
|
||||
signingCompany: '上海羚牛',
|
||||
businessDept: '业务2部',
|
||||
businessOwner: '李专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
contractEndDate: '2026-05-31',
|
||||
contactName: '孙七',
|
||||
contactPhone: '13900139002',
|
||||
creator: '李专员',
|
||||
createTime: '2025-02-18 10:00',
|
||||
updater: '李专员',
|
||||
updateTime: '2025-02-22 14:00',
|
||||
remark: '变更车辆数量',
|
||||
legalStampedContractUploaded: undefined,
|
||||
approvalFlowNodes: [
|
||||
{ nodeTitle: '法务审核', result: 'passed', operatorName: '李法务', operatorTime: '2025-02-21 18:00:00' },
|
||||
{ nodeTitle: '发起审批', result: 'passed', operatorName: '李专员', operatorTime: '2025-02-22 10:00:00' },
|
||||
{ nodeTitle: '业务负责人审批', result: 'pending', pendingApprovers: ['张经理', '赵总监'] }
|
||||
]
|
||||
},
|
||||
// 6. 审批通过 + 合同进行中(正式合同)
|
||||
{
|
||||
id: '6',
|
||||
contractCode: 'HT-ZL-2025-006',
|
||||
projectName: '南京氢能示范项目',
|
||||
vehicleCount: 3,
|
||||
vehicles: [
|
||||
{ vehicleType: '4.5吨冷链车-轻型厢式货车', brand: '品牌A', model: '型号A1', plateNo: '苏A60006', actualDelivery: '2025-01-20 08:00', deliveryPerson: '吴运维' },
|
||||
{ vehicleType: '18吨厢式货车-重型厢式货车', brand: '品牌B', model: '型号B1', plateNo: '苏A60007', actualDelivery: '2025-01-21 10:00', deliveryPerson: '郑运维' },
|
||||
{ vehicleType: '35吨牵引车头-重型半挂牵引车', brand: '品牌D', model: '型号D1', plateNo: '苏A60008', actualDelivery: '2025-01-22 14:00', deliveryPerson: '冯运维' }
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '合同进行中',
|
||||
customerName: '杭州某某租赁有限公司',
|
||||
signingCompany: '广东羚牛',
|
||||
businessDept: '业务3部',
|
||||
businessOwner: '王专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
contractEndDate: '2026-12-31',
|
||||
contactName: '周八',
|
||||
contactPhone: '13900139003',
|
||||
creator: '王专员',
|
||||
createTime: '2025-01-15 09:00',
|
||||
updater: '王专员',
|
||||
updateTime: '2025-01-25 11:00',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: true
|
||||
},
|
||||
// 7. 审批通过 + 合同进行中(试用合同,可转正式);法务未上传盖章件时「更多」显示上传盖章合同(演示仅此合同编码为未上传)
|
||||
{
|
||||
id: '7',
|
||||
contractCode: 'HT-ZL-2025-007',
|
||||
projectName: '无锡试用租赁项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '公务用车/小客车-小型普通客车', brand: '品牌C', model: '型号C2', plateNo: '苏B70007', actualDelivery: '2025-02-01 09:30', deliveryPerson: '陈运维' }
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '合同进行中',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '试用合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
contractEndDate: '2025-07-10',
|
||||
contactName: '吴九',
|
||||
contactPhone: '13900139004',
|
||||
creator: '张经理',
|
||||
createTime: '2025-01-28 10:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '试用 3 个月',
|
||||
legalStampedContractUploaded: false
|
||||
},
|
||||
// 8. 审批驳回 + 已提交审批(可编辑和重新提交)
|
||||
{
|
||||
id: '8',
|
||||
contractCode: 'HT-ZL-2025-008',
|
||||
projectName: '常州物流合作项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '重型集装箱半挂车-重型集装箱半挂车', brand: '品牌D', model: '型号D1', plateNo: '-', actualDelivery: '-' },
|
||||
{ vehicleType: '4.5吨货车-轻型厢式货车', brand: '品牌A', model: '型号A2', plateNo: '-', actualDelivery: '-' }
|
||||
],
|
||||
approvalStatus: '审批驳回',
|
||||
contractStatus: '已提交审批',
|
||||
customerName: '上海某某运输公司',
|
||||
signingCompany: '上海羚牛',
|
||||
businessDept: '业务2部',
|
||||
businessOwner: '李专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
contractEndDate: '2026-08-15',
|
||||
contactName: '郑十',
|
||||
contactPhone: '13900139005',
|
||||
creator: '李专员',
|
||||
createTime: '2025-02-20 14:00',
|
||||
updater: '李专员',
|
||||
updateTime: '2025-02-23 09:00',
|
||||
remark: '驳回原因:费用条款需调整',
|
||||
legalStampedContractUploaded: undefined
|
||||
},
|
||||
// 9. 审批通过 + 到期合同(审批已通过但合同结束日期已过)
|
||||
{
|
||||
id: '9',
|
||||
contractCode: 'HT-ZL-2024-009',
|
||||
projectName: '南通去年到期项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '18吨双飞翼货车-重型厢式货车', brand: '品牌B', model: '型号B1', plateNo: '苏F90009', actualDelivery: '2024-03-01 09:00', deliveryPerson: '褚运维' },
|
||||
{ vehicleType: '4.5吨冷链车-轻型厢式货车', brand: '品牌A', model: '型号A1', plateNo: '苏F90010', actualDelivery: '2024-03-02 10:00', deliveryPerson: '卫运维' }
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '到期合同',
|
||||
customerName: '杭州某某租赁有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务3部',
|
||||
businessOwner: '王专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
contractEndDate: '2024-12-31',
|
||||
contactName: '王五',
|
||||
contactPhone: '13800138003',
|
||||
creator: '王专员',
|
||||
createTime: '2024-02-20 10:00',
|
||||
updater: '王专员',
|
||||
updateTime: '2024-12-20 16:00',
|
||||
remark: '已到期可续签',
|
||||
legalStampedContractUploaded: true
|
||||
},
|
||||
// 10. 审批通过 + 已结束(操作列终止合同并完成审核)
|
||||
{
|
||||
id: '10',
|
||||
contractCode: 'HT-ZL-2024-010',
|
||||
projectName: '镇江到期合同项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '公务用车/小客车-小型普通客车', brand: '品牌C', model: '型号C1', plateNo: '苏L00100', actualDelivery: '2024-06-01 11:00', deliveryPerson: '蒋运维' }
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '已结束',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
contractEndDate: '2025-01-15',
|
||||
contactName: '张三',
|
||||
contactPhone: '13800138001',
|
||||
creator: '张经理',
|
||||
createTime: '2024-05-20 09:00',
|
||||
updater: '张经理',
|
||||
updateTime: '2025-01-10 14:00',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: true
|
||||
}
|
||||
];
|
||||
// 列表样例:字段与新增页主体合同 / 租赁订单 / 授权委托书对齐
|
||||
var rawList = LEASE_CONTRACT_LIST_RECORDS;
|
||||
|
||||
var KPI_CARDS = [
|
||||
{ key: 'all', title: '全部合同', desc: '显示全部合同' },
|
||||
@@ -1036,7 +769,7 @@ const Component = function() {
|
||||
if (status === '合同进行中') {
|
||||
items.push({ key: 'addVehicle', label: '新增车辆', onClick: function() { message.info('新增车辆(原型)'); } });
|
||||
items.push({ key: 'renew', label: '续签合同', onClick: function() { message.info('续签合同(原型)'); } });
|
||||
items.push({ key: 'authorized', label: '添加被授权人', onClick: function() { _authorizedModalRecord[1](record); _authorizedList[1]([{ name: '', phone: '', idCard: '' }]); _authorizedModalVisible[1](true); } });
|
||||
items.push({ key: 'authorized', label: '添加授权委托书', onClick: function() { _authorizedModalRecord[1](record); _authorizedList[1]([{ name: '', phone: '', idCard: '' }]); _authorizedModalVisible[1](true); } });
|
||||
items.push({ key: 'extraFee', label: '附加费用', onClick: function() {
|
||||
_extraFeeModalRecord[1](record);
|
||||
var vehicles = record.vehicles || [];
|
||||
@@ -1110,6 +843,52 @@ const Component = function() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderBusinessDeptCell(_, record) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'vm-stack' },
|
||||
React.createElement('span', { className: 'primary' }, record.businessDept || '-'),
|
||||
React.createElement('span', { className: 'sub' }, record.businessOwner || '-')
|
||||
);
|
||||
}
|
||||
|
||||
function renderStackCell(primary, sub, subClassName) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'vm-stack' },
|
||||
React.createElement('span', { className: 'primary' }, primary || '-'),
|
||||
React.createElement('span', { className: subClassName || 'sub' }, sub || '-')
|
||||
);
|
||||
}
|
||||
|
||||
function renderFeeTermsCell(_, record) {
|
||||
return renderStackCell(record.paymentPeriod, record.hydrogenPaymentMethod);
|
||||
}
|
||||
|
||||
function renderDeliveryCell(_, record) {
|
||||
return renderStackCell(formatDeliveryRegion(record.deliveryRegion), record.deliveryDate || '-');
|
||||
}
|
||||
|
||||
function renderLeaseOrderCell(_, record) {
|
||||
var insured = record.insuredVehicleCount != null ? '投保 ' + record.insuredVehicleCount + ' 辆' : '-';
|
||||
var liability = record.thirdPartyLiabilityMillion != null
|
||||
? '三者险 ' + record.thirdPartyLiabilityMillion + ' 百万'
|
||||
: '-';
|
||||
return renderStackCell(insured, liability);
|
||||
}
|
||||
|
||||
function renderContactCell(_, record) {
|
||||
return renderStackCell(record.contactName, record.contactPhone);
|
||||
}
|
||||
|
||||
function renderCreateAuditCell(_, record) {
|
||||
return renderStackCell(record.creator, record.createTime, 'code');
|
||||
}
|
||||
|
||||
function renderUpdateAuditCell(_, record) {
|
||||
return renderStackCell(record.updater, record.updateTime, 'code');
|
||||
}
|
||||
|
||||
function getOperationButtons(record) {
|
||||
var menuItems = getMoreMenuItems(record);
|
||||
var moreDropdown = menuItems.length > 0
|
||||
@@ -1242,18 +1021,70 @@ const Component = function() {
|
||||
},
|
||||
{ title: '合同状态', dataIndex: 'contractStatus', key: 'contractStatus', width: 110 },
|
||||
{ title: '签约公司', dataIndex: 'signingCompany', key: 'signingCompany', width: 100 },
|
||||
{ title: '业务部门', dataIndex: 'businessDept', key: 'businessDept', width: 100 },
|
||||
{ title: '业务负责人', dataIndex: 'businessOwner', key: 'businessOwner', width: 100 },
|
||||
{ title: '合同类型', dataIndex: 'contractType', key: 'contractType', width: 100 },
|
||||
{ title: '合同审批类型', dataIndex: 'contractApprovalType', key: 'contractApprovalType', width: 130 },
|
||||
{ title: '合同结束日期', dataIndex: 'contractEndDate', key: 'contractEndDate', width: 120 },
|
||||
{ title: '客户联系人', dataIndex: 'contactName', key: 'contactName', width: 100 },
|
||||
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', width: 120 },
|
||||
{ title: '创建人', dataIndex: 'creator', key: 'creator', width: 90 },
|
||||
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 150 },
|
||||
{ title: '更新人', dataIndex: 'updater', key: 'updater', width: 90 },
|
||||
{ title: '最后更新时间', dataIndex: 'updateTime', key: 'updateTime', width: 150 },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 100, ellipsis: true },
|
||||
{
|
||||
title: '业务部门',
|
||||
key: 'businessDept',
|
||||
width: 110,
|
||||
render: renderBusinessDeptCell
|
||||
},
|
||||
{
|
||||
title: '费用条款',
|
||||
key: 'feeTerms',
|
||||
width: 108,
|
||||
render: renderFeeTermsCell
|
||||
},
|
||||
{
|
||||
title: '交车安排',
|
||||
key: 'deliveryPlan',
|
||||
width: 128,
|
||||
render: renderDeliveryCell
|
||||
},
|
||||
{
|
||||
title: '租赁订单',
|
||||
key: 'leaseOrderSummary',
|
||||
width: 120,
|
||||
render: renderLeaseOrderCell
|
||||
},
|
||||
{
|
||||
title: '里程要求',
|
||||
key: 'mileageRequirement',
|
||||
width: 128,
|
||||
render: function(_, record) {
|
||||
return formatMileageSummary(record);
|
||||
}
|
||||
},
|
||||
{ title: '合同类型', dataIndex: 'contractType', key: 'contractType', width: 96 },
|
||||
{ title: '合同审批类型', dataIndex: 'contractApprovalType', key: 'contractApprovalType', width: 124 },
|
||||
{ title: '合同结束日期', dataIndex: 'contractEndDate', key: 'contractEndDate', width: 118 },
|
||||
{
|
||||
title: '客户联系人',
|
||||
key: 'contact',
|
||||
width: 118,
|
||||
render: renderContactCell
|
||||
},
|
||||
{
|
||||
title: '被授权人',
|
||||
key: 'delegateCount',
|
||||
width: 88,
|
||||
className: 'tabular-nums',
|
||||
render: function(_, record) {
|
||||
var count = record.delegateCount != null ? record.delegateCount : 0;
|
||||
return count > 0 ? count + ' 人' : '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建信息',
|
||||
key: 'createAudit',
|
||||
width: 148,
|
||||
render: renderCreateAuditCell
|
||||
},
|
||||
{
|
||||
title: '更新信息',
|
||||
key: 'updateAudit',
|
||||
width: 148,
|
||||
render: renderUpdateAuditCell
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 120, ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 140, fixed: 'right', render: function(_, record) { return getOperationButtons(record); } }
|
||||
];
|
||||
|
||||
@@ -1441,7 +1272,7 @@ const Component = function() {
|
||||
'查看需求说明'
|
||||
)
|
||||
),
|
||||
React.createElement('section', { className: 'vm-filter-card' },
|
||||
React.createElement('section', { className: 'vm-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' },
|
||||
React.createElement('header', { className: 'vm-filter-header' },
|
||||
React.createElement('h2', { className: 'vm-filter-title' }, '筛选条件')
|
||||
),
|
||||
@@ -1452,7 +1283,7 @@ const Component = function() {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-link',
|
||||
onClick: function() { _filterExpanded[1](!_filterExpanded[0]); }
|
||||
}, _filterExpanded[0] ? '收起' : '展开更多筛选项')
|
||||
}, _filterExpanded[0] ? '收起' : '更多筛选')
|
||||
: null,
|
||||
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-ghost', onClick: handleReset }, '重置'),
|
||||
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-primary', onClick: handleQuery },
|
||||
@@ -1475,7 +1306,7 @@ const Component = function() {
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vm-kpi-row', role: 'group', 'aria-label': '合同统计' },
|
||||
React.createElement('div', { className: 'vm-kpi-row', role: 'group', 'aria-label': '合同统计', 'data-annotation-id': 'lc-list-kpi' },
|
||||
KPI_CARDS.map(function(card) {
|
||||
return React.createElement('button', {
|
||||
key: card.key,
|
||||
@@ -1507,7 +1338,7 @@ const Component = function() {
|
||||
})
|
||||
),
|
||||
React.createElement('section', { className: 'vm-table-section' },
|
||||
React.createElement('div', { className: 'vm-table-toolbar' },
|
||||
React.createElement('div', { className: 'vm-table-toolbar', 'data-annotation-id': 'lc-list-toolbar' },
|
||||
React.createElement('div', { className: 'vm-table-actions' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
@@ -1544,7 +1375,7 @@ const Component = function() {
|
||||
}, '新增')
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vm-table-card' },
|
||||
React.createElement('div', { className: 'vm-table-card', 'data-annotation-id': 'lc-list-table' },
|
||||
React.createElement('div', { className: 'vm-table-wrap' },
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
@@ -1596,7 +1427,7 @@ const Component = function() {
|
||||
cancelText: '取消'
|
||||
}),
|
||||
React.createElement(Modal, {
|
||||
title: '添加被授权人',
|
||||
title: '添加授权委托书',
|
||||
open: _authorizedModalVisible[0],
|
||||
onCancel: function() { _authorizedModalVisible[1](false); _authorizedModalRecord[1](null); },
|
||||
width: 640,
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
import { paginateWordHtml } from './contract-word-preview.js';
|
||||
|
||||
var PREVIEW_ZOOM_STEPS = [50, 75, 90, 100, 110, 125, 150, 175, 200];
|
||||
|
||||
function EditablePreviewPageBody(props) {
|
||||
var pageIndex = props.pageIndex;
|
||||
var initialHtml = props.initialHtml;
|
||||
var onInput = props.onInput;
|
||||
var bodyRef = useRef(null);
|
||||
var seededHtmlRef = useRef('');
|
||||
|
||||
useEffect(function () {
|
||||
if (!bodyRef.current) return;
|
||||
if (seededHtmlRef.current === initialHtml) return;
|
||||
bodyRef.current.innerHTML = initialHtml || '';
|
||||
seededHtmlRef.current = initialHtml || '';
|
||||
}, [initialHtml]);
|
||||
|
||||
return React.createElement('div', {
|
||||
ref: bodyRef,
|
||||
className: 'ct-preview-page__body ct-word-doc lc-preview-page__body--editable',
|
||||
contentEditable: true,
|
||||
suppressContentEditableWarning: true,
|
||||
role: 'textbox',
|
||||
'aria-multiline': true,
|
||||
'aria-label': '合同预览正文,第 ' + (pageIndex + 1) + ' 页',
|
||||
onInput: function (event) {
|
||||
if (onInput) onInput(pageIndex, event.currentTarget.innerHTML);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function previewPagerIcon(name) {
|
||||
var common = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
@@ -29,12 +58,20 @@ function previewPagerIcon(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LeaseContractPreviewPanel({ html, zoom, onZoomChange }) {
|
||||
var pagesState = useState([]);
|
||||
export default function LeaseContractPreviewPanel({
|
||||
html,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
editable,
|
||||
onContentChange,
|
||||
}) {
|
||||
var baselinePagesState = useState([]);
|
||||
var pageIdxState = useState(0);
|
||||
var viewModeState = useState('scroll');
|
||||
var internalZoomState = useState(100);
|
||||
var pages = pagesState[0];
|
||||
var baselinePages = baselinePagesState[0];
|
||||
var setBaselinePages = baselinePagesState[1];
|
||||
var draftPagesRef = useRef([]);
|
||||
var pageIdx = pageIdxState[0];
|
||||
var setPageIdx = pageIdxState[1];
|
||||
var viewMode = viewModeState[0];
|
||||
@@ -44,16 +81,32 @@ export default function LeaseContractPreviewPanel({ html, zoom, onZoomChange })
|
||||
|
||||
useEffect(function () {
|
||||
if (!html) {
|
||||
pagesState[1]([]);
|
||||
setBaselinePages([]);
|
||||
draftPagesRef.current = [];
|
||||
return;
|
||||
}
|
||||
var timer = window.setTimeout(function () {
|
||||
pagesState[1](paginateWordHtml(html));
|
||||
var nextPages = paginateWordHtml(html);
|
||||
setBaselinePages(nextPages);
|
||||
draftPagesRef.current = nextPages.slice();
|
||||
pageIdxState[1](0);
|
||||
notifyContentChange(nextPages);
|
||||
}, 0);
|
||||
return function () { window.clearTimeout(timer); };
|
||||
}, [html]);
|
||||
|
||||
function notifyContentChange(nextPages) {
|
||||
if (!onContentChange) return;
|
||||
onContentChange((nextPages || []).join(''));
|
||||
}
|
||||
|
||||
function handlePageInput(pageIndex, nextHtml) {
|
||||
var draft = draftPagesRef.current ? draftPagesRef.current.slice() : [];
|
||||
draft[pageIndex] = nextHtml;
|
||||
draftPagesRef.current = draft;
|
||||
notifyContentChange(draft);
|
||||
}
|
||||
|
||||
function stepPreviewZoom(direction) {
|
||||
setPreviewZoom(function (current) {
|
||||
var idx = PREVIEW_ZOOM_STEPS.indexOf(current);
|
||||
@@ -78,7 +131,7 @@ export default function LeaseContractPreviewPanel({ html, zoom, onZoomChange })
|
||||
);
|
||||
}
|
||||
|
||||
if (!pages.length) {
|
||||
if (!baselinePages.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' },
|
||||
@@ -90,6 +143,31 @@ export default function LeaseContractPreviewPanel({ html, zoom, onZoomChange })
|
||||
);
|
||||
}
|
||||
|
||||
var editableHint = editable
|
||||
? React.createElement('div', {
|
||||
className: 'lc-preview-editable-hint',
|
||||
role: 'note',
|
||||
'data-annotation-id': 'lc-preview-editable-hint',
|
||||
},
|
||||
React.createElement('div', { className: 'lc-preview-editable-hint__card' },
|
||||
React.createElement(Info, {
|
||||
size: 16,
|
||||
className: 'lc-preview-editable-hint__icon',
|
||||
'aria-hidden': true,
|
||||
}),
|
||||
React.createElement('p', { className: 'lc-preview-editable-hint__text' },
|
||||
'预览正文可直接修改。若修改了',
|
||||
React.createElement('span', { className: 'lc-preview-editable-hint__risk' }, '风控红线条款'),
|
||||
',提交时将自动走',
|
||||
React.createElement('span', { className: 'lc-preview-editable-hint__tag lc-preview-editable-hint__tag--nonstd' }, '非标准合同审批'),
|
||||
';未改动风控条款则走',
|
||||
React.createElement('span', { className: 'lc-preview-editable-hint__tag lc-preview-editable-hint__tag--std' }, '标准审批'),
|
||||
'。',
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
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': '缩放' },
|
||||
@@ -138,13 +216,13 @@ export default function LeaseContractPreviewPanel({ html, zoom, onZoomChange })
|
||||
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('span', { className: 'ct-preview-pager__page-total' }, baselinePages.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)); },
|
||||
disabled: pageIdx >= baselinePages.length - 1,
|
||||
onClick: function () { setPageIdx(Math.min(baselinePages.length - 1, pageIdx + 1)); },
|
||||
}, '下一页'),
|
||||
),
|
||||
),
|
||||
@@ -168,25 +246,36 @@ export default function LeaseContractPreviewPanel({ html, zoom, onZoomChange })
|
||||
),
|
||||
);
|
||||
|
||||
function renderPageBody(pageHtml, i) {
|
||||
if (editable) {
|
||||
return React.createElement(EditablePreviewPageBody, {
|
||||
key: 'body-' + i + '-' + String(html || '').length,
|
||||
pageIndex: i,
|
||||
initialHtml: baselinePages[i],
|
||||
onInput: handlePageInput,
|
||||
});
|
||||
}
|
||||
return React.createElement('div', {
|
||||
key: 'body-' + i,
|
||||
className: 'ct-preview-page__body ct-word-doc',
|
||||
dangerouslySetInnerHTML: { __html: pageHtml },
|
||||
});
|
||||
}
|
||||
|
||||
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] },
|
||||
}),
|
||||
renderPageBody(baselinePages[pageIdx], pageIdx),
|
||||
React.createElement('div', { className: 'ct-preview-page__footer' }, '— ', pageIdx + 1, ' —'),
|
||||
)
|
||||
: pages.map(function (pageHtml, i) {
|
||||
: baselinePages.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 },
|
||||
}),
|
||||
renderPageBody(pageHtml, i),
|
||||
React.createElement('div', { className: 'ct-preview-page__footer' }, '— ', i + 1, ' —'),
|
||||
);
|
||||
});
|
||||
|
||||
return React.createElement('div', { className: 'ct-preview-doc ct-word-doc ct-word-paginated' },
|
||||
return React.createElement('div', { className: 'ct-preview-doc ct-word-doc ct-word-paginated' + (editable ? ' lc-preview-doc--editable' : '') },
|
||||
editableHint,
|
||||
previewToolbar,
|
||||
React.createElement('div', { className: 'ct-preview-body-shell' },
|
||||
React.createElement('div', { className: 'ct-preview-pages-wrap' },
|
||||
|
||||
358
src/prototypes/lease-contract-management/annotation-source.json
Normal file
358
src/prototypes/lease-contract-management/annotation-source.json
Normal file
File diff suppressed because one or more lines are too long
10
src/prototypes/lease-contract-management/canvas.excalidraw
Normal file
10
src/prototypes/lease-contract-management/canvas.excalidraw
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "@axhub/make",
|
||||
"elements": [],
|
||||
"appState": {
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* @name 租赁合同管理
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import * as antd from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import 'antd/dist/reset.css';
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import LeaseContractApp from './LeaseContractManagement.jsx';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
type LeaseContractPageId = 'list' | 'create';
|
||||
|
||||
type LeaseContractNavigationRef = {
|
||||
openCreate?: () => void;
|
||||
backToList?: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -53,9 +67,43 @@ const vmTheme = {
|
||||
};
|
||||
|
||||
export default function LeaseContractManagementPage() {
|
||||
return React.createElement(
|
||||
ConfigProvider,
|
||||
{ theme: vmTheme },
|
||||
React.createElement(LeaseContractApp),
|
||||
const [annotationPageId, setAnnotationPageId] = useState<LeaseContractPageId>('list');
|
||||
const navigationRef = useRef<LeaseContractNavigationRef>({});
|
||||
|
||||
const handleDirectoryRoute = useCallback((node: AnnotationDirectoryRouteNode) => {
|
||||
const payload = node.payload as { pageId?: string } | undefined;
|
||||
const pageId: LeaseContractPageId = payload?.pageId === 'create' ? 'create' : 'list';
|
||||
if (pageId === 'create') {
|
||||
navigationRef.current.openCreate?.();
|
||||
} else {
|
||||
navigationRef.current.backToList?.();
|
||||
}
|
||||
setAnnotationPageId(pageId);
|
||||
}, []);
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: annotationPageId,
|
||||
onDirectoryRoute: handleDirectoryRoute,
|
||||
}),
|
||||
[annotationPageId, handleDirectoryRoute],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={vmTheme}>
|
||||
<LeaseContractApp
|
||||
navigationRef={navigationRef}
|
||||
onPageViewChange={setAnnotationPageId}
|
||||
/>
|
||||
<AnnotationViewer
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
isDelegateRowComplete,
|
||||
isDelegateRowStarted,
|
||||
normalizeBrandModels,
|
||||
} from './lease-order-vars.js';
|
||||
|
||||
function isMainContractFieldsComplete(form) {
|
||||
var contractCode = (form.contractCode || '').trim();
|
||||
if (!contractCode) return false;
|
||||
@@ -34,21 +40,25 @@ export function isMainContractFormComplete(form) {
|
||||
/** 附件1:租赁订单卡片完成度 */
|
||||
export function isLeaseOrderFormComplete(form) {
|
||||
var leaseOrder = form.leaseOrder || {};
|
||||
if (leaseOrder.thirdPartyLiabilityMillion == null || leaseOrder.thirdPartyLiabilityMillion === '') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
var rows = leaseOrder.rows || [];
|
||||
if (!rows.length) return false;
|
||||
return rows.some(function (row) {
|
||||
return normalizeBrandModels(row).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** 授权委托书卡片完成度 */
|
||||
/** 授权委托书卡片完成度(选填;有填写时须完整) */
|
||||
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());
|
||||
});
|
||||
var started = delegates.filter(isDelegateRowStarted);
|
||||
if (!started.length) return true;
|
||||
return started.every(isDelegateRowComplete);
|
||||
}
|
||||
|
||||
/** 授权委托书是否有未完成行(用于徽章展示) */
|
||||
export function isPowerOfAttorneyOptionalEmpty(form) {
|
||||
var delegates = (form.powerOfAttorney && form.powerOfAttorney.delegates) || [];
|
||||
return !delegates.some(isDelegateRowStarted);
|
||||
}
|
||||
|
||||
/** 判断新增租赁合同左侧表单必填项是否均已填写 */
|
||||
|
||||
412
src/prototypes/lease-contract-management/lease-contract-list-data.js
vendored
Normal file
412
src/prototypes/lease-contract-management/lease-contract-list-data.js
vendored
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 租赁合同列表样例数据 — 字段与新增/编辑页表单结构对齐
|
||||
*/
|
||||
|
||||
export var PAYMENT_PERIOD_LABELS = {
|
||||
1: '1个月',
|
||||
3: '3个月',
|
||||
6: '6个月',
|
||||
12: '12个月',
|
||||
};
|
||||
|
||||
export var HYDROGEN_PAYMENT_LABELS = {
|
||||
self: '自行解决',
|
||||
prepay: '预付款',
|
||||
};
|
||||
|
||||
export var MILEAGE_PERIOD_LABELS = {
|
||||
month: '每月',
|
||||
quarter: '每季度',
|
||||
year: '每年度',
|
||||
};
|
||||
|
||||
export function formatMileageSummary(record) {
|
||||
if (!record.hasMinimumMileage) return '无最低里程要求';
|
||||
var period = MILEAGE_PERIOD_LABELS[record.mileagePeriod] || '';
|
||||
var km = record.mileageTargetKm != null ? record.mileageTargetKm : '-';
|
||||
return period + km + ' 公里';
|
||||
}
|
||||
|
||||
export function formatDeliveryRegion(region) {
|
||||
if (!region || !region.length) return '-';
|
||||
return region.join(' / ');
|
||||
}
|
||||
|
||||
export var LEASE_CONTRACT_LIST_RECORDS = [
|
||||
{
|
||||
id: '1',
|
||||
contractCode: 'HT-ZL-2025-001',
|
||||
projectName: '嘉兴氢能示范项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '浙A12345', actualDelivery: '2025-01-10 09:00', deliveryPerson: '张运维' },
|
||||
{ vehicleType: '18吨厢式货车', brand: '现代', model: '18吨氢燃料电池车', plateNo: '-', actualDelivery: '-', deliveryPerson: '-' },
|
||||
],
|
||||
approvalStatus: '未提交',
|
||||
contractStatus: '草稿',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
paymentPeriod: '1个月',
|
||||
hydrogenPaymentMethod: '自行解决',
|
||||
deliveryRegion: ['浙江省', '嘉兴市'],
|
||||
deliveryDate: '2025-03-01',
|
||||
insuredVehicleCount: 2,
|
||||
thirdPartyLiabilityMillion: 200,
|
||||
hasMinimumMileage: true,
|
||||
mileagePeriod: 'month',
|
||||
mileageTargetKm: 6000,
|
||||
delegateCount: 0,
|
||||
contractEndDate: '2026-02-16',
|
||||
contactName: '张三',
|
||||
contactPhone: '13800138001',
|
||||
creator: '张经理',
|
||||
createTime: '2025-01-05 10:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '草稿待完善',
|
||||
legalStampedContractUploaded: undefined,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contractCode: 'HT-ZL-2025-002',
|
||||
projectName: '上海物流租赁项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '小客车', brand: '宇通', model: '氢燃料电池客车', plateNo: '沪D66666', actualDelivery: '2025-02-01 11:00', deliveryPerson: '王运维' },
|
||||
],
|
||||
approvalStatus: '未提交',
|
||||
contractStatus: '草稿',
|
||||
customerName: '上海某某运输公司',
|
||||
signingCompany: '上海羚牛',
|
||||
businessDept: '业务2部',
|
||||
businessOwner: '李专员',
|
||||
contractType: '试用合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
paymentPeriod: '3个月',
|
||||
hydrogenPaymentMethod: '预付款',
|
||||
deliveryRegion: ['上海市', '上海市'],
|
||||
deliveryDate: '2025-02-20',
|
||||
insuredVehicleCount: 1,
|
||||
thirdPartyLiabilityMillion: 100,
|
||||
hasMinimumMileage: false,
|
||||
mileagePeriod: 'month',
|
||||
mileageTargetKm: null,
|
||||
delegateCount: 1,
|
||||
contractEndDate: '2025-08-01',
|
||||
contactName: '李四',
|
||||
contactPhone: '13800138002',
|
||||
creator: '李专员',
|
||||
createTime: '2025-02-10 09:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '试用期 3 个月',
|
||||
legalStampedContractUploaded: undefined,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
contractCode: 'HT-ZL-2025-003',
|
||||
projectName: '杭州城配租赁项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '4.5吨货车', brand: '跃进', model: '4.2米冷链车', plateNo: '浙B20002', actualDelivery: '2025-02-15 08:30', deliveryPerson: '赵运维' },
|
||||
],
|
||||
approvalStatus: '待审批',
|
||||
contractStatus: '已提交审批',
|
||||
customerName: '杭州某某租赁有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务3部',
|
||||
businessOwner: '王专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
paymentPeriod: '1个月',
|
||||
hydrogenPaymentMethod: '自行解决',
|
||||
deliveryRegion: ['浙江省', '杭州市'],
|
||||
deliveryDate: '2025-03-10',
|
||||
insuredVehicleCount: 1,
|
||||
thirdPartyLiabilityMillion: 200,
|
||||
hasMinimumMileage: true,
|
||||
mileagePeriod: 'quarter',
|
||||
mileageTargetKm: 18000,
|
||||
delegateCount: 2,
|
||||
contractEndDate: '2026-06-30',
|
||||
contactName: '王五',
|
||||
contactPhone: '13800138003',
|
||||
creator: '王专员',
|
||||
createTime: '2025-02-12 11:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: undefined,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
contractCode: 'HT-ZL-2025-004',
|
||||
projectName: '宁波冷链运输项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '9.6米氢燃料电池车', plateNo: '-', actualDelivery: '-', deliveryPerson: '-' },
|
||||
{ vehicleType: '49吨牵引车头', brand: '飞驰', model: '集卡头', plateNo: '浙C30003', actualDelivery: '2025-02-18 14:00', deliveryPerson: '孙运维' },
|
||||
],
|
||||
approvalStatus: '审批中',
|
||||
contractStatus: '已提交审批',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
paymentPeriod: '6个月',
|
||||
hydrogenPaymentMethod: '预付款',
|
||||
deliveryRegion: ['浙江省', '宁波市'],
|
||||
deliveryDate: '2025-04-01',
|
||||
insuredVehicleCount: 2,
|
||||
thirdPartyLiabilityMillion: 300,
|
||||
hasMinimumMileage: true,
|
||||
mileagePeriod: 'month',
|
||||
mileageTargetKm: 8000,
|
||||
delegateCount: 1,
|
||||
contractEndDate: '2026-03-01',
|
||||
contactName: '赵六',
|
||||
contactPhone: '13900139001',
|
||||
creator: '张经理',
|
||||
createTime: '2025-02-14 09:00',
|
||||
updater: '李专员',
|
||||
updateTime: '2025-02-15 16:00',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: undefined,
|
||||
approvalFlowNodes: [
|
||||
{ nodeTitle: '业务服务主管审批', result: 'passed', operatorName: '姚守涛', operatorTime: '2026-04-29 17:57:15' },
|
||||
{ nodeTitle: '发起审批', result: 'passed', operatorName: '超级用户', operatorTime: '2026-04-28 17:44:45' },
|
||||
{ nodeTitle: '业务负责人审批', result: 'pending', pendingApprovers: ['超级用户', '金可鹏'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
contractCode: 'HT-ZL-2025-005',
|
||||
projectName: '苏州城配试点项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '重型平板半挂车', brand: '飞驰', model: '半挂车', plateNo: '苏E50005', actualDelivery: '2025-02-20 09:00', deliveryPerson: '周运维' },
|
||||
],
|
||||
approvalStatus: '审批中',
|
||||
contractStatus: '变更',
|
||||
customerName: '上海某某运输公司',
|
||||
signingCompany: '上海羚牛',
|
||||
businessDept: '业务2部',
|
||||
businessOwner: '李专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
paymentPeriod: '3个月',
|
||||
hydrogenPaymentMethod: '自行解决',
|
||||
deliveryRegion: ['江苏省', '苏州市'],
|
||||
deliveryDate: '2025-03-15',
|
||||
insuredVehicleCount: 1,
|
||||
thirdPartyLiabilityMillion: 200,
|
||||
hasMinimumMileage: false,
|
||||
mileagePeriod: 'month',
|
||||
mileageTargetKm: null,
|
||||
delegateCount: 0,
|
||||
contractEndDate: '2026-05-31',
|
||||
contactName: '孙七',
|
||||
contactPhone: '13900139002',
|
||||
creator: '李专员',
|
||||
createTime: '2025-02-18 10:00',
|
||||
updater: '李专员',
|
||||
updateTime: '2025-02-22 14:00',
|
||||
remark: '变更车辆数量',
|
||||
legalStampedContractUploaded: undefined,
|
||||
approvalFlowNodes: [
|
||||
{ nodeTitle: '法务审核', result: 'passed', operatorName: '李法务', operatorTime: '2025-02-21 18:00:00' },
|
||||
{ nodeTitle: '发起审批', result: 'passed', operatorName: '李专员', operatorTime: '2025-02-22 10:00:00' },
|
||||
{ nodeTitle: '业务负责人审批', result: 'pending', pendingApprovers: ['张经理', '赵总监'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
contractCode: 'HT-ZL-2025-006',
|
||||
projectName: '南京氢能示范项目',
|
||||
vehicleCount: 3,
|
||||
vehicles: [
|
||||
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏A60006', actualDelivery: '2025-01-20 08:00', deliveryPerson: '吴运维' },
|
||||
{ vehicleType: '18吨厢式货车', brand: '现代', model: '18吨氢燃料电池车', plateNo: '苏A60007', actualDelivery: '2025-01-21 10:00', deliveryPerson: '郑运维' },
|
||||
{ vehicleType: '牵引车头', brand: '飞驰', model: '集卡头', plateNo: '苏A60008', actualDelivery: '2025-01-22 14:00', deliveryPerson: '冯运维' },
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '合同进行中',
|
||||
customerName: '杭州某某租赁有限公司',
|
||||
signingCompany: '广东羚牛',
|
||||
businessDept: '业务3部',
|
||||
businessOwner: '王专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
paymentPeriod: '12个月',
|
||||
hydrogenPaymentMethod: '预付款',
|
||||
deliveryRegion: ['江苏省', '南京市'],
|
||||
deliveryDate: '2025-01-15',
|
||||
insuredVehicleCount: 3,
|
||||
thirdPartyLiabilityMillion: 300,
|
||||
hasMinimumMileage: true,
|
||||
mileagePeriod: 'year',
|
||||
mileageTargetKm: 120000,
|
||||
delegateCount: 3,
|
||||
contractEndDate: '2026-12-31',
|
||||
contactName: '周八',
|
||||
contactPhone: '13900139003',
|
||||
creator: '王专员',
|
||||
createTime: '2025-01-15 09:00',
|
||||
updater: '王专员',
|
||||
updateTime: '2025-01-25 11:00',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: true,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
contractCode: 'HT-ZL-2025-007',
|
||||
projectName: '无锡试用租赁项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '小客车', brand: '宇通', model: '氢燃料电池客车', plateNo: '苏B70007', actualDelivery: '2025-02-01 09:30', deliveryPerson: '陈运维' },
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '合同进行中',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '试用合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
paymentPeriod: '1个月',
|
||||
hydrogenPaymentMethod: '自行解决',
|
||||
deliveryRegion: ['江苏省', '无锡市'],
|
||||
deliveryDate: '2025-02-05',
|
||||
insuredVehicleCount: 1,
|
||||
thirdPartyLiabilityMillion: 100,
|
||||
hasMinimumMileage: false,
|
||||
mileagePeriod: 'month',
|
||||
mileageTargetKm: null,
|
||||
delegateCount: 1,
|
||||
contractEndDate: '2025-07-10',
|
||||
contactName: '吴九',
|
||||
contactPhone: '13900139004',
|
||||
creator: '张经理',
|
||||
createTime: '2025-01-28 10:00',
|
||||
updater: '-',
|
||||
updateTime: '-',
|
||||
remark: '试用 3 个月',
|
||||
legalStampedContractUploaded: false,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
contractCode: 'HT-ZL-2025-008',
|
||||
projectName: '常州物流合作项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '集装箱半挂车', brand: '飞驰', model: '半挂车', plateNo: '-', actualDelivery: '-', deliveryPerson: '-' },
|
||||
{ vehicleType: '4.5吨货车', brand: '跃进', model: '4.2米冷链车', plateNo: '-', actualDelivery: '-', deliveryPerson: '-' },
|
||||
],
|
||||
approvalStatus: '审批驳回',
|
||||
contractStatus: '已提交审批',
|
||||
customerName: '上海某某运输公司',
|
||||
signingCompany: '上海羚牛',
|
||||
businessDept: '业务2部',
|
||||
businessOwner: '李专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
paymentPeriod: '3个月',
|
||||
hydrogenPaymentMethod: '预付款',
|
||||
deliveryRegion: ['江苏省', '常州市'],
|
||||
deliveryDate: '2025-04-20',
|
||||
insuredVehicleCount: 2,
|
||||
thirdPartyLiabilityMillion: 200,
|
||||
hasMinimumMileage: true,
|
||||
mileagePeriod: 'month',
|
||||
mileageTargetKm: 5000,
|
||||
delegateCount: 2,
|
||||
contractEndDate: '2026-08-15',
|
||||
contactName: '郑十',
|
||||
contactPhone: '13900139005',
|
||||
creator: '李专员',
|
||||
createTime: '2025-02-20 14:00',
|
||||
updater: '李专员',
|
||||
updateTime: '2025-02-23 09:00',
|
||||
remark: '驳回原因:费用条款需调整',
|
||||
legalStampedContractUploaded: undefined,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
contractCode: 'HT-ZL-2024-009',
|
||||
projectName: '南通去年到期项目',
|
||||
vehicleCount: 2,
|
||||
vehicles: [
|
||||
{ vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '9.6米氢燃料电池车', plateNo: '苏F90009', actualDelivery: '2024-03-01 09:00', deliveryPerson: '褚运维' },
|
||||
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏F90010', actualDelivery: '2024-03-02 10:00', deliveryPerson: '卫运维' },
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '到期合同',
|
||||
customerName: '杭州某某租赁有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务3部',
|
||||
businessOwner: '王专员',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '标准合同审批',
|
||||
paymentPeriod: '6个月',
|
||||
hydrogenPaymentMethod: '自行解决',
|
||||
deliveryRegion: ['江苏省', '南通市'],
|
||||
deliveryDate: '2024-02-15',
|
||||
insuredVehicleCount: 2,
|
||||
thirdPartyLiabilityMillion: 200,
|
||||
hasMinimumMileage: true,
|
||||
mileagePeriod: 'quarter',
|
||||
mileageTargetKm: 15000,
|
||||
delegateCount: 0,
|
||||
contractEndDate: '2024-12-31',
|
||||
contactName: '王五',
|
||||
contactPhone: '13800138003',
|
||||
creator: '王专员',
|
||||
createTime: '2024-02-20 10:00',
|
||||
updater: '王专员',
|
||||
updateTime: '2024-12-20 16:00',
|
||||
remark: '已到期可续签',
|
||||
legalStampedContractUploaded: true,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
contractCode: 'HT-ZL-2024-010',
|
||||
projectName: '镇江到期合同项目',
|
||||
vehicleCount: 1,
|
||||
vehicles: [
|
||||
{ vehicleType: '小客车', brand: '宇通', model: '氢燃料电池客车', plateNo: '苏L00100', actualDelivery: '2024-06-01 11:00', deliveryPerson: '蒋运维' },
|
||||
],
|
||||
approvalStatus: '审批通过',
|
||||
contractStatus: '已结束',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
signingCompany: '嘉兴羚牛',
|
||||
businessDept: '业务1部',
|
||||
businessOwner: '张经理',
|
||||
contractType: '正式合同',
|
||||
contractApprovalType: '非标准合同审批',
|
||||
paymentPeriod: '1个月',
|
||||
hydrogenPaymentMethod: '自行解决',
|
||||
deliveryRegion: ['江苏省', '镇江市'],
|
||||
deliveryDate: '2024-05-10',
|
||||
insuredVehicleCount: 1,
|
||||
thirdPartyLiabilityMillion: 100,
|
||||
hasMinimumMileage: false,
|
||||
mileagePeriod: 'month',
|
||||
mileageTargetKm: null,
|
||||
delegateCount: 1,
|
||||
contractEndDate: '2025-01-15',
|
||||
contactName: '张三',
|
||||
contactPhone: '13800138001',
|
||||
creator: '张经理',
|
||||
createTime: '2024-05-20 09:00',
|
||||
updater: '张经理',
|
||||
updateTime: '2025-01-10 14:00',
|
||||
remark: '-',
|
||||
legalStampedContractUploaded: true,
|
||||
},
|
||||
];
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
CUSTOMER_CREDENTIAL_ITEMS,
|
||||
CONTRACT_CODE_PREFIX,
|
||||
} from '../contract-template-management/contract-template-vars.js';
|
||||
import { calcRentServiceSubtotal, formatExtraServicesDisplay, formatLeasePeriod, normalizeExtraServices } from './lease-order-vars.js';
|
||||
import { calcRentServiceSubtotal, formatExtraServicesDisplay, formatLeasePeriod, normalizeExtraServices, normalizeBrandModels, normalizePlateNos, formatBrandModelPair, PLATE_ACTUAL_DELIVERY, calcInsuredVehicleCount, getRowVehicleCountForPricing, normalizeDelegateRows } from './lease-order-vars.js';
|
||||
import {
|
||||
injectPrototypeVehicleClauses,
|
||||
collectVehicleKeysFromLeaseOrder,
|
||||
applyVehicleClausePreviewFilter,
|
||||
} from './lease-contract-vehicle-clause.js';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value != null ? value : '')
|
||||
@@ -209,8 +214,9 @@ export function applyLeaseOrderAttachmentPatch(html, form, customer, lessor) {
|
||||
var orders = (form && form.leaseOrder && form.leaseOrder.rows)
|
||||
|| (form && form.leaseOrders)
|
||||
|| [];
|
||||
var leaseOrder = form && form.leaseOrder;
|
||||
if (orders.length > 0) {
|
||||
var rowsHtml = orders.map(buildLeaseOrderPreviewRow).join('');
|
||||
var rowsHtml = expandLeaseOrderPreviewRows(orders, leaseOrder).join('');
|
||||
var marker = '一、租赁车辆信息';
|
||||
var tableMarkerPos = slice.indexOf(marker);
|
||||
if (tableMarkerPos >= 0) {
|
||||
@@ -221,18 +227,84 @@ export function applyLeaseOrderAttachmentPatch(html, form, customer, lessor) {
|
||||
}
|
||||
}
|
||||
|
||||
if (leaseOrder) {
|
||||
slice = applyLeaseOrderSummaryPatch(slice, leaseOrder);
|
||||
slice = applyLeaseOrderDeliveryPatch(slice, leaseOrder);
|
||||
}
|
||||
|
||||
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 || '');
|
||||
function applyLeaseOrderSummaryPatch(slice, leaseOrder) {
|
||||
var insuredCount = calcInsuredVehicleCount(leaseOrder);
|
||||
var thirdParty = leaseOrder.thirdPartyLiabilityMillion != null && leaseOrder.thirdPartyLiabilityMillion !== ''
|
||||
? escapeHtml(leaseOrder.thirdPartyLiabilityMillion)
|
||||
: '';
|
||||
if (insuredCount > 0) {
|
||||
slice = slice.replace(
|
||||
/(租赁车辆含保险总计)\s*([\s\u00a0]*)(辆)/,
|
||||
'$1 ' + escapeHtml(insuredCount) + ' $3',
|
||||
);
|
||||
}
|
||||
if (thirdParty) {
|
||||
slice = slice.replace(
|
||||
/(三者责任险【)\s*([\s\u00a0]*)(】万元)/,
|
||||
'$1' + thirdParty + '$3',
|
||||
);
|
||||
}
|
||||
return slice;
|
||||
}
|
||||
|
||||
function applyLeaseOrderDeliveryPatch(slice, leaseOrder) {
|
||||
var region = leaseOrder.deliveryRegion || [];
|
||||
var regionText = region.length ? escapeHtml(region.join('')) : '';
|
||||
if (regionText) {
|
||||
slice = slice.replace(
|
||||
/(车辆交付(交还)地点:)\s*([\s\u00a0]*)(。)/,
|
||||
'$1' + regionText + '$3',
|
||||
);
|
||||
}
|
||||
if (leaseOrder.deliveryDate) {
|
||||
var parts = String(leaseOrder.deliveryDate).split('-');
|
||||
if (parts.length === 3) {
|
||||
var dateText = escapeHtml(parts[0] + '年' + parseInt(parts[1], 10) + '月' + parseInt(parts[2], 10) + '日');
|
||||
slice = slice.replace(
|
||||
/(车辆交付时间:)\s*([\s\u00a0\u2002]*年\s*[\s\u00a0\u2002]*月\s*[\s\u00a0\u2002]*日)/,
|
||||
'$1' + dateText,
|
||||
);
|
||||
}
|
||||
}
|
||||
return slice;
|
||||
}
|
||||
|
||||
function expandLeaseOrderPreviewRows(orders, leaseOrder) {
|
||||
var rows = [];
|
||||
orders.forEach(function (row) {
|
||||
var brandModels = normalizeBrandModels(row);
|
||||
var plates = normalizePlateNos(row);
|
||||
var lineCount = Math.max(brandModels.length, plates.length, 1);
|
||||
for (var i = 0; i < lineCount; i++) {
|
||||
var pair = brandModels[i] || brandModels[0] || [];
|
||||
var plate = plates[i] || plates[0] || PLATE_ACTUAL_DELIVERY;
|
||||
rows.push(buildLeaseOrderPreviewRow(row, pair, plate, leaseOrder));
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildLeaseOrderPreviewRow(row, brandModelPair, plate, leaseOrder) {
|
||||
var brandModelText = brandModelPair.length >= 2
|
||||
? escapeHtml(formatBrandModelPair(brandModelPair[0], brandModelPair[1]))
|
||||
: '';
|
||||
var brand = brandModelPair[0] ? escapeHtml(brandModelPair[0]) : '';
|
||||
var model = brandModelPair[1] ? escapeHtml(brandModelPair[1]) : '';
|
||||
var plateText = escapeHtml(plate || PLATE_ACTUAL_DELIVERY);
|
||||
var extraServices = normalizeExtraServices(row);
|
||||
var vehicleCount = getRowVehicleCountForPricing(row, leaseOrder || { rows: [row] });
|
||||
var subtotal = row.rentServiceSubtotal != null && row.rentServiceSubtotal !== ''
|
||||
? escapeHtml(row.rentServiceSubtotal)
|
||||
: (function () {
|
||||
var computed = calcRentServiceSubtotal(row.rent, row.serviceFee);
|
||||
var computed = calcRentServiceSubtotal(row.rent, row.serviceFee, vehicleCount);
|
||||
return computed != null ? escapeHtml(computed) : '';
|
||||
}());
|
||||
var rent = row.rent != null && row.rent !== '' ? escapeHtml(row.rent) : '';
|
||||
@@ -241,12 +313,14 @@ function buildLeaseOrderPreviewRow(row) {
|
||||
var extra = escapeHtml(formatExtraServicesDisplay(extraServices));
|
||||
var leasePeriod = escapeHtml(formatLeasePeriod(row));
|
||||
var border = 'border:1px solid #bfbfbf;padding:0 5px';
|
||||
var brandCell = brandModelText
|
||||
? '<p style="margin:0;text-align:center;font:9px Times"><b>' + brandModelText + '</b></p>'
|
||||
: '<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>';
|
||||
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:36px;' + border + '" valign="top">' + brandCell + '</td>'
|
||||
+ '<td style="min-width:51px;' + border + '" valign="middle">'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + plate + '</p></td>'
|
||||
+ '<p style="margin:0;text-align:center;font:9px Times">' + plateText + '</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">'
|
||||
@@ -262,6 +336,33 @@ function buildLeaseOrderPreviewRow(row) {
|
||||
+ '</tr>';
|
||||
}
|
||||
|
||||
/** 授权委托书:委托人信息反写 */
|
||||
export function applyPowerOfAttorneyPatch(html, form) {
|
||||
if (!html || !form || !form.powerOfAttorney) return html;
|
||||
var delegates = normalizeDelegateRows(form.powerOfAttorney.delegates || []);
|
||||
if (!delegates.length) return html;
|
||||
var marker = '授权委托书';
|
||||
var start = html.indexOf(marker);
|
||||
if (start < 0) return html;
|
||||
var sliceStart = html.lastIndexOf('<p', start);
|
||||
var sliceEnd = html.indexOf('附件', start + marker.length);
|
||||
if (sliceEnd < 0) sliceEnd = start + 4000;
|
||||
var head = html.slice(0, sliceStart >= 0 ? sliceStart : start);
|
||||
var slice = html.slice(sliceStart >= 0 ? sliceStart : start, sliceEnd);
|
||||
var tail = html.slice(sliceEnd);
|
||||
delegates.forEach(function (delegate, index) {
|
||||
var name = escapeHtml((delegate.name || '').trim());
|
||||
var contact = escapeHtml((delegate.contact || '').trim());
|
||||
var idNumber = escapeHtml((delegate.idNumber || '').trim());
|
||||
if (index === 0) {
|
||||
slice = slice.replace(/(委托人[::]\s*)([\s\u00a0]*)/, '$1' + name + ' ');
|
||||
slice = slice.replace(/(联系电话[::]\s*)([\s\u00a0]*)/, '$1' + contact + ' ');
|
||||
slice = slice.replace(/(身份证号码[::]\s*)([\s\u00a0]*)/, '$1' + idNumber + ' ');
|
||||
}
|
||||
});
|
||||
return head + slice + tail;
|
||||
}
|
||||
|
||||
function attachmentPlaceholderBlock(title, fileName, imageUrl) {
|
||||
if (imageUrl) {
|
||||
return '<div class="lc-preview-attach">'
|
||||
@@ -307,6 +408,7 @@ export function buildLeaseContractPreviewHtml(form) {
|
||||
var base = V266_LEASE_DOCUMENT_HTML || '';
|
||||
var vars = buildLeasePreviewVars(form || {});
|
||||
var html = stripUnresolvedPlaceholders(applyTemplateVars(base, vars));
|
||||
html = injectPrototypeVehicleClauses(html);
|
||||
var customer = getLeaseCustomerById(form && form.customerId);
|
||||
var lessor = getLessorCompanyById(form && form.lessorId);
|
||||
html = applyCustomerPartyPatch(html, customer);
|
||||
@@ -316,6 +418,9 @@ export function buildLeaseContractPreviewHtml(form) {
|
||||
html = applyPaymentPeriodPatch(html, form && form.feeInfo);
|
||||
html = applyFeeInfoPatch(html, form && form.feeInfo);
|
||||
html = applyLeaseOrderAttachmentPatch(html, form, customer, lessor);
|
||||
html = applyPowerOfAttorneyPatch(html, form);
|
||||
html = appendCustomerCredentialAttachments(html, customer);
|
||||
var vehicleKeys = collectVehicleKeysFromLeaseOrder(form);
|
||||
html = applyVehicleClausePreviewFilter(html, vehicleKeys);
|
||||
return html;
|
||||
}
|
||||
|
||||
53
src/prototypes/lease-contract-management/lease-contract-risk-detect.js
vendored
Normal file
53
src/prototypes/lease-contract-management/lease-contract-risk-detect.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 租赁合同预览:检测风控红线条款(模板标记)是否被修改
|
||||
*/
|
||||
|
||||
export var STANDARD_CONTRACT_APPROVAL = '标准合同审批';
|
||||
export var NONSTANDARD_CONTRACT_APPROVAL = '非标准合同审批';
|
||||
|
||||
var RISK_SELECTOR = '.ct-risk-redline[data-risk-redline="1"]';
|
||||
|
||||
function normalizeRiskText(value) {
|
||||
return String(value || '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/\u200b/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function collectRiskSnapshots(html) {
|
||||
if (!html || typeof document === 'undefined') return [];
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = String(html || '');
|
||||
var list = [];
|
||||
div.querySelectorAll(RISK_SELECTOR).forEach(function (el, index) {
|
||||
list.push({
|
||||
id: el.getAttribute('data-risk-id') || ('risk-' + index),
|
||||
text: normalizeRiskText(el.innerHTML),
|
||||
});
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 风控红线条款正文是否与基线不一致 */
|
||||
export function isRiskRedlineContentModified(baselineHtml, currentHtml) {
|
||||
var baseline = collectRiskSnapshots(baselineHtml);
|
||||
var current = collectRiskSnapshots(currentHtml);
|
||||
if (!baseline.length && !current.length) return false;
|
||||
if (baseline.length !== current.length) return true;
|
||||
for (var i = 0; i < baseline.length; i++) {
|
||||
if (baseline[i].text !== current[i].text) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mergePreviewPages(pages) {
|
||||
return (pages || []).join('');
|
||||
}
|
||||
|
||||
export function resolveContractApprovalType(baselineHtml, currentHtml) {
|
||||
return isRiskRedlineContentModified(baselineHtml, currentHtml)
|
||||
? NONSTANDARD_CONTRACT_APPROVAL
|
||||
: STANDARD_CONTRACT_APPROVAL;
|
||||
}
|
||||
137
src/prototypes/lease-contract-management/lease-contract-vehicle-clause.js
vendored
Normal file
137
src/prototypes/lease-contract-management/lease-contract-vehicle-clause.js
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 租赁合同预览:条件条款(按订单车辆品牌型号命中显隐)
|
||||
*/
|
||||
|
||||
export function vehicleBindingKey(binding) {
|
||||
return String(binding.brand || '') + '|' + String(binding.model || '');
|
||||
}
|
||||
|
||||
export function parseVehicleClauseBindings(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function vehicleClauseMatchesPreview(bindings, selectedKeys) {
|
||||
var keys = selectedKeys || [];
|
||||
if (!keys.length) return false;
|
||||
var list = bindings || [];
|
||||
if (!list.length) return false;
|
||||
return list.some(function (item) {
|
||||
return keys.indexOf(vehicleBindingKey(item)) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
|
||||
function unwrapVehicleClauseElement(clauseEl) {
|
||||
if (!clauseEl || !clauseEl.parentNode) return;
|
||||
var parent = clauseEl.parentNode;
|
||||
var footer = clauseEl.querySelector('.ct-vehicle-clause__footer');
|
||||
if (footer) footer.parentNode.removeChild(footer);
|
||||
while (clauseEl.firstChild) parent.insertBefore(clauseEl.firstChild, clauseEl);
|
||||
parent.removeChild(clauseEl);
|
||||
}
|
||||
|
||||
/** 标准合同 V26.6 原型内置的条件条款定义(与模板注入点一致) */
|
||||
export var PROTOTYPE_VEHICLE_CLAUSE_DEFS = [
|
||||
{
|
||||
id: 'lc-vc-18t-mileage',
|
||||
label: '2.1.5.4 持续达标减免',
|
||||
summary: '租赁期间每月行驶里程均满 6000 公里,可每月享受次月 2000 元/辆租金减免。',
|
||||
bindings: [{ brand: '现代', model: '18吨氢燃料电池车' }],
|
||||
paragraphMarker: '<b>2.1.5.4持续达标减免</b>',
|
||||
},
|
||||
{
|
||||
id: 'lc-vc-sl-mileage',
|
||||
label: '2.1.5.3 连续周期减免',
|
||||
summary: '单辆车连续两个月累计行驶里程达到 12000 公里,该两个月均能享受租金减免。',
|
||||
bindings: [{ brand: '苏龙', model: '9.6米氢燃料电池车' }],
|
||||
paragraphMarker: '<b>2.1.5.3连续周期减免:单辆车</b>',
|
||||
},
|
||||
{
|
||||
id: 'lc-vc-coldchain',
|
||||
label: '2.1.5.2 里程补足与累计',
|
||||
summary: '冷链车型次月里程可补足上月未达 6000 公里的差额部分。',
|
||||
bindings: [{ brand: '现代', model: '帕力安牌4.5吨冷链车' }],
|
||||
paragraphMarker: '<b>2.1.5.2里程补足与累计</b>',
|
||||
},
|
||||
];
|
||||
|
||||
export function getMatchedPrototypeClauses(brand, model) {
|
||||
if (!brand || !model) return [];
|
||||
var key = vehicleBindingKey({ brand: brand, model: model });
|
||||
return PROTOTYPE_VEHICLE_CLAUSE_DEFS.filter(function (def) {
|
||||
return def.bindings.some(function (item) {
|
||||
return vehicleBindingKey(item) === key;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function collectVehicleKeysFromLeaseOrder(form) {
|
||||
var rows = (form && form.leaseOrder && form.leaseOrder.rows) || [];
|
||||
var keys = [];
|
||||
rows.forEach(function (row) {
|
||||
var brandModels = row.brandModels || (row.brandModel && row.brandModel.length >= 2 ? [row.brandModel] : []);
|
||||
brandModels.forEach(function (pair) {
|
||||
if (pair && pair.length >= 2) {
|
||||
var key = vehicleBindingKey({ brand: pair[0], model: pair[1] });
|
||||
if (keys.indexOf(key) < 0) keys.push(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
function wrapClauseParagraph(html, def) {
|
||||
if (!html || html.indexOf('data-vehicle-clause-id="' + def.id + '"') >= 0) return html;
|
||||
var idx = html.indexOf(def.paragraphMarker);
|
||||
if (idx < 0) return html;
|
||||
var pStart = html.lastIndexOf('<p ', idx);
|
||||
var pEnd = html.indexOf('</p>', idx);
|
||||
if (pStart < 0 || pEnd < 0) return html;
|
||||
var paragraph = html.slice(pStart, pEnd + 4);
|
||||
if (paragraph.indexOf('ct-vehicle-clause') >= 0) return html;
|
||||
var wrapped = '<div class="ct-vehicle-clause" data-vehicle-clause="1" data-vehicle-clause-id="'
|
||||
+ def.id
|
||||
+ '" data-vehicle-bindings="'
|
||||
+ escapeAttr(JSON.stringify(def.bindings))
|
||||
+ '">'
|
||||
+ paragraph
|
||||
+ '</div>';
|
||||
return html.slice(0, pStart) + wrapped + html.slice(pEnd + 4);
|
||||
}
|
||||
|
||||
/** 为 V26.6 标准合同注入演示用条件条款包裹(正式环境由模板管理产出) */
|
||||
export function injectPrototypeVehicleClauses(html) {
|
||||
var next = String(html || '');
|
||||
PROTOTYPE_VEHICLE_CLAUSE_DEFS.forEach(function (def) {
|
||||
next = wrapClauseParagraph(next, def);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function applyVehicleClausePreviewFilter(html, selectedKeys) {
|
||||
if (!html || typeof document === 'undefined') return String(html || '');
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
var keys = selectedKeys || [];
|
||||
div.querySelectorAll('.ct-vehicle-clause[data-vehicle-clause="1"]').forEach(function (el) {
|
||||
var bindings = parseVehicleClauseBindings(el.getAttribute('data-vehicle-bindings'));
|
||||
if (!vehicleClauseMatchesPreview(bindings, keys)) {
|
||||
el.parentNode.removeChild(el);
|
||||
} else {
|
||||
unwrapVehicleClauseElement(el);
|
||||
}
|
||||
});
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -20,11 +20,14 @@ export function getReadyVehicles() {
|
||||
return ready.length > 0 ? ready : LEASE_READY_VEHICLE_FALLBACK;
|
||||
}
|
||||
|
||||
export function getReadyVehiclePlateOptions(brandModel) {
|
||||
export function getReadyVehiclePlateOptions(brandModels) {
|
||||
var vehicles = getReadyVehicles();
|
||||
if (brandModel && brandModel.length >= 2) {
|
||||
var models = normalizeBrandModels({ brandModels: brandModels });
|
||||
if (models.length > 0) {
|
||||
vehicles = vehicles.filter(function (vehicle) {
|
||||
return vehicle.brand === brandModel[0] && vehicle.model === brandModel[1];
|
||||
return models.some(function (pair) {
|
||||
return vehicle.brand === pair[0] && vehicle.model === pair[1];
|
||||
});
|
||||
});
|
||||
}
|
||||
return vehicles.map(function (vehicle) {
|
||||
@@ -35,6 +38,13 @@ export function getReadyVehiclePlateOptions(brandModel) {
|
||||
});
|
||||
}
|
||||
|
||||
export function parsePlateSearchText(text) {
|
||||
return String(text || '')
|
||||
.split(/[\n,,;;\s]+/)
|
||||
.map(function (item) { return item.trim().toUpperCase(); })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export var LEASE_VEHICLE_BRAND_MODEL_CATALOG = [
|
||||
{ brand: '现代', models: ['帕力安牌4.5吨冷链车', '18吨氢燃料电池车', '9.6米厢式货车'] },
|
||||
{ brand: '苏龙', models: ['9.6米氢燃料电池车'] },
|
||||
@@ -88,11 +98,15 @@ export function buildBrandModelCascaderOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
export var PLATE_ACTUAL_DELIVERY = '以实际交付为准';
|
||||
|
||||
export var POA_MAX_DELEGATES = 5;
|
||||
|
||||
export function createEmptyLeaseOrderRow() {
|
||||
return {
|
||||
id: 'lo-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
brandModel: [],
|
||||
plateNo: '',
|
||||
brandModels: [],
|
||||
plateNos: [PLATE_ACTUAL_DELIVERY],
|
||||
rentServiceSubtotal: null,
|
||||
rent: null,
|
||||
serviceFee: null,
|
||||
@@ -103,6 +117,101 @@ export function createEmptyLeaseOrderRow() {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBrandModels(row) {
|
||||
if (!row) return [];
|
||||
if (Array.isArray(row.brandModels) && row.brandModels.length) {
|
||||
return row.brandModels.filter(function (item) {
|
||||
return item && item.length >= 2 && item[0] && item[1];
|
||||
});
|
||||
}
|
||||
if (row.brandModel && row.brandModel.length >= 2) {
|
||||
return [row.brandModel.slice(0, 2)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizePlateNos(row) {
|
||||
if (!row) return [PLATE_ACTUAL_DELIVERY];
|
||||
if (Array.isArray(row.plateNos) && row.plateNos.length) {
|
||||
return row.plateNos.filter(function (item) { return Boolean(item); });
|
||||
}
|
||||
if (row.plateNo) {
|
||||
return [row.plateNo];
|
||||
}
|
||||
return [PLATE_ACTUAL_DELIVERY];
|
||||
}
|
||||
|
||||
export function formatBrandModelPair(brand, model) {
|
||||
if (!brand && !model) return '';
|
||||
if (!model) return brand;
|
||||
if (!brand) return model;
|
||||
return brand + '-' + model;
|
||||
}
|
||||
|
||||
export function formatBrandModelsDisplay(brandModels) {
|
||||
return (brandModels || []).map(function (pair) {
|
||||
return formatBrandModelPair(pair[0], pair[1]);
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
export function getRowVehicleCountForPricing(row, order) {
|
||||
var plates = normalizePlateNos(row);
|
||||
var actualPlates = plates.filter(function (plate) {
|
||||
return plate !== PLATE_ACTUAL_DELIVERY;
|
||||
});
|
||||
if (actualPlates.length > 0) return actualPlates.length;
|
||||
if (plates.indexOf(PLATE_ACTUAL_DELIVERY) >= 0) {
|
||||
var rows = (order && order.rows) || [];
|
||||
var allActualOnly = rows.length > 0 && rows.every(function (item) {
|
||||
var itemPlates = normalizePlateNos(item);
|
||||
return itemPlates.length === 1 && itemPlates[0] === PLATE_ACTUAL_DELIVERY;
|
||||
});
|
||||
if (allActualOnly && rows.length === 1) {
|
||||
return calcInsuredVehicleCount(order) || 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calcInsuredVehicleCount(order) {
|
||||
var rows = (order && order.rows) || [];
|
||||
var autoFromPlates = 0;
|
||||
var hasActualOnly = false;
|
||||
rows.forEach(function (row) {
|
||||
var plates = normalizePlateNos(row);
|
||||
var actualPlates = plates.filter(function (plate) {
|
||||
return plate !== PLATE_ACTUAL_DELIVERY;
|
||||
});
|
||||
if (actualPlates.length > 0) {
|
||||
autoFromPlates += actualPlates.length;
|
||||
return;
|
||||
}
|
||||
if (plates.indexOf(PLATE_ACTUAL_DELIVERY) >= 0) {
|
||||
hasActualOnly = true;
|
||||
}
|
||||
});
|
||||
if (autoFromPlates > 0) return autoFromPlates;
|
||||
if (hasActualOnly) {
|
||||
var manual = order && order.insuredVehicleCount != null ? Number(order.insuredVehicleCount) : 0;
|
||||
if (Number.isFinite(manual) && manual > 0) return Math.floor(manual);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isInsuredVehicleCountEditable(order) {
|
||||
var rows = (order && order.rows) || [];
|
||||
return rows.some(function (row) {
|
||||
var plates = normalizePlateNos(row);
|
||||
return plates.length === 1 && plates[0] === PLATE_ACTUAL_DELIVERY;
|
||||
}) && !rows.some(function (row) {
|
||||
return normalizePlateNos(row).some(function (plate) {
|
||||
return plate !== PLATE_ACTUAL_DELIVERY;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLeasePeriod(row) {
|
||||
if (!row) return '';
|
||||
if (row.leasePeriodStart && row.leasePeriodEnd) {
|
||||
@@ -111,20 +220,72 @@ export function formatLeasePeriod(row) {
|
||||
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 },
|
||||
/** 租赁订单附加服务(按类目分组;fee 仅作原型参考,不在选择器中展示) */
|
||||
export var LEASE_EXTRA_SERVICE_TREE = [
|
||||
{
|
||||
category: '交还车服务',
|
||||
items: [
|
||||
{ value: 'door-delivery', name: '送车服务费', fee: 300 },
|
||||
{ value: 'door-pickup', name: '接车服务费', fee: 300 },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '增值保障',
|
||||
items: [
|
||||
{ value: 'carefree-pack', name: '无忧包', fee: 500 },
|
||||
{ value: 'tire-insurance', name: '轮胎保', fee: 200 },
|
||||
{ value: 'maintenance-insurance', name: '养护保', fee: 180 },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '车辆加装',
|
||||
items: [
|
||||
{ value: 'tailgate', name: '尾板', fee: 150 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export var LEASE_EXTRA_SERVICE_CATALOG = LEASE_EXTRA_SERVICE_TREE.reduce(function (list, group) {
|
||||
return list.concat(group.items);
|
||||
}, []);
|
||||
|
||||
export function flattenExtraServiceOptions() {
|
||||
var options = [];
|
||||
LEASE_EXTRA_SERVICE_TREE.forEach(function (group) {
|
||||
group.items.forEach(function (item) {
|
||||
options.push({
|
||||
value: item.value,
|
||||
name: item.name,
|
||||
category: group.category,
|
||||
});
|
||||
});
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
export function matchExtraServiceOption(option, query) {
|
||||
var normalized = (query || '').trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return option.name.toLowerCase().indexOf(normalized) >= 0
|
||||
|| option.category.toLowerCase().indexOf(normalized) >= 0;
|
||||
}
|
||||
|
||||
export function getExtraServiceCategoryByValue(value) {
|
||||
for (var i = 0; i < LEASE_EXTRA_SERVICE_TREE.length; i++) {
|
||||
var group = LEASE_EXTRA_SERVICE_TREE[i];
|
||||
for (var j = 0; j < group.items.length; j++) {
|
||||
if (group.items[j].value === value) {
|
||||
return group.category;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export var LEASE_EXTRA_SERVICE_OPTIONS = LEASE_EXTRA_SERVICE_CATALOG.map(function (item) {
|
||||
return {
|
||||
value: item.value,
|
||||
label: item.name + ' ¥' + item.fee + '/月',
|
||||
label: item.name,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -172,34 +333,39 @@ export function sumExtraServiceFees(selectedValues) {
|
||||
|
||||
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('、');
|
||||
return getExtraServiceNames(selectedValues).join('、');
|
||||
}
|
||||
|
||||
export function calcRentServiceSubtotal(rent, serviceFee) {
|
||||
export function calcRentServiceSubtotal(rent, serviceFee, vehicleCount) {
|
||||
var serviceFeeTotal = serviceFee != null && serviceFee !== '' ? Number(serviceFee) : 0;
|
||||
if (!Number.isFinite(serviceFeeTotal)) serviceFeeTotal = 0;
|
||||
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;
|
||||
var count = vehicleCount != null && vehicleCount !== '' ? Number(vehicleCount) : 1;
|
||||
if (!Number.isFinite(count) || count <= 0) count = 1;
|
||||
return Math.round((rentVal + serviceFeeTotal) * count * 100) / 100;
|
||||
}
|
||||
|
||||
export function calcLeaseOrderKpis(rows) {
|
||||
export function getRowPlateCount(row) {
|
||||
var plates = normalizePlateNos(row);
|
||||
var actualPlates = plates.filter(function (plate) {
|
||||
return plate !== PLATE_ACTUAL_DELIVERY;
|
||||
});
|
||||
if (actualPlates.length > 0) return actualPlates.length;
|
||||
if (plates.indexOf(PLATE_ACTUAL_DELIVERY) >= 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calcLeaseOrderKpis(rows, order) {
|
||||
var rentServiceTaxTotal = 0;
|
||||
var depositTotal = 0;
|
||||
var hasSubtotal = false;
|
||||
var hasDeposit = false;
|
||||
(rows || []).forEach(function (row) {
|
||||
var subtotal = calcRentServiceSubtotal(row.rent, row.serviceFee);
|
||||
var vehicleCount = getRowVehicleCountForPricing(row, order || { rows: rows });
|
||||
var subtotal = calcRentServiceSubtotal(row.rent, row.serviceFee, vehicleCount);
|
||||
if (subtotal != null) {
|
||||
rentServiceTaxTotal += subtotal;
|
||||
hasSubtotal = true;
|
||||
@@ -207,7 +373,7 @@ export function calcLeaseOrderKpis(rows) {
|
||||
if (row.deposit != null && row.deposit !== '') {
|
||||
var depositVal = Number(row.deposit);
|
||||
if (Number.isFinite(depositVal)) {
|
||||
depositTotal += depositVal;
|
||||
depositTotal += depositVal * vehicleCount;
|
||||
hasDeposit = true;
|
||||
}
|
||||
}
|
||||
@@ -233,14 +399,37 @@ export function formatKpiAmount(value) {
|
||||
});
|
||||
}
|
||||
|
||||
export function countLeaseOrderRows(rows) {
|
||||
export function countLeaseOrderRows(rows, order) {
|
||||
if (!rows || !rows.length) return 0;
|
||||
return rows.length;
|
||||
if (order) return calcInsuredVehicleCount(order);
|
||||
var total = 0;
|
||||
rows.forEach(function (row) {
|
||||
total += getRowPlateCount(row);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
export function isDelegateRowStarted(row) {
|
||||
if (!row) return false;
|
||||
return Boolean((row.name || '').trim())
|
||||
|| Boolean((row.contact || '').trim())
|
||||
|| Boolean((row.idNumber || '').trim());
|
||||
}
|
||||
|
||||
export function isDelegateRowComplete(row) {
|
||||
if (!row) return false;
|
||||
return Boolean((row.name || '').trim())
|
||||
&& Boolean((row.contact || '').trim())
|
||||
&& Boolean((row.idNumber || '').trim());
|
||||
}
|
||||
|
||||
export function normalizeDelegateRows(delegates) {
|
||||
return (delegates || []).filter(isDelegateRowStarted);
|
||||
}
|
||||
|
||||
export function createDefaultLeaseOrderState() {
|
||||
return {
|
||||
insuredVehicleCount: null,
|
||||
insuredVehicleCount: 1,
|
||||
thirdPartyLiabilityMillion: null,
|
||||
deliveryRegion: [],
|
||||
deliveryDate: null,
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const protoRoot = path.resolve(__dirname, '..');
|
||||
const specRoot = path.join(protoRoot, '.spec');
|
||||
const resourcesPrd = path.resolve(protoRoot, '../../resources/lease-contract-management/PRD.md');
|
||||
|
||||
const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8');
|
||||
const createPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-create.md'), 'utf8');
|
||||
const fullPrd = fs.readFileSync(resourcesPrd, 'utf8');
|
||||
|
||||
const NODE_DEFS = [
|
||||
{ id: 'lc-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项,可展开至 12 项;查询与 KPI 联动。' },
|
||||
{ id: 'lc-list-kpi', title: 'KPI 统计卡片', pageId: 'list', color: '#64748b', aiPrompt: '五张 KPI 卡片口径与点击筛选。', annotationText: '全部 / 进行中 / 已终止 / 临期 / 过期。' },
|
||||
{ id: 'lc-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '费用模板、导出、新增入口。', annotationText: '「新增」进入租赁合同创建页。' },
|
||||
{ id: 'lc-list-table', title: '合同台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '台账表格列与操作。', annotationText: '' },
|
||||
{ id: 'lc-create-main-contract', title: '主体合同信息', pageId: 'create', color: '#32a06e', aiPrompt: '签约、里程、费用三块表单。', annotationText: '完成度徽章;甲乙档案只读同步。' },
|
||||
{ id: 'lc-create-lease-order', title: '附件1:租赁订单', pageId: 'create', color: '#2563eb', aiPrompt: '订单车辆与交车信息。', annotationText: '三者责任险等为完成度必填项。' },
|
||||
{ id: 'lc-create-poa', title: '授权委托书', pageId: 'create', color: '#7c3aed', aiPrompt: '被委托人列表编辑。', annotationText: '至少一行且姓名/联系方式/身份证完整。' },
|
||||
{ id: 'lc-create-preview', title: '实时预览', pageId: 'create', color: '#0ea5e9', aiPrompt: 'Word 版式预览与可编辑正文。', annotationText: '改动风控红线条款将触发非标准合同审批。' },
|
||||
{ id: 'lc-create-footer', title: '底栏操作', pageId: 'create', color: '#64748b', aiPrompt: '取消、保存草稿、提交审核。', annotationText: '保存/提交时展示将走的审批类型。' },
|
||||
];
|
||||
|
||||
const prdByKey = { list: listPrd, create: createPrd };
|
||||
|
||||
function extractSection(md, startHeading) {
|
||||
const lines = md.split('\n');
|
||||
let start = lines.findIndex((line) => line.trim() === startHeading || line.startsWith(startHeading));
|
||||
if (start < 0) return null;
|
||||
const startLevel = (lines[start].match(/^(#+)/)?.[1] ?? '##').length;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^#{1,6}\s/.test(lines[i])) {
|
||||
const level = lines[i].match(/^(#+)/)?.[1].length ?? 2;
|
||||
if (level <= startLevel) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.slice(start, end).join('\n').trim();
|
||||
}
|
||||
|
||||
function extractFromPrd({ prd, heading, sub, combine }) {
|
||||
const md = prdByKey[prd];
|
||||
if (!md) return null;
|
||||
if (combine?.length) {
|
||||
return combine.map((h) => extractSection(md, h)).filter(Boolean).join('\n\n---\n\n') || null;
|
||||
}
|
||||
const base = extractSection(md, heading);
|
||||
if (!base) return null;
|
||||
if (!sub) return base;
|
||||
return extractSection(base, sub) ?? base;
|
||||
}
|
||||
|
||||
const NODE_PRD_MAP = {
|
||||
'lc-list-filter': { prd: 'list', heading: '## 4. 筛选区' },
|
||||
'lc-list-kpi': { prd: 'list', heading: '## 5. KPI 统计卡片' },
|
||||
'lc-list-toolbar': { prd: 'list', heading: '## 6. 列表工具栏' },
|
||||
'lc-list-table': { prd: 'list', heading: '## 7. 列表字段说明', combine: ['## 7. 列表字段说明', '## 8. 操作与弹窗'] },
|
||||
'lc-create-main-contract': { prd: 'create', heading: '## 2. 主体合同信息' },
|
||||
'lc-create-lease-order': { prd: 'create', heading: '## 3. 附件1:租赁订单' },
|
||||
'lc-create-poa': { prd: 'create', heading: '## 4. 授权委托书' },
|
||||
'lc-create-preview': { prd: 'create', heading: '## 5. 实时预览', combine: ['## 5. 实时预览', '## 6. 审批类型判定'] },
|
||||
'lc-create-footer': { prd: 'create', heading: '## 7. 底栏操作' },
|
||||
};
|
||||
|
||||
|
||||
function buildFallbackMarkdown(node) {
|
||||
const parts = [`# ${node.title}`, ''];
|
||||
if (node.aiPrompt) parts.push(node.aiPrompt, '');
|
||||
if (node.annotationText?.trim()) parts.push('## 说明', '', node.annotationText.trim());
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
|
||||
function buildNodeMarkdown(node) {
|
||||
const mapping = NODE_PRD_MAP[node.id];
|
||||
if (mapping) {
|
||||
const prdBody = extractFromPrd(mapping);
|
||||
if (prdBody) return prdBody;
|
||||
}
|
||||
return buildFallbackMarkdown(node);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const nodes = NODE_DEFS.map((def, index) => ({
|
||||
id: def.id,
|
||||
index: index + 1,
|
||||
title: def.title,
|
||||
pageId: def.pageId,
|
||||
locator: {
|
||||
selectors: [`[data-annotation-id="${def.id}"]`],
|
||||
fingerprint: def.id,
|
||||
path: [],
|
||||
},
|
||||
aiPrompt: def.aiPrompt,
|
||||
annotationText: def.annotationText,
|
||||
hasMarkdown: true,
|
||||
color: def.color,
|
||||
images: [],
|
||||
createdAt: 1740787200000,
|
||||
updatedAt: 1740787200000,
|
||||
}));
|
||||
|
||||
const markdownMap = Object.fromEntries(nodes.map((node) => [node.id, buildNodeMarkdown(node)]));
|
||||
|
||||
const listPageNodes = nodes.filter((n) => n.pageId === 'list');
|
||||
const createPageNodes = nodes.filter((n) => n.pageId === 'create');
|
||||
|
||||
const overviewMd = extractSection(fullPrd, '## 1. 背景与目标') ?? '# 租赁合同管理';
|
||||
|
||||
const source = {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: {
|
||||
version: 2,
|
||||
prototypeName: 'lease-contract-management',
|
||||
pageId: 'list',
|
||||
updatedAt: now,
|
||||
nodes,
|
||||
},
|
||||
markdownMap,
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lc-doc-root',
|
||||
title: '租赁合同管理说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lc-doc-overview',
|
||||
title: '模块总览',
|
||||
markdown: overviewMd,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'lc-link-template',
|
||||
title: '合同模板管理',
|
||||
href: '/prototypes/contract-template-management',
|
||||
target: 'self',
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lc-doc-prd',
|
||||
title: 'PRD 全文',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lc-doc-prd-full',
|
||||
title: 'PRD 完整文档',
|
||||
markdown: fullPrd,
|
||||
markdownPath: 'src/resources/lease-contract-management/PRD.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lc-doc-prd-list',
|
||||
title: '列表页 PRD',
|
||||
markdown: listPrd,
|
||||
markdownPath: 'src/prototypes/lease-contract-management/.spec/requirements-prd-list.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lc-doc-prd-create',
|
||||
title: '新增页 PRD',
|
||||
markdown: createPrd,
|
||||
markdownPath: 'src/prototypes/lease-contract-management/.spec/requirements-prd-create.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lc-doc-prd-acceptance',
|
||||
title: '验收标准',
|
||||
markdown: extractSection(fullPrd, '## 12. 验收标准') ?? '## 验收标准',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lc-doc-list',
|
||||
title: '列表页模块',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'lc-route-list',
|
||||
title: '打开列表页',
|
||||
route: 'lease-contract:list',
|
||||
payload: { pageId: 'list' },
|
||||
},
|
||||
...listPageNodes.map((node) => ({
|
||||
type: 'markdown',
|
||||
id: `lc-doc-node-${node.id}`,
|
||||
title: node.title,
|
||||
markdown: markdownMap[node.id],
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'lc-doc-create',
|
||||
title: '新增页模块',
|
||||
defaultExpanded: false,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'lc-route-create',
|
||||
title: '打开新增页',
|
||||
route: 'lease-contract:create',
|
||||
payload: { pageId: 'create' },
|
||||
},
|
||||
...createPageNodes.map((node) => ({
|
||||
type: 'markdown',
|
||||
id: `lc-doc-node-${node.id}`,
|
||||
title: node.title,
|
||||
markdown: markdownMap[node.id],
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'lc-doc-annotation-hint',
|
||||
title: '标注查看提示',
|
||||
markdown: '## 标注查看提示\n\n右侧标注工具栏可浏览目录、切换主题与颜色筛选。列表页与新增页标注按当前页面自动显隐。\n\n**PRD 同步**:修改 `src/resources/lease-contract-management/PRD.md` 或 `.spec/requirements-prd-*.md` 后,运行 `node src/prototypes/lease-contract-management/scripts/build-annotation-source.mjs` 重新生成 `annotation-source.json`。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(protoRoot, 'annotation-source.json'),
|
||||
`${JSON.stringify(source, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
console.log(`Built annotation-source.json with ${nodes.length} nodes.`);
|
||||
@@ -24,6 +24,239 @@
|
||||
background: var(--ln-primary-soft);
|
||||
}
|
||||
|
||||
/* —— 主体合同信息编辑区 —— */
|
||||
.vm-page.lc-create-page .lc-main-contract-card__head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-panel {
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
background: var(--ln-surface-card);
|
||||
box-shadow: var(--ln-shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card__header {
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--ln-hairline);
|
||||
background: linear-gradient(180deg, #fafafa 0%, var(--ln-surface-card) 100%);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card.is-collapsed .lc-form-card__header {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card__title-row .vm-section-title {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card__hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card__hint-icon {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in srgb, var(--ln-primary) 72%, var(--ln-muted));
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card__hint-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-form-card__body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-signing-core {
|
||||
padding: 14px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: var(--ln-canvas-soft);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-party-profiles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-party-profiles-empty {
|
||||
margin-top: 16px;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
border-radius: var(--ln-radius-control);
|
||||
border: 1px dashed var(--ln-hairline-strong);
|
||||
background: var(--ln-canvas-soft);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-party-profiles-empty__title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-party-profiles-empty__desc {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ln-muted);
|
||||
max-width: 28rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-readonly-panel {
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-readonly-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--ln-hairline);
|
||||
background: color-mix(in srgb, var(--ln-primary-soft) 55%, #fff);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-readonly-panel__title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-readonly-panel__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--ln-radius-pill);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--ln-primary);
|
||||
background: #fff;
|
||||
border: 1px solid color-mix(in srgb, var(--ln-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-readonly-panel__body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-credentials-block {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-credentials-block__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-credentials-block__title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-credentials-block__hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-mileage-types-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-mileage-types-wrap__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb {
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb:hover {
|
||||
border-color: color-mix(in srgb, var(--ln-primary) 35%, var(--ln-hairline));
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb:focus-within {
|
||||
outline: 2px solid var(--ln-focus-ring, #32a06e);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-mileage-type {
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-mileage-type:hover {
|
||||
border-color: color-mix(in srgb, var(--ln-primary) 25%, var(--ln-hairline));
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-mileage-type.is-active {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ln-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb,
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-mileage-type {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -40,6 +273,134 @@
|
||||
.vm-page.lc-create-page .lc-lease-order-kpi-card {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 14px 16px 14px 52px;
|
||||
border-color: color-mix(in srgb, var(--ln-primary) 18%, var(--ln-hairline));
|
||||
background: linear-gradient(135deg, var(--ln-surface-card) 0%, var(--ln-primary-soft) 140%);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-kpi-card__icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9999px;
|
||||
background: var(--ln-primary-soft);
|
||||
color: var(--ln-primary-focus);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-kpi-card .vm-kpi-eyebrow {
|
||||
color: var(--ln-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-kpi-card .vm-kpi-val {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-form-completion-badge--optional {
|
||||
background: var(--ln-canvas-soft);
|
||||
color: var(--ln-muted);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-brand-model-picker__trigger,
|
||||
.vm-page.lc-create-page .lc-lease-order-plate-picker__trigger {
|
||||
min-height: 36px;
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-brand-model-picker__summary-list,
|
||||
.vm-page.lc-create-page .lc-lease-order-plate-picker__summary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-brand-model-picker__summary-line,
|
||||
.vm-page.lc-create-page .lc-lease-order-plate-picker__summary-line {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--ln-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lc-lease-order-brand-model-picker__selected-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lc-lease-order-brand-model-picker__selected-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lc-lease-order-plate-popover__panel {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lc-lease-order-plate-popover__bulk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lc-lease-order-plate-popover__bulk-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lc-lease-order-plate-popover__bulk-input {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ln-hairline-strong);
|
||||
border-radius: var(--ln-radius-control);
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.lc-lease-order-plate-popover__bulk-btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.lc-lease-order-plate-popover__actual {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-poa-editor .lc-form-hint {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-kpi-card:hover {
|
||||
@@ -89,104 +450,109 @@
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.vm-page.lc-create-page .lc-lease-order-service-picker {
|
||||
width: 100%;
|
||||
min-width: 168px;
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-field__display {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
min-height: 36px;
|
||||
max-height: none;
|
||||
padding: 6px 11px;
|
||||
white-space: normal;
|
||||
.vm-page.lc-create-page .lc-lease-order-service-picker__trigger {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-picker__summary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-field__display:not(:has(.lc-lease-order-service-field__list)) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
max-height: 36px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-field__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-field__list li {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: var(--ln-ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-field__pick-btn.ant-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal .ant-modal-body {
|
||||
padding-top: 12px;
|
||||
.vm-page.lc-create-page .lc-lease-order-service-picker__summary.is-placeholder {
|
||||
color: var(--ln-muted-soft);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
.lc-lease-order-service-popover .ant-popover-inner {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
.lc-lease-order-service-popover .lc-lease-order-service-popover__panel {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: min(420px, calc(100vh - 120px));
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__table th,
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--ln-hairline);
|
||||
font-size: 0.875rem;
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
.lc-lease-order-service-popover .vm-operate-city-edit-selected {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__table th {
|
||||
background: var(--ln-canvas-soft);
|
||||
color: var(--ln-muted);
|
||||
.lc-lease-order-service-popover .lc-lease-order-service-popover__selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lc-lease-order-service-popover .lc-lease-order-service-popover__selected-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid color-mix(in srgb, var(--ln-primary) 22%, transparent);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
.lc-lease-order-service-popover .lc-lease-order-service-popover__selected-tag button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--ln-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__col-check {
|
||||
width: 72px;
|
||||
text-align: center;
|
||||
.lc-lease-order-service-popover .lc-lease-order-service-popover__selected-tag button:hover {
|
||||
background: var(--ln-hairline);
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-service-modal__col-fee {
|
||||
width: 96px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
.lc-lease-order-service-popover .vm-operate-city-picker-panel {
|
||||
padding: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.lc-lease-order-service-popover .vm-operate-city-edit-main {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.lc-lease-order-service-popover .vm-operate-city-edit-main {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.lc-lease-order-service-popover .vm-operate-city-edit-step--province {
|
||||
padding-right: 0;
|
||||
border-right: none;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.lc-lease-order-service-popover .vm-operate-city-edit-step--province .vm-operate-city-edit-chips {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
max-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-customer-picker__trigger {
|
||||
@@ -544,7 +910,66 @@
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-editor__th-brand {
|
||||
min-width: 108px;
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-editor__brand-cell {
|
||||
min-width: 148px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-editor__brand-cell .lc-form-cascader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint--empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint--matched {
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: color-mix(in srgb, var(--ln-primary) 6%, #f8faf9);
|
||||
border: 1px solid color-mix(in srgb, var(--ln-primary) 14%, var(--ln-hairline));
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint__lead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
margin: 0 0 4px;
|
||||
color: color-mix(in srgb, var(--ln-ink) 80%, var(--ln-muted));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint__item {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint__label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--ln-primary);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint__summary {
|
||||
display: block;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-brand-model-clause-hint__icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-lease-order-editor__th-subtotal {
|
||||
@@ -863,6 +1288,105 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 12px 14px 0;
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint__card {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--ln-surface-card, #fff);
|
||||
border: 1px solid var(--ln-hairline, #e5e7eb);
|
||||
border-radius: var(--ln-radius-pill, 9999px);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint__icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--ln-primary-focus, #15803d);
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint__text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--ln-muted, #52525b);
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint__risk {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 2px;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--ln-radius-pill, 9999px);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint__tag--std {
|
||||
color: var(--ln-primary-focus, #15803d);
|
||||
background: var(--ln-primary-soft, #ecfdf3);
|
||||
border: 1px solid color-mix(in srgb, var(--ln-primary, #32a06e) 24%, transparent);
|
||||
}
|
||||
|
||||
.lc-preview-editable-hint__tag--nonstd {
|
||||
color: #b45309;
|
||||
background: #fff7ed;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 28%, transparent);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-preview-page__body--editable {
|
||||
cursor: text;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-preview-page__body--editable:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgba(50, 160, 110, 0.35);
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-preview-page__body--editable .ct-risk-redline {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.lc-approval-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lc-approval-type-badge--standard {
|
||||
color: #166534;
|
||||
background: #ecfdf3;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
.lc-approval-type-badge--nonstd {
|
||||
color: #b45309;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fdba74;
|
||||
}
|
||||
|
||||
.vm-page.lc-create-page .lc-create-editor-panel,
|
||||
.vm-page.lc-create-page.ct-page .ct-editor-workspace .ct-editor-column-card .ct-editor-panel.lc-create-editor-panel {
|
||||
display: flex;
|
||||
|
||||
@@ -194,6 +194,16 @@
|
||||
color: var(--ln-link);
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-list-table .vm-stack .code {
|
||||
font-family: var(--vm-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-list-table .ant-table-cell {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vm-page.lc-page .lc-project-info-btn .vm-stack .sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-body);
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface PhaseWorkItem {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export type RequirementStage = '原型设计' | '需求确认' | '需求跟踪' | '需求规划' | '无需求开发';
|
||||
export type RequirementStage = '原型设计' | '需求确认' | '需求跟踪' | '需求规划' | 'BUG修复' | '无需求开发';
|
||||
export type RequirementPriority = '高优先级' | '中优先级' | '低优先级';
|
||||
|
||||
export interface RequirementItem {
|
||||
@@ -262,7 +262,7 @@ export const TRACK_PROGRESS: TrackProgress[] = [
|
||||
key: 'prototype',
|
||||
label: '需求',
|
||||
count: 20,
|
||||
done: 8,
|
||||
done: 7,
|
||||
tabs: [
|
||||
{
|
||||
key: 'new-feature',
|
||||
@@ -273,17 +273,16 @@ export const TRACK_PROGRESS: TrackProgress[] = [
|
||||
{
|
||||
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-1', stage: '原型设计', priority: '高优先级', title: '标准合同管理功能原型设计', progress: 100 },
|
||||
{ id: 'WR-NF-W-2', stage: '原型设计', priority: '高优先级', title: '新合同体系功能原型设计', progress: 100 },
|
||||
{ id: 'WR-NF-W-3', stage: '需求确认', priority: '高优先级', title: '客户评级体系功能及原型设计,法务确认中', progress: 30 },
|
||||
{ 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-8', stage: '需求跟踪', priority: '高优先级', title: '保险采购功能优化', progress: 70 },
|
||||
{ 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-11', stage: '原型设计', priority: '高优先级', title: '租赁台账功能', progress: 100 },
|
||||
{ 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 },
|
||||
@@ -293,6 +292,7 @@ export const TRACK_PROGRESS: TrackProgress[] = [
|
||||
title: '小程序',
|
||||
items: [
|
||||
{ id: 'WR-NF-M-1', stage: '需求跟踪', priority: '中优先级', title: '审批中心「提车应收款、还车应结款」功能,测试发现部分问题,开发重新修改', progress: 65 },
|
||||
{ id: 'WR-NF-M-2', stage: 'BUG修复', priority: '中优先级', title: '年审任务在待办列表重复显示', progress: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 自营业务台账 · 产品需求说明(PRD)
|
||||
|
||||
> 参照《2026年业务二部运营台账总表》物流业务台账子表,按自营业务场景重新表达;行内编辑与导入为原型 Toast 演示。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 数据分析 — 自营业务台账 |
|
||||
| 目标用户 | 业务服务组操作人、主管 |
|
||||
| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |
|
||||
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 筛选区
|
||||
|
||||
默认展示 4 项,点击「更多筛选」展开。
|
||||
|
||||
| 筛选项 | 说明 |
|
||||
|---|---|
|
||||
| 月份 | YYYY-MM |
|
||||
| 出车日期 | 日期区间 |
|
||||
| 业务名称 | 精确匹配 |
|
||||
| 车牌号码 | 多选 |
|
||||
| 车辆承接方 | 我司 / 第三方车队 |
|
||||
| 品牌 / 型号 | 级联 |
|
||||
| 维护人 | 仅主管可见 |
|
||||
|
||||
---
|
||||
|
||||
## 3. KPI 统计卡片(6 张)
|
||||
|
||||
台账条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表工具栏
|
||||
|
||||
批量导入、导出、批量删除、添加行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 列表字段(核心列)
|
||||
|
||||
月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。
|
||||
|
||||
盈亏 = 金额 − 总成本(样例数据已预计算)。
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "self-operated-business-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1782697685043,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sob-filter",
|
||||
"index": 1,
|
||||
"title": "筛选条件",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"sob-filter\"]"
|
||||
],
|
||||
"fingerprint": "sob-filter",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "月份、出车日期、业务、车牌等筛选。",
|
||||
"annotationText": "默认 4 项,可展开;主管可见维护人。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "sob-kpi",
|
||||
"index": 2,
|
||||
"title": "KPI 统计",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"sob-kpi\"]"
|
||||
],
|
||||
"fingerprint": "sob-kpi",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "六张统计卡片口径。",
|
||||
"annotationText": "金额、成本、氢费、ETC、盈亏汇总。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#64748b",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "sob-toolbar",
|
||||
"index": 3,
|
||||
"title": "列表工具栏",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"sob-toolbar\"]"
|
||||
],
|
||||
"fingerprint": "sob-toolbar",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "导入、导出、批量删除、添加行。",
|
||||
"annotationText": "",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
},
|
||||
{
|
||||
"id": "sob-table",
|
||||
"index": 4,
|
||||
"title": "自营台账宽表",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"sob-table\"]"
|
||||
],
|
||||
"fingerprint": "sob-table",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "出车记录与费用明细。",
|
||||
"annotationText": "盈亏按金额减总成本展示。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
}
|
||||
]
|
||||
},
|
||||
"markdownMap": {
|
||||
"sob-filter": "## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---",
|
||||
"sob-kpi": "## 3. KPI 统计卡片(6 张)\n\n台账条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n---",
|
||||
"sob-toolbar": "## 4. 列表工具栏\n\n批量导入、导出、批量删除、添加行。\n\n---",
|
||||
"sob-table": "## 5. 列表字段(核心列)\n\n月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。\n\n盈亏 = 金额 − 总成本(样例数据已预计算)。"
|
||||
},
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "sob-doc-root",
|
||||
"title": "自营业务台账说明",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "sob-doc-overview",
|
||||
"title": "模块总览",
|
||||
"markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 数据分析 — 自营业务台账 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "sob-link-self",
|
||||
"title": "自营业务台账原型",
|
||||
"href": "/prototypes/self-operated-business-ledger",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "sob-doc-prd",
|
||||
"title": "PRD 全文",
|
||||
"markdown": "# 自营业务台账 · 产品需求说明(PRD)\n\n> 参照《2026年业务二部运营台账总表》物流业务台账子表,按自营业务场景重新表达;行内编辑与导入为原型 Toast 演示。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 数据分析 — 自营业务台账 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n\n---\n\n## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---\n\n## 3. KPI 统计卡片(6 张)\n\n台账条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n---\n\n## 4. 列表工具栏\n\n批量导入、导出、批量删除、添加行。\n\n---\n\n## 5. 列表字段(核心列)\n\n月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。\n\n盈亏 = 金额 − 总成本(样例数据已预计算)。\n"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "sob-doc-sob-filter",
|
||||
"title": "筛选条件",
|
||||
"markdown": "## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "sob-doc-sob-kpi",
|
||||
"title": "KPI 统计",
|
||||
"markdown": "## 3. KPI 统计卡片(6 张)\n\n台账条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "sob-doc-sob-toolbar",
|
||||
"title": "列表工具栏",
|
||||
"markdown": "## 4. 列表工具栏\n\n批量导入、导出、批量删除、添加行。\n\n---"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "sob-doc-sob-table",
|
||||
"title": "自营台账宽表",
|
||||
"markdown": "## 5. 列表字段(核心列)\n\n月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。\n\n盈亏 = 金额 − 总成本(样例数据已预计算)。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import type { SelfOperatedFilters, SelfOperatedLedgerRow } from '../types';
|
||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||
import { MultiSelectField } from '../../customer-management/components/MultiSelectField';
|
||||
import { BRAND_MODEL_MAP, UNDERTAKER_OPTIONS, buildSelfOptions } from '../utils/ledger';
|
||||
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
const IS_SUPERVISOR = true;
|
||||
|
||||
interface FilterPanelProps {
|
||||
records: SelfOperatedLedgerRow[];
|
||||
filters: SelfOperatedFilters;
|
||||
onChange: (patch: Partial<SelfOperatedFilters>) => void;
|
||||
onReset: () => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const monthOptions = useMemo(() => buildSelfOptions(records, 'month'), [records]);
|
||||
const businessOptions = useMemo(() => buildSelfOptions(records, 'businessName'), [records]);
|
||||
const plateOptions = useMemo(() => buildSelfOptions(records, 'plateNo'), [records]);
|
||||
const brandOptions = useMemo(() => Object.keys(BRAND_MODEL_MAP), []);
|
||||
const modelOptions = useMemo(
|
||||
() => (filters.brand ? BRAND_MODEL_MAP[filters.brand] ?? [] : []),
|
||||
[filters.brand],
|
||||
);
|
||||
const maintainerOptions = useMemo(() => buildSelfOptions(records, 'maintainerName'), [records]);
|
||||
|
||||
const fields = [
|
||||
{ label: '月份', control: <FilterPickerField value={filters.month} options={monthOptions} placeholder="全部月份" ariaLabel="月份" onChange={(month) => onChange({ month })} /> },
|
||||
{
|
||||
label: '出车日期',
|
||||
control: (
|
||||
<div className="ldb-date-range">
|
||||
<input type="date" className="vm-input" value={filters.dispatchFrom} aria-label="出车开始日期" onChange={(e) => onChange({ dispatchFrom: e.target.value })} />
|
||||
<span aria-hidden>—</span>
|
||||
<input type="date" className="vm-input" value={filters.dispatchTo} aria-label="出车结束日期" onChange={(e) => onChange({ dispatchTo: e.target.value })} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ label: '业务名称', control: <FilterPickerField value={filters.businessName} options={businessOptions} placeholder="请选择业务名称" ariaLabel="业务名称" onChange={(businessName) => onChange({ businessName })} /> },
|
||||
{ label: '车牌号码', control: <MultiSelectField values={filters.plateNos} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNos) => onChange({ plateNos })} /> },
|
||||
{ label: '车辆承接方', control: <FilterPickerField value={filters.vehicleUndertaker} options={UNDERTAKER_OPTIONS} placeholder="全部" ariaLabel="车辆承接方" onChange={(vehicleUndertaker) => onChange({ vehicleUndertaker })} /> },
|
||||
{
|
||||
label: '品牌',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.brand}
|
||||
options={brandOptions}
|
||||
placeholder="全部品牌"
|
||||
ariaLabel="品牌"
|
||||
onChange={(brand) => onChange({ brand, model: '' })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '型号',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.model}
|
||||
options={modelOptions}
|
||||
placeholder={filters.brand ? '全部型号' : '请先选择品牌'}
|
||||
ariaLabel="型号"
|
||||
onChange={(model) => onChange({ model })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (IS_SUPERVISOR) {
|
||||
fields.push({
|
||||
label: '维护人',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.maintainerName}
|
||||
options={maintainerOptions}
|
||||
placeholder="全部维护人"
|
||||
ariaLabel="维护人"
|
||||
onChange={(maintainerName) => onChange({ maintainerName })}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const visible = expanded ? fields : fields.slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card" data-annotation-id="sob-filter">
|
||||
<header className="vm-filter-header">
|
||||
<h1 className="ldb-page-title">自营业务台账</h1>
|
||||
<p className="ldb-page-desc">业务服务组操作人维护各自台账;主管可查看全部。支持导入、行内编辑与盈亏自动计算(原型演示)。</p>
|
||||
</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 > DEFAULT_VISIBLE && (
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
<ChevronDown size={14} aria-hidden style={{ transform: expanded ? 'rotate(180deg)' : undefined }} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
import type { SelfOperatedKpiSummary } from '../types';
|
||||
import { formatMoney } from '../utils/ledger';
|
||||
|
||||
const CARDS: Array<{ key: keyof SelfOperatedKpiSummary; title: string; desc: string; money?: boolean }> = [
|
||||
{ key: 'count', title: '台账条数', desc: '当前筛选条件下的全部台账记录条数' },
|
||||
{ key: 'amount', title: '金额合计', desc: '每条台账「金额」字段逐项相加', money: true },
|
||||
{ key: 'totalCost', title: '总成本合计', desc: '每条台账「总成本」字段逐项相加', money: true },
|
||||
{ key: 'hydrogenFee', title: '氢费总计', desc: '每条台账「氢费」字段逐项相加', money: true },
|
||||
{ key: 'etcFee', title: 'ETC费用总计', desc: '每条台账「ETC费用」字段逐项相加', money: true },
|
||||
{ key: 'profitLoss', title: '盈亏合计', desc: '每条台账「盈亏」字段逐项相加', money: true },
|
||||
];
|
||||
|
||||
interface LedgerKpiRowProps {
|
||||
summary: SelfOperatedKpiSummary;
|
||||
}
|
||||
|
||||
export function LedgerKpiRow({ summary }: LedgerKpiRowProps) {
|
||||
return (
|
||||
<div className="ldb-kpi-row" data-annotation-id="sob-kpi">
|
||||
{CARDS.map((card) => {
|
||||
const raw = summary[card.key];
|
||||
const value = card.money ? formatMoney(raw) : String(raw);
|
||||
const profitClass = card.key === 'profitLoss'
|
||||
? (summary.profitLoss >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss')
|
||||
: '';
|
||||
return (
|
||||
<div key={card.key} className="ldb-kpi-card">
|
||||
<span className="ldb-kpi-tip" tabIndex={0} aria-label={`${card.title}说明`}>
|
||||
<CircleHelp size={14} aria-hidden />
|
||||
<span className="ldb-kpi-tooltip" role="tooltip">{card.desc}</span>
|
||||
</span>
|
||||
<span className="ldb-kpi-label">{card.title}</span>
|
||||
<span className={`ldb-kpi-val tabular-nums ${profitClass}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import type { SelfOperatedLedgerRow } from '../types';
|
||||
import { formatMoney } from '../utils/ledger';
|
||||
|
||||
interface SelfLedgerTableProps {
|
||||
records: SelfOperatedLedgerRow[];
|
||||
selectedIds: Set<string>;
|
||||
onToggleRow: (id: string, checked: boolean) => void;
|
||||
onToggleAll: (checked: boolean) => void;
|
||||
onEdit: (record: SelfOperatedLedgerRow) => void;
|
||||
onDelete: (record: SelfOperatedLedgerRow) => void;
|
||||
}
|
||||
|
||||
export function SelfLedgerTable({
|
||||
records,
|
||||
selectedIds,
|
||||
onToggleRow,
|
||||
onToggleAll,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: SelfLedgerTableProps) {
|
||||
const allSelected = records.length > 0 && records.every((r) => selectedIds.has(r.id));
|
||||
|
||||
if (!records.length) {
|
||||
return <div className="vm-empty" data-annotation-id="sob-table"><p>暂无符合条件的台账记录</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vm-table-wrap ldb-table-wrap" data-annotation-id="sob-table">
|
||||
<table className="vm-table ldb-table sob-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ldb-col-check sticky-col-left">
|
||||
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
||||
</th>
|
||||
<th>月份</th>
|
||||
<th>出车日期</th>
|
||||
<th>业务名称</th>
|
||||
<th>司机</th>
|
||||
<th>电话</th>
|
||||
<th>车辆承接方</th>
|
||||
<th>车牌号码</th>
|
||||
<th>品牌</th>
|
||||
<th>型号</th>
|
||||
<th className="ldb-col-money">单价</th>
|
||||
<th className="ldb-col-money">数量</th>
|
||||
<th className="ldb-col-money">金额</th>
|
||||
<th className="ldb-col-money">氢费</th>
|
||||
<th className="ldb-col-money">ETC费用</th>
|
||||
<th className="ldb-col-money">总成本</th>
|
||||
<th className="ldb-col-money">盈亏</th>
|
||||
<th>维护人</th>
|
||||
<th className="sticky-col-right ldb-col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="ldb-col-check sticky-col-left">
|
||||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={(e) => onToggleRow(row.id, e.target.checked)} aria-label={`选择 ${row.plateNo}`} />
|
||||
</td>
|
||||
<td className="tabular-nums">{row.month}</td>
|
||||
<td className="tabular-nums">{row.dispatchDate}</td>
|
||||
<td className="ldb-col-name" title={row.businessName}>{row.businessName}</td>
|
||||
<td>{row.driver}</td>
|
||||
<td className="tabular-nums">{row.phone}</td>
|
||||
<td>{row.vehicleUndertaker}</td>
|
||||
<td className="tabular-nums">{row.plateNo}</td>
|
||||
<td>{row.brand}</td>
|
||||
<td title={row.model}>{row.model}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.unitPrice)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{row.quantity}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.amount)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.hydrogenFee)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.etcFee)}</td>
|
||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.totalCost)}</td>
|
||||
<td className={`ldb-col-money tabular-nums ${row.profitLoss >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss'}`}>{formatMoney(row.profitLoss)}</td>
|
||||
<td>{row.maintainerName}</td>
|
||||
<td className="sticky-col-right ldb-col-actions">
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(row)}>编辑</button>
|
||||
<span className="ldb-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link ldb-link-danger" onClick={() => onDelete(row)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{ "id": "s1", "month": "2026-01", "dispatchDate": "2026-01-01", "businessName": "嘉兴金小悦贸易有限公司", "driver": "王召善", "phone": "15112321670", "vehicleUndertaker": "我司", "plateNo": "粤AGQ8393", "brand": "现代", "model": "帕力安牌4.5吨冷链车", "unitPrice": 590, "quantity": 1, "amount": 590, "hydrogenFee": 303.8, "etcFee": 0, "totalCost": 701.86, "profitLoss": -111.86, "maintainerName": "谭晓峰" },
|
||||
{ "id": "s2", "month": "2026-01", "dispatchDate": "2026-01-01", "businessName": "嘉兴金小悦贸易有限公司", "driver": "杨国平", "phone": "13720987869", "vehicleUndertaker": "我司", "plateNo": "粤AGR3208", "brand": "现代", "model": "帕力安牌4.5吨冷链车", "unitPrice": 610, "quantity": 1, "amount": 610, "hydrogenFee": 304.15, "etcFee": 0, "totalCost": 702.21, "profitLoss": -92.21, "maintainerName": "谭晓峰" },
|
||||
{ "id": "s3", "month": "2026-01", "dispatchDate": "2026-01-01", "businessName": "嘉兴益顺冷链物流有限公司", "driver": "曹文想", "phone": "18321541863", "vehicleUndertaker": "我司", "plateNo": "粤AGP9835", "brand": "现代", "model": "帕力安牌4.5吨冷链车", "unitPrice": 553.94, "quantity": 1, "amount": 553.94, "hydrogenFee": 215.6, "etcFee": 37.04, "totalCost": 710.36, "profitLoss": -156.42, "maintainerName": "谭晓峰" },
|
||||
{ "id": "s4", "month": "2026-02", "dispatchDate": "2026-02-03", "businessName": "杭州绿道城配科技有限公司", "driver": "李强", "phone": "13800138001", "vehicleUndertaker": "我司", "plateNo": "浙F02608F", "brand": "现代", "model": "帕力安牌18吨双飞翼货车", "unitPrice": 680, "quantity": 2, "amount": 1360, "hydrogenFee": 420, "etcFee": 56, "totalCost": 923, "profitLoss": 437, "maintainerName": "刘洋" },
|
||||
{ "id": "s5", "month": "2026-02", "dispatchDate": "2026-02-05", "businessName": "上海诚新贸易有限公司", "driver": "周杰", "phone": "13900139002", "vehicleUndertaker": "第三方车队", "plateNo": "浙F00688F", "brand": "飞驰", "model": "49吨牵引车头", "unitPrice": 1200, "quantity": 1, "amount": 1200, "hydrogenFee": 380, "etcFee": 120, "totalCost": 890, "profitLoss": 310, "maintainerName": "刘洋" },
|
||||
{ "id": "s6", "month": "2026-02", "dispatchDate": "2026-02-08", "businessName": "深圳智联信息技术有限公司", "driver": "赵敏", "phone": "13600136003", "vehicleUndertaker": "我司", "plateNo": "沪A66921F", "brand": "苏龙", "model": "海格牌KLQ5180XYKFCEV", "unitPrice": 850, "quantity": 1, "amount": 850, "hydrogenFee": 290, "etcFee": 45, "totalCost": 620, "profitLoss": 230, "maintainerName": "谭晓峰" }
|
||||
]
|
||||
153
src/prototypes/self-operated-business-ledger/index.tsx
Normal file
153
src/prototypes/self-operated-business-ledger/index.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @name 自营业务台账
|
||||
*/
|
||||
import '../vehicle-management/style.css';
|
||||
import '../lease-business-ledger/styles/index.css';
|
||||
import './styles/index.css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Download, FileUp, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import seedRows from './data/rows.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { LedgerKpiRow } from './components/LedgerKpiRow';
|
||||
import { SelfLedgerTable } from './components/SelfLedgerTable';
|
||||
import { TablePagination } from '../vehicle-management/components/TablePagination';
|
||||
import {
|
||||
EMPTY_SELF_FILTERS,
|
||||
type SelfOperatedFilters,
|
||||
type SelfOperatedLedgerRow,
|
||||
} from './types';
|
||||
import { applySelfFilters, sumSelfKpi } from './utils/ledger';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function SelfOperatedBusinessLedgerApp() {
|
||||
const [records] = useState<SelfOperatedLedgerRow[]>(seedRows as SelfOperatedLedgerRow[]);
|
||||
const [pendingFilters, setPendingFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 3000);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => applySelfFilters(records, appliedFilters), [records, appliedFilters]);
|
||||
const kpi = useMemo(() => sumSelfKpi(filtered), [filtered]);
|
||||
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 handleSearch = () => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_SELF_FILTERS);
|
||||
setAppliedFilters(EMPTY_SELF_FILTERS);
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'list',
|
||||
onDirectoryRoute: (_node: AnnotationDirectoryRouteNode) => {},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vm-page ldb-page">
|
||||
<FilterPanel
|
||||
records={records}
|
||||
filters={pendingFilters}
|
||||
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
|
||||
onReset={handleReset}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
|
||||
<LedgerKpiRow summary={kpi} />
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar" data-annotation-id="sob-toolbar">
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => showToast('打开批量导入弹窗(原型演示)')}>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => showToast(`导出 ${filtered.length} 条(原型演示)`)}>
|
||||
<Download size={16} aria-hidden />
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost"
|
||||
onClick={() => showToast(`批量删除 ${selectedIds.size} 条(原型演示)`)}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden />
|
||||
批量删除
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={() => showToast('表格底部新增行(原型演示)')}>
|
||||
<Plus size={16} aria-hidden />
|
||||
添加行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<SelfLedgerTable
|
||||
records={paged}
|
||||
selectedIds={selectedIds}
|
||||
onToggleRow={(id, checked) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onToggleAll={(checked) => {
|
||||
setSelectedIds(checked ? new Set(paged.map((r) => r.id)) : new Set());
|
||||
}}
|
||||
onEdit={(row) => showToast(`编辑:${row.plateNo} · ${row.dispatchDate}(原型演示)`)}
|
||||
onDelete={(row) => showToast(`删除:${row.plateNo}(原型演示)`)}
|
||||
/>
|
||||
<div className="vm-table-footer ldb-table-footer">
|
||||
<span className="ldb-table-total">共 {filtered.length} 条</span>
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
||||
</div>
|
||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const protoRoot = path.resolve(__dirname, '..');
|
||||
const prd = fs.readFileSync(path.join(protoRoot, '.spec/requirements-prd.md'), 'utf8');
|
||||
|
||||
const NODE_DEFS = [
|
||||
{ id: 'sob-filter', title: '筛选条件', color: '#2563eb', aiPrompt: '月份、出车日期、业务、车牌等筛选。', annotationText: '默认 4 项,可展开;主管可见维护人。' },
|
||||
{ id: 'sob-kpi', title: 'KPI 统计', color: '#64748b', aiPrompt: '六张统计卡片口径。', annotationText: '金额、成本、氢费、ETC、盈亏汇总。' },
|
||||
{ id: 'sob-toolbar', title: '列表工具栏', color: '#0f766e', aiPrompt: '导入、导出、批量删除、添加行。', annotationText: '' },
|
||||
{ id: 'sob-table', title: '自营台账宽表', color: '#2563eb', aiPrompt: '出车记录与费用明细。', annotationText: '盈亏按金额减总成本展示。' },
|
||||
];
|
||||
|
||||
function extractSection(md, heading) {
|
||||
const lines = md.split('\n');
|
||||
const start = lines.findIndex((line) => line.startsWith(heading));
|
||||
if (start < 0) return null;
|
||||
const level = heading.match(/^#+/)?.[0].length ?? 2;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^#{1,6}\s/.test(lines[i])) {
|
||||
const l = lines[i].match(/^#+/)?.[0].length ?? 2;
|
||||
if (l <= level) { end = i; break; }
|
||||
}
|
||||
}
|
||||
return lines.slice(start, end).join('\n').trim();
|
||||
}
|
||||
|
||||
const map = {
|
||||
'sob-filter': '## 2. 筛选区',
|
||||
'sob-kpi': '## 3. KPI 统计卡片(6 张)',
|
||||
'sob-toolbar': '## 4. 列表工具栏',
|
||||
'sob-table': '## 5. 列表字段(核心列)',
|
||||
};
|
||||
|
||||
const nodes = NODE_DEFS.map((def, index) => ({
|
||||
id: def.id,
|
||||
index: index + 1,
|
||||
title: def.title,
|
||||
pageId: 'list',
|
||||
locator: { selectors: [`[data-annotation-id="${def.id}"]`], fingerprint: def.id, path: [] },
|
||||
aiPrompt: def.aiPrompt,
|
||||
annotationText: def.annotationText,
|
||||
hasMarkdown: true,
|
||||
color: def.color,
|
||||
images: [],
|
||||
createdAt: 1740787200000,
|
||||
updatedAt: 1740787200000,
|
||||
}));
|
||||
|
||||
const markdownMap = Object.fromEntries(
|
||||
nodes.map((node) => [node.id, extractSection(prd, map[node.id]) ?? `# ${node.title}\n\n${node.aiPrompt}`]),
|
||||
);
|
||||
|
||||
const source = {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: {
|
||||
version: 2,
|
||||
prototypeName: 'self-operated-business-ledger',
|
||||
pageId: 'list',
|
||||
updatedAt: Date.now(),
|
||||
nodes,
|
||||
},
|
||||
markdownMap,
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [{
|
||||
type: 'folder',
|
||||
id: 'sob-doc-root',
|
||||
title: '自营业务台账说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{ type: 'markdown', id: 'sob-doc-overview', title: '模块总览', markdown: extractSection(prd, '## 1. 模块定位') ?? prd },
|
||||
{ type: 'link', id: 'sob-link-self', title: '自营业务台账原型', href: '/prototypes/self-operated-business-ledger', target: 'self' },
|
||||
{ type: 'markdown', id: 'sob-doc-prd', title: 'PRD 全文', markdown: prd },
|
||||
...nodes.map((node) => ({
|
||||
type: 'markdown',
|
||||
id: `sob-doc-${node.id}`,
|
||||
title: node.title,
|
||||
markdown: markdownMap[node.id],
|
||||
})),
|
||||
],
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(protoRoot, 'annotation-source.json'), `${JSON.stringify(source, null, 2)}\n`);
|
||||
console.log(`Built annotation-source.json with ${nodes.length} nodes.`);
|
||||
@@ -0,0 +1,8 @@
|
||||
.sob-table {
|
||||
min-width: 1960px;
|
||||
}
|
||||
|
||||
/* 工具栏与上方 KPI、下方表格卡片间距一致(各 16px) */
|
||||
.vm-table-toolbar[data-annotation-id='sob-toolbar'] {
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
53
src/prototypes/self-operated-business-ledger/types.ts
Normal file
53
src/prototypes/self-operated-business-ledger/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface SelfOperatedLedgerRow {
|
||||
id: string;
|
||||
month: string;
|
||||
dispatchDate: string;
|
||||
businessName: string;
|
||||
driver: string;
|
||||
phone: string;
|
||||
vehicleUndertaker: string;
|
||||
plateNo: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
hydrogenFee: number;
|
||||
etcFee: number;
|
||||
totalCost: number;
|
||||
profitLoss: number;
|
||||
maintainerName: string;
|
||||
}
|
||||
|
||||
export interface SelfOperatedFilters {
|
||||
month: string;
|
||||
dispatchFrom: string;
|
||||
dispatchTo: string;
|
||||
businessName: string;
|
||||
plateNos: string[];
|
||||
vehicleUndertaker: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
maintainerName: string;
|
||||
}
|
||||
|
||||
export const EMPTY_SELF_FILTERS: SelfOperatedFilters = {
|
||||
month: '',
|
||||
dispatchFrom: '',
|
||||
dispatchTo: '',
|
||||
businessName: '',
|
||||
plateNos: [],
|
||||
vehicleUndertaker: '',
|
||||
brand: '',
|
||||
model: '',
|
||||
maintainerName: '',
|
||||
};
|
||||
|
||||
export interface SelfOperatedKpiSummary {
|
||||
count: number;
|
||||
amount: number;
|
||||
totalCost: number;
|
||||
hydrogenFee: number;
|
||||
etcFee: number;
|
||||
profitLoss: number;
|
||||
}
|
||||
51
src/prototypes/self-operated-business-ledger/utils/ledger.ts
Normal file
51
src/prototypes/self-operated-business-ledger/utils/ledger.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { SelfOperatedFilters, SelfOperatedKpiSummary, SelfOperatedLedgerRow } from '../types';
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function sumSelfKpi(rows: SelfOperatedLedgerRow[]): SelfOperatedKpiSummary {
|
||||
return rows.reduce(
|
||||
(acc, row) => ({
|
||||
count: acc.count + 1,
|
||||
amount: acc.amount + row.amount,
|
||||
totalCost: acc.totalCost + row.totalCost,
|
||||
hydrogenFee: acc.hydrogenFee + row.hydrogenFee,
|
||||
etcFee: acc.etcFee + row.etcFee,
|
||||
profitLoss: acc.profitLoss + row.profitLoss,
|
||||
}),
|
||||
{ count: 0, amount: 0, totalCost: 0, hydrogenFee: 0, etcFee: 0, profitLoss: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export function applySelfFilters(rows: SelfOperatedLedgerRow[], filters: SelfOperatedFilters): SelfOperatedLedgerRow[] {
|
||||
return rows.filter((row) => {
|
||||
if (filters.month && row.month !== filters.month) return false;
|
||||
if (filters.businessName && row.businessName !== filters.businessName) return false;
|
||||
if (filters.vehicleUndertaker && row.vehicleUndertaker !== filters.vehicleUndertaker) return false;
|
||||
if (filters.brand && row.brand !== filters.brand) return false;
|
||||
if (filters.model && row.model !== filters.model) return false;
|
||||
if (filters.maintainerName && row.maintainerName !== filters.maintainerName) return false;
|
||||
if (filters.plateNos.length > 0 && !filters.plateNos.includes(row.plateNo)) return false;
|
||||
if (filters.dispatchFrom && row.dispatchDate < filters.dispatchFrom) return false;
|
||||
if (filters.dispatchTo && row.dispatchDate > filters.dispatchTo) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSelfOptions(rows: SelfOperatedLedgerRow[], key: keyof SelfOperatedLedgerRow): string[] {
|
||||
const set = new Set<string>();
|
||||
rows.forEach((row) => {
|
||||
const value = row[key];
|
||||
if (typeof value === 'string' && value.trim()) set.add(value);
|
||||
});
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
|
||||
export const UNDERTAKER_OPTIONS = ['我司', '第三方车队'];
|
||||
|
||||
export const BRAND_MODEL_MAP: Record<string, string[]> = {
|
||||
现代: ['帕力安牌4.5吨冷链车', '帕力安牌18吨双飞翼货车'],
|
||||
飞驰: ['49吨牵引车头'],
|
||||
苏龙: ['海格牌KLQ5180XYKFCEV'],
|
||||
};
|
||||
5223
src/prototypes/vehicle-h2-fee-ledger/H2LedgerPage.jsx
Normal file
5223
src/prototypes/vehicle-h2-fee-ledger/H2LedgerPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
317
src/prototypes/vehicle-h2-fee-ledger/annotation-source.json
Normal file
317
src/prototypes/vehicle-h2-fee-ledger/annotation-source.json
Normal file
File diff suppressed because one or more lines are too long
15
src/prototypes/vehicle-h2-fee-ledger/globals.ts
Normal file
15
src/prototypes/vehicle-h2-fee-ledger/globals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import * as antd from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
React: typeof React;
|
||||
antd: typeof antd;
|
||||
dayjs: typeof dayjs;
|
||||
}
|
||||
}
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
window.dayjs = dayjs;
|
||||
38
src/prototypes/vehicle-h2-fee-ledger/index.tsx
Normal file
38
src/prototypes/vehicle-h2-fee-ledger/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @name 车辆氢费明细
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import './globals';
|
||||
import 'antd/dist/reset.css';
|
||||
import H2LedgerPage from './H2LedgerPage.jsx';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
export default function App() {
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'list',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<H2LedgerPage />
|
||||
<AnnotationViewer
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
src/prototypes/vehicle-management/.spec/prototype-review.md
Normal file
90
src/prototypes/vehicle-management/.spec/prototype-review.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Prototype Review
|
||||
|
||||
- 审查目标:`src/prototypes/vehicle-management`(入口:`index.tsx`,含列表页与详情页)
|
||||
- 用户资料/参考资料:
|
||||
- `.spec/requirements-prd-list.md`、`.spec/requirements-prd-detail.md`
|
||||
- `annotation-source.json` 及 `scripts/build-annotation-source.mjs` 中的原型实现补充
|
||||
- 源码:`index.tsx`、`pages/DetailPage.tsx`、`components/*`、`utils/*`、`data/*.json`
|
||||
- 关联只读参考:`src/prototypes/lease-contract-management` 中对「已备车」车辆的消费口径
|
||||
- 本轮用户未提供额外 PRD/会议纪要;`src/resources/` 下无车辆管理专属资源文档
|
||||
- 生成时间:2026-06-25
|
||||
|
||||
## 总体点评
|
||||
|
||||
车辆管理原型在业务主链路上完成度较高:**筛选(含 KPI 叠加)→ 宽表浏览 → 详情下钻 → 行内/弹窗维护 → Toast 演示跨模块跳转**,与两份 `.spec` PRD 描述的核心任务基本一致。列表页对证照/保险异常、交还车可点、运营城市来源区分(车机/GPS/人工)、所属项目按运营状态显隐等关键业务规则,在 `VehicleTable.tsx` 与 `utils/vehicle.ts` 中均有对应实现;详情页 11 个 Tab 与多类业务记录(事故、故障、违章、异动、调拨、年审)均已接通种子数据与筛选/分页模式。
|
||||
|
||||
当前主要风险不在「能不能演示」,而在 **角色权限不可验证**、**文档口径分叉** 与 **原型边界未对用户显式交代** 三类问题:权限常量写死为管理员/运维主管,导致 PRD 中的条件操作无法做负向验收;详情页 PRD 正文仍写弹窗改运营城市/运维负责人,而 annotation 补充与代码已改为内联面板;批量导入、外链跳转、内存台账等「演示态」能力需要在评审与交付时与真实系统能力明确区分,避免被误读为已闭环业务。
|
||||
|
||||
作为 ONE-OS 多原型中的车辆主数据模块,与租赁合同管理在「已备车车牌/品牌型号」上的引用关系清晰,但跨模块仍停留在 Toast,尚不能验证端到端数据一致性。
|
||||
|
||||
## P0-P3 优先级问题
|
||||
|
||||
### P1 - 权限与角色矩阵无法做负向验收
|
||||
|
||||
- 证据:`index.tsx` 中 `CAN_EDIT_OPS_MANAGER = true`、`IS_ADMIN = true` 写死;`VehicleTable` / `DetailPage` 仅据此控制「更多」菜单、运维负责人、编辑入口。PRD 列表 §2 明确 ADMIN / 运维主管不同取值下的可见性与可操作性。
|
||||
- 影响:无法验证非管理员是否隐藏「编辑」、非运维主管是否禁用负责人维护等业务前置条件;后续研发若直接复用该常量,会掩盖权限漏洞。
|
||||
- 修复方向:在原型中增加可切换的演示角色(或 dev 开关),至少覆盖「仅查看」「运维主管」「管理员」三态,并在 annotation/PRD 中标注验收用例。
|
||||
|
||||
### P1 - 详情统计条交互:PRD 正文与最新实现补充不一致(待确认)
|
||||
|
||||
- 证据:`requirements-prd-detail.md` §2.3 写运营城市/运维负责人为「弹窗」;`annotation-source.json` 的 `vm-detail-stats` / `vm-ops-modal` 补充写「详情页改用内联编辑面板」;`DetailPage.tsx` 实际使用 `DetailOperateCityInlineEdit`、`DetailOpsManagerInlineEdit`,列表页仍用 `OperateCityEditModal` / `OpsManagerModal`。
|
||||
- 影响:产品、测试若仍按 PRD 正文验收,会判为缺陷;业务培训材料也会出现「详情弹窗 vs 内联」双口径。
|
||||
- 修复方向:以 annotation 补充为准时,回写 `requirements-prd-detail.md` §2.3,并说明列表/详情双端交互差异的设计理由;若应统一为弹窗,则改回详情实现。
|
||||
|
||||
### P2 - 批量导入承诺与真实能力不匹配
|
||||
|
||||
- 证据:`ImportModal.tsx` 文案为「点击或拖拽文件到此处上传」,但上传区仅为 `button` + 隐藏 `input`,无 `onDrop`;`onImport` 仅 Toast「导入校验为原型演示」,不做字段校验、错误行反馈或台账合并。PRD 列表 §6.1 描述「下载模板 → 上传 → 校验」两步流程。
|
||||
- 影响:业务方可能认为导入链路已可评审字段规则与失败恢复;实际只能验证入口与模板下载,无法讨论必填项、重复车牌、格式错误等边界。
|
||||
- 修复方向:二选一并在 PRD 标注——(a)补齐拖拽与最小校验反馈(成功/失败条数示意);(b)收敛文案为「仅演示点击上传」,并在 annotation 标明非目标。
|
||||
|
||||
### P2 - 详情多 Tab 内筛选/分页状态不保留
|
||||
|
||||
- 证据:`DetailPage.tsx` 使用单一 `tab` state,各 `Detail*RecordsTab` 在切换 Tab 时重新挂载;`requirements-prd-detail.md` §16 已写明「切换 Tab 不保留筛选/分页」。
|
||||
- 影响:核对同一车辆「租赁记录 + 事故 + 保险」时需重复筛选,增加业务演示与 UAT 摩擦;不影响单 Tab 内逻辑正确性。
|
||||
- 修复方向:若需支撑联合核查场景,为各 Tab 提升独立 filter/page 状态到 `DetailPage` 或 URL 查询参数;否则在详情 PRD 验收项中明确「不保留」为已知限制。
|
||||
|
||||
### P3 - 跨模块与异常态仅停留在 Toast/空态
|
||||
|
||||
- 证据:`index.tsx` 中合同、交还车、证照、保险、事故等跳转均为 `showToast`;无加载中、接口失败、权限不足、会话过期等状态;数据为 `vehicles.json` 内存态,刷新即丢失编辑(PRD 列表 §12、详情 §16 已说明)。
|
||||
- 影响:适合流程走查与字段对照,不足以验证跨系统编号、状态回写、失败重试;对生产排期需单独标注为非本期。
|
||||
- 修复方向:维持原型边界时,在 PRD「原型局限」增加「跨模块一致性未验证」清单;若进入联调阶段,至少选 1 条主链路(如租赁合同详情)做真实路由或 mock 接口。
|
||||
|
||||
## 完整性与项目对齐
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 核心用户 | 运营、业务、运维、管理员 — 与 PRD 一致 |
|
||||
| 主流程 | 列表筛选/KPI → 表格 → 详情 Tab → 维护字段/负责人/城市 — 已覆盖 |
|
||||
| 入口/出口 | 车牌/查看进详情,返回列表保留 `pendingFilters`/`appliedFilters`/`kpiTab`/`page` 内存态 — 符合 PRD §9 |
|
||||
| 范围边界 | 导入/导出/外链为演示;地图依赖外网 — PRD 已声明 |
|
||||
| 项目对齐 | 模块位于 `src/prototypes/vehicle-management`,与 `lease-contract-management` 通过车辆台账、`READY_VEHICLE_STATUS` 等口径衔接;`src/resources/` 无独立车辆 PRD 副本,以 `.spec` 两份 Markdown 为准 |
|
||||
| 资料冲突/待确认 | ① 详情统计条「弹窗 vs 内联」见 P1;② 列表 PRD 未写「区域」筛选项,但 `FilterPanel` 与 annotation `vm-filter` 补充已实现华东/华南等映射 — 建议回写列表 PRD §4.1 |
|
||||
|
||||
**已对齐且表达较好的能力(节选)**:KPI 与筛选叠加(`applyFilters`);车牌多行精确匹配;运营城市省-市多选(`OperateCityField`);证照/保险异常与「禁止交车」;人工运营城市可改、车机/GPS 不可改;详情 11 Tab 与年审记录字段;`PageDocToolbar` + `@axhub/annotation` 需求说明与测试用例导出。
|
||||
|
||||
## 业务逻辑连贯性
|
||||
|
||||
- **流程顺序**:筛选需点「查询」才生效、重置清空待应用与已应用条件,与 PRD §4.2 一致;KPI 切换重置页码,逻辑自洽。
|
||||
- **状态迁移**:运营城市保存后 `operateCitySource` 置「人工」;运维负责人、车辆编辑写回 `records` 并 Toast 反馈,列表与详情通过 `activeDetail` 查找最新记录保持同步。
|
||||
- **角色权限**:正向路径完整,负向路径缺失(见 P1)。
|
||||
- **数据生命周期**:保险到期展示取自 `insurance-purchases.json` 最新采购(`buildInsuranceExpireMap`),与 PRD §3 一致;台账编辑不落盘,符合原型声明。
|
||||
- **跨页面一致性**:列表弹窗改城市 vs 详情内联改城市,业务结果一致但交互路径不同,需在文档中统一叙述以免误解为 bug。
|
||||
|
||||
## 状态、异常、边界与恢复
|
||||
|
||||
| 类别 | 检查结论 |
|
||||
|---|---|
|
||||
| 输入校验 | 车牌归一化、多车牌解析、运维候选人按区域过滤已实现;车辆编辑弹窗对运营状态/车辆状态有空值禁用保存 |
|
||||
| 边界条件 | 筛选无结果时 `VehicleTable` 展示 `vm-empty`;分页在 `total=0` 时仍保底 1 页;各详情 Tab 表内「暂无数据」单元格齐全 |
|
||||
| 空/加载/成功 | 无网络加载态(本地 JSON);成功以 Toast 为主,3 秒消失 |
|
||||
| 失败/权限 | 无 API 失败、权限不足、会话过期演示 |
|
||||
| 并发/重复 | 单用户内存编辑,无并发冲突处理 |
|
||||
| 集成失败 | 地图、OpenStreetMap 外网不可用无降级说明(PRD §12 仅提依赖外网) |
|
||||
| 恢复路径 | 筛选可重置;弹窗可取消(列表运维/城市);详情内联编辑失焦自动提交(无显式取消,误操作风险需在 UI Review 单独处理) |
|
||||
|
||||
## 证据与评估说明
|
||||
|
||||
- **用户资料优先级**:本轮无用户附加文档;以 `.spec` 两份 PRD + annotation 补充 + 源码为基线。
|
||||
- **读取范围**:`requirements-prd-list.md`、`requirements-prd-detail.md`、`ui-review.md`(仅作交叉参考,非本次业务结论来源)、`index.tsx` 及主要 components/utils、`annotation-source.json`、部分 `data/*.json`。
|
||||
- **独立评估**:`degraded` — 未拆分「业务完整性子代理」与「原型证据子代理」并行互盲评估;业务基线笔记与源码走查在同一轮完成。
|
||||
- **Impeccable critique 方法运用**:采用其「先证据后结论、优先级 ruthlessly、 persona/边界追问」思路,但输出结构、优先级定义与「不评视觉」边界以 `rules/prototype-review-guide.md` 为准;未生成 `.impeccable/critique` 产物。
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
### 4.1 字段清单
|
||||
|
||||
默认展示前 4 项,点击「展开更多筛选项」显示全部。
|
||||
默认展示前 4 项,点击「更多筛选」显示全部。
|
||||
|
||||
| 字段 | 控件 | 选项来源 | 匹配规则 |
|
||||
|---|---|---|---|
|
||||
|
||||
940
src/prototypes/vehicle-management/annotation-source.json
Normal file
940
src/prototypes/vehicle-management/annotation-source.json
Normal file
File diff suppressed because one or more lines are too long
@@ -11,16 +11,26 @@ interface DetailBasicInfoTabProps {
|
||||
record: VehicleRecord;
|
||||
onOpenLicenseEdit?: (record: VehicleRecord) => void;
|
||||
onSaveField?: (recordId: string, patch: Partial<VehicleRecord>) => void;
|
||||
onOpenLeaseContract?: (record: VehicleRecord, contractNo: string) => void;
|
||||
}
|
||||
|
||||
function isClickableProjectField(value: string): boolean {
|
||||
return Boolean(value && value !== '无');
|
||||
}
|
||||
|
||||
export function DetailBasicInfoTab({
|
||||
record,
|
||||
onOpenLicenseEdit,
|
||||
onSaveField,
|
||||
onOpenLeaseContract,
|
||||
}: DetailBasicInfoTabProps) {
|
||||
const project = resolveProjectDelivery(record, '无');
|
||||
const shanghaiPlate = isShanghaiPlate(record.plateNo);
|
||||
const handleLicenseEdit = () => onOpenLicenseEdit?.(record);
|
||||
const openContract = () => {
|
||||
if (!isClickableProjectField(project.contractNo)) return;
|
||||
onOpenLeaseContract?.(record, project.contractNo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-model-param-tab vm-model-param-tab--readonly">
|
||||
@@ -67,8 +77,34 @@ export function DetailBasicInfoTab({
|
||||
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="项目名称"
|
||||
empty={project.projectName === '无'}
|
||||
value={isClickableProjectField(project.projectName) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link"
|
||||
onClick={openContract}
|
||||
title="查看合同详情"
|
||||
>
|
||||
{project.projectName}
|
||||
</button>
|
||||
) : project.projectName}
|
||||
/>
|
||||
<DetailParamField
|
||||
label="合同编码"
|
||||
empty={project.contractNo === '无'}
|
||||
value={isClickableProjectField(project.contractNo) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-link code"
|
||||
onClick={openContract}
|
||||
title="查看合同详情"
|
||||
>
|
||||
{project.contractNo}
|
||||
</button>
|
||||
) : project.contractNo}
|
||||
/>
|
||||
<DetailParamField label="企业名称" value={project.companyName} empty={project.companyName === '无'} />
|
||||
</div>
|
||||
</DetailParamSection>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useId, useRef, useState } from 'react';
|
||||
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { SingleDateCalendarPanel } from './SingleDateCalendarPanel';
|
||||
import { SingleYearPickerPanel } from './SingleYearPickerPanel';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
import { buildYearOptions, formatDateYmd, isEmpty } from '../utils/vehicle';
|
||||
|
||||
interface DetailInlineEditFieldProps {
|
||||
@@ -11,15 +14,22 @@ interface DetailInlineEditFieldProps {
|
||||
/** 详情内联编辑:空值显示「未上传」,编辑图标由字段卡片右侧展示 */
|
||||
export function DetailInlineEditField({ value, editType, onSave }: DetailInlineEditFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | HTMLSelectElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const labelId = useId();
|
||||
const yearOptions = useMemo(() => buildYearOptions(), []);
|
||||
|
||||
const display = editType === 'date' ? formatDateYmd(value) || value : value;
|
||||
const empty = isEmpty(value);
|
||||
const editLabel = editType === 'year' ? '选择出厂年份' : '选择采购入库日期';
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
useDismissOnFocusOutside(editing, wrapRef, dismiss);
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const commit = (next: string) => {
|
||||
@@ -27,53 +37,63 @@ export function DetailInlineEditField({ value, editType, onSave }: DetailInlineE
|
||||
if (!isEmpty(next) && next !== value) onSave(next);
|
||||
};
|
||||
|
||||
const editButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={`vm-detail-edit-btn vm-detail-field-action${editing ? ' is-active' : ''}`}
|
||||
onClick={startEdit}
|
||||
aria-label={editLabel}
|
||||
title={editLabel}
|
||||
aria-expanded={editing ? true : undefined}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const draft = editType === 'date'
|
||||
? (isEmpty(value) ? '' : formatDateYmd(value))
|
||||
: (isEmpty(value) ? '' : value);
|
||||
|
||||
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)}
|
||||
/>
|
||||
<div className="vm-detail-inline-edit" ref={wrapRef}>
|
||||
<span id={labelId} className={empty ? 'vm-detail-inline-empty' : undefined}>
|
||||
{empty ? '未上传' : display}
|
||||
</span>
|
||||
{editButton}
|
||||
<div
|
||||
className="vm-detail-inline-popover"
|
||||
role="dialog"
|
||||
aria-label={editLabel}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
>
|
||||
{editType === 'year' ? (
|
||||
<SingleYearPickerPanel
|
||||
value={draft}
|
||||
years={yearOptions}
|
||||
onChange={commit}
|
||||
onComplete={dismiss}
|
||||
/>
|
||||
) : (
|
||||
<SingleDateCalendarPanel
|
||||
value={draft}
|
||||
onChange={commit}
|
||||
onComplete={dismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
return (
|
||||
<div className="vm-detail-inline-edit">
|
||||
<span id={labelId} className={empty ? 'vm-detail-inline-empty' : undefined}>
|
||||
{empty ? '未上传' : display}
|
||||
</span>
|
||||
{editButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { OperateCityPickerPanel } from './OperateCityPickerPanel';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
import { operateCitySourceTagClass } from '../utils/vehicle';
|
||||
|
||||
interface DetailOperateCityInlineEditProps {
|
||||
cityText: string;
|
||||
citySource: string | null;
|
||||
location: string;
|
||||
onSave: (location: string) => void;
|
||||
}
|
||||
|
||||
export function DetailOperateCityInlineEdit({
|
||||
cityText,
|
||||
citySource,
|
||||
location,
|
||||
onSave,
|
||||
}: DetailOperateCityInlineEditProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
useDismissOnFocusOutside(editing, wrapRef, dismiss);
|
||||
|
||||
const handleSelect = (nextLocation: string) => {
|
||||
setEditing(false);
|
||||
if (nextLocation && nextLocation !== location) {
|
||||
onSave(nextLocation);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-detail-inline-edit vm-detail-stat-inline-edit" ref={wrapRef}>
|
||||
<div className="vm-detail-stat-value vm-detail-stat-value--rich">
|
||||
<div className="vm-cell-tags">
|
||||
<span>{cityText}</span>
|
||||
{citySource ? (
|
||||
<span className={operateCitySourceTagClass(citySource)}>{citySource}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`vm-detail-edit-btn vm-detail-stat-action${editing ? ' is-active' : ''}`}
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label="修改运营城市"
|
||||
title="修改运营城市"
|
||||
aria-expanded={editing}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
</button>
|
||||
{editing ? (
|
||||
<div
|
||||
className="vm-detail-inline-popover vm-detail-inline-popover--operate-city"
|
||||
role="dialog"
|
||||
aria-label="修改运营城市"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
>
|
||||
<OperateCityPickerPanel
|
||||
location={location}
|
||||
onSelect={handleSelect}
|
||||
autoFocusSearch
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { OpsManagerPickerPanel } from './OpsManagerPickerPanel';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
import { resolveOpsStaffCandidates } from '../utils/vehicle';
|
||||
|
||||
interface DetailOpsManagerInlineEditProps {
|
||||
record: VehicleRecord;
|
||||
managers: string[];
|
||||
onSave: (managers: string[]) => void;
|
||||
}
|
||||
|
||||
function sameManagers(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const left = [...a].sort();
|
||||
const right = [...b].sort();
|
||||
return left.every((name, index) => name === right[index]);
|
||||
}
|
||||
|
||||
export function DetailOpsManagerInlineEdit({
|
||||
record,
|
||||
managers,
|
||||
onSave,
|
||||
}: DetailOpsManagerInlineEditProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(managers);
|
||||
const draftRef = useRef(managers);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const candidates = useMemo(() => resolveOpsStaffCandidates(record), [record]);
|
||||
|
||||
draftRef.current = draft;
|
||||
|
||||
const commitIfChanged = useCallback(() => {
|
||||
const next = draftRef.current;
|
||||
if (!sameManagers(next, managers)) {
|
||||
onSave(next);
|
||||
}
|
||||
}, [managers, onSave]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
commitIfChanged();
|
||||
setEditing(false);
|
||||
}, [commitIfChanged]);
|
||||
|
||||
useDismissOnFocusOutside(editing, wrapRef, dismiss);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(managers);
|
||||
}
|
||||
}, [editing, managers]);
|
||||
|
||||
const display = managers.length ? managers.join(',') : '无';
|
||||
|
||||
return (
|
||||
<div className="vm-detail-inline-edit vm-detail-stat-inline-edit" ref={wrapRef}>
|
||||
<div className="vm-detail-stat-value">
|
||||
<span>{display}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`vm-detail-edit-btn vm-detail-stat-action${editing ? ' is-active' : ''}`}
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label="修改运维负责人"
|
||||
title="修改运维负责人"
|
||||
aria-expanded={editing}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
</button>
|
||||
{editing ? (
|
||||
<div
|
||||
className="vm-detail-inline-popover vm-detail-inline-popover--ops"
|
||||
role="dialog"
|
||||
aria-label="修改运维负责人"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
>
|
||||
<OpsManagerPickerPanel
|
||||
value={draft}
|
||||
options={candidates}
|
||||
onChange={setDraft}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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';
|
||||
import { OPERATE_STATUS_OPTIONS, PROJECT_TYPE_OPTIONS, VEHICLE_SOURCE_OPTIONS, AREA_REGION_OPTIONS } from '../utils/vehicle';
|
||||
|
||||
interface FilterPanelProps {
|
||||
records: VehicleRecord[];
|
||||
@@ -116,6 +116,10 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '区域',
|
||||
control: renderPicker('areaRegion', '区域', [...AREA_REGION_OPTIONS], '请选择区域'),
|
||||
},
|
||||
{
|
||||
label: '品牌',
|
||||
control: renderPicker('brand', '品牌', brands, '请选择品牌'),
|
||||
@@ -173,7 +177,7 @@ export function FilterPanel({
|
||||
const visible = expanded ? fields : fields.slice(0, 4);
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card">
|
||||
<section className="vm-filter-card" data-annotation-id="vm-filter">
|
||||
<header className="vm-filter-header">
|
||||
<h2 className="vm-filter-title">筛选条件</h2>
|
||||
</header>
|
||||
@@ -188,7 +192,7 @@ export function FilterPanel({
|
||||
<div className="vm-filter-actions">
|
||||
{fields.length > 4 && (
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? '收起' : '展开更多筛选项'}
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ImportModal({ open, onClose, onImport }: ImportModalProps) {
|
||||
|
||||
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()}>
|
||||
<div className="vm-modal vm-modal-sm" data-annotation-id="vm-import-modal" 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="关闭">
|
||||
|
||||
@@ -38,6 +38,7 @@ export function KpiCards({ counts, active, onChange }: KpiCardsProps) {
|
||||
key={card.key}
|
||||
type="button"
|
||||
className={`vm-kpi-card ${active === card.key ? 'active' : ''}`}
|
||||
data-annotation-id={`vm-kpi-${card.key}`}
|
||||
onClick={() => onChange(card.key)}
|
||||
aria-pressed={active === card.key}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function LocationMapModal({ open, record, onClose }: LocationMapModalProp
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-map"
|
||||
data-annotation-id="vm-location-map-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="vm-location-map-title"
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React 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';
|
||||
import { OperateCityPickerPanel } from './OperateCityPickerPanel';
|
||||
import { formatOperateCityChipLabel } from '../utils/filters';
|
||||
import { formatOperateCity } from '../utils/vehicle';
|
||||
|
||||
interface OperateCityEditModalProps {
|
||||
open: boolean;
|
||||
@@ -19,92 +16,45 @@ interface OperateCityEditModalProps {
|
||||
export function OperateCityEditModal({
|
||||
open,
|
||||
record,
|
||||
records,
|
||||
records: _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));
|
||||
const handleSelect = (location: string) => {
|
||||
onSave(location);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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()}>
|
||||
<div
|
||||
className="vm-modal vm-modal-operate-city"
|
||||
data-annotation-id="vm-operate-city-modal"
|
||||
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>
|
||||
<p>{record.plateNo} · 当前 {formatOperateCityChipLabel(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>
|
||||
|
||||
<OperateCityPickerPanel
|
||||
location={record.location}
|
||||
onSelect={handleSelect}
|
||||
autoFocusSearch
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, X } from 'lucide-react';
|
||||
import type { VehicleRecord } from '../types';
|
||||
import { buildOperateCityTree, toOperateCityValue } from '../utils/filters';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
import { OperateCityProvincePicker } from './OperateCityProvincePicker';
|
||||
|
||||
interface OperateCityFieldProps {
|
||||
records: VehicleRecord[];
|
||||
@@ -115,18 +116,14 @@ export function OperateCityField({ records, value, onChange }: OperateCityFieldP
|
||||
{!query.trim() && (
|
||||
<label className="vm-operate-city-step vm-operate-city-step-single">
|
||||
<span>省份</span>
|
||||
<select
|
||||
className="vm-input"
|
||||
<OperateCityProvincePicker
|
||||
provinces={tree.map((node) => node.province)}
|
||||
value={activeProvince}
|
||||
onChange={(e) => {
|
||||
setProvince(e.target.value);
|
||||
onChange={(nextProvince) => {
|
||||
setProvince(nextProvince);
|
||||
}}
|
||||
aria-label="选择省份"
|
||||
>
|
||||
{tree.map((node) => (
|
||||
<option key={node.province} value={node.province}>{node.province}</option>
|
||||
))}
|
||||
</select>
|
||||
ariaLabel="选择省份"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import {
|
||||
buildChinaOperateCityTree,
|
||||
flattenOperateCityOptions,
|
||||
formatOperateCityChipLabel,
|
||||
matchOperateCityOption,
|
||||
operateCityValueToLocation,
|
||||
toOperateCityValue,
|
||||
type OperateCityOption,
|
||||
} from '../utils/filters';
|
||||
import { formatOperateCity, parseRegionParts } from '../utils/vehicle';
|
||||
|
||||
interface OperateCityPickerPanelProps {
|
||||
location: string;
|
||||
onSelect: (location: string) => void;
|
||||
autoFocusSearch?: boolean;
|
||||
}
|
||||
|
||||
export function OperateCityPickerPanel({
|
||||
location,
|
||||
onSelect,
|
||||
autoFocusSearch = false,
|
||||
}: OperateCityPickerPanelProps) {
|
||||
const tree = useMemo(() => buildChinaOperateCityTree(), []);
|
||||
const allOptions = useMemo(() => flattenOperateCityOptions(tree), [tree]);
|
||||
const [province, setProvince] = useState('');
|
||||
const [selected, setSelected] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const formatted = formatOperateCity(location);
|
||||
const { province: locProvince } = parseRegionParts(formatted);
|
||||
const fallback = tree.find((node) => node.province === locProvince)?.province
|
||||
|| tree[0]?.province
|
||||
|| '';
|
||||
setProvince(locProvince || fallback);
|
||||
setSelected(formatted);
|
||||
setQuery('');
|
||||
if (autoFocusSearch) {
|
||||
window.requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}
|
||||
}, [location, tree, autoFocusSearch]);
|
||||
|
||||
const activeProvince = province || tree[0]?.province || '';
|
||||
const searchText = query.trim();
|
||||
const isSearchMode = searchText.length > 0;
|
||||
|
||||
const displayOptions: OperateCityOption[] = useMemo(() => {
|
||||
if (isSearchMode) {
|
||||
return allOptions.filter((option) => matchOperateCityOption(option, searchText));
|
||||
}
|
||||
const node = tree.find((item) => item.province === activeProvince);
|
||||
if (!node) return [];
|
||||
return node.cities.map((city) => {
|
||||
const value = toOperateCityValue(activeProvince, city);
|
||||
return {
|
||||
value,
|
||||
label: formatOperateCityChipLabel(value),
|
||||
province: activeProvince,
|
||||
};
|
||||
});
|
||||
}, [isSearchMode, searchText, allOptions, tree, activeProvince]);
|
||||
|
||||
const pickOption = (option: OperateCityOption) => {
|
||||
setSelected(option.value);
|
||||
setProvince(option.province);
|
||||
onSelect(operateCityValueToLocation(option.value));
|
||||
};
|
||||
|
||||
const handleProvinceChange = (nextProvince: string) => {
|
||||
setProvince(nextProvince);
|
||||
setQuery('');
|
||||
const { province: selectedProvince } = parseRegionParts(selected);
|
||||
if (selectedProvince && selectedProvince !== nextProvince) {
|
||||
setSelected('');
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery('');
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
|
||||
const selectedLabel = selected ? formatOperateCityChipLabel(selected) : '';
|
||||
|
||||
return (
|
||||
<div className="vm-operate-city-edit-body vm-operate-city-picker-panel">
|
||||
<label className="vm-operate-city-edit-search">
|
||||
<Search size={16} aria-hidden className="vm-operate-city-edit-search-icon" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
className="vm-operate-city-edit-search-input"
|
||||
value={query}
|
||||
placeholder="输入省份或城市名称快速筛选"
|
||||
aria-label="搜索省份或城市"
|
||||
autoComplete="off"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-operate-city-edit-search-clear"
|
||||
onClick={clearSearch}
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
{selected ? (
|
||||
<div className="vm-operate-city-edit-selected" aria-live="polite">
|
||||
<span className="vm-operate-city-edit-selected-label">已选</span>
|
||||
<span className="vm-operate-city-edit-selected-value">{selectedLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`vm-operate-city-edit-main${isSearchMode ? ' is-search' : ''}`}>
|
||||
{!isSearchMode && (
|
||||
<div className="vm-operate-city-edit-step vm-operate-city-edit-step--province">
|
||||
<span className="vm-operate-city-step-label">省份</span>
|
||||
<div className="vm-operate-city-edit-chips" role="tablist" aria-label="省份快捷选择">
|
||||
{tree.map((node) => {
|
||||
const active = activeProvince === node.province;
|
||||
return (
|
||||
<button
|
||||
key={node.province}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`vm-operate-city-chip${active ? ' active' : ''}`}
|
||||
onClick={() => handleProvinceChange(node.province)}
|
||||
>
|
||||
{node.province}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vm-operate-city-edit-step vm-operate-city-edit-step--city">
|
||||
<span className="vm-operate-city-step-label">
|
||||
{isSearchMode ? `搜索结果(${displayOptions.length})` : '城市'}
|
||||
</span>
|
||||
<div className="vm-operate-city-edit-chips" role="listbox" aria-label="城市列表">
|
||||
{displayOptions.length === 0 ? (
|
||||
<p className="vm-operate-city-empty">
|
||||
{isSearchMode ? '未找到匹配的城市,请换个关键词' : '暂无可选城市'}
|
||||
</p>
|
||||
) : (
|
||||
displayOptions.map((option) => {
|
||||
const checked = selected === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
className={`vm-operate-city-chip${checked ? ' active' : ''}`}
|
||||
onClick={() => pickOption(option)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
|
||||
|
||||
interface OperateCityProvincePickerProps {
|
||||
provinces: string[];
|
||||
value: string;
|
||||
onChange: (province: string) => void;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function OperateCityProvincePicker({
|
||||
provinces,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel = '选择省份',
|
||||
placeholder = '请选择省份',
|
||||
}: OperateCityProvincePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismiss = useCallback(() => setOpen(false), []);
|
||||
|
||||
useDismissOnFocusOutside(open, fieldRef, dismiss);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return [...provinces].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}, [provinces]);
|
||||
|
||||
const pick = (next: string) => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-filter-picker-field" ref={fieldRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`vm-filter-picker-control vm-region-province-trigger ${open ? 'open' : ''}`}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<span className={`vm-region-province-value${value ? '' : ' is-placeholder'}`}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<ChevronDown size={14} className="vm-filter-picker-chevron" aria-hidden />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="vm-filter-picker-popover vm-region-province-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>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function OpsManagerModal({ open, record, onClose, onSave }: OpsManagerMod
|
||||
|
||||
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()}>
|
||||
<div className="vm-modal vm-modal-sm" data-annotation-id="vm-ops-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="vm-modal-header">
|
||||
<div>
|
||||
<h2>设置运维负责人</h2>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import type { OpsStaffOption } from './OpsManagerMultiSelect';
|
||||
|
||||
interface OpsManagerPickerPanelProps {
|
||||
value: string[];
|
||||
options: OpsStaffOption[];
|
||||
onChange: (names: string[]) => void;
|
||||
}
|
||||
|
||||
export function OpsManagerPickerPanel({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: OpsManagerPickerPanelProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim();
|
||||
if (!q) return options;
|
||||
return options.filter((item) => item.name.includes(q));
|
||||
}, [options, query]);
|
||||
|
||||
const toggle = (name: string) => {
|
||||
if (value.includes(name)) {
|
||||
onChange(value.filter((item) => item !== name));
|
||||
return;
|
||||
}
|
||||
onChange([...value, name]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-ops-picker-panel">
|
||||
<label className="vm-operate-city-edit-search vm-ops-picker-panel-search">
|
||||
<Search size={16} aria-hidden className="vm-operate-city-edit-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="vm-operate-city-edit-search-input"
|
||||
value={query}
|
||||
placeholder="输入姓名搜索"
|
||||
aria-label="搜索运维负责人"
|
||||
autoComplete="off"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-operate-city-edit-search-clear"
|
||||
onClick={() => setQuery('')}
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
{value.length > 0 ? (
|
||||
<div className="vm-ops-picker-tags vm-ops-picker-panel-tags">
|
||||
{value.map((name) => (
|
||||
<span key={name} className="vm-ops-picker-tag">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除${name}`}
|
||||
onClick={() => onChange(value.filter((item) => item !== name))}
|
||||
>
|
||||
<X size={12} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="vm-filter-picker-options vm-ops-picker-options vm-ops-picker-panel-options" role="listbox" aria-multiselectable="true" aria-label="运维负责人列表">
|
||||
{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' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(staff.name)}
|
||||
/>
|
||||
<span className="vm-ops-picker-name">{staff.name}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user