迭代 ONE-OS 多原型:统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步导航与合包页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-12 22:44:21 +08:00
parent 824512a224
commit 131b963e4a
317 changed files with 38569 additions and 12009 deletions

View File

@@ -0,0 +1,263 @@
import React, { useMemo, useState } from 'react';
import { Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import {
ArrowRightLeft,
Car,
CircleDollarSign,
Eye,
FileText,
MoreHorizontal,
OctagonX,
Pencil,
RefreshCw,
Trash2,
Undo2,
Upload,
Users,
type LucideIcon,
} from 'lucide-react';
export type OperationActionItem = {
key: string;
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
hidden?: boolean;
icon?: LucideIcon;
};
const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
edit: Pencil,
del: Trash2,
addVehicle: Car,
renew: RefreshCw,
authorized: FileText,
extraFee: CircleDollarSign,
toTripartite: Users,
terminate: OctagonX,
withdraw: Undo2,
toFormal: ArrowRightLeft,
stampSupplement: Upload,
uploadStamped: Upload,
};
function renderMenuItemLabel(item: OperationActionItem) {
const Icon = item.icon ?? MORE_ACTION_ICONS[item.key];
return (
<span
className={[
'vm-op-menu-item',
item.danger ? 'vm-op-menu-item--danger' : '',
].filter(Boolean).join(' ')}
>
{Icon ? (
<Icon className="vm-op-menu-item__icon" aria-hidden strokeWidth={1.75} />
) : (
<span className="vm-op-menu-item__icon-spacer" aria-hidden />
)}
<span className="vm-op-menu-item__label">{item.label}</span>
</span>
);
}
export type OperationPrimaryAction = {
onClick: () => void;
label?: string;
hidden?: boolean;
disabled?: boolean;
};
export type OperationActionsProps = {
/** 详情:固定外显主操作 */
view?: OperationPrimaryAction;
/**
* @deprecated 编辑已收入「更多」下拉,请放入 more 数组
*/
edit?: OperationPrimaryAction;
/** 其余操作(含编辑)收入「更多」下拉 */
more?: OperationActionItem[];
/** 更多按钮文案,默认「更多」 */
moreLabel?: string;
className?: string;
annotationId?: string;
};
function ViewIcon() {
return <Eye className="vm-op-action__icon" aria-hidden />;
}
function MoreIcon() {
return <MoreHorizontal className="vm-op-action__icon" aria-hidden />;
}
const PrimaryActionButton = React.forwardRef<
HTMLButtonElement,
{
icon: 'view' | 'more';
label: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
ariaLabel: string;
ariaHasPopup?: boolean;
className?: string;
}
>(function PrimaryActionButton(
{
icon,
label,
onClick,
disabled,
ariaLabel,
ariaHasPopup,
className,
},
ref,
) {
return (
<button
ref={ref}
type="button"
className={['vm-op-action', className].filter(Boolean).join(' ')}
aria-label={ariaLabel}
aria-haspopup={ariaHasPopup ? 'menu' : undefined}
disabled={disabled}
onClick={onClick}
>
{icon === 'view' ? <ViewIcon /> : <MoreIcon />}
<span className="vm-op-action__label">{label}</span>
</button>
);
});
function mergeMoreItems(
more: OperationActionItem[],
edit?: OperationPrimaryAction,
): OperationActionItem[] {
const visibleMore = more.filter((item) => !item.hidden);
if (!edit || edit.hidden) return visibleMore;
const hasEdit = visibleMore.some((item) => item.key === 'edit' || item.label === '编辑');
if (hasEdit) return visibleMore;
return [
{
key: 'edit',
label: edit.label || '编辑',
onClick: edit.onClick,
disabled: edit.disabled,
},
...visibleMore,
];
}
export function OperationActions({
view,
edit,
more = [],
moreLabel = '更多',
className,
annotationId,
}: OperationActionsProps) {
const [moreOpen, setMoreOpen] = useState(false);
const visibleMore = useMemo(
() => mergeMoreItems(more, edit),
[more, edit],
);
const menuItems: MenuProps['items'] = useMemo(() => {
const items: MenuProps['items'] = [];
visibleMore.forEach((item, index) => {
if (item.danger && index > 0 && !visibleMore[index - 1]?.danger) {
items.push({ type: 'divider' });
}
items.push({
key: item.key,
label: renderMenuItemLabel(item),
danger: item.danger,
disabled: item.disabled,
className: item.danger ? 'vm-op-dropdown-menu-item--danger' : undefined,
});
});
return items;
}, [visibleMore]);
const showView = !!(view && !view.hidden);
const showMore = visibleMore.length > 0;
if (!showView && !showMore) {
return <span className="vm-operation-actions vm-operation-actions--empty">-</span>;
}
const handleMenuClick: MenuProps['onClick'] = ({ key, domEvent }) => {
domEvent.stopPropagation();
const target = visibleMore.find((item) => item.key === key);
if (target && !target.disabled) target.onClick();
};
return (
<div
className={['vm-row-actions', 'vm-operation-actions', className].filter(Boolean).join(' ')}
data-annotation-id={annotationId}
onClick={(event) => event.stopPropagation()}
>
{showView ? (
<PrimaryActionButton
icon="view"
label={view?.label || '详情'}
ariaLabel={view?.label || '详情'}
disabled={view?.disabled}
onClick={(event) => {
event.stopPropagation();
if (!view?.disabled) view!.onClick();
}}
/>
) : null}
{showMore ? (
<Dropdown
menu={{
items: menuItems,
onClick: handleMenuClick,
className: 'vm-op-dropdown-menu',
}}
trigger={['click']}
placement="bottomRight"
getPopupContainer={() => document.body}
overlayClassName="vm-op-dropdown"
open={moreOpen}
onOpenChange={setMoreOpen}
align={{ offset: [0, 4] }}
>
<PrimaryActionButton
icon="more"
label={moreLabel}
ariaLabel={moreLabel + '操作'}
ariaHasPopup
className={moreOpen ? 'vm-op-action--open' : undefined}
/>
</Dropdown>
) : null}
</div>
);
}
/**
* 从扁平操作列表中拆分出 view / more编辑收入 more
*/
export function splitOperationActions(items: OperationActionItem[]): {
view?: OperationPrimaryAction;
more: OperationActionItem[];
} {
const visible = items.filter((item) => !item.hidden);
const viewItem = visible.find((item) => item.label === '详情' || item.label === '查看' || item.key === 'view');
const more = visible.filter((item) => item !== viewItem);
return {
view: viewItem
? {
onClick: viewItem.onClick,
label: viewItem.label === '查看' ? '详情' : viewItem.label,
disabled: viewItem.disabled,
}
: undefined,
more,
};
}

View File

@@ -0,0 +1,56 @@
/**
* 全局数字/金额格式化(千分位 + 固定小数位)
* 与 vehicle-management `.vm-expire-date` 数字展示规范配套使用。
*/
function toFiniteNumber(value, fallback = 0) {
if (value === null || value === undefined || value === '') return fallback;
const n = typeof value === 'number' ? value : parseFloat(String(value).replace(/,/g, ''));
return Number.isFinite(n) ? n : fallback;
}
/**
* @param {number|string|null|undefined} value
* @param {{ fractionDigits?: number, empty?: string }} [options]
*/
export function formatNumber(value, options) {
const fractionDigits = options && options.fractionDigits != null ? options.fractionDigits : 2;
const empty = options && options.empty != null ? options.empty : null;
if (value === null || value === undefined || value === '') {
return empty != null ? empty : (fractionDigits > 0 ? (0).toFixed(fractionDigits) : '0');
}
const n = toFiniteNumber(value, NaN);
if (Number.isNaN(n)) {
return empty != null ? empty : (fractionDigits > 0 ? (0).toFixed(fractionDigits) : '0');
}
return n.toLocaleString('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
/** 金额(默认 2 位小数,无货币符号) */
export function formatMoney(value, options) {
if (value == null || value === '') {
if (options && options.empty != null) return options.empty;
}
if (typeof value === 'number' && Number.isNaN(value)) {
if (options && options.empty != null) return options.empty;
}
return formatNumber(value, Object.assign({ fractionDigits: 2 }, options || {}));
}
/** 提车应收款等列表沿用的金额字符串(无「元」后缀) */
export function formatMoneyYuan(value) {
return formatMoney(value);
}
/** 金额 + 单位,默认「元」 */
export function formatMoneyWithUnit(value, unit) {
return formatMoney(value) + (unit != null ? unit : ' 元');
}
/** 整数数量(里程、计数等) */
export function formatInteger(value) {
return formatNumber(value, { fractionDigits: 0 });
}

121
src/common/ln-numeric.css Normal file
View File

@@ -0,0 +1,121 @@
/**
* 全局数字字体:对齐车辆管理「交强险到期时间」(.vm-expire-date)
* — JetBrains Mono / 系统等宽 + tabular-nums
*/
.tabular-nums,
.ln-num {
font-family: var(
--vm-font-mono,
ui-monospace,
"JetBrains Mono",
SFMono-Regular,
Menlo,
Consolas,
monospace
);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
.ln-money,
.ldb-col-money,
.ln-modal-detail-table__money,
.pr-linked-bill-native-table__money,
.vpr-money,
.vpr-money-cell,
.vpr-collect-kpi-card__value,
.vpr-collect-batch-panel__amount,
.vpr-collect-footer__summary strong,
.vpr-collect-td--num {
font-family: var(
--vm-font-mono,
ui-monospace,
"JetBrains Mono",
SFMono-Regular,
Menlo,
Consolas,
monospace
);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
.vm-kpi-val,
.vm-mileage,
.vm-expire-date,
.vm-handover-situation-line--mile,
.vm-handover-situation-line--time,
.vm-detail-gps-time,
.vm-detail-plate,
.vm-location-map-gps-time,
.vm-pagination-total-num,
.vm-filter-toggle-badge,
.vm-vin,
.vm-stack .code,
.vm-stack .vm-link.code {
font-family: var(
--vm-font-mono,
ui-monospace,
"JetBrains Mono",
SFMono-Regular,
Menlo,
Consolas,
monospace
);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
/* 链接态「未购买」等文案仍用正文字体 */
.vm-expire .vm-link.vm-expire-date {
font-family: inherit;
font-variant-numeric: normal;
font-feature-settings: normal;
}
.vm-page .ant-table .tabular-nums,
.vm-page .ant-table-cell.tabular-nums,
.vm-page .vm-list-table .tabular-nums {
font-family: var(
--vm-font-mono,
ui-monospace,
"JetBrains Mono",
SFMono-Regular,
Menlo,
Consolas,
monospace
);
font-variant-numeric: tabular-nums;
}
.vm-page .ant-input.tabular-nums,
.vm-page .ant-input-affix-wrapper.tabular-nums .ant-input,
.lc-form-readonly.tabular-nums,
.vm-stack .sub.tabular-nums {
font-family: var(
--vm-font-mono,
ui-monospace,
"JetBrains Mono",
SFMono-Regular,
Menlo,
Consolas,
monospace
);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
.vm-page .ant-input-number-input,
.vm-page .ant-input-affix-wrapper .ant-input[data-money="true"] {
font-family: var(
--vm-font-mono,
ui-monospace,
"JetBrains Mono",
SFMono-Regular,
Menlo,
Consolas,
monospace
);
font-variant-numeric: tabular-nums;
}

View File

@@ -1,12 +1,24 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as antd from 'antd';
import {
formatMoney,
formatMoneyYuan,
formatMoneyWithUnit,
formatNumber,
formatInteger,
} from '../format-number.js';
declare global {
interface Window {
React: typeof React;
ReactDOM: typeof ReactDOM;
antd: typeof antd;
__oneosFormatNumber: typeof formatNumber;
__oneosFormatMoney: typeof formatMoney;
__oneosFormatMoneyYuan: typeof formatMoneyYuan;
__oneosFormatMoneyWithUnit: typeof formatMoneyWithUnit;
__oneosFormatInteger: typeof formatInteger;
}
}
@@ -15,6 +27,11 @@ export function ensureOneosWebLegacyGlobals(): void {
window.React = React;
window.ReactDOM = ReactDOM;
window.antd = antd;
window.__oneosFormatNumber = formatNumber;
window.__oneosFormatMoney = formatMoney;
window.__oneosFormatMoneyYuan = formatMoneyYuan;
window.__oneosFormatMoneyWithUnit = formatMoneyWithUnit;
window.__oneosFormatInteger = formatInteger;
}
// Legacy jsx 可能在模块顶层使用 React.createElement须在页面 import 之前完成注入。

View File

@@ -0,0 +1,48 @@
# OperationActions · 列表操作列规范
> 完整规范见 `src/prototypes/vm-shared/DESIGN.md` · 操作列 OperationActions
## 组件
- 路径:`src/common/OperationActions.tsx`
- 样式:`src/common/vm-operation-actions.css`(经 `vehicle-management/style.css` 全局引入)
## 布局
```
[👁 详情] [✏️ 编辑] [⋮]
```
- **详情 / 编辑**:外显,带 Lucide 图标 + 文案,最小触控 44×44px
- **更多**:仅图标 `MoreHorizontal`,点击 Ant Dropdown 展开其余操作
- 无详情/编辑时省略对应按钮;无任何操作时显示 `-`
## 迁移步骤
1. 确认页面已 `import '../vehicle-management/style.css'`
2. `import { OperationActions, splitOperationActions } from '../../common/OperationActions'`
3. 将原 `getOperationButtons` / 内联 `Button link` 改为:
```tsx
<OperationActions
view={{ onClick: () => ... }}
edit={canEdit ? { onClick: () => ... } : undefined}
more={[...]}
/>
```
4. 操作列宽建议 `168``fixed: 'right'`
5.`more` 中移除已外显的「详情」「编辑」项
## 扁平列表迁移
```tsx
const { view, edit, more } = splitOperationActions(allItems);
return <OperationActions view={view} edit={edit} more={more} />;
```
## 禁止
- 横排超过 2 个 `Button type="link"` 主操作
- 文字「更多」链接触发器
- pipe `|` 分隔操作链接

View File

@@ -0,0 +1,37 @@
import React, { useMemo } from 'react';
import {
AnnotationViewer,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import { useAnnotationMermaidRenderer } from './prototype-annotation-mermaid';
import { buildAnnotationViewerSource } from './prototype-annotation-utils';
export interface PrototypeAnnotationHostProps {
source: AnnotationSourceDocument;
options: AnnotationViewerOptions;
children?: React.ReactNode;
}
/**
* 统一标注壳PRD 分章节展示在右侧「原型标注」目录面板,
* 不包含跨原型导航与目录内页面跳转节点。
*/
export function PrototypeAnnotationHost(props: PrototypeAnnotationHostProps) {
const { source, options, children } = props;
useAnnotationMermaidRenderer();
const viewerSource = useMemo(
() => buildAnnotationViewerSource(source, options.currentPageId),
[source, options.currentPageId],
);
return (
<>
{children}
<AnnotationViewer source={viewerSource} options={options} />
</>
);
}
export default PrototypeAnnotationHost;

View File

@@ -0,0 +1,19 @@
.axhub-mermaid-diagram {
display: block;
max-width: 100%;
margin: 8px 0;
overflow-x: auto;
border-radius: 8px;
background: #f8fafc;
padding: 8px;
}
.axhub-mermaid-diagram svg {
display: block;
max-width: 100%;
height: auto;
}
.axhub-annotation-directory-reader .axhub-mermaid-diagram {
padding: 12px;
}

View File

@@ -0,0 +1,111 @@
import { useEffect } from 'react';
import './prototype-annotation-mermaid.css';
const MERMAID_SELECTOR = 'pre code.language-mermaid';
let mermaidReady = false;
let renderQueue = Promise.resolve();
function collectShadowRoots(root: ParentNode): ShadowRoot[] {
const roots: ShadowRoot[] = [];
root.querySelectorAll('*').forEach((element) => {
if (element.shadowRoot) {
roots.push(element.shadowRoot);
roots.push(...collectShadowRoots(element.shadowRoot));
}
});
return roots;
}
function queryAllIncludingShadowRoots(selector: string, root: ParentNode = document): HTMLElement[] {
const results: HTMLElement[] = [];
root.querySelectorAll(selector).forEach((node) => {
if (node instanceof HTMLElement) results.push(node);
});
collectShadowRoots(root).forEach((shadowRoot) => {
results.push(...queryAllIncludingShadowRoots(selector, shadowRoot));
});
return results;
}
function observeDeepMutations(callback: () => void) {
const observed = new WeakSet<Node>();
const observeNode = (node: Node) => {
if (!(node instanceof Element) || observed.has(node)) return;
observed.add(node);
const observer = new MutationObserver(callback);
observer.observe(node, { childList: true, subtree: true });
collectShadowRoots(node).forEach((shadowRoot) => observeNode(shadowRoot));
node.querySelectorAll('*').forEach((child) => {
if (child.shadowRoot) observeNode(child.shadowRoot);
});
};
observeNode(document.body);
return () => {
// MutationObserver instances are GC'd with observed nodes on unmount.
};
}
function scheduleMermaidRender() {
renderQueue = renderQueue.then(async () => {
const codes = queryAllIncludingShadowRoots(MERMAID_SELECTOR);
if (codes.length === 0) return;
const mermaid = (await import('mermaid')).default;
if (!mermaidReady) {
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
securityLevel: 'strict',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
});
mermaidReady = true;
}
for (const code of codes) {
const pre = code.parentElement;
if (!pre || pre.dataset.mermaidRendered === 'true') continue;
const graphDefinition = code.textContent?.trim();
if (!graphDefinition) continue;
pre.dataset.mermaidRendered = 'pending';
const id = `axhub-mermaid-${Math.random().toString(36).slice(2, 10)}`;
try {
const { svg } = await mermaid.render(id, graphDefinition);
const ownerDocument = pre.ownerDocument ?? document;
const container = ownerDocument.createElement('div');
container.className = 'axhub-mermaid-diagram';
container.setAttribute('role', 'img');
container.setAttribute('aria-label', '流程图');
container.innerHTML = svg;
pre.replaceWith(container);
} catch (error) {
pre.dataset.mermaidRendered = 'error';
console.warn('[axhub-mermaid] render failed:', error);
}
}
});
}
/**
* 将标注面板 Markdown 中的 ```mermaid 代码块渲染为 SVG 流程图。
*/
export function useAnnotationMermaidRenderer() {
useEffect(() => {
scheduleMermaidRender();
const onMutate = () => {
scheduleMermaidRender();
};
const observer = new MutationObserver(onMutate);
observer.observe(document.body, { childList: true, subtree: true });
observeDeepMutations(onMutate);
return () => observer.disconnect();
}, []);
}

View File

@@ -0,0 +1,104 @@
import type { AnnotationSourceDocument } from '@axhub/annotation';
export const PROTOTYPE_NAV_FOLDER_ID = 'oneos-project-nav';
type DirectoryNode = {
id?: string;
type?: string;
title?: string;
markdown?: string;
markdownId?: string;
markdownPath?: string;
children?: DirectoryNode[];
payload?: { pageId?: string };
};
function isNavFolder(node: DirectoryNode | undefined): boolean {
return node?.type === 'folder' && node.id === PROTOTYPE_NAV_FOLDER_ID;
}
function isDirectoryJumpNode(node: DirectoryNode | undefined): boolean {
return node?.type === 'link' || node?.type === 'route';
}
function collectRoutePageIds(nodes: DirectoryNode[] | undefined): string[] {
const pageIds: string[] = [];
const walk = (items: DirectoryNode[] | undefined) => {
for (const node of items || []) {
if (node.type === 'route') {
const pageId = String(node.payload?.pageId || '').trim();
if (pageId) pageIds.push(pageId);
}
if (node.type === 'folder' && Array.isArray(node.children)) {
walk(node.children);
}
}
};
walk(nodes);
return pageIds;
}
function folderMatchesPage(folder: DirectoryNode, currentPageId?: string): boolean {
if (!currentPageId) return true;
const pageIds = collectRoutePageIds(folder.children);
if (!pageIds.length) return true;
return pageIds.includes(currentPageId);
}
function filterDirectoryNodesForAnnotationPanel(
nodes: DirectoryNode[] | undefined,
currentPageId?: string,
): DirectoryNode[] {
const result: DirectoryNode[] = [];
for (const node of nodes || []) {
if (isNavFolder(node) || isDirectoryJumpNode(node)) continue;
if (node.type === 'markdown') {
result.push(node);
continue;
}
if (node.type === 'folder') {
if (!folderMatchesPage(node, currentPageId)) continue;
const children = filterDirectoryNodesForAnnotationPanel(node.children, currentPageId);
if (!children.length) continue;
result.push({
...node,
children,
});
}
}
return result;
}
/** 供右侧「原型标注」目录使用:保留 PRD 分节,去除导航与页面跳转节点 */
export function buildAnnotationViewerSource(
source: AnnotationSourceDocument,
currentPageId?: string,
): AnnotationSourceDocument {
const nodes = filterDirectoryNodesForAnnotationPanel(
source.directory?.nodes as DirectoryNode[] | undefined,
currentPageId,
);
return {
...source,
directory: { nodes },
};
}
export function stripPrototypeNavFromDirectoryNodes(
nodes: DirectoryNode[] | undefined,
): DirectoryNode[] {
return (nodes || [])
.filter((node) => !isNavFolder(node))
.map((node) => {
if (node.type !== 'folder' || !Array.isArray(node.children)) return node;
return {
...node,
children: stripPrototypeNavFromDirectoryNodes(node.children),
};
});
}

View File

@@ -0,0 +1,111 @@
/**
* ONE-OS vm-btn 图标 — 通过 data-vm-icon 统一左侧图标
* 规范:所有 vm-btn除 link / back均应带图标
*/
.vm-btn[data-vm-icon]::before {
content: '';
width: 16px;
height: 16px;
flex-shrink: 0;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.vm-btn[data-vm-icon='search']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='rotate-ccw']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='download']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' x2='12' y1='15' y2='3'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' x2='12' y1='15' y2='3'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='plus']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3Cpath d='M12 5v14'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3Cpath d='M12 5v14'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='x']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='save']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'/%3E%3Cpath d='M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7'/%3E%3Cpath d='M7 3v4a1 1 0 0 0 1 1h7'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'/%3E%3Cpath d='M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7'/%3E%3Cpath d='M7 3v4a1 1 0 0 0 1 1h7'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='send']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m22 2-7 20-4-9-9-4z'/%3E%3Cpath d='M22 2 11 13'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m22 2-7 20-4-9-9-4z'/%3E%3Cpath d='M22 2 11 13'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='filter']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 3H2l8 9.46V19l4 2v-8.54z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 3H2l8 9.46V19l4 2v-8.54z'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='chevron-up']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='chevron-left']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='chevron-right']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='upload']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' x2='12' y1='3' y2='15'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' x2='12' y1='3' y2='15'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='eye']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='trash']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6'/%3E%3Cpath d='M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6'/%3E%3Cpath d='M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='import']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v12'/%3E%3Cpath d='m8 11 4 4 4-4'/%3E%3Cpath d='M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v12'/%3E%3Cpath d='m8 11 4 4 4-4'/%3E%3Cpath d='M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4'/%3E%3C/svg%3E");
}
.vm-btn[data-vm-icon='settings']::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
}
/* 行内 SVG 图标统一尺寸(迁移前兼容) */
.vm-btn > svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* 预览翻页等紧凑按钮 */
.vm-btn.ct-preview-pager__btn {
min-width: 0;
padding: 0 10px;
}

View File

@@ -0,0 +1,194 @@
/**
* 全局列表操作列 — OperationActions
* 规范vm-shared/DESIGN.md · 操作列
*/
.vm-operation-actions {
display: inline-flex;
align-items: center;
gap: 2px;
flex-wrap: nowrap;
}
.vm-op-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 44px;
min-height: 44px;
padding: 0 6px;
border: none;
border-radius: var(--ln-radius-control, 6px);
background: transparent;
color: var(--ln-link, var(--ln-primary, #32a06e));
font-size: 13px;
font-weight: 500;
line-height: 1.2;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.vm-op-action:hover,
.vm-op-action:focus-visible {
color: var(--ln-primary-hover, #278a5c);
background: color-mix(in srgb, var(--ln-primary, #32a06e) 8%, transparent);
outline: none;
}
.vm-op-action:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary, #32a06e) 35%, transparent);
}
.vm-op-action__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: currentColor;
}
.vm-op-action__label {
white-space: nowrap;
}
.vm-op-action--open,
.vm-op-action[aria-expanded="true"] {
color: var(--ln-primary-hover, #278a5c);
background: color-mix(in srgb, var(--ln-primary, #32a06e) 12%, transparent);
}
.vm-op-more-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
min-width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: var(--ln-radius-control, 6px);
background: transparent;
color: var(--ln-muted, #64748b);
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.vm-op-more-btn:hover,
.vm-op-more-btn:focus-visible {
color: var(--ln-ink, #0f172a);
background: var(--ln-canvas-soft, #f4f4f5);
outline: none;
}
.vm-op-more-btn:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary, #32a06e) 35%, transparent);
}
.vm-op-more-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* 更多下拉:挂到 body使用 overlayClassName 命中 */
.vm-op-dropdown.ant-dropdown {
z-index: 1060;
}
.vm-op-dropdown .vm-op-dropdown-menu.ant-dropdown-menu {
min-width: 176px;
padding: 6px;
border: 1px solid color-mix(in srgb, var(--ln-ink, #0f172a) 8%, transparent);
border-radius: 10px;
background: var(--ln-surface-card, #fff);
box-shadow:
0 12px 28px -8px rgba(15, 23, 42, 0.16),
0 4px 10px -4px rgba(15, 23, 42, 0.08);
}
.vm-op-dropdown .ant-dropdown-menu-item {
display: flex;
align-items: center;
min-height: 40px;
margin: 2px 0;
padding: 8px 10px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
color: var(--ln-ink, #0f172a);
transition: background-color 0.15s ease, color 0.15s ease;
}
.vm-op-dropdown .ant-dropdown-menu-item .ant-dropdown-menu-title-content {
flex: 1;
min-width: 0;
}
.vm-op-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):hover,
.vm-op-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active {
background: var(--ln-canvas-soft, #f4f4f5);
}
.vm-op-dropdown .ant-dropdown-menu-item-divider {
margin: 4px 8px;
background: color-mix(in srgb, var(--ln-ink, #0f172a) 8%, transparent);
}
.vm-op-menu-item {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
}
.vm-op-menu-item__icon,
.vm-op-menu-item__icon-spacer {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.vm-op-menu-item__icon {
color: var(--ln-muted, #64748b);
}
.vm-op-menu-item__label {
flex: 1;
white-space: nowrap;
}
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__label,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__label {
color: var(--ln-error, #e5484d);
font-weight: 500;
}
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__icon,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__icon {
color: var(--ln-error, #e5484d);
}
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled):hover,
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled):hover,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active {
background: color-mix(in srgb, var(--ln-error, #e5484d) 8%, transparent);
}
.vm-op-dropdown .ant-dropdown-menu-item-disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vm-operation-actions .ant-dropdown-menu-item-danger,
.vm-operation-actions .ant-dropdown-menu-item-danger > span {
color: var(--ln-error, #e5484d) !important;
}
@media (prefers-reduced-motion: reduce) {
.vm-op-action,
.vm-op-more-btn,
.vm-op-dropdown .ant-dropdown-menu-item {
transition: none;
}
}

View File

@@ -39,7 +39,7 @@
min-height: var(--vm-control-height);
height: var(--vm-control-height);
border-radius: var(--ln-radius-sm);
border-color: var(--ln-border);
border-color: var(--ln-hairline-strong);
font-size: 13px;
}
@@ -113,7 +113,7 @@
.vm-page.bdl-page .bdl-report-hint {
margin: 0;
font-size: 12px;
color: var(--ln-subtle);
color: var(--ln-muted-soft);
line-height: 1.45;
}
@@ -126,8 +126,9 @@
overflow: hidden;
max-height: none;
min-width: 0;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.06);
background: var(--ln-surface-card);
}
.vm-page.bdl-page .bdl-list-table.ant-table-wrapper,
@@ -136,55 +137,78 @@
max-width: 100%;
}
/* —— 表格边框与表头(使用 Linear token避免无效变量 —— */
.vm-page.bdl-page .bdl-list-table .ant-table-container {
border-radius: var(--ln-radius-card);
border: none;
}
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-container {
border: none;
}
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
white-space: nowrap;
font-variant-numeric: tabular-nums;
padding: 0 10px !important;
height: 38px !important;
padding: 8px 12px !important;
font-size: 13px;
border-color: var(--ln-hairline) !important;
box-sizing: border-box;
overflow: visible;
text-overflow: clip;
}
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th {
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-thead > tr > th,
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-summary > tr > td {
border-inline-end: 1px solid var(--ln-hairline) !important;
border-bottom: 1px solid var(--ln-hairline) !important;
}
/* 分组表:第一行业务板块 */
.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:first-child > th {
background: var(--ln-surface-strong) !important;
color: var(--ln-ink) !important;
font-size: 13px !important;
font-weight: 600 !important;
background: var(--ln-surface-2) !important;
border-bottom: 1px solid var(--ln-border) !important;
border-inline-end: 1px solid var(--ln-border-subtle) !important;
text-align: center;
border-bottom: 2px solid var(--ln-hairline-strong) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:first-child > th {
/* 分组表:第二行 业绩/成本/盈亏 */
.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:nth-child(2) > th {
background: var(--ln-canvas-soft) !important;
color: var(--ln-muted) !important;
font-size: 12px !important;
font-weight: 500 !important;
text-align: center;
background: var(--ln-surface-3) !important;
border-bottom: 2px solid var(--ln-border) !important;
}
/* 单层表头(按客户视图等) */
.vm-page.bdl-page .bdl-list-table:not(.bdl-list-table--grouped) .ant-table-thead > tr > th {
background: var(--ln-canvas-soft) !important;
color: var(--ln-muted) !important;
font-weight: 500 !important;
font-size: 12px !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
color: var(--ln-body);
border-bottom: 1px solid var(--ln-border-subtle) !important;
border-inline-end: 1px solid var(--ln-border-subtle) !important;
background: var(--ln-surface-card);
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td {
background: var(--ln-accent-soft) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td {
font-weight: 700;
background: var(--ln-surface-2) !important;
color: var(--ln-ink) !important;
border-top: 2px solid var(--ln-border) !important;
background: var(--ln-canvas-soft) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td,
.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
font-weight: 700;
background: var(--ln-surface-2) !important;
background: var(--ln-surface-strong) !important;
color: var(--ln-ink) !important;
border-top: 2px solid var(--ln-border) !important;
border-top: 2px solid var(--ln-hairline-strong) !important;
}
/* 冻结列(对齐 lc-list-table--nested */
@@ -214,7 +238,7 @@
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-left,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-end,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-right {
background: var(--ln-surface-2) !important;
background: var(--ln-surface-strong) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-end:not(.ant-table-cell-scrollbar),
@@ -248,21 +272,100 @@
box-shadow: inset -10px 0 8px -8px rgba(15, 23, 42, 0.12);
}
/* 纵向滚动条占位缝:防止表头/表体右侧冻结列错位(对齐 lc-list-table--nested */
.vm-page.bdl-page .bdl-list-table--nested .ant-table-thead > tr > th.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar)::before,
.vm-page.bdl-page .bdl-list-table--nested .ant-table-tbody > tr > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar)::before,
.vm-page.bdl-page .bdl-list-table--nested .ant-table-thead > tr > th.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar)::before,
.vm-page.bdl-page .bdl-list-table--nested .ant-table-tbody > tr > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar)::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: -17px;
width: 17px;
background: inherit;
pointer-events: none;
}
.vm-page.bdl-page .bdl-list-table--nested .ant-table-cell-scrollbar {
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
padding: 0 !important;
border: none !important;
visibility: hidden;
}
.vm-page.bdl-page .bdl-list-table--nested .ant-table-container {
overflow: hidden;
}
.vm-page.bdl-page .bdl-list-table--nested .ant-table-content {
overflow: auto hidden;
}
/* 金额列最小宽度,避免合计行大额被裁切 */
.vm-page.bdl-page .bdl-list-table .col-amount,
.vm-page.bdl-page .bdl-list-table .col-summary {
min-width: 128px;
}
.vm-page.bdl-page .bdl-list-table .col-summary {
min-width: 148px;
}
.vm-page.bdl-page .bdl-list-table .bdl-amount-cell {
display: inline-block;
max-width: 100%;
text-align: right;
}
.vm-page.bdl-page .bdl-list-table .biz-standbook-perf-link,
.vm-page.bdl-page .biz-standbook-perf-link {
cursor: pointer;
color: var(--ln-primary);
padding: 4px 8px;
margin: -4px -8px;
padding: 0;
margin: 0;
border: none;
background: transparent;
font: inherit;
border-radius: 6px;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
max-width: none;
overflow: visible;
text-align: inherit;
white-space: nowrap;
}
/* 钻取子页表格:复用主表边框规范 */
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-thead > tr > th,
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-summary > tr > td {
border-color: var(--ln-hairline) !important;
border-inline-end: 1px solid var(--ln-hairline) !important;
border-bottom: 1px solid var(--ln-hairline) !important;
padding: 8px 10px !important;
font-size: 13px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table .ant-table-thead > tr > th {
background: var(--ln-canvas-soft) !important;
color: var(--ln-muted) !important;
font-weight: 500 !important;
}
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table-wrap,
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table {
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
overflow: hidden;
}
.vm-page.bdl-page .biz-standbook-perf-link:hover {
color: var(--ln-primary-strong);
background: var(--ln-accent-soft);
color: var(--ln-primary-focus);
background: var(--ln-primary-soft);
}
.vm-page.bdl-page .biz-standbook-perf-link:focus {

View File

@@ -48,6 +48,7 @@ import {
splitFilterFields,
VM_FILTER_PRIMARY_VISIBLE_COUNT,
} from '../../common/vm-filter-panel.ts';
import { OperationActions } from '../../common/OperationActions';
var DEFAULT_LESSOR_COMPANIES = [
@@ -2593,9 +2594,11 @@ const Component = function (props) {
}
function createPreviewPagerBtn(label, opts) {
var icon = label === '上一页' ? 'chevron-left' : (label === '下一页' ? 'chevron-right' : null);
return React.createElement(Button, Object.assign({
size: 'small',
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn'
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
'data-vm-icon': icon,
}, opts), label);
}
@@ -4747,66 +4750,45 @@ const Component = function (props) {
function renderTemplateRowActions(record) {
if (isPrototypeReadOnly()) {
return React.createElement('div', { className: 'ct-row-actions' },
React.createElement(Button, {
type: 'link', size: 'small', className: 'ct-action-btn', key: 'view',
onClick: function () { openEdit(record); }
}, '查看')
);
return React.createElement(OperationActions, {
view: { onClick: function () { openEdit(record); } }
});
}
var published = isPublishedTemplate(record);
var enabled = isTemplateEnabled(record);
var actionLocked = published && enabled;
var lockTitle = '启用中的模板不可操作,请先停用';
var menuItems = [];
var more = [];
if (published) {
menuItems.push({
key: 'version-log',
label: '版本日志'
});
if (enabled) {
more.push({
key: 'disable',
label: '停用',
onClick: function () { confirmDisableTemplate(record); }
});
} else {
more.push({
key: 'enable',
label: '启用',
onClick: function () { toggleTemplateEnabled(record, true); }
});
}
more.push({ key: 'version-log', label: '版本日志', onClick: function () { openVersionLog(record); } });
}
menuItems.push({
more.push({
key: 'delete',
label: '删除',
disabled: actionLocked,
danger: true
danger: true,
onClick: actionLocked ? undefined : function () { confirmDelete(record); }
});
function onMenuClick(info) {
if (info.key === 'version-log') openVersionLog(record);
if (info.key === 'delete' && !actionLocked) confirmDelete(record);
}
return React.createElement('div', { className: 'ct-row-actions' },
published
? (enabled
? React.createElement(Button, {
type: 'link', size: 'small', className: 'ct-action-btn', key: 'disable',
onClick: function () { confirmDisableTemplate(record); }
}, '停用')
: React.createElement(Button, {
type: 'link', size: 'small', className: 'ct-action-btn', key: 'enable',
onClick: function () { toggleTemplateEnabled(record, true); }
}, '启用'))
: null,
React.createElement(Button, {
type: 'link', size: 'small', className: 'ct-action-btn', key: 'edit',
disabled: actionLocked,
title: actionLocked ? lockTitle : undefined,
onClick: actionLocked ? undefined : function () { openEdit(record); }
}, '编辑'),
menuItems.length
? React.createElement(Dropdown, {
key: 'more',
trigger: ['click'],
menu: { items: menuItems, onClick: onMenuClick }
}, React.createElement(Button, {
type: 'link', size: 'small', className: 'ct-action-btn ct-row-actions__more',
'aria-label': '更多操作'
}, '更多'))
: null
);
return React.createElement(OperationActions, {
edit: actionLocked
? { disabled: true, onClick: function () {} }
: { onClick: function () { openEdit(record); } },
more: more
});
}
function getListColumns() {
@@ -4836,7 +4818,7 @@ const Component = function (props) {
{ title: '最后更新时间', dataIndex: 'updateTime', key: 'updateTime', width: 140, className: 'ct-col-optional' },
{ title: '更新人', dataIndex: 'updater', key: 'updater', width: 100, className: 'ct-col-optional', render: formatPersonDisplayName },
{
title: '操作', key: 'action', width: 200, fixed: 'right',
title: '操作', key: 'action', width: 168, fixed: 'right',
render: function (_, record) { return renderTemplateRowActions(record); }
}
]);
@@ -5014,6 +4996,7 @@ const Component = function (props) {
? React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-link vm-filter-toggle',
'data-vm-icon': listFilterExpanded ? 'chevron-up' : 'filter',
onClick: function () { setListFilterExpanded(!listFilterExpanded); },
'aria-expanded': listFilterExpanded,
'aria-controls': expandPanelId
@@ -5024,12 +5007,11 @@ const Component = function (props) {
className: 'vm-filter-toggle-badge',
'aria-label': '已设置 ' + extraActiveCount + ' 项扩展筛选'
}, String(extraActiveCount))
: null,
renderFilterChevronIcon(listFilterExpanded)
: null
)
: null,
React.createElement(Button, { className: 'vm-btn vm-btn-ghost', onClick: resetListFilters }, '重置'),
React.createElement(Button, { type: 'primary', className: 'ct-btn-cta', onClick: applyListFilters }, '查询')
React.createElement(Button, { 'data-vm-icon': 'rotate-ccw', className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', onClick: resetListFilters }, '重置'),
React.createElement(Button, { 'data-vm-icon': 'search', className: 'vm-btn vm-btn-primary ldb-toolbar-btn', onClick: applyListFilters }, '查询')
)
);
}
@@ -5139,15 +5121,17 @@ const Component = function (props) {
)
: null,
React.createElement('div', { className: 'ct-editor-page__topbar vm-detail-topbar', 'data-annotation-id': 'ct-editor-topbar' },
React.createElement(Button, { className: 'ct-editor-page__back-btn vm-btn vm-btn-back', onClick: requestBackToList }, '返回列表'),
React.createElement(Button, { className: 'vm-btn vm-btn-back', onClick: requestBackToList }, '返回列表'),
readOnly
? null
: React.createElement('div', { className: 'ct-editor-page__topbar-actions' },
React.createElement(Button, {
'data-vm-icon': 'save',
className: 'vm-btn vm-btn-secondary',
onClick: function () { handleSaveTemplate(false); }
}, '保存'),
React.createElement(Button, {
'data-vm-icon': 'send',
type: 'primary',
className: 'vm-btn vm-btn-primary ct-btn-cta',
onClick: function () { handleSaveTemplate(true); }

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "contract-template-management",
"pageId": "list",
"updatedAt": 1783589987162,
"updatedAt": 1783800844961,
"nodes": [
{
"id": "ct-list-filter",
@@ -328,36 +328,6 @@
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-prototype-nav",
"title": "原型导航",
"href": "/prototypes/oneos-prototype-nav",
"target": "self"
},
{
"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": "folder",
"id": "ct-doc-root",

View File

@@ -71,5 +71,5 @@ export function getDefaultContractTemplateId(contractType, category) {
export function getContractTemplatePreviewBadge(templateId) {
var option = getContractTemplateOption(templateId);
if (!option) return '请先选择合同模板';
return [option.contractTypeLabel, option.fileName || option.title].filter(Boolean).join(' · ');
return option.contractTypeLabel || option.title || option.kind || '—';
}

View File

@@ -207,6 +207,7 @@ export var MILEAGE_PERIOD_OPTIONS = [
export var CONTRACT_CODE_PREFIX = 'LNZLHT';
export var DEFAULT_FEE_INFO = {
paymentMethod: 'advance',
paymentPeriod: 1,
hydrogenPaymentMethod: 'self',
prepayAmount: null,
@@ -225,16 +226,21 @@ export function generateAutoContractCodeSuffix() {
}
export var PAYMENT_PERIOD_OPTIONS = [
{ value: 1, label: '1个月' },
{ value: 3, label: '3个月' },
{ value: 6, label: '6个月' },
{ value: 12, label: '12个月' },
{ value: 1, label: '月' },
{ value: 3, label: '季付' },
{ value: 6, label: '半年付' },
{ value: 12, label: '年付' },
];
export var PAYMENT_METHOD_OPTIONS = [
{ value: 'advance', label: '先付后用' },
{ value: 'postpay', label: '先用后付' },
];
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
{ value: 'self', label: '自行解决' },
{ value: 'prepay', label: '预付款' },
{ value: 'month', label: '按月结算(原型占位,条款待法务确认)' },
{ value: 'month', label: '按月结算' },
];
export function getLessorCompanies() {

View File

@@ -6,11 +6,11 @@ import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import * as antd from 'antd';
import { ConfigProvider } from 'antd';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import 'antd/dist/reset.css';
import '../vehicle-management/style.css';
import './styles/index.css';
@@ -109,7 +109,7 @@ export function ContractTemplateManagementContent({ embedded = false }: { embedd
navigationRef={navigationRef}
onPageViewChange={setAnnotationPageId}
/>
<AnnotationViewer
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>

View File

@@ -803,21 +803,6 @@
box-shadow: inset 0 0 0 1px var(--ln-primary);
}
.vm-page.ct-page .ct-editor-page__back-btn.ant-btn {
padding: 4px 12px;
height: var(--vm-control-height);
font-size: 0.875rem;
color: var(--ln-muted);
border-color: var(--ln-hairline-strong);
background: var(--ln-surface-card);
}
.vm-page.ct-page .ct-editor-page__back-btn.ant-btn:hover {
color: var(--ln-primary);
border-color: var(--ln-primary);
background: var(--ln-primary-soft);
}
.vm-page.ct-page .ct-editor-page__sections {
flex-shrink: 0;
margin-bottom: 12px;

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "customer-management",
"pageId": "list",
"updatedAt": 1783589987164,
"updatedAt": 1783800844964,
"nodes": [
{
"id": "cm-list-filter",
@@ -245,36 +245,6 @@
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-prototype-nav",
"title": "原型导航",
"href": "/prototypes/oneos-prototype-nav",
"target": "self"
},
{
"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": "folder",
"id": "cm-doc-root",

View File

@@ -140,8 +140,8 @@ export function BatchRiskTagModal({
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmit}>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmit} data-vm-icon="send">
</button>
</footer>

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { OperationActions } from '../../../common/OperationActions';
import type { CustomerRecord } from '../types';
import type { CustomerRiskTag, MockUser } from '../types/riskTag';
import { RiskLabelTags } from './RiskLabelTags';
@@ -110,19 +111,25 @@ export function CustomerTable({
<td className="cm-col-address" title={record.address}>{record.address}</td>
<td className="cm-col-time tabular-nums">{record.createTime}</td>
<td className="sticky-col-right cm-col-actions">
<div className="cm-row-actions">
<button type="button" className="vm-btn vm-btn-link" onClick={() => onView(record)}></button>
<span className="cm-action-sep" aria-hidden>|</span>
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(record)}></button>
{canManage && (
<>
<span className="cm-action-sep" aria-hidden>|</span>
<button type="button" className="vm-btn vm-btn-link" onClick={() => onManageTags(record)}></button>
</>
)}
<span className="cm-action-sep" aria-hidden>|</span>
<button type="button" className="vm-btn vm-btn-link cm-link-danger" onClick={() => onDelete(record)}></button>
</div>
<OperationActions
view={{ label: '详情', onClick: () => onView(record) }}
edit={{ onClick: () => onEdit(record) }}
more={[
...(canManage
? [{
key: 'tags',
label: '管理标签',
onClick: () => onManageTags(record),
}]
: []),
{
key: 'delete',
label: '删除',
danger: true,
onClick: () => onDelete(record),
},
]}
/>
</td>
</tr>
))}

View File

@@ -191,19 +191,12 @@ export function FilterPanel({
type="button"
className="vm-btn vm-btn-link"
onClick={() => setExpanded(!expanded)}
>
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{expanded ? '收起' : '更多筛选'}
<ChevronDown
size={14}
aria-hidden
style={{ transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }}
/>
</button>
</button>
)}
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
<Search size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw"></button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
</button>
</div>
</section>

View File

@@ -252,11 +252,11 @@ export function RiskTagDrawer({
<footer className="cm-drawer-footer">
{tab === 'add' ? (
<>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => { setTab('tags'); resetAddForm(); }}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply}></button>
<button type="button" className="vm-btn vm-btn-ghost" data-vm-icon="x" onClick={() => { setTab('tags'); resetAddForm(); }}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply} data-vm-icon="send"></button>
</>
) : (
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
)}
</footer>
@@ -278,8 +278,8 @@ export function RiskTagDrawer({
onChange={(e) => setRemoveReason(e.target.value)}
/>
<div className="cm-drawer-overlay-actions">
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setRemoveTargetId(null)}></button>
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setRemoveTargetId(null)} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove} data-vm-icon="trash"></button>
</div>
</div>
)}
@@ -296,9 +296,9 @@ export function RiskTagDrawer({
onChange={(e) => setRejectReason(e.target.value)}
/>
<div className="cm-drawer-overlay-actions">
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setApproveTargetId(null)}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => handleConfirmApprove(false)}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={() => handleConfirmApprove(true)}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setApproveTargetId(null)} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => handleConfirmApprove(false)} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={() => handleConfirmApprove(true)} data-vm-icon="send"></button>
</div>
</div>
)}

View File

@@ -7,11 +7,11 @@ import './styles/index.css';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Download, FileUp, Plus, Tags } from 'lucide-react';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import seedCustomers from './data/customers.json';
import { FilterPanel } from './components/FilterPanel';
import { CustomerTable } from './components/CustomerTable';
@@ -330,9 +330,6 @@ export default function CustomerManagementApp() {
/>
) : (
<>
<nav className="cm-breadcrumb cm-breadcrumb-list" aria-label="面包屑">
<span className="cm-breadcrumb-muted"></span>
</nav>
<div className="cm-role-bar" data-annotation-id="cm-risk-role-switch">
<span className="cm-role-bar-label"></span>
@@ -366,36 +363,34 @@ export default function CustomerManagementApp() {
<section className="vm-table-section">
<div className="vm-table-toolbar" data-annotation-id="cm-list-toolbar">
<div className="vm-table-actions">
<button type="button" className="vm-btn vm-btn-primary" onClick={handleOpenCreate}>
<Plus size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={handleOpenCreate} data-vm-icon="plus">
</button>
<button
type="button"
className="vm-btn vm-btn-secondary"
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
data-vm-icon="import"
onClick={() => showToast('批量导入(原型演示)')}
>
<FileUp size={16} aria-hidden />
</button>
{canBatchTag(currentUser) && selectedIds.size > 0 && (
<button
type="button"
className="vm-btn vm-btn-secondary"
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
data-vm-icon="plus"
onClick={() => setBatchTagOpen(true)}
data-annotation-id="cm-batch-risk-tag-btn"
>
<Tags size={16} aria-hidden />
{selectedIds.size}
</button>
)}
</div>
<button
type="button"
className="vm-btn vm-btn-ghost"
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
data-vm-icon="download"
onClick={() => showToast(`批量导出(原型演示):当前列表共 ${filtered.length}`)}
>
<Download size={16} aria-hidden />
</button>
</div>
@@ -452,7 +447,7 @@ export default function CustomerManagementApp() {
onSubmit={handleBatchApply}
/>
<AnnotationViewer
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>

View File

@@ -68,13 +68,9 @@ export function CreatePage({
return (
<div className="cm-create-page">
<nav className="cm-breadcrumb" aria-label="面包屑">
<button type="button" className="cm-breadcrumb-link" onClick={onBack}></button>
<span aria-hidden>/</span>
<button type="button" className="cm-breadcrumb-link" onClick={onBack}></button>
<span aria-hidden>/</span>
<span className="cm-breadcrumb-current"></span>
</nav>
<div className="vm-form-topbar">
<button type="button" className="vm-btn vm-btn-back" onClick={onBack}></button>
</div>
<section className="cm-form-card" data-annotation-id="cm-create-base">
<h2 className="cm-form-card-title"></h2>
@@ -273,8 +269,8 @@ export function CreatePage({
</section>
<footer className="cm-create-footer" data-annotation-id="cm-create-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit}></button>
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw"></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit} data-vm-icon="send"></button>
</footer>
</div>
);

View File

@@ -5,6 +5,7 @@
// 与车辆管理「保险状态」联动:交强险 + 商业险均存在且在有效期内为正常,否则异常(禁止交车)
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { OperationActions } from '../../common/OperationActions';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import {
@@ -4434,37 +4435,20 @@ const Component = function () {
{
title: '操作',
key: 'action',
width: 188,
width: 168,
fixed: 'right',
render: (_, record) => (
<div className="lc-vehicle-ins-mgmt-actions">
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600 }} onClick={() => openVehicleInsHistoryEdit(record)}>编辑</Button>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => handleInsuranceRecordPreview(record)}>预览</Button>
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600, color: '#059669' }} onClick={() => handleInsuranceRecordDownload(record)}>下载</Button>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: getPolicyMoreMenuItems(record),
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
handlePolicyMoreMenuClick(record, key);
},
}}
>
<Tooltip title="更多">
<span
className="lc-vehicle-ins-policy-more-btn"
role="button"
tabIndex={0}
aria-label="更多操作"
onClick={(e) => e.stopPropagation()}
>
{ICONS.more}
</span>
</Tooltip>
</Dropdown>
</div>
<OperationActions
edit={{ onClick: () => openVehicleInsHistoryEdit(record) }}
more={[
{ key: 'preview', label: '预览', onClick: () => handleInsuranceRecordPreview(record) },
{ key: 'download', label: '下载', onClick: () => handleInsuranceRecordDownload(record) },
...getPolicyMoreMenuItems(record).map((item) => ({
...item,
onClick: item.key ? () => handlePolicyMoreMenuClick(record, item.key) : undefined,
})),
]}
/>
),
},
];
@@ -5937,33 +5921,42 @@ const Component = function () {
{
title: '操作',
key: 'action',
width: 96,
width: 168,
fixed: 'right',
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (_, row) => (
<div className="lc-compare-action-cell">
<Popover
trigger="click"
open={copyPopoverRowId === row.id}
onOpenChange={(open) => {
setCopyPopoverRowId(open ? row.id : null);
if (!open) setCopyCountDraft(1);
}}
content={(
<div className="lc-copy-pop">
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 8 }}>复制条数</div>
<InputNumber min={1} max={50} value={copyCountDraft} onChange={(v) => setCopyCountDraft(v || 1)} style={{ width: '100%' }} />
<div className="lc-copy-pop-actions">
<Button size="small" onClick={() => setCopyPopoverRowId(null)}>取消</Button>
<Button size="small" type="primary" onClick={() => handleCopyCompareRow(row, copyCountDraft)}>确认</Button>
</div>
<Popover
trigger="click"
open={copyPopoverRowId === row.id}
onOpenChange={(open) => {
setCopyPopoverRowId(open ? row.id : null);
if (!open) setCopyCountDraft(1);
}}
content={(
<div className="lc-copy-pop">
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 8 }}>复制条数</div>
<InputNumber min={1} max={50} value={copyCountDraft} onChange={(v) => setCopyCountDraft(v || 1)} style={{ width: '100%' }} />
<div className="lc-copy-pop-actions">
<Button size="small" onClick={() => setCopyPopoverRowId(null)}>取消</Button>
<Button size="small" type="primary" onClick={() => handleCopyCompareRow(row, copyCountDraft)}>确认</Button>
</div>
)}
>
<Button type="link" size="small">复制</Button>
</Popover>
<Button type="link" size="small" danger onClick={() => handleDeleteCompareRow(row.id)}>删除</Button>
</div>
</div>
)}
>
<OperationActions
more={[
{
key: 'copy',
label: '复制',
onClick: () => {
setCopyPopoverRowId(row.id);
setCopyCountDraft(1);
},
},
{ key: 'delete', label: '删除', danger: true, onClick: () => handleDeleteCompareRow(row.id) },
]}
/>
</Popover>
),
},
];
@@ -6501,17 +6494,12 @@ const Component = function () {
{
title: '操作',
key: 'action',
width: 64,
width: 168,
onHeaderCell: listColumnHeaderCell,
render: (record) => (
<Button
type="link"
size="small"
className="lc-action-btn"
onClick={() => openVehicleInsuranceMgmt(record)}
>
管理
</Button>
<OperationActions
view={{ label: '管理', onClick: () => openVehicleInsuranceMgmt(record) }}
/>
),
},
];
@@ -7658,17 +7646,13 @@ const Component = function () {
{
title: '操作',
key: 'action',
width: 120,
width: 168,
fixed: 'right',
render: (_, record) => (
<Space size={4}>
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600, color: '#10b981' }} onClick={() => openCompareEditor(record)}>
编辑
</Button>
<Button type="link" size="small" danger style={{ padding: 0, fontWeight: 600 }} onClick={() => handleDeleteCompareSheet(record)}>
删除
</Button>
</Space>
<OperationActions
edit={{ onClick: () => openCompareEditor(record) }}
more={[{ key: 'delete', label: '删除', danger: true, onClick: () => handleDeleteCompareSheet(record) }]}
/>
),
},
]}
@@ -8109,31 +8093,29 @@ const Component = function () {
{
title: '操作',
key: 'action',
width: 120,
width: 168,
fixed: 'right',
render: (_, record) => {
const recognizing = isPolicyRecognTaskRecognizing(record);
const noSuccess = !(record.recognSuccessCount > 0);
const disabled = recognizing || noSuccess;
const btn = (
<Button
type="link"
size="small"
style={{ padding: 0, fontWeight: 600, color: disabled ? undefined : '#10b981' }}
disabled={disabled}
onClick={() => openPolicyRecognTaskRecord(record)}
>
确认识别结果
</Button>
const action = (
<OperationActions
view={{
label: '确认识别结果',
disabled,
onClick: () => openPolicyRecognTaskRecord(record),
}}
/>
);
if (recognizing) {
return (
<Tooltip title="请等待识别完成后操作">
<span>{btn}</span>
<span>{action}</span>
</Tooltip>
);
}
return btn;
return action;
},
},
]}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "insurance-procurement",
"pageId": "list",
"updatedAt": 1783589987165,
"updatedAt": 1783800844965,
"nodes": [
{
"id": "ipc-header",
@@ -496,36 +496,6 @@
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-prototype-nav",
"title": "原型导航",
"href": "/prototypes/oneos-prototype-nav",
"target": "self"
},
{
"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": "folder",
"id": "ipc-doc-root",

View File

@@ -5,10 +5,10 @@
import React, { useMemo } from 'react';
import { ConfigProvider } from 'antd';
import {
AnnotationViewer,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import 'antd/dist/reset.css';
import './styles/index.css';
import InsuranceProcurement from './InsuranceProcurement.jsx';
@@ -82,7 +82,7 @@ export default function InsuranceProcurementPage() {
{ theme: ipcTheme },
React.createElement(InsuranceProcurement),
),
React.createElement(AnnotationViewer, {
React.createElement(PrototypeAnnotationHost, {
source: annotationSourceDocument as AnnotationSourceDocument,
options: annotationOptions,
}),

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,165 @@
# 租赁业务明细 · 产品需求说明PRD
> 对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」;业管人员按月份维护运营明细,公式列系统自动计算。
---
## 1. 模块定位
| 项 | 说明 |
|---|---|
| 模块名称 | 台账数据 — 租赁业务明细 |
| 原型路径 | `src/prototypes/lease-business-detail` |
| 预览地址 | `/prototypes/lease-business-detail` |
| 目标用户 | 业务二部业管 / 运营人员 |
| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |
| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 47 列宽表(表尾 23 项汇总)+ 分页 |
| 设计基底 | `vm-page` + `ldb-page` + `lc-page`(见 `ledger-shared/DESIGN.md` |
### 与「租赁业务台账」的分工
| 模块 | 定位 |
|---|---|
| **租赁业务明细**(本模块) | 对齐业管 Excel 运营台账宽表手工列导入维护6 个公式列自动计算;**不**做收款关联与账单状态 |
| **租赁业务台账** | 账单维度维护、关联收款记录、收款状态(未收/部分/已收)、归档与角色数据范围 |
下游 **业务部台账 / 业务汇总分析** 中「租赁业绩」数据来源为本模块明细(见 `oneos-web-data-analysis/05-业务部台账`)。
### 不做范围(当前原型)
- 「租赁业务盈亏月度汇总」独立 TabLegacy 数据分析页有,本原型待规划)
- 行内编辑、删除、服务端持久化
- 关联收款记录自动回写实收
- 员工/主管数据权限隔离
---
## 2. 筛选区
默认展示 4 项(一行),「更多筛选」展开车牌多选。
| 筛选项 | 说明 |
|---|---|
| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |
| 业务部门 | 精确匹配 |
| 业务员 | 精确匹配 |
| 客户名称 | 模糊包含 |
| 车牌号码 | 多选精确匹配(更多筛选) |
查询按钮触发筛选KPI 与表尾汇总基于**全量筛选结果**(非当前页)。
---
## 3. KPI 统计卡片7 张)
顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。
| KPI | 汇总字段 |
|---|---|
| 应收总计 | `receivableTotal` |
| 押金总计 | `deposit`(仅数值型) |
| 实收总计 | `receivedAmount` |
| 里程减免总计 | `mileageDeduction` |
| 其他减免总计 | `otherDeduction` |
| 未收总计 | `outstanding`>0 标红) |
| 盈亏总计 | `profitLoss`(正负着色) |
---
## 4. 列表工具栏
| 按钮 | 行为 |
|---|---|
| 批量导入 | 下载模板(仅手工录入列)→ 上传 Excel → 公式列自动计算 |
| 批量导出 | 导出当前筛选结果(完整 47 列,含公式列结果) |
导入必填:**年份、月份、车牌号码**。
---
## 5. 明细列表47 列)
表头顺序严格对齐 Excel「2.租赁车辆收入明细表」:
年份、月份、业务部门、业务员、付款日期、车牌号码、车型、客户名称、增值服务、享免政策、提车日期、合同生效日期、合同到期日期、起始日期、退车日期、平均天数、押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费收入、其他收入、里程减免金额、其他减免金额、实收金额、未收、开票日期、实际付款日期、付款方式、车辆标准成本、车辆实际成本、保险费、氢费、运维费成本、居间费、其他、总成本、盈亏、GPS里程、其他减免金额明细内容、资产归属、签约公司、备注。
### 交互
- 横向滚动;左冻结:**年份 + 车牌号码**
- 仅**日期类列**表头支持正序/倒序排序
- 公式列(`calculated`)浅灰底色,表示系统计算、导入模板不含
- 表尾**汇总行**23 个金额列 SUBTOTAL首列显示「汇总」
- 未收 > 0 单元格标红;盈亏正负着色
- 底部分页10 / 20 / 50 / 100
### 表尾汇总23 项)
押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。
---
## 6. 公式列(系统自动计算)
对齐 Excel 表内公式;实现见 `utils/calc-detail.ts`
| 字段 | 计算方式 |
|---|---|
| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入) + 其他收入 + 里程减免 + 其他减免 |
| **月度收入** | 月度租金 + 同上调整项 |
| **未收** | 应收合计 实收金额 |
| **车辆实际成本** | 车辆标准成本 × 平均天数 |
| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本) + 居间费 + 其他 |
| **盈亏** | 月度收入 总成本 |
减免金额在 Excel 中以负数录入;导入时填负数即可。
### 导入模板
- **含**41 列手工录入字段(不含上述 6 列)
- **不含**:应收合计、月度收入、未收、车辆实际成本、总成本、盈亏
- 导入后调用 `applyDetailCalculations` 统一重算
---
## 7. 样例数据
- 文件:`data/rows.json`
- 来源Excel《2026年业务二部运营台账总表6.17 - 新版》2026 年 16 月明细
- 条数553 条;汇总与 Excel SUBTOTAL 行一致
---
## 8. 源码入口
| 类型 | 路径 |
|---|---|
| 主入口 | `index.tsx` |
| 标注源 | `annotation-source.json` |
| 列定义 | `components/tableColumns.ts` |
| 筛选 / KPI 汇总 | `utils/detail.ts` |
| 公式计算 | `utils/calc-detail.ts` |
| 导入导出 | `utils/batch-io.ts` |
| 类型 | `types.ts` |
| 组件 | `components/FilterPanel.tsx``DetailKpiRow.tsx``DetailTable.tsx``DetailImportModal.tsx` |
---
## 9. 验收重点
1. 表头 47 列顺序与 Excel 样表一致;双「运维费」列位置正确
2. 筛选后 KPI、表尾汇总与明细行合计一致
3. 下载导入模板无 6 个公式列;导入后公式列自动计算且与 Excel 同口径
4. 导出为完整 47 列
5. 横向滚动与左冻结(年份、车牌)正常
6. 未收 > 0 标红;盈亏正负着色
---
## 10. 待规划项(评审记录)
详见 `.spec/prototype-review.md`
- P1盈亏月度汇总 Tab
- P1与租赁业务台账 / 收款模块集成口径
- P2导入重复键年+月+车牌)处理策略
- P2导入行级校验与实收 ≤ 应收校验

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,39 @@
/** 表头悬浮说明 */
export const COLUMN_HEADER_TIPS: Record<string, string> = {
'lbd-col-status': '按实收金额与应收合计自动计算:未收款 / 部分收款 / 已结清。',
'lbd-col-year-month': '展示合并后的统计月份YYYY-MM导入模板仍使用「年份」「月份」两列。',
'lbd-col-plate': '车牌号码与系统「车辆管理」匹配;不存在的车牌导入失败。',
'lbd-col-vehicle-type': '根据车牌号,显示系统「品牌·型号」。',
'lbd-col-business-dept': '根据车牌对应租赁合同显示业务部门与业务员;展示时合并为两行。',
'lbd-col-customer': '导入后与客户管理比对;不一致时提示正确客户并支持一键替换。',
'lbd-col-pickup-date': '对照交车管理校验提车日期;不一致时显示警告。',
'lbd-col-return-date': '对照还车记录校验退车日期;不一致时显示警告。',
'lbd-col-avg-days': '平均天数 = 本月在租天数 ÷ 当月自然日天数,保留两位小数。',
'lbd-col-deposit': '与租赁合同保证金比对;不一致时提示正确金额。',
'lbd-col-contract-rent': '与租赁合同租金比对;不一致时提示正确金额。',
'lbd-col-receivable-total':
'应收合计 = 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免金额(负数)+ 其他减免金额(负数)。',
'lbd-col-receivable-rent':
'应收租金 = 合同标的租金 × 租赁合同结算周期月份数;与导入值不一致时警告。',
'lbd-col-monthly-income':
'月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免。',
'lbd-col-monthly-rent':
'月度租金 = 合同标的租金 ÷ 付款周期月数,四舍五入保留两位小数。',
'lbd-col-mileage-deduction': '里程减免金额以负数显示;录入正数时警告。',
'lbd-col-other-deduction': '其他减免金额以负数显示;录入正数时警告。',
'lbd-col-outstanding': '未收 = 应收合计 实收金额;差额为零时显示 0。',
'lbd-col-payment-method': '根据合同付款间隔翻译1→月度、3→季度、6→半年、12→年度并附加预付/后付。',
'lbd-col-vehicle-actual-cost': '车辆实际成本 = 车辆标准成本 × 平均天数。',
'lbd-col-total-cost': '总成本 = 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他。',
'lbd-col-hydrogen-fee':
'显示该车牌号码对应客户名称,所有加氢记录,承担方式为我司承担的记录「加氢总价(元)」总和',
'lbd-col-profit-loss': '盈亏 = 月度收入 总成本;红色为正、绿色为负(股市配色)。',
'lbd-col-asset-owner': '等同于车辆「登记所有权」;与车辆管理不一致时警告。',
'lbd-col-signing-company': '显示与客户签订租赁合同的签约公司。',
};
export function resolveColumnHeaderTip(annotationId?: string, headerTip?: string): string | undefined {
if (headerTip) return headerTip;
if (annotationId && COLUMN_HEADER_TIPS[annotationId]) return COLUMN_HEADER_TIPS[annotationId];
return undefined;
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { X } from 'lucide-react';
import type { DetailChangeLogEntry } from '../utils/change-log';
import { displayText } from '../utils/detail';
interface DetailChangeLogModalProps {
open: boolean;
rowLabel: string;
entries: DetailChangeLogEntry[];
onClose: () => void;
}
function renderDiffValue(value: string, tone: 'before' | 'after') {
const text = displayText(value);
if (!text) {
return <span className="lbd-changelog-empty"></span>;
}
return (
<span className={`lbd-changelog-diff-text lbd-changelog-diff-text--${tone}`} title={text}>
{text}
</span>
);
}
export function DetailChangeLogModal({
open,
rowLabel: _rowLabel,
entries,
onClose,
}: DetailChangeLogModalProps) {
if (!open) return null;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-lg lbd-changelog-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lbd-changelog-title"
data-annotation-id="lbd-changelog-modal"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header lbd-changelog-header">
<h2 id="lbd-changelog-title" className="vm-modal-title"></h2>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="vm-modal-body lbd-changelog-body">
{entries.length === 0 ? (
<p className="ldb-confirm-text"></p>
) : (
<>
<p className="lbd-changelog-meta"> {entries.length} </p>
<div className="lbd-changelog-table-wrap">
<table className="lbd-changelog-table">
<colgroup>
<col style={{ width: '152px' }} />
<col style={{ width: '76px' }} />
<col style={{ width: '108px' }} />
<col style={{ width: '34%' }} />
<col style={{ width: '34%' }} />
</colgroup>
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id}>
<td className="lbd-changelog-cell-meta tabular-nums">{displayText(entry.operatedAt)}</td>
<td className="lbd-changelog-cell-meta">{displayText(entry.operatorName)}</td>
<td className="lbd-changelog-cell-field">
<span className="lbd-changelog-field-tag" title={entry.fieldLabel}>
{displayText(entry.fieldLabel) || '—'}
</span>
</td>
<td className="lbd-changelog-cell-diff">{renderDiffValue(entry.before, 'before')}</td>
<td className="lbd-changelog-cell-diff">{renderDiffValue(entry.after, 'after')}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
<footer className="vm-modal-footer lbd-changelog-footer">
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose} data-vm-icon="x"></button>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { X } from 'lucide-react';
interface DetailDeleteConfirmModalProps {
open: boolean;
rowLabel?: string;
onCancel: () => void;
onConfirm: () => void;
}
export function DetailDeleteConfirmModal({
open,
rowLabel,
onCancel,
onConfirm,
}: DetailDeleteConfirmModalProps) {
if (!open) return null;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onCancel}>
<div
className="vm-modal vm-modal-sm"
role="dialog"
aria-modal="true"
aria-labelledby="lbd-delete-title"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="lbd-delete-title" className="vm-modal-title"></h2>
<button type="button" className="vm-modal-close" onClick={onCancel} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="vm-modal-body">
<p className="ldb-confirm-text"></p>
{rowLabel && <p className="ldb-confirm-meta">{rowLabel}</p>}
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-danger" onClick={onConfirm} data-vm-icon="trash"></button>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import type { LeaseBusinessDetailRow } from '../types';
import { detailRowYearMonth } from '../utils/detail';
import { DETAIL_EDIT_FIELDS, draftFromRow, parseDraftToPatch } from '../utils/edit-fields';
interface DetailEditModalProps {
open: boolean;
row: LeaseBusinessDetailRow | null;
onClose: () => void;
onSave: (patch: Partial<LeaseBusinessDetailRow>) => void;
}
export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalProps) {
const [draft, setDraft] = useState<Record<string, string | number>>({});
useEffect(() => {
if (open && row) setDraft(draftFromRow(row));
}, [open, row]);
if (!open || !row) return null;
const rowLabel = `${detailRowYearMonth(row)} · ${row.plateNo}`;
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
onSave(parseDraftToPatch(draft));
};
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-lg lbd-edit-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lbd-edit-title"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="lbd-edit-title" className="vm-modal-title"> · {rowLabel}</h2>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<form id="lbd-edit-form" className="vm-modal-body lbd-edit-form" onSubmit={handleSubmit}>
<p className="lbd-edit-hint"></p>
<div className="lbd-edit-grid">
{DETAIL_EDIT_FIELDS.map((field) => (
<label key={field.key} className="lbd-edit-field">
<span className="lbd-edit-label">{field.label}</span>
<input
className="vm-input"
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
step={field.key === 'avgDays' ? '0.01' : field.type === 'number' ? '0.01' : undefined}
value={draft[field.key] ?? ''}
onChange={(event) => {
const value = field.type === 'number'
? event.target.value
: event.target.value;
setDraft((prev) => ({
...prev,
[field.key]: field.type === 'number' && value !== ''
? Number(value)
: value,
}));
}}
/>
</label>
))}
</div>
</form>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
<button type="submit" className="vm-btn vm-btn-primary" form="lbd-edit-form" data-vm-icon="save">
</button>
</footer>
</div>
</div>
);
}

View File

@@ -6,6 +6,8 @@ interface DetailImportModalProps {
onClose: () => void;
onDownloadTemplate: () => void;
onImport: (file: File) => void;
failedCount?: number;
onDownloadErrorLog?: () => void;
}
export function DetailImportModal({
@@ -13,6 +15,8 @@ export function DetailImportModal({
onClose,
onDownloadTemplate,
onImport,
failedCount = 0,
onDownloadErrorLog,
}: DetailImportModalProps) {
const inputRef = useRef<HTMLInputElement>(null);
if (!open) return null;
@@ -36,11 +40,9 @@ export function DetailImportModal({
<section className="vm-import-step">
<div className="vm-import-step-head">
<span className="vm-step-badge">1</span>
<span></span>
<span></span>
</div>
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate}>
<Download size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">
</button>
</section>
<section className="vm-import-step">
@@ -55,7 +57,6 @@ export function DetailImportModal({
>
<Upload size={28} aria-hidden />
<p></p>
<p className="hint"> .xls.xlsx</p>
<input
ref={inputRef}
type="file"
@@ -69,9 +70,18 @@ export function DetailImportModal({
/>
</button>
</section>
{failedCount > 0 && onDownloadErrorLog ? (
<section className="vm-import-step lbd-import-error-step">
<p className="lbd-import-error-summary">
<strong>{failedCount}</strong>
</p>
<button type="button" className="vm-btn vm-btn-secondary" onClick={onDownloadErrorLog} data-vm-icon="download">
</button>
</section>
) : null}
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
</footer>
</div>
</div>

View File

@@ -1,31 +1,55 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
import { OperationActions } from '../../../common/OperationActions';
import type { LeaseBusinessDetailRow } from '../types';
import {
DEFAULT_DETAIL_COLUMN_WIDTHS,
DETAIL_SUMMARY_COLUMN_KEYS,
DETAIL_TABLE_COLUMNS,
DETAIL_TABLE_MIN_WIDTH,
type DetailTableColumn,
} from './tableColumns';
import type { LeaseDetailKpiSummary } from '../types';
import {
detailRowYearMonth,
displayDeposit,
displayMoney,
displayOutstanding,
displayText,
formatMoney,
} from '../utils/detail';
import { resolveColumnHeaderTip } from '../columnHeaderTips';
import { HeaderWithTip } from './TableHeaderTip';
import { getFieldCheck, normalizeDeductionForDisplay } from '../utils/field-checks';
import { wrapFieldCell } from './FieldCheckIcon';
import { getVehicleRef, resolvePaymentMethodLabel } from '../utils/system-ref';
import { getHydrogenRefuelRecords } from '../utils/hydrogen-fee';
import {
DETAIL_PAYMENT_STATUS_CLASS,
paymentStatusSortValue,
resolveDetailPaymentStatus,
} from '../utils/payment-status';
import { canEditDetailRow } from '../utils/change-log';
import {
LedgerDetailPopover,
LedgerDetailTable,
} from '../../lease-business-ledger/components/LedgerDetailPopover';
interface DetailTableProps {
records: LeaseBusinessDetailRow[];
summary: LeaseDetailKpiSummary;
loading?: boolean;
isSupervisor: boolean;
onEdit: (row: LeaseBusinessDetailRow) => void;
onDelete: (row: LeaseBusinessDetailRow) => void;
onChangeLog: (row: LeaseBusinessDetailRow) => void;
onRowPatch?: (id: string, patch: Partial<LeaseBusinessDetailRow>) => void;
}
type SortDir = 'asc' | 'desc';
const STICKY_LEFT_KEYS = ['year', 'plateNo'] as const;
const ACTION_COL_WIDTH = 168;
const STICKY_LEFT_KEYS = ['paymentStatus', 'yearMonth', 'plateNo'] as const;
function buildStickyLayout(widths: Record<string, number>) {
let left = 0;
@@ -59,36 +83,85 @@ function stickyStyle(
};
}
function HeaderSort({
title,
sortActive,
sortDir,
onSort,
}: {
title: string;
sortActive?: boolean;
sortDir?: SortDir;
onSort: () => void;
}) {
return (
<button type="button" className={`ldb-th-sort ${sortActive ? 'is-active' : ''}`} onClick={onSort}>
<span className="ldb-th-label">{title}</span>
<span className="ldb-th-sort-icon" aria-hidden>
{sortActive
? (sortDir === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />)
: <ArrowUpDown size={14} />}
</span>
</button>
);
function getSortValue(row: LeaseBusinessDetailRow, key: string): string | number {
if (key === 'paymentStatus') {
return paymentStatusSortValue(row.receivableTotal, row.receivedAmount);
}
if (key === 'yearMonth') return row.year * 100 + row.month;
return row[key as keyof LeaseBusinessDetailRow] as string | number;
}
function renderCell(row: LeaseBusinessDetailRow, col: DetailTableColumn) {
function renderCellContent(
row: LeaseBusinessDetailRow,
col: DetailTableColumn,
): React.ReactNode {
switch (col.render) {
case 'payment-status': {
const label = resolveDetailPaymentStatus(row.receivableTotal, row.receivedAmount);
const statusClass = DETAIL_PAYMENT_STATUS_CLASS[label];
return <span className={`ldb-status-tag ${statusClass}`}>{label}</span>;
}
case 'year-month':
return <span className="tabular-nums">{detailRowYearMonth(row)}</span>;
case 'stack-dept':
return (
<div className="ldb-cell-stack">
<span className="ldb-cell-primary">{displayText(row.businessDept)}</span>
<span className="ldb-cell-secondary">{displayText(row.salesperson)}</span>
</div>
);
case 'vehicle-type': {
const vehicle = getVehicleRef(row.plateNo);
const label = vehicle?.brandModel || row.vehicleType;
return <span title={label}>{displayText(label)}</span>;
}
case 'payment-method':
return displayText(resolvePaymentMethodLabel(row));
case 'hydrogen-fee': {
const amount = row.hydrogenFee;
const text = displayMoney(amount, true);
if (!text) return '';
const records = getHydrogenRefuelRecords(row);
const tableRows = records.map((item) => ({
hydrogenTime: item.hydrogenTime,
stationName: item.stationName,
customerName: item.customerName,
plateNo: item.plateNo,
hydrogenKg: String(item.hydrogenKg),
unitPrice: formatMoney(item.unitPrice),
totalPrice: formatMoney(item.totalPrice),
}));
return (
<LedgerDetailPopover
label={text}
title="车辆氢费明细"
triggerClassName="tabular-nums"
>
<LedgerDetailTable
columns={[
{ key: 'hydrogenTime', title: '加氢时间' },
{ key: 'stationName', title: '加氢站名称' },
{ key: 'customerName', title: '客户名称' },
{ key: 'plateNo', title: '车牌号码' },
{ key: 'hydrogenKg', title: '加氢量' },
{ key: 'unitPrice', title: '加氢单价(元/kg' },
{ key: 'totalPrice', title: '加氢总价(元)', align: 'right' },
]}
rows={tableRows}
/>
</LedgerDetailPopover>
);
}
default:
break;
}
if (col.key === 'outstanding') {
const { text, warn } = displayOutstanding(row.outstanding);
if (!text) return '';
return warn
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
: text;
: <span className="tabular-nums">{text}</span>;
}
if (col.key === 'deposit') {
return <span className="tabular-nums">{displayDeposit(row.deposit)}</span>;
@@ -102,37 +175,63 @@ function renderCell(row: LeaseBusinessDetailRow, col: DetailTableColumn) {
if (col.key === 'customerName') {
return <span className="lbd-cell-customer" title={row.customerName}>{displayText(row.customerName)}</span>;
}
if (col.key === 'mileageDeduction' || col.key === 'otherDeduction') {
const normalized = normalizeDeductionForDisplay(row[col.key]);
if (normalized === 0) return '';
return <span className="tabular-nums">{displayMoney(normalized, false)}</span>;
}
if (col.key === 'otherDeductionDetail' || col.key === 'remark') {
const text = displayText(row[col.key]);
return text ? <span className="lbd-cell-remark" title={text}>{text}</span> : '';
}
const raw = row[col.key as keyof LeaseBusinessDetailRow];
if (col.money && typeof raw === 'number') return displayMoney(raw);
if (col.key === 'year' || col.key === 'month' || col.key === 'avgDays') {
return raw === 0 ? '' : String(raw);
if (col.key === 'avgDays') {
return raw === 0 ? '' : Number(raw).toFixed(2);
}
return displayText(raw);
}
function renderCell(
row: LeaseBusinessDetailRow,
col: DetailTableColumn,
onRowPatch?: (id: string, patch: Partial<LeaseBusinessDetailRow>) => void,
) {
const content = renderCellContent(row, col);
if (!col.checkField) return content;
const check = getFieldCheck(row, col.checkField);
return wrapFieldCell(
content,
check,
check.replaceAction && onRowPatch
? () => onRowPatch(row.id, check.replaceAction!)
: undefined,
);
}
function renderSummaryCell(col: DetailTableColumn, summary: LeaseDetailKpiSummary) {
if (col.key === 'year') {
if (col.key === 'yearMonth') {
return <span className="lbd-summary-label"></span>;
}
if (!DETAIL_SUMMARY_COLUMN_KEYS.has(col.key)) return '';
if (!col.summaryKey) return '';
const value = summary[col.key as keyof LeaseDetailKpiSummary];
if (col.key === 'outstanding') {
const value = summary[col.summaryKey];
if (col.summaryKey === 'outstanding') {
const { text, warn } = displayOutstanding(value);
if (!text) return '';
return warn
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
: text;
: <span className="tabular-nums">{text}</span>;
}
if (col.key === 'profitLoss') {
if (col.summaryKey === 'profitLoss') {
const text = formatMoney(value);
const cls = value >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg';
return <span className={`tabular-nums ${cls}`}>{text}</span>;
}
if (col.summaryKey === 'mileageDeduction' || col.summaryKey === 'otherDeduction') {
const normalized = normalizeDeductionForDisplay(value);
return normalized === 0 ? '' : displayMoney(normalized, false);
}
return displayMoney(value, false);
}
@@ -146,7 +245,16 @@ function TableSkeleton() {
);
}
export function DetailTable({ records, summary, loading = false }: DetailTableProps) {
export function DetailTable({
records,
summary,
loading = false,
isSupervisor,
onEdit,
onDelete,
onChangeLog,
onRowPatch,
}: DetailTableProps) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_DETAIL_COLUMN_WIDTHS }));
@@ -158,8 +266,8 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
if (!sortKey) return records;
const dir = sortDir === 'asc' ? 1 : -1;
return [...records].sort((a, b) => {
const av = a[sortKey as keyof LeaseBusinessDetailRow];
const bv = b[sortKey as keyof LeaseBusinessDetailRow];
const av = getSortValue(a, sortKey);
const bv = getSortValue(b, sortKey);
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av ?? '').localeCompare(String(bv ?? ''), 'zh-CN') * dir;
});
@@ -206,7 +314,7 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
const tableMinWidth = Math.max(
DETAIL_TABLE_MIN_WIDTH,
DETAIL_TABLE_COLUMNS.reduce(
ACTION_COL_WIDTH + DETAIL_TABLE_COLUMNS.reduce(
(sum, col) => sum + (columnWidths[col.key] ?? DEFAULT_DETAIL_COLUMN_WIDTHS[col.key] ?? 100),
0,
),
@@ -236,59 +344,89 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
{DETAIL_TABLE_COLUMNS.map((col) => (
<col key={col.key} style={{ width: columnWidths[col.key] ?? DEFAULT_DETAIL_COLUMN_WIDTHS[col.key] ?? 100 }} />
))}
<col style={{ width: ACTION_COL_WIDTH }} />
</colgroup>
<thead>
<tr>
{DETAIL_TABLE_COLUMNS.map((col) => (
<th
key={col.key}
className={[
col.money ? 'ldb-col-money tabular-nums' : '',
col.calculated ? 'lbd-col-calc' : '',
stickyClass(col),
'ldb-col-resizable',
].filter(Boolean).join(' ')}
style={stickyStyle(col, stickyLayout, true)}
>
{col.dateSort ? (
<HeaderSort
title={col.title}
sortActive={sortKey === col.key}
sortDir={sortDir}
onSort={() => handleSort(col.key)}
/>
) : (
col.title
)}
<span
className="ldb-col-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={`调整${col.title}列宽`}
onMouseDown={(event) => handleResizeStart(col.key, event)}
/>
</th>
))}
</tr>
</thead>
<tbody>
{sortedRecords.map((row) => (
<tr key={row.id}>
{DETAIL_TABLE_COLUMNS.map((col) => (
<td
{DETAIL_TABLE_COLUMNS.map((col) => {
const headerTip = resolveColumnHeaderTip(col.annotationId, col.headerTip);
return (
<th
key={col.key}
className={[
col.money ? 'ldb-col-money tabular-nums' : '',
col.calculated ? 'lbd-col-calc' : '',
stickyClass(col),
'ldb-col-resizable',
].filter(Boolean).join(' ')}
style={stickyStyle(col, stickyLayout, false)}
style={stickyStyle(col, stickyLayout, true)}
{...(col.annotationId ? { 'data-annotation-id': col.annotationId } : {})}
>
{renderCell(row, col)}
<div className="ldb-th-inner">
<HeaderWithTip
title={col.title}
tip={headerTip}
sortable={Boolean(col.dateSort)}
sortActive={sortKey === col.key}
sortDir={sortDir}
onSort={col.dateSort ? () => handleSort(col.key) : undefined}
/>
<span
className="ldb-col-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={`调整${col.title}列宽`}
onMouseDown={(event) => handleResizeStart(col.key, event)}
/>
</div>
</th>
);
})}
<th className="sticky-col-right ldb-col-actions" data-annotation-id="lbd-col-actions"></th>
</tr>
</thead>
<tbody>
{sortedRecords.map((row) => {
const canEdit = canEditDetailRow(row, isSupervisor);
const archived = row.archiveStatus === 'archived';
return (
<tr key={row.id} className={archived ? 'lbd-row-archived' : undefined}>
{DETAIL_TABLE_COLUMNS.map((col) => (
<td
key={col.key}
className={[
col.money ? 'ldb-col-money tabular-nums' : '',
col.calculated ? 'lbd-col-calc' : '',
stickyClass(col),
].filter(Boolean).join(' ')}
style={stickyStyle(col, stickyLayout, false)}
>
{renderCell(row, col, onRowPatch)}
</td>
))}
<td className="sticky-col-right ldb-col-actions">
<OperationActions
edit={canEdit ? { onClick: () => onEdit(row) } : undefined}
more={[
...(canEdit
? [{
key: 'delete',
label: '删除',
danger: true,
onClick: () => onDelete(row),
}]
: []),
{
key: 'changeLog',
label: '变更日志',
onClick: () => onChangeLog(row),
},
]}
/>
</td>
))}
</tr>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr className="lbd-summary-row">
@@ -306,6 +444,7 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
{renderSummaryCell(col, summary)}
</td>
))}
<td className="sticky-col-right ldb-col-actions" />
</tr>
</tfoot>
</table>

View File

@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { Check, AlertTriangle } from 'lucide-react';
import type { FieldCheckInfo } from '../utils/field-checks';
interface FieldCheckIconProps {
info: FieldCheckInfo;
onReplace?: () => void;
}
function PopoverPanel({
info,
onReplace,
onClose,
}: {
info: FieldCheckInfo;
onReplace?: () => void;
onClose: () => void;
}) {
return (
<div className="lbd-field-check-pop" role="dialog">
{info.title ? <div className="lbd-field-check-pop__title">{info.title}</div> : null}
{info.details?.map((line) => (
<div key={line} className="lbd-field-check-pop__line">{line}</div>
))}
{info.replaceAction && onReplace ? (
<div className="lbd-field-check-pop__actions">
<button type="button" className="vm-btn vm-btn-secondary vm-btn-sm" data-vm-icon="rotate-ccw" onClick={() => { onReplace(); onClose(); }}>
</button>
</div>
) : null}
</div>
);
}
export function FieldCheckIcon({ info, onReplace }: FieldCheckIconProps) {
const [open, setOpen] = useState(false);
if (info.status === 'none') return null;
const isOk = info.status === 'ok';
const icon = isOk
? <Check size={12} aria-hidden />
: <AlertTriangle size={12} aria-hidden />;
return (
<span className="lbd-field-check-wrap">
<button
type="button"
className={`lbd-field-check-icon ${isOk ? 'is-ok' : 'is-warn'}`}
aria-label={isOk ? '校验通过' : '校验警告'}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
onBlur={() => window.setTimeout(() => setOpen(false), 150)}
>
{icon}
</button>
{open && !isOk ? (
<PopoverPanel info={info} onReplace={onReplace} onClose={() => setOpen(false)} />
) : null}
{open && isOk && info.details?.length ? (
<div className="lbd-field-check-pop lbd-field-check-pop--ok" role="tooltip">
{info.details.map((line) => (
<div key={line} className="lbd-field-check-pop__line">{line}</div>
))}
</div>
) : null}
</span>
);
}
export function wrapFieldCell(
main: React.ReactNode,
info: FieldCheckInfo,
onReplace?: () => void,
): React.ReactNode {
if (info.status === 'none') return main;
return (
<span className="lbd-field-check-cell">
{main}
<FieldCheckIcon info={info} onReplace={onReplace} />
</span>
);
}

View File

@@ -4,8 +4,9 @@ import type { LeaseBusinessDetailRow, LeaseDetailFilters } from '../types';
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail';
import { DETAIL_PAYMENT_STATUS_OPTIONS } from '../utils/payment-status';
const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName'] as const;
const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName', 'paymentStatus'] as const;
const EXTRA_KEYS = ['plateNos'] as const;
type FieldKey = (typeof PRIMARY_KEYS)[number] | (typeof EXTRA_KEYS)[number];
@@ -98,6 +99,19 @@ export function FilterPanel({
/>
),
},
paymentStatus: {
key: 'paymentStatus',
label: '状态',
control: (
<FilterPickerField
value={filters.paymentStatus}
options={DETAIL_PAYMENT_STATUS_OPTIONS}
placeholder="全部状态"
ariaLabel="状态"
onChange={(paymentStatus) => onChange({ paymentStatus })}
/>
),
},
plateNos: {
key: 'plateNos',
label: '车牌号码',
@@ -158,21 +172,19 @@ export function FilterPanel({
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls="lbd-filter-expand-panel"
>
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{expanded ? '收起' : '更多筛选'}
{extraActiveCount > 0 && !expanded ? (
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
{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-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
</button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
<Search size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
</button>
</div>
</section>

View File

@@ -0,0 +1,100 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp } from 'lucide-react';
const HEADER_TIP_MAX_WIDTH = 300;
const HEADER_TIP_VIEWPORT_MARGIN = 12;
export function TableHeaderTip({ tip, title }: { tip: string; title: string }) {
const anchorRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({});
const reposition = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const maxWidth = Math.min(HEADER_TIP_MAX_WIDTH, window.innerWidth - HEADER_TIP_VIEWPORT_MARGIN * 2);
let left = rect.left;
left = Math.max(
HEADER_TIP_VIEWPORT_MARGIN,
Math.min(left, window.innerWidth - HEADER_TIP_VIEWPORT_MARGIN - maxWidth),
);
setStyle({ top: rect.bottom + 6, left, maxWidth });
}, []);
useEffect(() => {
if (!visible) return undefined;
reposition();
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [visible, reposition]);
return (
<>
<span
ref={anchorRef}
className="ldb-th-tip"
tabIndex={0}
aria-label={`${title}说明`}
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => { reposition(); setVisible(true); }}
onMouseLeave={() => setVisible(false)}
onFocus={() => { reposition(); setVisible(true); }}
onBlur={() => setVisible(false)}
>
<CircleHelp size={14} aria-hidden />
</span>
{visible && createPortal(
<div className="ldb-kpi-tooltip ldb-kpi-tooltip--portal" style={style} role="tooltip">
{tip}
</div>,
document.body,
)}
</>
);
}
export function HeaderWithTip({
title,
tip,
sortable,
sortActive,
sortDir,
onSort,
}: {
title: string;
tip?: string;
sortable?: boolean;
sortActive?: boolean;
sortDir?: 'asc' | 'desc';
onSort?: () => void;
}) {
const label = (
<span className={`ldb-th-label ${tip ? 'ldb-th-with-tip' : ''}`}>
{title}
{tip ? <TableHeaderTip tip={tip} title={title} /> : null}
</span>
);
if (!sortable || !onSort) return label;
return (
<button
type="button"
className={`ldb-th-sort ${sortActive ? 'is-active' : ''}`}
onClick={onSort}
>
{label}
<span className="ldb-th-sort-icon" aria-hidden>
{sortActive
? (sortDir === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />)
: <ArrowUpDown size={14} />}
</span>
</button>
);
}

View File

@@ -1,77 +1,325 @@
import type { LeaseBusinessDetailRow, LeaseDetailKpiSummary } from '../types';
export type DetailColumnRender =
| 'payment-status'
| 'year-month'
| 'stack-dept'
| 'vehicle-type'
| 'payment-method'
| 'hydrogen-fee';
export interface DetailTableColumn {
key: string;
title: string;
annotationId?: string;
headerTip?: string;
money?: boolean;
className?: string;
sticky?: 'left' | 'right';
defaultWidth?: number;
/** 日期类列,支持表头正序/倒序 */
dateSort?: boolean;
/** Excel 公式列,系统计算、导入模板不含 */
calculated?: boolean;
render?: DetailColumnRender;
/** 表尾汇总对应字段 */
summaryKey?: keyof LeaseDetailKpiSummary;
/** 单元格校验字段 */
checkField?:
| 'customerName'
| 'pickupDate'
| 'returnDate'
| 'deposit'
| 'contractRent'
| 'receivableRent'
| 'mileageDeduction'
| 'otherDeduction'
| 'assetOwner';
}
/** 列表表头列(严格对齐业务明细 Excel 样表 */
/** 页面展示列(年月、部门+业务员已合并 */
export const DETAIL_TABLE_COLUMNS: DetailTableColumn[] = [
{ key: 'year', title: '年份', defaultWidth: 64, sticky: 'left', className: 'tabular-nums' },
{ key: 'month', title: '月份', defaultWidth: 56, className: 'tabular-nums' },
{ key: 'businessDept', title: '业务部门', defaultWidth: 96 },
{ key: 'salesperson', title: '业务员', defaultWidth: 88 },
{
key: 'paymentStatus',
title: '状态',
render: 'payment-status',
sticky: 'left',
defaultWidth: 96,
annotationId: 'lbd-col-status',
},
{
key: 'yearMonth',
title: '月份',
render: 'year-month',
sticky: 'left',
className: 'tabular-nums',
defaultWidth: 88,
annotationId: 'lbd-col-year-month',
dateSort: true,
},
{
key: 'businessAssignment',
title: '业务部门',
render: 'stack-dept',
defaultWidth: 120,
annotationId: 'lbd-col-business-dept',
},
{ key: 'paymentDate', title: '付款日期', defaultWidth: 108, dateSort: true },
{ key: 'plateNo', title: '车牌号码', defaultWidth: 110, sticky: 'left', className: 'lbd-col-plate' },
{ key: 'vehicleType', title: '车型', defaultWidth: 72 },
{ key: 'customerName', title: '客户名称', className: 'ldb-col-name', defaultWidth: 200 },
{
key: 'plateNo',
title: '车牌号码',
defaultWidth: 110,
sticky: 'left',
className: 'lbd-col-plate',
annotationId: 'lbd-col-plate',
},
{
key: 'vehicleType',
title: '车型',
render: 'vehicle-type',
defaultWidth: 120,
annotationId: 'lbd-col-vehicle-type',
},
{
key: 'customerName',
title: '客户名称',
className: 'ldb-col-name',
defaultWidth: 200,
annotationId: 'lbd-col-customer',
checkField: 'customerName',
},
{ key: 'valueAddedService', title: '增值服务', defaultWidth: 96 },
{ key: 'exemptionPolicy', title: '享免政策', defaultWidth: 120 },
{ key: 'pickupDate', title: '提车日期', defaultWidth: 108, dateSort: true },
{
key: 'pickupDate',
title: '提车日期',
defaultWidth: 108,
dateSort: true,
annotationId: 'lbd-col-pickup-date',
checkField: 'pickupDate',
},
{ key: 'contractStartDate', title: '合同生效日期', defaultWidth: 118, dateSort: true },
{ key: 'contractEndDate', title: '合同到期日期', defaultWidth: 118, dateSort: true },
{ key: 'periodStartDate', title: '起始日期', defaultWidth: 108, dateSort: true },
{ key: 'returnDate', title: '退车日期', defaultWidth: 108, dateSort: true },
{ key: 'avgDays', title: '平均天数', defaultWidth: 88, className: 'tabular-nums' },
{ key: 'deposit', title: '押金', money: true, defaultWidth: 100 },
{ key: 'contractRent', title: '合同标的租金', money: true, defaultWidth: 110 },
{ key: 'receivableTotal', title: '应收合计', money: true, defaultWidth: 100, calculated: true },
{ key: 'receivableRent', title: '应收租金', money: true, defaultWidth: 100 },
{ key: 'monthlyIncome', title: '月度收入', money: true, defaultWidth: 100, calculated: true },
{ key: 'monthlyRent', title: '月度租金', money: true, defaultWidth: 100 },
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, defaultWidth: 110 },
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, defaultWidth: 100 },
{ key: 'opsFeeIncome', title: '运维费', money: true, defaultWidth: 90 },
{ key: 'otherIncome', title: '其他收入', money: true, defaultWidth: 100 },
{ key: 'mileageDeduction', title: '里程减免金额', money: true, defaultWidth: 110 },
{ key: 'otherDeduction', title: '其他减免金额', money: true, defaultWidth: 110 },
{ key: 'receivedAmount', title: '实收金额', money: true, defaultWidth: 100 },
{ key: 'outstanding', title: '未收', money: true, defaultWidth: 90, calculated: true },
{
key: 'returnDate',
title: '退车日期',
defaultWidth: 108,
dateSort: true,
annotationId: 'lbd-col-return-date',
checkField: 'returnDate',
},
{
key: 'avgDays',
title: '平均天数',
defaultWidth: 88,
className: 'tabular-nums',
annotationId: 'lbd-col-avg-days',
},
{
key: 'deposit',
title: '押金',
money: true,
defaultWidth: 100,
summaryKey: 'deposit',
annotationId: 'lbd-col-deposit',
checkField: 'deposit',
},
{
key: 'contractRent',
title: '合同标的租金',
money: true,
defaultWidth: 110,
summaryKey: 'contractRent',
annotationId: 'lbd-col-contract-rent',
checkField: 'contractRent',
},
{
key: 'receivableTotal',
title: '应收合计',
money: true,
defaultWidth: 100,
calculated: true,
summaryKey: 'receivableTotal',
annotationId: 'lbd-col-receivable-total',
},
{
key: 'receivableRent',
title: '应收租金',
money: true,
defaultWidth: 100,
summaryKey: 'receivableRent',
annotationId: 'lbd-col-receivable-rent',
checkField: 'receivableRent',
},
{
key: 'monthlyIncome',
title: '月度收入',
money: true,
defaultWidth: 100,
calculated: true,
summaryKey: 'monthlyIncome',
annotationId: 'lbd-col-monthly-income',
},
{
key: 'monthlyRent',
title: '月度租金',
money: true,
defaultWidth: 100,
summaryKey: 'monthlyRent',
annotationId: 'lbd-col-monthly-rent',
},
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, defaultWidth: 110, summaryKey: 'maintenanceIncome' },
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, defaultWidth: 100, summaryKey: 'insuranceSurcharge' },
{ key: 'opsFeeIncome', title: '运维费', money: true, defaultWidth: 90, summaryKey: 'opsFeeIncome' },
{ key: 'otherIncome', title: '其他收入', money: true, defaultWidth: 100, summaryKey: 'otherIncome' },
{
key: 'mileageDeduction',
title: '里程减免金额',
money: true,
defaultWidth: 110,
summaryKey: 'mileageDeduction',
annotationId: 'lbd-col-mileage-deduction',
checkField: 'mileageDeduction',
},
{
key: 'otherDeduction',
title: '其他减免金额',
money: true,
defaultWidth: 110,
summaryKey: 'otherDeduction',
annotationId: 'lbd-col-other-deduction',
checkField: 'otherDeduction',
},
{ key: 'receivedAmount', title: '实收金额', money: true, defaultWidth: 100, summaryKey: 'receivedAmount' },
{
key: 'outstanding',
title: '未收',
money: true,
defaultWidth: 90,
calculated: true,
summaryKey: 'outstanding',
annotationId: 'lbd-col-outstanding',
},
{ key: 'invoiceDate', title: '开票日期', defaultWidth: 108, dateSort: true },
{ key: 'actualPaymentDate', title: '实际付款日期', defaultWidth: 118, dateSort: true },
{ key: 'paymentMethod', title: '付款方式', defaultWidth: 100 },
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, defaultWidth: 110 },
{ key: 'vehicleActualCost', title: '车辆实际成本', money: true, defaultWidth: 110, calculated: true },
{ key: 'insuranceFee', title: '保险费', money: true, defaultWidth: 90 },
{ key: 'hydrogenFee', title: '氢费', money: true, defaultWidth: 90 },
{ key: 'opsFeeCost', title: '运维费', money: true, defaultWidth: 90 },
{ key: 'brokerageFee', title: '居间费', money: true, defaultWidth: 90 },
{ key: 'otherCost', title: '其他', money: true, defaultWidth: 90 },
{ key: 'totalCost', title: '总成本', money: true, defaultWidth: 100, calculated: true },
{ key: 'profitLoss', title: '盈亏', money: true, defaultWidth: 90, calculated: true },
{
key: 'paymentMethod',
title: '付款方式',
render: 'payment-method',
defaultWidth: 108,
annotationId: 'lbd-col-payment-method',
},
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, defaultWidth: 110, summaryKey: 'vehicleStandardCost' },
{
key: 'vehicleActualCost',
title: '车辆实际成本',
money: true,
defaultWidth: 110,
calculated: true,
summaryKey: 'vehicleActualCost',
annotationId: 'lbd-col-vehicle-actual-cost',
},
{ key: 'insuranceFee', title: '保险费', money: true, defaultWidth: 90, summaryKey: 'insuranceFee' },
{
key: 'hydrogenFee',
title: '氢费',
money: true,
defaultWidth: 90,
summaryKey: 'hydrogenFee',
render: 'hydrogen-fee',
annotationId: 'lbd-col-hydrogen-fee',
},
{ key: 'opsFeeCost', title: '运维费', money: true, defaultWidth: 90, summaryKey: 'opsFeeCost' },
{ key: 'brokerageFee', title: '居间费', money: true, defaultWidth: 90, summaryKey: 'brokerageFee' },
{ key: 'otherCost', title: '其他', money: true, defaultWidth: 90, summaryKey: 'otherCost' },
{
key: 'totalCost',
title: '总成本',
money: true,
defaultWidth: 100,
calculated: true,
summaryKey: 'totalCost',
annotationId: 'lbd-col-total-cost',
},
{
key: 'profitLoss',
title: '盈亏',
money: true,
defaultWidth: 90,
calculated: true,
summaryKey: 'profitLoss',
annotationId: 'lbd-col-profit-loss',
},
{ key: 'gpsMileage', title: 'GPS里程', defaultWidth: 96, className: 'tabular-nums' },
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', defaultWidth: 160 },
{ key: 'assetOwner', title: '资产归属', defaultWidth: 140 },
{ key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', defaultWidth: 180 },
{
key: 'assetOwner',
title: '资产归属',
defaultWidth: 140,
annotationId: 'lbd-col-asset-owner',
checkField: 'assetOwner',
},
{
key: 'signingCompany',
title: '签约公司',
className: 'ldb-col-name',
defaultWidth: 180,
annotationId: 'lbd-col-signing-company',
},
{ key: 'remark', title: '备注', className: 'ldb-col-remark', defaultWidth: 140 },
];
/** 列表导出表头(含公式列 */
export const DETAIL_EXPORT_HEADERS = DETAIL_TABLE_COLUMNS.map((col) => col.title);
/** Excel 导入/导出列规格47 列,含独立年份/月份/业务员 */
export const DETAIL_EXPORT_SPECS: { key: keyof LeaseBusinessDetailRow; title: string }[] = [
{ key: 'year', title: '年份' },
{ key: 'month', title: '月份' },
{ key: 'businessDept', title: '业务部门' },
{ key: 'salesperson', title: '业务员' },
{ key: 'paymentDate', title: '付款日期' },
{ key: 'plateNo', title: '车牌号码' },
{ key: 'vehicleType', title: '车型' },
{ key: 'customerName', title: '客户名称' },
{ key: 'valueAddedService', title: '增值服务' },
{ key: 'exemptionPolicy', title: '享免政策' },
{ key: 'pickupDate', title: '提车日期' },
{ key: 'contractStartDate', title: '合同生效日期' },
{ key: 'contractEndDate', title: '合同到期日期' },
{ key: 'periodStartDate', title: '起始日期' },
{ key: 'returnDate', title: '退车日期' },
{ key: 'avgDays', title: '平均天数' },
{ key: 'deposit', title: '押金' },
{ key: 'contractRent', title: '合同标的租金' },
{ key: 'receivableTotal', title: '应收合计' },
{ key: 'receivableRent', title: '应收租金' },
{ key: 'monthlyIncome', title: '月度收入' },
{ key: 'monthlyRent', title: '月度租金' },
{ key: 'maintenanceIncome', title: '维保包干收入' },
{ key: 'insuranceSurcharge', title: '保险上浮费' },
{ key: 'opsFeeIncome', title: '运维费' },
{ key: 'otherIncome', title: '其他收入' },
{ key: 'mileageDeduction', title: '里程减免金额' },
{ key: 'otherDeduction', title: '其他减免金额' },
{ key: 'receivedAmount', title: '实收金额' },
{ key: 'outstanding', title: '未收' },
{ key: 'invoiceDate', title: '开票日期' },
{ key: 'actualPaymentDate', title: '实际付款日期' },
{ key: 'paymentMethod', title: '付款方式' },
{ key: 'vehicleStandardCost', title: '车辆标准成本' },
{ key: 'vehicleActualCost', title: '车辆实际成本' },
{ key: 'insuranceFee', title: '保险费' },
{ key: 'hydrogenFee', title: '氢费' },
{ key: 'opsFeeCost', title: '运维费' },
{ key: 'brokerageFee', title: '居间费' },
{ key: 'otherCost', title: '其他' },
{ key: 'totalCost', title: '总成本' },
{ key: 'profitLoss', title: '盈亏' },
{ key: 'gpsMileage', title: 'GPS里程' },
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容' },
{ key: 'assetOwner', title: '资产归属' },
{ key: 'signingCompany', title: '签约公司' },
{ key: 'remark', title: '备注' },
];
/** 导入模板表头(仅手工录入列,不含公式列) */
export const DETAIL_IMPORT_TEMPLATE_HEADERS = DETAIL_TABLE_COLUMNS
.filter((col) => !col.calculated)
.map((col) => col.title);
/** @deprecated 使用 DETAIL_EXPORT_HEADERS / DETAIL_IMPORT_TEMPLATE_HEADERS */
export const DETAIL_IMPORT_HEADERS = DETAIL_EXPORT_HEADERS;
export const DETAIL_EXPORT_HEADERS = DETAIL_EXPORT_SPECS.map((s) => s.title);
export const DEFAULT_DETAIL_COLUMN_WIDTHS: Record<string, number> = Object.fromEntries(
DETAIL_TABLE_COLUMNS.map((col) => [col.key, col.defaultWidth ?? 100]),
@@ -82,29 +330,6 @@ export const DETAIL_TABLE_MIN_WIDTH = DETAIL_TABLE_COLUMNS.reduce(
0,
);
/** 表尾汇总行:对齐 Excel SUBTOTAL 的 23 个金额列 */
export const DETAIL_SUMMARY_COLUMN_KEYS = new Set([
'deposit',
'contractRent',
'receivableTotal',
'receivableRent',
'monthlyIncome',
'monthlyRent',
'maintenanceIncome',
'insuranceSurcharge',
'opsFeeIncome',
'otherIncome',
'mileageDeduction',
'otherDeduction',
'receivedAmount',
'outstanding',
'vehicleStandardCost',
'vehicleActualCost',
'insuranceFee',
'hydrogenFee',
'opsFeeCost',
'brokerageFee',
'otherCost',
'totalCost',
'profitLoss',
]);
export const DETAIL_SUMMARY_COLUMN_KEYS = new Set(
DETAIL_TABLE_COLUMNS.filter((c) => c.summaryKey).map((c) => c.summaryKey as string),
);

View File

@@ -0,0 +1,178 @@
{
"lbd-1": [
{
"id": "lbd-clog-seed-1-1",
"operatedAt": "2026-01-05 09:12:08",
"operatorId": "staff-001",
"operatorName": "刘念念",
"field": "_import",
"fieldLabel": "批量导入",
"before": "—",
"after": "新增明细"
},
{
"id": "lbd-clog-seed-1-2",
"operatedAt": "2026-01-06 14:25:33",
"operatorId": "staff-001",
"operatorName": "刘念念",
"field": "receivedAmount",
"fieldLabel": "实收金额",
"before": "0.00",
"after": "4,500.00"
},
{
"id": "lbd-clog-seed-1-3",
"operatedAt": "2026-01-07 10:08:19",
"operatorId": "staff-001",
"operatorName": "刘念念",
"field": "customerName",
"fieldLabel": "客户名称",
"before": "安徽驰远供应链",
"after": "安徽驰远供应链管理有限公司"
},
{
"id": "lbd-clog-seed-1-4",
"operatedAt": "2026-01-08 16:42:05",
"operatorId": "staff-001",
"operatorName": "刘念念",
"field": "actualPaymentDate",
"fieldLabel": "实际付款日期",
"before": "—",
"after": "2026-01-19"
},
{
"id": "lbd-clog-seed-1-5",
"operatedAt": "2026-01-10 11:30:00",
"operatorId": "staff-001",
"operatorName": "刘念念",
"field": "remark",
"fieldLabel": "备注",
"before": "—",
"after": "1月租金已收齐"
}
],
"lbd-33": [
{
"id": "lbd-clog-seed-33-1",
"operatedAt": "2026-01-12 08:55:12",
"operatorId": "staff-002",
"operatorName": "尚建华",
"field": "_import",
"fieldLabel": "批量导入",
"before": "—",
"after": "新增明细"
},
{
"id": "lbd-clog-seed-33-2",
"operatedAt": "2026-01-13 15:20:44",
"operatorId": "staff-002",
"operatorName": "尚建华",
"field": "contractRent",
"fieldLabel": "合同标的租金",
"before": "3,800.00",
"after": "4,000.00"
},
{
"id": "lbd-clog-seed-33-3",
"operatedAt": "2026-01-14 09:18:27",
"operatorId": "staff-002",
"operatorName": "尚建华",
"field": "receivedAmount",
"fieldLabel": "实收金额",
"before": "0.00",
"after": "4,000.00"
},
{
"id": "lbd-clog-seed-33-4",
"operatedAt": "2026-01-15 17:05:51",
"operatorId": "staff-002",
"operatorName": "尚建华",
"field": "valueAddedService",
"fieldLabel": "增值服务",
"before": "—",
"after": "含1000元氢费"
},
{
"id": "lbd-clog-seed-33-5",
"operatedAt": "2026-01-18 10:22:36",
"operatorId": "staff-002",
"operatorName": "尚建华",
"field": "_reconcile",
"fieldLabel": "完成对账",
"before": "未对账",
"after": "已对账2026-01-18"
},
{
"id": "lbd-clog-seed-33-6",
"operatedAt": "2026-01-18 10:22:36",
"operatorId": "staff-002",
"operatorName": "尚建华",
"field": "archiveStatus",
"fieldLabel": "对账状态",
"before": "未对账",
"after": "已对账"
},
{
"id": "lbd-clog-seed-33-7",
"operatedAt": "2026-01-20 14:11:08",
"operatorId": "supervisor-001",
"operatorName": "王主管",
"field": "remark",
"fieldLabel": "备注",
"before": "—",
"after": "主管复核:实收与合同一致,归档确认"
}
],
"lbd-280": [
{
"id": "lbd-clog-seed-280-1",
"operatedAt": "2026-04-10 09:40:15",
"operatorId": "staff-003",
"operatorName": "赵连飞",
"field": "_import",
"fieldLabel": "批量导入",
"before": "—",
"after": "新增明细"
},
{
"id": "lbd-clog-seed-280-2",
"operatedAt": "2026-04-11 11:05:22",
"operatorId": "staff-003",
"operatorName": "赵连飞",
"field": "vehicleStandardCost",
"fieldLabel": "车辆标准成本",
"before": "2,800.00",
"after": "3,000.00"
},
{
"id": "lbd-clog-seed-280-3",
"operatedAt": "2026-04-12 16:33:49",
"operatorId": "staff-003",
"operatorName": "赵连飞",
"field": "totalCost",
"fieldLabel": "总成本",
"before": "10,200.00",
"after": "11,190.22"
},
{
"id": "lbd-clog-seed-280-4",
"operatedAt": "2026-04-14 08:50:03",
"operatorId": "staff-003",
"operatorName": "赵连飞",
"field": "hydrogenFee",
"fieldLabel": "氢费",
"before": "0.00",
"after": "8,190.22"
},
{
"id": "lbd-clog-seed-280-5",
"operatedAt": "2026-04-15 13:28:17",
"operatorId": "supervisor-001",
"operatorName": "王主管",
"field": "profitLoss",
"fieldLabel": "盈亏",
"before": "-2,400.22",
"after": "-3,390.22"
}
]
}

View File

@@ -0,0 +1,162 @@
[
{
"hydrogenTime": "2026-01-08 09:15:00",
"stationName": "上海嘉定氢站",
"customerName": "安徽驰远供应链管理有限公司",
"plateNo": "沪A32896F",
"hydrogenKg": 42.5,
"unitPrice": 30.13,
"totalPrice": 1280.5,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-01-22 11:40:00",
"stationName": "上海嘉定氢站",
"customerName": "安徽驰远供应链管理有限公司",
"plateNo": "沪A32896F",
"hydrogenKg": 20,
"unitPrice": 31,
"totalPrice": 620,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-01-11 08:30:00",
"stationName": "嘉兴南湖氢站",
"customerName": "上海利合供应链管理有限公司",
"plateNo": "浙F08991F",
"hydrogenKg": 24,
"unitPrice": 30,
"totalPrice": 720,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-01-12 10:30:00",
"stationName": "乌鲁木齐米东加氢站",
"customerName": "新疆佳淇信息科技有限公司",
"plateNo": "粤AGR5098",
"hydrogenKg": 11.8,
"unitPrice": 45,
"totalPrice": 531.25,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-01-25 14:20:00",
"stationName": "乌鲁木齐米东加氢站",
"customerName": "新疆佳淇信息科技有限公司",
"plateNo": "粤AGR5098",
"hydrogenKg": 9.72,
"unitPrice": 45,
"totalPrice": 437.5,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-04-05 09:10:00",
"stationName": "嘉兴港区加氢站",
"customerName": "嘉兴市飞宇物流有限公司",
"plateNo": "粤AGP9713",
"hydrogenKg": 45.5,
"unitPrice": 45,
"totalPrice": 2047.56,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-04-12 15:22:00",
"stationName": "嘉兴港区加氢站",
"customerName": "嘉兴市飞宇物流有限公司",
"plateNo": "粤AGP9713",
"hydrogenKg": 45.5,
"unitPrice": 45,
"totalPrice": 2047.55,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-04-18 10:05:00",
"stationName": "嘉兴港区加氢站",
"customerName": "嘉兴市飞宇物流有限公司",
"plateNo": "粤AGP9713",
"hydrogenKg": 45.5,
"unitPrice": 45,
"totalPrice": 2047.55,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-04-25 16:48:00",
"stationName": "嘉兴港区加氢站",
"customerName": "嘉兴市飞宇物流有限公司",
"plateNo": "粤AGP9713",
"hydrogenKg": 45.5,
"unitPrice": 45,
"totalPrice": 2047.56,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-05-03 08:55:00",
"stationName": "乌鲁木齐头屯河加氢站",
"customerName": "黑龙江圣合商贸有限公司新疆分公司",
"plateNo": "粤AGP5711",
"hydrogenKg": 53.17,
"unitPrice": 45,
"totalPrice": 2392.58,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-05-18 13:30:00",
"stationName": "乌鲁木齐头屯河加氢站",
"customerName": "黑龙江圣合商贸有限公司新疆分公司",
"plateNo": "粤AGP5711",
"hydrogenKg": 53.17,
"unitPrice": 45,
"totalPrice": 2392.58,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-05-05 07:40:00",
"stationName": "嘉兴港区加氢站",
"customerName": "嘉兴市飞宇物流有限公司",
"plateNo": "粤AGP9713",
"hydrogenKg": 72.4,
"unitPrice": 45,
"totalPrice": 3258.5,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-05-15 18:15:00",
"stationName": "嘉兴港区加氢站",
"customerName": "嘉兴市飞宇物流有限公司",
"plateNo": "粤AGP9713",
"hydrogenKg": 72.4,
"unitPrice": 45,
"totalPrice": 3258.5,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-05-26 14:08:33",
"stationName": "嘉兴加氢站(一期)",
"customerName": "浙江绿运物流有限公司",
"plateNo": "浙A67890F",
"hydrogenKg": 10,
"unitPrice": 45,
"totalPrice": 450,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-05-30 09:30:22",
"stationName": "杭州临平加氢站",
"customerName": "杭州临平城配中心",
"plateNo": "浙B23456F",
"hydrogenKg": 15.3,
"unitPrice": 46,
"totalPrice": 703.8,
"borneBy": "我司承担"
},
{
"hydrogenTime": "2026-01-15 16:45:00",
"stationName": "嘉兴港区加氢站",
"customerName": "演示客户A",
"plateNo": "沪A65889F",
"hydrogenKg": 32,
"unitPrice": 30,
"totalPrice": 960,
"borneBy": "客户承担"
}
]

View File

@@ -8,34 +8,59 @@ import './styles/index.css';
import React, { useCallback, useMemo, useState } from 'react';
import { Download, FileUp } from 'lucide-react';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import seedRows from './data/rows.json';
import changeLogSeed from './data/change-log-seed.json';
import { FilterPanel } from './components/FilterPanel';
import { DetailKpiRow } from './components/DetailKpiRow';
import { DetailTable } from './components/DetailTable';
import { DetailImportModal } from './components/DetailImportModal';
import { DetailEditModal } from './components/DetailEditModal';
import { DetailChangeLogModal } from './components/DetailChangeLogModal';
import { DetailDeleteConfirmModal } from './components/DetailDeleteConfirmModal';
import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
import {
EMPTY_LEASE_DETAIL_FILTERS,
type LeaseBusinessDetailRow,
type LeaseDetailFilters,
} from './types';
import { applyDetailFilters, sumDetailKpi } from './utils/detail';
import { applyDetailFilters, detailRowYearMonth, sumDetailKpi } from './utils/detail';
import { applyDetailCalculations } from './utils/calc-detail';
import {
downloadDetailImportTemplate,
downloadDetailImportErrorLog,
exportDetailRows,
parseDetailImportFile,
type DetailImportErrorRow,
} from './utils/batch-io';
import {
appendChangeLogs,
collectRowChangeLogs,
createChangeLogEntry,
type DetailChangeLogsByRowId,
} from './utils/change-log';
import { isDetailSupervisor, resolveDetailOperator } from './utils/role';
import annotationSourceDocument from './annotation-source.json';
const ARCHIVED_DEMO_ROW_IDS = new Set(['lbd-33']);
export default function LeaseBusinessDetailApp() {
const operator = useMemo(() => resolveDetailOperator(), []);
const isSupervisor = isDetailSupervisor(operator);
const [records, setRecords] = useState<LeaseBusinessDetailRow[]>(() =>
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations(row)),
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations({
...row,
archiveStatus: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? 'archived' : (row.archiveStatus ?? 'active'),
reconciledAt: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? '2026-01-18' : (row.reconciledAt ?? ''),
})),
);
const [changeLogs, setChangeLogs] = useState<DetailChangeLogsByRowId>(
() => ({ ...(changeLogSeed as DetailChangeLogsByRowId) }),
);
const [pendingFilters, setPendingFilters] = useState<LeaseDetailFilters>(EMPTY_LEASE_DETAIL_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<LeaseDetailFilters>(EMPTY_LEASE_DETAIL_FILTERS);
@@ -43,7 +68,12 @@ export default function LeaseBusinessDetailApp() {
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [tableLoading, setTableLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importErrors, setImportErrors] = useState<DetailImportErrorRow[]>([]);
const [importErrorHeaders, setImportErrorHeaders] = useState<string[]>([]);
const [toast, setToast] = useState('');
const [editRow, setEditRow] = useState<LeaseBusinessDetailRow | null>(null);
const [deleteRow, setDeleteRow] = useState<LeaseBusinessDetailRow | null>(null);
const [changeLogRow, setChangeLogRow] = useState<LeaseBusinessDetailRow | null>(null);
const showToast = useCallback((msg: string) => {
setToast(msg);
@@ -61,6 +91,32 @@ export default function LeaseBusinessDetailApp() {
return filtered.slice(start, start + pageSize);
}, [filtered, page, pageSize]);
const updateRow = useCallback((
id: string,
patch: Partial<LeaseBusinessDetailRow>,
logMeta?: { forceField?: string; fieldLabel?: string },
) => {
setRecords((prev) => {
const before = prev.find((row) => row.id === id);
if (!before) return prev;
const after = applyDetailCalculations({ ...before, ...patch });
const logs = collectRowChangeLogs(operator, before, after);
if (logs.length) {
setChangeLogs((prevLogs) => appendChangeLogs(prevLogs, id, logs));
} else if (logMeta?.forceField) {
const entry = createChangeLogEntry(
operator,
logMeta.forceField,
before[logMeta.forceField as keyof LeaseBusinessDetailRow],
after[logMeta.forceField as keyof LeaseBusinessDetailRow],
{ fieldLabel: logMeta.fieldLabel, force: true },
);
if (entry) setChangeLogs((prevLogs) => appendChangeLogs(prevLogs, id, [entry]));
}
return prev.map((row) => (row.id === id ? after : row));
});
}, [operator]);
const handleSearch = () => {
setTableLoading(true);
window.setTimeout(() => {
@@ -78,20 +134,70 @@ export default function LeaseBusinessDetailApp() {
const handleImport = async (file: File) => {
try {
const imported = await parseDetailImportFile(file);
if (imported.length === 0) {
showToast('未解析到有效数据,请检查模板与必填列(年份、月份、车牌号码)');
const result = await parseDetailImportFile(file);
setImportErrors(result.errors);
setImportErrorHeaders(result.fileHeaders);
if (result.success.length === 0 && result.errors.length === 0) {
showToast('未解析到有效数据,请检查模板与必填列');
return;
}
setRecords((prev) => [...imported, ...prev]);
setImportOpen(false);
setPage(1);
showToast(`已导入 ${imported.length} 条明细,公式列已自动计算`);
if (result.success.length > 0) {
const imported = result.success.map((row) => applyDetailCalculations({
...row,
archiveStatus: 'active',
reconciledAt: '',
}));
setRecords((prev) => [...imported, ...prev]);
setChangeLogs((prev) => {
let next = { ...prev };
imported.forEach((row) => {
const entry = createChangeLogEntry(operator, '_import', null, null, {
fieldLabel: '批量导入',
beforeText: '—',
afterText: '新增明细',
force: true,
});
if (entry) next = appendChangeLogs(next, row.id, [entry]);
});
return next;
});
setPage(1);
}
if (result.errors.length > 0 && result.success.length > 0) {
showToast(`已导入 ${result.success.length} 条;${result.errors.length} 条失败,可下载错误日志`);
} else if (result.errors.length > 0) {
showToast(`${result.errors.length} 条导入失败,请下载错误日志查看原因`);
} else {
setImportOpen(false);
showToast(`已导入 ${result.success.length} 条明细,公式列已自动计算`);
}
} catch {
showToast('导入失败,请检查文件格式');
}
};
const handleDeleteConfirm = () => {
if (!deleteRow) return;
const entry = createChangeLogEntry(operator, '_delete', null, null, {
fieldLabel: '删除记录',
beforeText: `${detailRowYearMonth(deleteRow)} · ${deleteRow.plateNo}`,
afterText: '已删除',
force: true,
});
if (entry) setChangeLogs((prev) => appendChangeLogs(prev, deleteRow.id, [entry]));
setRecords((prev) => prev.filter((row) => row.id !== deleteRow.id));
setDeleteRow(null);
showToast('已删除该条明细');
};
const changeLogEntries = changeLogRow ? (changeLogs[changeLogRow.id] ?? []) : [];
const changeLogLabel = changeLogRow
? `${detailRowYearMonth(changeLogRow)} · ${changeLogRow.plateNo}`
: '';
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
@@ -108,6 +214,16 @@ export default function LeaseBusinessDetailApp() {
return (
<>
<div className="vm-page ldb-page lc-page lc-page--list-dense lbd-page">
<div className="lbd-role-banner" role="status">
<span>
{isSupervisor ? '业务管理部主管(可修改已锁定记录)' : '业管人员'}
</span>
{!isSupervisor && (
<span className="lbd-role-banner-hint">URL ?role=supervisor</span>
)}
</div>
<FilterPanel
records={records}
filters={pendingFilters}
@@ -124,27 +240,40 @@ export default function LeaseBusinessDetailApp() {
<button
type="button"
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
onClick={() => setImportOpen(true)}
data-vm-icon="import"
onClick={() => {
setImportErrors([]);
setImportErrorHeaders([]);
setImportOpen(true);
}}
>
<FileUp size={16} aria-hidden />
</button>
<button
type="button"
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
data-vm-icon="download"
onClick={() => {
exportDetailRows(filtered);
showToast(`已导出 ${filtered.length} 条明细`);
}}
>
<Download size={16} aria-hidden />
</button>
</div>
</div>
<div className="vm-table-card">
<DetailTable records={paged} summary={kpi} loading={tableLoading} />
<DetailTable
records={paged}
summary={kpi}
loading={tableLoading}
isSupervisor={isSupervisor}
onEdit={setEditRow}
onDelete={setDeleteRow}
onChangeLog={setChangeLogRow}
onRowPatch={(id, patch) => updateRow(id, patch)}
/>
<div className="vm-table-footer ldb-table-footer">
<TablePagination
page={Math.min(page, totalPages)}
@@ -168,9 +297,41 @@ export default function LeaseBusinessDetailApp() {
onClose={() => setImportOpen(false)}
onDownloadTemplate={downloadDetailImportTemplate}
onImport={handleImport}
failedCount={importErrors.length}
onDownloadErrorLog={
importErrors.length > 0
? () => downloadDetailImportErrorLog(importErrors, importErrorHeaders)
: undefined
}
/>
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
<DetailEditModal
open={Boolean(editRow)}
row={editRow}
onClose={() => setEditRow(null)}
onSave={(patch) => {
if (!editRow) return;
updateRow(editRow.id, patch);
setEditRow(null);
showToast('明细已保存,公式列已自动重算');
}}
/>
<DetailDeleteConfirmModal
open={Boolean(deleteRow)}
rowLabel={deleteRow ? `${detailRowYearMonth(deleteRow)} · ${deleteRow.plateNo}` : ''}
onCancel={() => setDeleteRow(null)}
onConfirm={handleDeleteConfirm}
/>
<DetailChangeLogModal
open={Boolean(changeLogRow)}
rowLabel={changeLogLabel}
entries={changeLogEntries}
onClose={() => setChangeLogRow(null)}
/>
<PrototypeAnnotationHost source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
</>
);
}

View File

@@ -324,15 +324,98 @@
}
.lbd-profit-pos {
color: var(--ln-success, #16a34a);
color: var(--ln-error, #dc2626);
font-weight: 600;
}
.lbd-profit-neg {
color: var(--ln-error, #dc2626);
color: var(--ln-success, #16a34a);
font-weight: 600;
}
.lbd-field-check-cell {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
}
.lbd-field-check-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.lbd-field-check-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 999px;
cursor: pointer;
}
.lbd-field-check-icon.is-ok {
background: color-mix(in srgb, var(--ln-success, #16a34a) 14%, transparent);
color: var(--ln-success, #16a34a);
}
.lbd-field-check-icon.is-warn {
background: color-mix(in srgb, var(--ln-error, #dc2626) 12%, transparent);
color: var(--ln-error, #dc2626);
}
.lbd-field-check-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 200px;
max-width: 280px;
padding: 8px 10px;
border-radius: var(--ln-radius-control);
background: var(--ln-ink);
color: #fff;
font-size: 0.75rem;
line-height: 1.45;
box-shadow: var(--ln-shadow-hover);
}
.lbd-field-check-pop--ok {
background: var(--ln-surface-card);
color: var(--ln-ink);
border: 1px solid var(--ln-hairline);
}
.lbd-field-check-pop__title {
font-weight: 600;
margin-bottom: 4px;
}
.lbd-field-check-pop__line + .lbd-field-check-pop__line {
margin-top: 2px;
}
.lbd-field-check-pop__actions {
margin-top: 8px;
}
.lbd-detail-table .ldb-th-inner {
display: flex;
align-items: center;
gap: 0;
min-height: 100%;
}
.lbd-detail-table .ldb-th-with-tip {
display: inline-flex;
align-items: center;
gap: 4px;
}
.lbd-cell-remark {
display: inline-block;
max-width: 140px;
@@ -345,6 +428,253 @@
padding: 48px 24px;
}
.lbd-import-error-step {
margin-top: 4px;
}
.lbd-import-error-summary {
margin: 0 0 10px;
font-size: 0.875rem;
color: var(--ln-muted);
line-height: 1.5;
}
.lbd-import-error-summary strong {
color: var(--ln-error, #dc2626);
font-weight: 600;
}
.ldb-import-modal .vm-upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 120px;
}
.ldb-import-modal .vm-upload-zone p {
margin: 0;
}
.lbd-page .vm-table-actions {
margin-left: auto;
}
.lbd-role-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: var(--ln-radius-control);
border: 1px solid var(--ln-hairline);
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
font-size: 0.875rem;
color: var(--ln-text-secondary);
}
.lbd-role-banner-hint {
color: var(--ln-muted);
font-size: 0.8125rem;
}
.lbd-detail-table .ldb-col-actions {
min-width: 200px;
}
.lbd-row-archived td {
background: color-mix(in srgb, var(--ln-muted) 6%, var(--ln-surface-card));
}
.lbd-row-archived:hover td {
background: color-mix(in srgb, var(--ln-muted) 10%, var(--ln-surface-card));
}
.lbd-changelog-meta {
margin: 0 0 12px;
font-size: 0.8125rem;
color: var(--ln-muted);
line-height: 1.4;
}
.lbd-changelog-modal.vm-modal-lg {
width: min(920px, calc(100vw - 32px));
}
.lbd-changelog-header {
align-items: center;
}
.lbd-changelog-header-text {
min-width: 0;
flex: 1;
}
.lbd-changelog-header .vm-modal-title {
line-height: 1.35;
}
.lbd-changelog-body {
overflow: hidden;
padding: 12px 24px 20px;
}
.lbd-changelog-footer {
justify-content: flex-end;
}
.lbd-changelog-table-wrap {
overflow: auto;
max-height: min(440px, 58vh);
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-card);
scrollbar-gutter: stable;
}
.lbd-changelog-table {
width: 100%;
min-width: 800px;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: 0.8125rem;
}
.lbd-changelog-table th,
.lbd-changelog-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--ln-hairline);
text-align: left;
line-height: 1.5;
}
.lbd-changelog-table thead th {
position: sticky;
top: 0;
z-index: 2;
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.02em;
color: var(--ln-muted);
white-space: nowrap;
vertical-align: middle;
background: color-mix(in srgb, var(--ln-canvas-soft) 92%, var(--ln-surface-card));
box-shadow: inset 0 -1px 0 var(--ln-hairline);
}
.lbd-changelog-table tbody tr:nth-child(even) td {
background: color-mix(in srgb, var(--ln-canvas-soft) 45%, transparent);
}
.lbd-changelog-table tbody tr:hover td {
background: color-mix(in srgb, var(--ln-primary) 5%, var(--ln-surface-card));
}
.lbd-changelog-table tbody tr:last-child td {
border-bottom: none;
}
.lbd-changelog-cell-meta {
vertical-align: middle;
white-space: nowrap;
color: var(--ln-text-secondary);
}
.lbd-changelog-cell-field {
vertical-align: middle;
}
.lbd-changelog-field-tag {
display: inline-block;
max-width: 100%;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--ln-primary) 18%, var(--ln-hairline));
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
color: var(--ln-ink);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.lbd-changelog-cell-diff {
vertical-align: middle;
}
.lbd-changelog-diff-text {
display: block;
max-width: 100%;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.8125rem;
line-height: 1.45;
word-break: break-word;
overflow-wrap: anywhere;
}
.lbd-changelog-diff-text--before {
color: #b42318;
background: color-mix(in srgb, #fecaca 28%, transparent);
}
.lbd-changelog-diff-text--after {
color: #067647;
background: color-mix(in srgb, #bbf7d0 32%, transparent);
}
.lbd-changelog-empty {
display: inline-block;
min-width: 1.25em;
color: var(--ln-muted);
text-align: center;
}
.lbd-edit-hint {
margin: 0 0 12px;
font-size: 0.8125rem;
color: var(--ln-muted);
line-height: 1.5;
}
.lbd-edit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 16px;
}
@media (max-width: 960px) {
.lbd-edit-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.lbd-edit-grid {
grid-template-columns: 1fr;
}
}
.lbd-edit-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.lbd-edit-label {
font-size: 0.8125rem;
color: var(--ln-text-secondary);
}
.lbd-edit-form {
max-height: min(68vh, 640px);
overflow: auto;
}

View File

@@ -1,3 +1,5 @@
export type DetailArchiveStatus = 'active' | 'archived';
export interface LeaseBusinessDetailRow {
id: string;
year: number;
@@ -47,6 +49,10 @@ export interface LeaseBusinessDetailRow {
assetOwner: string;
signingCompany: string;
remark: string;
/** 未对账可编辑;已对账后仅业务管理部主管可改 */
archiveStatus?: DetailArchiveStatus;
/** 完成对账日期 YYYY-MM-DD */
reconciledAt?: string;
}
export interface LeaseDetailFilters {
@@ -55,6 +61,8 @@ export interface LeaseDetailFilters {
salesperson: string;
/** 客户名称模糊匹配 */
customerName: string;
/** 收款状态:已结清 / 部分收款 / 未收款 */
paymentStatus: string;
plateNos: string[];
}
@@ -116,5 +124,6 @@ export const EMPTY_LEASE_DETAIL_FILTERS: LeaseDetailFilters = {
businessDept: '',
salesperson: '',
customerName: '',
paymentStatus: '',
plateNos: [],
};

View File

@@ -1,20 +1,83 @@
import * as XLSX from 'xlsx';
import type { LeaseBusinessDetailRow } from '../types';
import {
DETAIL_EXPORT_HEADERS,
DETAIL_IMPORT_TEMPLATE_HEADERS,
DETAIL_TABLE_COLUMNS,
} from '../components/tableColumns';
import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableColumns';
import { applyDetailCalculations } from './calc-detail';
import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref';
/** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */
export const DETAIL_IMPORT_TEMPLATE_HEADERS = [
'年份',
'月份',
'付款日期',
'车牌号码',
'客户名称',
'增值服务',
'享免政策',
'提车日期',
'合同生效日期',
'合同到期日期',
'起始日期',
'退车日期',
'平均天数',
'押金',
'合同标的租金',
'维保包干收入',
'保险上浮费',
'运维费',
'其他收入',
'里程减免金额',
'其他减免金额',
'实收金额',
'开票日期',
'实际付款日期',
'付款方式',
'车辆标准成本',
'车辆实际成本',
'保险费',
'运维费',
'居间费',
'其他',
'GPS里程',
'减免金额明细内容',
'资产归属',
'签约公司',
'备注',
] as const;
export const DETAIL_IMPORT_REQUIRED_HEADERS = [
'年份',
'月份',
'付款日期',
'车牌号码',
'客户名称',
'提车日期',
'合同生效日期',
'合同到期日期',
'平均天数',
'押金',
'合同标的租金',
'实收金额',
'资产归属',
'签约公司',
] as const;
export interface DetailImportErrorRow {
reason: string;
rawCells: (string | number)[];
}
export interface DetailImportParseResult {
success: LeaseBusinessDetailRow[];
errors: DetailImportErrorRow[];
/** 上传文件表头(用于错误日志) */
fileHeaders: string[];
}
const TEMPLATE_SAMPLE: Record<string, string | number> = {
年份: 2026,
月份: 2,
: '业务二部',
: '刘念忠',
: '2026-02-05',
: '沪A52898F',
: '18T',
: '上海虹钦物流有限公司',
: '',
: '',
@@ -23,28 +86,26 @@ const TEMPLATE_SAMPLE: Record<string, string | number> = {
: '2026-11-30',
: '2026-02-01',
退: '',
平均天数: 28,
平均天数: 1,
押金: 20000,
合同标的租金: 18500,
应收租金: 18500,
月度租金: 18500,
合同标的租金: 4500,
维保包干收入: 0,
保险上浮费: 0,
运维费: 0,
其他收入: 0,
里程减免金额: 0,
其他减免金额: 0,
实收金额: 18500,
实收金额: 4500,
: '2026-02-06',
: '2026-02-05',
: '月付指付',
车辆标准成本: 12000,
: '月度预付',
车辆标准成本: 3900,
车辆实际成本: 0,
保险费: 0,
: 3200,
居间费: 800,
居间: 500,
其他: 0,
GPS里程: '3260',
: '',
GPS里程: '',
: '',
: '浙江羚牛氢能科技有限公司',
: '浙江羚牛氢能科技有限公司',
: '',
@@ -81,11 +142,14 @@ const HEADER_TO_KEY: Record<string, keyof LeaseBusinessDetailRow> = {
: 'actualPaymentDate',
: 'paymentMethod',
: 'vehicleStandardCost',
: 'vehicleActualCost',
: 'insuranceFee',
: 'hydrogenFee',
: 'brokerageFee',
: 'otherCost',
GPS里程: 'gpsMileage',
'GPS 里程': 'gpsMileage',
: 'otherDeductionDetail',
: 'otherDeductionDetail',
: 'assetOwner',
: 'signingCompany',
@@ -97,7 +161,7 @@ const IMPORT_NUMERIC_KEYS = new Set<keyof LeaseBusinessDetailRow>([
'contractRent', 'receivableRent', 'monthlyRent',
'maintenanceIncome', 'insuranceSurcharge', 'opsFeeIncome', 'otherIncome',
'mileageDeduction', 'otherDeduction', 'receivedAmount',
'vehicleStandardCost', 'insuranceFee', 'hydrogenFee',
'vehicleStandardCost', 'vehicleActualCost', 'insuranceFee', 'hydrogenFee',
'opsFeeCost', 'brokerageFee', 'otherCost',
]);
@@ -108,6 +172,13 @@ const IMPORT_TEXT_KEYS = new Set<keyof LeaseBusinessDetailRow>([
'gpsMileage', 'otherDeductionDetail', 'assetOwner', 'signingCompany', 'remark',
]);
const REQUIRED_NUMERIC_HEADERS = new Set(['年份', '月份', '平均天数', '合同标的租金', '实收金额']);
function isCellEmpty(value: unknown): boolean {
if (value === null || value === undefined) return true;
return String(value).trim() === '';
}
function parseNumber(value: unknown): number {
if (value === null || value === undefined || value === '') return 0;
const n = Number(String(value).replace(/,/g, ''));
@@ -121,10 +192,94 @@ function parseDeposit(value: unknown): number | string {
return parseNumber(raw);
}
function normalizeHeader(header: string): string {
return header.replace(/(必填)/g, '').trim();
}
function resolveHeaderKey(header: string): keyof LeaseBusinessDetailRow | undefined {
return HEADER_TO_KEY[normalizeHeader(header)];
}
function validateRequiredFields(
headers: string[],
cells: (string | number)[],
): string[] {
const reasons: string[] = [];
const headerIndex = new Map(headers.map((h, i) => [normalizeHeader(h), i]));
DETAIL_IMPORT_REQUIRED_HEADERS.forEach((required) => {
const idx = headerIndex.get(required);
const raw = idx === undefined ? undefined : cells[idx];
if (required === '押金') {
if (isCellEmpty(raw)) reasons.push(`${required}不能为空`);
return;
}
if (REQUIRED_NUMERIC_HEADERS.has(required)) {
if (isCellEmpty(raw)) reasons.push(`${required}不能为空`);
return;
}
if (isCellEmpty(raw)) reasons.push(`${required}不能为空`);
});
return reasons;
}
function enrichDraftFromSystem(draft: Partial<LeaseBusinessDetailRow>): void {
if (!draft.plateNo) return;
const vehicle = getVehicleRef(draft.plateNo);
if (vehicle) {
draft.vehicleType = vehicle.brandModel || draft.vehicleType || '';
}
if (draft.customerName) {
const contract = getContractRef(draft.plateNo, draft.customerName);
if (contract) {
draft.businessDept = contract.businessDept;
draft.salesperson = contract.salesperson;
}
}
}
function parseRowDraft(
headers: string[],
cells: (string | number)[],
index: number,
): Partial<LeaseBusinessDetailRow> {
const draft: Partial<LeaseBusinessDetailRow> = {
id: `import-${Date.now()}-${index}`,
};
let opsFeeColIndex = 0;
headers.forEach((header, colIndex) => {
const normalized = normalizeHeader(header);
let key = resolveHeaderKey(header);
if (normalized === '运维费') {
opsFeeColIndex += 1;
key = opsFeeColIndex === 1 ? 'opsFeeIncome' : 'opsFeeCost';
}
if (!key) return;
const raw = cells[colIndex];
if (key === 'deposit') {
(draft as Record<string, unknown>)[key] = parseDeposit(raw);
} else if (IMPORT_NUMERIC_KEYS.has(key)) {
const num = parseNumber(raw);
if (key === 'mileageDeduction' || key === 'otherDeduction') {
(draft as Record<string, unknown>)[key] = num > 0 ? -Math.abs(num) : num;
} else {
(draft as Record<string, unknown>)[key] = num;
}
} else if (IMPORT_TEXT_KEYS.has(key)) {
(draft as Record<string, unknown>)[key] = String(raw ?? '').trim();
}
});
enrichDraftFromSystem(draft);
return draft;
}
function rowToExportCells(row: LeaseBusinessDetailRow): string[] {
let opsIncomeWritten = false;
return DETAIL_TABLE_COLUMNS.map((col) => {
const header = col.title;
return DETAIL_EXPORT_SPECS.map((spec) => {
const header = spec.title;
if (header === '运维费') {
if (!opsIncomeWritten) {
opsIncomeWritten = true;
@@ -132,7 +287,7 @@ function rowToExportCells(row: LeaseBusinessDetailRow): string[] {
}
return row.opsFeeCost === 0 ? '' : String(row.opsFeeCost);
}
const key = col.key as keyof LeaseBusinessDetailRow;
const key = spec.key;
const raw = row[key];
if (key === 'deposit') {
if (raw === '/' || raw === '') return raw === '/' ? '/' : '';
@@ -151,12 +306,25 @@ export function downloadDetailImportTemplate(): void {
}, []);
if (opsIndexes[1] !== undefined) sampleRow[opsIndexes[1]] = 0;
const sheet = XLSX.utils.aoa_to_sheet([DETAIL_IMPORT_TEMPLATE_HEADERS, sampleRow]);
const sheet = XLSX.utils.aoa_to_sheet([Array.from(DETAIL_IMPORT_TEMPLATE_HEADERS), sampleRow]);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
XLSX.writeFile(book, '租赁业务明细导入模板.xlsx');
}
export function downloadDetailImportErrorLog(
errors: DetailImportErrorRow[],
fileHeaders: string[],
): void {
if (!errors.length) return;
const headers = ['导入失败原因', ...(fileHeaders.length ? fileHeaders : Array.from(DETAIL_IMPORT_TEMPLATE_HEADERS))];
const body = errors.map((err) => [err.reason, ...err.rawCells]);
const sheet = XLSX.utils.aoa_to_sheet([headers, ...body]);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, '导入失败记录');
XLSX.writeFile(book, `租赁业务明细导入失败记录_${Date.now()}.xlsx`);
}
export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
const body = [DETAIL_EXPORT_HEADERS, ...rows.map(rowToExportCells)];
const sheet = XLSX.utils.aoa_to_sheet(body);
@@ -165,43 +333,38 @@ export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
XLSX.writeFile(book, `租赁业务明细_${Date.now()}.xlsx`);
}
export async function parseDetailImportFile(file: File): Promise<LeaseBusinessDetailRow[]> {
export async function parseDetailImportFile(file: File): Promise<DetailImportParseResult> {
const buffer = await file.arrayBuffer();
const book = XLSX.read(buffer, { type: 'array' });
const sheet = book.Sheets[book.SheetNames[0]];
const matrix = XLSX.utils.sheet_to_json<(string | number)[]>(sheet, { header: 1, defval: '' }) as (string | number)[][];
if (matrix.length < 2) return [];
if (matrix.length < 2) {
return { success: [], errors: [], fileHeaders: [] };
}
const headers = matrix[0].map((cell) => String(cell).trim());
const rows: LeaseBusinessDetailRow[] = [];
const fileHeaders = matrix[0].map((cell) => String(cell).trim());
const success: LeaseBusinessDetailRow[] = [];
const errors: DetailImportErrorRow[] = [];
matrix.slice(1).forEach((cells, index) => {
if (!cells.some((cell) => String(cell).trim())) return;
const draft: Partial<LeaseBusinessDetailRow> = {
id: `import-${Date.now()}-${index}`,
};
let opsFeeColIndex = 0;
headers.forEach((header, colIndex) => {
let key = HEADER_TO_KEY[header];
if (header === '运维费') {
opsFeeColIndex += 1;
key = opsFeeColIndex === 1 ? 'opsFeeIncome' : 'opsFeeCost';
}
if (!key) return;
const raw = cells[colIndex];
if (key === 'deposit') {
(draft as Record<string, unknown>)[key] = parseDeposit(raw);
} else if (IMPORT_NUMERIC_KEYS.has(key)) {
(draft as Record<string, unknown>)[key] = parseNumber(raw);
} else if (IMPORT_TEXT_KEYS.has(key)) {
(draft as Record<string, unknown>)[key] = String(raw ?? '').trim();
}
});
const rawCells = fileHeaders.map((_, colIndex) => cells[colIndex] ?? '');
const requiredReasons = validateRequiredFields(fileHeaders, cells);
if (requiredReasons.length > 0) {
errors.push({ reason: requiredReasons.join(''), rawCells });
return;
}
if (!draft.plateNo || !draft.year || !draft.month) return;
rows.push(applyDetailCalculations(draft));
const draft = parseRowDraft(fileHeaders, cells, index);
const plateNo = String(draft.plateNo ?? '').trim();
if (!isPlateInVehicleSystem(plateNo)) {
errors.push({ reason: '车牌号不存在', rawCells });
return;
}
success.push(applyDetailCalculations(draft));
});
return rows;
return { success, errors, fileHeaders };
}

View File

@@ -1,4 +1,6 @@
import type { LeaseBusinessDetailRow } from '../types';
import { sumHydrogenFeeForDetailRow } from './hydrogen-fee';
import { resolveSettlementMonths } from './system-ref';
/** 对齐 Excel「租赁车辆收入明细表」公式列 */
export const DETAIL_CALCULATED_KEYS = [
@@ -26,7 +28,55 @@ type IncomeAdjustments = Pick<
| 'otherDeduction'
>;
/** 应收合计 = 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入) + 其他收入 + 里程减免 + 其他减免 */
/** 账单月自然日天数 */
export function daysInMonth(year: number, month: number): number {
if (!year || !month) return 30;
return new Date(year, month, 0).getDate();
}
/** 本月在租天数(原型:有起始/退车日期则按区间,否则整月) */
export function calcLeaseDaysInMonth(
year: number,
month: number,
periodStartDate: string,
returnDate: string,
): number {
if (!year || !month) return 0;
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0);
let start = periodStartDate ? new Date(periodStartDate.slice(0, 10)) : monthStart;
let end = returnDate ? new Date(returnDate.slice(0, 10)) : monthEnd;
if (start < monthStart) start = monthStart;
if (end > monthEnd) end = monthEnd;
if (end < start) return 0;
const ms = end.getTime() - start.getTime();
return Math.max(0, Math.floor(ms / 86400000) + 1);
}
/** 平均天数 = 本月在租天数 / 当月天数 */
export function calcDetailAvgDays(
year: number,
month: number,
periodStartDate: string,
returnDate: string,
): number {
const leaseDays = calcLeaseDaysInMonth(year, month, periodStartDate, returnDate);
const dim = daysInMonth(year, month);
if (!dim) return 0;
return roundMoney(leaseDays / dim);
}
/** 月度租金 = 合同标的租金(月标准),付款周期大于 1 时仍按月标准展示 */
export function calcDetailMonthlyRent(contractRent: number): number {
return roundMoney(contractRent);
}
/** 应收租金 = 合同标的租金 × 结算周期月数 */
export function calcDetailReceivableRent(contractRent: number, settlementMonths: number): number {
return roundMoney(contractRent * settlementMonths);
}
/** 应收合计 = 应收租金 + 收入调整项 */
export function calcDetailReceivableTotal(
receivableRent: number,
adjustments: IncomeAdjustments,
@@ -127,19 +177,45 @@ export function applyDetailCalculations(
otherDeduction: row.otherDeduction ?? 0,
};
const receivableRent = row.receivableRent ?? 0;
const monthlyRent = row.monthlyRent ?? 0;
const year = row.year ?? 0;
const month = row.month ?? 0;
const contractRent = row.contractRent ?? 0;
const settlementMonths = resolveSettlementMonths({
...row,
year,
month,
contractRent,
} as LeaseBusinessDetailRow);
const computedAvgDays = calcDetailAvgDays(
year,
month,
row.periodStartDate ?? '',
row.returnDate ?? '',
);
const avgDays = row.avgDays && row.avgDays > 0 ? row.avgDays : computedAvgDays;
const monthlyRent = calcDetailMonthlyRent(contractRent);
const receivableRent = row.receivableRent && row.receivableRent > 0
? row.receivableRent
: calcDetailReceivableRent(contractRent, settlementMonths);
const receivedAmount = row.receivedAmount ?? 0;
const vehicleStandardCost = row.vehicleStandardCost ?? 0;
const avgDays = row.avgDays ?? 0;
const vehicleActualCost = calcDetailVehicleActualCost(vehicleStandardCost, avgDays);
const monthlyIncome = calcDetailMonthlyIncome(monthlyRent, adjustments);
const receivableTotal = calcDetailReceivableTotal(receivableRent, adjustments);
const hydrogenFee = sumHydrogenFeeForDetailRow({
plateNo: row.plateNo ?? '',
customerName: row.customerName ?? '',
year,
month,
pickupDate: row.pickupDate ?? '',
returnDate: row.returnDate ?? '',
});
const totalCost = calcDetailTotalCost({
vehicleActualCost,
insuranceFee: row.insuranceFee ?? 0,
hydrogenFee: row.hydrogenFee ?? 0,
hydrogenFee,
opsFeeCost: row.opsFeeCost ?? 0,
brokerageFee: row.brokerageFee ?? 0,
otherCost: row.otherCost ?? 0,
@@ -154,7 +230,7 @@ export function applyDetailCalculations(
...EMPTY_TEXT_FIELDS,
...row,
deposit: row.deposit ?? 0,
contractRent: row.contractRent ?? 0,
contractRent,
receivableRent,
monthlyRent,
...adjustments,
@@ -162,7 +238,7 @@ export function applyDetailCalculations(
vehicleStandardCost,
avgDays,
insuranceFee: row.insuranceFee ?? 0,
hydrogenFee: row.hydrogenFee ?? 0,
hydrogenFee,
opsFeeCost: row.opsFeeCost ?? 0,
brokerageFee: row.brokerageFee ?? 0,
otherCost: row.otherCost ?? 0,
@@ -172,5 +248,7 @@ export function applyDetailCalculations(
totalCost,
outstanding,
profitLoss,
archiveStatus: row.archiveStatus ?? 'active',
reconciledAt: row.reconciledAt ?? '',
};
}

View File

@@ -0,0 +1,126 @@
import type { LeaseBusinessDetailRow } from '../types';
import { DETAIL_EXPORT_SPECS } from '../components/tableColumns';
import { displayDeposit, displayMoney, displayText } from './detail';
import type { DetailOperator } from './role';
export interface DetailChangeLogEntry {
id: string;
operatedAt: string;
operatorId: string;
operatorName: string;
field: string;
fieldLabel: string;
before: string;
after: string;
}
export type DetailChangeLogsByRowId = Record<string, DetailChangeLogEntry[]>;
const EXTRA_FIELD_LABELS: Record<string, string> = {
archiveStatus: '对账状态',
reconciledAt: '对账日期',
_delete: '删除记录',
_import: '批量导入',
_reconcile: '完成对账',
};
const FIELD_LABELS: Record<string, string> = Object.fromEntries(
DETAIL_EXPORT_SPECS.map((spec) => [spec.key, spec.title]),
);
export function resolveFieldLabel(field: string): string {
return EXTRA_FIELD_LABELS[field] ?? FIELD_LABELS[field] ?? field;
}
const MONEY_FIELDS = new Set<string>([
'deposit', 'contractRent', 'receivableTotal', 'receivableRent', 'monthlyIncome', 'monthlyRent',
'maintenanceIncome', 'insuranceSurcharge', 'opsFeeIncome', 'otherIncome', 'mileageDeduction',
'otherDeduction', 'receivedAmount', 'outstanding', 'vehicleStandardCost', 'vehicleActualCost',
'insuranceFee', 'hydrogenFee', 'opsFeeCost', 'brokerageFee', 'otherCost', 'totalCost', 'profitLoss',
]);
export function formatLogValue(field: string, value: unknown): string {
if (field === 'archiveStatus') {
if (value === 'archived') return '已对账';
if (value === 'active') return '未对账';
return displayText(value);
}
if (field === 'year' || field === 'month') {
return value === null || value === undefined || value === '' ? '—' : String(value);
}
if (field === 'deposit') return displayDeposit(value as number | string) || '—';
if (MONEY_FIELDS.has(field) && typeof value === 'number') {
return displayMoney(value, false) || '0.00';
}
if (typeof value === 'number') return String(value);
return displayText(value) || '—';
}
export function valuesEqualForLog(field: string, before: unknown, after: unknown): boolean {
return formatLogValue(field, before) === formatLogValue(field, after);
}
const LOGGED_ROW_FIELDS: (keyof LeaseBusinessDetailRow)[] = [
...DETAIL_EXPORT_SPECS.map((spec) => spec.key),
'archiveStatus',
'reconciledAt',
];
export function createChangeLogEntry(
operator: DetailOperator,
field: string,
before: unknown,
after: unknown,
meta?: { fieldLabel?: string; beforeText?: string; afterText?: string; force?: boolean },
): DetailChangeLogEntry | null {
const beforeText = meta?.beforeText ?? formatLogValue(field, before);
const afterText = meta?.afterText ?? formatLogValue(field, after);
if (!meta?.force && beforeText === afterText) return null;
const now = new Date();
const operatedAt = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
return {
id: `lbd-clog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
operatedAt,
operatorId: operator.id,
operatorName: operator.name,
field,
fieldLabel: meta?.fieldLabel ?? resolveFieldLabel(field),
before: beforeText,
after: afterText,
};
}
export function appendChangeLogs(
logs: DetailChangeLogsByRowId,
rowId: string,
entries: DetailChangeLogEntry[],
): DetailChangeLogsByRowId {
if (!entries.length) return logs;
return {
...logs,
[rowId]: [...entries, ...(logs[rowId] ?? [])],
};
}
export function collectRowChangeLogs(
operator: DetailOperator,
before: LeaseBusinessDetailRow,
after: LeaseBusinessDetailRow,
): DetailChangeLogEntry[] {
const entries: DetailChangeLogEntry[] = [];
LOGGED_ROW_FIELDS.forEach((field) => {
const entry = createChangeLogEntry(operator, field, before[field], after[field]);
if (entry) entries.push(entry);
});
return entries;
}
export function canEditDetailRow(
row: Pick<LeaseBusinessDetailRow, 'archiveStatus'>,
isSupervisor: boolean,
): boolean {
if (row.archiveStatus === 'archived') return isSupervisor;
return true;
}

View File

@@ -4,6 +4,7 @@ import type {
LeaseDetailKpiSummary,
} from '../types';
import { EMPTY_LEASE_DETAIL_KPI } from '../types';
import { resolveDetailPaymentStatus } from './payment-status';
export function detailRowYearMonth(row: LeaseBusinessDetailRow): string {
return `${row.year}-${String(row.month).padStart(2, '0')}`;
@@ -30,7 +31,7 @@ export function displayDeposit(value: number | string): string {
}
export function displayOutstanding(value: number): { text: string; warn: boolean } {
if (value === 0) return { text: '', warn: false };
if (value === 0) return { text: '0.00', warn: false };
return { text: formatMoney(value), warn: value > 0 };
}
@@ -44,6 +45,10 @@ export function applyDetailFilters(
if (filters.businessDept && row.businessDept !== filters.businessDept) return false;
if (filters.salesperson && row.salesperson !== filters.salesperson) return false;
if (customerQ && !row.customerName.toLowerCase().includes(customerQ)) return false;
if (filters.paymentStatus) {
const status = resolveDetailPaymentStatus(row.receivableTotal, row.receivedAmount);
if (status !== filters.paymentStatus) return false;
}
if (filters.plateNos.length > 0 && !filters.plateNos.includes(row.plateNo)) return false;
return true;
});

View File

@@ -0,0 +1,87 @@
import type { LeaseBusinessDetailRow } from '../types';
export type DetailEditFieldType = 'text' | 'number' | 'date';
export interface DetailEditFieldSpec {
key: keyof LeaseBusinessDetailRow;
label: string;
type: DetailEditFieldType;
}
/** 编辑弹窗可改字段(对齐导入模板手工列) */
export const DETAIL_EDIT_FIELDS: DetailEditFieldSpec[] = [
{ key: 'year', label: '年份', type: 'number' },
{ key: 'month', label: '月份', type: 'number' },
{ key: 'paymentDate', label: '付款日期', type: 'date' },
{ key: 'plateNo', label: '车牌号码', type: 'text' },
{ key: 'customerName', label: '客户名称', type: 'text' },
{ key: 'valueAddedService', label: '增值服务', type: 'text' },
{ key: 'exemptionPolicy', label: '享免政策', type: 'text' },
{ key: 'pickupDate', label: '提车日期', type: 'date' },
{ key: 'contractStartDate', label: '合同生效日期', type: 'date' },
{ key: 'contractEndDate', label: '合同到期日期', type: 'date' },
{ key: 'periodStartDate', label: '起始日期', type: 'date' },
{ key: 'returnDate', label: '退车日期', type: 'date' },
{ key: 'avgDays', label: '平均天数', type: 'number' },
{ key: 'deposit', label: '押金', type: 'text' },
{ key: 'contractRent', label: '合同标的租金', type: 'number' },
{ key: 'receivableRent', label: '应收租金', type: 'number' },
{ key: 'maintenanceIncome', label: '维保包干收入', type: 'number' },
{ key: 'insuranceSurcharge', label: '保险上浮费', type: 'number' },
{ key: 'opsFeeIncome', label: '运维费(收入)', type: 'number' },
{ key: 'otherIncome', label: '其他收入', type: 'number' },
{ key: 'mileageDeduction', label: '里程减免金额', type: 'number' },
{ key: 'otherDeduction', label: '其他减免金额', type: 'number' },
{ key: 'receivedAmount', label: '实收金额', type: 'number' },
{ key: 'invoiceDate', label: '开票日期', type: 'date' },
{ key: 'actualPaymentDate', label: '实际付款日期', type: 'date' },
{ key: 'paymentMethod', label: '付款方式', type: 'text' },
{ key: 'vehicleStandardCost', label: '车辆标准成本', type: 'number' },
{ key: 'insuranceFee', label: '保险费', type: 'number' },
{ key: 'opsFeeCost', label: '运维费(成本)', type: 'number' },
{ key: 'brokerageFee', label: '居间费', type: 'number' },
{ key: 'otherCost', label: '其他', type: 'number' },
{ key: 'gpsMileage', label: 'GPS里程', type: 'text' },
{ key: 'otherDeductionDetail', label: '其他减免金额明细内容', type: 'text' },
{ key: 'assetOwner', label: '资产归属', type: 'text' },
{ key: 'signingCompany', label: '签约公司', type: 'text' },
{ key: 'remark', label: '备注', type: 'text' },
];
export function draftFromRow(row: LeaseBusinessDetailRow): Record<string, string | number> {
const draft: Record<string, string | number> = {};
DETAIL_EDIT_FIELDS.forEach((field) => {
const value = row[field.key];
if (field.key === 'deposit') {
draft[field.key] = value === '/' ? '/' : String(value ?? '');
return;
}
if (typeof value === 'number') {
draft[field.key] = value;
return;
}
draft[field.key] = String(value ?? '');
});
return draft;
}
export function parseDraftToPatch(
draft: Record<string, string | number>,
): Partial<LeaseBusinessDetailRow> {
const patch: Partial<LeaseBusinessDetailRow> = {};
DETAIL_EDIT_FIELDS.forEach((field) => {
const raw = draft[field.key];
if (field.key === 'deposit') {
const text = String(raw ?? '').trim();
patch.deposit = text === '/' || text === '' ? '/' : Number(text);
return;
}
if (field.type === 'number') {
const num = Number(raw);
patch[field.key] = Number.isFinite(num) ? num : 0;
return;
}
patch[field.key] = String(raw ?? '');
});
return patch;
}

View File

@@ -0,0 +1,172 @@
import type { LeaseBusinessDetailRow } from '../types';
import {
getContractRef,
getVehicleRef,
isKnownCustomerName,
isPlateInVehicleSystem,
resolveExpectedCustomer,
resolveSettlementMonths,
} from './system-ref';
export type FieldCheckStatus = 'none' | 'ok' | 'warn';
export interface FieldCheckInfo {
status: FieldCheckStatus;
title?: string;
details?: string[];
replaceAction?: Partial<LeaseBusinessDetailRow>;
}
function roundMoney(value: number): number {
return Math.round(value * 100) / 100;
}
function moneyEqual(a: number, b: number): boolean {
return Math.abs(a - b) < 0.005;
}
function ok(details: string[]): FieldCheckInfo {
return { status: 'ok', title: '与系统一致', details };
}
function warn(title: string, details: string[], replaceAction?: Partial<LeaseBusinessDetailRow>): FieldCheckInfo {
return { status: 'warn', title, details, replaceAction };
}
function none(): FieldCheckInfo {
return { status: 'none' };
}
export function checkPlateExists(row: LeaseBusinessDetailRow): FieldCheckInfo {
if (!row.plateNo) return none();
if (isPlateInVehicleSystem(row.plateNo)) return ok(['车牌已在车辆管理中登记']);
return warn('车牌不存在', ['该车牌号码未在系统「车辆管理」中登记,无法导入']);
}
export function checkCustomerName(row: LeaseBusinessDetailRow): FieldCheckInfo {
if (!row.plateNo || !row.customerName) return none();
const contract = getContractRef(row.plateNo, row.customerName);
const expected = resolveExpectedCustomer(row.plateNo, row.pickupDate, row.returnDate) || contract?.customerName || '';
if (expected && row.customerName.trim() === expected.trim()) {
return ok(['客户名称与系统登记一致']);
}
if (contract && row.customerName.trim() === contract.customerName.trim()) {
return ok(['客户名称与租赁合同一致']);
}
if (expected && row.customerName.trim() !== expected.trim()) {
return warn(
'客户名称与系统不对应',
['客户名称与系统「客户管理」不对应', `建议客户:${expected}`],
{ customerName: expected },
);
}
if (isKnownCustomerName(row.customerName)) {
return ok(['客户名称已在客户管理中登记']);
}
return warn(
'客户名称与系统不对应',
['客户名称与系统「客户管理」不对应', '请核对提车日期与退车日期'],
);
}
export function checkPickupDate(row: LeaseBusinessDetailRow): FieldCheckInfo {
const vehicle = getVehicleRef(row.plateNo);
if (!vehicle || !row.pickupDate) return none();
const systemDate = vehicle.deliveryDate;
if (!systemDate) return none();
if (row.pickupDate.slice(0, 10) === systemDate) return ok(['提车日期与交车管理一致']);
return warn('提车日期不一致', [`交车管理提车日期:${systemDate}`], { pickupDate: systemDate });
}
export function checkReturnDate(row: LeaseBusinessDetailRow): FieldCheckInfo {
const vehicle = getVehicleRef(row.plateNo);
if (!vehicle || !row.returnDate) return none();
const systemDate = vehicle.returnDate;
if (!systemDate) return none();
if (row.returnDate.slice(0, 10) === systemDate) return ok(['退车日期与还车记录一致']);
return warn('退车日期不一致', [`还车记录日期:${systemDate}`], { returnDate: systemDate });
}
export function checkDeposit(row: LeaseBusinessDetailRow): FieldCheckInfo {
const contract = getContractRef(row.plateNo, row.customerName);
if (!contract || typeof row.deposit !== 'number') return none();
if (moneyEqual(row.deposit, contract.deposit)) return ok(['押金与合同保证金一致']);
return warn('押金不一致', [`合同保证金:${contract.deposit.toFixed(2)}`], { deposit: contract.deposit });
}
export function checkContractRent(row: LeaseBusinessDetailRow): FieldCheckInfo {
const contract = getContractRef(row.plateNo, row.customerName);
if (!contract || !row.contractRent) return none();
if (moneyEqual(row.contractRent, contract.contractRent)) return ok(['租金与合同一致']);
return warn('租金不一致', [`合同租金:${contract.contractRent.toFixed(2)}`], { contractRent: contract.contractRent });
}
export function checkReceivableRent(row: LeaseBusinessDetailRow): FieldCheckInfo {
const months = resolveSettlementMonths(row);
const expected = roundMoney(row.contractRent * months);
if (!expected && !row.receivableRent) return none();
if (moneyEqual(row.receivableRent, expected)) return ok([`应收租金 = 合同标的租金 × ${months} 个月`]);
return warn('应收租金不一致', [`正确金额:${expected.toFixed(2)}`], { receivableRent: expected });
}
export function checkMileageDeduction(row: LeaseBusinessDetailRow): FieldCheckInfo {
if (row.mileageDeduction === 0) return none();
if (row.mileageDeduction < 0) return ok(['里程减免以负数显示']);
return warn('里程减免金额应为负数', ['请录入负值,例如 -500']);
}
export function checkOtherDeduction(row: LeaseBusinessDetailRow): FieldCheckInfo {
if (row.otherDeduction === 0) return none();
if (row.otherDeduction < 0) return ok(['其他减免以负数显示']);
return warn('其他减免金额应为负数', ['请录入负值']);
}
export function checkAssetOwner(row: LeaseBusinessDetailRow): FieldCheckInfo {
const vehicle = getVehicleRef(row.plateNo);
if (!vehicle || !row.assetOwner) return none();
const expected = vehicle.registeredOwnership;
if (!expected) return none();
if (row.assetOwner.trim() === expected.trim()) return ok(['与车辆登记所有权一致']);
return warn('资产归属不一致', [`登记所有权:${expected}`], { assetOwner: expected });
}
export function getFieldCheck(
row: LeaseBusinessDetailRow,
field:
| 'plateNo'
| 'customerName'
| 'pickupDate'
| 'returnDate'
| 'deposit'
| 'contractRent'
| 'receivableRent'
| 'mileageDeduction'
| 'otherDeduction'
| 'assetOwner',
): FieldCheckInfo {
switch (field) {
case 'plateNo': return checkPlateExists(row);
case 'customerName': return checkCustomerName(row);
case 'pickupDate': return checkPickupDate(row);
case 'returnDate': return checkReturnDate(row);
case 'deposit': return checkDeposit(row);
case 'contractRent': return checkContractRent(row);
case 'receivableRent': return checkReceivableRent(row);
case 'mileageDeduction': return checkMileageDeduction(row);
case 'otherDeduction': return checkOtherDeduction(row);
case 'assetOwner': return checkAssetOwner(row);
default: return none();
}
}
export function displayDeductionAmount(value: number): string {
if (value === 0) return '';
const abs = Math.abs(value);
const text = abs.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return value > 0 ? `-${text}` : `-${text}`;
}
export function normalizeDeductionForDisplay(value: number): number {
if (value === 0) return 0;
return value > 0 ? -Math.abs(value) : value;
}

View File

@@ -0,0 +1,100 @@
import type { LeaseBusinessDetailRow } from '../types';
import seedRecords from '../data/hydrogen-refuel-records.json';
export interface HydrogenRefuelRecord {
hydrogenTime: string;
stationName: string;
customerName: string;
plateNo: string;
hydrogenKg: number;
unitPrice: number;
totalPrice: number;
borneBy: '我司承担' | '客户承担';
}
const HYDROGEN_REFUEL_RECORDS = seedRecords as HydrogenRefuelRecord[];
function roundMoney(value: number): number {
return Math.round(value * 100) / 100;
}
function parseDateOnly(value: string): Date | null {
if (!value) return null;
const datePart = value.trim().slice(0, 10);
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
return new Date(year, month - 1, day);
}
function lastDayOfMonth(year: number, month: number): Date {
return new Date(year, month, 0);
}
function firstDayOfMonth(year: number, month: number): Date {
return new Date(year, month - 1, 1);
}
/** 提车~退车与账单月份的交集,作为加氢记录筛选区间 */
function resolveHydrogenDateRange(
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'pickupDate' | 'returnDate'>,
): { start: Date; end: Date } | null {
const pickup = parseDateOnly(row.pickupDate ?? '');
if (!pickup) return null;
const monthStart = firstDayOfMonth(row.year, row.month);
const monthEnd = lastDayOfMonth(row.year, row.month);
const returnDate = parseDateOnly(row.returnDate ?? '');
const leaseEnd = returnDate ?? monthEnd;
const start = pickup > monthStart ? pickup : monthStart;
const end = leaseEnd < monthEnd ? leaseEnd : monthEnd;
if (start > end) return null;
return { start, end };
}
function recordDate(record: HydrogenRefuelRecord): Date | null {
return parseDateOnly(record.hydrogenTime);
}
function normalizeCustomer(value: string): string {
return (value ?? '').trim();
}
export function getHydrogenRefuelRecords(
row: Pick<
LeaseBusinessDetailRow,
'plateNo' | 'customerName' | 'year' | 'month' | 'pickupDate' | 'returnDate'
>,
records: HydrogenRefuelRecord[] = HYDROGEN_REFUEL_RECORDS,
): HydrogenRefuelRecord[] {
const range = resolveHydrogenDateRange(row);
if (!range) return [];
const customer = normalizeCustomer(row.customerName);
return records.filter((item) => {
if (item.plateNo !== row.plateNo) return false;
if (normalizeCustomer(item.customerName) !== customer) return false;
if (item.borneBy !== '我司承担') return false;
const date = recordDate(item);
if (!date) return false;
return date >= range.start && date <= range.end;
});
}
export function sumHydrogenFeeForDetailRow(
row: Pick<
LeaseBusinessDetailRow,
'plateNo' | 'customerName' | 'year' | 'month' | 'pickupDate' | 'returnDate'
>,
records?: HydrogenRefuelRecord[],
): number {
return roundMoney(
getHydrogenRefuelRecords(row, records)
.reduce((sum, item) => sum + item.totalPrice, 0),
);
}
export { HYDROGEN_REFUEL_RECORDS };

View File

@@ -0,0 +1,37 @@
export type DetailPaymentStatus = '未收款' | '部分收款' | '已结清';
export const DETAIL_PAYMENT_STATUS_OPTIONS: DetailPaymentStatus[] = [
'未收款',
'部分收款',
'已结清',
];
const STATUS_SORT_ORDER: Record<DetailPaymentStatus, number> = {
未收款: 0,
部分收款: 1,
已结清: 2,
};
export const DETAIL_PAYMENT_STATUS_CLASS: Record<DetailPaymentStatus, string> = {
: 'ldb-status--unpaid',
: 'ldb-status--partial',
: 'ldb-status--paid',
};
/** 根据实收金额与应收合计计算收款状态 */
export function resolveDetailPaymentStatus(
receivableTotal: number,
receivedAmount: number | null | undefined,
): DetailPaymentStatus {
const received = receivedAmount ?? 0;
if (received <= 0) return '未收款';
if (received >= receivableTotal) return '已结清';
return '部分收款';
}
export function paymentStatusSortValue(
receivableTotal: number,
receivedAmount: number | null | undefined,
): number {
return STATUS_SORT_ORDER[resolveDetailPaymentStatus(receivableTotal, receivedAmount)];
}

View File

@@ -0,0 +1,32 @@
export type DetailViewerRole = 'staff' | 'supervisor';
export interface DetailOperator {
id: string;
name: string;
role: DetailViewerRole;
}
const STAFF_OPERATOR: DetailOperator = {
id: 'staff-001',
name: '刘念念',
role: 'staff',
};
const SUPERVISOR_OPERATOR: DetailOperator = {
id: 'supervisor-001',
name: '王主管',
role: 'supervisor',
};
/** 原型默认业管人员;主管预览 URL 追加 ?role=supervisor */
export function resolveDetailOperator(): DetailOperator {
if (typeof window === 'undefined') return STAFF_OPERATOR;
const params = new URLSearchParams(window.location.search);
const role = params.get('role');
if (role === 'supervisor' || role === 'manager') return SUPERVISOR_OPERATOR;
return STAFF_OPERATOR;
}
export function isDetailSupervisor(operator: DetailOperator): boolean {
return operator.role === 'supervisor';
}

View File

@@ -0,0 +1,205 @@
import vehicles from '../../vehicle-management/data/vehicles.json';
import customers from '../../customer-management/data/customers.json';
import seedRows from '../data/rows.json';
import type { LeaseBusinessDetailRow } from '../types';
export interface VehicleRef {
plateNo: string;
brand: string;
model: string;
brandModel: string;
registeredOwnership: string;
deliveryDate: string;
returnDate: string;
}
export interface ContractRef {
businessDept: string;
salesperson: string;
customerName: string;
deposit: number;
contractRent: number;
settlementMonths: number;
paymentTiming: 'pre' | 'post';
signingCompany: string;
pickupDate: string;
returnDate: string;
}
type VehicleSeed = {
plateNo?: string;
brand?: string;
model?: string;
ownership?: string;
lastDeliveryTime?: string;
lastReturnTime?: string;
};
const plateRegistry = new Map<string, VehicleRef>();
const contractByPlateCustomer = new Map<string, ContractRef>();
const customerNameSet = new Set<string>();
function parseBrandModel(vehicleType: string): { brand: string; model: string } {
const raw = String(vehicleType || '').trim();
if (!raw) return { brand: '', model: '' };
const slash = raw.indexOf('/');
if (slash >= 0) {
return { brand: raw.slice(0, slash).trim(), model: raw.slice(slash + 1).trim() };
}
const digit = raw.search(/\d/);
if (digit > 0) {
return { brand: raw.slice(0, digit).trim(), model: raw.slice(digit).trim() };
}
return { brand: raw, model: '' };
}
function formatBrandModel(brand: string, model: string): string {
if (brand && model) return `${brand}·${model}`;
return brand || model || '';
}
function normalizeDate(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw || raw === '-') return '';
return raw.slice(0, 10);
}
function parsePaymentTiming(method: string): 'pre' | 'post' {
return method.includes('后付') ? 'post' : 'pre';
}
function parseSettlementMonths(method: string): number {
if (method.includes('年度') || method.includes('一年')) return 12;
if (method.includes('半年')) return 6;
if (method.includes('季度')) return 3;
return 1;
}
function contractKey(plateNo: string, customerName: string): string {
return `${plateNo}::${customerName}`;
}
function registerVehicle(ref: VehicleRef): void {
plateRegistry.set(ref.plateNo, ref);
}
function registerContract(plateNo: string, customerName: string, row: Partial<LeaseBusinessDetailRow>): void {
if (!plateNo || !customerName) return;
const key = contractKey(plateNo, customerName);
if (contractByPlateCustomer.has(key)) return;
contractByPlateCustomer.set(key, {
businessDept: row.businessDept ?? '',
salesperson: row.salesperson ?? '',
customerName,
deposit: typeof row.deposit === 'number' ? row.deposit : 0,
contractRent: row.contractRent ?? 0,
settlementMonths: parseSettlementMonths(row.paymentMethod ?? ''),
paymentTiming: parsePaymentTiming(row.paymentMethod ?? ''),
signingCompany: row.signingCompany ?? '',
pickupDate: normalizeDate(row.pickupDate),
returnDate: normalizeDate(row.returnDate),
});
}
function bootstrap(): void {
(customers as { name?: string }[]).forEach((c) => {
if (c.name) customerNameSet.add(c.name.trim());
});
(vehicles as VehicleSeed[]).forEach((v) => {
if (!v.plateNo) return;
registerVehicle({
plateNo: v.plateNo,
brand: v.brand ?? '',
model: v.model ?? '',
brandModel: formatBrandModel(v.brand ?? '', v.model ?? ''),
registeredOwnership: v.ownership ?? '',
deliveryDate: normalizeDate(v.lastDeliveryTime),
returnDate: normalizeDate(v.lastReturnTime),
});
});
(seedRows as LeaseBusinessDetailRow[]).forEach((row) => {
if (!row.plateNo) return;
const parsed = parseBrandModel(row.vehicleType);
const existing = plateRegistry.get(row.plateNo);
if (!existing) {
registerVehicle({
plateNo: row.plateNo,
brand: parsed.brand,
model: parsed.model,
brandModel: formatBrandModel(parsed.brand, parsed.model) || row.vehicleType,
registeredOwnership: row.assetOwner ?? '',
deliveryDate: normalizeDate(row.pickupDate),
returnDate: normalizeDate(row.returnDate),
});
} else if (!existing.brandModel && row.vehicleType) {
existing.brandModel = row.vehicleType;
}
if (row.customerName) customerNameSet.add(row.customerName.trim());
registerContract(row.plateNo, row.customerName, row);
});
}
bootstrap();
export function isPlateInVehicleSystem(plateNo: string): boolean {
return Boolean(plateNo && plateRegistry.has(plateNo.trim()));
}
export function getVehicleRef(plateNo: string): VehicleRef | undefined {
return plateRegistry.get(plateNo.trim());
}
export function getContractRef(plateNo: string, customerName: string): ContractRef | undefined {
return contractByPlateCustomer.get(contractKey(plateNo.trim(), customerName.trim()));
}
export function resolveExpectedCustomer(
plateNo: string,
pickupDate: string,
returnDate: string,
): string {
const vehicle = getVehicleRef(plateNo);
const pickup = normalizeDate(pickupDate);
const ret = normalizeDate(returnDate);
let best = '';
contractByPlateCustomer.forEach((contract, key) => {
if (!key.startsWith(`${plateNo.trim()}::`)) return;
if (pickup && contract.pickupDate && contract.pickupDate !== pickup) return;
if (ret && contract.returnDate && contract.returnDate !== ret) return;
best = contract.customerName;
});
if (best) return best;
return vehicle ? '' : '';
}
export function isKnownCustomerName(name: string): boolean {
const trimmed = name.trim();
if (!trimmed) return false;
if (customerNameSet.has(trimmed)) return true;
return Array.from(customerNameSet).some((c) => c.includes(trimmed) || trimmed.includes(c));
}
export function formatPaymentMethodLabel(months: number, timing: 'pre' | 'post'): string {
const period =
months === 12 ? '年度'
: months === 6 ? '半年'
: months === 3 ? '季度'
: months === 1 ? '月度'
: `${months}个月`;
return `${period}${timing === 'post' ? '后付' : '预付'}`;
}
export function resolvePaymentMethodLabel(row: LeaseBusinessDetailRow): string {
const contract = getContractRef(row.plateNo, row.customerName);
if (contract) {
return formatPaymentMethodLabel(contract.settlementMonths, contract.paymentTiming);
}
return row.paymentMethod;
}
export function resolveSettlementMonths(row: LeaseBusinessDetailRow): number {
const contract = getContractRef(row.plateNo, row.customerName);
return contract?.settlementMonths ?? parseSettlementMonths(row.paymentMethod);
}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "lease-business-ledger",
"pageId": "list",
"updatedAt": 1783589987166,
"updatedAt": 1783800844966,
"nodes": [
{
"id": "lbl-filter",
@@ -1158,36 +1158,6 @@
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-prototype-nav",
"title": "原型导航",
"href": "/prototypes/oneos-prototype-nav",
"target": "self"
},
{
"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": "folder",
"id": "lbl-doc-root",

View File

@@ -109,8 +109,8 @@ export function AccidentLinkModal({
</p>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm}>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm} data-vm-icon="send">
</button>
</footer>

View File

@@ -33,8 +33,8 @@ export function ConfirmMarkModal({ open, count, onCancel, onConfirm }: ConfirmMa
<p className="ldb-confirm-meta"> {count} 稿</p>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm} data-vm-icon="send"></button>
</footer>
</div>
</div>

View File

@@ -34,8 +34,8 @@ export function DeleteConfirmModal({ open, plateNo = '', meta, onCancel, onConfi
<p className="ldb-confirm-meta">{meta ?? (plateNo ? `车牌:${plateNo}` : '')}</p>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}></button>
<button type="button" className="vm-btn vm-btn-danger" onClick={onConfirm}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-danger" onClick={onConfirm} data-vm-icon="trash"></button>
</footer>
</div>
</div>

View File

@@ -218,21 +218,19 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls="ldb-filter-expand-panel"
>
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{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-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
</button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
<Search size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
</button>
</div>
</section>

View File

@@ -38,9 +38,7 @@ export function LeaseLedgerImportModal({
<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 type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">
</button>
</section>
<section className="vm-import-step">
@@ -71,7 +69,7 @@ export function LeaseLedgerImportModal({
</section>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
</footer>
</div>
</div>

View File

@@ -1,6 +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 { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp } from 'lucide-react';
import { OperationActions } from '../../../common/OperationActions';
import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceLedgerRecord, ReceiptRecord, ReturnSettlementFeeRecord } from '../types';
import {
calcMonthlyIncome,
@@ -50,7 +51,7 @@ interface LeaseLedgerTableProps {
type SortDir = 'asc' | 'desc';
const CHECK_COL_WIDTH = 48;
const ACTION_COL_WIDTH = 208;
const ACTION_COL_WIDTH = 168;
function buildLeftStickyLayout(columnWidths: Record<string, number>) {
const keys: string[] = [];
@@ -539,7 +540,7 @@ function renderCell(
label={formatMoney(row.receivedAmount)}
title="收款明细"
footer={showReceiptLink ? (
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => ctx.onLinkReceipt(row)}>
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => ctx.onLinkReceipt(row)} data-vm-icon="plus">
</button>
) : undefined}
@@ -907,26 +908,23 @@ export function LeaseLedgerTable({
</td>
))}
<td className="sticky-col-right ldb-col-actions">
<div className="ldb-row-actions">
<button
type="button"
className="ldb-action-btn ldb-action-btn--history ldb-cell-clickable"
onClick={() => setHistoryRow(row)}
>
<History size={14} aria-hidden />
</button>
{canEdit && (
<button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}>
</button>
)}
{editingRowId === row.id && canEdit && (
<button type="button" className="ldb-action-btn ldb-action-btn--save" onClick={() => onSave(row)}>
</button>
)}
</div>
<OperationActions
edit={canEdit ? { onClick: () => onEdit(row) } : undefined}
more={[
{
key: 'history',
label: '操作记录',
onClick: () => setHistoryRow(row),
},
...(editingRowId === row.id && canEdit
? [{
key: 'save',
label: '保存',
onClick: () => onSave(row),
}]
: []),
]}
/>
</td>
</tr>
);

View File

@@ -118,9 +118,9 @@ function LedgerLineItemsEditor({
<button
type="button"
className="vm-btn vm-btn-ghost ldb-line-items-add"
data-vm-icon="plus"
onClick={() => onChange([...items, { ...EMPTY_ITEM }])}
>
<Plus size={16} aria-hidden />
</button>
</div>

View File

@@ -107,8 +107,8 @@ export function MaintenanceLinkModal({
</p>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm}>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm} data-vm-icon="send">
</button>
</footer>

View File

@@ -59,7 +59,7 @@ export function OperationHistoryModal({ open, plateNo, entries, onClose }: Opera
)}
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose} data-vm-icon="x"></button>
</footer>
</div>
</div>

View File

@@ -164,8 +164,8 @@ export function ReceiptLinkModal({
</div>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm}>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm} data-vm-icon="send">
</button>
</footer>

View File

@@ -7,11 +7,11 @@ import './styles/index.css';
import React, { useCallback, useMemo, useState } from 'react';
import { Archive, Download, FileUp, Save } from 'lucide-react';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import seedRows from './data/rows.json';
import seedReceipts from './data/receipts.json';
import seedReturnSettlementFees from './data/return-settlement-fees.json';
@@ -365,12 +365,10 @@ export default function LeaseBusinessLedgerApp() {
</label>
</div>
<div className="vm-table-actions">
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => setImportOpen(true)}>
<FileUp size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" data-vm-icon="import" onClick={() => setImportOpen(true)}>
</button>
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={() => showToast(`导出 ${listFiltered.length} 条(原型演示)`)}>
<Download size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" data-vm-icon="download" onClick={() => showToast(`导出 ${listFiltered.length} 条(原型演示)`)}>
</button>
<button
@@ -378,17 +376,15 @@ export default function LeaseBusinessLedgerApp() {
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
data-annotation-id="lbl-btn-save-unarchived"
onClick={handleSaveUnarchived}
>
<Save size={16} aria-hidden />
data-vm-icon="save">
</button>
<button
type="button"
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
data-annotation-id="lbl-btn-set-archived"
data-vm-icon="save"
onClick={handleSetArchived}
>
<Archive size={16} aria-hidden />
</button>
</div>
@@ -491,7 +487,7 @@ export default function LeaseBusinessLedgerApp() {
onDownloadTemplate={downloadLedgerBatchImportTemplate}
onImport={handleBatchImport}
/>
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
<PrototypeAnnotationHost source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
</>
);
}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "lease-business-line-overview",
"pageId": "overview",
"updatedAt": 1783589987166,
"updatedAt": 1783800844966,
"nodes": [
{
"id": "lblo-system-intro",
@@ -173,36 +173,6 @@
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-prototype-nav",
"title": "原型导航",
"href": "/prototypes/oneos-prototype-nav",
"target": "self"
},
{
"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": "folder",
"id": "lblo-doc-root",

View File

@@ -176,8 +176,8 @@ export const LEASE_MODULES: LineModule[] = [
],
closure:
'应收完成后,按合同约定生成交车任务;运维部对应区域操作员在交车模块查看并执行,车辆正式交付给承租方。',
prototypeHref: '/prototypes/payment-records',
prototypeLabel: '打开收款记录',
prototypeHref: '/prototypes/vehicle-pickup-receivable',
prototypeLabel: '打开提车应收款',
},
{
id: 'lease-ledger',

View File

@@ -0,0 +1,103 @@
# 租赁合同管理 · 标注与原型上下文
> 由 `extract-annotation-source` 从运行态 `window.__AXHUB_ANNOTATION_SOURCE__` 提取,并与仓库源码对齐。
> 原型地址:`/prototypes/lease-contract-management`
---
## 1. 提取摘要
| 项 | 值 |
|---|---|
| 标注节点数 | 22 |
| 列表页节点 | 8`pageId: list` |
| 新增页节点 | 14`pageId: create` |
| 图片附件 | 无 |
| 开发态 `source.root` | 未发布(以仓库路径为准) |
### 1.1 目录结构
```text
租赁合同管理说明
├── 模块总览
├── 合同模板管理(链接)
├── PRD 全文
│ ├── PRD 完整文档
│ ├── 列表页 PRD
│ ├── 新增页 PRD
│ └── 验收标准
├── 列表页模块route + 各标注说明)
├── 新增页模块route + 各标注说明)
├── 操作流程(正向 / 逆向流程图)
└── 标注查看提示
```
### 1.2 标注节点索引
**列表页**
| id | 标题 |
|---|---|
| `lc-list-filter` | 筛选条件 |
| `lc-list-kpi` | KPI 统计卡片 |
| `lc-fleet-summary` | 在租车辆概览 |
| `lc-list-toolbar` | 列表工具栏 |
| `lc-list-table` | 合同台账列表 |
| `lc-list-action-more` | 更多操作 |
| `lc-action-convert-tripartite` | 转三方合同 |
| `lc-action-trial-to-formal` | 试用转正式 |
**新增页**
| id | 标题 |
|---|---|
| `lc-create-main-contract` | 主体合同信息 |
| `lc-create-signing-method` | 合同签署方式 |
| `lc-card-signing` | 签约信息 |
| `lc-signing-customer-principal` | 乙方负责人 |
| `lc-create-lease-order` | 附件1租赁订单 |
| `lc-lease-order-brand-model` | 品牌车型与在库 |
| `lc-lease-order-rent` | 车辆租金与最低租金 |
| `lc-lease-order-service-content` | 服务项配置 |
| `lc-lease-order-lease-period` | 租赁期限与到期计算 |
| `lc-create-poa` | 授权委托书 |
| `lc-create-contract-remark` | 合同备注 |
| `lc-create-seal` | 用章类型 |
| `lc-create-preview` | 实时预览 |
| `lc-create-footer` | 底栏操作 |
定位器统一为:`[data-annotation-id="<id>"]`
---
## 2. 履约与交还车规则(列表子表)
| 规则 | 说明 |
|---|---|
| 审批未通过 | 合同下车辆**不视为已交车**;子表「交车」显示「未交车」,无租赁账单/还车应结款 |
| 审批通过 | 方进入履约态;有实际交车时间的车辆计入「已交车辆数」 |
| 子表还车 | **审批通过 + 已交车 + 未还车** 时,操作列显示「还车」 |
| 已还车 | 操作列为「—」;「还车应结款」按状态展示待提交/审批中/已完成 |
子表列:车辆信息、提车应收款、交车、租赁账单、还车、还车应结款、交车安排、里程要求、里程完成、操作。
---
## 3. 源码入口
| 能力 | 文件 |
|---|---|
| 标注/路由入口 | `index.tsx` |
| 列表主逻辑 | `LeaseContractManagement.jsx` |
| 列表数据与履约规则 | `lease-contract-list-data.js` |
| 新增页 | `LeaseContractCreate.jsx` |
| 主体表单 | `LeaseContractEditorForm.jsx` |
| 标注数据构建 | `scripts/build-annotation-source.mjs` |
| 标注数据 | `annotation-source.json` |
| PRD 全文 | `src/resources/lease-contract-management/PRD.md` |
同步命令:
```bash
node src/prototypes/lease-contract-management/scripts/build-annotation-source.mjs
```

View File

@@ -0,0 +1,371 @@
# 租赁合同管理 · 操作流程图
> 以「新增合同」为起点,覆盖主流程正向操作与可逆/异常逆向操作。
> 与列表操作列、子表交还车、签署闭环对齐原型实现。
---
## 1. 总览:从新增到履约结束
```mermaid
flowchart TB
subgraph create [草拟与审批]
A[列表 · 新增] --> B[新增/编辑页填写]
B --> C{底栏操作}
C -->|取消| Z1[返回列表 · 不保存]
C -->|保存草稿| D[草稿]
C -->|提交审核| E[待审批]
E --> F[审批中]
F --> G[审批通过]
F --> H[审批驳回]
F --> I[审批终止]
E --> J[撤回]
F --> J
J --> K[撤回 / 可再编辑]
H --> L[编辑后重提]
L --> E
D --> M[删除草稿]
end
subgraph signing [签署闭环]
G --> N{签署方式}
N -->|线上电子签章| O[E签宝发送乙方]
O --> P[签署完成归档]
N -->|线下人工上传| Q[盖章合同补传]
Q --> P
end
subgraph fulfill [履约]
G --> R[合同进行中]
R --> S[提车应收款]
S --> T[交车]
T --> U[在租 · 租赁账单]
U --> V[子表 · 还车]
V --> W[还车应结款]
W --> X[全部车辆已还车]
X --> Y[已终止 / 已结束]
end
subgraph change [进行中变更]
R --> AA[续签 / 增车 / 转三方 / 附加费用 / 添加授权书 / 试用转正式]
AA --> AB[变更审批]
AB --> G
R --> AC[主动终止]
AC --> AD[终止审批]
AD --> Y
end
```
---
## 2. 新增合同 · 正向流程
### 2.1 保存草稿
```mermaid
flowchart LR
A[点击新增] --> B[填写主体/订单/授权书/预览]
B --> C[系统判定标准/非标准审批类型]
C --> D[保存草稿]
D --> E[合同状态: 草稿]
E --> F[审批状态: 未提交]
F --> G[列表可编辑/删除]
```
### 2.2 提交审核 → 审批通过
```mermaid
flowchart TD
A[提交审核] --> B{必填与证照校验}
B -->|不通过| C[Toast 提示 · 停留当前页]
B -->|通过| D[审批状态: 待审批]
D --> E[合同状态: 已提交审批]
E --> F[进入审批流节点]
F --> G[审批中]
G --> H{终审结果}
H -->|通过| I[审批通过]
I --> J[合同状态: 合同进行中]
H -->|驳回| K[审批驳回 → 合同回草稿可编辑]
H -->|终止| L[审批终止]
```
### 2.3 签署闭环(审批通过后)
```mermaid
flowchart TD
A[审批通过] --> B{合同签署方式}
B -->|线上电子签章| C[向乙方负责人手机号发送 E签宝]
C --> D[乙方签署]
D --> E[电子合同自动归档]
B -->|线下人工上传| F[列表 · 盖章合同补传]
F --> G[法务/业务上传 PDF/图片]
G --> H[标记已补传 · 查看可预览下载]
E --> I[履约开始]
H --> I
```
---
## 3. 新增合同 · 逆向流程
### 3.1 取消草拟(不保存)
```mermaid
flowchart LR
A[新增页] --> B[点击取消/返回列表]
B --> C[丢弃未保存修改]
C --> D[回到列表 · 无新记录]
```
### 3.2 删除草稿
```mermaid
flowchart LR
A[草稿合同] --> B[更多 · 删除合同]
B --> C[二次确认]
C --> D[从台账移除]
```
### 3.3 撤回已提交审批
```mermaid
flowchart TD
A[待审批 / 审批中] --> B[更多 · 撤回合同]
B --> C[二次确认]
C --> D[审批状态: 撤回]
D --> E[可重新编辑后再次提交]
```
### 3.4 审批驳回后重提
```mermaid
flowchart LR
A[审批驳回] --> B[主按钮 · 编辑]
B --> C[修改表单/预览]
C --> D[保存草稿或提交审核]
D --> E[重新进入审批流]
```
---
## 4. 履约正向:交车 → 还车 → 结清
> **前置条件**:仅 `审批通过` 的合同才产生交车/还车履约数据。
```mermaid
flowchart TD
A[合同进行中] --> B[办理提车应收款]
B --> C[实际交车]
C --> D[子表 · 交车列展示里程/交车人/时间]
D --> E[租赁账单: 正常/欠费]
E --> F{是否还车}
F -->|未还车| G[子表操作列 · 还车]
G --> H[还车管理]
H --> I[子表 · 还车列展示还车信息]
I --> J[还车应结款]
J --> K{结款状态}
K -->|待提交| L[去处理]
K -->|审批中| M[展示审批人 · 同主表样式]
K -->|已完成| N[查看结款]
F -->|已还车| N
N --> O{全部车辆已还?}
O -->|是| P[KPI · 已终止口径]
```
### 4.1 子表还车操作条件
```mermaid
flowchart TD
A[展开已交车子表] --> B{审批通过?}
B -->|否| C[交车显示未交车 · 无还车按钮]
B -->|是| D{已交车且未还车?}
D -->|是| E[操作列: 还车]
D -->|否| F[操作列: —]
```
---
## 5. 履约逆向与异常
### 5.1 非审批通过合同不出现交车
```mermaid
flowchart LR
A[待审批/审批中/驳回/撤回] --> B[即使有 planned 交车计划]
B --> C[不计入已交车辆数]
C --> D[子表交车列: 未交车]
```
### 5.2 主动终止合同
```mermaid
flowchart TD
A[合同进行中] --> B[更多 · 主动终止合同]
B --> C[填写终止信息]
C --> D[提交终止审批]
D --> E{审批结果}
E -->|通过| F[合同已终止/已结束]
E -->|驳回| G[维持进行中]
```
### 5.3 续签 / 转三方导致旧合同终止
```mermaid
flowchart LR
A[进行中旧合同] --> B[续签或转三方]
B --> C[生成新合同并走审批]
C --> D[旧合同自动标记已终止]
D --> E[在租统计转移至新合同]
```
---
## 6. 进行中变更 · 正向流程
### 6.1 新增车辆
```mermaid
flowchart LR
A[合同进行中] --> B[更多 · 新增车辆]
B --> C[打开增车编辑页]
C --> D[仅新增车辆走审批]
D --> E[审批通过]
E --> F[新车生成提车应收款]
```
### 6.2 续签合同
```mermaid
flowchart TD
A[进行中/到期合同] --> B[更多 · 续签合同]
B --> C[复制原合同]
C --> D[锁定已交未还车辆]
D --> E[可新增车辆卡片]
E --> F[提交续签审批]
F --> G[新合同审批通过]
G --> H[旧合同自动终止]
```
### 6.3 试用转正式
```mermaid
flowchart TD
A[试用合同] --> B[更多 · 试用转正式]
B --> C[自动拉取客户/授权人/车辆订单]
C --> D[车辆订单可编辑]
D --> E{同时转三方?}
E -->|是| F[同一步维护三方协议与附件]
E -->|否| G[仅转正式]
F --> H[提交审批]
G --> H
H --> I[生成正式合同]
```
### 6.4 转三方合同
```mermaid
flowchart LR
A[合同进行中] --> B[更多 · 转三方合同]
B --> C[新增多条三方协议]
C --> D[上传协议附件与对方公函]
D --> E[提交变更审批]
E --> F[审批通过后沿用原签署方式]
```
### 6.5 附加费用
```mermaid
flowchart LR
A[合同进行中] --> B[更多 · 附加费用]
B --> C[按车辆录入服务项与费用]
C --> D[已有费用只读]
D --> E[提交审批]
E --> F[通过后按计费规则入账]
```
### 6.6 添加授权委托书
```mermaid
flowchart LR
A[合同进行中] --> B[更多 · 添加授权委托书]
B --> C[维护受托人列表]
C --> D[仅授权书走审批/签署]
D --> E[主合同无需重签]
```
---
## 7. 进行中变更 · 逆向流程
### 7.1 变更审批撤回
```mermaid
flowchart TD
A[增车/续签/转三方/附加费用/授权书/终止] --> B[已提交变更审批]
B --> C[撤回合同]
C --> D[变更单撤回]
D --> E[原进行中合同保持不变]
```
### 7.2 变更审批驳回
```mermaid
flowchart LR
A[变更审批驳回] --> B[合同状态回变更/草稿口径]
B --> C[业务修改后重新提交]
```
---
## 8. 交车安排编辑(列表与子表)
### 8.1 正向:修改交车安排
```mermaid
flowchart TD
A[列表 · 交车安排列] --> B{可编辑?}
B -->|未锁定| C[编辑区域/日期]
C --> D[同步所有未交车车辆]
D --> E[回写提车应收款 · 刷新待办]
B -->|已锁定| F[提示在提车应收款中调整]
```
### 8.2 子表:单车交车时间
```mermaid
flowchart LR
A[未交车车辆行] --> B[编辑本车交车时间]
B --> C[仅影响当前车辆]
C --> D[不影响已交车车辆]
```
---
## 9. 操作与状态对照表
| 操作 | 显示条件 | 正向结果 | 逆向/回退 |
|---|---|---|---|
| 新增 | 工具栏 | 进入新增页 | 取消不保存 |
| 保存草稿 | 新增/编辑页 | 草稿 | 删除草稿 |
| 提交审核 | 新增/编辑页 | 进入审批 | 撤回 |
| 编辑 | 草稿/驳回 | 修改后重提 | — |
| 删除 | 草稿 | 记录删除 | — |
| 撤回 | 待审批/审批中 | 审批撤回 | 可再提交 |
| 盖章补传 | 审批通过+线下 | 附件归档 | — |
| 交车 | 审批通过后履约 | 在租 | — |
| 还车 | 子表·已交未还 | 还车应结款 | — |
| 续签 | 进行中/到期 | 新合同+旧合同终止 | 撤回变更审批 |
| 增车 | 进行中 | 变更审批 | 驳回/撤回 |
| 转三方 | 进行中 | 变更审批 | 驳回/撤回 |
| 试用转正式 | 试用合同 | 新正式合同 | 驳回/撤回 |
| 附加费用 | 进行中 | 费用入账审批 | 驳回/撤回 |
| 添加授权书 | 进行中 | 授权书审批 | 驳回/撤回 |
| 主动终止 | 进行中 | 终止审批 | 驳回 |
---
## 10. 修订记录
| 版本 | 日期 | 说明 |
|---|---|---|
| v1.0 | 2026-07-12 | 从新增合同起梳理正向/逆向全流程;对齐审批通过后交还与子表还车规则 |

View File

@@ -15,7 +15,7 @@
## 4. 筛选区
默认展示 **第一行 4 个筛选项**3 列栅格),点击「展开更多筛选项」显示全部 12 项;支持「重置」「查询」。
默认展示 **第一行 4 个筛选项**3 列栅格),点击「展开更多筛选项」显示全部 **13 项**;支持「重置」「查询」。
| 字段 | 控件 | 说明 |
|---|---|---|
@@ -23,36 +23,45 @@
| 项目名称 | 可搜索下拉 | 模糊匹配项目名称 |
| 客户名称 | 可搜索下拉 | 模糊匹配客户名称 |
| 签约公司 | 下拉 | 租赁合同签约公司枚举 |
| 审批状态 | 多选下拉 | 全部 / 待审批 / 审批中 / 审批通过 / 审批驳回 / 未提交 |
| 合同状态 | 多选下拉 | 全部 / 草稿 / 变更 / 合同进行中 / 到期合同 / 已提交审批 / 已结束 |
| 审批状态 | 多选下拉 | 全部 / 未提交 / 待审批 / 审批中 / 审批通过 / 审批驳回 / 审批终止 / 撤回 |
| 合同状态 | 多选下拉 | 全部 / 草稿 / 已提交审批 / 合同进行中 / 已终止 |
| 业务部门 | 多选下拉 | 业务相关部门 |
| 业务负责人 | 多选下拉 | 业务组人员 |
| 合同类型 | 选下拉 | 全部 / 正式合同 / 试用合同 |
| 合同模板 | 选下拉 | 选项来自合同模板管理已发布且启用的模板「合同名称」维度(如正式合同试用合同、现代18吨正式合同可清空 |
| 标准合同名称 | 单选下拉 | **联动合同模板**仅在选择合同模板后启用选项为该模板类别下已发布文档名如「2026年标准商用车租赁合同」支持搜索可清空 |
| 审批类型 | 多选下拉 | 全部 / 标准合同 / 非标准合同 |
| 创建人 | 多选下拉 | 业务相关人员 |
| 合同结束日期 | 日期范围 | 单输入框双日历,中文界面,开始—结束精确至日 |
**合同模板联动规则**
1. 未选合同模板时,「标准合同名称」禁用并提示「请先选择合同模板」。
2. 切换合同模板时,自动清空已选标准合同名称。
3. 查询时按合同记录的 `contractTemplateId`(或由合同类型推断的模板 ID匹配合同模板与标准合同名称可单独或组合使用。
**交互**
- **查询**:将当前筛选条件写入「已应用筛选」,列表与 KPI 统计联动。
- **重置**:清空为默认(多选类默认「全部」),并刷新列表。
- **重置**:清空为默认(多选类默认「全部」,单选类清空),并刷新列表。
---
## 5. KPI 统计卡片
共 5 张卡片,点击切换列表筛选(与筛选区叠加生效)。
共 5 张卡片,点击切换列表筛选(与筛选区条件 **叠加** 生效)。
| 卡片 | 口径 |
| 卡片 | 口径(按合同状态) |
|---|---|
| 全部合同 | 不做 KPI 维度过滤 |
| 进行中 | 至少有一辆租赁车辆已交车且尚未还车 |
| 审批中 | 审批状态为「待审批」或「审批中」 |
| 临期合同 | 存在在租车辆,且合同签订有效期距今日 ≤ 30 天(以最后一辆在租车辆为准) |
| 已终止 | 全部车辆已还车;或续签/转三方产生新合同后旧合同自动标记为已终止 |
| 草稿 | 合同状态 = 草稿 |
| 进行中 | 合同状态 = 合同进行中 |
| 审批中 | 合同状态 = 已提交审批 |
| 已终止 | 合同状态 = 已终止 |
卡片右上角 `?` 悬停展示口径说明。
> **说明**KPI 按合同状态快速分桶;交还车履约口径(已交未还、临期等)见 §7.2 子表与 §6.6 全局规则,不在 KPI 卡片中重复统计。
---
## 6. 列表工具栏
@@ -81,24 +90,43 @@
| 列 | 说明 |
|---|---|
| 车辆数 | 租赁订单车辆总数;点击弹出车辆明细表 |
| 已交车 | 已有实际交车日期的车辆数;点击弹出已交车表 |
| 租赁订单 · 车辆数 | 租赁订单车辆总数;点击弹出车辆明细表 |
| 租赁订单 · 已交车 | **审批通过**且已有实际交车日期的车辆数;点击展开已交车表 |
车辆明细字段:车辆类型、品牌、型号、车牌号、实际交车日期、交车人
**履约规则**:非审批通过的合同不视为已交车;「已交车辆数」为 0 时按钮禁用
### 7.2.1 已交车子表
展开「已交车辆数」后展示车辆子表,列包括:
| 列 | 说明 |
|---|---|
| 车辆信息 | 车牌、VIN、品牌型号 |
| 提车应收款 | 支付状态,可跳转详情 |
| 交车 | 已交车展示里程/交车人/时间;未交车显示「未交车」 |
| 租赁账单 | 正常 / 欠费(仅已交车) |
| 还车 | 已还车展示还车信息;未还车显示「未还车」 |
| 还车应结款 | 还车后展示结款状态;**审批中**时展示当前审批人UI 与主表「审批状态」列一致,含节点悬停) |
| 交车安排 | 区域与计划/实际交车日期 |
| 里程要求 / 里程完成 | 有最低里程要求时展示 |
| 操作 | **审批通过 + 已交车 + 未还车** 显示「还车」链接 |
车辆明细 Popover 字段:车辆类型、品牌、型号、车牌号、实际交车日期、交车人。
### 7.3 状态与业务字段
| 列 | 说明 |
|---|---|
| 审批类型 | 标准合同 / 非标准合同;位于审批状态左侧 |
| 审批状态 | 待审批 / 审批中 / 审批通过 / 审批驳回 / 未提交;「审批中」悬停展示审批流节点 |
| 合同状态 | 草稿 / 变更 / 合同进行中 / 到期合同 / 已提交审批 / 已结束 |
| 合同签署方式 | 线上电子签章 / 线下人工上传;可展开查看或变更 |
| 审批状态 | 未提交 / 待审批 / 审批中 / 审批通过 / 审批驳回;「审批中」悬停展示审批流节点与当前处理人 |
| 签约公司 | 创建时所选甲方签约主体简称 |
| 业务部门 / 业务负责人 | 创建时选取 |
| 合同类型 | 正式合同 / 试用合同 |
| 客户联系人 / 联系电话 | 来自客户档案 |
| 被授权人 | 显示人数;点击弹出卡片查看姓名、联系方式、身份证号 |
| 创建人 / 创建时间 | 精确至分钟 |
| 业务部门 | 创建时选取 |
| 费用信息 | 付款方式、周期、保证金等摘要 |
| 交车安排 | 合同级交车区域与计划日期 |
| 整体里程完成情况 | 按在租车辆里程汇总展示完成进度 |
| 客户联系人 | 来自客户档案 |
| 受托人 | 显示人数;点击弹出卡片查看姓名、联系方式、身份证号 |
| 创建信息 | 创建人 / 创建时间,精确至分钟 |
| 最后更新 | 更新人 / 最后更新时间,无则显示 `-` |
**分页**:每页 10 / 20 / 50 条可选。
@@ -120,7 +148,7 @@
| 添加被授权人 | 合同进行中 | 弹窗编辑被授权人列表 |
| 附加费用 | 合同进行中 | 弹窗按车辆录入附加费用 |
| 转三方合同 | 合同进行中 | 弹窗新增三方协议,上传协议附件与对方公函 |
| 试用转正式 | 试用合同 | 单步弹窗:自动拉取客户/授权人/车辆订单(车辆可编辑),可选同时转三方 |
| 试用转正式 | 合同模板为「试用合同 | 单步弹窗:自动拉取客户/授权人/车辆订单(车辆可编辑),可选同时转三方 |
| 终止合同 | 合同进行中 | 二次确认,提交终止审批 |
| 上传盖章合同 | 审批通过且法务未上传 | 仅法务部可见;多文件上传后关闭入口 |
@@ -133,6 +161,8 @@
| 审批中 | 已有节点通过,未完成终审 |
| 审批通过 | 终审通过 |
| 审批驳回 | 任一节点的驳回;可编辑重提 |
| 审批终止 | 流程被终止 |
| 撤回 | 提交人主动撤回 |
### 8.3 合同状态口径
@@ -140,10 +170,8 @@
|---|---|
| 草稿 | 未提交审批 |
| 已提交审批 | 初次提交,终审未完成 |
| 变更 | 已通过基础上变更并重新审批中 |
| 合同进行中 | 终审通过且在有效期内 |
| 到期合同 | 已过结束日期 |
| 已结束 | 终止或自然结束 |
| 已终止 | 终止、自然结束,或续签/转三方后旧合同自动终止 |
### 8.4 弹窗摘要
@@ -155,3 +183,7 @@
- **转三方合同**:支持新增多条三方协议;每条可填协议名称、上传协议附件与对方公函
- **试用转正式**:自动带出客户信息、授权人与车辆订单;车辆订单字段可编辑;可勾选「同时转三方合同」并在同一步维护三方协议
- **上传盖章合同**:拖拽多文件;确认后标记已上传
### 8.5 操作流程文档
从「新增合同」起的正向与逆向全流程图见:`.spec/requirements-flow-operations.md`

View File

@@ -15,12 +15,24 @@ import {
import {
getPublishedContractTemplateOptions,
subscribePublishedContractTemplateOptions,
getContractTemplatePreviewBadge,
getContractTypeLabel,
} from '../contract-template-management/contract-template-catalog.js';
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState, hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js';
import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js';
import {
FLOW_MODE_CREATE,
FLOW_MODE_EDIT,
FLOW_MODE_ADD_VEHICLE,
FLOW_MODE_ADD_POA,
FLOW_MODE_TRIPARTITE,
getFlowPageTitle,
countNewPickupVehicles,
} from './lease-contract-flow-bridge.js';
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState, normalizeLeaseOrderState } from './lease-order-vars.js';
import {
buildLeaseContractPreviewHtml,
extractLeaseOrderAttachment1PreviewHtml,
extractPowerOfAttorneyPreviewHtml,
} from './lease-contract-preview-build.js';
import {
resolveLeaseContractApprovalType,
STANDARD_CONTRACT_APPROVAL,
@@ -34,13 +46,27 @@ import {
isPowerOfAttorneyOptionalEmpty,
isSealTypeSelected,
isLeaseContractFormComplete,
isTripartitePartyComplete,
} from './lease-contract-form-validation.js';
import LeaseContractPreviewPanel, { PreviewEditableHint } from './LeaseContractPreviewPanel.jsx';
import LeaseContractPreviewPanel, {
PreviewEditableHint,
PreviewApprovalHint,
PreviewAttachment1ReadonlyHint,
PreviewPowerOfAttorneyReadonlyHint,
} from './LeaseContractPreviewPanel.jsx';
import LeaseContractEditorForm, {
LeaseContractLeaseOrderSection,
LeaseContractPowerOfAttorneySection,
LeaseContractRemarkSection,
} from './LeaseContractEditorForm.jsx';
import {
CONTRACT_SIGNING_METHOD_ONLINE,
CONTRACT_SIGNING_METHOD_OPTIONS,
} from './lease-contract-signing.js';
import {
createPickupReceivableFromLeaseSubmit,
formatLeaseContractCode,
} from '../vehicle-pickup-receivable/pickup-receivable-bridge.js';
var SEAL_TYPE_OPTIONS = [
{ value: 'contract', label: '合同章' },
@@ -56,7 +82,49 @@ function normalizeSealTypes(types) {
return next.length ? next : ['contract'];
}
export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord }) {
function renderCreateStepNumberIcon(step, options) {
var opts = options || {};
var size = opts.size || 22;
var className = 'lc-create-step-icon' + (opts.className ? ' ' + opts.className : '');
var hideLabel = opts.hideLabel === true;
return React.createElement('svg', {
className: className,
width: size,
height: size,
viewBox: '0 0 24 24',
focusable: 'false',
'aria-hidden': hideLabel ? true : undefined,
role: hideLabel ? undefined : 'img',
'aria-label': hideLabel ? undefined : ('步骤 ' + step),
},
React.createElement('circle', {
cx: 12,
cy: 12,
r: 9.25,
className: 'lc-create-step-icon__ring',
}),
React.createElement('text', {
x: 12,
y: 12,
className: 'lc-create-step-icon__digit',
}, String(step)),
);
}
function renderCreateStepTitle(step, title) {
return React.createElement('div', { className: 'ct-panel-head__title lc-create-step-title' },
renderCreateStepNumberIcon(step, { className: 'lc-create-step-title__badge', hideLabel: true }),
React.createElement('span', { className: 'lc-create-step-title__text' }, title),
);
}
export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord, flowMode, flowInitialFormState }) {
var effectiveFlowMode = flowMode || (editRecord && editRecord.id ? FLOW_MODE_EDIT : FLOW_MODE_CREATE);
var isAddVehicleFlow = effectiveFlowMode === FLOW_MODE_ADD_VEHICLE;
var isAddPoaFlow = effectiveFlowMode === FLOW_MODE_ADD_POA;
var isTripartiteFlow = effectiveFlowMode === FLOW_MODE_TRIPARTITE;
var isScopedPreviewFlow = isAddVehicleFlow || isAddPoaFlow;
var pageTitle = getFlowPageTitle(effectiveFlowMode);
var contractTypeState = useState('');
var contractType = contractTypeState[0];
var setContractType = contractTypeState[1];
@@ -72,11 +140,9 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var setTemplateOptions = templateOptionsState[1];
var templateSelectOptions = useMemo(function () {
return templateOptions.map(function (option) {
var typeLabel = getContractTypeLabel(option.contractType) || option.contractType || '—';
var fileLabel = option.fileName || option.title || option.id;
return {
value: option.id,
label: typeLabel + ' · ' + fileLabel,
label: option.contractTypeLabel || option.title || option.kind || option.id,
contractType: option.contractType,
};
});
@@ -111,22 +177,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD));
var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO));
var contractCodeState = useState(function () { return generateAutoContractCodeSuffix(); });
var projectNameState = useState('');
var businessDeptState = useState('');
var businessOwnerState = useState('');
var leaseOrderState = useState(function () {
var initial = createDefaultLeaseOrderState();
return {
insuredVehicleCount: initial.insuredVehicleCount,
thirdPartyLiabilityMillion: initial.thirdPartyLiabilityMillion,
deliveryRegion: initial.deliveryRegion.slice(),
deliveryDate: initial.deliveryDate,
rows: initial.rows.map(function (row) {
return Object.assign({}, row, {
brandModels: row.brandModels ? row.brandModels.slice() : [],
plateNos: row.plateNos ? row.plateNos.slice() : undefined,
});
}),
};
return normalizeLeaseOrderState(createDefaultLeaseOrderState());
});
var lessorId = lessorIdState[0];
var setLessorId = lessorIdState[1];
@@ -138,6 +193,8 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var setFeeInfo = feeInfoState[1];
var contractCode = contractCodeState[0];
var setContractCode = contractCodeState[1];
var projectName = projectNameState[0];
var setProjectName = projectNameState[1];
var businessDept = businessDeptState[0];
var setBusinessDept = businessDeptState[1];
var businessOwner = businessOwnerState[0];
@@ -163,16 +220,31 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var customerPrincipalPhoneState = useState('');
var customerPrincipalPhone = customerPrincipalPhoneState[0];
var setCustomerPrincipalPhone = customerPrincipalPhoneState[1];
var thirdPartyCustomerIdState = useState('');
var thirdPartyCustomerId = thirdPartyCustomerIdState[0];
var setThirdPartyCustomerId = thirdPartyCustomerIdState[1];
var thirdPartyPrincipalNameState = useState('');
var thirdPartyPrincipalName = thirdPartyPrincipalNameState[0];
var setThirdPartyPrincipalName = thirdPartyPrincipalNameState[1];
var thirdPartyPrincipalPhoneState = useState('');
var thirdPartyPrincipalPhone = thirdPartyPrincipalPhoneState[0];
var setThirdPartyPrincipalPhone = thirdPartyPrincipalPhoneState[1];
var contractSigningMethodState = useState(CONTRACT_SIGNING_METHOD_ONLINE);
var contractSigningMethod = contractSigningMethodState[0];
var setContractSigningMethod = contractSigningMethodState[1];
var sealTypesState = useState(['contract']);
var sealTypes = sealTypesState[0];
var setSealTypes = sealTypesState[1];
var contractApprovalTypeState = useState(STANDARD_CONTRACT_APPROVAL);
var contractApprovalType = contractApprovalTypeState[0];
var setContractApprovalType = contractApprovalTypeState[1];
var previewMergedHtmlState = useState('');
var previewMergedHtml = previewMergedHtmlState[0];
var setPreviewMergedHtml = previewMergedHtmlState[1];
var previewHtml = useMemo(function () {
if (!contractTemplateId) return '';
return buildLeaseContractPreviewHtml({
var fullHtml = buildLeaseContractPreviewHtml({
contractTemplateId: contractTemplateId,
lessorId: lessorId,
customerId: customerId,
@@ -182,28 +254,40 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
leaseOrder: leaseOrder,
powerOfAttorney: powerOfAttorney,
});
}, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney]);
if (isAddVehicleFlow) return extractLeaseOrderAttachment1PreviewHtml(fullHtml);
if (isAddPoaFlow) return extractPowerOfAttorneyPreviewHtml(fullHtml);
return fullHtml;
}, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney, isAddVehicleFlow, isAddPoaFlow]);
var isPreviewEditable = !isScopedPreviewFlow;
var handlePreviewContentChange = useCallback(function (mergedHtml) {
if (!isPreviewEditable) return;
setPreviewMergedHtml(mergedHtml || '');
setContractApprovalType(resolveLeaseContractApprovalType(previewHtml, mergedHtml, {
leaseOrder: leaseOrder,
feeInfo: feeInfo,
sealTypes: sealTypes,
}));
}, [previewHtml, leaseOrder]);
}, [previewHtml, leaseOrder, feeInfo, sealTypes, isPreviewEditable]);
useEffect(function () {
if (hasLeaseOrderRentBelowMinimum(leaseOrder)) {
setContractApprovalType(NONSTANDARD_CONTRACT_APPROVAL);
return;
}
setContractApprovalType(STANDARD_CONTRACT_APPROVAL);
}, [previewHtml, leaseOrder]);
setContractApprovalType(resolveLeaseContractApprovalType(
previewHtml,
previewMergedHtml || previewHtml,
{
leaseOrder: leaseOrder,
feeInfo: feeInfo,
sealTypes: sealTypes,
},
));
}, [previewHtml, previewMergedHtml, leaseOrder, feeInfo, sealTypes]);
var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL;
var isTemplateSelected = Boolean(contractTemplateId);
var previewBadge = getContractTemplatePreviewBadge(contractTemplateId);
var isMainContractComplete = useMemo(function () {
return isMainContractFormComplete({
var baseComplete = isMainContractFormComplete({
lessorId: lessorId,
customerId: customerId,
contractCode: contractCode,
@@ -212,7 +296,15 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
mileage: mileage,
feeInfo: feeInfo,
});
}, [lessorId, customerId, contractCode, businessDept, businessOwner, mileage, feeInfo]);
if (!baseComplete) return false;
if (isTripartiteFlow) {
return isTripartitePartyComplete({
customerId: customerId,
thirdPartyCustomerId: thirdPartyCustomerId,
});
}
return true;
}, [lessorId, customerId, contractCode, businessDept, businessOwner, mileage, feeInfo, isTripartiteFlow, thirdPartyCustomerId]);
var isLeaseOrderComplete = useMemo(function () {
return isLeaseOrderFormComplete({ leaseOrder: leaseOrder });
@@ -258,11 +350,6 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
});
}
function getSealTypeLabel(value) {
var match = SEAL_TYPE_OPTIONS.find(function (item) { return item.value === value; });
return match ? match.label : value;
}
var columnRef = useRef(null);
var sectionRefs = {
template: useRef(null),
@@ -284,38 +371,46 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
useEffect(function () {
if (!editRecord || !editRecord.id) return;
var formState = buildLeaseContractEditFormState(editRecord);
if (!formState) return;
applyBridgeFormState(formState);
}, [editRecord && editRecord.id]);
useEffect(function () {
if (!flowInitialFormState) return;
applyBridgeFormState(flowInitialFormState);
}, [flowInitialFormState && flowInitialFormState.contractCode]);
function applyBridgeFormState(formState) {
if (!formState) return;
setContractTemplateId(formState.contractTemplateId || '');
setLessorId(formState.lessorId || '');
setCustomerId(formState.customerId || '');
setContractCode(formState.contractCode || '');
setProjectName(formState.projectName || '');
setBusinessDept(formState.businessDept || '');
setBusinessOwner(formState.businessOwner || '');
setMileage(Object.assign({}, formState.mileage));
setFeeInfo(Object.assign({}, formState.feeInfo));
setLeaseOrder({
insuredVehicleCount: formState.leaseOrder.insuredVehicleCount,
thirdPartyLiabilityMillion: formState.leaseOrder.thirdPartyLiabilityMillion,
deliveryRegion: (formState.leaseOrder.deliveryRegion || []).slice(),
deliveryDate: formState.leaseOrder.deliveryDate,
rows: (formState.leaseOrder.rows || []).map(function (row) {
return Object.assign({}, row, {
brandModels: row.brandModels ? row.brandModels.map(function (pair) { return pair.slice(); }) : [],
plateNos: row.plateNos ? row.plateNos.slice() : undefined,
extraServices: row.extraServices ? row.extraServices.slice() : [],
});
}),
});
setLeaseOrder(normalizeLeaseOrderState(formState.leaseOrder));
setPowerOfAttorney({
delegates: (formState.powerOfAttorney.delegates || []).map(function (row) {
delegates: ((formState.powerOfAttorney && formState.powerOfAttorney.delegates) || []).map(function (row) {
return Object.assign({}, row);
}),
});
setContractRemark(formState.contractRemark || '');
setCustomerPrincipalName(formState.customerPrincipalName || '');
setCustomerPrincipalPhone(formState.customerPrincipalPhone || '');
setThirdPartyCustomerId(formState.thirdPartyCustomerId || '');
setThirdPartyPrincipalName(formState.thirdPartyPrincipalName || '');
setThirdPartyPrincipalPhone(formState.thirdPartyPrincipalPhone || '');
setSealTypes(normalizeSealTypes(formState.sealTypes));
}, [editRecord && editRecord.id]);
if (formState.contractType) {
setContractType(formState.contractType);
} else if (formState.contractTemplateId) {
var tplMatch = templateOptions.find(function (item) { return item.id === formState.contractTemplateId; });
if (tplMatch) setContractType(tplMatch.contractType || '');
}
}
var createSteps = useMemo(function () {
return [
@@ -372,20 +467,16 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
contractRemark,
]);
var requiredDoneCount = useMemo(function () {
var count = 0;
if (isTemplateSelected) count += 1;
if (isMainContractComplete) count += 1;
if (isLeaseOrderComplete) count += 1;
if (isPowerOfAttorneyComplete) count += 1;
if (isSealTypeComplete) count += 1;
return count;
}, [isTemplateSelected, isMainContractComplete, isLeaseOrderComplete, isPowerOfAttorneyComplete, isSealTypeComplete]);
function scrollToCreateSection(stepKey) {
var target = sectionRefs[stepKey] && sectionRefs[stepKey].current;
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
var container = columnRef.current;
if (!target || !container) return;
var containerTop = container.getBoundingClientRect().top;
var targetTop = target.getBoundingClientRect().top;
container.scrollTo({
top: container.scrollTop + (targetTop - containerTop),
behavior: 'smooth',
});
}
function handleStepNavClick(step) {
@@ -404,15 +495,72 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
role: 'region',
'aria-label': '需先选择合同模板',
},
React.createElement('p', { className: 'lc-create-section__lock-text' }, '请先完成第 1 步选择合同模板,再填写本节内容。'),
React.createElement('p', { className: 'lc-create-section__lock-text' },
'请先完成',
renderCreateStepNumberIcon(1, { className: 'lc-create-section__lock-step-icon', size: 18, hideLabel: true }),
'选择合同模板,再填写本节内容。',
),
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-secondary lc-create-section__lock-btn',
'data-vm-icon': 'eye',
onClick: function () { scrollToCreateSection('template'); },
}, '前往选择模板'),
);
}
function isSectionFlowFormLocked(stepKey) {
return isAddPoaFlow && stepKey !== 'poa';
}
function renderFlowFormLock(stepKey) {
if (!isSectionFlowFormLocked(stepKey)) return null;
return React.createElement('div', {
className: 'lc-create-section__lock lc-create-section__lock--flow',
role: 'region',
'aria-label': '本流程仅可编辑授权委托书',
},
React.createElement('p', { className: 'lc-create-section__lock-text' },
'本流程仅可编辑受托人信息,其余内容沿用来源合同,无需修改。',
),
);
}
function getCreateSectionDisabledClass(stepKey) {
if (!isTemplateSelected && stepKey !== 'template') return ' lc-create-section--disabled';
if (isSectionFlowFormLocked(stepKey)) return ' lc-create-section--disabled';
return '';
}
function getPreviewPanelTitle() {
if (isAddVehicleFlow) return '附件1预览';
if (isAddPoaFlow) return '授权委托书预览';
return '实时预览';
}
function getPreviewPanelAriaLabel() {
return getPreviewPanelTitle();
}
function renderPreviewReadonlyHint() {
if (isAddVehicleFlow) return React.createElement(PreviewAttachment1ReadonlyHint);
if (isAddPoaFlow) return React.createElement(PreviewPowerOfAttorneyReadonlyHint);
return React.createElement(PreviewEditableHint);
}
function getPreviewHintId() {
if (isAddVehicleFlow) return 'lc-preview-attachment1-hint';
if (isAddPoaFlow) return 'lc-preview-poa-hint';
return 'lc-preview-editable-hint';
}
function getPreviewPanelCardClass() {
var cls = 'ct-editor-column-card ct-editor-column-card--preview';
if (isAddVehicleFlow) cls += ' ct-editor-column-card--preview-attachment1';
if (isAddPoaFlow) cls += ' ct-editor-column-card--preview-poa';
return cls;
}
function renderStepNav() {
return React.createElement('nav', {
className: 'lc-create-step-nav',
@@ -435,12 +583,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement('span', { className: 'lc-create-step-nav__index', 'aria-hidden': true },
step.done
? React.createElement(Check, { size: 14, strokeWidth: 2.5, 'aria-hidden': true })
: String(index + 1),
: renderCreateStepNumberIcon(index + 1, { className: 'lc-create-step-title__badge', hideLabel: true }),
),
React.createElement('span', { className: 'lc-create-step-nav__label' }, step.label),
step.optional
? React.createElement('span', { className: 'lc-create-step-nav__tag' }, '选填')
: null,
React.createElement('span', {
className: 'lc-create-step-nav__tag' + (step.optional ? '' : ' lc-create-step-nav__tag--required'),
}, step.optional ? '选填' : '必填'),
),
index < createSteps.length - 1
? React.createElement('span', { className: 'lc-create-step-nav__sep', 'aria-hidden': true })
@@ -453,34 +601,45 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
return React.createElement('div', { className: 'vm-page lc-page lc-create-page ct-page' },
React.createElement('div', { className: 'lc-create-topbar' },
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-back',
onClick: onBack,
}, '返回列表'),
React.createElement('div', { className: 'lc-create-topbar__intro' },
React.createElement('h1', { className: 'lc-create-topbar__title' }, '新增租赁合同'),
React.createElement('p', { className: 'lc-create-topbar__meta', role: 'status' },
'必填进度 ',
React.createElement('strong', null, requiredDoneCount),
' / 5',
isTemplateSelected
? React.createElement('span', { className: 'lc-create-topbar__meta-divider', 'aria-hidden': true }, '·')
: null,
isTemplateSelected
? React.createElement('span', { className: 'lc-create-topbar__meta-template' }, previewBadge)
: React.createElement('span', { className: 'lc-create-topbar__meta-hint' }, '请先选择合同模板'),
),
),
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-back',
onClick: onBack,
}, '返回列表'),
effectiveFlowMode !== FLOW_MODE_CREATE
? React.createElement('span', { className: 'lc-create-topbar__title' }, pageTitle)
: null,
),
flowInitialFormState && (flowInitialFormState._pickupReceivableHint || flowInitialFormState._signingHint || flowInitialFormState._sourceContractCode)
? React.createElement('div', {
className: 'lc-flow-banner',
role: 'note',
'data-annotation-id': 'lc-create-flow-hint',
},
flowInitialFormState._sourceContractCode
? React.createElement('p', { className: 'lc-flow-banner__line' },
'来源合同:',
React.createElement('strong', null, flowInitialFormState._sourceContractCode),
)
: null,
flowInitialFormState._pickupReceivableHint
? React.createElement('p', { className: 'lc-flow-banner__line' }, flowInitialFormState._pickupReceivableHint)
: null,
flowInitialFormState._signingHint
? React.createElement('p', { className: 'lc-flow-banner__line' }, flowInitialFormState._signingHint)
: null,
)
: null,
React.createElement('div', { className: 'ct-editor-page lc-create-editor-page' },
React.createElement('div', { className: 'ct-editor-workspace' },
React.createElement(Row, { gutter: [16, 16] },
React.createElement(Col, { xs: 24, lg: 12 },
React.createElement('div', { className: 'lc-create-editor-column', ref: columnRef },
React.createElement('div', { className: 'lc-create-editor-column-shell' },
renderStepNav(),
React.createElement('div', { className: 'lc-create-editor-column', ref: columnRef },
React.createElement('section', {
className: 'ct-editor-column-card lc-template-step-card lc-create-section',
className: 'ct-editor-column-card lc-template-step-card lc-create-section'
+ getCreateSectionDisabledClass('template'),
'aria-label': '选择合同类型',
'data-annotation-id': 'lc-create-template',
id: 'lc-create-section-template',
@@ -491,7 +650,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-describedby': 'lc-template-step-hint',
},
React.createElement('div', { className: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 1 步:选择合同类型'),
renderCreateStepTitle(1, '选择合同类型'),
React.createElement('span', {
className: 'lc-form-card__hint',
role: 'note',
@@ -503,7 +662,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-hidden': true,
}),
React.createElement('span', { className: 'lc-form-card__hint-text' },
'选择模板管理中已发布且启用的合同模板,右侧自动生成预览。',
'选择对应合同模板,右侧自动生成该合同预览内容',
),
),
),
@@ -514,33 +673,56 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}, isTemplateSelected ? '已选择' : '必选'),
),
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel lc-template-step-panel' },
React.createElement(Select, {
className: 'lc-template-select',
value: contractTemplateId || undefined,
placeholder: templateSelectOptions.length ? '请选择合同模板' : '暂无可用模板',
allowClear: true,
showSearch: true,
optionFilterProp: 'label',
disabled: !templateSelectOptions.length,
onChange: handleTemplateSelect,
options: templateSelectOptions,
'aria-label': '合同模板',
}),
React.createElement('div', { className: 'lc-template-step-fields', 'data-annotation-id': 'lc-create-template-fields' },
React.createElement('div', { className: 'lc-form-field' },
React.createElement('label', { className: 'lc-form-field__label' }, '合同模板'),
React.createElement('div', { className: 'lc-form-field__control' },
React.createElement(Select, {
className: 'lc-form-select lc-template-select',
value: contractTemplateId || undefined,
placeholder: templateSelectOptions.length ? '请选择合同模板' : '暂无可用模板',
allowClear: !isAddPoaFlow,
showSearch: true,
optionFilterProp: 'label',
disabled: !templateSelectOptions.length || isAddPoaFlow,
onChange: handleTemplateSelect,
options: templateSelectOptions,
'aria-label': '合同模板',
}),
),
),
React.createElement('div', {
className: 'lc-form-field',
'data-annotation-id': 'lc-create-signing-method',
},
React.createElement('label', { className: 'lc-form-field__label' }, '合同签署方式'),
React.createElement('div', { className: 'lc-form-field__control' },
React.createElement(Select, {
className: 'lc-form-select lc-template-signing-row__select',
value: contractSigningMethod,
options: CONTRACT_SIGNING_METHOD_OPTIONS,
onChange: function (value) { setContractSigningMethod(value || CONTRACT_SIGNING_METHOD_ONLINE); },
'aria-label': '合同签署方式',
}),
),
),
),
!templateSelectOptions.length
? React.createElement('p', { className: 'lc-template-step-empty' }, '暂无已启用的合同模板,请先在合同模板管理中发布并启用。')
: null,
),
renderFlowFormLock('template'),
),
React.createElement('section', {
className: 'ct-editor-column-card lc-main-contract-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
+ getCreateSectionDisabledClass('main'),
'aria-label': '主体合同信息',
'data-annotation-id': 'lc-create-main-contract',
id: 'lc-create-section-main',
ref: sectionRefs.main,
},
React.createElement('div', { className: 'ct-panel-head lc-main-contract-card__head' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 2 步:主体合同信息'),
renderCreateStepTitle(2, '主体合同信息'),
React.createElement('span', {
className: 'lc-form-completion-badge ' + (isMainContractComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
role: 'status',
@@ -551,15 +733,25 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement(LeaseContractEditorForm, {
lessorId: lessorId,
customerId: customerId,
thirdPartyCustomerId: thirdPartyCustomerId,
showThirdPartyParty: isTripartiteFlow,
contractCode: contractCode,
contractCodeReadonly: !(editRecord && editRecord.id),
projectName: projectName,
contractCodeReadonly: effectiveFlowMode !== FLOW_MODE_EDIT,
businessDept: businessDept,
businessOwner: businessOwner,
mileage: mileage,
feeInfo: feeInfo,
onLessorChange: setLessorId,
onCustomerChange: setCustomerId,
onCustomerChange: function (value) {
setCustomerId(value);
if (thirdPartyCustomerId && thirdPartyCustomerId === value) {
setThirdPartyCustomerId('');
}
},
onThirdPartyCustomerChange: setThirdPartyCustomerId,
onContractCodeChange: setContractCode,
onProjectNameChange: setProjectName,
onBusinessAssignmentChange: function (dept, owner) {
setBusinessDept(dept);
setBusinessOwner(owner);
@@ -572,20 +764,27 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
setCustomerPrincipalName(name);
setCustomerPrincipalPhone(phone);
},
thirdPartyPrincipalName: thirdPartyPrincipalName,
thirdPartyPrincipalPhone: thirdPartyPrincipalPhone,
onThirdPartyPrincipalChange: function (name, phone) {
setThirdPartyPrincipalName(name);
setThirdPartyPrincipalPhone(phone);
},
}),
),
renderSectionLock('main'),
renderFlowFormLock('main'),
),
React.createElement('section', {
className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
+ getCreateSectionDisabledClass('leaseOrder'),
'aria-label': '附件1租赁订单',
'data-annotation-id': 'lc-create-lease-order',
id: 'lc-create-section-lease-order',
ref: sectionRefs.leaseOrder,
},
React.createElement('div', { className: 'ct-panel-head' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 3 步:附件1 · 租赁订单'),
renderCreateStepTitle(3, '附件1 · 租赁订单'),
React.createElement('span', {
className: 'lc-form-completion-badge ' + (isLeaseOrderComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
role: 'status',
@@ -599,10 +798,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}),
),
renderSectionLock('leaseOrder'),
renderFlowFormLock('leaseOrder'),
),
React.createElement('section', {
className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
+ getCreateSectionDisabledClass('poa'),
'aria-label': '授权委托书',
'data-annotation-id': 'lc-create-poa',
id: 'lc-create-section-poa',
@@ -613,7 +813,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-describedby': 'lc-create-poa-hint',
},
React.createElement('div', { className: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 4 步:授权委托书'),
renderCreateStepTitle(4, '授权委托书'),
React.createElement('span', {
className: 'lc-form-card__hint lc-create-poa-hint',
role: 'note',
@@ -626,7 +826,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-hidden': true,
}),
React.createElement('span', { className: 'lc-form-card__hint-text' },
'添加受托人须同时上传授权委托书',
'添加受托人须同时上传授权委托书受托人主要用于交还车时E签宝签章',
),
),
),
@@ -651,14 +851,14 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
),
React.createElement('section', {
className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
+ getCreateSectionDisabledClass('remark'),
'aria-label': '合同备注',
'data-annotation-id': 'lc-create-contract-remark',
id: 'lc-create-section-remark',
ref: sectionRefs.remark,
},
React.createElement('div', { className: 'ct-panel-head' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 5 步:合同备注'),
renderCreateStepTitle(5, '合同备注'),
React.createElement('span', {
className: 'lc-form-completion-badge '
+ ((contractRemark || '').trim()
@@ -675,10 +875,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}),
),
renderSectionLock('remark'),
renderFlowFormLock('remark'),
),
React.createElement('section', {
className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
+ getCreateSectionDisabledClass('seal'),
'aria-label': '用章类型',
'data-annotation-id': 'lc-create-seal',
id: 'lc-create-section-seal',
@@ -686,22 +887,25 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
},
React.createElement('div', {
className: 'ct-panel-head lc-create-section__head lc-create-section-seal-head',
'aria-describedby': 'lc-create-seal-hint',
},
React.createElement('div', { className: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 6 步:用章类型'),
sealTypes.length
? React.createElement('span', {
className: 'lc-seal-type-head-tags',
'aria-label': '已选用章',
},
sealTypes.map(function (value) {
return React.createElement('span', {
key: value,
className: 'lc-seal-type-head-tag',
}, getSealTypeLabel(value));
}),
)
: null,
renderCreateStepTitle(6, '用章类型'),
React.createElement('span', {
className: 'lc-form-card__hint lc-create-seal-hint',
role: 'note',
id: 'lc-create-seal-hint',
'data-annotation-id': 'lc-create-seal-hint',
},
React.createElement(Info, {
size: 13,
className: 'lc-form-card__hint-icon',
'aria-hidden': true,
}),
React.createElement('span', { className: 'lc-form-card__hint-text' },
'额外勾选公章、法人章会进入非标审批流程',
),
),
),
React.createElement('span', {
className: 'lc-form-completion-badge ' + (isSealTypeComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
@@ -729,24 +933,29 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
),
),
renderSectionLock('seal'),
renderFlowFormLock('seal'),
),
),
),
),
React.createElement(Col, { xs: 24, lg: 12 },
React.createElement('section', {
className: 'ct-editor-column-card ct-editor-column-card--preview',
'aria-label': '实时预览',
className: getPreviewPanelCardClass(),
'aria-label': getPreviewPanelAriaLabel(),
'data-annotation-id': 'lc-create-preview',
},
React.createElement('div', {
className: 'ct-panel-head lc-create-preview-head',
'aria-describedby': isTemplateSelected ? 'lc-preview-editable-hint' : undefined,
'aria-describedby': isTemplateSelected ? getPreviewHintId() : undefined,
},
React.createElement('div', { className: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '实时预览'),
isTemplateSelected ? React.createElement(PreviewEditableHint) : null,
React.createElement('div', { className: 'ct-panel-head__title' },
getPreviewPanelTitle(),
),
isTemplateSelected ? renderPreviewReadonlyHint() : null,
),
React.createElement('div', { className: 'ct-panel-head__actions' },
isScopedPreviewFlow ? null : React.createElement(PreviewApprovalHint),
React.createElement('span', {
className: 'lc-approval-type-badge'
+ (isNonStandardApproval ? ' lc-approval-type-badge--nonstd' : ' lc-approval-type-badge--standard'),
@@ -761,8 +970,8 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
html: previewHtml,
zoom: workspaceZoom,
onZoomChange: setWorkspaceZoom,
editable: true,
onContentChange: handlePreviewContentChange,
editable: isPreviewEditable,
onContentChange: isPreviewEditable ? handlePreviewContentChange : undefined,
})
: React.createElement('div', { className: 'lc-template-preview-empty' }, '请先在左侧选择合同模板,右侧将自动生成对应预览。'),
),
@@ -775,11 +984,13 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-ghost',
'data-vm-icon': 'x',
onClick: onBack,
}, '取消'),
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-secondary',
'data-vm-icon': 'save',
onClick: function () {
if (!contractTemplateId) {
message.warning('请先选择合同模板');
@@ -791,12 +1002,32 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-primary',
'data-vm-icon': 'send',
onClick: function () {
if (!contractTemplateId) {
message.warning('请先选择合同模板');
return;
}
if (!isFormSubmitReady) {
if (isAddPoaFlow) {
if (!isPoaSubmitReady) {
message.warning('提交审核前须完成授权委托书填写');
scrollToCreateSection('poa');
return;
}
} else if (isTripartiteFlow) {
if (!isTripartitePartyComplete({
customerId: customerId,
thirdPartyCustomerId: thirdPartyCustomerId,
})) {
message.warning('转三方合同须选择丙方客户,且不能与乙方相同');
scrollToCreateSection('main');
return;
}
if (!isFormSubmitReady) {
message.warning('请先完成左侧必填项');
return;
}
} else if (!isFormSubmitReady) {
if (!isPoaSubmitReady) {
message.warning('提交审核前须完成授权委托书填写');
scrollToCreateSection('poa');
@@ -806,11 +1037,46 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
return;
}
var submitCustomer = getLeaseCustomerById(customerId);
if (hasBlockingCustomerCredentials(submitCustomer)) {
if (!isAddPoaFlow && hasBlockingCustomerCredentials(submitCustomer)) {
message.error(formatCredentialSubmitBlockMessage(summarizeCustomerCredentials(submitCustomer)));
scrollToCreateSection('main');
return;
}
var fullContractCode = formatLeaseContractCode(contractCode);
var newVehicleCount = countNewPickupVehicles(leaseOrder);
if (effectiveFlowMode === 'renew' && newVehicleCount > 0) {
message.info('续签:新增 ' + newVehicleCount + ' 台车辆将生成提车应收款,已交未还车辆不生成(原型)');
}
if (effectiveFlowMode === 'trialToFormal') {
message.info('转正式:已交未还车辆完成后不再生成交车任务;金额变更将重新记录提车应收款(原型)');
}
if (effectiveFlowMode === 'addVehicle') {
message.info('新增车辆:审批通过后按新增合同规则生成提车应收款(原型)');
}
if (effectiveFlowMode === 'addPoa') {
message.info('添加授权委托书:审批通过后仅对授权委托书发起签署,主合同无需重签(原型)');
message.success('提交审核成功(' + fullContractCode + '');
message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
return;
}
createPickupReceivableFromLeaseSubmit({
contractCode: fullContractCode,
contractType: getContractTypeLabel(contractType),
projectName: projectName
|| ((submitCustomer && submitCustomer.companyName)
? submitCustomer.companyName + '租赁项目'
: ''),
customerName: submitCustomer ? submitCustomer.companyName : '',
businessDept: businessDept,
businessPerson: businessOwner,
feeInfo: feeInfo,
leaseOrder: leaseOrder,
paymentMethod: feeInfo && feeInfo.paymentMethod === 'postpay' ? '后付' : '预付',
});
if (effectiveFlowMode === 'tripartite') {
message.info('转三方:审批通过后沿用原合同签署方式,线上 E签宝 / 线下盖章补传(原型)');
}
message.success('提交审核成功,已同步创建提车应收款(' + fullContractCode + '');
message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
},
}, '提交审核'),

View File

@@ -1,4 +1,5 @@
// 在租车辆概览 — 按品牌型号统计KPI 卡片 + 下方车型面板 / 隐藏后弹层)
import React from 'react';
import { buildOnLeaseFleetByBrandModel } from './lease-contract-list-data.js';
function FleetDetailModal(props) {
@@ -32,11 +33,11 @@ function FleetDetailModal(props) {
title: titleNode,
open: open,
onCancel: onClose,
footer: React.createElement('button', {
footer: React.createElement('button', { 'data-vm-icon': 'x',
type: 'button',
className: 'vm-btn vm-btn-ghost lc-fleet-detail-modal__close',
onClick: onClose,
}, '关闭'),
}, '关闭'),
width: 960,
centered: true,
destroyOnClose: true,
@@ -141,12 +142,13 @@ function FleetQuickViewModal(props) {
open: open,
onCancel: onClose,
footer: React.createElement('div', { className: 'lc-fleet-quick-modal__footer' },
React.createElement('button', {
React.createElement('button', { 'data-vm-icon': 'x',
type: 'button',
className: 'vm-btn vm-btn-ghost',
onClick: onClose,
}, '关闭'),
}, '关闭'),
React.createElement('button', {
'data-vm-icon': 'plus',
type: 'button',
className: 'vm-btn vm-btn-primary',
onClick: function () {

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import { Select } from 'antd';
import { Info } from 'lucide-react';
import { Select, Popover } from 'antd';
import { Info, FileDiff } from 'lucide-react';
import { paginateWordHtml } from './contract-word-preview.js';
import { collectClauseChangeRecords } from './lease-contract-risk-detect.js';
var PREVIEW_ZOOM_STEPS = [50, 75, 90, 100, 110, 125, 150, 175, 200];
@@ -48,7 +49,145 @@ export function PreviewEditableHint() {
React.createElement('span', { className: 'lc-form-card__hint-text lc-preview-editable-hint__text' },
'正文可编辑;改动',
React.createElement('span', { className: 'lc-preview-editable-hint__risk' }, '风控红线条款'),
'走非标准审批,未改则标准审批。',
'或新增条款走非标准审批,未改则标准审批。',
),
);
}
export function PreviewAttachment1ReadonlyHint() {
return React.createElement('span', {
className: 'lc-form-card__hint lc-preview-editable-hint lc-preview-editable-hint--inline lc-preview-attachment1-hint',
role: 'note',
'data-annotation-id': 'lc-preview-attachment1-hint',
id: 'lc-preview-attachment1-hint',
},
React.createElement(Info, {
size: 13,
className: 'lc-form-card__hint-icon',
'aria-hidden': true,
}),
React.createElement('span', { className: 'lc-form-card__hint-text lc-preview-editable-hint__text' },
'仅展示附件1条款主合同及其他附件不可编辑、无需重签请在左侧填写新增车辆订单。',
),
);
}
export function PreviewPowerOfAttorneyReadonlyHint() {
return React.createElement('span', {
className: 'lc-form-card__hint lc-preview-editable-hint lc-preview-editable-hint--inline lc-preview-poa-hint',
role: 'note',
'data-annotation-id': 'lc-preview-poa-hint',
id: 'lc-preview-poa-hint',
},
React.createElement(Info, {
size: 13,
className: 'lc-form-card__hint-icon',
'aria-hidden': true,
}),
React.createElement('span', { className: 'lc-form-card__hint-text lc-preview-editable-hint__text' },
'仅展示授权委托书,主合同及其他附件不可编辑、无需重签;请在左侧填写受托人信息。',
),
);
}
export function PreviewApprovalHint() {
return React.createElement('span', {
className: 'lc-form-card__hint lc-preview-approval-hint',
role: 'note',
'data-annotation-id': 'lc-preview-approval-hint',
id: 'lc-preview-approval-hint',
title: '非标准合同触发条件1.实时预览新增条款2.改动风控红线条款3.付款方式为先用后付4.付款周期为季付/半年付/年付5.车辆租金或保证金低于车型最低标准价6.用章类型额外选择公章或法人章',
},
React.createElement(Info, {
size: 13,
className: 'lc-form-card__hint-icon',
'aria-hidden': true,
}),
React.createElement('span', { className: 'lc-form-card__hint-text' }, '非标准触发条件'),
);
}
function PreviewClauseChangeRecordPanel(props) {
var records = props.records || [];
return React.createElement('div', {
className: 'lc-preview-clause-record-panel',
role: 'region',
'aria-label': '条款修改记录',
'data-annotation-id': 'lc-preview-clause-record',
},
React.createElement('div', { className: 'lc-preview-clause-record-panel__head' },
React.createElement('span', { className: 'lc-preview-clause-record-panel__title' }, '条款修改记录'),
records.length
? React.createElement('span', { className: 'lc-preview-clause-record-panel__count tabular-nums' }, records.length + ' 处')
: null,
),
records.length
? React.createElement('ul', { className: 'lc-preview-clause-record-panel__list' },
records.map(function (item) {
return React.createElement('li', {
key: item.id,
className: 'lc-preview-clause-record-panel__item lc-preview-clause-record-panel__item--' + item.kind,
},
React.createElement('span', { className: 'lc-preview-clause-record-panel__kind' }, item.kindLabel),
React.createElement('div', { className: 'lc-preview-clause-record-panel__diff' },
React.createElement('div', { className: 'lc-preview-clause-record-panel__col' },
React.createElement('span', { className: 'lc-preview-clause-record-panel__label' }, '标准条款'),
React.createElement('p', { className: 'lc-preview-clause-record-panel__text' }, item.standardText),
),
React.createElement('div', { className: 'lc-preview-clause-record-panel__col' },
React.createElement('span', { className: 'lc-preview-clause-record-panel__label' }, '修改后'),
React.createElement('p', { className: 'lc-preview-clause-record-panel__text' }, item.modifiedText),
),
),
);
}),
)
: React.createElement('p', { className: 'lc-preview-clause-record-panel__empty', role: 'status' },
'暂无条款修改,编辑预览正文后将在此对比标准条款与修改后内容。',
),
);
}
function PreviewClauseChangeRecordTrigger(props) {
var records = props.records || [];
var openState = useState(false);
var open = openState[0];
var setOpen = openState[1];
var count = records.length;
return React.createElement('div', {
className: 'ct-preview-pager__group ct-preview-pager__group--clause-record',
role: 'group',
'aria-label': '条款修改记录',
},
React.createElement(Popover, {
trigger: 'click',
placement: 'bottomRight',
open: open,
onOpenChange: setOpen,
overlayClassName: 'lc-preview-clause-record-popover',
getPopupContainer: function () { return document.body; },
content: React.createElement(PreviewClauseChangeRecordPanel, { records: records }),
},
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-secondary lc-preview-clause-record-btn'
+ (open ? ' is-active' : '')
+ (count ? ' has-changes' : ''),
'aria-expanded': open,
'aria-haspopup': 'dialog',
'data-annotation-id': 'lc-preview-clause-record-btn',
},
React.createElement(FileDiff, { size: 14, 'aria-hidden': true }),
React.createElement('span', { className: 'lc-preview-clause-record-btn__label' }, '条款修改记录'),
count
? React.createElement('span', {
className: 'lc-preview-clause-record-btn__badge tabular-nums',
'aria-label': count + ' 处修改',
}, count)
: null,
),
),
);
}
@@ -89,6 +228,7 @@ export default function LeaseContractPreviewPanel({
var pageIdxState = useState(0);
var viewModeState = useState('scroll');
var internalZoomState = useState(100);
var clauseRecordsState = useState([]);
var baselinePages = baselinePagesState[0];
var setBaselinePages = baselinePagesState[1];
var draftPagesRef = useRef([]);
@@ -96,6 +236,8 @@ export default function LeaseContractPreviewPanel({
var setPageIdx = pageIdxState[1];
var viewMode = viewModeState[0];
var setViewMode = viewModeState[1];
var clauseRecords = clauseRecordsState[0];
var setClauseRecords = clauseRecordsState[1];
var previewZoom = zoom != null ? zoom : internalZoomState[0];
var setPreviewZoom = onZoomChange || internalZoomState[1];
@@ -103,6 +245,7 @@ export default function LeaseContractPreviewPanel({
if (!html) {
setBaselinePages([]);
draftPagesRef.current = [];
setClauseRecords([]);
return;
}
var timer = window.setTimeout(function () {
@@ -110,14 +253,17 @@ export default function LeaseContractPreviewPanel({
setBaselinePages(nextPages);
draftPagesRef.current = nextPages.slice();
pageIdxState[1](0);
setClauseRecords([]);
notifyContentChange(nextPages);
}, 0);
return function () { window.clearTimeout(timer); };
}, [html]);
function notifyContentChange(nextPages) {
var merged = (nextPages || []).join('');
setClauseRecords(collectClauseChangeRecords(html, merged));
if (!onContentChange) return;
onContentChange((nextPages || []).join(''));
onContentChange(merged);
}
function handlePageInput(pageIndex, nextHtml) {
@@ -205,6 +351,7 @@ export default function LeaseContractPreviewPanel({
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
'data-vm-icon': 'chevron-left',
disabled: pageIdx <= 0,
onClick: function () { setPageIdx(Math.max(0, pageIdx - 1)); },
}, '上一页'),
@@ -216,6 +363,7 @@ export default function LeaseContractPreviewPanel({
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
'data-vm-icon': 'chevron-right',
disabled: pageIdx >= baselinePages.length - 1,
onClick: function () { setPageIdx(Math.min(baselinePages.length - 1, pageIdx + 1)); },
}, '下一页'),
@@ -238,6 +386,9 @@ export default function LeaseContractPreviewPanel({
}, '单页预览'),
),
),
editable
? React.createElement(PreviewClauseChangeRecordTrigger, { records: clauseRecords })
: null,
),
);

View File

@@ -0,0 +1,667 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Image } from 'antd';
import { StatusTag } from '../vehicle-management/components/StatusTag';
import {
getCustomerAttachmentPreviewUrl,
} from '../contract-template-management/contract-template-vars.js';
import {
summarizeCustomerCredentials,
} from './lease-customer-credentials.js';
import {
buildContractAttachments,
buildContractOperationLogs,
buildContractChangeRecords,
previewContractAttachment,
downloadContractAttachmentFile,
CHANGE_RECORD_TYPES,
} from './lease-contract-view-data.js';
import {
buildContractViewSummary,
buildContractViewTemplateFields,
buildContractViewSigningFields,
buildContractViewLessorProfileFields,
buildContractViewCustomerProfileFields,
buildContractViewMileageFields,
buildContractViewFeeFields,
buildContractViewLeaseOrderMetaFields,
buildContractViewVehicleCards,
buildContractViewAuditFields,
buildContractViewSealTypes,
getViewSectionNavItems,
resolveViewFormContext,
} from './lease-contract-view-sections.js';
import { getAuthorizedDelegates } from './lease-contract-list-data.js';
var SEAL_TYPE_OPTIONS = [
{ value: 'contract', label: '合同章' },
{ value: 'official', label: '公章' },
{ value: 'legal_person', label: '法人章' },
];
function formatChangeTypeLabel(type) {
var found = CHANGE_RECORD_TYPES.find(function (item) { return item.key === type; });
return found ? found.label : type || '-';
}
function contractStatusTone(status) {
if (status === '合同进行中') return 'green';
if (status === '已提交审批') return 'blue';
if (status === '已终止') return 'red';
return 'gray';
}
function approvalStatusTone(status) {
if (status === '审批通过') return 'green';
if (status === '审批中' || status === '待审批') return 'amber';
if (status === '审批驳回' || status === '审批终止') return 'red';
return 'gray';
}
var VIEW_TAB_SCROLL_OFFSET = 12;
function formatViewDisplayValue(value) {
return value != null && value !== '' && value !== '-' ? value : null;
}
function getParamSpanClass(props) {
if (props.wide || props.span === 3) return ' vm-model-param-item--wide';
if (props.span === 2) return ' vm-model-param-item--span-2';
return '';
}
function ViewParamField(props) {
var display = formatViewDisplayValue(props.value);
var empty = !display && props.children == null;
var valueNode = props.children != null
? props.children
: (display || '—');
return React.createElement('div', {
className: 'vm-model-param-item' + getParamSpanClass(props) + (props.className ? ' ' + props.className : ''),
},
React.createElement('span', { className: 'vm-model-param-label' }, props.label),
React.createElement('div', {
className: 'vm-model-param-value'
+ (empty ? ' is-empty' : '')
+ (props.numeric ? ' tabular-nums' : ''),
}, valueNode),
);
}
function ViewParamSection(props) {
return React.createElement('section', {
className: 'vm-model-param-section',
'data-annotation-id': props.annotationId,
},
React.createElement('h3', { className: 'vm-model-param-section-title' }, props.title),
props.children,
);
}
function renderFieldsGrid(fields, cols) {
var gridClass = 'vm-model-param-grid';
if (cols === 2) gridClass += ' vm-model-param-grid--2';
if (cols === 1) gridClass += ' vm-model-param-grid--1';
return React.createElement('div', { className: gridClass },
(fields || []).map(function (field) {
return React.createElement(ViewParamField, {
key: field.label,
label: field.label,
value: field.value,
numeric: field.mono,
wide: field.wide,
span: field.span,
});
}),
);
}
function ViewPartyFields(props) {
var fields = props.fields || [];
var contactName = (fields.find(function (f) { return f.label === '联系人姓名'; }) || {}).value;
var contactPhone = (fields.find(function (f) { return f.label === '联系人电话'; }) || {}).value;
function resolveSpan(field) {
if (field.span != null) return field.span;
if (field.label === '邮箱') return 1;
if (field.wide) return 3;
return 1;
}
var contactDisplay = [contactName, contactPhone]
.map(formatViewDisplayValue)
.filter(Boolean)
.join(' / ') || null;
return React.createElement('div', { className: 'vm-model-param-grid' },
fields.map(function (field) {
if (field.label === '联系人姓名' || field.label === '联系人电话') return null;
return React.createElement(ViewParamField, {
key: props.prefix + '-' + field.label,
label: field.label,
value: field.value,
numeric: field.mono,
span: resolveSpan(field),
});
}),
React.createElement(ViewParamField, {
key: props.prefix + '-contact-pair',
label: '联系人姓名及电话',
value: contactDisplay,
numeric: true,
span: 2,
}),
);
}
function ViewDetailStat(props) {
return React.createElement('div', { className: 'vm-detail-stat' },
React.createElement('div', { className: 'vm-detail-stat-main' },
React.createElement('span', { className: 'vm-detail-stat-label' }, props.label),
React.createElement('span', {
className: 'vm-detail-stat-value' + (props.numeric ? ' tabular-nums' : ''),
}, props.value),
),
);
}
function ViewCredentialThumb(props) {
var label = props.label;
var previewUrl = props.previewUrl;
var meta = props.meta || {};
var status = meta.status || 'unknown';
var thumbClass = 'lc-attach-thumb'
+ (status === 'expired' ? ' is-expired' : '')
+ (status === 'expiring' ? ' is-expiring' : '');
return React.createElement('div', {
className: thumbClass,
'aria-label': label,
},
React.createElement(Image, {
className: 'lc-attach-thumb__img',
src: previewUrl,
alt: label,
preview: { src: previewUrl },
fallback: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240"><rect width="100%" height="100%" fill="#f1f5f9"/></svg>'),
}),
React.createElement('div', { className: 'lc-attach-thumb__meta' },
React.createElement('span', { className: 'lc-attach-thumb__label' }, label),
meta.ocrVerified
? React.createElement('span', { className: 'lc-attach-thumb__ocr', title: '已通过 OCR 识别证照信息' }, 'OCR 已识别')
: React.createElement('span', { className: 'lc-attach-thumb__ocr is-pending' }, '待 OCR'),
meta.expiryDate
? React.createElement('span', { className: 'lc-attach-thumb__expiry' }, '有效期至 ' + meta.expiryDate)
: null,
React.createElement('span', {
className: 'lc-attach-thumb__status lc-attach-thumb__status--' + status,
role: 'status',
}, meta.statusLabel || '待识别'),
),
);
}
function ViewCredentialsBlock(props) {
var customer = props.customer;
if (!customer) return null;
var credentialSummary = summarizeCustomerCredentials(customer);
var embedded = props.embedded;
return React.createElement('div', {
className: 'lc-credentials-block lc-view-credentials-block'
+ (embedded ? ' lc-view-credentials-block--embedded' : ''),
'data-annotation-id': 'lc-view-credentials-ocr',
},
React.createElement('div', { className: 'lc-credentials-block__head' },
React.createElement('h4', { className: 'lc-credentials-block__title' }, '客户资质证照'),
),
React.createElement('div', {
className: 'lc-attach-gallery',
'aria-label': '客户资质证照预览',
},
credentialSummary.items.map(function (item) {
var previewUrl = getCustomerAttachmentPreviewUrl(customer, item.key, item.label);
return React.createElement(ViewCredentialThumb, {
key: item.key,
label: item.label,
previewUrl: previewUrl,
meta: item,
});
}),
),
);
}
function ViewSealChips(props) {
var selected = props.sealTypes || ['contract'];
var activeOptions = SEAL_TYPE_OPTIONS.filter(function (option) {
return selected.indexOf(option.value) >= 0;
});
if (!activeOptions.length) {
return React.createElement('p', { className: 'lc-view-section__empty' }, '未选择用章类型');
}
return React.createElement('div', {
className: 'lc-seal-type-chips lc-view-seal-type-chips',
role: 'group',
'aria-label': '用章类型',
'data-annotation-id': 'lc-view-seal-type',
},
activeOptions.map(function (option) {
return React.createElement('span', {
key: option.value,
className: 'lc-seal-type-chip lc-view-seal-type-chip is-active',
}, option.label);
}),
);
}
function ViewAttachmentCapsules(props) {
var attachments = props.attachments || [];
var record = props.record;
var onPreview = props.onPreview;
var onDownload = props.onDownload;
var Button = props.Button;
if (!attachments.length) {
return React.createElement('p', { className: 'lc-view-section__empty' }, '暂无合同附件');
}
return React.createElement('div', {
className: 'lc-view-attachment-capsules',
'aria-label': '合同附件列表',
},
attachments.map(function (file) {
var fileName = file.name || '未命名附件';
return React.createElement('div', {
key: file.uid || fileName,
className: 'lc-view-attachment-capsule',
},
React.createElement('span', {
className: 'lc-view-attachment-capsule__name',
title: fileName,
}, fileName),
React.createElement('div', { className: 'lc-view-attachment-capsule__actions' },
React.createElement(Button, {
type: 'link',
size: 'small',
onClick: function () { onPreview(file, record); },
}, '预览'),
React.createElement(Button, {
type: 'link',
size: 'small',
onClick: function () { onDownload(file, record); },
}, '下载'),
),
);
}),
);
}
function ViewRecordTimeline(props) {
var items = props.items || [];
if (!items.length) {
return React.createElement('p', { className: 'lc-view-section__empty' }, props.emptyText || '暂无记录');
}
return React.createElement('ol', { className: 'lc-view-timeline' },
items.map(function (item) {
return React.createElement('li', { key: item.id, className: 'lc-view-timeline__item' },
React.createElement('div', { className: 'lc-view-timeline__dot', 'aria-hidden': true }),
React.createElement('div', { className: 'lc-view-timeline__content' },
React.createElement('div', { className: 'lc-view-timeline__head' },
React.createElement('span', { className: 'lc-view-timeline__title' }, item.title),
item.badge
? React.createElement('span', { className: 'lc-view-timeline__badge' }, item.badge)
: null,
),
item.summary
? React.createElement('p', { className: 'lc-view-timeline__summary' }, item.summary)
: null,
React.createElement('div', { className: 'lc-view-timeline__meta' },
React.createElement('span', null, item.operatorName || '-'),
React.createElement('span', { className: 'tabular-nums' }, item.operateTime || '-'),
),
),
);
}),
);
}
export default function LeaseContractView({ record, onBack, stampedFilesOverride }) {
var antd = window.antd;
var Table = antd.Table;
var Button = antd.Button;
var message = antd.message;
var activeSectionState = useState('main');
var activeSection = activeSectionState[0];
var setActiveSection = activeSectionState[1];
var scrollRef = useRef(null);
var sectionRefs = useRef({});
var formContext = useMemo(function () { return resolveViewFormContext(record); }, [record]);
var attachments = useMemo(function () {
if (!record) return [];
if (stampedFilesOverride && stampedFilesOverride.length) {
return stampedFilesOverride.map(function (file) {
return { uid: file.uid, name: file.name, type: file.type || '' };
});
}
return buildContractAttachments(record);
}, [record, stampedFilesOverride]);
var operationLogs = useMemo(function () { return buildContractOperationLogs(record); }, [record]);
var changeRecords = useMemo(function () { return buildContractChangeRecords(record); }, [record]);
var summary = useMemo(function () { return buildContractViewSummary(record); }, [record]);
var navItems = useMemo(function () { return getViewSectionNavItems(); }, []);
var templateFields = useMemo(function () { return buildContractViewTemplateFields(record); }, [record]);
var signingFields = useMemo(function () { return buildContractViewSigningFields(record, formContext); }, [record, formContext]);
var lessorFields = useMemo(function () { return buildContractViewLessorProfileFields(formContext); }, [formContext]);
var customerFields = useMemo(function () { return buildContractViewCustomerProfileFields(formContext); }, [formContext]);
var mileageFields = useMemo(function () { return buildContractViewMileageFields(record, formContext); }, [record, formContext]);
var feeFields = useMemo(function () { return buildContractViewFeeFields(record, formContext); }, [record, formContext]);
var leaseOrderMetaFields = useMemo(function () { return buildContractViewLeaseOrderMetaFields(record); }, [record]);
var vehicleCards = useMemo(function () { return buildContractViewVehicleCards(record); }, [record]);
var auditFields = useMemo(function () { return buildContractViewAuditFields(record); }, [record]);
var sealTypes = useMemo(function () { return buildContractViewSealTypes(record, formContext); }, [record, formContext]);
var delegates = useMemo(function () { return getAuthorizedDelegates(record); }, [record]);
var contractRemark = useMemo(function () {
if (!record) return '';
return record.remark && record.remark !== '-' ? record.remark : '';
}, [record]);
var changeTimelineItems = useMemo(function () {
return changeRecords.map(function (item) {
return {
id: item.id,
title: item.typeLabel || formatChangeTypeLabel(item.type),
summary: item.summary,
operatorName: item.operatorName,
operateTime: item.operateTime,
badge: item.approvalStatus,
};
});
}, [changeRecords]);
var scrollToSection = useCallback(function (sectionKey) {
setActiveSection(sectionKey);
var target = sectionRefs.current[sectionKey];
var container = scrollRef.current;
if (!target || !container) return;
var containerTop = container.getBoundingClientRect().top;
var targetTop = target.getBoundingClientRect().top;
container.scrollTo({
top: container.scrollTop + (targetTop - containerTop) - VIEW_TAB_SCROLL_OFFSET,
behavior: 'smooth',
});
}, []);
useEffect(function () {
var container = scrollRef.current;
if (!container) return undefined;
function onScroll() {
var keys = navItems.map(function (item) { return item.key; });
var current = keys[0];
keys.forEach(function (key) {
var el = sectionRefs.current[key];
if (!el) return;
var top = el.getBoundingClientRect().top - container.getBoundingClientRect().top;
if (top <= VIEW_TAB_SCROLL_OFFSET + 4) current = key;
});
setActiveSection(current);
}
container.addEventListener('scroll', onScroll, { passive: true });
return function () { container.removeEventListener('scroll', onScroll); };
}, [navItems]);
function bindSectionRef(key) {
return function (node) {
sectionRefs.current[key] = node;
};
}
function renderAnchorSection(key, annotationId, children) {
return React.createElement('section', {
key: key,
id: 'lc-view-section-' + key,
className: 'lc-view-detail-anchor',
ref: bindSectionRef(key),
'data-annotation-id': annotationId,
}, children);
}
function renderScrollSections() {
return React.createElement(React.Fragment, null,
renderAnchorSection('template', 'lc-view-template',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '模板与签署', annotationId: 'lc-view-template-fields' },
renderFieldsGrid(templateFields, 2),
),
),
),
renderAnchorSection('main', 'lc-view-main-contract',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '签约信息', annotationId: 'lc-view-card-signing' },
renderFieldsGrid(signingFields, 3),
),
lessorFields.length
? React.createElement(ViewParamSection, { title: '甲方信息', annotationId: 'lc-view-lessor-account' },
React.createElement(ViewPartyFields, { fields: lessorFields, prefix: 'lessor' }),
)
: null,
customerFields.length
? React.createElement(ViewParamSection, { title: '乙方信息', annotationId: 'lc-view-invoice-info' },
React.createElement(ViewPartyFields, { fields: customerFields, prefix: 'customer' }),
formContext && formContext.customer
? React.createElement(ViewCredentialsBlock, { customer: formContext.customer, embedded: true })
: null,
)
: null,
React.createElement(ViewParamSection, { title: '里程标准', annotationId: 'lc-view-card-mileage' },
renderFieldsGrid(mileageFields, 3),
),
React.createElement(ViewParamSection, { title: '费用信息', annotationId: 'lc-view-card-fee' },
renderFieldsGrid(feeFields, 3),
),
),
),
renderAnchorSection('leaseOrder', 'lc-view-lease-order',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '订单概要', annotationId: 'lc-view-lease-order-summary' },
renderFieldsGrid(leaseOrderMetaFields, 3),
),
vehicleCards.length
? vehicleCards.map(function (card, index) {
return React.createElement(ViewParamSection, {
key: card.id,
title: '车辆 ' + String(index + 1).padStart(2, '0') + ' · ' + card.title,
annotationId: 'lc-view-vehicle-' + card.id,
},
React.createElement('div', { className: 'lc-view-vehicle-card__status' },
React.createElement(StatusTag, { label: card.status.label, tone: card.status.tone }),
),
renderFieldsGrid(card.fields, 3),
);
})
: React.createElement('p', { className: 'lc-view-section__empty' }, '暂无车辆订单'),
),
),
renderAnchorSection('poa', 'lc-view-poa',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '授权委托', annotationId: 'lc-view-poa-table' },
delegates.length
? React.createElement(Table, {
rowKey: function (_, index) { return 'poa-' + index; },
size: 'small',
className: 'lc-view-table',
columns: [
{ title: '受托人', dataIndex: 'name', key: 'name', width: 100 },
{ title: '联系方式', dataIndex: 'contact', key: 'contact', width: 130, className: 'tabular-nums', render: function (v, row) { return v || row.phone || '-'; } },
{ title: '身份证号', key: 'idNumber', width: 180, className: 'tabular-nums', render: function (_, row) { return row.idNumber || row.idCard || '-'; } },
],
dataSource: delegates,
pagination: false,
})
: React.createElement('p', { className: 'lc-view-section__empty' }, '未添加受托人'),
record.poaRemark
? React.createElement('p', { className: 'lc-view-remark-text lc-view-poa-remark' }, record.poaRemark)
: null,
),
),
),
renderAnchorSection('remark', 'lc-view-remark',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '合同备注', annotationId: 'lc-view-remark-body' },
contractRemark
? React.createElement('p', { className: 'lc-view-remark-text' }, contractRemark)
: React.createElement('p', { className: 'lc-view-section__empty' }, '未填写备注'),
),
),
),
renderAnchorSection('seal', 'lc-view-seal',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '用章类型', annotationId: 'lc-view-seal-chips' },
React.createElement(ViewSealChips, { sealTypes: sealTypes }),
),
),
),
renderAnchorSection('attachments', 'lc-view-attachments',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '合同附件', annotationId: 'lc-view-attachment-list' },
React.createElement(ViewAttachmentCapsules, {
attachments: attachments,
record: record,
Button: Button,
onPreview: function (file, contractRecord) {
var ok = previewContractAttachment(file, contractRecord);
if (!ok) message.info('预览:' + (file.name || '附件') + '(原型)');
},
onDownload: function (file, contractRecord) {
var ok = downloadContractAttachmentFile(file, contractRecord);
if (!ok) message.success('已开始下载:' + (file.name || '附件') + '(原型)');
},
}),
),
),
),
renderAnchorSection('audit', 'lc-view-audit',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '建档信息', annotationId: 'lc-view-audit-fields' },
renderFieldsGrid(auditFields, 3),
),
),
),
renderAnchorSection('operations', 'lc-view-operation-logs',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '操作记录', annotationId: 'lc-view-operation-table' },
React.createElement(Table, {
rowKey: 'id',
size: 'small',
className: 'lc-view-table lc-view-table--compact',
columns: [
{ title: '操作', dataIndex: 'action', key: 'action', width: 96 },
{ title: '操作人', dataIndex: 'operatorName', key: 'operatorName', width: 88 },
{ title: '操作时间', dataIndex: 'operateTime', key: 'operateTime', width: 132, className: 'tabular-nums' },
{ title: '修改人', dataIndex: 'modifierName', key: 'modifierName', width: 88 },
{ title: '修改时间', dataIndex: 'modifyTime', key: 'modifyTime', width: 132, className: 'tabular-nums' },
],
dataSource: operationLogs,
pagination: false,
locale: { emptyText: '暂无操作记录' },
scroll: { x: 580 },
}),
),
),
),
renderAnchorSection('changes', 'lc-view-change-records',
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
React.createElement(ViewParamSection, { title: '变更记录', annotationId: 'lc-view-change-body' },
changeTimelineItems.length <= 6
? React.createElement(ViewRecordTimeline, {
items: changeTimelineItems,
emptyText: '暂无变更记录',
})
: React.createElement(Table, {
rowKey: 'id',
size: 'small',
className: 'lc-view-table',
columns: [
{ title: '变更类型', key: 'typeLabel', width: 120, render: function (_, row) { return row.typeLabel || formatChangeTypeLabel(row.type); } },
{ title: '摘要', dataIndex: 'summary', key: 'summary', ellipsis: true },
{ title: '操作人', dataIndex: 'operatorName', key: 'operatorName', width: 88 },
{ title: '操作时间', dataIndex: 'operateTime', key: 'operateTime', width: 132, className: 'tabular-nums' },
{ title: '审批状态', dataIndex: 'approvalStatus', key: 'approvalStatus', width: 96 },
],
dataSource: changeRecords,
pagination: false,
scroll: { x: 640 },
}),
),
),
),
);
}
if (!record || !summary) {
return React.createElement('div', { className: 'vm-page lc-page lc-view-page vm-detail' },
React.createElement('div', { className: 'vm-detail-topbar' },
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-back', onClick: onBack }, '返回列表'),
),
React.createElement('p', { className: 'lc-view-page__empty' }, '未找到合同记录'),
);
}
return React.createElement('div', { className: 'vm-page lc-page lc-view-page vm-detail lc-view-detail-page' },
React.createElement('div', { className: 'vm-detail-topbar lc-view-detail-topbar', 'data-annotation-id': 'lc-view-topbar' },
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-back', onClick: onBack }, '返回列表'),
),
React.createElement('div', { className: 'lc-view-detail-shell' },
React.createElement('section', { className: 'vm-detail-card lc-view-detail-header-card' },
React.createElement('div', { className: 'vm-detail-hero', 'data-annotation-id': 'lc-view-hero' },
React.createElement('div', null,
React.createElement('div', { className: 'vm-detail-plate lc-view-hero__title' }, summary.projectName),
React.createElement('p', { className: 'vm-detail-meta' }, summary.customerName),
React.createElement('p', { className: 'vm-detail-meta mono tabular-nums' }, summary.contractCode),
),
React.createElement('div', { className: 'vm-detail-aside lc-view-hero__aside' },
React.createElement('div', { className: 'lc-view-hero-tags' },
React.createElement(StatusTag, { label: summary.displayStatus, tone: contractStatusTone(summary.displayStatus) }),
summary.showApprovalBadge
? React.createElement(StatusTag, { label: summary.approvalStatus, tone: approvalStatusTone(summary.approvalStatus) })
: null,
),
React.createElement('p', { className: 'vm-detail-meta' }, summary.contractType + ' · ' + summary.contractApprovalType),
React.createElement('p', { className: 'vm-detail-gps-time' }, '签署状态:' + summary.signingSubLabel),
),
),
React.createElement('div', { className: 'vm-detail-stats', 'data-annotation-id': 'lc-view-stats' },
React.createElement(ViewDetailStat, { label: '签署方式', value: summary.signingMethodLabel }),
React.createElement(ViewDetailStat, { label: '业务部门', value: record.businessDept || '—' }),
React.createElement(ViewDetailStat, { label: '业务负责人', value: summary.businessOwner }),
React.createElement(ViewDetailStat, {
label: '租赁车辆',
value: String(summary.vehicleCount || 0) + ' 辆',
numeric: true,
}),
),
React.createElement('div', {
className: 'vm-tabs lc-view-detail-tabs',
role: 'tablist',
'aria-label': '合同详情章节',
'data-annotation-id': 'lc-view-section-nav',
},
navItems.map(function (item) {
var isActive = activeSection === item.key;
return React.createElement('button', {
key: item.key,
type: 'button',
role: 'tab',
className: 'vm-tab' + (isActive ? ' active' : ''),
'aria-selected': isActive ? 'true' : 'false',
onClick: function () { scrollToSection(item.key); },
}, item.label);
}),
),
),
React.createElement('div', {
className: 'lc-view-detail-body',
ref: scrollRef,
'aria-label': '合同详情内容',
},
renderScrollSections(),
),
),
);
}

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,17 @@
/**
* @name 租赁合同管理
*/
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import * as antd from 'antd';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import 'antd/dist/reset.css';
import '../vehicle-management/style.css';
import './styles/index.css';
@@ -83,17 +82,6 @@ export default function LeaseContractManagementPage() {
const [annotationPageId, setAnnotationPageId] = useState<LeaseContractPageId>('list');
const navigationRef = useRef<LeaseContractNavigationRef>({});
const handleDirectoryRoute = useCallback((node: AnnotationDirectoryRouteNode) => {
const payload = node.payload as { pageId?: string } | undefined;
const pageId: LeaseContractPageId = payload?.pageId === 'create' ? 'create' : 'list';
if (pageId === 'create') {
navigationRef.current.openCreate?.();
} else {
navigationRef.current.backToList?.();
}
setAnnotationPageId(pageId);
}, []);
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
@@ -102,9 +90,8 @@ export default function LeaseContractManagementPage() {
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: annotationPageId,
onDirectoryRoute: handleDirectoryRoute,
}),
[annotationPageId, handleDirectoryRoute],
[annotationPageId],
);
return (
@@ -113,7 +100,7 @@ export default function LeaseContractManagementPage() {
navigationRef={navigationRef}
onPageViewChange={setAnnotationPageId}
/>
<AnnotationViewer
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>

View File

@@ -1,16 +1,47 @@
import { hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js';
import {
resolveContractApprovalType,
isRiskRedlineContentModified,
isPreviewNewClauseAdded,
STANDARD_CONTRACT_APPROVAL,
NONSTANDARD_CONTRACT_APPROVAL,
} from './lease-contract-risk-detect.js';
export { STANDARD_CONTRACT_APPROVAL, NONSTANDARD_CONTRACT_APPROVAL };
function isPostpayPaymentMethod(feeInfo) {
return feeInfo && feeInfo.paymentMethod === 'postpay';
}
function isNonMonthlyPaymentPeriod(feeInfo) {
var period = feeInfo && feeInfo.paymentPeriod;
return period === 3 || period === 6 || period === 12;
}
function hasExtraOfficialSeal(sealTypes) {
var types = sealTypes || [];
return types.indexOf('official') >= 0 || types.indexOf('legal_person') >= 0;
}
/** 综合风控红线修改与租金低于下限等规则,判定审批类型 */
export function resolveLeaseContractApprovalType(baselineHtml, currentHtml, form) {
if (form && hasLeaseOrderRentBelowMinimum(form.leaseOrder)) {
return NONSTANDARD_CONTRACT_APPROVAL;
}
if (form && isPostpayPaymentMethod(form.feeInfo)) {
return NONSTANDARD_CONTRACT_APPROVAL;
}
if (form && isNonMonthlyPaymentPeriod(form.feeInfo)) {
return NONSTANDARD_CONTRACT_APPROVAL;
}
if (form && hasExtraOfficialSeal(form.sealTypes)) {
return NONSTANDARD_CONTRACT_APPROVAL;
}
if (isPreviewNewClauseAdded(baselineHtml, currentHtml)) {
return NONSTANDARD_CONTRACT_APPROVAL;
}
if (isRiskRedlineContentModified(baselineHtml, currentHtml)) {
return NONSTANDARD_CONTRACT_APPROVAL;
}
return resolveContractApprovalType(baselineHtml, currentHtml);
}

View File

@@ -8,6 +8,8 @@ import {
createEmptyLeaseOrderRow,
createEmptyDelegateRow,
PLATE_ACTUAL_DELIVERY,
normalizeLeaseOrderState,
syncRowPricingFields,
} from './lease-order-vars.js';
import {
inferContractTemplateId,
@@ -32,13 +34,15 @@ function mapVehicleToLeaseOrderRow(vehicle, index) {
if (vehicle.brand && vehicle.model) {
row.brandModels = [[vehicle.brand, vehicle.model]];
}
row.vehicleQty = 1;
var plate = vehicle.plateNo;
row.plateNos = plate && plate !== '-' ? [plate] : [PLATE_ACTUAL_DELIVERY];
row.rent = vehicle.rent != null ? vehicle.rent : 8800;
row.serviceFee = vehicle.serviceFee != null ? vehicle.serviceFee : 1200;
row.deposit = vehicle.deposit != null ? vehicle.deposit : 20000;
row.leasePeriodMode = 'months';
row.leasePeriodMonths = vehicle.leasePeriodMonths != null ? vehicle.leasePeriodMonths : 12;
return row;
return syncRowPricingFields(row);
}
function mapAuthorizedDelegatesToPoaRows(delegates) {
@@ -62,18 +66,24 @@ function buildLeaseOrderFromRecord(record) {
? vehicles.map(mapVehicleToLeaseOrderRow)
: defaultOrder.rows.slice();
var deliveryDate = record.deliveryDate;
if (deliveryDate && isDeliveryDateTbd(deliveryDate)) {
var deliveryDateTbd = Boolean(record.deliveryDateTbd || isDeliveryDateTbd(deliveryDate));
if (deliveryDateTbd) {
deliveryDate = null;
}
return {
insuredVehicleCount: record.insuredVehicleCount != null ? record.insuredVehicleCount : rows.length || 1,
return normalizeLeaseOrderState({
thirdPartyLiabilityMillion: record.thirdPartyLiabilityMillion != null
? record.thirdPartyLiabilityMillion
: defaultOrder.thirdPartyLiabilityMillion,
deliveryRegion: (record.deliveryRegion || defaultOrder.deliveryRegion).slice(),
deliveryRegionMode: record.deliveryRegionTbd ? 'tbd' : 'region',
deliveryRegionTbd: Boolean(record.deliveryRegionTbd),
deliveryDate: deliveryDate || null,
deliveryDateStart: record.deliveryDateStart || deliveryDate || null,
deliveryDateEnd: record.deliveryDateEnd || null,
deliveryDateMode: deliveryDateTbd ? 'unconfirmed' : 'range',
deliveryDateTbd: deliveryDateTbd,
rows: rows,
};
});
}
function buildMileageFromRecord(record) {
@@ -97,6 +107,7 @@ function buildFeeInfoFromRecord(record) {
}
var isPrepay = hydrogenPaymentMethod === 'prepay';
return Object.assign({}, DEFAULT_FEE_INFO, {
paymentMethod: record.paymentMethod || DEFAULT_FEE_INFO.paymentMethod,
paymentPeriod: record.paymentPeriod != null ? record.paymentPeriod : DEFAULT_FEE_INFO.paymentPeriod,
hydrogenPaymentMethod: hydrogenPaymentMethod,
prepayAmount: record.prepayAmount != null ? record.prepayAmount : (isPrepay ? 50000 : null),
@@ -120,6 +131,7 @@ export function buildLeaseContractEditFormState(record) {
lessorId: lessorId,
customerId: customerId,
contractCode: extractContractCodeInput(record.contractCode),
projectName: record.projectName || '',
businessDept: record.businessDept || '',
businessOwner: record.businessOwner || '',
mileage: buildMileageFromRecord(record),
@@ -133,6 +145,9 @@ export function buildLeaseContractEditFormState(record) {
contractRemark: record.poaRemark || '',
customerPrincipalName: record.customerPrincipalName || '',
customerPrincipalPhone: record.customerPrincipalPhone || '',
thirdPartyCustomerId: record.thirdPartyCustomerId || '',
thirdPartyPrincipalName: record.thirdPartyPrincipalName || '',
thirdPartyPrincipalPhone: record.thirdPartyPrincipalPhone || '',
sealTypes: Array.isArray(record.sealTypes) && record.sealTypes.length
? record.sealTypes.slice()
: ['contract'],

View File

@@ -0,0 +1,185 @@
/**
* 列表合同 → 续签/转正式/增车/转三方 等流程表单初始态
*/
import { generateAutoContractCodeSuffix } from '../contract-template-management/contract-template-vars.js';
import { getDefaultContractTemplateId } from '../contract-template-management/contract-template-catalog.js';
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
import {
createDefaultLeaseOrderState,
createEmptyLeaseOrderRow,
createEmptyDelegateRow,
normalizeLeaseOrderState,
syncRowPricingFields,
} from './lease-order-vars.js';
import { canContractVehicleReturn, isContractApprovalPassed } from './lease-contract-list-data.js';
export var FLOW_MODE_CREATE = 'create';
export var FLOW_MODE_EDIT = 'edit';
export var FLOW_MODE_RENEW = 'renew';
export var FLOW_MODE_TRIAL_TO_FORMAL = 'trialToFormal';
export var FLOW_MODE_ADD_VEHICLE = 'addVehicle';
export var FLOW_MODE_ADD_POA = 'addPoa';
export var FLOW_MODE_TRIPARTITE = 'tripartite';
export var FLOW_MODE_LABELS = {
create: '新增租赁合同',
edit: '编辑租赁合同',
renew: '续签合同',
trialToFormal: '试用转正式',
addVehicle: '新增车辆',
addPoa: '添加授权委托书',
tripartite: '转三方合同',
};
export function getDeliveredNotReturnedVehicles(record) {
if (!record || !record.vehicles || !isContractApprovalPassed(record)) return [];
return record.vehicles.filter(function (vehicle) {
return canContractVehicleReturn(vehicle, record);
});
}
function mapLockedVehicleToRow(vehicle, index) {
var row = createEmptyLeaseOrderRow();
row.id = 'lo-flow-locked-' + index;
row.brandModels = vehicle.brand && vehicle.model ? [[vehicle.brand, vehicle.model]] : [];
row.vehicleQty = 1;
row.plateNos = vehicle.plateNo && vehicle.plateNo !== '-' ? [vehicle.plateNo] : [];
row.rent = vehicle.rent != null ? vehicle.rent : 8800;
row.serviceFee = vehicle.serviceFee != null ? vehicle.serviceFee : 1200;
row.deposit = vehicle.deposit != null ? vehicle.deposit : 20000;
row.leasePeriodMode = 'months';
row.leasePeriodMonths = vehicle.leasePeriodMonths != null ? vehicle.leasePeriodMonths : 12;
row._flowLocked = true;
row._flowLockedFields = ['brandModels', 'plateNos', 'vehicleQty'];
row._deliveredAt = vehicle.actualDelivery || null;
return syncRowPricingFields(row);
}
function buildRowsFromDeliveredVehicles(record) {
var region = (record.deliveryRegion || []).slice();
var vehicles = getDeliveredNotReturnedVehicles(record);
return vehicles.map(function (vehicle, index) {
var row = mapLockedVehicleToRow(vehicle, index);
row._deliveryRegion = region;
row._deliveredAt = vehicle.actualDelivery || null;
return row;
});
}
function cloneFormState(base) {
if (!base) return null;
return JSON.parse(JSON.stringify(base));
}
function withNewContractCode(state) {
if (!state) return state;
state.contractCode = generateAutoContractCodeSuffix();
return state;
}
function withLeaseOrderRows(state, rows, extra) {
if (!state) return state;
state.leaseOrder = normalizeLeaseOrderState(Object.assign({}, state.leaseOrder, extra || {}, {
rows: rows.length ? rows : [createEmptyLeaseOrderRow()],
}));
return state;
}
/** 续签:复制原合同,锁定已交未还车辆,可新增车辆卡片 */
export function buildRenewContractFormState(record) {
var base = cloneFormState(buildLeaseContractEditFormState(record));
if (!base) return null;
withNewContractCode(base);
var lockedRows = buildRowsFromDeliveredVehicles(record);
withLeaseOrderRows(base, lockedRows, {
deliveryRegion: (record.deliveryRegion || []).slice(),
deliveryRegionMode: record.deliveryRegionTbd ? 'tbd' : 'region',
deliveryDateMode: record.deliveryDateMode || 'range',
deliveryDateTbd: Boolean(record.deliveryDateTbd),
});
base._flowMode = FLOW_MODE_RENEW;
base._sourceContractCode = record.contractCode;
base._pickupReceivableHint = '已交未还车辆不生成提车应收款;新增车辆按新增合同规则生成提车应收款。';
return base;
}
/** 转正式:锁定已交未还车辆品牌型号车牌,租金服务项可改 */
export function buildTrialToFormalFormState(record) {
var base = cloneFormState(buildLeaseContractEditFormState(record));
if (!base) return null;
withNewContractCode(base);
base.contractType = 'formal';
var lockedRows = buildRowsFromDeliveredVehicles(record);
lockedRows.forEach(function (row) {
row._flowLockedFields = ['brandModels', 'plateNos', 'vehicleQty'];
});
withLeaseOrderRows(base, lockedRows);
base._flowMode = FLOW_MODE_TRIAL_TO_FORMAL;
base._sourceContractCode = record.contractCode;
base._pickupReceivableHint = '车辆金额变更将重新记录提车应收款;已交未还车辆完成后不再生成交车任务。';
return base;
}
/** 新增车辆仅新附件1 */
export function buildAddVehicleFormState(record) {
var base = buildLeaseContractEditFormState(record);
if (!base) return null;
base = cloneFormState(base);
withNewContractCode(base);
withLeaseOrderRows(base, [createEmptyLeaseOrderRow()]);
base._flowMode = FLOW_MODE_ADD_VEHICLE;
base._sourceContractCode = record.contractCode;
base._pickupReceivableHint = '审批通过后,新增车辆按新增合同规则生成提车应收款任务。';
base._signingHint = '无需重新签署主合同仅对新增附件1租赁订单发起审批。';
return base;
}
/** 添加授权委托书:仅维护受托人,预览仅授权委托书 */
export function buildAddPowerOfAttorneyFormState(record) {
var base = buildLeaseContractEditFormState(record);
if (!base) return null;
base = cloneFormState(base);
withNewContractCode(base);
base.powerOfAttorney = {
delegates: [createEmptyDelegateRow()],
};
base._flowMode = FLOW_MODE_ADD_POA;
base._sourceContractCode = record.contractCode;
base._signingHint = '无需重新签署主合同,仅对新增授权委托书发起审批。';
return base;
}
/** 转三方:模板固定转三方协议,可增车 */
export function buildTripartiteContractFormState(record) {
var base = cloneFormState(buildLeaseContractEditFormState(record));
if (!base) return null;
withNewContractCode(base);
var tripartiteTemplateId = getDefaultContractTemplateId('转三方协议');
base.contractTemplateId = tripartiteTemplateId
|| base.contractTemplateId
|| getDefaultContractTemplateId('合同')
|| '';
base._flowMode = FLOW_MODE_TRIPARTITE;
base._sourceContractCode = record.contractCode;
base._requiresTripartiteParty = true;
base._signingHint = '审批通过后沿用原合同签署方式;线上发送 E签宝链接线下需完成盖章合同补传。';
base.thirdPartyCustomerId = '';
base.thirdPartyPrincipalName = '';
base.thirdPartyPrincipalPhone = '';
var rows = base.leaseOrder && base.leaseOrder.rows ? base.leaseOrder.rows.slice() : [createEmptyLeaseOrderRow()];
withLeaseOrderRows(base, rows);
return base;
}
export function getFlowPageTitle(flowMode) {
return FLOW_MODE_LABELS[flowMode] || FLOW_MODE_LABELS.create;
}
export function countNewPickupVehicles(leaseOrder, lockedRowPrefix) {
if (!leaseOrder || !leaseOrder.rows) return 0;
var prefix = lockedRowPrefix || 'lo-flow-locked-';
return leaseOrder.rows.filter(function (row) {
return !row._flowLocked && String(row.id || '').indexOf(prefix) !== 0;
}).length;
}

View File

@@ -17,15 +17,16 @@ function isMainContractFieldsComplete(form) {
if (!mileage.period) return false;
if (mileage.targetKm == null || mileage.targetKm === '') return false;
if (mileage.reductionYuan == null || mileage.reductionYuan === '') return false;
if (!mileage.validUntil) return false;
}
var feeInfo = form.feeInfo || {};
if (!feeInfo.paymentMethod) return false;
if (feeInfo.paymentPeriod == null || feeInfo.paymentPeriod === '') return false;
if (!feeInfo.hydrogenPaymentMethod) return false;
if (feeInfo.hydrogenPaymentMethod === 'prepay') {
if (feeInfo.prepayAmount == null || feeInfo.prepayAmount === '') return false;
if (feeInfo.payAheadWorkdays == null || feeInfo.payAheadWorkdays === '') return false;
}
if (feeInfo.returnHydrogenDiffUnitPrice == null || feeInfo.returnHydrogenDiffUnitPrice === '') {
@@ -78,6 +79,14 @@ export function isSealTypeSelected(form) {
return Boolean(form.sealType);
}
/** 转三方:丙方客户已选且不与乙方重复 */
export function isTripartitePartyComplete(form) {
var thirdPartyCustomerId = (form.thirdPartyCustomerId || '').trim();
if (!thirdPartyCustomerId) return false;
if (form.customerId && thirdPartyCustomerId === form.customerId) return false;
return true;
}
/** 判断新增租赁合同左侧表单必填项是否均已填写(提交审核) */
export function isLeaseContractFormComplete(form) {
return isMainContractFieldsComplete(form)

View File

@@ -21,10 +21,15 @@ export var PAYMENT_PERIOD_CYCLE_LABELS = {
/** @deprecated 列表展示请用 formatPaymentPeriodLabel */
export var PAYMENT_PERIOD_LABELS = {
1: '1个月',
3: '3个月',
6: '6个月',
12: '12个月',
1: '月',
3: '季付',
6: '半年付',
12: '年付',
};
export var PAYMENT_METHOD_LABELS = {
advance: '先付后用',
postpay: '先用后付',
};
export var HYDROGEN_PAYMENT_LABELS = {
@@ -53,6 +58,11 @@ export function formatPaymentPeriodLabel(period) {
return String(period);
}
export function formatPaymentMethodLabel(method) {
if (!method) return '-';
return PAYMENT_METHOD_LABELS[method] || String(method);
}
export function formatHydrogenPaymentLabel(method) {
if (!method) return '-';
if (HYDROGEN_PAYMENT_LABELS[method]) return HYDROGEN_PAYMENT_LABELS[method];
@@ -292,6 +302,25 @@ export function resolveContractTemplateTypeLabel(record) {
return getContractTypeLabel(shortType);
}
/** 列表筛选:合同模板(合同名称维度,如正式合同 / 试用合同) */
export function resolveRecordContractTemplateCategory(record) {
if (!record) return '';
var templateId = record.contractTemplateId || inferContractTemplateId(record);
var option = templateId ? getContractTemplateOption(templateId) : null;
if (option && option.contractType) return option.contractType;
return record.contractType || '';
}
/** 列表筛选:标准合同名称(已发布模板文档名,如 2026年标准商用车租赁合同 */
export function resolveRecordContractTemplateStandardName(record) {
if (!record) return '';
var templateId = record.contractTemplateId || inferContractTemplateId(record);
if (!templateId) return '';
var option = getContractTemplateOption(templateId);
if (option) return option.title || option.fileName || templateId;
return templateId;
}
export function getReturnSettlementStatusTone(status) {
if (status === '已完成' || status === '已结算') return 'green';
if (status === '审批中') return 'amber';
@@ -322,6 +351,16 @@ export function getReturnSettlementDisplayTone(displayLabel) {
return 'gray';
}
export function resolveLeaseBillStatus(vehicle) {
if (!isContractVehicleDelivered(vehicle)) return null;
return vehicle.leaseBillStatus || '正常';
}
export function getLeaseBillStatusTone(status) {
if (status === '欠费') return 'red';
return 'green';
}
export function formatMileageSummary(record) {
if (!record.hasMinimumMileage) return '无最低里程要求';
var period = MILEAGE_PERIOD_LABELS[record.mileagePeriod] || '';
@@ -359,6 +398,41 @@ export function formatDeliveryRegion(region) {
return region.join(' / ');
}
export var DELIVERY_REGION_TBD_LABEL = '交还车时约定';
export var LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL = '暂未确认';
export function isContractDeliveryRegionTbd(record) {
if (!record) return false;
return Boolean(record.deliveryRegionTbd || record.deliveryRegionMode === 'tbd');
}
export function isContractDeliveryDateUnconfirmed(record) {
if (!record) return false;
return Boolean(
record.deliveryDateTbd
|| record.deliveryDateMode === 'unconfirmed'
|| isDeliveryDateTbd(record.deliveryDate)
|| record.deliveryDate === LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL,
);
}
export function formatContractDeliveryRegionLabel(record) {
if (!record) return '-';
if (isContractDeliveryRegionTbd(record)) return DELIVERY_REGION_TBD_LABEL;
return formatDeliveryRegion(record.deliveryRegion);
}
export function canEditContractDeliveryArrangement(record) {
if (!record) return false;
if (record.deliveryPlanLocked === true || record.pickupReceivableCompleted === true) {
return false;
}
return (record.vehicles || []).some(function (vehicle) {
return !isContractVehicleDelivered(vehicle);
});
}
var LEGACY_CONTRACT_APPROVAL_TYPE_MAP = {
'标准合同审批': '标准合同',
'非标准合同审批': '非标准合同',
@@ -369,6 +443,11 @@ export function formatContractApprovalTypeLabel(value) {
return LEGACY_CONTRACT_APPROVAL_TYPE_MAP[value] || value;
}
export function getContractApprovalFlowKindLabel(record) {
var label = formatContractApprovalTypeLabel(record && record.contractApprovalType);
return label === '非标准合同' ? '非标准合同流程' : '标准合同流程';
}
var SAMPLE_AUTHORIZED_DELEGATES = [
{ name: '王明', contact: '13900139001', idNumber: '330102199001011234' },
{ name: '李华', contact: '13900139002', idNumber: '330102199002022345' },
@@ -537,7 +616,7 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
],
approvalStatus: '未提交',
contractStatus: '草稿',
customerName: '上海某某运输公司',
customerName: '上海某某运输有限公司',
signingCompany: '上海羚牛',
businessDept: '业务2部',
businessOwner: '李专员',
@@ -615,10 +694,12 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
vehicleCount: 2,
vehicles: [
{ vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '9.6米氢燃料电池车', plateNo: '-', actualDelivery: '-', deliveryPerson: '-' },
{ vehicleType: '49吨牵引车头', brand: '飞驰', model: '集卡头', plateNo: '浙C30003', actualDelivery: '2025-02-18 14:00', returnTime: '2025-04-01 09:00', deliveryPerson: '孙运维' },
{ vehicleType: '49吨牵引车头', brand: '飞驰', model: '集卡头', plateNo: '浙C30003', actualDelivery: '2025-02-18 14:00', returnTime: '2025-04-01 09:00', deliveryPerson: '孙运维', returnSettlementStatus: '审批中', returnSettlementApprover: '赵主管、王财务', leaseBillStatus: '正常' },
],
approvalStatus: '审批中',
contractStatus: '已提交审批',
contractOriginFlow: 'renew',
sourceContractCode: 'HT-ZL-2024-009',
customerName: '嘉兴某某物流有限公司',
signingCompany: '嘉兴羚牛',
businessDept: '业务1部',
@@ -665,7 +746,7 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
],
approvalStatus: '审批中',
contractStatus: '变更',
customerName: '上海某某运输公司',
customerName: '上海某某运输有限公司',
signingCompany: '上海羚牛',
businessDept: '业务2部',
businessOwner: '李专员',
@@ -704,7 +785,7 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
vehicles: [
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏A60006', actualDelivery: '2025-01-20 08:00', returnTime: '2025-06-01 10:00', deliveryPerson: '吴运维', leasePeriodMonths: 24 },
{ vehicleType: '18吨厢式货车', brand: '现代', model: '18吨氢燃料电池车', plateNo: '苏A60007', actualDelivery: '2025-01-21 10:00', returnTime: '2025-06-01 10:00', deliveryPerson: '郑运维', leasePeriodMonths: 24 },
{ vehicleType: '牵引车头', brand: '飞驰', model: '集卡头', plateNo: '苏A60008', actualDelivery: '2025-01-22 14:00', returnTime: '2025-06-01 10:00', deliveryPerson: '冯运维', leasePeriodMonths: 24 },
{ vehicleType: '牵引车头', brand: '飞驰', model: '集卡头', plateNo: '苏A60008', actualDelivery: '2025-01-22 14:00', deliveryPerson: '冯运维', leasePeriodMonths: 24 },
],
approvalStatus: '审批通过',
contractStatus: '合同进行中',
@@ -739,6 +820,12 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
updater: '王专员',
updateTime: '2025-01-25 11:00',
remark: '-',
contractSigningMethod: 'offline_manual',
stampedContractFiles: [
{ uid: 'stamp-6-1', name: 'HT-ZL-2025-006-盖章合同.pdf' },
{ uid: 'stamp-6-2', name: 'HT-ZL-2025-006-授权委托书扫描件.jpg' },
],
offlineStampSupplementedAt: '2025-01-25 11:00',
legalStampedContractUploaded: true,
approvalFlowNodes: [
{ nodeTitle: '法务审核', result: 'passed', operatorName: '陈法务', operatorTime: '2025-01-22 16:40:00', comment: '标准合同审批,正文无红线条款改动。' },
@@ -753,7 +840,20 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
projectName: '无锡试用租赁项目',
vehicleCount: 1,
vehicles: [
{ vehicleType: '小客车', brand: '宇通', model: '氢燃料电池客车', plateNo: '苏B70007', actualDelivery: '2025-02-01 09:30', returnTime: '2025-05-15 16:00', deliveryPerson: '陈运维', leasePeriodMonths: 3 },
{
vehicleType: '小客车',
brand: '宇通',
model: '氢燃料电池客车',
plateNo: '苏B70007',
actualDelivery: '2025-02-01 09:30',
returnTime: '2025-05-15 16:00',
deliveryPerson: '陈运维',
leasePeriodMonths: 3,
extraServiceValues: ['wear-insurance'],
extraFee: '80',
extraFeeEffectiveDate: '2025-02-01',
extraFeeBillingMode: '先付后用',
},
],
approvalStatus: '审批通过',
contractStatus: '合同进行中',
@@ -786,6 +886,9 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
updater: '-',
updateTime: '-',
remark: '试用 3 个月',
contractSigningMethod: 'offline_manual',
customerPrincipalPhone: '13900139004',
customerPrincipalEmail: 'zhangsan@example.com',
legalStampedContractUploaded: false,
approvalFlowNodes: [
{ nodeTitle: '法务审核', result: 'passed', operatorName: '周法务', operatorTime: '2025-01-30 17:10:00', comment: '试用合同条款已按非标准审批要求补充说明。' },
@@ -804,7 +907,7 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
],
approvalStatus: '审批驳回',
contractStatus: '已提交审批',
customerName: '上海某某运输公司',
customerName: '上海某某运输有限公司',
signingCompany: '上海羚牛',
businessDept: '业务2部',
businessOwner: '李专员',
@@ -843,8 +946,8 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
{ vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '9.6米氢燃料电池车', plateNo: '苏F90009', actualDelivery: '2024-03-01 09:00', returnTime: '2024-12-20 15:00', deliveryPerson: '褚运维', leasePeriodMonths: 12 },
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏F90010', actualDelivery: '2024-03-02 10:00', returnTime: '2024-12-20 15:00', deliveryPerson: '卫运维', leasePeriodMonths: 12 },
],
approvalStatus: '审批通过',
contractStatus: '终止',
approvalStatus: '审批终止',
contractStatus: '审批终止',
terminatedBy: 'renewal',
supersededByContractCode: 'HT-ZL-2025-012',
customerName: '杭州某某租赁有限公司',
@@ -888,6 +991,8 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
],
approvalStatus: '审批通过',
contractStatus: '已结束',
contractOriginFlow: 'trialToFormal',
sourceContractCode: 'HT-ZL-2025-007',
customerName: '嘉兴某某物流有限公司',
signingCompany: '嘉兴羚牛',
businessDept: '业务1部',
@@ -918,6 +1023,7 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
updateTime: '2025-01-10 14:00',
remark: '-',
legalStampedContractUploaded: true,
onlineEsignCompletedAt: '2024-05-25 16:30',
approvalFlowNodes: [
{ nodeTitle: '业务负责人审批', result: 'passed', operatorName: '张经理', operatorTime: '2024-05-22 11:00:00', comment: '单车试用转正式合作,条款无异常。' },
{ nodeTitle: '发起审批', result: 'passed', operatorName: '张经理', operatorTime: '2024-05-20 09:10:00' },
@@ -967,6 +1073,8 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
vehicles: buildOnLeaseFleetDemoVehicles(ON_LEASE_FLEET_VEHICLE_CASES),
approvalStatus: '审批通过',
contractStatus: '合同进行中',
contractOriginFlow: 'renew',
sourceContractCode: 'HT-ZL-2024-009',
customerName: '嘉兴某某物流有限公司',
signingCompany: '嘉兴羚牛',
businessDept: '业务1部',
@@ -998,14 +1106,90 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
updateTime: '2025-02-10 09:00',
remark: '在租车辆概览用例合同(品牌/型号/车型与主数据枚举对齐)',
legalStampedContractUploaded: true,
onlineEsignCompletedAt: '2025-01-10 14:20',
onlineEsignContractFiles: [
{ uid: 'esign-12-1', name: 'HT-ZL-2025-012-电子签章合同.pdf' },
{ uid: 'esign-12-2', name: 'HT-ZL-2025-012-补充协议电子签章.pdf' },
],
approvalFlowNodes: [
{ nodeTitle: '法务审核', result: 'passed', operatorName: '陈法务', operatorTime: '2025-01-08 16:00:00', comment: '七车批量租赁,车型与公告型号已核对。' },
{ nodeTitle: '业务负责人审批', result: 'passed', operatorName: '张经理', operatorTime: '2025-01-07 11:00:00', comment: '同意按标准合同审批。' },
{ nodeTitle: '发起审批', result: 'passed', operatorName: '张经理', operatorTime: '2025-01-06 09:30:00' },
],
},
{
id: '13',
contractCode: 'HT-ZL-2026-010',
projectName: '宁波城配新车投放项目',
vehicleCount: 4,
vehicles: [
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '浙B88001', actualDelivery: '-', deliveryPerson: '-' },
{ vehicleType: '4.5吨厢式货车', brand: '福田', model: '欧马可S3', plateNo: '浙B88002', actualDelivery: '-', deliveryPerson: '-' },
{ vehicleType: '18吨厢式货车', brand: '东风', model: 'DFH1180', plateNo: '浙B88003', actualDelivery: '-', deliveryPerson: '-' },
{ vehicleType: '4.5吨厢式货车', brand: '江淮', model: '格尔发K5', plateNo: '浙B88004', actualDelivery: '-', deliveryPerson: '-' },
],
approvalStatus: '审批通过',
contractStatus: '合同进行中',
contractOriginFlow: 'tripartite',
sourceContractCode: 'HT-ZL-2025-006',
customerName: '宁波某某供应链有限公司',
signingCompany: '嘉兴羚牛',
businessDept: '业务1部',
businessOwner: '张经理',
contractType: '正式合同',
contractApprovalType: '标准合同',
paymentPeriod: 3,
hydrogenPaymentMethod: 'prepay',
deliveryRegion: ['浙江省', '宁波市'],
deliveryRegionMode: 'region',
deliveryRegionTbd: false,
deliveryDate: null,
deliveryDateMode: 'unconfirmed',
deliveryDateTbd: true,
deliveryDateStart: null,
deliveryDateEnd: null,
pickupReceivableCreated: true,
insuredVehicleCount: 4,
thirdPartyLiabilityMillion: 200,
hasMinimumMileage: true,
mileagePeriod: 'month',
mileageTargetKm: 6000,
delegateCount: 1,
poaUploaded: true,
authorizedDelegates: [
{ name: '林晓峰', contact: '13900139100', idNumber: '330102199101011234' },
],
poaRemark: '新车投放合同已审批通过,待业务办理提车应收款。',
contractEndDate: '2027-03-14',
contactName: '钱经理',
contactPhone: '13800138100',
creator: '张经理',
createTime: '2026-02-20 09:00',
updater: '张经理',
updateTime: '2026-02-28 16:30',
remark: '提车应收款「办理」演示用例:合同已提交审核并同步生成主记录,尚未办理提车。',
legalStampedContractUploaded: true,
onlineEsignCompletedAt: '2026-02-28 16:30',
approvalFlowNodes: [
{ nodeTitle: '法务审核', result: 'passed', operatorName: '陈法务', operatorTime: '2026-02-25 15:20:00', comment: '标准合同条款无修改,同意通过。' },
{ nodeTitle: '业务负责人审批', result: 'passed', operatorName: '张经理', operatorTime: '2026-02-24 11:00:00', comment: '宁波城配新车投放方案可行,同意通过。' },
{ nodeTitle: '发起审批', result: 'passed', operatorName: '张经理', operatorTime: '2026-02-22 10:00:00' },
],
},
];
export function getLeaseProjectNameOptions() {
var seen = {};
var options = [];
LEASE_CONTRACT_LIST_RECORDS.forEach(function (record) {
var name = record.projectName;
if (!name || seen[name]) return;
seen[name] = true;
options.push({ value: name, label: name });
});
return options;
}
export function resolveSigningCompanyFullName(record) {
var lessorId = record.lessorId || SIGNING_COMPANY_LESSOR_IDS[record.signingCompany];
var lessor = getLessorCompanyById(lessorId || record.signingCompany);
@@ -1032,9 +1216,15 @@ export function getCurrentApproverLabel(record) {
export function shouldHideCurrentApprover(approvalStatus) {
return approvalStatus === '审批通过'
|| approvalStatus === '审批驳回'
|| approvalStatus === '审批终止'
|| approvalStatus === '撤回';
}
/** 合同审批是否已通过(仅通过后才有交车/还车等履约数据) */
export function isContractApprovalPassed(record) {
return Boolean(record && String(record.approvalStatus || '').trim() === '审批通过');
}
export function formatContractHandoverMileage(mile) {
if (mile == null || mile === '' || mile === '-') return '';
var num = Number(mile);
@@ -1059,7 +1249,11 @@ export function isDeliveryDateTbd(value) {
export function formatContractDeliveryDateLabel(record) {
if (!record) return '-';
if (record.deliveryDateTbd || isDeliveryDateTbd(record.deliveryDate)) return DELIVERY_DATE_TBD_LABEL;
if (isContractDeliveryDateUnconfirmed(record)) return LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL;
if (record.deliveryDateStart && record.deliveryDateEnd) {
return record.deliveryDateStart + ' ~ ' + record.deliveryDateEnd;
}
if (record.deliveryDateTbd || isDeliveryDateTbd(record.deliveryDate)) return LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL;
return record.deliveryDate || '-';
}
@@ -1067,7 +1261,7 @@ export function resolveVehicleDeliveryDateTbd(vehicle, record) {
if (!vehicle || isContractVehicleDelivered(vehicle)) return false;
if (vehicle.deliveryDateTbd != null) return Boolean(vehicle.deliveryDateTbd);
if (!record) return false;
return Boolean(record.deliveryDateTbd || isDeliveryDateTbd(record.deliveryDate));
return isContractDeliveryDateUnconfirmed(record);
}
export function resolveVehiclePlannedDeliveryDate(vehicle, record) {
@@ -1087,9 +1281,9 @@ export function formatVehicleDeliveryPlanDateLabel(vehicle, record) {
var deliveredAt = formatContractHandoverDateTimeMinute(vehicle.deliveryTime || vehicle.actualDelivery);
return deliveredAt && deliveredAt !== '-' ? deliveredAt : '-';
}
if (resolveVehicleDeliveryDateTbd(vehicle, record)) return DELIVERY_DATE_TBD_LABEL;
if (resolveVehicleDeliveryDateTbd(vehicle, record)) return LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL;
var date = resolveVehiclePlannedDeliveryDate(vehicle, record);
if (!date || isDeliveryDateTbd(date)) return DELIVERY_DATE_TBD_LABEL;
if (!date || isDeliveryDateTbd(date)) return LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL;
return date;
}
@@ -1121,21 +1315,79 @@ export function isVehicleLeasePeriodExpired(vehicle, record, referenceDate) {
return ref.getTime() > leaseEnd.getTime();
}
var LEASE_STATUS_DYNAMIC_BASE = ['合同进行中', '到期合同'];
export function resolveContractStatusByVehicleLease(record) {
/**
* 合同状态标签:由保存/提交动作与审批状态共同决定。
* - 仅保存 → 草稿
* - 提交审批且未通过 → 已提交审批
* - 提交审批且审批通过 → 合同进行中
* - 提交审批且审批终止 → 已终止
* - 提交审批且审批驳回 → 草稿
*/
export function resolveContractDisplayStatus(record) {
if (!record) return '-';
var baseStatus = record.contractStatus || '-';
if (LEASE_STATUS_DYNAMIC_BASE.indexOf(baseStatus) === -1) return baseStatus;
var vehicles = record.vehicles || [];
if (!vehicles.length) return baseStatus;
var allDelivered = vehicles.every(isContractVehicleDelivered);
if (!allDelivered) return baseStatus === '到期合同' ? '合同进行中' : baseStatus;
var allLeaseExpired = vehicles.every(function (vehicle) {
return isVehicleLeasePeriodExpired(vehicle, record);
});
if (allLeaseExpired) return '到期合同';
return '合同进行中';
var approval = String(record.approvalStatus || '').trim();
if (approval === '审批终止') return '已终止';
if (approval === '审批驳回' || approval === '未提交' || approval === '撤回') return '草稿';
if (approval === '审批通过') return '合同进行中';
if (approval === '待审批' || approval === '审批中') return '已提交审批';
return String(record.contractStatus || '').trim() || '草稿';
}
var PROJECT_INFO_ORIGIN_FLOW_LABELS = {
renew: '续签',
trialToFormal: '转正式',
tripartite: '转三方',
};
function hasSubmittedContractForApproval(approval) {
return Boolean(approval && approval !== '未提交');
}
/** 项目信息列:基础状态标签(草稿 / 已提交 / 进行中 / 已终止) */
export function resolveProjectInfoBaseStatus(record) {
if (!record) return '草稿';
var approval = String(record.approvalStatus || '').trim();
if (approval === '未提交' || approval === '审批驳回' || approval === '撤回') return '草稿';
if (approval === '审批终止') return '已终止';
if (approval === '审批通过') return '进行中';
if (approval === '待审批' || approval === '审批中') return '已提交';
return '草稿';
}
/** 项目信息列:来源流程标签(续签 / 转正式 / 转三方),仅已提交审批后展示 */
export function resolveProjectInfoOriginTag(record) {
if (!record) return null;
var approval = String(record.approvalStatus || '').trim();
if (!hasSubmittedContractForApproval(approval)) return null;
var flow = String(record.contractOriginFlow || record._flowMode || '').trim();
return PROJECT_INFO_ORIGIN_FLOW_LABELS[flow] || null;
}
/** 项目信息列:完整标签序列(来源标签在前,基础状态在后) */
export function resolveProjectInfoStatusTags(record) {
var tags = [];
var origin = resolveProjectInfoOriginTag(record);
if (origin) tags.push(origin);
tags.push(resolveProjectInfoBaseStatus(record));
return tags;
}
export function projectInfoStatusTone(label) {
switch (label) {
case '进行中': return 'green';
case '已提交': return 'blue';
case '已终止': return 'red';
case '续签':
case '转正式':
case '转三方': return 'amber';
case '草稿':
default: return 'gray';
}
}
/** @deprecated 使用 resolveContractDisplayStatus */
export function resolveContractStatusByVehicleLease(record) {
return resolveContractDisplayStatus(record);
}
export function isContractVehicleDelivered(vehicle) {
@@ -1148,7 +1400,8 @@ export function isContractVehicleReturned(vehicle) {
return Boolean(time && String(time).trim() && time !== '-');
}
export function canContractVehicleReturn(vehicle) {
export function canContractVehicleReturn(vehicle, record) {
if (record && !isContractApprovalPassed(record)) return false;
return isContractVehicleDelivered(vehicle) && !isContractVehicleReturned(vehicle);
}
@@ -1171,7 +1424,7 @@ export function getLastDeliveredNotReturnedVehicle(record) {
var lastTs = -1;
for (var i = 0; i < vehicles.length; i++) {
var vehicle = vehicles[i];
if (!canContractVehicleReturn(vehicle)) continue;
if (!canContractVehicleReturn(vehicle, record)) continue;
var ts = getVehicleDeliveryTimestamp(vehicle);
if (ts == null) ts = i;
if (ts >= lastTs) {
@@ -1227,11 +1480,11 @@ export function isContractTerminatedForKpi(record) {
}
/** 不计入在租统计的合同状态 */
var ON_LEASE_EXCLUDED_CONTRACT_STATUSES = ['草稿', '已结束', '已终止', '未提交', '已提交审批'];
var ON_LEASE_EXCLUDED_CONTRACT_STATUSES = ['草稿', '已提交审批', '审批终止'];
/** 当前在租:已交车、未还车,且合同处于履约期 */
export function isVehicleCurrentlyOnLease(vehicle, record) {
if (!canContractVehicleReturn(vehicle)) return false;
if (!canContractVehicleReturn(vehicle, record)) return false;
if (!record) return false;
var status = String(record.contractStatus || '').trim();
if (!status || ON_LEASE_EXCLUDED_CONTRACT_STATUSES.indexOf(status) >= 0) return false;
@@ -1308,13 +1561,18 @@ export function getContractMileageForecastStatusTone(status) {
}
export function enrichLeaseContractVehicle(vehicle, index, recordIndex, record) {
var delivered = vehicle.actualDelivery && String(vehicle.actualDelivery).trim() && vehicle.actualDelivery !== '-';
var approvalPassed = isContractApprovalPassed(record);
var rawDelivered = vehicle.actualDelivery && String(vehicle.actualDelivery).trim() && vehicle.actualDelivery !== '-';
var delivered = approvalPassed && rawDelivered;
var seed = (recordIndex || 0) * 10 + (index || 0);
var demoDeliveryMiles = [25468, 6210, 31233, 48202, 12580];
var demoReturnMiles = [25372, 127012, 11578];
var demoCurrentMiles = [25468, 6210, 31233, 45822, 127012, 11578, 45195];
var demoReturnPersons = ['刘洋', '李娜', '吴磊'];
var hasExplicitReturn = vehicle.returnTime && String(vehicle.returnTime).trim() && vehicle.returnTime !== '-';
var hasExplicitReturn = approvalPassed
&& vehicle.returnTime
&& String(vehicle.returnTime).trim()
&& vehicle.returnTime !== '-';
var hasMinMileage = record && record.hasMinimumMileage;
var progress = vehicle.mileageProgress;
if (!hasMinMileage) {
@@ -1375,7 +1633,17 @@ export function enrichLeaseContractVehicle(vehicle, index, recordIndex, record)
}
var returnSettlementApprover = vehicle.returnSettlementApprover;
if (hasReturn && returnSettlementStatus === '审批中' && !returnSettlementApprover) {
returnSettlementApprover = ['王财务', '赵主管', '刘经理'][seed % 3];
var approverPool = ['王财务', '赵主管', '刘经理', '超级用户', '金可鹏'];
if (seed % 3 === 0) {
returnSettlementApprover = approverPool[seed % approverPool.length]
+ '、' + approverPool[(seed + 1) % approverPool.length];
} else {
returnSettlementApprover = approverPool[seed % approverPool.length];
}
}
var leaseBillStatus = vehicle.leaseBillStatus;
if (delivered && !leaseBillStatus) {
leaseBillStatus = seed % 5 === 0 ? '欠费' : '正常';
}
var dailyAvgMileage7d = vehicle.dailyAvgMileage7d;
if (dailyAvgMileage7d == null && delivered && hasMinMileage && record) {
@@ -1396,6 +1664,7 @@ export function enrichLeaseContractVehicle(vehicle, index, recordIndex, record)
vin: vehicle.vin || (vehicle.plateNo && vehicle.plateNo !== '-'
? 'LFWNKVPH8K1' + String(10000 + seed).slice(-5)
: '-'),
actualDelivery: delivered ? (vehicle.actualDelivery || deliveryTime || '-') : '-',
deliveryTime: deliveryTime,
deliveryMileage: deliveryMileage,
deliveryPerson: deliveryPerson,
@@ -1414,6 +1683,7 @@ export function enrichLeaseContractVehicle(vehicle, index, recordIndex, record)
periodStartMileage: periodStartMileage,
returnSettlementStatus: hasReturn ? returnSettlementStatus : null,
returnSettlementApprover: hasReturn && returnSettlementStatus === '审批中' ? returnSettlementApprover : null,
leaseBillStatus: delivered ? leaseBillStatus : null,
leasePeriodMonths: vehicle.leasePeriodMonths != null
? vehicle.leasePeriodMonths
: getVehicleLeasePeriodMonths(vehicle, record),
@@ -1455,6 +1725,7 @@ export function enrichLeaseContractRecord(record, recordIndex) {
var enriched = Object.assign({}, record, {
lessorId: lessorId,
contractTemplateId: record.contractTemplateId || templateId,
paymentMethod: record.paymentMethod || (recordIndex % 3 === 1 ? 'postpay' : 'advance'),
signingCompanyFullName: resolveSigningCompanyFullName(Object.assign({}, record, { lessorId: lessorId })),
updater: lastUpdateAudit.updater,
updateTime: lastUpdateAudit.updateTime,
@@ -1465,7 +1736,7 @@ export function enrichLeaseContractRecord(record, recordIndex) {
enriched.contractTemplateTypeLabel = resolveContractTemplateTypeLabel(enriched);
enriched.overallMileageProgress = computeContractOverallMileageProgress(enriched);
enriched.overallMileageForecastStatus = computeContractMileageForecastStatus(enriched);
enriched.contractStatus = resolveContractStatusByVehicleLease(enriched);
enriched.contractStatus = resolveContractDisplayStatus(enriched);
return enriched;
}

View File

@@ -4,6 +4,7 @@
import { getContractTemplateBaseHtml } from '../contract-template-management/contract-template-catalog.js';
import { getTemplateSectionVehicleBindings } from '../contract-template-management/contract-template-store.js';
import { applyAttachmentSectionVehicleFilter, renumberAttachmentSections } from '../contract-template-management/contract-template-section-filter.js';
import { splitMonolithicHtml } from '../contract-template-management/contract-template-section-html.js';
import {
applyTemplateVars,
buildLeasePreviewVars,
@@ -13,7 +14,7 @@ import {
CUSTOMER_CREDENTIAL_ITEMS,
CONTRACT_CODE_PREFIX,
} from '../contract-template-management/contract-template-vars.js';
import { calcRentServiceSubtotal, formatExtraServicesDisplay, formatLeasePeriod, normalizeExtraServices, normalizeBrandModels, normalizePlateNos, formatBrandModelPair, PLATE_ACTUAL_DELIVERY, calcInsuredVehicleCount, getRowVehicleCountForPricing, normalizeDelegateRows } from './lease-order-vars.js';
import { calcRentServiceSubtotal, formatExtraServicesPreviewLines, formatLeasePeriod, normalizeExtraServices, normalizeBrandModels, normalizePlateNos, formatBrandModelPair, PLATE_ACTUAL_DELIVERY, calcInsuredVehicleCount, getRowVehicleCountForPricing, normalizeDelegateRows, formatLeaseOrderDeliveryRegion, formatLeaseOrderDeliveryDate } from './lease-order-vars.js';
import {
injectPrototypeVehicleClauses,
collectVehicleKeysFromLeaseOrder,
@@ -127,6 +128,7 @@ export function applyMileageStandardPatch(html, mileage) {
export function applyPaymentPeriodPatch(html, feeInfo) {
if (!html || !feeInfo) return html;
var months = feeInfo.paymentPeriod != null ? String(feeInfo.paymentPeriod) : '';
var paymentMethodLabel = feeInfo.paymentMethod === 'postpay' ? '先用后付' : '先付后用';
var start = html.indexOf('2.1.1');
if (start < 0) return html;
var end = html.indexOf('2.1.2', start);
@@ -134,47 +136,77 @@ export function applyPaymentPeriodPatch(html, feeInfo) {
var head = html.slice(0, start);
var slice = html.slice(start, end);
var tail = html.slice(end);
slice = slice.replace(/(租金、服务费)(先付后用|先用后付)/, '$1' + escapeHtml(paymentMethodLabel));
slice = slice.replace(/(每【)\s*([\s\u00a0]*)(】个自然月为一个付款周期)/, '$1' + escapeHtml(months) + '$3');
return head + slice + tail;
}
/** 2.2 氢费支付方式:按月结算时切换为占位条款(正式文案待法务确认) */
/** 2.2.1 氢费支付方式:按月结算时切换为月结算条款 */
export function applyHydrogenSettlementPatch(html, feeInfo) {
if (!html || !feeInfo || feeInfo.hydrogenPaymentMethod !== 'month') return html;
var start = html.indexOf('2.2.1');
if (start < 0) return html;
var end = html.indexOf('2.2.3', start);
if (end < 0) end = html.indexOf('2.3', start);
if (end < 0) end = start + 2800;
var head = html.slice(0, start);
var slice = html.slice(start, end);
var tail = html.slice(end);
var placeholder = '<p class="p6 lc-h2-settlement-prototype" style="margin-left:12px;color:#92400e;background:#fffbeb;padding:8px 10px;border-radius:6px">'
+ '【原型占位 · 待法务确认】氢费按月结算:每月 1 日至当月最后一日为一个结算周期,'
+ '次月 5 日前结清上月氢费;具体条款以法务定稿为准。'
+ '</p>';
if (slice.indexOf('lc-h2-settlement-prototype') < 0) {
slice = slice.replace(/(2\.2\.1[\s\S]*?<\/p>)/, '$1' + placeholder);
}
return head + slice + tail;
}
/** 2.2.1 氢费支付:提前 x 个工作日 */
export function applyFeeInfoPatch(html, feeInfo) {
if (!html || !feeInfo) return html;
var days = feeInfo.payAheadWorkdays != null ? String(feeInfo.payAheadWorkdays) : '';
var start = html.indexOf('2.2.1');
if (start < 0) return html;
var end = html.indexOf('2.2.2', start);
if (end < 0) end = html.indexOf('2.2.3', start);
if (end < 0) end = start + 2200;
var head = html.slice(0, start);
var slice = html.slice(start, end);
var tail = html.slice(end);
var monthlyClause = '如需甲方安排加氢的,氢费按月结算:每月 1 日至当月最后一日为一个结算周期,'
+ '次月 5 日前结清上月氢费;乙方应按对账单确认金额及时支付,逾期支付的,甲方有权暂停安排加氢,'
+ '由此造成的一切损失由乙方承担,给甲方造成损失的,乙方还应承担赔偿责任。';
slice = slice.replace(/<p class="p6 lc-h2-settlement-prototype"[\s\S]*?<\/p>/g, '');
slice = slice.replace(
/2\.2\.1[\s\S]*?<\/p>/,
'2.2.1 ' + escapeHtml(monthlyClause) + '</p>',
);
return head + slice + tail;
}
/** 2.2.1 氢费支付:预付款走提车应收款;其他方式可回填提前工作日 */
export function applyFeeInfoPatch(html, feeInfo) {
if (!html || !feeInfo) return html;
if (feeInfo.hydrogenPaymentMethod === 'month') return html;
var start = html.indexOf('2.2.1');
if (start < 0) return html;
var end = html.indexOf('2.2.2', start);
if (end < 0) end = html.indexOf('2.2.3', start);
if (end < 0) end = start + 2200;
var head = html.slice(0, start);
var slice = html.slice(start, end);
var tail = html.slice(end);
if (feeInfo.hydrogenPaymentMethod === 'prepay') {
var prepayAmount = feeInfo.prepayAmount != null && feeInfo.prepayAmount !== ''
? escapeHtml(String(feeInfo.prepayAmount))
: '—';
var prepayNote = '预付款金额 ' + prepayAmount + ' 元;无需单独约定支付时间,在实际提车时通过「提车应收款」分摊支付'
+ '(全部提车一次性付清;分批提车可部分领取,末次提车须结清剩余金额,不可修改)。';
slice = slice.replace(/(提前【)\s*([\s\u00a0]*)(】个工作日内)/, escapeHtml(prepayNote));
return head + slice + tail;
}
var days = feeInfo.payAheadWorkdays != null ? String(feeInfo.payAheadWorkdays) : '';
slice = slice.replace(/(提前【)\s*([\s\u00a0]*)(】个工作日内)/, '$1' + escapeHtml(days) + '$3');
return head + slice + tail;
}
/** 2.2.5 还车氢量差单价:与左侧「还车氢量差单价」表单联动 */
export function applyReturnHydrogenDiffPatch(html, feeInfo) {
if (!html || !feeInfo) return html;
var price = feeInfo.returnHydrogenDiffUnitPrice != null && feeInfo.returnHydrogenDiffUnitPrice !== ''
? escapeHtml(String(feeInfo.returnHydrogenDiffUnitPrice))
: '';
var start = html.indexOf('2.2.5');
if (start < 0) return html;
var end = html.indexOf('2.2.6', start);
if (end < 0) end = html.indexOf('2.3', start);
if (end < 0) end = start + 600;
var head = html.slice(0, start);
var slice = html.slice(start, end);
var tail = html.slice(end);
slice = slice.replace(/(则按照【)\s*([\s\u00a0]*)(】元\/公斤)/, '$1' + price + '$3');
return head + slice + tail;
}
/** 附件1租赁订单编号、甲乙方与车辆列表反写 */
export function applyLeaseOrderAttachmentPatch(html, form, customer, lessor) {
if (!html) return html;
@@ -279,23 +311,19 @@ function applyLeaseOrderSummaryPatch(slice, leaseOrder) {
}
function applyLeaseOrderDeliveryPatch(slice, leaseOrder) {
var region = leaseOrder.deliveryRegion || [];
var regionText = region.length ? escapeHtml(region.join('')) : '';
if (regionText) {
var regionText = formatLeaseOrderDeliveryRegion(leaseOrder);
if (regionText && regionText !== '-') {
slice = slice.replace(
/(车辆交付(交还)地点:)\s*([\s\u00a0]*)(。)/,
'$1' + regionText + '$3',
'$1' + escapeHtml(regionText) + '$3',
);
}
if (leaseOrder.deliveryDate) {
var parts = String(leaseOrder.deliveryDate).split('-');
if (parts.length === 3) {
var dateText = escapeHtml(parts[0] + '年' + parseInt(parts[1], 10) + '月' + parseInt(parts[2], 10) + '日');
slice = slice.replace(
/(车辆交付时间:)\s*([\s\u00a0\u2002]*年\s*[\s\u00a0\u2002]*月\s*[\s\u00a0\u2002]*日)/,
'$1' + dateText,
);
}
var dateText = formatLeaseOrderDeliveryDate(leaseOrder);
if (dateText && dateText !== '-') {
slice = slice.replace(
/(车辆交付时间:)\s*([\s\u00a0\u2002]*年\s*[\s\u00a0\u2002]*月\s*[\s\u00a0\u2002]*日)/,
'$1' + escapeHtml(dateText),
);
}
return slice;
}
@@ -333,7 +361,9 @@ function buildLeaseOrderPreviewRow(row, brandModelPair, plate, leaseOrder) {
var rent = row.rent != null && row.rent !== '' ? escapeHtml(row.rent) : '';
var serviceFee = row.serviceFee != null && row.serviceFee !== '' ? escapeHtml(row.serviceFee) : '';
var deposit = row.deposit != null && row.deposit !== '' ? escapeHtml(row.deposit) : '';
var extra = escapeHtml(formatExtraServicesDisplay(extraServices));
var extra = formatExtraServicesPreviewLines(extraServices).map(function (line) {
return '<p style="margin:0;text-align:center;font:9px Times">' + escapeHtml(line) + '</p>';
}).join('');
var leasePeriod = escapeHtml(formatLeasePeriod(row));
var border = 'border:1px solid #bfbfbf;padding:0 5px';
var brandCell = brandModelText
@@ -431,6 +461,52 @@ function stripUnresolvedPlaceholders(html) {
return String(html || '').replace(/\{\{[^{}]+\}\}/g, '');
}
/** 添加授权委托书等场景:从完整合同 HTML 中截取授权委托书预览片段 */
export function extractPowerOfAttorneyPreviewHtml(html) {
if (!html) return '';
var sections = splitMonolithicHtml(html);
var slice = String(sections.authorization || '').trim();
if (!slice) return html;
return '<div class="lc-preview-scoped-only lc-preview-poa-only" data-preview-scope="poa">'
+ '<p class="lc-preview-scoped-only__banner" role="note">'
+ '以下为授权委托书预览,主合同及其他附件无需重新签署。'
+ '</p>'
+ slice
+ '</div>';
}
/** 新增车辆等场景:从完整合同 HTML 中截取附件1租赁订单预览片段 */
export function extractLeaseOrderAttachment1PreviewHtml(html) {
if (!html) return '';
var text = String(html);
var markers = ['附件1', '1租赁订单', '附件 1'];
var start = -1;
for (var i = 0; i < markers.length; i++) {
var pos = text.indexOf(markers[i]);
if (pos >= 0) {
start = pos;
break;
}
}
if (start < 0) return text;
var endMarkers = ['附件2', '附件 2', '附件二'];
var end = text.length;
for (var j = 0; j < endMarkers.length; j++) {
var endPos = text.indexOf(endMarkers[j], start + 4);
if (endPos >= 0 && endPos < end) end = endPos;
}
var slice = text.slice(start, end).trim();
if (!slice) return text;
return '<div class="lc-preview-scoped-only lc-preview-attachment1-only" data-preview-scope="attachment1">'
+ '<p class="lc-preview-scoped-only__banner" role="note">'
+ '以下为新增租赁订单附件1预览主合同及其他附件无需重新签署。'
+ '</p>'
+ slice
+ '</div>';
}
export function buildLeaseContractPreviewHtml(form) {
var templateId = form && form.contractTemplateId;
var base = getContractTemplateBaseHtml(templateId) || '';
@@ -449,6 +525,7 @@ export function buildLeaseContractPreviewHtml(form) {
html = applyMileageStandardPatch(html, form && form.mileage);
html = applyPaymentPeriodPatch(html, form && form.feeInfo);
html = applyFeeInfoPatch(html, form && form.feeInfo);
html = applyReturnHydrogenDiffPatch(html, form && form.feeInfo);
html = applyHydrogenSettlementPatch(html, form && form.feeInfo);
html = applyLeaseOrderAttachmentPatch(html, form, customer, lessor);
html = applyPowerOfAttorneyPatch(html, form);

View File

@@ -6,6 +6,7 @@ export var STANDARD_CONTRACT_APPROVAL = '标准合同';
export var NONSTANDARD_CONTRACT_APPROVAL = '非标准合同';
var RISK_SELECTOR = '.ct-risk-redline[data-risk-redline="1"]';
var LOCKED_SELECTOR = '[data-section-locked="1"], .ct-section-locked';
function normalizeRiskText(value) {
return String(value || '')
@@ -16,6 +17,13 @@ function normalizeRiskText(value) {
.trim();
}
function truncatePreviewText(value, maxLen) {
var text = normalizeRiskText(value);
if (!text) return '(空)';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + '…';
}
function collectRiskSnapshots(html) {
if (!html || typeof document === 'undefined') return [];
var div = document.createElement('div');
@@ -30,6 +38,22 @@ function collectRiskSnapshots(html) {
return list;
}
function collectParagraphSnapshots(html) {
if (!html || typeof document === 'undefined') return [];
var div = document.createElement('div');
div.innerHTML = String(html || '');
var list = [];
div.querySelectorAll('p, li, td, th').forEach(function (el, index) {
var text = normalizeRiskText(el.textContent || '');
if (!text || text.length < 8) return;
list.push({
id: 'block-' + index,
text: text,
});
});
return list;
}
/** 风控红线条款正文是否与基线不一致 */
export function isRiskRedlineContentModified(baselineHtml, currentHtml) {
var baseline = collectRiskSnapshots(baselineHtml);
@@ -42,12 +66,85 @@ export function isRiskRedlineContentModified(baselineHtml, currentHtml) {
return false;
}
/** 预览正文是否新增条款(相对基线出现新的段落块) */
export function isPreviewNewClauseAdded(baselineHtml, currentHtml) {
var baseline = collectParagraphSnapshots(baselineHtml);
var current = collectParagraphSnapshots(currentHtml);
if (current.length > baseline.length) return true;
var baselineSet = {};
baseline.forEach(function (item) {
baselineSet[item.text] = true;
});
for (var i = 0; i < current.length; i++) {
if (!baselineSet[current[i].text]) return true;
}
return false;
}
/** 收集标准条款与修改后条款记录(原型对比) */
export function collectClauseChangeRecords(baselineHtml, currentHtml) {
if (!baselineHtml || !currentHtml || typeof document === 'undefined') return [];
var records = [];
var baselineRisks = collectRiskSnapshots(baselineHtml);
var currentRisks = collectRiskSnapshots(currentHtml);
var maxRisk = Math.max(baselineRisks.length, currentRisks.length);
for (var i = 0; i < maxRisk; i++) {
var before = baselineRisks[i];
var after = currentRisks[i];
var beforeText = before ? before.text : '';
var afterText = after ? after.text : '';
if (beforeText === afterText) continue;
records.push({
id: (after && after.id) || (before && before.id) || ('risk-' + i),
kind: 'risk',
kindLabel: '风控红线',
standardText: truncatePreviewText(beforeText, 180),
modifiedText: truncatePreviewText(afterText, 180),
});
}
var baselineBlocks = collectParagraphSnapshots(baselineHtml);
var currentBlocks = collectParagraphSnapshots(currentHtml);
var baselineMap = {};
baselineBlocks.forEach(function (item) {
baselineMap[item.text] = item;
});
currentBlocks.forEach(function (item, index) {
if (baselineMap[item.text]) return;
records.push({
id: 'new-' + index,
kind: 'new',
kindLabel: '新增条款',
standardText: '(标准合同无对应段落)',
modifiedText: truncatePreviewText(item.text, 180),
});
});
var currentMap = {};
currentBlocks.forEach(function (item) {
currentMap[item.text] = item;
});
baselineBlocks.forEach(function (item, index) {
if (currentMap[item.text]) return;
records.push({
id: 'removed-' + index,
kind: 'removed',
kindLabel: '删除段落',
standardText: truncatePreviewText(item.text, 180),
modifiedText: '(已删除)',
});
});
return records.slice(0, 12);
}
export function mergePreviewPages(pages) {
return (pages || []).join('');
}
export function resolveContractApprovalType(baselineHtml, currentHtml) {
return isRiskRedlineContentModified(baselineHtml, currentHtml)
|| isPreviewNewClauseAdded(baselineHtml, currentHtml)
? NONSTANDARD_CONTRACT_APPROVAL
: STANDARD_CONTRACT_APPROVAL;
}

View File

@@ -0,0 +1,181 @@
import { getLeaseCustomerById } from '../contract-template-management/contract-template-vars.js';
export var CONTRACT_SIGNING_METHOD_ONLINE = 'online_esign';
export var CONTRACT_SIGNING_METHOD_OFFLINE = 'offline_manual';
var PARTY_B_CUSTOMER_IDS_BY_NAME = {
'嘉兴某某物流有限公司': '1',
'上海某某运输有限公司': '2',
'杭州某某租赁有限公司': '3',
};
export var CONTRACT_SIGNING_METHOD_OPTIONS = [
{ value: CONTRACT_SIGNING_METHOD_ONLINE, label: '线上电子签章' },
{ value: CONTRACT_SIGNING_METHOD_OFFLINE, label: '线下人工上传' },
];
export function formatContractSigningMethodLabel(method) {
if (method === CONTRACT_SIGNING_METHOD_OFFLINE) return '线下人工上传';
if (method === CONTRACT_SIGNING_METHOD_ONLINE) return '线上电子签章';
return '线上电子签章';
}
export function isOfflineContractSigning(method) {
return method === CONTRACT_SIGNING_METHOD_OFFLINE;
}
export function resolveContractSigningMethod(method) {
return method || CONTRACT_SIGNING_METHOD_ONLINE;
}
export function formatSigningStatusMinute(value) {
if (!value) return null;
var text = String(value).trim();
if (!text || text === '-') return null;
if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(text)) {
return text.slice(0, 16);
}
if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(text)) {
return text;
}
return text;
}
export function resolveOnlineEsignCompletedAt(record, completedAtOverride) {
if (!record) return null;
if (completedAtOverride && completedAtOverride[record.id]) {
return completedAtOverride[record.id];
}
return record.onlineEsignCompletedAt || null;
}
export function resolveOfflineStampSupplementedAt(record, completedAtOverride) {
if (!record) return null;
if (completedAtOverride && completedAtOverride[record.id]) {
return completedAtOverride[record.id];
}
return record.offlineStampSupplementedAt || null;
}
export function getContractSigningSubLabel(record, options) {
options = options || {};
var method = resolveContractSigningMethod(record && record.contractSigningMethod);
var completedAtOverride = options.completedAtOverride || null;
if (isOfflineContractSigning(method)) {
if (!options.hasUploaded) return '待补传';
return formatSigningStatusMinute(resolveOfflineStampSupplementedAt(record, completedAtOverride)) || '-';
}
return formatSigningStatusMinute(resolveOnlineEsignCompletedAt(record, completedAtOverride)) || '-';
}
export function getOnlineEsignContractFiles(record) {
if (!record) return [];
if (record.onlineEsignContractFiles && record.onlineEsignContractFiles.length) {
return record.onlineEsignContractFiles;
}
if (resolveOnlineEsignCompletedAt(record, null)) {
var code = record.contractCode || '租赁合同';
return [{ uid: 'esign-' + (record.id || code), name: code + '-电子签章合同.pdf' }];
}
return [];
}
export function hasOnlineEsignCompleted(record, completedAtOverride) {
return !!formatSigningStatusMinute(resolveOnlineEsignCompletedAt(record, completedAtOverride));
}
export function openOnlineEsignPreviewInNewTab(record) {
var code = record && record.contractCode ? record.contractCode : '租赁合同';
var customer = record && record.customerName ? record.customerName : '-';
var principalPhone = record && record.customerPrincipalPhone ? record.customerPrincipalPhone : '-';
var previewWindow = window.open('', '_blank');
if (!previewWindow) {
return false;
}
previewWindow.document.write(
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" />'
+ '<title>' + code + ' · E签宝电子签章预览</title>'
+ '<style>body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:#f8fafc;color:#0f172a}'
+ '.wrap{max-width:860px;margin:32px auto;padding:24px 28px;background:#fff;border:1px solid #e2e8f0;border-radius:12px}'
+ 'h1{font-size:20px;margin:0 0 8px}p{margin:8px 0;color:#475569;line-height:1.6}'
+ '.doc{margin-top:20px;padding:20px;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc;min-height:320px}'
+ '</style></head><body><div class="wrap">'
+ '<h1>E签宝电子签章合同预览</h1>'
+ '<p>合同编码:' + code + '</p>'
+ '<p>乙方:' + customer + '</p>'
+ '<p>乙方负责人手机号:' + principalPhone + '</p>'
+ '<div class="doc"><p>此处为线上电子签章合同正文预览(原型)。</p><p>审批通过后,系统已向乙方负责人发送 E签宝签署链接。</p></div>'
+ '</div></body></html>',
);
previewWindow.document.close();
return true;
}
export function openContractAttachmentPreviewInNewTab(file, record) {
var code = record && record.contractCode ? record.contractCode : '租赁合同';
var fileName = file && file.name ? file.name : '附件';
var previewWindow = window.open('', '_blank');
if (!previewWindow) {
return false;
}
previewWindow.document.write(
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" />'
+ '<title>' + fileName + '</title>'
+ '<style>body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:#f1f5f9;color:#0f172a}'
+ '.wrap{max-width:860px;margin:32px auto;padding:24px 28px;background:#fff;border:1px solid #e2e8f0;border-radius:12px}'
+ 'h1{font-size:18px;margin:0 0 8px}p{margin:8px 0;color:#475569;line-height:1.6}'
+ '.preview{margin-top:16px;padding:24px;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc;min-height:280px}'
+ '</style></head><body><div class="wrap">'
+ '<h1>' + fileName + '</h1>'
+ '<p>合同编码:' + code + '</p>'
+ '<div class="preview"><p>线下人工上传附件预览(原型)。</p></div>'
+ '</div></body></html>',
);
previewWindow.document.close();
return true;
}
export function downloadContractAttachment(file, record) {
var fileName = file && file.name ? file.name : '附件';
var code = record && record.contractCode ? record.contractCode : '租赁合同';
var blob = new Blob(['原型下载占位:' + code + ' / ' + fileName], { type: 'text/plain;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return true;
}
export function resolvePartyBEmailForStamp(record) {
if (!record) return '';
if (record.customerPrincipalEmail) return record.customerPrincipalEmail;
if (record.partyBEmail) return record.partyBEmail;
var customerId = record.customerId || PARTY_B_CUSTOMER_IDS_BY_NAME[record.customerName] || '';
var customer = getLeaseCustomerById(customerId);
return customer && customer.email ? customer.email : '';
}
export function isPartyBEmailValid(email) {
var value = (email || '').trim();
if (!value) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
export function downloadPartyASignedContract(record) {
var code = record && record.contractCode ? record.contractCode : '租赁合同';
var fileName = code + '-甲方已签章.pdf';
var blob = new Blob(['原型:' + code + ' 甲方已签章合同(请手动发送给乙方)'], { type: 'application/pdf' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return true;
}

View File

@@ -0,0 +1,124 @@
/**
* 合同查看页:附件、操作记录、变更记录(原型样例)
*/
import { openContractAttachmentPreviewInNewTab, downloadContractAttachment } from './lease-contract-signing.js';
export var CHANGE_RECORD_TYPES = [
{ key: 'tripartite', label: '转三方协议' },
{ key: 'poa', label: '添加授权委托书' },
{ key: 'extraFee', label: '附加费用' },
{ key: 'ownerChange', label: '变更业务负责人' },
{ key: 'terminate', label: '主动终止合同' },
];
function padMinute(value) {
if (!value) return '-';
var text = String(value).trim();
if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(text)) return text.slice(0, 16);
return text;
}
export function buildContractAttachments(record) {
if (!record) return [];
var code = record.contractCode || '租赁合同';
var files = [];
if (record.stampedContractFiles && record.stampedContractFiles.length) {
files = record.stampedContractFiles.slice();
} else if (record.onlineEsignContractFiles && record.onlineEsignContractFiles.length) {
files = record.onlineEsignContractFiles.slice();
} else if (record.onlineEsignCompletedAt || record.legalStampedContractUploaded) {
files = [{ uid: 'esign-main', name: code + '-电子签章合同.pdf' }];
}
if (record.contractSigningMethod !== 'offline_manual' && !files.length && record.approvalStatus === '审批通过') {
files.push({ uid: 'draft-main', name: code + '-租赁合同正文.pdf' });
}
return files;
}
export function buildContractOperationLogs(record) {
if (!record) return [];
var logs = [
{
id: 'op-create',
action: '创建合同',
operatorName: record.creator || '-',
operateTime: padMinute(record.createTime),
modifierName: record.updater && record.updater !== '-' ? record.updater : record.creator || '-',
modifyTime: padMinute(record.updateTime && record.updateTime !== '-' ? record.updateTime : record.createTime),
},
];
if (record.approvalStatus && record.approvalStatus !== '未提交' && record.approvalStatus !== '草稿') {
logs.push({
id: 'op-submit',
action: '提交审核',
operatorName: record.creator || '-',
operateTime: padMinute(record.createTime),
modifierName: record.updater || record.creator || '-',
modifyTime: padMinute(record.updateTime),
});
}
if (record.approvalStatus === '审批通过') {
logs.push({
id: 'op-approve',
action: '审批通过',
operatorName: record.businessOwner || '业务负责人',
operateTime: padMinute(record.updateTime),
modifierName: record.updater || record.businessOwner || '-',
modifyTime: padMinute(record.updateTime),
});
}
return logs;
}
export function buildContractChangeRecords(record) {
if (!record) return [];
var list = [];
if (record.changeRecords && record.changeRecords.length) {
return record.changeRecords.map(function (item, index) {
return Object.assign({ id: item.id || ('chg-' + index) }, item);
});
}
if (record.poaUploaded) {
list.push({
id: 'chg-poa',
type: 'poa',
typeLabel: '添加授权委托书',
summary: '已维护授权委托书与受托人信息',
operatorName: record.creator || '-',
operateTime: padMinute(record.createTime),
approvalStatus: '审批通过',
});
}
if (record.contractApprovalType === '非标准合同') {
list.push({
id: 'chg-nonstd',
type: 'extraFee',
typeLabel: '附加费用',
summary: '历史附加费用变更记录(原型样例)',
operatorName: record.businessOwner || '-',
operateTime: padMinute(record.updateTime),
approvalStatus: '审批通过',
});
}
if (record.terminatedBy) {
list.push({
id: 'chg-term',
type: 'terminate',
typeLabel: '主动终止合同',
summary: record.remark || '合同已终止',
operatorName: record.updater || record.businessOwner || '-',
operateTime: padMinute(record.updateTime),
approvalStatus: '审批通过',
});
}
return list;
}
export function previewContractAttachment(file, record) {
return openContractAttachmentPreviewInNewTab(file, record);
}
export function downloadContractAttachmentFile(file, record) {
return downloadContractAttachment(file, record);
}

View File

@@ -0,0 +1,371 @@
/**
* 合同查看页:对齐新增页左侧编辑区章节与字段
*/
import { getContractTemplateOption } from '../contract-template-management/contract-template-catalog.js';
import {
getLessorCompanyById,
getLeaseCustomerById,
getCustomerInvoicePreview,
getLessorAccountPreview,
getLessorContactPreview,
getCustomerContactPreview,
MILEAGE_PERIOD_OPTIONS,
PAYMENT_METHOD_OPTIONS,
PAYMENT_PERIOD_OPTIONS,
HYDROGEN_PAYMENT_METHOD_OPTIONS,
CONTRACT_CODE_PREFIX,
} from '../contract-template-management/contract-template-vars.js';
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
import {
formatContractDeliveryRegionLabel,
formatContractDeliveryDateLabel,
formatPaymentMethodLabel,
formatPaymentPeriodLabel,
formatHydrogenPaymentLabel,
formatContractApprovalTypeLabel,
formatMileageSummary,
resolveContractDisplayStatus,
getAuthorizedDelegates,
isContractVehicleDelivered,
isContractVehicleReturned,
inferContractTemplateId,
resolveContractTemplateTypeLabel,
} from './lease-contract-list-data.js';
import {
formatContractSigningMethodLabel,
resolveContractSigningMethod,
getContractSigningSubLabel,
isOfflineContractSigning,
} from './lease-contract-signing.js';
var SEAL_TYPE_LABELS = {
contract: '合同章',
official: '公章',
legal_person: '法人章',
};
var VIEW_SECTIONS = [
{ key: 'template', label: '选择模板', step: 1 },
{ key: 'main', label: '主体合同', step: 2 },
{ key: 'leaseOrder', label: '租赁订单', step: 3 },
{ key: 'poa', label: '授权委托', step: 4, optional: true },
{ key: 'remark', label: '合同备注', step: 5, optional: true },
{ key: 'seal', label: '用章类型', step: 6 },
{ key: 'attachments', label: '合同附件', viewExtra: true, step: 7 },
{ key: 'audit', label: '建档信息', viewExtra: true, step: 8 },
{ key: 'operations', label: '操作记录', viewExtra: true, step: 9 },
{ key: 'changes', label: '变更记录', viewExtra: true, step: 10 },
];
function optionLabel(options, value) {
if (!options || !options.length) return value || '-';
var found = options.find(function (item) { return item.value === value; });
return found ? found.label : (value != null && value !== '' ? String(value) : '-');
}
function signingExtras(record) {
return {
hasUploaded: Boolean(record.legalStampedContractUploaded || record.stampedContractFiles && record.stampedContractFiles.length),
};
}
function resolveSigningSubLabel(record) {
var signingSub = getContractSigningSubLabel(record, signingExtras(record));
if (!signingSub || signingSub === '-') {
var method = resolveContractSigningMethod(record.contractSigningMethod);
signingSub = isOfflineContractSigning(method) ? '待补传' : '待签署';
}
return signingSub;
}
export function resolveViewFormContext(record) {
if (!record) return null;
var formState = buildLeaseContractEditFormState(record);
if (!formState) return null;
var lessor = getLessorCompanyById(formState.lessorId);
var customer = getLeaseCustomerById(formState.customerId);
return {
formState: formState,
lessor: lessor,
customer: customer,
lessorAccount: getLessorAccountPreview(lessor),
lessorContact: getLessorContactPreview(lessor),
customerContact: getCustomerContactPreview(customer),
invoice: getCustomerInvoicePreview(customer),
};
}
export function getViewSectionNavItems() {
return VIEW_SECTIONS.slice();
}
export function formatSealTypesLabel(sealTypes) {
if (!sealTypes || !sealTypes.length) return '合同章';
return sealTypes.map(function (key) {
return SEAL_TYPE_LABELS[key] || key;
}).join('、');
}
export function resolveVehicleLeaseStatus(vehicle) {
if (!vehicle) return { label: '待交车', tone: 'gray' };
if (isContractVehicleReturned(vehicle)) return { label: '已还车', tone: 'gray' };
if (isContractVehicleDelivered(vehicle)) return { label: '已交车', tone: 'green' };
return { label: '待交车', tone: 'blue' };
}
export function buildContractViewSummary(record) {
if (!record) return null;
var displayStatus = resolveContractDisplayStatus(record);
var signingMethod = resolveContractSigningMethod(record.contractSigningMethod);
var signingSub = resolveSigningSubLabel(record);
return {
projectName: record.projectName || '-',
contractCode: record.contractCode || '-',
customerName: record.customerName || '-',
displayStatus: displayStatus,
approvalStatus: record.approvalStatus || '-',
showApprovalBadge: shouldShowApprovalBadge(displayStatus, record.approvalStatus),
contractType: record.contractType || '-',
contractApprovalType: formatContractApprovalTypeLabel(record.contractApprovalType),
signingMethodLabel: formatContractSigningMethodLabel(signingMethod),
signingSubLabel: signingSub,
vehicleCount: record.vehicleCount != null ? record.vehicleCount : (record.vehicles ? record.vehicles.length : 0),
contractEndDate: record.contractEndDate || '-',
businessOwner: record.businessOwner || '-',
};
}
function shouldShowApprovalBadge(displayStatus, approvalStatus) {
if (!approvalStatus || approvalStatus === '-') return false;
if (approvalStatus === '审批通过' && displayStatus === '合同进行中') return false;
return true;
}
export function buildContractViewTemplateFields(record) {
if (!record) return [];
var templateId = record.contractTemplateId || inferContractTemplateId(record);
var option = templateId ? getContractTemplateOption(templateId) : null;
var templateLabel = option && option.title
? option.title
: resolveContractTemplateTypeLabel(record);
return [
{ label: '合同模板', value: templateLabel },
{ label: '合同签署方式', value: formatContractSigningMethodLabel(resolveContractSigningMethod(record.contractSigningMethod)) },
{ label: '签署状态', value: resolveSigningSubLabel(record) },
{ label: '审批类型', value: formatContractApprovalTypeLabel(record.contractApprovalType) },
];
}
export function buildContractViewSigningFields(record, ctx) {
if (!record) return [];
var formState = ctx && ctx.formState;
var code = record.contractCode || (formState && formState.contractCode
? CONTRACT_CODE_PREFIX + formState.contractCode
: '-');
return [
{ label: '合同编码', value: code, mono: true },
{ label: '业务部门 / 业务人员', value: [record.businessDept, record.businessOwner].filter(Boolean).join(' / ') || '-' },
{ label: '项目名称', value: record.projectName || '-', wide: true },
{ label: '甲方', value: record.signingCompanyFullName || record.signingCompany },
{ label: '乙方', value: record.customerName },
{ label: '乙方负责人姓名', value: record.customerPrincipalName || (formState && formState.customerPrincipalName) || '-' },
{ label: '乙方负责人手机号', value: record.customerPrincipalPhone || (formState && formState.customerPrincipalPhone) || '-', mono: true },
];
}
export function buildContractViewLessorProfileFields(ctx) {
if (!ctx || !ctx.lessorAccount) return [];
var account = ctx.lessorAccount;
var contact = ctx.lessorContact || {};
return [
{ label: '户名', value: account.accountName },
{ label: '开户行', value: account.bankName },
{ label: '账号', value: account.bankAccount, mono: true },
{ label: '通讯地址', value: contact.mailAddress, wide: true },
{ label: '联系人姓名', value: contact.contactName },
{ label: '联系人电话', value: contact.contactPhone, mono: true },
{ label: '邮箱', value: contact.email, wide: true },
];
}
export function buildContractViewCustomerProfileFields(ctx) {
if (!ctx || !ctx.invoice) return [];
var invoice = ctx.invoice;
var contact = ctx.customerContact || {};
return [
{ label: '企业名称', value: invoice.companyName, wide: true },
{ label: '开户银行', value: invoice.bank },
{ label: '银行账号', value: invoice.bankAccount, mono: true },
{ label: '纳税人识别号', value: invoice.taxId, mono: true },
{ label: '企业地址', value: invoice.mailingAddress, wide: true },
{ label: '企业电话', value: invoice.companyPhone, mono: true },
{ label: '通讯地址', value: contact.mailAddress, wide: true },
{ label: '联系人姓名', value: contact.contactName },
{ label: '联系人电话', value: contact.contactPhone, mono: true },
{ label: '邮箱', value: contact.email, wide: true },
];
}
export function buildContractViewMileageFields(record, ctx) {
if (!record) return [];
var mileage = ctx && ctx.formState && ctx.formState.mileage;
var hasRequirement = Boolean(record.hasMinimumMileage || (mileage && mileage.hasRequirement));
var fields = [
{ label: '是否有里程要求', value: hasRequirement ? '是' : '否' },
];
if (hasRequirement) {
var periodLabel = optionLabel(MILEAGE_PERIOD_OPTIONS, record.mileagePeriod || (mileage && mileage.period));
fields.push(
{ label: '里程要求类型', value: periodLabel },
{ label: '里程要求', value: formatMileageSummary(record) },
{ label: '次月租金减免金额', value: record.mileageReductionYuan != null ? record.mileageReductionYuan + ' 元' : '-', mono: true },
{ label: '减免有效期至', value: record.mileageValidUntil || '-', mono: true },
);
}
return fields;
}
export function buildContractViewFeeFields(record, ctx) {
if (!record) return [];
var feeInfo = ctx && ctx.formState && ctx.formState.feeInfo;
var paymentMethod = record.paymentMethod || (feeInfo && feeInfo.paymentMethod);
var hydrogenMethod = record.hydrogenPaymentMethod || (feeInfo && feeInfo.hydrogenPaymentMethod);
var fields = [
{ label: '付款方式', value: formatPaymentMethodLabel(paymentMethod) },
{ label: '付款周期', value: formatPaymentPeriodLabel(record.paymentPeriod != null ? record.paymentPeriod : (feeInfo && feeInfo.paymentPeriod)) },
{ label: '氢费支付方式', value: formatHydrogenPaymentLabel(hydrogenMethod) },
];
if (hydrogenMethod === 'prepay' || record.prepayAmount != null) {
fields.push({
label: '预付款金额',
value: record.prepayAmount != null ? record.prepayAmount + ' 元' : (feeInfo && feeInfo.prepayAmount != null ? feeInfo.prepayAmount + ' 元' : '-'),
mono: true,
});
}
if (hydrogenMethod === 'month' || record.payAheadWorkdays != null) {
fields.push({
label: '提前付款工作日',
value: record.payAheadWorkdays != null ? record.payAheadWorkdays + ' 天' : (feeInfo && feeInfo.payAheadWorkdays != null ? feeInfo.payAheadWorkdays + ' 天' : '-'),
mono: true,
});
}
var diffPrice = record.returnHydrogenDiffUnitPrice != null
? record.returnHydrogenDiffUnitPrice
: (feeInfo && feeInfo.returnHydrogenDiffUnitPrice);
fields.push({
label: '还车氢量差单价',
value: diffPrice != null ? diffPrice + ' 元/kg' : '-',
mono: true,
});
return fields;
}
export function buildContractViewLeaseOrderMetaFields(record) {
if (!record) return [];
return [
{ label: '交车区域', value: formatContractDeliveryRegionLabel(record), wide: true },
{ label: '交车时间', value: formatContractDeliveryDateLabel(record), mono: true },
{ label: '保险金额', value: record.thirdPartyLiabilityMillion != null ? record.thirdPartyLiabilityMillion + ' 万元' : '-', mono: true },
{ label: '在保车辆数', value: record.insuredVehicleCount != null ? record.insuredVehicleCount + ' 辆' : '-', mono: true },
];
}
export function buildContractViewSealTypes(record, ctx) {
var sealTypes = record && record.sealTypes;
if ((!sealTypes || !sealTypes.length) && ctx && ctx.formState) {
sealTypes = ctx.formState.sealTypes;
}
return Array.isArray(sealTypes) && sealTypes.length ? sealTypes.slice() : ['contract'];
}
/** @deprecated 使用 buildContractViewSigningFields + 档案分组 */
export function buildContractViewPartyGroups(record) {
if (!record) return [];
return [
{
title: '签约主体',
fields: [
{ label: '签约公司(甲方)', value: record.signingCompany },
{ label: '客户名称(乙方)', value: record.customerName, wide: true },
],
},
{
title: '联系与业务',
fields: [
{ label: '联系人', value: record.contactName },
{ label: '联系电话', value: record.contactPhone, mono: true },
{ label: '业务部门', value: record.businessDept },
{ label: '业务负责人', value: record.businessOwner },
],
},
{
title: '合同属性',
fields: [
{ label: '合同类型', value: record.contractType },
{ label: '审批类型', value: formatContractApprovalTypeLabel(record.contractApprovalType) },
{ label: '用章类型', value: formatSealTypesLabel(record.sealTypes) },
],
},
{
title: '签署信息',
fields: [
{ label: '合同签署方式', value: formatContractSigningMethodLabel(resolveContractSigningMethod(record.contractSigningMethod)) },
{ label: '签署状态', value: resolveSigningSubLabel(record) },
],
},
];
}
/** @deprecated 使用 buildContractViewFeeFields + buildContractViewLeaseOrderMetaFields */
export function buildContractViewFeeGroups(record) {
if (!record) return [];
return [
{
title: '付款与结算',
fields: buildContractViewFeeFields(record, resolveViewFormContext(record)),
},
{
title: '交车与保险',
fields: buildContractViewLeaseOrderMetaFields(record),
},
];
}
export function buildContractViewVehicleCards(record) {
if (!record || !record.vehicles) return [];
return record.vehicles.map(function (vehicle, index) {
var status = resolveVehicleLeaseStatus(vehicle);
return {
id: 'view-vehicle-' + index,
title: [vehicle.brand, vehicle.model].filter(Boolean).join(' ') || ('车辆 ' + (index + 1)),
status: status,
fields: [
{ label: '车辆类型', value: vehicle.vehicleType },
{ label: '品牌 / 型号', value: [vehicle.brand, vehicle.model].filter(Boolean).join(' / ') },
{ label: '车牌号', value: vehicle.plateNo, mono: true },
{ label: '租金', value: vehicle.rent != null ? vehicle.rent + ' 元/月' : '-', mono: true },
{ label: '服务费', value: vehicle.serviceFee != null ? vehicle.serviceFee + ' 元/月' : '-', mono: true },
{ label: '保证金', value: vehicle.deposit != null ? vehicle.deposit + ' 元' : '-', mono: true },
{ label: '租赁期限', value: vehicle.leasePeriodMonths != null ? vehicle.leasePeriodMonths + ' 个月' : '-' },
{ label: '交车时间', value: vehicle.actualDelivery || vehicle.deliveryTime || '-' },
{ label: '还车时间', value: vehicle.returnTime || '-' },
{ label: '交车负责人', value: vehicle.deliveryPerson },
],
};
});
}
export function buildContractViewAuditFields(record) {
if (!record) return [];
return [
{ label: '创建人', value: record.creator },
{ label: '创建时间', value: record.createTime, mono: true },
{ label: '更新人', value: record.updater },
{ label: '更新时间', value: record.updateTime, mono: true },
];
}
export function buildContractPreviewFormFromRecord(record) {
return buildLeaseContractEditFormState(record);
}

View File

@@ -82,6 +82,34 @@ export var PROVINCE_CITY_CASCADER_OPTIONS = [
},
];
export function flattenProvinceCityOptions() {
var list = [];
PROVINCE_CITY_CASCADER_OPTIONS.forEach(function (prov) {
(prov.children || []).forEach(function (city) {
list.push({
province: prov.value,
city: city.value,
label: prov.label + ' / ' + city.label,
});
});
});
return list;
}
export function matchProvinceCityOption(option, query) {
var normalized = (query || '').trim().toLowerCase();
if (!normalized) return true;
return option.province.toLowerCase().indexOf(normalized) >= 0
|| option.city.toLowerCase().indexOf(normalized) >= 0
|| option.label.toLowerCase().indexOf(normalized) >= 0;
}
export function formatDeliveryRegionDisplay(region) {
if (!region || !region.length) return '';
if (region.length >= 2) return region[0] + ' / ' + region[1];
return region[0] || '';
}
export function buildBrandModelCascaderOptions() {
return LEASE_VEHICLE_BRAND_MODEL_CATALOG.map(function (item) {
return {
@@ -96,19 +124,36 @@ export function buildBrandModelCascaderOptions() {
export var PLATE_ACTUAL_DELIVERY = '以实际交付为准';
export var PLATE_MODE_ACTUAL = 'actual';
export var PLATE_MODE_SPECIFIC = 'specific';
export var PLATE_SPECIFIC_LABEL = '选择特定车辆';
export var DELIVERY_REGION_TBD_LABEL = '交还车时约定';
export var DELIVERY_REGION_TBD_DISPLAY = '提车应收款或合同车辆子表还车时,按实际情况配置交还地点';
export var LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL = '暂未确认';
export var LEASE_DELIVERY_DATE_UNCONFIRMED_DISPLAY = '提车前通过提车应收款功能生成交车任务';
export var POA_MAX_DELEGATES = 5;
export function createEmptyLeaseOrderRow() {
return {
id: 'lo-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
brandModels: [],
vehicleQty: 1,
plateMode: PLATE_MODE_ACTUAL,
plateNos: [PLATE_ACTUAL_DELIVERY],
rentServiceSubtotal: null,
rent: null,
serviceFee: null,
deposit: null,
extraServices: [],
leasePeriodMonths: null,
leasePeriodMode: 'months',
leasePeriodMonths: 1,
leasePeriodStart: null,
leasePeriodEnd: null,
};
@@ -138,6 +183,139 @@ export function normalizePlateNos(row) {
return [PLATE_ACTUAL_DELIVERY];
}
export function getPlateMode(row) {
if (!row) return PLATE_MODE_ACTUAL;
if (row.plateMode === PLATE_MODE_SPECIFIC || row.plateMode === PLATE_MODE_ACTUAL) {
return row.plateMode;
}
var plates = normalizePlateNos(row);
if (plates.length === 1 && plates[0] === PLATE_ACTUAL_DELIVERY) {
return PLATE_MODE_ACTUAL;
}
return PLATE_MODE_SPECIFIC;
}
export function findVehicleByPlateNo(plate) {
var normalized = String(plate || '').trim().toUpperCase();
if (!normalized || normalized === PLATE_ACTUAL_DELIVERY) return null;
var i;
for (i = 0; i < vehiclesData.length; i++) {
if (String(vehiclesData[i].plateNo || '').trim().toUpperCase() === normalized) {
return vehiclesData[i];
}
}
for (i = 0; i < LEASE_READY_VEHICLE_FALLBACK.length; i++) {
if (String(LEASE_READY_VEHICLE_FALLBACK[i].plateNo || '').trim().toUpperCase() === normalized) {
return LEASE_READY_VEHICLE_FALLBACK[i];
}
}
return null;
}
export function validatePlateForLeaseOrder(plate, brandModels) {
var normalized = String(plate || '').trim().toUpperCase();
if (!normalized) {
return {
plate: plate,
ok: false,
reason: 'empty',
message: '车牌号不能为空',
};
}
var models = normalizeBrandModels({ brandModels: brandModels });
if (!models.length) {
return {
plate: normalized,
ok: false,
reason: 'no_brand_model',
message: '请先选择品牌 / 型号',
};
}
var vehicle = findVehicleByPlateNo(normalized);
if (!vehicle) {
return {
plate: normalized,
ok: false,
reason: 'not_found',
message: '未找到该车牌',
};
}
if (vehicle.vehicleStatus !== READY_VEHICLE_STATUS) {
return {
plate: normalized,
ok: false,
reason: 'not_ready',
message: '车辆状态为「' + vehicle.vehicleStatus + '」,需为已备车',
vehicleStatus: vehicle.vehicleStatus,
};
}
var brandMatched = models.some(function (pair) {
return vehicle.brand === pair[0] && vehicle.model === pair[1];
});
if (!brandMatched) {
return {
plate: normalized,
ok: false,
reason: 'brand_mismatch',
message: '品牌 / 型号不一致(车辆为 ' + formatBrandModelPair(vehicle.brand, vehicle.model) + '',
vehicleBrand: vehicle.brand,
vehicleModel: vehicle.model,
};
}
return {
plate: normalized,
ok: true,
reason: 'matched',
message: '校验通过',
resolvedPlate: vehicle.plateNo,
};
}
export function validatePlatesForLeaseOrder(plates, brandModels) {
var results = [];
var matched = [];
(plates || []).forEach(function (plate) {
var result = validatePlateForLeaseOrder(plate, brandModels);
results.push(result);
if (result.ok && result.resolvedPlate && matched.indexOf(result.resolvedPlate) < 0) {
matched.push(result.resolvedPlate);
}
});
return { results: results, matched: matched };
}
export function resolvePlateAgainstAssets(plate, brandModels) {
var result = validatePlateForLeaseOrder(plate, brandModels);
return result.ok ? result.resolvedPlate : null;
}
export function matchPlatesAgainstAssets(plates, brandModels) {
var matched = [];
(plates || []).forEach(function (plate) {
var resolved = resolvePlateAgainstAssets(plate, brandModels);
if (resolved && matched.indexOf(resolved) < 0) matched.push(resolved);
});
return matched;
}
export function syncRowPlateFields(row) {
if (!row) return row;
var next = Object.assign({}, row);
var brandModels = normalizeBrandModels(next);
var plateMode = getPlateMode(next);
next.plateMode = plateMode;
if (plateMode === PLATE_MODE_ACTUAL) {
next.plateNos = [PLATE_ACTUAL_DELIVERY];
return next;
}
var matched = matchPlatesAgainstAssets(normalizePlateNos(next), brandModels);
next.plateNos = matched;
var qty = next.vehicleQty != null ? Number(next.vehicleQty) : 1;
if (!Number.isFinite(qty) || qty <= 0) qty = 1;
if (matched.length > qty) next.vehicleQty = matched.length;
return next;
}
export function formatBrandModelPair(brand, model) {
if (!brand && !model) return '';
if (!model) return brand;
@@ -151,24 +329,33 @@ export function formatBrandModelsDisplay(brandModels) {
}).filter(Boolean);
}
export function getRowVehicleCountForPricing(row, order) {
export function getRowVehicleQty(row) {
if (!row) return 0;
var plates = normalizePlateNos(row);
var actualPlates = plates.filter(function (plate) {
return plate !== PLATE_ACTUAL_DELIVERY;
});
if (actualPlates.length > 0) return actualPlates.length;
if (plates.indexOf(PLATE_ACTUAL_DELIVERY) >= 0) {
var rows = (order && order.rows) || [];
var allActualOnly = rows.length > 0 && rows.every(function (item) {
var itemPlates = normalizePlateNos(item);
return itemPlates.length === 1 && itemPlates[0] === PLATE_ACTUAL_DELIVERY;
});
if (allActualOnly && rows.length === 1) {
return calcInsuredVehicleCount(order) || 1;
}
return 1;
}
return 0;
var qty = row.vehicleQty != null ? Number(row.vehicleQty) : 1;
if (!Number.isFinite(qty) || qty <= 0) return 1;
return Math.floor(qty);
}
export function getRowVehicleCountForPricing(row) {
return getRowVehicleQty(row) || 1;
}
export function calcInsuredVehicleCount(order) {
var rows = (order && order.rows) || [];
if (!rows.length) return 0;
return rows.reduce(function (sum, row) {
return sum + getRowVehicleQty(row);
}, 0);
}
/** @deprecated 车辆数由各行数量自动汇总,不再支持手动编辑 */
export function isInsuredVehicleCountEditable() {
return false;
}
export function countNationalInStockByBrandModel(brand, model) {
@@ -180,6 +367,67 @@ export function countNationalInStockByBrandModel(brand, model) {
}).length;
}
var IN_STOCK_MUNICIPALITIES = ['北京市', '上海市', '天津市', '重庆市'];
function parseInStockProvinceCity(location) {
if (!location) return { province: '未登记', city: '未登记' };
var loc = String(location).trim();
var i;
for (i = 0; i < IN_STOCK_MUNICIPALITIES.length; i++) {
var municipality = IN_STOCK_MUNICIPALITIES[i];
if (loc.indexOf(municipality) === 0) {
return { province: municipality, city: municipality };
}
}
var provMatch = loc.match(/^(.*?(?:省|自治区))/);
if (!provMatch) {
var cityOnly = loc.match(/^(.+?市)/);
if (cityOnly) return { province: cityOnly[1], city: cityOnly[1] };
return { province: '未登记', city: '未登记' };
}
var province = provMatch[1];
var rest = loc.slice(provMatch[1].length);
var cityMatch = rest.match(/^(.+?市)/) || rest.match(/^(.+?(?:州|盟|地区))/);
return {
province: province,
city: cityMatch ? cityMatch[1] : '省内其他',
};
}
/** 按品牌型号统计在库车辆,并按省、市分组 */
export function getInStockBreakdownByBrandModel(brand, model) {
if (!brand || !model) {
return { total: 0, regions: [] };
}
var grouped = {};
vehiclesData.forEach(function (vehicle) {
if (vehicle.brand !== brand || vehicle.model !== model) return;
if (vehicle.operateStatus !== '可运营' && vehicle.operateStatus !== '待运营') return;
var parts = parseInStockProvinceCity(vehicle.location);
var province = parts.province;
var city = parts.city;
if (!grouped[province]) grouped[province] = {};
grouped[province][city] = (grouped[province][city] || 0) + 1;
});
var regions = Object.keys(grouped).sort(function (a, b) {
return a.localeCompare(b, 'zh-CN');
}).map(function (province) {
var cities = Object.keys(grouped[province]).sort(function (a, b) {
return a.localeCompare(b, 'zh-CN');
}).map(function (city) {
return { city: city, count: grouped[province][city] };
});
var provinceTotal = cities.reduce(function (sum, item) {
return sum + item.count;
}, 0);
return { province: province, total: provinceTotal, cities: cities };
});
return {
total: countNationalInStockByBrandModel(brand, model),
regions: regions,
};
}
/** 车型最低租金配置(原型);键为 brand|model */
export var LEASE_VEHICLE_MIN_RENT_BY_MODEL = {
'飞驰|集卡头': 15000,
@@ -215,48 +463,16 @@ export function hasLeaseOrderRentBelowMinimum(order) {
});
}
export function calcInsuredVehicleCount(order) {
var rows = (order && order.rows) || [];
var autoFromPlates = 0;
var hasActualOnly = false;
rows.forEach(function (row) {
var plates = normalizePlateNos(row);
var actualPlates = plates.filter(function (plate) {
return plate !== PLATE_ACTUAL_DELIVERY;
});
if (actualPlates.length > 0) {
autoFromPlates += actualPlates.length;
return;
}
if (plates.indexOf(PLATE_ACTUAL_DELIVERY) >= 0) {
hasActualOnly = true;
}
});
if (autoFromPlates > 0) return autoFromPlates;
if (hasActualOnly) {
var manual = order && order.insuredVehicleCount != null ? Number(order.insuredVehicleCount) : 0;
if (Number.isFinite(manual) && manual > 0) return Math.floor(manual);
return 1;
}
return 0;
}
export function isInsuredVehicleCountEditable(order) {
var rows = (order && order.rows) || [];
return rows.some(function (row) {
var plates = normalizePlateNos(row);
return plates.length === 1 && plates[0] === PLATE_ACTUAL_DELIVERY;
}) && !rows.some(function (row) {
return normalizePlateNos(row).some(function (plate) {
return plate !== PLATE_ACTUAL_DELIVERY;
});
});
}
export function formatLeasePeriod(row) {
if (!row) return '';
if (row.leasePeriodMode === 'fixed' || (row.leasePeriodStart && row.leasePeriodEnd && !row.leasePeriodMonths)) {
if (row.leasePeriodStart && row.leasePeriodEnd) {
return row.leasePeriodStart + ' ~ ' + row.leasePeriodEnd;
}
return row.leasePeriodStart || row.leasePeriodEnd || '';
}
if (row.leasePeriodMonths != null && row.leasePeriodMonths !== '') {
return String(row.leasePeriodMonths) + ' 个月';
return '提车起算 ' + String(row.leasePeriodMonths) + ' 个月';
}
if (row.leasePeriodStart && row.leasePeriodEnd) {
return row.leasePeriodStart + ' ~ ' + row.leasePeriodEnd;
@@ -264,44 +480,196 @@ export function formatLeasePeriod(row) {
return row.leasePeriodStart || row.leasePeriodEnd || '';
}
/** 租赁订单附加服务按类目分组fee 仅作原型参考,不在选择器中展示) */
export function normalizeExtraServices(row) {
if (!row) return [];
var values = [];
if (Array.isArray(row.extraServices)) values = row.extraServices.slice();
else if (row.extraService) values = [row.extraService];
return values.filter(function (value) {
return Boolean(getExtraServiceByValue(value));
});
}
export function calcRowServiceFee(row) {
return sumFixedExtraServiceFees(normalizeExtraServices(row));
}
export function formatServiceFeeAmount(value) {
var num = value != null && value !== '' ? Number(value) : 0;
if (!Number.isFinite(num)) num = 0;
return num.toFixed(2);
}
export function syncRowPricingFields(row) {
var next = Object.assign({}, row);
next.serviceFee = calcRowServiceFee(next);
next.rentServiceSubtotal = calcRentServiceSubtotal(
next.rent,
next.serviceFee,
getRowVehicleCountForPricing(next),
);
return next;
}
export function formatLeaseOrderDeliveryRegion(order) {
if (!order) return '-';
if (order.deliveryRegionMode === 'tbd' || order.deliveryRegionTbd) {
return DELIVERY_REGION_TBD_LABEL;
}
var region = order.deliveryRegion || [];
if (!region.length) return '-';
return region.join(' / ');
}
export function formatLeaseOrderDeliveryDate(order) {
if (!order) return '-';
if (order.deliveryDateMode === 'unconfirmed' || order.deliveryDateTbd) {
return LEASE_DELIVERY_DATE_UNCONFIRMED_LABEL;
}
if (order.deliveryDateStart && order.deliveryDateEnd) {
return order.deliveryDateStart + ' ~ ' + order.deliveryDateEnd;
}
if (order.deliveryDate) return order.deliveryDate;
return '-';
}
export function normalizeLeaseOrderState(order) {
var base = order || createDefaultLeaseOrderState();
var rows = (base.rows || [createEmptyLeaseOrderRow()]).map(function (row) {
var next = Object.assign({}, createEmptyLeaseOrderRow(), row);
if (!next.leasePeriodMode) {
next.leasePeriodMode = (next.leasePeriodStart && next.leasePeriodEnd && !next.leasePeriodMonths)
? 'fixed'
: 'months';
}
if (next.leasePeriodMode === 'months' && (next.leasePeriodMonths == null || next.leasePeriodMonths === '')) {
next.leasePeriodMonths = 1;
}
return syncRowPricingFields(syncRowPlateFields(next));
});
var normalized = Object.assign({}, createDefaultLeaseOrderState(), base, {
rows: rows.length ? rows : [createEmptyLeaseOrderRow()],
deliveryRegionMode: base.deliveryRegionMode
|| (base.deliveryRegionTbd ? 'tbd' : ((base.deliveryRegion && base.deliveryRegion.length) ? 'region' : 'tbd')),
deliveryDateMode: base.deliveryDateMode
|| (base.deliveryDateTbd ? 'unconfirmed' : ((base.deliveryDateStart && base.deliveryDateEnd) ? 'range' : 'unconfirmed')),
deliveryDateStart: base.deliveryDateStart || null,
deliveryDateEnd: base.deliveryDateEnd || null,
deliveryDateTbd: Boolean(base.deliveryDateTbd || base.deliveryDateMode === 'unconfirmed'),
deliveryRegionTbd: Boolean(base.deliveryRegionTbd || base.deliveryRegionMode === 'tbd'),
});
normalized.insuredVehicleCount = calcInsuredVehicleCount(normalized);
return normalized;
}
/** 租赁订单服务项:固定费用 / 浮动费用 */
export var LEASE_SERVICE_FEE_TYPE = {
FIXED: 'fixed',
FLOATING: 'floating',
};
export var LEASE_EXTRA_SERVICE_TREE = [
{
category: '交还车服务',
items: [
{ value: 'door-delivery', name: '送车服务费', fee: 300 },
{ value: 'door-pickup', name: '接车服务费', fee: 300 },
category: '固定费用',
feeType: LEASE_SERVICE_FEE_TYPE.FIXED,
subCategories: [
{
name: '无忧包',
items: [
{ value: 'wear-insurance', name: '易损保', fee: 80, unitLabel: '元/车/月' },
{ value: 'maintenance-insurance', name: '养护保', fee: 200, unitLabel: '元/车/月' },
{ value: 'tire-insurance', name: '轮胎保', fee: 100, unitLabel: '元/车/月' },
],
},
{
name: '交还车服务',
items: [
{ value: 'door-delivery', name: '送车服务费', fee: 300, unitLabel: '元/车/月' },
{ value: 'door-pickup', name: '接车服务费', fee: 300, unitLabel: '元/车/月' },
],
},
{
name: '车辆加装',
items: [
{ value: 'tailgate', name: '尾板', fee: 150, unitLabel: '元/车/月' },
],
},
],
},
{
category: '增值保障',
items: [
{ value: 'carefree-pack', name: '无忧包', fee: 500 },
{ value: 'tire-insurance', name: '轮胎保', fee: 200 },
{ value: 'maintenance-insurance', name: '养护保', fee: 180 },
],
},
{
category: '车辆加装',
items: [
{ value: 'tailgate', name: '尾板', fee: 150 },
category: '浮动费用',
feeType: LEASE_SERVICE_FEE_TYPE.FLOATING,
subCategories: [
{
name: '里程计费',
items: [
{
value: 'mileage-maintenance',
name: '维保费',
billingRule: '0.10 元/公里,按实际行驶里程计入租赁账单',
},
{
value: 'transport-risk',
name: '运保费',
billingRule: '0.08 元/公里,按实际行驶里程计入租赁账单',
},
],
},
{
name: '使用期计费',
items: [
{
value: 'usage-extra',
name: '超期使用费',
billingRule: '按车辆实际使用天数核算,计入后续租赁账单',
},
{
value: 'energy-adjust',
name: '能源费补缴',
billingRule: '按氢量/电量实际用量与合同约定单价核算',
},
],
},
],
},
];
export var LEASE_EXTRA_SERVICE_CATALOG = LEASE_EXTRA_SERVICE_TREE.reduce(function (list, group) {
return list.concat(group.items);
}, []);
function forEachExtraServiceItem(callback) {
LEASE_EXTRA_SERVICE_TREE.forEach(function (root) {
(root.subCategories || []).forEach(function (sub) {
(sub.items || []).forEach(function (item) {
callback(item, root, sub);
});
});
});
}
function buildExtraServiceCatalog() {
var list = [];
forEachExtraServiceItem(function (item, root, sub) {
list.push(Object.assign({}, item, {
feeType: root.feeType,
category: root.category,
subCategory: sub.name,
}));
});
return list;
}
export var LEASE_EXTRA_SERVICE_CATALOG = buildExtraServiceCatalog();
export function flattenExtraServiceOptions() {
var options = [];
LEASE_EXTRA_SERVICE_TREE.forEach(function (group) {
group.items.forEach(function (item) {
options.push({
value: item.value,
name: item.name,
category: group.category,
});
forEachExtraServiceItem(function (item, root, sub) {
options.push({
value: item.value,
name: item.name,
category: root.category,
subCategory: sub.name,
feeType: root.feeType,
fee: item.fee,
unitLabel: item.unitLabel,
billingRule: item.billingRule,
});
});
return options;
@@ -311,19 +679,19 @@ export function matchExtraServiceOption(option, query) {
var normalized = (query || '').trim().toLowerCase();
if (!normalized) return true;
return option.name.toLowerCase().indexOf(normalized) >= 0
|| option.category.toLowerCase().indexOf(normalized) >= 0;
|| (option.category || '').toLowerCase().indexOf(normalized) >= 0
|| (option.subCategory || '').toLowerCase().indexOf(normalized) >= 0
|| (option.billingRule || '').toLowerCase().indexOf(normalized) >= 0;
}
export function getExtraServiceCategoryByValue(value) {
for (var i = 0; i < LEASE_EXTRA_SERVICE_TREE.length; i++) {
var group = LEASE_EXTRA_SERVICE_TREE[i];
for (var j = 0; j < group.items.length; j++) {
if (group.items[j].value === value) {
return group.category;
}
}
}
return '';
var item = getExtraServiceByValue(value);
return item ? item.category : '';
}
export function getExtraServiceSubCategoryByValue(value) {
var item = getExtraServiceByValue(value);
return item ? item.subCategory : '';
}
export var LEASE_EXTRA_SERVICE_OPTIONS = LEASE_EXTRA_SERVICE_CATALOG.map(function (item) {
@@ -334,15 +702,13 @@ export var LEASE_EXTRA_SERVICE_OPTIONS = LEASE_EXTRA_SERVICE_CATALOG.map(functio
});
export function getExtraServiceFeeByValue(value) {
for (var i = 0; i < LEASE_EXTRA_SERVICE_CATALOG.length; i++) {
if (LEASE_EXTRA_SERVICE_CATALOG[i].value === value) {
return LEASE_EXTRA_SERVICE_CATALOG[i].fee;
}
}
return 0;
var item = getExtraServiceByValue(value);
if (!item || item.feeType !== LEASE_SERVICE_FEE_TYPE.FIXED) return 0;
return item.fee != null ? Number(item.fee) : 0;
}
export function getExtraServiceByValue(value) {
if (!value) return null;
for (var i = 0; i < LEASE_EXTRA_SERVICE_CATALOG.length; i++) {
if (LEASE_EXTRA_SERVICE_CATALOG[i].value === value) {
return LEASE_EXTRA_SERVICE_CATALOG[i];
@@ -359,14 +725,63 @@ export function getExtraServiceNames(selectedValues) {
});
}
export function normalizeExtraServices(row) {
if (!row) return [];
if (Array.isArray(row.extraServices)) return row.extraServices;
if (row.extraService) return [row.extraService];
return [];
export function formatExtraServiceUnitPrice(item) {
if (!item || item.feeType !== LEASE_SERVICE_FEE_TYPE.FIXED) return '';
var fee = item.fee != null ? item.fee : 0;
return fee.toLocaleString('zh-CN') + (item.unitLabel || '元/车/月');
}
export function sumExtraServiceFees(selectedValues) {
export function getExtraFeeLockedServiceValues(existingRows) {
var values = [];
(existingRows || []).forEach(function (row) {
var rowValues = row.serviceValues || [];
if (!rowValues.length && row.serviceValue) {
rowValues = [row.serviceValue];
}
rowValues.forEach(function (value) {
if (value && values.indexOf(value) < 0) values.push(value);
});
});
return values;
}
export function getExtraFeeNewServiceValues(selectedValues, lockedValues) {
return (selectedValues || []).filter(function (value) {
return (lockedValues || []).indexOf(value) < 0;
});
}
export function formatExtraFeeExistingServiceLabel(serviceValues) {
var names = getExtraServiceNames(serviceValues || []);
return names.length ? names.join('、') : '历史附加服务项';
}
export function formatExtraServicesDisplay(selectedValues) {
if (!selectedValues || !selectedValues.length) return '';
return selectedValues.map(function (value) {
var item = getExtraServiceByValue(value);
if (!item) return value;
if (item.feeType === LEASE_SERVICE_FEE_TYPE.FIXED) {
return item.name + '' + formatExtraServiceUnitPrice(item) + '';
}
return item.name + '' + (item.billingRule || '浮动计费') + '';
}).join('、');
}
/** 合同预览:服务内容每行一条 */
export function formatExtraServicesPreviewLines(selectedValues) {
if (!selectedValues || !selectedValues.length) return [];
return selectedValues.map(function (value) {
var item = getExtraServiceByValue(value);
if (!item) return value;
if (item.feeType === LEASE_SERVICE_FEE_TYPE.FIXED) {
return item.name + '' + formatExtraServiceUnitPrice(item) + '';
}
return item.name + '' + (item.billingRule || '浮动计费');
});
}
export function sumFixedExtraServiceFees(selectedValues) {
if (!selectedValues || !selectedValues.length) return 0;
var total = 0;
selectedValues.forEach(function (value) {
@@ -375,9 +790,9 @@ export function sumExtraServiceFees(selectedValues) {
return total;
}
export function formatExtraServicesDisplay(selectedValues) {
if (!selectedValues || !selectedValues.length) return '';
return getExtraServiceNames(selectedValues).join('、');
/** @deprecated 仅统计固定费用;浮动费用不计入合同初始服务费 */
export function sumExtraServiceFees(selectedValues) {
return sumFixedExtraServiceFees(selectedValues);
}
export function calcRentServiceSubtotal(rent, serviceFee, vehicleCount) {
@@ -408,7 +823,7 @@ export function calcLeaseOrderKpis(rows, order) {
var hasSubtotal = false;
var hasDeposit = false;
(rows || []).forEach(function (row) {
var vehicleCount = getRowVehicleCountForPricing(row, order || { rows: rows });
var vehicleCount = getRowVehicleCountForPricing(row);
var subtotal = calcRentServiceSubtotal(row.rent, row.serviceFee, vehicleCount);
if (subtotal != null) {
rentServiceTaxTotal += subtotal;
@@ -476,7 +891,13 @@ export function createDefaultLeaseOrderState() {
insuredVehicleCount: 1,
thirdPartyLiabilityMillion: null,
deliveryRegion: [],
deliveryRegionMode: 'tbd',
deliveryRegionTbd: true,
deliveryDate: null,
deliveryDateStart: null,
deliveryDateEnd: null,
deliveryDateMode: 'unconfirmed',
deliveryDateTbd: true,
rows: [createEmptyLeaseOrderRow()],
};
}

View File

@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderMermaidInMarkdown } from './render-mermaid-blocks.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const protoRoot = path.resolve(__dirname, '..');
@@ -10,10 +11,19 @@ const resourcesPrd = path.resolve(protoRoot, '../../resources/lease-contract-man
const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8');
const createPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-create.md'), 'utf8');
const fullPrd = fs.readFileSync(resourcesPrd, 'utf8');
const flowPrdRaw = fs.readFileSync(path.join(specRoot, 'requirements-flow-operations.md'), 'utf8');
const annotationContextPrd = fs.readFileSync(path.join(specRoot, 'requirements-annotation-context.md'), 'utf8');
const [flowPrd, fullPrdRendered, listPrdRendered, createPrdRendered] = await Promise.all([
renderMermaidInMarkdown(flowPrdRaw, '操作流程图'),
renderMermaidInMarkdown(fullPrd, '流程图'),
renderMermaidInMarkdown(listPrd, '流程图'),
renderMermaidInMarkdown(createPrd, '流程图'),
]);
const NODE_DEFS = [
{ id: 'lc-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项,可展开至 12 项;查询与 KPI 联动。' },
{ id: 'lc-list-kpi', title: 'KPI 统计卡片', pageId: 'list', color: '#64748b', aiPrompt: '五张 KPI 卡片口径与点击筛选。', annotationText: '全部 / 进行中 / 审批中 / 临期 / 已终止。' },
{ id: 'lc-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项,可展开至 13 项;合同模板联动标准合同名称;查询与 KPI 联动。' },
{ id: 'lc-list-kpi', title: 'KPI 统计卡片', pageId: 'list', color: '#64748b', aiPrompt: '五张 KPI 卡片口径与点击筛选。', annotationText: '全部 / 草稿 / 进行中 / 审批中 / 已终止。' },
{ id: 'lc-fleet-summary', title: '在租车辆概览', pageId: 'list', color: '#32a06e', aiPrompt: '按品牌型号统计在租车辆,支持品牌标签筛选。', annotationText: '顶部展示在租总数;品牌标签筛选车型卡片;点击卡片查看车辆明细。' },
{ id: 'lc-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '费用模板、导出、新增入口。', annotationText: '「新增」进入租赁合同创建页。' },
{ id: 'lc-list-table', title: '合同台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '台账表格列与操作。', annotationText: '' },
@@ -21,7 +31,9 @@ const NODE_DEFS = [
{ id: 'lc-action-convert-tripartite', title: '转三方合同', pageId: 'list', color: '#7c3aed', aiPrompt: '进行中合同转三方。', annotationText: '' },
{ id: 'lc-action-trial-to-formal', title: '试用转正式', pageId: 'list', color: '#0f766e', aiPrompt: '试用合同一步转正式。', annotationText: '' },
{ id: 'lc-create-main-contract', title: '主体合同信息', pageId: 'create', color: '#32a06e', aiPrompt: '签约、里程、费用三块表单。', annotationText: '完成度徽章;甲乙档案只读同步。' },
{ id: 'lc-create-signing-method', title: '合同签署方式', pageId: 'create', color: '#0369a1', aiPrompt: '线上电子签章或线下人工上传。', annotationText: '影响电子签发送或盖章合同补传闭环。' },
{ id: 'lc-card-signing', title: '签约信息', pageId: 'create', color: '#0d9488', aiPrompt: '合同编号、甲乙双方与乙方负责人。', annotationText: '' },
{ id: 'lc-credentials-ocr', title: '客户资质证照 OCR', pageId: 'create', color: '#dc2626', aiPrompt: '客户证照 OCR 识别与有效期校验。', annotationText: '' },
{ id: 'lc-signing-customer-principal', title: '乙方负责人', pageId: 'create', color: '#0d9488', aiPrompt: '乙方签约对接负责人。', annotationText: '' },
{ id: 'lc-create-lease-order', title: '附件1租赁订单', pageId: 'create', color: '#2563eb', aiPrompt: '订单车辆与交车信息。', annotationText: '三者责任险等为完成度必填项。' },
{ id: 'lc-lease-order-brand-model', title: '品牌车型与在库', pageId: 'create', color: '#0f766e', aiPrompt: '选择车型后展示全国在库台数。', annotationText: '运营状态为「可运营」「待运营」计为在库;数据来自车辆管理主数据。' },
@@ -30,11 +42,12 @@ const NODE_DEFS = [
{ id: 'lc-lease-order-lease-period', title: '租赁期限与到期计算', pageId: 'create', color: '#0369a1', aiPrompt: '租期两种计算模式与合同到期规则。', annotationText: '' },
{ id: 'lc-create-poa', title: '授权委托书', pageId: 'create', color: '#7c3aed', aiPrompt: '受托人列表编辑。', annotationText: '至少一行且姓名/联系方式/身份证完整。' },
{ id: 'lc-create-contract-remark', title: '合同备注', pageId: 'create', color: '#64748b', aiPrompt: '独立卡片填写合同备注。', annotationText: '选填,最多 500 字;与授权委托书分离。' },
{ id: 'lc-create-seal', title: '用章类型', pageId: 'create', color: '#7c3aed', aiPrompt: '电子签章用章类型多选。', annotationText: '' },
{ id: 'lc-create-preview', title: '实时预览', pageId: 'create', color: '#0ea5e9', aiPrompt: 'Word 版式预览与可编辑正文。', annotationText: '改动风控红线条款将触发非标准合同。' },
{ id: 'lc-create-footer', title: '底栏操作', pageId: 'create', color: '#64748b', aiPrompt: '取消、保存草稿、提交审核。', annotationText: '保存/提交时展示将走的审批类型。' },
];
const prdByKey = { list: listPrd, create: createPrd };
const prdByKey = { list: listPrdRendered, create: createPrdRendered };
function extractSection(md, startHeading) {
const lines = md.split('\n');
@@ -110,9 +123,52 @@ const CUSTOM_NODE_MARKDOWN = {
选择乙方后,在签约信息中补充填写:
- **乙方负责人姓名**:签约对接负责人,可与客户档案中的联系人不同
- **乙方负责人手机号**用于履约沟通与通知
- **乙方负责人手机号**E签宝电子合同签章时乙方经办人
选填;用于合同履约联系,不覆盖客户档案只读信息。`,
**签署闭环:**
- **线上电子签章**:审批通过后自动向该手机号发送 E签宝电子签章文件
- **线下人工上传**:审批通过不发送电子签,需在列表「盖章合同补传」上传 PDF/图片,查看时可预览与下载全部附件`,
'lc-fleet-summary': `# 在租车辆概览
按品牌型号统计在租车辆,支持品牌标签筛选。
## 说明
顶部展示在租总数;品牌标签筛选车型卡片;点击卡片查看车辆明细。`,
'lc-credentials-ocr': `# 客户资质证照OCR 与有效期)
对 **客户营业执照**、**法人身份证正反面**、**道路运输许可证** 做 OCR 识别,校验有效期。
| 规则 | 说明 |
|---|---|
| OCR | 上传后自动识别证照信息与有效期 |
| 即将到期 | 距到期 ≤ 3 个月时,工作台提前生成待办推送给业管 |
| 已过期 | 业管未更新前,禁止提交新合同 |
| 页面展示 | 缩略图旁显示 OCR 标识、有效期与状态徽章;临期/过期时顶部展示提示条`,
'lc-create-seal': `# 用章类型(第 6 步)
支持多选,可选范围仅包含:
| 用章 | 说明 |
|---|---|
| 合同章 | 合同正文用章 |
| 公章 | 公司公章 |
| 法人章 | 法定代表人章 |
**不含**财务章、发票章。
默认选中合同章;至少选择一种用章后方可提交审核。
**提示**:额外勾选公章、法人章会进入非标审批流程。`,
'lc-create-signing-method': `# 合同签署方式
与「选择合同类型」同一步配置,可选:
| 方式 | 说明 |
|---|---|
| 线上电子签章 | 审批通过后向乙方负责人手机号发送 E签宝文件并自动归档 |
| 线下人工上传 | 不发送电子签,审批通过后通过列表「盖章合同补传」完成闭环 |`,
'lc-lease-order-lease-period': `# 租赁期限与到期计算
租赁到期时间支持两种计算模式:
@@ -204,7 +260,32 @@ const markdownMap = Object.fromEntries(nodes.map((node) => [node.id, buildNodeMa
const listPageNodes = nodes.filter((n) => n.pageId === 'list');
const createPageNodes = nodes.filter((n) => n.pageId === 'create');
const overviewMd = extractSection(fullPrd, '## 1. 背景与目标') ?? '# 租赁合同管理';
function buildOverviewMarkdown(fullPrdText, listPrdText) {
const parts = [
'# 租赁合同管理 · 模块总览',
'商用车租赁合同全生命周期管理:台账筛选与 KPI 监控、结构化草拟与实时预览、标准/非标准审批判定、进行中变更与法务归档。',
];
const modulePosition = extractSection(listPrdText, '## 1. 模块定位');
if (modulePosition) parts.push(modulePosition);
for (const heading of [
'## 1. 背景与目标',
'## 2. 用户与场景',
'## 3. 名词解释',
'## 4. 信息架构',
'## 6. 业务规则(全局)',
'## 9. 与其他模块的关系',
'## 12. 验收标准',
]) {
const body = extractSection(fullPrdText, heading);
if (body) parts.push(body);
}
return parts.join('\n\n---\n\n').trim() || '# 租赁合同管理';
}
const overviewMd = buildOverviewMarkdown(fullPrdRendered, listPrdRendered);
const source = {
documentVersion: 1,
@@ -249,28 +330,98 @@ const source = {
type: 'markdown',
id: 'lc-doc-prd-full',
title: 'PRD 完整文档',
markdown: fullPrd,
markdown: fullPrdRendered,
markdownPath: 'src/resources/lease-contract-management/PRD.md',
},
{
type: 'markdown',
id: 'lc-doc-prd-list',
title: '列表页 PRD',
markdown: listPrd,
markdown: listPrdRendered,
markdownPath: 'src/prototypes/lease-contract-management/.spec/requirements-prd-list.md',
},
{
type: 'markdown',
id: 'lc-doc-prd-create',
title: '新增页 PRD',
markdown: createPrd,
markdown: createPrdRendered,
markdownPath: 'src/prototypes/lease-contract-management/.spec/requirements-prd-create.md',
},
{
type: 'markdown',
id: 'lc-doc-prd-acceptance',
title: '验收标准',
markdown: extractSection(fullPrd, '## 12. 验收标准') ?? '## 验收标准',
markdown: extractSection(fullPrdRendered, '## 12. 验收标准') ?? '## 验收标准',
},
{
type: 'markdown',
id: 'lc-doc-annotation-context',
title: '标注与原型上下文',
markdown: annotationContextPrd,
markdownPath: 'src/prototypes/lease-contract-management/.spec/requirements-annotation-context.md',
},
],
},
{
type: 'folder',
id: 'lc-doc-flows',
title: '操作流程',
defaultExpanded: false,
children: [
{
type: 'markdown',
id: 'lc-doc-flow-overview',
title: '全流程总览',
markdown: extractSection(flowPrd, '## 1. 总览:从新增到履约结束') ?? flowPrd,
markdownPath: 'src/prototypes/lease-contract-management/.spec/requirements-flow-operations.md',
},
{
type: 'markdown',
id: 'lc-doc-flow-create-forward',
title: '新增合同 · 正向',
markdown: extractSection(flowPrd, '## 2. 新增合同 · 正向流程') ?? '',
},
{
type: 'markdown',
id: 'lc-doc-flow-create-reverse',
title: '新增合同 · 逆向',
markdown: extractSection(flowPrd, '## 3. 新增合同 · 逆向流程') ?? '',
},
{
type: 'markdown',
id: 'lc-doc-flow-fulfill',
title: '履约 · 交还车',
markdown: extractSection(flowPrd, '## 4. 履约正向:交车 → 还车 → 结清') ?? '',
},
{
type: 'markdown',
id: 'lc-doc-flow-fulfill-reverse',
title: '履约 · 逆向与异常',
markdown: extractSection(flowPrd, '## 5. 履约逆向与异常') ?? '',
},
{
type: 'markdown',
id: 'lc-doc-flow-change-forward',
title: '进行中变更 · 正向',
markdown: extractSection(flowPrd, '## 6. 进行中变更 · 正向流程') ?? '',
},
{
type: 'markdown',
id: 'lc-doc-flow-change-reverse',
title: '进行中变更 · 逆向',
markdown: extractSection(flowPrd, '## 7. 进行中变更 · 逆向流程') ?? '',
},
{
type: 'markdown',
id: 'lc-doc-flow-delivery-edit',
title: '交车安排编辑',
markdown: extractSection(flowPrd, '## 8. 交车安排编辑(列表与子表)') ?? '',
},
{
type: 'markdown',
id: 'lc-doc-flow-matrix',
title: '操作与状态对照表',
markdown: extractSection(flowPrd, '## 9. 操作与状态对照表') ?? '',
},
],
},
@@ -320,7 +471,7 @@ const source = {
type: 'markdown',
id: 'lc-doc-annotation-hint',
title: '标注查看提示',
markdown: '## 标注查看提示\n\n右侧标注工具栏可浏览目录、切换主题与颜色筛选。列表页与新增页标注按当前页面自动显隐。\n\n**PRD 同步**:修改 `src/resources/lease-contract-management/PRD.md` 或 `.spec/requirements-prd-*.md` 后,运行 `node src/prototypes/lease-contract-management/scripts/build-annotation-source.mjs` 重新生成 `annotation-source.json`。',
markdown: '## 标注查看提示\n\n右侧标注工具栏可浏览目录、切换主题与颜色筛选。列表页与新增页标注按当前页面自动显隐。\n\n**PRD 同步**:修改 `src/resources/lease-contract-management/PRD.md` 或 `.spec/requirements-prd-*.md`、`.spec/requirements-flow-operations.md` 后,运行 `node src/prototypes/lease-contract-management/scripts/build-annotation-source.mjs` 重新生成 `annotation-source.json`(含 Mermaid 流程图预渲染为 SVG 图片)。',
},
],
},

View File

@@ -0,0 +1,79 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '../../../..');
const mmdcBin = path.join(projectRoot, 'node_modules/.bin/mmdc');
const MERMAID_BLOCK_RE = /```mermaid\n([\s\S]*?)```/g;
function renderMermaidToSvg(code) {
if (!fs.existsSync(mmdcBin)) {
throw new Error('mmdc not found. Run: npm install -D @mermaid-js/mermaid-cli');
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-mermaid-'));
const inputPath = path.join(tempDir, 'diagram.mmd');
const outputPath = path.join(tempDir, 'diagram.svg');
try {
fs.writeFileSync(inputPath, code.trim(), 'utf8');
execFileSync(
mmdcBin,
['-i', inputPath, '-o', outputPath, '-b', 'transparent', '-t', 'neutral'],
{ stdio: 'pipe' },
);
return fs.readFileSync(outputPath, 'utf8');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function svgToMarkdownImage(svg, alt = '流程图') {
const base64 = Buffer.from(svg, 'utf8').toString('base64');
return `\n\n![${alt}](data:image/svg+xml;base64,${base64})\n\n`;
}
/**
* Replace ```mermaid fenced blocks with inline SVG images (data URI).
* Annotation markdown renderer supports ![alt](data:image/svg+xml;base64,...).
*/
export async function renderMermaidInMarkdown(markdown, alt = '流程图') {
if (!markdown || !markdown.includes('```mermaid')) {
return markdown;
}
const blocks = [];
let match;
const re = new RegExp(MERMAID_BLOCK_RE.source, 'g');
while ((match = re.exec(markdown)) !== null) {
blocks.push({ start: match.index, end: match.index + match[0].length, code: match[1] });
}
if (blocks.length === 0) return markdown;
const rendered = blocks.map((block, index) => {
try {
const svg = renderMermaidToSvg(block.code);
return { ...block, image: svgToMarkdownImage(svg, `${alt} ${index + 1}`) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[mermaid] render failed for block ${index + 1}: ${message}`);
return {
...block,
image: `\n\n> ⚠️ 流程图渲染失败:${message}\n\n\`\`\`mermaid\n${block.code}\`\`\`\n\n`,
};
}
});
let result = '';
let cursor = 0;
for (const block of rendered) {
result += markdown.slice(cursor, block.start);
result += block.image;
cursor = block.end;
}
result += markdown.slice(cursor);
return result;
}

View File

@@ -61,7 +61,7 @@ vm-page ldb-page
- 底部分页:使用 `src/common/TablePagination.tsx`,布局见 `vm-shared/DESIGN.md`
- 表头排序:`ldb-th-sort`;列宽:`ldb-col-resize-handle`
- 金额列:`ldb-col-money` + `tabular-nums`**与文本列统一左对齐**(含明细弹层触发按钮 `ldb-detail-popover-trigger`
- 操作列:`ldb-action-btn--edit` / `--delete`,右冻结 `sticky-col-right`
- 操作列:使用 `src/common/OperationActions`(详情/编辑外显 + ⋮ 更多),右冻结 `sticky-col-right`;规范见 [`vm-shared/DESIGN.md`](../vm-shared/DESIGN.md) OperationActions 章节。`ldb-action-btn--*` 仅用于表单内嵌行操作等例外场景
- 勾选列:左冻结 `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` 类名

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "oneos-prototype-nav",
"pageId": "nav",
"updatedAt": 1783600000000,
"updatedAt": 1783800844967,
"nodes": [
{
"id": "opn-hero",

View File

@@ -30,6 +30,8 @@ import {
iconForLink,
loadNavSections,
loadRecentUpdates,
resolvePrototypeHref,
linkHref,
type NavLinkItem,
type NavSection,
type NavSubGroup,
@@ -317,6 +319,13 @@ function ChangelogEntryRow({
</time>
</div>
<p className="opn-changelog-summary">{entry.summary}</p>
{entry.details && entry.details.length > 0 && (
<ul className="opn-changelog-details">
{entry.details.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
)}
{entry.files.length > 0 && (
<details className="opn-changelog-files">
<summary>
@@ -402,7 +411,7 @@ function ChangelogDetailPanel({
<div className="opn-detail-toolbar">
<a
className="opn-detail-open-btn"
href={link.href}
href={linkHref(link)}
{...NEW_TAB_LINK_PROPS}
aria-label={`查看 ${link.title} 原型(在新标签页打开)`}
>
@@ -474,7 +483,7 @@ function RecentUpdatesPanel({
onClick={() => onSelectLink({
id: item.prototypeId,
title: item.title,
href: `/prototypes/${item.prototypeId}`,
href: resolvePrototypeHref(item.prototypeId),
prototypeId: item.prototypeId,
version: item.version,
})}
@@ -482,6 +491,11 @@ function RecentUpdatesPanel({
<span className="opn-updates-link-main">
<span className="opn-updates-link-title">{item.title}</span>
<span className="opn-updates-summary">{item.summary}</span>
{item.details && item.details.length > 0 && (
<span className="opn-updates-detail">
{item.details[0]}
</span>
)}
</span>
<span className="opn-updates-side">
<span className="opn-updates-version">{item.version}</span>
@@ -501,7 +515,21 @@ function RecentUpdatesPanel({
export default function OneosPrototypeNavApp() {
const [isAuthed, setIsAuthed] = useState(readAuthSession);
const sections = useMemo(() => loadNavSections(), []);
const sections = useMemo(() => {
const loaded = loadNavSections();
const patchLink = (link: NavLinkItem): NavLinkItem => ({
...link,
href: linkHref(link),
});
return loaded.map((section) => ({
...section,
links: section.links.map(patchLink),
subGroups: section.subGroups.map((group) => ({
...group,
links: group.links.map(patchLink),
})),
}));
}, []);
const recentUpdates = useMemo(() => loadRecentUpdates(), []);
const registryUpdatedAt = useMemo(() => getRegistryUpdatedAt(), []);
const allLinks = useMemo(() => flattenNavLinks(sections), [sections]);
@@ -512,7 +540,7 @@ export default function OneosPrototypeNavApp() {
const searchRef = useRef<HTMLInputElement>(null);
const handleSelectLink = useCallback((link: NavLinkItem) => {
setSelectedLink(link);
setSelectedLink({ ...link, href: linkHref(link) });
}, []);
const handleCloseDetail = useCallback(() => {

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