迭代 ONE-OS 多原型:统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步导航与合包页面。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
263
src/common/OperationActions.tsx
Normal file
263
src/common/OperationActions.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
56
src/common/format-number.js
Normal file
56
src/common/format-number.js
Normal 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
121
src/common/ln-numeric.css
Normal 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;
|
||||
}
|
||||
@@ -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 之前完成注入。
|
||||
|
||||
48
src/common/operation-actions-spec.md
Normal file
48
src/common/operation-actions-spec.md
Normal 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 `|` 分隔操作链接
|
||||
37
src/common/prototype-annotation-host.tsx
Normal file
37
src/common/prototype-annotation-host.tsx
Normal 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;
|
||||
19
src/common/prototype-annotation-mermaid.css
Normal file
19
src/common/prototype-annotation-mermaid.css
Normal 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;
|
||||
}
|
||||
111
src/common/prototype-annotation-mermaid.tsx
Normal file
111
src/common/prototype-annotation-mermaid.tsx
Normal 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();
|
||||
}, []);
|
||||
}
|
||||
104
src/common/prototype-annotation-utils.ts
Normal file
104
src/common/prototype-annotation-utils.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
111
src/common/vm-button-icons.css
Normal file
111
src/common/vm-button-icons.css
Normal 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;
|
||||
}
|
||||
194
src/common/vm-operation-actions.css
Normal file
194
src/common/vm-operation-actions.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || '—';
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
165
src/prototypes/lease-business-detail/.spec/requirements-prd.md
Normal file
165
src/prototypes/lease-business-detail/.spec/requirements-prd.md
Normal 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-业务部台账`)。
|
||||
|
||||
### 不做范围(当前原型)
|
||||
|
||||
- 「租赁业务盈亏月度汇总」独立 Tab(Legacy 数据分析页有,本原型待规划)
|
||||
- 行内编辑、删除、服务端持久化
|
||||
- 关联收款记录自动回写实收
|
||||
- 员工/主管数据权限隔离
|
||||
|
||||
---
|
||||
|
||||
## 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 年 1–6 月明细
|
||||
- 条数: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
39
src/prototypes/lease-business-detail/columnHeaderTips.ts
Normal file
39
src/prototypes/lease-business-detail/columnHeaderTips.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
178
src/prototypes/lease-business-detail/data/change-log-seed.json
Normal file
178
src/prototypes/lease-business-detail/data/change-log-seed.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "客户承担"
|
||||
}
|
||||
]
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
126
src/prototypes/lease-business-detail/utils/change-log.ts
Normal file
126
src/prototypes/lease-business-detail/utils/change-log.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
87
src/prototypes/lease-business-detail/utils/edit-fields.ts
Normal file
87
src/prototypes/lease-business-detail/utils/edit-fields.ts
Normal 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;
|
||||
}
|
||||
172
src/prototypes/lease-business-detail/utils/field-checks.ts
Normal file
172
src/prototypes/lease-business-detail/utils/field-checks.ts
Normal 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;
|
||||
}
|
||||
100
src/prototypes/lease-business-detail/utils/hydrogen-fee.ts
Normal file
100
src/prototypes/lease-business-detail/utils/hydrogen-fee.ts
Normal 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 };
|
||||
37
src/prototypes/lease-business-detail/utils/payment-status.ts
Normal file
37
src/prototypes/lease-business-detail/utils/payment-status.ts
Normal 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)];
|
||||
}
|
||||
32
src/prototypes/lease-business-detail/utils/role.ts
Normal file
32
src/prototypes/lease-business-detail/utils/role.ts
Normal 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';
|
||||
}
|
||||
205
src/prototypes/lease-business-detail/utils/system-ref.ts
Normal file
205
src/prototypes/lease-business-detail/utils/system-ref.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -176,8 +176,8 @@ export const LEASE_MODULES: LineModule[] = [
|
||||
],
|
||||
closure:
|
||||
'应收完成后,按合同约定生成交车任务;运维部对应区域操作员在交车模块查看并执行,车辆正式交付给承租方。',
|
||||
prototypeHref: '/prototypes/payment-records',
|
||||
prototypeLabel: '打开收款记录',
|
||||
prototypeHref: '/prototypes/vehicle-pickup-receivable',
|
||||
prototypeLabel: '打开提车应收款',
|
||||
},
|
||||
{
|
||||
id: 'lease-ledger',
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 | 从新增合同起梳理正向/逆向全流程;对齐审批通过后交还与子表还车规则 |
|
||||
@@ -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`
|
||||
|
||||
@@ -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 + '(原型)');
|
||||
},
|
||||
}, '提交审核'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 () {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
667
src/prototypes/lease-contract-management/LeaseContractView.jsx
Normal file
667
src/prototypes/lease-contract-management/LeaseContractView.jsx
Normal 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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
185
src/prototypes/lease-contract-management/lease-contract-flow-bridge.js
vendored
Normal file
185
src/prototypes/lease-contract-management/lease-contract-flow-bridge.js
vendored
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
181
src/prototypes/lease-contract-management/lease-contract-signing.js
vendored
Normal file
181
src/prototypes/lease-contract-management/lease-contract-signing.js
vendored
Normal 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;
|
||||
}
|
||||
124
src/prototypes/lease-contract-management/lease-contract-view-data.js
vendored
Normal file
124
src/prototypes/lease-contract-management/lease-contract-view-data.js
vendored
Normal 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);
|
||||
}
|
||||
371
src/prototypes/lease-contract-management/lease-contract-view-sections.js
vendored
Normal file
371
src/prototypes/lease-contract-management/lease-contract-view-sections.js
vendored
Normal 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);
|
||||
}
|
||||
@@ -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()],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 图片)。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace ```mermaid fenced blocks with inline SVG images (data URI).
|
||||
* Annotation markdown renderer supports .
|
||||
*/
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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` 类名
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-prototype-nav",
|
||||
"pageId": "nav",
|
||||
"updatedAt": 1783600000000,
|
||||
"updatedAt": 1783800844967,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "opn-hero",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user