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

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

View File

@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-07-09T09:46:07.467Z", "updatedAt": "2026-07-11T03:51:46.250Z",
"prototypes": [ "prototypes": [
{
"id": "item-prototypes-task-work-order",
"kind": "item",
"title": "task work order",
"itemKey": "prototypes/task-work-order"
},
{ {
"id": "item-prototypes-lease-business-line-overview", "id": "item-prototypes-lease-business-line-overview",
"kind": "item", "kind": "item",
@@ -56,12 +62,6 @@
"title": "oneos web ledger data", "title": "oneos web ledger data",
"itemKey": "prototypes/oneos-web-ledger-data" "itemKey": "prototypes/oneos-web-ledger-data"
}, },
{
"id": "item-prototypes-oneos-web-login",
"kind": "item",
"title": "oneos web login",
"itemKey": "prototypes/oneos-web-login"
},
{ {
"id": "item-prototypes-oneos-web-ops", "id": "item-prototypes-oneos-web-ops",
"kind": "item", "kind": "item",
@@ -74,18 +74,6 @@
"title": "oneos web procurement", "title": "oneos web procurement",
"itemKey": "prototypes/oneos-web-procurement" "itemKey": "prototypes/oneos-web-procurement"
}, },
{
"id": "item-prototypes-oneos-web-requirements",
"kind": "item",
"title": "oneos web requirements",
"itemKey": "prototypes/oneos-web-requirements"
},
{
"id": "item-prototypes-oneos-web-vehicle-asset",
"kind": "item",
"title": "oneos web vehicle asset",
"itemKey": "prototypes/oneos-web-vehicle-asset"
},
{ {
"id": "item:prototypes:oneos-prototype-nav", "id": "item:prototypes:oneos-prototype-nav",
"kind": "item", "kind": "item",
@@ -206,7 +194,7 @@
{ {
"id": "item:prototypes:self-operated-business-ledger", "id": "item:prototypes:self-operated-business-ledger",
"kind": "item", "kind": "item",
"title": "物流业务台账", "title": "物流业务明细",
"itemKey": "prototypes/self-operated-business-ledger" "itemKey": "prototypes/self-operated-business-ledger"
}, },
{ {
@@ -228,6 +216,12 @@
"kind": "folder", "kind": "folder",
"title": "财务管理", "title": "财务管理",
"children": [ "children": [
{
"id": "item:prototypes:vehicle-pickup-receivable",
"kind": "item",
"title": "提车应收款",
"itemKey": "prototypes/vehicle-pickup-receivable"
},
{ {
"id": "item:prototypes:payment-records", "id": "item:prototypes:payment-records",
"kind": "item", "kind": "item",

View File

@@ -0,0 +1,36 @@
---
description: 原型标注布局——PRD 在右侧目录分章展示,禁止侧边原型导航与目录内跳转
globs: src/prototypes/**/*
alwaysApply: false
---
# 原型标注布局(全局)
开发或改造 `src/prototypes/` 下任意带 `@axhub/annotation` 的原型时,**必须**遵循 `rules/prototype-annotation-layout-guide.md`。
## 硬性要求
1. 使用 `PrototypeAnnotationHost`,禁止直接挂载裸 `AnnotationViewer`
2. 禁止在 `annotation-source.json` 写入 `id: oneos-project-nav` 导航目录
3. 跨原型跳转只通过 `/prototypes/oneos-prototype-nav`(原型导航页)
4. PRD / 模块说明放在 `directory` 的 `markdown` 节点,由右侧「原型标注」目录分章节展示
5. 不在页面正文下方内联展示 PRD目录内不展示 `link` / `route` 跳转节点
## 标准模板
```tsx
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
```
## 同步命令
菜单或标注目录调整后:
```bash
npm run project-nav:sync
```

View File

@@ -41,7 +41,7 @@ CHANGELOG_NOTE="修复筛选联动" npm run nav:sync -- --prototype customer-man
- 脚本路径:`scripts/sync-prototype-nav-registry.mjs` - 脚本路径:`scripts/sync-prototype-nav-registry.mjs`
- 检测原型目录内容哈希;有变化则版本递增(`v1.0` → `v1.1` → …) - 检测原型目录内容哈希;有变化则版本递增(`v1.0` → `v1.1` → …)
- 写入 `src/prototypes/oneos-prototype-nav/prototype-registry.json` - 写入 `src/prototypes/oneos-prototype-nav/prototype-registry.json`
- 同步 `nav-menu.json` 与各原型标注目录`npm run project-nav:sync` 逻辑 - 同步 `nav-menu.json`,并清理各原型 `annotation-source.json` 中的 `oneos-project-nav` 节点`npm run project-nav:sync`
- 同步小羚羚小程序目录(`npm run nav:xll-sync`,读取 `make-project-20260629` 项目标注目录) - 同步小羚羚小程序目录(`npm run nav:xll-sync`,读取 `make-project-20260629` 项目标注目录)
- 本地 `npm run dev` 时Vite 插件会在保存文件后自动触发同步(无需手动跑命令) - 本地 `npm run dev` 时Vite 插件会在保存文件后自动触发同步(无需手动跑命令)

View File

@@ -20,6 +20,7 @@
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` | | 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` | | 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw``canvas-assets/` | 原型画布和画布素材 | | 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw``canvas-assets/` | 原型画布和画布素材 |
| 原型标注布局 | `src/common/prototype-annotation-host.tsx` | `rules/prototype-annotation-layout-guide.md` |
## ⚠️ 重要原则 ## ⚠️ 重要原则

2932
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"metadata:sync": "node scripts/sync-project-metadata.mjs", "metadata:sync": "node scripts/sync-project-metadata.mjs",
"project-nav:sync": "node scripts/sync-project-prototype-directory.mjs", "project-nav:sync": "node scripts/sync-project-prototype-directory.mjs",
"nav:sync": "node scripts/sync-prototype-nav-registry.mjs", "nav:sync": "node scripts/sync-prototype-nav-registry.mjs",
"nav:enrich-git": "node scripts/enrich-prototype-registry-from-git.mjs",
"nav:xll-sync": "node scripts/sync-xll-nav-menu.mjs", "nav:xll-sync": "node scripts/sync-xll-nav-menu.mjs",
"oneos-web:sync": "node scripts/sync-oneos-web-prototypes.mjs && node scripts/scan-entries.js && npm run metadata:sync", "oneos-web:sync": "node scripts/sync-oneos-web-prototypes.mjs && node scripts/scan-entries.js && npm run metadata:sync",
"build": "node scripts/sync-vendor-if-present.mjs && node scripts/scan-entries.js && node scripts/build-all.js", "build": "node scripts/sync-vendor-if-present.mjs && node scripts/scan-entries.js && node scripts/build-all.js",
@@ -28,12 +29,14 @@
"font:subset:beginner-guide": "node scripts/subset-beginner-guide-fonts.mjs" "font:subset:beginner-guide": "node scripts/subset-beginner-guide-fonts.mjs"
}, },
"devDependencies": { "devDependencies": {
"@mermaid-js/mermaid-cli": "^11.16.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.16",
"@vitest/ui": "4.0.16", "@vitest/ui": "4.0.16",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"iconv-lite": "^0.7.1", "iconv-lite": "^0.7.1",
"jsdom": "^29.1.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"subset-font": "^2.5.0", "subset-font": "^2.5.0",
@@ -48,6 +51,7 @@
"docx-preview": "^0.3.7", "docx-preview": "^0.3.7",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"mermaid": "^11.16.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
} }
} }

View File

@@ -53,6 +53,8 @@
|---|---| |---|---|
| 正文字体 `--vm-font` | Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif | | 正文字体 `--vm-font` | Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif |
| 等宽 `--vm-font-mono` | ui-monospace, JetBrains Mono, SFMono-Regular, Menlo, Consolas, monospace | | 等宽 `--vm-font-mono` | ui-monospace, JetBrains Mono, SFMono-Regular, Menlo, Consolas, monospace |
| 数字/金额 | 使用 `--vm-font-mono` + `tabular-nums`(全局见 `src/common/ln-numeric.css`,对齐 `.vm-expire-date` |
| 金额格式 | `src/common/format-number.js`(千分位 + 2 位小数Legacy 页可用 `window.__oneosFormatMoney` |
| 页面基础字号 | `0.875rem`14px | | 页面基础字号 | `0.875rem`14px |
| 筛选标签 | `0.75rem` / 500 | | 筛选标签 | `0.75rem` / 500 |
| 表头 | `0.75rem` / 500 | | 表头 | `0.75rem` / 500 |
@@ -186,7 +188,7 @@ import '../vehicle-management/style.css';
| 次要 | `vm-btn vm-btn-secondary` | 带边框白底 | | 次要 | `vm-btn vm-btn-secondary` | 带边框白底 |
| 幽灵 | `vm-btn vm-btn-ghost` | 取消、重置、工具栏次要操作 | | 幽灵 | `vm-btn vm-btn-ghost` | 取消、重置、工具栏次要操作 |
| 文字链 | `vm-btn vm-btn-link` | 表格内操作、行内链接 | | 文字链 | `vm-btn vm-btn-link` | 表格内操作、行内链接 |
| 返回 | `vm-btn vm-btn-back` | 详情/编辑顶栏返回 | | 返回 | `vm-btn vm-btn-back` | 表单/详情/编辑顶栏左上角Chevron 图标 + 白底描边hover 主色字/边框 + 浅绿底 |
**规格**:高度 36px圆角 8px字号 0.875rem / 500禁用 opacity 0.5。 **规格**:高度 36px圆角 8px字号 0.875rem / 500禁用 opacity 0.5。

View File

@@ -0,0 +1,115 @@
# 原型标注布局规范
适用于 `src/prototypes/` 下所有接入 `@axhub/annotation` 的原型页面。
## 核心原则
1. **禁止在右侧「原型目录」中展示跨原型导航**
- 不得在 `annotation-source.json``directory.nodes` 中维护 `id: oneos-project-nav` 的「ONE-OS 原型导航」文件夹
- 跨原型跳转统一由 **`/prototypes/oneos-prototype-nav`(原型导航页)** 承担
2. **PRD 说明展示在右侧「原型标注」目录面板**
- 产品需求、模块说明、分章节 PRD 等内容,通过 `directory``markdown` 节点写入
- 用户在右侧工具栏打开「原型标注 / 原型目录」,按章节阅读 Markdown 正文
- **不在**页面正文下方内联展示 PRD 大段内容
3. **去除目录内页面跳转**
- `type: link` 跨原型/跨模块链接不在目录树中展示
- `type: route` 页面内跳转节点不在目录树中展示(`route` 仅用于声明 `pageId` 绑定,供按当前页过滤章节)
- 页面切换由原型自身导航按钮、Tab、Hash 等)完成
4. **标注工具栏保留**
- 页面元素批注、主题切换、颜色筛选等 `@axhub/annotation` 能力继续可用
## 标准接入方式
所有新原型与存量改造,**必须**使用公共壳组件:
```tsx
import {
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import annotationSourceDocument from './annotation-source.json';
export default function MyPrototypePage() {
const annotationOptions = useMemo<AnnotationViewerOptions>(() => ({
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: 'list', // 多页面原型按当前页传入
}), []);
return (
<>
<MyApp />
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</>
);
}
```
### 多页面原型
- `options.currentPageId` 与页面路由保持一致
- `annotation-source.json` 中带 `route` 子节点的文件夹,仅在其 `payload.pageId` 匹配当前页时,才在右侧目录中展示对应 PRD 章节
-`route` 绑定的 PRD 文件夹如「PRD 全文」「模块总览」)在所有子页面均展示
- 一般**不需要**再实现 `onDirectoryRoute`(目录内已无 `route` 可点击节点)
## annotation-source.json 目录约定
`directory.nodes` 中只允许保留 **本产品说明**,结构建议:
```text
directory.nodes
├── folder · 模块说明 / PRD 全文 / 列表页模块 / 新增页模块 …
│ ├── markdown · 章节正文(内联 markdown 或 markdownMap
│ └── route · 仅用于声明 pageId 绑定(不在 UI 展示)
```
**禁止写入:**
- `id: oneos-project-nav` 导航文件夹
- 依赖 `type: link` 的跨原型跳转(改放原型导航页)
**不在目录 UI 展示(但可保留在 JSON 中供过滤):**
- `type: link` 节点
- `type: route` 节点(仅作 pageId 绑定元数据)
## 脚本与同步
| 命令 | 作用 |
|------|------|
| `npm run project-nav:sync` | 更新原型导航页 `nav-menu.json`,并清理各原型目录中的导航节点 |
| `node scripts/strip-prototype-nav-from-directory.mjs` | 批量移除 `oneos-project-nav` |
| `node scripts/migrate-prototype-annotation-host.mjs` | 将 `AnnotationViewer` 迁移为 `PrototypeAnnotationHost` |
修改 sidebar 菜单或批量整理标注目录后,执行 `npm run project-nav:sync`
## Agent 开发检查清单
新建或改造带标注的原型时:
- [ ] `index.tsx` 使用 `PrototypeAnnotationHost`,不直接挂载裸 `AnnotationViewer`
- [ ] `annotation-source.json``oneos-project-nav` 节点
- [ ] PRD / 模块说明以 `markdown` 节点写入 `directory`,能在右侧「原型目录」分章节阅读
- [ ] 页面正文下方**无**大块 PRD 内联区域
- [ ] 右侧目录**无**跨原型 link、**无**可点击的 route 页面跳转
- [ ] 跨原型入口统一引导用户打开「原型导航」页
- [ ] 多页面原型正确传入 `currentPageId`,右侧 PRD 章节与当前页匹配
## 相关文件
| 文件 | 说明 |
|------|------|
| `src/common/prototype-annotation-host.tsx` | 标注壳:过滤目录 + 批注工具栏 |
| `src/common/prototype-annotation-utils.ts` | PRD 目录过滤与导航节点清理 |
| `src/prototypes/oneos-prototype-nav/` | 全局原型导航页 |
| `scripts/sync-project-prototype-directory.mjs` | 导航菜单同步(不再注入侧边导航树) |

View File

@@ -38,6 +38,15 @@ src/prototypes/<name>/
- 只有 Make 批注/编辑工具启用、且当前选中的是带安全本地 `markdownPath` 的目录 Markdown 正文子节点时,批注气泡卡片才显示“文档编辑”按钮。 - 只有 Make 批注/编辑工具启用、且当前选中的是带安全本地 `markdownPath` 的目录 Markdown 正文子节点时,批注气泡卡片才显示“文档编辑”按钮。
- 导出/发布时会构建期内联 `markdownPath` 正文,不依赖运行时请求 `.md` 文件。 - 导出/发布时会构建期内联 `markdownPath` 正文,不依赖运行时请求 `.md` 文件。
## 原型标注与 PRD 布局(强制)
跨原型跳转与 PRD 阅读方式已全局统一,详见 `rules/prototype-annotation-layout-guide.md`
- **禁止** 在 `annotation-source.json``directory` 中维护 `oneos-project-nav`ONE-OS 原型导航);跨原型入口统一使用 `/prototypes/oneos-prototype-nav`
- **必须** 通过 `PrototypeAnnotationHost``src/common/prototype-annotation-host.tsx`)挂载标注,不在各原型直接使用裸 `AnnotationViewer`
- PRD / 模块说明写在 `directory``markdown` 节点中,由右侧「原型标注」目录分章节展示;**不在**页面正文下方内联 PRD
- 目录内不展示 `link` / `route` 跳转节点;多页面原型通过 `options.currentPageId` 控制右侧 PRD 章节与当前页匹配
每个原型的 `index.tsx` 顶部建议包含面向用户的中文 `@name`,用于预览列表展示名: 每个原型的 `index.tsx` 顶部建议包含面向用户的中文 `@name`,用于预览列表展示名:
```typescript ```typescript

View File

@@ -0,0 +1,339 @@
/**
* 从 Git 提交历史生成/补全原型导航注册表中的详细变更日志。
*
* 用法:
* node scripts/enrich-prototype-registry-from-git.mjs
* node scripts/enrich-prototype-registry-from-git.mjs --prototype vehicle-h2-fee-ledger
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json');
const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']);
const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']);
const MAX_CHANGELOG = 30;
const MAX_RECENT = 40;
function parseArgs(argv) {
const args = { prototype: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--prototype' && argv[i + 1]) args.prototype = argv[++i].trim();
}
return args;
}
function shouldTrackFile(relativePath) {
const base = path.basename(relativePath);
if (IGNORED_FILE_NAMES.has(base)) return false;
if (relativePath === 'annotation-source.json') return false;
return TRACKED_EXTENSIONS.has(path.extname(relativePath).toLowerCase());
}
function normalizePrototypePath(filePath, prototypeId) {
const normalized = String(filePath || '').replace(/\\/gu, '/').trim();
const prefix = `src/prototypes/${prototypeId}/`;
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
return null;
}
function parseCommitBlocks(raw) {
const blocks = raw.split(/\n?----\n/u).map((block) => block.trim()).filter(Boolean);
const commits = [];
for (const block of blocks) {
const lines = block.split('\n');
const hash = lines[0]?.trim();
if (!/^[0-9a-f]{7,40}$/iu.test(hash || '')) continue;
const dateLine = lines[1]?.trim();
const subject = lines[2]?.trim() || '';
if (!dateLine) continue;
const fileStart = lines.findIndex((line, index) => index > 2 && line.trim() === '');
const bodyEnd = fileStart >= 0 ? fileStart : lines.length;
const body = lines.slice(3, bodyEnd).join('\n').trim();
const files = (fileStart >= 0 ? lines.slice(fileStart + 1) : [])
.map((line) => line.trim())
.filter(Boolean);
commits.push({ hash, dateLine, subject, body, files });
}
return commits;
}
function getPrototypeCommits(prototypeId) {
const relDir = `src/prototypes/${prototypeId}`;
if (!fs.existsSync(path.join(projectRoot, relDir))) return [];
let raw = '';
try {
raw = execSync(
`git log --pretty=format:----%n%H%n%ci%n%s%n%b --name-only -- ${relDir}`,
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 },
);
} catch {
return [];
}
return parseCommitBlocks(raw);
}
function formatDateParts(dateLine) {
const match = dateLine.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/u);
if (!match) {
const date = new Date(dateLine);
if (Number.isNaN(date.getTime())) return { date: '1970-01-01', time: '00:00', iso: null };
return {
date: date.toISOString().slice(0, 10),
time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
iso: date.toISOString(),
};
}
const [, y, m, d, hh, mm] = match;
return {
date: `${y}-${m}-${d}`,
time: `${hh}:${mm}`,
iso: new Date(`${y}-${m}-${d}T${hh}:${mm}:00+08:00`).toISOString(),
};
}
function extractBodyLines(body) {
return body
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !/^Co-authored-by:/iu.test(line))
.map((line) => line.replace(/^[-*•]\s*/u, '').trim())
.filter(Boolean);
}
function strictKeywords(title, prototypeId) {
const keywords = new Set([title, prototypeId]);
for (const part of String(title || '').split(/[·、/()\s「」]+/u)) {
const trimmed = part.trim();
if (trimmed.length >= 3) keywords.add(trimmed);
}
if (title.includes('氢费明细')) keywords.add('氢费明细');
else if (title.includes('氢费')) keywords.add('氢费');
if (title.includes('原型导航')) keywords.add('原型导航');
if (title.includes('合同模板')) keywords.add('合同模板');
if (title.includes('租赁合同')) keywords.add('租赁合同');
if (title.includes('租赁业务')) keywords.add('租赁业务');
if (title.includes('收款记录')) keywords.add('收款记录');
if (title.includes('维修保养')) keywords.add('维修保养');
if (title.includes('还车应结')) keywords.add('还车应结');
return [...keywords];
}
function lineMatchesPrototype(line, title, prototypeId) {
if (!line) return false;
if (line.includes(title)) return true;
if (line.toLowerCase().includes(prototypeId.toLowerCase())) return true;
return strictKeywords(title, prototypeId).some((keyword) => (
keyword.length >= 3 && line.includes(keyword)
));
}
function commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines) {
if (changedFiles.length > 0) return true;
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
const textMatches = [commit.subject, ...bodyLines].some((line) => (
lineMatchesPrototype(line, title, prototypeId)
));
if (textMatches && onlyAnnotation) return true;
if (textMatches) return true;
return false;
}
function summarizeFileChanges(files) {
const notes = [];
const prd = files.filter((f) => f.includes('.spec/') && f.endsWith('.md'));
const comments = files.filter((f) => f.includes('prototype-comments') || f.includes('annotation-source'));
const styles = files.filter((f) => f.endsWith('.css'));
const logic = files.filter((f) => /\.(tsx|jsx|ts|js)$/u.test(f) && !f.endsWith('.css'));
const data = files.filter((f) => f.includes('/data/') || f.endsWith('.json'));
if (logic.length) {
const names = [...new Set(logic.map((f) => path.basename(f)))].slice(0, 4);
notes.push(`页面逻辑与交互:${names.join('、')}${logic.length > names.length ? ' 等' : ''}`);
}
if (styles.length) notes.push(`样式与布局:${[...new Set(styles.map((f) => path.basename(f)))].join('、')}`);
if (prd.length) notes.push(`需求说明与 PRD${prd.map((f) => path.basename(f)).join('、')}`);
if (comments.length) notes.push('原型标注目录与批注说明同步');
if (data.length) notes.push(`演示数据与配置:${data.map((f) => path.basename(f)).join('、')}`);
return notes;
}
function buildSummary(subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation) {
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
if (matched.length === 1) return matched[0];
if (matched.length > 1) return matched[0];
const fragments = splitSubjectFragments(subject, prototypeId, title);
if (fragments.length === 1) return fragments[0];
if (fragments.length > 1) return fragments[0];
if (onlyAnnotation && changedFiles.length === 0) return '原型标注目录同步';
if (changedFiles.length) return summarizeFileChanges(changedFiles)[0] || subject;
if (lineMatchesPrototype(subject, title, prototypeId)) return subject;
return subject;
}
function dedupeDetails(summary, details) {
return [...new Set(details)].filter((line) => (
line
&& line !== summary
&& !summary.includes(line)
&& line.length > 2
));
}
function splitSubjectFragments(subject, prototypeId, title) {
const segments = subject
.split(/[;]/u)
.map((part) => part.trim())
.filter(Boolean);
const matched = segments.filter((segment) => lineMatchesPrototype(segment, title, prototypeId));
if (!matched.length) return [];
const fragments = [];
for (const segment of matched) {
const colonIndex = segment.indexOf('');
const tail = colonIndex >= 0 ? segment.slice(colonIndex + 1) : segment;
tail.split(/[、,]/u)
.map((part) => part.trim())
.filter((part) => part.length >= 4)
.forEach((part) => fragments.push(part));
}
return [...new Set(fragments)];
}
function buildDetails(subject, bodyLines, prototypeId, title, changedFiles) {
const details = [];
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
details.push(...matched);
for (const fragment of splitSubjectFragments(subject, prototypeId, title)) {
if (!details.includes(fragment) && fragment !== subject) details.push(fragment);
}
for (const note of summarizeFileChanges(changedFiles)) {
if (!details.includes(note)) details.push(note);
}
if (!details.length && changedFiles.length) {
details.push(`变更 ${changedFiles.length} 个文件`);
}
return details.filter((line) => line !== subject);
}
function bumpVersion(indexFromOldest) {
return `v1.${indexFromOldest}`;
}
function buildChangelogFromGit(prototypeId, title) {
const commits = getPrototypeCommits(prototypeId);
if (!commits.length) return [];
const chronological = [...commits].reverse();
const entries = [];
for (const commit of chronological) {
const { date, time } = formatDateParts(commit.dateLine);
const bodyLines = extractBodyLines(commit.body);
const changedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter((file) => file && shouldTrackFile(file));
const allChangedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter(Boolean);
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
if (!commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines)) {
continue;
}
const summary = buildSummary(commit.subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation);
const details = dedupeDetails(
summary,
buildDetails(commit.subject, bodyLines, prototypeId, title, changedFiles),
);
entries.push({
version: '',
date,
time,
summary,
details,
commit: commit.hash.slice(0, 7),
files: [...new Set(changedFiles)].slice(0, 12),
});
}
return entries.reverse().map((entry, index, list) => ({
...entry,
version: bumpVersion(list.length - index),
}));
}
function rebuildRecentUpdates(registry) {
const rows = [];
for (const [prototypeId, record] of Object.entries(registry.prototypes || {})) {
const latest = record.changelog?.[0];
if (!latest) continue;
rows.push({
prototypeId,
title: record.title || prototypeId,
version: latest.version,
date: latest.date,
time: latest.time,
summary: latest.summary,
details: latest.details || [],
files: latest.files || [],
commit: latest.commit,
_sort: `${latest.date}T${latest.time}`,
});
}
rows.sort((a, b) => b._sort.localeCompare(a._sort));
return rows.slice(0, MAX_RECENT).map(({ _sort, ...row }) => row);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
const prototypeIds = args.prototype
? [args.prototype]
: Object.keys(registry.prototypes || {});
let updated = 0;
for (const prototypeId of prototypeIds) {
const record = registry.prototypes[prototypeId];
if (!record) continue;
const changelog = buildChangelogFromGit(prototypeId, record.title || prototypeId);
if (!changelog.length) continue;
record.changelog = changelog.slice(0, MAX_CHANGELOG);
record.revision = changelog.length;
record.version = changelog[0].version;
const latestIso = getPrototypeCommits(prototypeId)[0];
if (latestIso) {
const { iso } = formatDateParts(latestIso.dateLine);
if (iso) record.lastUpdated = iso;
}
registry.prototypes[prototypeId] = record;
updated += 1;
console.log(`[git] ${prototypeId}: ${changelog.length} entries → ${changelog[0].version}`);
}
registry.recentUpdates = rebuildRecentUpdates(registry);
registry.updatedAt = new Date().toISOString();
fs.writeFileSync(REGISTRY_PATH, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
console.log(`完成:补全 ${updated} 个原型的 Git 变更日志`);
}
main();

View File

@@ -0,0 +1,62 @@
/**
* 将原型 index.tsx 中的 AnnotationViewer 迁移为 PrototypeAnnotationHost。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
const hostImport = "import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';";
function listIndexFiles() {
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(prototypesRoot, entry.name, 'index.tsx'))
.filter((filePath) => fs.existsSync(filePath))
.filter((filePath) => fs.readFileSync(filePath, 'utf8').includes('AnnotationViewer'));
}
function migrateFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
if (!content.includes('AnnotationViewer')) return false;
if (content.includes('PrototypeAnnotationHost')) {
console.log(`[skip] ${path.relative(projectRoot, filePath)}`);
return false;
}
content = content.replace(
/import\s*\{([^}]*)\}\s*from\s*'@axhub\/annotation';/u,
(match, importsBlock) => {
const cleaned = importsBlock
.split(',')
.map((item) => item.trim())
.filter((item) => item && item !== 'AnnotationViewer')
.join(',\n ');
return `import {\n ${cleaned}\n} from '@axhub/annotation';`;
},
);
if (!content.includes(hostImport)) {
content = content.replace(
/\} from '@axhub\/annotation';/u,
`} from '@axhub/annotation';\n${hostImport}`,
);
}
content = content
.replace(/<AnnotationViewer\b/gu, '<PrototypeAnnotationHost')
.replace(/<\/AnnotationViewer>/gu, '</PrototypeAnnotationHost>')
.replace(/React\.createElement\(AnnotationViewer,/gu, 'React.createElement(PrototypeAnnotationHost,');
fs.writeFileSync(filePath, content, 'utf8');
console.log(`[migrate] ${path.relative(projectRoot, filePath)}`);
return true;
}
let changed = 0;
for (const filePath of listIndexFiles()) {
if (migrateFile(filePath)) changed += 1;
}
console.log(`完成:迁移 ${changed} 个 index.tsx`);

View File

@@ -66,7 +66,7 @@ function toPublishPath(resource) {
function resolveObjectPrefix(entry) { function resolveObjectPrefix(entry) {
const normalized = entry.path.replace(/^\/+|\/+$/gu, ''); const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u); const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
if (prototypeMatch?.[1]) return prototypeMatch[1]; if (prototypeMatch?.[1]) return `prototypes/${prototypeMatch[1]}`;
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u); const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`; if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
return entry.id; return entry.id;

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) out.push(full);
}
return out;
}
function cleanup(source) {
let s = source;
// Fix corrupted fragment from partial breadcrumb removal (37-新增后装设备)
s = s.replace(
/\{ style: styles\.page \},\s*\{ e\.preventDefault\(\); \} \}, '运维管理'\),[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g,
'{ style: styles.page },'
);
// Top bar: breadcrumb spans + req button -> req button only
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\(Button, \{ type: 'link'[\s\S]*?'查看需求说明'\)\s*)\),/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),`
);
// Lease contract rows with broken partial breadcrumb
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\))/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1)`
);
// Lease contract row - only breadcrumb spans ending with )), no req
s = s.replace(
/React\.createElement\('div', \{ style: \{ marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\)\),/g,
''
);
// 后装设备 list page partial breadcrumb block inside createElement
s = s.replace(
/React\.createElement\('div', \{ style: styles\.breadcrumbLeft \},[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g,
''
);
// Orphan breadcrumb link lines after broken removal
s = s.replace(
/\n\t\t\tReact\.createElement\('a', \{ href: '#', style: styles\.breadcrumbLink[\s\S]*?breadcrumbCurrent \}, '[^']+'\)\n\t\t\),/g,
''
);
// Remove breadcrumbItems variable blocks
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\t\tbreadcrumbItems\.push\([^)]+\);\n/g, '');
// 备车/交车任务 inline breadcrumb rows (partial)
s = s.replace(
/React\.createElement\('div', \{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g,
''
);
s = s.replace(
/React\.createElement\('span', \{ key: '[^']+', style: styles\.breadcrumbSep \}, ' \/ '\),?\n?\s*/g,
''
);
// Arco/fault pages: remove Breadcrumb from Layout header if any remain
s = s.replace(/,\s*breadcrumb:\s*React\.createElement\(Breadcrumb[\s\S]*?\)/g, '');
// business pages partial breadcrumb in flex div
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', alignItems: 'center', marginBottom: 16 \} \},[\s\S]*?styles\.breadcrumbSep[\s\S]*?\),/g,
''
);
// Fix double spaces in lease contract lines
s = s.replace(/marginBottom: 16 \} \},\s{2}React/g, 'marginBottom: 16 } }, React');
// 27-交车管理: broken topbar after breadcrumb removal
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\(Button,/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, React.createElement(Button,`
);
return s;
}
function fixCollectPageIndent(source) {
return source.replace(
/(\}, '返回'\),\n)\s+React\.createElement\(Button,/g,
`$1\t\t\tReact.createElement(Button,`
);
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue;
const next = fixCollectPageIndent(cleanup(source));
if (next !== source) {
fs.writeFileSync(file, next);
changed++;
console.log('fixed:', path.relative(process.cwd(), file));
}
}
console.log(`Pass 2 done. ${changed} file(s).`);

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) out.push(full);
}
return out;
}
function cleanup(source) {
let s = source;
// Lease contract topbar: remove span trail, keep req action
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\)\),/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),`
);
// Topbar only breadcrumb spans (no req button)
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),/g,
''
);
// Unused breadcrumb vars
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\t\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\tvar breadcrumbNodes = \[[\s\S]*?\];\n/g, '\n');
// Broken 后装设备 page wrapper
s = s.replace(
/(\{ style: styles\.page \},\n)\s*React\.createElement\('a', \{ href: '#', style: styles\.requirementLink[\s\S]*?'查看需求说明'\)\n\t\t\),/g,
`$1\t\tReact.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } },\n\t\t\tReact.createElement('a', { href: '#', style: styles.requirementLink, onClick: function (e) { e.preventDefault(); setShowRequirementModal(true); } }, '查看需求说明')\n\t\t),\n`
);
// Empty if blocks left from breadcrumb removal in 业务台账
s = s.replace(/\n\tif \(view === 'sales' \|\| view === 'project'\) \{\s*\}\n\tif \(view === 'project'\) \{\s*\}\n/g, '\n');
return s;
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue;
const next = cleanup(source);
if (next !== source) {
fs.writeFileSync(file, next);
changed++;
console.log('fixed:', path.relative(process.cwd(), file));
}
}
console.log(`Pass 3 done. ${changed} file(s).`);

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Remove breadcrumb UI from prototype pages under src/prototypes.
* Keeps sibling actions such as「查看需求说明」when present in breadcrumbRight.
*/
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) {
out.push(full);
}
}
return out;
}
function removeBalancedFrom(source, openIndex) {
let i = openIndex;
if (source[i] !== '(') return null;
let depth = 0;
let inStr = null;
let escape = false;
for (; i < source.length; i++) {
const ch = source[i];
if (inStr) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inStr) inStr = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
inStr = ch;
continue;
}
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return source.slice(openIndex, i + 1);
}
}
return null;
}
function removeReactBreadcrumbCalls(source) {
const token = 'React.createElement(Breadcrumb,';
let result = source;
let idx = 0;
while ((idx = result.indexOf(token, idx)) !== -1) {
const open = result.indexOf('(', idx);
const chunk = removeBalancedFrom(result, open);
if (!chunk) break;
let start = idx;
let end = open + chunk.length;
while (start > 0 && /[\t ]/.test(result[start - 1])) start--;
if (result[start - 1] === ',') start--;
if (result[start - 1] === '\n' && result[start - 2] === ',') start--;
while (end < result.length && /[\t ,]/.test(result[end])) end++;
if (result[end] === '\n') end++;
result = result.slice(0, start) + result.slice(end);
idx = start;
}
return result;
}
function removeBreadcrumbImports(source) {
return source
.replace(/^\s*var Breadcrumb = antd\.Breadcrumb;\n/gm, '')
.replace(/^\s*const Breadcrumb = antd\.Breadcrumb;\n/gm, '')
.replace(/^\s*import\s+\{[^}]*\bBreadcrumb\b[^}]*\}\s+from\s+['"][^'"]+['"];\n/gm, (line) =>
line.replace(/\bBreadcrumb,?\s*/g, '').replace(/,\s*,/g, ',').replace(/\{\s*,/g, '{').replace(/,\s*\}/g, '}').replace(/import\s+\{\s*\}\s+from[^;]+;\n/g, '')
);
}
function removeNavBreadcrumbs(source) {
return source.replace(
/\n?\s*<nav[^>]*breadcrumb[^>]*>[\s\S]*?<\/nav>\n?/gi,
'\n'
);
}
function replaceJsxBreadcrumbBar(source) {
// JSX: breadcrumb row with optional right actions
return source.replace(
/\{\/\*[\s*]*面包屑[\s*]*\*\/\}\s*\n?\s*<div style=\{styles\.breadcrumb\}>[\s\S]*?<div style=\{styles\.breadcrumbRight\}>([\s\S]*?)<\/div>\s*<\/div>/g,
(_, right) => `\n\t\t\t<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>${right.trim()}</div>`
);
}
function replaceCreateElementBreadcrumbBar(source) {
// createElement breadcrumb with Left + Right — keep Right only
const pattern = /React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb \},[\s\S]*?React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumbRight \},([\s\S]*?)\)\s*\)/g;
return source.replace(
pattern,
(_, right) =>
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, ${right.trim()})`
);
}
function replaceSimpleCreateElementBreadcrumb(source) {
// div with only styles.breadcrumb path segments (no Right sibling)
return source.replace(
/React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g,
''
);
}
function replaceLeaseContractBreadcrumbRow(source) {
// Long inline breadcrumb + req button row
return source.replace(
/React\.createElement\('div',\s*\{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('div',\s*\{ style: styles\.breadcrumb[^}]+\}[\s\S]*?'查看需求说明'\)\),/g,
(match) => {
const req = match.match(/React\.createElement\('button'[\s\S]*?'查看需求说明'\)/);
if (!req) return '';
return `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, ${req[0]}),`;
}
);
}
function removeEmptyBreadcrumbWrappers(source) {
// div wrapper that only wrapped breadcrumb (now empty)
return source
.replace(
/React\.createElement\('div',\s*\{ style: \{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 \} \},\s*\),?\n?/g,
''
)
.replace(
/React\.createElement\('div',\s*\{ style: \{ marginBottom: 16 \} \},\s*\),?\n?/g,
''
)
.replace(
/React\.createElement\('div',\s*\{ className: 'vpr-collect-topbar__main' \},\s*\),?\n?/g,
''
);
}
function transformFile(filePath) {
let source = fs.readFileSync(filePath, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) return false;
const original = source;
source = removeReactBreadcrumbCalls(source);
source = removeBreadcrumbImports(source);
source = removeNavBreadcrumbs(source);
source = replaceJsxBreadcrumbBar(source);
source = replaceCreateElementBreadcrumbBar(source);
source = replaceSimpleCreateElementBreadcrumb(source);
source = replaceLeaseContractBreadcrumbRow(source);
source = removeEmptyBreadcrumbWrappers(source);
// header with only req link after breadcrumb removed
source = source.replace(
/React\.createElement\(\s*'header',\s*\{ className: 'vr-page-header' \},\s*React\.createElement\(\s*Button,/g,
`React.createElement('header', { className: 'vr-page-header', style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button,`
);
// flex topbar: space-between -> flex-end when breadcrumb was first child
source = source.replace(
/justifyContent: 'space-between', marginBottom: 16 \} \},\s*React\.createElement\(Button, \{ type: 'link'/g,
`justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button, { type: 'link'`
);
if (source !== original) {
fs.writeFileSync(filePath, source);
return true;
}
return false;
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
if (transformFile(file)) {
changed++;
console.log('updated:', path.relative(process.cwd(), file));
}
}
// CSS tweak for collect topbar
const vprCss = path.join(ROOT, 'vehicle-pickup-receivable/styles/index.css');
if (fs.existsSync(vprCss)) {
let css = fs.readFileSync(vprCss, 'utf8');
const next = css
.replace('/* 办理页顶栏:返回 + 面包屑 */', '/* 办理页顶栏:返回 + 操作 */')
.replace(
/\.vm-page\.vpr-collect-page \.vpr-collect-topbar \{\n display: flex;\n align-items: center;\n gap: 16px;/,
`.vm-page.vpr-collect-page .vpr-collect-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;`
)
.replace(/\n\.vm-page\.vpr-collect-page \.vpr-collect-topbar__main \{[^}]+\}\n?/g, '\n');
if (next !== css) {
fs.writeFileSync(vprCss, next);
console.log('updated:', path.relative(process.cwd(), vprCss));
}
}
console.log(`\nDone. ${changed} file(s) updated.`);

View File

@@ -0,0 +1,92 @@
/**
* 从各原型 annotation-source.json 移除 ONE-OS 原型导航目录节点。
*
* 用法:
* node scripts/strip-prototype-nav-from-directory.mjs
* node scripts/strip-prototype-nav-from-directory.mjs --prototype lease-contract-management
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NAV_FOLDER_ID = 'oneos-project-nav';
function parseArgs(argv) {
const args = { prototype: '', projectRoot: '' };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--prototype' && argv[i + 1]) {
args.prototype = argv[++i].trim();
} else if (arg === '--project-root' && argv[i + 1]) {
args.projectRoot = path.resolve(argv[++i].trim());
}
}
return args;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function stripNavNodes(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
return list
.filter((node) => node?.id !== NAV_FOLDER_ID)
.map((node) => {
if (node?.type === 'folder' && Array.isArray(node.children)) {
return { ...node, children: stripNavNodes(node.children) };
}
return node;
});
}
function listPrototypeIds(projectRoot) {
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
if (!fs.existsSync(prototypesRoot)) return [];
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()
&& fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json')))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
function stripPrototype(prototypeId, projectRoot) {
const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json');
if (!fs.existsSync(annotationPath)) return false;
const annotation = readJson(annotationPath);
const before = JSON.stringify(annotation.directory?.nodes || []);
annotation.directory = {
nodes: stripNavNodes(annotation.directory?.nodes),
};
const after = JSON.stringify(annotation.directory.nodes || []);
if (before === after) {
console.log(`[skip] ${prototypeId}: 无导航节点`);
return false;
}
annotation.data = annotation.data || {};
annotation.data.updatedAt = Date.now();
writeJson(annotationPath, annotation);
console.log(`[strip] ${prototypeId}: 已移除原型导航目录`);
return true;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
const targets = args.prototype ? [args.prototype] : listPrototypeIds(projectRoot);
let changed = 0;
for (const prototypeId of targets) {
if (stripPrototype(prototypeId, projectRoot)) changed += 1;
}
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
}
main();

View File

@@ -26,6 +26,7 @@ const STANDALONE_MODULES = [
{ slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true }, { slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true },
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true }, { slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
{ slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true }, { slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true },
{ slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true },
]; ];
const MODULES = [ const MODULES = [
@@ -39,7 +40,17 @@ const MODULES = [
title: '财务管理', title: '财务管理',
dir: '财务管理', dir: '财务管理',
excludeDirs: ['文档'], excludeDirs: ['文档'],
excludeFiles: ['还车应结款.jsx', '还车应结款-查看.jsx', '还车应结款-费用明细.jsx'], excludeFiles: [
'还车应结款.jsx',
'还车应结款-查看.jsx',
'还车应结款-费用明细.jsx',
'提车应收款.jsx',
'提车应收款-查看.jsx',
'提车应收款-开票信息.jsx',
'提车应收款-审核.jsx',
'提车应收款-提车收款单.jsx',
'提车收款单-编辑.jsx',
],
}, },
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' }, { slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' }, { slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },

View File

@@ -1,5 +1,6 @@
/** /**
* 将 .axhub/make/sidebar-tree.json 左侧菜单同步到原型 annotation-source.json 的「原型目录」 * 将 .axhub/make/sidebar-tree.json 同步到原型导航页 nav-menu.json
* 不再向各原型 annotation-source.json 注入「ONE-OS 原型导航」目录。
* *
* 用法: * 用法:
* node scripts/sync-project-prototype-directory.mjs * node scripts/sync-project-prototype-directory.mjs
@@ -33,56 +34,6 @@ function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
} }
function prototypeIdFromItemKey(itemKey) {
const normalized = String(itemKey || '').trim().replace(/^\/+/u, '');
const match = normalized.match(/^prototypes\/(.+)$/u);
return match ? match[1] : '';
}
function navLinkId(prototypeId) {
return `nav-link-${prototypeId.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+|-+$/gu, '').toLowerCase()}`;
}
function createLinkNode(sidebarItem, currentPrototypeId) {
const prototypeId = prototypeIdFromItemKey(sidebarItem.itemKey);
if (!prototypeId) return null;
const isCurrent = prototypeId === currentPrototypeId;
return {
type: 'link',
id: navLinkId(prototypeId),
title: isCurrent ? `${sidebarItem.title}(当前)` : sidebarItem.title,
href: `/prototypes/${prototypeId}`,
target: 'self',
};
}
function sidebarItemsToDirectoryChildren(sidebarItems, currentPrototypeId) {
const children = [];
for (const item of sidebarItems || []) {
if (item?.kind === 'item') {
const link = createLinkNode(item, currentPrototypeId);
if (link) children.push(link);
continue;
}
if (item?.kind !== 'folder' || !Array.isArray(item.children)) {
continue;
}
const folderChildren = item.children
.filter((child) => child?.kind === 'item')
.map((child) => createLinkNode(child, currentPrototypeId))
.filter(Boolean);
if (!folderChildren.length) continue;
children.push({
type: 'folder',
id: item.id ? `nav-${item.id}` : `nav-folder-${children.length + 1}`,
title: item.title || '分组',
defaultExpanded: item.defaultExpanded !== false,
children: folderChildren,
});
}
return children;
}
function loadSidebarPrototypes(projectRoot) { function loadSidebarPrototypes(projectRoot) {
const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json'); const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json');
if (!fs.existsSync(sidebarPath)) { if (!fs.existsSync(sidebarPath)) {
@@ -102,6 +53,18 @@ function listPrototypeIdsWithAnnotation(projectRoot) {
.sort((a, b) => a.localeCompare(b, 'zh-CN')); .sort((a, b) => a.localeCompare(b, 'zh-CN'));
} }
function stripNavNodes(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
return list
.filter((node) => node?.id !== NAV_FOLDER_ID)
.map((node) => {
if (node?.type === 'folder' && Array.isArray(node.children)) {
return { ...node, children: stripNavNodes(node.children) };
}
return node;
});
}
function fixPublishedLinks(nodes, prototypeId) { function fixPublishedLinks(nodes, prototypeId) {
const walk = (items) => { const walk = (items) => {
for (const node of items || []) { for (const node of items || []) {
@@ -127,7 +90,7 @@ function fixPublishedLinks(nodes, prototypeId) {
walk(nodes); walk(nodes);
} }
function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) { function cleanupPrototypeDirectory(prototypeId, projectRoot) {
const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json'); const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json');
if (!fs.existsSync(annotationPath)) { if (!fs.existsSync(annotationPath)) {
console.log(`[skip] ${prototypeId}: 无 annotation-source.json`); console.log(`[skip] ${prototypeId}: 无 annotation-source.json`);
@@ -135,27 +98,22 @@ function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) {
} }
const annotation = readJson(annotationPath); const annotation = readJson(annotationPath);
const navChildren = sidebarItemsToDirectoryChildren(sidebarItems, prototypeId);
const navFolder = {
type: 'folder',
id: NAV_FOLDER_ID,
title: 'ONE-OS 原型导航',
defaultExpanded: true,
children: navChildren,
};
const existingNodes = Array.isArray(annotation.directory?.nodes) ? annotation.directory.nodes : []; const existingNodes = Array.isArray(annotation.directory?.nodes) ? annotation.directory.nodes : [];
const preservedNodes = existingNodes.filter((node) => node?.id !== NAV_FOLDER_ID); const cleanedNodes = stripNavNodes(existingNodes);
fixPublishedLinks(preservedNodes, prototypeId); fixPublishedLinks(cleanedNodes, prototypeId);
annotation.directory = { const before = JSON.stringify(existingNodes);
nodes: [navFolder, ...preservedNodes], const after = JSON.stringify(cleanedNodes);
}; if (before === after) {
console.log(`[ok] ${prototypeId}: 目录已不含原型导航`);
return false;
}
annotation.directory = { nodes: cleanedNodes };
annotation.data = annotation.data || {}; annotation.data = annotation.data || {};
annotation.data.updatedAt = Date.now(); annotation.data.updatedAt = Date.now();
writeJson(annotationPath, annotation); writeJson(annotationPath, annotation);
console.log(`[sync] ${prototypeId}: 已写入 ${navChildren.length} 个顶级目录项(含分组)`); console.log(`[cleanup] ${prototypeId}: 已移除原型导航目录节点`);
return true; return true;
} }
@@ -186,11 +144,11 @@ function main() {
let changed = 0; let changed = 0;
for (const prototypeId of targets) { for (const prototypeId of targets) {
if (syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot)) { if (cleanupPrototypeDirectory(prototypeId, projectRoot)) {
changed += 1; changed += 1;
} }
} }
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`); console.log(`完成:处理 ${targets.length} 个原型,清理 ${changed} 个 annotation-source.json`);
syncPrototypeNavMenu(projectRoot, sidebarItems); syncPrototypeNavMenu(projectRoot, sidebarItems);
} }

View File

@@ -1,6 +1,12 @@
{ {
"version": 1, "version": 1,
"skills": { "skills": {
"extract-annotation-source": {
"source": "lintendo/Axhub-Skills",
"sourceType": "github",
"skillPath": "skills/extract-annotation-source/SKILL.md",
"computedHash": "github-main-sync-2026-07-09"
},
"to-prd": { "to-prd": {
"source": "mattpocock/skills", "source": "mattpocock/skills",
"sourceType": "github", "sourceType": "github",

View File

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

View File

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

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

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

View File

@@ -1,12 +1,24 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import * as antd from 'antd'; import * as antd from 'antd';
import {
formatMoney,
formatMoneyYuan,
formatMoneyWithUnit,
formatNumber,
formatInteger,
} from '../format-number.js';
declare global { declare global {
interface Window { interface Window {
React: typeof React; React: typeof React;
ReactDOM: typeof ReactDOM; ReactDOM: typeof ReactDOM;
antd: typeof antd; 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.React = React;
window.ReactDOM = ReactDOM; window.ReactDOM = ReactDOM;
window.antd = antd; window.antd = antd;
window.__oneosFormatNumber = formatNumber;
window.__oneosFormatMoney = formatMoney;
window.__oneosFormatMoneyYuan = formatMoneyYuan;
window.__oneosFormatMoneyWithUnit = formatMoneyWithUnit;
window.__oneosFormatInteger = formatInteger;
} }
// Legacy jsx 可能在模块顶层使用 React.createElement须在页面 import 之前完成注入。 // Legacy jsx 可能在模块顶层使用 React.createElement须在页面 import 之前完成注入。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@
min-height: var(--vm-control-height); min-height: var(--vm-control-height);
height: var(--vm-control-height); height: var(--vm-control-height);
border-radius: var(--ln-radius-sm); border-radius: var(--ln-radius-sm);
border-color: var(--ln-border); border-color: var(--ln-hairline-strong);
font-size: 13px; font-size: 13px;
} }
@@ -113,7 +113,7 @@
.vm-page.bdl-page .bdl-report-hint { .vm-page.bdl-page .bdl-report-hint {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
color: var(--ln-subtle); color: var(--ln-muted-soft);
line-height: 1.45; line-height: 1.45;
} }
@@ -126,8 +126,9 @@
overflow: hidden; overflow: hidden;
max-height: none; max-height: none;
min-width: 0; min-width: 0;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card); 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, .vm-page.bdl-page .bdl-list-table.ant-table-wrapper,
@@ -136,55 +137,78 @@
max-width: 100%; max-width: 100%;
} }
/* —— 表格边框与表头(使用 Linear token避免无效变量 —— */
.vm-page.bdl-page .bdl-list-table .ant-table-container { .vm-page.bdl-page .bdl-list-table .ant-table-container {
border-radius: var(--ln-radius-card); 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-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; white-space: nowrap;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
padding: 0 10px !important; padding: 8px 12px !important;
height: 38px !important;
font-size: 13px; 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; color: var(--ln-ink) !important;
font-size: 13px !important;
font-weight: 600 !important; font-weight: 600 !important;
background: var(--ln-surface-2) !important; text-align: center;
border-bottom: 1px solid var(--ln-border) !important; border-bottom: 2px solid var(--ln-hairline-strong) !important;
border-inline-end: 1px solid var(--ln-border-subtle) !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; 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 { .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
color: var(--ln-body); color: var(--ln-body);
border-bottom: 1px solid var(--ln-border-subtle) !important; background: var(--ln-surface-card);
border-inline-end: 1px solid var(--ln-border-subtle) !important;
} }
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td { .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td {
background: var(--ln-accent-soft) !important; background: var(--ln-canvas-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;
} }
.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 { .vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
font-weight: 700; font-weight: 700;
background: var(--ln-surface-2) !important; background: var(--ln-surface-strong) !important;
color: var(--ln-ink) !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 */ /* 冻结列(对齐 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-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-end,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-right { .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), .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); 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 { .vm-page.bdl-page .biz-standbook-perf-link {
cursor: pointer; cursor: pointer;
color: var(--ln-primary); color: var(--ln-primary);
padding: 4px 8px; padding: 0;
margin: -4px -8px; margin: 0;
border: none; border: none;
background: transparent; background: transparent;
font: inherit; font: inherit;
border-radius: 6px; border-radius: 4px;
transition: background 0.2s, color 0.2s; 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 { .vm-page.bdl-page .biz-standbook-perf-link:hover {
color: var(--ln-primary-strong); color: var(--ln-primary-focus);
background: var(--ln-accent-soft); background: var(--ln-primary-soft);
} }
.vm-page.bdl-page .biz-standbook-perf-link:focus { .vm-page.bdl-page .biz-standbook-perf-link:focus {

View File

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

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "contract-template-management", "prototypeName": "contract-template-management",
"pageId": "list", "pageId": "list",
"updatedAt": 1783589987162, "updatedAt": 1783800844961,
"nodes": [ "nodes": [
{ {
"id": "ct-list-filter", "id": "ct-list-filter",
@@ -328,36 +328,6 @@
"assetMap": {}, "assetMap": {},
"directory": { "directory": {
"nodes": [ "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", "type": "folder",
"id": "ct-doc-root", "id": "ct-doc-root",

View File

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

View File

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

View File

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

View File

@@ -803,21 +803,6 @@
box-shadow: inset 0 0 0 1px var(--ln-primary); 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 { .vm-page.ct-page .ct-editor-page__sections {
flex-shrink: 0; flex-shrink: 0;
margin-bottom: 12px; margin-bottom: 12px;

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "customer-management", "prototypeName": "customer-management",
"pageId": "list", "pageId": "list",
"updatedAt": 1783589987164, "updatedAt": 1783800844964,
"nodes": [ "nodes": [
{ {
"id": "cm-list-filter", "id": "cm-list-filter",
@@ -245,36 +245,6 @@
"assetMap": {}, "assetMap": {},
"directory": { "directory": {
"nodes": [ "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", "type": "folder",
"id": "cm-doc-root", "id": "cm-doc-root",

View File

@@ -140,8 +140,8 @@ export function BatchRiskTagModal({
</div> </div>
<footer className="vm-modal-footer"> <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>
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmit}> <button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmit} data-vm-icon="send">
</button> </button>
</footer> </footer>

View File

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

View File

@@ -191,19 +191,12 @@ export function FilterPanel({
type="button" type="button"
className="vm-btn vm-btn-link" className="vm-btn vm-btn-link"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
> data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{expanded ? '收起' : '更多筛选'} {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-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw"></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}> <button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
<Search size={16} aria-hidden />
</button> </button>
</div> </div>
</section> </section>

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "insurance-procurement", "prototypeName": "insurance-procurement",
"pageId": "list", "pageId": "list",
"updatedAt": 1783589987165, "updatedAt": 1783800844965,
"nodes": [ "nodes": [
{ {
"id": "ipc-header", "id": "ipc-header",
@@ -496,36 +496,6 @@
"assetMap": {}, "assetMap": {},
"directory": { "directory": {
"nodes": [ "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", "type": "folder",
"id": "ipc-doc-root", "id": "ipc-doc-root",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,55 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
import { OperationActions } from '../../../common/OperationActions';
import type { LeaseBusinessDetailRow } from '../types'; import type { LeaseBusinessDetailRow } from '../types';
import { import {
DEFAULT_DETAIL_COLUMN_WIDTHS, DEFAULT_DETAIL_COLUMN_WIDTHS,
DETAIL_SUMMARY_COLUMN_KEYS,
DETAIL_TABLE_COLUMNS, DETAIL_TABLE_COLUMNS,
DETAIL_TABLE_MIN_WIDTH, DETAIL_TABLE_MIN_WIDTH,
type DetailTableColumn, type DetailTableColumn,
} from './tableColumns'; } from './tableColumns';
import type { LeaseDetailKpiSummary } from '../types'; import type { LeaseDetailKpiSummary } from '../types';
import { import {
detailRowYearMonth,
displayDeposit, displayDeposit,
displayMoney, displayMoney,
displayOutstanding, displayOutstanding,
displayText, displayText,
formatMoney, formatMoney,
} from '../utils/detail'; } 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 { interface DetailTableProps {
records: LeaseBusinessDetailRow[]; records: LeaseBusinessDetailRow[];
summary: LeaseDetailKpiSummary; summary: LeaseDetailKpiSummary;
loading?: boolean; 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'; 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>) { function buildStickyLayout(widths: Record<string, number>) {
let left = 0; let left = 0;
@@ -59,36 +83,85 @@ function stickyStyle(
}; };
} }
function HeaderSort({ function getSortValue(row: LeaseBusinessDetailRow, key: string): string | number {
title, if (key === 'paymentStatus') {
sortActive, return paymentStatusSortValue(row.receivableTotal, row.receivedAmount);
sortDir, }
onSort, if (key === 'yearMonth') return row.year * 100 + row.month;
}: { return row[key as keyof LeaseBusinessDetailRow] as string | number;
title: string; }
sortActive?: boolean;
sortDir?: SortDir; function renderCellContent(
onSort: () => void; row: LeaseBusinessDetailRow,
}) { col: DetailTableColumn,
return ( ): React.ReactNode {
<button type="button" className={`ldb-th-sort ${sortActive ? 'is-active' : ''}`} onClick={onSort}> switch (col.render) {
<span className="ldb-th-label">{title}</span> case 'payment-status': {
<span className="ldb-th-sort-icon" aria-hidden> const label = resolveDetailPaymentStatus(row.receivableTotal, row.receivedAmount);
{sortActive const statusClass = DETAIL_PAYMENT_STATUS_CLASS[label];
? (sortDir === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />) return <span className={`ldb-status-tag ${statusClass}`}>{label}</span>;
: <ArrowUpDown size={14} />} }
</span> case 'year-month':
</button> 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;
} }
function renderCell(row: LeaseBusinessDetailRow, col: DetailTableColumn) {
if (col.key === 'outstanding') { if (col.key === 'outstanding') {
const { text, warn } = displayOutstanding(row.outstanding); const { text, warn } = displayOutstanding(row.outstanding);
if (!text) return ''; if (!text) return '';
return warn return warn
? <span className="lbd-outstanding-warn tabular-nums">{text}</span> ? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
: text; : <span className="tabular-nums">{text}</span>;
} }
if (col.key === 'deposit') { if (col.key === 'deposit') {
return <span className="tabular-nums">{displayDeposit(row.deposit)}</span>; return <span className="tabular-nums">{displayDeposit(row.deposit)}</span>;
@@ -102,37 +175,63 @@ function renderCell(row: LeaseBusinessDetailRow, col: DetailTableColumn) {
if (col.key === 'customerName') { if (col.key === 'customerName') {
return <span className="lbd-cell-customer" title={row.customerName}>{displayText(row.customerName)}</span>; 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') { if (col.key === 'otherDeductionDetail' || col.key === 'remark') {
const text = displayText(row[col.key]); const text = displayText(row[col.key]);
return text ? <span className="lbd-cell-remark" title={text}>{text}</span> : ''; return text ? <span className="lbd-cell-remark" title={text}>{text}</span> : '';
} }
const raw = row[col.key as keyof LeaseBusinessDetailRow]; const raw = row[col.key as keyof LeaseBusinessDetailRow];
if (col.money && typeof raw === 'number') return displayMoney(raw); if (col.money && typeof raw === 'number') return displayMoney(raw);
if (col.key === 'year' || col.key === 'month' || col.key === 'avgDays') { if (col.key === 'avgDays') {
return raw === 0 ? '' : String(raw); return raw === 0 ? '' : Number(raw).toFixed(2);
} }
return displayText(raw); 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) { function renderSummaryCell(col: DetailTableColumn, summary: LeaseDetailKpiSummary) {
if (col.key === 'year') { if (col.key === 'yearMonth') {
return <span className="lbd-summary-label"></span>; 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]; const value = summary[col.summaryKey];
if (col.key === 'outstanding') { if (col.summaryKey === 'outstanding') {
const { text, warn } = displayOutstanding(value); const { text, warn } = displayOutstanding(value);
if (!text) return '';
return warn return warn
? <span className="lbd-outstanding-warn tabular-nums">{text}</span> ? <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 text = formatMoney(value);
const cls = value >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg'; const cls = value >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg';
return <span className={`tabular-nums ${cls}`}>{text}</span>; 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); 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 [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc'); const [sortDir, setSortDir] = useState<SortDir>('asc');
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_DETAIL_COLUMN_WIDTHS })); 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; if (!sortKey) return records;
const dir = sortDir === 'asc' ? 1 : -1; const dir = sortDir === 'asc' ? 1 : -1;
return [...records].sort((a, b) => { return [...records].sort((a, b) => {
const av = a[sortKey as keyof LeaseBusinessDetailRow]; const av = getSortValue(a, sortKey);
const bv = b[sortKey as keyof LeaseBusinessDetailRow]; const bv = getSortValue(b, sortKey);
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir; if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av ?? '').localeCompare(String(bv ?? ''), 'zh-CN') * 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( const tableMinWidth = Math.max(
DETAIL_TABLE_MIN_WIDTH, 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), (sum, col) => sum + (columnWidths[col.key] ?? DEFAULT_DETAIL_COLUMN_WIDTHS[col.key] ?? 100),
0, 0,
), ),
@@ -236,10 +344,13 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
{DETAIL_TABLE_COLUMNS.map((col) => ( {DETAIL_TABLE_COLUMNS.map((col) => (
<col key={col.key} style={{ width: columnWidths[col.key] ?? DEFAULT_DETAIL_COLUMN_WIDTHS[col.key] ?? 100 }} /> <col key={col.key} style={{ width: columnWidths[col.key] ?? DEFAULT_DETAIL_COLUMN_WIDTHS[col.key] ?? 100 }} />
))} ))}
<col style={{ width: ACTION_COL_WIDTH }} />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
{DETAIL_TABLE_COLUMNS.map((col) => ( {DETAIL_TABLE_COLUMNS.map((col) => {
const headerTip = resolveColumnHeaderTip(col.annotationId, col.headerTip);
return (
<th <th
key={col.key} key={col.key}
className={[ className={[
@@ -249,17 +360,17 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
'ldb-col-resizable', 'ldb-col-resizable',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
style={stickyStyle(col, stickyLayout, true)} style={stickyStyle(col, stickyLayout, true)}
{...(col.annotationId ? { 'data-annotation-id': col.annotationId } : {})}
> >
{col.dateSort ? ( <div className="ldb-th-inner">
<HeaderSort <HeaderWithTip
title={col.title} title={col.title}
tip={headerTip}
sortable={Boolean(col.dateSort)}
sortActive={sortKey === col.key} sortActive={sortKey === col.key}
sortDir={sortDir} sortDir={sortDir}
onSort={() => handleSort(col.key)} onSort={col.dateSort ? () => handleSort(col.key) : undefined}
/> />
) : (
col.title
)}
<span <span
className="ldb-col-resize-handle" className="ldb-col-resize-handle"
role="separator" role="separator"
@@ -267,13 +378,19 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
aria-label={`调整${col.title}列宽`} aria-label={`调整${col.title}列宽`}
onMouseDown={(event) => handleResizeStart(col.key, event)} onMouseDown={(event) => handleResizeStart(col.key, event)}
/> />
</div>
</th> </th>
))} );
})}
<th className="sticky-col-right ldb-col-actions" data-annotation-id="lbd-col-actions"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedRecords.map((row) => ( {sortedRecords.map((row) => {
<tr key={row.id}> 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) => ( {DETAIL_TABLE_COLUMNS.map((col) => (
<td <td
key={col.key} key={col.key}
@@ -284,11 +401,32 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
style={stickyStyle(col, stickyLayout, false)} style={stickyStyle(col, stickyLayout, false)}
> >
{renderCell(row, col)} {renderCell(row, col, onRowPatch)}
</td> </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> </tbody>
<tfoot> <tfoot>
<tr className="lbd-summary-row"> <tr className="lbd-summary-row">
@@ -306,6 +444,7 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
{renderSummaryCell(col, summary)} {renderSummaryCell(col, summary)}
</td> </td>
))} ))}
<td className="sticky-col-right ldb-col-actions" />
</tr> </tr>
</tfoot> </tfoot>
</table> </table>

View File

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

View File

@@ -4,8 +4,9 @@ import type { LeaseBusinessDetailRow, LeaseDetailFilters } from '../types';
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField'; import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField'; import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail'; 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; const EXTRA_KEYS = ['plateNos'] as const;
type FieldKey = (typeof PRIMARY_KEYS)[number] | (typeof EXTRA_KEYS)[number]; 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: { plateNos: {
key: 'plateNos', key: 'plateNos',
label: '车牌号码', label: '车牌号码',
@@ -158,21 +172,19 @@ export function FilterPanel({
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
aria-expanded={expanded} aria-expanded={expanded}
aria-controls="lbd-filter-expand-panel" aria-controls="lbd-filter-expand-panel"
>
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{expanded ? '收起' : '更多筛选'} {expanded ? '收起' : '更多筛选'}
{extraActiveCount > 0 && !expanded ? ( {extraActiveCount > 0 && !expanded ? (
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选"> <span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
{extraActiveCount} {extraActiveCount}
</span> </span>
) : null} ) : null}
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
</button> </button>
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}> <button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
</button> </button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}> <button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
<Search size={16} aria-hidden />
</button> </button>
</div> </div>
</section> </section>

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,34 +8,59 @@ import './styles/index.css';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { Download, FileUp } from 'lucide-react'; import { Download, FileUp } from 'lucide-react';
import { import {
AnnotationViewer,
type AnnotationDirectoryRouteNode, type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument, type AnnotationSourceDocument,
type AnnotationViewerOptions, type AnnotationViewerOptions
} from '@axhub/annotation'; } from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import seedRows from './data/rows.json'; import seedRows from './data/rows.json';
import changeLogSeed from './data/change-log-seed.json';
import { FilterPanel } from './components/FilterPanel'; import { FilterPanel } from './components/FilterPanel';
import { DetailKpiRow } from './components/DetailKpiRow'; import { DetailKpiRow } from './components/DetailKpiRow';
import { DetailTable } from './components/DetailTable'; import { DetailTable } from './components/DetailTable';
import { DetailImportModal } from './components/DetailImportModal'; 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 { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
import { import {
EMPTY_LEASE_DETAIL_FILTERS, EMPTY_LEASE_DETAIL_FILTERS,
type LeaseBusinessDetailRow, type LeaseBusinessDetailRow,
type LeaseDetailFilters, type LeaseDetailFilters,
} from './types'; } from './types';
import { applyDetailFilters, sumDetailKpi } from './utils/detail'; import { applyDetailFilters, detailRowYearMonth, sumDetailKpi } from './utils/detail';
import { applyDetailCalculations } from './utils/calc-detail'; import { applyDetailCalculations } from './utils/calc-detail';
import { import {
downloadDetailImportTemplate, downloadDetailImportTemplate,
downloadDetailImportErrorLog,
exportDetailRows, exportDetailRows,
parseDetailImportFile, parseDetailImportFile,
type DetailImportErrorRow,
} from './utils/batch-io'; } 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'; import annotationSourceDocument from './annotation-source.json';
const ARCHIVED_DEMO_ROW_IDS = new Set(['lbd-33']);
export default function LeaseBusinessDetailApp() { export default function LeaseBusinessDetailApp() {
const operator = useMemo(() => resolveDetailOperator(), []);
const isSupervisor = isDetailSupervisor(operator);
const [records, setRecords] = useState<LeaseBusinessDetailRow[]>(() => 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 [pendingFilters, setPendingFilters] = useState<LeaseDetailFilters>(EMPTY_LEASE_DETAIL_FILTERS);
const [appliedFilters, setAppliedFilters] = 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 [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [tableLoading, setTableLoading] = useState(false); const [tableLoading, setTableLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [importErrors, setImportErrors] = useState<DetailImportErrorRow[]>([]);
const [importErrorHeaders, setImportErrorHeaders] = useState<string[]>([]);
const [toast, setToast] = useState(''); 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) => { const showToast = useCallback((msg: string) => {
setToast(msg); setToast(msg);
@@ -61,6 +91,32 @@ export default function LeaseBusinessDetailApp() {
return filtered.slice(start, start + pageSize); return filtered.slice(start, start + pageSize);
}, [filtered, page, 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 = () => { const handleSearch = () => {
setTableLoading(true); setTableLoading(true);
window.setTimeout(() => { window.setTimeout(() => {
@@ -78,20 +134,70 @@ export default function LeaseBusinessDetailApp() {
const handleImport = async (file: File) => { const handleImport = async (file: File) => {
try { try {
const imported = await parseDetailImportFile(file); const result = await parseDetailImportFile(file);
if (imported.length === 0) { setImportErrors(result.errors);
showToast('未解析到有效数据,请检查模板与必填列(年份、月份、车牌号码)'); setImportErrorHeaders(result.fileHeaders);
if (result.success.length === 0 && result.errors.length === 0) {
showToast('未解析到有效数据,请检查模板与必填列');
return; return;
} }
if (result.success.length > 0) {
const imported = result.success.map((row) => applyDetailCalculations({
...row,
archiveStatus: 'active',
reconciledAt: '',
}));
setRecords((prev) => [...imported, ...prev]); setRecords((prev) => [...imported, ...prev]);
setImportOpen(false); 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); setPage(1);
showToast(`已导入 ${imported.length} 条明细,公式列已自动计算`); }
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 { } catch {
showToast('导入失败,请检查文件格式'); 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>( const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({ () => ({
showToolbar: true, showToolbar: true,
@@ -108,6 +214,16 @@ export default function LeaseBusinessDetailApp() {
return ( return (
<> <>
<div className="vm-page ldb-page lc-page lc-page--list-dense lbd-page"> <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 <FilterPanel
records={records} records={records}
filters={pendingFilters} filters={pendingFilters}
@@ -124,27 +240,40 @@ export default function LeaseBusinessDetailApp() {
<button <button
type="button" type="button"
className="vm-btn vm-btn-secondary ldb-toolbar-btn" 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>
<button <button
type="button" type="button"
className="vm-btn vm-btn-ghost ldb-toolbar-btn" className="vm-btn vm-btn-ghost ldb-toolbar-btn"
data-vm-icon="download"
onClick={() => { onClick={() => {
exportDetailRows(filtered); exportDetailRows(filtered);
showToast(`已导出 ${filtered.length} 条明细`); showToast(`已导出 ${filtered.length} 条明细`);
}} }}
> >
<Download size={16} aria-hidden />
</button> </button>
</div> </div>
</div> </div>
<div className="vm-table-card"> <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"> <div className="vm-table-footer ldb-table-footer">
<TablePagination <TablePagination
page={Math.min(page, totalPages)} page={Math.min(page, totalPages)}
@@ -168,9 +297,41 @@ export default function LeaseBusinessDetailApp() {
onClose={() => setImportOpen(false)} onClose={() => setImportOpen(false)}
onDownloadTemplate={downloadDetailImportTemplate} onDownloadTemplate={downloadDetailImportTemplate}
onImport={handleImport} onImport={handleImport}
failedCount={importErrors.length}
onDownloadErrorLog={
importErrors.length > 0
? () => downloadDetailImportErrorLog(importErrors, importErrorHeaders)
: undefined
}
/> />
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} /> <DetailEditModal
open={Boolean(editRow)}
row={editRow}
onClose={() => setEditRow(null)}
onSave={(patch) => {
if (!editRow) return;
updateRow(editRow.id, patch);
setEditRow(null);
showToast('明细已保存,公式列已自动重算');
}}
/>
<DetailDeleteConfirmModal
open={Boolean(deleteRow)}
rowLabel={deleteRow ? `${detailRowYearMonth(deleteRow)} · ${deleteRow.plateNo}` : ''}
onCancel={() => setDeleteRow(null)}
onConfirm={handleDeleteConfirm}
/>
<DetailChangeLogModal
open={Boolean(changeLogRow)}
rowLabel={changeLogLabel}
entries={changeLogEntries}
onClose={() => setChangeLogRow(null)}
/>
<PrototypeAnnotationHost source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
</> </>
); );
} }

View File

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

View File

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

View File

@@ -1,20 +1,83 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import type { LeaseBusinessDetailRow } from '../types'; import type { LeaseBusinessDetailRow } from '../types';
import { import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableColumns';
DETAIL_EXPORT_HEADERS,
DETAIL_IMPORT_TEMPLATE_HEADERS,
DETAIL_TABLE_COLUMNS,
} from '../components/tableColumns';
import { applyDetailCalculations } from './calc-detail'; 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> = { const TEMPLATE_SAMPLE: Record<string, string | number> = {
年份: 2026, 年份: 2026,
月份: 2, 月份: 2,
: '业务二部',
: '刘念忠',
: '2026-02-05', : '2026-02-05',
: '沪A52898F', : '沪A52898F',
: '18T',
: '上海虹钦物流有限公司', : '上海虹钦物流有限公司',
: '', : '',
: '', : '',
@@ -23,28 +86,26 @@ const TEMPLATE_SAMPLE: Record<string, string | number> = {
: '2026-11-30', : '2026-11-30',
: '2026-02-01', : '2026-02-01',
退: '', 退: '',
平均天数: 28, 平均天数: 1,
押金: 20000, 押金: 20000,
合同标的租金: 18500, 合同标的租金: 4500,
应收租金: 18500,
月度租金: 18500,
维保包干收入: 0, 维保包干收入: 0,
保险上浮费: 0, 保险上浮费: 0,
运维费: 0, 运维费: 0,
其他收入: 0, 其他收入: 0,
里程减免金额: 0, 里程减免金额: 0,
其他减免金额: 0, 其他减免金额: 0,
实收金额: 18500, 实收金额: 4500,
: '2026-02-06', : '2026-02-06',
: '2026-02-05', : '2026-02-05',
: '月付指付', : '月度预付',
车辆标准成本: 12000, 车辆标准成本: 3900,
车辆实际成本: 0,
保险费: 0, 保险费: 0,
: 3200, 居间: 500,
居间费: 800,
其他: 0, 其他: 0,
GPS里程: '3260', GPS里程: '',
: '', : '',
: '浙江羚牛氢能科技有限公司', : '浙江羚牛氢能科技有限公司',
: '浙江羚牛氢能科技有限公司', : '浙江羚牛氢能科技有限公司',
: '', : '',
@@ -81,11 +142,14 @@ const HEADER_TO_KEY: Record<string, keyof LeaseBusinessDetailRow> = {
: 'actualPaymentDate', : 'actualPaymentDate',
: 'paymentMethod', : 'paymentMethod',
: 'vehicleStandardCost', : 'vehicleStandardCost',
: 'vehicleActualCost',
: 'insuranceFee', : 'insuranceFee',
: 'hydrogenFee', : 'hydrogenFee',
: 'brokerageFee', : 'brokerageFee',
: 'otherCost', : 'otherCost',
GPS里程: 'gpsMileage', GPS里程: 'gpsMileage',
'GPS 里程': 'gpsMileage',
: 'otherDeductionDetail',
: 'otherDeductionDetail', : 'otherDeductionDetail',
: 'assetOwner', : 'assetOwner',
: 'signingCompany', : 'signingCompany',
@@ -97,7 +161,7 @@ const IMPORT_NUMERIC_KEYS = new Set<keyof LeaseBusinessDetailRow>([
'contractRent', 'receivableRent', 'monthlyRent', 'contractRent', 'receivableRent', 'monthlyRent',
'maintenanceIncome', 'insuranceSurcharge', 'opsFeeIncome', 'otherIncome', 'maintenanceIncome', 'insuranceSurcharge', 'opsFeeIncome', 'otherIncome',
'mileageDeduction', 'otherDeduction', 'receivedAmount', 'mileageDeduction', 'otherDeduction', 'receivedAmount',
'vehicleStandardCost', 'insuranceFee', 'hydrogenFee', 'vehicleStandardCost', 'vehicleActualCost', 'insuranceFee', 'hydrogenFee',
'opsFeeCost', 'brokerageFee', 'otherCost', 'opsFeeCost', 'brokerageFee', 'otherCost',
]); ]);
@@ -108,6 +172,13 @@ const IMPORT_TEXT_KEYS = new Set<keyof LeaseBusinessDetailRow>([
'gpsMileage', 'otherDeductionDetail', 'assetOwner', 'signingCompany', 'remark', '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 { function parseNumber(value: unknown): number {
if (value === null || value === undefined || value === '') return 0; if (value === null || value === undefined || value === '') return 0;
const n = Number(String(value).replace(/,/g, '')); const n = Number(String(value).replace(/,/g, ''));
@@ -121,10 +192,94 @@ function parseDeposit(value: unknown): number | string {
return parseNumber(raw); 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[] { function rowToExportCells(row: LeaseBusinessDetailRow): string[] {
let opsIncomeWritten = false; let opsIncomeWritten = false;
return DETAIL_TABLE_COLUMNS.map((col) => { return DETAIL_EXPORT_SPECS.map((spec) => {
const header = col.title; const header = spec.title;
if (header === '运维费') { if (header === '运维费') {
if (!opsIncomeWritten) { if (!opsIncomeWritten) {
opsIncomeWritten = true; opsIncomeWritten = true;
@@ -132,7 +287,7 @@ function rowToExportCells(row: LeaseBusinessDetailRow): string[] {
} }
return row.opsFeeCost === 0 ? '' : String(row.opsFeeCost); return row.opsFeeCost === 0 ? '' : String(row.opsFeeCost);
} }
const key = col.key as keyof LeaseBusinessDetailRow; const key = spec.key;
const raw = row[key]; const raw = row[key];
if (key === 'deposit') { if (key === 'deposit') {
if (raw === '/' || raw === '') return raw === '/' ? '/' : ''; if (raw === '/' || raw === '') return raw === '/' ? '/' : '';
@@ -151,12 +306,25 @@ export function downloadDetailImportTemplate(): void {
}, []); }, []);
if (opsIndexes[1] !== undefined) sampleRow[opsIndexes[1]] = 0; 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(); const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细'); XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
XLSX.writeFile(book, '租赁业务明细导入模板.xlsx'); 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 { export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
const body = [DETAIL_EXPORT_HEADERS, ...rows.map(rowToExportCells)]; const body = [DETAIL_EXPORT_HEADERS, ...rows.map(rowToExportCells)];
const sheet = XLSX.utils.aoa_to_sheet(body); const sheet = XLSX.utils.aoa_to_sheet(body);
@@ -165,43 +333,38 @@ export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
XLSX.writeFile(book, `租赁业务明细_${Date.now()}.xlsx`); 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 buffer = await file.arrayBuffer();
const book = XLSX.read(buffer, { type: 'array' }); const book = XLSX.read(buffer, { type: 'array' });
const sheet = book.Sheets[book.SheetNames[0]]; const sheet = book.Sheets[book.SheetNames[0]];
const matrix = XLSX.utils.sheet_to_json<(string | number)[]>(sheet, { header: 1, defval: '' }) as (string | number)[][]; 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 fileHeaders = matrix[0].map((cell) => String(cell).trim());
const rows: LeaseBusinessDetailRow[] = []; const success: LeaseBusinessDetailRow[] = [];
const errors: DetailImportErrorRow[] = [];
matrix.slice(1).forEach((cells, index) => { matrix.slice(1).forEach((cells, index) => {
if (!cells.some((cell) => String(cell).trim())) return; if (!cells.some((cell) => String(cell).trim())) return;
const draft: Partial<LeaseBusinessDetailRow> = {
id: `import-${Date.now()}-${index}`,
};
let opsFeeColIndex = 0;
headers.forEach((header, colIndex) => { const rawCells = fileHeaders.map((_, colIndex) => cells[colIndex] ?? '');
let key = HEADER_TO_KEY[header]; const requiredReasons = validateRequiredFields(fileHeaders, cells);
if (header === '运维费') { if (requiredReasons.length > 0) {
opsFeeColIndex += 1; errors.push({ reason: requiredReasons.join(''), rawCells });
key = opsFeeColIndex === 1 ? 'opsFeeIncome' : 'opsFeeCost'; return;
} }
if (!key) return;
const raw = cells[colIndex]; const draft = parseRowDraft(fileHeaders, cells, index);
if (key === 'deposit') { const plateNo = String(draft.plateNo ?? '').trim();
(draft as Record<string, unknown>)[key] = parseDeposit(raw); if (!isPlateInVehicleSystem(plateNo)) {
} else if (IMPORT_NUMERIC_KEYS.has(key)) { errors.push({ reason: '车牌号不存在', rawCells });
(draft as Record<string, unknown>)[key] = parseNumber(raw); return;
} else if (IMPORT_TEXT_KEYS.has(key)) {
(draft as Record<string, unknown>)[key] = String(raw ?? '').trim();
} }
success.push(applyDetailCalculations(draft));
}); });
if (!draft.plateNo || !draft.year || !draft.month) return; return { success, errors, fileHeaders };
rows.push(applyDetailCalculations(draft));
});
return rows;
} }

View File

@@ -1,4 +1,6 @@
import type { LeaseBusinessDetailRow } from '../types'; import type { LeaseBusinessDetailRow } from '../types';
import { sumHydrogenFeeForDetailRow } from './hydrogen-fee';
import { resolveSettlementMonths } from './system-ref';
/** 对齐 Excel「租赁车辆收入明细表」公式列 */ /** 对齐 Excel「租赁车辆收入明细表」公式列 */
export const DETAIL_CALCULATED_KEYS = [ export const DETAIL_CALCULATED_KEYS = [
@@ -26,7 +28,55 @@ type IncomeAdjustments = Pick<
| 'otherDeduction' | '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( export function calcDetailReceivableTotal(
receivableRent: number, receivableRent: number,
adjustments: IncomeAdjustments, adjustments: IncomeAdjustments,
@@ -127,19 +177,45 @@ export function applyDetailCalculations(
otherDeduction: row.otherDeduction ?? 0, otherDeduction: row.otherDeduction ?? 0,
}; };
const receivableRent = row.receivableRent ?? 0; const year = row.year ?? 0;
const monthlyRent = row.monthlyRent ?? 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 receivedAmount = row.receivedAmount ?? 0;
const vehicleStandardCost = row.vehicleStandardCost ?? 0; const vehicleStandardCost = row.vehicleStandardCost ?? 0;
const avgDays = row.avgDays ?? 0;
const vehicleActualCost = calcDetailVehicleActualCost(vehicleStandardCost, avgDays); const vehicleActualCost = calcDetailVehicleActualCost(vehicleStandardCost, avgDays);
const monthlyIncome = calcDetailMonthlyIncome(monthlyRent, adjustments); const monthlyIncome = calcDetailMonthlyIncome(monthlyRent, adjustments);
const receivableTotal = calcDetailReceivableTotal(receivableRent, 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({ const totalCost = calcDetailTotalCost({
vehicleActualCost, vehicleActualCost,
insuranceFee: row.insuranceFee ?? 0, insuranceFee: row.insuranceFee ?? 0,
hydrogenFee: row.hydrogenFee ?? 0, hydrogenFee,
opsFeeCost: row.opsFeeCost ?? 0, opsFeeCost: row.opsFeeCost ?? 0,
brokerageFee: row.brokerageFee ?? 0, brokerageFee: row.brokerageFee ?? 0,
otherCost: row.otherCost ?? 0, otherCost: row.otherCost ?? 0,
@@ -154,7 +230,7 @@ export function applyDetailCalculations(
...EMPTY_TEXT_FIELDS, ...EMPTY_TEXT_FIELDS,
...row, ...row,
deposit: row.deposit ?? 0, deposit: row.deposit ?? 0,
contractRent: row.contractRent ?? 0, contractRent,
receivableRent, receivableRent,
monthlyRent, monthlyRent,
...adjustments, ...adjustments,
@@ -162,7 +238,7 @@ export function applyDetailCalculations(
vehicleStandardCost, vehicleStandardCost,
avgDays, avgDays,
insuranceFee: row.insuranceFee ?? 0, insuranceFee: row.insuranceFee ?? 0,
hydrogenFee: row.hydrogenFee ?? 0, hydrogenFee,
opsFeeCost: row.opsFeeCost ?? 0, opsFeeCost: row.opsFeeCost ?? 0,
brokerageFee: row.brokerageFee ?? 0, brokerageFee: row.brokerageFee ?? 0,
otherCost: row.otherCost ?? 0, otherCost: row.otherCost ?? 0,
@@ -172,5 +248,7 @@ export function applyDetailCalculations(
totalCost, totalCost,
outstanding, outstanding,
profitLoss, profitLoss,
archiveStatus: row.archiveStatus ?? 'active',
reconciledAt: row.reconciledAt ?? '',
}; };
} }

View File

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

View File

@@ -4,6 +4,7 @@ import type {
LeaseDetailKpiSummary, LeaseDetailKpiSummary,
} from '../types'; } from '../types';
import { EMPTY_LEASE_DETAIL_KPI } from '../types'; import { EMPTY_LEASE_DETAIL_KPI } from '../types';
import { resolveDetailPaymentStatus } from './payment-status';
export function detailRowYearMonth(row: LeaseBusinessDetailRow): string { export function detailRowYearMonth(row: LeaseBusinessDetailRow): string {
return `${row.year}-${String(row.month).padStart(2, '0')}`; 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 } { 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 }; 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.businessDept && row.businessDept !== filters.businessDept) return false;
if (filters.salesperson && row.salesperson !== filters.salesperson) return false; if (filters.salesperson && row.salesperson !== filters.salesperson) return false;
if (customerQ && !row.customerName.toLowerCase().includes(customerQ)) 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; if (filters.plateNos.length > 0 && !filters.plateNos.includes(row.plateNo)) return false;
return true; return true;
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "lease-business-ledger", "prototypeName": "lease-business-ledger",
"pageId": "list", "pageId": "list",
"updatedAt": 1783589987166, "updatedAt": 1783800844966,
"nodes": [ "nodes": [
{ {
"id": "lbl-filter", "id": "lbl-filter",
@@ -1158,36 +1158,6 @@
"assetMap": {}, "assetMap": {},
"directory": { "directory": {
"nodes": [ "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", "type": "folder",
"id": "lbl-doc-root", "id": "lbl-doc-root",

View File

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

View File

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

View File

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

View File

@@ -218,21 +218,19 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
aria-expanded={expanded} aria-expanded={expanded}
aria-controls="ldb-filter-expand-panel" aria-controls="ldb-filter-expand-panel"
>
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{expanded ? '收起' : '更多筛选'} {expanded ? '收起' : '更多筛选'}
{extraActiveCount > 0 && !expanded ? ( {extraActiveCount > 0 && !expanded ? (
<span className="ldb-filter-toggle-badge" aria-label={`已设置 ${extraActiveCount} 项扩展筛选`}> <span className="ldb-filter-toggle-badge" aria-label={`已设置 ${extraActiveCount} 项扩展筛选`}>
{extraActiveCount} {extraActiveCount}
</span> </span>
) : null} ) : null}
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
</button> </button>
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}> <button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
</button> </button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}> <button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
<Search size={16} aria-hidden />
</button> </button>
</div> </div>
</section> </section>

View File

@@ -38,9 +38,7 @@ export function LeaseLedgerImportModal({
<span className="vm-step-badge">1</span> <span className="vm-step-badge">1</span>
<span></span> <span></span>
</div> </div>
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate}> <button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">
<Download size={16} aria-hidden />
</button> </button>
</section> </section>
<section className="vm-import-step"> <section className="vm-import-step">
@@ -71,7 +69,7 @@ export function LeaseLedgerImportModal({
</section> </section>
</div> </div>
<footer className="vm-modal-footer"> <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> </footer>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ export function OperationHistoryModal({ open, plateNo, entries, onClose }: Opera
)} )}
</div> </div>
<footer className="vm-modal-footer"> <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> </footer>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "lease-business-line-overview", "prototypeName": "lease-business-line-overview",
"pageId": "overview", "pageId": "overview",
"updatedAt": 1783589987166, "updatedAt": 1783800844966,
"nodes": [ "nodes": [
{ {
"id": "lblo-system-intro", "id": "lblo-system-intro",
@@ -173,36 +173,6 @@
"assetMap": {}, "assetMap": {},
"directory": { "directory": {
"nodes": [ "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", "type": "folder",
"id": "lblo-doc-root", "id": "lblo-doc-root",

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,12 +15,24 @@ import {
import { import {
getPublishedContractTemplateOptions, getPublishedContractTemplateOptions,
subscribePublishedContractTemplateOptions, subscribePublishedContractTemplateOptions,
getContractTemplatePreviewBadge,
getContractTypeLabel, getContractTypeLabel,
} from '../contract-template-management/contract-template-catalog.js'; } from '../contract-template-management/contract-template-catalog.js';
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js'; import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState, hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js'; import {
import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js'; 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 { import {
resolveLeaseContractApprovalType, resolveLeaseContractApprovalType,
STANDARD_CONTRACT_APPROVAL, STANDARD_CONTRACT_APPROVAL,
@@ -34,13 +46,27 @@ import {
isPowerOfAttorneyOptionalEmpty, isPowerOfAttorneyOptionalEmpty,
isSealTypeSelected, isSealTypeSelected,
isLeaseContractFormComplete, isLeaseContractFormComplete,
isTripartitePartyComplete,
} from './lease-contract-form-validation.js'; } from './lease-contract-form-validation.js';
import LeaseContractPreviewPanel, { PreviewEditableHint } from './LeaseContractPreviewPanel.jsx'; import LeaseContractPreviewPanel, {
PreviewEditableHint,
PreviewApprovalHint,
PreviewAttachment1ReadonlyHint,
PreviewPowerOfAttorneyReadonlyHint,
} from './LeaseContractPreviewPanel.jsx';
import LeaseContractEditorForm, { import LeaseContractEditorForm, {
LeaseContractLeaseOrderSection, LeaseContractLeaseOrderSection,
LeaseContractPowerOfAttorneySection, LeaseContractPowerOfAttorneySection,
LeaseContractRemarkSection, LeaseContractRemarkSection,
} from './LeaseContractEditorForm.jsx'; } 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 = [ var SEAL_TYPE_OPTIONS = [
{ value: 'contract', label: '合同章' }, { value: 'contract', label: '合同章' },
@@ -56,7 +82,49 @@ function normalizeSealTypes(types) {
return next.length ? next : ['contract']; 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 contractTypeState = useState('');
var contractType = contractTypeState[0]; var contractType = contractTypeState[0];
var setContractType = contractTypeState[1]; var setContractType = contractTypeState[1];
@@ -72,11 +140,9 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var setTemplateOptions = templateOptionsState[1]; var setTemplateOptions = templateOptionsState[1];
var templateSelectOptions = useMemo(function () { var templateSelectOptions = useMemo(function () {
return templateOptions.map(function (option) { return templateOptions.map(function (option) {
var typeLabel = getContractTypeLabel(option.contractType) || option.contractType || '—';
var fileLabel = option.fileName || option.title || option.id;
return { return {
value: option.id, value: option.id,
label: typeLabel + ' · ' + fileLabel, label: option.contractTypeLabel || option.title || option.kind || option.id,
contractType: option.contractType, contractType: option.contractType,
}; };
}); });
@@ -111,22 +177,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD)); var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD));
var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO)); var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO));
var contractCodeState = useState(function () { return generateAutoContractCodeSuffix(); }); var contractCodeState = useState(function () { return generateAutoContractCodeSuffix(); });
var projectNameState = useState('');
var businessDeptState = useState(''); var businessDeptState = useState('');
var businessOwnerState = useState(''); var businessOwnerState = useState('');
var leaseOrderState = useState(function () { var leaseOrderState = useState(function () {
var initial = createDefaultLeaseOrderState(); return normalizeLeaseOrderState(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,
});
}),
};
}); });
var lessorId = lessorIdState[0]; var lessorId = lessorIdState[0];
var setLessorId = lessorIdState[1]; var setLessorId = lessorIdState[1];
@@ -138,6 +193,8 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var setFeeInfo = feeInfoState[1]; var setFeeInfo = feeInfoState[1];
var contractCode = contractCodeState[0]; var contractCode = contractCodeState[0];
var setContractCode = contractCodeState[1]; var setContractCode = contractCodeState[1];
var projectName = projectNameState[0];
var setProjectName = projectNameState[1];
var businessDept = businessDeptState[0]; var businessDept = businessDeptState[0];
var setBusinessDept = businessDeptState[1]; var setBusinessDept = businessDeptState[1];
var businessOwner = businessOwnerState[0]; var businessOwner = businessOwnerState[0];
@@ -163,16 +220,31 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var customerPrincipalPhoneState = useState(''); var customerPrincipalPhoneState = useState('');
var customerPrincipalPhone = customerPrincipalPhoneState[0]; var customerPrincipalPhone = customerPrincipalPhoneState[0];
var setCustomerPrincipalPhone = customerPrincipalPhoneState[1]; 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 sealTypesState = useState(['contract']);
var sealTypes = sealTypesState[0]; var sealTypes = sealTypesState[0];
var setSealTypes = sealTypesState[1]; var setSealTypes = sealTypesState[1];
var contractApprovalTypeState = useState(STANDARD_CONTRACT_APPROVAL); var contractApprovalTypeState = useState(STANDARD_CONTRACT_APPROVAL);
var contractApprovalType = contractApprovalTypeState[0]; var contractApprovalType = contractApprovalTypeState[0];
var setContractApprovalType = contractApprovalTypeState[1]; var setContractApprovalType = contractApprovalTypeState[1];
var previewMergedHtmlState = useState('');
var previewMergedHtml = previewMergedHtmlState[0];
var setPreviewMergedHtml = previewMergedHtmlState[1];
var previewHtml = useMemo(function () { var previewHtml = useMemo(function () {
if (!contractTemplateId) return ''; if (!contractTemplateId) return '';
return buildLeaseContractPreviewHtml({ var fullHtml = buildLeaseContractPreviewHtml({
contractTemplateId: contractTemplateId, contractTemplateId: contractTemplateId,
lessorId: lessorId, lessorId: lessorId,
customerId: customerId, customerId: customerId,
@@ -182,28 +254,40 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
leaseOrder: leaseOrder, leaseOrder: leaseOrder,
powerOfAttorney: powerOfAttorney, 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) { var handlePreviewContentChange = useCallback(function (mergedHtml) {
if (!isPreviewEditable) return;
setPreviewMergedHtml(mergedHtml || '');
setContractApprovalType(resolveLeaseContractApprovalType(previewHtml, mergedHtml, { setContractApprovalType(resolveLeaseContractApprovalType(previewHtml, mergedHtml, {
leaseOrder: leaseOrder, leaseOrder: leaseOrder,
feeInfo: feeInfo,
sealTypes: sealTypes,
})); }));
}, [previewHtml, leaseOrder]); }, [previewHtml, leaseOrder, feeInfo, sealTypes, isPreviewEditable]);
useEffect(function () { useEffect(function () {
if (hasLeaseOrderRentBelowMinimum(leaseOrder)) { setContractApprovalType(resolveLeaseContractApprovalType(
setContractApprovalType(NONSTANDARD_CONTRACT_APPROVAL); previewHtml,
return; previewMergedHtml || previewHtml,
} {
setContractApprovalType(STANDARD_CONTRACT_APPROVAL); leaseOrder: leaseOrder,
}, [previewHtml, leaseOrder]); feeInfo: feeInfo,
sealTypes: sealTypes,
},
));
}, [previewHtml, previewMergedHtml, leaseOrder, feeInfo, sealTypes]);
var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL; var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL;
var isTemplateSelected = Boolean(contractTemplateId); var isTemplateSelected = Boolean(contractTemplateId);
var previewBadge = getContractTemplatePreviewBadge(contractTemplateId);
var isMainContractComplete = useMemo(function () { var isMainContractComplete = useMemo(function () {
return isMainContractFormComplete({ var baseComplete = isMainContractFormComplete({
lessorId: lessorId, lessorId: lessorId,
customerId: customerId, customerId: customerId,
contractCode: contractCode, contractCode: contractCode,
@@ -212,7 +296,15 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
mileage: mileage, mileage: mileage,
feeInfo: feeInfo, 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 () { var isLeaseOrderComplete = useMemo(function () {
return isLeaseOrderFormComplete({ leaseOrder: leaseOrder }); 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 columnRef = useRef(null);
var sectionRefs = { var sectionRefs = {
template: useRef(null), template: useRef(null),
@@ -284,38 +371,46 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
useEffect(function () { useEffect(function () {
if (!editRecord || !editRecord.id) return; if (!editRecord || !editRecord.id) return;
var formState = buildLeaseContractEditFormState(editRecord); 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; if (!formState) return;
setContractTemplateId(formState.contractTemplateId || ''); setContractTemplateId(formState.contractTemplateId || '');
setLessorId(formState.lessorId || ''); setLessorId(formState.lessorId || '');
setCustomerId(formState.customerId || ''); setCustomerId(formState.customerId || '');
setContractCode(formState.contractCode || ''); setContractCode(formState.contractCode || '');
setProjectName(formState.projectName || '');
setBusinessDept(formState.businessDept || ''); setBusinessDept(formState.businessDept || '');
setBusinessOwner(formState.businessOwner || ''); setBusinessOwner(formState.businessOwner || '');
setMileage(Object.assign({}, formState.mileage)); setMileage(Object.assign({}, formState.mileage));
setFeeInfo(Object.assign({}, formState.feeInfo)); setFeeInfo(Object.assign({}, formState.feeInfo));
setLeaseOrder({ setLeaseOrder(normalizeLeaseOrderState(formState.leaseOrder));
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() : [],
});
}),
});
setPowerOfAttorney({ setPowerOfAttorney({
delegates: (formState.powerOfAttorney.delegates || []).map(function (row) { delegates: ((formState.powerOfAttorney && formState.powerOfAttorney.delegates) || []).map(function (row) {
return Object.assign({}, row); return Object.assign({}, row);
}), }),
}); });
setContractRemark(formState.contractRemark || ''); setContractRemark(formState.contractRemark || '');
setCustomerPrincipalName(formState.customerPrincipalName || ''); setCustomerPrincipalName(formState.customerPrincipalName || '');
setCustomerPrincipalPhone(formState.customerPrincipalPhone || ''); setCustomerPrincipalPhone(formState.customerPrincipalPhone || '');
setThirdPartyCustomerId(formState.thirdPartyCustomerId || '');
setThirdPartyPrincipalName(formState.thirdPartyPrincipalName || '');
setThirdPartyPrincipalPhone(formState.thirdPartyPrincipalPhone || '');
setSealTypes(normalizeSealTypes(formState.sealTypes)); 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 () { var createSteps = useMemo(function () {
return [ return [
@@ -372,20 +467,16 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
contractRemark, 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) { function scrollToCreateSection(stepKey) {
var target = sectionRefs[stepKey] && sectionRefs[stepKey].current; var target = sectionRefs[stepKey] && sectionRefs[stepKey].current;
if (!target) return; var container = columnRef.current;
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 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) { function handleStepNavClick(step) {
@@ -404,15 +495,72 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
role: 'region', role: 'region',
'aria-label': '需先选择合同模板', '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', { React.createElement('button', {
type: 'button', type: 'button',
className: 'vm-btn vm-btn-secondary lc-create-section__lock-btn', className: 'vm-btn vm-btn-secondary lc-create-section__lock-btn',
'data-vm-icon': 'eye',
onClick: function () { scrollToCreateSection('template'); }, 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() { function renderStepNav() {
return React.createElement('nav', { return React.createElement('nav', {
className: 'lc-create-step-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 }, React.createElement('span', { className: 'lc-create-step-nav__index', 'aria-hidden': true },
step.done step.done
? React.createElement(Check, { size: 14, strokeWidth: 2.5, 'aria-hidden': true }) ? 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), React.createElement('span', { className: 'lc-create-step-nav__label' }, step.label),
step.optional React.createElement('span', {
? React.createElement('span', { className: 'lc-create-step-nav__tag' }, '选填') className: 'lc-create-step-nav__tag' + (step.optional ? '' : ' lc-create-step-nav__tag--required'),
: null, }, step.optional ? '选填' : '必填'),
), ),
index < createSteps.length - 1 index < createSteps.length - 1
? React.createElement('span', { className: 'lc-create-step-nav__sep', 'aria-hidden': true }) ? React.createElement('span', { className: 'lc-create-step-nav__sep', 'aria-hidden': true })
@@ -458,29 +606,40 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
className: 'vm-btn vm-btn-back', className: 'vm-btn vm-btn-back',
onClick: onBack, onClick: onBack,
}, '返回列表'), }, '返回列表'),
React.createElement('div', { className: 'lc-create-topbar__intro' }, effectiveFlowMode !== FLOW_MODE_CREATE
React.createElement('h1', { className: 'lc-create-topbar__title' }, '新增租赁合同'), ? React.createElement('span', { className: 'lc-create-topbar__title' }, pageTitle)
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, : null,
isTemplateSelected
? React.createElement('span', { className: 'lc-create-topbar__meta-template' }, previewBadge)
: React.createElement('span', { className: 'lc-create-topbar__meta-hint' }, '请先选择合同模板'),
),
),
), ),
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-page lc-create-editor-page' },
React.createElement('div', { className: 'ct-editor-workspace' }, React.createElement('div', { className: 'ct-editor-workspace' },
React.createElement(Row, { gutter: [16, 16] }, React.createElement(Row, { gutter: [16, 16] },
React.createElement(Col, { xs: 24, lg: 12 }, 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(), renderStepNav(),
React.createElement('div', { className: 'lc-create-editor-column', ref: columnRef },
React.createElement('section', { 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': '选择合同类型', 'aria-label': '选择合同类型',
'data-annotation-id': 'lc-create-template', 'data-annotation-id': 'lc-create-template',
id: 'lc-create-section-template', id: 'lc-create-section-template',
@@ -491,7 +650,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-describedby': 'lc-template-step-hint', 'aria-describedby': 'lc-template-step-hint',
}, },
React.createElement('div', { className: 'lc-template-step-card__title-row' }, React.createElement('div', { className: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 1 步:选择合同类型'), renderCreateStepTitle(1, '选择合同类型'),
React.createElement('span', { React.createElement('span', {
className: 'lc-form-card__hint', className: 'lc-form-card__hint',
role: 'note', role: 'note',
@@ -503,7 +662,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-hidden': true, 'aria-hidden': true,
}), }),
React.createElement('span', { className: 'lc-form-card__hint-text' }, React.createElement('span', { className: 'lc-form-card__hint-text' },
'选择模板管理中已发布且启用的合同模板,右侧自动生成预览。', '选择对应合同模板,右侧自动生成该合同预览内容',
), ),
), ),
), ),
@@ -514,33 +673,56 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}, isTemplateSelected ? '已选择' : '必选'), }, isTemplateSelected ? '已选择' : '必选'),
), ),
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel lc-template-step-panel' }, React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel lc-template-step-panel' },
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, { React.createElement(Select, {
className: 'lc-template-select', className: 'lc-form-select lc-template-select',
value: contractTemplateId || undefined, value: contractTemplateId || undefined,
placeholder: templateSelectOptions.length ? '请选择合同模板' : '暂无可用模板', placeholder: templateSelectOptions.length ? '请选择合同模板' : '暂无可用模板',
allowClear: true, allowClear: !isAddPoaFlow,
showSearch: true, showSearch: true,
optionFilterProp: 'label', optionFilterProp: 'label',
disabled: !templateSelectOptions.length, disabled: !templateSelectOptions.length || isAddPoaFlow,
onChange: handleTemplateSelect, onChange: handleTemplateSelect,
options: templateSelectOptions, options: templateSelectOptions,
'aria-label': '合同模板', '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 !templateSelectOptions.length
? React.createElement('p', { className: 'lc-template-step-empty' }, '暂无已启用的合同模板,请先在合同模板管理中发布并启用。') ? React.createElement('p', { className: 'lc-template-step-empty' }, '暂无已启用的合同模板,请先在合同模板管理中发布并启用。')
: null, : null,
), ),
renderFlowFormLock('template'),
), ),
React.createElement('section', { React.createElement('section', {
className: 'ct-editor-column-card lc-main-contract-card lc-create-section' className: 'ct-editor-column-card lc-main-contract-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'), + getCreateSectionDisabledClass('main'),
'aria-label': '主体合同信息', 'aria-label': '主体合同信息',
'data-annotation-id': 'lc-create-main-contract', 'data-annotation-id': 'lc-create-main-contract',
id: 'lc-create-section-main', id: 'lc-create-section-main',
ref: sectionRefs.main, ref: sectionRefs.main,
}, },
React.createElement('div', { className: 'ct-panel-head lc-main-contract-card__head' }, 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', { React.createElement('span', {
className: 'lc-form-completion-badge ' + (isMainContractComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'), className: 'lc-form-completion-badge ' + (isMainContractComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
role: 'status', role: 'status',
@@ -551,15 +733,25 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement(LeaseContractEditorForm, { React.createElement(LeaseContractEditorForm, {
lessorId: lessorId, lessorId: lessorId,
customerId: customerId, customerId: customerId,
thirdPartyCustomerId: thirdPartyCustomerId,
showThirdPartyParty: isTripartiteFlow,
contractCode: contractCode, contractCode: contractCode,
contractCodeReadonly: !(editRecord && editRecord.id), projectName: projectName,
contractCodeReadonly: effectiveFlowMode !== FLOW_MODE_EDIT,
businessDept: businessDept, businessDept: businessDept,
businessOwner: businessOwner, businessOwner: businessOwner,
mileage: mileage, mileage: mileage,
feeInfo: feeInfo, feeInfo: feeInfo,
onLessorChange: setLessorId, onLessorChange: setLessorId,
onCustomerChange: setCustomerId, onCustomerChange: function (value) {
setCustomerId(value);
if (thirdPartyCustomerId && thirdPartyCustomerId === value) {
setThirdPartyCustomerId('');
}
},
onThirdPartyCustomerChange: setThirdPartyCustomerId,
onContractCodeChange: setContractCode, onContractCodeChange: setContractCode,
onProjectNameChange: setProjectName,
onBusinessAssignmentChange: function (dept, owner) { onBusinessAssignmentChange: function (dept, owner) {
setBusinessDept(dept); setBusinessDept(dept);
setBusinessOwner(owner); setBusinessOwner(owner);
@@ -572,20 +764,27 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
setCustomerPrincipalName(name); setCustomerPrincipalName(name);
setCustomerPrincipalPhone(phone); setCustomerPrincipalPhone(phone);
}, },
thirdPartyPrincipalName: thirdPartyPrincipalName,
thirdPartyPrincipalPhone: thirdPartyPrincipalPhone,
onThirdPartyPrincipalChange: function (name, phone) {
setThirdPartyPrincipalName(name);
setThirdPartyPrincipalPhone(phone);
},
}), }),
), ),
renderSectionLock('main'), renderSectionLock('main'),
renderFlowFormLock('main'),
), ),
React.createElement('section', { React.createElement('section', {
className: 'ct-editor-column-card lc-create-section' className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'), + getCreateSectionDisabledClass('leaseOrder'),
'aria-label': '附件1租赁订单', 'aria-label': '附件1租赁订单',
'data-annotation-id': 'lc-create-lease-order', 'data-annotation-id': 'lc-create-lease-order',
id: 'lc-create-section-lease-order', id: 'lc-create-section-lease-order',
ref: sectionRefs.leaseOrder, ref: sectionRefs.leaseOrder,
}, },
React.createElement('div', { className: 'ct-panel-head' }, React.createElement('div', { className: 'ct-panel-head' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 3 步:附件1 · 租赁订单'), renderCreateStepTitle(3, '附件1 · 租赁订单'),
React.createElement('span', { React.createElement('span', {
className: 'lc-form-completion-badge ' + (isLeaseOrderComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'), className: 'lc-form-completion-badge ' + (isLeaseOrderComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
role: 'status', role: 'status',
@@ -599,10 +798,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}), }),
), ),
renderSectionLock('leaseOrder'), renderSectionLock('leaseOrder'),
renderFlowFormLock('leaseOrder'),
), ),
React.createElement('section', { React.createElement('section', {
className: 'ct-editor-column-card lc-create-section' className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'), + getCreateSectionDisabledClass('poa'),
'aria-label': '授权委托书', 'aria-label': '授权委托书',
'data-annotation-id': 'lc-create-poa', 'data-annotation-id': 'lc-create-poa',
id: 'lc-create-section-poa', id: 'lc-create-section-poa',
@@ -613,7 +813,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-describedby': 'lc-create-poa-hint', 'aria-describedby': 'lc-create-poa-hint',
}, },
React.createElement('div', { className: 'lc-template-step-card__title-row' }, React.createElement('div', { className: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 4 步:授权委托书'), renderCreateStepTitle(4, '授权委托书'),
React.createElement('span', { React.createElement('span', {
className: 'lc-form-card__hint lc-create-poa-hint', className: 'lc-form-card__hint lc-create-poa-hint',
role: 'note', role: 'note',
@@ -626,7 +826,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
'aria-hidden': true, 'aria-hidden': true,
}), }),
React.createElement('span', { className: 'lc-form-card__hint-text' }, React.createElement('span', { className: 'lc-form-card__hint-text' },
'添加受托人须同时上传授权委托书', '添加受托人须同时上传授权委托书受托人主要用于交还车时E签宝签章',
), ),
), ),
), ),
@@ -651,14 +851,14 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
), ),
React.createElement('section', { React.createElement('section', {
className: 'ct-editor-column-card lc-create-section' className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'), + getCreateSectionDisabledClass('remark'),
'aria-label': '合同备注', 'aria-label': '合同备注',
'data-annotation-id': 'lc-create-contract-remark', 'data-annotation-id': 'lc-create-contract-remark',
id: 'lc-create-section-remark', id: 'lc-create-section-remark',
ref: sectionRefs.remark, ref: sectionRefs.remark,
}, },
React.createElement('div', { className: 'ct-panel-head' }, React.createElement('div', { className: 'ct-panel-head' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 5 步:合同备注'), renderCreateStepTitle(5, '合同备注'),
React.createElement('span', { React.createElement('span', {
className: 'lc-form-completion-badge ' className: 'lc-form-completion-badge '
+ ((contractRemark || '').trim() + ((contractRemark || '').trim()
@@ -675,10 +875,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}), }),
), ),
renderSectionLock('remark'), renderSectionLock('remark'),
renderFlowFormLock('remark'),
), ),
React.createElement('section', { React.createElement('section', {
className: 'ct-editor-column-card lc-create-section' className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'), + getCreateSectionDisabledClass('seal'),
'aria-label': '用章类型', 'aria-label': '用章类型',
'data-annotation-id': 'lc-create-seal', 'data-annotation-id': 'lc-create-seal',
id: 'lc-create-section-seal', id: 'lc-create-section-seal',
@@ -686,22 +887,25 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}, },
React.createElement('div', { React.createElement('div', {
className: 'ct-panel-head lc-create-section__head lc-create-section-seal-head', 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: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 6 步:用章类型'), renderCreateStepTitle(6, '用章类型'),
sealTypes.length React.createElement('span', {
? React.createElement('span', { className: 'lc-form-card__hint lc-create-seal-hint',
className: 'lc-seal-type-head-tags', role: 'note',
'aria-label': '已选用章', id: 'lc-create-seal-hint',
'data-annotation-id': 'lc-create-seal-hint',
}, },
sealTypes.map(function (value) { React.createElement(Info, {
return React.createElement('span', { size: 13,
key: value, className: 'lc-form-card__hint-icon',
className: 'lc-seal-type-head-tag', 'aria-hidden': true,
}, getSealTypeLabel(value));
}), }),
) React.createElement('span', { className: 'lc-form-card__hint-text' },
: null, '额外勾选公章、法人章会进入非标审批流程',
),
),
), ),
React.createElement('span', { React.createElement('span', {
className: 'lc-form-completion-badge ' + (isSealTypeComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'), 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'), renderSectionLock('seal'),
renderFlowFormLock('seal'),
),
), ),
), ),
), ),
React.createElement(Col, { xs: 24, lg: 12 }, React.createElement(Col, { xs: 24, lg: 12 },
React.createElement('section', { React.createElement('section', {
className: 'ct-editor-column-card ct-editor-column-card--preview', className: getPreviewPanelCardClass(),
'aria-label': '实时预览', 'aria-label': getPreviewPanelAriaLabel(),
'data-annotation-id': 'lc-create-preview', 'data-annotation-id': 'lc-create-preview',
}, },
React.createElement('div', { React.createElement('div', {
className: 'ct-panel-head lc-create-preview-head', 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: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '实时预览'), React.createElement('div', { className: 'ct-panel-head__title' },
isTemplateSelected ? React.createElement(PreviewEditableHint) : null, getPreviewPanelTitle(),
),
isTemplateSelected ? renderPreviewReadonlyHint() : null,
), ),
React.createElement('div', { className: 'ct-panel-head__actions' }, React.createElement('div', { className: 'ct-panel-head__actions' },
isScopedPreviewFlow ? null : React.createElement(PreviewApprovalHint),
React.createElement('span', { React.createElement('span', {
className: 'lc-approval-type-badge' className: 'lc-approval-type-badge'
+ (isNonStandardApproval ? ' lc-approval-type-badge--nonstd' : ' lc-approval-type-badge--standard'), + (isNonStandardApproval ? ' lc-approval-type-badge--nonstd' : ' lc-approval-type-badge--standard'),
@@ -761,8 +970,8 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
html: previewHtml, html: previewHtml,
zoom: workspaceZoom, zoom: workspaceZoom,
onZoomChange: setWorkspaceZoom, onZoomChange: setWorkspaceZoom,
editable: true, editable: isPreviewEditable,
onContentChange: handlePreviewContentChange, onContentChange: isPreviewEditable ? handlePreviewContentChange : undefined,
}) })
: React.createElement('div', { className: 'lc-template-preview-empty' }, '请先在左侧选择合同模板,右侧将自动生成对应预览。'), : React.createElement('div', { className: 'lc-template-preview-empty' }, '请先在左侧选择合同模板,右侧将自动生成对应预览。'),
), ),
@@ -775,11 +984,13 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement('button', { React.createElement('button', {
type: 'button', type: 'button',
className: 'vm-btn vm-btn-ghost', className: 'vm-btn vm-btn-ghost',
'data-vm-icon': 'x',
onClick: onBack, onClick: onBack,
}, '取消'), }, '取消'),
React.createElement('button', { React.createElement('button', {
type: 'button', type: 'button',
className: 'vm-btn vm-btn-secondary', className: 'vm-btn vm-btn-secondary',
'data-vm-icon': 'save',
onClick: function () { onClick: function () {
if (!contractTemplateId) { if (!contractTemplateId) {
message.warning('请先选择合同模板'); message.warning('请先选择合同模板');
@@ -791,12 +1002,32 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement('button', { React.createElement('button', {
type: 'button', type: 'button',
className: 'vm-btn vm-btn-primary', className: 'vm-btn vm-btn-primary',
'data-vm-icon': 'send',
onClick: function () { onClick: function () {
if (!contractTemplateId) { if (!contractTemplateId) {
message.warning('请先选择合同模板'); message.warning('请先选择合同模板');
return; return;
} }
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) { if (!isFormSubmitReady) {
message.warning('请先完成左侧必填项');
return;
}
} else if (!isFormSubmitReady) {
if (!isPoaSubmitReady) { if (!isPoaSubmitReady) {
message.warning('提交审核前须完成授权委托书填写'); message.warning('提交审核前须完成授权委托书填写');
scrollToCreateSection('poa'); scrollToCreateSection('poa');
@@ -806,11 +1037,46 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
return; return;
} }
var submitCustomer = getLeaseCustomerById(customerId); var submitCustomer = getLeaseCustomerById(customerId);
if (hasBlockingCustomerCredentials(submitCustomer)) { if (!isAddPoaFlow && hasBlockingCustomerCredentials(submitCustomer)) {
message.error(formatCredentialSubmitBlockMessage(summarizeCustomerCredentials(submitCustomer))); message.error(formatCredentialSubmitBlockMessage(summarizeCustomerCredentials(submitCustomer)));
scrollToCreateSection('main'); scrollToCreateSection('main');
return; 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 + '(原型)'); message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
}, },
}, '提交审核'), }, '提交审核'),

View File

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

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Select } from 'antd'; import { Select, Popover } from 'antd';
import { Info } from 'lucide-react'; import { Info, FileDiff } from 'lucide-react';
import { paginateWordHtml } from './contract-word-preview.js'; 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]; 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-form-card__hint-text lc-preview-editable-hint__text' },
'正文可编辑;改动', '正文可编辑;改动',
React.createElement('span', { className: 'lc-preview-editable-hint__risk' }, '风控红线条款'), 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 pageIdxState = useState(0);
var viewModeState = useState('scroll'); var viewModeState = useState('scroll');
var internalZoomState = useState(100); var internalZoomState = useState(100);
var clauseRecordsState = useState([]);
var baselinePages = baselinePagesState[0]; var baselinePages = baselinePagesState[0];
var setBaselinePages = baselinePagesState[1]; var setBaselinePages = baselinePagesState[1];
var draftPagesRef = useRef([]); var draftPagesRef = useRef([]);
@@ -96,6 +236,8 @@ export default function LeaseContractPreviewPanel({
var setPageIdx = pageIdxState[1]; var setPageIdx = pageIdxState[1];
var viewMode = viewModeState[0]; var viewMode = viewModeState[0];
var setViewMode = viewModeState[1]; var setViewMode = viewModeState[1];
var clauseRecords = clauseRecordsState[0];
var setClauseRecords = clauseRecordsState[1];
var previewZoom = zoom != null ? zoom : internalZoomState[0]; var previewZoom = zoom != null ? zoom : internalZoomState[0];
var setPreviewZoom = onZoomChange || internalZoomState[1]; var setPreviewZoom = onZoomChange || internalZoomState[1];
@@ -103,6 +245,7 @@ export default function LeaseContractPreviewPanel({
if (!html) { if (!html) {
setBaselinePages([]); setBaselinePages([]);
draftPagesRef.current = []; draftPagesRef.current = [];
setClauseRecords([]);
return; return;
} }
var timer = window.setTimeout(function () { var timer = window.setTimeout(function () {
@@ -110,14 +253,17 @@ export default function LeaseContractPreviewPanel({
setBaselinePages(nextPages); setBaselinePages(nextPages);
draftPagesRef.current = nextPages.slice(); draftPagesRef.current = nextPages.slice();
pageIdxState[1](0); pageIdxState[1](0);
setClauseRecords([]);
notifyContentChange(nextPages); notifyContentChange(nextPages);
}, 0); }, 0);
return function () { window.clearTimeout(timer); }; return function () { window.clearTimeout(timer); };
}, [html]); }, [html]);
function notifyContentChange(nextPages) { function notifyContentChange(nextPages) {
var merged = (nextPages || []).join('');
setClauseRecords(collectClauseChangeRecords(html, merged));
if (!onContentChange) return; if (!onContentChange) return;
onContentChange((nextPages || []).join('')); onContentChange(merged);
} }
function handlePageInput(pageIndex, nextHtml) { function handlePageInput(pageIndex, nextHtml) {
@@ -205,6 +351,7 @@ export default function LeaseContractPreviewPanel({
React.createElement('button', { React.createElement('button', {
type: 'button', type: 'button',
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn', className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
'data-vm-icon': 'chevron-left',
disabled: pageIdx <= 0, disabled: pageIdx <= 0,
onClick: function () { setPageIdx(Math.max(0, pageIdx - 1)); }, onClick: function () { setPageIdx(Math.max(0, pageIdx - 1)); },
}, '上一页'), }, '上一页'),
@@ -216,6 +363,7 @@ export default function LeaseContractPreviewPanel({
React.createElement('button', { React.createElement('button', {
type: 'button', type: 'button',
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn', className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
'data-vm-icon': 'chevron-right',
disabled: pageIdx >= baselinePages.length - 1, disabled: pageIdx >= baselinePages.length - 1,
onClick: function () { setPageIdx(Math.min(baselinePages.length - 1, pageIdx + 1)); }, onClick: function () { setPageIdx(Math.min(baselinePages.length - 1, pageIdx + 1)); },
}, '下一页'), }, '下一页'),
@@ -238,6 +386,9 @@ export default function LeaseContractPreviewPanel({
}, '单页预览'), }, '单页预览'),
), ),
), ),
editable
? React.createElement(PreviewClauseChangeRecordTrigger, { records: clauseRecords })
: null,
), ),
); );

View File

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

File diff suppressed because one or more lines are too long

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