迭代 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
67
src/common/vehicle-brand-model-catalog.js
Normal file
67
src/common/vehicle-brand-model-catalog.js
Normal 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
118
src/common/vm-checkbox.css
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/common/vm-filter-panel.ts
Normal file
29
src/common/vm-filter-panel.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,53 +2,107 @@
|
||||
|
||||
- 审查目标:`src/prototypes/contract-template-management`
|
||||
- 用户资料/参考资料:
|
||||
- 本轮用户任务:处理评审结论中 P1–P3 共 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 it is too large
Load Diff
File diff suppressed because one or more lines are too long
27
src/prototypes/contract-template-management/contract-template-docx-fidelity.js
vendored
Normal file
27
src/prototypes/contract-template-management/contract-template-docx-fidelity.js
vendored
Normal 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 || {})
|
||||
);
|
||||
}
|
||||
173
src/prototypes/contract-template-management/contract-template-section-headings.js
vendored
Normal file
173
src/prototypes/contract-template-management/contract-template-section-headings.js
vendored
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
341
src/prototypes/contract-template-management/contract-template-word-import.js
vendored
Normal file
341
src/prototypes/contract-template-management/contract-template-word-import.js
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "customer-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981485,
|
||||
"updatedAt": 1783352657922,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "cm-list-filter",
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "insurance-procurement",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981486,
|
||||
"updatedAt": 1783352657923,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ipc-header",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 里程取账单时间段的数据,自动展示。',
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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 />
|
||||
查询
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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=客户承担 的维保金额;可关联 linkedMaintenanceIds(sumMaintenanceForBill)。',
|
||||
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': {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
src/prototypes/lease-business-ledger/utils/constants.ts
Normal file
23
src/prototypes/lease-business-ledger/utils/constants.ts
Normal 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];
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-line-overview",
|
||||
"pageId": "overview",
|
||||
"updatedAt": 1783328981487,
|
||||
"updatedAt": 1783352657924,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lblo-system-intro",
|
||||
|
||||
@@ -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: '打开车辆维修明细',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-contract-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981487,
|
||||
"updatedAt": 1783352657925,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lc-list-filter",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
@@ -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。已欠费站点不计入预警。
|
||||
|
||||
**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 / 低频1–2 / 无加氢0
|
||||
- KPI:全部 / 签约·普通 / **预付余额预警** / **已欠费** / 无加氢
|
||||
- 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作
|
||||
- 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
## KPI 口径(已确认)
|
||||
|
||||
全部 / 签约·普通 / **高频(≥3 次)** / **低频(1–2 次)** / **无加氢(0 次)**,与《站点管理使用说明书》及页面内嵌 PRD 一致。
|
||||
全部 / 签约·普通 / **预付余额预警** / **已欠费** / **无加氢(0 次)**,与 legacy《站点信息-产品需求说明》一致。
|
||||
|
||||
预警/欠费 KPI 点击打开站点列表弹窗,支持一键发起充值单。详见 `.spec/requirements-prd-kpi-balance-alert.md`。
|
||||
|
||||
## 角色演示
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "self-operated-business-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981489,
|
||||
"updatedAt": 1783352657926,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sob-filter",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "supplier-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981489,
|
||||
"updatedAt": 1783352657926,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sm-list-filter",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "vehicle-h2-fee-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981490,
|
||||
"updatedAt": 1783352657927,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "vh2-header",
|
||||
|
||||
@@ -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`
|
||||
2941
src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx
Normal file
2941
src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
406
src/prototypes/vehicle-maintenance-ledger/annotation-source.json
Normal file
406
src/prototypes/vehicle-maintenance-ledger/annotation-source.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
18
src/prototypes/vehicle-maintenance-ledger/globals.ts
Normal file
18
src/prototypes/vehicle-maintenance-ledger/globals.ts
Normal 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;
|
||||
41
src/prototypes/vehicle-maintenance-ledger/index.tsx
Normal file
41
src/prototypes/vehicle-maintenance-ledger/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
640
src/prototypes/vehicle-maintenance-ledger/styles/index.css
Normal file
640
src/prototypes/vehicle-maintenance-ledger/styles/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "vehicle-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981491,
|
||||
"updatedAt": 1783352657928,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "vm-doc-toolbar",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user