迭代 ONE-OS 多原型:统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步导航与合包页面。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-09T09:46:07.467Z",
|
||||
"updatedAt": "2026-07-11T03:51:46.250Z",
|
||||
"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",
|
||||
"kind": "item",
|
||||
@@ -56,12 +62,6 @@
|
||||
"title": "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",
|
||||
"kind": "item",
|
||||
@@ -74,18 +74,6 @@
|
||||
"title": "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",
|
||||
"kind": "item",
|
||||
@@ -206,7 +194,7 @@
|
||||
{
|
||||
"id": "item:prototypes:self-operated-business-ledger",
|
||||
"kind": "item",
|
||||
"title": "物流业务台账",
|
||||
"title": "物流业务明细",
|
||||
"itemKey": "prototypes/self-operated-business-ledger"
|
||||
},
|
||||
{
|
||||
@@ -228,6 +216,12 @@
|
||||
"kind": "folder",
|
||||
"title": "财务管理",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:vehicle-pickup-receivable",
|
||||
"kind": "item",
|
||||
"title": "提车应收款",
|
||||
"itemKey": "prototypes/vehicle-pickup-receivable"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:payment-records",
|
||||
"kind": "item",
|
||||
|
||||
36
.cursor/rules/prototype-annotation-layout.mdc
Normal file
36
.cursor/rules/prototype-annotation-layout.mdc
Normal 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
|
||||
```
|
||||
@@ -41,7 +41,7 @@ CHANGELOG_NOTE="修复筛选联动" npm run nav:sync -- --prototype customer-man
|
||||
- 脚本路径:`scripts/sync-prototype-nav-registry.mjs`
|
||||
- 检测原型目录内容哈希;有变化则版本递增(`v1.0` → `v1.1` → …)
|
||||
- 写入 `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 dev` 时,Vite 插件会在保存文件后自动触发同步(无需手动跑命令)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
|
||||
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
||||
| 画布 | `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
2932
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
"metadata:sync": "node scripts/sync-project-metadata.mjs",
|
||||
"project-nav:sync": "node scripts/sync-project-prototype-directory.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",
|
||||
"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",
|
||||
@@ -28,12 +29,14 @@
|
||||
"font:subset:beginner-guide": "node scripts/subset-beginner-guide-fonts.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mermaid-js/mermaid-cli": "^11.16.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vitest/ui": "4.0.16",
|
||||
"extract-zip": "^2.0.1",
|
||||
"iconv-lite": "^0.7.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"subset-font": "^2.5.0",
|
||||
@@ -48,6 +51,7 @@
|
||||
"docx-preview": "^0.3.7",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
|---|---|
|
||||
| 正文字体 `--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` + `tabular-nums`(全局见 `src/common/ln-numeric.css`,对齐 `.vm-expire-date`) |
|
||||
| 金额格式 | `src/common/format-number.js`(千分位 + 2 位小数;Legacy 页可用 `window.__oneosFormatMoney`) |
|
||||
| 页面基础字号 | `0.875rem`(14px) |
|
||||
| 筛选标签 | `0.75rem` / 500 |
|
||||
| 表头 | `0.75rem` / 500 |
|
||||
@@ -186,7 +188,7 @@ import '../vehicle-management/style.css';
|
||||
| 次要 | `vm-btn vm-btn-secondary` | 带边框白底 |
|
||||
| 幽灵 | `vm-btn vm-btn-ghost` | 取消、重置、工具栏次要操作 |
|
||||
| 文字链 | `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。
|
||||
|
||||
|
||||
115
rules/prototype-annotation-layout-guide.md
Normal file
115
rules/prototype-annotation-layout-guide.md
Normal 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` | 导航菜单同步(不再注入侧边导航树) |
|
||||
@@ -38,6 +38,15 @@ src/prototypes/<name>/
|
||||
- 只有 Make 批注/编辑工具启用、且当前选中的是带安全本地 `markdownPath` 的目录 Markdown 正文子节点时,批注气泡卡片才显示“文档编辑”按钮。
|
||||
- 导出/发布时会构建期内联 `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`,用于预览列表展示名:
|
||||
|
||||
```typescript
|
||||
|
||||
339
scripts/enrich-prototype-registry-from-git.mjs
Normal file
339
scripts/enrich-prototype-registry-from-git.mjs
Normal 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();
|
||||
62
scripts/migrate-prototype-annotation-host.mjs
Normal file
62
scripts/migrate-prototype-annotation-host.mjs
Normal 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`);
|
||||
@@ -66,7 +66,7 @@ function toPublishPath(resource) {
|
||||
function resolveObjectPrefix(entry) {
|
||||
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
|
||||
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);
|
||||
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
|
||||
return entry.id;
|
||||
|
||||
112
scripts/remove-breadcrumbs-pass2.mjs
Normal file
112
scripts/remove-breadcrumbs-pass2.mjs
Normal 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).`);
|
||||
62
scripts/remove-breadcrumbs-pass3.mjs
Normal file
62
scripts/remove-breadcrumbs-pass3.mjs
Normal 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).`);
|
||||
213
scripts/remove-breadcrumbs.mjs
Normal file
213
scripts/remove-breadcrumbs.mjs
Normal 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.`);
|
||||
92
scripts/strip-prototype-nav-from-directory.mjs
Normal file
92
scripts/strip-prototype-nav-from-directory.mjs
Normal 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();
|
||||
@@ -26,6 +26,7 @@ const STANDALONE_MODULES = [
|
||||
{ slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true },
|
||||
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
|
||||
{ slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true },
|
||||
{ slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true },
|
||||
];
|
||||
|
||||
const MODULES = [
|
||||
@@ -39,7 +40,17 @@ const MODULES = [
|
||||
title: '财务管理',
|
||||
dir: '财务管理',
|
||||
excludeDirs: ['文档'],
|
||||
excludeFiles: ['还车应结款.jsx', '还车应结款-查看.jsx', '还车应结款-费用明细.jsx'],
|
||||
excludeFiles: [
|
||||
'还车应结款.jsx',
|
||||
'还车应结款-查看.jsx',
|
||||
'还车应结款-费用明细.jsx',
|
||||
'提车应收款.jsx',
|
||||
'提车应收款-查看.jsx',
|
||||
'提车应收款-开票信息.jsx',
|
||||
'提车应收款-审核.jsx',
|
||||
'提车应收款-提车收款单.jsx',
|
||||
'提车收款单-编辑.jsx',
|
||||
],
|
||||
},
|
||||
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
|
||||
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },
|
||||
|
||||
@@ -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
|
||||
@@ -33,56 +34,6 @@ function writeJson(filePath, value) {
|
||||
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) {
|
||||
const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json');
|
||||
if (!fs.existsSync(sidebarPath)) {
|
||||
@@ -102,6 +53,18 @@ function listPrototypeIdsWithAnnotation(projectRoot) {
|
||||
.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) {
|
||||
const walk = (items) => {
|
||||
for (const node of items || []) {
|
||||
@@ -127,7 +90,7 @@ function fixPublishedLinks(nodes, prototypeId) {
|
||||
walk(nodes);
|
||||
}
|
||||
|
||||
function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) {
|
||||
function cleanupPrototypeDirectory(prototypeId, projectRoot) {
|
||||
const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json');
|
||||
if (!fs.existsSync(annotationPath)) {
|
||||
console.log(`[skip] ${prototypeId}: 无 annotation-source.json`);
|
||||
@@ -135,27 +98,22 @@ function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) {
|
||||
}
|
||||
|
||||
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 preservedNodes = existingNodes.filter((node) => node?.id !== NAV_FOLDER_ID);
|
||||
fixPublishedLinks(preservedNodes, prototypeId);
|
||||
const cleanedNodes = stripNavNodes(existingNodes);
|
||||
fixPublishedLinks(cleanedNodes, prototypeId);
|
||||
|
||||
annotation.directory = {
|
||||
nodes: [navFolder, ...preservedNodes],
|
||||
};
|
||||
const before = JSON.stringify(existingNodes);
|
||||
const after = JSON.stringify(cleanedNodes);
|
||||
if (before === after) {
|
||||
console.log(`[ok] ${prototypeId}: 目录已不含原型导航`);
|
||||
return false;
|
||||
}
|
||||
|
||||
annotation.directory = { nodes: cleanedNodes };
|
||||
annotation.data = annotation.data || {};
|
||||
annotation.data.updatedAt = Date.now();
|
||||
|
||||
writeJson(annotationPath, annotation);
|
||||
console.log(`[sync] ${prototypeId}: 已写入 ${navChildren.length} 个顶级目录项(含分组)`);
|
||||
console.log(`[cleanup] ${prototypeId}: 已移除原型导航目录节点`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -186,11 +144,11 @@ function main() {
|
||||
|
||||
let changed = 0;
|
||||
for (const prototypeId of targets) {
|
||||
if (syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot)) {
|
||||
if (cleanupPrototypeDirectory(prototypeId, projectRoot)) {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
|
||||
console.log(`完成:处理 ${targets.length} 个原型,清理 ${changed} 个 annotation-source.json`);
|
||||
syncPrototypeNavMenu(projectRoot, sidebarItems);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"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": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
|
||||
263
src/common/OperationActions.tsx
Normal file
263
src/common/OperationActions.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Car,
|
||||
CircleDollarSign,
|
||||
Eye,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
OctagonX,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Upload,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type OperationActionItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
|
||||
edit: Pencil,
|
||||
del: Trash2,
|
||||
addVehicle: Car,
|
||||
renew: RefreshCw,
|
||||
authorized: FileText,
|
||||
extraFee: CircleDollarSign,
|
||||
toTripartite: Users,
|
||||
terminate: OctagonX,
|
||||
withdraw: Undo2,
|
||||
toFormal: ArrowRightLeft,
|
||||
stampSupplement: Upload,
|
||||
uploadStamped: Upload,
|
||||
};
|
||||
|
||||
function renderMenuItemLabel(item: OperationActionItem) {
|
||||
const Icon = item.icon ?? MORE_ACTION_ICONS[item.key];
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'vm-op-menu-item',
|
||||
item.danger ? 'vm-op-menu-item--danger' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className="vm-op-menu-item__icon" aria-hidden strokeWidth={1.75} />
|
||||
) : (
|
||||
<span className="vm-op-menu-item__icon-spacer" aria-hidden />
|
||||
)}
|
||||
<span className="vm-op-menu-item__label">{item.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type OperationPrimaryAction = {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type OperationActionsProps = {
|
||||
/** 详情:固定外显主操作 */
|
||||
view?: OperationPrimaryAction;
|
||||
/**
|
||||
* @deprecated 编辑已收入「更多」下拉,请放入 more 数组
|
||||
*/
|
||||
edit?: OperationPrimaryAction;
|
||||
/** 其余操作(含编辑)收入「更多」下拉 */
|
||||
more?: OperationActionItem[];
|
||||
/** 更多按钮文案,默认「更多」 */
|
||||
moreLabel?: string;
|
||||
className?: string;
|
||||
annotationId?: string;
|
||||
};
|
||||
|
||||
function ViewIcon() {
|
||||
return <Eye className="vm-op-action__icon" aria-hidden />;
|
||||
}
|
||||
|
||||
function MoreIcon() {
|
||||
return <MoreHorizontal className="vm-op-action__icon" aria-hidden />;
|
||||
}
|
||||
|
||||
const PrimaryActionButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
icon: 'view' | 'more';
|
||||
label: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
ariaHasPopup?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(function PrimaryActionButton(
|
||||
{
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
ariaHasPopup,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={['vm-op-action', className].filter(Boolean).join(' ')}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup={ariaHasPopup ? 'menu' : undefined}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon === 'view' ? <ViewIcon /> : <MoreIcon />}
|
||||
<span className="vm-op-action__label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
function mergeMoreItems(
|
||||
more: OperationActionItem[],
|
||||
edit?: OperationPrimaryAction,
|
||||
): OperationActionItem[] {
|
||||
const visibleMore = more.filter((item) => !item.hidden);
|
||||
if (!edit || edit.hidden) return visibleMore;
|
||||
const hasEdit = visibleMore.some((item) => item.key === 'edit' || item.label === '编辑');
|
||||
if (hasEdit) return visibleMore;
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: edit.label || '编辑',
|
||||
onClick: edit.onClick,
|
||||
disabled: edit.disabled,
|
||||
},
|
||||
...visibleMore,
|
||||
];
|
||||
}
|
||||
|
||||
export function OperationActions({
|
||||
view,
|
||||
edit,
|
||||
more = [],
|
||||
moreLabel = '更多',
|
||||
className,
|
||||
annotationId,
|
||||
}: OperationActionsProps) {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const visibleMore = useMemo(
|
||||
() => mergeMoreItems(more, edit),
|
||||
[more, edit],
|
||||
);
|
||||
|
||||
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||
const items: MenuProps['items'] = [];
|
||||
visibleMore.forEach((item, index) => {
|
||||
if (item.danger && index > 0 && !visibleMore[index - 1]?.danger) {
|
||||
items.push({ type: 'divider' });
|
||||
}
|
||||
items.push({
|
||||
key: item.key,
|
||||
label: renderMenuItemLabel(item),
|
||||
danger: item.danger,
|
||||
disabled: item.disabled,
|
||||
className: item.danger ? 'vm-op-dropdown-menu-item--danger' : undefined,
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}, [visibleMore]);
|
||||
|
||||
const showView = !!(view && !view.hidden);
|
||||
const showMore = visibleMore.length > 0;
|
||||
|
||||
if (!showView && !showMore) {
|
||||
return <span className="vm-operation-actions vm-operation-actions--empty">-</span>;
|
||||
}
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
const target = visibleMore.find((item) => item.key === key);
|
||||
if (target && !target.disabled) target.onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={['vm-row-actions', 'vm-operation-actions', className].filter(Boolean).join(' ')}
|
||||
data-annotation-id={annotationId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{showView ? (
|
||||
<PrimaryActionButton
|
||||
icon="view"
|
||||
label={view?.label || '详情'}
|
||||
ariaLabel={view?.label || '详情'}
|
||||
disabled={view?.disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!view?.disabled) view!.onClick();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{showMore ? (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
onClick: handleMenuClick,
|
||||
className: 'vm-op-dropdown-menu',
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
getPopupContainer={() => document.body}
|
||||
overlayClassName="vm-op-dropdown"
|
||||
open={moreOpen}
|
||||
onOpenChange={setMoreOpen}
|
||||
align={{ offset: [0, 4] }}
|
||||
>
|
||||
<PrimaryActionButton
|
||||
icon="more"
|
||||
label={moreLabel}
|
||||
ariaLabel={moreLabel + '操作'}
|
||||
ariaHasPopup
|
||||
className={moreOpen ? 'vm-op-action--open' : undefined}
|
||||
/>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从扁平操作列表中拆分出 view / more(编辑收入 more)。
|
||||
*/
|
||||
export function splitOperationActions(items: OperationActionItem[]): {
|
||||
view?: OperationPrimaryAction;
|
||||
more: OperationActionItem[];
|
||||
} {
|
||||
const visible = items.filter((item) => !item.hidden);
|
||||
const viewItem = visible.find((item) => item.label === '详情' || item.label === '查看' || item.key === 'view');
|
||||
const more = visible.filter((item) => item !== viewItem);
|
||||
return {
|
||||
view: viewItem
|
||||
? {
|
||||
onClick: viewItem.onClick,
|
||||
label: viewItem.label === '查看' ? '详情' : viewItem.label,
|
||||
disabled: viewItem.disabled,
|
||||
}
|
||||
: undefined,
|
||||
more,
|
||||
};
|
||||
}
|
||||
56
src/common/format-number.js
Normal file
56
src/common/format-number.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 全局数字/金额格式化(千分位 + 固定小数位)
|
||||
* 与 vehicle-management `.vm-expire-date` 数字展示规范配套使用。
|
||||
*/
|
||||
|
||||
function toFiniteNumber(value, fallback = 0) {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
const n = typeof value === 'number' ? value : parseFloat(String(value).replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string|null|undefined} value
|
||||
* @param {{ fractionDigits?: number, empty?: string }} [options]
|
||||
*/
|
||||
export function formatNumber(value, options) {
|
||||
const fractionDigits = options && options.fractionDigits != null ? options.fractionDigits : 2;
|
||||
const empty = options && options.empty != null ? options.empty : null;
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return empty != null ? empty : (fractionDigits > 0 ? (0).toFixed(fractionDigits) : '0');
|
||||
}
|
||||
const n = toFiniteNumber(value, NaN);
|
||||
if (Number.isNaN(n)) {
|
||||
return empty != null ? empty : (fractionDigits > 0 ? (0).toFixed(fractionDigits) : '0');
|
||||
}
|
||||
return n.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
}
|
||||
|
||||
/** 金额(默认 2 位小数,无货币符号) */
|
||||
export function formatMoney(value, options) {
|
||||
if (value == null || value === '') {
|
||||
if (options && options.empty != null) return options.empty;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isNaN(value)) {
|
||||
if (options && options.empty != null) return options.empty;
|
||||
}
|
||||
return formatNumber(value, Object.assign({ fractionDigits: 2 }, options || {}));
|
||||
}
|
||||
|
||||
/** 提车应收款等列表沿用的金额字符串(无「元」后缀) */
|
||||
export function formatMoneyYuan(value) {
|
||||
return formatMoney(value);
|
||||
}
|
||||
|
||||
/** 金额 + 单位,默认「元」 */
|
||||
export function formatMoneyWithUnit(value, unit) {
|
||||
return formatMoney(value) + (unit != null ? unit : ' 元');
|
||||
}
|
||||
|
||||
/** 整数数量(里程、计数等) */
|
||||
export function formatInteger(value) {
|
||||
return formatNumber(value, { fractionDigits: 0 });
|
||||
}
|
||||
121
src/common/ln-numeric.css
Normal file
121
src/common/ln-numeric.css
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 全局数字字体:对齐车辆管理「交强险到期时间」(.vm-expire-date)
|
||||
* — JetBrains Mono / 系统等宽 + tabular-nums
|
||||
*/
|
||||
|
||||
.tabular-nums,
|
||||
.ln-num {
|
||||
font-family: var(
|
||||
--vm-font-mono,
|
||||
ui-monospace,
|
||||
"JetBrains Mono",
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace
|
||||
);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
.ln-money,
|
||||
.ldb-col-money,
|
||||
.ln-modal-detail-table__money,
|
||||
.pr-linked-bill-native-table__money,
|
||||
.vpr-money,
|
||||
.vpr-money-cell,
|
||||
.vpr-collect-kpi-card__value,
|
||||
.vpr-collect-batch-panel__amount,
|
||||
.vpr-collect-footer__summary strong,
|
||||
.vpr-collect-td--num {
|
||||
font-family: var(
|
||||
--vm-font-mono,
|
||||
ui-monospace,
|
||||
"JetBrains Mono",
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace
|
||||
);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
.vm-kpi-val,
|
||||
.vm-mileage,
|
||||
.vm-expire-date,
|
||||
.vm-handover-situation-line--mile,
|
||||
.vm-handover-situation-line--time,
|
||||
.vm-detail-gps-time,
|
||||
.vm-detail-plate,
|
||||
.vm-location-map-gps-time,
|
||||
.vm-pagination-total-num,
|
||||
.vm-filter-toggle-badge,
|
||||
.vm-vin,
|
||||
.vm-stack .code,
|
||||
.vm-stack .vm-link.code {
|
||||
font-family: var(
|
||||
--vm-font-mono,
|
||||
ui-monospace,
|
||||
"JetBrains Mono",
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace
|
||||
);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
/* 链接态「未购买」等文案仍用正文字体 */
|
||||
.vm-expire .vm-link.vm-expire-date {
|
||||
font-family: inherit;
|
||||
font-variant-numeric: normal;
|
||||
font-feature-settings: normal;
|
||||
}
|
||||
|
||||
.vm-page .ant-table .tabular-nums,
|
||||
.vm-page .ant-table-cell.tabular-nums,
|
||||
.vm-page .vm-list-table .tabular-nums {
|
||||
font-family: var(
|
||||
--vm-font-mono,
|
||||
ui-monospace,
|
||||
"JetBrains Mono",
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace
|
||||
);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vm-page .ant-input.tabular-nums,
|
||||
.vm-page .ant-input-affix-wrapper.tabular-nums .ant-input,
|
||||
.lc-form-readonly.tabular-nums,
|
||||
.vm-stack .sub.tabular-nums {
|
||||
font-family: var(
|
||||
--vm-font-mono,
|
||||
ui-monospace,
|
||||
"JetBrains Mono",
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace
|
||||
);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
.vm-page .ant-input-number-input,
|
||||
.vm-page .ant-input-affix-wrapper .ant-input[data-money="true"] {
|
||||
font-family: var(
|
||||
--vm-font-mono,
|
||||
ui-monospace,
|
||||
"JetBrains Mono",
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace
|
||||
);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import * as antd from 'antd';
|
||||
import {
|
||||
formatMoney,
|
||||
formatMoneyYuan,
|
||||
formatMoneyWithUnit,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
} from '../format-number.js';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
React: typeof React;
|
||||
ReactDOM: typeof ReactDOM;
|
||||
antd: typeof antd;
|
||||
__oneosFormatNumber: typeof formatNumber;
|
||||
__oneosFormatMoney: typeof formatMoney;
|
||||
__oneosFormatMoneyYuan: typeof formatMoneyYuan;
|
||||
__oneosFormatMoneyWithUnit: typeof formatMoneyWithUnit;
|
||||
__oneosFormatInteger: typeof formatInteger;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +27,11 @@ export function ensureOneosWebLegacyGlobals(): void {
|
||||
window.React = React;
|
||||
window.ReactDOM = ReactDOM;
|
||||
window.antd = antd;
|
||||
window.__oneosFormatNumber = formatNumber;
|
||||
window.__oneosFormatMoney = formatMoney;
|
||||
window.__oneosFormatMoneyYuan = formatMoneyYuan;
|
||||
window.__oneosFormatMoneyWithUnit = formatMoneyWithUnit;
|
||||
window.__oneosFormatInteger = formatInteger;
|
||||
}
|
||||
|
||||
// Legacy jsx 可能在模块顶层使用 React.createElement,须在页面 import 之前完成注入。
|
||||
|
||||
48
src/common/operation-actions-spec.md
Normal file
48
src/common/operation-actions-spec.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# OperationActions · 列表操作列规范
|
||||
|
||||
> 完整规范见 `src/prototypes/vm-shared/DESIGN.md` · 操作列 OperationActions
|
||||
|
||||
## 组件
|
||||
|
||||
- 路径:`src/common/OperationActions.tsx`
|
||||
- 样式:`src/common/vm-operation-actions.css`(经 `vehicle-management/style.css` 全局引入)
|
||||
|
||||
## 布局
|
||||
|
||||
```
|
||||
[👁 详情] [✏️ 编辑] [⋮]
|
||||
```
|
||||
|
||||
- **详情 / 编辑**:外显,带 Lucide 图标 + 文案,最小触控 44×44px
|
||||
- **更多**:仅图标 `MoreHorizontal`,点击 Ant Dropdown 展开其余操作
|
||||
- 无详情/编辑时省略对应按钮;无任何操作时显示 `-`
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
1. 确认页面已 `import '../vehicle-management/style.css'`
|
||||
2. `import { OperationActions, splitOperationActions } from '../../common/OperationActions'`
|
||||
3. 将原 `getOperationButtons` / 内联 `Button link` 改为:
|
||||
|
||||
```tsx
|
||||
<OperationActions
|
||||
view={{ onClick: () => ... }}
|
||||
edit={canEdit ? { onClick: () => ... } : undefined}
|
||||
more={[...]}
|
||||
/>
|
||||
```
|
||||
|
||||
4. 操作列宽建议 `168`,`fixed: 'right'`
|
||||
5. 从 `more` 中移除已外显的「详情」「编辑」项
|
||||
|
||||
## 扁平列表迁移
|
||||
|
||||
```tsx
|
||||
const { view, edit, more } = splitOperationActions(allItems);
|
||||
return <OperationActions view={view} edit={edit} more={more} />;
|
||||
```
|
||||
|
||||
## 禁止
|
||||
|
||||
- 横排超过 2 个 `Button type="link"` 主操作
|
||||
- 文字「更多」链接触发器
|
||||
- pipe `|` 分隔操作链接
|
||||
37
src/common/prototype-annotation-host.tsx
Normal file
37
src/common/prototype-annotation-host.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { useAnnotationMermaidRenderer } from './prototype-annotation-mermaid';
|
||||
import { buildAnnotationViewerSource } from './prototype-annotation-utils';
|
||||
|
||||
export interface PrototypeAnnotationHostProps {
|
||||
source: AnnotationSourceDocument;
|
||||
options: AnnotationViewerOptions;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一标注壳:PRD 分章节展示在右侧「原型标注」目录面板,
|
||||
* 不包含跨原型导航与目录内页面跳转节点。
|
||||
*/
|
||||
export function PrototypeAnnotationHost(props: PrototypeAnnotationHostProps) {
|
||||
const { source, options, children } = props;
|
||||
useAnnotationMermaidRenderer();
|
||||
|
||||
const viewerSource = useMemo(
|
||||
() => buildAnnotationViewerSource(source, options.currentPageId),
|
||||
[source, options.currentPageId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<AnnotationViewer source={viewerSource} options={options} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrototypeAnnotationHost;
|
||||
19
src/common/prototype-annotation-mermaid.css
Normal file
19
src/common/prototype-annotation-mermaid.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.axhub-mermaid-diagram {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.axhub-mermaid-diagram svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.axhub-annotation-directory-reader .axhub-mermaid-diagram {
|
||||
padding: 12px;
|
||||
}
|
||||
111
src/common/prototype-annotation-mermaid.tsx
Normal file
111
src/common/prototype-annotation-mermaid.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect } from 'react';
|
||||
import './prototype-annotation-mermaid.css';
|
||||
|
||||
const MERMAID_SELECTOR = 'pre code.language-mermaid';
|
||||
|
||||
let mermaidReady = false;
|
||||
let renderQueue = Promise.resolve();
|
||||
|
||||
function collectShadowRoots(root: ParentNode): ShadowRoot[] {
|
||||
const roots: ShadowRoot[] = [];
|
||||
root.querySelectorAll('*').forEach((element) => {
|
||||
if (element.shadowRoot) {
|
||||
roots.push(element.shadowRoot);
|
||||
roots.push(...collectShadowRoots(element.shadowRoot));
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
function queryAllIncludingShadowRoots(selector: string, root: ParentNode = document): HTMLElement[] {
|
||||
const results: HTMLElement[] = [];
|
||||
root.querySelectorAll(selector).forEach((node) => {
|
||||
if (node instanceof HTMLElement) results.push(node);
|
||||
});
|
||||
collectShadowRoots(root).forEach((shadowRoot) => {
|
||||
results.push(...queryAllIncludingShadowRoots(selector, shadowRoot));
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
function observeDeepMutations(callback: () => void) {
|
||||
const observed = new WeakSet<Node>();
|
||||
|
||||
const observeNode = (node: Node) => {
|
||||
if (!(node instanceof Element) || observed.has(node)) return;
|
||||
observed.add(node);
|
||||
const observer = new MutationObserver(callback);
|
||||
observer.observe(node, { childList: true, subtree: true });
|
||||
collectShadowRoots(node).forEach((shadowRoot) => observeNode(shadowRoot));
|
||||
node.querySelectorAll('*').forEach((child) => {
|
||||
if (child.shadowRoot) observeNode(child.shadowRoot);
|
||||
});
|
||||
};
|
||||
|
||||
observeNode(document.body);
|
||||
return () => {
|
||||
// MutationObserver instances are GC'd with observed nodes on unmount.
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleMermaidRender() {
|
||||
renderQueue = renderQueue.then(async () => {
|
||||
const codes = queryAllIncludingShadowRoots(MERMAID_SELECTOR);
|
||||
if (codes.length === 0) return;
|
||||
|
||||
const mermaid = (await import('mermaid')).default;
|
||||
if (!mermaidReady) {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'neutral',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
||||
});
|
||||
mermaidReady = true;
|
||||
}
|
||||
|
||||
for (const code of codes) {
|
||||
const pre = code.parentElement;
|
||||
if (!pre || pre.dataset.mermaidRendered === 'true') continue;
|
||||
|
||||
const graphDefinition = code.textContent?.trim();
|
||||
if (!graphDefinition) continue;
|
||||
|
||||
pre.dataset.mermaidRendered = 'pending';
|
||||
const id = `axhub-mermaid-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
try {
|
||||
const { svg } = await mermaid.render(id, graphDefinition);
|
||||
const ownerDocument = pre.ownerDocument ?? document;
|
||||
const container = ownerDocument.createElement('div');
|
||||
container.className = 'axhub-mermaid-diagram';
|
||||
container.setAttribute('role', 'img');
|
||||
container.setAttribute('aria-label', '流程图');
|
||||
container.innerHTML = svg;
|
||||
pre.replaceWith(container);
|
||||
} catch (error) {
|
||||
pre.dataset.mermaidRendered = 'error';
|
||||
console.warn('[axhub-mermaid] render failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标注面板 Markdown 中的 ```mermaid 代码块渲染为 SVG 流程图。
|
||||
*/
|
||||
export function useAnnotationMermaidRenderer() {
|
||||
useEffect(() => {
|
||||
scheduleMermaidRender();
|
||||
|
||||
const onMutate = () => {
|
||||
scheduleMermaidRender();
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(onMutate);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
observeDeepMutations(onMutate);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}
|
||||
104
src/common/prototype-annotation-utils.ts
Normal file
104
src/common/prototype-annotation-utils.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { AnnotationSourceDocument } from '@axhub/annotation';
|
||||
|
||||
export const PROTOTYPE_NAV_FOLDER_ID = 'oneos-project-nav';
|
||||
|
||||
type DirectoryNode = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
markdown?: string;
|
||||
markdownId?: string;
|
||||
markdownPath?: string;
|
||||
children?: DirectoryNode[];
|
||||
payload?: { pageId?: string };
|
||||
};
|
||||
|
||||
function isNavFolder(node: DirectoryNode | undefined): boolean {
|
||||
return node?.type === 'folder' && node.id === PROTOTYPE_NAV_FOLDER_ID;
|
||||
}
|
||||
|
||||
function isDirectoryJumpNode(node: DirectoryNode | undefined): boolean {
|
||||
return node?.type === 'link' || node?.type === 'route';
|
||||
}
|
||||
|
||||
function collectRoutePageIds(nodes: DirectoryNode[] | undefined): string[] {
|
||||
const pageIds: string[] = [];
|
||||
const walk = (items: DirectoryNode[] | undefined) => {
|
||||
for (const node of items || []) {
|
||||
if (node.type === 'route') {
|
||||
const pageId = String(node.payload?.pageId || '').trim();
|
||||
if (pageId) pageIds.push(pageId);
|
||||
}
|
||||
if (node.type === 'folder' && Array.isArray(node.children)) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
return pageIds;
|
||||
}
|
||||
|
||||
function folderMatchesPage(folder: DirectoryNode, currentPageId?: string): boolean {
|
||||
if (!currentPageId) return true;
|
||||
const pageIds = collectRoutePageIds(folder.children);
|
||||
if (!pageIds.length) return true;
|
||||
return pageIds.includes(currentPageId);
|
||||
}
|
||||
|
||||
function filterDirectoryNodesForAnnotationPanel(
|
||||
nodes: DirectoryNode[] | undefined,
|
||||
currentPageId?: string,
|
||||
): DirectoryNode[] {
|
||||
const result: DirectoryNode[] = [];
|
||||
|
||||
for (const node of nodes || []) {
|
||||
if (isNavFolder(node) || isDirectoryJumpNode(node)) continue;
|
||||
|
||||
if (node.type === 'markdown') {
|
||||
result.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'folder') {
|
||||
if (!folderMatchesPage(node, currentPageId)) continue;
|
||||
const children = filterDirectoryNodesForAnnotationPanel(node.children, currentPageId);
|
||||
if (!children.length) continue;
|
||||
result.push({
|
||||
...node,
|
||||
children,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 供右侧「原型标注」目录使用:保留 PRD 分节,去除导航与页面跳转节点 */
|
||||
export function buildAnnotationViewerSource(
|
||||
source: AnnotationSourceDocument,
|
||||
currentPageId?: string,
|
||||
): AnnotationSourceDocument {
|
||||
const nodes = filterDirectoryNodesForAnnotationPanel(
|
||||
source.directory?.nodes as DirectoryNode[] | undefined,
|
||||
currentPageId,
|
||||
);
|
||||
|
||||
return {
|
||||
...source,
|
||||
directory: { nodes },
|
||||
};
|
||||
}
|
||||
|
||||
export function stripPrototypeNavFromDirectoryNodes(
|
||||
nodes: DirectoryNode[] | undefined,
|
||||
): DirectoryNode[] {
|
||||
return (nodes || [])
|
||||
.filter((node) => !isNavFolder(node))
|
||||
.map((node) => {
|
||||
if (node.type !== 'folder' || !Array.isArray(node.children)) return node;
|
||||
return {
|
||||
...node,
|
||||
children: stripPrototypeNavFromDirectoryNodes(node.children),
|
||||
};
|
||||
});
|
||||
}
|
||||
111
src/common/vm-button-icons.css
Normal file
111
src/common/vm-button-icons.css
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ONE-OS vm-btn 图标 — 通过 data-vm-icon 统一左侧图标
|
||||
* 规范:所有 vm-btn(除 link / back)均应带图标
|
||||
*/
|
||||
|
||||
.vm-btn[data-vm-icon]::before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
background-color: currentColor;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='search']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='rotate-ccw']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='download']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' x2='12' y1='15' y2='3'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' x2='12' y1='15' y2='3'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='plus']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3Cpath d='M12 5v14'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3Cpath d='M12 5v14'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='x']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='save']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'/%3E%3Cpath d='M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7'/%3E%3Cpath d='M7 3v4a1 1 0 0 0 1 1h7'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'/%3E%3Cpath d='M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7'/%3E%3Cpath d='M7 3v4a1 1 0 0 0 1 1h7'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='send']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m22 2-7 20-4-9-9-4z'/%3E%3Cpath d='M22 2 11 13'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m22 2-7 20-4-9-9-4z'/%3E%3Cpath d='M22 2 11 13'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='filter']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 3H2l8 9.46V19l4 2v-8.54z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 3H2l8 9.46V19l4 2v-8.54z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='chevron-up']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='chevron-left']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='chevron-right']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='upload']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' x2='12' y1='3' y2='15'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' x2='12' y1='3' y2='15'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='eye']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='trash']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6'/%3E%3Cpath d='M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6'/%3E%3Cpath d='M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='import']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v12'/%3E%3Cpath d='m8 11 4 4 4-4'/%3E%3Cpath d='M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v12'/%3E%3Cpath d='m8 11 4 4 4-4'/%3E%3Cpath d='M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vm-btn[data-vm-icon='settings']::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 行内 SVG 图标统一尺寸(迁移前兼容) */
|
||||
.vm-btn > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 预览翻页等紧凑按钮 */
|
||||
.vm-btn.ct-preview-pager__btn {
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
194
src/common/vm-operation-actions.css
Normal file
194
src/common/vm-operation-actions.css
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 全局列表操作列 — OperationActions
|
||||
* 规范:vm-shared/DESIGN.md · 操作列
|
||||
*/
|
||||
|
||||
.vm-operation-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.vm-op-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-radius: var(--ln-radius-control, 6px);
|
||||
background: transparent;
|
||||
color: var(--ln-link, var(--ln-primary, #32a06e));
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.vm-op-action:hover,
|
||||
.vm-op-action:focus-visible {
|
||||
color: var(--ln-primary-hover, #278a5c);
|
||||
background: color-mix(in srgb, var(--ln-primary, #32a06e) 8%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vm-op-action:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary, #32a06e) 35%, transparent);
|
||||
}
|
||||
|
||||
.vm-op-action__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.vm-op-action__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-op-action--open,
|
||||
.vm-op-action[aria-expanded="true"] {
|
||||
color: var(--ln-primary-hover, #278a5c);
|
||||
background: color-mix(in srgb, var(--ln-primary, #32a06e) 12%, transparent);
|
||||
}
|
||||
|
||||
.vm-op-more-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--ln-radius-control, 6px);
|
||||
background: transparent;
|
||||
color: var(--ln-muted, #64748b);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.vm-op-more-btn:hover,
|
||||
.vm-op-more-btn:focus-visible {
|
||||
color: var(--ln-ink, #0f172a);
|
||||
background: var(--ln-canvas-soft, #f4f4f5);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vm-op-more-btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary, #32a06e) 35%, transparent);
|
||||
}
|
||||
|
||||
.vm-op-more-btn__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 更多下拉:挂到 body,使用 overlayClassName 命中 */
|
||||
.vm-op-dropdown.ant-dropdown {
|
||||
z-index: 1060;
|
||||
}
|
||||
|
||||
.vm-op-dropdown .vm-op-dropdown-menu.ant-dropdown-menu {
|
||||
min-width: 176px;
|
||||
padding: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--ln-ink, #0f172a) 8%, transparent);
|
||||
border-radius: 10px;
|
||||
background: var(--ln-surface-card, #fff);
|
||||
box-shadow:
|
||||
0 12px 28px -8px rgba(15, 23, 42, 0.16),
|
||||
0 4px 10px -4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.vm-op-dropdown .ant-dropdown-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
margin: 2px 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--ln-ink, #0f172a);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.vm-op-dropdown .ant-dropdown-menu-item .ant-dropdown-menu-title-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vm-op-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):hover,
|
||||
.vm-op-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active {
|
||||
background: var(--ln-canvas-soft, #f4f4f5);
|
||||
}
|
||||
|
||||
.vm-op-dropdown .ant-dropdown-menu-item-divider {
|
||||
margin: 4px 8px;
|
||||
background: color-mix(in srgb, var(--ln-ink, #0f172a) 8%, transparent);
|
||||
}
|
||||
|
||||
.vm-op-menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vm-op-menu-item__icon,
|
||||
.vm-op-menu-item__icon-spacer {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vm-op-menu-item__icon {
|
||||
color: var(--ln-muted, #64748b);
|
||||
}
|
||||
|
||||
.vm-op-menu-item__label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__label,
|
||||
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__label {
|
||||
color: var(--ln-error, #e5484d);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__icon,
|
||||
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__icon {
|
||||
color: var(--ln-error, #e5484d);
|
||||
}
|
||||
|
||||
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled):hover,
|
||||
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active,
|
||||
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled):hover,
|
||||
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active {
|
||||
background: color-mix(in srgb, var(--ln-error, #e5484d) 8%, transparent);
|
||||
}
|
||||
|
||||
.vm-op-dropdown .ant-dropdown-menu-item-disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vm-operation-actions .ant-dropdown-menu-item-danger,
|
||||
.vm-operation-actions .ant-dropdown-menu-item-danger > span {
|
||||
color: var(--ln-error, #e5484d) !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vm-op-action,
|
||||
.vm-op-more-btn,
|
||||
.vm-op-dropdown .ant-dropdown-menu-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
min-height: var(--vm-control-height);
|
||||
height: var(--vm-control-height);
|
||||
border-radius: var(--ln-radius-sm);
|
||||
border-color: var(--ln-border);
|
||||
border-color: var(--ln-hairline-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
.vm-page.bdl-page .bdl-report-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--ln-subtle);
|
||||
color: var(--ln-muted-soft);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -126,8 +126,9 @@
|
||||
overflow: hidden;
|
||||
max-height: none;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.06);
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table.ant-table-wrapper,
|
||||
@@ -136,55 +137,78 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* —— 表格边框与表头(使用 Linear token,避免无效变量) —— */
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-container {
|
||||
border-radius: var(--ln-radius-card);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-container {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th,
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 10px !important;
|
||||
height: 38px !important;
|
||||
padding: 8px 12px !important;
|
||||
font-size: 13px;
|
||||
border-color: var(--ln-hairline) !important;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th {
|
||||
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-thead > tr > th,
|
||||
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-summary > tr > td {
|
||||
border-inline-end: 1px solid var(--ln-hairline) !important;
|
||||
border-bottom: 1px solid var(--ln-hairline) !important;
|
||||
}
|
||||
|
||||
/* 分组表:第一行业务板块 */
|
||||
.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:first-child > th {
|
||||
background: var(--ln-surface-strong) !important;
|
||||
color: var(--ln-ink) !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
background: var(--ln-surface-2) !important;
|
||||
border-bottom: 1px solid var(--ln-border) !important;
|
||||
border-inline-end: 1px solid var(--ln-border-subtle) !important;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid var(--ln-hairline-strong) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:first-child > th {
|
||||
/* 分组表:第二行 业绩/成本/盈亏 */
|
||||
.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:nth-child(2) > th {
|
||||
background: var(--ln-canvas-soft) !important;
|
||||
color: var(--ln-muted) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
text-align: center;
|
||||
background: var(--ln-surface-3) !important;
|
||||
border-bottom: 2px solid var(--ln-border) !important;
|
||||
}
|
||||
|
||||
/* 单层表头(按客户视图等) */
|
||||
.vm-page.bdl-page .bdl-list-table:not(.bdl-list-table--grouped) .ant-table-thead > tr > th {
|
||||
background: var(--ln-canvas-soft) !important;
|
||||
color: var(--ln-muted) !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
|
||||
color: var(--ln-body);
|
||||
border-bottom: 1px solid var(--ln-border-subtle) !important;
|
||||
border-inline-end: 1px solid var(--ln-border-subtle) !important;
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td {
|
||||
background: var(--ln-accent-soft) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td {
|
||||
font-weight: 700;
|
||||
background: var(--ln-surface-2) !important;
|
||||
color: var(--ln-ink) !important;
|
||||
border-top: 2px solid var(--ln-border) !important;
|
||||
background: var(--ln-canvas-soft) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td,
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
|
||||
font-weight: 700;
|
||||
background: var(--ln-surface-2) !important;
|
||||
background: var(--ln-surface-strong) !important;
|
||||
color: var(--ln-ink) !important;
|
||||
border-top: 2px solid var(--ln-border) !important;
|
||||
border-top: 2px solid var(--ln-hairline-strong) !important;
|
||||
}
|
||||
|
||||
/* 冻结列(对齐 lc-list-table--nested) */
|
||||
@@ -214,7 +238,7 @@
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-left,
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-end,
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-right {
|
||||
background: var(--ln-surface-2) !important;
|
||||
background: var(--ln-surface-strong) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-end:not(.ant-table-cell-scrollbar),
|
||||
@@ -248,21 +272,100 @@
|
||||
box-shadow: inset -10px 0 8px -8px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
/* 纵向滚动条占位缝:防止表头/表体右侧冻结列错位(对齐 lc-list-table--nested) */
|
||||
.vm-page.bdl-page .bdl-list-table--nested .ant-table-thead > tr > th.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar)::before,
|
||||
.vm-page.bdl-page .bdl-list-table--nested .ant-table-tbody > tr > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar)::before,
|
||||
.vm-page.bdl-page .bdl-list-table--nested .ant-table-thead > tr > th.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar)::before,
|
||||
.vm-page.bdl-page .bdl-list-table--nested .ant-table-tbody > tr > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -17px;
|
||||
width: 17px;
|
||||
background: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table--nested .ant-table-cell-scrollbar {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table--nested .ant-table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table--nested .ant-table-content {
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
/* 金额列最小宽度,避免合计行大额被裁切 */
|
||||
.vm-page.bdl-page .bdl-list-table .col-amount,
|
||||
.vm-page.bdl-page .bdl-list-table .col-summary {
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .col-summary {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .bdl-amount-cell {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .biz-standbook-perf-link,
|
||||
.vm-page.bdl-page .biz-standbook-perf-link {
|
||||
cursor: pointer;
|
||||
color: var(--ln-primary);
|
||||
padding: 4px 8px;
|
||||
margin: -4px -8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-align: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 钻取子页表格:复用主表边框规范 */
|
||||
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-thead > tr > th,
|
||||
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
|
||||
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-summary > tr > td {
|
||||
border-color: var(--ln-hairline) !important;
|
||||
border-inline-end: 1px solid var(--ln-hairline) !important;
|
||||
border-bottom: 1px solid var(--ln-hairline) !important;
|
||||
padding: 8px 10px !important;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table .ant-table-thead > tr > th {
|
||||
background: var(--ln-canvas-soft) !important;
|
||||
color: var(--ln-muted) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table-wrap,
|
||||
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table {
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .biz-standbook-perf-link:hover {
|
||||
color: var(--ln-primary-strong);
|
||||
background: var(--ln-accent-soft);
|
||||
color: var(--ln-primary-focus);
|
||||
background: var(--ln-primary-soft);
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .biz-standbook-perf-link:focus {
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
splitFilterFields,
|
||||
VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
||||
} from '../../common/vm-filter-panel.ts';
|
||||
import { OperationActions } from '../../common/OperationActions';
|
||||
|
||||
|
||||
var DEFAULT_LESSOR_COMPANIES = [
|
||||
@@ -2593,9 +2594,11 @@ const Component = function (props) {
|
||||
}
|
||||
|
||||
function createPreviewPagerBtn(label, opts) {
|
||||
var icon = label === '上一页' ? 'chevron-left' : (label === '下一页' ? 'chevron-right' : null);
|
||||
return React.createElement(Button, Object.assign({
|
||||
size: 'small',
|
||||
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn'
|
||||
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
|
||||
'data-vm-icon': icon,
|
||||
}, opts), label);
|
||||
}
|
||||
|
||||
@@ -4747,66 +4750,45 @@ const Component = function (props) {
|
||||
|
||||
function renderTemplateRowActions(record) {
|
||||
if (isPrototypeReadOnly()) {
|
||||
return React.createElement('div', { className: 'ct-row-actions' },
|
||||
React.createElement(Button, {
|
||||
type: 'link', size: 'small', className: 'ct-action-btn', key: 'view',
|
||||
onClick: function () { openEdit(record); }
|
||||
}, '查看')
|
||||
);
|
||||
return React.createElement(OperationActions, {
|
||||
view: { onClick: function () { openEdit(record); } }
|
||||
});
|
||||
}
|
||||
var published = isPublishedTemplate(record);
|
||||
var enabled = isTemplateEnabled(record);
|
||||
var actionLocked = published && enabled;
|
||||
var lockTitle = '启用中的模板不可操作,请先停用';
|
||||
var menuItems = [];
|
||||
var more = [];
|
||||
|
||||
if (published) {
|
||||
menuItems.push({
|
||||
key: 'version-log',
|
||||
label: '版本日志'
|
||||
if (enabled) {
|
||||
more.push({
|
||||
key: 'disable',
|
||||
label: '停用',
|
||||
onClick: function () { confirmDisableTemplate(record); }
|
||||
});
|
||||
} else {
|
||||
more.push({
|
||||
key: 'enable',
|
||||
label: '启用',
|
||||
onClick: function () { toggleTemplateEnabled(record, true); }
|
||||
});
|
||||
}
|
||||
menuItems.push({
|
||||
more.push({ key: 'version-log', label: '版本日志', onClick: function () { openVersionLog(record); } });
|
||||
}
|
||||
more.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
disabled: actionLocked,
|
||||
danger: true
|
||||
danger: true,
|
||||
onClick: actionLocked ? undefined : function () { confirmDelete(record); }
|
||||
});
|
||||
|
||||
function onMenuClick(info) {
|
||||
if (info.key === 'version-log') openVersionLog(record);
|
||||
if (info.key === 'delete' && !actionLocked) confirmDelete(record);
|
||||
}
|
||||
|
||||
return React.createElement('div', { className: 'ct-row-actions' },
|
||||
published
|
||||
? (enabled
|
||||
? React.createElement(Button, {
|
||||
type: 'link', size: 'small', className: 'ct-action-btn', key: 'disable',
|
||||
onClick: function () { confirmDisableTemplate(record); }
|
||||
}, '停用')
|
||||
: React.createElement(Button, {
|
||||
type: 'link', size: 'small', className: 'ct-action-btn', key: 'enable',
|
||||
onClick: function () { toggleTemplateEnabled(record, true); }
|
||||
}, '启用'))
|
||||
: null,
|
||||
React.createElement(Button, {
|
||||
type: 'link', size: 'small', className: 'ct-action-btn', key: 'edit',
|
||||
disabled: actionLocked,
|
||||
title: actionLocked ? lockTitle : undefined,
|
||||
onClick: actionLocked ? undefined : function () { openEdit(record); }
|
||||
}, '编辑'),
|
||||
menuItems.length
|
||||
? React.createElement(Dropdown, {
|
||||
key: 'more',
|
||||
trigger: ['click'],
|
||||
menu: { items: menuItems, onClick: onMenuClick }
|
||||
}, React.createElement(Button, {
|
||||
type: 'link', size: 'small', className: 'ct-action-btn ct-row-actions__more',
|
||||
'aria-label': '更多操作'
|
||||
}, '更多'))
|
||||
: null
|
||||
);
|
||||
return React.createElement(OperationActions, {
|
||||
edit: actionLocked
|
||||
? { disabled: true, onClick: function () {} }
|
||||
: { onClick: function () { openEdit(record); } },
|
||||
more: more
|
||||
});
|
||||
}
|
||||
|
||||
function getListColumns() {
|
||||
@@ -4836,7 +4818,7 @@ const Component = function (props) {
|
||||
{ title: '最后更新时间', dataIndex: 'updateTime', key: 'updateTime', width: 140, className: 'ct-col-optional' },
|
||||
{ title: '更新人', dataIndex: 'updater', key: 'updater', width: 100, className: 'ct-col-optional', render: formatPersonDisplayName },
|
||||
{
|
||||
title: '操作', key: 'action', width: 200, fixed: 'right',
|
||||
title: '操作', key: 'action', width: 168, fixed: 'right',
|
||||
render: function (_, record) { return renderTemplateRowActions(record); }
|
||||
}
|
||||
]);
|
||||
@@ -5014,6 +4996,7 @@ const Component = function (props) {
|
||||
? React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-link vm-filter-toggle',
|
||||
'data-vm-icon': listFilterExpanded ? 'chevron-up' : 'filter',
|
||||
onClick: function () { setListFilterExpanded(!listFilterExpanded); },
|
||||
'aria-expanded': listFilterExpanded,
|
||||
'aria-controls': expandPanelId
|
||||
@@ -5024,12 +5007,11 @@ const Component = function (props) {
|
||||
className: 'vm-filter-toggle-badge',
|
||||
'aria-label': '已设置 ' + extraActiveCount + ' 项扩展筛选'
|
||||
}, String(extraActiveCount))
|
||||
: null,
|
||||
renderFilterChevronIcon(listFilterExpanded)
|
||||
: null
|
||||
)
|
||||
: null,
|
||||
React.createElement(Button, { className: 'vm-btn vm-btn-ghost', onClick: resetListFilters }, '重置'),
|
||||
React.createElement(Button, { type: 'primary', className: 'ct-btn-cta', onClick: applyListFilters }, '查询')
|
||||
React.createElement(Button, { 'data-vm-icon': 'rotate-ccw', className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', onClick: resetListFilters }, '重置'),
|
||||
React.createElement(Button, { 'data-vm-icon': 'search', className: 'vm-btn vm-btn-primary ldb-toolbar-btn', onClick: applyListFilters }, '查询')
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -5139,15 +5121,17 @@ const Component = function (props) {
|
||||
)
|
||||
: null,
|
||||
React.createElement('div', { className: 'ct-editor-page__topbar vm-detail-topbar', 'data-annotation-id': 'ct-editor-topbar' },
|
||||
React.createElement(Button, { className: 'ct-editor-page__back-btn vm-btn vm-btn-back', onClick: requestBackToList }, '← 返回列表'),
|
||||
React.createElement(Button, { className: 'vm-btn vm-btn-back', onClick: requestBackToList }, '返回列表'),
|
||||
readOnly
|
||||
? null
|
||||
: React.createElement('div', { className: 'ct-editor-page__topbar-actions' },
|
||||
React.createElement(Button, {
|
||||
'data-vm-icon': 'save',
|
||||
className: 'vm-btn vm-btn-secondary',
|
||||
onClick: function () { handleSaveTemplate(false); }
|
||||
}, '保存'),
|
||||
React.createElement(Button, {
|
||||
'data-vm-icon': 'send',
|
||||
type: 'primary',
|
||||
className: 'vm-btn vm-btn-primary ct-btn-cta',
|
||||
onClick: function () { handleSaveTemplate(true); }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "contract-template-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783589987162,
|
||||
"updatedAt": 1783800844961,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ct-list-filter",
|
||||
@@ -328,36 +328,6 @@
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "oneos-project-nav",
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
"title": "OneOS",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-workbench",
|
||||
"title": "工作台",
|
||||
"href": "/prototypes/oneos-web-workbench",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "ct-doc-root",
|
||||
|
||||
@@ -71,5 +71,5 @@ export function getDefaultContractTemplateId(contractType, category) {
|
||||
export function getContractTemplatePreviewBadge(templateId) {
|
||||
var option = getContractTemplateOption(templateId);
|
||||
if (!option) return '请先选择合同模板';
|
||||
return [option.contractTypeLabel, option.fileName || option.title].filter(Boolean).join(' · ');
|
||||
return option.contractTypeLabel || option.title || option.kind || '—';
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@ export var MILEAGE_PERIOD_OPTIONS = [
|
||||
export var CONTRACT_CODE_PREFIX = 'LNZLHT';
|
||||
|
||||
export var DEFAULT_FEE_INFO = {
|
||||
paymentMethod: 'advance',
|
||||
paymentPeriod: 1,
|
||||
hydrogenPaymentMethod: 'self',
|
||||
prepayAmount: null,
|
||||
@@ -225,16 +226,21 @@ export function generateAutoContractCodeSuffix() {
|
||||
}
|
||||
|
||||
export var PAYMENT_PERIOD_OPTIONS = [
|
||||
{ value: 1, label: '1个月' },
|
||||
{ value: 3, label: '3个月' },
|
||||
{ value: 6, label: '6个月' },
|
||||
{ value: 12, label: '12个月' },
|
||||
{ value: 1, label: '月付' },
|
||||
{ value: 3, label: '季付' },
|
||||
{ value: 6, label: '半年付' },
|
||||
{ value: 12, label: '年付' },
|
||||
];
|
||||
|
||||
export var PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'advance', label: '先付后用' },
|
||||
{ value: 'postpay', label: '先用后付' },
|
||||
];
|
||||
|
||||
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'self', label: '自行解决' },
|
||||
{ value: 'prepay', label: '预付款' },
|
||||
{ value: 'month', label: '按月结算(原型占位,条款待法务确认)' },
|
||||
{ value: 'month', label: '按月结算' },
|
||||
];
|
||||
|
||||
export function getLessorCompanies() {
|
||||
|
||||
@@ -6,11 +6,11 @@ import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import * as antd from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
type AnnotationViewerOptions
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import 'antd/dist/reset.css';
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
@@ -109,7 +109,7 @@ export function ContractTemplateManagementContent({ embedded = false }: { embedd
|
||||
navigationRef={navigationRef}
|
||||
onPageViewChange={setAnnotationPageId}
|
||||
/>
|
||||
<AnnotationViewer
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
|
||||
@@ -803,21 +803,6 @@
|
||||
box-shadow: inset 0 0 0 1px var(--ln-primary);
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-editor-page__back-btn.ant-btn {
|
||||
padding: 4px 12px;
|
||||
height: var(--vm-control-height);
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-muted);
|
||||
border-color: var(--ln-hairline-strong);
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-editor-page__back-btn.ant-btn:hover {
|
||||
color: var(--ln-primary);
|
||||
border-color: var(--ln-primary);
|
||||
background: var(--ln-primary-soft);
|
||||
}
|
||||
|
||||
.vm-page.ct-page .ct-editor-page__sections {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "customer-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783589987164,
|
||||
"updatedAt": 1783800844964,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "cm-list-filter",
|
||||
@@ -245,36 +245,6 @@
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "oneos-project-nav",
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
"title": "OneOS",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-workbench",
|
||||
"title": "工作台",
|
||||
"href": "/prototypes/oneos-web-workbench",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "cm-doc-root",
|
||||
|
||||
@@ -140,8 +140,8 @@ export function BatchRiskTagModal({
|
||||
</div>
|
||||
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmit}>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmit} data-vm-icon="send">
|
||||
提交批量打标
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { OperationActions } from '../../../common/OperationActions';
|
||||
import type { CustomerRecord } from '../types';
|
||||
import type { CustomerRiskTag, MockUser } from '../types/riskTag';
|
||||
import { RiskLabelTags } from './RiskLabelTags';
|
||||
@@ -110,19 +111,25 @@ export function CustomerTable({
|
||||
<td className="cm-col-address" title={record.address}>{record.address}</td>
|
||||
<td className="cm-col-time tabular-nums">{record.createTime}</td>
|
||||
<td className="sticky-col-right cm-col-actions">
|
||||
<div className="cm-row-actions">
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onView(record)}>详情</button>
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(record)}>编辑</button>
|
||||
{canManage && (
|
||||
<>
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onManageTags(record)}>管理标签</button>
|
||||
</>
|
||||
)}
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link cm-link-danger" onClick={() => onDelete(record)}>删除</button>
|
||||
</div>
|
||||
<OperationActions
|
||||
view={{ label: '详情', onClick: () => onView(record) }}
|
||||
edit={{ onClick: () => onEdit(record) }}
|
||||
more={[
|
||||
...(canManage
|
||||
? [{
|
||||
key: 'tags',
|
||||
label: '管理标签',
|
||||
onClick: () => onManageTags(record),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -191,19 +191,12 @@ export function FilterPanel({
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -252,11 +252,11 @@ export function RiskTagDrawer({
|
||||
<footer className="cm-drawer-footer">
|
||||
{tab === 'add' ? (
|
||||
<>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => { setTab('tags'); resetAddForm(); }}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply}>提交打标</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" data-vm-icon="x" onClick={() => { setTab('tags'); resetAddForm(); }}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply} data-vm-icon="send">提交打标</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>关闭</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">关闭</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
@@ -278,8 +278,8 @@ export function RiskTagDrawer({
|
||||
onChange={(e) => setRemoveReason(e.target.value)}
|
||||
/>
|
||||
<div className="cm-drawer-overlay-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setRemoveTargetId(null)}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove}>确认移除</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setRemoveTargetId(null)} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove} data-vm-icon="trash">确认移除</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -296,9 +296,9 @@ export function RiskTagDrawer({
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
<div className="cm-drawer-overlay-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setApproveTargetId(null)}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => handleConfirmApprove(false)}>驳回</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={() => handleConfirmApprove(true)}>通过</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setApproveTargetId(null)} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => handleConfirmApprove(false)} data-vm-icon="x">驳回</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={() => handleConfirmApprove(true)} data-vm-icon="send">通过</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,11 +7,11 @@ import './styles/index.css';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Download, FileUp, Plus, Tags } from 'lucide-react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
type AnnotationViewerOptions
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import seedCustomers from './data/customers.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { CustomerTable } from './components/CustomerTable';
|
||||
@@ -330,9 +330,6 @@ export default function CustomerManagementApp() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<nav className="cm-breadcrumb cm-breadcrumb-list" aria-label="面包屑">
|
||||
<span className="cm-breadcrumb-muted">业务管理</span>
|
||||
</nav>
|
||||
|
||||
<div className="cm-role-bar" data-annotation-id="cm-risk-role-switch">
|
||||
<span className="cm-role-bar-label">当前操作角色(原型演示):</span>
|
||||
@@ -366,36 +363,34 @@ export default function CustomerManagementApp() {
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar" data-annotation-id="cm-list-toolbar">
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleOpenCreate}>
|
||||
<Plus size={16} aria-hidden />
|
||||
新增客户
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={handleOpenCreate} data-vm-icon="plus">新增客户
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary"
|
||||
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
|
||||
data-vm-icon="import"
|
||||
onClick={() => showToast('批量导入(原型演示)')}
|
||||
>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
{canBatchTag(currentUser) && selectedIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary"
|
||||
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
|
||||
data-vm-icon="plus"
|
||||
onClick={() => setBatchTagOpen(true)}
|
||||
data-annotation-id="cm-batch-risk-tag-btn"
|
||||
>
|
||||
<Tags size={16} aria-hidden />
|
||||
批量打标({selectedIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost"
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
data-vm-icon="download"
|
||||
onClick={() => showToast(`批量导出(原型演示):当前列表共 ${filtered.length} 条`)}
|
||||
>
|
||||
<Download size={16} aria-hidden />
|
||||
批量导出
|
||||
</button>
|
||||
</div>
|
||||
@@ -452,7 +447,7 @@ export default function CustomerManagementApp() {
|
||||
onSubmit={handleBatchApply}
|
||||
/>
|
||||
|
||||
<AnnotationViewer
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
|
||||
@@ -68,13 +68,9 @@ export function CreatePage({
|
||||
|
||||
return (
|
||||
<div className="cm-create-page">
|
||||
<nav className="cm-breadcrumb" aria-label="面包屑">
|
||||
<button type="button" className="cm-breadcrumb-link" onClick={onBack}>业务管理</button>
|
||||
<span aria-hidden>/</span>
|
||||
<button type="button" className="cm-breadcrumb-link" onClick={onBack}>客户管理</button>
|
||||
<span aria-hidden>/</span>
|
||||
<span className="cm-breadcrumb-current">新增</span>
|
||||
</nav>
|
||||
<div className="vm-form-topbar">
|
||||
<button type="button" className="vm-btn vm-btn-back" onClick={onBack}>返回</button>
|
||||
</div>
|
||||
|
||||
<section className="cm-form-card" data-annotation-id="cm-create-base">
|
||||
<h2 className="cm-form-card-title">客户信息</h2>
|
||||
@@ -273,8 +269,8 @@ export function CreatePage({
|
||||
</section>
|
||||
|
||||
<footer className="cm-create-footer" data-annotation-id="cm-create-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit}>提交</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit} data-vm-icon="send">提交</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// 与车辆管理「保险状态」联动:交强险 + 商业险均存在且在有效期内为正常,否则异常(禁止交车)
|
||||
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { OperationActions } from '../../common/OperationActions';
|
||||
import dayjs from 'dayjs';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import {
|
||||
@@ -4434,37 +4435,20 @@ const Component = function () {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 188,
|
||||
width: 168,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<div className="lc-vehicle-ins-mgmt-actions">
|
||||
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600 }} onClick={() => openVehicleInsHistoryEdit(record)}>编辑</Button>
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => handleInsuranceRecordPreview(record)}>预览</Button>
|
||||
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600, color: '#059669' }} onClick={() => handleInsuranceRecordDownload(record)}>下载</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: getPolicyMoreMenuItems(record),
|
||||
onClick: ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
handlePolicyMoreMenuClick(record, key);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title="更多">
|
||||
<span
|
||||
className="lc-vehicle-ins-policy-more-btn"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="更多操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{ICONS.more}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<OperationActions
|
||||
edit={{ onClick: () => openVehicleInsHistoryEdit(record) }}
|
||||
more={[
|
||||
{ key: 'preview', label: '预览', onClick: () => handleInsuranceRecordPreview(record) },
|
||||
{ key: 'download', label: '下载', onClick: () => handleInsuranceRecordDownload(record) },
|
||||
...getPolicyMoreMenuItems(record).map((item) => ({
|
||||
...item,
|
||||
onClick: item.key ? () => handlePolicyMoreMenuClick(record, item.key) : undefined,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -5937,11 +5921,10 @@ const Component = function () {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 96,
|
||||
width: 168,
|
||||
fixed: 'right',
|
||||
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
|
||||
render: (_, row) => (
|
||||
<div className="lc-compare-action-cell">
|
||||
<Popover
|
||||
trigger="click"
|
||||
open={copyPopoverRowId === row.id}
|
||||
@@ -5960,10 +5943,20 @@ const Component = function () {
|
||||
</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>
|
||||
<Button type="link" size="small" danger onClick={() => handleDeleteCompareRow(row.id)}>删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -6501,17 +6494,12 @@ const Component = function () {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 64,
|
||||
width: 168,
|
||||
onHeaderCell: listColumnHeaderCell,
|
||||
render: (record) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="lc-action-btn"
|
||||
onClick={() => openVehicleInsuranceMgmt(record)}
|
||||
>
|
||||
管理
|
||||
</Button>
|
||||
<OperationActions
|
||||
view={{ label: '管理', onClick: () => openVehicleInsuranceMgmt(record) }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -7658,17 +7646,13 @@ const Component = function () {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
width: 168,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600, color: '#10b981' }} onClick={() => openCompareEditor(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" size="small" danger style={{ padding: 0, fontWeight: 600 }} onClick={() => handleDeleteCompareSheet(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
<OperationActions
|
||||
edit={{ onClick: () => openCompareEditor(record) }}
|
||||
more={[{ key: 'delete', label: '删除', danger: true, onClick: () => handleDeleteCompareSheet(record) }]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -8109,31 +8093,29 @@ const Component = function () {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
width: 168,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const recognizing = isPolicyRecognTaskRecognizing(record);
|
||||
const noSuccess = !(record.recognSuccessCount > 0);
|
||||
const disabled = recognizing || noSuccess;
|
||||
const btn = (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0, fontWeight: 600, color: disabled ? undefined : '#10b981' }}
|
||||
disabled={disabled}
|
||||
onClick={() => openPolicyRecognTaskRecord(record)}
|
||||
>
|
||||
确认识别结果
|
||||
</Button>
|
||||
const action = (
|
||||
<OperationActions
|
||||
view={{
|
||||
label: '确认识别结果',
|
||||
disabled,
|
||||
onClick: () => openPolicyRecognTaskRecord(record),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (recognizing) {
|
||||
return (
|
||||
<Tooltip title="请等待识别完成后操作">
|
||||
<span>{btn}</span>
|
||||
<span>{action}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return btn;
|
||||
return action;
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "insurance-procurement",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783589987165,
|
||||
"updatedAt": 1783800844965,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ipc-header",
|
||||
@@ -496,36 +496,6 @@
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "oneos-project-nav",
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
"title": "OneOS",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-workbench",
|
||||
"title": "工作台",
|
||||
"href": "/prototypes/oneos-web-workbench",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "ipc-doc-root",
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
type AnnotationViewerOptions
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import 'antd/dist/reset.css';
|
||||
import './styles/index.css';
|
||||
import InsuranceProcurement from './InsuranceProcurement.jsx';
|
||||
@@ -82,7 +82,7 @@ export default function InsuranceProcurementPage() {
|
||||
{ theme: ipcTheme },
|
||||
React.createElement(InsuranceProcurement),
|
||||
),
|
||||
React.createElement(AnnotationViewer, {
|
||||
React.createElement(PrototypeAnnotationHost, {
|
||||
source: annotationSourceDocument as AnnotationSourceDocument,
|
||||
options: annotationOptions,
|
||||
}),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
165
src/prototypes/lease-business-detail/.spec/requirements-prd.md
Normal file
165
src/prototypes/lease-business-detail/.spec/requirements-prd.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 租赁业务明细 · 产品需求说明(PRD)
|
||||
|
||||
> 对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」;业管人员按月份维护运营明细,公式列系统自动计算。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 台账数据 — 租赁业务明细 |
|
||||
| 原型路径 | `src/prototypes/lease-business-detail` |
|
||||
| 预览地址 | `/prototypes/lease-business-detail` |
|
||||
| 目标用户 | 业务二部业管 / 运营人员 |
|
||||
| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |
|
||||
| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 47 列宽表(表尾 23 项汇总)+ 分页 |
|
||||
| 设计基底 | `vm-page` + `ldb-page` + `lc-page`(见 `ledger-shared/DESIGN.md`) |
|
||||
|
||||
### 与「租赁业务台账」的分工
|
||||
|
||||
| 模块 | 定位 |
|
||||
|---|---|
|
||||
| **租赁业务明细**(本模块) | 对齐业管 Excel 运营台账宽表;手工列导入维护;6 个公式列自动计算;**不**做收款关联与账单状态 |
|
||||
| **租赁业务台账** | 账单维度维护、关联收款记录、收款状态(未收/部分/已收)、归档与角色数据范围 |
|
||||
|
||||
下游 **业务部台账 / 业务汇总分析** 中「租赁业绩」数据来源为本模块明细(见 `oneos-web-data-analysis/05-业务部台账`)。
|
||||
|
||||
### 不做范围(当前原型)
|
||||
|
||||
- 「租赁业务盈亏月度汇总」独立 Tab(Legacy 数据分析页有,本原型待规划)
|
||||
- 行内编辑、删除、服务端持久化
|
||||
- 关联收款记录自动回写实收
|
||||
- 员工/主管数据权限隔离
|
||||
|
||||
---
|
||||
|
||||
## 2. 筛选区
|
||||
|
||||
默认展示 4 项(一行),「更多筛选」展开车牌多选。
|
||||
|
||||
| 筛选项 | 说明 |
|
||||
|---|---|
|
||||
| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |
|
||||
| 业务部门 | 精确匹配 |
|
||||
| 业务员 | 精确匹配 |
|
||||
| 客户名称 | 模糊包含 |
|
||||
| 车牌号码 | 多选精确匹配(更多筛选) |
|
||||
|
||||
查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。
|
||||
|
||||
---
|
||||
|
||||
## 3. KPI 统计卡片(7 张)
|
||||
|
||||
顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。
|
||||
|
||||
| KPI | 汇总字段 |
|
||||
|---|---|
|
||||
| 应收总计 | `receivableTotal` |
|
||||
| 押金总计 | `deposit`(仅数值型) |
|
||||
| 实收总计 | `receivedAmount` |
|
||||
| 里程减免总计 | `mileageDeduction` |
|
||||
| 其他减免总计 | `otherDeduction` |
|
||||
| 未收总计 | `outstanding`(>0 标红) |
|
||||
| 盈亏总计 | `profitLoss`(正负着色) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 批量导入 | 下载模板(仅手工录入列)→ 上传 Excel → 公式列自动计算 |
|
||||
| 批量导出 | 导出当前筛选结果(完整 47 列,含公式列结果) |
|
||||
|
||||
导入必填:**年份、月份、车牌号码**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 明细列表(47 列)
|
||||
|
||||
表头顺序严格对齐 Excel「2.租赁车辆收入明细表」:
|
||||
|
||||
年份、月份、业务部门、业务员、付款日期、车牌号码、车型、客户名称、增值服务、享免政策、提车日期、合同生效日期、合同到期日期、起始日期、退车日期、平均天数、押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、开票日期、实际付款日期、付款方式、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏、GPS里程、其他减免金额明细内容、资产归属、签约公司、备注。
|
||||
|
||||
### 交互
|
||||
|
||||
- 横向滚动;左冻结:**年份 + 车牌号码**
|
||||
- 仅**日期类列**表头支持正序/倒序排序
|
||||
- 公式列(`calculated`)浅灰底色,表示系统计算、导入模板不含
|
||||
- 表尾**汇总行**:23 个金额列 SUBTOTAL,首列显示「汇总」
|
||||
- 未收 > 0 单元格标红;盈亏正负着色
|
||||
- 底部分页:10 / 20 / 50 / 100
|
||||
|
||||
### 表尾汇总(23 项)
|
||||
|
||||
押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。
|
||||
|
||||
---
|
||||
|
||||
## 6. 公式列(系统自动计算)
|
||||
|
||||
对齐 Excel 表内公式;实现见 `utils/calc-detail.ts`。
|
||||
|
||||
| 字段 | 计算方式 |
|
||||
|---|---|
|
||||
| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入) + 其他收入 + 里程减免 + 其他减免 |
|
||||
| **月度收入** | 月度租金 + 同上调整项 |
|
||||
| **未收** | 应收合计 − 实收金额 |
|
||||
| **车辆实际成本** | 车辆标准成本 × 平均天数 |
|
||||
| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本) + 居间费 + 其他 |
|
||||
| **盈亏** | 月度收入 − 总成本 |
|
||||
|
||||
减免金额在 Excel 中以负数录入;导入时填负数即可。
|
||||
|
||||
### 导入模板
|
||||
|
||||
- **含**:41 列手工录入字段(不含上述 6 列)
|
||||
- **不含**:应收合计、月度收入、未收、车辆实际成本、总成本、盈亏
|
||||
- 导入后调用 `applyDetailCalculations` 统一重算
|
||||
|
||||
---
|
||||
|
||||
## 7. 样例数据
|
||||
|
||||
- 文件:`data/rows.json`
|
||||
- 来源:Excel《2026年业务二部运营台账总表6.17 - 新版》2026 年 1–6 月明细
|
||||
- 条数:553 条;汇总与 Excel SUBTOTAL 行一致
|
||||
|
||||
---
|
||||
|
||||
## 8. 源码入口
|
||||
|
||||
| 类型 | 路径 |
|
||||
|---|---|
|
||||
| 主入口 | `index.tsx` |
|
||||
| 标注源 | `annotation-source.json` |
|
||||
| 列定义 | `components/tableColumns.ts` |
|
||||
| 筛选 / KPI 汇总 | `utils/detail.ts` |
|
||||
| 公式计算 | `utils/calc-detail.ts` |
|
||||
| 导入导出 | `utils/batch-io.ts` |
|
||||
| 类型 | `types.ts` |
|
||||
| 组件 | `components/FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收重点
|
||||
|
||||
1. 表头 47 列顺序与 Excel 样表一致;双「运维费」列位置正确
|
||||
2. 筛选后 KPI、表尾汇总与明细行合计一致
|
||||
3. 下载导入模板无 6 个公式列;导入后公式列自动计算且与 Excel 同口径
|
||||
4. 导出为完整 47 列
|
||||
5. 横向滚动与左冻结(年份、车牌)正常
|
||||
6. 未收 > 0 标红;盈亏正负着色
|
||||
|
||||
---
|
||||
|
||||
## 10. 待规划项(评审记录)
|
||||
|
||||
详见 `.spec/prototype-review.md`:
|
||||
|
||||
- P1:盈亏月度汇总 Tab
|
||||
- P1:与租赁业务台账 / 收款模块集成口径
|
||||
- P2:导入重复键(年+月+车牌)处理策略
|
||||
- P2:导入行级校验与实收 ≤ 应收校验
|
||||
File diff suppressed because one or more lines are too long
39
src/prototypes/lease-business-detail/columnHeaderTips.ts
Normal file
39
src/prototypes/lease-business-detail/columnHeaderTips.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** 表头悬浮说明 */
|
||||
export const COLUMN_HEADER_TIPS: Record<string, string> = {
|
||||
'lbd-col-status': '按实收金额与应收合计自动计算:未收款 / 部分收款 / 已结清。',
|
||||
'lbd-col-year-month': '展示合并后的统计月份(YYYY-MM);导入模板仍使用「年份」「月份」两列。',
|
||||
'lbd-col-plate': '车牌号码与系统「车辆管理」匹配;不存在的车牌导入失败。',
|
||||
'lbd-col-vehicle-type': '根据车牌号,显示系统「品牌·型号」。',
|
||||
'lbd-col-business-dept': '根据车牌对应租赁合同显示业务部门与业务员;展示时合并为两行。',
|
||||
'lbd-col-customer': '导入后与客户管理比对;不一致时提示正确客户并支持一键替换。',
|
||||
'lbd-col-pickup-date': '对照交车管理校验提车日期;不一致时显示警告。',
|
||||
'lbd-col-return-date': '对照还车记录校验退车日期;不一致时显示警告。',
|
||||
'lbd-col-avg-days': '平均天数 = 本月在租天数 ÷ 当月自然日天数,保留两位小数。',
|
||||
'lbd-col-deposit': '与租赁合同保证金比对;不一致时提示正确金额。',
|
||||
'lbd-col-contract-rent': '与租赁合同租金比对;不一致时提示正确金额。',
|
||||
'lbd-col-receivable-total':
|
||||
'应收合计 = 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免金额(负数)+ 其他减免金额(负数)。',
|
||||
'lbd-col-receivable-rent':
|
||||
'应收租金 = 合同标的租金 × 租赁合同结算周期月份数;与导入值不一致时警告。',
|
||||
'lbd-col-monthly-income':
|
||||
'月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免。',
|
||||
'lbd-col-monthly-rent':
|
||||
'月度租金 = 合同标的租金 ÷ 付款周期月数,四舍五入保留两位小数。',
|
||||
'lbd-col-mileage-deduction': '里程减免金额以负数显示;录入正数时警告。',
|
||||
'lbd-col-other-deduction': '其他减免金额以负数显示;录入正数时警告。',
|
||||
'lbd-col-outstanding': '未收 = 应收合计 − 实收金额;差额为零时显示 0。',
|
||||
'lbd-col-payment-method': '根据合同付款间隔翻译:1→月度、3→季度、6→半年、12→年度,并附加预付/后付。',
|
||||
'lbd-col-vehicle-actual-cost': '车辆实际成本 = 车辆标准成本 × 平均天数。',
|
||||
'lbd-col-total-cost': '总成本 = 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他。',
|
||||
'lbd-col-hydrogen-fee':
|
||||
'显示该车牌号码对应客户名称,所有加氢记录,承担方式为我司承担的记录「加氢总价(元)」总和',
|
||||
'lbd-col-profit-loss': '盈亏 = 月度收入 − 总成本;红色为正、绿色为负(股市配色)。',
|
||||
'lbd-col-asset-owner': '等同于车辆「登记所有权」;与车辆管理不一致时警告。',
|
||||
'lbd-col-signing-company': '显示与客户签订租赁合同的签约公司。',
|
||||
};
|
||||
|
||||
export function resolveColumnHeaderTip(annotationId?: string, headerTip?: string): string | undefined {
|
||||
if (headerTip) return headerTip;
|
||||
if (annotationId && COLUMN_HEADER_TIPS[annotationId]) return COLUMN_HEADER_TIPS[annotationId];
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { DetailChangeLogEntry } from '../utils/change-log';
|
||||
import { displayText } from '../utils/detail';
|
||||
|
||||
interface DetailChangeLogModalProps {
|
||||
open: boolean;
|
||||
rowLabel: string;
|
||||
entries: DetailChangeLogEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function renderDiffValue(value: string, tone: 'before' | 'after') {
|
||||
const text = displayText(value);
|
||||
if (!text) {
|
||||
return <span className="lbd-changelog-empty">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className={`lbd-changelog-diff-text lbd-changelog-diff-text--${tone}`} title={text}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailChangeLogModal({
|
||||
open,
|
||||
rowLabel: _rowLabel,
|
||||
entries,
|
||||
onClose,
|
||||
}: DetailChangeLogModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-lg lbd-changelog-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="lbd-changelog-title"
|
||||
data-annotation-id="lbd-changelog-modal"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header lbd-changelog-header">
|
||||
<h2 id="lbd-changelog-title" className="vm-modal-title">变更日志</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-modal-body lbd-changelog-body">
|
||||
{entries.length === 0 ? (
|
||||
<p className="ldb-confirm-text">暂无变更记录</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="lbd-changelog-meta">共 {entries.length} 条变更记录</p>
|
||||
<div className="lbd-changelog-table-wrap">
|
||||
<table className="lbd-changelog-table">
|
||||
<colgroup>
|
||||
<col style={{ width: '152px' }} />
|
||||
<col style={{ width: '76px' }} />
|
||||
<col style={{ width: '108px' }} />
|
||||
<col style={{ width: '34%' }} />
|
||||
<col style={{ width: '34%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">操作时间</th>
|
||||
<th scope="col">操作人</th>
|
||||
<th scope="col">字段</th>
|
||||
<th scope="col">修改前</th>
|
||||
<th scope="col">修改后</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="lbd-changelog-cell-meta tabular-nums">{displayText(entry.operatedAt)}</td>
|
||||
<td className="lbd-changelog-cell-meta">{displayText(entry.operatorName)}</td>
|
||||
<td className="lbd-changelog-cell-field">
|
||||
<span className="lbd-changelog-field-tag" title={entry.fieldLabel}>
|
||||
{displayText(entry.fieldLabel) || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="lbd-changelog-cell-diff">{renderDiffValue(entry.before, 'before')}</td>
|
||||
<td className="lbd-changelog-cell-diff">{renderDiffValue(entry.after, 'after')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="vm-modal-footer lbd-changelog-footer">
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose} data-vm-icon="x">关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface DetailDeleteConfirmModalProps {
|
||||
open: boolean;
|
||||
rowLabel?: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DetailDeleteConfirmModal({
|
||||
open,
|
||||
rowLabel,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DetailDeleteConfirmModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onCancel}>
|
||||
<div
|
||||
className="vm-modal vm-modal-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="lbd-delete-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="lbd-delete-title" className="vm-modal-title">确认删除</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onCancel} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-modal-body">
|
||||
<p className="ldb-confirm-text">删除后该条明细将无法恢复,是否确认删除?</p>
|
||||
{rowLabel && <p className="ldb-confirm-meta">{rowLabel}</p>}
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-danger" onClick={onConfirm} data-vm-icon="trash">确认删除</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { detailRowYearMonth } from '../utils/detail';
|
||||
import { DETAIL_EDIT_FIELDS, draftFromRow, parseDraftToPatch } from '../utils/edit-fields';
|
||||
|
||||
interface DetailEditModalProps {
|
||||
open: boolean;
|
||||
row: LeaseBusinessDetailRow | null;
|
||||
onClose: () => void;
|
||||
onSave: (patch: Partial<LeaseBusinessDetailRow>) => void;
|
||||
}
|
||||
|
||||
export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalProps) {
|
||||
const [draft, setDraft] = useState<Record<string, string | number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && row) setDraft(draftFromRow(row));
|
||||
}, [open, row]);
|
||||
|
||||
if (!open || !row) return null;
|
||||
|
||||
const rowLabel = `${detailRowYearMonth(row)} · ${row.plateNo}`;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
onSave(parseDraftToPatch(draft));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-lg lbd-edit-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="lbd-edit-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="lbd-edit-title" className="vm-modal-title">编辑明细 · {rowLabel}</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<form id="lbd-edit-form" className="vm-modal-body lbd-edit-form" onSubmit={handleSubmit}>
|
||||
<p className="lbd-edit-hint">公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏、氢费等)保存后由系统自动重算。</p>
|
||||
<div className="lbd-edit-grid">
|
||||
{DETAIL_EDIT_FIELDS.map((field) => (
|
||||
<label key={field.key} className="lbd-edit-field">
|
||||
<span className="lbd-edit-label">{field.label}</span>
|
||||
<input
|
||||
className="vm-input"
|
||||
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
||||
step={field.key === 'avgDays' ? '0.01' : field.type === 'number' ? '0.01' : undefined}
|
||||
value={draft[field.key] ?? ''}
|
||||
onChange={(event) => {
|
||||
const value = field.type === 'number'
|
||||
? event.target.value
|
||||
: event.target.value;
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[field.key]: field.type === 'number' && value !== ''
|
||||
? Number(value)
|
||||
: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">取消</button>
|
||||
<button type="submit" className="vm-btn vm-btn-primary" form="lbd-edit-form" data-vm-icon="save">
|
||||
保存
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ interface DetailImportModalProps {
|
||||
onClose: () => void;
|
||||
onDownloadTemplate: () => void;
|
||||
onImport: (file: File) => void;
|
||||
failedCount?: number;
|
||||
onDownloadErrorLog?: () => void;
|
||||
}
|
||||
|
||||
export function DetailImportModal({
|
||||
@@ -13,6 +15,8 @@ export function DetailImportModal({
|
||||
onClose,
|
||||
onDownloadTemplate,
|
||||
onImport,
|
||||
failedCount = 0,
|
||||
onDownloadErrorLog,
|
||||
}: DetailImportModalProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
if (!open) return null;
|
||||
@@ -36,11 +40,9 @@ export function DetailImportModal({
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">1</span>
|
||||
<span>下载导入模板(仅含手工录入列,公式列由系统自动计算)</span>
|
||||
<span>下载导入模板</span>
|
||||
</div>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate}>
|
||||
<Download size={16} aria-hidden />
|
||||
下载明细导入模板
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">下载明细导入模板
|
||||
</button>
|
||||
</section>
|
||||
<section className="vm-import-step">
|
||||
@@ -55,7 +57,6 @@ export function DetailImportModal({
|
||||
>
|
||||
<Upload size={28} aria-hidden />
|
||||
<p>点击选择文件上传</p>
|
||||
<p className="hint">支持 .xls、.xlsx;必填:年份、月份、车牌号码。应收合计、月度收入、未收、车辆实际成本、总成本、盈亏由系统自动计算</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
@@ -69,9 +70,18 @@ export function DetailImportModal({
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
{failedCount > 0 && onDownloadErrorLog ? (
|
||||
<section className="vm-import-step lbd-import-error-step">
|
||||
<p className="lbd-import-error-summary">
|
||||
本次有 <strong>{failedCount}</strong> 条记录导入失败(如车牌号不存在),可下载错误日志核对。
|
||||
</p>
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={onDownloadErrorLog} data-vm-icon="download">下载错误日志
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>关闭</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,55 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||
import { OperationActions } from '../../../common/OperationActions';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import {
|
||||
DEFAULT_DETAIL_COLUMN_WIDTHS,
|
||||
DETAIL_SUMMARY_COLUMN_KEYS,
|
||||
DETAIL_TABLE_COLUMNS,
|
||||
DETAIL_TABLE_MIN_WIDTH,
|
||||
type DetailTableColumn,
|
||||
} from './tableColumns';
|
||||
import type { LeaseDetailKpiSummary } from '../types';
|
||||
import {
|
||||
detailRowYearMonth,
|
||||
displayDeposit,
|
||||
displayMoney,
|
||||
displayOutstanding,
|
||||
displayText,
|
||||
formatMoney,
|
||||
} from '../utils/detail';
|
||||
import { resolveColumnHeaderTip } from '../columnHeaderTips';
|
||||
import { HeaderWithTip } from './TableHeaderTip';
|
||||
import { getFieldCheck, normalizeDeductionForDisplay } from '../utils/field-checks';
|
||||
import { wrapFieldCell } from './FieldCheckIcon';
|
||||
import { getVehicleRef, resolvePaymentMethodLabel } from '../utils/system-ref';
|
||||
import { getHydrogenRefuelRecords } from '../utils/hydrogen-fee';
|
||||
import {
|
||||
DETAIL_PAYMENT_STATUS_CLASS,
|
||||
paymentStatusSortValue,
|
||||
resolveDetailPaymentStatus,
|
||||
} from '../utils/payment-status';
|
||||
import { canEditDetailRow } from '../utils/change-log';
|
||||
import {
|
||||
LedgerDetailPopover,
|
||||
LedgerDetailTable,
|
||||
} from '../../lease-business-ledger/components/LedgerDetailPopover';
|
||||
|
||||
interface DetailTableProps {
|
||||
records: LeaseBusinessDetailRow[];
|
||||
summary: LeaseDetailKpiSummary;
|
||||
loading?: boolean;
|
||||
isSupervisor: boolean;
|
||||
onEdit: (row: LeaseBusinessDetailRow) => void;
|
||||
onDelete: (row: LeaseBusinessDetailRow) => void;
|
||||
onChangeLog: (row: LeaseBusinessDetailRow) => void;
|
||||
onRowPatch?: (id: string, patch: Partial<LeaseBusinessDetailRow>) => void;
|
||||
}
|
||||
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
const STICKY_LEFT_KEYS = ['year', 'plateNo'] as const;
|
||||
const ACTION_COL_WIDTH = 168;
|
||||
|
||||
const STICKY_LEFT_KEYS = ['paymentStatus', 'yearMonth', 'plateNo'] as const;
|
||||
|
||||
function buildStickyLayout(widths: Record<string, number>) {
|
||||
let left = 0;
|
||||
@@ -59,36 +83,85 @@ function stickyStyle(
|
||||
};
|
||||
}
|
||||
|
||||
function HeaderSort({
|
||||
title,
|
||||
sortActive,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: {
|
||||
title: string;
|
||||
sortActive?: boolean;
|
||||
sortDir?: SortDir;
|
||||
onSort: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`ldb-th-sort ${sortActive ? 'is-active' : ''}`} onClick={onSort}>
|
||||
<span className="ldb-th-label">{title}</span>
|
||||
<span className="ldb-th-sort-icon" aria-hidden>
|
||||
{sortActive
|
||||
? (sortDir === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />)
|
||||
: <ArrowUpDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
function getSortValue(row: LeaseBusinessDetailRow, key: string): string | number {
|
||||
if (key === 'paymentStatus') {
|
||||
return paymentStatusSortValue(row.receivableTotal, row.receivedAmount);
|
||||
}
|
||||
if (key === 'yearMonth') return row.year * 100 + row.month;
|
||||
return row[key as keyof LeaseBusinessDetailRow] as string | number;
|
||||
}
|
||||
|
||||
function renderCell(row: LeaseBusinessDetailRow, col: DetailTableColumn) {
|
||||
function renderCellContent(
|
||||
row: LeaseBusinessDetailRow,
|
||||
col: DetailTableColumn,
|
||||
): React.ReactNode {
|
||||
switch (col.render) {
|
||||
case 'payment-status': {
|
||||
const label = resolveDetailPaymentStatus(row.receivableTotal, row.receivedAmount);
|
||||
const statusClass = DETAIL_PAYMENT_STATUS_CLASS[label];
|
||||
return <span className={`ldb-status-tag ${statusClass}`}>{label}</span>;
|
||||
}
|
||||
case 'year-month':
|
||||
return <span className="tabular-nums">{detailRowYearMonth(row)}</span>;
|
||||
case 'stack-dept':
|
||||
return (
|
||||
<div className="ldb-cell-stack">
|
||||
<span className="ldb-cell-primary">{displayText(row.businessDept)}</span>
|
||||
<span className="ldb-cell-secondary">{displayText(row.salesperson)}</span>
|
||||
</div>
|
||||
);
|
||||
case 'vehicle-type': {
|
||||
const vehicle = getVehicleRef(row.plateNo);
|
||||
const label = vehicle?.brandModel || row.vehicleType;
|
||||
return <span title={label}>{displayText(label)}</span>;
|
||||
}
|
||||
case 'payment-method':
|
||||
return displayText(resolvePaymentMethodLabel(row));
|
||||
case 'hydrogen-fee': {
|
||||
const amount = row.hydrogenFee;
|
||||
const text = displayMoney(amount, true);
|
||||
if (!text) return '';
|
||||
const records = getHydrogenRefuelRecords(row);
|
||||
const tableRows = records.map((item) => ({
|
||||
hydrogenTime: item.hydrogenTime,
|
||||
stationName: item.stationName,
|
||||
customerName: item.customerName,
|
||||
plateNo: item.plateNo,
|
||||
hydrogenKg: String(item.hydrogenKg),
|
||||
unitPrice: formatMoney(item.unitPrice),
|
||||
totalPrice: formatMoney(item.totalPrice),
|
||||
}));
|
||||
return (
|
||||
<LedgerDetailPopover
|
||||
label={text}
|
||||
title="车辆氢费明细"
|
||||
triggerClassName="tabular-nums"
|
||||
>
|
||||
<LedgerDetailTable
|
||||
columns={[
|
||||
{ key: 'hydrogenTime', title: '加氢时间' },
|
||||
{ key: 'stationName', title: '加氢站名称' },
|
||||
{ key: 'customerName', title: '客户名称' },
|
||||
{ key: 'plateNo', title: '车牌号码' },
|
||||
{ key: 'hydrogenKg', title: '加氢量' },
|
||||
{ key: 'unitPrice', title: '加氢单价(元/kg)' },
|
||||
{ key: 'totalPrice', title: '加氢总价(元)', align: 'right' },
|
||||
]}
|
||||
rows={tableRows}
|
||||
/>
|
||||
</LedgerDetailPopover>
|
||||
);
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (col.key === 'outstanding') {
|
||||
const { text, warn } = displayOutstanding(row.outstanding);
|
||||
if (!text) return '';
|
||||
return warn
|
||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||
: text;
|
||||
: <span className="tabular-nums">{text}</span>;
|
||||
}
|
||||
if (col.key === 'deposit') {
|
||||
return <span className="tabular-nums">{displayDeposit(row.deposit)}</span>;
|
||||
@@ -102,37 +175,63 @@ function renderCell(row: LeaseBusinessDetailRow, col: DetailTableColumn) {
|
||||
if (col.key === 'customerName') {
|
||||
return <span className="lbd-cell-customer" title={row.customerName}>{displayText(row.customerName)}</span>;
|
||||
}
|
||||
if (col.key === 'mileageDeduction' || col.key === 'otherDeduction') {
|
||||
const normalized = normalizeDeductionForDisplay(row[col.key]);
|
||||
if (normalized === 0) return '';
|
||||
return <span className="tabular-nums">{displayMoney(normalized, false)}</span>;
|
||||
}
|
||||
if (col.key === 'otherDeductionDetail' || col.key === 'remark') {
|
||||
const text = displayText(row[col.key]);
|
||||
return text ? <span className="lbd-cell-remark" title={text}>{text}</span> : '';
|
||||
}
|
||||
const raw = row[col.key as keyof LeaseBusinessDetailRow];
|
||||
if (col.money && typeof raw === 'number') return displayMoney(raw);
|
||||
if (col.key === 'year' || col.key === 'month' || col.key === 'avgDays') {
|
||||
return raw === 0 ? '' : String(raw);
|
||||
if (col.key === 'avgDays') {
|
||||
return raw === 0 ? '' : Number(raw).toFixed(2);
|
||||
}
|
||||
return displayText(raw);
|
||||
}
|
||||
|
||||
function renderCell(
|
||||
row: LeaseBusinessDetailRow,
|
||||
col: DetailTableColumn,
|
||||
onRowPatch?: (id: string, patch: Partial<LeaseBusinessDetailRow>) => void,
|
||||
) {
|
||||
const content = renderCellContent(row, col);
|
||||
if (!col.checkField) return content;
|
||||
|
||||
const check = getFieldCheck(row, col.checkField);
|
||||
return wrapFieldCell(
|
||||
content,
|
||||
check,
|
||||
check.replaceAction && onRowPatch
|
||||
? () => onRowPatch(row.id, check.replaceAction!)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSummaryCell(col: DetailTableColumn, summary: LeaseDetailKpiSummary) {
|
||||
if (col.key === 'year') {
|
||||
if (col.key === 'yearMonth') {
|
||||
return <span className="lbd-summary-label">汇总</span>;
|
||||
}
|
||||
if (!DETAIL_SUMMARY_COLUMN_KEYS.has(col.key)) return '';
|
||||
if (!col.summaryKey) return '';
|
||||
|
||||
const value = summary[col.key as keyof LeaseDetailKpiSummary];
|
||||
if (col.key === 'outstanding') {
|
||||
const value = summary[col.summaryKey];
|
||||
if (col.summaryKey === 'outstanding') {
|
||||
const { text, warn } = displayOutstanding(value);
|
||||
if (!text) return '';
|
||||
return warn
|
||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||
: text;
|
||||
: <span className="tabular-nums">{text}</span>;
|
||||
}
|
||||
if (col.key === 'profitLoss') {
|
||||
if (col.summaryKey === 'profitLoss') {
|
||||
const text = formatMoney(value);
|
||||
const cls = value >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg';
|
||||
return <span className={`tabular-nums ${cls}`}>{text}</span>;
|
||||
}
|
||||
if (col.summaryKey === 'mileageDeduction' || col.summaryKey === 'otherDeduction') {
|
||||
const normalized = normalizeDeductionForDisplay(value);
|
||||
return normalized === 0 ? '' : displayMoney(normalized, false);
|
||||
}
|
||||
return displayMoney(value, false);
|
||||
}
|
||||
|
||||
@@ -146,7 +245,16 @@ function TableSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailTable({ records, summary, loading = false }: DetailTableProps) {
|
||||
export function DetailTable({
|
||||
records,
|
||||
summary,
|
||||
loading = false,
|
||||
isSupervisor,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onChangeLog,
|
||||
onRowPatch,
|
||||
}: DetailTableProps) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_DETAIL_COLUMN_WIDTHS }));
|
||||
@@ -158,8 +266,8 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
|
||||
if (!sortKey) return records;
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...records].sort((a, b) => {
|
||||
const av = a[sortKey as keyof LeaseBusinessDetailRow];
|
||||
const bv = b[sortKey as keyof LeaseBusinessDetailRow];
|
||||
const av = getSortValue(a, sortKey);
|
||||
const bv = getSortValue(b, sortKey);
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||
return String(av ?? '').localeCompare(String(bv ?? ''), 'zh-CN') * dir;
|
||||
});
|
||||
@@ -206,7 +314,7 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
|
||||
|
||||
const tableMinWidth = Math.max(
|
||||
DETAIL_TABLE_MIN_WIDTH,
|
||||
DETAIL_TABLE_COLUMNS.reduce(
|
||||
ACTION_COL_WIDTH + DETAIL_TABLE_COLUMNS.reduce(
|
||||
(sum, col) => sum + (columnWidths[col.key] ?? DEFAULT_DETAIL_COLUMN_WIDTHS[col.key] ?? 100),
|
||||
0,
|
||||
),
|
||||
@@ -236,10 +344,13 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
|
||||
{DETAIL_TABLE_COLUMNS.map((col) => (
|
||||
<col key={col.key} style={{ width: columnWidths[col.key] ?? DEFAULT_DETAIL_COLUMN_WIDTHS[col.key] ?? 100 }} />
|
||||
))}
|
||||
<col style={{ width: ACTION_COL_WIDTH }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{DETAIL_TABLE_COLUMNS.map((col) => (
|
||||
{DETAIL_TABLE_COLUMNS.map((col) => {
|
||||
const headerTip = resolveColumnHeaderTip(col.annotationId, col.headerTip);
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
@@ -249,17 +360,17 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
|
||||
'ldb-col-resizable',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={stickyStyle(col, stickyLayout, true)}
|
||||
{...(col.annotationId ? { 'data-annotation-id': col.annotationId } : {})}
|
||||
>
|
||||
{col.dateSort ? (
|
||||
<HeaderSort
|
||||
<div className="ldb-th-inner">
|
||||
<HeaderWithTip
|
||||
title={col.title}
|
||||
tip={headerTip}
|
||||
sortable={Boolean(col.dateSort)}
|
||||
sortActive={sortKey === col.key}
|
||||
sortDir={sortDir}
|
||||
onSort={() => handleSort(col.key)}
|
||||
onSort={col.dateSort ? () => handleSort(col.key) : undefined}
|
||||
/>
|
||||
) : (
|
||||
col.title
|
||||
)}
|
||||
<span
|
||||
className="ldb-col-resize-handle"
|
||||
role="separator"
|
||||
@@ -267,13 +378,19 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
|
||||
aria-label={`调整${col.title}列宽`}
|
||||
onMouseDown={(event) => handleResizeStart(col.key, event)}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<th className="sticky-col-right ldb-col-actions" data-annotation-id="lbd-col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRecords.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{sortedRecords.map((row) => {
|
||||
const canEdit = canEditDetailRow(row, isSupervisor);
|
||||
const archived = row.archiveStatus === 'archived';
|
||||
return (
|
||||
<tr key={row.id} className={archived ? 'lbd-row-archived' : undefined}>
|
||||
{DETAIL_TABLE_COLUMNS.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
@@ -284,11 +401,32 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
|
||||
].filter(Boolean).join(' ')}
|
||||
style={stickyStyle(col, stickyLayout, false)}
|
||||
>
|
||||
{renderCell(row, col)}
|
||||
{renderCell(row, col, onRowPatch)}
|
||||
</td>
|
||||
))}
|
||||
<td className="sticky-col-right ldb-col-actions">
|
||||
<OperationActions
|
||||
edit={canEdit ? { onClick: () => onEdit(row) } : undefined}
|
||||
more={[
|
||||
...(canEdit
|
||||
? [{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'changeLog',
|
||||
label: '变更日志',
|
||||
onClick: () => onChangeLog(row),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="lbd-summary-row">
|
||||
@@ -306,6 +444,7 @@ export function DetailTable({ records, summary, loading = false }: DetailTablePr
|
||||
{renderSummaryCell(col, summary)}
|
||||
</td>
|
||||
))}
|
||||
<td className="sticky-col-right ldb-col-actions" />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, AlertTriangle } from 'lucide-react';
|
||||
import type { FieldCheckInfo } from '../utils/field-checks';
|
||||
|
||||
interface FieldCheckIconProps {
|
||||
info: FieldCheckInfo;
|
||||
onReplace?: () => void;
|
||||
}
|
||||
|
||||
function PopoverPanel({
|
||||
info,
|
||||
onReplace,
|
||||
onClose,
|
||||
}: {
|
||||
info: FieldCheckInfo;
|
||||
onReplace?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="lbd-field-check-pop" role="dialog">
|
||||
{info.title ? <div className="lbd-field-check-pop__title">{info.title}</div> : null}
|
||||
{info.details?.map((line) => (
|
||||
<div key={line} className="lbd-field-check-pop__line">{line}</div>
|
||||
))}
|
||||
{info.replaceAction && onReplace ? (
|
||||
<div className="lbd-field-check-pop__actions">
|
||||
<button type="button" className="vm-btn vm-btn-secondary vm-btn-sm" data-vm-icon="rotate-ccw" onClick={() => { onReplace(); onClose(); }}>
|
||||
一键替换
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldCheckIcon({ info, onReplace }: FieldCheckIconProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (info.status === 'none') return null;
|
||||
|
||||
const isOk = info.status === 'ok';
|
||||
const icon = isOk
|
||||
? <Check size={12} aria-hidden />
|
||||
: <AlertTriangle size={12} aria-hidden />;
|
||||
|
||||
return (
|
||||
<span className="lbd-field-check-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`lbd-field-check-icon ${isOk ? 'is-ok' : 'is-warn'}`}
|
||||
aria-label={isOk ? '校验通过' : '校验警告'}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||
onBlur={() => window.setTimeout(() => setOpen(false), 150)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{open && !isOk ? (
|
||||
<PopoverPanel info={info} onReplace={onReplace} onClose={() => setOpen(false)} />
|
||||
) : null}
|
||||
{open && isOk && info.details?.length ? (
|
||||
<div className="lbd-field-check-pop lbd-field-check-pop--ok" role="tooltip">
|
||||
{info.details.map((line) => (
|
||||
<div key={line} className="lbd-field-check-pop__line">{line}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapFieldCell(
|
||||
main: React.ReactNode,
|
||||
info: FieldCheckInfo,
|
||||
onReplace?: () => void,
|
||||
): React.ReactNode {
|
||||
if (info.status === 'none') return main;
|
||||
return (
|
||||
<span className="lbd-field-check-cell">
|
||||
{main}
|
||||
<FieldCheckIcon info={info} onReplace={onReplace} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import type { LeaseBusinessDetailRow, LeaseDetailFilters } from '../types';
|
||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
|
||||
import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail';
|
||||
import { DETAIL_PAYMENT_STATUS_OPTIONS } from '../utils/payment-status';
|
||||
|
||||
const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName'] as const;
|
||||
const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName', 'paymentStatus'] as const;
|
||||
const EXTRA_KEYS = ['plateNos'] as const;
|
||||
|
||||
type FieldKey = (typeof PRIMARY_KEYS)[number] | (typeof EXTRA_KEYS)[number];
|
||||
@@ -98,6 +99,19 @@ export function FilterPanel({
|
||||
/>
|
||||
),
|
||||
},
|
||||
paymentStatus: {
|
||||
key: 'paymentStatus',
|
||||
label: '状态',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.paymentStatus}
|
||||
options={DETAIL_PAYMENT_STATUS_OPTIONS}
|
||||
placeholder="全部状态"
|
||||
ariaLabel="状态"
|
||||
onChange={(paymentStatus) => onChange({ paymentStatus })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
plateNos: {
|
||||
key: 'plateNos',
|
||||
label: '车牌号码',
|
||||
@@ -158,21 +172,19 @@ export function FilterPanel({
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="lbd-filter-expand-panel"
|
||||
>
|
||||
|
||||
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
{extraActiveCount > 0 && !expanded ? (
|
||||
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
|
||||
{extraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp } from 'lucide-react';
|
||||
|
||||
const HEADER_TIP_MAX_WIDTH = 300;
|
||||
const HEADER_TIP_VIEWPORT_MARGIN = 12;
|
||||
|
||||
export function TableHeaderTip({ tip, title }: { tip: string; title: string }) {
|
||||
const anchorRef = useRef<HTMLSpanElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const maxWidth = Math.min(HEADER_TIP_MAX_WIDTH, window.innerWidth - HEADER_TIP_VIEWPORT_MARGIN * 2);
|
||||
let left = rect.left;
|
||||
left = Math.max(
|
||||
HEADER_TIP_VIEWPORT_MARGIN,
|
||||
Math.min(left, window.innerWidth - HEADER_TIP_VIEWPORT_MARGIN - maxWidth),
|
||||
);
|
||||
setStyle({ top: rect.bottom + 6, left, maxWidth });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return undefined;
|
||||
reposition();
|
||||
window.addEventListener('scroll', reposition, true);
|
||||
window.addEventListener('resize', reposition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [visible, reposition]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={anchorRef}
|
||||
className="ldb-th-tip"
|
||||
tabIndex={0}
|
||||
aria-label={`${title}说明`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseEnter={() => { reposition(); setVisible(true); }}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onFocus={() => { reposition(); setVisible(true); }}
|
||||
onBlur={() => setVisible(false)}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden />
|
||||
</span>
|
||||
{visible && createPortal(
|
||||
<div className="ldb-kpi-tooltip ldb-kpi-tooltip--portal" style={style} role="tooltip">
|
||||
{tip}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderWithTip({
|
||||
title,
|
||||
tip,
|
||||
sortable,
|
||||
sortActive,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: {
|
||||
title: string;
|
||||
tip?: string;
|
||||
sortable?: boolean;
|
||||
sortActive?: boolean;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: () => void;
|
||||
}) {
|
||||
const label = (
|
||||
<span className={`ldb-th-label ${tip ? 'ldb-th-with-tip' : ''}`}>
|
||||
{title}
|
||||
{tip ? <TableHeaderTip tip={tip} title={title} /> : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!sortable || !onSort) return label;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ldb-th-sort ${sortActive ? 'is-active' : ''}`}
|
||||
onClick={onSort}
|
||||
>
|
||||
{label}
|
||||
<span className="ldb-th-sort-icon" aria-hidden>
|
||||
{sortActive
|
||||
? (sortDir === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />)
|
||||
: <ArrowUpDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,325 @@
|
||||
import type { LeaseBusinessDetailRow, LeaseDetailKpiSummary } from '../types';
|
||||
|
||||
export type DetailColumnRender =
|
||||
| 'payment-status'
|
||||
| 'year-month'
|
||||
| 'stack-dept'
|
||||
| 'vehicle-type'
|
||||
| 'payment-method'
|
||||
| 'hydrogen-fee';
|
||||
|
||||
export interface DetailTableColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
annotationId?: string;
|
||||
headerTip?: string;
|
||||
money?: boolean;
|
||||
className?: string;
|
||||
sticky?: 'left' | 'right';
|
||||
defaultWidth?: number;
|
||||
/** 日期类列,支持表头正序/倒序 */
|
||||
dateSort?: boolean;
|
||||
/** Excel 公式列,系统计算、导入模板不含 */
|
||||
calculated?: boolean;
|
||||
render?: DetailColumnRender;
|
||||
/** 表尾汇总对应字段 */
|
||||
summaryKey?: keyof LeaseDetailKpiSummary;
|
||||
/** 单元格校验字段 */
|
||||
checkField?:
|
||||
| 'customerName'
|
||||
| 'pickupDate'
|
||||
| 'returnDate'
|
||||
| 'deposit'
|
||||
| 'contractRent'
|
||||
| 'receivableRent'
|
||||
| 'mileageDeduction'
|
||||
| 'otherDeduction'
|
||||
| 'assetOwner';
|
||||
}
|
||||
|
||||
/** 列表表头列(严格对齐业务明细 Excel 样表) */
|
||||
/** 页面展示列(年月、部门+业务员已合并) */
|
||||
export const DETAIL_TABLE_COLUMNS: DetailTableColumn[] = [
|
||||
{ key: 'year', title: '年份', defaultWidth: 64, sticky: 'left', className: 'tabular-nums' },
|
||||
{ key: 'month', title: '月份', defaultWidth: 56, className: 'tabular-nums' },
|
||||
{ key: 'businessDept', title: '业务部门', defaultWidth: 96 },
|
||||
{ key: 'salesperson', title: '业务员', defaultWidth: 88 },
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
title: '状态',
|
||||
render: 'payment-status',
|
||||
sticky: 'left',
|
||||
defaultWidth: 96,
|
||||
annotationId: 'lbd-col-status',
|
||||
},
|
||||
{
|
||||
key: 'yearMonth',
|
||||
title: '月份',
|
||||
render: 'year-month',
|
||||
sticky: 'left',
|
||||
className: 'tabular-nums',
|
||||
defaultWidth: 88,
|
||||
annotationId: 'lbd-col-year-month',
|
||||
dateSort: true,
|
||||
},
|
||||
{
|
||||
key: 'businessAssignment',
|
||||
title: '业务部门',
|
||||
render: 'stack-dept',
|
||||
defaultWidth: 120,
|
||||
annotationId: 'lbd-col-business-dept',
|
||||
},
|
||||
{ key: 'paymentDate', title: '付款日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'plateNo', title: '车牌号码', defaultWidth: 110, sticky: 'left', className: 'lbd-col-plate' },
|
||||
{ key: 'vehicleType', title: '车型', defaultWidth: 72 },
|
||||
{ key: 'customerName', title: '客户名称', className: 'ldb-col-name', defaultWidth: 200 },
|
||||
{
|
||||
key: 'plateNo',
|
||||
title: '车牌号码',
|
||||
defaultWidth: 110,
|
||||
sticky: 'left',
|
||||
className: 'lbd-col-plate',
|
||||
annotationId: 'lbd-col-plate',
|
||||
},
|
||||
{
|
||||
key: 'vehicleType',
|
||||
title: '车型',
|
||||
render: 'vehicle-type',
|
||||
defaultWidth: 120,
|
||||
annotationId: 'lbd-col-vehicle-type',
|
||||
},
|
||||
{
|
||||
key: 'customerName',
|
||||
title: '客户名称',
|
||||
className: 'ldb-col-name',
|
||||
defaultWidth: 200,
|
||||
annotationId: 'lbd-col-customer',
|
||||
checkField: 'customerName',
|
||||
},
|
||||
{ key: 'valueAddedService', title: '增值服务', defaultWidth: 96 },
|
||||
{ key: 'exemptionPolicy', title: '享免政策', defaultWidth: 120 },
|
||||
{ key: 'pickupDate', title: '提车日期', defaultWidth: 108, dateSort: true },
|
||||
{
|
||||
key: 'pickupDate',
|
||||
title: '提车日期',
|
||||
defaultWidth: 108,
|
||||
dateSort: true,
|
||||
annotationId: 'lbd-col-pickup-date',
|
||||
checkField: 'pickupDate',
|
||||
},
|
||||
{ key: 'contractStartDate', title: '合同生效日期', defaultWidth: 118, dateSort: true },
|
||||
{ key: 'contractEndDate', title: '合同到期日期', defaultWidth: 118, dateSort: true },
|
||||
{ key: 'periodStartDate', title: '起始日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'returnDate', title: '退车日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'avgDays', title: '平均天数', defaultWidth: 88, className: 'tabular-nums' },
|
||||
{ key: 'deposit', title: '押金', money: true, defaultWidth: 100 },
|
||||
{ key: 'contractRent', title: '合同标的租金', money: true, defaultWidth: 110 },
|
||||
{ key: 'receivableTotal', title: '应收合计', money: true, defaultWidth: 100, calculated: true },
|
||||
{ key: 'receivableRent', title: '应收租金', money: true, defaultWidth: 100 },
|
||||
{ key: 'monthlyIncome', title: '月度收入', money: true, defaultWidth: 100, calculated: true },
|
||||
{ key: 'monthlyRent', title: '月度租金', money: true, defaultWidth: 100 },
|
||||
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, defaultWidth: 110 },
|
||||
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, defaultWidth: 100 },
|
||||
{ key: 'opsFeeIncome', title: '运维费', money: true, defaultWidth: 90 },
|
||||
{ key: 'otherIncome', title: '其他收入', money: true, defaultWidth: 100 },
|
||||
{ key: 'mileageDeduction', title: '里程减免金额', money: true, defaultWidth: 110 },
|
||||
{ key: 'otherDeduction', title: '其他减免金额', money: true, defaultWidth: 110 },
|
||||
{ key: 'receivedAmount', title: '实收金额', money: true, defaultWidth: 100 },
|
||||
{ key: 'outstanding', title: '未收', money: true, defaultWidth: 90, calculated: true },
|
||||
{
|
||||
key: 'returnDate',
|
||||
title: '退车日期',
|
||||
defaultWidth: 108,
|
||||
dateSort: true,
|
||||
annotationId: 'lbd-col-return-date',
|
||||
checkField: 'returnDate',
|
||||
},
|
||||
{
|
||||
key: 'avgDays',
|
||||
title: '平均天数',
|
||||
defaultWidth: 88,
|
||||
className: 'tabular-nums',
|
||||
annotationId: 'lbd-col-avg-days',
|
||||
},
|
||||
{
|
||||
key: 'deposit',
|
||||
title: '押金',
|
||||
money: true,
|
||||
defaultWidth: 100,
|
||||
summaryKey: 'deposit',
|
||||
annotationId: 'lbd-col-deposit',
|
||||
checkField: 'deposit',
|
||||
},
|
||||
{
|
||||
key: 'contractRent',
|
||||
title: '合同标的租金',
|
||||
money: true,
|
||||
defaultWidth: 110,
|
||||
summaryKey: 'contractRent',
|
||||
annotationId: 'lbd-col-contract-rent',
|
||||
checkField: 'contractRent',
|
||||
},
|
||||
{
|
||||
key: 'receivableTotal',
|
||||
title: '应收合计',
|
||||
money: true,
|
||||
defaultWidth: 100,
|
||||
calculated: true,
|
||||
summaryKey: 'receivableTotal',
|
||||
annotationId: 'lbd-col-receivable-total',
|
||||
},
|
||||
{
|
||||
key: 'receivableRent',
|
||||
title: '应收租金',
|
||||
money: true,
|
||||
defaultWidth: 100,
|
||||
summaryKey: 'receivableRent',
|
||||
annotationId: 'lbd-col-receivable-rent',
|
||||
checkField: 'receivableRent',
|
||||
},
|
||||
{
|
||||
key: 'monthlyIncome',
|
||||
title: '月度收入',
|
||||
money: true,
|
||||
defaultWidth: 100,
|
||||
calculated: true,
|
||||
summaryKey: 'monthlyIncome',
|
||||
annotationId: 'lbd-col-monthly-income',
|
||||
},
|
||||
{
|
||||
key: 'monthlyRent',
|
||||
title: '月度租金',
|
||||
money: true,
|
||||
defaultWidth: 100,
|
||||
summaryKey: 'monthlyRent',
|
||||
annotationId: 'lbd-col-monthly-rent',
|
||||
},
|
||||
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, defaultWidth: 110, summaryKey: 'maintenanceIncome' },
|
||||
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, defaultWidth: 100, summaryKey: 'insuranceSurcharge' },
|
||||
{ key: 'opsFeeIncome', title: '运维费', money: true, defaultWidth: 90, summaryKey: 'opsFeeIncome' },
|
||||
{ key: 'otherIncome', title: '其他收入', money: true, defaultWidth: 100, summaryKey: 'otherIncome' },
|
||||
{
|
||||
key: 'mileageDeduction',
|
||||
title: '里程减免金额',
|
||||
money: true,
|
||||
defaultWidth: 110,
|
||||
summaryKey: 'mileageDeduction',
|
||||
annotationId: 'lbd-col-mileage-deduction',
|
||||
checkField: 'mileageDeduction',
|
||||
},
|
||||
{
|
||||
key: 'otherDeduction',
|
||||
title: '其他减免金额',
|
||||
money: true,
|
||||
defaultWidth: 110,
|
||||
summaryKey: 'otherDeduction',
|
||||
annotationId: 'lbd-col-other-deduction',
|
||||
checkField: 'otherDeduction',
|
||||
},
|
||||
{ key: 'receivedAmount', title: '实收金额', money: true, defaultWidth: 100, summaryKey: 'receivedAmount' },
|
||||
{
|
||||
key: 'outstanding',
|
||||
title: '未收',
|
||||
money: true,
|
||||
defaultWidth: 90,
|
||||
calculated: true,
|
||||
summaryKey: 'outstanding',
|
||||
annotationId: 'lbd-col-outstanding',
|
||||
},
|
||||
{ key: 'invoiceDate', title: '开票日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'actualPaymentDate', title: '实际付款日期', defaultWidth: 118, dateSort: true },
|
||||
{ key: 'paymentMethod', title: '付款方式', defaultWidth: 100 },
|
||||
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, defaultWidth: 110 },
|
||||
{ key: 'vehicleActualCost', title: '车辆实际成本', money: true, defaultWidth: 110, calculated: true },
|
||||
{ key: 'insuranceFee', title: '保险费', money: true, defaultWidth: 90 },
|
||||
{ key: 'hydrogenFee', title: '氢费', money: true, defaultWidth: 90 },
|
||||
{ key: 'opsFeeCost', title: '运维费', money: true, defaultWidth: 90 },
|
||||
{ key: 'brokerageFee', title: '居间费', money: true, defaultWidth: 90 },
|
||||
{ key: 'otherCost', title: '其他', money: true, defaultWidth: 90 },
|
||||
{ key: 'totalCost', title: '总成本', money: true, defaultWidth: 100, calculated: true },
|
||||
{ key: 'profitLoss', title: '盈亏', money: true, defaultWidth: 90, calculated: true },
|
||||
{
|
||||
key: 'paymentMethod',
|
||||
title: '付款方式',
|
||||
render: 'payment-method',
|
||||
defaultWidth: 108,
|
||||
annotationId: 'lbd-col-payment-method',
|
||||
},
|
||||
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, defaultWidth: 110, summaryKey: 'vehicleStandardCost' },
|
||||
{
|
||||
key: 'vehicleActualCost',
|
||||
title: '车辆实际成本',
|
||||
money: true,
|
||||
defaultWidth: 110,
|
||||
calculated: true,
|
||||
summaryKey: 'vehicleActualCost',
|
||||
annotationId: 'lbd-col-vehicle-actual-cost',
|
||||
},
|
||||
{ key: 'insuranceFee', title: '保险费', money: true, defaultWidth: 90, summaryKey: 'insuranceFee' },
|
||||
{
|
||||
key: 'hydrogenFee',
|
||||
title: '氢费',
|
||||
money: true,
|
||||
defaultWidth: 90,
|
||||
summaryKey: 'hydrogenFee',
|
||||
render: 'hydrogen-fee',
|
||||
annotationId: 'lbd-col-hydrogen-fee',
|
||||
},
|
||||
{ key: 'opsFeeCost', title: '运维费', money: true, defaultWidth: 90, summaryKey: 'opsFeeCost' },
|
||||
{ key: 'brokerageFee', title: '居间费', money: true, defaultWidth: 90, summaryKey: 'brokerageFee' },
|
||||
{ key: 'otherCost', title: '其他', money: true, defaultWidth: 90, summaryKey: 'otherCost' },
|
||||
{
|
||||
key: 'totalCost',
|
||||
title: '总成本',
|
||||
money: true,
|
||||
defaultWidth: 100,
|
||||
calculated: true,
|
||||
summaryKey: 'totalCost',
|
||||
annotationId: 'lbd-col-total-cost',
|
||||
},
|
||||
{
|
||||
key: 'profitLoss',
|
||||
title: '盈亏',
|
||||
money: true,
|
||||
defaultWidth: 90,
|
||||
calculated: true,
|
||||
summaryKey: 'profitLoss',
|
||||
annotationId: 'lbd-col-profit-loss',
|
||||
},
|
||||
{ key: 'gpsMileage', title: 'GPS里程', defaultWidth: 96, className: 'tabular-nums' },
|
||||
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', defaultWidth: 160 },
|
||||
{ key: 'assetOwner', title: '资产归属', defaultWidth: 140 },
|
||||
{ key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', defaultWidth: 180 },
|
||||
{
|
||||
key: 'assetOwner',
|
||||
title: '资产归属',
|
||||
defaultWidth: 140,
|
||||
annotationId: 'lbd-col-asset-owner',
|
||||
checkField: 'assetOwner',
|
||||
},
|
||||
{
|
||||
key: 'signingCompany',
|
||||
title: '签约公司',
|
||||
className: 'ldb-col-name',
|
||||
defaultWidth: 180,
|
||||
annotationId: 'lbd-col-signing-company',
|
||||
},
|
||||
{ key: 'remark', title: '备注', className: 'ldb-col-remark', defaultWidth: 140 },
|
||||
];
|
||||
|
||||
/** 列表导出表头(含公式列) */
|
||||
export const DETAIL_EXPORT_HEADERS = DETAIL_TABLE_COLUMNS.map((col) => col.title);
|
||||
/** Excel 导入/导出列规格(47 列,含独立年份/月份/业务员) */
|
||||
export const DETAIL_EXPORT_SPECS: { key: keyof LeaseBusinessDetailRow; title: string }[] = [
|
||||
{ key: 'year', title: '年份' },
|
||||
{ key: 'month', title: '月份' },
|
||||
{ key: 'businessDept', title: '业务部门' },
|
||||
{ key: 'salesperson', title: '业务员' },
|
||||
{ key: 'paymentDate', title: '付款日期' },
|
||||
{ key: 'plateNo', title: '车牌号码' },
|
||||
{ key: 'vehicleType', title: '车型' },
|
||||
{ key: 'customerName', title: '客户名称' },
|
||||
{ key: 'valueAddedService', title: '增值服务' },
|
||||
{ key: 'exemptionPolicy', title: '享免政策' },
|
||||
{ key: 'pickupDate', title: '提车日期' },
|
||||
{ key: 'contractStartDate', title: '合同生效日期' },
|
||||
{ key: 'contractEndDate', title: '合同到期日期' },
|
||||
{ key: 'periodStartDate', title: '起始日期' },
|
||||
{ key: 'returnDate', title: '退车日期' },
|
||||
{ key: 'avgDays', title: '平均天数' },
|
||||
{ key: 'deposit', title: '押金' },
|
||||
{ key: 'contractRent', title: '合同标的租金' },
|
||||
{ key: 'receivableTotal', title: '应收合计' },
|
||||
{ key: 'receivableRent', title: '应收租金' },
|
||||
{ key: 'monthlyIncome', title: '月度收入' },
|
||||
{ key: 'monthlyRent', title: '月度租金' },
|
||||
{ key: 'maintenanceIncome', title: '维保包干收入' },
|
||||
{ key: 'insuranceSurcharge', title: '保险上浮费' },
|
||||
{ key: 'opsFeeIncome', title: '运维费' },
|
||||
{ key: 'otherIncome', title: '其他收入' },
|
||||
{ key: 'mileageDeduction', title: '里程减免金额' },
|
||||
{ key: 'otherDeduction', title: '其他减免金额' },
|
||||
{ key: 'receivedAmount', title: '实收金额' },
|
||||
{ key: 'outstanding', title: '未收' },
|
||||
{ key: 'invoiceDate', title: '开票日期' },
|
||||
{ key: 'actualPaymentDate', title: '实际付款日期' },
|
||||
{ key: 'paymentMethod', title: '付款方式' },
|
||||
{ key: 'vehicleStandardCost', title: '车辆标准成本' },
|
||||
{ key: 'vehicleActualCost', title: '车辆实际成本' },
|
||||
{ key: 'insuranceFee', title: '保险费' },
|
||||
{ key: 'hydrogenFee', title: '氢费' },
|
||||
{ key: 'opsFeeCost', title: '运维费' },
|
||||
{ key: 'brokerageFee', title: '居间费' },
|
||||
{ key: 'otherCost', title: '其他' },
|
||||
{ key: 'totalCost', title: '总成本' },
|
||||
{ key: 'profitLoss', title: '盈亏' },
|
||||
{ key: 'gpsMileage', title: 'GPS里程' },
|
||||
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容' },
|
||||
{ key: 'assetOwner', title: '资产归属' },
|
||||
{ key: 'signingCompany', title: '签约公司' },
|
||||
{ key: 'remark', title: '备注' },
|
||||
];
|
||||
|
||||
/** 导入模板表头(仅手工录入列,不含公式列) */
|
||||
export const DETAIL_IMPORT_TEMPLATE_HEADERS = DETAIL_TABLE_COLUMNS
|
||||
.filter((col) => !col.calculated)
|
||||
.map((col) => col.title);
|
||||
|
||||
/** @deprecated 使用 DETAIL_EXPORT_HEADERS / DETAIL_IMPORT_TEMPLATE_HEADERS */
|
||||
export const DETAIL_IMPORT_HEADERS = DETAIL_EXPORT_HEADERS;
|
||||
export const DETAIL_EXPORT_HEADERS = DETAIL_EXPORT_SPECS.map((s) => s.title);
|
||||
|
||||
export const DEFAULT_DETAIL_COLUMN_WIDTHS: Record<string, number> = Object.fromEntries(
|
||||
DETAIL_TABLE_COLUMNS.map((col) => [col.key, col.defaultWidth ?? 100]),
|
||||
@@ -82,29 +330,6 @@ export const DETAIL_TABLE_MIN_WIDTH = DETAIL_TABLE_COLUMNS.reduce(
|
||||
0,
|
||||
);
|
||||
|
||||
/** 表尾汇总行:对齐 Excel SUBTOTAL 的 23 个金额列 */
|
||||
export const DETAIL_SUMMARY_COLUMN_KEYS = new Set([
|
||||
'deposit',
|
||||
'contractRent',
|
||||
'receivableTotal',
|
||||
'receivableRent',
|
||||
'monthlyIncome',
|
||||
'monthlyRent',
|
||||
'maintenanceIncome',
|
||||
'insuranceSurcharge',
|
||||
'opsFeeIncome',
|
||||
'otherIncome',
|
||||
'mileageDeduction',
|
||||
'otherDeduction',
|
||||
'receivedAmount',
|
||||
'outstanding',
|
||||
'vehicleStandardCost',
|
||||
'vehicleActualCost',
|
||||
'insuranceFee',
|
||||
'hydrogenFee',
|
||||
'opsFeeCost',
|
||||
'brokerageFee',
|
||||
'otherCost',
|
||||
'totalCost',
|
||||
'profitLoss',
|
||||
]);
|
||||
export const DETAIL_SUMMARY_COLUMN_KEYS = new Set(
|
||||
DETAIL_TABLE_COLUMNS.filter((c) => c.summaryKey).map((c) => c.summaryKey as string),
|
||||
);
|
||||
|
||||
178
src/prototypes/lease-business-detail/data/change-log-seed.json
Normal file
178
src/prototypes/lease-business-detail/data/change-log-seed.json
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"lbd-1": [
|
||||
{
|
||||
"id": "lbd-clog-seed-1-1",
|
||||
"operatedAt": "2026-01-05 09:12:08",
|
||||
"operatorId": "staff-001",
|
||||
"operatorName": "刘念念",
|
||||
"field": "_import",
|
||||
"fieldLabel": "批量导入",
|
||||
"before": "—",
|
||||
"after": "新增明细"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-1-2",
|
||||
"operatedAt": "2026-01-06 14:25:33",
|
||||
"operatorId": "staff-001",
|
||||
"operatorName": "刘念念",
|
||||
"field": "receivedAmount",
|
||||
"fieldLabel": "实收金额",
|
||||
"before": "0.00",
|
||||
"after": "4,500.00"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-1-3",
|
||||
"operatedAt": "2026-01-07 10:08:19",
|
||||
"operatorId": "staff-001",
|
||||
"operatorName": "刘念念",
|
||||
"field": "customerName",
|
||||
"fieldLabel": "客户名称",
|
||||
"before": "安徽驰远供应链",
|
||||
"after": "安徽驰远供应链管理有限公司"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-1-4",
|
||||
"operatedAt": "2026-01-08 16:42:05",
|
||||
"operatorId": "staff-001",
|
||||
"operatorName": "刘念念",
|
||||
"field": "actualPaymentDate",
|
||||
"fieldLabel": "实际付款日期",
|
||||
"before": "—",
|
||||
"after": "2026-01-19"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-1-5",
|
||||
"operatedAt": "2026-01-10 11:30:00",
|
||||
"operatorId": "staff-001",
|
||||
"operatorName": "刘念念",
|
||||
"field": "remark",
|
||||
"fieldLabel": "备注",
|
||||
"before": "—",
|
||||
"after": "1月租金已收齐"
|
||||
}
|
||||
],
|
||||
"lbd-33": [
|
||||
{
|
||||
"id": "lbd-clog-seed-33-1",
|
||||
"operatedAt": "2026-01-12 08:55:12",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "_import",
|
||||
"fieldLabel": "批量导入",
|
||||
"before": "—",
|
||||
"after": "新增明细"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-2",
|
||||
"operatedAt": "2026-01-13 15:20:44",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "contractRent",
|
||||
"fieldLabel": "合同标的租金",
|
||||
"before": "3,800.00",
|
||||
"after": "4,000.00"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-3",
|
||||
"operatedAt": "2026-01-14 09:18:27",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "receivedAmount",
|
||||
"fieldLabel": "实收金额",
|
||||
"before": "0.00",
|
||||
"after": "4,000.00"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-4",
|
||||
"operatedAt": "2026-01-15 17:05:51",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "valueAddedService",
|
||||
"fieldLabel": "增值服务",
|
||||
"before": "—",
|
||||
"after": "含1000元氢费"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-5",
|
||||
"operatedAt": "2026-01-18 10:22:36",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "_reconcile",
|
||||
"fieldLabel": "完成对账",
|
||||
"before": "未对账",
|
||||
"after": "已对账(2026-01-18)"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-6",
|
||||
"operatedAt": "2026-01-18 10:22:36",
|
||||
"operatorId": "staff-002",
|
||||
"operatorName": "尚建华",
|
||||
"field": "archiveStatus",
|
||||
"fieldLabel": "对账状态",
|
||||
"before": "未对账",
|
||||
"after": "已对账"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-33-7",
|
||||
"operatedAt": "2026-01-20 14:11:08",
|
||||
"operatorId": "supervisor-001",
|
||||
"operatorName": "王主管",
|
||||
"field": "remark",
|
||||
"fieldLabel": "备注",
|
||||
"before": "—",
|
||||
"after": "主管复核:实收与合同一致,归档确认"
|
||||
}
|
||||
],
|
||||
"lbd-280": [
|
||||
{
|
||||
"id": "lbd-clog-seed-280-1",
|
||||
"operatedAt": "2026-04-10 09:40:15",
|
||||
"operatorId": "staff-003",
|
||||
"operatorName": "赵连飞",
|
||||
"field": "_import",
|
||||
"fieldLabel": "批量导入",
|
||||
"before": "—",
|
||||
"after": "新增明细"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-280-2",
|
||||
"operatedAt": "2026-04-11 11:05:22",
|
||||
"operatorId": "staff-003",
|
||||
"operatorName": "赵连飞",
|
||||
"field": "vehicleStandardCost",
|
||||
"fieldLabel": "车辆标准成本",
|
||||
"before": "2,800.00",
|
||||
"after": "3,000.00"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-280-3",
|
||||
"operatedAt": "2026-04-12 16:33:49",
|
||||
"operatorId": "staff-003",
|
||||
"operatorName": "赵连飞",
|
||||
"field": "totalCost",
|
||||
"fieldLabel": "总成本",
|
||||
"before": "10,200.00",
|
||||
"after": "11,190.22"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-280-4",
|
||||
"operatedAt": "2026-04-14 08:50:03",
|
||||
"operatorId": "staff-003",
|
||||
"operatorName": "赵连飞",
|
||||
"field": "hydrogenFee",
|
||||
"fieldLabel": "氢费",
|
||||
"before": "0.00",
|
||||
"after": "8,190.22"
|
||||
},
|
||||
{
|
||||
"id": "lbd-clog-seed-280-5",
|
||||
"operatedAt": "2026-04-15 13:28:17",
|
||||
"operatorId": "supervisor-001",
|
||||
"operatorName": "王主管",
|
||||
"field": "profitLoss",
|
||||
"fieldLabel": "盈亏",
|
||||
"before": "-2,400.22",
|
||||
"after": "-3,390.22"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
[
|
||||
{
|
||||
"hydrogenTime": "2026-01-08 09:15:00",
|
||||
"stationName": "上海嘉定氢站",
|
||||
"customerName": "安徽驰远供应链管理有限公司",
|
||||
"plateNo": "沪A32896F",
|
||||
"hydrogenKg": 42.5,
|
||||
"unitPrice": 30.13,
|
||||
"totalPrice": 1280.5,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-01-22 11:40:00",
|
||||
"stationName": "上海嘉定氢站",
|
||||
"customerName": "安徽驰远供应链管理有限公司",
|
||||
"plateNo": "沪A32896F",
|
||||
"hydrogenKg": 20,
|
||||
"unitPrice": 31,
|
||||
"totalPrice": 620,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-01-11 08:30:00",
|
||||
"stationName": "嘉兴南湖氢站",
|
||||
"customerName": "上海利合供应链管理有限公司",
|
||||
"plateNo": "浙F08991F",
|
||||
"hydrogenKg": 24,
|
||||
"unitPrice": 30,
|
||||
"totalPrice": 720,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-01-12 10:30:00",
|
||||
"stationName": "乌鲁木齐米东加氢站",
|
||||
"customerName": "新疆佳淇信息科技有限公司",
|
||||
"plateNo": "粤AGR5098",
|
||||
"hydrogenKg": 11.8,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 531.25,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-01-25 14:20:00",
|
||||
"stationName": "乌鲁木齐米东加氢站",
|
||||
"customerName": "新疆佳淇信息科技有限公司",
|
||||
"plateNo": "粤AGR5098",
|
||||
"hydrogenKg": 9.72,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 437.5,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-04-05 09:10:00",
|
||||
"stationName": "嘉兴港区加氢站",
|
||||
"customerName": "嘉兴市飞宇物流有限公司",
|
||||
"plateNo": "粤AGP9713",
|
||||
"hydrogenKg": 45.5,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 2047.56,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-04-12 15:22:00",
|
||||
"stationName": "嘉兴港区加氢站",
|
||||
"customerName": "嘉兴市飞宇物流有限公司",
|
||||
"plateNo": "粤AGP9713",
|
||||
"hydrogenKg": 45.5,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 2047.55,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-04-18 10:05:00",
|
||||
"stationName": "嘉兴港区加氢站",
|
||||
"customerName": "嘉兴市飞宇物流有限公司",
|
||||
"plateNo": "粤AGP9713",
|
||||
"hydrogenKg": 45.5,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 2047.55,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-04-25 16:48:00",
|
||||
"stationName": "嘉兴港区加氢站",
|
||||
"customerName": "嘉兴市飞宇物流有限公司",
|
||||
"plateNo": "粤AGP9713",
|
||||
"hydrogenKg": 45.5,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 2047.56,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-05-03 08:55:00",
|
||||
"stationName": "乌鲁木齐头屯河加氢站",
|
||||
"customerName": "黑龙江圣合商贸有限公司新疆分公司",
|
||||
"plateNo": "粤AGP5711",
|
||||
"hydrogenKg": 53.17,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 2392.58,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-05-18 13:30:00",
|
||||
"stationName": "乌鲁木齐头屯河加氢站",
|
||||
"customerName": "黑龙江圣合商贸有限公司新疆分公司",
|
||||
"plateNo": "粤AGP5711",
|
||||
"hydrogenKg": 53.17,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 2392.58,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-05-05 07:40:00",
|
||||
"stationName": "嘉兴港区加氢站",
|
||||
"customerName": "嘉兴市飞宇物流有限公司",
|
||||
"plateNo": "粤AGP9713",
|
||||
"hydrogenKg": 72.4,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 3258.5,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-05-15 18:15:00",
|
||||
"stationName": "嘉兴港区加氢站",
|
||||
"customerName": "嘉兴市飞宇物流有限公司",
|
||||
"plateNo": "粤AGP9713",
|
||||
"hydrogenKg": 72.4,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 3258.5,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-05-26 14:08:33",
|
||||
"stationName": "嘉兴加氢站(一期)",
|
||||
"customerName": "浙江绿运物流有限公司",
|
||||
"plateNo": "浙A67890F",
|
||||
"hydrogenKg": 10,
|
||||
"unitPrice": 45,
|
||||
"totalPrice": 450,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-05-30 09:30:22",
|
||||
"stationName": "杭州临平加氢站",
|
||||
"customerName": "杭州临平城配中心",
|
||||
"plateNo": "浙B23456F",
|
||||
"hydrogenKg": 15.3,
|
||||
"unitPrice": 46,
|
||||
"totalPrice": 703.8,
|
||||
"borneBy": "我司承担"
|
||||
},
|
||||
{
|
||||
"hydrogenTime": "2026-01-15 16:45:00",
|
||||
"stationName": "嘉兴港区加氢站",
|
||||
"customerName": "演示客户A",
|
||||
"plateNo": "沪A65889F",
|
||||
"hydrogenKg": 32,
|
||||
"unitPrice": 30,
|
||||
"totalPrice": 960,
|
||||
"borneBy": "客户承担"
|
||||
}
|
||||
]
|
||||
@@ -8,34 +8,59 @@ import './styles/index.css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Download, FileUp } from 'lucide-react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
type AnnotationViewerOptions
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import seedRows from './data/rows.json';
|
||||
import changeLogSeed from './data/change-log-seed.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { DetailKpiRow } from './components/DetailKpiRow';
|
||||
import { DetailTable } from './components/DetailTable';
|
||||
import { DetailImportModal } from './components/DetailImportModal';
|
||||
import { DetailEditModal } from './components/DetailEditModal';
|
||||
import { DetailChangeLogModal } from './components/DetailChangeLogModal';
|
||||
import { DetailDeleteConfirmModal } from './components/DetailDeleteConfirmModal';
|
||||
import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
|
||||
import {
|
||||
EMPTY_LEASE_DETAIL_FILTERS,
|
||||
type LeaseBusinessDetailRow,
|
||||
type LeaseDetailFilters,
|
||||
} from './types';
|
||||
import { applyDetailFilters, sumDetailKpi } from './utils/detail';
|
||||
import { applyDetailFilters, detailRowYearMonth, sumDetailKpi } from './utils/detail';
|
||||
import { applyDetailCalculations } from './utils/calc-detail';
|
||||
import {
|
||||
downloadDetailImportTemplate,
|
||||
downloadDetailImportErrorLog,
|
||||
exportDetailRows,
|
||||
parseDetailImportFile,
|
||||
type DetailImportErrorRow,
|
||||
} from './utils/batch-io';
|
||||
import {
|
||||
appendChangeLogs,
|
||||
collectRowChangeLogs,
|
||||
createChangeLogEntry,
|
||||
type DetailChangeLogsByRowId,
|
||||
} from './utils/change-log';
|
||||
import { isDetailSupervisor, resolveDetailOperator } from './utils/role';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const ARCHIVED_DEMO_ROW_IDS = new Set(['lbd-33']);
|
||||
|
||||
export default function LeaseBusinessDetailApp() {
|
||||
const operator = useMemo(() => resolveDetailOperator(), []);
|
||||
const isSupervisor = isDetailSupervisor(operator);
|
||||
|
||||
const [records, setRecords] = useState<LeaseBusinessDetailRow[]>(() =>
|
||||
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations(row)),
|
||||
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations({
|
||||
...row,
|
||||
archiveStatus: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? 'archived' : (row.archiveStatus ?? 'active'),
|
||||
reconciledAt: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? '2026-01-18' : (row.reconciledAt ?? ''),
|
||||
})),
|
||||
);
|
||||
const [changeLogs, setChangeLogs] = useState<DetailChangeLogsByRowId>(
|
||||
() => ({ ...(changeLogSeed as DetailChangeLogsByRowId) }),
|
||||
);
|
||||
const [pendingFilters, setPendingFilters] = useState<LeaseDetailFilters>(EMPTY_LEASE_DETAIL_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<LeaseDetailFilters>(EMPTY_LEASE_DETAIL_FILTERS);
|
||||
@@ -43,7 +68,12 @@ export default function LeaseBusinessDetailApp() {
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importErrors, setImportErrors] = useState<DetailImportErrorRow[]>([]);
|
||||
const [importErrorHeaders, setImportErrorHeaders] = useState<string[]>([]);
|
||||
const [toast, setToast] = useState('');
|
||||
const [editRow, setEditRow] = useState<LeaseBusinessDetailRow | null>(null);
|
||||
const [deleteRow, setDeleteRow] = useState<LeaseBusinessDetailRow | null>(null);
|
||||
const [changeLogRow, setChangeLogRow] = useState<LeaseBusinessDetailRow | null>(null);
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
@@ -61,6 +91,32 @@ export default function LeaseBusinessDetailApp() {
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, page, pageSize]);
|
||||
|
||||
const updateRow = useCallback((
|
||||
id: string,
|
||||
patch: Partial<LeaseBusinessDetailRow>,
|
||||
logMeta?: { forceField?: string; fieldLabel?: string },
|
||||
) => {
|
||||
setRecords((prev) => {
|
||||
const before = prev.find((row) => row.id === id);
|
||||
if (!before) return prev;
|
||||
const after = applyDetailCalculations({ ...before, ...patch });
|
||||
const logs = collectRowChangeLogs(operator, before, after);
|
||||
if (logs.length) {
|
||||
setChangeLogs((prevLogs) => appendChangeLogs(prevLogs, id, logs));
|
||||
} else if (logMeta?.forceField) {
|
||||
const entry = createChangeLogEntry(
|
||||
operator,
|
||||
logMeta.forceField,
|
||||
before[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
||||
after[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
||||
{ fieldLabel: logMeta.fieldLabel, force: true },
|
||||
);
|
||||
if (entry) setChangeLogs((prevLogs) => appendChangeLogs(prevLogs, id, [entry]));
|
||||
}
|
||||
return prev.map((row) => (row.id === id ? after : row));
|
||||
});
|
||||
}, [operator]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setTableLoading(true);
|
||||
window.setTimeout(() => {
|
||||
@@ -78,20 +134,70 @@ export default function LeaseBusinessDetailApp() {
|
||||
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const imported = await parseDetailImportFile(file);
|
||||
if (imported.length === 0) {
|
||||
showToast('未解析到有效数据,请检查模板与必填列(年份、月份、车牌号码)');
|
||||
const result = await parseDetailImportFile(file);
|
||||
setImportErrors(result.errors);
|
||||
setImportErrorHeaders(result.fileHeaders);
|
||||
|
||||
if (result.success.length === 0 && result.errors.length === 0) {
|
||||
showToast('未解析到有效数据,请检查模板与必填列');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success.length > 0) {
|
||||
const imported = result.success.map((row) => applyDetailCalculations({
|
||||
...row,
|
||||
archiveStatus: 'active',
|
||||
reconciledAt: '',
|
||||
}));
|
||||
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);
|
||||
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 {
|
||||
showToast('导入失败,请检查文件格式');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteRow) return;
|
||||
const entry = createChangeLogEntry(operator, '_delete', null, null, {
|
||||
fieldLabel: '删除记录',
|
||||
beforeText: `${detailRowYearMonth(deleteRow)} · ${deleteRow.plateNo}`,
|
||||
afterText: '已删除',
|
||||
force: true,
|
||||
});
|
||||
if (entry) setChangeLogs((prev) => appendChangeLogs(prev, deleteRow.id, [entry]));
|
||||
setRecords((prev) => prev.filter((row) => row.id !== deleteRow.id));
|
||||
setDeleteRow(null);
|
||||
showToast('已删除该条明细');
|
||||
};
|
||||
|
||||
const changeLogEntries = changeLogRow ? (changeLogs[changeLogRow.id] ?? []) : [];
|
||||
const changeLogLabel = changeLogRow
|
||||
? `${detailRowYearMonth(changeLogRow)} · ${changeLogRow.plateNo}`
|
||||
: '';
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
@@ -108,6 +214,16 @@ export default function LeaseBusinessDetailApp() {
|
||||
return (
|
||||
<>
|
||||
<div className="vm-page ldb-page lc-page lc-page--list-dense lbd-page">
|
||||
<div className="lbd-role-banner" role="status">
|
||||
<span>
|
||||
当前视角:
|
||||
{isSupervisor ? '业务管理部主管(可修改已锁定记录)' : '业管人员'}
|
||||
</span>
|
||||
{!isSupervisor && (
|
||||
<span className="lbd-role-banner-hint">主管预览:URL 追加 ?role=supervisor</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
records={records}
|
||||
filters={pendingFilters}
|
||||
@@ -124,27 +240,40 @@ export default function LeaseBusinessDetailApp() {
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
|
||||
onClick={() => setImportOpen(true)}
|
||||
data-vm-icon="import"
|
||||
onClick={() => {
|
||||
setImportErrors([]);
|
||||
setImportErrorHeaders([]);
|
||||
setImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
data-vm-icon="download"
|
||||
onClick={() => {
|
||||
exportDetailRows(filtered);
|
||||
showToast(`已导出 ${filtered.length} 条明细`);
|
||||
}}
|
||||
>
|
||||
<Download size={16} aria-hidden />
|
||||
批量导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<DetailTable records={paged} summary={kpi} loading={tableLoading} />
|
||||
<DetailTable
|
||||
records={paged}
|
||||
summary={kpi}
|
||||
loading={tableLoading}
|
||||
isSupervisor={isSupervisor}
|
||||
onEdit={setEditRow}
|
||||
onDelete={setDeleteRow}
|
||||
onChangeLog={setChangeLogRow}
|
||||
onRowPatch={(id, patch) => updateRow(id, patch)}
|
||||
/>
|
||||
<div className="vm-table-footer ldb-table-footer">
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
@@ -168,9 +297,41 @@ export default function LeaseBusinessDetailApp() {
|
||||
onClose={() => setImportOpen(false)}
|
||||
onDownloadTemplate={downloadDetailImportTemplate}
|
||||
onImport={handleImport}
|
||||
failedCount={importErrors.length}
|
||||
onDownloadErrorLog={
|
||||
importErrors.length > 0
|
||||
? () => downloadDetailImportErrorLog(importErrors, importErrorHeaders)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
<DetailEditModal
|
||||
open={Boolean(editRow)}
|
||||
row={editRow}
|
||||
onClose={() => setEditRow(null)}
|
||||
onSave={(patch) => {
|
||||
if (!editRow) return;
|
||||
updateRow(editRow.id, patch);
|
||||
setEditRow(null);
|
||||
showToast('明细已保存,公式列已自动重算');
|
||||
}}
|
||||
/>
|
||||
|
||||
<DetailDeleteConfirmModal
|
||||
open={Boolean(deleteRow)}
|
||||
rowLabel={deleteRow ? `${detailRowYearMonth(deleteRow)} · ${deleteRow.plateNo}` : ''}
|
||||
onCancel={() => setDeleteRow(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
|
||||
<DetailChangeLogModal
|
||||
open={Boolean(changeLogRow)}
|
||||
rowLabel={changeLogLabel}
|
||||
entries={changeLogEntries}
|
||||
onClose={() => setChangeLogRow(null)}
|
||||
/>
|
||||
|
||||
<PrototypeAnnotationHost source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,15 +324,98 @@
|
||||
}
|
||||
|
||||
.lbd-profit-pos {
|
||||
color: var(--ln-success, #16a34a);
|
||||
color: var(--ln-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lbd-profit-neg {
|
||||
color: var(--ln-error, #dc2626);
|
||||
color: var(--ln-success, #16a34a);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lbd-field-check-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.lbd-field-check-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lbd-field-check-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lbd-field-check-icon.is-ok {
|
||||
background: color-mix(in srgb, var(--ln-success, #16a34a) 14%, transparent);
|
||||
color: var(--ln-success, #16a34a);
|
||||
}
|
||||
|
||||
.lbd-field-check-icon.is-warn {
|
||||
background: color-mix(in srgb, var(--ln-error, #dc2626) 12%, transparent);
|
||||
color: var(--ln-error, #dc2626);
|
||||
}
|
||||
|
||||
.lbd-field-check-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: var(--ln-ink);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
box-shadow: var(--ln-shadow-hover);
|
||||
}
|
||||
|
||||
.lbd-field-check-pop--ok {
|
||||
background: var(--ln-surface-card);
|
||||
color: var(--ln-ink);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.lbd-field-check-pop__title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lbd-field-check-pop__line + .lbd-field-check-pop__line {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.lbd-field-check-pop__actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.lbd-detail-table .ldb-th-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.lbd-detail-table .ldb-th-with-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lbd-cell-remark {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
@@ -345,6 +428,253 @@
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.lbd-import-error-step {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.lbd-import-error-summary {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lbd-import-error-summary strong {
|
||||
color: var(--ln-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ldb-import-modal .vm-upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.ldb-import-modal .vm-upload-zone p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lbd-page .vm-table-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.lbd-role-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-text-secondary);
|
||||
}
|
||||
|
||||
.lbd-role-banner-hint {
|
||||
color: var(--ln-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.lbd-detail-table .ldb-col-actions {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.lbd-row-archived td {
|
||||
background: color-mix(in srgb, var(--ln-muted) 6%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-row-archived:hover td {
|
||||
background: color-mix(in srgb, var(--ln-muted) 10%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-changelog-meta {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.lbd-changelog-modal.vm-modal-lg {
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.lbd-changelog-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lbd-changelog-header-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lbd-changelog-header .vm-modal-title {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.lbd-changelog-body {
|
||||
overflow: hidden;
|
||||
padding: 12px 24px 20px;
|
||||
}
|
||||
|
||||
.lbd-changelog-footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lbd-changelog-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: min(440px, 58vh);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: var(--ln-surface-card);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.lbd-changelog-table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.lbd-changelog-table th,
|
||||
.lbd-changelog-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--ln-hairline);
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lbd-changelog-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ln-muted);
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
background: color-mix(in srgb, var(--ln-canvas-soft) 92%, var(--ln-surface-card));
|
||||
box-shadow: inset 0 -1px 0 var(--ln-hairline);
|
||||
}
|
||||
|
||||
.lbd-changelog-table tbody tr:nth-child(even) td {
|
||||
background: color-mix(in srgb, var(--ln-canvas-soft) 45%, transparent);
|
||||
}
|
||||
|
||||
.lbd-changelog-table tbody tr:hover td {
|
||||
background: color-mix(in srgb, var(--ln-primary) 5%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-changelog-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lbd-changelog-cell-meta {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
color: var(--ln-text-secondary);
|
||||
}
|
||||
|
||||
.lbd-changelog-cell-field {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.lbd-changelog-field-tag {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--ln-primary) 18%, var(--ln-hairline));
|
||||
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
|
||||
color: var(--ln-ink);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.lbd-changelog-cell-diff {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.lbd-changelog-diff-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lbd-changelog-diff-text--before {
|
||||
color: #b42318;
|
||||
background: color-mix(in srgb, #fecaca 28%, transparent);
|
||||
}
|
||||
|
||||
.lbd-changelog-diff-text--after {
|
||||
color: #067647;
|
||||
background: color-mix(in srgb, #bbf7d0 32%, transparent);
|
||||
}
|
||||
|
||||
.lbd-changelog-empty {
|
||||
display: inline-block;
|
||||
min-width: 1.25em;
|
||||
color: var(--ln-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lbd-edit-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lbd-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.lbd-edit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lbd-edit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.lbd-edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lbd-edit-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-text-secondary);
|
||||
}
|
||||
|
||||
.lbd-edit-form {
|
||||
max-height: min(68vh, 640px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type DetailArchiveStatus = 'active' | 'archived';
|
||||
|
||||
export interface LeaseBusinessDetailRow {
|
||||
id: string;
|
||||
year: number;
|
||||
@@ -47,6 +49,10 @@ export interface LeaseBusinessDetailRow {
|
||||
assetOwner: string;
|
||||
signingCompany: string;
|
||||
remark: string;
|
||||
/** 未对账可编辑;已对账后仅业务管理部主管可改 */
|
||||
archiveStatus?: DetailArchiveStatus;
|
||||
/** 完成对账日期 YYYY-MM-DD */
|
||||
reconciledAt?: string;
|
||||
}
|
||||
|
||||
export interface LeaseDetailFilters {
|
||||
@@ -55,6 +61,8 @@ export interface LeaseDetailFilters {
|
||||
salesperson: string;
|
||||
/** 客户名称模糊匹配 */
|
||||
customerName: string;
|
||||
/** 收款状态:已结清 / 部分收款 / 未收款 */
|
||||
paymentStatus: string;
|
||||
plateNos: string[];
|
||||
}
|
||||
|
||||
@@ -116,5 +124,6 @@ export const EMPTY_LEASE_DETAIL_FILTERS: LeaseDetailFilters = {
|
||||
businessDept: '',
|
||||
salesperson: '',
|
||||
customerName: '',
|
||||
paymentStatus: '',
|
||||
plateNos: [],
|
||||
};
|
||||
|
||||
@@ -1,20 +1,83 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import {
|
||||
DETAIL_EXPORT_HEADERS,
|
||||
DETAIL_IMPORT_TEMPLATE_HEADERS,
|
||||
DETAIL_TABLE_COLUMNS,
|
||||
} from '../components/tableColumns';
|
||||
import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableColumns';
|
||||
import { applyDetailCalculations } from './calc-detail';
|
||||
import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref';
|
||||
|
||||
/** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */
|
||||
export const DETAIL_IMPORT_TEMPLATE_HEADERS = [
|
||||
'年份',
|
||||
'月份',
|
||||
'付款日期',
|
||||
'车牌号码',
|
||||
'客户名称',
|
||||
'增值服务',
|
||||
'享免政策',
|
||||
'提车日期',
|
||||
'合同生效日期',
|
||||
'合同到期日期',
|
||||
'起始日期',
|
||||
'退车日期',
|
||||
'平均天数',
|
||||
'押金',
|
||||
'合同标的租金',
|
||||
'维保包干收入',
|
||||
'保险上浮费',
|
||||
'运维费',
|
||||
'其他收入',
|
||||
'里程减免金额',
|
||||
'其他减免金额',
|
||||
'实收金额',
|
||||
'开票日期',
|
||||
'实际付款日期',
|
||||
'付款方式',
|
||||
'车辆标准成本',
|
||||
'车辆实际成本',
|
||||
'保险费',
|
||||
'运维费',
|
||||
'居间费',
|
||||
'其他',
|
||||
'GPS里程',
|
||||
'减免金额明细内容',
|
||||
'资产归属',
|
||||
'签约公司',
|
||||
'备注',
|
||||
] as const;
|
||||
|
||||
export const DETAIL_IMPORT_REQUIRED_HEADERS = [
|
||||
'年份',
|
||||
'月份',
|
||||
'付款日期',
|
||||
'车牌号码',
|
||||
'客户名称',
|
||||
'提车日期',
|
||||
'合同生效日期',
|
||||
'合同到期日期',
|
||||
'平均天数',
|
||||
'押金',
|
||||
'合同标的租金',
|
||||
'实收金额',
|
||||
'资产归属',
|
||||
'签约公司',
|
||||
] as const;
|
||||
|
||||
export interface DetailImportErrorRow {
|
||||
reason: string;
|
||||
rawCells: (string | number)[];
|
||||
}
|
||||
|
||||
export interface DetailImportParseResult {
|
||||
success: LeaseBusinessDetailRow[];
|
||||
errors: DetailImportErrorRow[];
|
||||
/** 上传文件表头(用于错误日志) */
|
||||
fileHeaders: string[];
|
||||
}
|
||||
|
||||
const TEMPLATE_SAMPLE: Record<string, string | number> = {
|
||||
年份: 2026,
|
||||
月份: 2,
|
||||
业务部门: '业务二部',
|
||||
业务员: '刘念忠',
|
||||
付款日期: '2026-02-05',
|
||||
车牌号码: '沪A52898F',
|
||||
车型: '18T',
|
||||
客户名称: '上海虹钦物流有限公司',
|
||||
增值服务: '',
|
||||
享免政策: '',
|
||||
@@ -23,28 +86,26 @@ const TEMPLATE_SAMPLE: Record<string, string | number> = {
|
||||
合同到期日期: '2026-11-30',
|
||||
起始日期: '2026-02-01',
|
||||
退车日期: '',
|
||||
平均天数: 28,
|
||||
平均天数: 1,
|
||||
押金: 20000,
|
||||
合同标的租金: 18500,
|
||||
应收租金: 18500,
|
||||
月度租金: 18500,
|
||||
合同标的租金: 4500,
|
||||
维保包干收入: 0,
|
||||
保险上浮费: 0,
|
||||
运维费: 0,
|
||||
其他收入: 0,
|
||||
里程减免金额: 0,
|
||||
其他减免金额: 0,
|
||||
实收金额: 18500,
|
||||
实收金额: 4500,
|
||||
开票日期: '2026-02-06',
|
||||
实际付款日期: '2026-02-05',
|
||||
付款方式: '月付指付',
|
||||
车辆标准成本: 12000,
|
||||
付款方式: '月度预付',
|
||||
车辆标准成本: 3900,
|
||||
车辆实际成本: 0,
|
||||
保险费: 0,
|
||||
氢费: 3200,
|
||||
居间费: 800,
|
||||
居间费: 500,
|
||||
其他: 0,
|
||||
GPS里程: '3260',
|
||||
其他减免金额明细内容: '',
|
||||
GPS里程: '',
|
||||
减免金额明细内容: '',
|
||||
资产归属: '浙江羚牛氢能科技有限公司',
|
||||
签约公司: '浙江羚牛氢能科技有限公司',
|
||||
备注: '',
|
||||
@@ -81,11 +142,14 @@ const HEADER_TO_KEY: Record<string, keyof LeaseBusinessDetailRow> = {
|
||||
实际付款日期: 'actualPaymentDate',
|
||||
付款方式: 'paymentMethod',
|
||||
车辆标准成本: 'vehicleStandardCost',
|
||||
车辆实际成本: 'vehicleActualCost',
|
||||
保险费: 'insuranceFee',
|
||||
氢费: 'hydrogenFee',
|
||||
居间费: 'brokerageFee',
|
||||
其他: 'otherCost',
|
||||
GPS里程: 'gpsMileage',
|
||||
'GPS 里程': 'gpsMileage',
|
||||
减免金额明细内容: 'otherDeductionDetail',
|
||||
其他减免金额明细内容: 'otherDeductionDetail',
|
||||
资产归属: 'assetOwner',
|
||||
签约公司: 'signingCompany',
|
||||
@@ -97,7 +161,7 @@ const IMPORT_NUMERIC_KEYS = new Set<keyof LeaseBusinessDetailRow>([
|
||||
'contractRent', 'receivableRent', 'monthlyRent',
|
||||
'maintenanceIncome', 'insuranceSurcharge', 'opsFeeIncome', 'otherIncome',
|
||||
'mileageDeduction', 'otherDeduction', 'receivedAmount',
|
||||
'vehicleStandardCost', 'insuranceFee', 'hydrogenFee',
|
||||
'vehicleStandardCost', 'vehicleActualCost', 'insuranceFee', 'hydrogenFee',
|
||||
'opsFeeCost', 'brokerageFee', 'otherCost',
|
||||
]);
|
||||
|
||||
@@ -108,6 +172,13 @@ const IMPORT_TEXT_KEYS = new Set<keyof LeaseBusinessDetailRow>([
|
||||
'gpsMileage', 'otherDeductionDetail', 'assetOwner', 'signingCompany', 'remark',
|
||||
]);
|
||||
|
||||
const REQUIRED_NUMERIC_HEADERS = new Set(['年份', '月份', '平均天数', '合同标的租金', '实收金额']);
|
||||
|
||||
function isCellEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
return String(value).trim() === '';
|
||||
}
|
||||
|
||||
function parseNumber(value: unknown): number {
|
||||
if (value === null || value === undefined || value === '') return 0;
|
||||
const n = Number(String(value).replace(/,/g, ''));
|
||||
@@ -121,10 +192,94 @@ function parseDeposit(value: unknown): number | string {
|
||||
return parseNumber(raw);
|
||||
}
|
||||
|
||||
function normalizeHeader(header: string): string {
|
||||
return header.replace(/(必填)/g, '').trim();
|
||||
}
|
||||
|
||||
function resolveHeaderKey(header: string): keyof LeaseBusinessDetailRow | undefined {
|
||||
return HEADER_TO_KEY[normalizeHeader(header)];
|
||||
}
|
||||
|
||||
function validateRequiredFields(
|
||||
headers: string[],
|
||||
cells: (string | number)[],
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
const headerIndex = new Map(headers.map((h, i) => [normalizeHeader(h), i]));
|
||||
|
||||
DETAIL_IMPORT_REQUIRED_HEADERS.forEach((required) => {
|
||||
const idx = headerIndex.get(required);
|
||||
const raw = idx === undefined ? undefined : cells[idx];
|
||||
if (required === '押金') {
|
||||
if (isCellEmpty(raw)) reasons.push(`${required}不能为空`);
|
||||
return;
|
||||
}
|
||||
if (REQUIRED_NUMERIC_HEADERS.has(required)) {
|
||||
if (isCellEmpty(raw)) reasons.push(`${required}不能为空`);
|
||||
return;
|
||||
}
|
||||
if (isCellEmpty(raw)) reasons.push(`${required}不能为空`);
|
||||
});
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function enrichDraftFromSystem(draft: Partial<LeaseBusinessDetailRow>): void {
|
||||
if (!draft.plateNo) return;
|
||||
const vehicle = getVehicleRef(draft.plateNo);
|
||||
if (vehicle) {
|
||||
draft.vehicleType = vehicle.brandModel || draft.vehicleType || '';
|
||||
}
|
||||
if (draft.customerName) {
|
||||
const contract = getContractRef(draft.plateNo, draft.customerName);
|
||||
if (contract) {
|
||||
draft.businessDept = contract.businessDept;
|
||||
draft.salesperson = contract.salesperson;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseRowDraft(
|
||||
headers: string[],
|
||||
cells: (string | number)[],
|
||||
index: number,
|
||||
): Partial<LeaseBusinessDetailRow> {
|
||||
const draft: Partial<LeaseBusinessDetailRow> = {
|
||||
id: `import-${Date.now()}-${index}`,
|
||||
};
|
||||
let opsFeeColIndex = 0;
|
||||
|
||||
headers.forEach((header, colIndex) => {
|
||||
const normalized = normalizeHeader(header);
|
||||
let key = resolveHeaderKey(header);
|
||||
if (normalized === '运维费') {
|
||||
opsFeeColIndex += 1;
|
||||
key = opsFeeColIndex === 1 ? 'opsFeeIncome' : 'opsFeeCost';
|
||||
}
|
||||
if (!key) return;
|
||||
const raw = cells[colIndex];
|
||||
if (key === 'deposit') {
|
||||
(draft as Record<string, unknown>)[key] = parseDeposit(raw);
|
||||
} else if (IMPORT_NUMERIC_KEYS.has(key)) {
|
||||
const num = parseNumber(raw);
|
||||
if (key === 'mileageDeduction' || key === 'otherDeduction') {
|
||||
(draft as Record<string, unknown>)[key] = num > 0 ? -Math.abs(num) : num;
|
||||
} else {
|
||||
(draft as Record<string, unknown>)[key] = num;
|
||||
}
|
||||
} else if (IMPORT_TEXT_KEYS.has(key)) {
|
||||
(draft as Record<string, unknown>)[key] = String(raw ?? '').trim();
|
||||
}
|
||||
});
|
||||
|
||||
enrichDraftFromSystem(draft);
|
||||
return draft;
|
||||
}
|
||||
|
||||
function rowToExportCells(row: LeaseBusinessDetailRow): string[] {
|
||||
let opsIncomeWritten = false;
|
||||
return DETAIL_TABLE_COLUMNS.map((col) => {
|
||||
const header = col.title;
|
||||
return DETAIL_EXPORT_SPECS.map((spec) => {
|
||||
const header = spec.title;
|
||||
if (header === '运维费') {
|
||||
if (!opsIncomeWritten) {
|
||||
opsIncomeWritten = true;
|
||||
@@ -132,7 +287,7 @@ function rowToExportCells(row: LeaseBusinessDetailRow): string[] {
|
||||
}
|
||||
return row.opsFeeCost === 0 ? '' : String(row.opsFeeCost);
|
||||
}
|
||||
const key = col.key as keyof LeaseBusinessDetailRow;
|
||||
const key = spec.key;
|
||||
const raw = row[key];
|
||||
if (key === 'deposit') {
|
||||
if (raw === '/' || raw === '') return raw === '/' ? '/' : '';
|
||||
@@ -151,12 +306,25 @@ export function downloadDetailImportTemplate(): void {
|
||||
}, []);
|
||||
if (opsIndexes[1] !== undefined) sampleRow[opsIndexes[1]] = 0;
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([DETAIL_IMPORT_TEMPLATE_HEADERS, sampleRow]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([Array.from(DETAIL_IMPORT_TEMPLATE_HEADERS), sampleRow]);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
|
||||
XLSX.writeFile(book, '租赁业务明细导入模板.xlsx');
|
||||
}
|
||||
|
||||
export function downloadDetailImportErrorLog(
|
||||
errors: DetailImportErrorRow[],
|
||||
fileHeaders: string[],
|
||||
): void {
|
||||
if (!errors.length) return;
|
||||
const headers = ['导入失败原因', ...(fileHeaders.length ? fileHeaders : Array.from(DETAIL_IMPORT_TEMPLATE_HEADERS))];
|
||||
const body = errors.map((err) => [err.reason, ...err.rawCells]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, ...body]);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, '导入失败记录');
|
||||
XLSX.writeFile(book, `租赁业务明细导入失败记录_${Date.now()}.xlsx`);
|
||||
}
|
||||
|
||||
export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
|
||||
const body = [DETAIL_EXPORT_HEADERS, ...rows.map(rowToExportCells)];
|
||||
const sheet = XLSX.utils.aoa_to_sheet(body);
|
||||
@@ -165,43 +333,38 @@ export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
|
||||
XLSX.writeFile(book, `租赁业务明细_${Date.now()}.xlsx`);
|
||||
}
|
||||
|
||||
export async function parseDetailImportFile(file: File): Promise<LeaseBusinessDetailRow[]> {
|
||||
export async function parseDetailImportFile(file: File): Promise<DetailImportParseResult> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const book = XLSX.read(buffer, { type: 'array' });
|
||||
const sheet = book.Sheets[book.SheetNames[0]];
|
||||
const matrix = XLSX.utils.sheet_to_json<(string | number)[]>(sheet, { header: 1, defval: '' }) as (string | number)[][];
|
||||
if (matrix.length < 2) return [];
|
||||
if (matrix.length < 2) {
|
||||
return { success: [], errors: [], fileHeaders: [] };
|
||||
}
|
||||
|
||||
const headers = matrix[0].map((cell) => String(cell).trim());
|
||||
const rows: LeaseBusinessDetailRow[] = [];
|
||||
const fileHeaders = matrix[0].map((cell) => String(cell).trim());
|
||||
const success: LeaseBusinessDetailRow[] = [];
|
||||
const errors: DetailImportErrorRow[] = [];
|
||||
|
||||
matrix.slice(1).forEach((cells, index) => {
|
||||
if (!cells.some((cell) => String(cell).trim())) return;
|
||||
const draft: Partial<LeaseBusinessDetailRow> = {
|
||||
id: `import-${Date.now()}-${index}`,
|
||||
};
|
||||
let opsFeeColIndex = 0;
|
||||
|
||||
headers.forEach((header, colIndex) => {
|
||||
let key = HEADER_TO_KEY[header];
|
||||
if (header === '运维费') {
|
||||
opsFeeColIndex += 1;
|
||||
key = opsFeeColIndex === 1 ? 'opsFeeIncome' : 'opsFeeCost';
|
||||
const rawCells = fileHeaders.map((_, colIndex) => cells[colIndex] ?? '');
|
||||
const requiredReasons = validateRequiredFields(fileHeaders, cells);
|
||||
if (requiredReasons.length > 0) {
|
||||
errors.push({ reason: requiredReasons.join(';'), rawCells });
|
||||
return;
|
||||
}
|
||||
if (!key) return;
|
||||
const raw = cells[colIndex];
|
||||
if (key === 'deposit') {
|
||||
(draft as Record<string, unknown>)[key] = parseDeposit(raw);
|
||||
} else if (IMPORT_NUMERIC_KEYS.has(key)) {
|
||||
(draft as Record<string, unknown>)[key] = parseNumber(raw);
|
||||
} else if (IMPORT_TEXT_KEYS.has(key)) {
|
||||
(draft as Record<string, unknown>)[key] = String(raw ?? '').trim();
|
||||
|
||||
const draft = parseRowDraft(fileHeaders, cells, index);
|
||||
const plateNo = String(draft.plateNo ?? '').trim();
|
||||
if (!isPlateInVehicleSystem(plateNo)) {
|
||||
errors.push({ reason: '车牌号不存在', rawCells });
|
||||
return;
|
||||
}
|
||||
|
||||
success.push(applyDetailCalculations(draft));
|
||||
});
|
||||
|
||||
if (!draft.plateNo || !draft.year || !draft.month) return;
|
||||
rows.push(applyDetailCalculations(draft));
|
||||
});
|
||||
|
||||
return rows;
|
||||
return { success, errors, fileHeaders };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { sumHydrogenFeeForDetailRow } from './hydrogen-fee';
|
||||
import { resolveSettlementMonths } from './system-ref';
|
||||
|
||||
/** 对齐 Excel「租赁车辆收入明细表」公式列 */
|
||||
export const DETAIL_CALCULATED_KEYS = [
|
||||
@@ -26,7 +28,55 @@ type IncomeAdjustments = Pick<
|
||||
| 'otherDeduction'
|
||||
>;
|
||||
|
||||
/** 应收合计 = 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入) + 其他收入 + 里程减免 + 其他减免 */
|
||||
/** 账单月自然日天数 */
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
if (!year || !month) return 30;
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
/** 本月在租天数(原型:有起始/退车日期则按区间,否则整月) */
|
||||
export function calcLeaseDaysInMonth(
|
||||
year: number,
|
||||
month: number,
|
||||
periodStartDate: string,
|
||||
returnDate: string,
|
||||
): number {
|
||||
if (!year || !month) return 0;
|
||||
const monthStart = new Date(year, month - 1, 1);
|
||||
const monthEnd = new Date(year, month, 0);
|
||||
let start = periodStartDate ? new Date(periodStartDate.slice(0, 10)) : monthStart;
|
||||
let end = returnDate ? new Date(returnDate.slice(0, 10)) : monthEnd;
|
||||
if (start < monthStart) start = monthStart;
|
||||
if (end > monthEnd) end = monthEnd;
|
||||
if (end < start) return 0;
|
||||
const ms = end.getTime() - start.getTime();
|
||||
return Math.max(0, Math.floor(ms / 86400000) + 1);
|
||||
}
|
||||
|
||||
/** 平均天数 = 本月在租天数 / 当月天数 */
|
||||
export function calcDetailAvgDays(
|
||||
year: number,
|
||||
month: number,
|
||||
periodStartDate: string,
|
||||
returnDate: string,
|
||||
): number {
|
||||
const leaseDays = calcLeaseDaysInMonth(year, month, periodStartDate, returnDate);
|
||||
const dim = daysInMonth(year, month);
|
||||
if (!dim) return 0;
|
||||
return roundMoney(leaseDays / dim);
|
||||
}
|
||||
|
||||
/** 月度租金 = 合同标的租金(月标准),付款周期大于 1 时仍按月标准展示 */
|
||||
export function calcDetailMonthlyRent(contractRent: number): number {
|
||||
return roundMoney(contractRent);
|
||||
}
|
||||
|
||||
/** 应收租金 = 合同标的租金 × 结算周期月数 */
|
||||
export function calcDetailReceivableRent(contractRent: number, settlementMonths: number): number {
|
||||
return roundMoney(contractRent * settlementMonths);
|
||||
}
|
||||
|
||||
/** 应收合计 = 应收租金 + 收入调整项 */
|
||||
export function calcDetailReceivableTotal(
|
||||
receivableRent: number,
|
||||
adjustments: IncomeAdjustments,
|
||||
@@ -127,19 +177,45 @@ export function applyDetailCalculations(
|
||||
otherDeduction: row.otherDeduction ?? 0,
|
||||
};
|
||||
|
||||
const receivableRent = row.receivableRent ?? 0;
|
||||
const monthlyRent = row.monthlyRent ?? 0;
|
||||
const year = row.year ?? 0;
|
||||
const month = row.month ?? 0;
|
||||
const contractRent = row.contractRent ?? 0;
|
||||
const settlementMonths = resolveSettlementMonths({
|
||||
...row,
|
||||
year,
|
||||
month,
|
||||
contractRent,
|
||||
} as LeaseBusinessDetailRow);
|
||||
|
||||
const computedAvgDays = calcDetailAvgDays(
|
||||
year,
|
||||
month,
|
||||
row.periodStartDate ?? '',
|
||||
row.returnDate ?? '',
|
||||
);
|
||||
const avgDays = row.avgDays && row.avgDays > 0 ? row.avgDays : computedAvgDays;
|
||||
const monthlyRent = calcDetailMonthlyRent(contractRent);
|
||||
const receivableRent = row.receivableRent && row.receivableRent > 0
|
||||
? row.receivableRent
|
||||
: calcDetailReceivableRent(contractRent, settlementMonths);
|
||||
const receivedAmount = row.receivedAmount ?? 0;
|
||||
const vehicleStandardCost = row.vehicleStandardCost ?? 0;
|
||||
const avgDays = row.avgDays ?? 0;
|
||||
|
||||
const vehicleActualCost = calcDetailVehicleActualCost(vehicleStandardCost, avgDays);
|
||||
const monthlyIncome = calcDetailMonthlyIncome(monthlyRent, adjustments);
|
||||
const receivableTotal = calcDetailReceivableTotal(receivableRent, adjustments);
|
||||
const hydrogenFee = sumHydrogenFeeForDetailRow({
|
||||
plateNo: row.plateNo ?? '',
|
||||
customerName: row.customerName ?? '',
|
||||
year,
|
||||
month,
|
||||
pickupDate: row.pickupDate ?? '',
|
||||
returnDate: row.returnDate ?? '',
|
||||
});
|
||||
const totalCost = calcDetailTotalCost({
|
||||
vehicleActualCost,
|
||||
insuranceFee: row.insuranceFee ?? 0,
|
||||
hydrogenFee: row.hydrogenFee ?? 0,
|
||||
hydrogenFee,
|
||||
opsFeeCost: row.opsFeeCost ?? 0,
|
||||
brokerageFee: row.brokerageFee ?? 0,
|
||||
otherCost: row.otherCost ?? 0,
|
||||
@@ -154,7 +230,7 @@ export function applyDetailCalculations(
|
||||
...EMPTY_TEXT_FIELDS,
|
||||
...row,
|
||||
deposit: row.deposit ?? 0,
|
||||
contractRent: row.contractRent ?? 0,
|
||||
contractRent,
|
||||
receivableRent,
|
||||
monthlyRent,
|
||||
...adjustments,
|
||||
@@ -162,7 +238,7 @@ export function applyDetailCalculations(
|
||||
vehicleStandardCost,
|
||||
avgDays,
|
||||
insuranceFee: row.insuranceFee ?? 0,
|
||||
hydrogenFee: row.hydrogenFee ?? 0,
|
||||
hydrogenFee,
|
||||
opsFeeCost: row.opsFeeCost ?? 0,
|
||||
brokerageFee: row.brokerageFee ?? 0,
|
||||
otherCost: row.otherCost ?? 0,
|
||||
@@ -172,5 +248,7 @@ export function applyDetailCalculations(
|
||||
totalCost,
|
||||
outstanding,
|
||||
profitLoss,
|
||||
archiveStatus: row.archiveStatus ?? 'active',
|
||||
reconciledAt: row.reconciledAt ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
126
src/prototypes/lease-business-detail/utils/change-log.ts
Normal file
126
src/prototypes/lease-business-detail/utils/change-log.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import { DETAIL_EXPORT_SPECS } from '../components/tableColumns';
|
||||
import { displayDeposit, displayMoney, displayText } from './detail';
|
||||
import type { DetailOperator } from './role';
|
||||
|
||||
export interface DetailChangeLogEntry {
|
||||
id: string;
|
||||
operatedAt: string;
|
||||
operatorId: string;
|
||||
operatorName: string;
|
||||
field: string;
|
||||
fieldLabel: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
export type DetailChangeLogsByRowId = Record<string, DetailChangeLogEntry[]>;
|
||||
|
||||
const EXTRA_FIELD_LABELS: Record<string, string> = {
|
||||
archiveStatus: '对账状态',
|
||||
reconciledAt: '对账日期',
|
||||
_delete: '删除记录',
|
||||
_import: '批量导入',
|
||||
_reconcile: '完成对账',
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = Object.fromEntries(
|
||||
DETAIL_EXPORT_SPECS.map((spec) => [spec.key, spec.title]),
|
||||
);
|
||||
|
||||
export function resolveFieldLabel(field: string): string {
|
||||
return EXTRA_FIELD_LABELS[field] ?? FIELD_LABELS[field] ?? field;
|
||||
}
|
||||
|
||||
const MONEY_FIELDS = new Set<string>([
|
||||
'deposit', 'contractRent', 'receivableTotal', 'receivableRent', 'monthlyIncome', 'monthlyRent',
|
||||
'maintenanceIncome', 'insuranceSurcharge', 'opsFeeIncome', 'otherIncome', 'mileageDeduction',
|
||||
'otherDeduction', 'receivedAmount', 'outstanding', 'vehicleStandardCost', 'vehicleActualCost',
|
||||
'insuranceFee', 'hydrogenFee', 'opsFeeCost', 'brokerageFee', 'otherCost', 'totalCost', 'profitLoss',
|
||||
]);
|
||||
|
||||
export function formatLogValue(field: string, value: unknown): string {
|
||||
if (field === 'archiveStatus') {
|
||||
if (value === 'archived') return '已对账';
|
||||
if (value === 'active') return '未对账';
|
||||
return displayText(value);
|
||||
}
|
||||
if (field === 'year' || field === 'month') {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value);
|
||||
}
|
||||
if (field === 'deposit') return displayDeposit(value as number | string) || '—';
|
||||
if (MONEY_FIELDS.has(field) && typeof value === 'number') {
|
||||
return displayMoney(value, false) || '0.00';
|
||||
}
|
||||
if (typeof value === 'number') return String(value);
|
||||
return displayText(value) || '—';
|
||||
}
|
||||
|
||||
export function valuesEqualForLog(field: string, before: unknown, after: unknown): boolean {
|
||||
return formatLogValue(field, before) === formatLogValue(field, after);
|
||||
}
|
||||
|
||||
const LOGGED_ROW_FIELDS: (keyof LeaseBusinessDetailRow)[] = [
|
||||
...DETAIL_EXPORT_SPECS.map((spec) => spec.key),
|
||||
'archiveStatus',
|
||||
'reconciledAt',
|
||||
];
|
||||
|
||||
export function createChangeLogEntry(
|
||||
operator: DetailOperator,
|
||||
field: string,
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
meta?: { fieldLabel?: string; beforeText?: string; afterText?: string; force?: boolean },
|
||||
): DetailChangeLogEntry | null {
|
||||
const beforeText = meta?.beforeText ?? formatLogValue(field, before);
|
||||
const afterText = meta?.afterText ?? formatLogValue(field, after);
|
||||
if (!meta?.force && beforeText === afterText) return null;
|
||||
|
||||
const now = new Date();
|
||||
const operatedAt = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
return {
|
||||
id: `lbd-clog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
operatedAt,
|
||||
operatorId: operator.id,
|
||||
operatorName: operator.name,
|
||||
field,
|
||||
fieldLabel: meta?.fieldLabel ?? resolveFieldLabel(field),
|
||||
before: beforeText,
|
||||
after: afterText,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendChangeLogs(
|
||||
logs: DetailChangeLogsByRowId,
|
||||
rowId: string,
|
||||
entries: DetailChangeLogEntry[],
|
||||
): DetailChangeLogsByRowId {
|
||||
if (!entries.length) return logs;
|
||||
return {
|
||||
...logs,
|
||||
[rowId]: [...entries, ...(logs[rowId] ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectRowChangeLogs(
|
||||
operator: DetailOperator,
|
||||
before: LeaseBusinessDetailRow,
|
||||
after: LeaseBusinessDetailRow,
|
||||
): DetailChangeLogEntry[] {
|
||||
const entries: DetailChangeLogEntry[] = [];
|
||||
LOGGED_ROW_FIELDS.forEach((field) => {
|
||||
const entry = createChangeLogEntry(operator, field, before[field], after[field]);
|
||||
if (entry) entries.push(entry);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function canEditDetailRow(
|
||||
row: Pick<LeaseBusinessDetailRow, 'archiveStatus'>,
|
||||
isSupervisor: boolean,
|
||||
): boolean {
|
||||
if (row.archiveStatus === 'archived') return isSupervisor;
|
||||
return true;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
LeaseDetailKpiSummary,
|
||||
} from '../types';
|
||||
import { EMPTY_LEASE_DETAIL_KPI } from '../types';
|
||||
import { resolveDetailPaymentStatus } from './payment-status';
|
||||
|
||||
export function detailRowYearMonth(row: LeaseBusinessDetailRow): string {
|
||||
return `${row.year}-${String(row.month).padStart(2, '0')}`;
|
||||
@@ -30,7 +31,7 @@ export function displayDeposit(value: number | string): string {
|
||||
}
|
||||
|
||||
export function displayOutstanding(value: number): { text: string; warn: boolean } {
|
||||
if (value === 0) return { text: '', warn: false };
|
||||
if (value === 0) return { text: '0.00', warn: false };
|
||||
return { text: formatMoney(value), warn: value > 0 };
|
||||
}
|
||||
|
||||
@@ -44,6 +45,10 @@ export function applyDetailFilters(
|
||||
if (filters.businessDept && row.businessDept !== filters.businessDept) return false;
|
||||
if (filters.salesperson && row.salesperson !== filters.salesperson) return false;
|
||||
if (customerQ && !row.customerName.toLowerCase().includes(customerQ)) return false;
|
||||
if (filters.paymentStatus) {
|
||||
const status = resolveDetailPaymentStatus(row.receivableTotal, row.receivedAmount);
|
||||
if (status !== filters.paymentStatus) return false;
|
||||
}
|
||||
if (filters.plateNos.length > 0 && !filters.plateNos.includes(row.plateNo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
87
src/prototypes/lease-business-detail/utils/edit-fields.ts
Normal file
87
src/prototypes/lease-business-detail/utils/edit-fields.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
|
||||
export type DetailEditFieldType = 'text' | 'number' | 'date';
|
||||
|
||||
export interface DetailEditFieldSpec {
|
||||
key: keyof LeaseBusinessDetailRow;
|
||||
label: string;
|
||||
type: DetailEditFieldType;
|
||||
}
|
||||
|
||||
/** 编辑弹窗可改字段(对齐导入模板手工列) */
|
||||
export const DETAIL_EDIT_FIELDS: DetailEditFieldSpec[] = [
|
||||
{ key: 'year', label: '年份', type: 'number' },
|
||||
{ key: 'month', label: '月份', type: 'number' },
|
||||
{ key: 'paymentDate', label: '付款日期', type: 'date' },
|
||||
{ key: 'plateNo', label: '车牌号码', type: 'text' },
|
||||
{ key: 'customerName', label: '客户名称', type: 'text' },
|
||||
{ key: 'valueAddedService', label: '增值服务', type: 'text' },
|
||||
{ key: 'exemptionPolicy', label: '享免政策', type: 'text' },
|
||||
{ key: 'pickupDate', label: '提车日期', type: 'date' },
|
||||
{ key: 'contractStartDate', label: '合同生效日期', type: 'date' },
|
||||
{ key: 'contractEndDate', label: '合同到期日期', type: 'date' },
|
||||
{ key: 'periodStartDate', label: '起始日期', type: 'date' },
|
||||
{ key: 'returnDate', label: '退车日期', type: 'date' },
|
||||
{ key: 'avgDays', label: '平均天数', type: 'number' },
|
||||
{ key: 'deposit', label: '押金', type: 'text' },
|
||||
{ key: 'contractRent', label: '合同标的租金', type: 'number' },
|
||||
{ key: 'receivableRent', label: '应收租金', type: 'number' },
|
||||
{ key: 'maintenanceIncome', label: '维保包干收入', type: 'number' },
|
||||
{ key: 'insuranceSurcharge', label: '保险上浮费', type: 'number' },
|
||||
{ key: 'opsFeeIncome', label: '运维费(收入)', type: 'number' },
|
||||
{ key: 'otherIncome', label: '其他收入', type: 'number' },
|
||||
{ key: 'mileageDeduction', label: '里程减免金额', type: 'number' },
|
||||
{ key: 'otherDeduction', label: '其他减免金额', type: 'number' },
|
||||
{ key: 'receivedAmount', label: '实收金额', type: 'number' },
|
||||
{ key: 'invoiceDate', label: '开票日期', type: 'date' },
|
||||
{ key: 'actualPaymentDate', label: '实际付款日期', type: 'date' },
|
||||
{ key: 'paymentMethod', label: '付款方式', type: 'text' },
|
||||
{ key: 'vehicleStandardCost', label: '车辆标准成本', type: 'number' },
|
||||
{ key: 'insuranceFee', label: '保险费', type: 'number' },
|
||||
{ key: 'opsFeeCost', label: '运维费(成本)', type: 'number' },
|
||||
{ key: 'brokerageFee', label: '居间费', type: 'number' },
|
||||
{ key: 'otherCost', label: '其他', type: 'number' },
|
||||
{ key: 'gpsMileage', label: 'GPS里程', type: 'text' },
|
||||
{ key: 'otherDeductionDetail', label: '其他减免金额明细内容', type: 'text' },
|
||||
{ key: 'assetOwner', label: '资产归属', type: 'text' },
|
||||
{ key: 'signingCompany', label: '签约公司', type: 'text' },
|
||||
{ key: 'remark', label: '备注', type: 'text' },
|
||||
];
|
||||
|
||||
export function draftFromRow(row: LeaseBusinessDetailRow): Record<string, string | number> {
|
||||
const draft: Record<string, string | number> = {};
|
||||
DETAIL_EDIT_FIELDS.forEach((field) => {
|
||||
const value = row[field.key];
|
||||
if (field.key === 'deposit') {
|
||||
draft[field.key] = value === '/' ? '/' : String(value ?? '');
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
draft[field.key] = value;
|
||||
return;
|
||||
}
|
||||
draft[field.key] = String(value ?? '');
|
||||
});
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function parseDraftToPatch(
|
||||
draft: Record<string, string | number>,
|
||||
): Partial<LeaseBusinessDetailRow> {
|
||||
const patch: Partial<LeaseBusinessDetailRow> = {};
|
||||
DETAIL_EDIT_FIELDS.forEach((field) => {
|
||||
const raw = draft[field.key];
|
||||
if (field.key === 'deposit') {
|
||||
const text = String(raw ?? '').trim();
|
||||
patch.deposit = text === '/' || text === '' ? '/' : Number(text);
|
||||
return;
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
const num = Number(raw);
|
||||
patch[field.key] = Number.isFinite(num) ? num : 0;
|
||||
return;
|
||||
}
|
||||
patch[field.key] = String(raw ?? '');
|
||||
});
|
||||
return patch;
|
||||
}
|
||||
172
src/prototypes/lease-business-detail/utils/field-checks.ts
Normal file
172
src/prototypes/lease-business-detail/utils/field-checks.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import {
|
||||
getContractRef,
|
||||
getVehicleRef,
|
||||
isKnownCustomerName,
|
||||
isPlateInVehicleSystem,
|
||||
resolveExpectedCustomer,
|
||||
resolveSettlementMonths,
|
||||
} from './system-ref';
|
||||
|
||||
export type FieldCheckStatus = 'none' | 'ok' | 'warn';
|
||||
|
||||
export interface FieldCheckInfo {
|
||||
status: FieldCheckStatus;
|
||||
title?: string;
|
||||
details?: string[];
|
||||
replaceAction?: Partial<LeaseBusinessDetailRow>;
|
||||
}
|
||||
|
||||
function roundMoney(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function moneyEqual(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < 0.005;
|
||||
}
|
||||
|
||||
function ok(details: string[]): FieldCheckInfo {
|
||||
return { status: 'ok', title: '与系统一致', details };
|
||||
}
|
||||
|
||||
function warn(title: string, details: string[], replaceAction?: Partial<LeaseBusinessDetailRow>): FieldCheckInfo {
|
||||
return { status: 'warn', title, details, replaceAction };
|
||||
}
|
||||
|
||||
function none(): FieldCheckInfo {
|
||||
return { status: 'none' };
|
||||
}
|
||||
|
||||
export function checkPlateExists(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
if (!row.plateNo) return none();
|
||||
if (isPlateInVehicleSystem(row.plateNo)) return ok(['车牌已在车辆管理中登记']);
|
||||
return warn('车牌不存在', ['该车牌号码未在系统「车辆管理」中登记,无法导入']);
|
||||
}
|
||||
|
||||
export function checkCustomerName(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
if (!row.plateNo || !row.customerName) return none();
|
||||
const contract = getContractRef(row.plateNo, row.customerName);
|
||||
const expected = resolveExpectedCustomer(row.plateNo, row.pickupDate, row.returnDate) || contract?.customerName || '';
|
||||
if (expected && row.customerName.trim() === expected.trim()) {
|
||||
return ok(['客户名称与系统登记一致']);
|
||||
}
|
||||
if (contract && row.customerName.trim() === contract.customerName.trim()) {
|
||||
return ok(['客户名称与租赁合同一致']);
|
||||
}
|
||||
if (expected && row.customerName.trim() !== expected.trim()) {
|
||||
return warn(
|
||||
'客户名称与系统不对应',
|
||||
['客户名称与系统「客户管理」不对应', `建议客户:${expected}`],
|
||||
{ customerName: expected },
|
||||
);
|
||||
}
|
||||
if (isKnownCustomerName(row.customerName)) {
|
||||
return ok(['客户名称已在客户管理中登记']);
|
||||
}
|
||||
return warn(
|
||||
'客户名称与系统不对应',
|
||||
['客户名称与系统「客户管理」不对应', '请核对提车日期与退车日期'],
|
||||
);
|
||||
}
|
||||
|
||||
export function checkPickupDate(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
const vehicle = getVehicleRef(row.plateNo);
|
||||
if (!vehicle || !row.pickupDate) return none();
|
||||
const systemDate = vehicle.deliveryDate;
|
||||
if (!systemDate) return none();
|
||||
if (row.pickupDate.slice(0, 10) === systemDate) return ok(['提车日期与交车管理一致']);
|
||||
return warn('提车日期不一致', [`交车管理提车日期:${systemDate}`], { pickupDate: systemDate });
|
||||
}
|
||||
|
||||
export function checkReturnDate(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
const vehicle = getVehicleRef(row.plateNo);
|
||||
if (!vehicle || !row.returnDate) return none();
|
||||
const systemDate = vehicle.returnDate;
|
||||
if (!systemDate) return none();
|
||||
if (row.returnDate.slice(0, 10) === systemDate) return ok(['退车日期与还车记录一致']);
|
||||
return warn('退车日期不一致', [`还车记录日期:${systemDate}`], { returnDate: systemDate });
|
||||
}
|
||||
|
||||
export function checkDeposit(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
const contract = getContractRef(row.plateNo, row.customerName);
|
||||
if (!contract || typeof row.deposit !== 'number') return none();
|
||||
if (moneyEqual(row.deposit, contract.deposit)) return ok(['押金与合同保证金一致']);
|
||||
return warn('押金不一致', [`合同保证金:${contract.deposit.toFixed(2)}`], { deposit: contract.deposit });
|
||||
}
|
||||
|
||||
export function checkContractRent(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
const contract = getContractRef(row.plateNo, row.customerName);
|
||||
if (!contract || !row.contractRent) return none();
|
||||
if (moneyEqual(row.contractRent, contract.contractRent)) return ok(['租金与合同一致']);
|
||||
return warn('租金不一致', [`合同租金:${contract.contractRent.toFixed(2)}`], { contractRent: contract.contractRent });
|
||||
}
|
||||
|
||||
export function checkReceivableRent(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
const months = resolveSettlementMonths(row);
|
||||
const expected = roundMoney(row.contractRent * months);
|
||||
if (!expected && !row.receivableRent) return none();
|
||||
if (moneyEqual(row.receivableRent, expected)) return ok([`应收租金 = 合同标的租金 × ${months} 个月`]);
|
||||
return warn('应收租金不一致', [`正确金额:${expected.toFixed(2)}`], { receivableRent: expected });
|
||||
}
|
||||
|
||||
export function checkMileageDeduction(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
if (row.mileageDeduction === 0) return none();
|
||||
if (row.mileageDeduction < 0) return ok(['里程减免以负数显示']);
|
||||
return warn('里程减免金额应为负数', ['请录入负值,例如 -500']);
|
||||
}
|
||||
|
||||
export function checkOtherDeduction(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
if (row.otherDeduction === 0) return none();
|
||||
if (row.otherDeduction < 0) return ok(['其他减免以负数显示']);
|
||||
return warn('其他减免金额应为负数', ['请录入负值']);
|
||||
}
|
||||
|
||||
export function checkAssetOwner(row: LeaseBusinessDetailRow): FieldCheckInfo {
|
||||
const vehicle = getVehicleRef(row.plateNo);
|
||||
if (!vehicle || !row.assetOwner) return none();
|
||||
const expected = vehicle.registeredOwnership;
|
||||
if (!expected) return none();
|
||||
if (row.assetOwner.trim() === expected.trim()) return ok(['与车辆登记所有权一致']);
|
||||
return warn('资产归属不一致', [`登记所有权:${expected}`], { assetOwner: expected });
|
||||
}
|
||||
|
||||
export function getFieldCheck(
|
||||
row: LeaseBusinessDetailRow,
|
||||
field:
|
||||
| 'plateNo'
|
||||
| 'customerName'
|
||||
| 'pickupDate'
|
||||
| 'returnDate'
|
||||
| 'deposit'
|
||||
| 'contractRent'
|
||||
| 'receivableRent'
|
||||
| 'mileageDeduction'
|
||||
| 'otherDeduction'
|
||||
| 'assetOwner',
|
||||
): FieldCheckInfo {
|
||||
switch (field) {
|
||||
case 'plateNo': return checkPlateExists(row);
|
||||
case 'customerName': return checkCustomerName(row);
|
||||
case 'pickupDate': return checkPickupDate(row);
|
||||
case 'returnDate': return checkReturnDate(row);
|
||||
case 'deposit': return checkDeposit(row);
|
||||
case 'contractRent': return checkContractRent(row);
|
||||
case 'receivableRent': return checkReceivableRent(row);
|
||||
case 'mileageDeduction': return checkMileageDeduction(row);
|
||||
case 'otherDeduction': return checkOtherDeduction(row);
|
||||
case 'assetOwner': return checkAssetOwner(row);
|
||||
default: return none();
|
||||
}
|
||||
}
|
||||
|
||||
export function displayDeductionAmount(value: number): string {
|
||||
if (value === 0) return '';
|
||||
const abs = Math.abs(value);
|
||||
const text = abs.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return value > 0 ? `-${text}` : `-${text}`;
|
||||
}
|
||||
|
||||
export function normalizeDeductionForDisplay(value: number): number {
|
||||
if (value === 0) return 0;
|
||||
return value > 0 ? -Math.abs(value) : value;
|
||||
}
|
||||
100
src/prototypes/lease-business-detail/utils/hydrogen-fee.ts
Normal file
100
src/prototypes/lease-business-detail/utils/hydrogen-fee.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import seedRecords from '../data/hydrogen-refuel-records.json';
|
||||
|
||||
export interface HydrogenRefuelRecord {
|
||||
hydrogenTime: string;
|
||||
stationName: string;
|
||||
customerName: string;
|
||||
plateNo: string;
|
||||
hydrogenKg: number;
|
||||
unitPrice: number;
|
||||
totalPrice: number;
|
||||
borneBy: '我司承担' | '客户承担';
|
||||
}
|
||||
|
||||
const HYDROGEN_REFUEL_RECORDS = seedRecords as HydrogenRefuelRecord[];
|
||||
|
||||
function roundMoney(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function parseDateOnly(value: string): Date | null {
|
||||
if (!value) return null;
|
||||
const datePart = value.trim().slice(0, 10);
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart);
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year: number, month: number): Date {
|
||||
return new Date(year, month, 0);
|
||||
}
|
||||
|
||||
function firstDayOfMonth(year: number, month: number): Date {
|
||||
return new Date(year, month - 1, 1);
|
||||
}
|
||||
|
||||
/** 提车~退车与账单月份的交集,作为加氢记录筛选区间 */
|
||||
function resolveHydrogenDateRange(
|
||||
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'pickupDate' | 'returnDate'>,
|
||||
): { start: Date; end: Date } | null {
|
||||
const pickup = parseDateOnly(row.pickupDate ?? '');
|
||||
if (!pickup) return null;
|
||||
|
||||
const monthStart = firstDayOfMonth(row.year, row.month);
|
||||
const monthEnd = lastDayOfMonth(row.year, row.month);
|
||||
const returnDate = parseDateOnly(row.returnDate ?? '');
|
||||
const leaseEnd = returnDate ?? monthEnd;
|
||||
|
||||
const start = pickup > monthStart ? pickup : monthStart;
|
||||
const end = leaseEnd < monthEnd ? leaseEnd : monthEnd;
|
||||
if (start > end) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function recordDate(record: HydrogenRefuelRecord): Date | null {
|
||||
return parseDateOnly(record.hydrogenTime);
|
||||
}
|
||||
|
||||
function normalizeCustomer(value: string): string {
|
||||
return (value ?? '').trim();
|
||||
}
|
||||
|
||||
export function getHydrogenRefuelRecords(
|
||||
row: Pick<
|
||||
LeaseBusinessDetailRow,
|
||||
'plateNo' | 'customerName' | 'year' | 'month' | 'pickupDate' | 'returnDate'
|
||||
>,
|
||||
records: HydrogenRefuelRecord[] = HYDROGEN_REFUEL_RECORDS,
|
||||
): HydrogenRefuelRecord[] {
|
||||
const range = resolveHydrogenDateRange(row);
|
||||
if (!range) return [];
|
||||
|
||||
const customer = normalizeCustomer(row.customerName);
|
||||
return records.filter((item) => {
|
||||
if (item.plateNo !== row.plateNo) return false;
|
||||
if (normalizeCustomer(item.customerName) !== customer) return false;
|
||||
if (item.borneBy !== '我司承担') return false;
|
||||
const date = recordDate(item);
|
||||
if (!date) return false;
|
||||
return date >= range.start && date <= range.end;
|
||||
});
|
||||
}
|
||||
|
||||
export function sumHydrogenFeeForDetailRow(
|
||||
row: Pick<
|
||||
LeaseBusinessDetailRow,
|
||||
'plateNo' | 'customerName' | 'year' | 'month' | 'pickupDate' | 'returnDate'
|
||||
>,
|
||||
records?: HydrogenRefuelRecord[],
|
||||
): number {
|
||||
return roundMoney(
|
||||
getHydrogenRefuelRecords(row, records)
|
||||
.reduce((sum, item) => sum + item.totalPrice, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export { HYDROGEN_REFUEL_RECORDS };
|
||||
37
src/prototypes/lease-business-detail/utils/payment-status.ts
Normal file
37
src/prototypes/lease-business-detail/utils/payment-status.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type DetailPaymentStatus = '未收款' | '部分收款' | '已结清';
|
||||
|
||||
export const DETAIL_PAYMENT_STATUS_OPTIONS: DetailPaymentStatus[] = [
|
||||
'未收款',
|
||||
'部分收款',
|
||||
'已结清',
|
||||
];
|
||||
|
||||
const STATUS_SORT_ORDER: Record<DetailPaymentStatus, number> = {
|
||||
未收款: 0,
|
||||
部分收款: 1,
|
||||
已结清: 2,
|
||||
};
|
||||
|
||||
export const DETAIL_PAYMENT_STATUS_CLASS: Record<DetailPaymentStatus, string> = {
|
||||
未收款: 'ldb-status--unpaid',
|
||||
部分收款: 'ldb-status--partial',
|
||||
已结清: 'ldb-status--paid',
|
||||
};
|
||||
|
||||
/** 根据实收金额与应收合计计算收款状态 */
|
||||
export function resolveDetailPaymentStatus(
|
||||
receivableTotal: number,
|
||||
receivedAmount: number | null | undefined,
|
||||
): DetailPaymentStatus {
|
||||
const received = receivedAmount ?? 0;
|
||||
if (received <= 0) return '未收款';
|
||||
if (received >= receivableTotal) return '已结清';
|
||||
return '部分收款';
|
||||
}
|
||||
|
||||
export function paymentStatusSortValue(
|
||||
receivableTotal: number,
|
||||
receivedAmount: number | null | undefined,
|
||||
): number {
|
||||
return STATUS_SORT_ORDER[resolveDetailPaymentStatus(receivableTotal, receivedAmount)];
|
||||
}
|
||||
32
src/prototypes/lease-business-detail/utils/role.ts
Normal file
32
src/prototypes/lease-business-detail/utils/role.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type DetailViewerRole = 'staff' | 'supervisor';
|
||||
|
||||
export interface DetailOperator {
|
||||
id: string;
|
||||
name: string;
|
||||
role: DetailViewerRole;
|
||||
}
|
||||
|
||||
const STAFF_OPERATOR: DetailOperator = {
|
||||
id: 'staff-001',
|
||||
name: '刘念念',
|
||||
role: 'staff',
|
||||
};
|
||||
|
||||
const SUPERVISOR_OPERATOR: DetailOperator = {
|
||||
id: 'supervisor-001',
|
||||
name: '王主管',
|
||||
role: 'supervisor',
|
||||
};
|
||||
|
||||
/** 原型默认业管人员;主管预览 URL 追加 ?role=supervisor */
|
||||
export function resolveDetailOperator(): DetailOperator {
|
||||
if (typeof window === 'undefined') return STAFF_OPERATOR;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const role = params.get('role');
|
||||
if (role === 'supervisor' || role === 'manager') return SUPERVISOR_OPERATOR;
|
||||
return STAFF_OPERATOR;
|
||||
}
|
||||
|
||||
export function isDetailSupervisor(operator: DetailOperator): boolean {
|
||||
return operator.role === 'supervisor';
|
||||
}
|
||||
205
src/prototypes/lease-business-detail/utils/system-ref.ts
Normal file
205
src/prototypes/lease-business-detail/utils/system-ref.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import vehicles from '../../vehicle-management/data/vehicles.json';
|
||||
import customers from '../../customer-management/data/customers.json';
|
||||
import seedRows from '../data/rows.json';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
|
||||
export interface VehicleRef {
|
||||
plateNo: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
brandModel: string;
|
||||
registeredOwnership: string;
|
||||
deliveryDate: string;
|
||||
returnDate: string;
|
||||
}
|
||||
|
||||
export interface ContractRef {
|
||||
businessDept: string;
|
||||
salesperson: string;
|
||||
customerName: string;
|
||||
deposit: number;
|
||||
contractRent: number;
|
||||
settlementMonths: number;
|
||||
paymentTiming: 'pre' | 'post';
|
||||
signingCompany: string;
|
||||
pickupDate: string;
|
||||
returnDate: string;
|
||||
}
|
||||
|
||||
type VehicleSeed = {
|
||||
plateNo?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
ownership?: string;
|
||||
lastDeliveryTime?: string;
|
||||
lastReturnTime?: string;
|
||||
};
|
||||
|
||||
const plateRegistry = new Map<string, VehicleRef>();
|
||||
const contractByPlateCustomer = new Map<string, ContractRef>();
|
||||
const customerNameSet = new Set<string>();
|
||||
|
||||
function parseBrandModel(vehicleType: string): { brand: string; model: string } {
|
||||
const raw = String(vehicleType || '').trim();
|
||||
if (!raw) return { brand: '', model: '' };
|
||||
const slash = raw.indexOf('/');
|
||||
if (slash >= 0) {
|
||||
return { brand: raw.slice(0, slash).trim(), model: raw.slice(slash + 1).trim() };
|
||||
}
|
||||
const digit = raw.search(/\d/);
|
||||
if (digit > 0) {
|
||||
return { brand: raw.slice(0, digit).trim(), model: raw.slice(digit).trim() };
|
||||
}
|
||||
return { brand: raw, model: '' };
|
||||
}
|
||||
|
||||
function formatBrandModel(brand: string, model: string): string {
|
||||
if (brand && model) return `${brand}·${model}`;
|
||||
return brand || model || '';
|
||||
}
|
||||
|
||||
function normalizeDate(value: unknown): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw || raw === '-') return '';
|
||||
return raw.slice(0, 10);
|
||||
}
|
||||
|
||||
function parsePaymentTiming(method: string): 'pre' | 'post' {
|
||||
return method.includes('后付') ? 'post' : 'pre';
|
||||
}
|
||||
|
||||
function parseSettlementMonths(method: string): number {
|
||||
if (method.includes('年度') || method.includes('一年')) return 12;
|
||||
if (method.includes('半年')) return 6;
|
||||
if (method.includes('季度')) return 3;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function contractKey(plateNo: string, customerName: string): string {
|
||||
return `${plateNo}::${customerName}`;
|
||||
}
|
||||
|
||||
function registerVehicle(ref: VehicleRef): void {
|
||||
plateRegistry.set(ref.plateNo, ref);
|
||||
}
|
||||
|
||||
function registerContract(plateNo: string, customerName: string, row: Partial<LeaseBusinessDetailRow>): void {
|
||||
if (!plateNo || !customerName) return;
|
||||
const key = contractKey(plateNo, customerName);
|
||||
if (contractByPlateCustomer.has(key)) return;
|
||||
contractByPlateCustomer.set(key, {
|
||||
businessDept: row.businessDept ?? '',
|
||||
salesperson: row.salesperson ?? '',
|
||||
customerName,
|
||||
deposit: typeof row.deposit === 'number' ? row.deposit : 0,
|
||||
contractRent: row.contractRent ?? 0,
|
||||
settlementMonths: parseSettlementMonths(row.paymentMethod ?? ''),
|
||||
paymentTiming: parsePaymentTiming(row.paymentMethod ?? ''),
|
||||
signingCompany: row.signingCompany ?? '',
|
||||
pickupDate: normalizeDate(row.pickupDate),
|
||||
returnDate: normalizeDate(row.returnDate),
|
||||
});
|
||||
}
|
||||
|
||||
function bootstrap(): void {
|
||||
(customers as { name?: string }[]).forEach((c) => {
|
||||
if (c.name) customerNameSet.add(c.name.trim());
|
||||
});
|
||||
|
||||
(vehicles as VehicleSeed[]).forEach((v) => {
|
||||
if (!v.plateNo) return;
|
||||
registerVehicle({
|
||||
plateNo: v.plateNo,
|
||||
brand: v.brand ?? '',
|
||||
model: v.model ?? '',
|
||||
brandModel: formatBrandModel(v.brand ?? '', v.model ?? ''),
|
||||
registeredOwnership: v.ownership ?? '',
|
||||
deliveryDate: normalizeDate(v.lastDeliveryTime),
|
||||
returnDate: normalizeDate(v.lastReturnTime),
|
||||
});
|
||||
});
|
||||
|
||||
(seedRows as LeaseBusinessDetailRow[]).forEach((row) => {
|
||||
if (!row.plateNo) return;
|
||||
const parsed = parseBrandModel(row.vehicleType);
|
||||
const existing = plateRegistry.get(row.plateNo);
|
||||
if (!existing) {
|
||||
registerVehicle({
|
||||
plateNo: row.plateNo,
|
||||
brand: parsed.brand,
|
||||
model: parsed.model,
|
||||
brandModel: formatBrandModel(parsed.brand, parsed.model) || row.vehicleType,
|
||||
registeredOwnership: row.assetOwner ?? '',
|
||||
deliveryDate: normalizeDate(row.pickupDate),
|
||||
returnDate: normalizeDate(row.returnDate),
|
||||
});
|
||||
} else if (!existing.brandModel && row.vehicleType) {
|
||||
existing.brandModel = row.vehicleType;
|
||||
}
|
||||
if (row.customerName) customerNameSet.add(row.customerName.trim());
|
||||
registerContract(row.plateNo, row.customerName, row);
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
export function isPlateInVehicleSystem(plateNo: string): boolean {
|
||||
return Boolean(plateNo && plateRegistry.has(plateNo.trim()));
|
||||
}
|
||||
|
||||
export function getVehicleRef(plateNo: string): VehicleRef | undefined {
|
||||
return plateRegistry.get(plateNo.trim());
|
||||
}
|
||||
|
||||
export function getContractRef(plateNo: string, customerName: string): ContractRef | undefined {
|
||||
return contractByPlateCustomer.get(contractKey(plateNo.trim(), customerName.trim()));
|
||||
}
|
||||
|
||||
export function resolveExpectedCustomer(
|
||||
plateNo: string,
|
||||
pickupDate: string,
|
||||
returnDate: string,
|
||||
): string {
|
||||
const vehicle = getVehicleRef(plateNo);
|
||||
const pickup = normalizeDate(pickupDate);
|
||||
const ret = normalizeDate(returnDate);
|
||||
let best = '';
|
||||
contractByPlateCustomer.forEach((contract, key) => {
|
||||
if (!key.startsWith(`${plateNo.trim()}::`)) return;
|
||||
if (pickup && contract.pickupDate && contract.pickupDate !== pickup) return;
|
||||
if (ret && contract.returnDate && contract.returnDate !== ret) return;
|
||||
best = contract.customerName;
|
||||
});
|
||||
if (best) return best;
|
||||
return vehicle ? '' : '';
|
||||
}
|
||||
|
||||
export function isKnownCustomerName(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return false;
|
||||
if (customerNameSet.has(trimmed)) return true;
|
||||
return Array.from(customerNameSet).some((c) => c.includes(trimmed) || trimmed.includes(c));
|
||||
}
|
||||
|
||||
export function formatPaymentMethodLabel(months: number, timing: 'pre' | 'post'): string {
|
||||
const period =
|
||||
months === 12 ? '年度'
|
||||
: months === 6 ? '半年'
|
||||
: months === 3 ? '季度'
|
||||
: months === 1 ? '月度'
|
||||
: `${months}个月`;
|
||||
return `${period}${timing === 'post' ? '后付' : '预付'}`;
|
||||
}
|
||||
|
||||
export function resolvePaymentMethodLabel(row: LeaseBusinessDetailRow): string {
|
||||
const contract = getContractRef(row.plateNo, row.customerName);
|
||||
if (contract) {
|
||||
return formatPaymentMethodLabel(contract.settlementMonths, contract.paymentTiming);
|
||||
}
|
||||
return row.paymentMethod;
|
||||
}
|
||||
|
||||
export function resolveSettlementMonths(row: LeaseBusinessDetailRow): number {
|
||||
const contract = getContractRef(row.plateNo, row.customerName);
|
||||
return contract?.settlementMonths ?? parseSettlementMonths(row.paymentMethod);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783589987166,
|
||||
"updatedAt": 1783800844966,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lbl-filter",
|
||||
@@ -1158,36 +1158,6 @@
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "oneos-project-nav",
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
"title": "OneOS",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-workbench",
|
||||
"title": "工作台",
|
||||
"href": "/prototypes/oneos-web-workbench",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "lbl-doc-root",
|
||||
|
||||
@@ -109,8 +109,8 @@ export function AccidentLinkModal({
|
||||
</p>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm}>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm} data-vm-icon="send">
|
||||
确认关联
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -33,8 +33,8 @@ export function ConfirmMarkModal({ open, count, onCancel, onConfirm }: ConfirmMa
|
||||
<p className="ldb-confirm-meta">已选择 {count} 条草稿记录</p>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}>确认</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm} data-vm-icon="send">确认</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,8 @@ export function DeleteConfirmModal({ open, plateNo = '', meta, onCancel, onConfi
|
||||
<p className="ldb-confirm-meta">{meta ?? (plateNo ? `车牌:${plateNo}` : '')}</p>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-danger" onClick={onConfirm}>确认删除</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-danger" onClick={onConfirm} data-vm-icon="trash">确认删除</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,21 +218,19 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="ldb-filter-expand-panel"
|
||||
>
|
||||
|
||||
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
{extraActiveCount > 0 && !expanded ? (
|
||||
<span className="ldb-filter-toggle-badge" aria-label={`已设置 ${extraActiveCount} 项扩展筛选`}>
|
||||
{extraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -38,9 +38,7 @@ export function LeaseLedgerImportModal({
|
||||
<span className="vm-step-badge">1</span>
|
||||
<span>下载导入模板</span>
|
||||
</div>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate}>
|
||||
<Download size={16} aria-hidden />
|
||||
下载台账导入模板
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">下载台账导入模板
|
||||
</button>
|
||||
</section>
|
||||
<section className="vm-import-step">
|
||||
@@ -71,7 +69,7 @@ export function LeaseLedgerImportModal({
|
||||
</section>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>关闭</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp, History } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp } from 'lucide-react';
|
||||
import { OperationActions } from '../../../common/OperationActions';
|
||||
import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceLedgerRecord, ReceiptRecord, ReturnSettlementFeeRecord } from '../types';
|
||||
import {
|
||||
calcMonthlyIncome,
|
||||
@@ -50,7 +51,7 @@ interface LeaseLedgerTableProps {
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
const CHECK_COL_WIDTH = 48;
|
||||
const ACTION_COL_WIDTH = 208;
|
||||
const ACTION_COL_WIDTH = 168;
|
||||
|
||||
function buildLeftStickyLayout(columnWidths: Record<string, number>) {
|
||||
const keys: string[] = [];
|
||||
@@ -539,7 +540,7 @@ function renderCell(
|
||||
label={formatMoney(row.receivedAmount)}
|
||||
title="收款明细"
|
||||
footer={showReceiptLink ? (
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => ctx.onLinkReceipt(row)}>
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => ctx.onLinkReceipt(row)} data-vm-icon="plus">
|
||||
关联收款
|
||||
</button>
|
||||
) : undefined}
|
||||
@@ -907,26 +908,23 @@ export function LeaseLedgerTable({
|
||||
</td>
|
||||
))}
|
||||
<td className="sticky-col-right ldb-col-actions">
|
||||
<div className="ldb-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ldb-action-btn ldb-action-btn--history ldb-cell-clickable"
|
||||
onClick={() => setHistoryRow(row)}
|
||||
>
|
||||
<History size={14} aria-hidden />
|
||||
操作记录
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{editingRowId === row.id && canEdit && (
|
||||
<button type="button" className="ldb-action-btn ldb-action-btn--save" onClick={() => onSave(row)}>
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<OperationActions
|
||||
edit={canEdit ? { onClick: () => onEdit(row) } : undefined}
|
||||
more={[
|
||||
{
|
||||
key: 'history',
|
||||
label: '操作记录',
|
||||
onClick: () => setHistoryRow(row),
|
||||
},
|
||||
...(editingRowId === row.id && canEdit
|
||||
? [{
|
||||
key: 'save',
|
||||
label: '保存',
|
||||
onClick: () => onSave(row),
|
||||
}]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -118,9 +118,9 @@ function LedgerLineItemsEditor({
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-line-items-add"
|
||||
data-vm-icon="plus"
|
||||
onClick={() => onChange([...items, { ...EMPTY_ITEM }])}
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
添加一行
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -107,8 +107,8 @@ export function MaintenanceLinkModal({
|
||||
</p>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm}>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm} data-vm-icon="send">
|
||||
确认关联
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function OperationHistoryModal({ open, plateNo, entries, onClose }: Opera
|
||||
)}
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose}>关闭</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose} data-vm-icon="x">关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,8 +164,8 @@ export function ReceiptLinkModal({
|
||||
</div>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm}>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x">取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" disabled={selected.size === 0} onClick={handleConfirm} data-vm-icon="send">
|
||||
确认关联
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -7,11 +7,11 @@ import './styles/index.css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Archive, Download, FileUp, Save } from 'lucide-react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
type AnnotationViewerOptions
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import seedRows from './data/rows.json';
|
||||
import seedReceipts from './data/receipts.json';
|
||||
import seedReturnSettlementFees from './data/return-settlement-fees.json';
|
||||
@@ -365,12 +365,10 @@ export default function LeaseBusinessLedgerApp() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => setImportOpen(true)}>
|
||||
<FileUp size={16} aria-hidden />
|
||||
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" data-vm-icon="import" onClick={() => setImportOpen(true)}>
|
||||
批量导入
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={() => showToast(`导出 ${listFiltered.length} 条(原型演示)`)}>
|
||||
<Download size={16} aria-hidden />
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" data-vm-icon="download" onClick={() => showToast(`导出 ${listFiltered.length} 条(原型演示)`)}>
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
@@ -378,17 +376,15 @@ export default function LeaseBusinessLedgerApp() {
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
data-annotation-id="lbl-btn-save-unarchived"
|
||||
onClick={handleSaveUnarchived}
|
||||
>
|
||||
<Save size={16} aria-hidden />
|
||||
保存
|
||||
data-vm-icon="save">保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
data-annotation-id="lbl-btn-set-archived"
|
||||
data-vm-icon="save"
|
||||
onClick={handleSetArchived}
|
||||
>
|
||||
<Archive size={16} aria-hidden />
|
||||
设置为归档
|
||||
</button>
|
||||
</div>
|
||||
@@ -491,7 +487,7 @@ export default function LeaseBusinessLedgerApp() {
|
||||
onDownloadTemplate={downloadLedgerBatchImportTemplate}
|
||||
onImport={handleBatchImport}
|
||||
/>
|
||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
<PrototypeAnnotationHost source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-line-overview",
|
||||
"pageId": "overview",
|
||||
"updatedAt": 1783589987166,
|
||||
"updatedAt": 1783800844966,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lblo-system-intro",
|
||||
@@ -173,36 +173,6 @@
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "oneos-project-nav",
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
"title": "OneOS",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-workbench",
|
||||
"title": "工作台",
|
||||
"href": "/prototypes/oneos-web-workbench",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "lblo-doc-root",
|
||||
|
||||
@@ -176,8 +176,8 @@ export const LEASE_MODULES: LineModule[] = [
|
||||
],
|
||||
closure:
|
||||
'应收完成后,按合同约定生成交车任务;运维部对应区域操作员在交车模块查看并执行,车辆正式交付给承租方。',
|
||||
prototypeHref: '/prototypes/payment-records',
|
||||
prototypeLabel: '打开收款记录',
|
||||
prototypeHref: '/prototypes/vehicle-pickup-receivable',
|
||||
prototypeLabel: '打开提车应收款',
|
||||
},
|
||||
{
|
||||
id: 'lease-ledger',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 租赁合同管理 · 标注与原型上下文
|
||||
|
||||
> 由 `extract-annotation-source` 从运行态 `window.__AXHUB_ANNOTATION_SOURCE__` 提取,并与仓库源码对齐。
|
||||
> 原型地址:`/prototypes/lease-contract-management`
|
||||
|
||||
---
|
||||
|
||||
## 1. 提取摘要
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 标注节点数 | 22 |
|
||||
| 列表页节点 | 8(`pageId: list`) |
|
||||
| 新增页节点 | 14(`pageId: create`) |
|
||||
| 图片附件 | 无 |
|
||||
| 开发态 `source.root` | 未发布(以仓库路径为准) |
|
||||
|
||||
### 1.1 目录结构
|
||||
|
||||
```text
|
||||
租赁合同管理说明
|
||||
├── 模块总览
|
||||
├── 合同模板管理(链接)
|
||||
├── PRD 全文
|
||||
│ ├── PRD 完整文档
|
||||
│ ├── 列表页 PRD
|
||||
│ ├── 新增页 PRD
|
||||
│ └── 验收标准
|
||||
├── 列表页模块(route + 各标注说明)
|
||||
├── 新增页模块(route + 各标注说明)
|
||||
├── 操作流程(正向 / 逆向流程图)
|
||||
└── 标注查看提示
|
||||
```
|
||||
|
||||
### 1.2 标注节点索引
|
||||
|
||||
**列表页**
|
||||
|
||||
| id | 标题 |
|
||||
|---|---|
|
||||
| `lc-list-filter` | 筛选条件 |
|
||||
| `lc-list-kpi` | KPI 统计卡片 |
|
||||
| `lc-fleet-summary` | 在租车辆概览 |
|
||||
| `lc-list-toolbar` | 列表工具栏 |
|
||||
| `lc-list-table` | 合同台账列表 |
|
||||
| `lc-list-action-more` | 更多操作 |
|
||||
| `lc-action-convert-tripartite` | 转三方合同 |
|
||||
| `lc-action-trial-to-formal` | 试用转正式 |
|
||||
|
||||
**新增页**
|
||||
|
||||
| id | 标题 |
|
||||
|---|---|
|
||||
| `lc-create-main-contract` | 主体合同信息 |
|
||||
| `lc-create-signing-method` | 合同签署方式 |
|
||||
| `lc-card-signing` | 签约信息 |
|
||||
| `lc-signing-customer-principal` | 乙方负责人 |
|
||||
| `lc-create-lease-order` | 附件1:租赁订单 |
|
||||
| `lc-lease-order-brand-model` | 品牌车型与在库 |
|
||||
| `lc-lease-order-rent` | 车辆租金与最低租金 |
|
||||
| `lc-lease-order-service-content` | 服务项配置 |
|
||||
| `lc-lease-order-lease-period` | 租赁期限与到期计算 |
|
||||
| `lc-create-poa` | 授权委托书 |
|
||||
| `lc-create-contract-remark` | 合同备注 |
|
||||
| `lc-create-seal` | 用章类型 |
|
||||
| `lc-create-preview` | 实时预览 |
|
||||
| `lc-create-footer` | 底栏操作 |
|
||||
|
||||
定位器统一为:`[data-annotation-id="<id>"]`
|
||||
|
||||
---
|
||||
|
||||
## 2. 履约与交还车规则(列表子表)
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 审批未通过 | 合同下车辆**不视为已交车**;子表「交车」显示「未交车」,无租赁账单/还车应结款 |
|
||||
| 审批通过 | 方进入履约态;有实际交车时间的车辆计入「已交车辆数」 |
|
||||
| 子表还车 | **审批通过 + 已交车 + 未还车** 时,操作列显示「还车」 |
|
||||
| 已还车 | 操作列为「—」;「还车应结款」按状态展示待提交/审批中/已完成 |
|
||||
|
||||
子表列:车辆信息、提车应收款、交车、租赁账单、还车、还车应结款、交车安排、里程要求、里程完成、操作。
|
||||
|
||||
---
|
||||
|
||||
## 3. 源码入口
|
||||
|
||||
| 能力 | 文件 |
|
||||
|---|---|
|
||||
| 标注/路由入口 | `index.tsx` |
|
||||
| 列表主逻辑 | `LeaseContractManagement.jsx` |
|
||||
| 列表数据与履约规则 | `lease-contract-list-data.js` |
|
||||
| 新增页 | `LeaseContractCreate.jsx` |
|
||||
| 主体表单 | `LeaseContractEditorForm.jsx` |
|
||||
| 标注数据构建 | `scripts/build-annotation-source.mjs` |
|
||||
| 标注数据 | `annotation-source.json` |
|
||||
| PRD 全文 | `src/resources/lease-contract-management/PRD.md` |
|
||||
|
||||
同步命令:
|
||||
|
||||
```bash
|
||||
node src/prototypes/lease-contract-management/scripts/build-annotation-source.mjs
|
||||
```
|
||||
@@ -0,0 +1,371 @@
|
||||
# 租赁合同管理 · 操作流程图
|
||||
|
||||
> 以「新增合同」为起点,覆盖主流程正向操作与可逆/异常逆向操作。
|
||||
> 与列表操作列、子表交还车、签署闭环对齐原型实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览:从新增到履约结束
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph create [草拟与审批]
|
||||
A[列表 · 新增] --> B[新增/编辑页填写]
|
||||
B --> C{底栏操作}
|
||||
C -->|取消| Z1[返回列表 · 不保存]
|
||||
C -->|保存草稿| D[草稿]
|
||||
C -->|提交审核| E[待审批]
|
||||
E --> F[审批中]
|
||||
F --> G[审批通过]
|
||||
F --> H[审批驳回]
|
||||
F --> I[审批终止]
|
||||
E --> J[撤回]
|
||||
F --> J
|
||||
J --> K[撤回 / 可再编辑]
|
||||
H --> L[编辑后重提]
|
||||
L --> E
|
||||
D --> M[删除草稿]
|
||||
end
|
||||
|
||||
subgraph signing [签署闭环]
|
||||
G --> N{签署方式}
|
||||
N -->|线上电子签章| O[E签宝发送乙方]
|
||||
O --> P[签署完成归档]
|
||||
N -->|线下人工上传| Q[盖章合同补传]
|
||||
Q --> P
|
||||
end
|
||||
|
||||
subgraph fulfill [履约]
|
||||
G --> R[合同进行中]
|
||||
R --> S[提车应收款]
|
||||
S --> T[交车]
|
||||
T --> U[在租 · 租赁账单]
|
||||
U --> V[子表 · 还车]
|
||||
V --> W[还车应结款]
|
||||
W --> X[全部车辆已还车]
|
||||
X --> Y[已终止 / 已结束]
|
||||
end
|
||||
|
||||
subgraph change [进行中变更]
|
||||
R --> AA[续签 / 增车 / 转三方 / 附加费用 / 添加授权书 / 试用转正式]
|
||||
AA --> AB[变更审批]
|
||||
AB --> G
|
||||
R --> AC[主动终止]
|
||||
AC --> AD[终止审批]
|
||||
AD --> Y
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 新增合同 · 正向流程
|
||||
|
||||
### 2.1 保存草稿
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[点击新增] --> B[填写主体/订单/授权书/预览]
|
||||
B --> C[系统判定标准/非标准审批类型]
|
||||
C --> D[保存草稿]
|
||||
D --> E[合同状态: 草稿]
|
||||
E --> F[审批状态: 未提交]
|
||||
F --> G[列表可编辑/删除]
|
||||
```
|
||||
|
||||
### 2.2 提交审核 → 审批通过
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[提交审核] --> B{必填与证照校验}
|
||||
B -->|不通过| C[Toast 提示 · 停留当前页]
|
||||
B -->|通过| D[审批状态: 待审批]
|
||||
D --> E[合同状态: 已提交审批]
|
||||
E --> F[进入审批流节点]
|
||||
F --> G[审批中]
|
||||
G --> H{终审结果}
|
||||
H -->|通过| I[审批通过]
|
||||
I --> J[合同状态: 合同进行中]
|
||||
H -->|驳回| K[审批驳回 → 合同回草稿可编辑]
|
||||
H -->|终止| L[审批终止]
|
||||
```
|
||||
|
||||
### 2.3 签署闭环(审批通过后)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[审批通过] --> B{合同签署方式}
|
||||
B -->|线上电子签章| C[向乙方负责人手机号发送 E签宝]
|
||||
C --> D[乙方签署]
|
||||
D --> E[电子合同自动归档]
|
||||
B -->|线下人工上传| F[列表 · 盖章合同补传]
|
||||
F --> G[法务/业务上传 PDF/图片]
|
||||
G --> H[标记已补传 · 查看可预览下载]
|
||||
E --> I[履约开始]
|
||||
H --> I
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 新增合同 · 逆向流程
|
||||
|
||||
### 3.1 取消草拟(不保存)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[新增页] --> B[点击取消/返回列表]
|
||||
B --> C[丢弃未保存修改]
|
||||
C --> D[回到列表 · 无新记录]
|
||||
```
|
||||
|
||||
### 3.2 删除草稿
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[草稿合同] --> B[更多 · 删除合同]
|
||||
B --> C[二次确认]
|
||||
C --> D[从台账移除]
|
||||
```
|
||||
|
||||
### 3.3 撤回已提交审批
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[待审批 / 审批中] --> B[更多 · 撤回合同]
|
||||
B --> C[二次确认]
|
||||
C --> D[审批状态: 撤回]
|
||||
D --> E[可重新编辑后再次提交]
|
||||
```
|
||||
|
||||
### 3.4 审批驳回后重提
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[审批驳回] --> B[主按钮 · 编辑]
|
||||
B --> C[修改表单/预览]
|
||||
C --> D[保存草稿或提交审核]
|
||||
D --> E[重新进入审批流]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 履约正向:交车 → 还车 → 结清
|
||||
|
||||
> **前置条件**:仅 `审批通过` 的合同才产生交车/还车履约数据。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[合同进行中] --> B[办理提车应收款]
|
||||
B --> C[实际交车]
|
||||
C --> D[子表 · 交车列展示里程/交车人/时间]
|
||||
D --> E[租赁账单: 正常/欠费]
|
||||
E --> F{是否还车}
|
||||
F -->|未还车| G[子表操作列 · 还车]
|
||||
G --> H[还车管理]
|
||||
H --> I[子表 · 还车列展示还车信息]
|
||||
I --> J[还车应结款]
|
||||
J --> K{结款状态}
|
||||
K -->|待提交| L[去处理]
|
||||
K -->|审批中| M[展示审批人 · 同主表样式]
|
||||
K -->|已完成| N[查看结款]
|
||||
F -->|已还车| N
|
||||
N --> O{全部车辆已还?}
|
||||
O -->|是| P[KPI · 已终止口径]
|
||||
```
|
||||
|
||||
### 4.1 子表还车操作条件
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[展开已交车子表] --> B{审批通过?}
|
||||
B -->|否| C[交车显示未交车 · 无还车按钮]
|
||||
B -->|是| D{已交车且未还车?}
|
||||
D -->|是| E[操作列: 还车]
|
||||
D -->|否| F[操作列: —]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 履约逆向与异常
|
||||
|
||||
### 5.1 非审批通过合同不出现交车
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[待审批/审批中/驳回/撤回] --> B[即使有 planned 交车计划]
|
||||
B --> C[不计入已交车辆数]
|
||||
C --> D[子表交车列: 未交车]
|
||||
```
|
||||
|
||||
### 5.2 主动终止合同
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[合同进行中] --> B[更多 · 主动终止合同]
|
||||
B --> C[填写终止信息]
|
||||
C --> D[提交终止审批]
|
||||
D --> E{审批结果}
|
||||
E -->|通过| F[合同已终止/已结束]
|
||||
E -->|驳回| G[维持进行中]
|
||||
```
|
||||
|
||||
### 5.3 续签 / 转三方导致旧合同终止
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[进行中旧合同] --> B[续签或转三方]
|
||||
B --> C[生成新合同并走审批]
|
||||
C --> D[旧合同自动标记已终止]
|
||||
D --> E[在租统计转移至新合同]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 进行中变更 · 正向流程
|
||||
|
||||
### 6.1 新增车辆
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[合同进行中] --> B[更多 · 新增车辆]
|
||||
B --> C[打开增车编辑页]
|
||||
C --> D[仅新增车辆走审批]
|
||||
D --> E[审批通过]
|
||||
E --> F[新车生成提车应收款]
|
||||
```
|
||||
|
||||
### 6.2 续签合同
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[进行中/到期合同] --> B[更多 · 续签合同]
|
||||
B --> C[复制原合同]
|
||||
C --> D[锁定已交未还车辆]
|
||||
D --> E[可新增车辆卡片]
|
||||
E --> F[提交续签审批]
|
||||
F --> G[新合同审批通过]
|
||||
G --> H[旧合同自动终止]
|
||||
```
|
||||
|
||||
### 6.3 试用转正式
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[试用合同] --> B[更多 · 试用转正式]
|
||||
B --> C[自动拉取客户/授权人/车辆订单]
|
||||
C --> D[车辆订单可编辑]
|
||||
D --> E{同时转三方?}
|
||||
E -->|是| F[同一步维护三方协议与附件]
|
||||
E -->|否| G[仅转正式]
|
||||
F --> H[提交审批]
|
||||
G --> H
|
||||
H --> I[生成正式合同]
|
||||
```
|
||||
|
||||
### 6.4 转三方合同
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[合同进行中] --> B[更多 · 转三方合同]
|
||||
B --> C[新增多条三方协议]
|
||||
C --> D[上传协议附件与对方公函]
|
||||
D --> E[提交变更审批]
|
||||
E --> F[审批通过后沿用原签署方式]
|
||||
```
|
||||
|
||||
### 6.5 附加费用
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[合同进行中] --> B[更多 · 附加费用]
|
||||
B --> C[按车辆录入服务项与费用]
|
||||
C --> D[已有费用只读]
|
||||
D --> E[提交审批]
|
||||
E --> F[通过后按计费规则入账]
|
||||
```
|
||||
|
||||
### 6.6 添加授权委托书
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[合同进行中] --> B[更多 · 添加授权委托书]
|
||||
B --> C[维护受托人列表]
|
||||
C --> D[仅授权书走审批/签署]
|
||||
D --> E[主合同无需重签]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 进行中变更 · 逆向流程
|
||||
|
||||
### 7.1 变更审批撤回
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[增车/续签/转三方/附加费用/授权书/终止] --> B[已提交变更审批]
|
||||
B --> C[撤回合同]
|
||||
C --> D[变更单撤回]
|
||||
D --> E[原进行中合同保持不变]
|
||||
```
|
||||
|
||||
### 7.2 变更审批驳回
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[变更审批驳回] --> B[合同状态回变更/草稿口径]
|
||||
B --> C[业务修改后重新提交]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 交车安排编辑(列表与子表)
|
||||
|
||||
### 8.1 正向:修改交车安排
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[列表 · 交车安排列] --> B{可编辑?}
|
||||
B -->|未锁定| C[编辑区域/日期]
|
||||
C --> D[同步所有未交车车辆]
|
||||
D --> E[回写提车应收款 · 刷新待办]
|
||||
B -->|已锁定| F[提示在提车应收款中调整]
|
||||
```
|
||||
|
||||
### 8.2 子表:单车交车时间
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[未交车车辆行] --> B[编辑本车交车时间]
|
||||
B --> C[仅影响当前车辆]
|
||||
C --> D[不影响已交车车辆]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 操作与状态对照表
|
||||
|
||||
| 操作 | 显示条件 | 正向结果 | 逆向/回退 |
|
||||
|---|---|---|---|
|
||||
| 新增 | 工具栏 | 进入新增页 | 取消不保存 |
|
||||
| 保存草稿 | 新增/编辑页 | 草稿 | 删除草稿 |
|
||||
| 提交审核 | 新增/编辑页 | 进入审批 | 撤回 |
|
||||
| 编辑 | 草稿/驳回 | 修改后重提 | — |
|
||||
| 删除 | 草稿 | 记录删除 | — |
|
||||
| 撤回 | 待审批/审批中 | 审批撤回 | 可再提交 |
|
||||
| 盖章补传 | 审批通过+线下 | 附件归档 | — |
|
||||
| 交车 | 审批通过后履约 | 在租 | — |
|
||||
| 还车 | 子表·已交未还 | 还车应结款 | — |
|
||||
| 续签 | 进行中/到期 | 新合同+旧合同终止 | 撤回变更审批 |
|
||||
| 增车 | 进行中 | 变更审批 | 驳回/撤回 |
|
||||
| 转三方 | 进行中 | 变更审批 | 驳回/撤回 |
|
||||
| 试用转正式 | 试用合同 | 新正式合同 | 驳回/撤回 |
|
||||
| 附加费用 | 进行中 | 费用入账审批 | 驳回/撤回 |
|
||||
| 添加授权书 | 进行中 | 授权书审批 | 驳回/撤回 |
|
||||
| 主动终止 | 进行中 | 终止审批 | 驳回 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| v1.0 | 2026-07-12 | 从新增合同起梳理正向/逆向全流程;对齐审批通过后交还与子表还车规则 |
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## 4. 筛选区
|
||||
|
||||
默认展示 **第一行 4 个筛选项**(3 列栅格),点击「展开更多筛选项」显示全部 12 项;支持「重置」「查询」。
|
||||
默认展示 **第一行 4 个筛选项**(3 列栅格),点击「展开更多筛选项」显示全部 **13 项**;支持「重置」「查询」。
|
||||
|
||||
| 字段 | 控件 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -23,36 +23,45 @@
|
||||
| 项目名称 | 可搜索下拉 | 模糊匹配项目名称 |
|
||||
| 客户名称 | 可搜索下拉 | 模糊匹配客户名称 |
|
||||
| 签约公司 | 下拉 | 租赁合同签约公司枚举 |
|
||||
| 审批状态 | 多选下拉 | 全部 / 待审批 / 审批中 / 审批通过 / 审批驳回 / 未提交 |
|
||||
| 合同状态 | 多选下拉 | 全部 / 草稿 / 变更 / 合同进行中 / 到期合同 / 已提交审批 / 已结束 |
|
||||
| 审批状态 | 多选下拉 | 全部 / 未提交 / 待审批 / 审批中 / 审批通过 / 审批驳回 / 审批终止 / 撤回 |
|
||||
| 合同状态 | 多选下拉 | 全部 / 草稿 / 已提交审批 / 合同进行中 / 已终止 |
|
||||
| 业务部门 | 多选下拉 | 业务相关部门 |
|
||||
| 业务负责人 | 多选下拉 | 业务组人员 |
|
||||
| 合同类型 | 多选下拉 | 全部 / 正式合同 / 试用合同 |
|
||||
| 合同模板 | 单选下拉 | 选项来自合同模板管理已发布且启用的模板「合同名称」维度(如正式合同、试用合同、现代18吨正式合同);可清空 |
|
||||
| 标准合同名称 | 单选下拉 | **联动合同模板**:仅在选择合同模板后启用;选项为该模板类别下已发布文档名(如「2026年标准商用车租赁合同」);支持搜索;可清空 |
|
||||
| 审批类型 | 多选下拉 | 全部 / 标准合同 / 非标准合同 |
|
||||
| 创建人 | 多选下拉 | 业务相关人员 |
|
||||
| 合同结束日期 | 日期范围 | 单输入框双日历,中文界面,开始—结束精确至日 |
|
||||
|
||||
**合同模板联动规则**:
|
||||
|
||||
1. 未选合同模板时,「标准合同名称」禁用并提示「请先选择合同模板」。
|
||||
2. 切换合同模板时,自动清空已选标准合同名称。
|
||||
3. 查询时按合同记录的 `contractTemplateId`(或由合同类型推断的模板 ID)匹配;合同模板与标准合同名称可单独或组合使用。
|
||||
|
||||
**交互**:
|
||||
|
||||
- **查询**:将当前筛选条件写入「已应用筛选」,列表与 KPI 统计联动。
|
||||
- **重置**:清空为默认(多选类默认「全部」),并刷新列表。
|
||||
- **重置**:清空为默认(多选类默认「全部」,单选类清空),并刷新列表。
|
||||
|
||||
---
|
||||
|
||||
## 5. KPI 统计卡片
|
||||
|
||||
共 5 张卡片,点击切换列表筛选(与筛选区叠加生效)。
|
||||
共 5 张卡片,点击切换列表筛选(与筛选区条件 **叠加** 生效)。
|
||||
|
||||
| 卡片 | 口径 |
|
||||
| 卡片 | 口径(按合同状态) |
|
||||
|---|---|
|
||||
| 全部合同 | 不做 KPI 维度过滤 |
|
||||
| 进行中 | 至少有一辆租赁车辆已交车且尚未还车 |
|
||||
| 审批中 | 审批状态为「待审批」或「审批中」 |
|
||||
| 临期合同 | 存在在租车辆,且合同签订有效期距今日 ≤ 30 天(以最后一辆在租车辆为准) |
|
||||
| 已终止 | 全部车辆已还车;或续签/转三方产生新合同后旧合同自动标记为已终止 |
|
||||
| 草稿 | 合同状态 = 草稿 |
|
||||
| 进行中 | 合同状态 = 合同进行中 |
|
||||
| 审批中 | 合同状态 = 已提交审批 |
|
||||
| 已终止 | 合同状态 = 已终止 |
|
||||
|
||||
卡片右上角 `?` 悬停展示口径说明。
|
||||
|
||||
> **说明**:KPI 按合同状态快速分桶;交还车履约口径(已交未还、临期等)见 §7.2 子表与 §6.6 全局规则,不在 KPI 卡片中重复统计。
|
||||
|
||||
---
|
||||
|
||||
## 6. 列表工具栏
|
||||
@@ -81,24 +90,43 @@
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 车辆数 | 租赁订单车辆总数;点击弹出车辆明细表 |
|
||||
| 已交车 | 已有实际交车日期的车辆数;点击弹出已交车列表 |
|
||||
| 租赁订单 · 车辆数 | 租赁订单车辆总数;点击弹出车辆明细表 |
|
||||
| 租赁订单 · 已交车 | **审批通过**且已有实际交车日期的车辆数;点击展开已交车子表 |
|
||||
|
||||
车辆明细字段:车辆类型、品牌、型号、车牌号、实际交车日期、交车人。
|
||||
**履约规则**:非审批通过的合同不视为已交车;「已交车辆数」为 0 时按钮禁用。
|
||||
|
||||
### 7.2.1 已交车子表
|
||||
|
||||
展开「已交车辆数」后展示车辆子表,列包括:
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 车辆信息 | 车牌、VIN、品牌型号 |
|
||||
| 提车应收款 | 支付状态,可跳转详情 |
|
||||
| 交车 | 已交车展示里程/交车人/时间;未交车显示「未交车」 |
|
||||
| 租赁账单 | 正常 / 欠费(仅已交车) |
|
||||
| 还车 | 已还车展示还车信息;未还车显示「未还车」 |
|
||||
| 还车应结款 | 还车后展示结款状态;**审批中**时展示当前审批人(UI 与主表「审批状态」列一致,含节点悬停) |
|
||||
| 交车安排 | 区域与计划/实际交车日期 |
|
||||
| 里程要求 / 里程完成 | 有最低里程要求时展示 |
|
||||
| 操作 | **审批通过 + 已交车 + 未还车** 显示「还车」链接 |
|
||||
|
||||
车辆明细 Popover 字段:车辆类型、品牌、型号、车牌号、实际交车日期、交车人。
|
||||
|
||||
### 7.3 状态与业务字段
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 审批类型 | 标准合同 / 非标准合同;位于审批状态左侧 |
|
||||
| 审批状态 | 待审批 / 审批中 / 审批通过 / 审批驳回 / 未提交;「审批中」悬停展示审批流节点 |
|
||||
| 合同状态 | 草稿 / 变更 / 合同进行中 / 到期合同 / 已提交审批 / 已结束 |
|
||||
| 合同签署方式 | 线上电子签章 / 线下人工上传;可展开查看或变更 |
|
||||
| 审批状态 | 未提交 / 待审批 / 审批中 / 审批通过 / 审批驳回等;「审批中」悬停展示审批流节点与当前处理人 |
|
||||
| 签约公司 | 创建时所选甲方签约主体简称 |
|
||||
| 业务部门 / 业务负责人 | 创建时选取 |
|
||||
| 合同类型 | 正式合同 / 试用合同 |
|
||||
| 客户联系人 / 联系电话 | 来自客户档案 |
|
||||
| 被授权人 | 显示人数;点击弹出卡片查看姓名、联系方式、身份证号 |
|
||||
| 创建人 / 创建时间 | 精确至分钟 |
|
||||
| 业务部门 | 创建时选取 |
|
||||
| 费用信息 | 付款方式、周期、保证金等摘要 |
|
||||
| 交车安排 | 合同级交车区域与计划日期 |
|
||||
| 整体里程完成情况 | 按在租车辆里程汇总展示完成进度 |
|
||||
| 客户联系人 | 来自客户档案 |
|
||||
| 受托人 | 显示人数;点击弹出卡片查看姓名、联系方式、身份证号 |
|
||||
| 创建信息 | 创建人 / 创建时间,精确至分钟 |
|
||||
| 最后更新 | 更新人 / 最后更新时间,无则显示 `-` |
|
||||
|
||||
**分页**:每页 10 / 20 / 50 条可选。
|
||||
@@ -120,7 +148,7 @@
|
||||
| 添加被授权人 | 合同进行中 | 弹窗编辑被授权人列表 |
|
||||
| 附加费用 | 合同进行中 | 弹窗按车辆录入附加费用 |
|
||||
| 转三方合同 | 合同进行中 | 弹窗新增三方协议,上传协议附件与对方公函 |
|
||||
| 试用转正式 | 试用合同 | 单步弹窗:自动拉取客户/授权人/车辆订单(车辆可编辑),可选同时转三方 |
|
||||
| 试用转正式 | 合同模板为「试用合同」 | 单步弹窗:自动拉取客户/授权人/车辆订单(车辆可编辑),可选同时转三方 |
|
||||
| 终止合同 | 合同进行中 | 二次确认,提交终止审批 |
|
||||
| 上传盖章合同 | 审批通过且法务未上传 | 仅法务部可见;多文件上传后关闭入口 |
|
||||
|
||||
@@ -133,6 +161,8 @@
|
||||
| 审批中 | 已有节点通过,未完成终审 |
|
||||
| 审批通过 | 终审通过 |
|
||||
| 审批驳回 | 任一节点的驳回;可编辑重提 |
|
||||
| 审批终止 | 流程被终止 |
|
||||
| 撤回 | 提交人主动撤回 |
|
||||
|
||||
### 8.3 合同状态口径
|
||||
|
||||
@@ -140,10 +170,8 @@
|
||||
|---|---|
|
||||
| 草稿 | 未提交审批 |
|
||||
| 已提交审批 | 初次提交,终审未完成 |
|
||||
| 变更 | 已通过基础上变更并重新审批中 |
|
||||
| 合同进行中 | 终审通过且在有效期内 |
|
||||
| 到期合同 | 已过结束日期 |
|
||||
| 已结束 | 终止或自然结束 |
|
||||
| 已终止 | 终止、自然结束,或续签/转三方后旧合同自动终止 |
|
||||
|
||||
### 8.4 弹窗摘要
|
||||
|
||||
@@ -155,3 +183,7 @@
|
||||
- **转三方合同**:支持新增多条三方协议;每条可填协议名称、上传协议附件与对方公函
|
||||
- **试用转正式**:自动带出客户信息、授权人与车辆订单;车辆订单字段可编辑;可勾选「同时转三方合同」并在同一步维护三方协议
|
||||
- **上传盖章合同**:拖拽多文件;确认后标记已上传
|
||||
|
||||
### 8.5 操作流程文档
|
||||
|
||||
从「新增合同」起的正向与逆向全流程图见:`.spec/requirements-flow-operations.md`
|
||||
|
||||
@@ -15,12 +15,24 @@ import {
|
||||
import {
|
||||
getPublishedContractTemplateOptions,
|
||||
subscribePublishedContractTemplateOptions,
|
||||
getContractTemplatePreviewBadge,
|
||||
getContractTypeLabel,
|
||||
} from '../contract-template-management/contract-template-catalog.js';
|
||||
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
|
||||
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState, hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js';
|
||||
import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js';
|
||||
import {
|
||||
FLOW_MODE_CREATE,
|
||||
FLOW_MODE_EDIT,
|
||||
FLOW_MODE_ADD_VEHICLE,
|
||||
FLOW_MODE_ADD_POA,
|
||||
FLOW_MODE_TRIPARTITE,
|
||||
getFlowPageTitle,
|
||||
countNewPickupVehicles,
|
||||
} from './lease-contract-flow-bridge.js';
|
||||
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState, normalizeLeaseOrderState } from './lease-order-vars.js';
|
||||
import {
|
||||
buildLeaseContractPreviewHtml,
|
||||
extractLeaseOrderAttachment1PreviewHtml,
|
||||
extractPowerOfAttorneyPreviewHtml,
|
||||
} from './lease-contract-preview-build.js';
|
||||
import {
|
||||
resolveLeaseContractApprovalType,
|
||||
STANDARD_CONTRACT_APPROVAL,
|
||||
@@ -34,13 +46,27 @@ import {
|
||||
isPowerOfAttorneyOptionalEmpty,
|
||||
isSealTypeSelected,
|
||||
isLeaseContractFormComplete,
|
||||
isTripartitePartyComplete,
|
||||
} from './lease-contract-form-validation.js';
|
||||
import LeaseContractPreviewPanel, { PreviewEditableHint } from './LeaseContractPreviewPanel.jsx';
|
||||
import LeaseContractPreviewPanel, {
|
||||
PreviewEditableHint,
|
||||
PreviewApprovalHint,
|
||||
PreviewAttachment1ReadonlyHint,
|
||||
PreviewPowerOfAttorneyReadonlyHint,
|
||||
} from './LeaseContractPreviewPanel.jsx';
|
||||
import LeaseContractEditorForm, {
|
||||
LeaseContractLeaseOrderSection,
|
||||
LeaseContractPowerOfAttorneySection,
|
||||
LeaseContractRemarkSection,
|
||||
} from './LeaseContractEditorForm.jsx';
|
||||
import {
|
||||
CONTRACT_SIGNING_METHOD_ONLINE,
|
||||
CONTRACT_SIGNING_METHOD_OPTIONS,
|
||||
} from './lease-contract-signing.js';
|
||||
import {
|
||||
createPickupReceivableFromLeaseSubmit,
|
||||
formatLeaseContractCode,
|
||||
} from '../vehicle-pickup-receivable/pickup-receivable-bridge.js';
|
||||
|
||||
var SEAL_TYPE_OPTIONS = [
|
||||
{ value: 'contract', label: '合同章' },
|
||||
@@ -56,7 +82,49 @@ function normalizeSealTypes(types) {
|
||||
return next.length ? next : ['contract'];
|
||||
}
|
||||
|
||||
export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord }) {
|
||||
function renderCreateStepNumberIcon(step, options) {
|
||||
var opts = options || {};
|
||||
var size = opts.size || 22;
|
||||
var className = 'lc-create-step-icon' + (opts.className ? ' ' + opts.className : '');
|
||||
var hideLabel = opts.hideLabel === true;
|
||||
return React.createElement('svg', {
|
||||
className: className,
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
focusable: 'false',
|
||||
'aria-hidden': hideLabel ? true : undefined,
|
||||
role: hideLabel ? undefined : 'img',
|
||||
'aria-label': hideLabel ? undefined : ('步骤 ' + step),
|
||||
},
|
||||
React.createElement('circle', {
|
||||
cx: 12,
|
||||
cy: 12,
|
||||
r: 9.25,
|
||||
className: 'lc-create-step-icon__ring',
|
||||
}),
|
||||
React.createElement('text', {
|
||||
x: 12,
|
||||
y: 12,
|
||||
className: 'lc-create-step-icon__digit',
|
||||
}, String(step)),
|
||||
);
|
||||
}
|
||||
|
||||
function renderCreateStepTitle(step, title) {
|
||||
return React.createElement('div', { className: 'ct-panel-head__title lc-create-step-title' },
|
||||
renderCreateStepNumberIcon(step, { className: 'lc-create-step-title__badge', hideLabel: true }),
|
||||
React.createElement('span', { className: 'lc-create-step-title__text' }, title),
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord, flowMode, flowInitialFormState }) {
|
||||
var effectiveFlowMode = flowMode || (editRecord && editRecord.id ? FLOW_MODE_EDIT : FLOW_MODE_CREATE);
|
||||
var isAddVehicleFlow = effectiveFlowMode === FLOW_MODE_ADD_VEHICLE;
|
||||
var isAddPoaFlow = effectiveFlowMode === FLOW_MODE_ADD_POA;
|
||||
var isTripartiteFlow = effectiveFlowMode === FLOW_MODE_TRIPARTITE;
|
||||
var isScopedPreviewFlow = isAddVehicleFlow || isAddPoaFlow;
|
||||
var pageTitle = getFlowPageTitle(effectiveFlowMode);
|
||||
var contractTypeState = useState('');
|
||||
var contractType = contractTypeState[0];
|
||||
var setContractType = contractTypeState[1];
|
||||
@@ -72,11 +140,9 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
var setTemplateOptions = templateOptionsState[1];
|
||||
var templateSelectOptions = useMemo(function () {
|
||||
return templateOptions.map(function (option) {
|
||||
var typeLabel = getContractTypeLabel(option.contractType) || option.contractType || '—';
|
||||
var fileLabel = option.fileName || option.title || option.id;
|
||||
return {
|
||||
value: option.id,
|
||||
label: typeLabel + ' · ' + fileLabel,
|
||||
label: option.contractTypeLabel || option.title || option.kind || option.id,
|
||||
contractType: option.contractType,
|
||||
};
|
||||
});
|
||||
@@ -111,22 +177,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD));
|
||||
var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO));
|
||||
var contractCodeState = useState(function () { return generateAutoContractCodeSuffix(); });
|
||||
var projectNameState = useState('');
|
||||
var businessDeptState = useState('');
|
||||
var businessOwnerState = useState('');
|
||||
var leaseOrderState = useState(function () {
|
||||
var initial = createDefaultLeaseOrderState();
|
||||
return {
|
||||
insuredVehicleCount: initial.insuredVehicleCount,
|
||||
thirdPartyLiabilityMillion: initial.thirdPartyLiabilityMillion,
|
||||
deliveryRegion: initial.deliveryRegion.slice(),
|
||||
deliveryDate: initial.deliveryDate,
|
||||
rows: initial.rows.map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
brandModels: row.brandModels ? row.brandModels.slice() : [],
|
||||
plateNos: row.plateNos ? row.plateNos.slice() : undefined,
|
||||
});
|
||||
}),
|
||||
};
|
||||
return normalizeLeaseOrderState(createDefaultLeaseOrderState());
|
||||
});
|
||||
var lessorId = lessorIdState[0];
|
||||
var setLessorId = lessorIdState[1];
|
||||
@@ -138,6 +193,8 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
var setFeeInfo = feeInfoState[1];
|
||||
var contractCode = contractCodeState[0];
|
||||
var setContractCode = contractCodeState[1];
|
||||
var projectName = projectNameState[0];
|
||||
var setProjectName = projectNameState[1];
|
||||
var businessDept = businessDeptState[0];
|
||||
var setBusinessDept = businessDeptState[1];
|
||||
var businessOwner = businessOwnerState[0];
|
||||
@@ -163,16 +220,31 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
var customerPrincipalPhoneState = useState('');
|
||||
var customerPrincipalPhone = customerPrincipalPhoneState[0];
|
||||
var setCustomerPrincipalPhone = customerPrincipalPhoneState[1];
|
||||
var thirdPartyCustomerIdState = useState('');
|
||||
var thirdPartyCustomerId = thirdPartyCustomerIdState[0];
|
||||
var setThirdPartyCustomerId = thirdPartyCustomerIdState[1];
|
||||
var thirdPartyPrincipalNameState = useState('');
|
||||
var thirdPartyPrincipalName = thirdPartyPrincipalNameState[0];
|
||||
var setThirdPartyPrincipalName = thirdPartyPrincipalNameState[1];
|
||||
var thirdPartyPrincipalPhoneState = useState('');
|
||||
var thirdPartyPrincipalPhone = thirdPartyPrincipalPhoneState[0];
|
||||
var setThirdPartyPrincipalPhone = thirdPartyPrincipalPhoneState[1];
|
||||
var contractSigningMethodState = useState(CONTRACT_SIGNING_METHOD_ONLINE);
|
||||
var contractSigningMethod = contractSigningMethodState[0];
|
||||
var setContractSigningMethod = contractSigningMethodState[1];
|
||||
var sealTypesState = useState(['contract']);
|
||||
var sealTypes = sealTypesState[0];
|
||||
var setSealTypes = sealTypesState[1];
|
||||
var contractApprovalTypeState = useState(STANDARD_CONTRACT_APPROVAL);
|
||||
var contractApprovalType = contractApprovalTypeState[0];
|
||||
var setContractApprovalType = contractApprovalTypeState[1];
|
||||
var previewMergedHtmlState = useState('');
|
||||
var previewMergedHtml = previewMergedHtmlState[0];
|
||||
var setPreviewMergedHtml = previewMergedHtmlState[1];
|
||||
|
||||
var previewHtml = useMemo(function () {
|
||||
if (!contractTemplateId) return '';
|
||||
return buildLeaseContractPreviewHtml({
|
||||
var fullHtml = buildLeaseContractPreviewHtml({
|
||||
contractTemplateId: contractTemplateId,
|
||||
lessorId: lessorId,
|
||||
customerId: customerId,
|
||||
@@ -182,28 +254,40 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
leaseOrder: leaseOrder,
|
||||
powerOfAttorney: powerOfAttorney,
|
||||
});
|
||||
}, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney]);
|
||||
if (isAddVehicleFlow) return extractLeaseOrderAttachment1PreviewHtml(fullHtml);
|
||||
if (isAddPoaFlow) return extractPowerOfAttorneyPreviewHtml(fullHtml);
|
||||
return fullHtml;
|
||||
}, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney, isAddVehicleFlow, isAddPoaFlow]);
|
||||
|
||||
var isPreviewEditable = !isScopedPreviewFlow;
|
||||
|
||||
var handlePreviewContentChange = useCallback(function (mergedHtml) {
|
||||
if (!isPreviewEditable) return;
|
||||
setPreviewMergedHtml(mergedHtml || '');
|
||||
setContractApprovalType(resolveLeaseContractApprovalType(previewHtml, mergedHtml, {
|
||||
leaseOrder: leaseOrder,
|
||||
feeInfo: feeInfo,
|
||||
sealTypes: sealTypes,
|
||||
}));
|
||||
}, [previewHtml, leaseOrder]);
|
||||
}, [previewHtml, leaseOrder, feeInfo, sealTypes, isPreviewEditable]);
|
||||
|
||||
useEffect(function () {
|
||||
if (hasLeaseOrderRentBelowMinimum(leaseOrder)) {
|
||||
setContractApprovalType(NONSTANDARD_CONTRACT_APPROVAL);
|
||||
return;
|
||||
}
|
||||
setContractApprovalType(STANDARD_CONTRACT_APPROVAL);
|
||||
}, [previewHtml, leaseOrder]);
|
||||
setContractApprovalType(resolveLeaseContractApprovalType(
|
||||
previewHtml,
|
||||
previewMergedHtml || previewHtml,
|
||||
{
|
||||
leaseOrder: leaseOrder,
|
||||
feeInfo: feeInfo,
|
||||
sealTypes: sealTypes,
|
||||
},
|
||||
));
|
||||
}, [previewHtml, previewMergedHtml, leaseOrder, feeInfo, sealTypes]);
|
||||
|
||||
var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL;
|
||||
var isTemplateSelected = Boolean(contractTemplateId);
|
||||
var previewBadge = getContractTemplatePreviewBadge(contractTemplateId);
|
||||
|
||||
var isMainContractComplete = useMemo(function () {
|
||||
return isMainContractFormComplete({
|
||||
var baseComplete = isMainContractFormComplete({
|
||||
lessorId: lessorId,
|
||||
customerId: customerId,
|
||||
contractCode: contractCode,
|
||||
@@ -212,7 +296,15 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
mileage: mileage,
|
||||
feeInfo: feeInfo,
|
||||
});
|
||||
}, [lessorId, customerId, contractCode, businessDept, businessOwner, mileage, feeInfo]);
|
||||
if (!baseComplete) return false;
|
||||
if (isTripartiteFlow) {
|
||||
return isTripartitePartyComplete({
|
||||
customerId: customerId,
|
||||
thirdPartyCustomerId: thirdPartyCustomerId,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}, [lessorId, customerId, contractCode, businessDept, businessOwner, mileage, feeInfo, isTripartiteFlow, thirdPartyCustomerId]);
|
||||
|
||||
var isLeaseOrderComplete = useMemo(function () {
|
||||
return isLeaseOrderFormComplete({ leaseOrder: leaseOrder });
|
||||
@@ -258,11 +350,6 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
});
|
||||
}
|
||||
|
||||
function getSealTypeLabel(value) {
|
||||
var match = SEAL_TYPE_OPTIONS.find(function (item) { return item.value === value; });
|
||||
return match ? match.label : value;
|
||||
}
|
||||
|
||||
var columnRef = useRef(null);
|
||||
var sectionRefs = {
|
||||
template: useRef(null),
|
||||
@@ -284,38 +371,46 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
useEffect(function () {
|
||||
if (!editRecord || !editRecord.id) return;
|
||||
var formState = buildLeaseContractEditFormState(editRecord);
|
||||
if (!formState) return;
|
||||
applyBridgeFormState(formState);
|
||||
}, [editRecord && editRecord.id]);
|
||||
|
||||
useEffect(function () {
|
||||
if (!flowInitialFormState) return;
|
||||
applyBridgeFormState(flowInitialFormState);
|
||||
}, [flowInitialFormState && flowInitialFormState.contractCode]);
|
||||
|
||||
function applyBridgeFormState(formState) {
|
||||
if (!formState) return;
|
||||
setContractTemplateId(formState.contractTemplateId || '');
|
||||
setLessorId(formState.lessorId || '');
|
||||
setCustomerId(formState.customerId || '');
|
||||
setContractCode(formState.contractCode || '');
|
||||
setProjectName(formState.projectName || '');
|
||||
setBusinessDept(formState.businessDept || '');
|
||||
setBusinessOwner(formState.businessOwner || '');
|
||||
setMileage(Object.assign({}, formState.mileage));
|
||||
setFeeInfo(Object.assign({}, formState.feeInfo));
|
||||
setLeaseOrder({
|
||||
insuredVehicleCount: formState.leaseOrder.insuredVehicleCount,
|
||||
thirdPartyLiabilityMillion: formState.leaseOrder.thirdPartyLiabilityMillion,
|
||||
deliveryRegion: (formState.leaseOrder.deliveryRegion || []).slice(),
|
||||
deliveryDate: formState.leaseOrder.deliveryDate,
|
||||
rows: (formState.leaseOrder.rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
brandModels: row.brandModels ? row.brandModels.map(function (pair) { return pair.slice(); }) : [],
|
||||
plateNos: row.plateNos ? row.plateNos.slice() : undefined,
|
||||
extraServices: row.extraServices ? row.extraServices.slice() : [],
|
||||
});
|
||||
}),
|
||||
});
|
||||
setLeaseOrder(normalizeLeaseOrderState(formState.leaseOrder));
|
||||
setPowerOfAttorney({
|
||||
delegates: (formState.powerOfAttorney.delegates || []).map(function (row) {
|
||||
delegates: ((formState.powerOfAttorney && formState.powerOfAttorney.delegates) || []).map(function (row) {
|
||||
return Object.assign({}, row);
|
||||
}),
|
||||
});
|
||||
setContractRemark(formState.contractRemark || '');
|
||||
setCustomerPrincipalName(formState.customerPrincipalName || '');
|
||||
setCustomerPrincipalPhone(formState.customerPrincipalPhone || '');
|
||||
setThirdPartyCustomerId(formState.thirdPartyCustomerId || '');
|
||||
setThirdPartyPrincipalName(formState.thirdPartyPrincipalName || '');
|
||||
setThirdPartyPrincipalPhone(formState.thirdPartyPrincipalPhone || '');
|
||||
setSealTypes(normalizeSealTypes(formState.sealTypes));
|
||||
}, [editRecord && editRecord.id]);
|
||||
if (formState.contractType) {
|
||||
setContractType(formState.contractType);
|
||||
} else if (formState.contractTemplateId) {
|
||||
var tplMatch = templateOptions.find(function (item) { return item.id === formState.contractTemplateId; });
|
||||
if (tplMatch) setContractType(tplMatch.contractType || '');
|
||||
}
|
||||
}
|
||||
|
||||
var createSteps = useMemo(function () {
|
||||
return [
|
||||
@@ -372,20 +467,16 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
contractRemark,
|
||||
]);
|
||||
|
||||
var requiredDoneCount = useMemo(function () {
|
||||
var count = 0;
|
||||
if (isTemplateSelected) count += 1;
|
||||
if (isMainContractComplete) count += 1;
|
||||
if (isLeaseOrderComplete) count += 1;
|
||||
if (isPowerOfAttorneyComplete) count += 1;
|
||||
if (isSealTypeComplete) count += 1;
|
||||
return count;
|
||||
}, [isTemplateSelected, isMainContractComplete, isLeaseOrderComplete, isPowerOfAttorneyComplete, isSealTypeComplete]);
|
||||
|
||||
function scrollToCreateSection(stepKey) {
|
||||
var target = sectionRefs[stepKey] && sectionRefs[stepKey].current;
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
var container = columnRef.current;
|
||||
if (!target || !container) return;
|
||||
var containerTop = container.getBoundingClientRect().top;
|
||||
var targetTop = target.getBoundingClientRect().top;
|
||||
container.scrollTo({
|
||||
top: container.scrollTop + (targetTop - containerTop),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
function handleStepNavClick(step) {
|
||||
@@ -404,15 +495,72 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
role: 'region',
|
||||
'aria-label': '需先选择合同模板',
|
||||
},
|
||||
React.createElement('p', { className: 'lc-create-section__lock-text' }, '请先完成第 1 步选择合同模板,再填写本节内容。'),
|
||||
React.createElement('p', { className: 'lc-create-section__lock-text' },
|
||||
'请先完成',
|
||||
renderCreateStepNumberIcon(1, { className: 'lc-create-section__lock-step-icon', size: 18, hideLabel: true }),
|
||||
'选择合同模板,再填写本节内容。',
|
||||
),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary lc-create-section__lock-btn',
|
||||
'data-vm-icon': 'eye',
|
||||
onClick: function () { scrollToCreateSection('template'); },
|
||||
}, '前往选择模板'),
|
||||
);
|
||||
}
|
||||
|
||||
function isSectionFlowFormLocked(stepKey) {
|
||||
return isAddPoaFlow && stepKey !== 'poa';
|
||||
}
|
||||
|
||||
function renderFlowFormLock(stepKey) {
|
||||
if (!isSectionFlowFormLocked(stepKey)) return null;
|
||||
return React.createElement('div', {
|
||||
className: 'lc-create-section__lock lc-create-section__lock--flow',
|
||||
role: 'region',
|
||||
'aria-label': '本流程仅可编辑授权委托书',
|
||||
},
|
||||
React.createElement('p', { className: 'lc-create-section__lock-text' },
|
||||
'本流程仅可编辑受托人信息,其余内容沿用来源合同,无需修改。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getCreateSectionDisabledClass(stepKey) {
|
||||
if (!isTemplateSelected && stepKey !== 'template') return ' lc-create-section--disabled';
|
||||
if (isSectionFlowFormLocked(stepKey)) return ' lc-create-section--disabled';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getPreviewPanelTitle() {
|
||||
if (isAddVehicleFlow) return '附件1预览';
|
||||
if (isAddPoaFlow) return '授权委托书预览';
|
||||
return '实时预览';
|
||||
}
|
||||
|
||||
function getPreviewPanelAriaLabel() {
|
||||
return getPreviewPanelTitle();
|
||||
}
|
||||
|
||||
function renderPreviewReadonlyHint() {
|
||||
if (isAddVehicleFlow) return React.createElement(PreviewAttachment1ReadonlyHint);
|
||||
if (isAddPoaFlow) return React.createElement(PreviewPowerOfAttorneyReadonlyHint);
|
||||
return React.createElement(PreviewEditableHint);
|
||||
}
|
||||
|
||||
function getPreviewHintId() {
|
||||
if (isAddVehicleFlow) return 'lc-preview-attachment1-hint';
|
||||
if (isAddPoaFlow) return 'lc-preview-poa-hint';
|
||||
return 'lc-preview-editable-hint';
|
||||
}
|
||||
|
||||
function getPreviewPanelCardClass() {
|
||||
var cls = 'ct-editor-column-card ct-editor-column-card--preview';
|
||||
if (isAddVehicleFlow) cls += ' ct-editor-column-card--preview-attachment1';
|
||||
if (isAddPoaFlow) cls += ' ct-editor-column-card--preview-poa';
|
||||
return cls;
|
||||
}
|
||||
|
||||
function renderStepNav() {
|
||||
return React.createElement('nav', {
|
||||
className: 'lc-create-step-nav',
|
||||
@@ -435,12 +583,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
React.createElement('span', { className: 'lc-create-step-nav__index', 'aria-hidden': true },
|
||||
step.done
|
||||
? React.createElement(Check, { size: 14, strokeWidth: 2.5, 'aria-hidden': true })
|
||||
: String(index + 1),
|
||||
: renderCreateStepNumberIcon(index + 1, { className: 'lc-create-step-title__badge', hideLabel: true }),
|
||||
),
|
||||
React.createElement('span', { className: 'lc-create-step-nav__label' }, step.label),
|
||||
step.optional
|
||||
? React.createElement('span', { className: 'lc-create-step-nav__tag' }, '选填')
|
||||
: null,
|
||||
React.createElement('span', {
|
||||
className: 'lc-create-step-nav__tag' + (step.optional ? '' : ' lc-create-step-nav__tag--required'),
|
||||
}, step.optional ? '选填' : '必填'),
|
||||
),
|
||||
index < createSteps.length - 1
|
||||
? React.createElement('span', { className: 'lc-create-step-nav__sep', 'aria-hidden': true })
|
||||
@@ -458,29 +606,40 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
className: 'vm-btn vm-btn-back',
|
||||
onClick: onBack,
|
||||
}, '返回列表'),
|
||||
React.createElement('div', { className: 'lc-create-topbar__intro' },
|
||||
React.createElement('h1', { className: 'lc-create-topbar__title' }, '新增租赁合同'),
|
||||
React.createElement('p', { className: 'lc-create-topbar__meta', role: 'status' },
|
||||
'必填进度 ',
|
||||
React.createElement('strong', null, requiredDoneCount),
|
||||
' / 5',
|
||||
isTemplateSelected
|
||||
? React.createElement('span', { className: 'lc-create-topbar__meta-divider', 'aria-hidden': true }, '·')
|
||||
effectiveFlowMode !== FLOW_MODE_CREATE
|
||||
? React.createElement('span', { className: 'lc-create-topbar__title' }, pageTitle)
|
||||
: 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-workspace' },
|
||||
React.createElement(Row, { gutter: [16, 16] },
|
||||
React.createElement(Col, { xs: 24, lg: 12 },
|
||||
React.createElement('div', { className: 'lc-create-editor-column', ref: columnRef },
|
||||
React.createElement('div', { className: 'lc-create-editor-column-shell' },
|
||||
renderStepNav(),
|
||||
React.createElement('div', { className: 'lc-create-editor-column', ref: columnRef },
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card lc-template-step-card lc-create-section',
|
||||
className: 'ct-editor-column-card lc-template-step-card lc-create-section'
|
||||
+ getCreateSectionDisabledClass('template'),
|
||||
'aria-label': '选择合同类型',
|
||||
'data-annotation-id': 'lc-create-template',
|
||||
id: 'lc-create-section-template',
|
||||
@@ -491,7 +650,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
'aria-describedby': 'lc-template-step-hint',
|
||||
},
|
||||
React.createElement('div', { className: 'lc-template-step-card__title-row' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '第 1 步:选择合同类型'),
|
||||
renderCreateStepTitle(1, '选择合同类型'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-card__hint',
|
||||
role: 'note',
|
||||
@@ -503,7 +662,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
'aria-hidden': true,
|
||||
}),
|
||||
React.createElement('span', { className: 'lc-form-card__hint-text' },
|
||||
'请选择模板管理中已发布且启用的合同模板,右侧将自动生成预览。',
|
||||
'选择对应合同模板后,右侧自动生成该合同预览内容',
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -514,33 +673,56 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
}, isTemplateSelected ? '已选择' : '必选'),
|
||||
),
|
||||
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel lc-template-step-panel' },
|
||||
React.createElement('div', { className: 'lc-template-step-fields', 'data-annotation-id': 'lc-create-template-fields' },
|
||||
React.createElement('div', { className: 'lc-form-field' },
|
||||
React.createElement('label', { className: 'lc-form-field__label' }, '合同模板'),
|
||||
React.createElement('div', { className: 'lc-form-field__control' },
|
||||
React.createElement(Select, {
|
||||
className: 'lc-template-select',
|
||||
className: 'lc-form-select lc-template-select',
|
||||
value: contractTemplateId || undefined,
|
||||
placeholder: templateSelectOptions.length ? '请选择合同模板' : '暂无可用模板',
|
||||
allowClear: true,
|
||||
allowClear: !isAddPoaFlow,
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
disabled: !templateSelectOptions.length,
|
||||
disabled: !templateSelectOptions.length || isAddPoaFlow,
|
||||
onChange: handleTemplateSelect,
|
||||
options: templateSelectOptions,
|
||||
'aria-label': '合同模板',
|
||||
}),
|
||||
),
|
||||
),
|
||||
React.createElement('div', {
|
||||
className: 'lc-form-field',
|
||||
'data-annotation-id': 'lc-create-signing-method',
|
||||
},
|
||||
React.createElement('label', { className: 'lc-form-field__label' }, '合同签署方式'),
|
||||
React.createElement('div', { className: 'lc-form-field__control' },
|
||||
React.createElement(Select, {
|
||||
className: 'lc-form-select lc-template-signing-row__select',
|
||||
value: contractSigningMethod,
|
||||
options: CONTRACT_SIGNING_METHOD_OPTIONS,
|
||||
onChange: function (value) { setContractSigningMethod(value || CONTRACT_SIGNING_METHOD_ONLINE); },
|
||||
'aria-label': '合同签署方式',
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
!templateSelectOptions.length
|
||||
? React.createElement('p', { className: 'lc-template-step-empty' }, '暂无已启用的合同模板,请先在合同模板管理中发布并启用。')
|
||||
: null,
|
||||
),
|
||||
renderFlowFormLock('template'),
|
||||
),
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card lc-main-contract-card lc-create-section'
|
||||
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
|
||||
+ getCreateSectionDisabledClass('main'),
|
||||
'aria-label': '主体合同信息',
|
||||
'data-annotation-id': 'lc-create-main-contract',
|
||||
id: 'lc-create-section-main',
|
||||
ref: sectionRefs.main,
|
||||
},
|
||||
React.createElement('div', { className: 'ct-panel-head lc-main-contract-card__head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '第 2 步:主体合同信息'),
|
||||
renderCreateStepTitle(2, '主体合同信息'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-completion-badge ' + (isMainContractComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
|
||||
role: 'status',
|
||||
@@ -551,15 +733,25 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
React.createElement(LeaseContractEditorForm, {
|
||||
lessorId: lessorId,
|
||||
customerId: customerId,
|
||||
thirdPartyCustomerId: thirdPartyCustomerId,
|
||||
showThirdPartyParty: isTripartiteFlow,
|
||||
contractCode: contractCode,
|
||||
contractCodeReadonly: !(editRecord && editRecord.id),
|
||||
projectName: projectName,
|
||||
contractCodeReadonly: effectiveFlowMode !== FLOW_MODE_EDIT,
|
||||
businessDept: businessDept,
|
||||
businessOwner: businessOwner,
|
||||
mileage: mileage,
|
||||
feeInfo: feeInfo,
|
||||
onLessorChange: setLessorId,
|
||||
onCustomerChange: setCustomerId,
|
||||
onCustomerChange: function (value) {
|
||||
setCustomerId(value);
|
||||
if (thirdPartyCustomerId && thirdPartyCustomerId === value) {
|
||||
setThirdPartyCustomerId('');
|
||||
}
|
||||
},
|
||||
onThirdPartyCustomerChange: setThirdPartyCustomerId,
|
||||
onContractCodeChange: setContractCode,
|
||||
onProjectNameChange: setProjectName,
|
||||
onBusinessAssignmentChange: function (dept, owner) {
|
||||
setBusinessDept(dept);
|
||||
setBusinessOwner(owner);
|
||||
@@ -572,20 +764,27 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
setCustomerPrincipalName(name);
|
||||
setCustomerPrincipalPhone(phone);
|
||||
},
|
||||
thirdPartyPrincipalName: thirdPartyPrincipalName,
|
||||
thirdPartyPrincipalPhone: thirdPartyPrincipalPhone,
|
||||
onThirdPartyPrincipalChange: function (name, phone) {
|
||||
setThirdPartyPrincipalName(name);
|
||||
setThirdPartyPrincipalPhone(phone);
|
||||
},
|
||||
}),
|
||||
),
|
||||
renderSectionLock('main'),
|
||||
renderFlowFormLock('main'),
|
||||
),
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card lc-create-section'
|
||||
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
|
||||
+ getCreateSectionDisabledClass('leaseOrder'),
|
||||
'aria-label': '附件1:租赁订单',
|
||||
'data-annotation-id': 'lc-create-lease-order',
|
||||
id: 'lc-create-section-lease-order',
|
||||
ref: sectionRefs.leaseOrder,
|
||||
},
|
||||
React.createElement('div', { className: 'ct-panel-head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '第 3 步:附件1 · 租赁订单'),
|
||||
renderCreateStepTitle(3, '附件1 · 租赁订单'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-completion-badge ' + (isLeaseOrderComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
|
||||
role: 'status',
|
||||
@@ -599,10 +798,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
}),
|
||||
),
|
||||
renderSectionLock('leaseOrder'),
|
||||
renderFlowFormLock('leaseOrder'),
|
||||
),
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card lc-create-section'
|
||||
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
|
||||
+ getCreateSectionDisabledClass('poa'),
|
||||
'aria-label': '授权委托书',
|
||||
'data-annotation-id': 'lc-create-poa',
|
||||
id: 'lc-create-section-poa',
|
||||
@@ -613,7 +813,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
'aria-describedby': 'lc-create-poa-hint',
|
||||
},
|
||||
React.createElement('div', { className: 'lc-template-step-card__title-row' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '第 4 步:授权委托书'),
|
||||
renderCreateStepTitle(4, '授权委托书'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-card__hint lc-create-poa-hint',
|
||||
role: 'note',
|
||||
@@ -626,7 +826,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
'aria-hidden': true,
|
||||
}),
|
||||
React.createElement('span', { className: 'lc-form-card__hint-text' },
|
||||
'添加受托人须同时上传授权委托书',
|
||||
'添加受托人须同时上传授权委托书,受托人主要用于交还车时E签宝签章',
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -651,14 +851,14 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
),
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card lc-create-section'
|
||||
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
|
||||
+ getCreateSectionDisabledClass('remark'),
|
||||
'aria-label': '合同备注',
|
||||
'data-annotation-id': 'lc-create-contract-remark',
|
||||
id: 'lc-create-section-remark',
|
||||
ref: sectionRefs.remark,
|
||||
},
|
||||
React.createElement('div', { className: 'ct-panel-head' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '第 5 步:合同备注'),
|
||||
renderCreateStepTitle(5, '合同备注'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-completion-badge '
|
||||
+ ((contractRemark || '').trim()
|
||||
@@ -675,10 +875,11 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
}),
|
||||
),
|
||||
renderSectionLock('remark'),
|
||||
renderFlowFormLock('remark'),
|
||||
),
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card lc-create-section'
|
||||
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
|
||||
+ getCreateSectionDisabledClass('seal'),
|
||||
'aria-label': '用章类型',
|
||||
'data-annotation-id': 'lc-create-seal',
|
||||
id: 'lc-create-section-seal',
|
||||
@@ -686,22 +887,25 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
},
|
||||
React.createElement('div', {
|
||||
className: 'ct-panel-head lc-create-section__head lc-create-section-seal-head',
|
||||
'aria-describedby': 'lc-create-seal-hint',
|
||||
},
|
||||
React.createElement('div', { className: 'lc-template-step-card__title-row' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '第 6 步:用章类型'),
|
||||
sealTypes.length
|
||||
? React.createElement('span', {
|
||||
className: 'lc-seal-type-head-tags',
|
||||
'aria-label': '已选用章',
|
||||
renderCreateStepTitle(6, '用章类型'),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-card__hint lc-create-seal-hint',
|
||||
role: 'note',
|
||||
id: 'lc-create-seal-hint',
|
||||
'data-annotation-id': 'lc-create-seal-hint',
|
||||
},
|
||||
sealTypes.map(function (value) {
|
||||
return React.createElement('span', {
|
||||
key: value,
|
||||
className: 'lc-seal-type-head-tag',
|
||||
}, getSealTypeLabel(value));
|
||||
React.createElement(Info, {
|
||||
size: 13,
|
||||
className: 'lc-form-card__hint-icon',
|
||||
'aria-hidden': true,
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
React.createElement('span', { className: 'lc-form-card__hint-text' },
|
||||
'额外勾选公章、法人章会进入非标审批流程',
|
||||
),
|
||||
),
|
||||
),
|
||||
React.createElement('span', {
|
||||
className: 'lc-form-completion-badge ' + (isSealTypeComplete ? 'lc-form-completion-badge--done' : 'lc-form-completion-badge--pending'),
|
||||
@@ -729,24 +933,29 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
),
|
||||
),
|
||||
renderSectionLock('seal'),
|
||||
renderFlowFormLock('seal'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
React.createElement(Col, { xs: 24, lg: 12 },
|
||||
React.createElement('section', {
|
||||
className: 'ct-editor-column-card ct-editor-column-card--preview',
|
||||
'aria-label': '实时预览',
|
||||
className: getPreviewPanelCardClass(),
|
||||
'aria-label': getPreviewPanelAriaLabel(),
|
||||
'data-annotation-id': 'lc-create-preview',
|
||||
},
|
||||
React.createElement('div', {
|
||||
className: 'ct-panel-head lc-create-preview-head',
|
||||
'aria-describedby': isTemplateSelected ? 'lc-preview-editable-hint' : undefined,
|
||||
'aria-describedby': isTemplateSelected ? getPreviewHintId() : undefined,
|
||||
},
|
||||
React.createElement('div', { className: 'lc-template-step-card__title-row' },
|
||||
React.createElement('div', { className: 'ct-panel-head__title' }, '实时预览'),
|
||||
isTemplateSelected ? React.createElement(PreviewEditableHint) : null,
|
||||
React.createElement('div', { className: 'ct-panel-head__title' },
|
||||
getPreviewPanelTitle(),
|
||||
),
|
||||
isTemplateSelected ? renderPreviewReadonlyHint() : null,
|
||||
),
|
||||
React.createElement('div', { className: 'ct-panel-head__actions' },
|
||||
isScopedPreviewFlow ? null : React.createElement(PreviewApprovalHint),
|
||||
React.createElement('span', {
|
||||
className: 'lc-approval-type-badge'
|
||||
+ (isNonStandardApproval ? ' lc-approval-type-badge--nonstd' : ' lc-approval-type-badge--standard'),
|
||||
@@ -761,8 +970,8 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
html: previewHtml,
|
||||
zoom: workspaceZoom,
|
||||
onZoomChange: setWorkspaceZoom,
|
||||
editable: true,
|
||||
onContentChange: handlePreviewContentChange,
|
||||
editable: isPreviewEditable,
|
||||
onContentChange: isPreviewEditable ? handlePreviewContentChange : undefined,
|
||||
})
|
||||
: React.createElement('div', { className: 'lc-template-preview-empty' }, '请先在左侧选择合同模板,右侧将自动生成对应预览。'),
|
||||
),
|
||||
@@ -775,11 +984,13 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost',
|
||||
'data-vm-icon': 'x',
|
||||
onClick: onBack,
|
||||
}, '取消'),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary',
|
||||
'data-vm-icon': 'save',
|
||||
onClick: function () {
|
||||
if (!contractTemplateId) {
|
||||
message.warning('请先选择合同模板');
|
||||
@@ -791,12 +1002,32 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-primary',
|
||||
'data-vm-icon': 'send',
|
||||
onClick: function () {
|
||||
if (!contractTemplateId) {
|
||||
message.warning('请先选择合同模板');
|
||||
return;
|
||||
}
|
||||
if (isAddPoaFlow) {
|
||||
if (!isPoaSubmitReady) {
|
||||
message.warning('提交审核前须完成授权委托书填写');
|
||||
scrollToCreateSection('poa');
|
||||
return;
|
||||
}
|
||||
} else if (isTripartiteFlow) {
|
||||
if (!isTripartitePartyComplete({
|
||||
customerId: customerId,
|
||||
thirdPartyCustomerId: thirdPartyCustomerId,
|
||||
})) {
|
||||
message.warning('转三方合同须选择丙方客户,且不能与乙方相同');
|
||||
scrollToCreateSection('main');
|
||||
return;
|
||||
}
|
||||
if (!isFormSubmitReady) {
|
||||
message.warning('请先完成左侧必填项');
|
||||
return;
|
||||
}
|
||||
} else if (!isFormSubmitReady) {
|
||||
if (!isPoaSubmitReady) {
|
||||
message.warning('提交审核前须完成授权委托书填写');
|
||||
scrollToCreateSection('poa');
|
||||
@@ -806,11 +1037,46 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
||||
return;
|
||||
}
|
||||
var submitCustomer = getLeaseCustomerById(customerId);
|
||||
if (hasBlockingCustomerCredentials(submitCustomer)) {
|
||||
if (!isAddPoaFlow && hasBlockingCustomerCredentials(submitCustomer)) {
|
||||
message.error(formatCredentialSubmitBlockMessage(summarizeCustomerCredentials(submitCustomer)));
|
||||
scrollToCreateSection('main');
|
||||
return;
|
||||
}
|
||||
var fullContractCode = formatLeaseContractCode(contractCode);
|
||||
var newVehicleCount = countNewPickupVehicles(leaseOrder);
|
||||
if (effectiveFlowMode === 'renew' && newVehicleCount > 0) {
|
||||
message.info('续签:新增 ' + newVehicleCount + ' 台车辆将生成提车应收款,已交未还车辆不生成(原型)');
|
||||
}
|
||||
if (effectiveFlowMode === 'trialToFormal') {
|
||||
message.info('转正式:已交未还车辆完成后不再生成交车任务;金额变更将重新记录提车应收款(原型)');
|
||||
}
|
||||
if (effectiveFlowMode === 'addVehicle') {
|
||||
message.info('新增车辆:审批通过后按新增合同规则生成提车应收款(原型)');
|
||||
}
|
||||
if (effectiveFlowMode === 'addPoa') {
|
||||
message.info('添加授权委托书:审批通过后仅对授权委托书发起签署,主合同无需重签(原型)');
|
||||
message.success('提交审核成功(' + fullContractCode + ')');
|
||||
message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
|
||||
return;
|
||||
}
|
||||
createPickupReceivableFromLeaseSubmit({
|
||||
contractCode: fullContractCode,
|
||||
contractType: getContractTypeLabel(contractType),
|
||||
projectName: projectName
|
||||
|| ((submitCustomer && submitCustomer.companyName)
|
||||
? submitCustomer.companyName + '租赁项目'
|
||||
: ''),
|
||||
customerName: submitCustomer ? submitCustomer.companyName : '',
|
||||
businessDept: businessDept,
|
||||
businessPerson: businessOwner,
|
||||
feeInfo: feeInfo,
|
||||
leaseOrder: leaseOrder,
|
||||
paymentMethod: feeInfo && feeInfo.paymentMethod === 'postpay' ? '后付' : '预付',
|
||||
});
|
||||
if (effectiveFlowMode === 'tripartite') {
|
||||
message.info('转三方:审批通过后沿用原合同签署方式,线上 E签宝 / 线下盖章补传(原型)');
|
||||
}
|
||||
message.success('提交审核成功,已同步创建提车应收款(' + fullContractCode + ')');
|
||||
message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
|
||||
},
|
||||
}, '提交审核'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
// 在租车辆概览 — 按品牌型号统计(KPI 卡片 + 下方车型面板 / 隐藏后弹层)
|
||||
import React from 'react';
|
||||
import { buildOnLeaseFleetByBrandModel } from './lease-contract-list-data.js';
|
||||
|
||||
function FleetDetailModal(props) {
|
||||
@@ -32,7 +33,7 @@ function FleetDetailModal(props) {
|
||||
title: titleNode,
|
||||
open: open,
|
||||
onCancel: onClose,
|
||||
footer: React.createElement('button', {
|
||||
footer: React.createElement('button', { 'data-vm-icon': 'x',
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost lc-fleet-detail-modal__close',
|
||||
onClick: onClose,
|
||||
@@ -141,12 +142,13 @@ function FleetQuickViewModal(props) {
|
||||
open: open,
|
||||
onCancel: onClose,
|
||||
footer: React.createElement('div', { className: 'lc-fleet-quick-modal__footer' },
|
||||
React.createElement('button', {
|
||||
React.createElement('button', { 'data-vm-icon': 'x',
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost',
|
||||
onClick: onClose,
|
||||
}, '关闭'),
|
||||
React.createElement('button', {
|
||||
'data-vm-icon': 'plus',
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-primary',
|
||||
onClick: function () {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Select, Popover } from 'antd';
|
||||
import { Info, FileDiff } from 'lucide-react';
|
||||
import { paginateWordHtml } from './contract-word-preview.js';
|
||||
import { collectClauseChangeRecords } from './lease-contract-risk-detect.js';
|
||||
|
||||
var PREVIEW_ZOOM_STEPS = [50, 75, 90, 100, 110, 125, 150, 175, 200];
|
||||
|
||||
@@ -48,7 +49,145 @@ export function PreviewEditableHint() {
|
||||
React.createElement('span', { className: 'lc-form-card__hint-text lc-preview-editable-hint__text' },
|
||||
'正文可编辑;改动',
|
||||
React.createElement('span', { className: 'lc-preview-editable-hint__risk' }, '风控红线条款'),
|
||||
'走非标准审批,未改则标准审批。',
|
||||
'或新增条款走非标准审批,未改则标准审批。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewAttachment1ReadonlyHint() {
|
||||
return React.createElement('span', {
|
||||
className: 'lc-form-card__hint lc-preview-editable-hint lc-preview-editable-hint--inline lc-preview-attachment1-hint',
|
||||
role: 'note',
|
||||
'data-annotation-id': 'lc-preview-attachment1-hint',
|
||||
id: 'lc-preview-attachment1-hint',
|
||||
},
|
||||
React.createElement(Info, {
|
||||
size: 13,
|
||||
className: 'lc-form-card__hint-icon',
|
||||
'aria-hidden': true,
|
||||
}),
|
||||
React.createElement('span', { className: 'lc-form-card__hint-text lc-preview-editable-hint__text' },
|
||||
'仅展示附件1条款,主合同及其他附件不可编辑、无需重签;请在左侧填写新增车辆订单。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewPowerOfAttorneyReadonlyHint() {
|
||||
return React.createElement('span', {
|
||||
className: 'lc-form-card__hint lc-preview-editable-hint lc-preview-editable-hint--inline lc-preview-poa-hint',
|
||||
role: 'note',
|
||||
'data-annotation-id': 'lc-preview-poa-hint',
|
||||
id: 'lc-preview-poa-hint',
|
||||
},
|
||||
React.createElement(Info, {
|
||||
size: 13,
|
||||
className: 'lc-form-card__hint-icon',
|
||||
'aria-hidden': true,
|
||||
}),
|
||||
React.createElement('span', { className: 'lc-form-card__hint-text lc-preview-editable-hint__text' },
|
||||
'仅展示授权委托书,主合同及其他附件不可编辑、无需重签;请在左侧填写受托人信息。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewApprovalHint() {
|
||||
return React.createElement('span', {
|
||||
className: 'lc-form-card__hint lc-preview-approval-hint',
|
||||
role: 'note',
|
||||
'data-annotation-id': 'lc-preview-approval-hint',
|
||||
id: 'lc-preview-approval-hint',
|
||||
title: '非标准合同触发条件:1.实时预览新增条款;2.改动风控红线条款;3.付款方式为先用后付;4.付款周期为季付/半年付/年付;5.车辆租金或保证金低于车型最低标准价;6.用章类型额外选择公章或法人章',
|
||||
},
|
||||
React.createElement(Info, {
|
||||
size: 13,
|
||||
className: 'lc-form-card__hint-icon',
|
||||
'aria-hidden': true,
|
||||
}),
|
||||
React.createElement('span', { className: 'lc-form-card__hint-text' }, '非标准触发条件'),
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewClauseChangeRecordPanel(props) {
|
||||
var records = props.records || [];
|
||||
|
||||
return React.createElement('div', {
|
||||
className: 'lc-preview-clause-record-panel',
|
||||
role: 'region',
|
||||
'aria-label': '条款修改记录',
|
||||
'data-annotation-id': 'lc-preview-clause-record',
|
||||
},
|
||||
React.createElement('div', { className: 'lc-preview-clause-record-panel__head' },
|
||||
React.createElement('span', { className: 'lc-preview-clause-record-panel__title' }, '条款修改记录'),
|
||||
records.length
|
||||
? React.createElement('span', { className: 'lc-preview-clause-record-panel__count tabular-nums' }, records.length + ' 处')
|
||||
: null,
|
||||
),
|
||||
records.length
|
||||
? React.createElement('ul', { className: 'lc-preview-clause-record-panel__list' },
|
||||
records.map(function (item) {
|
||||
return React.createElement('li', {
|
||||
key: item.id,
|
||||
className: 'lc-preview-clause-record-panel__item lc-preview-clause-record-panel__item--' + item.kind,
|
||||
},
|
||||
React.createElement('span', { className: 'lc-preview-clause-record-panel__kind' }, item.kindLabel),
|
||||
React.createElement('div', { className: 'lc-preview-clause-record-panel__diff' },
|
||||
React.createElement('div', { className: 'lc-preview-clause-record-panel__col' },
|
||||
React.createElement('span', { className: 'lc-preview-clause-record-panel__label' }, '标准条款'),
|
||||
React.createElement('p', { className: 'lc-preview-clause-record-panel__text' }, item.standardText),
|
||||
),
|
||||
React.createElement('div', { className: 'lc-preview-clause-record-panel__col' },
|
||||
React.createElement('span', { className: 'lc-preview-clause-record-panel__label' }, '修改后'),
|
||||
React.createElement('p', { className: 'lc-preview-clause-record-panel__text' }, item.modifiedText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
)
|
||||
: React.createElement('p', { className: 'lc-preview-clause-record-panel__empty', role: 'status' },
|
||||
'暂无条款修改,编辑预览正文后将在此对比标准条款与修改后内容。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewClauseChangeRecordTrigger(props) {
|
||||
var records = props.records || [];
|
||||
var openState = useState(false);
|
||||
var open = openState[0];
|
||||
var setOpen = openState[1];
|
||||
var count = records.length;
|
||||
|
||||
return React.createElement('div', {
|
||||
className: 'ct-preview-pager__group ct-preview-pager__group--clause-record',
|
||||
role: 'group',
|
||||
'aria-label': '条款修改记录',
|
||||
},
|
||||
React.createElement(Popover, {
|
||||
trigger: 'click',
|
||||
placement: 'bottomRight',
|
||||
open: open,
|
||||
onOpenChange: setOpen,
|
||||
overlayClassName: 'lc-preview-clause-record-popover',
|
||||
getPopupContainer: function () { return document.body; },
|
||||
content: React.createElement(PreviewClauseChangeRecordPanel, { records: records }),
|
||||
},
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary lc-preview-clause-record-btn'
|
||||
+ (open ? ' is-active' : '')
|
||||
+ (count ? ' has-changes' : ''),
|
||||
'aria-expanded': open,
|
||||
'aria-haspopup': 'dialog',
|
||||
'data-annotation-id': 'lc-preview-clause-record-btn',
|
||||
},
|
||||
React.createElement(FileDiff, { size: 14, 'aria-hidden': true }),
|
||||
React.createElement('span', { className: 'lc-preview-clause-record-btn__label' }, '条款修改记录'),
|
||||
count
|
||||
? React.createElement('span', {
|
||||
className: 'lc-preview-clause-record-btn__badge tabular-nums',
|
||||
'aria-label': count + ' 处修改',
|
||||
}, count)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -89,6 +228,7 @@ export default function LeaseContractPreviewPanel({
|
||||
var pageIdxState = useState(0);
|
||||
var viewModeState = useState('scroll');
|
||||
var internalZoomState = useState(100);
|
||||
var clauseRecordsState = useState([]);
|
||||
var baselinePages = baselinePagesState[0];
|
||||
var setBaselinePages = baselinePagesState[1];
|
||||
var draftPagesRef = useRef([]);
|
||||
@@ -96,6 +236,8 @@ export default function LeaseContractPreviewPanel({
|
||||
var setPageIdx = pageIdxState[1];
|
||||
var viewMode = viewModeState[0];
|
||||
var setViewMode = viewModeState[1];
|
||||
var clauseRecords = clauseRecordsState[0];
|
||||
var setClauseRecords = clauseRecordsState[1];
|
||||
var previewZoom = zoom != null ? zoom : internalZoomState[0];
|
||||
var setPreviewZoom = onZoomChange || internalZoomState[1];
|
||||
|
||||
@@ -103,6 +245,7 @@ export default function LeaseContractPreviewPanel({
|
||||
if (!html) {
|
||||
setBaselinePages([]);
|
||||
draftPagesRef.current = [];
|
||||
setClauseRecords([]);
|
||||
return;
|
||||
}
|
||||
var timer = window.setTimeout(function () {
|
||||
@@ -110,14 +253,17 @@ export default function LeaseContractPreviewPanel({
|
||||
setBaselinePages(nextPages);
|
||||
draftPagesRef.current = nextPages.slice();
|
||||
pageIdxState[1](0);
|
||||
setClauseRecords([]);
|
||||
notifyContentChange(nextPages);
|
||||
}, 0);
|
||||
return function () { window.clearTimeout(timer); };
|
||||
}, [html]);
|
||||
|
||||
function notifyContentChange(nextPages) {
|
||||
var merged = (nextPages || []).join('');
|
||||
setClauseRecords(collectClauseChangeRecords(html, merged));
|
||||
if (!onContentChange) return;
|
||||
onContentChange((nextPages || []).join(''));
|
||||
onContentChange(merged);
|
||||
}
|
||||
|
||||
function handlePageInput(pageIndex, nextHtml) {
|
||||
@@ -205,6 +351,7 @@ export default function LeaseContractPreviewPanel({
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
|
||||
'data-vm-icon': 'chevron-left',
|
||||
disabled: pageIdx <= 0,
|
||||
onClick: function () { setPageIdx(Math.max(0, pageIdx - 1)); },
|
||||
}, '上一页'),
|
||||
@@ -216,6 +363,7 @@ export default function LeaseContractPreviewPanel({
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-secondary ct-preview-pager__btn',
|
||||
'data-vm-icon': 'chevron-right',
|
||||
disabled: pageIdx >= baselinePages.length - 1,
|
||||
onClick: function () { setPageIdx(Math.min(baselinePages.length - 1, pageIdx + 1)); },
|
||||
}, '下一页'),
|
||||
@@ -238,6 +386,9 @@ export default function LeaseContractPreviewPanel({
|
||||
}, '单页预览'),
|
||||
),
|
||||
),
|
||||
editable
|
||||
? React.createElement(PreviewClauseChangeRecordTrigger, { records: clauseRecords })
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
667
src/prototypes/lease-contract-management/LeaseContractView.jsx
Normal file
667
src/prototypes/lease-contract-management/LeaseContractView.jsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Image } from 'antd';
|
||||
import { StatusTag } from '../vehicle-management/components/StatusTag';
|
||||
import {
|
||||
getCustomerAttachmentPreviewUrl,
|
||||
} from '../contract-template-management/contract-template-vars.js';
|
||||
import {
|
||||
summarizeCustomerCredentials,
|
||||
} from './lease-customer-credentials.js';
|
||||
import {
|
||||
buildContractAttachments,
|
||||
buildContractOperationLogs,
|
||||
buildContractChangeRecords,
|
||||
previewContractAttachment,
|
||||
downloadContractAttachmentFile,
|
||||
CHANGE_RECORD_TYPES,
|
||||
} from './lease-contract-view-data.js';
|
||||
import {
|
||||
buildContractViewSummary,
|
||||
buildContractViewTemplateFields,
|
||||
buildContractViewSigningFields,
|
||||
buildContractViewLessorProfileFields,
|
||||
buildContractViewCustomerProfileFields,
|
||||
buildContractViewMileageFields,
|
||||
buildContractViewFeeFields,
|
||||
buildContractViewLeaseOrderMetaFields,
|
||||
buildContractViewVehicleCards,
|
||||
buildContractViewAuditFields,
|
||||
buildContractViewSealTypes,
|
||||
getViewSectionNavItems,
|
||||
resolveViewFormContext,
|
||||
} from './lease-contract-view-sections.js';
|
||||
import { getAuthorizedDelegates } from './lease-contract-list-data.js';
|
||||
|
||||
var SEAL_TYPE_OPTIONS = [
|
||||
{ value: 'contract', label: '合同章' },
|
||||
{ value: 'official', label: '公章' },
|
||||
{ value: 'legal_person', label: '法人章' },
|
||||
];
|
||||
|
||||
function formatChangeTypeLabel(type) {
|
||||
var found = CHANGE_RECORD_TYPES.find(function (item) { return item.key === type; });
|
||||
return found ? found.label : type || '-';
|
||||
}
|
||||
|
||||
function contractStatusTone(status) {
|
||||
if (status === '合同进行中') return 'green';
|
||||
if (status === '已提交审批') return 'blue';
|
||||
if (status === '已终止') return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
function approvalStatusTone(status) {
|
||||
if (status === '审批通过') return 'green';
|
||||
if (status === '审批中' || status === '待审批') return 'amber';
|
||||
if (status === '审批驳回' || status === '审批终止') return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
var VIEW_TAB_SCROLL_OFFSET = 12;
|
||||
|
||||
function formatViewDisplayValue(value) {
|
||||
return value != null && value !== '' && value !== '-' ? value : null;
|
||||
}
|
||||
|
||||
function getParamSpanClass(props) {
|
||||
if (props.wide || props.span === 3) return ' vm-model-param-item--wide';
|
||||
if (props.span === 2) return ' vm-model-param-item--span-2';
|
||||
return '';
|
||||
}
|
||||
|
||||
function ViewParamField(props) {
|
||||
var display = formatViewDisplayValue(props.value);
|
||||
var empty = !display && props.children == null;
|
||||
var valueNode = props.children != null
|
||||
? props.children
|
||||
: (display || '—');
|
||||
return React.createElement('div', {
|
||||
className: 'vm-model-param-item' + getParamSpanClass(props) + (props.className ? ' ' + props.className : ''),
|
||||
},
|
||||
React.createElement('span', { className: 'vm-model-param-label' }, props.label),
|
||||
React.createElement('div', {
|
||||
className: 'vm-model-param-value'
|
||||
+ (empty ? ' is-empty' : '')
|
||||
+ (props.numeric ? ' tabular-nums' : ''),
|
||||
}, valueNode),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewParamSection(props) {
|
||||
return React.createElement('section', {
|
||||
className: 'vm-model-param-section',
|
||||
'data-annotation-id': props.annotationId,
|
||||
},
|
||||
React.createElement('h3', { className: 'vm-model-param-section-title' }, props.title),
|
||||
props.children,
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldsGrid(fields, cols) {
|
||||
var gridClass = 'vm-model-param-grid';
|
||||
if (cols === 2) gridClass += ' vm-model-param-grid--2';
|
||||
if (cols === 1) gridClass += ' vm-model-param-grid--1';
|
||||
return React.createElement('div', { className: gridClass },
|
||||
(fields || []).map(function (field) {
|
||||
return React.createElement(ViewParamField, {
|
||||
key: field.label,
|
||||
label: field.label,
|
||||
value: field.value,
|
||||
numeric: field.mono,
|
||||
wide: field.wide,
|
||||
span: field.span,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewPartyFields(props) {
|
||||
var fields = props.fields || [];
|
||||
var contactName = (fields.find(function (f) { return f.label === '联系人姓名'; }) || {}).value;
|
||||
var contactPhone = (fields.find(function (f) { return f.label === '联系人电话'; }) || {}).value;
|
||||
function resolveSpan(field) {
|
||||
if (field.span != null) return field.span;
|
||||
if (field.label === '邮箱') return 1;
|
||||
if (field.wide) return 3;
|
||||
return 1;
|
||||
}
|
||||
var contactDisplay = [contactName, contactPhone]
|
||||
.map(formatViewDisplayValue)
|
||||
.filter(Boolean)
|
||||
.join(' / ') || null;
|
||||
return React.createElement('div', { className: 'vm-model-param-grid' },
|
||||
fields.map(function (field) {
|
||||
if (field.label === '联系人姓名' || field.label === '联系人电话') return null;
|
||||
return React.createElement(ViewParamField, {
|
||||
key: props.prefix + '-' + field.label,
|
||||
label: field.label,
|
||||
value: field.value,
|
||||
numeric: field.mono,
|
||||
span: resolveSpan(field),
|
||||
});
|
||||
}),
|
||||
React.createElement(ViewParamField, {
|
||||
key: props.prefix + '-contact-pair',
|
||||
label: '联系人姓名及电话',
|
||||
value: contactDisplay,
|
||||
numeric: true,
|
||||
span: 2,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewDetailStat(props) {
|
||||
return React.createElement('div', { className: 'vm-detail-stat' },
|
||||
React.createElement('div', { className: 'vm-detail-stat-main' },
|
||||
React.createElement('span', { className: 'vm-detail-stat-label' }, props.label),
|
||||
React.createElement('span', {
|
||||
className: 'vm-detail-stat-value' + (props.numeric ? ' tabular-nums' : ''),
|
||||
}, props.value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewCredentialThumb(props) {
|
||||
var label = props.label;
|
||||
var previewUrl = props.previewUrl;
|
||||
var meta = props.meta || {};
|
||||
var status = meta.status || 'unknown';
|
||||
var thumbClass = 'lc-attach-thumb'
|
||||
+ (status === 'expired' ? ' is-expired' : '')
|
||||
+ (status === 'expiring' ? ' is-expiring' : '');
|
||||
return React.createElement('div', {
|
||||
className: thumbClass,
|
||||
'aria-label': label,
|
||||
},
|
||||
React.createElement(Image, {
|
||||
className: 'lc-attach-thumb__img',
|
||||
src: previewUrl,
|
||||
alt: label,
|
||||
preview: { src: previewUrl },
|
||||
fallback: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240"><rect width="100%" height="100%" fill="#f1f5f9"/></svg>'),
|
||||
}),
|
||||
React.createElement('div', { className: 'lc-attach-thumb__meta' },
|
||||
React.createElement('span', { className: 'lc-attach-thumb__label' }, label),
|
||||
meta.ocrVerified
|
||||
? React.createElement('span', { className: 'lc-attach-thumb__ocr', title: '已通过 OCR 识别证照信息' }, 'OCR 已识别')
|
||||
: React.createElement('span', { className: 'lc-attach-thumb__ocr is-pending' }, '待 OCR'),
|
||||
meta.expiryDate
|
||||
? React.createElement('span', { className: 'lc-attach-thumb__expiry' }, '有效期至 ' + meta.expiryDate)
|
||||
: null,
|
||||
React.createElement('span', {
|
||||
className: 'lc-attach-thumb__status lc-attach-thumb__status--' + status,
|
||||
role: 'status',
|
||||
}, meta.statusLabel || '待识别'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewCredentialsBlock(props) {
|
||||
var customer = props.customer;
|
||||
if (!customer) return null;
|
||||
var credentialSummary = summarizeCustomerCredentials(customer);
|
||||
var embedded = props.embedded;
|
||||
return React.createElement('div', {
|
||||
className: 'lc-credentials-block lc-view-credentials-block'
|
||||
+ (embedded ? ' lc-view-credentials-block--embedded' : ''),
|
||||
'data-annotation-id': 'lc-view-credentials-ocr',
|
||||
},
|
||||
React.createElement('div', { className: 'lc-credentials-block__head' },
|
||||
React.createElement('h4', { className: 'lc-credentials-block__title' }, '客户资质证照'),
|
||||
),
|
||||
React.createElement('div', {
|
||||
className: 'lc-attach-gallery',
|
||||
'aria-label': '客户资质证照预览',
|
||||
},
|
||||
credentialSummary.items.map(function (item) {
|
||||
var previewUrl = getCustomerAttachmentPreviewUrl(customer, item.key, item.label);
|
||||
return React.createElement(ViewCredentialThumb, {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
previewUrl: previewUrl,
|
||||
meta: item,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewSealChips(props) {
|
||||
var selected = props.sealTypes || ['contract'];
|
||||
var activeOptions = SEAL_TYPE_OPTIONS.filter(function (option) {
|
||||
return selected.indexOf(option.value) >= 0;
|
||||
});
|
||||
if (!activeOptions.length) {
|
||||
return React.createElement('p', { className: 'lc-view-section__empty' }, '未选择用章类型');
|
||||
}
|
||||
return React.createElement('div', {
|
||||
className: 'lc-seal-type-chips lc-view-seal-type-chips',
|
||||
role: 'group',
|
||||
'aria-label': '用章类型',
|
||||
'data-annotation-id': 'lc-view-seal-type',
|
||||
},
|
||||
activeOptions.map(function (option) {
|
||||
return React.createElement('span', {
|
||||
key: option.value,
|
||||
className: 'lc-seal-type-chip lc-view-seal-type-chip is-active',
|
||||
}, option.label);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewAttachmentCapsules(props) {
|
||||
var attachments = props.attachments || [];
|
||||
var record = props.record;
|
||||
var onPreview = props.onPreview;
|
||||
var onDownload = props.onDownload;
|
||||
var Button = props.Button;
|
||||
if (!attachments.length) {
|
||||
return React.createElement('p', { className: 'lc-view-section__empty' }, '暂无合同附件');
|
||||
}
|
||||
return React.createElement('div', {
|
||||
className: 'lc-view-attachment-capsules',
|
||||
'aria-label': '合同附件列表',
|
||||
},
|
||||
attachments.map(function (file) {
|
||||
var fileName = file.name || '未命名附件';
|
||||
return React.createElement('div', {
|
||||
key: file.uid || fileName,
|
||||
className: 'lc-view-attachment-capsule',
|
||||
},
|
||||
React.createElement('span', {
|
||||
className: 'lc-view-attachment-capsule__name',
|
||||
title: fileName,
|
||||
}, fileName),
|
||||
React.createElement('div', { className: 'lc-view-attachment-capsule__actions' },
|
||||
React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: function () { onPreview(file, record); },
|
||||
}, '预览'),
|
||||
React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: function () { onDownload(file, record); },
|
||||
}, '下载'),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ViewRecordTimeline(props) {
|
||||
var items = props.items || [];
|
||||
if (!items.length) {
|
||||
return React.createElement('p', { className: 'lc-view-section__empty' }, props.emptyText || '暂无记录');
|
||||
}
|
||||
return React.createElement('ol', { className: 'lc-view-timeline' },
|
||||
items.map(function (item) {
|
||||
return React.createElement('li', { key: item.id, className: 'lc-view-timeline__item' },
|
||||
React.createElement('div', { className: 'lc-view-timeline__dot', 'aria-hidden': true }),
|
||||
React.createElement('div', { className: 'lc-view-timeline__content' },
|
||||
React.createElement('div', { className: 'lc-view-timeline__head' },
|
||||
React.createElement('span', { className: 'lc-view-timeline__title' }, item.title),
|
||||
item.badge
|
||||
? React.createElement('span', { className: 'lc-view-timeline__badge' }, item.badge)
|
||||
: null,
|
||||
),
|
||||
item.summary
|
||||
? React.createElement('p', { className: 'lc-view-timeline__summary' }, item.summary)
|
||||
: null,
|
||||
React.createElement('div', { className: 'lc-view-timeline__meta' },
|
||||
React.createElement('span', null, item.operatorName || '-'),
|
||||
React.createElement('span', { className: 'tabular-nums' }, item.operateTime || '-'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeaseContractView({ record, onBack, stampedFilesOverride }) {
|
||||
var antd = window.antd;
|
||||
var Table = antd.Table;
|
||||
var Button = antd.Button;
|
||||
var message = antd.message;
|
||||
|
||||
var activeSectionState = useState('main');
|
||||
var activeSection = activeSectionState[0];
|
||||
var setActiveSection = activeSectionState[1];
|
||||
|
||||
var scrollRef = useRef(null);
|
||||
var sectionRefs = useRef({});
|
||||
|
||||
var formContext = useMemo(function () { return resolveViewFormContext(record); }, [record]);
|
||||
var attachments = useMemo(function () {
|
||||
if (!record) return [];
|
||||
if (stampedFilesOverride && stampedFilesOverride.length) {
|
||||
return stampedFilesOverride.map(function (file) {
|
||||
return { uid: file.uid, name: file.name, type: file.type || '' };
|
||||
});
|
||||
}
|
||||
return buildContractAttachments(record);
|
||||
}, [record, stampedFilesOverride]);
|
||||
var operationLogs = useMemo(function () { return buildContractOperationLogs(record); }, [record]);
|
||||
var changeRecords = useMemo(function () { return buildContractChangeRecords(record); }, [record]);
|
||||
var summary = useMemo(function () { return buildContractViewSummary(record); }, [record]);
|
||||
var navItems = useMemo(function () { return getViewSectionNavItems(); }, []);
|
||||
var templateFields = useMemo(function () { return buildContractViewTemplateFields(record); }, [record]);
|
||||
var signingFields = useMemo(function () { return buildContractViewSigningFields(record, formContext); }, [record, formContext]);
|
||||
var lessorFields = useMemo(function () { return buildContractViewLessorProfileFields(formContext); }, [formContext]);
|
||||
var customerFields = useMemo(function () { return buildContractViewCustomerProfileFields(formContext); }, [formContext]);
|
||||
var mileageFields = useMemo(function () { return buildContractViewMileageFields(record, formContext); }, [record, formContext]);
|
||||
var feeFields = useMemo(function () { return buildContractViewFeeFields(record, formContext); }, [record, formContext]);
|
||||
var leaseOrderMetaFields = useMemo(function () { return buildContractViewLeaseOrderMetaFields(record); }, [record]);
|
||||
var vehicleCards = useMemo(function () { return buildContractViewVehicleCards(record); }, [record]);
|
||||
var auditFields = useMemo(function () { return buildContractViewAuditFields(record); }, [record]);
|
||||
var sealTypes = useMemo(function () { return buildContractViewSealTypes(record, formContext); }, [record, formContext]);
|
||||
var delegates = useMemo(function () { return getAuthorizedDelegates(record); }, [record]);
|
||||
|
||||
var contractRemark = useMemo(function () {
|
||||
if (!record) return '';
|
||||
return record.remark && record.remark !== '-' ? record.remark : '';
|
||||
}, [record]);
|
||||
|
||||
var changeTimelineItems = useMemo(function () {
|
||||
return changeRecords.map(function (item) {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.typeLabel || formatChangeTypeLabel(item.type),
|
||||
summary: item.summary,
|
||||
operatorName: item.operatorName,
|
||||
operateTime: item.operateTime,
|
||||
badge: item.approvalStatus,
|
||||
};
|
||||
});
|
||||
}, [changeRecords]);
|
||||
|
||||
var scrollToSection = useCallback(function (sectionKey) {
|
||||
setActiveSection(sectionKey);
|
||||
var target = sectionRefs.current[sectionKey];
|
||||
var container = scrollRef.current;
|
||||
if (!target || !container) return;
|
||||
var containerTop = container.getBoundingClientRect().top;
|
||||
var targetTop = target.getBoundingClientRect().top;
|
||||
container.scrollTo({
|
||||
top: container.scrollTop + (targetTop - containerTop) - VIEW_TAB_SCROLL_OFFSET,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(function () {
|
||||
var container = scrollRef.current;
|
||||
if (!container) return undefined;
|
||||
function onScroll() {
|
||||
var keys = navItems.map(function (item) { return item.key; });
|
||||
var current = keys[0];
|
||||
keys.forEach(function (key) {
|
||||
var el = sectionRefs.current[key];
|
||||
if (!el) return;
|
||||
var top = el.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
if (top <= VIEW_TAB_SCROLL_OFFSET + 4) current = key;
|
||||
});
|
||||
setActiveSection(current);
|
||||
}
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return function () { container.removeEventListener('scroll', onScroll); };
|
||||
}, [navItems]);
|
||||
|
||||
function bindSectionRef(key) {
|
||||
return function (node) {
|
||||
sectionRefs.current[key] = node;
|
||||
};
|
||||
}
|
||||
|
||||
function renderAnchorSection(key, annotationId, children) {
|
||||
return React.createElement('section', {
|
||||
key: key,
|
||||
id: 'lc-view-section-' + key,
|
||||
className: 'lc-view-detail-anchor',
|
||||
ref: bindSectionRef(key),
|
||||
'data-annotation-id': annotationId,
|
||||
}, children);
|
||||
}
|
||||
|
||||
function renderScrollSections() {
|
||||
return React.createElement(React.Fragment, null,
|
||||
renderAnchorSection('template', 'lc-view-template',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '模板与签署', annotationId: 'lc-view-template-fields' },
|
||||
renderFieldsGrid(templateFields, 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('main', 'lc-view-main-contract',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '签约信息', annotationId: 'lc-view-card-signing' },
|
||||
renderFieldsGrid(signingFields, 3),
|
||||
),
|
||||
lessorFields.length
|
||||
? React.createElement(ViewParamSection, { title: '甲方信息', annotationId: 'lc-view-lessor-account' },
|
||||
React.createElement(ViewPartyFields, { fields: lessorFields, prefix: 'lessor' }),
|
||||
)
|
||||
: null,
|
||||
customerFields.length
|
||||
? React.createElement(ViewParamSection, { title: '乙方信息', annotationId: 'lc-view-invoice-info' },
|
||||
React.createElement(ViewPartyFields, { fields: customerFields, prefix: 'customer' }),
|
||||
formContext && formContext.customer
|
||||
? React.createElement(ViewCredentialsBlock, { customer: formContext.customer, embedded: true })
|
||||
: null,
|
||||
)
|
||||
: null,
|
||||
React.createElement(ViewParamSection, { title: '里程标准', annotationId: 'lc-view-card-mileage' },
|
||||
renderFieldsGrid(mileageFields, 3),
|
||||
),
|
||||
React.createElement(ViewParamSection, { title: '费用信息', annotationId: 'lc-view-card-fee' },
|
||||
renderFieldsGrid(feeFields, 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('leaseOrder', 'lc-view-lease-order',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '订单概要', annotationId: 'lc-view-lease-order-summary' },
|
||||
renderFieldsGrid(leaseOrderMetaFields, 3),
|
||||
),
|
||||
vehicleCards.length
|
||||
? vehicleCards.map(function (card, index) {
|
||||
return React.createElement(ViewParamSection, {
|
||||
key: card.id,
|
||||
title: '车辆 ' + String(index + 1).padStart(2, '0') + ' · ' + card.title,
|
||||
annotationId: 'lc-view-vehicle-' + card.id,
|
||||
},
|
||||
React.createElement('div', { className: 'lc-view-vehicle-card__status' },
|
||||
React.createElement(StatusTag, { label: card.status.label, tone: card.status.tone }),
|
||||
),
|
||||
renderFieldsGrid(card.fields, 3),
|
||||
);
|
||||
})
|
||||
: React.createElement('p', { className: 'lc-view-section__empty' }, '暂无车辆订单'),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('poa', 'lc-view-poa',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '授权委托', annotationId: 'lc-view-poa-table' },
|
||||
delegates.length
|
||||
? React.createElement(Table, {
|
||||
rowKey: function (_, index) { return 'poa-' + index; },
|
||||
size: 'small',
|
||||
className: 'lc-view-table',
|
||||
columns: [
|
||||
{ title: '受托人', dataIndex: 'name', key: 'name', width: 100 },
|
||||
{ title: '联系方式', dataIndex: 'contact', key: 'contact', width: 130, className: 'tabular-nums', render: function (v, row) { return v || row.phone || '-'; } },
|
||||
{ title: '身份证号', key: 'idNumber', width: 180, className: 'tabular-nums', render: function (_, row) { return row.idNumber || row.idCard || '-'; } },
|
||||
],
|
||||
dataSource: delegates,
|
||||
pagination: false,
|
||||
})
|
||||
: React.createElement('p', { className: 'lc-view-section__empty' }, '未添加受托人'),
|
||||
record.poaRemark
|
||||
? React.createElement('p', { className: 'lc-view-remark-text lc-view-poa-remark' }, record.poaRemark)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('remark', 'lc-view-remark',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '合同备注', annotationId: 'lc-view-remark-body' },
|
||||
contractRemark
|
||||
? React.createElement('p', { className: 'lc-view-remark-text' }, contractRemark)
|
||||
: React.createElement('p', { className: 'lc-view-section__empty' }, '未填写备注'),
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('seal', 'lc-view-seal',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '用章类型', annotationId: 'lc-view-seal-chips' },
|
||||
React.createElement(ViewSealChips, { sealTypes: sealTypes }),
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('attachments', 'lc-view-attachments',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '合同附件', annotationId: 'lc-view-attachment-list' },
|
||||
React.createElement(ViewAttachmentCapsules, {
|
||||
attachments: attachments,
|
||||
record: record,
|
||||
Button: Button,
|
||||
onPreview: function (file, contractRecord) {
|
||||
var ok = previewContractAttachment(file, contractRecord);
|
||||
if (!ok) message.info('预览:' + (file.name || '附件') + '(原型)');
|
||||
},
|
||||
onDownload: function (file, contractRecord) {
|
||||
var ok = downloadContractAttachmentFile(file, contractRecord);
|
||||
if (!ok) message.success('已开始下载:' + (file.name || '附件') + '(原型)');
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('audit', 'lc-view-audit',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '建档信息', annotationId: 'lc-view-audit-fields' },
|
||||
renderFieldsGrid(auditFields, 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('operations', 'lc-view-operation-logs',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '操作记录', annotationId: 'lc-view-operation-table' },
|
||||
React.createElement(Table, {
|
||||
rowKey: 'id',
|
||||
size: 'small',
|
||||
className: 'lc-view-table lc-view-table--compact',
|
||||
columns: [
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 96 },
|
||||
{ title: '操作人', dataIndex: 'operatorName', key: 'operatorName', width: 88 },
|
||||
{ title: '操作时间', dataIndex: 'operateTime', key: 'operateTime', width: 132, className: 'tabular-nums' },
|
||||
{ title: '修改人', dataIndex: 'modifierName', key: 'modifierName', width: 88 },
|
||||
{ title: '修改时间', dataIndex: 'modifyTime', key: 'modifyTime', width: 132, className: 'tabular-nums' },
|
||||
],
|
||||
dataSource: operationLogs,
|
||||
pagination: false,
|
||||
locale: { emptyText: '暂无操作记录' },
|
||||
scroll: { x: 580 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
renderAnchorSection('changes', 'lc-view-change-records',
|
||||
React.createElement('div', { className: 'vm-model-param-tab vm-model-param-tab--readonly' },
|
||||
React.createElement(ViewParamSection, { title: '变更记录', annotationId: 'lc-view-change-body' },
|
||||
changeTimelineItems.length <= 6
|
||||
? React.createElement(ViewRecordTimeline, {
|
||||
items: changeTimelineItems,
|
||||
emptyText: '暂无变更记录',
|
||||
})
|
||||
: React.createElement(Table, {
|
||||
rowKey: 'id',
|
||||
size: 'small',
|
||||
className: 'lc-view-table',
|
||||
columns: [
|
||||
{ title: '变更类型', key: 'typeLabel', width: 120, render: function (_, row) { return row.typeLabel || formatChangeTypeLabel(row.type); } },
|
||||
{ title: '摘要', dataIndex: 'summary', key: 'summary', ellipsis: true },
|
||||
{ title: '操作人', dataIndex: 'operatorName', key: 'operatorName', width: 88 },
|
||||
{ title: '操作时间', dataIndex: 'operateTime', key: 'operateTime', width: 132, className: 'tabular-nums' },
|
||||
{ title: '审批状态', dataIndex: 'approvalStatus', key: 'approvalStatus', width: 96 },
|
||||
],
|
||||
dataSource: changeRecords,
|
||||
pagination: false,
|
||||
scroll: { x: 640 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!record || !summary) {
|
||||
return React.createElement('div', { className: 'vm-page lc-page lc-view-page vm-detail' },
|
||||
React.createElement('div', { className: 'vm-detail-topbar' },
|
||||
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-back', onClick: onBack }, '返回列表'),
|
||||
),
|
||||
React.createElement('p', { className: 'lc-view-page__empty' }, '未找到合同记录'),
|
||||
);
|
||||
}
|
||||
|
||||
return React.createElement('div', { className: 'vm-page lc-page lc-view-page vm-detail lc-view-detail-page' },
|
||||
React.createElement('div', { className: 'vm-detail-topbar lc-view-detail-topbar', 'data-annotation-id': 'lc-view-topbar' },
|
||||
React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-back', onClick: onBack }, '返回列表'),
|
||||
),
|
||||
React.createElement('div', { className: 'lc-view-detail-shell' },
|
||||
React.createElement('section', { className: 'vm-detail-card lc-view-detail-header-card' },
|
||||
React.createElement('div', { className: 'vm-detail-hero', 'data-annotation-id': 'lc-view-hero' },
|
||||
React.createElement('div', null,
|
||||
React.createElement('div', { className: 'vm-detail-plate lc-view-hero__title' }, summary.projectName),
|
||||
React.createElement('p', { className: 'vm-detail-meta' }, summary.customerName),
|
||||
React.createElement('p', { className: 'vm-detail-meta mono tabular-nums' }, summary.contractCode),
|
||||
),
|
||||
React.createElement('div', { className: 'vm-detail-aside lc-view-hero__aside' },
|
||||
React.createElement('div', { className: 'lc-view-hero-tags' },
|
||||
React.createElement(StatusTag, { label: summary.displayStatus, tone: contractStatusTone(summary.displayStatus) }),
|
||||
summary.showApprovalBadge
|
||||
? React.createElement(StatusTag, { label: summary.approvalStatus, tone: approvalStatusTone(summary.approvalStatus) })
|
||||
: null,
|
||||
),
|
||||
React.createElement('p', { className: 'vm-detail-meta' }, summary.contractType + ' · ' + summary.contractApprovalType),
|
||||
React.createElement('p', { className: 'vm-detail-gps-time' }, '签署状态:' + summary.signingSubLabel),
|
||||
),
|
||||
),
|
||||
React.createElement('div', { className: 'vm-detail-stats', 'data-annotation-id': 'lc-view-stats' },
|
||||
React.createElement(ViewDetailStat, { label: '签署方式', value: summary.signingMethodLabel }),
|
||||
React.createElement(ViewDetailStat, { label: '业务部门', value: record.businessDept || '—' }),
|
||||
React.createElement(ViewDetailStat, { label: '业务负责人', value: summary.businessOwner }),
|
||||
React.createElement(ViewDetailStat, {
|
||||
label: '租赁车辆',
|
||||
value: String(summary.vehicleCount || 0) + ' 辆',
|
||||
numeric: true,
|
||||
}),
|
||||
),
|
||||
React.createElement('div', {
|
||||
className: 'vm-tabs lc-view-detail-tabs',
|
||||
role: 'tablist',
|
||||
'aria-label': '合同详情章节',
|
||||
'data-annotation-id': 'lc-view-section-nav',
|
||||
},
|
||||
navItems.map(function (item) {
|
||||
var isActive = activeSection === item.key;
|
||||
return React.createElement('button', {
|
||||
key: item.key,
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
className: 'vm-tab' + (isActive ? ' active' : ''),
|
||||
'aria-selected': isActive ? 'true' : 'false',
|
||||
onClick: function () { scrollToSection(item.key); },
|
||||
}, item.label);
|
||||
}),
|
||||
),
|
||||
),
|
||||
React.createElement('div', {
|
||||
className: 'lc-view-detail-body',
|
||||
ref: scrollRef,
|
||||
'aria-label': '合同详情内容',
|
||||
},
|
||||
renderScrollSections(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user