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:
王冕
2026-06-30 15:27:23 +08:00
parent 00ca1846af
commit af29b26fe8
309 changed files with 73875 additions and 3838 deletions

View File

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

View File

@@ -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 one or more lines are too long

View File

@@ -0,0 +1,11 @@
{
"type": "excalidraw",
"version": 2,
"source": "axhub-make",
"elements": [],
"appState": {
"gridSize": 20,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,60 @@
# 客户管理 · 新增页产品需求说明PRD
> 从列表页「新增客户」进入。保存为原型 Toast不对接真实接口。
---
## 1. 页面框架
| 项 | 说明 |
|---|---|
| 面包屑 | 业务管理 / 客户管理 / 新增 |
| 布局 | 三组卡片表单 + 底部 sticky 操作栏 |
| 操作 | 重置(清空表单)、提交(校验后 Toast 成功并返回列表) |
---
## 2. 客户信息
| 字段 | 必填 | 控件 | 说明 |
|---|---|---|---|
| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |
| 客户分级 | 否 | 下拉 | A / B / C |
| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |
| 客户简称 | 否 | 文本 | 选填 |
| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |
| 通讯地址 | 否 | 文本 | |
| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |
| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |
| 备注 | 否 | 多行文本 | |
---
## 3. 联系人信息
| 字段 | 必填 | 控件 |
|---|---|---|
| 姓名 | 否 | 文本 |
| 手机号 | 否 | 文本 |
| 职位 | 否 | 文本 |
---
## 4. 付款及开票信息
| 字段 | 必填 | 控件 | 说明 |
|---|---|---|---|
| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |
| 纳税人识别号 | 是 | 文本 | |
| 注册地址 | 否 | 文本 | |
| 注册电话 | 否 | 文本 | |
| 开户银行 | 是 | 文本 | |
| 账号 | 否 | 文本 | |
---
## 5. 校验与提交
- 提交前校验所有必填项
- 通过后 Toast「客户已保存原型演示」并返回列表
- 重置清空全部字段,客户类型默认「企业」

View File

@@ -0,0 +1,96 @@
# 客户管理 · 列表页产品需求说明PRD
> 本页为客户主数据台账列表。标注「原型演示」的跳转、导入、导出仅 Toast 提示。
---
## 1. 模块定位
| 项 | 说明 |
|---|---|
| 模块名称 | 客户管理 — 列表页 |
| 目标用户 | 销售、业务负责人、运营、风控 |
| 核心任务 | 按多维度筛选客户台账,查看标签与状态,新增客户或进入详情/编辑 |
| 页面结构 | 面包屑 + 筛选区 + 工具栏 + 表格 + 分页 |
---
## 2. 客户编号规则(集团多子公司)
- 总长度 7仅含大写字母 AZ 与数字 09
- 结构:第 1 位 = 签约/归属子公司代码;第 27 位 = 6 位数字流水号000001999999
- 流水号在「同一子公司代码」下递增
- 校验正则:`/^[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`)。

View 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仅含大写字母 AZ 与数字 09\n- 结构:第 1 位 = 签约/归属子公司代码;第 27 位 = 6 位数字流水号000001999999\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- 重置清空全部字段,客户类型默认「企业」"
}
]
}
]
}
]
}
}

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

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

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

View File

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

View File

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

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

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

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

View File

@@ -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.`);

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

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

View 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[]> = {
: ['张伟', '王芳', '周杰', '陈静', '许晴'],
: ['刘洋', '孙丽', '曹阳'],
: ['吴磊', '赵敏', '韩雪'],
: ['李强', '郑华', '冯涛'],
};

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

View File

@@ -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'
? '逾期险种筛选(交强险、商业险)'

View 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- 右侧标注工具栏可切换主题、按颜色筛选标注"
}
]
}
]
}
}

View File

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

View 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 里程取账单时间段的数据,自动展示。
### 其他减免金额明细内容
手动输入,备注明细内容。
### 资产归属
显示车辆实际归属公司名称,按车辆档案自动获取。
### 签约公司
租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。
### 备注
手动输入。

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "单方事故" }
]

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

View File

@@ -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": "已对账" }
]

View File

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

View 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": "全额到账" }
]

View 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": "客户承担"
}
]

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

View File

@@ -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.`);

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

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

View 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] ?? '';
}

View File

@@ -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 拼装 HTMLA4 分页展示。
- 支持缩放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` 的已发布版本)。
- 模板内风控红线条款、条件条款、锁定区标记在预览中保留。
- 业务侧仅编辑表单字段与允许编辑的预览正文;锁定区不可改(正式环境由编辑器约束)。

View File

@@ -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 位小数)、生效日期
- **上传盖章合同**:拖拽多文件;确认后标记已上传

View File

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

View File

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

View File

@@ -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' },

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
{
"type": "excalidraw",
"version": 2,
"source": "@axhub/make",
"elements": [],
"appState": {
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

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

View File

@@ -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);
}
/** 判断新增租赁合同左侧表单必填项是否均已填写 */

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

View File

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

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

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

View File

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

View File

@@ -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.`);

View File

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

View File

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

View File

@@ -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 },
],
},
{

View File

@@ -0,0 +1,50 @@
# 自营业务台账 · 产品需求说明PRD
> 参照《2026年业务二部运营台账总表》物流业务台账子表按自营业务场景重新表达行内编辑与导入为原型 Toast 演示。
---
## 1. 模块定位
| 项 | 说明 |
|---|---|
| 模块名称 | 数据分析 — 自营业务台账 |
| 目标用户 | 业务服务组操作人、主管 |
| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |
---
## 2. 筛选区
默认展示 4 项,点击「更多筛选」展开。
| 筛选项 | 说明 |
|---|---|
| 月份 | YYYY-MM |
| 出车日期 | 日期区间 |
| 业务名称 | 精确匹配 |
| 车牌号码 | 多选 |
| 车辆承接方 | 我司 / 第三方车队 |
| 品牌 / 型号 | 级联 |
| 维护人 | 仅主管可见 |
---
## 3. KPI 统计卡片6 张)
台账条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。
---
## 4. 列表工具栏
批量导入、导出、批量删除、添加行。
---
## 5. 列表字段(核心列)
月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。
盈亏 = 金额 总成本(样例数据已预计算)。

View File

@@ -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盈亏 = 金额 总成本(样例数据已预计算)。"
}
]
}
]
}
}

View File

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

View File

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

View File

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

View File

@@ -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": "谭晓峰" }
]

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

View File

@@ -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.`);

View File

@@ -0,0 +1,8 @@
.sob-table {
min-width: 1960px;
}
/* 工具栏与上方 KPI、下方表格卡片间距一致各 16px */
.vm-table-toolbar[data-annotation-id='sob-toolbar'] {
padding: 0 0 16px;
}

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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

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

View 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` 产物。

View File

@@ -49,7 +49,7 @@
### 4.1 字段清单
默认展示前 4 项,点击「展开更多筛选」显示全部。
默认展示前 4 项,点击「更多筛选」显示全部。
| 字段 | 控件 | 选项来源 | 匹配规则 |
|---|---|---|---|

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="关闭">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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