迭代 ONE-OS 多原型:租赁台账增强、合同模板 Word 导入,统一全局分页与筛选规范。

- 租赁业务台账:运维费汇总、明细钻取弹层、其他收入/减免批量导入
- 合同模板:Word 导入与 docx 预览,模板编辑增强
- 全局规范:TablePagination 统一为「共 x 条 → 页码 → 每页 N 条」,vm-checkbox 与筛选面板
- 新增车辆维修明细、还车应结款原型;车辆管理、收款记录、工作台、加氢站等同步对齐
- 更新侧栏、Legacy Shell、标注源与 to-prd 技能

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-08 11:44:53 +08:00
parent d0d7458989
commit 6d14c58744
118 changed files with 12360 additions and 1884 deletions

View File

@@ -7,12 +7,17 @@ export const DEFAULT_PAGE_SIZE = 20;
/** 全局可选每页条数 */
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const;
/** 加氢站等场景的紧凑每页条数选项 */
export const COMPACT_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const;
export interface TablePaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
/** 每页条数下拉选项,默认 PAGE_SIZE_OPTIONS */
pageSizeOptions?: readonly number[];
}
function buildPageItems(current: number, totalPages: number): Array<number | 'ellipsis'> {
@@ -40,66 +45,74 @@ export function TablePagination({
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = PAGE_SIZE_OPTIONS,
}: TablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]);
if (total === 0) return null;
const sizeOptions = pageSizeOptions.length > 0 ? pageSizeOptions : PAGE_SIZE_OPTIONS;
return (
<div className="vm-pagination" role="navigation" aria-label="表格分页">
<label className="vm-pagination-size">
<span className="vm-pagination-size-label"></span>
<select
className="vm-pagination-select"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
aria-label="每页显示条数"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="vm-pagination-size-label"></span>
</label>
<span className="vm-pagination-total">
<span className="vm-pagination-total-num tabular-nums">{total}</span>
</span>
<div className="vm-pagination-pages">
<button
type="button"
className="vm-pagination-nav"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeft size={16} aria-hidden />
</button>
{pageItems.map((item, index) => (
item === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden></span>
) : (
{total > 0 && (
<>
<div className="vm-pagination-pages">
<button
key={item}
type="button"
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
onClick={() => onPageChange(item)}
aria-current={page === item ? 'page' : undefined}
className="vm-pagination-nav"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
{item}
<ChevronLeft size={16} aria-hidden />
</button>
)
))}
<button
type="button"
className="vm-pagination-nav"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRight size={16} aria-hidden />
</button>
</div>
{pageItems.map((item, index) => (
item === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden></span>
) : (
<button
key={item}
type="button"
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
onClick={() => onPageChange(item)}
aria-current={page === item ? 'page' : undefined}
>
{item}
</button>
)
))}
<button
type="button"
className="vm-pagination-nav"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRight size={16} aria-hidden />
</button>
</div>
<label className="vm-pagination-size">
<span className="vm-pagination-size-label"></span>
<select
className="vm-pagination-select"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
aria-label="每页显示条数"
>
{sizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="vm-pagination-size-label"></span>
</label>
</>
)}
</div>
);
}

View File

@@ -76,9 +76,16 @@
.ln-modal-detail-table .ant-table-thead > tr > th.ln-modal-detail-table__money,
.ln-modal-detail-table .ant-table-tbody > tr > td.ln-modal-detail-table__money {
text-align: left;
font-variant-numeric: tabular-nums;
}
.ln-modal-detail-table .ant-table-thead > tr > th.ant-table-cell-align-right,
.ln-modal-detail-table .ant-table-tbody > tr > td.ant-table-cell-align-right,
.ln-modal-detail-table .ant-table-summary .ant-table-cell-align-right {
text-align: left !important;
}
/* 左侧冻结列(收款时间等) */
.ln-modal-detail-table .ant-table-cell-fix-left,
.ln-modal-detail-table .ant-table-cell-fix-start {

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import { ConfigProvider, Layout, Menu, Typography } from 'antd';
import { defineHashPageRoute, useHashPage } from '../useHashPage';
import { clearHostPrototypeRouteInfo, defineHashPageRoute, useHashPage } from '../useHashPage';
import { ensureOneosWebLegacyGlobals } from './legacyGlobals';
import '../../resources/oneos-web-legacy/styles/oneos-app.css';
@@ -28,13 +28,79 @@ const oneosTheme = {
},
};
export function OneosWebLegacyShell({
function OneosWebLegacyPageLayout({
children,
moduleTitle,
pages,
activePageId,
onSelectPage,
}: {
children: React.ReactNode;
moduleTitle: string;
pages: OneosWebLegacyPage[];
activePageId?: string;
onSelectPage?: (pageId: string) => void;
}) {
const showMenu = pages.length > 1 && activePageId && onSelectPage;
return (
<ConfigProvider theme={oneosTheme}>
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
{showMenu ? (
<Layout.Sider
width={220}
theme="light"
style={{ borderRight: '1px solid #e5e7eb' }}
>
<div style={{ padding: '16px 16px 8px', fontWeight: 600 }}>{moduleTitle}</div>
<Menu
mode="inline"
selectedKeys={[activePageId]}
items={pages.map((item) => ({
key: item.id,
label: item.title,
onClick: () => onSelectPage(item.id),
}))}
/>
</Layout.Sider>
) : null}
<Layout.Content style={{ minHeight: '100vh', overflow: 'auto' }}>
{children}
</Layout.Content>
</Layout>
</ConfigProvider>
);
}
function OneosWebLegacySinglePageShell({ pages }: OneosWebLegacyShellProps) {
const active = pages[0];
useEffect(() => {
clearHostPrototypeRouteInfo();
}, []);
if (!active) {
return (
<Typography.Text type="secondary">
</Typography.Text>
);
}
const ActivePage = active.component;
return (
<OneosWebLegacyPageLayout moduleTitle="" pages={pages}>
<ActivePage />
</OneosWebLegacyPageLayout>
);
}
function OneosWebLegacyMultiPageShell({
moduleTitle,
pages,
defaultPageId,
}: OneosWebLegacyShellProps) {
ensureOneosWebLegacyGlobals();
const route = useMemo(
() => defineHashPageRoute(
pages.map((page) => ({ id: page.id, title: page.title })),
@@ -54,33 +120,25 @@ export function OneosWebLegacyShell({
}
const ActivePage = active.component;
const showMenu = pages.length > 1;
return (
<ConfigProvider theme={oneosTheme}>
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
{showMenu ? (
<Layout.Sider
width={220}
theme="light"
style={{ borderRight: '1px solid #e5e7eb' }}
>
<div style={{ padding: '16px 16px 8px', fontWeight: 600 }}>{moduleTitle}</div>
<Menu
mode="inline"
selectedKeys={[active.id]}
items={pages.map((item) => ({
key: item.id,
label: item.title,
onClick: () => setPage(item.id),
}))}
/>
</Layout.Sider>
) : null}
<Layout.Content style={{ minHeight: '100vh', overflow: 'auto' }}>
<ActivePage />
</Layout.Content>
</Layout>
</ConfigProvider>
<OneosWebLegacyPageLayout
moduleTitle={moduleTitle}
pages={pages}
activePageId={active.id}
onSelectPage={setPage}
>
<ActivePage />
</OneosWebLegacyPageLayout>
);
}
export function OneosWebLegacyShell(props: OneosWebLegacyShellProps) {
ensureOneosWebLegacyGlobals();
if (props.pages.length <= 1) {
return <OneosWebLegacySinglePageShell {...props} />;
}
return <OneosWebLegacyMultiPageShell {...props} />;
}

View File

@@ -0,0 +1,67 @@
/**
* 品牌·型号主数据:优先来自车辆管理种子数据,并与租赁合同演示枚举合并。
*/
import vehicles from '../prototypes/vehicle-management/data/vehicles.json';
/** 租赁合同/条件条款演示用补充车型(车辆台账中可能尚未收录) */
var LEASE_DEMO_EXTRA_CATALOG = [
{ brand: '现代', models: ['18吨氢燃料电池车', '9.6米厢式货车', '帕力安牌4.5吨冷链车'] },
{ brand: '苏龙', models: ['9.6米氢燃料电池车', '海格牌18吨双飞翼货车'] },
{ brand: '飞驰', models: ['集卡头', '半挂车'] },
{ brand: '跃进', models: ['4.2米冷链车', '4.5吨冷链车'] },
{ brand: '宇通', models: ['氢燃料电池客车'] },
];
function mergeCatalogs() {
var map = new Map();
var sources = arguments;
for (var s = 0; s < sources.length; s++) {
var list = sources[s] || [];
list.forEach(function (item) {
if (!item || !item.brand) return;
if (!map.has(item.brand)) map.set(item.brand, new Set());
var models = item.models || [];
models.forEach(function (model) {
if (model) map.get(item.brand).add(model);
});
});
}
return Array.from(map.entries())
.map(function (entry) {
return {
brand: entry[0],
models: Array.from(entry[1]).sort(function (a, b) {
return String(a).localeCompare(String(b), 'zh-CN');
}),
};
})
.sort(function (a, b) {
return a.brand.localeCompare(b.brand, 'zh-CN');
});
}
function buildFromVehicleManagement() {
var map = new Map();
(vehicles || []).forEach(function (row) {
if (!row || !row.brand || !row.model) return;
if (!map.has(row.brand)) map.set(row.brand, new Set());
map.get(row.brand).add(row.model);
});
return Array.from(map.entries()).map(function (entry) {
return {
brand: entry[0],
models: Array.from(entry[1]).sort(function (a, b) {
return String(a).localeCompare(String(b), 'zh-CN');
}),
};
});
}
var cachedCatalog = null;
export function getVehicleBrandModelCatalog() {
if (!cachedCatalog) {
cachedCatalog = mergeCatalogs(buildFromVehicleManagement(), LEASE_DEMO_EXTRA_CATALOG);
}
return cachedCatalog;
}

118
src/common/vm-checkbox.css Normal file
View File

@@ -0,0 +1,118 @@
/**
* ONE-OS vm-page 多选框 — 全局样式
* 规范src/prototypes/vm-shared/DESIGN.md
*/
.vm-checkbox,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
display: inline-grid;
place-content: center;
width: 18px;
height: 18px;
margin: 0;
flex-shrink: 0;
border: 1.5px solid var(--ln-hairline-strong);
border-radius: 6px;
background-color: var(--ln-surface-card);
background-repeat: no-repeat;
background-position: center;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.05);
cursor: pointer;
vertical-align: middle;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.14s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.vm-checkbox:hover:not(:disabled),
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--ln-primary) 55%, var(--ln-hairline-strong));
box-shadow:
inset 0 1px 2px rgba(15, 23, 42, 0.04),
0 0 0 3px color-mix(in srgb, var(--ln-primary) 14%, transparent);
}
.vm-checkbox:active:not(:disabled),
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:active:not(:disabled) {
transform: scale(0.92);
}
.vm-checkbox:checked,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:checked {
border-color: var(--ln-primary-focus);
background-color: var(--ln-primary);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.75 8.25 6.5 11 12.25 5'/%3E%3C/svg%3E");
background-size: 13px 13px;
box-shadow:
0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.vm-checkbox:indeterminate,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:indeterminate {
border-color: var(--ln-primary-focus);
background-color: var(--ln-primary);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round'%3E%3Cpath d='M4.25 8h7.5'/%3E%3C/svg%3E");
background-size: 13px 13px;
box-shadow:
0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.vm-checkbox:focus-visible,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:focus-visible {
outline: none;
box-shadow:
inset 0 1px 2px rgba(15, 23, 42, 0.04),
0 0 0 3px color-mix(in srgb, var(--ln-primary) 28%, transparent);
}
.vm-checkbox:checked:focus-visible,
.vm-checkbox:indeterminate:focus-visible,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:checked:focus-visible,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:indeterminate:focus-visible {
box-shadow:
0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent),
0 0 0 3px color-mix(in srgb, var(--ln-primary) 28%, transparent);
}
.vm-checkbox:disabled,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 表格勾选列 */
.vm-col-check,
.ldb-col-check,
.cm-col-check {
width: 48px;
text-align: center;
vertical-align: middle;
}
.vm-col-check .vm-checkbox,
.ldb-col-check .vm-checkbox,
.cm-col-check .vm-checkbox,
.vm-col-check > input[type="checkbox"],
.ldb-col-check > input[type="checkbox"],
.cm-col-check > input[type="checkbox"] {
display: inline-grid;
}
@media (prefers-reduced-motion: reduce) {
.vm-checkbox,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"] {
transition: none;
}
.vm-checkbox:active:not(:disabled),
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:active:not(:disabled) {
transform: none;
}
}

View File

@@ -0,0 +1,29 @@
/**
* 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致)
*
* 规则:筛选项超过 1 行(默认 4 项)时,其余收入折叠区,通过「更多筛选」展开。
*/
export const VM_FILTER_COLUMNS_PER_ROW = 4;
/** 默认首行展示的筛选项数量1 行) */
export const VM_FILTER_PRIMARY_VISIBLE_COUNT = VM_FILTER_COLUMNS_PER_ROW;
export function splitFilterFields<T>(
fields: T[],
primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT,
): { primary: T[]; extra: T[] } {
if (fields.length <= primaryCount) {
return { primary: fields, extra: [] };
}
return {
primary: fields.slice(0, primaryCount),
extra: fields.slice(primaryCount),
};
}
export function shouldShowFilterExpand(
fieldCount: number,
primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT,
): boolean {
return fieldCount > primaryCount;
}

View File

@@ -2,6 +2,7 @@
* ONE-OS vm-page 列表分页 — 全局样式
* 规范src/prototypes/vm-shared/DESIGN.md
* 组件src/common/TablePagination.tsx
* 元素顺序:共 x 条 → 页码 → 每页 N 条
*/
.vm-pagination {
@@ -10,7 +11,9 @@
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
width: 100%;
width: auto;
flex: 0 1 auto;
margin-left: auto;
padding: 0;
}
@@ -23,6 +26,18 @@
flex-shrink: 0;
}
.vm-pagination-total {
font-size: 0.875rem;
color: var(--ln-muted);
white-space: nowrap;
flex-shrink: 0;
}
.vm-pagination-total-num {
color: var(--ln-ink);
font-weight: 600;
}
.vm-pagination-size-label {
white-space: nowrap;
}
@@ -65,31 +80,41 @@
color: var(--ln-ink);
font-size: 0.875rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease;
}
.vm-pagination-nav:disabled {
opacity: 0.45;
opacity: 0.4;
cursor: not-allowed;
background: var(--ln-canvas-soft);
}
.vm-pagination-nav:hover:not(:disabled),
.vm-pagination-page:hover:not(.active) {
background: var(--ln-canvas-soft);
border-color: var(--ln-hairline-strong);
border-color: var(--ln-primary);
color: var(--ln-primary-focus);
}
.vm-pagination-nav:active:not(:disabled),
.vm-pagination-page:active:not(.active) {
transform: scale(0.96);
}
.vm-pagination-page.active {
background: var(--ln-primary);
border-color: var(--ln-primary);
color: var(--ln-on-primary);
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.28);
}
.vm-pagination-nav:focus-visible,
.vm-pagination-page:focus-visible {
outline: none;
border-color: var(--vm-focus-border-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
}
.vm-pagination-ellipsis {
@@ -100,6 +125,20 @@
height: var(--vm-control-height);
color: var(--ln-muted);
font-size: 0.875rem;
letter-spacing: 0.08em;
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.vm-pagination-nav,
.vm-pagination-page {
transition: none;
}
.vm-pagination-nav:active:not(:disabled),
.vm-pagination-page:active:not(.active) {
transform: none;
}
}
/* 详情 Tab 等紧凑场景vm-table-footer--split vm-table-footer--compact */
@@ -136,3 +175,7 @@
height: 28px;
font-size: 0.8125rem;
}
.vm-table-footer--compact .vm-pagination-total {
font-size: 0.8125rem;
}

View File

@@ -2,53 +2,107 @@
- 审查目标:`src/prototypes/contract-template-management`
- 用户资料/参考资料:
- 本轮用户任务:处理评审结论中 P1P3 共 5 条问题
- 本轮用户任务:对「合同模板管理」原型执行 Prototype Review / 需求评审
- `src/resources/contract-template-management/PRD.md`v1.2
- `src/prototypes/contract-template-management/annotation-source.json`
- `contract-template-seed.js``contract-template-section-html.js``contract-template-catalog.js``contract-template-store.js`
- 关联下游:`lease-contract-preview-build.js``LeaseContractCreate.jsx`
- 生成时间2026-06-29复审
- `src/prototypes/contract-template-management/index.tsx``ContractTemplate.jsx`
- `contract-template-store.js``contract-template-catalog.js``contract-template-section-html.js``contract-template-section-filter.js``contract-template-seed.js`
- 下游消费:`lease-contract-management/lease-contract-preview-build.js``LeaseContractCreate.jsx`
- `annotation-source.json``.spec/prototype-comments.json`
- 生成时间2026-07-07
## 总体点评
上一轮评审中的 **5 条优先级问题均已落地**。合同模板管理原型现已具备:
合同模板管理原型在**主业务链路上已基本成立**:列表筛选与嵌套章节子表、草稿/发布/启用停用、版本日志与快照查看、编辑页 9 章节 Tab、锁定/风控/条件条款工具、离开未保存确认,以及经 `contract-template-store` 向租赁合同创建贯通预览,均能在同浏览器 `localStorage` 会话内端到端演示。这与 PRD v1.2 对「Word 文档级模型 + 启用态控制 + 跨模块消费」的定位一致。
- 租赁创建预览优先消费 store 中 `sectionContents`(静态 HTML 仅兜底)
- 冷启动种子:直接进入租赁创建即可选用已启用模板
- PRD / 标注与实现对齐(`contractType`、列表筛选、子表列、章节 Tab 交互)
- `localStorage` 持久化(兼写 `sessionStorage`
- 移除 `isDefault` /「标准模板」残留逻辑
当前最影响「按真实法务作业验收」的缺口集中在 **Word 导入仍为文件名驱动的 Mock**,无法证明 PRD §9 对任意 `.doc/.docx` 的拆分策略;其次是 **标注/内嵌 PRD 与 v1.2 字段口径仍有残留不一致**,以及租赁预览侧 **条件条款存在模板 HTML 与硬编码注入双轨**。权限、存储失败、正式 API 与异常分支属原型已知边界,应在研发阶段补齐,但不阻断当前演示型原型价值。
原型可作为研发与测试的**端到端演示基线**;正式环境仍需 API 持久化、权限与异常分支。
## P0-P3 优先级问题
## P0-P3 优先级问题(复审)
### P1 - Word 导入未实现真实解析与章节拆分
| 级别 | 问题 | 状态 |
|---|---|---|
| P1 | 租赁预览未消费 store 章节正文 | **已修复**`getContractTemplateBaseHtml` 优先 `mergeMonolithicHtml(doc.sectionContents)` |
| P1 | PRD / 标注与实现字段口径不一致 | **已修复** — PRD v1.2 + `build-annotation-source.mjs` 同步 |
| P1 | 冷启动租赁创建无模板 | **已修复**`catalog.js` 调用 `ensureContractTemplateStore(buildInitialWordTemplates)` |
| P2 | 仅 sessionStorage 持久化 | **已缓解** — store 读写 `localStorage` 优先 |
| P3 | `isDefault` / 标准模板残留 | **已修复** — 种子、store 默认逻辑、编辑区文案已清理 |
- 证据:`ContractTemplate.jsx``handleImportWord` 仅调用 `mockWordBlocks(file.name, categoryFilter)`,按文件名关键字返回内置 `v266LeaseBlocks` / 试用合同块或通用占位块,未读取上传文件内容,也未调用 PRD §9 所述 `sanitizeWordExportHtml` + 标记切分流程。
- 影响:法务无法在本原型中验证「上传任意 Word → 自动拆成 9 章」这一核心维护路径;验收标准 §16.2「导入 Word 后 9 章节正确拆分」仅对少数种子文件名成立,与 PRD 表述的业务成立条件存在明显差距。
- 修复方向:原型层至少接入 `.docx → HTML` 解析(或明确标注为演示 Mock 并下调 PRD 验收项);研发按 §9.2§9.4 实现服务端/前端拆分,并在导入失败时给出可恢复提示(重试、保留原稿、查看错误原因)。
### P2 - 标注与内嵌 PRD 仍使用「合同名称」,与 v1.2「合同类型」冲突
- 证据:`annotation-source.json` 列表筛选节点 `ct-list-filter``annotationText``markdownMap.ct-list-filter` 仍写「合同名称 / 选项来自模板名称」;而 `PRD.md` v1.2 §7.2、§7.4 与实现中筛选字段 `keyword`、列表列 `contractType`、编辑区「合同类型」均已统一为 **合同类型**
- 影响:评审、测试与业务方按标注操作时会对筛选对象产生误解,造成需求口径分裂;与 PRD §16.4「标注与 PRD 一致」未完全满足。
- 修复方向:运行或更新 `scripts/build-annotation-source.mjs`,将筛选说明改为「合同类型(可搜索下拉,精确匹配 `contractType`)」;同步目录内嵌 PRD 片段版本号至 v1.2。
### P2 - 租赁预览条件条款存在「模板 HTML」与硬编码注入双轨
- 证据:`lease-contract-preview-build.js` 在读取 store 合并 HTML 后仍调用 `injectPrototypeVehicleClauses`;该函数在 `lease-contract-vehicle-clause.js` 中按 `PROTOTYPE_VEHICLE_CLAUSE_DEFS` 与固定 `paragraphMarker` 包裹段落,注释写明「正式环境由模板管理产出」。模板管理侧条件条款则通过编辑区「条件」写入 `data-vehicle-clause` / `data-vehicle-bindings`
- 影响:法务在模板管理中新设或调整条件条款后,租赁预览对标准 V26.6 类合同可能仍部分依赖硬编码注入点;与 PRD「创建合同时按订单车辆过滤条件条款」的单一数据源目标不完全一致增加联调与回归风险。
- 修复方向:租赁预览改为**仅**解析模板 HTML 中的条件条款标记;硬编码注入降级为种子数据初始化脚本或 feature flag并在跨模块验收用例中覆盖「仅改模板、不改租赁代码」场景。
### P2 - 车型主数据未与车辆管理模块打通
- 证据PRD §11 要求品牌·型号与车辆管理一致;实现中 `ContractTemplate.jsx` / `VehicleBrandModelPicker.jsx` 使用 `LEASE_VEHICLE_BRAND_MODEL_CATALOG``lease-order-vars.js` 内置枚举),未读取 `vehicle-management` 主数据或接口。
- 影响:章节「适用车型」绑定与预览模拟车型的选项集可能与正式车辆台账漂移;绑定存的是字符串,正式环境若车型更名会导致历史模板命中失败。
- 修复方向:原型可保留内置枚举但需在 PRD/标注标明;研发对接车辆主数据 API并约定品牌型号唯一键与变更策略。
### P3 - 权限边界与存储异常未在原型中表达
- 证据PRD §13 建议法务可编辑/发布、客服只读等角色能力;原型无角色切换或操作拦截。`contract-template-store.js``writeToStorage``localStorage` 失败时静默忽略,无用户提示或降级策略。
- 影响:无法演示「启用中不可编辑」之外的组织级权限;极端环境下用户以为已保存但实际未持久化,造成跨页面数据丢失误解。
- 修复方向:原型可增加「法务 / 客服只读」演示开关;存储失败时 `message.error` 并阻止离开编辑页;正式环境对接权限服务与后端持久化。
## 完整性与项目对齐
| 维度 | 评估 |
| 维度 | 结论 |
|---|---|
| 跨模块正文 | 模板管理编辑 ↔ 租赁创建预览:**已贯通**(同会话 / localStorage 内) |
| 冷启动 | 无需先打开模板管理:**已支持** |
| 资料一致性 | PRD、标注、列表筛选/列、子表、§8.3 Tab**已对齐** |
| 正式环境差距 | API 持久化、角色权限、Word 解析失败等异常分支仍待研发 |
| 核心用户 | 法务为主、客服/风控间接用户 — 与 PRD §2 一致 |
| 主流程 | 列表 → 新增/编辑 → 保存/发布 → 启用/停用 → 租赁创建选用 — **可演示** |
| 入口/出口 | `index.tsx` + `ContractTemplate.jsx` 列表/编辑双视图;`navigationRef` 与标注目录可切换 — 对齐 |
| 范围边界 | 仅 `lease` 分类业务闭环;`purchase`/`service` 代码预留未暴露 — 符合非目标 |
| 跨模块 | `contract-template-catalog.js` 冷启动种子、`getContractTemplateBaseHtml` 优先 store — 与 PRD §12、§16.3 对齐 |
| 资料冲突 | **待确认**`annotation-source.json` 筛选字段仍称「合同名称」,与 PRD v1.2 / 源码「合同类型」冲突(见 P2 |
| 项目 metadata | 路由 `/prototypes/contract-template-management`;与 `lease-contract-management` PRD 关联描述一致 |
## 验收建议(请用户在预览中确认)
**已覆盖且与 PRD 一致的主要能力(摘要)**:嵌套列表 9 章节子表及风控/条件条款计数;启用中锁定编辑删除、停用二次确认;发布递增版本号、已发布再发布写 `versionLogs` 且默认停用;合同主体非空与合同类型校验;未保存离开确认;版本快照只读查看;附件章节车型绑定与租赁侧 `applyAttachmentSectionVehicleFilter` 联动。
1. **租赁预览贯通**:在模板管理编辑某章节并保存 → 打开租赁创建 → 选用同一模板 → 预览正文应反映修改。
2. **冷启动**:清空站点 localStorage 后**直接**打开租赁创建 → 应能选择已启用模板(如「正式合同」「试用合同」)。
3. **列表筛选**:列表页「合同类型」筛选与主表「合同类型」列一致。
4. **持久化**:刷新浏览器后模板停用/发布状态应保留localStorage
## 业务逻辑连贯性
## 修订记录
- **状态迁移**:草稿(无版本号)→ 发布(有版本号、默认启用)→ 停用(可编辑)→ 再发布(版本递增、写快照、默认停用)— 与 PRD §6.2§6.3 一致,源码 `handleSaveTemplate` / `toggleTemplateEnabled` 可印证。
- **启用与下游**`getPublishedEnabledLeaseOptions` 过滤 `published && enabled`;停用后 `LeaseContractCreate` 订阅 store 更新选项 — 连贯。
- **版本号生成**`generateNextVersionNo``category === lease` 下全部已发布模板取最大 minor 递增 — 符合 PRD §6.1「同分类下递增」字面规则;若业务期望「每份 Word 文件独立版本序列」,需产品另行确认(当前无冲突证据,记为待确认项)。
- **条件条款命中**:模板编辑预览与租赁预览均遵循「订单车辆任意命中则展示」;模板管理预览未选模拟车型时隐藏条件段落,与 PRD §8.7.3 一致。
- **数据生命周期**`creator/updater/createTime/updateTime` 在保存时更新;删除仅草稿或已发布且停用 — 符合 §6.5。
| 日期 | 说明 |
## 状态、异常、边界与恢复
| 类别 | 审查结论 |
|---|---|
| 2026-06-29 | 复审5 条评审问题全部处理完毕 |
| 输入校验 | 保存/发布校验合同主体非空、合同类型必填;条件条款弹窗要求至少一个车型 — 已覆盖 |
| 空状态 | 列表无数据、筛选无结果、版本日志为空、编辑区无内容预览引导 — 已覆盖 |
| 操作确认 | 停用、删除、未保存离开 — 有 Modal 确认 |
| 权限失败 | 未实现角色权限 — 见 P3 |
| 导入失败 | 非真实 Word 解析,无格式错误/拆分失败分支 — 见 P1 |
| 存储失败 | `localStorage` 写入失败静默 — 见 P3 |
| 并发/重复点击 | 保存无 loading 锁,快速连点可能重复写入(原型风险低)— P3 |
| 跨页面恢复 | 刷新后 `localStorage` 恢复模板;清空存储后 `ensureContractTemplateStore` 重新种子 — 可恢复 |
| 下游一致性 | 停用模板后租赁侧选项实时收缩;预览读 store `sectionContents` — 已检查通过 |
## 证据与评估说明
- **用户资料优先级**:本轮无额外用户附件;以 PRD v1.2 为业务基线,与 `annotation-source.json` 冲突处已标「待确认」。
- **读取范围**
- 规范:`rules/prototype-review-guide.md`
- 资料:`src/resources/contract-template-management/PRD.md`
- 源码:`index.tsx``ContractTemplate.jsx`(列表/编辑/保存发布/导入/版本日志)、`contract-template-store.js``contract-template-catalog.js``contract-template-section-filter.js`
- 下游:`lease-contract-preview-build.js``lease-contract-vehicle-clause.js``LeaseContractCreate.jsx`
- 标注:`annotation-source.json`;未展开阅读 `canvas.excalidraw` 全量
- **独立评估**`degraded`(未使用子代理分拆「业务基线」与「原型证据」两轮独立评估,在同一审查中顺序完成)
---
## 修复记录2026-07-07 后续)
| 优先级 | 问题 | 状态 | 说明 |
|---|---|---|---|
| P1 | Word 导入 Mock | **已修复** | `contract-template-word-import.js` + mammoth 解析 `.docx`;失败回退演示模板并提示 |
| P2 | 标注「合同名称」口径 | **已修复** | `build-annotation-source.mjs` 同步 `annotationText` / `annotationsByElementId` / `markdownMap` |
| P2 | 租赁预览条件条款双轨 | **已修复** | `injectPrototypeVehicleClauses` 在 HTML 已有条件标记时跳过 |
| P2 | 车型主数据未打通 | **已修复(原型)** | `src/common/vehicle-brand-model-catalog.js` 读取车辆管理种子并合并演示车型 |
| P3 | 权限与存储异常 | **已修复(原型)** | `?role=cs` 只读演示;存储失败 `message.error` 且阻止离开编辑页 |

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
/**
* docx-preview 高保真 Word 渲染(预览区)
*/
import { renderAsync } from 'docx-preview';
var DEFAULT_RENDER_OPTIONS = {
className: 'ct-docx-preview',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
trimXmlDeclaration: true,
useBase64URL: true,
};
export async function renderDocxPreview(container, arrayBuffer, options) {
if (!container || !arrayBuffer) return;
container.innerHTML = '';
await renderAsync(
arrayBuffer,
container,
null,
Object.assign({}, DEFAULT_RENDER_OPTIONS, options || {})
);
}

View File

@@ -0,0 +1,173 @@
/**
* 从 Word HTML 中按「标题」段落提取章节 Tab并拆分/合并章节内容。
*/
import { DOCUMENT_SECTION_TABS } from './contract-template-section-html.js';
var DEFAULT_MAIN_TAB = { key: 'main', label: '合同主体' };
var HEADING_TAGS = { H1: true, H2: true, H3: true, H4: true, H5: true, H6: true };
export function getDefaultSectionTabs() {
return DOCUMENT_SECTION_TABS.slice();
}
export function emptySectionMapForTabs(tabs) {
var map = {};
(tabs || []).forEach(function (tab) {
map[tab.key] = '';
});
return map;
}
function normalizeHeadingText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function slugifySectionKey(label, index) {
var slug = String(label || '')
.toLowerCase()
.replace(/[^\u4e00-\u9fa5a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 24);
return slug ? 'section_' + slug + '_' + index : 'section_' + index;
}
function isSectionTitleElement(node) {
if (!node || node.nodeType !== 1) return false;
if (!node.classList || !node.classList.contains('ct-word-section-title')) return false;
if (node.closest && node.closest('table')) return false;
var text = normalizeHeadingText(node.textContent);
return text.length > 0 && text.length <= 80;
}
function isHeadingElement(node) {
if (!node || node.nodeType !== 1) return false;
if (isSectionTitleElement(node)) return true;
if (!HEADING_TAGS[node.nodeName]) return false;
if (node.closest && node.closest('table')) return false;
var text = normalizeHeadingText(node.textContent);
return text.length > 0 && text.length <= 80;
}
function collectHeadingElements(root) {
var headings = [];
if (!root) return headings;
var seen = new Set();
function push(node) {
if (!node || seen.has(node)) return;
if (!isHeadingElement(node)) return;
seen.add(node);
headings.push(node);
}
root.querySelectorAll('.ct-word-section-title,h1,h2,h3,h4,h5,h6').forEach(push);
return headings;
}
/** 从 Word HTML 提取章节 Tab无标题时返回 null调用方回退默认 Tab */
export function extractSectionTabsFromHtml(html) {
if (typeof document === 'undefined') return null;
var source = String(html || '').trim();
if (!source) return null;
var div = document.createElement('div');
div.innerHTML = source;
var headings = collectHeadingElements(div);
if (!headings.length) return null;
var tabs = [Object.assign({}, DEFAULT_MAIN_TAB)];
var usedKeys = { main: true };
headings.forEach(function (heading, index) {
var label = normalizeHeadingText(heading.textContent);
if (!label) return;
var key = slugifySectionKey(label, index + 1);
while (usedKeys[key]) {
key = key + '_' + index;
}
usedKeys[key] = true;
tabs.push({ key: key, label: label, headingText: label });
});
return tabs.length > 1 ? tabs : null;
}
function isWordBlockNode(node) {
if (!node) return false;
if (node.nodeType === 3) return !!String(node.textContent || '').trim();
return node.nodeType === 1 && node.nodeName !== 'STYLE';
}
function detectDynamicSectionStartKey(node, tabs) {
if (!node || node.nodeType !== 1 || !tabs || !tabs.length) return null;
if (!isHeadingElement(node)) return null;
var text = normalizeHeadingText(node.textContent);
for (var i = 1; i < tabs.length; i++) {
if (tabs[i].label === text || tabs[i].headingText === text) {
return tabs[i].key;
}
}
return null;
}
/** 按章节 Tab 将整份 HTML 拆为 section map */
export function splitHtmlBySectionTabs(html, tabs) {
var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs();
var source = String(html || '');
var result = emptySectionMapForTabs(sectionTabs);
if (!source.trim()) return result;
if (typeof document === 'undefined') {
return result;
}
var div = document.createElement('div');
div.innerHTML = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
var root = div.querySelector('.ct-word-doc') || div;
var children = [];
root.childNodes.forEach(function (node) {
if (isWordBlockNode(node)) children.push(node);
});
if (!children.length) {
result.main = source;
return result;
}
var holders = {};
var currentKey = 'main';
children.forEach(function (child) {
var startKey = detectDynamicSectionStartKey(child, sectionTabs);
if (startKey) currentKey = startKey;
if (!holders[currentKey]) holders[currentKey] = document.createElement('div');
holders[currentKey].appendChild(child.cloneNode(true));
});
sectionTabs.forEach(function (tab) {
result[tab.key] = holders[tab.key] ? holders[tab.key].innerHTML : '';
});
return result;
}
/** 将章节 map 合并为整份 HTML */
export function mergeHtmlBySectionTabs(sectionMap, tabs) {
var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs();
var map = sectionMap || {};
var out = String(map.main || '');
sectionTabs.slice(1).forEach(function (tab) {
var chunk = map[tab.key];
if (chunk) out += String(chunk);
});
return out;
}
export function resolveSectionTabs(record) {
if (record && Array.isArray(record.sectionTabs) && record.sectionTabs.length) {
return record.sectionTabs;
}
return getDefaultSectionTabs();
}
export function getSectionTabLabel(tabs, key) {
var list = tabs || getDefaultSectionTabs();
var tab = list.find(function (item) { return item.key === key; });
return tab ? tab.label : key;
}

View File

@@ -37,7 +37,8 @@ function getTrialAgreementSectionMap(fileName) {
function createWordDocument(params) {
return Object.assign({
category: 'lease',
contractType: '',
contractName: '',
contractType: '合同',
fileName: '未命名合同.docx',
versionName: '未命名合同',
versionNo: null,
@@ -62,7 +63,8 @@ export function buildInitialWordTemplates() {
return [
createWordDocument({
id: 'doc-1',
contractType: '现代18吨正式合同',
contractName: '现代18吨正式合同',
contractType: '合同',
fileName: '现代18吨车型-数智中心版.docx',
versionName: '现代18吨车型-数智中心版',
versionNo: 'v1.00',
@@ -99,7 +101,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-2',
contractType: '正式合同',
contractName: '正式合同',
contractType: '合同',
fileName: '2026年标准商用车租赁合同.docx',
versionName: '2026年标准商用车租赁合同',
versionNo: 'v1.01',
@@ -115,7 +118,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-3',
contractType: '正式合同',
contractName: '正式合同',
contractType: '合同',
fileName: '2026年二季度草案.docx',
versionName: '2026年二季度草案',
versionNo: null,
@@ -130,7 +134,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-4',
contractType: '试用合同',
contractName: '试用合同',
contractType: '合同',
fileName: '商用车试用协议.docx',
versionName: '商用车试用协议',
versionNo: 'v1.00',
@@ -146,7 +151,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-5',
contractType: '现代18吨试用合同',
contractName: '现代18吨试用合同',
contractType: '合同',
fileName: '商用车试用协议-现代18吨.docx',
versionName: '商用车试用协议-现代18吨',
versionNo: 'v1.00',

View File

@@ -4,7 +4,7 @@
*/
import {
getContractTypeLabel,
getContractNameLabel,
normalizeContractType,
resolveContractType,
} from './contract-template-types.js';
@@ -12,6 +12,7 @@ import {
var STORAGE_KEY = 'oneos_contract_templates_v1';
var templates = null;
var listeners = new Set();
var lastWriteSucceeded = true;
function readFromStorage() {
if (typeof localStorage !== 'undefined') {
@@ -37,22 +38,33 @@ function readFromStorage() {
}
function writeToStorage() {
if (!templates) return;
if (!templates) {
lastWriteSucceeded = true;
return true;
}
var payload = JSON.stringify(templates);
var localOk = true;
var sessionOk = true;
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, payload);
} catch (err) {
/* 原型忽略存储失败 */
localOk = false;
}
}
if (typeof sessionStorage !== 'undefined') {
try {
sessionStorage.setItem(STORAGE_KEY, payload);
} catch (err) {
/* 原型忽略存储失败 */
sessionOk = false;
}
}
lastWriteSucceeded = localOk || sessionOk;
return lastWriteSucceeded;
}
export function wasLastContractTemplateWriteOk() {
return lastWriteSucceeded;
}
function notify() {
@@ -117,7 +129,7 @@ var VEHICLE_HINT_BY_ID = {
};
function inferKind(doc) {
return resolveContractType(doc);
return resolveContractName(doc);
}
function inferVehicleHint(doc) {
@@ -126,14 +138,14 @@ function inferVehicleHint(doc) {
}
export function mapTemplateToLeaseOption(doc) {
var contractType = resolveContractType(doc);
var contractName = resolveContractName(doc);
return {
id: doc.id,
title: doc.versionName || stripExtension(doc.fileName) || doc.id,
fileName: doc.fileName || doc.versionName || '',
versionNo: doc.versionNo || '—',
contractType: contractType,
contractTypeLabel: getContractTypeLabel(contractType),
contractType: contractName,
contractTypeLabel: getContractNameLabel(contractName),
kind: inferKind(doc),
vehicleHint: inferVehicleHint(doc),
};

View File

@@ -1,23 +1,78 @@
/**
* 合同类型 — 用户自定义文本,合同模板管理与租赁合同创建共用
* 合同模板字段口径:
* - contractName合同名称用户自定义文本
* - contractType类型枚举合同 / 授权委托书 / 转三方协议 / 其他)
*/
export function normalizeContractType(value) {
export var CONTRACT_TYPE_ENUM_OPTIONS = ['合同', '授权委托书', '转三方协议', '其他'];
export var DEFAULT_DOCUMENT_CONTRACT_TYPE = '合同';
export function isDocumentContractTypeEnum(value) {
return CONTRACT_TYPE_ENUM_OPTIONS.indexOf(String(value || '').trim()) >= 0;
}
export function normalizeContractName(value) {
return String(value || '').trim();
}
export function getContractTypeLabel(value) {
var text = normalizeContractType(value);
export function normalizeDocumentContractType(value) {
var text = String(value || '').trim();
return isDocumentContractTypeEnum(text) ? text : DEFAULT_DOCUMENT_CONTRACT_TYPE;
}
export function getContractNameLabel(value) {
var text = normalizeContractName(value);
return text || '—';
}
/** 无 contractType 字段时,按文件名推断(兼容旧数据) */
export function inferContractTypeFromDoc(doc) {
if (doc && doc.contractType) return normalizeContractType(doc.contractType);
export function getDocumentContractTypeLabel(value) {
var text = normalizeDocumentContractType(value);
return text || '—';
}
/** 无 contractName 时,按文件名推断(兼容旧数据) */
export function inferContractNameFromDoc(doc) {
if (doc && doc.contractName) return normalizeContractName(doc.contractName);
var legacy = doc && doc.contractType;
if (legacy && !isDocumentContractTypeEnum(legacy)) {
return normalizeContractName(legacy);
}
var name = String((doc && (doc.fileName || doc.versionName)) || '');
return /试用/.test(name) ? '试用合同' : '正式合同';
}
export function resolveContractType(doc) {
return inferContractTypeFromDoc(doc);
export function resolveContractName(doc) {
return inferContractNameFromDoc(doc);
}
export function resolveDocumentContractType(doc) {
if (doc && doc.contractName != null && isDocumentContractTypeEnum(doc.contractType)) {
return normalizeDocumentContractType(doc.contractType);
}
if (doc && isDocumentContractTypeEnum(doc.contractType)) {
return normalizeDocumentContractType(doc.contractType);
}
return DEFAULT_DOCUMENT_CONTRACT_TYPE;
}
/** @deprecated 租赁合同等下游仍按「合同名称」消费,保留别名 */
export function normalizeContractType(value) {
return normalizeContractName(value);
}
/** @deprecated 保留别名,语义为合同名称 */
export function getContractTypeLabel(value) {
return getContractNameLabel(value);
}
/** @deprecated 保留别名,语义为合同名称 */
export function resolveContractType(doc) {
return resolveContractName(doc);
}
export function getDocumentContractTypeSelectOptions() {
return CONTRACT_TYPE_ENUM_OPTIONS.map(function (value) {
return { value: value, label: value };
});
}

View File

@@ -0,0 +1,341 @@
/**
* Word (.doc / .docx) → HTML 导入
*/
import mammoth from 'mammoth';
var MAMMOTH_STYLE_MAP = [
"p[style-name='Title'] => h1.ct-word-doc-title:fresh",
"p[style-name='Heading 1'] => p.ct-word-section-title:fresh",
"p[style-name='Heading 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='Heading 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"p[style-name='标题'] => p.ct-word-section-title:fresh",
"p[style-name='标题 1'] => p.ct-word-section-title:fresh",
"p[style-name='标题1'] => p.ct-word-section-title:fresh",
"p[style-name='标题 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='标题2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='标题 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"p[style-name='标题3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"highlight => mark.ct-word-highlight",
"r[style-name='Highlight'] => mark.ct-word-highlight",
"r[style-name='高亮强调'] => mark.ct-word-highlight",
];
var OLE_METADATA_PATTERNS = [
/Root Entry/i,
/SummaryInformation/i,
/DocumentSummaryInformation/i,
/\bWordDocument\b/,
/WpsCustomData/i,
/MsoDataStore/i,
/PKSOProductBuildVer/i,
/KSOProductBuildVer/i,
/\bICV\b/,
/\bFormText\b/,
/MERGEFORMAT/i,
/OLE_LINK/i,
];
var LEGACY_DOC_JUNK_EXACT = {
'Root Entry': true,
'SummaryInformation': true,
'DocumentSummaryInformation': true,
'WordDocument': true,
'Word.Document': true,
'Data': true,
'1Table': true,
'WpsCustomData': true,
'MsoDataStore': true,
'Normal': true,
'Default Paragraph Font': true,
'Table Normal': true,
};
export var IMPORTED_WORD_STYLE_ELEMENT_ID = 'ct-imported-word-styles';
export function buildImportedWordBaselineCss() {
return [
'.ct-word-doc--imported{font-family:SimSun,"宋体","Songti SC",STSong,serif;font-size:11pt;line-height:1.15;color:#000;letter-spacing:normal;word-break:normal;overflow-wrap:break-word}',
'.ct-word-doc--imported p,.ct-word-doc--imported li{margin:0;text-align:inherit;text-indent:inherit}',
'.ct-word-doc--imported h1,.ct-word-doc--imported h2,.ct-word-doc--imported h3,.ct-word-doc--imported h4,.ct-word-doc--imported h5,.ct-word-doc--imported h6{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit}',
'.ct-word-doc--imported .ct-word-section-title{font-weight:700;margin:0;text-align:justify}',
'.ct-word-doc--imported .ct-word-doc-title{text-align:center;font-weight:700;font-size:18pt;margin:0 0 6pt}',
'.ct-word-doc--imported mark.ct-word-highlight,.ct-word-doc--imported .ct-word-highlight{background-color:#ffff00;color:inherit;padding:0}',
'.ct-word-doc--imported table{border-collapse:collapse;width:auto;max-width:100%;table-layout:auto}',
'.ct-word-doc--imported td,.ct-word-doc--imported th{vertical-align:top}',
'.ct-word-doc--imported img{max-width:100%;height:auto}',
'.ct-word-doc--imported strong,.ct-word-doc--imported b{font-weight:700}',
].join('\n');
}
function scopeCssToImportedRoot(cssText) {
var scoped = String(cssText || '').trim();
if (!scoped) return '';
if (scoped.indexOf('.ct-word-doc--imported') >= 0) return scoped;
return scoped.replace(/(^|\})([^{@]+)\{/g, function (match, prefix, selector) {
var parts = selector.split(',').map(function (part) {
var trimmed = part.trim();
if (!trimmed || trimmed.indexOf('@') === 0) return trimmed;
return '.ct-word-doc--imported ' + trimmed;
});
return prefix + parts.join(',') + '{';
});
}
export function extractInlineStylesFromImportedHtml(html) {
var content = String(html || '');
var cssParts = [];
content = content.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, function (_, css) {
if (css && css.trim()) cssParts.push(css.trim());
return '';
});
var cssText = cssParts.map(scopeCssToImportedRoot).filter(Boolean).join('\n');
return { html: content.trim(), cssText: cssText };
}
export async function extractDocxThemeStyles(arrayBuffer) {
try {
var JSZip = (await import('jszip')).default;
var zip = await JSZip.loadAsync(arrayBuffer);
var stylesFile = zip.file('word/styles.xml');
if (!stylesFile) return '';
var stylesXml = await stylesFile.async('text');
var fontFamily = '宋体';
var fontSize = '11pt';
var docDefaultsMatch = stylesXml.match(/<w:docDefaults[\s\S]*?<\/w:docDefaults>/);
if (docDefaultsMatch) {
var block = docDefaultsMatch[0];
var eastAsia = block.match(/w:eastAsia="([^"]+)"/);
if (eastAsia && eastAsia[1]) fontFamily = eastAsia[1];
var ascii = block.match(/w:ascii="([^"]+)"/);
if (!eastAsia && ascii && ascii[1]) fontFamily = ascii[1];
var sz = block.match(/<w:sz[^>]*w:val="(\d+)"/);
if (sz && sz[1]) fontSize = (parseInt(sz[1], 10) / 2) + 'pt';
}
return '.ct-word-doc--imported{font-family:"' + fontFamily + '",SimSun,"宋体","Songti SC",serif;font-size:' + fontSize + ';line-height:1.15;color:#000}';
} catch (e) {
return '';
}
}
export function injectImportedWordStyles(cssText) {
if (typeof document === 'undefined') return;
var merged = [buildImportedWordBaselineCss(), String(cssText || '').trim()].filter(Boolean).join('\n');
var styleEl = document.getElementById(IMPORTED_WORD_STYLE_ELEMENT_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = IMPORTED_WORD_STYLE_ELEMENT_ID;
document.head.appendChild(styleEl);
}
if (styleEl.textContent !== merged) styleEl.textContent = merged;
}
export function prepareImportedWordHtml(html, extraCss) {
var source = sanitizeImportedWordHtml(String(html || ''));
if (!isImportedWordDocHtml(source)) return source;
var extracted = extractInlineStylesFromImportedHtml(source);
injectImportedWordStyles([extracted.cssText, extraCss].filter(Boolean).join('\n'));
var body = extracted.html.trim();
if (!body) return '';
if (body.indexOf('ct-word-doc--imported') >= 0) return body;
return wrapWordHtml(body);
}
function wrapWordHtml(html) {
var body = String(html || '').trim();
if (!body) return '';
if (body.indexOf('ct-word-doc') >= 0) return body;
return '<div class="ct-word-doc ct-word-doc--imported">' + body + '</div>';
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectWordFileFormat(arrayBuffer) {
var view = new Uint8Array(arrayBuffer || []);
if (view.length >= 4 && view[0] === 0x50 && view[1] === 0x4b) return 'docx';
if (view.length >= 8 && view[0] === 0xd0 && view[1] === 0xcf && view[2] === 0x11 && view[3] === 0xe0) return 'doc';
return 'unknown';
}
export function isImportedWordDocHtml(html) {
return /ct-word-doc--imported/.test(String(html || ''));
}
export function isCorruptWordImportHtml(html) {
var source = String(html || '');
if (!source.trim()) return true;
var plain = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, '');
if (!plain.replace(/\s/g, '')) return true;
var hits = 0;
OLE_METADATA_PATTERNS.forEach(function (pattern) {
if (pattern.test(plain)) hits += 1;
});
if (hits >= 2) return true;
if (/[\u4e00-\u9fff]/.test(plain)) return false;
if (/合同|甲方|乙方|租赁|附件/.test(plain)) return false;
return hits >= 1 && plain.length < 500;
}
export function sanitizeImportedWordHtml(html) {
var content = String(html || '').trim();
if (!content) return '';
content = content
.replace(/<\/p>\s*n\s*<p/gi, '</p><p')
.replace(/<\/table>\s*n\s*<p/gi, '</table><p')
.replace(/<\/table>\s*n\s*<table/gi, '</table><table');
if (content.indexOf('ct-word-doc--imported') < 0 && content.indexOf('ct-word-doc') >= 0) {
content = content.replace('ct-word-doc', 'ct-word-doc ct-word-doc--imported');
}
return content;
}
function looksLikeHeadingLine(line) {
var text = String(line || '').trim();
if (!text || text.length > 80) return false;
if (/^附件\s*\d+/i.test(text)) return true;
if (/^第[一二三四五六七八九十\d]+[章节部分]/i.test(text)) return true;
if (/^[一二三四五六七八九十\d]+[、.:]/.test(text) && text.length <= 40) return true;
return text.length <= 24 && !/[。;,,;]/.test(text);
}
function isLegacyDocJunkLine(line) {
var text = String(line || '').trim();
if (!text) return true;
if (LEGACY_DOC_JUNK_EXACT[text]) return true;
if (/^Text\d+$/i.test(text)) return true;
if (/^P\s*\d+$/i.test(text)) return true;
if (/^[\d\s*桰漀]+$/.test(text)) return true;
if (text.length <= 28 && !/[\u4e00-\u9fff]/.test(text) && /^[\x00-\x7F\s]+$/.test(text)) return true;
if (OLE_METADATA_PATTERNS.some(function (pattern) { return pattern.test(text); })) return true;
return false;
}
function plainTextToHtml(text) {
var lines = String(text || '')
.replace(/\r/g, '\n')
.split('\n')
.map(function (line) { return line.trim(); })
.filter(function (line) { return line && !isLegacyDocJunkLine(line); });
if (!lines.length) return '';
return lines.map(function (line) {
if (looksLikeHeadingLine(line)) {
return '<p class="ct-word-section-title">' + escapeHtml(line) + '</p>';
}
return '<p class="ct-word-body">' + escapeHtml(line) + '</p>';
}).join('');
}
/** 从旧版 .doc 二进制中粗提取可读文本(原型层兜底) */
function extractTextFromLegacyDoc(arrayBuffer) {
var view = new Uint8Array(arrayBuffer);
var chunks = [];
var current = '';
function flush() {
var text = current.replace(/\s+/g, ' ').trim();
if (text.length >= 4 && !isLegacyDocJunkLine(text)) chunks.push(text);
current = '';
}
for (var i = 0; i < view.length - 1; i += 2) {
var code = view[i] | (view[i + 1] << 8);
if (
(code >= 0x20 && code <= 0x7e)
|| (code >= 0x4e00 && code <= 0x9fff)
|| code === 0x0a
|| code === 0x0d
|| code === 0x3001
|| code === 0x3002
|| code === 0xff1a
|| code === 0xff1b
) {
current += String.fromCharCode(code);
} else {
flush();
}
}
flush();
var merged = chunks.join('\n');
if (!merged.trim()) {
var latin = new TextDecoder('latin1').decode(view);
merged = latin.replace(/[^\x20-\x7E\u4e00-\u9fff\r\n]+/g, '\n');
}
return merged.replace(/\n{3,}/g, '\n\n').trim();
}
export async function convertDocxArrayBufferToHtml(arrayBuffer) {
if (detectWordFileFormat(arrayBuffer) !== 'docx') {
throw new Error('不是有效的 .docx 文件');
}
var themeCss = await extractDocxThemeStyles(arrayBuffer);
var result = await mammoth.convertToHtml(
{ arrayBuffer: arrayBuffer },
{
styleMap: MAMMOTH_STYLE_MAP,
includeDefaultStyleMap: true,
includeEmbeddedStyleMap: true,
ignoreEmptyParagraphs: false,
}
);
var html = String(result.value || '').trim();
if (!html || isCorruptWordImportHtml(html)) {
var messages = (result.messages || []).map(function (m) { return m.message; }).filter(Boolean);
throw new Error(messages[0] || 'Word 文件解析结果无效');
}
return prepareImportedWordHtml(wrapWordHtml(html), themeCss);
}
export async function convertDocxFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
return convertDocxArrayBufferToHtml(arrayBuffer);
}
export async function convertLegacyDocFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
if (detectWordFileFormat(arrayBuffer) !== 'doc') {
throw new Error('不是有效的 .doc 文件');
}
var text = extractTextFromLegacyDoc(arrayBuffer);
if (!text || !/[\u4e00-\u9fff]/.test(text)) {
throw new Error('未能从 .doc 文件中提取正文,请另存为 .docx 后重试');
}
var html = plainTextToHtml(text);
if (!html || isCorruptWordImportHtml(html)) {
throw new Error('未能从 .doc 文件中生成可用内容,请另存为 .docx 后重试');
}
return prepareImportedWordHtml(wrapWordHtml(html), '');
}
export async function convertWordFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
var format = detectWordFileFormat(arrayBuffer);
var name = String(file.name || '').toLowerCase();
if (format === 'docx' || (/\.docx$/i.test(name) && format !== 'doc')) {
return convertDocxArrayBufferToHtml(arrayBuffer);
}
if (format === 'doc' || /\.doc$/i.test(name)) {
return convertLegacyDocFileToHtml(file);
}
throw new Error('无法识别 Word 文件格式,请使用 .doc 或 .docx');
}
export function detectWordFileFormatFromName(name, arrayBuffer) {
if (arrayBuffer) return detectWordFileFormat(arrayBuffer);
var lower = String(name || '').toLowerCase();
if (/\.docx$/i.test(lower)) return 'docx';
if (/\.doc$/i.test(lower)) return 'doc';
return 'unknown';
}
export function isDocxArrayBuffer(arrayBuffer) {
return detectWordFileFormat(arrayBuffer) === 'docx';
}

View File

@@ -40,6 +40,8 @@ const overviewMd = `## 模块定位
| 目标用户 | 法务人员 |
| 核心任务 | 维护 Word 版商用车租赁合同模板,按章节编辑,发布版本并控制启用态 |
| 数据模型 | 一份 Word 文件 = 一条记录,内含 9 个章节内容 |
| 车型主数据 | 品牌·型号选项来自 [车辆管理](/prototypes/vehicle-management) 种子数据,并与租赁演示车型合并 |
| 角色演示 | 法务默认可编辑URL 加 ?role=cs 或 ?role=readonly 进入客服只读模式 |
| PRD 文档 | 见目录「PRD 全文 → PRD 完整文档」 |
| 资源文件 | \`src/resources/contract-template-management/PRD.md\` |
@@ -66,8 +68,8 @@ const DOC_MARKDOWN = {
'ct-doc-prd-acceptance': extractSection(fullPrd, '## 16. 验收标准') ?? '## 16. 验收标准',
'ct-doc-node-filter': extractSection(fullPrd, '### 7.2 筛选区') ?? '',
'ct-doc-node-table': combineSections(['### 7.4 主表字段', '### 7.5 主表操作', '### 7.6 子表(展开行)']),
'ct-doc-node-tabs': combineSections(['### 8.3 章节 Tab', '### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)', '### 8.5 适用车型绑定(仅附件章节 Tab']),
'ct-doc-node-workspace': combineSections(['### 8.6 编辑区(左侧)', '### 8.7 实时预览(右侧)']),
'ct-doc-node-tabs': combineSections(['### 8.3 章节 Tab', '### 8.5 条件条款说明(合同主体及租赁订单~安全责任协议)', '### 8.6 适用车型绑定(仅附件章节 Tab']),
'ct-doc-node-workspace': combineSections(['### 8.7 编辑区(左侧)', '### 8.8 实时预览(右侧)']),
'ct-doc-annotation-hint': '## 标注查看提示\n\n右侧标注工具栏可浏览目录、切换主题与颜色筛选。列表页与编辑页标注按当前页面自动显隐。\n\n**PRD 同步**目录「PRD 全文」已与 `src/resources/contract-template-management/PRD.md` 对齐;修改 PRD 后运行 `node src/prototypes/contract-template-management/scripts/build-annotation-source.mjs` 重新生成 `annotation-source.json`。',
};
@@ -76,10 +78,10 @@ const MARKDOWN_MAP = {
'ct-list-table': combineSections(['### 7.4 主表字段', '### 7.5 主表操作', '### 7.6 子表(展开行)']),
'ct-editor-topbar': combineSections(['### 8.2 顶栏', '### 6.2 保存与发布']),
'ct-section-tabs': extractSection(fullPrd, '### 8.3 章节 Tab') ?? '',
'ct-contract-type': source.markdownMap?.['ct-contract-type'] ?? '',
'ct-vehicle-binding': extractSection(fullPrd, '### 8.5 适用车型绑定(仅附件章节 Tab') ?? source.markdownMap?.['ct-vehicle-binding'] ?? '',
'ct-editor-panel': combineSections(['### 8.6 编辑区(左侧)']),
'ct-preview-panel': extractSection(fullPrd, '### 8.7 实时预览(右侧)') ?? '',
'ct-contract-type': extractSection(fullPrd, '### 8.4 合同主体配置(仅合同主体 Tab') ?? '',
'ct-vehicle-binding': extractSection(fullPrd, '### 8.6 适用车型绑定(仅附件章节 Tab') ?? source.markdownMap?.['ct-vehicle-binding'] ?? '',
'ct-editor-panel': combineSections(['### 8.7 编辑区(左侧)']),
'ct-preview-panel': extractSection(fullPrd, '### 8.8 实时预览(右侧)') ?? '',
};
function patchDirectoryMarkdown(nodes) {
@@ -97,6 +99,44 @@ function patchDirectoryMarkdown(nodes) {
const ctDocRoot = source.directory?.nodes?.find((n) => n.id === 'ct-doc-root');
if (ctDocRoot) patchDirectoryMarkdown(ctDocRoot.children);
function patchListNodes(nodes) {
for (const node of nodes ?? []) {
if (node.id === 'ct-list-filter') {
node.annotationText = '支持按合同名称(可搜索下拉)、类型、状态、创建人、启用状态筛选;超过 4 项时默认折叠扩展筛选项。';
node.updatedAt = Date.now();
}
if (node.id === 'ct-list-toolbar') {
node.annotationText = '进入新增页;未导入 Word 前不展示章节 Tab。';
node.updatedAt = Date.now();
}
if (node.id === 'ct-section-tabs') {
node.aiPrompt = '由 Word「标题」样式自动生成的章节 Tab。';
node.annotationText = '导入 Word 后,按文档中标记为「标题」的段落文本自动生成章节 Tab新增模式未导入前整段隐藏含合同主体。';
node.updatedAt = Date.now();
}
if (node.id === 'ct-contract-type') {
node.title = '合同主体配置';
node.aiPrompt = '合同主体章节下的合同名称与类型配置。';
node.annotationText = '仅当存在「合同主体」Tab 且选中时展示;合同名称与类型均为必填,同步至列表页。';
node.updatedAt = Date.now();
}
}
}
patchListNodes(source.data?.nodes);
if (source.data?.annotationsByElementId && MARKDOWN_MAP['ct-list-filter']) {
source.data.annotationsByElementId['ct-list-filter'] = MARKDOWN_MAP['ct-list-filter'];
if (MARKDOWN_MAP['ct-list-table']) {
source.data.annotationsByElementId['ct-list-table'] = MARKDOWN_MAP['ct-list-table'];
}
if (MARKDOWN_MAP['ct-contract-type']) {
source.data.annotationsByElementId['ct-contract-type'] = MARKDOWN_MAP['ct-contract-type'];
}
if (MARKDOWN_MAP['ct-section-tabs']) {
source.data.annotationsByElementId['ct-section-tabs'] = MARKDOWN_MAP['ct-section-tabs'];
}
}
source.markdownMap = { ...source.markdownMap, ...MARKDOWN_MAP };
source.data.updatedAt = Date.now();

View File

@@ -1572,10 +1572,123 @@
table-layout: auto;
}
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--imported,
.vm-page.ct-page .ct-preview-page__body.ct-word-doc--imported,
.vm-page.ct-page .ct-preview-doc .ct-word-doc--imported {
display: block;
width: 100%;
min-width: 0;
padding: 0 !important;
font-size: unset;
line-height: unset;
color: #000;
letter-spacing: normal;
word-break: normal;
overflow-wrap: break-word;
}
.vm-page.ct-page .ct-word-doc--imported p,
.vm-page.ct-page .ct-word-doc--imported li {
margin: 0;
text-align: inherit;
text-indent: inherit;
}
.vm-page.ct-page .ct-word-doc--imported h1,
.vm-page.ct-page .ct-word-doc--imported h2,
.vm-page.ct-page .ct-word-doc--imported h3,
.vm-page.ct-page .ct-word-doc--imported h4,
.vm-page.ct-page .ct-word-doc--imported h5,
.vm-page.ct-page .ct-word-doc--imported h6 {
margin: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
.vm-page.ct-page .ct-word-doc--imported .ct-word-section-title {
font-weight: 700;
margin: 0;
}
.vm-page.ct-page .ct-word-doc--imported mark.ct-word-highlight,
.vm-page.ct-page .ct-word-doc--imported .ct-word-highlight {
background-color: #ffff00;
color: inherit;
padding: 0;
}
.vm-page.ct-page .ct-word-doc--imported table {
border-collapse: collapse;
width: auto;
max-width: 100%;
table-layout: auto;
}
.vm-page.ct-page .ct-word-doc--imported td,
.vm-page.ct-page .ct-word-doc--imported th {
vertical-align: top;
}
.vm-page.ct-page .ct-word-doc--imported img {
max-width: 100%;
height: auto;
}
.vm-page.ct-page .ct-preview-page__body.ct-word-doc--imported {
overflow: visible;
word-break: normal;
}
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 style {
display: none !important;
}
.vm-page.ct-page .ct-preview-pages--docx-fidelity {
align-items: center;
width: 100%;
}
.vm-page.ct-page .ct-docx-fidelity-banner {
width: 100%;
max-width: 210mm;
margin: 0 auto 12px;
padding: 8px 12px;
font-size: 12px;
line-height: 1.5;
color: #334155;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-sizing: border-box;
}
.vm-page.ct-page .ct-docx-fidelity-root {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.vm-page.ct-page .ct-docx-fidelity-root .docx-wrapper {
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
margin: 0 auto;
}
.vm-page.ct-page .ct-docx-fidelity-fallback {
width: 210mm;
max-width: calc(100% - 10px);
padding: 24px;
text-align: center;
color: #64748b;
font-size: 13px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.vm-page.ct-page .ct-rich-wrap--word-a4 .ct-editor-a4-shell {
position: relative;
flex: 1;
@@ -2047,10 +2160,36 @@
}
.vm-page.ct-page .ct-contract-type-card__form {
max-width: 360px;
max-width: none;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-end;
gap: 16px;
}
.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-input {
.vm-page.ct-page .ct-contract-type-card__field {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
flex: 1 1 0;
min-width: 0;
}
.vm-page.ct-page .ct-contract-type-card__field--type {
flex: 0 0 200px;
max-width: 220px;
}
.vm-page.ct-page .ct-contract-type-card__label {
font-size: 12px;
font-weight: 500;
color: #64748b;
}
.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-input,
.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-select {
width: 100%;
}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "customer-management",
"pageId": "list",
"updatedAt": 1783328981485,
"updatedAt": 1783352657922,
"nodes": [
{
"id": "cm-list-filter",

View File

@@ -175,7 +175,7 @@ export function FilterPanel({
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>
<h2 className="vm-filter-title"></h2>
</header>
<div className="vm-filter-grid">
{visibleFields.map((field) => (

View File

@@ -68,6 +68,7 @@ export function MultiSelectField({
<label key={option} className="cm-multi-option">
<input
type="checkbox"
className="vm-checkbox"
checked={values.includes(option)}
onChange={() => toggle(option)}
/>

View File

@@ -414,7 +414,6 @@ export default function CustomerManagementApp() {
onManageTags={setTagDrawerRecord}
/>
<div className="vm-table-footer">
<div className="cm-table-total"> {filtered.length} </div>
<TablePagination
page={Math.min(page, totalPages)}
pageSize={pageSize}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "insurance-procurement",
"pageId": "list",
"updatedAt": 1783328981486,
"updatedAt": 1783352657923,
"nodes": [
{
"id": "ipc-header",

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "lease-business-ledger",
"pageId": "list",
"updatedAt": 1783329264666,
"updatedAt": 1783352657924,
"nodes": [
{
"id": "lbl-filter",
@@ -1158,6 +1158,135 @@
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "folder",
"id": "nav-folder-1782874576229-r8atbu",
"title": "OneOS",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-workbench",
"title": "工作台",
"href": "/prototypes/oneos-web-workbench",
"target": "self"
}
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-login",
"title": "登录",
"href": "/prototypes/oneos-web-login",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-vehicle-asset",
"title": "车辆管理",
"href": "/prototypes/oneos-web-vehicle-asset",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-contract-template",
"title": "合同模板管理",
"href": "/prototypes/oneos-web-contract-template",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-help-center",
"title": "帮助中心",
"href": "/prototypes/oneos-web-help-center",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-finance",
"title": "财务管理",
"href": "/prototypes/oneos-web-finance",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-procurement",
"title": "采购管理",
"href": "/prototypes/oneos-web-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-lease-contract",
"title": "车辆租赁合同",
"href": "/prototypes/oneos-web-lease-contract",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-h2-station",
"title": "加氢站管理",
"href": "/prototypes/oneos-web-h2-station",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-data-analysis",
"title": "数据分析",
"href": "/prototypes/oneos-web-data-analysis",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ledger-data",
"title": "台账数据",
"href": "/prototypes/oneos-web-ledger-data",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-business",
"title": "业务管理",
"href": "/prototypes/oneos-web-business",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ops",
"title": "运维管理",
"href": "/prototypes/oneos-web-ops",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-requirements",
"title": "需求说明",
"href": "/prototypes/oneos-web-requirements",
"target": "self"
}
]
}
]
},
{
"type": "folder",
"id": "lbl-doc-root",
@@ -1174,7 +1303,7 @@
"type": "link",
"id": "lbl-link-self",
"title": "租赁业务台账原型",
"href": "/prototypes/lease-business-ledger",
"href": "./index.html",
"target": "self"
},
{

View File

@@ -10,6 +10,8 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
'lbl-col-customer': '客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。',
'lbl-col-value-added-service':
'签订合同时勾选的增值服务项目,按车牌自动匹配合同数据;多项服务各占一行展示。',
'lbl-col-exemption-policy':
'手选享免政策帕力安18T里程优惠政策、帕力安4.5T里程优惠政策;未归档/草稿状态可编辑。',
'lbl-col-pickup-date': '运维实际交车日期。',
'lbl-col-contract-start-date': '等于提车日期,即租赁合同生效日期,系统自动反写,只读。',
'lbl-col-contract-end-date':
@@ -30,7 +32,7 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
'lbl-col-maintenance-income': '合同勾选维保包干时:单价(元/km× 里程数,必填;未勾选时默认 0 且不可编辑。',
'lbl-col-insurance-surcharge': '事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。',
'lbl-col-ops-income':
'收入侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「客户承担」的金额;最新一期账单可关联记录。',
'收入侧运维费:汇总还车应结款中「未结算保养」「未结算维修」「清洗费」等 10 项运维费用,按账单月内该车牌求和。',
'lbl-col-other-income': '支持多条明细录入(事由、金额、备注);合计自动计入应收与月度收入。',
'lbl-col-mileage-deduction':
'里程减免金额 = GPS 里程 × 里程减免单价;有 GPS 里程时默认单价 0.5 元/km无 GPS 时为 0。',
@@ -48,7 +50,7 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
'lbl-col-hydrogen-fee':
'仅汇总该账单月内、氢费明细中「我司承担」的氢费(含未对账/已对账);合同为客户承担时不计入。',
'lbl-col-ops-cost':
'成本侧运维费:自动汇总该账单月内该车牌维修/保养台账中「我司承担」的金额。',
'成本侧运维费:汇总「维修保养明细」中费用总计(含税),按账单月内该车牌求和。',
'lbl-col-brokerage-fee': '手动输入。',
'lbl-col-other-cost': '前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。',
'lbl-col-gps-mileage': '从 BI 里程取账单时间段的数据,自动展示。',

View File

@@ -89,6 +89,7 @@ export function AccidentLinkModal({
<td className="ldb-col-check">
<input
type="checkbox"
className="vm-checkbox"
checked={selected.has(item.id)}
onChange={() => toggle(item.id)}
aria-label={`选择 ${item.accidentNo}`}

View File

@@ -1,12 +1,25 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
import type { LeaseLedgerFilters, LeaseLedgerRow } from '../types';
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField';
import { buildLeaseOptions } from '../utils/ledger';
const STATUS_OPTIONS = ['未收款', '部分收款', '已收款'];
const DEFAULT_VISIBLE = 4;
/** PRD §2默认首行 4 项 */
const PRIMARY_FIELD_KEYS = ['year', 'month', 'dept', 'salesman'] as const;
/** PRD §2「更多筛选」展开其余 4 项 */
const EXTRA_FIELD_KEYS = ['plateNos', 'customerName', 'status', 'paymentDue'] as const;
type FieldKey = (typeof PRIMARY_FIELD_KEYS)[number] | (typeof EXTRA_FIELD_KEYS)[number];
interface FilterFieldDef {
key: FieldKey;
label: string;
control: React.ReactNode;
}
interface FilterPanelProps {
records: LeaseLedgerRow[];
@@ -16,24 +29,125 @@ interface FilterPanelProps {
onSearch: () => void;
}
function hasExtraFiltersActive(filters: LeaseLedgerFilters): boolean {
return Boolean(
filters.plateNos.length > 0
|| filters.customerName.trim()
|| filters.status
|| filters.paymentFrom
|| filters.paymentTo,
);
}
export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) {
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(() => hasExtraFiltersActive(filters));
useEffect(() => {
if (hasExtraFiltersActive(filters)) {
setExpanded(true);
}
}, [filters.plateNos, filters.customerName, filters.status, filters.paymentFrom, filters.paymentTo]);
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: (
const fieldMap = useMemo<Record<FieldKey, FilterFieldDef>>(() => ({
year: {
key: 'year',
label: '年份',
control: (
<FilterPickerField
value={filters.year}
options={yearOptions}
placeholder="全部年份"
ariaLabel="年份"
onChange={(year) => onChange({ year })}
/>
),
},
month: {
key: 'month',
label: '月份',
control: (
<FilterPickerField
value={filters.month}
options={monthOptions}
placeholder="全部月份"
ariaLabel="月份"
onChange={(month) => onChange({ month })}
/>
),
},
dept: {
key: 'dept',
label: '业务部门',
control: (
<FilterPickerField
value={filters.dept}
options={deptOptions}
placeholder="全部部门"
ariaLabel="业务部门"
onChange={(dept) => onChange({ dept })}
/>
),
},
salesman: {
key: 'salesman',
label: '业务员',
control: (
<FilterPickerField
value={filters.salesman}
options={salesmanOptions}
placeholder="全部业务员"
ariaLabel="业务员"
onChange={(salesman) => onChange({ salesman })}
/>
),
},
plateNos: {
key: 'plateNos',
label: '车牌号码',
control: (
<MultiPlateFilterField
value={filters.plateNos}
placeholder="点击输入多个车牌"
ariaLabel="车牌号码"
onChange={(plateNos) => onChange({ plateNos })}
/>
),
},
customerName: {
key: 'customerName',
label: '客户名称',
control: (
<input
className="vm-input"
value={filters.customerName}
placeholder="输入客户名称(模糊匹配)"
aria-label="客户名称"
onChange={(e) => onChange({ customerName: e.target.value })}
/>
),
},
status: {
key: 'status',
label: '明细状态',
control: (
<FilterPickerField
value={filters.status}
options={STATUS_OPTIONS}
placeholder="全部状态"
ariaLabel="明细状态"
onChange={(status) => onChange({ status })}
/>
),
},
paymentDue: {
key: 'paymentDue',
label: '应付款日',
control: (
<DateRangeFilterField
startDate={filters.paymentFrom}
endDate={filters.paymentTo}
@@ -44,46 +158,78 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
/>
),
},
];
}), [
filters,
onChange,
yearOptions,
monthOptions,
deptOptions,
salesmanOptions,
]);
const primaryFields = fields.slice(0, DEFAULT_VISIBLE);
const extraFields = fields.slice(DEFAULT_VISIBLE);
const primaryFields = PRIMARY_FIELD_KEYS.map((key) => fieldMap[key]);
const extraFields = EXTRA_FIELD_KEYS.map((key) => fieldMap[key]);
const extraActiveCount = [
filters.plateNos.length > 0,
filters.customerName.trim(),
filters.status,
filters.paymentFrom || filters.paymentTo,
].filter(Boolean).length;
const renderField = (field: FilterFieldDef) => (
<label key={field.key} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
);
return (
<section className="vm-filter-card ldb-filter-card" data-annotation-id="lbl-filter" aria-label="筛选条件">
<section
className="vm-filter-card ldb-filter-card"
data-annotation-id="lbl-filter"
aria-label="筛选条件"
>
<header className="vm-filter-header">
<h1 className="ldb-page-title"></h1>
<h2 className="vm-filter-title"></h2>
</header>
<div className="vm-filter-grid">
{primaryFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))}
</div>
{extraFields.length > 0 && (
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}>
<div className="ldb-filter-body">
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
{primaryFields.map(renderField)}
</div>
<div
className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}
aria-hidden={!expanded}
id="ldb-filter-expand-panel"
>
<div className="ldb-filter-expand-inner">
<div className="vm-filter-grid ldb-filter-expand-grid">
{extraFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))}
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
{expanded ? extraFields.map(renderField) : null}
</div>
</div>
</div>
)}
<div className="vm-filter-actions">
{extraFields.length > 0 && (
<button type="button" className="vm-btn vm-btn-link ldb-filter-toggle" onClick={() => setExpanded(!expanded)} aria-expanded={expanded}>
{expanded ? '收起' : '更多筛选'}
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
</button>
)}
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}></button>
</div>
<div className="vm-filter-actions ldb-filter-actions">
<button
type="button"
className="vm-btn vm-btn-link ldb-filter-toggle"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls="ldb-filter-expand-panel"
>
{expanded ? '收起' : '更多筛选'}
{extraActiveCount > 0 && !expanded ? (
<span className="ldb-filter-toggle-badge" aria-label={`已设置 ${extraActiveCount} 项扩展筛选`}>
{extraActiveCount}
</span>
) : null}
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
</button>
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}>
</button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
<Search size={16} aria-hidden />

View File

@@ -0,0 +1,79 @@
import React, { useRef } from 'react';
import { Download, Upload, X } from 'lucide-react';
interface LeaseLedgerImportModalProps {
open: boolean;
onClose: () => void;
onDownloadTemplate: () => void;
onImport: (file: File) => void;
}
export function LeaseLedgerImportModal({
open,
onClose,
onDownloadTemplate,
onImport,
}: LeaseLedgerImportModalProps) {
const inputRef = useRef<HTMLInputElement>(null);
if (!open) return null;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-lg ldb-import-modal"
role="dialog"
aria-modal="true"
aria-labelledby="ldb-import-title"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="ldb-import-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-import-body">
<section className="vm-import-step">
<div className="vm-import-step-head">
<span className="vm-step-badge">1</span>
<span></span>
</div>
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate}>
<Download size={16} aria-hidden />
</button>
</section>
<section className="vm-import-step">
<div className="vm-import-step-head">
<span className="vm-step-badge">2</span>
<span></span>
</div>
<button
type="button"
className="vm-upload-zone"
onClick={() => inputRef.current?.click()}
>
<Upload size={28} aria-hidden />
<p></p>
<p className="hint"> .csv.xls.xlsx</p>
<input
ref={inputRef}
type="file"
accept=".csv,.xls,.xlsx"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = '';
}}
/>
</button>
</section>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
</footer>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp, History } from 'lucide-react';
import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceOrder, ReceiptRecord } from '../types';
import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceLedgerRecord, ReceiptRecord, ReturnSettlementFeeRecord } from '../types';
import {
calcMonthlyIncome,
calcUnreceived,
@@ -12,10 +12,13 @@ import {
formatMoney,
getHydrogenFeesForBill,
getLinkedReceiptsForRow,
getMaintenanceOrdersForBill,
getMaintenanceLedgerRecordsForBill,
getReturnSettlementFeesForBill,
isBlankDisplayValue,
} from '../utils/ledger';
import { LEASE_TABLE_COLUMNS, STATUS_CLASS, ARCHIVE_STATUS_CLASS, type LeaseTableColumn } from './tableColumns';
import { LEASE_TABLE_COLUMNS, LEFT_STICKY_LAST_COLUMN_KEY, STATUS_CLASS, ARCHIVE_STATUS_CLASS, type LeaseTableColumn } from './tableColumns';
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
import { EXEMPTION_POLICY_OPTIONS } from '../utils/constants';
import { resolveColumnHeaderTip } from '../columnHeaderTips';
import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover';
import { LedgerLineItemsCell } from './LedgerLineItemsCell';
@@ -25,7 +28,8 @@ interface LeaseLedgerTableProps {
records: LeaseLedgerRow[];
selectedIds: Set<string>;
loading?: boolean;
maintenanceOrders: MaintenanceOrder[];
returnSettlementFees: ReturnSettlementFeeRecord[];
maintenanceLedgerRecords: MaintenanceLedgerRecord[];
receipts: ReceiptRecord[];
hydrogenFees: HydrogenFeeRecord[];
onToggleRow: (id: string, checked: boolean) => void;
@@ -40,6 +44,7 @@ interface LeaseLedgerTableProps {
onBrokerageFeeChange: (recordId: string, value: number) => void;
onRemarkChange: (recordId: string, value: string) => void;
onDeductionDetailChange: (recordId: string, value: string) => void;
onExemptionPolicyChange: (recordId: string, value: string) => void;
}
type SortDir = 'asc' | 'desc';
@@ -47,6 +52,45 @@ type SortDir = 'asc' | 'desc';
const CHECK_COL_WIDTH = 48;
const ACTION_COL_WIDTH = 208;
function buildLeftStickyLayout(columnWidths: Record<string, number>) {
const keys: string[] = [];
for (const col of LEASE_TABLE_COLUMNS) {
keys.push(col.key);
if (col.key === LEFT_STICKY_LAST_COLUMN_KEY) break;
}
const keySet = new Set(keys);
let left = CHECK_COL_WIDTH;
const leftOffsets: Record<string, number> = {};
for (const key of keys) {
leftOffsets[key] = left;
left += columnWidths[key] ?? DEFAULT_COLUMN_WIDTHS[key] ?? 100;
}
return { keys, keySet, leftOffsets, lastKey: LEFT_STICKY_LAST_COLUMN_KEY };
}
function stickyLeftClass(colKey: string, layout: ReturnType<typeof buildLeftStickyLayout>): string {
if (!layout.keySet.has(colKey)) return '';
const classes = ['sticky-col-left'];
if (colKey === layout.lastKey) classes.push('sticky-col-left-last');
return classes.join(' ');
}
function stickyLeftStyle(
colKey: string,
layout: ReturnType<typeof buildLeftStickyLayout>,
isHeader: boolean,
): React.CSSProperties | undefined {
const index = layout.keys.indexOf(colKey);
if (index < 0) return undefined;
return {
left: layout.leftOffsets[colKey],
zIndex: (isHeader ? 4 : 3) + index,
};
}
const CHECK_STICKY_HEAD_STYLE: React.CSSProperties = { left: 0, zIndex: 3 };
const CHECK_STICKY_BODY_STYLE: React.CSSProperties = { left: 0, zIndex: 2 };
const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
status: 88,
billingYearMonth: 88,
@@ -58,7 +102,7 @@ const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
plateNo: 120,
customerName: 160,
valueAddedService: 96,
exemptionPolicy: 96,
exemptionPolicy: 176,
pickupDate: 108,
contractStartDate: 116,
contractEndDate: 116,
@@ -284,7 +328,8 @@ function renderCell(
col: LeaseTableColumn,
index: number,
ctx: {
maintenanceOrders: MaintenanceOrder[];
returnSettlementFees: ReturnSettlementFeeRecord[];
maintenanceLedgerRecords: MaintenanceLedgerRecord[];
receipts: ReceiptRecord[];
hydrogenFees: HydrogenFeeRecord[];
canEdit: boolean;
@@ -295,6 +340,7 @@ function renderCell(
onBrokerageFeeChange: (recordId: string, value: number) => void;
onRemarkChange: (recordId: string, value: string) => void;
onDeductionDetailChange: (recordId: string, value: string) => void;
onExemptionPolicyChange: (recordId: string, value: string) => void;
},
) {
switch (col.render) {
@@ -316,6 +362,19 @@ function renderCell(
{displayText(row.valueAddedService)}
</span>
);
case 'exemption-policy':
if (!ctx.canEdit) {
return <span title={row.exemptionPolicy}>{displayText(row.exemptionPolicy)}</span>;
}
return (
<FilterPickerField
value={row.exemptionPolicy}
options={[...EXEMPTION_POLICY_OPTIONS]}
placeholder="请选择享免政策"
ariaLabel={`${row.plateNo} 享免政策`}
onChange={(value) => ctx.onExemptionPolicyChange(row.id, value)}
/>
);
case 'stack-dept':
return (
<div className="ldb-cell-stack">
@@ -370,25 +429,25 @@ function renderCell(
<span className="ldb-computed-val">{formatMoney(row.insuranceSurcharge)}</span>
);
case 'detail-popover-maintenance-income': {
const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '客户承担');
const tableRows = orders.map((item) => ({
date: item.completedAt.split(' ')[0],
const fees = getReturnSettlementFeesForBill(row, ctx.returnSettlementFees);
const tableRows = fees.map((item) => ({
date: item.settlementDate.split(' ')[0],
plateNo: item.plateNo,
type: item.type,
feeItem: item.feeItem,
amount: formatMoney(item.amount),
}));
return (
<LedgerDetailPopover
label={formatMoney(row.opsFeeIncome)}
title="客户承担维修保养明细"
title="还车应结款运维费明细"
triggerClassName="tabular-nums"
>
<LedgerDetailTable
columns={[
{ key: 'date', title: '日期' },
{ key: 'plateNo', title: '车牌号' },
{ key: 'type', title: '类型' },
{ key: 'amount', title: '费用总计', align: 'right' },
{ key: 'feeItem', title: '费用项' },
{ key: 'amount', title: '金额', align: 'right' },
]}
rows={tableRows}
/>
@@ -396,17 +455,17 @@ function renderCell(
);
}
case 'detail-popover-maintenance-cost': {
const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '我司承担');
const tableRows = orders.map((item) => ({
date: item.completedAt.split(' ')[0],
const records = getMaintenanceLedgerRecordsForBill(row, ctx.maintenanceLedgerRecords);
const tableRows = records.map((item) => ({
date: item.repairDate.split(' ')[0],
plateNo: item.plateNo,
type: item.type,
amount: formatMoney(item.amount),
amount: formatMoney(item.totalCost),
}));
return (
<LedgerDetailPopover
label={formatMoney(row.opsFeeCost)}
title="我司承担维修保养明细"
title="维修保养明细"
triggerClassName="tabular-nums"
>
<LedgerDetailTable
@@ -414,7 +473,7 @@ function renderCell(
{ key: 'date', title: '日期' },
{ key: 'plateNo', title: '车牌号' },
{ key: 'type', title: '类型' },
{ key: 'amount', title: '费用总计', align: 'right' },
{ key: 'amount', title: '费用总计(含税)', align: 'right' },
]}
rows={tableRows}
/>
@@ -540,8 +599,8 @@ function renderCell(
{ key: 'refuelDate', title: '加氢日期' },
{ key: 'plateNo', title: '车牌号' },
{ key: 'stationName', title: '加氢站名称' },
{ key: 'volume', title: '加氢量', align: 'right' },
{ key: 'unitCost', title: '成本单价', align: 'right' },
{ key: 'volume', title: '加氢量' },
{ key: 'unitCost', title: '成本单价' },
{ key: 'totalCost', title: '成本总价', align: 'right' },
{ key: 'borneBy', title: '承担方式' },
]}
@@ -633,7 +692,8 @@ export function LeaseLedgerTable({
records,
selectedIds,
loading = false,
maintenanceOrders,
returnSettlementFees,
maintenanceLedgerRecords,
receipts,
hydrogenFees,
onToggleRow,
@@ -648,12 +708,17 @@ export function LeaseLedgerTable({
onBrokerageFeeChange,
onRemarkChange,
onDeductionDetailChange,
onExemptionPolicyChange,
}: LeaseLedgerTableProps) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [historyRow, setHistoryRow] = useState<LeaseLedgerRow | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_COLUMN_WIDTHS }));
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
const leftStickyLayout = useMemo(
() => buildLeftStickyLayout(columnWidths),
[columnWidths],
);
const sortedRecords = useMemo(() => {
if (!sortKey) return records;
@@ -669,6 +734,14 @@ export function LeaseLedgerTable({
}, [records, sortKey, sortDir]);
const allSelected = sortedRecords.length > 0 && sortedRecords.every((r) => selectedIds.has(r.id));
const someSelected = sortedRecords.some((r) => selectedIds.has(r.id)) && !allSelected;
const headerCheckRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (headerCheckRef.current) {
headerCheckRef.current.indeterminate = someSelected;
}
}, [someSelected]);
const handleSort = useCallback((key: string) => {
setSortKey((prev) => {
@@ -738,8 +811,19 @@ export function LeaseLedgerTable({
</colgroup>
<thead>
<tr>
<th className="ldb-col-check sticky-col-left" data-annotation-id="lbl-col-check">
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
<th
className="ldb-col-check sticky-col-left"
style={CHECK_STICKY_HEAD_STYLE}
data-annotation-id="lbl-col-check"
>
<input
ref={headerCheckRef}
type="checkbox"
className="vm-checkbox"
checked={allSelected}
onChange={(e) => onToggleAll(e.target.checked)}
aria-label="全选"
/>
</th>
{LEASE_TABLE_COLUMNS.map((col) => {
const sortable = col.sortable !== false;
@@ -750,9 +834,10 @@ export function LeaseLedgerTable({
className={[
col.money ? 'ldb-col-money' : '',
col.className,
col.sticky === 'left' ? 'sticky-col-left' : '',
stickyLeftClass(col.key, leftStickyLayout),
'ldb-col-resizable',
].filter(Boolean).join(' ')}
style={stickyLeftStyle(col.key, leftStickyLayout, true)}
data-annotation-id={col.annotationId}
>
<div className="ldb-th-inner">
@@ -782,7 +867,8 @@ export function LeaseLedgerTable({
{sortedRecords.map((row, index) => {
const canEdit = canStaffEditRow(row);
const cellCtx = {
maintenanceOrders,
returnSettlementFees,
maintenanceLedgerRecords,
receipts,
hydrogenFees,
canEdit,
@@ -793,13 +879,15 @@ export function LeaseLedgerTable({
onBrokerageFeeChange,
onRemarkChange,
onDeductionDetailChange,
onExemptionPolicyChange,
};
return (
<tr key={row.id}>
<td className="ldb-col-check sticky-col-left">
<td className="ldb-col-check sticky-col-left" style={CHECK_STICKY_BODY_STYLE}>
<input
type="checkbox"
className="vm-checkbox"
checked={selectedIds.has(row.id)}
onChange={(e) => onToggleRow(row.id, e.target.checked)}
aria-label={`选择第 ${index + 1}`}
@@ -811,8 +899,9 @@ export function LeaseLedgerTable({
className={[
col.money ? 'ldb-col-money tabular-nums' : '',
col.className,
col.sticky === 'left' ? 'sticky-col-left' : '',
stickyLeftClass(col.key, leftStickyLayout),
].filter(Boolean).join(' ')}
style={stickyLeftStyle(col.key, leftStickyLayout, false)}
>
{renderCell(row, col, index, cellCtx)}
</td>

View File

@@ -1,4 +1,6 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import React, { useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface LedgerDetailPopoverProps {
label: string;
@@ -16,44 +18,65 @@ export function LedgerDetailPopover({
footer,
}: LedgerDetailPopoverProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const panelId = useId();
const dialogId = useId();
const titleId = useId();
useEffect(() => {
if (!open) return;
const onDocClick = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open]);
const close = () => setOpen(false);
return (
<div className="ldb-detail-popover-wrap" ref={wrapRef}>
<>
<button
type="button"
className={`ldb-detail-popover-trigger ldb-cell-clickable cursor-pointer ${triggerClassName}`.trim()}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((prev) => !prev)}
aria-controls={open ? dialogId : undefined}
onClick={() => setOpen(true)}
>
{label}
</button>
{open && (
<div id={panelId} className="ldb-detail-popover-card" role="dialog" aria-label={title ?? label}>
{title && <div className="ldb-detail-popover-title">{title}</div>}
<div className="ldb-detail-popover-body">{children}</div>
{footer && <div className="ldb-detail-popover-footer">{footer}</div>}
</div>
{open && createPortal(
<div className="vm-modal-backdrop ldb-detail-card-backdrop" role="presentation" onClick={close}>
<div
id={dialogId}
className="vm-modal ldb-detail-card-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header ldb-detail-card-header">
<h2 id={titleId} className="vm-modal-title">{title ?? label}</h2>
<button type="button" className="vm-modal-close" onClick={close} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="vm-modal-body ldb-detail-card-body">
<div className="ldb-detail-card-summary" aria-label="汇总">
<span className="ldb-detail-card-summary-label"></span>
<span className="ldb-detail-card-summary-value tabular-nums">{label}</span>
</div>
<div className="ldb-detail-card-content">{children}</div>
</div>
{footer && <footer className="vm-modal-footer ldb-detail-card-footer">{footer}</footer>}
</div>
</div>,
document.body,
)}
</div>
</>
);
}
@@ -69,27 +92,38 @@ export function LedgerDetailTable({ columns, rows, emptyText = '暂无明细' }:
}
return (
<div className="ldb-detail-popover-table-wrap">
<table className="ldb-detail-popover-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} className={col.align === 'right' ? 'text-right' : ''}>{col.title}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
<>
<p className="ldb-detail-card-meta"> {rows.length} </p>
<div className="ldb-detail-card-table-wrap">
<table className="ldb-detail-popover-table">
<thead>
<tr>
{columns.map((col) => (
<td key={col.key} className={col.align === 'right' ? 'text-right tabular-nums' : ''}>
{row[col.key] ?? ''}
</td>
<th
key={col.key}
className={col.align === 'right' ? 'ldb-detail-col-amount' : undefined}
>
{col.title}
</th>
))}
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{columns.map((col) => (
<td
key={col.key}
className={col.align === 'right' ? 'ldb-detail-col-amount tabular-nums' : undefined}
>
{row[col.key] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

View File

@@ -1,6 +1,8 @@
import React, { useMemo } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import type { LedgerLineItem } from '../types';
import { formatMoney } from '../utils/ledger';
import { lineItemsShowDetailColumns } from '../utils/ledger-batch-import';
import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover';
interface LedgerLineItemsCellProps {
@@ -15,6 +17,117 @@ interface LedgerLineItemsCellProps {
const EMPTY_ITEM: LedgerLineItem = { reason: '', amount: 0, remark: '' };
function updateItem(
items: LedgerLineItem[],
index: number,
patch: Partial<LedgerLineItem>,
): LedgerLineItem[] {
const next = [...items];
next[index] = { ...next[index], ...patch };
return next;
}
function LedgerLineItemsEditor({
items,
reasonLabel,
ariaLabel,
onChange,
}: {
items: LedgerLineItem[];
reasonLabel: string;
ariaLabel: string;
onChange: (items: LedgerLineItem[]) => void;
}) {
const rowCount = items.length;
const showDetailColumns = lineItemsShowDetailColumns(items);
return (
<>
<p className="ldb-detail-card-meta">
{rowCount > 0 ? `${rowCount} 条明细记录` : '暂无明细,请添加记录'}
</p>
<div className="ldb-detail-card-table-wrap ldb-line-items-table-wrap">
<table className="ldb-detail-popover-table ldb-line-items-table">
<thead>
<tr>
{showDetailColumns && <th>{reasonLabel}</th>}
<th className="ldb-detail-col-amount"></th>
{showDetailColumns && <th></th>}
<th className="ldb-line-items-col-action" aria-label="操作"></th>
</tr>
</thead>
<tbody>
{rowCount === 0 ? (
<tr>
<td colSpan={showDetailColumns ? 4 : 2} className="ldb-line-items-empty-cell"></td>
</tr>
) : items.map((item, index) => (
<tr key={index}>
{showDetailColumns && (
<td>
<input
type="text"
className="vm-input ldb-line-items-input"
value={item.reason}
placeholder={reasonLabel}
aria-label={`${ariaLabel} ${reasonLabel}`}
onChange={(event) => onChange(updateItem(items, index, { reason: event.target.value }))}
/>
</td>
)}
<td className="ldb-detail-col-amount">
<input
type="number"
className="vm-input ldb-line-items-input ldb-line-items-input--amount tabular-nums"
value={item.amount || ''}
min={0}
step={0.01}
placeholder="0.00"
aria-label={`${ariaLabel} 金额`}
onChange={(event) => onChange(updateItem(items, index, { amount: Number(event.target.value) || 0 }))}
/>
</td>
{showDetailColumns && (
<td>
<input
type="text"
className="vm-input ldb-line-items-input"
value={item.remark}
placeholder="备注"
aria-label={`${ariaLabel} 备注`}
onChange={(event) => onChange(updateItem(items, index, { remark: event.target.value }))}
/>
</td>
)}
<td className="ldb-line-items-col-action">
<button
type="button"
className="ldb-line-items-row-delete"
aria-label={`删除第 ${index + 1}`}
onClick={() => onChange(items.filter((_, rowIndex) => rowIndex !== index))}
>
<Trash2 size={16} aria-hidden />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="ldb-line-items-toolbar">
<button
type="button"
className="vm-btn vm-btn-ghost ldb-line-items-add"
onClick={() => onChange([...items, { ...EMPTY_ITEM }])}
>
<Plus size={16} aria-hidden />
</button>
</div>
</>
);
}
export function LedgerLineItemsCell({
items,
total,
@@ -26,6 +139,7 @@ export function LedgerLineItemsCell({
}: LedgerLineItemsCellProps) {
const displayItems = items.length > 0 ? items : [];
const sum = total;
const showDetailColumns = lineItemsShowDetailColumns(displayItems);
const tableRows = useMemo(
() => displayItems.map((item) => ({
@@ -36,65 +150,24 @@ export function LedgerLineItemsCell({
[displayItems],
);
const editor = canEdit ? (
<div className="ldb-line-items-editor">
{displayItems.map((item, index) => (
<div key={index} className="ldb-line-items-row">
<input
type="text"
className="vm-input ldb-line-items-input"
value={item.reason}
placeholder={reasonLabel}
aria-label={`${ariaLabel} ${reasonLabel}`}
onChange={(e) => {
const next = [...displayItems];
next[index] = { ...next[index], reason: e.target.value };
onChange(next);
}}
/>
<input
type="number"
className="vm-input ldb-line-items-input tabular-nums"
value={item.amount || ''}
min={0}
step={0.01}
placeholder="金额"
aria-label={`${ariaLabel} 金额`}
onChange={(e) => {
const next = [...displayItems];
next[index] = { ...next[index], amount: Number(e.target.value) || 0 };
onChange(next);
}}
/>
<input
type="text"
className="vm-input ldb-line-items-input"
value={item.remark}
placeholder="备注"
aria-label={`${ariaLabel} 备注`}
onChange={(e) => {
const next = [...displayItems];
next[index] = { ...next[index], remark: e.target.value };
onChange(next);
}}
/>
</div>
))}
<button
type="button"
className="vm-btn vm-btn-ghost ldb-line-items-add"
onClick={() => onChange([...displayItems, { ...EMPTY_ITEM }])}
>
</button>
</div>
const readColumns = showDetailColumns
? [
{ key: 'reason', title: reasonLabel },
{ key: 'amount', title: '金额', align: 'right' as const },
{ key: 'remark', title: '备注' },
]
: [{ key: 'amount', title: '金额', align: 'right' as const }];
const content = canEdit ? (
<LedgerLineItemsEditor
items={displayItems}
reasonLabel={reasonLabel}
ariaLabel={ariaLabel}
onChange={onChange}
/>
) : (
<LedgerDetailTable
columns={[
{ key: 'reason', title: reasonLabel },
{ key: 'amount', title: '金额', align: 'right' },
{ key: 'remark', title: '备注' },
]}
columns={readColumns}
rows={tableRows}
emptyText="暂无明细"
/>
@@ -106,7 +179,7 @@ export function LedgerLineItemsCell({
title={title}
triggerClassName={canEdit ? 'ldb-line-items-trigger--editable' : ''}
>
{editor}
{content}
</LedgerDetailPopover>
);
}

View File

@@ -87,6 +87,7 @@ export function MaintenanceLinkModal({
<td className="ldb-col-check">
<input
type="checkbox"
className="vm-checkbox"
checked={selected.has(item.id)}
onChange={() => toggle(item.id)}
aria-label={`选择 ${item.orderNo}`}

View File

@@ -127,6 +127,7 @@ export function ReceiptLinkModal({
<td className="ldb-col-check">
<input
type="checkbox"
className="vm-checkbox"
checked={checked}
onChange={() => toggle(item.id, item.amount)}
aria-label={`选择到账记录 ${item.arrivalTime}`}

View File

@@ -1,5 +1,7 @@
import type { BillArchiveStatus, LeaseDetailStatus } from '../types';
export const LEFT_STICKY_LAST_COLUMN_KEY = 'customerName';
/** 宽表列定义(对齐 Excel「租赁车辆收入明细表」 */
export interface LeaseTableColumn {
key: string;
@@ -21,7 +23,7 @@ export interface LeaseTableColumn {
| 'invoice-date' | 'vehicle-standard-cost' | 'vehicle-actual-cost'
| 'insurance-readonly' | 'detail-popover-hydrogen' | 'detail-popover-maintenance-cost'
| 'brokerage-fee' | 'other-cost' | 'gps-mileage'
| 'deduction-detail' | 'remark-text' | 'year-month' | 'text' | 'maintainer-time' | 'value-added-service' | 'archive-status';
| 'deduction-detail' | 'remark-text' | 'year-month' | 'text' | 'maintainer-time' | 'value-added-service' | 'exemption-policy' | 'archive-status';
}
export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [
@@ -43,7 +45,7 @@ export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [
annotationId: 'lbl-col-value-added-service',
sortable: false,
},
{ key: 'exemptionPolicy', title: '享免政策', sortable: false },
{ key: 'exemptionPolicy', title: '享免政策', render: 'exemption-policy', annotationId: 'lbl-col-exemption-policy', sortable: false },
{ key: 'pickupDate', title: '提车日期', date: true, annotationId: 'lbl-col-pickup-date' },
{
key: 'contractStartDate',

View File

@@ -0,0 +1,26 @@
[
{
"id": "mlr1",
"plateNo": "沪A32896F",
"orderNo": "BY202601003",
"type": "保养",
"repairDate": "2026-01-07",
"totalCost": 450
},
{
"id": "mlr2",
"plateNo": "沪A65889F",
"orderNo": "WX202601022",
"type": "维修",
"repairDate": "2026-01-16",
"totalCost": 1250
},
{
"id": "mlr3",
"plateNo": "浙F08991F",
"orderNo": "BY202601011",
"type": "保养",
"repairDate": "2026-01-09",
"totalCost": 560
}
]

View File

@@ -0,0 +1,23 @@
[
{
"id": "rsf1",
"plateNo": "浙F05580F",
"feeItem": "未结算维修",
"amount": 860,
"settlementDate": "2026-01-12"
},
{
"id": "rsf2",
"plateNo": "浙F05580F",
"feeItem": "未结算保养",
"amount": 420,
"settlementDate": "2026-01-18"
},
{
"id": "rsf3",
"plateNo": "沪A50990F",
"feeItem": "未结算维修",
"amount": 1500,
"settlementDate": "2026-01-20"
}
]

View File

@@ -15,7 +15,7 @@
"customerName": "安徽驰远供应链管理有限公司",
"projectName": "安徽驰远供应链管租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安18T里程优惠政策",
"pickupDate": "2024-11-30",
"contractStartDate": "2025-12-01",
"contractEndDate": "2026-11-30",
@@ -88,7 +88,7 @@
"customerName": "安徽驰远供应链管理有限公司",
"projectName": "安徽驰远供应链管租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安18T里程优惠政策",
"pickupDate": "2024-11-30",
"contractStartDate": "2025-12-01",
"contractEndDate": "2026-11-30",
@@ -164,7 +164,7 @@
"customerName": "安徽驰远供应链管理有限公司",
"projectName": "安徽驰远供应链管租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安18T里程优惠政策",
"pickupDate": "2024-08-20",
"contractStartDate": "2025-09-01",
"contractEndDate": "2026-08-31",
@@ -231,7 +231,7 @@
"customerName": "嘉兴港区韵达快递有限公司",
"projectName": "嘉兴港区韵达快递租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安4.5T里程优惠政策",
"pickupDate": "2024-10-29",
"contractStartDate": "2025-11-01",
"contractEndDate": "2026-10-31",
@@ -298,7 +298,7 @@
"customerName": "嘉兴港区韵达快递有限公司",
"projectName": "嘉兴港区韵达快递租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安18T里程优惠政策",
"pickupDate": "2024-12-03",
"contractStartDate": "2025-12-03",
"contractEndDate": "2026-12-02",
@@ -367,7 +367,7 @@
"customerName": "上海利合供应链管理有限公司",
"projectName": "上海利合供应链管租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安18T里程优惠政策",
"pickupDate": "2024-03-01",
"contractStartDate": "2025-03-01",
"contractEndDate": "2026-02-28",
@@ -435,7 +435,7 @@
"customerName": "上海朔宇物流有限公司",
"projectName": "上海朔宇物流租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安18T里程优惠政策",
"pickupDate": "2025-09-26",
"contractStartDate": "2025-10-01",
"contractEndDate": "2026-09-30",
@@ -507,7 +507,7 @@
"customerName": "苏州望亭远方物流有限公司",
"projectName": "苏州望亭远方物流租赁项目",
"valueAddedService": "",
"exemptionPolicy": "",
"exemptionPolicy": "帕力安18T里程优惠政策",
"pickupDate": "2025-08-21",
"contractStartDate": "2025-09-01",
"contractEndDate": "2026-08-31",

View File

@@ -14,7 +14,8 @@ import {
} from '@axhub/annotation';
import seedRows from './data/rows.json';
import seedReceipts from './data/receipts.json';
import seedMaintenance from './data/maintenance-orders.json';
import seedReturnSettlementFees from './data/return-settlement-fees.json';
import seedMaintenanceLedger from './data/maintenance-ledger-records.json';
import seedBiMileage from './data/bi-mileage.json';
import seedHydrogenFees from './data/hydrogen-fees.json';
import { FilterPanel } from './components/FilterPanel';
@@ -29,8 +30,9 @@ import {
type LeaseLedgerFilters,
type LeaseLedgerRow,
type ListViewMode,
type MaintenanceOrder,
type MaintenanceLedgerRecord,
type ReceiptRecord,
type ReturnSettlementFeeRecord,
} from './types';
import {
applyLeaseFilters,
@@ -42,9 +44,18 @@ import {
CURRENT_STAFF_USER,
sumLeaseKpi,
} from './utils/ledger';
import { LeaseLedgerImportModal } from './components/LeaseLedgerImportModal';
import {
applyLedgerImportToRow,
createDraftLedgerRowFromImport,
downloadLedgerBatchImportTemplate,
matchLedgerImportRow,
parseLedgerBatchImportFile,
} from './utils/ledger-batch-import';
import annotationSourceDocument from './annotation-source.json';
const MAINTENANCE_ORDERS = seedMaintenance as MaintenanceOrder[];
const RETURN_SETTLEMENT_FEES = seedReturnSettlementFees as ReturnSettlementFeeRecord[];
const MAINTENANCE_LEDGER_RECORDS = seedMaintenanceLedger as MaintenanceLedgerRecord[];
const BI_MILEAGE = seedBiMileage as BiMileageRecord[];
const HYDROGEN_FEES = seedHydrogenFees as HydrogenFeeRecord[];
@@ -55,7 +66,8 @@ function formatMaintainerTime(date = new Date()): string {
function mapRows(rows: LeaseLedgerRow[], receipts: ReceiptRecord[]): LeaseLedgerRow[] {
return rows.map((row) => enrichLeaseRow(row, {
maintenanceOrders: MAINTENANCE_ORDERS,
returnSettlementFees: RETURN_SETTLEMENT_FEES,
maintenanceLedgerRecords: MAINTENANCE_LEDGER_RECORDS,
biMileage: BI_MILEAGE,
hydrogenFees: HYDROGEN_FEES,
receipts,
@@ -76,6 +88,7 @@ export default function LeaseBusinessLedgerApp() {
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [tableLoading, setTableLoading] = useState(false);
const [editingRowId, setEditingRowId] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const showToast = useCallback((msg: string) => {
setToast(msg);
@@ -91,6 +104,12 @@ export default function LeaseBusinessLedgerApp() {
() => applyListViewFilter(filtered, { showUnpaidOnly, listViewMode }),
[filtered, showUnpaidOnly, listViewMode],
);
const toolbarListCount = useMemo(() => {
if (showUnpaidOnly) {
return filtered.filter((row) => row.status !== '已收款').length;
}
return filtered.length;
}, [filtered, showUnpaidOnly]);
const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]);
const totalPages = Math.max(1, Math.ceil(listFiltered.length / pageSize));
const pagedRecords = useMemo(() => {
@@ -256,6 +275,44 @@ export default function LeaseBusinessLedgerApp() {
));
};
const handleExemptionPolicyChange = (recordId: string, value: string) => {
setRecords((prev) => mapRows(
prev.map((row) => (row.id === recordId ? { ...row, exemptionPolicy: value } : row)),
receipts,
));
};
const handleBatchImport = async (file: File) => {
const text = await file.text();
const importRows = parseLedgerBatchImportFile(text);
if (importRows.length === 0) {
showToast('未识别到有效数据,请使用模板填写必填项及需导入的字段');
return;
}
let updated = 0;
let created = 0;
setRecords((prev) => {
const next = [...prev];
importRows.forEach((importRow, index) => {
const hitIndex = next.findIndex((row) => matchLedgerImportRow(row, importRow));
if (hitIndex >= 0) {
next[hitIndex] = applyLedgerImportToRow(next[hitIndex], importRow);
updated += 1;
return;
}
next.push(createDraftLedgerRowFromImport(importRow, index));
created += 1;
});
return mapRows(next, receipts);
});
setImportOpen(false);
const parts: string[] = [];
if (updated > 0) parts.push(`更新 ${updated}`);
if (created > 0) parts.push(`新增 ${created}`);
showToast(`导入完成:${parts.join('')};自动计算字段已重新汇总`);
};
const handleListViewChange = (mode: ListViewMode) => {
setListViewMode(mode);
setPage(1);
@@ -295,20 +352,32 @@ export default function LeaseBusinessLedgerApp() {
<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');
setPage(1);
}}
/>
</label>
<div className="ldb-toolbar-unpaid-group">
<label
className={`ldb-toolbar-unpaid-filter${showUnpaidOnly ? ' is-active' : ''}`}
aria-label="仅显示未收款数据"
>
<input
type="checkbox"
className="vm-checkbox"
checked={showUnpaidOnly}
onChange={(e) => {
setShowUnpaidOnly(e.target.checked);
setListViewMode(e.target.checked ? 'unpaid' : 'all');
setPage(1);
}}
/>
<span></span>
</label>
<span
className="ldb-toolbar-data-count"
aria-label={showUnpaidOnly ? `未收款数据 ${toolbarListCount}` : `总数据 ${toolbarListCount}`}
>
<span className="ldb-toolbar-data-count-num tabular-nums">{toolbarListCount}</span>
</span>
</div>
<div className="vm-table-actions">
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => showToast('批量导入(原型演示)')}>
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => setImportOpen(true)}>
<FileUp size={16} aria-hidden />
</button>
@@ -341,7 +410,8 @@ export default function LeaseBusinessLedgerApp() {
<LeaseLedgerTable
records={pagedRecords}
loading={tableLoading}
maintenanceOrders={MAINTENANCE_ORDERS}
returnSettlementFees={RETURN_SETTLEMENT_FEES}
maintenanceLedgerRecords={MAINTENANCE_LEDGER_RECORDS}
receipts={receipts}
hydrogenFees={HYDROGEN_FEES}
selectedIds={selectedIds}
@@ -399,21 +469,19 @@ export default function LeaseBusinessLedgerApp() {
onBrokerageFeeChange={handleBrokerageFeeChange}
onRemarkChange={handleRemarkChange}
onDeductionDetailChange={handleDeductionDetailChange}
onExemptionPolicyChange={handleExemptionPolicyChange}
/>
<div className="vm-table-footer ldb-table-footer">
<span className="ldb-table-total"> {listFiltered.length} </span>
<div className="ldb-table-footer-actions">
<TablePagination
page={Math.min(page, totalPages)}
pageSize={pageSize}
total={listFiltered.length}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
<TablePagination
page={Math.min(page, totalPages)}
pageSize={pageSize}
total={listFiltered.length}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</div>
</section>
@@ -429,6 +497,12 @@ export default function LeaseBusinessLedgerApp() {
onClose={() => setReceiptRow(null)}
onConfirm={handleReceiptConfirm}
/>
<LeaseLedgerImportModal
open={importOpen}
onClose={() => setImportOpen(false)}
onDownloadTemplate={downloadLedgerBatchImportTemplate}
onImport={handleBatchImport}
/>
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
</>
);

View File

@@ -28,6 +28,11 @@ export const FIELD_ANNOTATION_SPEC = {
formula: '按车牌匹配合同增值服务配置,多项服务分行展示。',
editable: '只读。',
},
'lbl-col-exemption-policy': {
source: '租赁合同或业务手工维护。',
formula: '手选枚举帕力安18T里程优惠政策、帕力安4.5T里程优惠政策。',
editable: '未归档/草稿状态可手选。',
},
'lbl-col-pickup-date': {
source: '交车管理 / 运维 E 签宝签字完成时间。',
formula: '取该车辆实际交车办结时间。',
@@ -99,9 +104,9 @@ export const FIELD_ANNOTATION_SPEC = {
editable: '关联后自动汇总,支持多选事故单。',
},
'lbl-col-ops-income': {
source: '维修/保养台账(客户承担)、账单月内该车牌完工记录。',
formula: '汇总 bearBy=客户承担 的维保金额;可关联 linkedMaintenanceIdssumMaintenanceForBill。',
editable: '关联后自动汇总;最新账单可关联记录。',
source: '还车应结款费用明细(运维相关 10 项)。',
formula: '汇总账单月内该车牌还车应结款中清洗费、未结算保养、未结算维修等 10 项费用sumReturnSettlementOpsFeeForBill。',
editable: '只读,系统自动汇总。',
},
'lbl-col-other-income': {
source: '行内其他收入明细(事由、金额、备注)。',
@@ -164,8 +169,8 @@ export const FIELD_ANNOTATION_SPEC = {
editable: '只读。',
},
'lbl-col-ops-cost': {
source: '维修/保养台账(我司承担)、账单月内该车牌。',
formula: '汇总 bearBy=我司承担 的维保金额sumMaintenanceForBill。',
source: '维修保养明细台账。',
formula: '汇总账单月内该车牌维修保养明细「费用总计含税sumMaintenanceLedgerOpsFeeCostForBill。',
editable: '只读。',
},
'lbl-col-brokerage-fee': {

View File

@@ -104,20 +104,93 @@
justify-content: space-between;
}
.ldb-toolbar-unpaid-group {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ldb-toolbar-unpaid-filter {
display: inline-flex;
align-items: center;
gap: 8px;
gap: 10px;
min-height: 36px;
padding: 0 14px 0 10px;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.2;
color: var(--ln-ink);
background: linear-gradient(180deg, var(--ln-surface-card) 0%, color-mix(in srgb, var(--ln-canvas-soft) 35%, var(--ln-surface-card)) 100%);
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-pill);
box-shadow: var(--ln-shadow-soft);
cursor: pointer;
user-select: none;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease,
color 0.18s ease,
transform 0.12s ease;
}
.ldb-toolbar-unpaid-filter input {
width: 16px;
height: 16px;
cursor: pointer;
.ldb-toolbar-unpaid-filter:hover {
border-color: color-mix(in srgb, var(--ln-primary) 42%, var(--ln-hairline));
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.07);
}
.ldb-toolbar-unpaid-filter:active {
transform: translateY(1px);
box-shadow: var(--ln-shadow-soft);
}
.ldb-toolbar-unpaid-filter.is-active {
border-color: var(--ln-primary);
background: color-mix(in srgb, var(--ln-primary) 9%, var(--ln-surface-card));
color: var(--ln-primary-focus);
font-weight: 600;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--ln-primary) 20%, transparent),
0 2px 10px color-mix(in srgb, var(--ln-primary) 12%, transparent);
}
.ldb-toolbar-unpaid-filter.is-active:hover {
border-color: var(--ln-primary-focus);
background: color-mix(in srgb, var(--ln-primary) 12%, var(--ln-surface-card));
}
.ldb-toolbar-unpaid-filter:focus-within {
outline: 2px solid color-mix(in srgb, var(--ln-primary) 35%, transparent);
outline-offset: 2px;
}
.ldb-toolbar-data-count {
display: inline-flex;
align-items: center;
gap: 2px;
min-height: 28px;
padding: 4px 10px;
font-size: 0.8125rem;
color: var(--ln-muted);
background: color-mix(in srgb, var(--ln-primary) 7%, var(--ln-surface-muted, #f1f5f9));
border-radius: var(--ln-radius-pill);
white-space: nowrap;
}
.ldb-toolbar-data-count-num {
font-weight: 700;
color: var(--ln-primary);
}
@media (prefers-reduced-motion: reduce) {
.ldb-toolbar-unpaid-filter {
transition: none;
}
.ldb-toolbar-unpaid-filter:active {
transform: none;
}
}
.ldb-kpi-card--clickable {
@@ -191,8 +264,7 @@
white-space: nowrap;
}
.ldb-col-check { width: 48px; }
.ldb-col-money { text-align: right; white-space: nowrap; }
.ldb-col-money { text-align: left; 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: 168px; white-space: nowrap; }
@@ -418,7 +490,7 @@
.ldb-table .vm-table thead th.ldb-col-money,
.ldb-table .vm-table tbody td.ldb-col-money {
text-align: right;
text-align: left;
}
.ldb-link-preview {
@@ -434,7 +506,7 @@
.ldb-income-cell {
display: inline-flex;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
gap: 6px;
width: 100%;
}
@@ -468,7 +540,7 @@
max-width: 120px;
padding: 4px 8px;
font-size: 0.8125rem;
text-align: right;
text-align: left;
}
.ldb-computed-val {
@@ -567,7 +639,7 @@
width: 88px;
padding: 4px 6px;
font-size: 0.8125rem;
text-align: right;
text-align: left;
}
.ldb-col-unreceived-warn {
@@ -580,21 +652,16 @@
line-height: 1.45;
}
.ldb-detail-popover-wrap {
position: relative;
display: inline-flex;
justify-content: flex-end;
width: 100%;
}
.ldb-detail-popover-trigger {
display: inline-block;
max-width: 100%;
border: none;
background: transparent;
padding: 2px 6px;
margin: -2px -6px;
font: inherit;
color: inherit;
text-align: right;
text-align: left;
}
.ldb-cell-clickable {
@@ -623,96 +690,244 @@
color: var(--ln-ink);
}
.ldb-detail-popover-card {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 30;
min-width: 320px;
max-width: min(520px, 80vw);
padding: 12px;
border-radius: var(--ln-radius-card);
border: 1px solid var(--ln-hairline);
background: var(--ln-surface-card);
box-shadow: var(--ln-shadow-hover);
.ldb-detail-card-modal {
width: min(640px, calc(100vw - 48px));
max-height: min(560px, calc(100vh - 48px));
display: flex;
flex-direction: column;
overflow: hidden;
animation: ldb-detail-card-in 0.22s ease-out;
}
.ldb-detail-popover-title {
margin-bottom: 8px;
.ldb-detail-card-backdrop {
animation: ldb-detail-backdrop-in 0.18s ease-out;
}
@keyframes ldb-detail-card-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ldb-detail-backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ldb-detail-card-modal,
.ldb-detail-card-backdrop {
animation: none;
}
}
.ldb-detail-card-header {
flex-shrink: 0;
padding-bottom: 16px;
border-bottom: 1px solid var(--ln-hairline);
}
.ldb-detail-card-body {
flex: 1;
min-height: 0;
padding: 16px 24px 24px;
overflow: auto;
max-height: none;
}
.ldb-detail-card-summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 12px 14px;
border-radius: var(--ln-radius-control);
background: var(--ln-surface-strong);
border: 1px solid var(--ln-hairline);
}
.ldb-detail-card-summary-label {
font-size: 0.8125rem;
color: var(--ln-muted);
font-weight: 500;
}
.ldb-detail-card-summary-value {
font-size: 1.125rem;
font-weight: 600;
color: var(--ln-ink);
letter-spacing: -0.01em;
}
.ldb-detail-popover-body {
max-height: 280px;
overflow: auto;
.ldb-detail-card-content {
min-width: 0;
}
.ldb-detail-popover-footer {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--ln-hairline);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.ldb-detail-popover-empty {
margin: 0;
.ldb-detail-card-meta {
margin: 0 0 8px;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.ldb-detail-popover-table-wrap {
.ldb-detail-card-table-wrap {
overflow: auto;
max-height: min(360px, 48vh);
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-card);
}
.ldb-detail-card-footer {
flex-shrink: 0;
margin: 0;
padding: 16px 24px 20px;
border-top: 1px solid var(--ln-hairline);
}
.ldb-detail-popover-empty {
margin: 0;
padding: 28px 16px;
text-align: center;
font-size: 0.875rem;
color: var(--ln-muted);
border: 1px dashed var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-canvas-soft);
}
.ldb-detail-popover-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
font-size: 0.8125rem;
}
.ldb-detail-popover-table th,
.ldb-detail-popover-table td {
padding: 6px 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--ln-hairline);
text-align: left;
vertical-align: top;
vertical-align: middle;
}
.ldb-detail-popover-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.ldb-detail-popover-table th {
font-weight: 600;
font-size: 0.75rem;
color: var(--ln-muted);
white-space: nowrap;
background: var(--ln-canvas-soft);
}
.ldb-detail-popover-table .text-right {
.ldb-detail-popover-table tbody tr:hover {
background: rgba(37, 99, 235, 0.04);
}
.ldb-detail-popover-table tbody tr:last-child td {
border-bottom: none;
}
.ldb-detail-col-amount {
text-align: right;
font-weight: 500;
color: var(--ln-ink);
}
.ldb-line-items-table-wrap {
max-height: min(400px, 52vh);
}
.ldb-line-items-table th.ldb-line-items-col-action,
.ldb-line-items-table td.ldb-line-items-col-action {
width: 56px;
text-align: center;
padding-left: 8px;
padding-right: 8px;
}
.ldb-line-items-table td.ldb-detail-col-amount {
text-align: right;
}
.ldb-line-items-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.ldb-line-items-row {
display: grid;
grid-template-columns: 1.2fr 0.8fr 1fr;
gap: 6px;
}
.ldb-line-items-input {
padding: 4px 6px;
font-size: 0.75rem;
width: 100%;
min-height: 36px;
padding: 6px 10px;
font-size: 0.8125rem;
border-radius: var(--ln-radius-control);
}
.ldb-line-items-input--amount {
text-align: right;
}
.ldb-line-items-input:focus {
outline: none;
border-color: var(--vm-focus-border-color, #2563eb);
}
.ldb-line-items-empty-cell {
padding: 24px 12px !important;
text-align: center;
color: var(--ln-muted);
font-size: 0.875rem;
}
.ldb-line-items-row-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--ln-radius-control);
background: transparent;
color: var(--ln-muted);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.ldb-line-items-row-delete:hover {
color: var(--ln-error, #dc2626);
background: #fef2f2;
border-color: #fecaca;
}
.ldb-line-items-row-delete:focus-visible {
outline: none;
border-color: var(--vm-focus-border-color, #2563eb);
}
.ldb-line-items-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.ldb-import-modal {
width: min(640px, calc(100vw - 48px));
}
.ldb-line-items-add {
align-self: flex-start;
padding: 4px 8px;
font-size: 0.75rem;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 0.8125rem;
}
.ldb-action-btn--history {

View File

@@ -5,6 +5,8 @@ export type ListViewMode = 'all' | 'unpaid';
export type MaintenanceBearBy = '客户承担' | '我司承担';
export type ContractEndMode = 'term' | 'date';
export type { ExemptionPolicy } from './utils/constants';
export interface LedgerLineItem {
reason: string;
amount: number;
@@ -103,7 +105,7 @@ export interface LeaseLedgerFilters {
month: string;
dept: string;
salesman: string;
plateNo: string;
plateNos: string[];
customerName: string;
status: string;
paymentFrom: string;
@@ -115,7 +117,7 @@ export const EMPTY_LEASE_FILTERS: LeaseLedgerFilters = {
month: '',
dept: '',
salesman: '',
plateNo: '',
plateNos: [],
customerName: '',
status: '',
paymentFrom: '',
@@ -171,6 +173,25 @@ export interface MaintenanceOrder {
completedAt: string;
}
/** 还车应结款费用明细(原型种子) */
export interface ReturnSettlementFeeRecord {
id: string;
plateNo: string;
feeItem: string;
amount: number;
settlementDate: string;
}
/** 维修保养明细台账(原型种子) */
export interface MaintenanceLedgerRecord {
id: string;
plateNo: string;
orderNo: string;
type: string;
repairDate: string;
totalCost: number;
}
export interface AccidentRecord {
id: string;
plateNo: string;

View File

@@ -0,0 +1,23 @@
/** 享免政策枚举(手选) */
export const EXEMPTION_POLICY_OPTIONS = [
'帕力安18T里程优惠政策',
'帕力安4.5T里程优惠政策',
] as const;
export type ExemptionPolicy = (typeof EXEMPTION_POLICY_OPTIONS)[number];
/** 还车应结款中计入收入侧运维费的费用项(与财务管理-还车应结款一致) */
export const RETURN_SETTLEMENT_OPS_FEE_ITEMS = [
'清洗费',
'未结算保养',
'未结算维修',
'车损费用',
'工具损坏丢失费用',
'证件丢失费用',
'广告损坏丢失费用',
'送车服务费',
'接车服务费',
'轮胎磨损费用',
] as const;
export type ReturnSettlementOpsFeeItem = (typeof RETURN_SETTLEMENT_OPS_FEE_ITEMS)[number];

View File

@@ -0,0 +1,385 @@
import type { BillArchiveStatus, LeaseLedgerRow } from '../types';
export interface LedgerImportRow {
year: number;
month: number;
plateNo: string;
patch: Partial<LeaseLedgerRow>;
otherIncomeAmounts: number[];
otherDeductionAmounts: number[];
}
type ImportColumnType = 'string' | 'amount' | 'integer' | 'boolean';
interface ImportScalarColumn {
header: string;
key: keyof LeaseLedgerRow;
type?: ImportColumnType;
required?: boolean;
}
const INCOME_AMOUNT_HEADERS = ['其他收入金额', '其他收入金额2', '其他收入金额3'] as const;
const DEDUCTION_AMOUNT_HEADERS = ['其他减免金额', '其他减免金额2', '其他减免金额3'] as const;
/** 可导入字段(不含系统自动计算项) */
export const LEDGER_MANUAL_IMPORT_COLUMNS: ImportScalarColumn[] = [
{ header: '年份', key: 'year', type: 'integer', required: true },
{ header: '月份', key: 'month', type: 'integer', required: true },
{ header: '车牌号码', key: 'plateNo', required: true },
{ header: '业务部门', key: 'dept' },
{ header: '业务员', key: 'salesman' },
{ header: '客户名称', key: 'customerName' },
{ header: '项目名称', key: 'projectName' },
{ header: '享免政策', key: 'exemptionPolicy' },
{ header: '提车日期', key: 'pickupDate' },
{ header: '退车日期', key: 'returnDate' },
{ header: '保证金', key: 'deposit' },
{ header: '合同标的租金', key: 'contractRent', type: 'amount' },
{ header: '付款周期月数', key: 'paymentCycleMonths', type: 'integer' },
{ header: '保险采购金额', key: 'insurancePurchaseAmount', type: 'amount' },
{ header: '保险有效天数', key: 'insuranceEffectiveDays', type: 'integer' },
{ header: '保险上浮费', key: 'insuranceSurcharge', type: 'amount' },
{ header: '氢费承担方式', key: 'hydrogenBorneBy' },
{ header: '维保包干', key: 'maintenancePackageEnabled', type: 'boolean' },
{ header: '维保包干单价', key: 'maintenanceUnitPricePerKm', type: 'amount' },
{ header: '里程数', key: 'mileage', type: 'amount' },
{ header: '里程减免单价', key: 'mileageDeductionRate', type: 'amount' },
{ header: '实收金额', key: 'receivedAmount', type: 'amount' },
{ header: '居间费', key: 'brokerageFee', type: 'amount' },
{ header: '其他', key: 'otherCost', type: 'amount' },
{ header: '其他减免金额明细内容', key: 'otherDeductionDetail' },
{ header: '备注', key: 'remark' },
{ header: '账单状态', key: 'archiveStatus' },
{ header: '资产归属', key: 'assetOwnership' },
];
const TEMPLATE_SAMPLE: Record<string, string> = {
: '2026',
: '1',
: '沪A61121F',
: '业务二部',
: '刘念念',
: '安徽驰远供应链管理有限公司',
: '安徽驰远供应链管租赁项目',
: '帕力安18T里程优惠政策',
: '2024-11-30',
退: '',
: '/',
: '4500',
: '1',
: '12000',
: '365',
: '0',
: '我司承担',
: '否',
: '0',
: '0',
: '0.5',
: '4500',
: '120.00',
2: '',
3: '',
: '30.00',
2: '',
3: '',
: '500',
: '0',
: '',
: '',
: '草稿',
: '租赁',
};
function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (ch === '"') {
inQuotes = !inQuotes;
continue;
}
if (ch === ',' && !inQuotes) {
result.push(current.trim());
current = '';
continue;
}
current += ch;
}
result.push(current.trim());
return result;
}
function normalizeHeader(value: string): string {
return value.replace(/^\*/, '').trim();
}
function parseAmount(value: string): number {
const cleaned = value.replace(/,/g, '').trim();
if (!cleaned) return 0;
const num = Number(cleaned);
return Number.isFinite(num) ? Math.round(num * 100) / 100 : 0;
}
function parseInteger(value: string): number {
const cleaned = value.trim();
if (!cleaned) return 0;
const num = Number(cleaned);
return Number.isFinite(num) ? Math.trunc(num) : 0;
}
function parseBoolean(value: string): boolean | undefined {
const text = value.trim();
if (!text) return undefined;
if (['是', 'true', '1', '启用', 'Y', 'y'].includes(text)) return true;
if (['否', 'false', '0', '关闭', 'N', 'n'].includes(text)) return false;
return undefined;
}
function parseArchiveStatus(value: string): BillArchiveStatus | undefined {
const text = value.trim();
if (text === '草稿' || text === '未归档' || text === '已归档') return text;
return undefined;
}
function parseHydrogenBorneBy(value: string): LeaseLedgerRow['hydrogenBorneBy'] | undefined {
const text = value.trim();
if (text === '我司承担' || text === '客户承担') return text;
return undefined;
}
function parseAssetOwnership(value: string): LeaseLedgerRow['assetOwnership'] | undefined {
const text = value.trim();
if (text === '自有' || text === '租赁' || text === '挂靠') return text;
return undefined;
}
function collectAmountColumns(headers: string[], names: readonly string[]): number[] {
const cols: number[] = [];
headers.forEach((cell, index) => {
if (names.includes(cell as typeof names[number])) cols.push(index);
});
return cols;
}
function parseScalarValue(
column: ImportScalarColumn,
raw: string,
): string | number | boolean | BillArchiveStatus | LeaseLedgerRow['hydrogenBorneBy'] | LeaseLedgerRow['assetOwnership'] | undefined {
const text = raw.trim();
if (!text) return undefined;
switch (column.type) {
case 'amount':
return parseAmount(text);
case 'integer':
return parseInteger(text);
case 'boolean': {
const bool = parseBoolean(text);
return bool;
}
default:
break;
}
if (column.key === 'archiveStatus') return parseArchiveStatus(text);
if (column.key === 'hydrogenBorneBy') return parseHydrogenBorneBy(text);
if (column.key === 'assetOwnership') return parseAssetOwnership(text);
if (column.key === 'deposit') return text;
return text;
}
function buildTemplateHeaders(): string[] {
return [
...LEDGER_MANUAL_IMPORT_COLUMNS.map((col) => (col.required ? `*${col.header}` : col.header)),
...INCOME_AMOUNT_HEADERS,
...DEDUCTION_AMOUNT_HEADERS,
];
}
function buildTemplateSampleRow(): string[] {
const headers = buildTemplateHeaders().map(normalizeHeader);
return headers.map((header) => TEMPLATE_SAMPLE[header] ?? '');
}
export function lineItemsShowDetailColumns(items: { reason: string; remark: string }[]): boolean {
return items.some((item) => item.reason.trim() || item.remark.trim());
}
export function downloadLedgerBatchImportTemplate(): void {
const headers = buildTemplateHeaders();
const sample = buildTemplateSampleRow();
const csv = `\uFEFF${headers.join(',')}\n${sample.join(',')}`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = '租赁业务台账导入模板.csv';
anchor.click();
URL.revokeObjectURL(url);
}
export function parseLedgerBatchImportFile(text: string): LedgerImportRow[] {
const lines = text
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
let headerIndex = -1;
let headerCells: string[] = [];
const columnIndex = new Map<string, number>();
for (let i = 0; i < lines.length; i += 1) {
const cells = parseCsvLine(lines[i]).map(normalizeHeader);
if (cells.includes('年份') && cells.includes('月份') && (cells.includes('车牌号码') || cells.includes('车牌号'))) {
headerIndex = i;
headerCells = cells;
cells.forEach((cell, index) => columnIndex.set(cell, index));
if (!columnIndex.has('车牌号码') && columnIndex.has('车牌号')) {
columnIndex.set('车牌号码', columnIndex.get('车牌号')!);
}
break;
}
}
if (headerIndex < 0) return [];
const incomeAmountCols = collectAmountColumns(headerCells, INCOME_AMOUNT_HEADERS);
const deductionAmountCols = collectAmountColumns(headerCells, DEDUCTION_AMOUNT_HEADERS);
const rows: LedgerImportRow[] = [];
for (let i = headerIndex + 1; i < lines.length; i += 1) {
const cells = parseCsvLine(lines[i]);
const year = parseInteger(cells[columnIndex.get('年份') ?? -1] ?? '');
const month = parseInteger(cells[columnIndex.get('月份') ?? -1] ?? '');
const plateNo = (cells[columnIndex.get('车牌号码') ?? -1] ?? '').trim();
if (!year || !month || !plateNo) continue;
const patch: Partial<LeaseLedgerRow> = {};
for (const column of LEDGER_MANUAL_IMPORT_COLUMNS) {
if (column.key === 'year' || column.key === 'month' || column.key === 'plateNo') continue;
const colIdx = columnIndex.get(column.header);
if (colIdx == null) continue;
const parsed = parseScalarValue(column, cells[colIdx] ?? '');
if (parsed === undefined) continue;
(patch as Record<string, unknown>)[column.key] = parsed;
}
const otherIncomeAmounts = incomeAmountCols
.map((col) => parseAmount(cells[col] ?? ''))
.filter((amount) => amount > 0);
const otherDeductionAmounts = deductionAmountCols
.map((col) => parseAmount(cells[col] ?? ''))
.filter((amount) => amount > 0);
const hasPatch = Object.keys(patch).length > 0;
if (!hasPatch && otherIncomeAmounts.length === 0 && otherDeductionAmounts.length === 0) continue;
rows.push({ year, month, plateNo, patch, otherIncomeAmounts, otherDeductionAmounts });
}
return rows;
}
export function buildAmountOnlyLineItems(amounts: number[]): LeaseLedgerRow['otherIncomeItems'] {
return amounts.map((amount) => ({ reason: '', amount, remark: '' }));
}
export function applyLedgerImportToRow(
row: LeaseLedgerRow,
importRow: LedgerImportRow,
): LeaseLedgerRow {
return {
...row,
...importRow.patch,
...(importRow.otherIncomeAmounts.length > 0
? { otherIncomeItems: buildAmountOnlyLineItems(importRow.otherIncomeAmounts) }
: {}),
...(importRow.otherDeductionAmounts.length > 0
? { otherDeductionItems: buildAmountOnlyLineItems(importRow.otherDeductionAmounts) }
: {}),
};
}
export function createDraftLedgerRowFromImport(
importRow: LedgerImportRow,
idSuffix: number,
): LeaseLedgerRow {
const plateKey = importRow.plateNo.replace(/\s+/g, '');
const base: LeaseLedgerRow = {
id: `import-${importRow.year}-${importRow.month}-${plateKey}-${idSuffix}`,
status: '未收款',
archiveStatus: '草稿',
maintainerName: '批量导入',
year: importRow.year,
month: importRow.month,
plateNo: importRow.plateNo,
dept: '',
salesman: '',
vehicleBrand: '',
vehicleModel: '',
customerName: '',
projectName: '',
valueAddedService: '',
exemptionPolicy: '',
pickupDate: '',
contractStartDate: '',
contractEndDate: '',
periodStartDate: '',
returnDate: '',
avgDays: 0,
deposit: '/',
contractRent: 0,
paymentCycleMonths: 1,
serviceFee: 0,
maintenancePackageEnabled: false,
maintenanceUnitPricePerKm: 0,
mileage: 0,
customerBearsMaintenance: false,
linkedMaintenanceIds: [],
linkedAccidentIds: [],
linkedReceiptIds: [],
hydrogenBorneBy: '',
isLatestBill: false,
receivableTotal: 0,
receivableRent: 0,
monthlyIncome: 0,
monthlyRent: 0,
maintenanceIncome: 0,
insuranceSurcharge: 0,
opsFeeIncome: 0,
otherIncome: 0,
mileageDeduction: 0,
otherDeduction: 0,
receivedAmount: 0,
invoiceDate: '',
actualPaymentDate: '',
paymentMethod: '',
vehicleStandardCost: 0,
vehicleActualCost: 0,
insuranceFee: 0,
hydrogenFee: 0,
opsFeeCost: 0,
brokerageFee: 0,
otherCost: 0,
totalCost: 0,
profitLoss: 0,
gpsMileage: '',
otherDeductionDetail: '',
assetOwnership: '自有',
signingCompany: '',
remark: '',
};
return applyLedgerImportToRow(base, importRow);
}
export function matchLedgerImportRow(row: LeaseLedgerRow, importRow: LedgerImportRow): boolean {
return row.year === importRow.year
&& row.month === importRow.month
&& row.plateNo.replace(/\s+/g, '') === importRow.plateNo.replace(/\s+/g, '');
}

View File

@@ -9,9 +9,12 @@ import type {
LeaseKpiSummary,
ListViewMode,
MaintenanceBearBy,
MaintenanceLedgerRecord,
MaintenanceOrder,
ReceiptRecord,
ReturnSettlementFeeRecord,
} from '../types';
import { RETURN_SETTLEMENT_OPS_FEE_ITEMS } from './constants';
/** 原型:车牌主数据(导入/新增时按车牌自动匹配品牌型号) */
export const PLATE_VEHICLE_MAP: Record<string, { brand: string; model: string }> = {
@@ -376,6 +379,48 @@ export function sumMaintenanceForBill(
);
}
export function getReturnSettlementFeesForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
records: ReturnSettlementFeeRecord[],
): ReturnSettlementFeeRecord[] {
const allowedItems = new Set<string>(RETURN_SETTLEMENT_OPS_FEE_ITEMS);
return records.filter(
(item) => item.plateNo === row.plateNo
&& isInBillMonth(item.settlementDate, row.year, row.month)
&& allowedItems.has(item.feeItem),
);
}
export function sumReturnSettlementOpsFeeForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
records: ReturnSettlementFeeRecord[],
): number {
return roundMoney(
getReturnSettlementFeesForBill(row, records)
.reduce((sum, item) => sum + item.amount, 0),
);
}
export function getMaintenanceLedgerRecordsForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
records: MaintenanceLedgerRecord[],
): MaintenanceLedgerRecord[] {
return records.filter(
(item) => item.plateNo === row.plateNo
&& isInBillMonth(item.repairDate, row.year, row.month),
);
}
export function sumMaintenanceLedgerOpsFeeCostForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
records: MaintenanceLedgerRecord[],
): number {
return roundMoney(
getMaintenanceLedgerRecordsForBill(row, records)
.reduce((sum, item) => sum + item.totalCost, 0),
);
}
export function getHydrogenFeesForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
records: HydrogenFeeRecord[],
@@ -517,6 +562,8 @@ export function enrichLeaseRow(
row: LeaseLedgerRow,
options?: {
maintenanceOrders?: MaintenanceOrder[];
returnSettlementFees?: ReturnSettlementFeeRecord[];
maintenanceLedgerRecords?: MaintenanceLedgerRecord[];
accidentRecords?: AccidentRecord[];
biMileage?: BiMileageRecord[];
hydrogenFees?: HydrogenFeeRecord[];
@@ -546,7 +593,8 @@ export function enrichLeaseRow(
const linkedReceiptIds = row.linkedReceiptIds ?? [];
const hasReceipt = row.receivedAmount > 0
&& (linkedReceiptIds.length > 0 || !isBlankDisplayValue(row.actualPaymentDate));
const maintenanceOrders = options?.maintenanceOrders ?? [];
const returnSettlementFees = options?.returnSettlementFees ?? [];
const maintenanceLedgerRecords = options?.maintenanceLedgerRecords ?? [];
const accidentRecords = options?.accidentRecords ?? [];
const biMileage = options?.biMileage ?? [];
const hydrogenFees = options?.hydrogenFees ?? [];
@@ -568,8 +616,8 @@ export function enrichLeaseRow(
const otherDeduction = sumLineItems(row.otherDeductionItems) || row.otherDeduction || 0;
const maintenanceIncome = calcMaintenanceIncome(row);
const opsFeeIncome = sumMaintenanceForBill(row, maintenanceOrders, '客户承担');
const opsFeeCost = sumMaintenanceForBill(row, maintenanceOrders, '我司承担');
const opsFeeIncome = sumReturnSettlementOpsFeeForBill(row, returnSettlementFees);
const opsFeeCost = sumMaintenanceLedgerOpsFeeCostForBill(row, maintenanceLedgerRecords);
const insuranceSurcharge = linkedAccidentIds.length > 0
? roundMoney(sumLinkedAccidentSurcharge(linkedAccidentIds, accidentRecords))
: row.insuranceSurcharge;
@@ -693,7 +741,10 @@ export function applyLeaseFilters(rows: LeaseLedgerRow[], filters: LeaseLedgerFi
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.plateNos.length > 0) {
const rowPlate = row.plateNo.replace(/\s+/g, '').toUpperCase();
if (!filters.plateNos.includes(rowPlate)) 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.paymentDueDate ?? '') < filters.paymentFrom) return false;

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "lease-business-line-overview",
"pageId": "overview",
"updatedAt": 1783328981487,
"updatedAt": 1783352657924,
"nodes": [
{
"id": "lblo-system-intro",

View File

@@ -216,6 +216,8 @@ export const LEASE_MODULES: LineModule[] = [
],
closure:
'业务管理组发起还车应结款总审批;审批完成后自动归档,租赁单条业务从签约到还车全链路闭环。',
prototypeHref: '/prototypes/vehicle-return-settlement',
prototypeLabel: '打开还车应结款',
},
];
@@ -590,7 +592,7 @@ export const OPS_MODULES: LineModule[] = [
'维修与保养费用计入车辆盈亏核算,实现业财一体化。',
],
closure: '维保成本可追溯、可分摊,支撑单车盈亏与对客户计费闭环。',
prototypeHref: '/prototypes/oneos-web-ledger-data',
prototypeHref: '/prototypes/vehicle-maintenance-ledger',
prototypeLabel: '打开车辆维修明细',
},
{

View File

@@ -4,6 +4,7 @@
import LeaseContractCreate from './LeaseContractCreate.jsx';
import LeaseContractFleetSummary from './LeaseContractFleetSummary.jsx';
import { StatusTag } from '../vehicle-management/components/StatusTag';
import { TablePagination } from '../../common/TablePagination';
import {
LEASE_CONTRACT_LIST_RECORDS,
enrichLeaseContractListRecords,
@@ -391,7 +392,7 @@ const Component = function(props) {
var fleetKpiDesc = '按品牌型号统计在租车辆;点击展开下方车型卡片。隐藏面板后,再次点击将弹出各车型数量卡片。';
var listChromeOffsetPx = (_fleetExpanded[0] && !_fleetPanelHidden[0]) ? 592 : 468;
var listChromeOffsetPx = (_fleetExpanded[0] && !_fleetPanelHidden[0]) ? 558 : 434;
var filteredList = useMemo(function() {
var overrides = _recordOverrides[0] || {};
@@ -475,99 +476,6 @@ const Component = function(props) {
return filteredList.slice(start, start + _pageSize[0]);
}, [filteredList, safePage, _pageSize[0]]);
function buildPageItems(current, pages) {
if (pages <= 7) {
var all = [];
for (var pi = 1; pi <= pages; pi++) all.push(pi);
return all;
}
var items = [1];
if (current > 3) items.push('ellipsis');
var startP = Math.max(2, current - 1);
var endP = Math.min(pages - 1, current + 1);
for (var pj = startP; pj <= endP; pj++) items.push(pj);
if (current < pages - 2) items.push('ellipsis');
if (pages > 1) items.push(pages);
return items;
}
function renderTablePagination() {
if (totalCount === 0) return null;
var pageItems = buildPageItems(safePage, totalPages);
var pageSizeOptions = [10, 20, 50, 100];
return React.createElement(
'div',
{ className: 'vm-pagination', role: 'navigation', 'aria-label': '表格分页' },
React.createElement(
'label',
{ className: 'vm-pagination-size' },
React.createElement('span', { className: 'vm-pagination-size-label' }, '每页'),
React.createElement(
'select',
{
className: 'vm-pagination-select',
value: _pageSize[0],
'aria-label': '每页显示条数',
onChange: function(e) {
_pageSize[1](Number(e.target.value));
_page[1](1);
}
},
pageSizeOptions.map(function(size) {
return React.createElement('option', { key: size, value: size }, size);
})
),
React.createElement('span', { className: 'vm-pagination-size-label' }, '条')
),
React.createElement(
'div',
{ className: 'vm-pagination-pages' },
React.createElement(
'button',
{
type: 'button',
className: 'vm-pagination-nav',
disabled: safePage <= 1,
'aria-label': '上一页',
onClick: function() { _page[1](safePage - 1); }
},
React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
React.createElement('path', { d: 'm15 18-6-6 6-6' })
)
),
pageItems.map(function(item, index) {
if (item === 'ellipsis') {
return React.createElement('span', { key: 'ellipsis-' + index, className: 'vm-pagination-ellipsis', 'aria-hidden': true }, '…');
}
return React.createElement(
'button',
{
key: item,
type: 'button',
className: 'vm-pagination-page' + (safePage === item ? ' active' : ''),
'aria-current': safePage === item ? 'page' : undefined,
onClick: function() { _page[1](item); }
},
item
);
}),
React.createElement(
'button',
{
type: 'button',
className: 'vm-pagination-nav',
disabled: safePage >= totalPages,
'aria-label': '下一页',
onClick: function() { _page[1](safePage + 1); }
},
React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
React.createElement('path', { d: 'm9 18 6-6-6-6' })
)
)
)
);
}
var handleQuery = useCallback(function() {
_appliedFilter[1]({
contractCode: _contractCode[0],
@@ -2765,21 +2673,23 @@ const Component = function(props) {
style: { '--lc-list-chrome-offset': listChromeOffsetPx + 'px' },
},
React.createElement('style', null, ONEOS_ANT_TABLE_GLOBAL_FIX.join('\n')),
React.createElement('section', { className: 'vm-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' },
React.createElement('section', { className: 'vm-filter-card ldb-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' },
React.createElement('header', { className: 'vm-filter-header' },
React.createElement('h2', { className: 'vm-filter-title' }, '筛选条件')
),
React.createElement('div', { className: 'vm-filter-grid' }, filterNodes),
React.createElement('div', { className: 'vm-filter-actions' },
React.createElement('div', { className: 'ldb-filter-body' },
React.createElement('div', { className: 'vm-filter-grid ldb-filter-grid ldb-filter-grid--primary' }, filterNodes)
),
React.createElement('div', { className: 'vm-filter-actions ldb-filter-actions' },
filterItems.length > 4
? React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-link',
className: 'vm-btn vm-btn-link ldb-filter-toggle',
onClick: function() { _filterExpanded[1](!_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 },
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', onClick: handleReset }, '重置'),
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-primary ldb-toolbar-btn', onClick: handleQuery },
React.createElement('svg', {
xmlns: 'http://www.w3.org/2000/svg',
width: 16,
@@ -2934,7 +2844,18 @@ const Component = function(props) {
},
})
),
React.createElement('div', { className: 'vm-table-footer' }, renderTablePagination())
React.createElement('div', { className: 'vm-table-footer' },
React.createElement(TablePagination, {
page: safePage,
pageSize: _pageSize[0],
total: totalCount,
onPageChange: function(nextPage) { _page[1](nextPage); },
onPageSizeChange: function(size) {
_pageSize[1](size);
_page[1](1);
}
})
)
)
),
React.createElement(Modal, {

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "lease-contract-management",
"pageId": "list",
"updatedAt": 1783328981487,
"updatedAt": 1783352657925,
"nodes": [
{
"id": "lc-list-filter",

View File

@@ -111,9 +111,15 @@ function wrapClauseParagraph(html, def) {
return html.slice(0, pStart) + wrapped + html.slice(pEnd + 4);
}
function htmlHasVehicleClauseMarks(html) {
var source = String(html || '');
return source.indexOf('ct-vehicle-clause') >= 0 || source.indexOf('data-vehicle-clause') >= 0;
}
/** 为 V26.6 标准合同注入演示用条件条款包裹(正式环境由模板管理产出) */
export function injectPrototypeVehicleClauses(html) {
var next = String(html || '');
if (htmlHasVehicleClauseMarks(next)) return next;
PROTOTYPE_VEHICLE_CLAUSE_DEFS.forEach(function (def) {
next = wrapClauseParagraph(next, def);
});

View File

@@ -45,13 +45,9 @@ export function parsePlateSearchText(text) {
.filter(Boolean);
}
export var LEASE_VEHICLE_BRAND_MODEL_CATALOG = [
{ brand: '现代', models: ['帕力安牌4.5吨冷链车', '18吨氢燃料电池车', '9.6米厢式货车'] },
{ brand: '苏龙', models: ['9.6米氢燃料电池车'] },
{ brand: '飞驰', models: ['集卡头', '半挂车'] },
{ brand: '跃进', models: ['4.2米冷链车'] },
{ brand: '宇通', models: ['氢燃料电池客车'] },
];
import { getVehicleBrandModelCatalog } from '../../common/vehicle-brand-model-catalog.js';
export var LEASE_VEHICLE_BRAND_MODEL_CATALOG = getVehicleBrandModelCatalog();
export var PROVINCE_CITY_CASCADER_OPTIONS = [
{

View File

@@ -1,6 +1,7 @@
/**
* 租赁合同管理 — 模块样式(基于 vehicle-management vm-*
*/
@import url('../../ledger-shared/styles/ledger-page.css');
@import url('../../contract-template-management/styles/contract-template.css');
@import url('./lease-contract.css');
@import url('./lease-contract-create.css');

View File

@@ -81,7 +81,7 @@
/* —— 列表紧凑模式:一屏展示更多行 —— */
.vm-page.lc-page.lc-page--list-dense {
--lc-list-chrome-offset: 468px;
--lc-list-chrome-offset: 434px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 32px);
@@ -246,13 +246,13 @@
@media (max-height: 820px) {
.vm-page.lc-page.lc-page--list-dense {
--lc-list-chrome-offset: 440px;
--lc-list-chrome-offset: 406px;
}
}
@media (min-height: 960px) {
.vm-page.lc-page.lc-page--list-dense {
--lc-list-chrome-offset: 452px;
--lc-list-chrome-offset: 418px;
}
}

View File

@@ -10,6 +10,7 @@
|------|----------|----------|
| 租赁业务台账 | `vm-page ldb-page` | `lease-business-ledger/styles/index.css` |
| 自营业务台账 | `vm-page ldb-page` | 同上(复用租赁台账模块样式) |
| 车辆维修明细 | `vm-page ldb-page mldb-page` | `vehicle-maintenance-ledger/styles/index.css` |
| 租赁合同管理 | `vm-page lc-page` | `lease-contract-management/styles/lease-contract.css` |
台账页(`ldb-page`)与合同列表页(`lc-page`)在以下维度保持一致:
@@ -30,7 +31,7 @@ vm-page ldb-page
│ ├── vm-table-toolbar ldb-table-toolbar
│ └── vm-table-card
│ ├── ldb-table-wrap > ldb-table
│ └── vm-table-footer ldb-table-footer # 左侧总条数 + TablePagination见 vm-shared/DESIGN.md
│ └── vm-table-footer ldb-table-footer # TablePagination见 vm-shared/DESIGN.md
```
## 类名约定
@@ -43,8 +44,8 @@ vm-page ldb-page
## 筛选区
- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区
- 日期区间分隔符使用「**至**」(`ldb-date-range-sep`),不用 em dash
- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区(与全局 `vm-filter-expand` / `src/common/vm-filter-panel.ts` 规则一致)
- 日期区间使用 `DateRangeFilterField`(见 `vm-shared/DESIGN.md`),展示分隔符为「**至**」
- 重置 / 查询按钮加 `ldb-toolbar-btn`
## KPI
@@ -57,13 +58,16 @@ vm-page ldb-page
- 容器:`ldb-table-wrap` + `ldb-table`
- 底部分页:使用 `src/common/TablePagination.tsx`,布局见 `vm-shared/DESIGN.md`
- 表头排序:`ldb-th-sort`;列宽:`ldb-col-resize-handle`
- 金额列:`ldb-col-money` + `tabular-nums`
- 金额列:`ldb-col-money` + `tabular-nums`**与文本列统一左对齐**(含明细弹层触发按钮 `ldb-detail-popover-trigger`
- 操作列:`ldb-action-btn--edit` / `--delete`,右冻结 `sticky-col-right`
- 勾选列:左冻结 `sticky-col-left`
- 租赁业务台账:自勾选列至「客户名称」(`LEFT_STICKY_LAST_COLUMN_KEY`)均为左冻结,「增值服务」及右侧列参与横向滚动;末列冻结列加 `sticky-col-left-last` 分隔阴影,列类名 `ldb-col-check`(全局规范见 [`vm-shared/DESIGN.md`](../vm-shared/DESIGN.md) 多选框章节)
- 工具栏 / 弹窗内多选:为 `input` 添加 `vm-checkbox` 类名
## 共享样式文件
- 页面壳层:`ledger-shared/styles/ledger-page.css`
- 台账组件样式:`lease-business-ledger/styles/index.css`import 上述壳层)
- 多选框:`src/common/vm-checkbox.css`(由 `vehicle-management/style.css` 全局引入)
新增台账类页面应直接复用 `ldb-page` 与现有组件,避免再建模块专属页面类(如 `sob-page`)。

View File

@@ -10,7 +10,27 @@
padding-bottom: 48px;
}
.vm-page.ldb-page .vm-filter-card.ldb-filter-card {
.vm-page.ldb-page .ldb-page-header,
.vm-page.lc-page .lc-page-header {
margin-bottom: 16px;
}
.vm-page.ldb-page .ldb-page-header h1,
.vm-page.lc-page .lc-page-header h1 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--ln-ink);
letter-spacing: -0.02em;
}
.vm-page.ldb-page .vm-filter-card.ldb-filter-card,
.vm-page.lc-page .vm-filter-card.ldb-filter-card {
margin-bottom: 16px;
}
.vm-page.ldb-page .vm-filter-card.ldb-filter-card .vm-filter-header,
.vm-page.lc-page .vm-filter-card.ldb-filter-card .vm-filter-header {
margin-bottom: 16px;
}
@@ -78,7 +98,30 @@
position: sticky;
left: 0;
z-index: 2;
box-shadow: 4px 0 8px -4px rgba(15, 23, 42, 0.06);
box-shadow: none;
}
.ldb-table .sticky-col-left-last {
box-shadow: 4px 0 8px -4px rgba(15, 23, 42, 0.08);
}
.ldb-table .sticky-col-left-last::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: -12px;
width: 12px;
pointer-events: none;
background: linear-gradient(to right, var(--ln-surface-card) 40%, transparent);
}
.ldb-table thead th.sticky-col-left-last::after {
background: linear-gradient(to right, var(--ln-canvas-soft) 40%, transparent);
}
.ldb-table tbody tr:hover td.sticky-col-left-last::after {
background: linear-gradient(to right, var(--ln-canvas-soft) 40%, transparent);
}
.ldb-table .sticky-col-right {
@@ -119,7 +162,7 @@
.ldb-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 16px;
flex-wrap: wrap;
}
@@ -154,24 +197,86 @@
color: var(--ln-muted);
}
.ldb-filter-expand.is-expanded .ldb-filter-expand-inner {
overflow: visible;
}
.ldb-filter-body {
display: flex;
flex-direction: column;
gap: 0;
overflow: visible;
}
.vm-page.ldb-page .vm-filter-card.ldb-filter-card {
overflow: visible;
}
.vm-page.ldb-page .vm-filter-grid.ldb-filter-grid--primary {
margin-bottom: 0;
}
.ldb-filter-expand {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease, opacity 0.22s ease;
transition: grid-template-rows 0.28s ease, opacity 0.22s ease, margin-top 0.22s ease;
opacity: 0;
margin-top: 0;
pointer-events: none;
visibility: hidden;
}
.ldb-filter-expand.is-expanded {
grid-template-rows: 1fr;
opacity: 1;
margin-top: 12px;
pointer-events: auto;
visibility: visible;
}
.ldb-filter-expand-inner {
overflow: hidden;
min-height: 0;
}
.ldb-filter-expand-grid {
padding-top: 4px;
padding-top: 0;
}
.ldb-filter-actions {
margin-top: 16px;
}
.ldb-filter-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ldb-filter-toggle-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--ln-primary);
color: #fff;
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.ldb-filter-expand {
transition: none;
}
.ldb-filter-toggle-icon {
transition: none;
}
}
.ldb-filter-toggle-icon {

View File

@@ -1,32 +1,26 @@
/**
* @name 财务管理
* 自 ONE-OS web端 原稿复刻(10 个页面)
* 自 ONE-OS web端 原稿复刻(7 个页面)
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import React from 'react';
import Page1 from './pages/01-还车应结款-查看.jsx';
import Page2 from './pages/02-还车应款-费用明细.jsx';
import Page3 from './pages/03-还车应结款.jsx';
import Page4 from './pages/04-提车收款单-编辑.jsx';
import Page5 from './pages/05-提车应收款-查看.jsx';
import Page6 from './pages/06-提车应收款-开票信息.jsx';
import Page7 from './pages/07-提车应收款-审核.jsx';
import Page8 from './pages/08-提车应收款-提车收款单.jsx';
import Page9 from './pages/09-提车应收款.jsx';
import Page10 from './pages/10-租赁账单.jsx';
import Page1 from './pages/04-提车收款单-编辑.jsx';
import Page2 from './pages/05-提车应款-查看.jsx';
import Page3 from './pages/06-提车应收款-开票信息.jsx';
import Page4 from './pages/07-提车收款-审核.jsx';
import Page5 from './pages/08-提车应收款-提车收款单.jsx';
import Page6 from './pages/09-提车应收款.jsx';
import Page7 from './pages/10-租赁账单.jsx';
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
const pages = [
{ id: 'u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e-u', title: '还车应结款-查看', component: Page1 },
{ id: 'u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e-u-2', title: '车应款-费用明细', component: Page2 },
{ id: 'u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e', title: '车应结款', component: Page3 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u6536u6b3eu5355-u', title: '提车收款单-编辑', component: Page4 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u', title: '提车应收款-查看', component: Page5 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-6', title: '提车应收款-开票信息', component: Page6 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-7', title: '提车应收款-审核', component: Page7 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-8', title: '提车应收款-提车收款单', component: Page8 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e', title: '提车应收款', component: Page9 },
{ id: 'u8d22u52a1u7ba1u7406-u79dfu8d41u8d26u5355', title: '租赁账单', component: Page10 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u6536u6b3eu5355-u', title: '提车收款单-编辑', component: Page1 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u', title: '车应款-查看', component: Page2 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-6', title: '车应收款-开票信息', component: Page3 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-7', title: '提车收款-审核', component: Page4 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-8', title: '提车应收款-提车收款单', component: Page5 },
{ id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e', title: '提车应收款', component: Page6 },
{ id: 'u8d22u52a1u7ba1u7406-u79dfu8d41u8d26u5355', title: '租赁账单', component: Page7 },
];
export default function OneosWebFinance() {
@@ -34,7 +28,7 @@ export default function OneosWebFinance() {
<OneosWebLegacyShell
moduleTitle="财务管理"
pages={pages}
defaultPageId="u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e-u"
defaultPageId="u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e"
/>
);
}

View File

@@ -0,0 +1,206 @@
# 加氢站管理 · 站点信息 — 预付余额预警 / 已欠费 KPI 交互说明
| 项 | 说明 |
|---|---|
| 所属模块 | 加氢站管理 → 站点信息 |
| 关联页面 | `/prototypes/oneos-web-h2-station-site` |
| 目标用户 | 加氢站运营、财务结算 |
| 关联能力 | 余额提醒设置、发起充值单、预付余额下钻 |
---
## 1. 业务背景
运维与财务需要**快速发现预付余额不足或已欠费的加氢站**,并批量发起充值,避免氢费结算中断。列表页通过两张 KPI 卡片汇总风险站点,点击后进入专项处理流程,而不是像「全部 / 无加氢」那样仅筛选主列表。
**与列表「加氢量」列高频/低频标签的区别**
| 维度 | KPI预付余额预警 / 已欠费 | 列表列:加氢量旁高频/低频 |
|---|---|---|
| 依据 | 预付余额与提醒阈值 | 加氢次数(来自氢费明细) |
| 点击行为 | 打开站点列表弹窗 → 可批量充值 | 无 KPI 级交互;加氢量可下钻明细 |
| 业务目的 | 资金风险预警与充值 | 运营活跃度展示 |
---
## 2. KPI 统计口径
两张卡片与主列表**并行展示**,统计基于当前台账内全部站点(不受列表筛选影响,但随站点数据实时重算)。
| KPI 卡片 | 计入规则 | 不计入 |
|---|---|---|
| **预付余额预警站点** | 已设置 `balanceAlertThreshold`> 0`prepaidBalance ≥ 0`,且 `prepaidBalance < balanceAlertThreshold` | 未设阈值;已欠费(余额 < 0 |
| **已欠费站点** | `prepaidBalance < 0` | 余额 ≥ 0 |
**互斥关系**:同一站点若已欠费,只计入「已欠费」,不再计入「预付余额预警」。
**阈值来源**(影响预警站点数量):
| 入口 | 行为 |
|---|---|
| 工具栏 → **全站余额提醒设置** | 将同一阈值批量写入全部站点 |
| 行内更多 → **余额提醒设置** | 仅更新当前站点阈值 |
---
## 3. 与其他 KPI 的交互差异
| KPI 类型 | 点击后行为 | 选中态(绿色描边) |
|---|---|---|
| 全部加氢站 / 无加氢站点 | 筛选主列表,回到第 1 页;与签约筛选互斥 | `categoryTab` 匹配且未选签约筛选 |
| 签约站点 / 普通站点 | 快捷签约筛选,重置 KPI 为「全部」 | `appliedFilters.signed` 匹配 |
| **预付余额预警 / 已欠费** | **打开站点列表弹窗**,不筛选主列表 | 弹窗打开且 `type` 与卡片一致 |
点击预警/欠费 KPI 时:
- **不**修改主列表筛选条件
- **不**切换 `categoryTab` 为列表筛选项
- 弹窗打开期间,对应 KPI 卡片保持选中高亮
---
## 4. 点击 KPI → 站点列表弹窗
### 4.1 触发
用户点击「预付余额预警站点」或「已欠费站点」任一张 KPI 卡片。
系统按口径从当前台账筛选站点,打开居中弹窗:
| 属性 | 说明 |
|---|---|
| 标题 | 「预付余额预警站点」或「已欠费站点」 |
| 宽度 | 约 1200px |
| 数据范围 | 符合该 KPI 口径的全部站点(非仅当前页) |
### 4.2 弹窗内容
**顶部摘要区**
- 标题与卡片名称一致
- 副文案:`共 N 个站点 · 字段与列表页一致`
- 主操作按钮:**一键发起充值单**(站点数为 0 时禁用)
**站点表格**
- 列与主列表一致(**不含操作列**):加氢站名称、合作时间、营业状态、营业时间、当前成本价格、加氢次数、加氢量、预付余额、联系方式等
- 预付余额列:负值标红并显示「已欠费」(与主列表一致)
- 超过 8 条时分页,每页 8 条
- 无数据时展示「暂无站点」
**底部按钮**
| 按钮 | 行为 |
|---|---|
| 关闭 | 关闭弹窗,清除 KPI 选中态 |
| 一键发起充值单 | 见下文第 5 节 |
---
## 5. 一键发起充值单
### 5.1 流程
```mermaid
flowchart TD
A[点击 KPI 卡片] --> B[打开站点列表弹窗]
B --> C{用户操作}
C -->|关闭| D[结束]
C -->|一键发起充值单| E[关闭弹窗]
E --> F[打开发起充值单弹窗]
F --> G[预填弹窗内全部站点行]
G --> H[用户填写付款金额]
H --> I[发起付款流程]
```
### 5.2 预填规则
关闭站点列表弹窗后,打开「发起充值单」弹窗,并为弹窗内**每一个站点**自动生成一行:
| 预填字段 | 来源 |
|---|---|
| 站点 | 站点名称(不可为空) |
| 当前余额 | 站点 `prepaidBalance` 格式化展示 |
| 收款公司 / 银行账号 / 开户行 | 站点关联供应商付款信息 |
| 转账用途 | 默认「羚牛预付氢费款 YYYY-MM-DD」 |
| 付款金额 | **留空**,由用户填写 |
**提示文案**
- 预警站点:`已载入 N 个预警站点,请填写付款金额后发起`
- 欠费站点:`已载入 N 个欠费站点,请填写付款金额后发起`
### 5.3 提交校验
与工具栏「发起充值单」一致:
- 每行须选择站点并填写付款金额
- 站点须已关联供应商且收款信息完整;否则提示补全后再发起
- 原型环境:校验通过后提示「已发起审批流程」
### 5.4 与其他充值入口的关系
| 入口 | 预填站点 |
|---|---|
| 工具栏 → 发起充值单 | 空白一行,用户自选 |
| KPI 弹窗 → 一键发起充值单 | 预填当前弹窗内全部站点 |
| 行内更多(无批量) | — |
---
## 6. 端到端场景示例
### 场景 A日常巡检余额
1. 财务打开站点信息页,看到 KPI「预付余额预警站点 = 1」
2. 点击该卡片,弹窗列出余额低于阈值但仍 ≥ 0 的站点
3. 确认后点击「一键发起充值单」,填写各站付款金额并提交
### 场景 B处理欠费
1. KPI「已欠费站点」显示 1列表中对应行预付余额为红色「已欠费 -3,250.00」
2. 点击 KPI弹窗仅展示欠费站点
3. 一键发起充值单,补足欠费金额
### 场景 C配置阈值后统计变化
1. 工具栏「全站余额提醒设置」统一设为 50,000 元
2. KPI 预警数量随 `prepaidBalance` 与阈值比较实时更新
3. 某站余额扣减至阈值以下后自动出现在预警 KPI 中
---
## 7. 异常与边界
| 场景 | 预期行为 |
|---|---|
| KPI 数值为 0 | 可点击;弹窗内表格为空,「一键发起充值单」禁用 |
| 弹窗内点击关闭 | 弹窗关闭KPI 选中态取消 |
| 站点未设阈值 | 不计入预警 KPI即使余额较低 |
| 已欠费站点 | 只出现在「已欠费」,不出现在「预警」 |
| 主列表正在筛选 | 不影响 KPI 统计与弹窗站点范围(口径为全量台账) |
---
## 8. 验收清单
- [ ] 预警 KPI 仅统计:有阈值、余额 ≥ 0 且 < 阈值
- [ ] 欠费 KPI 仅统计:余额 < 0与预警互斥
- [ ] 点击预警/欠费 KPI 打开弹窗,**主列表不筛选**
- [ ] 弹窗打开时对应 KPI 卡片呈选中态
- [ ] 弹窗表格字段与主列表一致(无操作列)
- [ ] 「一键发起充值单」预填全部站点及当前余额、供应商信息
- [ ] 预填后付款金额须用户填写;提交校验与工具栏充值一致
- [ ] 全站/单站余额提醒设置保存后,预警 KPI 数量联动更新
---
## 9. 实现参考(原型)
| 能力 | 位置 |
|---|---|
| KPI 卡片配置 | `H2_STATION_KPI_CARDS` |
| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` |
| 打开弹窗 | `openAlertStationModal` |
| 批量充值 | `handleBatchRechargeFromAlert``openRechargeModal` |
| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |

View File

@@ -16,7 +16,7 @@
用户进入「加氢站管理 → 站点信息」后,列表页自上而下为:
1. **筛选区** — 名称、签约、地区、营业状态
2. **KPI 分类** — 全部 / 签约·普通 / 高频 / 低频 / 无加氢
2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢
3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点
4. **站点列表** — 主数据 + 运营指标 + 操作列
5. **分页栏** — 每页 5/10/20/50 条
@@ -50,11 +50,29 @@
| 全部加氢站 | 列表全部站点 |
| 签约站点 | 已签约,点击快捷筛选 |
| 普通站点 | 未签约,点击快捷筛选 |
| 高频加氢站 | 该站加氢次数 ≥ 3 |
| 低频加氢站 | 有加氢但次数 < 3 |
| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 |
| 已欠费站点 | 预付余额 < 0点击打开站点列表弹窗可一键发起充值单 |
| 无加氢站点 | 加氢次数 = 0 |
加氢次数来自「车辆氢费明细」共享 Store按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)
加氢次数来自「车辆氢费明细」共享 Store按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换
> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。
### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要)
这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。
| 步骤 | 用户操作 | 系统反馈 |
|---|---|---|
| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 |
| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 |
| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 |
| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) |
| — | 点击「关闭」 | 关闭弹窗KPI 选中态取消 |
**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。
**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。
---

View File

@@ -58,6 +58,7 @@
├── 查看详情
├── 营业状态 / 价格配置
├── 加氢量下钻 / 预付余额下钻
├── 预付余额预警 / 已欠费 KPI 站点列表 → 一键充值
├── 生成对账单(两阶段)
└── 查看对账记录
```
@@ -66,10 +67,11 @@
## 3. 列表与 KPI
> 详见 `.spec/requirements-prd-list.md`
> 列表筛选与字段详见 `.spec/requirements-prd-list.md`
> **预付余额预警 / 已欠费 KPI 点击交互**详见 `.spec/requirements-prd-kpi-balance-alert.md`
- 筛选:名称、签约、地区、营业状态
- KPI全部 / 签约·普通 / 高频≥3 / 低频12 / 无加氢0
- KPI全部 / 签约·普通 / **预付余额预警** / **已欠费** / 无加氢
- 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作
- 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体

View File

@@ -20,7 +20,9 @@
## KPI 口径(已确认)
全部 / 签约·普通 / **高频≥3 次)** / **低频12 次)** / **无加氢0 次)**,与《站点管理使用说明书》及页面内嵌 PRD 一致。
全部 / 签约·普通 / **预付余额预警** / **已欠费** / **无加氢0 次)**,与 legacy《站点信息-产品需求说明》一致。
预警/欠费 KPI 点击打开站点列表弹窗,支持一键发起充值单。详见 `.spec/requirements-prd-kpi-balance-alert.md`
## 角色演示

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@ const specRoot = path.join(protoRoot, '.spec');
const fullPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd.md'), 'utf8');
const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8');
const statementPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-statement.md'), 'utf8');
const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8');
const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8');
const NODE_DEFS = [
@@ -29,8 +30,8 @@ const NODE_DEFS = [
title: 'KPI 快捷筛选',
pageId: 'site-info',
color: '#2563eb',
aiPrompt: '六张 KPI 卡片口径与点击筛选。',
annotationText: '全部 / 签约·普通 / 高频 / 低频 / 无加氢。',
aiPrompt: '六张 KPI 卡片口径;预警/欠费点击打开弹窗并支持一键充值。',
annotationText: '全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢。预警与欠费点击打开站点列表弹窗。',
locator: {
selectors: ['.h2-station-page .vm-kpi-row', '[data-annotation-id="h2-site-kpi"]'],
fingerprint: 'kpi|site-info',
@@ -155,7 +156,7 @@ const overviewMd = [
'',
'## 3. 验收重点',
'',
'1. KPI 高频/低频/无加氢与氢费明细一致',
'1. KPI 预付余额预警/已欠费口径与点击弹窗、批量充值一致',
'2. 对账单两阶段流程与跨页回写',
'3. 列表布局与租赁合同 vm 规范一致',
].join('\n');
@@ -222,6 +223,13 @@ const source = {
markdown: listPrd,
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md',
},
{
type: 'markdown',
id: 'h2-site-doc-prd-kpi-balance',
title: '预付余额 KPI 交互',
markdown: kpiBalanceAlertPrd,
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md',
},
{
type: 'markdown',
id: 'h2-site-doc-prd-statement',

View File

@@ -1,6 +1,8 @@
// 【重要】必须使用 const Component 作为组件变量名
// 加氢站管理 - 站点信息(列表 + KPI 分类 + 独立新建/编辑页 + 使用说明书)
import { TablePagination, COMPACT_PAGE_SIZE_OPTIONS } from '../../../common/TablePagination';
var H2_REGION_CASCADER_OPTIONS = [
{ value: '浙江省', label: '浙江省', children: [{ value: '嘉兴市', label: '嘉兴市' }, { value: '杭州市', label: '杭州市' }, { value: '宁波市', label: '宁波市' }] },
{ value: '上海市', label: '上海市', children: [{ value: '上海市', label: '上海市' }] },
@@ -10,8 +12,8 @@ var H2_REGION_CASCADER_OPTIONS = [
var H2_STATION_KPI_CARDS = [
{ key: 'all', type: 'total', title: '全部加氢站', desc: '纳入台账管理的全部站点' },
{ key: 'high', type: 'normal', title: '高频加氢站', desc: '近周期内加氢次数 ≥ 3 次的站点' },
{ key: 'low', type: 'warning', title: '低频加氢站', desc: '近周期内有加氢但次数 < 3 次的站点' },
{ key: 'balanceAlert', type: 'warning', title: '预付余额预警站点', desc: '预付余额低于所设提醒阈值的站点' },
{ key: 'arrears', type: 'unuploaded', title: '已欠费站点', desc: '预付余额小于 0 的站点' },
{ key: 'none', type: 'unuploaded', title: '无加氢站点', desc: '暂无加氢记录的站点' }
];
@@ -2108,20 +2110,24 @@ function h2KpiCardIcon(key) {
{ tag: 'line', x1: 19, y1: 16, x2: 16, y2: 19 }
], size);
}
if (key === 'high') {
if (key === 'balanceAlert') {
return h2SvgIcon([
{ d: 'M3 22h18' },
{ d: 'M6 22V12l4-2v12' },
{ d: 'M12 22V8l4-2v16' },
{ d: 'M18 22V14l3-1.5v9.5' }
{ d: 'M12 2v4' },
{ d: 'M12 18v4' },
{ d: 'M4.93 4.93l2.83 2.83' },
{ d: 'M16.24 16.24l2.83 2.83' },
{ d: 'M2 12h4' },
{ d: 'M18 12h4' },
{ d: 'M4.93 19.07l2.83-2.83' },
{ d: 'M16.24 7.76l2.83-2.83' },
{ tag: 'circle', cx: 12, cy: 12, r: 4 }
], size);
}
if (key === 'low') {
if (key === 'arrears') {
return h2SvgIcon([
{ d: 'M3 22h18' },
{ d: 'M8 22V16l3-1.5V22' },
{ d: 'M14 22V18l3-1.5V22' },
{ tag: 'line', x1: 6, y1: 22, x2: 20, y2: 22 }
{ d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' },
{ tag: 'line', x1: 12, y1: 9, x2: 12, y2: 13 },
{ tag: 'line', x1: 12, y1: 17, x2: 12.01, y2: 17 }
], size);
}
if (key === 'none') {
@@ -5866,13 +5872,12 @@ const Component = function () {
}, [listData]);
var categoryCounts = useMemo(function () {
var counts = { all: listData.length, high: 0, low: 0, none: 0 };
var counts = { all: listData.length, balanceAlert: 0, arrears: 0, none: 0 };
listData.forEach(function (r) {
if (h2IsArrearsStation(r)) counts.arrears += 1;
else if (h2IsBalanceAlertStation(r)) counts.balanceAlert += 1;
var stats = h2CalcRefuelStats(r.name);
var freq = h2DeriveFrequencyByRefuelCount(stats.count);
if (freq === 'high') counts.high += 1;
else if (freq === 'low') counts.low += 1;
else counts.none += 1;
if (h2DeriveFrequencyByRefuelCount(stats.count) === 'none') counts.none += 1;
});
return counts;
}, [listData, ledgerDataRevision]);
@@ -5887,11 +5892,12 @@ const Component = function () {
var list = listData.slice();
if (categoryTab !== 'all') {
list = list.filter(function (r) {
var stats = h2CalcRefuelStats(r.name);
var freq = h2DeriveFrequencyByRefuelCount(stats.count);
if (categoryTab === 'high') return freq === 'high';
if (categoryTab === 'low') return freq === 'low';
if (categoryTab === 'none') return freq === 'none';
if (categoryTab === 'balanceAlert') return h2IsBalanceAlertStation(r);
if (categoryTab === 'arrears') return h2IsArrearsStation(r);
if (categoryTab === 'none') {
var stats = h2CalcRefuelStats(r.name);
return h2DeriveFrequencyByRefuelCount(stats.count) === 'none';
}
return true;
});
}
@@ -5937,86 +5943,23 @@ const Component = function () {
return sortedList.slice(start, start + pageSize);
}, [sortedList, safePage, pageSize]);
function h2BuildPageItems(current, pages) {
if (pages <= 7) {
var all = [];
var pi;
for (pi = 1; pi <= pages; pi++) all.push(pi);
return all;
}
var items = [1];
if (current > 3) items.push('ellipsis');
var startP = Math.max(2, current - 1);
var endP = Math.min(pages - 1, current + 1);
for (var pj = startP; pj <= endP; pj++) items.push(pj);
if (current < pages - 2) items.push('ellipsis');
if (pages > 1) items.push(pages);
return items;
}
var renderH2TablePagination = useCallback(function () {
if (totalCount === 0) return null;
var pageItems = h2BuildPageItems(safePage, totalPages);
var pageSizeOptions = [5, 10, 20, 50];
return React.createElement('div', { className: 'vm-pagination', role: 'navigation', 'aria-label': '表格分页' },
React.createElement('label', { className: 'vm-pagination-size' },
React.createElement('span', { className: 'vm-pagination-size-label' }, '每页'),
React.createElement('select', {
className: 'vm-pagination-select',
value: pageSize,
'aria-label': '每页显示条数',
onChange: function (e) {
setPageSize(Number(e.target.value));
setPage(1);
}
}, pageSizeOptions.map(function (size) {
return React.createElement('option', { key: size, value: size }, size);
})),
React.createElement('span', { className: 'vm-pagination-size-label' }, '条')
),
React.createElement('div', { className: 'vm-pagination-pages' },
React.createElement('button', {
type: 'button',
className: 'vm-pagination-nav',
disabled: safePage <= 1,
'aria-label': '上一页',
onClick: function () { setPage(safePage - 1); }
},
React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
React.createElement('path', { d: 'm15 18-6-6 6-6' })
)
),
pageItems.map(function (item, index) {
if (item === 'ellipsis') {
return React.createElement('span', { key: 'ellipsis-' + index, className: 'vm-pagination-ellipsis', 'aria-hidden': true }, '…');
}
return React.createElement('button', {
key: item,
type: 'button',
className: 'vm-pagination-page' + (safePage === item ? ' active' : ''),
'aria-current': safePage === item ? 'page' : undefined,
onClick: function () { setPage(item); }
}, item);
}),
React.createElement('button', {
type: 'button',
className: 'vm-pagination-nav',
disabled: safePage >= totalPages,
'aria-label': '下一页',
onClick: function () { setPage(safePage + 1); }
},
React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
React.createElement('path', { d: 'm9 18 6-6-6-6' })
)
)
),
React.createElement('div', { className: 'vm-table-total' }, '共 ' + totalCount + ' 条')
);
}, [totalCount, safePage, totalPages, pageSize]);
return React.createElement(TablePagination, {
page: safePage,
pageSize: pageSize,
total: totalCount,
pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS,
onPageChange: setPage,
onPageSizeChange: function (size) {
setPageSize(size);
setPage(1);
}
});
}, [totalCount, safePage, pageSize]);
var handleKpiCardClick = useCallback(function (key) {
setCategoryTab(key);
if (key === 'all' || key === 'high' || key === 'low' || key === 'none') {
if (key === 'all' || key === 'none') {
setListFilters(function (p) { return Object.assign({}, p, { signed: undefined }); });
setAppliedFilters(function (p) { return Object.assign({}, p, { signed: undefined }); });
}
@@ -9548,10 +9491,16 @@ const Component = function () {
});
}),
H2_STATION_KPI_CARDS.slice(1).map(function (card) {
var isAlertKpi = card.key === 'balanceAlert' || card.key === 'arrears';
return renderVmKpiCard(card, {
active: categoryTab === card.key && !appliedFilters.signed,
active: isAlertKpi
? (alertStationModal.open && alertStationModal.type === card.key)
: (categoryTab === card.key && !appliedFilters.signed),
count: categoryCounts[card.key] || 0,
onClick: function () { handleKpiCardClick(card.key); },
onClick: function () {
if (isAlertKpi) openAlertStationModal(card.key);
else handleKpiCardClick(card.key);
},
icon: h2KpiIcon(card.key)
});
})
@@ -9989,7 +9938,11 @@ const Component = function () {
open: alertStationModal.open,
onCancel: closeAlertStationModal,
footer: React.createElement(Space, null,
React.createElement(Button, { onClick: closeAlertStationModal, style: { borderRadius: 8 } }, '关闭'),
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-ghost',
onClick: closeAlertStationModal
}, '关闭'),
React.createElement(Button, {
type: 'primary',
disabled: !(alertStationModal.stations || []).length,

View File

@@ -60,7 +60,7 @@
font-weight: 600;
}
.vm-page.lc-page.h2-site-page .vm-table-total,
.vm-page.lc-page.h2-site-page .vm-pagination-total,
.vm-page.lc-page.h2-site-page .vm-pagination-size,
.vm-page.lc-page.h2-site-page .vm-pagination-nav,
.vm-page.lc-page.h2-site-page .vm-pagination-page,
@@ -69,7 +69,7 @@
font-size: 0.875rem;
}
.vm-page.lc-page.h2-site-page .vm-table-total {
.vm-page.lc-page.h2-site-page .vm-pagination-total {
color: var(--ln-muted);
}
@@ -163,12 +163,17 @@
--h2-table-scroll-x: 1624px;
}
/* 锁定表头/表体同宽,避免容器宽于列宽合计时表体被拉伸导致错位(租赁合同 scroll.x 大于容器则无此问题) */
/* 宽屏铺满容器;窄屏保持列宽合计最小值并可横向滚动 */
.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-header > table,
.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-body > table {
width: var(--h2-table-scroll-x) !important;
width: 100% !important;
min-width: var(--h2-table-scroll-x) !important;
table-layout: fixed !important;
}
.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-content > table {
width: 100% !important;
min-width: var(--h2-table-scroll-x) !important;
max-width: var(--h2-table-scroll-x) !important;
table-layout: fixed !important;
}
@@ -176,6 +181,15 @@
overflow-x: auto !important;
}
.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-container {
width: 100%;
}
.vm-page.lc-page.h2-site-page .h2-site-list-table.ant-table-wrapper,
.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-wrapper {
width: 100%;
}
.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-cell-scrollbar {
width: 0 !important;
min-width: 0 !important;

View File

@@ -1,12 +1,11 @@
/**
* @name 台账数据
* 自 ONE-OS web端 原稿复刻(5 个页面)
* 自 ONE-OS web端 原稿复刻(4 个页面;车辆维修明细已独立至 vehicle-maintenance-ledger
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import React from 'react';
import Page1 from './pages/01-保险分摊明细.jsx';
import Page2 from './pages/02-车辆氢费明细.jsx';
import Page3 from './pages/03-车辆维修明细.jsx';
import Page4 from './pages/04-氢费采购端汇总报表.jsx';
import Page5 from './pages/05-租赁业务台账.jsx';
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
@@ -14,7 +13,6 @@ import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyS
const pages = [
{ id: 'u53f0u8d26u6570u636e-u4fddu9669u5206u644au660eu7', title: '保险分摊明细', component: Page1 },
{ id: 'u53f0u8d26u6570u636e-u8f66u8f86u6c22u8d39u660eu7', title: '车辆氢费明细', component: Page2 },
{ id: 'u53f0u8d26u6570u636e-u8f66u8f86u7ef4u4feeu660eu7', title: '车辆维修明细', component: Page3 },
{ id: 'u53f0u8d26u6570u636e-u6c22u8d39u91c7u8d2du7aefu6', title: '氢费采购端汇总报表', component: Page4 },
{ id: 'u53f0u8d26u6570u636e-u79dfu8d41u4e1au52a1u53f0u8', title: '租赁业务台账', component: Page5 },
];

View File

@@ -34,18 +34,25 @@ const Component = function () {
var Title = Typography.Title;
var RangePicker = DatePicker.RangePicker;
var pageBg = '#f5f7fa';
var cardRadius = 12;
var cardShadow = '0 1px 2px rgba(0,0,0,0.04), 0 6px 16px rgba(15,23,42,0.06)';
var cardStyle = { borderRadius: cardRadius, boxShadow: cardShadow, border: '1px solid rgba(0,0,0,0.05)' };
var pageBg = '#f8fafc';
var cardRadius = 14;
var cardShadow = '0 1px 2px rgba(15,23,42,0.04), 0 8px 24px rgba(15,23,42,0.06)';
var cardStyle = {
borderRadius: cardRadius,
boxShadow: cardShadow,
border: '1px solid #e2e8f0',
background: '#ffffff'
};
var pagePadY = 16;
var pagePadX = 20;
var pagePadX = 24;
var layoutGutter = 16;
var accentBlue = '#1677ff';
var accentPurple = '#722ed1';
var accentCyan = '#13c2c2';
var accentOrange = '#fa8c16';
var accentGeekblue = '#2f54eb';
var accentBlue = '#2563eb';
var accentPurple = '#7c3aed';
var accentCyan = '#0891b2';
var accentOrange = '#f97316';
var accentGeekblue = '#1d4ed8';
var textPrimary = '#1e293b';
var textSecondary = '#64748b';
// 工作台卡片内待办 / 通知默认展示条数
var wbDashPreviewMax = 5;
@@ -77,6 +84,47 @@ const Component = function () {
var noticeModalOpen = noticeModalState[0];
var setNoticeModalOpen = noticeModalState[1];
var noticePreviewState = useState(null);
var noticePreviewRecord = noticePreviewState[0];
var setNoticePreviewRecord = noticePreviewState[1];
var quickCustomizeOpenState = useState(false);
var quickCustomizeOpen = quickCustomizeOpenState[0];
var setQuickCustomizeOpen = quickCustomizeOpenState[1];
var quickCustomDraftState = useState([]);
var quickCustomDraft = quickCustomDraftState[0];
var setQuickCustomDraft = quickCustomDraftState[1];
var QUICK_STORAGE_KEY = 'oneos-workbench-quick-v1';
function quickItemKey(it) {
if (!it) return '';
return it.action ? 'action:' + it.action : 'p:' + (it.p || it.t);
}
function loadQuickCustom() {
if (typeof window === 'undefined' || !window.localStorage) return {};
try {
var raw = window.localStorage.getItem(QUICK_STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch (_e) {
return {};
}
}
function saveQuickCustom(next) {
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.setItem(QUICK_STORAGE_KEY, JSON.stringify(next));
} catch (_e) { /* ignore */ }
}
}
var quickCustomState = useState(loadQuickCustom);
var quickCustom = quickCustomState[0];
var setQuickCustom = quickCustomState[1];
var reportTabState = useState('task');
var reportTab = reportTabState[0];
var setReportTab = reportTabState[1];
@@ -414,13 +462,48 @@ const Component = function () {
var noticeListState = useState(function () {
return [
{ id: 'n1', time: '2026-02-27 08:00', type: '商业险到期提醒', content: '「粤A12345」商业险将在2026-04-15到期请尽快处理', read: false },
{ id: 'n2', time: '2026-02-26 18:00', type: '营运证到期提醒', content: '「粤B88888」营运证还有90天到期请尽快更新营运证', read: false },
{ id: 'n3', time: '2026-02-26 10:00', type: '行驶证到期提醒', content: '「A11111」行驶证还有90天到期,请尽快进行年审', read: false },
{ id: 'n4', time: '2026-02-25 14:00', type: '租赁合同到期提醒', content: '「HT-2025-088」「某某产业园项目」还有30天到期请尽快处理', read: false },
{ id: 'n5', time: '2026-02-25 09:00', type: '租赁账单生成提醒', content: '「HT-2025-088」「某某产业园项目」「ZD-202602-031」已生成请尽快处理', read: false },
{ id: 'n6', time: '2026-02-24 11:00', type: '氢费余额不足提醒', content: '「某某物流」「华南干线项目」氢费余额已不足500元请尽快通知客户处理', read: false },
{ id: 'n7', time: '2026-02-24 08:30', type: '审批流程提醒', content: '「李四」「异动审核」审批节点已到达,请进行审批', read: false }
{
id: 'n1', time: '2026-02-27 08:00', type: '商业险到期提醒', priority: 'high',
content: '「A12345」商业险将在2026-04-15到期,请尽快处理', read: false,
detail: '车牌粤A12345 的商业险保单将于 2026-04-15 到期。请核对保单信息与承保公司,在到期前完成续保,避免车辆脱保影响运营。',
target: { label: '前往证照管理', path: 'web端/运维管理/车辆业务/证照管理.jsx' }
},
{
id: 'n2', time: '2026-02-26 18:00', type: '营运证到期提醒', priority: 'high',
content: '「粤B88888」营运证还有90天到期请尽快更新营运证', read: false,
detail: '车牌粤B88888 的营运证剩余有效期 90 天。请提前准备年审/换证材料,并安排车辆送检时间。',
target: { label: '前往证照管理', path: 'web端/运维管理/车辆业务/证照管理.jsx' }
},
{
id: 'n3', time: '2026-02-26 10:00', type: '行驶证到期提醒', priority: 'medium',
content: '「浙A11111」行驶证还有90天到期请尽快进行年审', read: false,
detail: '车牌浙A11111 行驶证将于 90 天内到期。请确认车辆检验状态,并同步更新行驶证影像与关键日期。',
target: { label: '前往证照管理', path: 'web端/运维管理/车辆业务/证照管理.jsx' }
},
{
id: 'n4', time: '2026-02-25 14:00', type: '租赁合同到期提醒', priority: 'high',
content: '「HT-2025-088」「某某产业园项目」还有30天到期请尽快处理', read: false,
detail: '合同 HT-2025-088某某产业园项目将于 30 天后到期。请与业管确认续签或还车计划,避免超期运营。',
target: { label: '查看租赁合同', path: 'web端/车辆租赁合同/车辆租赁合同.jsx' }
},
{
id: 'n5', time: '2026-02-25 09:00', type: '租赁账单生成提醒', priority: 'medium',
content: '「HT-2025-088」「某某产业园项目」「ZD-202602-031」已生成请尽快处理', read: false,
detail: '账单 ZD-202602-031 已生成,关联合同 HT-2025-088 / 某某产业园项目。请核对账期与应收金额,并跟进客户付款。',
target: { label: '查看租赁账单', path: 'web端/业务管理/租赁账单.jsx' }
},
{
id: 'n6', time: '2026-02-24 11:00', type: '氢费余额不足提醒', priority: 'high',
content: '「某某物流」「华南干线项目」氢费余额已不足500元请尽快通知客户处理', read: false,
detail: '客户某某物流(华南干线项目)氢费账户余额低于 500 元。请通知客户充值,避免加氢订单中断。',
target: { label: '查看氢费账单', path: 'web端/财务管理/氢费账单.jsx' }
},
{
id: 'n7', time: '2026-02-24 08:30', type: '审批流程提醒', priority: 'medium',
content: '「李四」「异动审核」审批节点已到达,请进行审批', read: false,
detail: '异动审核流程(申请人:李四)已流转至您的审批节点。请查看申请详情并在工作台或审批中心完成处理。',
target: { label: '前往审批中心', path: 'web端/审批中心.jsx' }
}
];
});
var noticeList = noticeListState[0];
@@ -448,11 +531,37 @@ const Component = function () {
setNoticeModalOpen(true);
}, []);
var openNoticePreview = useCallback(function (record) {
if (!record) return;
setNoticePreviewRecord(record);
}, []);
var closeNoticePreview = useCallback(function () {
setNoticePreviewRecord(null);
}, []);
var handleNoticePreviewNavigate = useCallback(function (record) {
if (!record || !record.target || !record.target.path) {
message.info('该通知暂无关联页面(原型)');
return;
}
protoNav(record.target.path);
closeNoticePreview();
}, [closeNoticePreview]);
var pushUrgeNotice = useCallback(function (taskRow) {
var adminName = mockAdminDisplayName;
var content = '' + adminName + ')已对(' + taskRow.taskName + ')进行催办,请尽快处理';
setNoticeList(function (prev) {
return [{ id: 'urge-' + Date.now(), time: formatNoticeNow(), type: '催办提醒', content: content, read: false }].concat(prev || []);
return [{
id: 'urge-' + Date.now(),
time: formatNoticeNow(),
type: '催办提醒',
priority: 'high',
content: content,
detail: '管理员 ' + adminName + ' 对任务「' + taskRow.taskName + '」发起了催办。请优先处理该待办,完成后状态将同步更新。',
read: false
}].concat(prev || []);
});
message.success('催办提醒已推送至通知列表');
}, []);
@@ -1257,6 +1366,66 @@ const Component = function () {
};
}, []);
var openQuickCustomize = useCallback(function () {
var role = quickByRole[roleTab];
var catalog = role ? role.items : [];
var saved = quickCustom[roleTab];
var keys = saved && saved.length
? saved.slice()
: catalog.map(quickItemKey);
setQuickCustomDraft(keys);
setQuickCustomizeOpen(true);
}, [quickByRole, roleTab, quickCustom]);
var toggleQuickDraftKey = useCallback(function (key, checked) {
setQuickCustomDraft(function (prev) {
var next = (prev || []).slice();
if (checked) {
if (next.indexOf(key) < 0) next.push(key);
} else {
next = next.filter(function (k) { return k !== key; });
}
return next;
});
}, []);
var moveQuickDraftKey = useCallback(function (key, dir) {
setQuickCustomDraft(function (prev) {
var next = (prev || []).slice();
var idx = next.indexOf(key);
if (idx < 0) return next;
var swap = dir === 'up' ? idx - 1 : idx + 1;
if (swap < 0 || swap >= next.length) return next;
var tmp = next[swap];
next[swap] = next[idx];
next[idx] = tmp;
return next;
});
}, []);
var saveQuickCustomize = useCallback(function () {
if (!quickCustomDraft.length) {
message.warning('请至少保留一个快速入口');
return;
}
var next = Object.assign({}, quickCustom);
next[roleTab] = quickCustomDraft.slice();
setQuickCustom(next);
saveQuickCustom(next);
setQuickCustomizeOpen(false);
message.success('快速入口已保存');
}, [quickCustom, quickCustomDraft, roleTab]);
var resetQuickCustomize = useCallback(function () {
var next = Object.assign({}, quickCustom);
delete next[roleTab];
setQuickCustom(next);
saveQuickCustom(next);
var role = quickByRole[roleTab];
setQuickCustomDraft(role ? role.items.map(quickItemKey) : []);
message.success('已恢复默认快速入口');
}, [quickByRole, quickCustom, roleTab]);
var noticeColumns = useMemo(function () {
return [
{ title: '时间', dataIndex: 'time', key: 'time', width: 150 },
@@ -1274,22 +1443,35 @@ const Component = function () {
},
{
title: '操作',
key: 'markRead',
width: 88,
key: 'actions',
width: 140,
render: function (_, record) {
if (record.read) {
return React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '—');
}
return React.createElement(Button, {
type: 'link',
size: 'small',
style: { padding: 0, height: 'auto' },
onClick: function () { markNoticeRead(record.id); }
}, '设为已读');
return React.createElement(Space, { size: 4 },
React.createElement(Button, {
type: 'link',
size: 'small',
style: { padding: 0, height: 'auto' },
onClick: function (e) {
if (e && e.stopPropagation) e.stopPropagation();
openNoticePreview(record);
}
}, '预览'),
!record.read
? React.createElement(Button, {
type: 'link',
size: 'small',
style: { padding: 0, height: 'auto' },
onClick: function (e) {
if (e && e.stopPropagation) e.stopPropagation();
markNoticeRead(record.id);
}
}, '已读')
: null
);
}
}
];
}, [markNoticeRead]);
}, [markNoticeRead, openNoticePreview]);
var overdueDeliveryPopoverTableStyle = { width: '100%', borderCollapse: 'collapse', fontSize: 12 };
var overdueDeliveryPopoverThStyle = { padding: '6px 8px', textAlign: 'left', borderBottom: '1px solid #f0f0f0', backgroundColor: '#fafafa', fontWeight: 600 };
@@ -1404,30 +1586,23 @@ const Component = function () {
function renderTodoStatChip(filterKey, labelText, count, pillBg, numColor) {
var selected = todoBoardFilter === filterKey;
return React.createElement('span', {
className: 'workbench-filter-chip' + (selected ? ' workbench-filter-chip--active' : ''),
role: 'button',
tabIndex: 0,
'data-tone': filterKey,
onClick: function () { setTodoBoardFilter(filterKey); },
onKeyDown: function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setTodoBoardFilter(filterKey);
}
},
style: {
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 10px',
borderRadius: 6,
fontSize: 12,
cursor: 'pointer',
background: pillBg,
boxShadow: selected ? '0 0 0 2px ' + numColor : undefined,
outline: 'none'
}
},
React.createElement('span', { style: { color: 'rgba(0,0,0,0.5)' } }, labelText),
React.createElement('span', { style: { fontWeight: 600, color: numColor } }, count)
React.createElement('span', { className: 'workbench-filter-chip-label' }, labelText),
React.createElement('span', {
className: 'workbench-filter-chip-count',
style: { color: numColor }
}, count)
);
}
@@ -1629,12 +1804,12 @@ const Component = function () {
}
return React.createElement(Card, {
key: s.key,
className: 'workbench-warning-metric-card',
className: 'workbench-warning-metric-card workbench-metric-card' + (onClick ? ' workbench-metric-card--interactive' : ''),
size: 'small',
bordered: false,
style: cardStyleMerged,
bodyStyle: {
padding: '14px 16px',
padding: '16px 18px',
flex: 1,
display: 'flex',
flexDirection: 'column',
@@ -1656,28 +1831,17 @@ const Component = function () {
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 12, width: '100%', boxSizing: 'border-box' } },
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 } },
React.createElement('div', {
style: {
width: 40,
height: 40,
borderRadius: 10,
background: s.iconBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
fontWeight: 600,
color: s.color,
flexShrink: 0
}
className: 'workbench-metric-icon',
style: { background: s.iconBg, color: s.color }
}, s.icon),
React.createElement('div', { style: { minWidth: 0 } },
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', marginBottom: 0 } },
React.createElement('span', { style: { fontSize: 12, color: 'rgba(0,0,0,0.55)', lineHeight: 1.35 } }, s.title),
React.createElement('div', { className: 'workbench-metric-copy' },
React.createElement('div', { className: 'workbench-metric-title-row' },
React.createElement('span', { className: 'workbench-metric-title' }, s.title),
renderWorkbenchMetricHintIcon(s.key)
),
valueRow != null
? valueRow
: React.createElement('div', { style: { fontSize: 22, fontWeight: 600, color: accentOrange, lineHeight: 1.2 } }, s.value)
: React.createElement('div', { className: 'workbench-metric-value' }, s.value)
)
),
trailing || null
@@ -1719,16 +1883,16 @@ const Component = function () {
};
if (s.key === 'w_en_h2_import_today') {
cardProps.valueRow = energyH2ImportTodayCount > 0
? React.createElement('div', { style: { fontSize: 22, fontWeight: 600, color: accentOrange, lineHeight: 1.2 } }, energyH2ImportTodayCount)
: React.createElement('div', { style: { fontSize: 11, lineHeight: 1.45, display: 'block', color: accentOrange, fontWeight: 500 } },
? React.createElement('div', { className: 'workbench-metric-value' }, energyH2ImportTodayCount)
: React.createElement('div', { className: 'workbench-metric-hint-copy' },
'今日还未导入任何加氢记录,请确认是否有加氢明细需要导入'
);
}
if (s.key === 'w_fin_energy_recharge') {
cardProps.value = financeEnergyRechargeYuan;
cardProps.valueRow = financeEnergyRechargeYuan != null
? React.createElement('div', { style: { fontSize: 22, fontWeight: 600, color: accentOrange, lineHeight: 1.2 } }, formatWorkbenchFinanceYuan(financeEnergyRechargeYuan))
: React.createElement('div', { style: { fontSize: 11, lineHeight: 1.45, display: 'block', color: accentOrange, fontWeight: 500 } },
? React.createElement('div', { className: 'workbench-metric-value workbench-metric-value--compact' }, formatWorkbenchFinanceYuan(financeEnergyRechargeYuan))
: React.createElement('div', { className: 'workbench-metric-hint-copy' },
'请确认是否有能源账户充值记录'
);
}
@@ -1740,58 +1904,67 @@ const Component = function () {
var titleNode = React.createElement(Space, { size: 8, wrap: true, align: 'center' },
React.createElement('span', { className: 'workbench-dash-pair-head-title' }, '通知'),
noticeNewCount > 0
? React.createElement(Badge, { count: noticeNewCount, style: { backgroundColor: '#fa8c16' } })
: null
? React.createElement(Badge, { count: noticeNewCount, style: { backgroundColor: accentOrange } })
: null,
React.createElement(Text, { type: 'secondary', className: 'workbench-notice-panel-hint' }, '点击条目预览详情')
);
var lines = preview.map(function (n, idx) {
var isLast = idx === preview.length - 1;
return React.createElement('div', {
var lines = preview.map(function (n) {
var priorityClass = n.priority === 'high'
? ' workbench-notice-card--high'
: (n.priority === 'medium' ? ' workbench-notice-card--medium' : '');
var unreadClass = !n.read ? ' workbench-notice-card--unread' : '';
return React.createElement('button', {
key: n.id,
className: 'workbench-notice-detail-line',
style: {
padding: '6px 0',
borderBottom: isLast ? 'none' : '1px solid rgba(0,0,0,0.06)'
}
type: 'button',
className: 'workbench-notice-card' + priorityClass + unreadClass,
onClick: function () { openNoticePreview(n); },
'aria-label': '预览通知:' + n.type
},
React.createElement('div', { style: { fontSize: 11, color: 'rgba(0,0,0,0.45)', marginBottom: 4, lineHeight: 1.3 } },
n.time,
React.createElement('span', { style: { margin: '0 6px', color: 'rgba(0,0,0,0.25)' } }, '·'),
n.type,
React.createElement('div', { className: 'workbench-notice-card-top' },
React.createElement('span', { className: 'workbench-notice-card-type' }, n.type),
!n.read
? React.createElement(Tag, { color: 'warning', style: { marginLeft: 6, fontSize: 11, lineHeight: '18px', padding: '0 6px' } }, '未读')
? React.createElement('span', { className: 'workbench-notice-card-badge' }, '未读')
: null
),
React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%' } },
React.createElement(Text, {
ellipsis: { tooltip: true },
style: { fontSize: 12, color: 'rgba(0,0,0,0.78)', display: 'block', lineHeight: 1.45, flex: 1, minWidth: 0 }
}, n.content),
!n.read
? React.createElement(Button, {
type: 'link',
size: 'small',
style: { padding: 0, flexShrink: 0, height: 'auto', lineHeight: 1.45, fontSize: 12 },
onClick: function () { markNoticeRead(n.id); }
}, '设为已读')
: null
React.createElement('div', { className: 'workbench-notice-card-content' }, n.content),
React.createElement('div', { className: 'workbench-notice-card-foot' },
React.createElement('span', { className: 'workbench-notice-card-time' }, n.time),
React.createElement('span', { className: 'workbench-notice-card-action', 'aria-hidden': true }, '预览 ')
)
);
});
return React.createElement(Card, {
key: 'notice-panel',
className: 'workbench-notice-panel workbench-dash-pair-head',
className: 'workbench-notice-panel workbench-dash-pair-head workbench-surface-card',
bordered: false,
title: titleNode,
style: Object.assign({}, cardStyle, { flex: 1, width: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }),
bodyStyle: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' },
extra: React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: openNoticeModal }, '更多')
extra: React.createElement(Space, { size: 4 },
noticeNewCount > 0
? React.createElement(Button, {
type: 'link',
size: 'small',
style: { padding: 0 },
onClick: function () {
setNoticeList(function (prev) {
return (prev || []).map(function (item) {
return Object.assign({}, item, { read: true });
});
});
message.success('已全部标为已读');
}
}, '全部已读')
: null,
React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: openNoticeModal }, '更多')
)
},
React.createElement('div', { className: 'workbench-dash-card-body-center' },
preview.length === 0
? React.createElement('div', { className: 'workbench-dash-card-body-inner workbench-dash-card-body-empty' },
React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '暂无通知')
React.createElement(Text, { type: 'secondary', style: { fontSize: 13 } }, '暂无通知')
)
: React.createElement('div', { className: 'workbench-dash-card-body-inner workbench-notice-detail-scroll' }, lines)
: React.createElement('div', { className: 'workbench-dash-card-body-inner workbench-notice-detail-scroll workbench-notice-card-list' }, lines)
)
);
}
@@ -1802,12 +1975,11 @@ const Component = function () {
key: 'task',
label: '任务处理情况',
children: React.createElement('div', { className: 'workbench-task-report-pane' },
React.createElement('div', { style: { flexShrink: 0, fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, lineHeight: 1.5 } },
'横轴:员工 · 纵轴:任务数量 · ',
React.createElement('span', { style: { color: '#52c41a', fontWeight: 500 } }, '■'),
' 完成任务 ',
React.createElement('span', { style: { color: '#f5222d', fontWeight: 500 } }, '■'),
' 超时任务(示意)'
React.createElement('div', { className: 'workbench-chart-legend' },
React.createElement('span', { className: 'workbench-chart-legend-swatch', style: { background: '#16a34a' } }),
'完成任务 ',
React.createElement('span', { className: 'workbench-chart-legend-swatch', style: { background: '#f87171', marginLeft: 8 } }),
'超时任务(示意)'
),
renderTaskEmployeeBarChart(taskEmployeeBars, 'done', 'overdue', '#52c41a', '#ff7875', 52)
)
@@ -1843,13 +2015,27 @@ const Component = function () {
label: '车辆运营情况',
children: React.createElement('div', { style: { padding: '8px 0 0' } },
React.createElement('div', { style: { fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8 } }, '近12个月 · 运营车辆 / 闲置车辆'),
renderBarGroup(vehicleMonthBars, 'op', 'idle', '#1677ff', '#91caff')
renderBarGroup(vehicleMonthBars, 'op', 'idle', accentBlue, '#93c5fd')
)
}
];
}, [vehicleMonthBars, contractMonthBars, taskEmployeeBars, contractPaymentDonut]);
var quickItems = quickByRole[roleTab] ? quickByRole[roleTab].items : [];
var quickItems = useMemo(function () {
var role = quickByRole[roleTab];
if (!role) return [];
var catalog = role.items;
var catalogMap = {};
catalog.forEach(function (it) {
catalogMap[quickItemKey(it)] = it;
});
var saved = quickCustom[roleTab];
if (!saved || !saved.length) return catalog;
return saved.map(function (key) { return catalogMap[key]; }).filter(Boolean);
}, [quickByRole, roleTab, quickCustom]);
var quickCatalogItems = quickByRole[roleTab] ? quickByRole[roleTab].items : [];
var quickIsCustomized = !!(quickCustom[roleTab] && quickCustom[roleTab].length);
var quickRoleKeys = ['ye', 'yeEnergy', 'ops', 'finance', 'safety', 'legal'];
@@ -1867,6 +2053,57 @@ const Component = function () {
}, [quickByRole, roleTab]);
var oneScreenCss =
'@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");' +
'.workbench-one-screen{' +
'--wb-primary:#2563eb;--wb-primary-soft:#eff6ff;--wb-primary-border:#bfdbfe;' +
'--wb-accent:#f97316;--wb-accent-soft:#fff7ed;' +
'--wb-success:#16a34a;--wb-danger:#dc2626;' +
'--wb-text:#1e293b;--wb-text-secondary:#64748b;--wb-text-muted:#94a3b8;' +
'--wb-surface:#ffffff;--wb-bg:#f8fafc;--wb-border:#e2e8f0;' +
'--wb-radius:14px;--wb-shadow:0 1px 2px rgba(15,23,42,0.04),0 8px 24px rgba(15,23,42,0.06);' +
'font-family:Inter,-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--wb-text);' +
'-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}' +
'.workbench-hero{flex-shrink:0;margin-bottom:16px;padding:18px 20px;border-radius:var(--wb-radius);' +
'border:1px solid var(--wb-border);background:linear-gradient(135deg,#fff 0%,#f8fafc 52%,#eff6ff 100%);' +
'box-shadow:var(--wb-shadow)}' +
'.workbench-hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}' +
'.workbench-hero-greeting{margin:0;font-size:22px;font-weight:700;line-height:1.3;color:var(--wb-text);letter-spacing:-0.02em}' +
'.workbench-hero-greeting em{font-style:normal;color:var(--wb-primary)}' +
'.workbench-hero-sub{margin-top:6px;font-size:14px;line-height:1.6;color:var(--wb-text-secondary)}' +
'.workbench-hero-sub strong{font-weight:600;color:var(--wb-text)}' +
'.workbench-hero-stats{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}' +
'.workbench-hero-stat{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;' +
'background:var(--wb-surface);border:1px solid var(--wb-border);font-size:13px;line-height:1.4;color:var(--wb-text-secondary)}' +
'.workbench-hero-stat b{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--wb-text)}' +
'.workbench-hero-stat--warn b{color:var(--wb-accent)}' +
'.workbench-hero-stat--info b{color:var(--wb-primary)}' +
'.workbench-section-kicker{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;' +
'letter-spacing:0.04em;text-transform:uppercase;color:var(--wb-text-muted)}' +
'.workbench-section-kicker-dot{width:6px;height:6px;border-radius:50%;background:var(--wb-primary);flex-shrink:0}' +
'.workbench-filter-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;min-height:32px;' +
'border-radius:999px;font-size:13px;cursor:pointer;border:1px solid var(--wb-border);background:var(--wb-surface);' +
'transition:border-color .18s,background .18s,box-shadow .18s,transform .18s;outline:none;user-select:none}' +
'.workbench-filter-chip:hover{border-color:var(--wb-primary-border);background:var(--wb-primary-soft)}' +
'.workbench-filter-chip:focus-visible{box-shadow:0 0 0 3px rgba(37,99,235,0.2)}' +
'.workbench-filter-chip--active{border-color:var(--wb-primary);background:var(--wb-primary-soft);box-shadow:0 0 0 1px rgba(37,99,235,0.12)}' +
'.workbench-filter-chip-label{color:var(--wb-text-secondary)}' +
'.workbench-filter-chip-count{font-weight:700;font-variant-numeric:tabular-nums}' +
'.workbench-metric-card{background:var(--wb-surface)!important;border:1px solid var(--wb-border)!important;box-shadow:var(--wb-shadow)!important;transition:transform .2s ease,box-shadow .2s ease,border-color .2s ease}' +
'.workbench-metric-card--interactive{cursor:pointer}' +
'.workbench-metric-card--interactive:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(15,23,42,0.08),0 12px 28px rgba(37,99,235,0.08)!important;border-color:var(--wb-primary-border)!important}' +
'.workbench-metric-card--interactive:focus-visible{outline:2px solid rgba(37,99,235,0.35);outline-offset:2px}' +
'.workbench-metric-icon{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;flex-shrink:0}' +
'.workbench-metric-copy{min-width:0}' +
'.workbench-metric-title-row{display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin-bottom:2px}' +
'.workbench-metric-title{font-size:13px;line-height:1.4;color:var(--wb-text-secondary);font-weight:500}' +
'.workbench-metric-value{font-size:24px;font-weight:700;line-height:1.2;color:var(--wb-accent);font-variant-numeric:tabular-nums;letter-spacing:-0.02em}' +
'.workbench-metric-value--compact{font-size:18px}' +
'.workbench-metric-hint-copy{font-size:12px;line-height:1.5;color:var(--wb-accent);font-weight:500}' +
'.workbench-dash-card-body-empty{min-height:140px;background:linear-gradient(180deg,transparent 0%,rgba(248,250,252,0.9) 100%);border-radius:12px}' +
'.workbench-chart-legend{font-size:13px;line-height:1.5;color:var(--wb-text-secondary);margin-bottom:10px}' +
'.workbench-chart-legend-swatch{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:-1px}' +
'.workbench-surface-card{background:var(--wb-surface)!important;border:1px solid var(--wb-border)!important;box-shadow:var(--wb-shadow)!important}' +
'@media (prefers-reduced-motion:reduce){.workbench-metric-card,.workbench-notice-card,.workbench-quick-item,.workbench-filter-chip{transition:none!important}.workbench-metric-card--interactive:hover{transform:none}}' +
'.workbench-one-screen .workbench-report-tabs.ant-tabs{height:100%;display:flex;flex-direction:column;min-height:0}' +
'.workbench-one-screen .workbench-report-tabs .ant-tabs-content-holder{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}' +
'.workbench-one-screen .workbench-report-tabs .ant-tabs-content{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}' +
@@ -1882,8 +2119,7 @@ const Component = function () {
'.workbench-main-split>.ant-row.workbench-todo-notice-row,.workbench-main-split>.ant-row.workbench-report-quick-row{flex:1 1 0!important;min-height:0!important;height:auto!important;max-height:none!important;margin:0!important;overflow:hidden}' +
'.workbench-todo-notice-row>.ant-col,.workbench-report-quick-row>.ant-col{display:flex!important;flex-direction:column;min-height:0}' +
'.workbench-notice-slot,.workbench-todo-slot,.workbench-report-slot{min-height:0}' +
'.workbench-dash-pair-head.ant-card .ant-card-head{min-height:56px;padding:0 18px;display:flex;align-items:center;border-bottom:1px solid rgba(0,0,0,0.06)}' +
'.workbench-dash-pair-head.ant-card .ant-card-head-title,.workbench-dash-pair-head-title{font-size:16px;font-weight:600;line-height:24px;color:rgba(0,0,0,0.88)}' +
'.workbench-dash-pair-head.ant-card .ant-card-head{min-height:56px;padding:0 20px;display:flex;align-items:center;border-bottom:1px solid var(--wb-border);background:linear-gradient(180deg,#fff 0%,#fafbfc 100%)}' +
'.workbench-dash-pair-head.ant-card .ant-card-extra{padding:0}' +
'.workbench-dash-pair-head.ant-card .ant-card-body{padding-top:10px!important}' +
'.workbench-dash-pair-head-sub{font-size:12px!important;line-height:20px!important}' +
@@ -1896,6 +2132,42 @@ const Component = function () {
'.workbench-report-tabs .ant-tabs-nav{margin-bottom:8px;}' +
'.workbench-notice-inline-hint{font-size:11px;font-weight:500;color:#ad4e00;cursor:pointer;user-select:none;white-space:nowrap}' +
'.workbench-notice-inline-hint:hover{color:#d46b08;text-decoration:underline}' +
'.workbench-notice-panel-hint{font-size:12px!important;line-height:20px!important}' +
'.workbench-notice-card-list{display:flex;flex-direction:column;gap:8px;padding:2px 0}' +
'.workbench-notice-card{display:block;width:100%;box-sizing:border-box;text-align:left;border:1px solid rgba(0,0,0,0.06);border-radius:10px;background:#fafafa;padding:10px 12px;cursor:pointer;transition:border-color .18s,background .18s,box-shadow .18s,transform .18s;outline:none;font:inherit}' +
'.workbench-notice-card:hover{border-color:rgba(37,99,235,0.28);background:#f5f9ff;box-shadow:0 2px 8px rgba(37,99,235,0.08)}' +
'.workbench-notice-card:focus-visible{box-shadow:0 0 0 2px rgba(37,99,235,0.24)}' +
'.workbench-notice-card--unread{border-left:3px solid var(--wb-accent);background:var(--wb-accent-soft)}' +
'.workbench-notice-card--unread:hover{background:#fff7ef}' +
'.workbench-notice-card--high.workbench-notice-card--unread{border-left-color:#f5222d}' +
'.workbench-notice-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}' +
'.workbench-notice-card-type{font-size:12px;font-weight:600;color:rgba(0,0,0,0.78);line-height:1.35}' +
'.workbench-notice-card-badge{font-size:11px;line-height:18px;padding:0 6px;border-radius:999px;background:#fff7e6;color:#d46b08;font-weight:500;flex-shrink:0}' +
'.workbench-notice-card:active{transform:scale(0.99)}' +
'.workbench-notice-card-content{font-size:14px;line-height:1.55;color:var(--wb-text);display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}' +
'.workbench-notice-card-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px}' +
'.workbench-notice-card-time{font-size:12px;color:rgba(0,0,0,0.45);font-variant-numeric:tabular-nums}' +
'.workbench-notice-card-action{font-size:12px;color:var(--wb-primary);font-weight:600;flex-shrink:0}' +
'.workbench-notice-table-row:hover>td{background:#f5f9ff!important}' +
'.workbench-notice-preview{display:flex;flex-direction:column;gap:14px}' +
'.workbench-notice-preview-meta{display:flex;align-items:center;flex-wrap:wrap;gap:8px}' +
'.workbench-notice-preview-summary{font-size:15px;line-height:1.55;font-weight:600;color:rgba(0,0,0,0.88)}' +
'.workbench-notice-preview-detail{border:1px solid rgba(0,0,0,0.06);border-radius:10px;background:#fafafa;padding:12px 14px}' +
'.workbench-notice-preview-detail-label{font-size:12px;font-weight:600;color:rgba(0,0,0,0.45);margin-bottom:6px;letter-spacing:0.02em}' +
'.workbench-notice-preview-detail-body{font-size:14px;line-height:1.6;color:rgba(0,0,0,0.78)}' +
'.workbench-quick-customize{display:grid;grid-template-columns:1fr 1fr;gap:16px;min-height:280px}' +
'@media (max-width: 640px){.workbench-quick-customize{grid-template-columns:1fr}}' +
'.workbench-quick-customize-col{display:flex;flex-direction:column;min-height:0;border:1px solid rgba(0,0,0,0.06);border-radius:10px;background:#fafafa;overflow:hidden}' +
'.workbench-quick-customize-title{padding:10px 12px;font-size:13px;font-weight:600;color:rgba(0,0,0,0.78);border-bottom:1px solid rgba(0,0,0,0.06);background:#fff}' +
'.workbench-quick-customize-count{font-weight:400;color:rgba(0,0,0,0.45)}' +
'.workbench-quick-customize-list,.workbench-quick-customize-order{flex:1;min-height:0;overflow:auto;padding:8px;display:flex;flex-direction:column;gap:6px}' +
'.workbench-quick-customize-option{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;background:#fff;border:1px solid rgba(0,0,0,0.04);cursor:pointer;transition:background .15s,border-color .15s}' +
'.workbench-quick-customize-option:hover{border-color:rgba(22,119,255,0.2);background:#f8fbff}' +
'.workbench-quick-customize-option.is-checked{border-color:rgba(22,119,255,0.28);background:#f0f7ff}' +
'.workbench-quick-customize-option-label{font-size:13px;line-height:1.4;color:rgba(0,0,0,0.78)}' +
'.workbench-quick-customize-order-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;background:#fff;border:1px solid rgba(0,0,0,0.04)}' +
'.workbench-quick-customize-order-index{width:22px;height:22px;border-radius:6px;background:#f0f5ff;color:#1677ff;font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;font-variant-numeric:tabular-nums}' +
'.workbench-quick-customize-order-label{flex:1;min-width:0;font-size:13px;line-height:1.4;color:rgba(0,0,0,0.78)}' +
'.workbench-todo-table .ant-table-thead>tr>th{color:#707d8f!important;font-weight:400!important;font-size:12px!important;background:#f7f8fa!important;border-bottom:1px solid rgba(0,0,0,0.06)!important;padding:12px 12px!important}' +
'.workbench-todo-table .ant-table-thead>tr>th::before{display:none!important}' +
'.workbench-todo-table .ant-table-column-sorter{color:#98a1b0!important}' +
@@ -1903,22 +2175,25 @@ const Component = function () {
'.workbench-todo-table.ant-table-small .ant-table-thead>tr>th{padding:10px 12px!important}' +
'.workbench-todo-table .ant-table-thead>tr>th.ant-table-column-sort{background:#f7f8fa!important}' +
'.workbench-todo-table .ant-table-tbody>tr>td.ant-table-column-sort{background:#fff!important}' +
'.workbench-todo-table .ant-table-tbody>tr.ant-table-row:hover>td.ant-table-column-sort{background:#fafafa!important}' +
'.workbench-todo-table .ant-table-tbody>tr>td{font-size:14px!important;line-height:1.5!important;color:var(--wb-text)!important;padding:12px!important}' +
'.workbench-todo-table .ant-table-tbody>tr>td .ant-btn-link{font-weight:500}' +
'.workbench-todo-table .ant-table-tbody>tr.ant-table-row{cursor:pointer;transition:background .15s}' +
'.workbench-todo-table .ant-table-tbody>tr.ant-table-row:hover>td{background:#f8fbff!important}' +
'.workbench-one-screen .workbench-quick-card.ant-card{display:flex;flex-direction:column;min-height:0;height:100%}' +
'.workbench-one-screen .workbench-quick-card .ant-card-body{padding:0;flex:1;min-height:0;display:flex;flex-direction:column}' +
'.workbench-quick-scroll{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch}' +
'.workbench-quick-item{display:flex;align-items:center;gap:6px;width:100%;box-sizing:border-box;padding:5px 6px;border-radius:6px;border:1px solid rgba(0,0,0,0.06);background:#fafafa;cursor:pointer;transition:border-color .15s,background .15s,box-shadow .15s;text-align:left;outline:none}' +
'.workbench-quick-item:hover{border-color:rgba(22,119,255,0.22);background:#f3f8ff;box-shadow:0 1px 2px rgba(22,119,255,0.06)}' +
'.workbench-quick-item:focus-visible{box-shadow:0 0 0 2px rgba(22,119,255,0.2)}' +
'.workbench-quick-item-icon{flex-shrink:0;width:26px;height:26px;border-radius:6px;background:linear-gradient(135deg,#f0f5ff,#d6e4ff);line-height:26px;text-align:center;font-size:12px;font-weight:600;color:#1677ff}' +
'.workbench-quick-item-label{flex:1;min-width:0;font-size:11px;line-height:1.3;color:rgba(0,0,0,0.78);word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}' +
'.workbench-quick-item{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:10px 12px;min-height:44px;border-radius:10px;border:1px solid var(--wb-border);background:var(--wb-surface);cursor:pointer;transition:border-color .18s,background .18s,box-shadow .18s,transform .18s;text-align:left;outline:none}' +
'.workbench-quick-item:hover{border-color:var(--wb-primary-border);background:var(--wb-primary-soft);box-shadow:0 2px 8px rgba(37,99,235,0.08);transform:translateY(-1px)}' +
'.workbench-quick-item:focus-visible{box-shadow:0 0 0 3px rgba(37,99,235,0.2)}' +
'.workbench-quick-item-icon{flex-shrink:0;width:32px;height:32px;border-radius:10px;background:linear-gradient(135deg,#eff6ff,#dbeafe);line-height:32px;text-align:center;font-size:13px;font-weight:700;color:var(--wb-primary)}' +
'.workbench-quick-item-label{flex:1;min-width:0;font-size:12px;line-height:1.35;color:rgba(0,0,0,0.78);word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}' +
'.workbench-quick-item--action{padding:7px 10px;min-height:38px}' +
'.workbench-quick-item--primary{border-color:rgba(22,119,255,0.2);background:linear-gradient(135deg,#f8fbff 0%,#eef5ff 100%)}' +
'.workbench-quick-item--primary:hover{border-color:rgba(22,119,255,0.35);background:linear-gradient(135deg,#f0f7ff 0%,#e6f0ff 100%)}' +
'.workbench-quick-item--primary .workbench-quick-item-icon{background:linear-gradient(135deg,#1677ff,#4096ff);color:#fff;box-shadow:0 2px 6px rgba(22,119,255,0.25)}' +
'.workbench-quick-item--orange{border-color:rgba(250,140,22,0.22);background:linear-gradient(135deg,#fffaf5 0%,#fff3e6 100%)}' +
'.workbench-quick-item--orange:hover{border-color:rgba(250,140,22,0.38);background:linear-gradient(135deg,#fff7ef 0%,#ffebd6 100%)}' +
'.workbench-quick-item--orange .workbench-quick-item-icon{background:linear-gradient(135deg,#fa8c16,#ffa940);color:#fff;box-shadow:0 2px 6px rgba(250,140,22,0.22)}' +
'.workbench-quick-item--primary{border-color:rgba(37,99,235,0.22);background:linear-gradient(135deg,#f8fbff 0%,#eff6ff 100%)}' +
'.workbench-quick-item--primary:hover{border-color:rgba(37,99,235,0.38);background:linear-gradient(135deg,#eff6ff 0%,#dbeafe 100%)}' +
'.workbench-quick-item--primary .workbench-quick-item-icon{background:linear-gradient(135deg,#2563eb,#3b82f6);color:#fff;box-shadow:0 4px 10px rgba(37,99,235,0.28)}' +
'.workbench-quick-item--orange{border-color:rgba(249,115,22,0.24);background:linear-gradient(135deg,#fffaf5 0%,#fff7ed 100%)}' +
'.workbench-quick-item--orange:hover{border-color:rgba(249,115,22,0.4);background:linear-gradient(135deg,#fff7ed 0%,#ffedd5 100%)}' +
'.workbench-quick-item--orange .workbench-quick-item-icon{background:linear-gradient(135deg,#f97316,#fb923c);color:#fff;box-shadow:0 4px 10px rgba(249,115,22,0.24)}' +
'.workbench-quick-section-hint{font-size:11px;line-height:1.35;color:rgba(0,0,0,0.45);padding:0 4px 6px;font-weight:500;letter-spacing:0.02em}' +
'.workbench-gen-task-modal .ant-modal-body{padding-top:14px}' +
'.workbench-gen-task-form-label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:rgba(0,0,0,0.88);line-height:1.4}' +
@@ -1932,7 +2207,9 @@ const Component = function () {
'.workbench-header-warning-dept-tabs.ant-tabs{min-width:0}' +
'.workbench-header-warning-dept-tabs .ant-tabs-nav{margin:0!important;min-height:32px}' +
'.workbench-header-warning-dept-tabs .ant-tabs-nav::before{border-bottom:none!important}' +
'.workbench-header-warning-dept-tabs .ant-tabs-tab{padding:4px 8px!important;font-size:12px}' +
'.workbench-header-warning-dept-tabs .ant-tabs-tab{padding:6px 12px!important;font-size:13px;border-radius:999px;margin:0 2px!important;transition:background .18s,color .18s}' +
'.workbench-header-warning-dept-tabs .ant-tabs-tab-active{background:var(--wb-primary-soft)!important}' +
'.workbench-header-warning-dept-tabs .ant-tabs-tab-active .ant-tabs-tab-btn{color:var(--wb-primary)!important;font-weight:600}' +
'.workbench-header-warning-dept-tabs .ant-tabs-nav-wrap{justify-content:flex-end}' +
'.workbench-header-warning-dept-tabs .ant-tabs-content-holder{display:none!important}' +
'.workbench-overdue-delivery-modal .ant-table-tbody>tr>td{color:#a8071a!important;background-color:#fff2f0!important;border-color:#ffccc7!important}' +
@@ -1952,8 +2229,11 @@ const Component = function () {
'.workbench-one-screen .ant-breadcrumb{font-size:13px;line-height:1.5}' +
'.workbench-one-screen .ant-breadcrumb a,.workbench-one-screen .ant-breadcrumb .ant-breadcrumb-link{color:rgba(0,0,0,0.65)}' +
'.workbench-one-screen .ant-breadcrumb li:last-child .ant-breadcrumb-link{color:rgba(0,0,0,0.88);font-weight:500}' +
'.workbench-report-tabs .ant-tabs-ink-bar{background:#1677ff;height:2px;border-radius:1px}' +
'.workbench-report-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:rgba(0,0,0,0.88);font-weight:600}' +
'.workbench-report-tabs .ant-tabs-ink-bar{background:var(--wb-primary);height:3px;border-radius:2px}' +
'.workbench-report-tabs .ant-tabs-tab .ant-tabs-tab-btn{font-size:14px;color:var(--wb-text-secondary);transition:color .18s}' +
'.workbench-report-tabs .ant-tabs-tab:hover .ant-tabs-tab-btn{color:var(--wb-text)}' +
'.workbench-report-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:var(--wb-text);font-weight:600}' +
'.workbench-dash-pair-head.ant-card .ant-card-head-title,.workbench-dash-pair-head-title{font-size:17px;font-weight:700;line-height:26px;color:var(--wb-text);letter-spacing:-0.01em}' +
'.workbench-report-slot .ant-card .ant-card-head{min-height:52px;padding:0 18px;border-bottom:1px solid rgba(0,0,0,0.06)}' +
'.workbench-report-slot .ant-card .ant-card-head-title{font-size:16px;font-weight:600;color:rgba(0,0,0,0.88)}';
@@ -1972,9 +2252,33 @@ const Component = function () {
},
React.createElement('style', null, oneScreenCss),
React.createElement('div', { style: { flexShrink: 0, marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid rgba(0,0,0,0.06)' } },
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10, flexWrap: 'wrap', gap: 10 } },
React.createElement(Breadcrumb, { items: [{ title: '运管平台' }, { title: '工作台' }] }),
React.createElement('div', { className: 'workbench-hero' },
React.createElement('div', { className: 'workbench-hero-top' },
React.createElement('div', { style: { minWidth: 0, flex: '1 1 280px' } },
React.createElement('div', { className: 'workbench-section-kicker', style: { marginBottom: 8 } },
React.createElement('span', { className: 'workbench-section-kicker-dot' }),
'运管平台 · 工作台'
),
React.createElement('h1', { className: 'workbench-hero-greeting' },
'欢迎回来,',
React.createElement('em', null, mockOperatorDisplayName)
),
React.createElement('p', { className: 'workbench-hero-sub' },
'集中查看待办、预警指标与业务通知,快速进入常用功能。'
),
React.createElement('div', { className: 'workbench-hero-stats' },
React.createElement('span', { className: 'workbench-hero-stat workbench-hero-stat--info' },
React.createElement('b', null, todoSummary.pending + todoSummary.overdue),
' 条待办'
),
noticeNewCount > 0
? React.createElement('span', { className: 'workbench-hero-stat workbench-hero-stat--warn' },
React.createElement('b', null, noticeNewCount),
' 条未读通知'
)
: React.createElement('span', { className: 'workbench-hero-stat' }, '通知已全部处理')
)
),
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', minWidth: 0 } },
React.createElement(Tabs, {
className: 'workbench-header-warning-dept-tabs',
@@ -1985,8 +2289,7 @@ const Component = function () {
tabBarGutter: 4
})
)
),
React.createElement(Title, { level: 5, style: { margin: 0, fontWeight: 600, fontSize: 18, color: 'rgba(0,0,0,0.88)', letterSpacing: '-0.01em' } }, '欢迎回来,' + mockOperatorDisplayName)
)
),
React.createElement('div', { style: { flexShrink: 0, marginBottom: 12, boxSizing: 'border-box' } },
React.createElement('div', { className: 'workbench-top-metrics', style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, alignItems: 'stretch' } },
@@ -2034,7 +2337,7 @@ const Component = function () {
},
React.createElement('div', { className: 'workbench-todo-slot', style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', width: '100%' } },
React.createElement(Card, {
className: 'workbench-dash-pair-head',
className: 'workbench-dash-pair-head workbench-surface-card',
title: React.createElement(Space, { size: 8, wrap: true, align: 'center' },
React.createElement('span', { className: 'workbench-dash-pair-head-title' }, '待办任务'),
React.createElement(Badge, { count: todoSummary.pending + todoSummary.overdue, style: { backgroundColor: accentBlue } }),
@@ -2091,6 +2394,7 @@ const Component = function () {
React.createElement(Card, {
title: '统计报表',
bordered: false,
className: 'workbench-surface-card',
style: Object.assign({}, cardStyle, { flex: 1, width: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }),
bodyStyle: { paddingTop: 4, paddingBottom: 8, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }
},
@@ -2112,17 +2416,23 @@ const Component = function () {
style: { display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0 }
},
React.createElement(Card, {
className: 'workbench-quick-card',
className: 'workbench-quick-card workbench-surface-card',
title: '快速入口',
bordered: false,
style: Object.assign({}, cardStyle, { flex: 1, width: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }),
bodyStyle: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden', padding: 0 },
extra: React.createElement(Dropdown, {
menu: roleSwitchMenuProps,
trigger: ['click'],
placement: 'bottomRight'
},
React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 } }, '切换角色')
extra: React.createElement(Space, { size: 8, wrap: true },
quickIsCustomized
? React.createElement(Tag, { color: 'processing', style: { margin: 0, fontSize: 11, lineHeight: '18px' } }, '已自定义')
: null,
React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: openQuickCustomize }, '自定义'),
React.createElement(Dropdown, {
menu: roleSwitchMenuProps,
trigger: ['click'],
placement: 'bottomRight'
},
React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 } }, '切换角色')
)
)
},
React.createElement('div', { className: 'workbench-quick-scroll', style: { padding: '6px 6px 4px' } },
@@ -2265,10 +2575,137 @@ const Component = function () {
columns: noticeColumns,
dataSource: noticeList,
pagination: { pageSize: 8, showSizeChanger: false },
scroll: { x: 720 }
scroll: { x: 720 },
onRow: function (record) {
return {
className: 'workbench-notice-table-row',
style: { cursor: 'pointer' },
onClick: function () { openNoticePreview(record); }
};
}
})
),
React.createElement(Modal, {
title: noticePreviewRecord ? noticePreviewRecord.type : '通知详情',
open: !!noticePreviewRecord,
width: 560,
onCancel: closeNoticePreview,
destroyOnClose: true,
footer: noticePreviewRecord
? React.createElement(Space, { wrap: true },
!noticePreviewRecord.read
? React.createElement(Button, {
onClick: function () {
markNoticeRead(noticePreviewRecord.id);
}
}, '标为已读')
: null,
noticePreviewRecord.target && noticePreviewRecord.target.path
? React.createElement(Button, {
type: 'primary',
onClick: function () { handleNoticePreviewNavigate(noticePreviewRecord); }
}, noticePreviewRecord.target.label || '前往处理')
: null,
React.createElement(Button, { onClick: closeNoticePreview }, '关闭')
)
: null
},
noticePreviewRecord
? React.createElement('div', { className: 'workbench-notice-preview' },
React.createElement('div', { className: 'workbench-notice-preview-meta' },
React.createElement(Tag, {
color: noticePreviewRecord.priority === 'high' ? 'error' : (noticePreviewRecord.priority === 'medium' ? 'warning' : 'default'),
style: { margin: 0 }
}, noticePreviewRecord.priority === 'high' ? '高优先级' : (noticePreviewRecord.priority === 'medium' ? '中优先级' : '普通')),
React.createElement(Text, { type: 'secondary', style: { fontSize: 13 } }, noticePreviewRecord.time),
!noticePreviewRecord.read
? React.createElement(Tag, { color: 'warning', style: { margin: 0 } }, '未读')
: React.createElement(Tag, { style: { margin: 0 } }, '已读')
),
React.createElement('div', { className: 'workbench-notice-preview-summary' }, noticePreviewRecord.content),
React.createElement('div', { className: 'workbench-notice-preview-detail' },
React.createElement('div', { className: 'workbench-notice-preview-detail-label' }, '详细说明'),
React.createElement('div', { className: 'workbench-notice-preview-detail-body' },
noticePreviewRecord.detail || noticePreviewRecord.content
)
)
)
: null
),
React.createElement(Modal, {
title: '自定义快速入口 · ' + (quickByRole[roleTab] ? quickByRole[roleTab].label : ''),
open: quickCustomizeOpen,
width: 640,
onCancel: function () { setQuickCustomizeOpen(false); },
destroyOnClose: true,
footer: React.createElement(Space, { wrap: true },
React.createElement(Button, { onClick: resetQuickCustomize }, '恢复默认'),
React.createElement(Button, { onClick: function () { setQuickCustomizeOpen(false); } }, '取消'),
React.createElement(Button, { type: 'primary', onClick: saveQuickCustomize }, '保存')
)
},
React.createElement(Text, { type: 'secondary', style: { display: 'block', marginBottom: 12, fontSize: 13, lineHeight: 1.5 } },
'勾选要在工作台展示的功能,并可调整顺序。设置将保存在本浏览器,不同角色独立配置。'
),
React.createElement('div', { className: 'workbench-quick-customize' },
React.createElement('div', { className: 'workbench-quick-customize-col' },
React.createElement('div', { className: 'workbench-quick-customize-title' }, '可选功能'),
React.createElement('div', { className: 'workbench-quick-customize-list' },
quickCatalogItems.map(function (it) {
var key = quickItemKey(it);
var checked = quickCustomDraft.indexOf(key) >= 0;
return React.createElement('label', {
key: key,
className: 'workbench-quick-customize-option' + (checked ? ' is-checked' : '')
},
React.createElement(Checkbox, {
checked: checked,
onChange: function (e) {
toggleQuickDraftKey(key, e.target.checked);
}
}),
React.createElement('span', { className: 'workbench-quick-customize-option-label' }, it.t)
);
})
)
),
React.createElement('div', { className: 'workbench-quick-customize-col' },
React.createElement('div', { className: 'workbench-quick-customize-title' },
'已选顺序 ',
React.createElement('span', { className: 'workbench-quick-customize-count' }, '(' + quickCustomDraft.length + ')')
),
quickCustomDraft.length === 0
? React.createElement(Text, { type: 'secondary', style: { fontSize: 13 } }, '请从左侧勾选至少一项')
: React.createElement('div', { className: 'workbench-quick-customize-order' },
quickCustomDraft.map(function (key, idx) {
var item = quickCatalogItems.find(function (it) { return quickItemKey(it) === key; });
if (!item) return null;
return React.createElement('div', { key: key, className: 'workbench-quick-customize-order-item' },
React.createElement('span', { className: 'workbench-quick-customize-order-index' }, idx + 1),
React.createElement('span', { className: 'workbench-quick-customize-order-label' }, item.t),
React.createElement(Space, { size: 0 },
React.createElement(Button, {
type: 'text',
size: 'small',
disabled: idx === 0,
onClick: function () { moveQuickDraftKey(key, 'up'); }
}, '上移'),
React.createElement(Button, {
type: 'text',
size: 'small',
disabled: idx === quickCustomDraft.length - 1,
onClick: function () { moveQuickDraftKey(key, 'down'); }
}, '下移')
)
);
})
)
)
)
),
React.createElement(Modal, {
title: '超期未交车',
open: overdueDeliveryModalOpen,

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "payment-records",
"pageId": "list",
"updatedAt": 1783328981488,
"updatedAt": 1783401000000,
"nodes": [
{
"id": "pr-filter",
@@ -19,8 +19,8 @@
"fingerprint": "pr-filter",
"path": []
},
"aiPrompt": "客户、收款时间、操作人、未认领筛选。",
"annotationText": "默认展示 4 项筛选支持重置与查询。",
"aiPrompt": "汇款账户名、公司主体、银行、收款时间、操作人、未认领筛选。",
"annotationText": "默认首行展示 4 项筛选(汇款账户名、公司主体、银行、收款时间);超过 1 行时其余项收入「更多筛选」。支持重置与查询。",
"hasMarkdown": false,
"color": "#2563eb",
"images": [],
@@ -40,7 +40,7 @@
"path": []
},
"aiPrompt": "新增、批量导入、导出与收款列表。",
"annotationText": "列表含收款时间、客户、金额、备注、关联账单、未认领金额、操作人、操作时间;操作列支持关联账单。",
"annotationText": "列表含收款时间、汇款账户名、公司主体、银行、金额、备注、关联账单、未认领金额、操作人、操作时间;操作列支持关联账单。",
"hasMarkdown": false,
"color": "#0f766e",
"images": [],
@@ -66,6 +66,26 @@
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "pr-add-modal",
"index": 4,
"title": "新增收款",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"pr-add-modal\"]"
],
"fingerprint": "pr-add-modal",
"path": []
},
"aiPrompt": "新增收款表单字段与校验。",
"annotationText": "必填:收款时间、汇款账户名、金额;选填:公司主体、银行、备注。字段口径与列表列一致。",
"hasMarkdown": false,
"color": "#ea580c",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1783401200000
}
]
},

View File

@@ -1,16 +1,21 @@
import React, { useEffect, useState } from 'react';
import { DatePicker, Input, InputNumber, Modal } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { DatePicker, Input, InputNumber, Modal, Select } from 'antd';
import dayjs from 'dayjs';
import type { PaymentRecord } from '../types';
import { buildBankOptions, buildCompanyEntityOptions } from '../utils/payment';
export interface AddPaymentFormValues {
paymentTime: string;
customerName: string;
companyEntity: string;
bank: string;
amount: number;
remark: string;
}
interface AddPaymentModalProps {
open: boolean;
records: PaymentRecord[];
onClose: () => void;
onConfirm: (values: AddPaymentFormValues) => void;
}
@@ -18,13 +23,18 @@ interface AddPaymentModalProps {
const DEFAULT_FORM: AddPaymentFormValues = {
paymentTime: '',
customerName: '',
companyEntity: '',
bank: '',
amount: 0,
remark: '',
};
export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalProps) {
export function AddPaymentModal({ open, records, onClose, onConfirm }: AddPaymentModalProps) {
const [form, setForm] = useState<AddPaymentFormValues>(DEFAULT_FORM);
const companyEntityOptions = useMemo(() => buildCompanyEntityOptions(records), [records]);
const bankOptions = useMemo(() => buildBankOptions(records), [records]);
useEffect(() => {
if (!open) setForm(DEFAULT_FORM);
}, [open]);
@@ -34,6 +44,8 @@ export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalPro
onConfirm({
paymentTime: form.paymentTime,
customerName: form.customerName.trim(),
companyEntity: form.companyEntity.trim(),
bank: form.bank.trim(),
amount: Number(form.amount),
remark: form.remark.trim(),
});
@@ -69,14 +81,44 @@ export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalPro
/>
</label>
<label className="vm-filter-field">
<span> <em className="pr-required">*</em></span>
<span> <em className="pr-required">*</em></span>
<Input
value={form.customerName}
placeholder="请输入户名"
aria-label="户名"
placeholder="请输入汇款账户名"
aria-label="汇款账户名"
onChange={(e) => setForm((prev) => ({ ...prev, customerName: e.target.value }))}
/>
</label>
<label className="vm-filter-field">
<span></span>
<Select
style={{ width: '100%' }}
placeholder="请选择公司主体"
aria-label="公司主体"
value={form.companyEntity || undefined}
onChange={(value) => setForm((prev) => ({ ...prev, companyEntity: value || '' }))}
allowClear
showSearch
options={companyEntityOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
</label>
<label className="vm-filter-field">
<span></span>
<Select
style={{ width: '100%' }}
placeholder="请选择收款银行"
aria-label="银行"
value={form.bank || undefined}
onChange={(value) => setForm((prev) => ({ ...prev, bank: value || '' }))}
allowClear
showSearch
options={bankOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
</label>
<label className="vm-filter-field">
<span> <em className="pr-required">*</em></span>
<InputNumber

View File

@@ -126,7 +126,7 @@ export function BillLinkModal({ open, record, billPool, onClose, onConfirm }: Bi
title: '未收金额',
key: 'unreceived',
width: 120,
align: 'right',
align: 'left',
className: 'tabular-nums',
render: (_, bill) => formatMoney(bill.unreceived),
},
@@ -134,7 +134,7 @@ export function BillLinkModal({ open, record, billPool, onClose, onConfirm }: Bi
title: '本次认领',
key: 'alloc',
width: 140,
align: 'right',
align: 'left',
render: (_, bill) => {
const key = `${bill.type}:${bill.id}`;
const checked = selected.has(key);

View File

@@ -1,9 +1,15 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
import { DatePicker, Select } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
shouldShowFilterExpand,
splitFilterFields,
VM_FILTER_PRIMARY_VISIBLE_COUNT,
} from '../../../common/vm-filter-panel';
import type { PaymentRecord, PaymentRecordFilters } from '../types';
import { buildCustomerOptions, buildOperatorOptions } from '../utils/payment';
import { buildBankOptions, buildCompanyEntityOptions, buildCustomerOptions, buildOperatorOptions } from '../utils/payment';
const { RangePicker } = DatePicker;
@@ -15,6 +21,12 @@ const UNCLAIMED_OPTIONS = [
{ value: 'no', label: '已全部关联' },
];
interface FilterFieldDef {
key: string;
label: string;
control: React.ReactNode;
}
interface FilterPanelProps {
records: PaymentRecord[];
filters: PaymentRecordFilters;
@@ -23,8 +35,22 @@ interface FilterPanelProps {
onSearch: () => void;
}
function hasExtraFiltersActive(filters: PaymentRecordFilters): boolean {
return Boolean(filters.operatorName.trim() || filters.unclaimed);
}
export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) {
const [expanded, setExpanded] = useState(() => hasExtraFiltersActive(filters));
useEffect(() => {
if (hasExtraFiltersActive(filters)) {
setExpanded(true);
}
}, [filters.operatorName, filters.unclaimed]);
const customerOptions = useMemo(() => buildCustomerOptions(records), [records]);
const companyEntityOptions = useMemo(() => buildCompanyEntityOptions(records), [records]);
const bankOptions = useMemo(() => buildBankOptions(records), [records]);
const operatorOptions = useMemo(
() => buildOperatorOptions(records).map((name) => ({ value: name, label: name })),
[records],
@@ -38,91 +64,182 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
return null;
}, [filters.paymentFrom, filters.paymentTo]);
const fields = useMemo<FilterFieldDef[]>(() => [
{
key: 'customerName',
label: '汇款账户名',
control: (
<Select
placeholder="请选择或输入汇款账户名"
style={{ width: '100%' }}
value={filters.customerName || undefined}
onChange={(value) => onChange({ customerName: value || '' })}
allowClear
showSearch
options={customerOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
),
},
{
key: 'companyEntity',
label: '公司主体',
control: (
<Select
placeholder="请选择公司主体"
style={{ width: '100%' }}
value={filters.companyEntity || undefined}
onChange={(value) => onChange({ companyEntity: value || '' })}
allowClear
showSearch
options={companyEntityOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
),
},
{
key: 'bank',
label: '银行',
control: (
<Select
placeholder="请选择收款银行"
style={{ width: '100%' }}
value={filters.bank || undefined}
onChange={(value) => onChange({ bank: value || '' })}
allowClear
showSearch
options={bankOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
),
},
{
key: 'paymentTime',
label: '收款时间',
control: (
<RangePicker
className="lc-filter-select pr-payment-range"
style={{ width: '100%' }}
picker="date"
showTime={false}
format={DATE_FORMAT}
separator="至"
placeholder={['开始日期', '结束日期']}
allowClear
inputReadOnly
value={dateRangeValue}
onChange={(values) => {
onChange({
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
});
}}
/>
),
},
{
key: 'operatorName',
label: '操作人',
control: (
<Select
placeholder="全部操作人"
style={{ width: '100%' }}
value={filters.operatorName || undefined}
onChange={(value) => onChange({ operatorName: value || '' })}
allowClear
showSearch
options={operatorOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
),
},
{
key: 'unclaimed',
label: '未关联',
control: (
<Select
placeholder="全部"
style={{ width: '100%' }}
value={filters.unclaimed || undefined}
onChange={(value) => onChange({ unclaimed: (value ?? '') as PaymentRecordFilters['unclaimed'] })}
allowClear
options={UNCLAIMED_OPTIONS.filter((o) => o.value !== '')}
/>
),
},
], [
bankOptions,
companyEntityOptions,
customerOptions,
dateRangeValue,
filters,
onChange,
operatorOptions,
]);
const { primary, extra } = splitFilterFields(fields, VM_FILTER_PRIMARY_VISIBLE_COUNT);
const showExpand = shouldShowFilterExpand(fields.length);
const extraActiveCount = [filters.operatorName.trim(), filters.unclaimed].filter(Boolean).length;
const expandPanelId = 'pr-filter-expand-panel';
const renderField = (field: FilterFieldDef) => (
<label key={field.key} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
);
return (
<section className="vm-filter-card" data-annotation-id="pr-filter" aria-label="筛选条件">
<header className="vm-filter-header">
<h2 className="vm-filter-title"></h2>
</header>
<div className="vm-filter-grid">
<label className="vm-filter-field">
<span></span>
<Select
placeholder="请选择或输入客户名称"
style={{ width: '100%' }}
value={filters.customerName || undefined}
onChange={(value) => onChange({ customerName: value || '' })}
allowClear
showSearch
options={customerOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
</label>
<label className="vm-filter-field">
<span></span>
<RangePicker
className="lc-filter-select pr-payment-range"
style={{ width: '100%' }}
picker="date"
showTime={false}
format={DATE_FORMAT}
separator="至"
placeholder={['开始日期', '结束日期']}
allowClear
inputReadOnly
value={dateRangeValue}
onChange={(values) => {
onChange({
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
});
}}
/>
</label>
<label className="vm-filter-field">
<span></span>
<Select
placeholder="全部操作人"
style={{ width: '100%' }}
value={filters.operatorName || undefined}
onChange={(value) => onChange({ operatorName: value || '' })}
allowClear
showSearch
options={operatorOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
</label>
<label className="vm-filter-field">
<span></span>
<Select
placeholder="全部"
style={{ width: '100%' }}
value={filters.unclaimed || undefined}
onChange={(value) => onChange({ unclaimed: (value ?? '') as PaymentRecordFilters['unclaimed'] })}
allowClear
options={UNCLAIMED_OPTIONS.filter((o) => o.value !== '')}
/>
</label>
<div className="vm-filter-body">
<div className="vm-filter-grid vm-filter-grid--primary" data-filter-tier="primary">
{primary.map(renderField)}
</div>
{showExpand ? (
<div
className={`vm-filter-expand ${expanded ? 'is-expanded' : ''}`}
aria-hidden={!expanded}
id={expandPanelId}
>
<div className="vm-filter-expand-inner">
<div className="vm-filter-grid vm-filter-expand-grid" data-filter-tier="extra">
{expanded ? extra.map(renderField) : null}
</div>
</div>
</div>
) : null}
</div>
<div className="vm-filter-actions">
{showExpand ? (
<button
type="button"
className="vm-btn vm-btn-link vm-filter-toggle"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={expandPanelId}
>
{expanded ? '收起' : '更多筛选'}
{extraActiveCount > 0 && !expanded ? (
<span className="vm-filter-toggle-badge" aria-label={`已设置 ${extraActiveCount} 项扩展筛选`}>
{extraActiveCount}
</span>
) : null}
<ChevronDown size={14} aria-hidden className={`vm-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
</button>
) : null}
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<Search size={16} aria-hidden />
</button>
</div>

View File

@@ -99,24 +99,24 @@ function ExportIcon() {
const RECEIVED_COLUMNS: ColumnsType<PaymentReceivedDetailRow> = [
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 220, ellipsis: true },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 172, ellipsis: true },
];
const LINKED_COLUMNS: ColumnsType<PaymentLinkedDetailRow> = [
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 200, ellipsis: true },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '认领金额', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '认领金额', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 160, ellipsis: true },
];
const UNLINKED_COLUMNS: ColumnsType<PaymentUnlinkedDetailRow> = [
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 148, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 168, ellipsis: true },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '已认领', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '未关联金额', dataIndex: 'unlinkedAmount', key: 'unlinkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: (v: number) => (
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 100, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '已认领', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 100, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '未关联金额', dataIndex: 'unlinkedAmount', key: 'unlinkedAmount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: (v: number) => (
<span className={v > 0 ? 'pr-unlinked--pending' : undefined}>{formatMoney(v)}</span>
) },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 140, ellipsis: true },

View File

@@ -47,20 +47,35 @@ export function PaymentRecordsTable({
render: (value: string) => value || '—',
},
{
title: '户名',
title: '汇款账户名',
dataIndex: 'customerName',
key: 'customerName',
width: 200,
ellipsis: true,
render: (value: string) => value || '—',
},
{
title: '公司主体',
dataIndex: 'companyEntity',
key: 'companyEntity',
width: 240,
ellipsis: true,
render: (value: string) => value || '—',
},
{
title: '银行',
dataIndex: 'bank',
key: 'bank',
width: 180,
ellipsis: true,
render: (value: string) => value || '—',
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
align: 'right',
className: 'tabular-nums',
className: 'tabular-nums ldb-col-money',
render: (value: number) => formatMoney(value),
},
{
@@ -99,8 +114,7 @@ export function PaymentRecordsTable({
title: '未关联金额',
key: 'unlinked',
width: 120,
align: 'right',
className: 'tabular-nums',
className: 'tabular-nums ldb-col-money',
render: (_, row) => {
const unlinked = calcUnclaimedAmount(row);
return (

View File

@@ -3,6 +3,8 @@
"id": "pr1",
"paymentTime": "2026-01-19 14:30",
"customerName": "安徽驰远供应链管理有限公司",
"companyEntity": "浙江羚牛氢能科技有限公司",
"bank": "中国工商银行嘉兴分行",
"amount": 5700,
"remark": "银行转账",
"linkedBills": [
@@ -17,6 +19,8 @@
"id": "pr2",
"paymentTime": "2026-02-02 10:00",
"customerName": "嘉兴港区韵达快递有限公司",
"companyEntity": "浙江羚牛氢能科技有限公司",
"bank": "招商银行嘉兴分行",
"amount": 3500,
"remark": "分两笔到账",
"linkedBills": [
@@ -29,6 +33,8 @@
"id": "pr3",
"paymentTime": "2026-02-02 16:20",
"customerName": "嘉兴港区韵达快递有限公司",
"companyEntity": "浙江羚牛氢能科技有限公司",
"bank": "招商银行嘉兴分行",
"amount": 1500,
"remark": "第二笔到账",
"linkedBills": [],
@@ -39,6 +45,8 @@
"id": "pr4",
"paymentTime": "2025-12-25 09:15",
"customerName": "上海朔宇物流有限公司",
"companyEntity": "羚牛氢能科技(广东)有限公司",
"bank": "中国建设银行上海分行",
"amount": 2500,
"remark": "部分认领",
"linkedBills": [
@@ -52,6 +60,8 @@
"id": "pr5",
"paymentTime": "2025-12-26 15:40",
"customerName": "上海利合供应链管理有限公司",
"companyEntity": "羚牛氢能科技(广东)有限公司",
"bank": "中国银行上海分行",
"amount": 4000,
"remark": "全额到账",
"linkedBills": [
@@ -64,6 +74,8 @@
"id": "pr6",
"paymentTime": "2026-01-08 11:20",
"customerName": "苏州望亭远方物流有限公司",
"companyEntity": "广州开发区交投氢能运营管理有限公司",
"bank": "中国农业银行苏州分行",
"amount": 1800,
"remark": "待关联氢费账单",
"linkedBills": [],

View File

@@ -176,6 +176,8 @@ function PaymentRecordsContent() {
id: `pr-${Date.now()}`,
paymentTime: values.paymentTime.length === 16 ? `${values.paymentTime}:00` : values.paymentTime,
customerName: values.customerName,
companyEntity: values.companyEntity,
bank: values.bank,
amount: values.amount,
remark: values.remark,
linkedBills: [],
@@ -294,6 +296,7 @@ function PaymentRecordsContent() {
<AddPaymentModal
open={addOpen}
records={records}
onClose={() => setAddOpen(false)}
onConfirm={handleAdd}
/>

View File

@@ -440,7 +440,7 @@
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__money {
width: 15%;
text-align: right;
text-align: left;
white-space: nowrap;
font-size: 11px;
}

View File

@@ -19,6 +19,8 @@ export interface PaymentRecord {
id: string;
paymentTime: string;
customerName: string;
companyEntity: string;
bank: string;
amount: number;
remark: string;
linkedBills: LinkedBill[];
@@ -40,6 +42,8 @@ export interface BillCandidate {
export interface PaymentRecordFilters {
customerName: string;
companyEntity: string;
bank: string;
paymentFrom: string;
paymentTo: string;
operatorName: string;
@@ -48,6 +52,8 @@ export interface PaymentRecordFilters {
export const EMPTY_PAYMENT_FILTERS: PaymentRecordFilters = {
customerName: '',
companyEntity: '',
bank: '',
paymentFrom: '',
paymentTo: '',
operatorName: '',

View File

@@ -37,6 +37,12 @@ export function applyPaymentFilters(
if (filters.customerName.trim()) {
if (row.customerName !== filters.customerName.trim()) return false;
}
if (filters.companyEntity.trim()) {
if (row.companyEntity !== filters.companyEntity.trim()) return false;
}
if (filters.bank.trim()) {
if (row.bank !== filters.bank.trim()) return false;
}
if (filters.operatorName.trim()) {
if (row.operatorName !== filters.operatorName.trim()) return false;
}
@@ -65,6 +71,23 @@ export function buildCustomerOptions(records: PaymentRecord[]): Array<{ value: s
return Array.from(set).sort().map((name) => ({ value: name, label: name }));
}
function buildFieldOptions(records: PaymentRecord[], field: 'companyEntity' | 'bank'): Array<{ value: string; label: string }> {
const set = new Set<string>();
records.forEach((row) => {
const value = row[field];
if (value) set.add(value);
});
return Array.from(set).sort().map((name) => ({ value: name, label: name }));
}
export function buildCompanyEntityOptions(records: PaymentRecord[]): Array<{ value: string; label: string }> {
return buildFieldOptions(records, 'companyEntity');
}
export function buildBankOptions(records: PaymentRecord[]): Array<{ value: string; label: string }> {
return buildFieldOptions(records, 'bank');
}
export function sumPaymentAmount(records: PaymentRecord[]): number {
return records.reduce((sum, row) => sum + (row.amount || 0), 0);
}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "self-operated-business-ledger",
"pageId": "list",
"updatedAt": 1783328981489,
"updatedAt": 1783352657926,
"nodes": [
{
"id": "sob-filter",

View File

@@ -2,6 +2,7 @@ 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 { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField';
import { MultiSelectField } from '../../customer-management/components/MultiSelectField';
import { BRAND_MODEL_MAP, UNDERTAKER_OPTIONS, buildSelfOptions } from '../utils/ledger';
@@ -34,11 +35,14 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
{
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 className="ldb-date-range-sep" aria-hidden></span>
<input type="date" className="vm-input" value={filters.dispatchTo} aria-label="出车结束日期" onChange={(e) => onChange({ dispatchTo: e.target.value })} />
</div>
<DateRangeFilterField
startDate={filters.dispatchFrom}
endDate={filters.dispatchTo}
ariaLabel="出车日期"
startPlaceholder="开始日期"
endPlaceholder="结束日期"
onChange={({ startDate, endDate }) => onChange({ dispatchFrom: startDate, dispatchTo: endDate })}
/>
),
},
{ label: '业务名称', control: <FilterPickerField value={filters.businessName} options={businessOptions} placeholder="请选择业务名称" ariaLabel="业务名称" onChange={(businessName) => onChange({ businessName })} /> },
@@ -91,33 +95,45 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
return (
<section className="vm-filter-card ldb-filter-card" data-annotation-id="sob-filter" aria-label="筛选条件">
<header className="vm-filter-header">
<h1 className="ldb-page-title"></h1>
<h2 className="vm-filter-title"></h2>
</header>
<div className="vm-filter-grid">
{primaryFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))}
</div>
{extraFields.length > 0 && (
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}>
<div className="ldb-filter-expand-inner">
<div className="vm-filter-grid ldb-filter-expand-grid">
{extraFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))}
<div className="ldb-filter-body">
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
{primaryFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))}
</div>
{extraFields.length > 0 && (
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`} aria-hidden={!expanded}>
<div className="ldb-filter-expand-inner">
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
{expanded
? extraFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))
: null}
</div>
</div>
</div>
</div>
)}
<div className="vm-filter-actions">
)}
</div>
<div className="vm-filter-actions ldb-filter-actions">
{extraFields.length > 0 && (
<button type="button" className="vm-btn vm-btn-link ldb-filter-toggle" onClick={() => setExpanded(!expanded)} aria-expanded={expanded}>
<button
type="button"
className="vm-btn vm-btn-link ldb-filter-toggle"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
{expanded ? '收起' : '更多筛选'}
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
</button>

View File

@@ -98,6 +98,14 @@ export function SelfLedgerTable({
}, [records, sortKey, sortDir]);
const allSelected = sortedRecords.length > 0 && sortedRecords.every((r) => selectedIds.has(r.id));
const someSelected = sortedRecords.some((r) => selectedIds.has(r.id)) && !allSelected;
const headerCheckRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (headerCheckRef.current) {
headerCheckRef.current.indeterminate = someSelected;
}
}, [someSelected]);
const handleSort = useCallback((key: string) => {
setSortKey((prev) => {
@@ -168,7 +176,13 @@ export function SelfLedgerTable({
<thead>
<tr>
<th className="ldb-col-check sticky-col-left">
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
<input
ref={headerCheckRef}
type="checkbox"
checked={allSelected}
onChange={(e) => onToggleAll(e.target.checked)}
aria-label="全选"
/>
</th>
{SELF_TABLE_COLUMNS.map((col) => (
<th

View File

@@ -146,7 +146,6 @@ export default function SelfOperatedBusinessLedgerApp() {
onDelete={(row) => setDeleteTarget(row)}
/>
<div className="vm-table-footer ldb-table-footer">
<span className="ldb-table-total"> {filtered.length} </span>
<div className="ldb-table-footer-actions">
<TablePagination
page={Math.min(page, totalPages)}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "supplier-management",
"pageId": "list",
"updatedAt": 1783328981489,
"updatedAt": 1783352657926,
"nodes": [
{
"id": "sm-list-filter",

View File

@@ -223,7 +223,6 @@ export default function SupplierManagementApp() {
onDelete={(record) => showToast(`删除:${record.supplierName}(原型演示)`)}
/>
<div className="vm-table-footer sm-table-footer">
<div className="sm-table-total"> {filtered.length} </div>
<TablePagination
page={Math.min(page, totalPages)}
pageSize={pageSize}

View File

@@ -124,7 +124,7 @@
.sm-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 16px;
flex-wrap: wrap;
}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "vehicle-h2-fee-ledger",
"pageId": "list",
"updatedAt": 1783328981490,
"updatedAt": 1783352657927,
"nodes": [
{
"id": "vh2-header",

View File

@@ -0,0 +1,129 @@
# 车辆维修明细 · 产品需求说明PRD
| 项 | 内容 |
|---|---|
| 文档版本 | v1.1(对齐原型批注与现行页面) |
| 模块名称 | 台账管理 — 车辆维修/保养明细 |
| 交互原型 | `/prototypes/vehicle-maintenance-ledger` |
| 修订说明 | 同步列表列顺序、列名、承担方式列移除、批量导入校验与失败导出 |
---
## 1. 模块定位
面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动(客户承担 → 应收运维费;我司承担 → 运维费成本)。
| 项 | 说明 |
|---|---|
| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |
| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |
| 录入方式 | 工具栏「批量导入」Excel/CSV或表格底部「新增一行」手填 |
| 导入后状态 | **待保存(未保存)**,可补全字段、**手动补传附件** |
| 保存 | 将本人未提交记录标记为 **已保存** |
| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |
| 附件 | 照片、Word、Excel、PDF 等多附件;未提交可补传;已提交默认只读,主管可增删 |
| 数据权限 | **运维人员仅可操作本人记录****他人记录仅可查看** |
| 导入导出 | 批量导入xlsx/csv 模板)与按当前筛选结果导出 Excel |
### 状态流转
```
批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)
```
### 原型验收提示
- 默认以运维人员「张运维」登录演示
- 主管视角URL 追加 `?role=supervisor`
---
## 2. 筛选区
| 筛选项 | 说明 |
|---|---|
| 进修日期 | 日期区间 |
| 车牌号 | 可搜索下拉,精确匹配 |
| 类型 | 多选:维修 / 保养 |
| 提报人 | 多选 |
操作:**重置**、**查询**。
---
## 3. 列表工具栏
| 按钮 | 行为 |
|---|---|
| 批量导入 | 下载模板 → 上传文件 → 校验预览 → 确认导入 |
| 导出 | 导出当前筛选结果 Excel |
| 保存 | 将勾选且本人未提交记录标记为已保存 |
| 确认提交 | 将勾选且本人已保存记录归档为已提交 |
列表上方提示:维修保养台账涉及租赁账单,请及时填报并确认提交。
---
## 4. 列表字段(现行)
从左至右:
序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费(单位/数量/单价/金额)→ 配件费(单位/数量/单价/金额)→ 费用总计(含税)→ 附件 → **提报人****提报时间** → 操作
说明:
- **承担方式列已从列表移除**2026-07 批注);承担方式仍可在导入模板字段中维护,供下游台账汇总口径使用。
- **提报人、提报时间** 固定于操作列前(右侧固定列)。
- 表头「进修日期」已更名为 **进修/保养日期**
行操作:编辑、复制、删除(权限与状态约束)、更多(变更日志等)。
表格底部:**新增一行**。
---
## 5. 批量导入
### 5.1 下载模板
- 支持 `.xlsx` / `.xls` / `.csv`
- 「类型」列下方以 **序列** 展示可选值:`1.维修``2.保养`
- 填写须与枚举 **完全匹配**,否则导入校验失败
### 5.2 上传与校验
- 上传后系统解析并区分 **可导入** / **失败** 条数
- 预览列表最后一列 **备注**
- 通过:显示「可导入」
- 失败:显示具体原因(如类型不匹配、车牌不在清单等)
- 存在失败记录时可 **下载失败记录** Excel含原字段 + 备注列)
### 5.3 确认导入
- 仅导入校验通过的行,状态为 **待保存(未保存)**
- 导入后可补传附件,再执行保存与确认提交
---
## 6. 批注变更记录2026-07
| 序号 | 变更 | 说明 |
|---|---|---|
| 1 | 承担方式 | 先去除必填星号,后 **从列表移除承担方式列** |
| 2 | 提报人 / 提报时间 | 移至 **操作列前** 两列(右侧固定) |
| 3 | 进修/保养日期 | 列表表头「进修日期」改为 **进修/保养日期** |
| 4 | 批量导入 | 模板类型枚举序列展示;导入结果预览 **备注** 列;失败记录可下载 |
---
## 7. 源码入口
| 文件 | 说明 |
|---|---|
| `index.tsx` | 原型入口,挂载页面与 AnnotationViewer |
| `MaintenanceLedgerPage.jsx` | 筛选、表格、导入导出、附件、状态流转 |
| `annotation-source.json` | 标注目录与 PRD 节点 |
| `styles/index.css` | 页面样式 |
| `globals.ts` | React / antd / dayjs / XLSX 全局注入 |
预览地址:`http://localhost:51722/prototypes/vehicle-maintenance-ledger`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,406 @@
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": {
"version": 2,
"prototypeName": "vehicle-maintenance-ledger",
"pageId": "list",
"updatedAt": 1783430400000,
"nodes": [
{
"id": "vmaint-header",
"index": 1,
"title": "页面标题与模块定位",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"vmaint-header\"]"
],
"fingerprint": "vmaint-header",
"path": []
},
"aiPrompt": "说明台账管理模块下车辆维修明细的定位。",
"annotationText": "# 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动。\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账管理 → 车辆维修明细 |\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV或表格底部「新增一行」手填 |\n| 导入后状态 | 标记为 **待保存(未保存)**,可继续补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;本人未提交可随时补传;已提交默认只读,主管编辑后可增删 |\n| 数据权限 | **运维人员仅可操作本人记录****他人记录仅可查看** |\n| 导入导出 | 批量导入xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n## 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n## 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角预览:页面 URL 追加 `?role=supervisor`",
"hasMarkdown": true,
"color": "#0f766e",
"images": [],
"createdAt": 1783400000000,
"updatedAt": 1783430400000,
"controls": []
},
{
"id": "mldb-filter",
"index": 2,
"title": "筛选条件",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"mldb-filter\"]"
],
"fingerprint": "mldb-filter",
"path": []
},
"aiPrompt": "进修日期、车牌、类型、提报人筛选。",
"annotationText": "## 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作**重置**、**查询**。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1783400000000,
"updatedAt": 1783430400000,
"controls": []
},
{
"id": "mldb-toolbar",
"index": 3,
"title": "列表工具栏",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"mldb-toolbar\"]"
],
"fingerprint": "mldb-toolbar",
"path": []
},
"aiPrompt": "批量导入、导出、保存、确认提交。",
"annotationText": "## 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 勾选本人未提交记录后标记为已保存 |\n| 确认提交 | 勾选本人已保存记录后归档为已提交 |",
"hasMarkdown": true,
"color": "#64748b",
"images": [],
"createdAt": 1783400000000,
"updatedAt": 1783430400000,
"controls": []
},
{
"id": "mldb-table-hint",
"index": 4,
"title": "列表提示",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"mldb-table-hint\"]"
],
"fingerprint": "mldb-table-hint",
"path": []
},
"aiPrompt": "租赁账单联动提示文案。",
"annotationText": "维修保养台账涉及租赁账单,请及时填报并确认提交。",
"hasMarkdown": true,
"color": "#64748b",
"images": [],
"createdAt": 1783400000000,
"updatedAt": 1783430400000,
"controls": []
},
{
"id": "mldb-table",
"index": 5,
"title": "维修明细列表",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"mldb-table\"]"
],
"fingerprint": "mldb-table",
"path": []
},
"aiPrompt": "宽表字段、列顺序与行操作。",
"annotationText": "## 列表字段(现行)\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费 → 配件费 → 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n- **承担方式列已从列表移除**2026-07 批注)\n- **提报人、提报时间** 位于操作列前,右侧固定\n- 表头「进修日期」已改为 **进修/保养日期**",
"hasMarkdown": true,
"color": "#0f766e",
"images": [],
"createdAt": 1783400000000,
"updatedAt": 1783430400000,
"controls": []
}
],
"pages": [
{
"id": "list",
"title": "车辆维修明细",
"route": "/list"
}
],
"directory": {
"title": "原型目录",
"nodes": [
{
"type": "folder",
"id": "vmaint-doc-root",
"title": "车辆维修明细说明",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "vmaint-doc-overview",
"title": "模块总览",
"markdown": "# 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动。\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账管理 → 车辆维修明细 |\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV或表格底部「新增一行」手填 |\n| 导入后状态 | 标记为 **待保存(未保存)**,可继续补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;本人未提交可随时补传;已提交默认只读,主管编辑后可增删 |\n| 数据权限 | **运维人员仅可操作本人记录****他人记录仅可查看** |\n| 导入导出 | 批量导入xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n## 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n## 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角预览:页面 URL 追加 `?role=supervisor`"
},
{
"type": "markdown",
"id": "vmaint-doc-prd",
"title": "PRD 全文",
"markdown": "# 车辆维修明细 · 产品需求说明PRD\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.1(对齐原型批注与现行页面) |\n| 模块名称 | 台账管理 — 车辆维修/保养明细 |\n| 交互原型 | `/prototypes/vehicle-maintenance-ledger` |\n| 修订说明 | 同步列表列顺序、列名、承担方式列移除、批量导入校验与失败导出 |\n\n---\n\n## 1. 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动(客户承担 → 应收运维费;我司承担 → 运维费成本)。\n\n| 项 | 说明 |\n|---|---|\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV或表格底部「新增一行」手填 |\n| 导入后状态 | **待保存(未保存)**,可补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;未提交可补传;已提交默认只读,主管可增删 |\n| 数据权限 | **运维人员仅可操作本人记录****他人记录仅可查看** |\n| 导入导出 | 批量导入xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n### 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n### 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角URL 追加 `?role=supervisor`\n\n---\n\n## 2. 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作**重置**、**查询**。\n\n---\n\n## 3. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传文件 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 将勾选且本人未提交记录标记为已保存 |\n| 确认提交 | 将勾选且本人已保存记录归档为已提交 |\n\n列表上方提示维修保养台账涉及租赁账单请及时填报并确认提交。\n\n---\n\n## 4. 列表字段(现行)\n\n从左至右\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费(单位/数量/单价/金额)→ 配件费(单位/数量/单价/金额)→ 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n说明\n\n- **承担方式列已从列表移除**2026-07 批注);承担方式仍可在导入模板字段中维护,供下游台账汇总口径使用。\n- **提报人、提报时间** 固定于操作列前(右侧固定列)。\n- 表头「进修日期」已更名为 **进修/保养日期**。\n\n行操作编辑、复制、删除权限与状态约束、更多变更日志等。\n\n表格底部**新增一行**。\n\n---\n\n## 5. 批量导入\n\n### 5.1 下载模板\n\n- 支持 `.xlsx` / `.xls` / `.csv`\n- 「类型」列下方以 **序列** 展示可选值:`1.维修`、`2.保养`\n- 填写须与枚举 **完全匹配**,否则导入校验失败\n\n### 5.2 上传与校验\n\n- 上传后系统解析并区分 **可导入** / **失败** 条数\n- 预览列表最后一列 **备注**\n - 通过:显示「可导入」\n - 失败:显示具体原因(如类型不匹配、车牌不在清单等)\n- 存在失败记录时可 **下载失败记录** Excel含原字段 + 备注列)\n\n### 5.3 确认导入\n\n- 仅导入校验通过的行,状态为 **待保存(未保存)**\n- 导入后可补传附件,再执行保存与确认提交\n\n---\n\n## 6. 批注变更记录2026-07\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 先去除必填星号,后 **从列表移除承担方式列** |\n| 2 | 提报人 / 提报时间 | 移至 **操作列前** 两列(右侧固定) |\n| 3 | 进修/保养日期 | 列表表头「进修日期」改为 **进修/保养日期** |\n| 4 | 批量导入 | 模板类型枚举序列展示;导入结果预览 **备注** 列;失败记录可下载 |\n\n---\n\n## 7. 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口,挂载页面与 AnnotationViewer |\n| `MaintenanceLedgerPage.jsx` | 筛选、表格、导入导出、附件、状态流转 |\n| `annotation-source.json` | 标注目录与 PRD 节点 |\n| `styles/index.css` | 页面样式 |\n| `globals.ts` | React / antd / dayjs / XLSX 全局注入 |\n\n预览地址`http://localhost:51722/prototypes/vehicle-maintenance-ledger`\n",
"markdownPath": "src/prototypes/vehicle-maintenance-ledger/.spec/requirements-prd.md"
},
{
"type": "markdown",
"id": "vmaint-doc-changelog",
"title": "批注变更记录",
"markdown": "## 批注变更记录2026-07\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 去除必填星号后,**从列表移除承担方式列** |\n| 2 | 提报人/提报时间 | 移至操作列前最后两列 |\n| 3 | 进修/保养日期 | 列表表头更名 |\n| 4 | 批量导入 | 类型枚举序列、备注列预览、失败记录下载 |"
},
{
"type": "folder",
"id": "vmaint-doc-page",
"title": "页面说明",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "vmaint-doc-filter",
"title": "筛选条件",
"markdown": "## 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作**重置**、**查询**。"
},
{
"type": "markdown",
"id": "vmaint-doc-toolbar",
"title": "列表工具栏",
"markdown": "## 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 勾选本人未提交记录后标记为已保存 |\n| 确认提交 | 勾选本人已保存记录后归档为已提交 |"
},
{
"type": "markdown",
"id": "vmaint-doc-table",
"title": "列表字段",
"markdown": "## 列表字段(现行)\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费 → 配件费 → 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n- **承担方式列已从列表移除**2026-07 批注)\n- **提报人、提报时间** 位于操作列前,右侧固定\n- 表头「进修日期」已改为 **进修/保养日期**"
},
{
"type": "markdown",
"id": "vmaint-doc-import",
"title": "批量导入",
"markdown": "## 批量导入\n\n### 模板\n\n- 「类型」列下方序列展示:**1.维修**、**2.保养**\n- 填写须完全匹配,否则校验失败\n\n### 导入结果\n\n- 预览列表最后一列 **备注**:通过显示「可导入」,失败显示原因\n- 可 **下载失败记录** Excel原字段 + 备注列)\n- 确认导入后状态为 **待保存(未保存)**"
}
]
},
{
"type": "markdown",
"id": "vmaint-doc-source",
"title": "源码入口",
"markdown": "## 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口 |\n| `MaintenanceLedgerPage.jsx` | 页面主体 |\n| `annotation-source.json` | 标注与目录 |\n| `.spec/requirements-prd.md` | PRD 全文 |\n\n预览`/prototypes/vehicle-maintenance-ledger`"
},
{
"type": "link",
"id": "vmaint-link-self",
"title": "本原型预览",
"href": "/prototypes/vehicle-maintenance-ledger",
"target": "self"
}
]
}
]
}
},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "folder",
"id": "nav-folder-1782874576229-r8atbu",
"title": "OneOS",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-workbench",
"title": "工作台",
"href": "/prototypes/oneos-web-workbench",
"target": "self"
}
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-login",
"title": "登录",
"href": "/prototypes/oneos-web-login",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-vehicle-asset",
"title": "车辆管理",
"href": "/prototypes/oneos-web-vehicle-asset",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-contract-template",
"title": "合同模板管理",
"href": "/prototypes/oneos-web-contract-template",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-help-center",
"title": "帮助中心",
"href": "/prototypes/oneos-web-help-center",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-finance",
"title": "财务管理",
"href": "/prototypes/oneos-web-finance",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-procurement",
"title": "采购管理",
"href": "/prototypes/oneos-web-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-lease-contract",
"title": "车辆租赁合同",
"href": "/prototypes/oneos-web-lease-contract",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-h2-station",
"title": "加氢站管理",
"href": "/prototypes/oneos-web-h2-station",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-data-analysis",
"title": "数据分析",
"href": "/prototypes/oneos-web-data-analysis",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ledger-data",
"title": "台账数据",
"href": "/prototypes/oneos-web-ledger-data",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-business",
"title": "业务管理",
"href": "/prototypes/oneos-web-business",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ops",
"title": "运维管理",
"href": "/prototypes/oneos-web-ops",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-requirements",
"title": "需求说明",
"href": "/prototypes/oneos-web-requirements",
"target": "self"
}
]
}
]
},
{
"type": "folder",
"id": "vmaint-doc-root",
"title": "车辆维修明细说明",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "vmaint-doc-overview",
"title": "模块总览",
"markdown": "# 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动。\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账管理 → 车辆维修明细 |\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV或表格底部「新增一行」手填 |\n| 导入后状态 | 标记为 **待保存(未保存)**,可继续补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;本人未提交可随时补传;已提交默认只读,主管编辑后可增删 |\n| 数据权限 | **运维人员仅可操作本人记录****他人记录仅可查看** |\n| 导入导出 | 批量导入xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n## 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n## 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角预览:页面 URL 追加 `?role=supervisor`"
},
{
"type": "markdown",
"id": "vmaint-doc-prd",
"title": "PRD 全文",
"markdown": "# 车辆维修明细 · 产品需求说明PRD\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.1(对齐原型批注与现行页面) |\n| 模块名称 | 台账管理 — 车辆维修/保养明细 |\n| 交互原型 | `/prototypes/vehicle-maintenance-ledger` |\n| 修订说明 | 同步列表列顺序、列名、承担方式列移除、批量导入校验与失败导出 |\n\n---\n\n## 1. 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动(客户承担 → 应收运维费;我司承担 → 运维费成本)。\n\n| 项 | 说明 |\n|---|---|\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV或表格底部「新增一行」手填 |\n| 导入后状态 | **待保存(未保存)**,可补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;未提交可补传;已提交默认只读,主管可增删 |\n| 数据权限 | **运维人员仅可操作本人记录****他人记录仅可查看** |\n| 导入导出 | 批量导入xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n### 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n### 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角URL 追加 `?role=supervisor`\n\n---\n\n## 2. 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作**重置**、**查询**。\n\n---\n\n## 3. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传文件 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 将勾选且本人未提交记录标记为已保存 |\n| 确认提交 | 将勾选且本人已保存记录归档为已提交 |\n\n列表上方提示维修保养台账涉及租赁账单请及时填报并确认提交。\n\n---\n\n## 4. 列表字段(现行)\n\n从左至右\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费(单位/数量/单价/金额)→ 配件费(单位/数量/单价/金额)→ 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n说明\n\n- **承担方式列已从列表移除**2026-07 批注);承担方式仍可在导入模板字段中维护,供下游台账汇总口径使用。\n- **提报人、提报时间** 固定于操作列前(右侧固定列)。\n- 表头「进修日期」已更名为 **进修/保养日期**。\n\n行操作编辑、复制、删除权限与状态约束、更多变更日志等。\n\n表格底部**新增一行**。\n\n---\n\n## 5. 批量导入\n\n### 5.1 下载模板\n\n- 支持 `.xlsx` / `.xls` / `.csv`\n- 「类型」列下方以 **序列** 展示可选值:`1.维修`、`2.保养`\n- 填写须与枚举 **完全匹配**,否则导入校验失败\n\n### 5.2 上传与校验\n\n- 上传后系统解析并区分 **可导入** / **失败** 条数\n- 预览列表最后一列 **备注**\n - 通过:显示「可导入」\n - 失败:显示具体原因(如类型不匹配、车牌不在清单等)\n- 存在失败记录时可 **下载失败记录** Excel含原字段 + 备注列)\n\n### 5.3 确认导入\n\n- 仅导入校验通过的行,状态为 **待保存(未保存)**\n- 导入后可补传附件,再执行保存与确认提交\n\n---\n\n## 6. 批注变更记录2026-07\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 先去除必填星号,后 **从列表移除承担方式列** |\n| 2 | 提报人 / 提报时间 | 移至 **操作列前** 两列(右侧固定) |\n| 3 | 进修/保养日期 | 列表表头「进修日期」改为 **进修/保养日期** |\n| 4 | 批量导入 | 模板类型枚举序列展示;导入结果预览 **备注** 列;失败记录可下载 |\n\n---\n\n## 7. 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口,挂载页面与 AnnotationViewer |\n| `MaintenanceLedgerPage.jsx` | 筛选、表格、导入导出、附件、状态流转 |\n| `annotation-source.json` | 标注目录与 PRD 节点 |\n| `styles/index.css` | 页面样式 |\n| `globals.ts` | React / antd / dayjs / XLSX 全局注入 |\n\n预览地址`http://localhost:51722/prototypes/vehicle-maintenance-ledger`\n",
"markdownPath": "src/prototypes/vehicle-maintenance-ledger/.spec/requirements-prd.md"
},
{
"type": "markdown",
"id": "vmaint-doc-changelog",
"title": "批注变更记录",
"markdown": "## 批注变更记录2026-07\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 去除必填星号后,**从列表移除承担方式列** |\n| 2 | 提报人/提报时间 | 移至操作列前最后两列 |\n| 3 | 进修/保养日期 | 列表表头更名 |\n| 4 | 批量导入 | 类型枚举序列、备注列预览、失败记录下载 |"
},
{
"type": "folder",
"id": "vmaint-doc-page",
"title": "页面说明",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "vmaint-doc-filter",
"title": "筛选条件",
"markdown": "## 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作**重置**、**查询**。"
},
{
"type": "markdown",
"id": "vmaint-doc-toolbar",
"title": "列表工具栏",
"markdown": "## 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 勾选本人未提交记录后标记为已保存 |\n| 确认提交 | 勾选本人已保存记录后归档为已提交 |"
},
{
"type": "markdown",
"id": "vmaint-doc-table",
"title": "列表字段",
"markdown": "## 列表字段(现行)\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费 → 配件费 → 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n- **承担方式列已从列表移除**2026-07 批注)\n- **提报人、提报时间** 位于操作列前,右侧固定\n- 表头「进修日期」已改为 **进修/保养日期**"
},
{
"type": "markdown",
"id": "vmaint-doc-import",
"title": "批量导入",
"markdown": "## 批量导入\n\n### 模板\n\n- 「类型」列下方序列展示:**1.维修**、**2.保养**\n- 填写须完全匹配,否则校验失败\n\n### 导入结果\n\n- 预览列表最后一列 **备注**:通过显示「可导入」,失败显示原因\n- 可 **下载失败记录** Excel原字段 + 备注列)\n- 确认导入后状态为 **待保存(未保存)**"
}
]
},
{
"type": "markdown",
"id": "vmaint-doc-source",
"title": "源码入口",
"markdown": "## 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口 |\n| `MaintenanceLedgerPage.jsx` | 页面主体 |\n| `annotation-source.json` | 标注与目录 |\n| `.spec/requirements-prd.md` | PRD 全文 |\n\n预览`/prototypes/vehicle-maintenance-ledger`"
},
{
"type": "link",
"id": "vmaint-link-self",
"title": "本原型预览",
"href": "/prototypes/vehicle-maintenance-ledger",
"target": "self"
}
]
}
]
}
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import * as antd from 'antd';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
declare global {
interface Window {
React: typeof React;
antd: typeof antd;
dayjs: typeof dayjs;
XLSX: typeof XLSX;
}
}
window.React = React;
window.antd = antd;
window.dayjs = dayjs;
window.XLSX = XLSX;

View File

@@ -0,0 +1,41 @@
/**
* @name 车辆维修明细
* 设计规范ledger-shared/DESIGN.md · vm-page + ldb-page与租赁业务台账对齐
*/
import React, { useMemo } from 'react';
import {
AnnotationViewer,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import '../vehicle-management/style.css';
import './styles/index.css';
import './globals';
import 'antd/dist/reset.css';
import MaintenanceLedgerPage from './MaintenanceLedgerPage.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 (
<>
<MaintenanceLedgerPage />
<AnnotationViewer
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</>
);
}

View File

@@ -0,0 +1,640 @@
/* 车辆维修明细 — 台账管理 · 对齐 lease-business-ledger / ledger-shared DESIGN.md */
@import '../../lease-business-ledger/styles/index.css';
.mldb-page .vm-filter-field .ant-picker,
.mldb-page .vm-filter-field .ant-select {
width: 100%;
}
.mldb-page .ldb-page-header {
margin-bottom: 16px;
}
.mldb-page .ldb-page-header h1 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--ln-ink);
letter-spacing: -0.02em;
}
.mldb-page .vm-filter-field .ant-picker,
.mldb-page .vm-filter-field .ant-select .ant-select-selector {
min-height: var(--vm-control-height);
border-radius: var(--ln-radius-control);
border-color: var(--ln-hairline);
font-size: 0.875rem;
}
.mldb-page .vm-filter-field .ant-picker:hover,
.mldb-page .vm-filter-field .ant-select:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--ln-primary);
}
.mldb-page .vm-filter-field .ant-picker-focused,
.mldb-page .vm-filter-field .ant-select-focused .ant-select-selector {
border-color: var(--ln-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary) 18%, transparent);
}
.mldb-page .vm-table-card > .ldb-table-wrap {
overflow: auto;
scrollbar-gutter: stable;
max-height: calc(100vh - 640px);
min-width: 0;
}
/* Ant Design Table → 租赁业务台账 ldb-table 视觉规范 */
.mldb-page .mldb-ant-table.ldb-table {
min-width: 100%;
}
.mldb-page .mldb-ant-table .ant-table {
font-size: 0.8125rem;
background: transparent;
}
.mldb-page .mldb-ant-table .ant-table-container,
.mldb-page .mldb-ant-table .ant-table-content,
.mldb-page .mldb-ant-table table {
border-radius: 0;
}
.mldb-page .mldb-ant-table .ant-table-thead > tr > th,
.mldb-page .mldb-ant-table .ant-table-thead .ant-table-cell {
padding: 10px 12px !important;
height: auto !important;
white-space: nowrap;
color: var(--ln-muted) !important;
font-weight: 500 !important;
font-size: 0.75rem !important;
letter-spacing: 0;
text-transform: none;
background: var(--ln-canvas-soft) !important;
border-bottom: 1px solid var(--ln-hairline) !important;
border-inline-end: none !important;
text-align: left;
vertical-align: middle;
transition: background-color 0.18s ease;
}
.mldb-page .mldb-ant-table .ant-table-thead > tr > th.ant-table-cell-fix-left,
.mldb-page .mldb-ant-table .ant-table-thead > tr > th.ant-table-cell-fix-right,
.mldb-page .mldb-ant-table .ant-table-thead > tr > th.sticky-col-left,
.mldb-page .mldb-ant-table .ant-table-thead > tr > th.sticky-col-right {
z-index: 3;
background: var(--ln-canvas-soft) !important;
}
.mldb-page .mldb-ant-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
padding: 10px 12px !important;
vertical-align: middle !important;
font-size: 0.8125rem;
color: var(--ln-ink);
border-bottom: 1px solid var(--ln-hairline) !important;
border-inline-end: none !important;
background: var(--ln-surface-card);
transition: background-color 0.18s ease;
}
.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td {
background: var(--ln-canvas-soft) !important;
}
.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.ant-table-cell-fix-left,
.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.ant-table-cell-fix-right,
.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.sticky-col-left,
.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.sticky-col-right {
background: var(--ln-canvas-soft) !important;
}
.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.ant-table-cell-fix-left,
.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.ant-table-cell-fix-right,
.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.sticky-col-left,
.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.sticky-col-right {
z-index: 2;
background: var(--ln-surface-card);
}
.mldb-page .mldb-ant-table .ant-table-cell-fix-left-last::after,
.mldb-page .mldb-ant-table .ant-table-cell-fix-right-first::after {
box-shadow: none !important;
}
.mldb-page .mldb-ant-table .ant-table-summary > tr > td {
font-weight: 700;
background: var(--ln-surface-muted, #f8fafc) !important;
color: var(--ln-ink) !important;
border-top: 2px solid var(--ln-hairline) !important;
border-bottom: none !important;
padding: 10px 12px !important;
}
.mldb-page .mldb-ant-table .ant-table-summary .ldb-col-money {
text-align: left;
}
.mldb-page .ldb-cell-readonly {
color: var(--ln-muted);
background: var(--ln-surface-muted, #f8fafc);
border-radius: var(--ln-radius-xs);
padding: 4px 8px;
}
.mldb-page .maint-inline-input {
width: 100%;
}
.mldb-page .mldb-col-status {
white-space: nowrap;
}
.mldb-page .mldb-status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
max-width: 100%;
}
.mldb-page .mldb-table-hint {
display: inline-flex;
align-items: flex-start;
gap: 8px;
margin: 0;
flex: 0 1 auto;
width: fit-content;
max-width: 100%;
padding: 8px 12px;
border-radius: var(--ln-radius-control);
border: 1px solid #fcd34d;
background: #fffbeb;
color: #92400e;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.45;
}
.mldb-page .mldb-table-hint__icon {
flex-shrink: 0;
margin-top: 2px;
color: #d97706;
}
.mldb-page .mldb-table-hint__text {
margin: 0;
min-width: 0;
white-space: nowrap;
}
@media (max-width: 960px) {
.mldb-page .mldb-table-hint {
width: auto;
}
.mldb-page .mldb-table-hint__text {
white-space: normal;
}
}
.mldb-page .vm-table-toolbar.ldb-table-toolbar {
justify-content: space-between;
gap: 12px;
}
.mldb-page .maint-repair-date-picker,
.mldb-page .maint-repair-date-picker .ant-picker-input {
width: 100%;
min-width: 132px;
}
.mldb-page .maint-repair-date-picker .ant-picker-input > input {
min-width: 118px;
}
.mldb-page .maint-row-tier-0 > td {
background: var(--ln-surface-muted, #f8fafc) !important;
}
.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-tier-0:hover > td {
background: color-mix(in srgb, var(--ln-canvas-soft) 85%, var(--ln-surface-muted, #f8fafc)) !important;
}
.mldb-page .maint-row-tier-1 > td {
background: var(--ln-surface-card) !important;
}
.mldb-page .maint-row-tier-2 > td {
background: #fffbeb !important;
}
.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-tier-2:hover > td {
background: color-mix(in srgb, var(--ln-canvas-soft) 70%, #fffbeb) !important;
}
.mldb-page .maint-row-tier-boundary > td {
border-top: 2px solid color-mix(in srgb, var(--ln-primary) 35%, var(--ln-hairline)) !important;
}
.mldb-page .maint-action-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--ln-radius-control);
cursor: pointer;
color: var(--ln-muted);
transition: background 0.15s ease, color 0.15s ease;
}
.mldb-page .maint-action-icon-btn:hover {
background: color-mix(in srgb, var(--ln-primary) 10%, transparent);
color: var(--ln-primary);
}
.mldb-page .maint-action-icon-btn.maint-action-icon-danger:hover {
background: color-mix(in srgb, var(--ln-error) 10%, transparent);
color: var(--ln-error);
}
.mldb-page .maint-action-icon-btn.is-disabled {
color: var(--ln-muted-soft);
cursor: not-allowed;
pointer-events: none;
}
.mldb-page .maint-row-more-btn.maint-action-icon-btn:hover {
background: var(--ln-surface-muted, #f5f5f5);
color: var(--ln-ink);
}
.mldb-page .mldb-add-row-btn {
width: 100%;
margin-top: 12px;
}
.mldb-page .mldb-add-row-btn.ant-btn-dashed {
border-color: var(--ln-hairline);
color: var(--ln-muted);
border-radius: var(--ln-radius-control);
min-height: var(--vm-control-height);
}
.mldb-page .mldb-add-row-btn.ant-btn-dashed:hover {
border-color: var(--ln-primary);
color: var(--ln-primary);
}
.mldb-page .mldb-bear-by-tag {
display: inline-block;
padding: 2px 8px;
border-radius: var(--ln-radius-pill);
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
.mldb-page .mldb-bear-by-tag--customer {
color: #0369a1;
background: #e0f2fe;
}
.mldb-page .mldb-bear-by-tag--company {
color: #b45309;
background: #fef3c7;
}
.mldb-page .mldb-attach-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 44px;
min-height: 32px;
padding: 4px 8px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-card);
color: var(--ln-muted);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.mldb-page .mldb-attach-btn:hover {
border-color: var(--ln-primary);
color: var(--ln-primary);
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
}
.mldb-page .mldb-attach-btn:focus-visible {
outline: 2px solid var(--ln-primary);
outline-offset: 2px;
}
.mldb-page .mldb-attach-btn.has-files {
color: var(--ln-primary);
border-color: color-mix(in srgb, var(--ln-primary) 35%, var(--ln-hairline));
background: color-mix(in srgb, var(--ln-primary) 8%, var(--ln-surface-card));
}
.mldb-page .mldb-attach-count {
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: var(--ln-radius-pill);
background: var(--ln-primary);
color: #fff;
font-size: 0.6875rem;
font-weight: 600;
line-height: 16px;
text-align: center;
}
.mldb-attach-modal .mldb-attach-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.mldb-attach-modal .mldb-attach-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 240px;
overflow: auto;
}
.mldb-attach-modal .mldb-attach-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-muted, #f8fafc);
}
.mldb-attach-modal .mldb-attach-kind {
flex-shrink: 0;
padding: 2px 8px;
border-radius: var(--ln-radius-pill);
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
}
.mldb-attach-modal .mldb-attach-kind--image {
color: #0369a1;
background: #e0f2fe;
}
.mldb-attach-modal .mldb-attach-kind--word {
color: #1d4ed8;
background: #dbeafe;
}
.mldb-attach-modal .mldb-attach-kind--excel {
color: #047857;
background: #d1fae5;
}
.mldb-attach-modal .mldb-attach-kind--pdf {
color: #b91c1c;
background: #fee2e2;
}
.mldb-attach-modal .mldb-attach-kind--other {
color: #475569;
background: #e2e8f0;
}
.mldb-attach-modal .mldb-attach-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mldb-attach-modal .mldb-attach-name {
border: none;
background: none;
padding: 0;
text-align: left;
color: var(--ln-ink);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mldb-attach-modal .mldb-attach-name:hover {
color: var(--ln-primary);
text-decoration: underline;
}
.mldb-attach-modal .mldb-attach-name:focus-visible {
outline: 2px solid var(--ln-primary);
outline-offset: 2px;
}
.mldb-attach-modal .mldb-attach-size {
font-size: 0.75rem;
color: var(--ln-muted);
}
.mldb-attach-modal .mldb-attach-remove {
flex-shrink: 0;
border: none;
background: none;
color: var(--ln-error, #dc2626);
font-size: 0.75rem;
cursor: pointer;
padding: 4px 6px;
border-radius: var(--ln-radius-control);
}
.mldb-attach-modal .mldb-attach-remove:hover {
background: color-mix(in srgb, var(--ln-error, #dc2626) 10%, transparent);
}
.mldb-attach-modal .mldb-attach-empty {
margin: 0;
padding: 12px;
text-align: center;
color: var(--ln-muted);
font-size: 0.875rem;
background: var(--ln-surface-muted, #f8fafc);
border-radius: var(--ln-radius-control);
}
.mldb-attach-modal .mldb-attach-readonly-hint {
margin: 0;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.mldb-attach-modal .mldb-attach-upload.ant-upload-wrapper .ant-upload-drag {
border-radius: var(--ln-radius-control) !important;
border-color: var(--ln-hairline) !important;
background: var(--ln-surface-card) !important;
}
.mldb-attach-modal .mldb-attach-upload.ant-upload-wrapper .ant-upload-drag:hover {
border-color: var(--ln-primary) !important;
}
.mldb-attach-modal .mldb-attach-upload-title {
margin: 0 0 4px;
font-size: 0.875rem;
font-weight: 500;
color: var(--ln-ink);
}
.mldb-attach-modal .mldb-attach-upload-hint {
margin: 0;
font-size: 0.75rem;
color: var(--ln-muted);
line-height: 1.5;
}
.mldb-import-template-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
margin-bottom: 12px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-muted, #f8fafc);
}
.mldb-import-template-text strong {
display: block;
margin-bottom: 4px;
color: var(--ln-ink);
font-size: 0.875rem;
}
.mldb-import-template-text p {
margin: 0;
font-size: 0.8125rem;
color: var(--ln-muted);
line-height: 1.5;
}
.mldb-import-template-btn {
flex-shrink: 0;
border-radius: var(--ln-radius-control) !important;
font-weight: 600 !important;
}
.mldb-import-alert {
margin-bottom: 12px !important;
border-radius: var(--ln-radius-control) !important;
}
.mldb-import-upload.ant-upload-wrapper .ant-upload-drag {
border-radius: var(--ln-radius-control) !important;
border-color: var(--ln-hairline) !important;
background: var(--ln-surface-card) !important;
}
.mldb-import-upload-title {
margin: 0 0 4px;
font-size: 0.875rem;
font-weight: 500;
color: var(--ln-ink);
}
.mldb-import-upload-hint {
margin: 0;
font-size: 0.75rem;
color: var(--ln-muted);
}
.mldb-import-preview {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-muted, #f8fafc);
}
.mldb-import-preview-title {
font-weight: 600;
margin-bottom: 6px;
color: var(--ln-ink);
font-size: 0.8125rem;
}
.mldb-import-preview-stats {
margin-bottom: 8px;
font-size: 0.8125rem;
}
.mldb-import-preview-ok {
color: #047857;
font-weight: 600;
}
.mldb-import-preview-err {
margin-left: 12px;
color: #dc2626;
font-weight: 600;
}
.mldb-import-error-list {
margin: 0;
padding-left: 18px;
font-size: 0.75rem;
color: #b91c1c;
line-height: 1.6;
max-height: 160px;
overflow: auto;
}
.mldb-import-fail-actions {
margin-bottom: 8px;
}
.mldb-import-fail-download {
padding-left: 0 !important;
min-height: 44px;
font-weight: 600;
}
.mldb-import-preview-table {
margin-top: 4px;
}
.mldb-import-preview-table .ant-table {
font-size: 0.75rem;
}
.mldb-import-preview-row--error > td {
background: #fef2f2 !important;
}
.mldb-import-preview-remark-ok {
color: #047857;
font-weight: 600;
}
.mldb-import-preview-remark-err {
color: #b91c1c;
}
@media (prefers-reduced-motion: reduce) {
.mldb-page .maint-action-icon-btn {
transition: none;
}
}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "vehicle-management",
"pageId": "list",
"updatedAt": 1783328981491,
"updatedAt": 1783352657928,
"nodes": [
{
"id": "vm-doc-toolbar",

View File

@@ -21,9 +21,9 @@ function formatDisplayText(
startPlaceholder: string,
endPlaceholder: string,
): string {
if (startDate && endDate) return `${startDate} ~ ${endDate}`;
if (startDate) return `${startDate} ~ ${endPlaceholder}`;
if (endDate) return `${startPlaceholder} ~ ${endDate}`;
if (startDate && endDate) return `${startDate} ${endDate}`;
if (startDate) return `${startDate} ${endPlaceholder}`;
if (endDate) return `${startPlaceholder} ${endDate}`;
return '';
}
@@ -39,7 +39,7 @@ export function DateRangeFilterField({
const fieldRef = useRef<HTMLDivElement>(null);
const hasValue = Boolean(startDate || endDate);
const displayText = formatDisplayText(startDate, endDate, startPlaceholder, endPlaceholder);
const placeholder = `${startPlaceholder} ~ ${endPlaceholder}`;
const placeholder = `${startPlaceholder} ${endPlaceholder}`;
const dismiss = useCallback(() => {
setOpen(false);

View File

@@ -189,17 +189,14 @@ export function DetailAccidentRecordsTab({
</tbody>
</table>
</div>
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
<span className="vm-table-total"> {filtered.length} </span>
{filtered.length > 0 && (
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
<div className="vm-table-footer vm-table-footer--compact">
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</div>

View File

@@ -180,17 +180,14 @@ export function DetailAnnualReviewRecordsTab({
</tbody>
</table>
</div>
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
<span className="vm-table-total"> {filtered.length} </span>
{filtered.length > 0 && (
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
<div className="vm-table-footer vm-table-footer--compact">
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</div>

View File

@@ -249,17 +249,14 @@ export function DetailFaultRecordsTab({
</tbody>
</table>
</div>
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
<span className="vm-table-total"> {filtered.length} </span>
{filtered.length > 0 && (
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
<div className="vm-table-footer vm-table-footer--compact">
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</div>

View File

@@ -232,17 +232,14 @@ export function DetailLeaseRecordsTab({
</tbody>
</table>
</div>
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
<span className="vm-table-total"> {filtered.length} </span>
{filtered.length > 0 && (
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
<div className="vm-table-footer vm-table-footer--compact">
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</div>

View File

@@ -183,17 +183,14 @@ export function DetailMovementRecordsTab({
</tbody>
</table>
</div>
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
<span className="vm-table-total"> {filtered.length} </span>
{filtered.length > 0 && (
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
<div className="vm-table-footer vm-table-footer--compact">
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</div>

View File

@@ -166,17 +166,14 @@ export function DetailTransferRecordsTab({
</tbody>
</table>
</div>
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
<span className="vm-table-total"> {filtered.length} </span>
{filtered.length > 0 && (
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
<div className="vm-table-footer vm-table-footer--compact">
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</div>

View File

@@ -188,17 +188,14 @@ export function DetailViolationRecordsTab({
</tbody>
</table>
</div>
<div className="vm-table-footer vm-table-footer--split vm-table-footer--compact">
<span className="vm-table-total"> {filtered.length} </span>
{filtered.length > 0 && (
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
<div className="vm-table-footer vm-table-footer--compact">
<TablePagination
page={currentPage}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, X } from 'lucide-react';
import { useDismissOnFocusOutside } from './useDismissOnFocusOutside';
@@ -8,6 +9,21 @@ interface FilterPickerFieldProps {
placeholder?: string;
ariaLabel?: string;
onChange: (value: string) => void;
/** 输入时大小写不敏感模糊匹配 */
caseInsensitive?: boolean;
/** 输入框始终可编辑,聚焦即展开下拉(未输入时展示全部选项) */
alwaysSearchable?: boolean;
}
interface PopoverPosition {
top: number;
left: number;
width: number;
}
function normalizeForMatch(text: string, caseInsensitive: boolean): string {
const trimmed = text.trim();
return caseInsensitive ? trimmed.toUpperCase() : trimmed;
}
export function FilterPickerField({
@@ -16,16 +32,22 @@ export function FilterPickerField({
placeholder = '请选择',
ariaLabel = '筛选选择',
onChange,
caseInsensitive = false,
alwaysSearchable = false,
}: FilterPickerFieldProps) {
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [query, setQuery] = useState('');
const fieldRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0, width: 0 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setQuery(value);
}, [value, editing]);
if (open) return;
if (!alwaysSearchable && editing) return;
setQuery(value);
}, [value, editing, open, alwaysSearchable]);
const dismiss = useCallback(() => {
setOpen(false);
@@ -33,13 +55,36 @@ export function FilterPickerField({
setQuery(value);
}, [value]);
useDismissOnFocusOutside(open, fieldRef, dismiss);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
const updatePosition = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
setPosition({
top: rect.bottom + 6,
left: rect.left,
width: rect.width,
});
}, []);
useEffect(() => {
if (!open) return;
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open, updatePosition, options.length, query]);
const filtered = useMemo(() => {
const q = query.trim();
if (!q) return options;
return options.filter((item) => item.includes(q));
}, [options, query]);
const nq = normalizeForMatch(q, caseInsensitive);
return options.filter((item) => normalizeForMatch(item, caseInsensitive).includes(nq));
}, [options, query, caseInsensitive]);
const pick = (next: string) => {
onChange(next);
@@ -56,37 +101,98 @@ export function FilterPickerField({
setQuery('');
setOpen(false);
setEditing(false);
window.requestAnimationFrame(() => {
updatePosition();
inputRef.current?.focus();
});
};
const openPicker = () => {
setOpen(true);
setQuery(value);
if (!alwaysSearchable) {
setEditing(true);
window.requestAnimationFrame(() => inputRef.current?.select());
}
window.requestAnimationFrame(updatePosition);
};
const togglePicker = () => {
if (open) {
dismiss();
return;
}
openPicker();
inputRef.current?.focus();
};
const startEdit = () => {
setEditing(true);
setOpen(true);
setQuery(value);
window.requestAnimationFrame(() => inputRef.current?.select());
};
const inputValue = alwaysSearchable || editing ? query : value;
const inputReadOnly = !alwaysSearchable && !editing;
const popover = open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="vm-filter-picker-popover vm-filter-picker-popover--portal"
role="listbox"
aria-label={ariaLabel}
style={{
top: position.top,
left: position.left,
width: position.width,
}}
>
<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>,
document.body,
)
: null;
return (
<div className="vm-filter-picker-field" ref={fieldRef}>
<div className="vm-filter-picker-field" ref={anchorRef}>
<div className={`vm-filter-picker-control ${open ? 'open' : ''}`}>
<input
ref={inputRef}
type="text"
className="vm-filter-picker-input"
value={editing ? query : value}
value={inputValue}
placeholder={placeholder}
aria-label={ariaLabel}
aria-expanded={open}
aria-autocomplete="list"
role="combobox"
readOnly={!editing}
onFocus={startEdit}
readOnly={inputReadOnly}
onFocus={openPicker}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
if (!alwaysSearchable) setEditing(true);
window.requestAnimationFrame(updatePosition);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') dismiss();
if (event.key === 'Enter' && filtered.length === 1) {
event.preventDefault();
pick(filtered[0]);
}
}}
/>
{value ? (
@@ -100,32 +206,19 @@ export function FilterPickerField({
<X size={14} aria-hidden />
</button>
) : null}
<ChevronDown size={14} className="vm-filter-picker-chevron" aria-hidden />
<button
type="button"
className="vm-filter-picker-chevron-btn"
aria-label={`${open ? '收起' : '展开'}${ariaLabel}列表`}
aria-expanded={open}
tabIndex={-1}
onMouseDown={(event) => event.preventDefault()}
onClick={togglePicker}
>
<ChevronDown size={14} aria-hidden className={`vm-filter-picker-chevron ${open ? 'is-open' : ''}`} />
</button>
</div>
{open && (
<div className="vm-filter-picker-popover" role="listbox" aria-label={ariaLabel}>
<div className="vm-filter-picker-options">
{filtered.length === 0 ? (
<p className="vm-filter-picker-empty"></p>
) : (
filtered.map((item) => (
<button
key={item}
type="button"
role="option"
aria-selected={value === item}
className={`vm-filter-picker-option ${value === item ? 'checked' : ''}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => pick(item)}
>
{item}
</button>
))
)}
</div>
</div>
)}
{popover}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More