迭代 ONE-OS 多原型:租赁/自营台账增强、租赁合同与条线说明,统一台账壳层与侧栏导航

- 新增业务部台账、收款记录、业务条线说明及 ledger-shared / vm-shared 公共壳层
- 租赁业务台账:字段计算、宽表交互、标注 PRD 与表头说明修复
- 租赁合同:列表/表单增强、客户资质与审批规则
- 侧栏调整:工作台移至 OneOS 根目录,台账与数据分析分组更新
- 补充 S3 批量发布脚本与弹窗明细表规范

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-06 17:18:23 +08:00
parent f5e31268dc
commit d0d7458989
96 changed files with 14434 additions and 2101 deletions

View File

@@ -33,8 +33,8 @@
"region": "cn-hangzhou", "region": "cn-hangzhou",
"bucket": "model-lnoneos", "bucket": "model-lnoneos",
"prefix": "", "prefix": "",
"baseUrl": "http://prototype.lnoneos.com/", "baseUrl": "https://prototype.lnoneos.com",
"endpoint": "" "endpoint": "https://model-lnoneos.oss-cn-hangzhou.aliyuncs.com"
}, },
"githubPages": { "githubPages": {
"repository": "", "repository": "",
@@ -42,11 +42,7 @@
"sourceDirectory": "/" "sourceDirectory": "/"
}, },
"publishSettings": { "publishSettings": {
"includeSource": true, "includeSource": true
"visibleTargets": [
"axhub",
"s3"
]
} }
} }
} }

View File

@@ -1,12 +1,18 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-07-01T09:58:50.712Z", "updatedAt": "2026-07-06T05:10:19.174Z",
"prototypes": [ "prototypes": [
{ {
"id": "folder-1782874576229-r8atbu", "id": "folder-1782874576229-r8atbu",
"kind": "folder", "kind": "folder",
"title": "OneOS", "title": "OneOS",
"children": [ "children": [
{
"id": "item:prototypes:oneos-web-workbench",
"kind": "item",
"title": "工作台",
"itemKey": "prototypes/oneos-web-workbench"
},
{ {
"id": "folder-1782874599304-roj8fd", "id": "folder-1782874599304-roj8fd",
"kind": "folder", "kind": "folder",
@@ -76,38 +82,71 @@
"itemKey": "prototypes/oneos-web-h2-station-site" "itemKey": "prototypes/oneos-web-h2-station-site"
} }
] ]
},
{
"id": "folder-oneos-data-analysis",
"kind": "folder",
"title": "数据分析",
"children": [
{
"id": "item:prototypes:business-dept-ledger",
"kind": "item",
"title": "业务部台账",
"itemKey": "prototypes/business-dept-ledger"
}
]
},
{
"id": "folder-1783314569299-4ejj2g",
"kind": "folder",
"title": "台账管理",
"children": [
{
"id": "item:prototypes:lease-business-ledger",
"kind": "item",
"title": "租赁业务台账",
"itemKey": "prototypes/lease-business-ledger"
},
{
"id": "item:prototypes:self-operated-business-ledger",
"kind": "item",
"title": "自营业务台账",
"itemKey": "prototypes/self-operated-business-ledger"
},
{
"id": "item:prototypes:vehicle-h2-fee-ledger",
"kind": "item",
"title": "车辆氢费明细",
"itemKey": "prototypes/vehicle-h2-fee-ledger"
}
]
},
{
"id": "folder-1783314610587-rwspc1",
"kind": "folder",
"title": "财务管理",
"children": [
{
"id": "item:prototypes:payment-records",
"kind": "item",
"title": "收款记录",
"itemKey": "prototypes/payment-records"
}
]
} }
] ]
}, },
{ {
"id": "item:prototypes:lease-business-ledger", "id": "item:prototypes:lease-business-line-overview",
"kind": "item", "kind": "item",
"title": "租赁业务台账", "title": "业务条线说明",
"itemKey": "prototypes/lease-business-ledger" "itemKey": "prototypes/lease-business-line-overview"
},
{
"id": "item:prototypes:vehicle-h2-fee-ledger",
"kind": "item",
"title": "车辆氢费明细",
"itemKey": "prototypes/vehicle-h2-fee-ledger"
},
{
"id": "item:prototypes:self-operated-business-ledger",
"kind": "item",
"title": "自营业务台账",
"itemKey": "prototypes/self-operated-business-ledger"
}, },
{ {
"id": "folder-prototypes-oneos-web", "id": "folder-prototypes-oneos-web",
"kind": "folder", "kind": "folder",
"title": "ONE-OS Web 端", "title": "ONE-OS Web 端",
"children": [ "children": [
{
"id": "item:prototypes:oneos-web-workbench",
"kind": "item",
"title": "工作台",
"itemKey": "prototypes/oneos-web-workbench"
},
{ {
"id": "item:prototypes:oneos-web-login", "id": "item:prototypes:oneos-web-login",
"kind": "item", "kind": "item",

View File

@@ -0,0 +1,40 @@
---
description: 弹窗/抽屉内 Ant Design Table 列对齐规范;完成含明细列表的弹窗后必须检查
globs: src/prototypes/**/*.{tsx,jsx,ts,js}
alwaysApply: false
---
# 弹窗明细表列对齐(防错行)
## 适用场景
Modal、Drawer 内展示明细列表的 `Table`,尤其启用 `scroll.y` 或 `scroll.x` 时。
## 必须遵守
1. **Table 属性**
- `className={LN_MODAL_DETAIL_TABLE_CLASS}`(来自 `src/common/modalDetailTable.ts`
- `tableLayout="fixed"`
- `scroll.x` 使用 `sumColumnScrollX(columns)`,值为各列 `width` 之和(可 +8~16 预留滚动条)
2. **列定义**
- **每一列**都必须设置数字 `width`,禁止仅 `ellipsis: true` 而不设宽度
- 金额列:`align: 'right'` + `className: 'tabular-nums'`
- 长文本列:`ellipsis: true` + 明确 `width`
3. **样式**
- 项目已提供 `src/common/modal-detail-table.css`(经 `vehicle-management/style.css` 全局引入)
- 不要覆盖 `.ant-table-header` / `.ant-table-body` 的 margin 或隐藏 measure-row 的局部 hack统一用 `ln-modal-detail-table`
## 完成前自检
实现或修改弹窗明细表后,在浏览器中确认:
- [ ] 表头文字与每列首行数据**垂直对齐**(不错行、不整体右偏)
- [ ] 纵向滚动时表头与表体列宽仍一致
- [ ] 金额列右对齐、长文本省略号正常
- [ ] 开发控制台无 `弹窗表格列缺少 width` 警告
## 参考实现
`src/prototypes/payment-records/components/PaymentKpiDetailModal.tsx`

View File

@@ -16,6 +16,7 @@
| 产物/场景 | 位置 | 参考文档 | | 产物/场景 | 位置 | 参考文档 |
|-----------|------|----------| |-----------|------|----------|
| vm-page 列表分页 | `src/common/TablePagination.tsx` + `vm-pagination.css` | `src/prototypes/vm-shared/DESIGN.md` |
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` | | 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` | | 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw``canvas-assets/` | 原型画布和画布素材 | | 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw``canvas-assets/` | 原型画布和画布素材 |

View File

@@ -13,6 +13,7 @@
| 产物/场景 | 位置 | 参考文档 | | 产物/场景 | 位置 | 参考文档 |
|-----------|------|----------| |-----------|------|----------|
| vm-page 列表分页 | `src/common/TablePagination.tsx` + `vm-pagination.css` | `src/prototypes/vm-shared/DESIGN.md` |
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` | | 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` | | 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
| UI Review 结论 | `src/prototypes/<prototype-name>/.spec/ui-review.md` | `rules/ui-review-guide.md` | | UI Review 结论 | `src/prototypes/<prototype-name>/.spec/ui-review.md` | `rules/ui-review-guide.md` |

View File

@@ -86,6 +86,16 @@ export default function MyApp() {
- 使用主题 CSS Variables 时,按所选 `DESIGN.md` 和主题规则引入,不复制另一套 token。 - 使用主题 CSS Variables 时,按所选 `DESIGN.md` 和主题规则引入,不复制另一套 token。
## 列表分页vm-page 强制)
`vm-page` 下的数据列表页,表格底部 **必须** 使用全局分页组件与样式,规范见 `src/prototypes/vm-shared/DESIGN.md`
- 组件:`import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination'`
- 样式:引入 `vehicle-management/style.css`(已包含 `src/common/vm-pagination.css`
- 禁止:下拉加载更多、自建分页 UI、与 `vm-pagination*` 不一致的第三方分页
参考:`vehicle-management/index.tsx``lease-business-ledger/index.tsx`
## 验收流程 ## 验收流程
运行原型验收脚本: 运行原型验收脚本:

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env node
/**
* 将项目原型页发布到 S3阿里云 OSS
* 流程Make Admin export-html 构建静态包 → 按页面独立前缀上传到 OSS。
*
* 用法:
* node scripts/publish-all-to-s3.mjs
* node scripts/publish-all-to-s3.mjs --dry-run
* node scripts/publish-all-to-s3.mjs --group prototypes
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import extract from 'extract-zip';
import { readServerInfo } from './utils/serverInfo.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const CONFIG_PATH = path.join(projectRoot, '.axhub/make/axhub.config.json');
function parseArgs(argv) {
const args = {
projectId: 'oneos1.2',
adminOrigin: '',
dryRun: false,
group: 'all',
includeSource: true,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim();
else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim();
else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim();
else if (arg === '--dry-run') args.dryRun = true;
else if (arg === '--no-source') args.includeSource = false;
}
return args;
}
function resolveAdminOrigin(explicit) {
if (explicit) return explicit.replace(/\/+$/u, '');
const info = readServerInfo(projectRoot, 'admin');
if (info?.origin) return info.origin.replace(/\/+$/u, '');
return 'http://localhost:53817';
}
function readS3Config() {
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const s3 = raw?.cloudPublishing?.s3;
if (!s3?.accessKeyId || !s3?.secretAccessKey || !s3?.bucket) {
throw new Error('请在 .axhub/make/axhub.config.json 中配置 cloudPublishing.s3');
}
return {
...s3,
includeSource: raw?.cloudPublishing?.publishSettings?.includeSource !== false,
};
}
function toPublishPath(resource) {
const raw = resource.filePath || resource.sourcePath || '';
return String(raw).replace(/\\/gu, '/').replace(/\/index\.tsx?$/iu, '');
}
function resolveObjectPrefix(entry) {
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
if (prototypeMatch?.[1]) return prototypeMatch[1];
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
return entry.id;
}
function resolvePublicUrl(baseUrl, prefix) {
const base = String(baseUrl || '').replace(/\/?$/u, '/');
const key = `${prefix.replace(/^\/+|\/+$/gu, '')}/index.html`;
return `${base}${key.split('/').map(encodeURIComponent).join('/')}`;
}
function guessContentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webp': 'image/webp',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.txt': 'text/plain; charset=utf-8',
};
return map[ext] || 'application/octet-stream';
}
async function collectPublishEntries(adminOrigin, projectId, groupFilter) {
const res = await fetch(`${adminOrigin}/api/projects/${encodeURIComponent(projectId)}/resources`);
if (!res.ok) {
throw new Error(`GET /api/projects/${projectId}/resources failed: ${res.status}`);
}
const data = await res.json();
const groups = groupFilter === 'all' ? ['prototypes', 'themes'] : [groupFilter];
const entries = [];
for (const group of groups) {
for (const item of data.resources?.[group] || []) {
const publishPath = toPublishPath(item);
if (!publishPath) continue;
entries.push({
group,
id: item.id,
title: item.title || item.id,
path: publishPath,
prefix: resolveObjectPrefix({ path: publishPath, id: item.id }),
});
}
}
return entries;
}
async function exportHtmlZip(adminOrigin, projectId, entry, includeSource) {
const params = new URLSearchParams({
path: entry.path,
projectId,
});
if (includeSource) params.set('includeSource', 'true');
const res = await fetch(`${adminOrigin}/api/export-html?${params.toString()}`);
if (!res.ok) {
const text = await res.text().catch(() => '');
let message = text;
try {
message = JSON.parse(text).error || text;
} catch {
// keep raw text
}
throw new Error(message || `export-html failed (${res.status})`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
throw new Error('export-html 未返回有效 ZIP');
}
return buffer;
}
function resolveS3Endpoint(s3Config) {
const bucket = String(s3Config.bucket || '').trim();
const endpoint = String(s3Config.endpoint || '').trim().replace(/\/+$/u, '');
const region = String(s3Config.region || 'cn-hangzhou').trim();
if (endpoint) {
// 配置里可能是 bucket 级域名model-lnoneos.oss-...AWS SDK 只需区域 endpoint。
const bucketHostPrefix = bucket ? `${bucket}.` : '';
if (bucketHostPrefix && endpoint.includes(bucketHostPrefix)) {
return endpoint.replace(bucketHostPrefix, '');
}
return endpoint;
}
return `https://oss-${region}.aliyuncs.com`;
}
function createS3Client(s3Config) {
return new S3Client({
region: s3Config.region || 'cn-hangzhou',
endpoint: resolveS3Endpoint(s3Config),
forcePathStyle: false,
credentials: {
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
},
});
}
async function uploadDirectory(client, bucket, prefix, dir) {
const uploaded = [];
const walk = async (currentDir, relative = '') => {
for (const name of fs.readdirSync(currentDir)) {
const abs = path.join(currentDir, name);
const rel = relative ? `${relative}/${name}` : name;
if (fs.statSync(abs).isDirectory()) {
await walk(abs, rel);
continue;
}
const key = [prefix.replace(/^\/+|\/+$/gu, ''), rel].filter(Boolean).join('/');
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fs.readFileSync(abs),
ContentType: guessContentType(abs),
}));
uploaded.push(key);
}
};
await walk(dir);
return uploaded;
}
async function publishEntry(client, s3Config, adminOrigin, projectId, entry, includeSource) {
const zipBuffer = await exportHtmlZip(adminOrigin, projectId, entry, includeSource);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'axhub-publish-'));
const zipPath = path.join(tempDir, 'bundle.zip');
const extractDir = path.join(tempDir, 'site');
fs.writeFileSync(zipPath, zipBuffer);
fs.mkdirSync(extractDir, { recursive: true });
await extract(zipPath, { dir: extractDir });
const objectPrefix = s3Config.prefix
? `${String(s3Config.prefix).replace(/^\/+|\/+$/gu, '')}/${entry.prefix}`
: entry.prefix;
const keys = await uploadDirectory(client, s3Config.bucket, objectPrefix, extractDir);
fs.rmSync(tempDir, { recursive: true, force: true });
return {
url: resolvePublicUrl(s3Config.baseUrl, objectPrefix),
fileCount: keys.length,
keys,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const adminOrigin = resolveAdminOrigin(args.adminOrigin);
const s3Config = readS3Config();
const includeSource = args.includeSource && s3Config.includeSource;
const entries = await collectPublishEntries(adminOrigin, args.projectId, args.group);
console.log(`Admin: ${adminOrigin}`);
console.log(`Bucket: ${s3Config.bucket}`);
console.log(`Base URL: ${s3Config.baseUrl}`);
console.log(`Pages to publish: ${entries.length}`);
if (args.dryRun) {
for (const entry of entries) {
console.log(`[dry-run] ${entry.group}/${entry.id} -> ${entry.prefix}/index.html`);
}
return;
}
const client = createS3Client(s3Config);
const results = [];
let failed = 0;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
const label = `[${i + 1}/${entries.length}] ${entry.group}/${entry.id}`;
process.stdout.write(`${label} ... `);
try {
const result = await publishEntry(client, s3Config, adminOrigin, args.projectId, entry, includeSource);
console.log(`OK ${result.url} (${result.fileCount} files)`);
results.push({ ...entry, status: 'success', ...result });
} catch (error) {
failed += 1;
const message = error instanceof Error ? error.message : String(error);
console.log(`FAIL ${message}`);
results.push({ ...entry, status: 'failed', error: message });
}
}
const reportPath = path.join(
projectRoot,
'.axhub/make/exports',
`cloud.publish.s3-all-${new Date().toISOString().replace(/[:.]/gu, '-')}.json`,
);
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify({
schemaVersion: 2,
projectId: args.projectId,
target: 's3',
method: 'export-html+s3-upload',
total: entries.length,
success: entries.length - failed,
failed,
completedAt: new Date().toISOString(),
results: results.map(({ keys, ...rest }) => ({
...rest,
fileCount: rest.fileCount ?? keys?.length ?? 0,
})),
}, null, 2)}\n`, 'utf8');
console.log(`\nDone: ${entries.length - failed}/${entries.length} succeeded`);
if (failed > 0) process.exitCode = 1;
console.log(`Report: ${reportPath}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -20,6 +20,12 @@ const XLL_MODULES = [
{ slug: 'oneos-web-safety-training', title: '业务-司机安全培训', skipCodegen: true }, { slug: 'oneos-web-safety-training', title: '业务-司机安全培训', skipCodegen: true },
]; ];
/** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */
const STANDALONE_MODULES = [
{ slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true },
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
];
const MODULES = [ const MODULES = [
{ slug: 'oneos-web-workbench', title: '工作台', files: ['工作台.jsx'] }, { slug: 'oneos-web-workbench', title: '工作台', files: ['工作台.jsx'] },
{ slug: 'oneos-web-login', title: '登录', files: ['登录.jsx'] }, { slug: 'oneos-web-login', title: '登录', files: ['登录.jsx'] },
@@ -29,8 +35,8 @@ const MODULES = [
{ slug: 'oneos-web-finance', title: '财务管理', dir: '财务管理', excludeDirs: ['文档'] }, { slug: 'oneos-web-finance', title: '财务管理', dir: '财务管理', excludeDirs: ['文档'] },
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' }, { slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' }, { slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },
{ slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'] }, { slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'], excludeFiles: ['站点信息.jsx'] },
{ slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析' }, { slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx'] },
{ slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'] }, { slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'] },
{ slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] }, { slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] },
{ slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' }, { slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' },
@@ -81,6 +87,7 @@ function collectJsxFiles(module) {
continue; continue;
} }
if (!entry.name.endsWith('.jsx')) continue; if (!entry.name.endsWith('.jsx')) continue;
if (module.excludeFiles?.includes(entry.name)) continue;
results.push({ results.push({
rel, rel,
abs: full, abs: full,
@@ -223,6 +230,15 @@ function toComponentName(slug) {
.join(''); .join('');
} }
const WORKBENCH_SIDEBAR_ITEM = {
id: 'item:prototypes:oneos-web-workbench',
kind: 'item',
title: '工作台',
itemKey: 'prototypes/oneos-web-workbench',
};
const ONEOS_NAV_FOLDER_ID = 'folder-1782874576229-r8atbu';
function toSidebarItem(module) { function toSidebarItem(module) {
return { return {
id: `item:prototypes:${module.slug}`, id: `item:prototypes:${module.slug}`,
@@ -232,6 +248,17 @@ function toSidebarItem(module) {
}; };
} }
function ensureWorkbenchInOneOsRoot(prototypes) {
for (const item of prototypes) {
if (item?.id !== ONEOS_NAV_FOLDER_ID || item.kind !== 'folder' || !Array.isArray(item.children)) {
continue;
}
const rest = item.children.filter((child) => child.itemKey !== WORKBENCH_SIDEBAR_ITEM.itemKey);
item.children = [WORKBENCH_SIDEBAR_ITEM, ...rest];
return;
}
}
function updateSidebar(webModules, xllModules) { function updateSidebar(webModules, xllModules) {
const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8')); const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8'));
const prefix = 'item:prototypes:oneos-web-'; const prefix = 'item:prototypes:oneos-web-';
@@ -244,6 +271,10 @@ function updateSidebar(webModules, xllModules) {
&& !String(item.itemKey).startsWith('prototypes/oneos-web-'), && !String(item.itemKey).startsWith('prototypes/oneos-web-'),
); );
ensureWorkbenchInOneOsRoot(existing);
const webModulesForFolder = webModules.filter((module) => module.slug !== 'oneos-web-workbench');
const xllFolder = { const xllFolder = {
id: 'folder-prototypes-xll-miniapp', id: 'folder-prototypes-xll-miniapp',
kind: 'folder', kind: 'folder',
@@ -256,7 +287,7 @@ function updateSidebar(webModules, xllModules) {
kind: 'folder', kind: 'folder',
title: 'ONE-OS Web 端', title: 'ONE-OS Web 端',
defaultExpanded: true, defaultExpanded: true,
children: webModules.map(toSidebarItem), children: webModulesForFolder.map(toSidebarItem),
}; };
sidebar.prototypes = [...existing, xllFolder, oneosFolder]; sidebar.prototypes = [...existing, xllFolder, oneosFolder];
@@ -270,7 +301,7 @@ function main() {
process.exit(1); process.exit(1);
} }
const allModules = [...XLL_MODULES, ...MODULES]; const allModules = [...XLL_MODULES, ...STANDALONE_MODULES, ...MODULES];
for (const module of allModules) { for (const module of allModules) {
if (module.skipCodegen) { if (module.skipCodegen) {

View File

@@ -0,0 +1,105 @@
import React, { useMemo } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
/** 全局默认每页条数,见 vm-shared/DESIGN.md */
export const DEFAULT_PAGE_SIZE = 20;
/** 全局可选每页条数 */
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const;
export interface TablePaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
function buildPageItems(current: number, totalPages: number): Array<number | 'ellipsis'> {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const items: Array<number | 'ellipsis'> = [1];
if (current > 3) items.push('ellipsis');
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let page = start; page <= end; page += 1) {
items.push(page);
}
if (current < totalPages - 2) items.push('ellipsis');
if (totalPages > 1) items.push(totalPages);
return items;
}
export function TablePagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]);
if (total === 0) return null;
return (
<div className="vm-pagination" role="navigation" aria-label="表格分页">
<label className="vm-pagination-size">
<span className="vm-pagination-size-label"></span>
<select
className="vm-pagination-select"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
aria-label="每页显示条数"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="vm-pagination-size-label"></span>
</label>
<div className="vm-pagination-pages">
<button
type="button"
className="vm-pagination-nav"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeft size={16} aria-hidden />
</button>
{pageItems.map((item, index) => (
item === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden></span>
) : (
<button
key={item}
type="button"
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
onClick={() => onPageChange(item)}
aria-current={page === item ? 'page' : undefined}
>
{item}
</button>
)
))}
<button
type="button"
className="vm-pagination-nav"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRight size={16} aria-hidden />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
/**
* 弹窗 / 抽屉内 Ant Design Table 通用样式
* 防止 scroll.y 场景下表头与表体列宽错行、measure-row 撑开列宽等问题。
* 使用方式Table className="ln-modal-detail-table" tableLayout="fixed" + 每列显式 width
*/
.ln-modal-detail-table.ant-table-wrapper .ant-table {
background: transparent;
}
.ln-modal-detail-table.ant-table-wrapper .ant-table-container {
border-inline-start: none;
border-top: none;
}
.ln-modal-detail-table.ant-table-wrapper .ant-table-header {
margin-bottom: 0 !important;
overflow: hidden !important;
}
.ln-modal-detail-table.ant-table-wrapper .ant-table-body {
margin-top: 0 !important;
overflow: auto !important;
}
.ln-modal-detail-table.ant-table-wrapper .ant-table-body > table,
.ln-modal-detail-table.ant-table-wrapper .ant-table-header > table {
margin-top: 0 !important;
table-layout: fixed !important;
}
.ln-modal-detail-table .ant-table-tbody > tr.ant-table-measure-row,
.ln-modal-detail-table .ant-table-tbody > tr.ant-table-measure-row > td,
.ln-modal-detail-table .ant-table-tbody > tr.ant-table-measure-row > th {
display: none !important;
height: 0 !important;
max-height: 0 !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
border: none !important;
line-height: 0 !important;
font-size: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
pointer-events: none !important;
}
.ln-modal-detail-table .ant-table-thead > tr > th {
background: var(--ln-canvas-soft, #f8fafc) !important;
font-size: 12px !important;
font-weight: 600 !important;
color: var(--ln-muted, #64748b) !important;
padding: 8px 12px !important;
white-space: nowrap;
}
.ln-modal-detail-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
padding: 8px 12px !important;
font-size: 13px;
vertical-align: middle;
}
.ln-modal-detail-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td.ant-table-cell-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ln-modal-detail-table .ant-table-tbody > tr:hover > td {
background: var(--ln-canvas-soft, #f8fafc) !important;
}
.ln-modal-detail-table .ant-table-cell-scrollbar {
box-shadow: none !important;
}
.ln-modal-detail-table .ant-table-thead > tr > th.ln-modal-detail-table__money,
.ln-modal-detail-table .ant-table-tbody > tr > td.ln-modal-detail-table__money {
font-variant-numeric: tabular-nums;
}
/* 左侧冻结列(收款时间等) */
.ln-modal-detail-table .ant-table-cell-fix-left,
.ln-modal-detail-table .ant-table-cell-fix-start {
z-index: 2;
background: var(--ln-surface-card, #fff) !important;
}
.ln-modal-detail-table .ant-table-thead > tr > th.ant-table-cell-fix-left,
.ln-modal-detail-table .ant-table-thead > tr > th.ant-table-cell-fix-start {
z-index: 3;
background: var(--ln-canvas-soft, #f8fafc) !important;
}
.ln-modal-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.ln-modal-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-start {
background: var(--ln-canvas-soft, #f8fafc) !important;
}
.ln-modal-detail-table .ant-table-cell-fix-left-last::after,
.ln-modal-detail-table .ant-table-cell-fix-start-last::after {
box-shadow: inset 10px 0 8px -8px rgba(15, 23, 42, 0.12);
}
.ln-modal-detail-table .ln-modal-detail-table__sticky-time {
white-space: nowrap;
}
/* 弹窗明细列表面板容器 */
.ln-modal-detail-panel {
border: 1px solid var(--ln-hairline, #e2e8f0);
border-radius: var(--ln-radius-card, 12px);
background: var(--ln-surface-card, #fff);
overflow: hidden;
box-shadow: var(--ln-shadow-soft, 0 1px 2px rgba(24, 24, 27, 0.06));
}
.ln-modal-detail-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--ln-hairline, #e2e8f0);
background: linear-gradient(180deg, #fafbfc 0%, #f6f7f7 100%);
}
.ln-modal-detail-panel__title {
font-size: 13px;
font-weight: 600;
color: var(--ln-body-strong, #18181b);
}
.ln-modal-detail-panel__meta {
font-size: 12px;
color: var(--ln-muted, #71717a);
}
.ln-modal-detail-panel .ln-modal-detail-table.ant-table-wrapper {
padding: 0;
}
.ln-modal-detail-panel .ln-modal-detail-table .ant-table-pagination {
margin: 0 !important;
padding: 10px 14px !important;
border-top: 1px solid var(--ln-hairline, #e2e8f0);
background: #fafbfc;
}
.ln-modal-detail-empty {
padding: 28px 16px;
text-align: center;
color: var(--ln-muted, #71717a);
}
.ln-modal-detail-empty p {
margin: 0;
font-size: 14px;
color: var(--ln-body, #52525b);
}
.ln-modal-detail-empty__hint {
margin-top: 6px !important;
font-size: 12px !important;
color: var(--ln-muted, #94a3b8) !important;
}
@media (prefers-reduced-motion: reduce) {
.pr-kpi-detail-modal .ant-modal {
animation: none !important;
}
}

View File

@@ -0,0 +1,25 @@
import type { ColumnsType } from 'antd/es/table';
/** 弹窗明细表统一 className配合 src/common/modal-detail-table.css */
export const LN_MODAL_DETAIL_TABLE_CLASS = 'ln-modal-detail-table';
/** 计算 Ant Table scroll.xexpand 默认预留滚动条/展开列余量 */
export function sumColumnScrollX<T>(columns: ColumnsType<T>, expand = 0): number {
const base = columns.reduce((sum, col) => sum + (Number(col.width) || 0), 0);
return base + expand;
}
/** 开发环境检查:弹窗表每列必须设置 width避免表头表体错行 */
export function warnMissingModalColumnWidths<T>(
columns: ColumnsType<T>,
context: string,
): void {
if (typeof import.meta !== 'undefined' && !import.meta.env?.DEV) return;
const missing = columns.filter((col) => col.width == null);
if (missing.length > 0) {
console.warn(
`[${context}] 弹窗表格列缺少 width可能导致表头与内容错行`,
missing.map((col) => col.key ?? col.title),
);
}
}

View File

@@ -0,0 +1,138 @@
/**
* ONE-OS vm-page 列表分页 — 全局样式
* 规范src/prototypes/vm-shared/DESIGN.md
* 组件src/common/TablePagination.tsx
*/
.vm-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
width: 100%;
padding: 0;
}
.vm-pagination-size {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
color: var(--ln-muted);
flex-shrink: 0;
}
.vm-pagination-size-label {
white-space: nowrap;
}
.vm-pagination-select {
min-width: 72px;
height: var(--vm-control-height);
padding: 0 28px 0 10px;
border: 1px solid var(--ln-hairline-strong);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-card);
color: var(--ln-ink);
font-size: 0.875rem;
cursor: pointer;
}
.vm-pagination-select:focus {
outline: none;
border-color: var(--vm-focus-border-color);
}
.vm-pagination-pages {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.vm-pagination-nav,
.vm-pagination-page {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--vm-control-height);
height: var(--vm-control-height);
padding: 0 8px;
border: 1px solid var(--ln-hairline-strong);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-card);
color: var(--ln-ink);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.vm-pagination-nav:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vm-pagination-nav:hover:not(:disabled),
.vm-pagination-page:hover:not(.active) {
background: var(--ln-canvas-soft);
border-color: var(--ln-hairline-strong);
}
.vm-pagination-page.active {
background: var(--ln-primary);
border-color: var(--ln-primary);
color: var(--ln-on-primary);
}
.vm-pagination-nav:focus-visible,
.vm-pagination-page:focus-visible {
outline: none;
border-color: var(--vm-focus-border-color);
}
.vm-pagination-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: var(--vm-control-height);
color: var(--ln-muted);
font-size: 0.875rem;
}
/* 详情 Tab 等紧凑场景vm-table-footer--split vm-table-footer--compact */
.vm-table-footer--compact .vm-pagination {
width: auto;
flex: 0 1 auto;
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
}
.vm-table-footer--compact .vm-pagination-size {
gap: 4px;
font-size: 0.8125rem;
}
.vm-table-footer--compact .vm-pagination-select {
min-width: 52px;
height: 28px;
padding: 0 22px 0 8px;
font-size: 0.8125rem;
}
.vm-table-footer--compact .vm-pagination-nav,
.vm-table-footer--compact .vm-pagination-page {
min-width: 28px;
height: 28px;
padding: 0 6px;
font-size: 0.8125rem;
}
.vm-table-footer--compact .vm-pagination-ellipsis {
min-width: 20px;
height: 28px;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,26 @@
/**
* @name 业务部台账
* 自 ONE-OS Web 端 / 数据分析 / 业务部台账 独立预览OneOS 菜单入口)
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import React, { useEffect } from 'react';
import * as antd from 'antd';
import { ConfigProvider } from 'antd';
import 'antd/dist/reset.css';
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import BusinessDeptLedgerPage from '../oneos-web-data-analysis/pages/05-业务部台账.jsx';
window.React = React;
window.antd = antd;
export default function BusinessDeptLedger() {
useEffect(() => {
clearHostPrototypeRouteInfo();
}, []);
return (
<ConfigProvider>
<BusinessDeptLedgerPage />
</ConfigProvider>
);
}

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "contract-template-management", "prototypeName": "contract-template-management",
"pageId": "list", "pageId": "list",
"updatedAt": 1782871095265, "updatedAt": 1783328981482,
"nodes": [ "nodes": [
{ {
"id": "ct-list-filter", "id": "ct-list-filter",
@@ -332,66 +332,10 @@
"title": "ONE-OS 原型导航", "title": "ONE-OS 原型导航",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{
"type": "link",
"id": "nav-link-contract-template-management",
"title": "合同模板管理(当前)",
"href": "/prototypes/contract-template-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-insurance-procurement",
"title": "保险采购",
"href": "/prototypes/insurance-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-management",
"title": "车辆管理",
"href": "/prototypes/vehicle-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-contract-management",
"title": "租赁合同管理",
"href": "/prototypes/lease-contract-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-customer-management",
"title": "客户管理",
"href": "/prototypes/customer-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-business-ledger",
"title": "租赁业务台账",
"href": "/prototypes/lease-business-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-h2-fee-ledger",
"title": "车辆氢费明细",
"href": "/prototypes/vehicle-h2-fee-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-self-operated-business-ledger",
"title": "自营业务台账",
"href": "/prototypes/self-operated-business-ledger",
"target": "self"
},
{ {
"type": "folder", "type": "folder",
"id": "nav-folder-prototypes-oneos-web", "id": "nav-folder-1782874576229-r8atbu",
"title": "ONE-OS Web 端", "title": "OneOS",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{ {
@@ -400,7 +344,22 @@
"title": "工作台", "title": "工作台",
"href": "/prototypes/oneos-web-workbench", "href": "/prototypes/oneos-web-workbench",
"target": "self" "target": "self"
}, }
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{ {
"type": "link", "type": "link",
"id": "nav-link-oneos-web-login", "id": "nav-link-oneos-web-login",

View File

@@ -93,3 +93,24 @@ export function applyAttachmentSectionVehicleFilter(html, sectionVehicleBindings
} }
return out; return out;
} }
/** 附件章节删减后,按当前保留顺序重排「附件 N」序号 */
export function renumberAttachmentSections(html) {
var source = String(html || '');
var ranges = collectSectionRanges(source);
if (!ranges.length) return source;
var chunks = [];
var cursor = 0;
ranges.forEach(function (range, idx) {
var newNo = idx + 1;
chunks.push(source.slice(cursor, range.start));
var slice = source.slice(range.start, range.end);
slice = slice.replace(/附件\s*\d+\s*[:]/, '附件' + newNo + '');
slice = slice.replace(/<b>\s*(\d+):租赁订单\s*<\/b>/, '<b>' + newNo + ':租赁订单 </b>');
chunks.push(slice);
cursor = range.end;
});
chunks.push(source.slice(cursor));
return chunks.join('');
}

View File

@@ -68,10 +68,10 @@ export var MOCK_LEASE_CUSTOMERS = [
bankAccount: '6222021234567890123', bankAccount: '6222021234567890123',
taxId: '91330400MA2XXXXX1', taxId: '91330400MA2XXXXX1',
attachments: { attachments: {
businessLicense: { name: '嘉兴某某物流-营业执照.pdf' }, businessLicense: { name: '嘉兴某某物流-营业执照.pdf', ocrVerified: true, expiryDate: '2028-06-30' },
legalIdFront: { name: '法人身份证-正面.jpg' }, legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
legalIdBack: { name: '法人身份证-反面.jpg' }, legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
roadTransportPermit: { name: '道路运输经营许可证.pdf' }, roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2027-12-31' },
}, },
}, },
{ {
@@ -91,10 +91,10 @@ export var MOCK_LEASE_CUSTOMERS = [
bankAccount: '6217001234567890123', bankAccount: '6217001234567890123',
taxId: '91310000MA2XXXXX2', taxId: '91310000MA2XXXXX2',
attachments: { attachments: {
businessLicense: { name: '上海某某运输-营业执照.pdf' }, businessLicense: { name: '上海某某运输-营业执照.pdf', ocrVerified: true, expiryDate: '2027-03-01' },
legalIdFront: { name: '法人身份证-正面.jpg' }, legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
legalIdBack: { name: '法人身份证-反面.jpg' }, legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
roadTransportPermit: { name: '道路运输经营许可证.pdf' }, roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-01-10' },
}, },
}, },
{ {
@@ -114,10 +114,10 @@ export var MOCK_LEASE_CUSTOMERS = [
bankAccount: '6228481234567890123', bankAccount: '6228481234567890123',
taxId: '91330100MA2XXXXX3', taxId: '91330100MA2XXXXX3',
attachments: { attachments: {
businessLicense: { name: '杭州某某租赁-营业执照.pdf' }, businessLicense: { name: '杭州某某租赁-营业执照.pdf', ocrVerified: true, expiryDate: '2026-09-20' },
legalIdFront: { name: '法人身份证-正面.jpg' }, legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
legalIdBack: { name: '法人身份证-反面.jpg' }, legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
roadTransportPermit: { name: '道路运输经营许可证.pdf' }, roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-02-01' },
}, },
}, },
]; ];
@@ -214,6 +214,16 @@ export var DEFAULT_FEE_INFO = {
returnHydrogenDiffUnitPrice: null, returnHydrogenDiffUnitPrice: null,
}; };
/** 新建合同时系统自动生成的编号后缀(不含 LNZLHT 前缀) */
export function generateAutoContractCodeSuffix() {
var now = new Date();
var y = now.getFullYear();
var m = String(now.getMonth() + 1).padStart(2, '0');
var d = String(now.getDate()).padStart(2, '0');
var seq = String(Math.floor(Math.random() * 9000) + 1000);
return y + m + d + seq;
}
export var PAYMENT_PERIOD_OPTIONS = [ export var PAYMENT_PERIOD_OPTIONS = [
{ value: 1, label: '1个月' }, { value: 1, label: '1个月' },
{ value: 3, label: '3个月' }, { value: 3, label: '3个月' },
@@ -224,6 +234,7 @@ export var PAYMENT_PERIOD_OPTIONS = [
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [ export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
{ value: 'self', label: '自行解决' }, { value: 'self', label: '自行解决' },
{ value: 'prepay', label: '预付款' }, { value: 'prepay', label: '预付款' },
{ value: 'month', label: '按月结算(原型占位,条款待法务确认)' },
]; ];
export function getLessorCompanies() { export function getLessorCompanies() {

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "customer-management", "prototypeName": "customer-management",
"pageId": "list", "pageId": "list",
"updatedAt": 1782896901748, "updatedAt": 1783328981485,
"nodes": [ "nodes": [
{ {
"id": "cm-list-filter", "id": "cm-list-filter",
@@ -245,6 +245,135 @@
"assetMap": {}, "assetMap": {},
"directory": { "directory": {
"nodes": [ "nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "folder",
"id": "nav-folder-1782874576229-r8atbu",
"title": "OneOS",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-workbench",
"title": "工作台",
"href": "/prototypes/oneos-web-workbench",
"target": "self"
}
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-login",
"title": "登录",
"href": "/prototypes/oneos-web-login",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-vehicle-asset",
"title": "车辆管理",
"href": "/prototypes/oneos-web-vehicle-asset",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-contract-template",
"title": "合同模板管理",
"href": "/prototypes/oneos-web-contract-template",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-help-center",
"title": "帮助中心",
"href": "/prototypes/oneos-web-help-center",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-finance",
"title": "财务管理",
"href": "/prototypes/oneos-web-finance",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-procurement",
"title": "采购管理",
"href": "/prototypes/oneos-web-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-lease-contract",
"title": "车辆租赁合同",
"href": "/prototypes/oneos-web-lease-contract",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-h2-station",
"title": "加氢站管理",
"href": "/prototypes/oneos-web-h2-station",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-data-analysis",
"title": "数据分析",
"href": "/prototypes/oneos-web-data-analysis",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ledger-data",
"title": "台账数据",
"href": "/prototypes/oneos-web-ledger-data",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-business",
"title": "业务管理",
"href": "/prototypes/oneos-web-business",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ops",
"title": "运维管理",
"href": "/prototypes/oneos-web-ops",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-requirements",
"title": "需求说明",
"href": "/prototypes/oneos-web-requirements",
"target": "self"
}
]
}
]
},
{ {
"type": "folder", "type": "folder",
"id": "cm-doc-root", "id": "cm-doc-root",
@@ -261,7 +390,7 @@
"type": "link", "type": "link",
"id": "cm-link-self", "id": "cm-link-self",
"title": "客户管理原型", "title": "客户管理原型",
"href": "/prototypes/customer-management", "href": "./index.html",
"target": "self" "target": "self"
}, },
{ {

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "insurance-procurement", "prototypeName": "insurance-procurement",
"pageId": "list", "pageId": "list",
"updatedAt": 1782871095269, "updatedAt": 1783328981486,
"nodes": [ "nodes": [
{ {
"id": "ipc-header", "id": "ipc-header",
@@ -502,66 +502,10 @@
"title": "ONE-OS 原型导航", "title": "ONE-OS 原型导航",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{
"type": "link",
"id": "nav-link-contract-template-management",
"title": "合同模板管理",
"href": "/prototypes/contract-template-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-insurance-procurement",
"title": "保险采购(当前)",
"href": "/prototypes/insurance-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-management",
"title": "车辆管理",
"href": "/prototypes/vehicle-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-contract-management",
"title": "租赁合同管理",
"href": "/prototypes/lease-contract-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-customer-management",
"title": "客户管理",
"href": "/prototypes/customer-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-business-ledger",
"title": "租赁业务台账",
"href": "/prototypes/lease-business-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-h2-fee-ledger",
"title": "车辆氢费明细",
"href": "/prototypes/vehicle-h2-fee-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-self-operated-business-ledger",
"title": "自营业务台账",
"href": "/prototypes/self-operated-business-ledger",
"target": "self"
},
{ {
"type": "folder", "type": "folder",
"id": "nav-folder-prototypes-oneos-web", "id": "nav-folder-1782874576229-r8atbu",
"title": "ONE-OS Web 端", "title": "OneOS",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{ {
@@ -570,7 +514,22 @@
"title": "工作台", "title": "工作台",
"href": "/prototypes/oneos-web-workbench", "href": "/prototypes/oneos-web-workbench",
"target": "self" "target": "self"
}, }
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{ {
"type": "link", "type": "link",
"id": "nav-link-oneos-web-login", "id": "nav-link-oneos-web-login",

View File

@@ -1,6 +1,6 @@
# 租赁业务台账 · 产品需求说明PRD # 租赁业务台账 · 产品需求说明PRD
> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。 > 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,收款关联与状态自动计算为原型演示。
--- ---
@@ -10,8 +10,8 @@
|---|---| |---|---|
| 模块名称 | 台账数据 — 租赁业务台账 | | 模块名称 | 台账数据 — 租赁业务台账 |
| 目标用户 | 租赁业务客服、业务服务组主管 | | 目标用户 | 租赁业务客服、业务服务组主管 |
| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 | | 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、关联收款记录 |
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页 | | 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 底部分页 |
--- ---
@@ -25,8 +25,8 @@
| 业务部门 / 业务员 | 精确匹配 | | 业务部门 / 业务员 | 精确匹配 |
| 车牌号码 | 精确匹配 | | 车牌号码 | 精确匹配 |
| 客户名称 | 模糊包含 | | 客户名称 | 模糊包含 |
| 明细状态 | 草稿 / 已确认 / 部分收款 / 已收款 | | 明细状态 | 未收款 / 部分收款 / 已收款 |
| 付款日 | 日期区间 | | 付款日 | 单输入框双日历区间,精确至日 |
--- ---
@@ -55,18 +55,18 @@
|---|---| |---|---|
| 批量导入 | 下载模板并上传 Excel原型 Toast | | 批量导入 | 下载模板并上传 Excel原型 Toast |
| 导出 | 导出当前筛选结果 | | 导出 | 导出当前筛选结果 |
| 标记为已确认 | 勾选草稿行后二次确认 | | 保存 | 勾选记录后设为未归档,保存后不可编辑 |
| 提交收款审批 | 勾选行提交审批 | | 设置为归档 | 勾选记录后锁定为已归档 |
添加行已移至表格底部;列表采用下拉加载更多,不做分页 表格底部提供分页,可切换页码与每页条数
### 标记为已确认 ### 保存
勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改 勾选台账记录后点击,将账单状态设为**未归档**。未归档数据不可编辑,仅草稿可编辑
### 添加行 ### 设置为归档
位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制 勾选台账记录后点击,将账单状态设为**已归档**并锁定编辑
--- ---
@@ -78,132 +78,344 @@
--- ---
## 6. 状态流转 ## 6. 收款状态
1. 新增/导入 → **草稿**(业务服务组可删改) ## 状态
2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)
3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款** **数据来源**:收款记录模块中关联到本账单的分配金额、账单应收合计。
**计算方式**:分配金额 ≥ 应收合计且应收 > 0 → 已收款0 < 分配金额 < 应收 → 部分收款;未关联收款 → 未收款。
**录入方式**:系统自动计算,只读。
**业务说明**:当收款记录中分配金额完全覆盖账单的应收金额时,显示已收款。当收款记录中分配金额≤账单的应收金额时,显示部分收款。未分配收款记录时,显示为未收款。其他状态去除
--- ---
## 7. 字段说明 ## 7. 字段说明
### 业务部门列 ### 应付款日
业务部门与业务负责人合并为一列:第一行显示为业务部门,第二行显示为业务负责人 **数据来源**:账单起始日期(优先)或提车日期作为开始计费日;账单所属年月
**计算方式**:首期账单应付款日 = 开始计费日;后续每期 = 账单所属自然月 5 日。
**录入方式**:系统自动计算,只读。
### 车牌号码列 **业务说明**:交车后按「开始计费日期」生成首期账单应付款日;后续每期账单在所属自然月 5 日生成下一期应付款日。
车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。 ### 车牌号码
### 客户名称列 **数据来源**:车辆档案(按车牌匹配品牌、型号)。
**计算方式**:导入/新增台账时按车牌号反写车辆信息。
**录入方式**:只读展示(车牌 + 车型两行)。
客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。 **业务说明**:车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。
### 客户名称
**数据来源**:租赁合同(按账单日期 + 车牌匹配客户、项目)。
**计算方式**:匹配当前车辆在账期内的有效合同,带出客户名称与项目名称。
**录入方式**:只读展示(客户 + 项目两行)。
**业务说明**:客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。
### 增值服务
**数据来源**:租赁合同签订时勾选的增值服务项目。
**计算方式**:按车牌匹配合同增值服务配置,多项服务分行展示。
**录入方式**:只读。
**业务说明**:签订合同时勾选的增值服务项目,按车牌自动匹配合同数据;多项服务各占一行展示。
### 提车日期 ### 提车日期
等于交车管理中该车辆运维 E 签宝签字完成时间。 **数据来源**:交车管理 / 运维 E 签宝签字完成时间。
**计算方式**:取该车辆实际交车办结时间。
**录入方式**:只读。
**业务说明**:运维实际交车日期。
### 合同生效日期
**数据来源**:提车日期(交车办结时间)。
**计算方式**:合同生效日期 = 提车日期YYYY-MM-DD系统自动反写。
**录入方式**:系统自动反写,只读。
**业务说明**:等于提车日期,即租赁合同生效日期,系统自动反写,只读。
### 合同到期日期
**数据来源**:合同租期月数 + 提车日期,或合同约定的到期日。
**计算方式**:日期模式:取合同存储的 contractEndDate租期模式提车日 + leaseTermMonths 个月。
**录入方式**:系统自动计算,只读。
**业务说明**:合同到期日:租期模式按提车日期 + 租期月数计算;日期模式取合同中约定的到期日。
### 起始日期
**数据来源**:账单年月、提车日期。
**计算方式**:月前已提车 → 当月 1 日;月内提车 → 提车日;月后提车 → 空calcPeriodStartDate
**录入方式**:系统自动计算,只读。
**业务说明**:账单所属月内的租赁起始日:月前已提车取当月 1 日,月内提车取提车日;系统自动计算,只读。
### 退车日期
**数据来源**:退车管理登记的退车日期。
**计算方式**:取该车牌退车办结日期;未退车为空。
**录入方式**:只读。
**业务说明**:退车管理中登记的退车日期;未退车时为空。
### 平均天数
**数据来源**:账单月内租赁天数、当月自然日天数。
**计算方式**:平均天数 = 账单月内租赁天数 ÷ 当月自然日天数保留两位小数calcAvgDays
**录入方式**:系统自动计算,只读。
**业务说明**:平均天数 = 账单月内租赁天数 ÷ 当月自然日天数,保留两位小数;用于成本分摊口径。
### 保证金 ### 保证金
与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示 **数据来源**:提车应收款 / 合同保证金
**计算方式**:与创建合同时保证金、提车应收款中该车辆保证金一致。
**录入方式**:只读。
**业务说明**:显示该车辆实付保证金金额,与提车应收款时该车辆收取的提车应收款相同。
### 合同标的租金 ### 合同标的租金
等于该车辆在签订该合同时合同中的租金(月租金标准 **数据来源**:租赁合同月租金标准。
**计算方式**:签订合同时该车辆的月租金(合同标的租金)。
**录入方式**:合同带入,只读。
**业务说明**:等于该车辆在签订该合同时合同中的租金(月租金标准)。
### 应收合计
**数据来源**:应收租金、保险上浮费、运维费(收入侧)、其他收入、里程/其他减免。
**计算方式**:应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免 其他减免calcReceivableTotal
**录入方式**:系统自动汇总,只读。
**业务说明**:应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免金额 其他减免金额。
### 应收租金 ### 应收租金
按合同付款周期1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。 **数据来源**:合同标的租金、合同付款周期月数。
**计算方式**:应收租金 = 合同标的租金 × 付款周期月数1/3/6/12calcReceivableRent
**录入方式**:系统自动计算,只读。
**业务说明**按合同付款周期1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。
### 月度收入 ### 月度收入
租金 + 服务费 里程减免金额 其他减免金额(后两项为减项) **数据来源**:月度租金、维保包干、保险上浮费、运维费(收入侧)、其他收入、减免项
**计算方式**:月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免 其他减免calcMonthlyIncome
**录入方式**:系统自动计算,只读。
**业务说明**:月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免金额 其他减免金额。
### 月度租金 ### 月度租金
根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。 **数据来源**合同标的租金。
**计算方式**:月度租金 = 合同标的租金calcMonthlyRent
**录入方式**:只读。
**业务说明**:根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。
### 维保包干收入 ### 维保包干收入
合同勾选维保包干时:单价(元/km× 里程数,必填;未勾选时默认 0 且不可编辑 **数据来源**:合同维保包干配置、账单里程
**计算方式**:勾选维保包干时:单价(元/km× 里程数;未勾选为 0calcMaintenanceIncome
**录入方式**:包干未勾选时不可编辑。
**业务说明**:合同勾选维保包干时:单价(元/km× 里程数,必填;未勾选时默认 0 且不可编辑。
### 保险上浮费 ### 保险上浮费
事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选 **数据来源**:事故台账关联的保险上浮费
**计算方式**:关联事故单后汇总 linkedAccidentIds 对应上浮费;最新账单未关联时显示警示。
**录入方式**:关联后自动汇总,支持多选事故单。
### 运维费(收入侧) **业务说明**:事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。
维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。 ### 运维费
**数据来源**:维修/保养台账(客户承担)、账单月内该车牌完工记录。
**计算方式**:汇总 bearBy=客户承担 的维保金额;可关联 linkedMaintenanceIdssumMaintenanceForBill
**录入方式**:关联后自动汇总;最新账单可关联记录。
**业务说明**:收入侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「客户承担」的金额;最新一期账单可关联记录。
### 其他收入 ### 其他收入
手工填写,预留作为特殊情况记录使用 **数据来源**:行内其他收入明细(事由、金额、备注)
**计算方式**多条明细金额合计计入应收与月度收入sumLineItems
**录入方式**:草稿状态可编辑明细。
**业务说明**:支持多条明细录入(事由、金额、备注);合计自动计入应收与月度收入。
### 里程减免金额
**数据来源**BI 里程GPS、里程减免单价。
**计算方式**:里程减免 = GPS 里程 × 单价;有 GPS 时默认 0.5 元/km无 GPS 为 0calcMileageDeduction
**录入方式**:系统自动计算,只读。
**业务说明**:里程减免金额 = GPS 里程 × 里程减免单价;有 GPS 里程时默认单价 0.5 元/km无 GPS 时为 0。
### 其他减免金额
**数据来源**:行内其他减免明细。
**计算方式**多条明细金额合计从应收与月度收入扣减sumLineItems
**录入方式**:草稿状态可编辑明细。
**业务说明**:支持多条明细录入(事由、金额、备注);合计自动从应收与月度收入中扣减。
### 实收金额 ### 实收金额
根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配 **数据来源**:收款记录模块关联分配金额
**计算方式**关联收款单后按分配计入金额汇总不超过应收合计calcReceivedAmountFromReceipts
**录入方式**:通过「关联收款」操作维护。
**业务说明**:从「收款记录」功能关联该账单后分配的实收金额;点击可查看收款明细列表。
### 未收金额 ### 未收金额
未收金额 = 应收合计 实收金额,系统自动计算,不可手工录入 **数据来源**应收合计实收金额。
**计算方式**:未收金额 = 应收合计 实收金额calcUnreceived
**录入方式**:系统自动计算,不可手工录入。
**业务说明**:未收金额 = 应收合计 实收金额,系统自动计算,不可手工录入。
### 开票日期 ### 开票日期
账单开票后由财务维护,自动反写,只读 **数据来源**:关联收款记录中的开票信息
**计算方式**resolveInvoiceDateFromReceipts 取关联收款单开票日期。
**录入方式**:只读。
**业务说明**:从「收款记录」功能关联开票记录,显示开票日期。
### 实际付款日期 ### 实际付款日期
关联收款记录时取对应到账记录的付款日期;未关联时为空 **数据来源**:关联收款记录的到账日期
**计算方式**:有关联收款时取到账记录付款日期;未关联为空。
**录入方式**:只读。
**业务说明**:关联收款记录时取对应到账记录的付款日期;未关联时为空。
### 付款方式 ### 付款方式
由合同付款周期决定1 个月→月度预付3 个月→季度预付6 个月→半年预付12 个月→年度预付 **数据来源**:合同付款周期月数
**计算方式**1→月度预付3→季度预付6→半年预付12→年度预付resolvePaymentMethod
**录入方式**:只读。
**业务说明**由合同付款周期决定1 个月→月度预付3 个月→季度预付6 个月→半年预付12 个月→年度预付。
### 车辆标准成本 ### 车辆标准成本
固定成本,按车型明细维护,只读 **数据来源**:车型成本明细 / 车牌标准成本配置PLATE_STANDARD_COST
**计算方式**:按车型维护的固定月标准成本。
**录入方式**:只读。
**业务说明**:固定成本,按车型明细维护,只读。
### 车辆实际成本 ### 车辆实际成本
车辆标准成本 ×(实际天数 / 30自动计算 **数据来源**:车辆标准成本、账单月内租赁天数
**计算方式**:车辆实际成本 = 标准成本 × 租赁天数 / 30calcVehicleActualCost
**录入方式**:系统自动计算,只读。
**业务说明**:车辆实际成本 = 车辆标准成本 × 账单月内租赁天数 / 30自动计算。
### 保险费 ### 保险费
暂保留,不可编辑和导入;后续可能自动计算 **数据来源**:保险采购金额、保险有效天数、账单月租赁天数
**计算方式**:保险费 =(采购金额 / 有效天数)× 租赁天数四舍五入两位calcInsuranceFee
**录入方式**:只读。
**业务说明**:保险费 =(保险采购金额 / 保险有效天数)× 账单月内租赁天数,四舍五入保留两位小数。
### 氢费 ### 氢费
表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账) **数据来源**:氢费明细台账、合同氢费承担方式
**计算方式**合同为「我司承担」时汇总账单月内该车牌氢费sumHydrogenFeeForBill客户承担不计入。
**录入方式**:只读。
### 运维费(成本侧) **业务说明**:仅汇总该账单月内、氢费明细中「我司承担」的氢费(含未对账/已对账);合同为客户承担时不计入。
与收入侧运维费平进平出,自动同步。 ### 运维费
**数据来源**:维修/保养台账(我司承担)、账单月内该车牌。
**计算方式**:汇总 bearBy=我司承担 的维保金额sumMaintenanceForBill
**录入方式**:只读。
**业务说明**:成本侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「我司承担」的金额。
### 居间费 ### 居间费
手动输入。 **数据来源**:行内手工录入。
**计算方式**:直接存储 brokerageFee 字段。
**录入方式**:草稿可编辑。
### 其他(成本侧) **业务说明**:手动输入。
前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。 ### 其他
**数据来源**:行内手工录入。
**计算方式**:直接存储 otherCost前方有其他收入时本项必填。
**录入方式**:草稿可编辑。
**业务说明**:前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。
### GPS里程 ### GPS里程
从 BI 里程取账单时间段的数据,自动展示 **数据来源**BI 里程数据bi-mileage.json / 车机 GPS
**计算方式**lookupBiMileage(车牌, 年, 月) 取账单期里程。
**录入方式**:只读。
**业务说明**:从 BI 里程取账单时间段的数据,自动展示。
### 其他减免金额明细内容 ### 其他减免金额明细内容
手动输入,备注明细内容 **数据来源**:行内手工录入
**计算方式**:存储 otherDeductionDetail 文本。
**录入方式**:草稿可编辑。
**业务说明**:手动输入,备注明细内容。
### 资产归属 ### 资产归属
显示车辆实际归属公司名称,按车辆档案自动获取 **数据来源**:车辆档案资产归属
**计算方式**:按车牌从 PLATE_ASSET_OWNER / 车辆档案反写。
**录入方式**:只读。
**业务说明**:显示车辆实际归属公司名称,按车辆档案自动获取。
### 签约公司 ### 签约公司
租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集 **数据来源**租赁合同甲方。
**计算方式**按车牌匹配签订合同时甲方名称PLATE_SIGNING_COMPANY
**录入方式**:只读。
**业务说明**:显示租赁合同签订时甲方名称。
### 备注 ### 备注
手动输入。 **数据来源**:行内手工录入。
**计算方式**:存储 remark 文本。
**录入方式**:草稿可编辑。
**业务说明**:本列为人工手动填写。
### 账单状态
**数据来源**:账单归档状态字段 archiveStatus。
**计算方式**:草稿 / 未归档 / 已归档;工具栏「保存」「设置为归档」批量变更。
**录入方式**:仅草稿可编辑业务字段。
**业务说明**:状态分为已归档、未归档、草稿。仅草稿可编辑;工具栏「保存」将勾选记录设为未归档(不可编辑);「设置为归档」将勾选记录锁定为已归档。
### 维护时间
**数据来源**:台账行维护记录。
**计算方式**:展示最近一次保存/维护时间maintainerTime
**录入方式**:系统记录,只读。
**业务说明**展示最近一次维护时间YYYY-MM-DD HH:MM维护人显示最后一次维护用户姓名。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,66 @@
/** 表头悬浮说明 — 与 annotation-source 中 lbl-col-* 标注对齐 */
export const COLUMN_HEADER_TIPS: Record<string, string> = {
'lbl-col-status':
'当收款记录中分配金额完全覆盖账单的应收金额时,显示已收款。当收款记录中分配金额≤账单的应收金额时,显示部分收款。未分配收款记录时,显示为未收款。其他状态去除',
'lbl-col-archive-status':
'状态分为已归档、未归档、草稿。仅草稿可编辑;工具栏「保存」将勾选记录设为未归档(不可编辑);「设置为归档」将勾选记录锁定为已归档。',
'lbl-col-payment-due':
'交车后按「开始计费日期」生成首期账单应付款日;后续每期账单在所属自然月 5 日生成下一期应付款日。',
'lbl-col-plate': '车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。',
'lbl-col-customer': '客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。',
'lbl-col-value-added-service':
'签订合同时勾选的增值服务项目,按车牌自动匹配合同数据;多项服务各占一行展示。',
'lbl-col-pickup-date': '运维实际交车日期。',
'lbl-col-contract-start-date': '等于提车日期,即租赁合同生效日期,系统自动反写,只读。',
'lbl-col-contract-end-date':
'合同到期日:租期模式按提车日期 + 租期月数计算;日期模式取合同中约定的到期日。',
'lbl-col-period-start-date':
'账单所属月内的租赁起始日:月前已提车取当月 1 日,月内提车取提车日;系统自动计算,只读。',
'lbl-col-return-date': '退车管理中登记的退车日期;未退车时为空。',
'lbl-col-avg-days':
'平均天数 = 账单月内租赁天数 ÷ 当月自然日天数,保留两位小数;用于成本分摊口径。',
'lbl-col-deposit': '显示该车辆实付保证金金额,与提车应收款时该车辆收取的提车应收款相同。',
'lbl-col-contract-rent': '等于该车辆在签订该合同时合同中的租金(月租金标准)。',
'lbl-col-receivable-total':
'应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免金额 其他减免金额。',
'lbl-col-receivable-rent': '按合同付款周期1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。',
'lbl-col-monthly-income':
'月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免金额 其他减免金额。',
'lbl-col-monthly-rent': '根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。',
'lbl-col-maintenance-income': '合同勾选维保包干时:单价(元/km× 里程数,必填;未勾选时默认 0 且不可编辑。',
'lbl-col-insurance-surcharge': '事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。',
'lbl-col-ops-income':
'收入侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「客户承担」的金额;最新一期账单可关联记录。',
'lbl-col-other-income': '支持多条明细录入(事由、金额、备注);合计自动计入应收与月度收入。',
'lbl-col-mileage-deduction':
'里程减免金额 = GPS 里程 × 里程减免单价;有 GPS 里程时默认单价 0.5 元/km无 GPS 时为 0。',
'lbl-col-other-deduction': '支持多条明细录入(事由、金额、备注);合计自动从应收与月度收入中扣减。',
'lbl-col-received-amount': '从「收款记录」功能关联该账单后分配的实收金额;点击可查看收款明细列表。',
'lbl-col-unreceived': '未收金额 = 应收合计 实收金额,系统自动计算,不可手工录入。',
'lbl-col-invoice-date': '从「收款记录」功能关联开票记录,显示开票日期。',
'lbl-col-actual-payment': '关联收款记录时取对应到账记录的付款日期;未关联时为空。',
'lbl-col-payment-method': '由合同付款周期决定1 个月→月度预付3 个月→季度预付6 个月→半年预付12 个月→年度预付。',
'lbl-col-vehicle-standard-cost': '固定成本,按车型明细维护,只读。',
'lbl-col-vehicle-actual-cost':
'车辆实际成本 = 车辆标准成本 × 账单月内租赁天数 / 30自动计算。',
'lbl-col-insurance-fee':
'保险费 =(保险采购金额 / 保险有效天数)× 账单月内租赁天数,四舍五入保留两位小数。',
'lbl-col-hydrogen-fee':
'仅汇总该账单月内、氢费明细中「我司承担」的氢费(含未对账/已对账);合同为客户承担时不计入。',
'lbl-col-ops-cost':
'成本侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「我司承担」的金额。',
'lbl-col-brokerage-fee': '手动输入。',
'lbl-col-other-cost': '前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。',
'lbl-col-gps-mileage': '从 BI 里程取账单时间段的数据,自动展示。',
'lbl-col-deduction-detail': '手动输入,备注明细内容。',
'lbl-col-asset-ownership': '显示车辆实际归属公司名称,按车辆档案自动获取。',
'lbl-col-signing-company': '显示租赁合同签订时甲方名称。',
'lbl-col-remark': '本列为人工手动填写。',
'lbl-col-maintainer-time': '展示最近一次维护时间YYYY-MM-DD HH:MM维护人显示最后一次维护用户姓名。',
};
export function resolveColumnHeaderTip(annotationId?: string, headerTip?: string): string | undefined {
if (headerTip) return headerTip;
if (annotationId && COLUMN_HEADER_TIPS[annotationId]) return COLUMN_HEADER_TIPS[annotationId];
return undefined;
}

View File

@@ -3,12 +3,13 @@ import { X } from 'lucide-react';
interface DeleteConfirmModalProps { interface DeleteConfirmModalProps {
open: boolean; open: boolean;
plateNo: string; plateNo?: string;
meta?: string;
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
} }
export function DeleteConfirmModal({ open, plateNo, onCancel, onConfirm }: DeleteConfirmModalProps) { export function DeleteConfirmModal({ open, plateNo = '', meta, onCancel, onConfirm }: DeleteConfirmModalProps) {
if (!open) return null; if (!open) return null;
return ( return (
@@ -30,7 +31,7 @@ export function DeleteConfirmModal({ open, plateNo, onCancel, onConfirm }: Delet
<p className="ldb-confirm-text"> <p className="ldb-confirm-text">
</p> </p>
<p className="ldb-confirm-meta">{plateNo ? `车牌:${plateNo}` : ''}</p> <p className="ldb-confirm-meta">{meta ?? (plateNo ? `车牌:${plateNo}` : '')}</p>
</div> </div>
<footer className="vm-modal-footer"> <footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}></button> <button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}></button>

View File

@@ -2,9 +2,10 @@ import React, { useMemo, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react'; import { ChevronDown, Search } from 'lucide-react';
import type { LeaseLedgerFilters, LeaseLedgerRow } from '../types'; import type { LeaseLedgerFilters, LeaseLedgerRow } from '../types';
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField'; import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField';
import { buildLeaseOptions } from '../utils/ledger'; import { buildLeaseOptions } from '../utils/ledger';
const STATUS_OPTIONS = ['草稿', '已确认', '部分收款', '已收款']; const STATUS_OPTIONS = ['未收款', '部分收款', '已收款'];
const DEFAULT_VISIBLE = 4; const DEFAULT_VISIBLE = 4;
interface FilterPanelProps { interface FilterPanelProps {
@@ -32,12 +33,15 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
{ label: '车牌号码', control: <FilterPickerField value={filters.plateNo} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNo) => onChange({ plateNo })} /> }, { label: '车牌号码', control: <FilterPickerField value={filters.plateNo} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNo) => onChange({ plateNo })} /> },
{ label: '客户名称', control: <input className="vm-input" value={filters.customerName} placeholder="输入客户名称" aria-label="客户名称" onChange={(e) => onChange({ customerName: e.target.value })} /> }, { label: '客户名称', control: <input className="vm-input" value={filters.customerName} placeholder="输入客户名称" aria-label="客户名称" onChange={(e) => onChange({ customerName: e.target.value })} /> },
{ label: '明细状态', control: <FilterPickerField value={filters.status} options={STATUS_OPTIONS} placeholder="全部状态" ariaLabel="明细状态" onChange={(status) => onChange({ status })} /> }, { label: '明细状态', control: <FilterPickerField value={filters.status} options={STATUS_OPTIONS} placeholder="全部状态" ariaLabel="明细状态" onChange={(status) => onChange({ status })} /> },
{ label: '付款日', control: ( { label: '付款日', control: (
<div className="ldb-date-range"> <DateRangeFilterField
<input type="date" className="vm-input" value={filters.paymentFrom} aria-label="付款开始日期" onChange={(e) => onChange({ paymentFrom: e.target.value })} /> startDate={filters.paymentFrom}
<span className="ldb-date-range-sep" aria-hidden></span> endDate={filters.paymentTo}
<input type="date" className="vm-input" value={filters.paymentTo} aria-label="付款结束日期" onChange={(e) => onChange({ paymentTo: e.target.value })} /> ariaLabel="付款日"
</div> startPlaceholder="开始日期"
endPlaceholder="结束日期"
onChange={({ startDate, endDate }) => onChange({ paymentFrom: startDate, paymentTo: endDate })}
/>
), ),
}, },
]; ];
@@ -46,7 +50,7 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
const extraFields = fields.slice(DEFAULT_VISIBLE); const extraFields = fields.slice(DEFAULT_VISIBLE);
return ( return (
<section className="vm-filter-card ldb-filter-card" data-annotation-id="lbl-filter"> <section className="vm-filter-card ldb-filter-card" data-annotation-id="lbl-filter" aria-label="筛选条件">
<header className="vm-filter-header"> <header className="vm-filter-header">
<h1 className="ldb-page-title"></h1> <h1 className="ldb-page-title"></h1>
</header> </header>

View File

@@ -1,34 +1,41 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ArrowUpDown, AlertTriangle, CircleHelp } from 'lucide-react'; import { createPortal } from 'react-dom';
import type { AccidentRecord, LeaseLedgerRow, MaintenanceOrder } from '../types'; import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp, History } from 'lucide-react';
import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceOrder, ReceiptRecord } from '../types';
import { import {
calcMonthlyIncome, calcMonthlyIncome,
calcUnreceived, calcUnreceived,
canStaffDeleteRow,
canStaffEditRow, canStaffEditRow,
displayMoney, displayMoney,
displayText, displayText,
formatBillingYearMonth,
formatMoney, formatMoney,
getHydrogenFeesForBill,
getLinkedReceiptsForRow,
getMaintenanceOrdersForBill,
isBlankDisplayValue, isBlankDisplayValue,
needsAccidentLinkWarning,
needsMaintenanceLinkWarning,
} from '../utils/ledger'; } from '../utils/ledger';
import { LEASE_TABLE_COLUMNS, STATUS_CLASS, type LeaseTableColumn } from './tableColumns'; import { LEASE_TABLE_COLUMNS, STATUS_CLASS, ARCHIVE_STATUS_CLASS, type LeaseTableColumn } from './tableColumns';
import { resolveColumnHeaderTip } from '../columnHeaderTips';
import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover';
import { LedgerLineItemsCell } from './LedgerLineItemsCell';
import { OperationHistoryModal } from './OperationHistoryModal';
interface LeaseLedgerTableProps { interface LeaseLedgerTableProps {
records: LeaseLedgerRow[]; records: LeaseLedgerRow[];
selectedIds: Set<string>; selectedIds: Set<string>;
loading?: boolean; loading?: boolean;
maintenanceOrders: MaintenanceOrder[]; maintenanceOrders: MaintenanceOrder[];
accidentRecords: AccidentRecord[]; receipts: ReceiptRecord[];
hydrogenFees: HydrogenFeeRecord[];
onToggleRow: (id: string, checked: boolean) => void; onToggleRow: (id: string, checked: boolean) => void;
onToggleAll: (checked: boolean) => void; onToggleAll: (checked: boolean) => void;
onEdit: (record: LeaseLedgerRow) => void; onEdit: (record: LeaseLedgerRow) => void;
onDelete: (record: LeaseLedgerRow) => void; onSave: (record: LeaseLedgerRow) => void;
editingRowId: string | null;
onLinkReceipt: (record: LeaseLedgerRow) => void; onLinkReceipt: (record: LeaseLedgerRow) => void;
onLinkMaintenance: (record: LeaseLedgerRow) => void; onOtherIncomeItemsChange: (recordId: string, items: LeaseLedgerRow['otherIncomeItems']) => void;
onLinkAccident: (record: LeaseLedgerRow) => void; onOtherDeductionItemsChange: (recordId: string, items: LeaseLedgerRow['otherDeductionItems']) => void;
onOtherIncomeChange: (recordId: string, value: number) => void;
onOtherCostChange: (recordId: string, value: number) => void; onOtherCostChange: (recordId: string, value: number) => void;
onBrokerageFeeChange: (recordId: string, value: number) => void; onBrokerageFeeChange: (recordId: string, value: number) => void;
onRemarkChange: (recordId: string, value: string) => void; onRemarkChange: (recordId: string, value: string) => void;
@@ -38,16 +45,16 @@ interface LeaseLedgerTableProps {
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
const CHECK_COL_WIDTH = 48; const CHECK_COL_WIDTH = 48;
const ACTION_COL_WIDTH = 168; const ACTION_COL_WIDTH = 208;
const DEFAULT_COLUMN_WIDTHS: Record<string, number> = { const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
_index: 56,
status: 88, status: 88,
billingYearMonth: 88,
maintainerName: 88, maintainerName: 88,
year: 72, archiveStatus: 88,
month: 64, maintainerTime: 140,
dept: 120, dept: 120,
paymentDate: 108, paymentDueDate: 108,
plateNo: 120, plateNo: 120,
customerName: 160, customerName: 160,
valueAddedService: 96, valueAddedService: 96,
@@ -91,11 +98,70 @@ const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
remark: 140, remark: 140,
}; };
function IncomeWarning({ title, onClick }: { title: string; onClick: () => void }) { const HEADER_TIP_MAX_WIDTH = 280;
const HEADER_TIP_VIEWPORT_MARGIN = 12;
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 ( return (
<button type="button" className="ldb-income-warn" title={title} aria-label={title} onClick={onClick}> <>
<AlertTriangle size={14} aria-hidden /> <span
</button> 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(
<span className="ldb-kpi-tooltip ldb-kpi-tooltip--portal" role="tooltip" style={style}>
{tip}
</span>,
document.body,
)}
</>
); );
} }
@@ -117,12 +183,7 @@ function HeaderWithTip({
const label = ( const label = (
<span className="ldb-th-label"> <span className="ldb-th-label">
{title} {title}
{tip && ( {tip && <TableHeaderTip tip={tip} title={title} />}
<span className="ldb-th-tip" tabIndex={0} aria-label={`${title}说明`} onClick={(e) => e.stopPropagation()}>
<CircleHelp size={14} aria-hidden />
<span className="ldb-kpi-tooltip" role="tooltip">{tip}</span>
</span>
)}
</span> </span>
); );
@@ -205,6 +266,8 @@ function getSortValue(row: LeaseLedgerRow, col: LeaseTableColumn): string | numb
return row.customerName; return row.customerName;
case 'status': case 'status':
return row.status; return row.status;
case 'year-month':
return row.year * 100 + row.month;
default: default:
break; break;
} }
@@ -222,11 +285,12 @@ function renderCell(
index: number, index: number,
ctx: { ctx: {
maintenanceOrders: MaintenanceOrder[]; maintenanceOrders: MaintenanceOrder[];
accidentRecords: AccidentRecord[]; receipts: ReceiptRecord[];
hydrogenFees: HydrogenFeeRecord[];
canEdit: boolean; canEdit: boolean;
onLinkMaintenance: (record: LeaseLedgerRow) => void; onLinkReceipt: (record: LeaseLedgerRow) => void;
onLinkAccident: (record: LeaseLedgerRow) => void; onOtherIncomeItemsChange: (recordId: string, items: LeaseLedgerRow['otherIncomeItems']) => void;
onOtherIncomeChange: (recordId: string, value: number) => void; onOtherDeductionItemsChange: (recordId: string, items: LeaseLedgerRow['otherDeductionItems']) => void;
onOtherCostChange: (recordId: string, value: number) => void; onOtherCostChange: (recordId: string, value: number) => void;
onBrokerageFeeChange: (recordId: string, value: number) => void; onBrokerageFeeChange: (recordId: string, value: number) => void;
onRemarkChange: (recordId: string, value: string) => void; onRemarkChange: (recordId: string, value: string) => void;
@@ -236,6 +300,22 @@ function renderCell(
switch (col.render) { switch (col.render) {
case 'status': case 'status':
return <span className={`ldb-status-tag ${STATUS_CLASS[row.status]}`}>{row.status}</span>; return <span className={`ldb-status-tag ${STATUS_CLASS[row.status]}`}>{row.status}</span>;
case 'archive-status':
return (
<span className={`ldb-status-tag ${ARCHIVE_STATUS_CLASS[row.archiveStatus]}`}>
{row.archiveStatus}
</span>
);
case 'year-month':
return (
<span className="tabular-nums">{formatBillingYearMonth(row.year, row.month)}</span>
);
case 'value-added-service':
return (
<span className="ldb-cell-multiline" title={row.valueAddedService}>
{displayText(row.valueAddedService)}
</span>
);
case 'stack-dept': case 'stack-dept':
return ( return (
<div className="ldb-cell-stack"> <div className="ldb-cell-stack">
@@ -265,10 +345,10 @@ function renderCell(
case 'asset-owner': case 'asset-owner':
return <span title={row.assetOwner}>{displayText(row.assetOwner)}</span>; return <span title={row.assetOwner}>{displayText(row.assetOwner)}</span>;
case 'signing-company': case 'signing-company':
return <span className="ldb-cell-readonly" title="租赁合同甲方自动获取">{displayText(row.signingCompany)}</span>; return <span className="ldb-cell-readonly" title="租赁合同签订时甲方名称">{displayText(row.signingCompany)}</span>;
case 'monthly-income': case 'monthly-income':
return ( return (
<span className="ldb-computed-val" title="租金 + 服务费 里程减免 其他减免"> <span className="ldb-computed-val" title="月度租金+里程维保收入+保险上浮费+运维费+其他收入−里程减免其他减免">
{formatMoney(calcMonthlyIncome(row))} {formatMoney(calcMonthlyIncome(row))}
</span> </span>
); );
@@ -285,42 +365,84 @@ function renderCell(
{formatMoney(row.maintenanceIncome)} {formatMoney(row.maintenanceIncome)}
</span> </span>
); );
case 'insurance-surcharge': { case 'insurance-surcharge':
const warn = needsAccidentLinkWarning(row, ctx.accidentRecords);
return ( return (
<div className="ldb-income-cell"> <span className="ldb-computed-val">{formatMoney(row.insuranceSurcharge)}</span>
{warn && ( );
<IncomeWarning title="有未关联的事故单据,请点击关联" onClick={() => ctx.onLinkAccident(row)} /> case 'detail-popover-maintenance-income': {
)} const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '客户承担');
<span className={warn ? 'ldb-income-pending' : ''}>{formatMoney(row.insuranceSurcharge)}</span> const tableRows = orders.map((item) => ({
</div> date: item.completedAt.split(' ')[0],
plateNo: item.plateNo,
type: item.type,
amount: formatMoney(item.amount),
}));
return (
<LedgerDetailPopover
label={formatMoney(row.opsFeeIncome)}
title="客户承担维修保养明细"
triggerClassName="tabular-nums"
>
<LedgerDetailTable
columns={[
{ key: 'date', title: '日期' },
{ key: 'plateNo', title: '车牌号' },
{ key: 'type', title: '类型' },
{ key: 'amount', title: '费用总计', align: 'right' },
]}
rows={tableRows}
/>
</LedgerDetailPopover>
); );
} }
case 'ops-fee-income': { case 'detail-popover-maintenance-cost': {
const warn = needsMaintenanceLinkWarning(row, ctx.maintenanceOrders); const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '我司承担');
const tableRows = orders.map((item) => ({
date: item.completedAt.split(' ')[0],
plateNo: item.plateNo,
type: item.type,
amount: formatMoney(item.amount),
}));
return ( return (
<div className="ldb-income-cell"> <LedgerDetailPopover
{warn && ( label={formatMoney(row.opsFeeCost)}
<IncomeWarning title="维修/保养为客户承担,请关联维修保养单" onClick={() => ctx.onLinkMaintenance(row)} /> title="我司承担维修保养明细"
)} triggerClassName="tabular-nums"
<span className={warn ? 'ldb-income-pending' : ''}>{formatMoney(row.opsFeeIncome)}</span> >
</div> <LedgerDetailTable
columns={[
{ key: 'date', title: '日期' },
{ key: 'plateNo', title: '车牌号' },
{ key: 'type', title: '类型' },
{ key: 'amount', title: '费用总计', align: 'right' },
]}
rows={tableRows}
/>
</LedgerDetailPopover>
); );
} }
case 'ops-fee-cost': case 'line-items-income':
return ( return (
<span className="ldb-computed-val" title="与收入侧运维费平进平出"> <LedgerLineItemsCell
{formatMoney(row.opsFeeCost)} items={row.otherIncomeItems ?? []}
</span> total={row.otherIncome}
); canEdit={ctx.canEdit}
case 'other-income': reasonLabel="收入来源"
if (!ctx.canEdit) return displayMoney(row.otherIncome, true); title="其他收入明细"
return (
<MoneyInput
value={row.otherIncome}
placeholder="手工填写"
ariaLabel={`${row.plateNo} 其他收入`} ariaLabel={`${row.plateNo} 其他收入`}
onChange={(v) => ctx.onOtherIncomeChange(row.id, v)} onChange={(items) => ctx.onOtherIncomeItemsChange(row.id, items)}
/>
);
case 'line-items-deduction':
return (
<LedgerLineItemsCell
items={row.otherDeductionItems ?? []}
total={row.otherDeduction}
canEdit={ctx.canEdit}
reasonLabel="减免原因"
title="其他减免金额明细"
ariaLabel={`${row.plateNo} 其他减免`}
onChange={(items) => ctx.onOtherDeductionItemsChange(row.id, items)}
/> />
); );
case 'other-cost': case 'other-cost':
@@ -344,15 +466,38 @@ function renderCell(
onChange={(v) => ctx.onBrokerageFeeChange(row.id, v)} onChange={(v) => ctx.onBrokerageFeeChange(row.id, v)}
/> />
); );
case 'received-amount': case 'received-amount': {
const linked = getLinkedReceiptsForRow(row, ctx.receipts);
const showReceiptLink = row.status !== '已收款';
const tableRows = linked.map((item) => ({
time: item.arrivalTime,
customer: item.customerName,
amount: formatMoney(item.amount),
remark: item.remark ?? '',
}));
return ( return (
<span title={row.linkedReceiptIds?.length ? `已关联 ${row.linkedReceiptIds.length} 条收款明细` : '关联收款记录后自动归集'}> <LedgerDetailPopover
{formatMoney(row.receivedAmount)} label={formatMoney(row.receivedAmount)}
{row.linkedReceiptIds?.length > 0 && ( title="收款明细"
<span className="ldb-linked-badge">{row.linkedReceiptIds.length}</span> footer={showReceiptLink ? (
)} <button type="button" className="vm-btn vm-btn-secondary" onClick={() => ctx.onLinkReceipt(row)}>
</span>
</button>
) : undefined}
>
<LedgerDetailTable
columns={[
{ key: 'time', title: '收款时间' },
{ key: 'customer', title: '客户名称' },
{ key: 'amount', title: '收款金额', align: 'right' },
{ key: 'remark', title: '备注' },
]}
rows={tableRows}
emptyText="尚未关联收款记录"
/>
</LedgerDetailPopover>
); );
}
case 'invoice-date': case 'invoice-date':
return <span className="ldb-cell-readonly tabular-nums">{displayText(row.invoiceDate)}</span>; return <span className="ldb-cell-readonly tabular-nums">{displayText(row.invoiceDate)}</span>;
case 'vehicle-standard-cost': case 'vehicle-standard-cost':
@@ -363,25 +508,48 @@ function renderCell(
); );
case 'vehicle-actual-cost': case 'vehicle-actual-cost':
return ( return (
<span className="ldb-computed-val" title={`标准成本 × (${row.avgDays}/30)`}> <span className="ldb-computed-val" title={`车辆标准成本 × 实际天数(${row.actualLeaseDays ?? 0} 天)`}>
{formatMoney(row.vehicleActualCost)} {formatMoney(row.vehicleActualCost)}
</span> </span>
); );
case 'insurance-readonly': case 'insurance-readonly':
return ( return (
<span className="ldb-cell-readonly" title="暂不可编辑与导入,后续可能自动计算"> <span className="ldb-computed-val" title="保险采购金额/生效天数 × 当月实际使用天数">
{displayMoney(row.insuranceFee, true)} {displayMoney(row.insuranceFee, true)}
</span> </span>
); );
case 'hydrogen-fee': case 'detail-popover-hydrogen': {
const records = getHydrogenFeesForBill(row, ctx.hydrogenFees);
const tableRows = records.map((item) => ({
refuelDate: item.refuelDate ?? `${item.year}-${String(item.month).padStart(2, '0')}-01`,
plateNo: item.plateNo,
stationName: item.stationName ?? '—',
volume: item.volume != null ? String(item.volume) : '—',
unitCost: item.unitCost != null ? formatMoney(item.unitCost) : '—',
totalCost: formatMoney(item.totalCost ?? item.amount),
borneBy: item.borneBy ?? '我司承担',
}));
return ( return (
<span <LedgerDetailPopover
className={row.hydrogenBorneBy === '我司承担' ? 'ldb-computed-val' : 'ldb-cell-readonly'} label={displayMoney(row.hydrogenFee, true)}
title={row.hydrogenBorneBy === '我司承担' ? '我司承担氢费账单期汇总' : '客户承担或无氢费'} title="车辆氢费明细"
triggerClassName="tabular-nums"
> >
{displayMoney(row.hydrogenFee, true)} <LedgerDetailTable
</span> columns={[
{ key: 'refuelDate', title: '加氢日期' },
{ key: 'plateNo', title: '车牌号' },
{ key: 'stationName', title: '加氢站名称' },
{ key: 'volume', title: '加氢量', align: 'right' },
{ key: 'unitCost', title: '成本单价', align: 'right' },
{ key: 'totalCost', title: '成本总价', align: 'right' },
{ key: 'borneBy', title: '承担方式' },
]}
rows={tableRows}
/>
</LedgerDetailPopover>
); );
}
case 'gps-mileage': case 'gps-mileage':
return ( return (
<span className="ldb-computed-val tabular-nums" title="BI 里程 · 账单时间段"> <span className="ldb-computed-val tabular-nums" title="BI 里程 · 账单时间段">
@@ -408,11 +576,33 @@ function renderCell(
onChange={(v) => ctx.onRemarkChange(row.id, v)} onChange={(v) => ctx.onRemarkChange(row.id, v)}
/> />
); );
case 'maintainer-time':
return <span className="tabular-nums">{displayText(row.maintainerTime)}</span>;
default: default:
break; break;
} }
if (col.key === 'unreceived') return formatMoney(calcUnreceived(row)); if (col.key === 'unreceived') {
return (
<span className="ldb-col-unreceived-warn">{formatMoney(calcUnreceived(row))}</span>
);
}
if (col.key === 'avgDays') {
return (
<span className="tabular-nums" title="本月在租天数/当月天数">
{row.avgDays.toFixed(2)}
</span>
);
}
if (col.key === 'mileageDeduction') {
return (
<span className="ldb-computed-val" title="根据 GPS 里程自动计算">
{formatMoney(row.mileageDeduction)}
</span>
);
}
if (col.key === 'profitLoss') { if (col.key === 'profitLoss') {
const val = row.profitLoss; const val = row.profitLoss;
@@ -423,7 +613,7 @@ function renderCell(
); );
} }
const raw = col.key === '_index' ? index + 1 : row[col.key as keyof LeaseLedgerRow]; const raw = row[col.key as keyof LeaseLedgerRow];
if (col.money && typeof raw === 'number') return formatMoney(raw); if (col.money && typeof raw === 'number') return formatMoney(raw);
if (isBlankDisplayValue(raw)) return ''; if (isBlankDisplayValue(raw)) return '';
return String(raw); return String(raw);
@@ -444,15 +634,16 @@ export function LeaseLedgerTable({
selectedIds, selectedIds,
loading = false, loading = false,
maintenanceOrders, maintenanceOrders,
accidentRecords, receipts,
hydrogenFees,
onToggleRow, onToggleRow,
onToggleAll, onToggleAll,
onEdit, onEdit,
onDelete, onSave,
editingRowId,
onLinkReceipt, onLinkReceipt,
onLinkMaintenance, onOtherIncomeItemsChange,
onLinkAccident, onOtherDeductionItemsChange,
onOtherIncomeChange,
onOtherCostChange, onOtherCostChange,
onBrokerageFeeChange, onBrokerageFeeChange,
onRemarkChange, onRemarkChange,
@@ -460,6 +651,7 @@ export function LeaseLedgerTable({
}: LeaseLedgerTableProps) { }: LeaseLedgerTableProps) {
const [sortKey, setSortKey] = useState<string | null>(null); const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc'); const [sortDir, setSortDir] = useState<SortDir>('asc');
const [historyRow, setHistoryRow] = useState<LeaseLedgerRow | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_COLUMN_WIDTHS })); const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_COLUMN_WIDTHS }));
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null); const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
@@ -546,11 +738,12 @@ export function LeaseLedgerTable({
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th className="ldb-col-check sticky-col-left"> <th className="ldb-col-check sticky-col-left" data-annotation-id="lbl-col-check">
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" /> <input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
</th> </th>
{LEASE_TABLE_COLUMNS.map((col) => { {LEASE_TABLE_COLUMNS.map((col) => {
const sortable = col.sortable !== false && col.key !== '_index'; const sortable = col.sortable !== false;
const headerTip = resolveColumnHeaderTip(col.annotationId, col.headerTip);
return ( return (
<th <th
key={col.key} key={col.key}
@@ -561,18 +754,11 @@ export function LeaseLedgerTable({
'ldb-col-resizable', 'ldb-col-resizable',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
data-annotation-id={col.annotationId} data-annotation-id={col.annotationId}
title={
col.key === 'opsFeeIncome'
? '收入侧运维费'
: col.key === 'opsFeeCost'
? '成本侧运维费,与收入侧平进平出'
: undefined
}
> >
<div className="ldb-th-inner"> <div className="ldb-th-inner">
<HeaderWithTip <HeaderWithTip
title={col.title} title={col.title}
tip={col.headerTip} tip={headerTip}
sortable={sortable} sortable={sortable}
sortActive={sortKey === col.key} sortActive={sortKey === col.key}
sortDir={sortDir} sortDir={sortDir}
@@ -589,22 +775,20 @@ export function LeaseLedgerTable({
</th> </th>
); );
})} })}
<th className="sticky-col-right ldb-col-actions"></th> <th className="sticky-col-right ldb-col-actions" data-annotation-id="lbl-col-actions"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedRecords.map((row, index) => { {sortedRecords.map((row, index) => {
const canEdit = canStaffEditRow(row); const canEdit = canStaffEditRow(row);
const canDelete = canStaffDeleteRow(row);
const isDraft = row.status === '草稿';
const showReceiptLink = row.status === '已确认' || row.status === '部分收款';
const cellCtx = { const cellCtx = {
maintenanceOrders, maintenanceOrders,
accidentRecords, receipts,
hydrogenFees,
canEdit, canEdit,
onLinkMaintenance, onLinkReceipt,
onLinkAccident, onOtherIncomeItemsChange,
onOtherIncomeChange, onOtherDeductionItemsChange,
onOtherCostChange, onOtherCostChange,
onBrokerageFeeChange, onBrokerageFeeChange,
onRemarkChange, onRemarkChange,
@@ -635,19 +819,22 @@ export function LeaseLedgerTable({
))} ))}
<td className="sticky-col-right ldb-col-actions"> <td className="sticky-col-right ldb-col-actions">
<div className="ldb-row-actions"> <div className="ldb-row-actions">
{showReceiptLink && ( <button
<button type="button" className="ldb-action-btn ldb-action-btn--link" onClick={() => onLinkReceipt(row)}> type="button"
className="ldb-action-btn ldb-action-btn--history ldb-cell-clickable"
</button> onClick={() => setHistoryRow(row)}
)} >
<History size={14} aria-hidden />
</button>
{canEdit && ( {canEdit && (
<button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}> <button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}>
</button> </button>
)} )}
{isDraft && canDelete && ( {editingRowId === row.id && canEdit && (
<button type="button" className="ldb-action-btn ldb-action-btn--delete" onClick={() => onDelete(row)}> <button type="button" className="ldb-action-btn ldb-action-btn--save" onClick={() => onSave(row)}>
</button> </button>
)} )}
</div> </div>
@@ -657,6 +844,12 @@ export function LeaseLedgerTable({
})} })}
</tbody> </tbody>
</table> </table>
<OperationHistoryModal
open={Boolean(historyRow)}
plateNo={historyRow?.plateNo ?? ''}
entries={historyRow?.operationHistory ?? []}
onClose={() => setHistoryRow(null)}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useId, useRef, useState } from 'react';
interface LedgerDetailPopoverProps {
label: string;
title?: string;
triggerClassName?: string;
children: React.ReactNode;
footer?: React.ReactNode;
}
export function LedgerDetailPopover({
label,
title,
triggerClassName = '',
children,
footer,
}: LedgerDetailPopoverProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const panelId = useId();
useEffect(() => {
if (!open) return;
const onDocClick = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="ldb-detail-popover-wrap" ref={wrapRef}>
<button
type="button"
className={`ldb-detail-popover-trigger ldb-cell-clickable cursor-pointer ${triggerClassName}`.trim()}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((prev) => !prev)}
>
{label}
</button>
{open && (
<div id={panelId} className="ldb-detail-popover-card" role="dialog" aria-label={title ?? label}>
{title && <div className="ldb-detail-popover-title">{title}</div>}
<div className="ldb-detail-popover-body">{children}</div>
{footer && <div className="ldb-detail-popover-footer">{footer}</div>}
</div>
)}
</div>
);
}
interface LedgerDetailTableProps {
columns: { key: string; title: string; align?: 'left' | 'right' }[];
rows: Record<string, React.ReactNode>[];
emptyText?: string;
}
export function LedgerDetailTable({ columns, rows, emptyText = '暂无明细' }: LedgerDetailTableProps) {
if (!rows.length) {
return <p className="ldb-detail-popover-empty">{emptyText}</p>;
}
return (
<div className="ldb-detail-popover-table-wrap">
<table className="ldb-detail-popover-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} className={col.align === 'right' ? 'text-right' : ''}>{col.title}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{columns.map((col) => (
<td key={col.key} className={col.align === 'right' ? 'text-right tabular-nums' : ''}>
{row[col.key] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import React, { useMemo } from 'react';
import type { LedgerLineItem } from '../types';
import { formatMoney } from '../utils/ledger';
import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover';
interface LedgerLineItemsCellProps {
items: LedgerLineItem[];
total: number;
canEdit: boolean;
reasonLabel: string;
title: string;
ariaLabel: string;
onChange: (items: LedgerLineItem[]) => void;
}
const EMPTY_ITEM: LedgerLineItem = { reason: '', amount: 0, remark: '' };
export function LedgerLineItemsCell({
items,
total,
canEdit,
reasonLabel,
title,
ariaLabel,
onChange,
}: LedgerLineItemsCellProps) {
const displayItems = items.length > 0 ? items : [];
const sum = total;
const tableRows = useMemo(
() => displayItems.map((item) => ({
reason: item.reason,
amount: formatMoney(item.amount),
remark: item.remark,
})),
[displayItems],
);
const editor = canEdit ? (
<div className="ldb-line-items-editor">
{displayItems.map((item, index) => (
<div key={index} className="ldb-line-items-row">
<input
type="text"
className="vm-input ldb-line-items-input"
value={item.reason}
placeholder={reasonLabel}
aria-label={`${ariaLabel} ${reasonLabel}`}
onChange={(e) => {
const next = [...displayItems];
next[index] = { ...next[index], reason: e.target.value };
onChange(next);
}}
/>
<input
type="number"
className="vm-input ldb-line-items-input tabular-nums"
value={item.amount || ''}
min={0}
step={0.01}
placeholder="金额"
aria-label={`${ariaLabel} 金额`}
onChange={(e) => {
const next = [...displayItems];
next[index] = { ...next[index], amount: Number(e.target.value) || 0 };
onChange(next);
}}
/>
<input
type="text"
className="vm-input ldb-line-items-input"
value={item.remark}
placeholder="备注"
aria-label={`${ariaLabel} 备注`}
onChange={(e) => {
const next = [...displayItems];
next[index] = { ...next[index], remark: e.target.value };
onChange(next);
}}
/>
</div>
))}
<button
type="button"
className="vm-btn vm-btn-ghost ldb-line-items-add"
onClick={() => onChange([...displayItems, { ...EMPTY_ITEM }])}
>
</button>
</div>
) : (
<LedgerDetailTable
columns={[
{ key: 'reason', title: reasonLabel },
{ key: 'amount', title: '金额', align: 'right' },
{ key: 'remark', title: '备注' },
]}
rows={tableRows}
emptyText="暂无明细"
/>
);
return (
<LedgerDetailPopover
label={formatMoney(sum)}
title={title}
triggerClassName={canEdit ? 'ldb-line-items-trigger--editable' : ''}
>
{editor}
</LedgerDetailPopover>
);
}

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { X } from 'lucide-react';
import type { OperationHistoryEntry } from '../types';
import { displayText } from '../utils/ledger';
interface OperationHistoryModalProps {
open: boolean;
plateNo: string;
entries: OperationHistoryEntry[];
onClose: () => void;
}
export function OperationHistoryModal({ open, plateNo, entries, onClose }: OperationHistoryModalProps) {
if (!open) return null;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-md"
role="dialog"
aria-modal="true"
aria-labelledby="operation-history-title"
onClick={(e) => e.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="operation-history-title" className="vm-modal-title"> · {plateNo}</h2>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="vm-modal-body">
{entries.length === 0 ? (
<p className="ldb-confirm-text"></p>
) : (
<div className="ldb-detail-popover-table-wrap">
<table className="ldb-detail-popover-table ldb-history-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry, index) => (
<tr key={`${entry.operatedAt}-${index}`}>
<td className="tabular-nums">{displayText(entry.operatedAt)}</td>
<td>{displayText(entry.operatorName)}</td>
<td>{displayText(entry.field)}</td>
<td>{displayText(entry.before)}</td>
<td>{displayText(entry.after)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose}></button>
</footer>
</div>
</div>
);
}

View File

@@ -72,7 +72,7 @@ export function ReceiptLinkModal({
? '已收款' ? '已收款'
: linkedAmount > 0 : linkedAmount > 0
? '部分收款' ? '部分收款'
: '已确认'; : '未收款';
return ( return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}> <div className="vm-modal-backdrop" role="presentation" onClick={onClose}>

View File

@@ -1,4 +1,4 @@
import type { LeaseDetailStatus } from './types'; import type { BillArchiveStatus, LeaseDetailStatus } from '../types';
/** 宽表列定义(对齐 Excel「租赁车辆收入明细表」 */ /** 宽表列定义(对齐 Excel「租赁车辆收入明细表」 */
export interface LeaseTableColumn { export interface LeaseTableColumn {
@@ -15,74 +15,172 @@ export interface LeaseTableColumn {
render?: render?:
| 'stack-dept' | 'stack-plate' | 'stack-customer' | 'status' | 'deposit' | 'stack-dept' | 'stack-plate' | 'stack-customer' | 'status' | 'deposit'
| 'asset-owner' | 'signing-company' | 'asset-owner' | 'signing-company'
| 'monthly-income' | 'maintenance-income' | 'insurance-surcharge' | 'ops-fee-income' | 'other-income' | 'monthly-income' | 'maintenance-income' | 'insurance-surcharge'
| 'received-amount' | 'invoice-date' | 'vehicle-standard-cost' | 'vehicle-actual-cost' | 'detail-popover-maintenance-income' | 'line-items-income' | 'line-items-deduction'
| 'insurance-readonly' | 'hydrogen-fee' | 'ops-fee-cost' | 'received-amount' | 'detail-popover-receipts'
| 'invoice-date' | 'vehicle-standard-cost' | 'vehicle-actual-cost'
| 'insurance-readonly' | 'detail-popover-hydrogen' | 'detail-popover-maintenance-cost'
| 'brokerage-fee' | 'other-cost' | 'gps-mileage' | 'brokerage-fee' | 'other-cost' | 'gps-mileage'
| 'deduction-detail' | 'remark-text' | 'text'; | 'deduction-detail' | 'remark-text' | 'year-month' | 'text' | 'maintainer-time' | 'value-added-service' | 'archive-status';
} }
export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [ export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [
{ key: '_index', title: '序号', sticky: 'left', className: 'tabular-nums' }, { key: 'status', title: '状态', render: 'status', annotationId: 'lbl-col-status', sortable: false },
{ key: 'status', title: '状态', render: 'status', annotationId: 'lbl-col-status' }, { key: 'billingYearMonth', title: '年月', className: 'tabular-nums', render: 'year-month', sortable: false },
{ key: 'maintainerName', title: '维护人' }, { key: 'dept', title: '业务部门', render: 'stack-dept', sortable: false },
{ key: 'year', title: '年份', className: 'tabular-nums' }, {
{ key: 'month', title: '月份', className: 'tabular-nums' }, key: 'paymentDueDate',
{ key: 'dept', title: '业务部门', render: 'stack-dept', annotationId: 'lbl-col-dept' }, title: '应付款日',
{ key: 'paymentDate', title: '付款日期', date: true }, date: true,
annotationId: 'lbl-col-payment-due',
},
{ key: 'plateNo', title: '车牌号码', render: 'stack-plate', annotationId: 'lbl-col-plate' }, { key: 'plateNo', title: '车牌号码', render: 'stack-plate', annotationId: 'lbl-col-plate' },
{ key: 'customerName', title: '客户名称', render: 'stack-customer', annotationId: 'lbl-col-customer' }, { key: 'customerName', title: '客户名称', render: 'stack-customer', annotationId: 'lbl-col-customer', sortable: false },
{ key: 'valueAddedService', title: '增值服务' }, {
{ key: 'exemptionPolicy', title: '享免政策' }, key: 'valueAddedService',
title: '增值服务',
render: 'value-added-service',
annotationId: 'lbl-col-value-added-service',
sortable: false,
},
{ key: 'exemptionPolicy', title: '享免政策', sortable: false },
{ key: 'pickupDate', title: '提车日期', date: true, annotationId: 'lbl-col-pickup-date' }, { key: 'pickupDate', title: '提车日期', date: true, annotationId: 'lbl-col-pickup-date' },
{ key: 'contractStartDate', title: '合同生效日期', date: true }, {
{ key: 'contractEndDate', title: '合同到期日期', date: true }, key: 'contractStartDate',
{ key: 'periodStartDate', title: '起始日期', date: true }, title: '合同生效日期',
{ key: 'returnDate', title: '退车日期', date: true }, date: true,
{ key: 'avgDays', title: '平均天数', className: 'tabular-nums' }, annotationId: 'lbl-col-contract-start-date',
{ key: 'deposit', title: '保证金', render: 'deposit', annotationId: 'lbl-col-deposit' }, },
{ key: 'contractRent', title: '合同标的租金', money: true, annotationId: 'lbl-col-contract-rent' }, {
{ key: 'receivableTotal', title: '应收合计', money: true }, key: 'contractEndDate',
{ key: 'receivableRent', title: '应收租金', money: true, annotationId: 'lbl-col-receivable-rent' }, title: '合同到期日期',
{ key: 'monthlyIncome', title: '月度收入', money: true, render: 'monthly-income', annotationId: 'lbl-col-monthly-income' }, date: true,
{ key: 'monthlyRent', title: '月度租金', money: true, annotationId: 'lbl-col-monthly-rent' }, annotationId: 'lbl-col-contract-end-date',
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, render: 'maintenance-income', annotationId: 'lbl-col-maintenance-income' }, },
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, render: 'insurance-surcharge', annotationId: 'lbl-col-insurance-surcharge' }, {
{ key: 'opsFeeIncome', title: '运维费', money: true, className: 'ldb-col-ops-income', render: 'ops-fee-income', annotationId: 'lbl-col-ops-income' }, key: 'periodStartDate',
{ key: 'otherIncome', title: '其他收入', money: true, render: 'other-income', annotationId: 'lbl-col-other-income' }, title: '起始日期',
{ key: 'mileageDeduction', title: '里程减免金额', money: true }, date: true,
{ key: 'otherDeduction', title: '其他减免金额', money: true }, annotationId: 'lbl-col-period-start-date',
{ key: 'receivedAmount', title: '实收金额', money: true, render: 'received-amount', annotationId: 'lbl-col-received-amount' }, },
{ key: 'unreceived', title: '未收金额', money: true, annotationId: 'lbl-col-unreceived' }, {
key: 'returnDate',
title: '退车日期',
date: true,
annotationId: 'lbl-col-return-date',
},
{
key: 'avgDays',
title: '平均天数',
className: 'tabular-nums',
annotationId: 'lbl-col-avg-days',
sortable: false,
},
{ key: 'deposit', title: '保证金', render: 'deposit', annotationId: 'lbl-col-deposit', sortable: false },
{ key: 'contractRent', title: '合同标的租金', money: true, annotationId: 'lbl-col-contract-rent', sortable: false },
{
key: 'receivableTotal',
title: '应收合计',
money: true,
annotationId: 'lbl-col-receivable-total',
sortable: false,
},
{ key: 'receivableRent', title: '应收租金', money: true, annotationId: 'lbl-col-receivable-rent', sortable: false },
{ key: 'monthlyIncome', title: '月度收入', money: true, render: 'monthly-income', annotationId: 'lbl-col-monthly-income', sortable: false },
{ key: 'monthlyRent', title: '月度租金', money: true, annotationId: 'lbl-col-monthly-rent', sortable: false },
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, render: 'maintenance-income', annotationId: 'lbl-col-maintenance-income', sortable: false },
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, render: 'insurance-surcharge', annotationId: 'lbl-col-insurance-surcharge', sortable: false },
{
key: 'opsFeeIncome',
title: '运维费',
money: true,
className: 'ldb-col-ops-income',
render: 'detail-popover-maintenance-income',
annotationId: 'lbl-col-ops-income',
sortable: false,
},
{
key: 'otherIncome',
title: '其他收入',
money: true,
render: 'line-items-income',
annotationId: 'lbl-col-other-income',
sortable: false,
},
{
key: 'mileageDeduction',
title: '里程减免金额',
money: true,
annotationId: 'lbl-col-mileage-deduction',
sortable: false,
},
{
key: 'otherDeduction',
title: '其他减免金额',
money: true,
render: 'line-items-deduction',
annotationId: 'lbl-col-other-deduction',
sortable: false,
},
{ key: 'receivedAmount', title: '实收金额', money: true, render: 'received-amount', annotationId: 'lbl-col-received-amount', sortable: false },
{ key: 'unreceived', title: '未收金额', money: true, annotationId: 'lbl-col-unreceived', sortable: false },
{ key: 'invoiceDate', title: '开票日期', date: true, render: 'invoice-date', annotationId: 'lbl-col-invoice-date' }, { key: 'invoiceDate', title: '开票日期', date: true, render: 'invoice-date', annotationId: 'lbl-col-invoice-date' },
{ key: 'actualPaymentDate', title: '实际付款日期', date: true, annotationId: 'lbl-col-actual-payment' }, { key: 'actualPaymentDate', title: '实际付款日期', date: true, annotationId: 'lbl-col-actual-payment' },
{ key: 'paymentMethod', title: '付款方式', annotationId: 'lbl-col-payment-method' }, { key: 'paymentMethod', title: '付款方式', annotationId: 'lbl-col-payment-method', sortable: false },
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, render: 'vehicle-standard-cost', annotationId: 'lbl-col-vehicle-standard-cost' }, { key: 'vehicleStandardCost', title: '车辆标准成本', money: true, render: 'vehicle-standard-cost', annotationId: 'lbl-col-vehicle-standard-cost', sortable: false },
{ key: 'vehicleActualCost', title: '车辆实际成本', money: true, render: 'vehicle-actual-cost', annotationId: 'lbl-col-vehicle-actual-cost' }, { key: 'vehicleActualCost', title: '车辆实际成本', money: true, render: 'vehicle-actual-cost', annotationId: 'lbl-col-vehicle-actual-cost', sortable: false },
{ key: 'insuranceFee', title: '保险费', money: true, render: 'insurance-readonly', annotationId: 'lbl-col-insurance-fee' }, { key: 'insuranceFee', title: '保险费', money: true, render: 'insurance-readonly', annotationId: 'lbl-col-insurance-fee', sortable: false },
{ {
key: 'hydrogenFee', key: 'hydrogenFee',
title: '氢费', title: '氢费',
money: true, money: true,
render: 'hydrogen-fee', render: 'detail-popover-hydrogen',
annotationId: 'lbl-col-hydrogen-fee', annotationId: 'lbl-col-hydrogen-fee',
headerTip: '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)', sortable: false,
}, },
{ key: 'opsFeeCost', title: '运维费', money: true, className: 'ldb-col-ops-cost', render: 'ops-fee-cost', annotationId: 'lbl-col-ops-cost' }, {
{ key: 'brokerageFee', title: '居间费', money: true, render: 'brokerage-fee', annotationId: 'lbl-col-brokerage-fee' }, key: 'opsFeeCost',
{ key: 'otherCost', title: '其他', money: true, render: 'other-cost', annotationId: 'lbl-col-other-cost' }, title: '运维费',
{ key: 'totalCost', title: '总成本', money: true }, money: true,
{ key: 'profitLoss', title: '盈亏', money: true }, className: 'ldb-col-ops-cost',
{ key: 'gpsMileage', title: 'GPS里程', render: 'gps-mileage', annotationId: 'lbl-col-gps-mileage' }, render: 'detail-popover-maintenance-cost',
annotationId: 'lbl-col-ops-cost',
sortable: false,
},
{ key: 'brokerageFee', title: '居间费', money: true, render: 'brokerage-fee', annotationId: 'lbl-col-brokerage-fee', sortable: false },
{ key: 'otherCost', title: '其他', money: true, render: 'other-cost', annotationId: 'lbl-col-other-cost', sortable: false },
{ key: 'totalCost', title: '总成本', money: true, sortable: false },
{ key: 'profitLoss', title: '盈亏', money: true, sortable: false },
{ key: 'gpsMileage', title: 'GPS里程', render: 'gps-mileage', annotationId: 'lbl-col-gps-mileage', sortable: false },
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', render: 'deduction-detail', annotationId: 'lbl-col-deduction-detail' }, { key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', render: 'deduction-detail', annotationId: 'lbl-col-deduction-detail' },
{ key: 'assetOwner', title: '资产归属', render: 'asset-owner', annotationId: 'lbl-col-asset-ownership' }, { key: 'assetOwner', title: '资产归属', render: 'asset-owner', annotationId: 'lbl-col-asset-ownership', sortable: false },
{ key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', render: 'signing-company', annotationId: 'lbl-col-signing-company' }, { key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', render: 'signing-company', annotationId: 'lbl-col-signing-company', sortable: false },
{ key: 'remark', title: '备注', className: 'ldb-col-remark', render: 'remark-text', annotationId: 'lbl-col-remark' }, { key: 'remark', title: '备注', className: 'ldb-col-remark', render: 'remark-text', annotationId: 'lbl-col-remark', sortable: false },
{
key: 'archiveStatus',
title: '账单状态',
render: 'archive-status',
annotationId: 'lbl-col-archive-status',
sortable: false,
},
{ key: 'maintainerName', title: '维护人', sortable: false },
{
key: 'maintainerTime',
title: '维护时间',
className: 'tabular-nums',
annotationId: 'lbl-col-maintainer-time',
render: 'maintainer-time',
},
]; ];
export const STATUS_CLASS: Record<LeaseDetailStatus, string> = { export const STATUS_CLASS: Record<LeaseDetailStatus, string> = {
稿: 'ldb-status--draft', : 'ldb-status--unpaid',
: 'ldb-status--confirmed',
: 'ldb-status--partial', : 'ldb-status--partial',
: 'ldb-status--paid', : 'ldb-status--paid',
}; };
export const ARCHIVE_STATUS_CLASS: Record<BillArchiveStatus, string> = {
: 'ldb-status--archived',
: 'ldb-status--unarchived',
稿: 'ldb-status--draft',
};

View File

@@ -1,5 +1,54 @@
[ [
{ "plateNo": "沪A32896F", "year": 2026, "month": 1, "amount": 1280.5, "status": "已对账" }, {
{ "plateNo": "沪A65889F", "year": 2026, "month": 1, "amount": 960, "status": "未对账" }, "plateNo": "沪A32896F",
{ "plateNo": "浙F08991F", "year": 2026, "month": 1, "amount": 720, "status": "已对账" } "year": 2026,
"month": 1,
"amount": 1280.5,
"status": "已对账",
"refuelDate": "2026-01-08",
"stationName": "上海嘉定氢站",
"volume": 42.5,
"unitCost": 30.13,
"totalCost": 1280.5,
"borneBy": "我司承担"
},
{
"plateNo": "沪A32896F",
"year": 2026,
"month": 1,
"amount": 620,
"status": "未对账",
"refuelDate": "2026-01-22",
"stationName": "上海嘉定氢站",
"volume": 20,
"unitCost": 31,
"totalCost": 620,
"borneBy": "我司承担"
},
{
"plateNo": "沪A65889F",
"year": 2026,
"month": 1,
"amount": 960,
"status": "未对账",
"refuelDate": "2026-01-15",
"stationName": "嘉兴港区加氢站",
"volume": 32,
"unitCost": 30,
"totalCost": 960,
"borneBy": "客户承担"
},
{
"plateNo": "浙F08991F",
"year": 2026,
"month": 1,
"amount": 720,
"status": "已对账",
"refuelDate": "2026-01-11",
"stationName": "嘉兴南湖氢站",
"volume": 24,
"unitCost": 30,
"totalCost": 720,
"borneBy": "我司承担"
}
] ]

View File

@@ -1,6 +1,65 @@
[ [
{ "id": "mo1", "plateNo": "浙F05580F", "orderNo": "WX202601001", "type": "维修", "bearBy": "客户承担", "amount": 860, "completedAt": "2026-01-12" }, {
{ "id": "mo2", "plateNo": "浙F05580F", "orderNo": "BY202601008", "type": "保养", "bearBy": "客户承担", "amount": 420, "completedAt": "2026-01-18" }, "id": "mo1",
{ "id": "mo3", "plateNo": "沪A50990F", "orderNo": "WX202601015", "type": "维修", "bearBy": "客户承担", "amount": 1500, "completedAt": "2026-01-20" }, "plateNo": "浙F05580F",
{ "id": "mo4", "plateNo": "沪A65889F", "orderNo": "BY202512020", "type": "保养", "bearBy": "公司承担", "amount": 380, "completedAt": "2025-12-20" } "orderNo": "WX202601001",
"type": "维修",
"bearBy": "客户承担",
"amount": 860,
"completedAt": "2026-01-12"
},
{
"id": "mo2",
"plateNo": "浙F05580F",
"orderNo": "BY202601008",
"type": "保养",
"bearBy": "客户承担",
"amount": 420,
"completedAt": "2026-01-18"
},
{
"id": "mo3",
"plateNo": "沪A50990F",
"orderNo": "WX202601015",
"type": "维修",
"bearBy": "客户承担",
"amount": 1500,
"completedAt": "2026-01-20"
},
{
"id": "mo4",
"plateNo": "沪A65889F",
"orderNo": "BY202512020",
"type": "保养",
"bearBy": "我司承担",
"amount": 380,
"completedAt": "2025-12-20"
},
{
"id": "mo5",
"plateNo": "沪A65889F",
"orderNo": "WX202601022",
"type": "维修",
"bearBy": "我司承担",
"amount": 1250,
"completedAt": "2026-01-16"
},
{
"id": "mo6",
"plateNo": "浙F08991F",
"orderNo": "BY202601011",
"type": "保养",
"bearBy": "我司承担",
"amount": 560,
"completedAt": "2026-01-09"
},
{
"id": "mo7",
"plateNo": "沪A32896F",
"orderNo": "BY202601003",
"type": "保养",
"bearBy": "我司承担",
"amount": 450,
"completedAt": "2026-01-07"
}
] ]

View File

@@ -1,8 +1,50 @@
[ [
{ "id": "rc1", "arrivalTime": "2026-01-19 14:30", "customerName": "安徽驰远供应链管理有限公司", "amount": 4500, "remark": "银行转账" }, {
{ "id": "rc2", "arrivalTime": "2026-02-02 10:00", "customerName": "嘉兴港区韵达快递有限公司", "amount": 2000, "remark": "第一笔到账" }, "id": "rc1",
{ "id": "rc3", "arrivalTime": "2026-02-02 16:20", "customerName": "嘉兴港区韵达快递有限公司", "amount": 1500, "remark": "第二笔到账" }, "arrivalTime": "2026-01-19 14:30",
{ "id": "rc4", "arrivalTime": "2025-12-25 09:15", "customerName": "上海朔宇物流有限公司", "amount": 1500, "remark": "部分到账" }, "customerName": "安徽驰远供应链管理有限公司",
{ "id": "rc5", "arrivalTime": "2025-12-25 11:00", "customerName": "上海朔宇物流有限公司", "amount": 1000, "remark": "第二笔部分到账" }, "amount": 4500,
{ "id": "rc6", "arrivalTime": "2025-12-26 15:40", "customerName": "上海利合供应链管理有限公司", "amount": 4000, "remark": "全额到账" } "remark": "银行转账",
"invoiceDate": "2026-01-05"
},
{
"id": "rc2",
"arrivalTime": "2026-02-02 10:00",
"customerName": "嘉兴港区韵达快递有限公司",
"amount": 2000,
"remark": "第一笔到账",
"invoiceDate": "2026-01-27"
},
{
"id": "rc3",
"arrivalTime": "2026-02-02 16:20",
"customerName": "嘉兴港区韵达快递有限公司",
"amount": 1500,
"remark": "第二笔到账",
"invoiceDate": "2026-01-28"
},
{
"id": "rc4",
"arrivalTime": "2025-12-25 09:15",
"customerName": "上海朔宇物流有限公司",
"amount": 1500,
"remark": "部分到账",
"invoiceDate": "2025-12-29"
},
{
"id": "rc5",
"arrivalTime": "2025-12-25 11:00",
"customerName": "上海朔宇物流有限公司",
"amount": 1000,
"remark": "第二笔部分到账",
"invoiceDate": "2025-12-29"
},
{
"id": "rc6",
"arrivalTime": "2025-12-26 15:40",
"customerName": "上海利合供应链管理有限公司",
"amount": 4000,
"remark": "全额到账",
"invoiceDate": "2025-12-29"
}
] ]

View File

@@ -3,11 +3,11 @@
"id": "l1", "id": "l1",
"status": "已收款", "status": "已收款",
"maintainerName": "刘念念", "maintainerName": "刘念念",
"maintainerTime": "2026-01-10 14:30",
"year": 2026, "year": 2026,
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "沪A32896F", "plateNo": "沪A32896F",
"vehicleBrand": "苏龙", "vehicleBrand": "苏龙",
"vehicleModel": "18T", "vehicleModel": "18T",
@@ -19,34 +19,38 @@
"pickupDate": "2024-11-30", "pickupDate": "2024-11-30",
"contractStartDate": "2025-12-01", "contractStartDate": "2025-12-01",
"contractEndDate": "2026-11-30", "contractEndDate": "2026-11-30",
"contractEndMode": "term",
"leaseTermMonths": 24,
"periodStartDate": "", "periodStartDate": "",
"returnDate": "", "returnDate": "",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 4500.0, "contractRent": 4500,
"receivableTotal": 4500.0, "receivableTotal": 4500,
"receivableRent": 4500.0, "receivableRent": 4500,
"monthlyIncome": 4500.0, "monthlyIncome": 4500,
"monthlyRent": 4500.0, "monthlyRent": 4500,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 0.0, "otherIncome": 0,
"mileageDeduction": 0.0, "mileageDeduction": 0,
"otherDeduction": 0.0, "otherDeduction": 0,
"receivedAmount": 4500.0, "receivedAmount": 4500,
"invoiceDate": "2026-01-05", "invoiceDate": "2026-01-05",
"actualPaymentDate": "2026-01-19", "actualPaymentDate": "2026-01-19",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 3900.0, "vehicleStandardCost": 3900,
"vehicleActualCost": 3900.0, "vehicleActualCost": 3900,
"insuranceFee": 0.0, "insuranceFee": 0,
"insurancePurchaseAmount": 12000,
"insuranceEffectiveDays": 365,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 500.0, "brokerageFee": 500,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 4400.0, "totalCost": 4400,
"profitLoss": 100.0, "profitLoss": 100,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "安吉天地物流科技有限公司", "assetOwner": "安吉天地物流科技有限公司",
@@ -65,17 +69,18 @@
"linkedReceiptIds": [ "linkedReceiptIds": [
"rc1" "rc1"
], ],
"hydrogenBorneBy": "我司承担" "hydrogenBorneBy": "我司承担",
"archiveStatus": "已归档"
}, },
{ {
"id": "l2", "id": "l2",
"status": "已收款", "status": "已收款",
"maintainerName": "刘念念", "maintainerName": "刘念念",
"maintainerTime": "2026-01-11 09:20",
"year": 2026, "year": 2026,
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "沪A61121F", "plateNo": "沪A61121F",
"vehicleBrand": "苏龙", "vehicleBrand": "苏龙",
"vehicleModel": "18T", "vehicleModel": "18T",
@@ -87,34 +92,44 @@
"pickupDate": "2024-11-30", "pickupDate": "2024-11-30",
"contractStartDate": "2025-12-01", "contractStartDate": "2025-12-01",
"contractEndDate": "2026-11-30", "contractEndDate": "2026-11-30",
"contractEndMode": "term",
"leaseTermMonths": 24,
"periodStartDate": "", "periodStartDate": "",
"returnDate": "", "returnDate": "",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 4500.0, "contractRent": 4500,
"receivableTotal": 4500.0, "receivableTotal": 4500,
"receivableRent": 4500.0, "receivableRent": 4500,
"monthlyIncome": 4500.0, "monthlyIncome": 4500,
"monthlyRent": 4500.0, "monthlyRent": 4500,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 0.0, "otherIncome": 0,
"mileageDeduction": 50, "mileageDeduction": 0,
"otherDeduction": 30, "otherDeduction": 0,
"receivedAmount": 4500.0, "otherDeductionItems": [
{
"reason": "超里程扣减",
"amount": 30,
"remark": "按协议扣减"
}
],
"mileageDeductionRate": 0.5,
"receivedAmount": 4500,
"invoiceDate": "2026-01-05", "invoiceDate": "2026-01-05",
"actualPaymentDate": "2026-01-19", "actualPaymentDate": "2026-01-19",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 3900.0, "vehicleStandardCost": 3900,
"vehicleActualCost": 3900.0, "vehicleActualCost": 3900,
"insuranceFee": 0.0, "insuranceFee": 0,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 500.0, "brokerageFee": 500,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 4400.0, "totalCost": 4400,
"profitLoss": 100.0, "profitLoss": 100,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "安吉天地物流科技有限公司", "assetOwner": "安吉天地物流科技有限公司",
@@ -131,7 +146,8 @@
"linkedAccidentIds": [], "linkedAccidentIds": [],
"isLatestBill": false, "isLatestBill": false,
"linkedReceiptIds": [], "linkedReceiptIds": [],
"hydrogenBorneBy": "客户承担" "hydrogenBorneBy": "客户承担",
"archiveStatus": "已归档"
}, },
{ {
"id": "l3", "id": "l3",
@@ -141,7 +157,6 @@
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "沪A69695F", "plateNo": "沪A69695F",
"vehicleBrand": "苏龙", "vehicleBrand": "苏龙",
"vehicleModel": "18T", "vehicleModel": "18T",
@@ -153,34 +168,35 @@
"pickupDate": "2024-08-20", "pickupDate": "2024-08-20",
"contractStartDate": "2025-09-01", "contractStartDate": "2025-09-01",
"contractEndDate": "2026-08-31", "contractEndDate": "2026-08-31",
"contractEndMode": "date",
"periodStartDate": "", "periodStartDate": "",
"returnDate": "", "returnDate": "",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 4500.0, "contractRent": 4500,
"receivableTotal": 4500.0, "receivableTotal": 4500,
"receivableRent": 4500.0, "receivableRent": 4500,
"monthlyIncome": 4500.0, "monthlyIncome": 4500,
"monthlyRent": 4500.0, "monthlyRent": 4500,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 0.0, "otherIncome": 0,
"mileageDeduction": 0.0, "mileageDeduction": 0,
"otherDeduction": 0.0, "otherDeduction": 0,
"receivedAmount": 4500.0, "receivedAmount": 4500,
"invoiceDate": "2026-01-05", "invoiceDate": "2026-01-05",
"actualPaymentDate": "2026-01-19", "actualPaymentDate": "2026-01-19",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 3900.0, "vehicleStandardCost": 3900,
"vehicleActualCost": 3900.0, "vehicleActualCost": 3900,
"insuranceFee": 0.0, "insuranceFee": 0,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 500.0, "brokerageFee": 500,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 4400.0, "totalCost": 4400,
"profitLoss": 100.0, "profitLoss": 100,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "安吉天地物流科技有限公司", "assetOwner": "安吉天地物流科技有限公司",
@@ -197,7 +213,8 @@
"linkedAccidentIds": [], "linkedAccidentIds": [],
"isLatestBill": false, "isLatestBill": false,
"linkedReceiptIds": [], "linkedReceiptIds": [],
"hydrogenBorneBy": "客户承担" "hydrogenBorneBy": "客户承担",
"archiveStatus": "已归档"
}, },
{ {
"id": "l4", "id": "l4",
@@ -207,7 +224,6 @@
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "粤AGF4535", "plateNo": "粤AGF4535",
"vehicleBrand": "现代", "vehicleBrand": "现代",
"vehicleModel": "4.5T/交投", "vehicleModel": "4.5T/交投",
@@ -219,34 +235,35 @@
"pickupDate": "2024-10-29", "pickupDate": "2024-10-29",
"contractStartDate": "2025-11-01", "contractStartDate": "2025-11-01",
"contractEndDate": "2026-10-31", "contractEndDate": "2026-10-31",
"contractEndMode": "date",
"periodStartDate": "", "periodStartDate": "",
"returnDate": "", "returnDate": "",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 2000.0, "contractRent": 2000,
"receivableTotal": 2000.0, "receivableTotal": 2000,
"receivableRent": 2000.0, "receivableRent": 2000,
"monthlyIncome": 2000.0, "monthlyIncome": 2000,
"monthlyRent": 2000.0, "monthlyRent": 2000,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 0.0, "otherIncome": 0,
"mileageDeduction": 0.0, "mileageDeduction": 0,
"otherDeduction": 0.0, "otherDeduction": 0,
"receivedAmount": 2000.0, "receivedAmount": 2000,
"invoiceDate": "2026-01-27", "invoiceDate": "2026-01-27",
"actualPaymentDate": "2026-02-02", "actualPaymentDate": "2026-02-02",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 1500.0, "vehicleStandardCost": 1500,
"vehicleActualCost": 1500.0, "vehicleActualCost": 1500,
"insuranceFee": 0.0, "insuranceFee": 0,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 0.0, "brokerageFee": 0,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 1500.0, "totalCost": 1500,
"profitLoss": 500.0, "profitLoss": 500,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "广州开发区交投氢能运营管理有限公司", "assetOwner": "广州开发区交投氢能运营管理有限公司",
@@ -263,7 +280,8 @@
"linkedAccidentIds": [], "linkedAccidentIds": [],
"isLatestBill": false, "isLatestBill": false,
"linkedReceiptIds": [], "linkedReceiptIds": [],
"hydrogenBorneBy": "客户承担" "hydrogenBorneBy": "客户承担",
"archiveStatus": "已归档"
}, },
{ {
"id": "l5", "id": "l5",
@@ -273,7 +291,6 @@
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "沪A65889F", "plateNo": "沪A65889F",
"vehicleBrand": "苏龙", "vehicleBrand": "苏龙",
"vehicleModel": "18T", "vehicleModel": "18T",
@@ -285,34 +302,36 @@
"pickupDate": "2024-12-03", "pickupDate": "2024-12-03",
"contractStartDate": "2025-12-03", "contractStartDate": "2025-12-03",
"contractEndDate": "2026-12-02", "contractEndDate": "2026-12-02",
"contractEndMode": "term",
"leaseTermMonths": 12,
"periodStartDate": "", "periodStartDate": "",
"returnDate": "", "returnDate": "",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 3500.0, "contractRent": 3500,
"receivableTotal": 3500.0, "receivableTotal": 3500,
"receivableRent": 3500.0, "receivableRent": 3500,
"monthlyIncome": 3500.0, "monthlyIncome": 3500,
"monthlyRent": 3500.0, "monthlyRent": 3500,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 0.0, "otherIncome": 0,
"mileageDeduction": 0.0, "mileageDeduction": 0,
"otherDeduction": 0.0, "otherDeduction": 0,
"receivedAmount": 3500.0, "receivedAmount": 3500,
"invoiceDate": "2026-01-27", "invoiceDate": "2026-01-27",
"actualPaymentDate": "2026-02-02", "actualPaymentDate": "2026-02-02",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 3900.0, "vehicleStandardCost": 3900,
"vehicleActualCost": 3900.0, "vehicleActualCost": 3900,
"insuranceFee": 0.0, "insuranceFee": 0,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 0.0, "brokerageFee": 0,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 3900.0, "totalCost": 3900,
"profitLoss": -400.0, "profitLoss": -400,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "羚牛新能源科技(上海)有限公司", "assetOwner": "羚牛新能源科技(上海)有限公司",
@@ -329,17 +348,18 @@
"linkedAccidentIds": [], "linkedAccidentIds": [],
"isLatestBill": true, "isLatestBill": true,
"linkedReceiptIds": [], "linkedReceiptIds": [],
"hydrogenBorneBy": "客户承担" "hydrogenBorneBy": "客户承担",
"archiveStatus": "已归档"
}, },
{ {
"id": "l6", "id": "l6",
"status": "已确认", "status": "未收款",
"maintainerName": "刘念念", "maintainerName": "刘念念",
"maintainerTime": "2026-01-10 14:30",
"year": 2026, "year": 2026,
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "浙F08991F", "plateNo": "浙F08991F",
"vehicleBrand": "飞驰", "vehicleBrand": "飞驰",
"vehicleModel": "18T", "vehicleModel": "18T",
@@ -351,34 +371,35 @@
"pickupDate": "2024-03-01", "pickupDate": "2024-03-01",
"contractStartDate": "2025-03-01", "contractStartDate": "2025-03-01",
"contractEndDate": "2026-02-28", "contractEndDate": "2026-02-28",
"contractEndMode": "date",
"periodStartDate": "", "periodStartDate": "",
"returnDate": "2026-02-04", "returnDate": "2026-02-04",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 4000.0, "contractRent": 4000,
"receivableTotal": 4000.0, "receivableTotal": 4000,
"receivableRent": 4000.0, "receivableRent": 12000,
"monthlyIncome": 4000.0, "monthlyIncome": 4000,
"monthlyRent": 4000.0, "monthlyRent": 4000,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 0.0, "otherIncome": 0,
"mileageDeduction": 0.0, "mileageDeduction": 0,
"otherDeduction": 0.0, "otherDeduction": 0,
"receivedAmount": 0, "receivedAmount": 0,
"invoiceDate": "2025-12-29", "invoiceDate": "2025-12-29",
"actualPaymentDate": "", "actualPaymentDate": "",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 3000.0, "vehicleStandardCost": 3000,
"vehicleActualCost": 3000.0, "vehicleActualCost": 3000,
"insuranceFee": 0.0, "insuranceFee": 0,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 0.0, "brokerageFee": 0,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 3000.0, "totalCost": 3000,
"profitLoss": 1000.0, "profitLoss": 1000,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "浙江羚牛氢能科技有限公司", "assetOwner": "浙江羚牛氢能科技有限公司",
@@ -395,17 +416,18 @@
"linkedAccidentIds": [], "linkedAccidentIds": [],
"isLatestBill": true, "isLatestBill": true,
"linkedReceiptIds": [], "linkedReceiptIds": [],
"hydrogenBorneBy": "我司承担" "hydrogenBorneBy": "我司承担",
"archiveStatus": "未归档"
}, },
{ {
"id": "l7", "id": "l7",
"status": "部分收款", "status": "部分收款",
"maintainerName": "刘念念", "maintainerName": "刘念念",
"maintainerTime": "2026-01-12 16:45",
"year": 2026, "year": 2026,
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "浙F05580F", "plateNo": "浙F05580F",
"vehicleBrand": "苏龙", "vehicleBrand": "苏龙",
"vehicleModel": "18T", "vehicleModel": "18T",
@@ -417,34 +439,36 @@
"pickupDate": "2025-09-26", "pickupDate": "2025-09-26",
"contractStartDate": "2025-10-01", "contractStartDate": "2025-10-01",
"contractEndDate": "2026-09-30", "contractEndDate": "2026-09-30",
"contractEndMode": "term",
"leaseTermMonths": 12,
"periodStartDate": "", "periodStartDate": "",
"returnDate": "", "returnDate": "",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 4000.0, "contractRent": 4000,
"receivableTotal": 4000.0, "receivableTotal": 4000,
"receivableRent": 4000.0, "receivableRent": 4000,
"monthlyIncome": 4000.0, "monthlyIncome": 4000,
"monthlyRent": 4000.0, "monthlyRent": 4000,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 0.0, "otherIncome": 0,
"mileageDeduction": 0, "mileageDeduction": 0,
"otherDeduction": 0, "otherDeduction": 0,
"receivedAmount": 2500.0, "receivedAmount": 2500,
"invoiceDate": "2025-12-29", "invoiceDate": "2025-12-29",
"actualPaymentDate": "2025-12-25", "actualPaymentDate": "2025-12-25",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 3900.0, "vehicleStandardCost": 3900,
"vehicleActualCost": 3900.0, "vehicleActualCost": 3900,
"insuranceFee": 0.0, "insuranceFee": 0,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 0.0, "brokerageFee": 0,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 3900.0, "totalCost": 3900,
"profitLoss": 100.0, "profitLoss": 100,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "上海宇速物流有限公司", "assetOwner": "上海宇速物流有限公司",
@@ -460,18 +484,22 @@
"linkedMaintenanceIds": [], "linkedMaintenanceIds": [],
"linkedAccidentIds": [], "linkedAccidentIds": [],
"isLatestBill": true, "isLatestBill": true,
"linkedReceiptIds": [], "linkedReceiptIds": [
"hydrogenBorneBy": "客户承担" "rc4",
"rc5"
],
"hydrogenBorneBy": "客户承担",
"archiveStatus": "未归档"
}, },
{ {
"id": "l8", "id": "l8",
"status": "草稿", "status": "未收款",
"maintainerName": "王芳", "maintainerName": "王芳",
"maintainerTime": "2026-01-09 11:30",
"year": 2026, "year": 2026,
"month": 1, "month": 1,
"dept": "业务二部", "dept": "业务二部",
"salesman": "刘念念", "salesman": "刘念念",
"paymentDate": "2026-01-01",
"plateNo": "沪A50990F", "plateNo": "沪A50990F",
"vehicleBrand": "苏龙", "vehicleBrand": "苏龙",
"vehicleModel": "18T", "vehicleModel": "18T",
@@ -483,34 +511,45 @@
"pickupDate": "2025-08-21", "pickupDate": "2025-08-21",
"contractStartDate": "2025-09-01", "contractStartDate": "2025-09-01",
"contractEndDate": "2026-08-31", "contractEndDate": "2026-08-31",
"contractEndMode": "date",
"leaseTermMonths": 12,
"periodStartDate": "", "periodStartDate": "",
"returnDate": "", "returnDate": "",
"avgDays": 1, "avgDays": 1,
"deposit": "/", "deposit": "/",
"contractRent": 5500.0, "contractRent": 5500,
"receivableTotal": 5500.0, "receivableTotal": 5500,
"receivableRent": 5500.0, "receivableRent": 33000,
"monthlyIncome": 5500.0, "monthlyIncome": 5500,
"monthlyRent": 5500.0, "monthlyRent": 5500,
"maintenanceIncome": 0.0, "maintenanceIncome": 0,
"insuranceSurcharge": 0.0, "insuranceSurcharge": 0,
"opsFeeIncome": 0.0, "opsFeeIncome": 0,
"otherIncome": 120, "otherIncome": 0,
"mileageDeduction": 0.0, "otherIncomeItems": [
"otherDeduction": 0.0, {
"reason": "迟还补偿",
"amount": 120,
"remark": "客户延迟还车"
}
],
"mileageDeduction": 0,
"otherDeduction": 0,
"receivedAmount": 0, "receivedAmount": 0,
"invoiceDate": "2025-12-02", "invoiceDate": "2025-12-02",
"actualPaymentDate": "", "actualPaymentDate": "",
"paymentMethod": "月度预付", "paymentMethod": "月度预付",
"vehicleStandardCost": 3900.0, "vehicleStandardCost": 3900,
"vehicleActualCost": 3900.0, "vehicleActualCost": 3900,
"insuranceFee": 0.0, "insuranceFee": 0,
"insurancePurchaseAmount": 9800,
"insuranceEffectiveDays": 365,
"hydrogenFee": 0, "hydrogenFee": 0,
"opsFeeCost": 0.0, "opsFeeCost": 0,
"brokerageFee": 1500.0, "brokerageFee": 1500,
"otherCost": 0.0, "otherCost": 0,
"totalCost": 5400.0, "totalCost": 5400,
"profitLoss": 100.0, "profitLoss": 100,
"gpsMileage": "", "gpsMileage": "",
"otherDeductionDetail": "", "otherDeductionDetail": "",
"assetOwner": "上海宇速物流有限公司", "assetOwner": "上海宇速物流有限公司",
@@ -527,6 +566,23 @@
"linkedAccidentIds": [], "linkedAccidentIds": [],
"isLatestBill": true, "isLatestBill": true,
"linkedReceiptIds": [], "linkedReceiptIds": [],
"hydrogenBorneBy": "客户承担" "hydrogenBorneBy": "客户承担",
"operationHistory": [
{
"operatedAt": "2026-01-08 10:15",
"operatorName": "王芳",
"field": "合同标的租金",
"before": "5400",
"after": "5500"
},
{
"operatedAt": "2026-01-09 11:30",
"operatorName": "王芳",
"field": "居间费",
"before": "1400",
"after": "1500"
}
],
"archiveStatus": "草稿"
} }
] ]

View File

@@ -1,10 +1,11 @@
/** /**
* @name 租赁业务台账 * @name 租赁业务台账
* 设计规范ledger-shared/DESIGN.md · vm-page + ldb-page
*/ */
import '../vehicle-management/style.css'; import '../vehicle-management/style.css';
import './styles/index.css'; import './styles/index.css';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { CheckCircle2, Download, FileUp, Plus, Send } from 'lucide-react'; import { Archive, Download, FileUp, Save } from 'lucide-react';
import { import {
AnnotationViewer, AnnotationViewer,
type AnnotationDirectoryRouteNode, type AnnotationDirectoryRouteNode,
@@ -14,20 +15,15 @@ import {
import seedRows from './data/rows.json'; import seedRows from './data/rows.json';
import seedReceipts from './data/receipts.json'; import seedReceipts from './data/receipts.json';
import seedMaintenance from './data/maintenance-orders.json'; import seedMaintenance from './data/maintenance-orders.json';
import seedAccidents from './data/accident-records.json';
import seedBiMileage from './data/bi-mileage.json'; import seedBiMileage from './data/bi-mileage.json';
import seedHydrogenFees from './data/hydrogen-fees.json'; import seedHydrogenFees from './data/hydrogen-fees.json';
import { FilterPanel } from './components/FilterPanel'; import { FilterPanel } from './components/FilterPanel';
import { LedgerKpiRow } from './components/LedgerKpiRow'; import { LedgerKpiRow } from './components/LedgerKpiRow';
import { LeaseLedgerTable } from './components/LeaseLedgerTable'; import { LeaseLedgerTable } from './components/LeaseLedgerTable';
import { ConfirmMarkModal } from './components/ConfirmMarkModal';
import { DeleteConfirmModal } from './components/DeleteConfirmModal';
import { ReceiptLinkModal } from './components/ReceiptLinkModal'; import { ReceiptLinkModal } from './components/ReceiptLinkModal';
import { MaintenanceLinkModal } from './components/MaintenanceLinkModal'; import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
import { AccidentLinkModal } from './components/AccidentLinkModal';
import { import {
EMPTY_LEASE_FILTERS, EMPTY_LEASE_FILTERS,
type AccidentRecord,
type BiMileageRecord, type BiMileageRecord,
type HydrogenFeeRecord, type HydrogenFeeRecord,
type LeaseLedgerFilters, type LeaseLedgerFilters,
@@ -39,48 +35,47 @@ import {
import { import {
applyLeaseFilters, applyLeaseFilters,
applyListViewFilter, applyListViewFilter,
canStaffDeleteRow,
canStaffEditRow, canStaffEditRow,
enrichLeaseRow, enrichLeaseRow,
filterRecordsByRole, filterRecordsByRole,
latestReceiptPaymentDate, latestReceiptPaymentDate,
LOAD_BATCH_SIZE, CURRENT_STAFF_USER,
resolveStatusAfterReceipt,
sumLeaseKpi, sumLeaseKpi,
} from './utils/ledger'; } from './utils/ledger';
import annotationSourceDocument from './annotation-source.json'; import annotationSourceDocument from './annotation-source.json';
const MAINTENANCE_ORDERS = seedMaintenance as MaintenanceOrder[]; const MAINTENANCE_ORDERS = seedMaintenance as MaintenanceOrder[];
const ACCIDENT_RECORDS = seedAccidents as AccidentRecord[];
const BI_MILEAGE = seedBiMileage as BiMileageRecord[]; const BI_MILEAGE = seedBiMileage as BiMileageRecord[];
const HYDROGEN_FEES = seedHydrogenFees as HydrogenFeeRecord[]; const HYDROGEN_FEES = seedHydrogenFees as HydrogenFeeRecord[];
function mapRows(rows: LeaseLedgerRow[]): LeaseLedgerRow[] { function formatMaintainerTime(date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function mapRows(rows: LeaseLedgerRow[], receipts: ReceiptRecord[]): LeaseLedgerRow[] {
return rows.map((row) => enrichLeaseRow(row, { return rows.map((row) => enrichLeaseRow(row, {
maintenanceOrders: MAINTENANCE_ORDERS, maintenanceOrders: MAINTENANCE_ORDERS,
accidentRecords: ACCIDENT_RECORDS,
biMileage: BI_MILEAGE, biMileage: BI_MILEAGE,
hydrogenFees: HYDROGEN_FEES, hydrogenFees: HYDROGEN_FEES,
receipts,
})); }));
} }
export default function LeaseBusinessLedgerApp() { export default function LeaseBusinessLedgerApp() {
const [records, setRecords] = useState<LeaseLedgerRow[]>(() => mapRows(seedRows as LeaseLedgerRow[])); const [records, setRecords] = useState<LeaseLedgerRow[]>(() => mapRows(seedRows as LeaseLedgerRow[], seedReceipts as ReceiptRecord[]));
const [receipts] = useState<ReceiptRecord[]>(seedReceipts as ReceiptRecord[]); const [receipts] = useState<ReceiptRecord[]>(seedReceipts as ReceiptRecord[]);
const [pendingFilters, setPendingFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS); const [pendingFilters, setPendingFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS); const [appliedFilters, setAppliedFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
const [confirmOpen, setConfirmOpen] = useState(false);
const [receiptRow, setReceiptRow] = useState<LeaseLedgerRow | null>(null); const [receiptRow, setReceiptRow] = useState<LeaseLedgerRow | null>(null);
const [maintenanceRow, setMaintenanceRow] = useState<LeaseLedgerRow | null>(null);
const [accidentRow, setAccidentRow] = useState<LeaseLedgerRow | null>(null);
const [showUnpaidOnly, setShowUnpaidOnly] = useState(false); const [showUnpaidOnly, setShowUnpaidOnly] = useState(false);
const [listViewMode, setListViewMode] = useState<ListViewMode>('all'); const [listViewMode, setListViewMode] = useState<ListViewMode>('all');
const [visibleCount, setVisibleCount] = useState(LOAD_BATCH_SIZE); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [tableLoading, setTableLoading] = useState(false); const [tableLoading, setTableLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<LeaseLedgerRow | null>(null); const [editingRowId, setEditingRowId] = useState<string | null>(null);
const [submitApprovalOpen, setSubmitApprovalOpen] = useState(false);
const showToast = useCallback((msg: string) => { const showToast = useCallback((msg: string) => {
setToast(msg); setToast(msg);
@@ -97,25 +92,17 @@ export default function LeaseBusinessLedgerApp() {
[filtered, showUnpaidOnly, listViewMode], [filtered, showUnpaidOnly, listViewMode],
); );
const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]); const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]);
const visibleRecords = useMemo( const totalPages = Math.max(1, Math.ceil(listFiltered.length / pageSize));
() => listFiltered.slice(0, visibleCount), const pagedRecords = useMemo(() => {
[listFiltered, visibleCount], const start = (page - 1) * pageSize;
); return listFiltered.slice(start, start + pageSize);
const hasMore = visibleCount < listFiltered.length; }, [listFiltered, page, pageSize]);
useEffect(() => {
setVisibleCount(LOAD_BATCH_SIZE);
}, [appliedFilters, showUnpaidOnly, listViewMode]);
const draftSelectedCount = useMemo(
() => Array.from(selectedIds).filter((id) => records.find((r) => r.id === id)?.status === '草稿').length,
[selectedIds, records],
);
const handleSearch = () => { const handleSearch = () => {
setTableLoading(true); setTableLoading(true);
window.setTimeout(() => { window.setTimeout(() => {
setAppliedFilters(pendingFilters); setAppliedFilters(pendingFilters);
setPage(1);
setSelectedIds(new Set()); setSelectedIds(new Set());
setTableLoading(false); setTableLoading(false);
}, 320); }, 320);
@@ -124,29 +111,78 @@ export default function LeaseBusinessLedgerApp() {
const handleReset = () => { const handleReset = () => {
setPendingFilters(EMPTY_LEASE_FILTERS); setPendingFilters(EMPTY_LEASE_FILTERS);
setAppliedFilters(EMPTY_LEASE_FILTERS); setAppliedFilters(EMPTY_LEASE_FILTERS);
setPage(1);
setSelectedIds(new Set()); setSelectedIds(new Set());
setShowUnpaidOnly(false); setShowUnpaidOnly(false);
setListViewMode('all'); setListViewMode('all');
}; };
const handleMarkConfirmedClick = () => { const handleSetArchived = () => {
if (draftSelectedCount === 0) { if (selectedIds.size === 0) {
showToast('请先勾选草稿状态的记录'); showToast('请先勾选需要归档的记录');
return; return;
} }
setConfirmOpen(true); const ids = new Set(selectedIds);
const now = formatMaintainerTime();
setRecords((prev) => mapRows(
prev.map((row) => {
if (!ids.has(row.id)) return row;
return {
...row,
archiveStatus: '已归档' as const,
maintainerName: CURRENT_STAFF_USER,
maintainerTime: now,
operationHistory: [
...(row.operationHistory ?? []),
{
operatedAt: now,
operatorName: CURRENT_STAFF_USER,
field: '账单状态',
before: row.archiveStatus,
after: '已归档',
},
],
};
}),
receipts,
));
setEditingRowId(null);
setSelectedIds(new Set());
showToast(`已将 ${ids.size} 条记录设置为已归档`);
}; };
const handleConfirmMark = () => { const handleSaveUnarchived = () => {
const draftIds = new Set( if (selectedIds.size === 0) {
Array.from(selectedIds).filter((id) => records.find((r) => r.id === id)?.status === '草稿'), showToast('请先勾选需要保存的记录');
); return;
}
const ids = new Set(selectedIds);
const now = formatMaintainerTime();
setRecords((prev) => mapRows( setRecords((prev) => mapRows(
prev.map((row) => (draftIds.has(row.id) ? { ...row, status: '已确认' as const } : row)), prev.map((row) => {
if (!ids.has(row.id)) return row;
return {
...row,
archiveStatus: '未归档' as const,
maintainerName: CURRENT_STAFF_USER,
maintainerTime: now,
operationHistory: [
...(row.operationHistory ?? []),
{
operatedAt: now,
operatorName: CURRENT_STAFF_USER,
field: '账单状态',
before: row.archiveStatus,
after: '未归档',
},
],
};
}),
receipts,
)); ));
setConfirmOpen(false); setEditingRowId(null);
setSelectedIds(new Set()); setSelectedIds(new Set());
showToast(`确认 ${draftIds.size} 条记录,业务服务组人员不可再删改`); showToast(` ${ids.size} 条记录保存为未归档`);
}; };
const handleReceiptConfirm = ( const handleReceiptConfirm = (
@@ -170,70 +206,59 @@ export default function LeaseBusinessLedgerApp() {
receivedAmount, receivedAmount,
linkedReceiptIds: [...new Set([...(row.linkedReceiptIds ?? []), ...selectedReceiptIds])], linkedReceiptIds: [...new Set([...(row.linkedReceiptIds ?? []), ...selectedReceiptIds])],
actualPaymentDate: paymentDate, actualPaymentDate: paymentDate,
status: resolveStatusAfterReceipt(row.receivableTotal, receivedAmount),
}; };
}), }),
receipts,
)); ));
setReceiptRow(null); setReceiptRow(null);
showToast(linkedAmount > billAmount ? '收款关联成功,多出部分请在单据下自行分配' : '收款记录关联成功'); showToast(linkedAmount > billAmount ? '收款关联成功,多出部分请在单据下自行分配' : '收款记录关联成功');
}; };
const handleMaintenanceConfirm = (recordId: string, selectedIds: string[]) => { const handleOtherIncomeItemsChange = (recordId: string, items: LeaseLedgerRow['otherIncomeItems']) => {
setRecords((prev) => mapRows( setRecords((prev) => mapRows(
prev.map((row) => ( prev.map((row) => (row.id === recordId ? { ...row, otherIncomeItems: items ?? [] } : row)),
row.id === recordId receipts,
? { ...row, linkedMaintenanceIds: [...new Set([...row.linkedMaintenanceIds, ...selectedIds])] }
: row
)),
)); ));
setMaintenanceRow(null);
showToast('维修/保养记录关联成功,运维费已自动计算');
}; };
const handleAccidentConfirm = (recordId: string, selectedIds: string[]) => { const handleOtherDeductionItemsChange = (recordId: string, items: LeaseLedgerRow['otherDeductionItems']) => {
setRecords((prev) => mapRows( setRecords((prev) => mapRows(
prev.map((row) => ( prev.map((row) => (row.id === recordId ? { ...row, otherDeductionItems: items ?? [] } : row)),
row.id === recordId receipts,
? { ...row, linkedAccidentIds: [...new Set([...row.linkedAccidentIds, ...selectedIds])] }
: row
)),
));
setAccidentRow(null);
showToast('事故单据关联成功,保险上浮费已自动汇总');
};
const handleOtherIncomeChange = (recordId: string, value: number) => {
setRecords((prev) => mapRows(
prev.map((row) => (row.id === recordId ? { ...row, otherIncome: value } : row)),
)); ));
}; };
const handleOtherCostChange = (recordId: string, value: number) => { const handleOtherCostChange = (recordId: string, value: number) => {
setRecords((prev) => mapRows( setRecords((prev) => mapRows(
prev.map((row) => (row.id === recordId ? { ...row, otherCost: value } : row)), prev.map((row) => (row.id === recordId ? { ...row, otherCost: value } : row)),
receipts,
)); ));
}; };
const handleBrokerageFeeChange = (recordId: string, value: number) => { const handleBrokerageFeeChange = (recordId: string, value: number) => {
setRecords((prev) => mapRows( setRecords((prev) => mapRows(
prev.map((row) => (row.id === recordId ? { ...row, brokerageFee: value } : row)), prev.map((row) => (row.id === recordId ? { ...row, brokerageFee: value } : row)),
receipts,
)); ));
}; };
const handleRemarkChange = (recordId: string, value: string) => { const handleRemarkChange = (recordId: string, value: string) => {
setRecords((prev) => mapRows( setRecords((prev) => mapRows(
prev.map((row) => (row.id === recordId ? { ...row, remark: value } : row)), prev.map((row) => (row.id === recordId ? { ...row, remark: value } : row)),
receipts,
)); ));
}; };
const handleDeductionDetailChange = (recordId: string, value: string) => { const handleDeductionDetailChange = (recordId: string, value: string) => {
setRecords((prev) => mapRows( setRecords((prev) => mapRows(
prev.map((row) => (row.id === recordId ? { ...row, otherDeductionDetail: value } : row)), prev.map((row) => (row.id === recordId ? { ...row, otherDeductionDetail: value } : row)),
receipts,
)); ));
}; };
const handleListViewChange = (mode: ListViewMode) => { const handleListViewChange = (mode: ListViewMode) => {
setListViewMode(mode); setListViewMode(mode);
setPage(1);
if (mode === 'unpaid') setShowUnpaidOnly(true); if (mode === 'unpaid') setShowUnpaidOnly(true);
if (mode === 'all') setShowUnpaidOnly(false); if (mode === 'all') setShowUnpaidOnly(false);
}; };
@@ -277,6 +302,7 @@ export default function LeaseBusinessLedgerApp() {
onChange={(e) => { onChange={(e) => {
setShowUnpaidOnly(e.target.checked); setShowUnpaidOnly(e.target.checked);
setListViewMode(e.target.checked ? 'unpaid' : 'all'); setListViewMode(e.target.checked ? 'unpaid' : 'all');
setPage(1);
}} }}
/> />
@@ -293,36 +319,33 @@ export default function LeaseBusinessLedgerApp() {
<button <button
type="button" type="button"
className="vm-btn vm-btn-ghost ldb-toolbar-btn" className="vm-btn vm-btn-ghost ldb-toolbar-btn"
data-annotation-id="lbl-btn-mark-confirmed" data-annotation-id="lbl-btn-save-unarchived"
onClick={handleMarkConfirmedClick} onClick={handleSaveUnarchived}
> >
<CheckCircle2 size={16} aria-hidden /> <Save size={16} aria-hidden />
</button> </button>
<button <button
type="button" type="button"
className="vm-btn vm-btn-primary ldb-toolbar-btn" className="vm-btn vm-btn-ghost ldb-toolbar-btn"
onClick={() => { data-annotation-id="lbl-btn-set-archived"
if (selectedIds.size === 0) { onClick={handleSetArchived}
showToast('请先勾选需要提交审批的记录');
return;
}
setSubmitApprovalOpen(true);
}}
> >
<Send size={16} aria-hidden /> <Archive size={16} aria-hidden />
</button> </button>
</div> </div>
</div> </div>
<div className="vm-table-card"> <div className="vm-table-card">
<LeaseLedgerTable <LeaseLedgerTable
records={visibleRecords} records={pagedRecords}
loading={tableLoading} loading={tableLoading}
maintenanceOrders={MAINTENANCE_ORDERS} maintenanceOrders={MAINTENANCE_ORDERS}
accidentRecords={ACCIDENT_RECORDS} receipts={receipts}
hydrogenFees={HYDROGEN_FEES}
selectedIds={selectedIds} selectedIds={selectedIds}
editingRowId={editingRowId}
onToggleRow={(id, checked) => { onToggleRow={(id, checked) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -336,47 +359,60 @@ export default function LeaseBusinessLedgerApp() {
}} }}
onEdit={(row) => { onEdit={(row) => {
if (!canStaffEditRow(row)) { if (!canStaffEditRow(row)) {
showToast('已确认记录仅业务服务组主管可修改'); showToast('仅草稿状态的数据可以编辑');
return; return;
} }
showToast(`行内编辑:${row.plateNo}(原型演示)`); setEditingRowId(row.id);
showToast(`正在编辑 ${row.plateNo},完成后请点击保存`);
}} }}
onDelete={(row) => { onSave={(row) => {
if (!canStaffDeleteRow(row)) return; const now = formatMaintainerTime();
setDeleteTarget(row); setRecords((prev) => mapRows(
prev.map((item) => {
if (item.id !== row.id) return item;
return {
...item,
archiveStatus: '未归档' as const,
maintainerName: CURRENT_STAFF_USER,
maintainerTime: now,
operationHistory: [
...(item.operationHistory ?? []),
{
operatedAt: now,
operatorName: CURRENT_STAFF_USER,
field: '账单状态',
before: item.archiveStatus,
after: '未归档',
},
],
};
}),
receipts,
));
setEditingRowId(null);
showToast(`已保存 ${row.plateNo},账单状态已设为未归档`);
}} }}
onLinkReceipt={(row) => setReceiptRow(row)} onLinkReceipt={(row) => setReceiptRow(row)}
onLinkMaintenance={(row) => setMaintenanceRow(row)} onOtherIncomeItemsChange={handleOtherIncomeItemsChange}
onLinkAccident={(row) => setAccidentRow(row)} onOtherDeductionItemsChange={handleOtherDeductionItemsChange}
onOtherIncomeChange={handleOtherIncomeChange}
onOtherCostChange={handleOtherCostChange} onOtherCostChange={handleOtherCostChange}
onBrokerageFeeChange={handleBrokerageFeeChange} onBrokerageFeeChange={handleBrokerageFeeChange}
onRemarkChange={handleRemarkChange} onRemarkChange={handleRemarkChange}
onDeductionDetailChange={handleDeductionDetailChange} onDeductionDetailChange={handleDeductionDetailChange}
/> />
<div className="vm-table-footer ldb-table-footer"> <div className="vm-table-footer ldb-table-footer">
<span className="ldb-table-total"> <span className="ldb-table-total"> {listFiltered.length} </span>
{visibleRecords.length} / {listFiltered.length}
</span>
<div className="ldb-table-footer-actions"> <div className="ldb-table-footer-actions">
{hasMore && ( <TablePagination
<button page={Math.min(page, totalPages)}
type="button" pageSize={pageSize}
className="vm-btn vm-btn-ghost" total={listFiltered.length}
onClick={() => setVisibleCount((n) => n + LOAD_BATCH_SIZE)} onPageChange={setPage}
> onPageSizeChange={(size) => {
setPageSize(size);
</button> setPage(1);
)} }}
<button />
type="button"
className="vm-btn vm-btn-secondary"
data-annotation-id="lbl-btn-add-row"
onClick={() => showToast('表格底部新增行(原型演示)')}
>
<Plus size={16} aria-hidden />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -385,53 +421,6 @@ export default function LeaseBusinessLedgerApp() {
{toast && <div className="vm-toast" role="status">{toast}</div>} {toast && <div className="vm-toast" role="status">{toast}</div>}
</div> </div>
<ConfirmMarkModal
open={confirmOpen}
count={draftSelectedCount}
onCancel={() => setConfirmOpen(false)}
onConfirm={handleConfirmMark}
/>
<DeleteConfirmModal
open={Boolean(deleteTarget)}
plateNo={deleteTarget?.plateNo ?? ''}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => {
if (!deleteTarget) return;
setRecords((prev) => prev.filter((row) => row.id !== deleteTarget.id));
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(deleteTarget.id);
return next;
});
setDeleteTarget(null);
showToast(`已删除 ${deleteTarget.plateNo}(原型)`);
}}
/>
{submitApprovalOpen && (
<div className="vm-modal-backdrop" role="presentation" onClick={() => setSubmitApprovalOpen(false)}>
<div className="vm-modal vm-modal-sm" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<header className="vm-modal-header">
<h2 className="vm-modal-title"></h2>
</header>
<div className="vm-modal-body">
<p className="ldb-confirm-text"> {selectedIds.size} </p>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setSubmitApprovalOpen(false)}></button>
<button
type="button"
className="vm-btn vm-btn-primary"
onClick={() => {
setSubmitApprovalOpen(false);
showToast(`已提交 ${selectedIds.size} 条收款审批(原型演示)`);
}}
>
</button>
</footer>
</div>
</div>
)}
<ReceiptLinkModal <ReceiptLinkModal
key={receiptRow?.id ?? 'closed'} key={receiptRow?.id ?? 'closed'}
open={Boolean(receiptRow)} open={Boolean(receiptRow)}
@@ -440,23 +429,6 @@ export default function LeaseBusinessLedgerApp() {
onClose={() => setReceiptRow(null)} onClose={() => setReceiptRow(null)}
onConfirm={handleReceiptConfirm} onConfirm={handleReceiptConfirm}
/> />
<MaintenanceLinkModal
key={maintenanceRow?.id ?? 'closed'}
open={Boolean(maintenanceRow)}
record={maintenanceRow}
orders={MAINTENANCE_ORDERS}
onClose={() => setMaintenanceRow(null)}
onConfirm={handleMaintenanceConfirm}
/>
<AccidentLinkModal
key={accidentRow?.id ?? 'closed'}
open={Boolean(accidentRow)}
record={accidentRow}
accidents={ACCIDENT_RECORDS}
onClose={() => setAccidentRow(null)}
onConfirm={handleAccidentConfirm}
/>
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} /> <AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
</> </>
); );

View File

@@ -1,12 +1,14 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { FIELD_ANNOTATION_SPEC } from './field-annotation-spec.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const protoRoot = path.resolve(__dirname, '..'); const protoRoot = path.resolve(__dirname, '..');
const prdPath = path.join(protoRoot, '.spec/requirements-prd.md'); const prdPath = path.join(protoRoot, '.spec/requirements-prd.md');
const sourcePath = path.join(protoRoot, 'annotation-source.json'); const sourcePath = path.join(protoRoot, 'annotation-source.json');
const columnsPath = path.join(protoRoot, 'components/tableColumns.ts'); const columnsPath = path.join(protoRoot, 'components/tableColumns.ts');
const tipsPath = path.join(protoRoot, 'columnHeaderTips.ts');
const legacyPrd = fs.existsSync(prdPath) ? fs.readFileSync(prdPath, 'utf8') : ''; const legacyPrd = fs.existsSync(prdPath) ? fs.readFileSync(prdPath, 'utf8') : '';
const existing = fs.existsSync(sourcePath) const existing = fs.existsSync(sourcePath)
@@ -15,18 +17,120 @@ const existing = fs.existsSync(sourcePath)
/** 保留批注工具里已写入的 markdown避免被 PRD 摘要覆盖 */ /** 保留批注工具里已写入的 markdown避免被 PRD 摘要覆盖 */
const markdownMap = { ...(existing?.markdownMap ?? {}) }; const markdownMap = { ...(existing?.markdownMap ?? {}) };
delete markdownMap['lbl-btn-mark-confirmed'];
delete markdownMap['lbl-btn-add-row'];
markdownMap['lbl-col-status'] = [
'## 收款状态',
'',
'当收款记录中分配金额完全覆盖账单的应收金额时,显示**已收款**。',
'当收款记录中分配金额≤账单的应收金额时,显示**部分收款**。',
'未分配收款记录时,显示为**未收款**。其他状态去除。',
'',
'---',
].join('\n');
markdownMap['lbl-filter'] = [
'## 2. 筛选区',
'',
'默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。',
'',
'| 筛选项 | 说明 |',
'|---|---|',
'| 年份 / 月份 | 账单所属年月 |',
'| 业务部门 / 业务员 | 精确匹配 |',
'| 车牌号码 | 精确匹配 |',
'| 客户名称 | 模糊包含 |',
'| 明细状态 | 未收款 / 部分收款 / 已收款 |',
'| 应付款日 | 单输入框双日历区间,精确至日 |',
'',
'---',
].join('\n');
markdownMap['lbl-toolbar'] = [
'## 4. 列表工具栏',
'',
'左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。',
'',
'| 按钮 | 行为 |',
'|---|---|',
'| 批量导入 | 下载模板并上传 Excel原型 Toast |',
'| 导出 | 导出当前筛选结果 |',
'| 保存 | 勾选记录后设为未归档,保存后不可编辑 |',
'| 设置为归档 | 勾选记录后锁定为已归档 |',
'',
'表格底部提供分页,可切换页码与每页条数。',
'',
'---',
].join('\n');
markdownMap['lbl-col-archive-status'] = [
'## 账单状态',
'',
'状态分为**已归档**、**未归档**、**草稿**。',
'',
'**仅草稿**状态的数据可以编辑;未归档、已归档均不可编辑。',
'工具栏「保存」:勾选记录后设为**未归档**。',
'工具栏「设置为归档」:勾选记录后设为**已归档**。',
'',
'---',
].join('\n');
markdownMap['lbl-btn-save-unarchived'] = [
'## 保存',
'',
'勾选台账记录后点击,将账单状态设为**未归档**。未归档数据不可编辑,仅草稿可编辑。',
'',
'---',
].join('\n');
markdownMap['lbl-btn-set-archived'] = [
'## 设置为归档',
'',
'勾选台账记录后点击,将账单状态设为**已归档**并锁定编辑。',
'',
'---',
].join('\n');
function parseColumnTitles(source) {
const titles = {};
for (const match of source.matchAll(/\{[^{}]*annotationId:\s*'(lbl-[^']+)'[^{}]*\}/gs)) {
const block = match[0];
const id = match[1];
const title = block.match(/title:\s*'([^']+)'/)?.[1];
if (title) titles[id] = title;
}
return titles;
}
const columnsSource = fs.readFileSync(columnsPath, 'utf8'); const columnsSource = fs.readFileSync(columnsPath, 'utf8');
const columnAnnotationIds = [...columnsSource.matchAll(/annotationId:\s*'(lbl-[^']+)'/g)].map((m) => m[1]); const columnAnnotationIds = [...columnsSource.matchAll(/annotationId:\s*'(lbl-[^']+)'/g)].map((m) => m[1]);
const columnTitles = parseColumnTitles(columnsSource);
const tipsSource = fs.readFileSync(tipsPath, 'utf8');
const columnHeaderTips = Object.fromEntries(
[...tipsSource.matchAll(/'(lbl-col-[^']+)':\s*\n?\s*'([^']*(?:\\'[^']*)*)'/gs)].map((m) => [
m[1],
m[2].replace(/\\'/g, "'"),
]),
);
function buildColumnFieldMarkdown(id) {
const spec = FIELD_ANNOTATION_SPEC[id];
const title = columnTitles[id] ?? titleFromMarkdown(markdownMap[id]) ?? id.replace(/^lbl-col-/, '');
const tip = columnHeaderTips[id];
const lines = [`## ${title}`, ''];
if (spec?.source) lines.push(`**数据来源**${spec.source}`);
if (spec?.formula) lines.push(`**计算方式**${spec.formula}`);
if (spec?.editable) lines.push(`**录入方式**${spec.editable}`);
if (tip) lines.push('', `**业务说明**${tip}`);
lines.push('', '---');
return lines.join('\n');
}
const NODE_META = { const NODE_META = {
'lbl-filter': { title: '筛选条件', color: '#2563eb', aiPrompt: '年份、月份、部门、业务员等筛选。' }, 'lbl-filter': { title: '筛选条件', color: '#2563eb', aiPrompt: '年份、月份、部门、业务员等筛选。' },
'lbl-kpi': { title: 'KPI 统计', color: '#64748b', aiPrompt: '六张统计卡片口径。' }, 'lbl-kpi': { title: 'KPI 统计', color: '#64748b', aiPrompt: '六张统计卡片口径。' },
'lbl-kpi-count': { title: '台账条数', color: '#64748b', aiPrompt: '台账条数 KPI 点击切换列表。' }, 'lbl-kpi-count': { title: '台账条数', color: '#64748b', aiPrompt: '台账条数 KPI 点击切换列表。' },
'lbl-kpi-unreceived': { title: '未收总计', color: '#64748b', aiPrompt: '未收总计 KPI 筛选未收款。' }, 'lbl-kpi-unreceived': { title: '未收总计', color: '#64748b', aiPrompt: '未收总计 KPI 筛选未收款。' },
'lbl-toolbar': { title: '列表工具栏', color: '#0f766e', aiPrompt: '导入、导出、标记确认、提交审批。' }, 'lbl-toolbar': { title: '列表工具栏', color: '#0f766e', aiPrompt: '导入、导出、保存、设置为归档。' },
'lbl-btn-mark-confirmed': { title: '标记为已确认', color: '#0f766e', aiPrompt: '草稿批量确认与权限收口。' }, 'lbl-btn-save-unarchived': { title: '保存', color: '#0f766e', aiPrompt: '勾选记录批量设为未归档。' },
'lbl-btn-add-row': { title: '添加行', color: '#0f766e', aiPrompt: '表格底部新增行。' }, 'lbl-btn-set-archived': { title: '设置为归档', color: '#0f766e', aiPrompt: '勾选记录批量归档。' },
'lbl-table': { title: '租赁台账宽表', color: '#2563eb', aiPrompt: '运营明细台账列表与行操作。' }, 'lbl-table': { title: '租赁台账宽表', color: '#2563eb', aiPrompt: '运营明细台账列表与行操作。' },
}; };
@@ -58,6 +162,10 @@ function resolveMeta(id) {
if (NODE_META[id]) return NODE_META[id]; if (NODE_META[id]) return NODE_META[id];
const md = markdownMap[id]; const md = markdownMap[id];
const fromMd = md ? titleFromMarkdown(md) : null; const fromMd = md ? titleFromMarkdown(md) : null;
const fromColumn = columnTitles[id];
if (fromColumn) {
return { title: fromColumn, color: '#64748b', aiPrompt: `${fromColumn}字段的数据来源与计算口径。` };
}
if (fromMd) { if (fromMd) {
return { title: fromMd, color: '#64748b', aiPrompt: `${fromMd}字段说明。` }; return { title: fromMd, color: '#64748b', aiPrompt: `${fromMd}字段说明。` };
} }
@@ -87,7 +195,7 @@ function buildFullPrd(map, columnIds) {
return [ return [
'# 租赁业务台账 · 产品需求说明PRD', '# 租赁业务台账 · 产品需求说明PRD',
'', '',
'> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。', '> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,收款关联与状态自动计算为原型演示。',
'', '',
'---', '---',
'', '',
@@ -98,8 +206,8 @@ function buildFullPrd(map, columnIds) {
'|---|---|', '|---|---|',
'| 模块名称 | 台账数据 — 租赁业务台账 |', '| 模块名称 | 台账数据 — 租赁业务台账 |',
'| 目标用户 | 租赁业务客服、业务服务组主管 |', '| 目标用户 | 租赁业务客服、业务服务组主管 |',
'| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 |', '| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、关联收款记录 |',
'| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页 |', '| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 底部分页 |',
].join('\n'), ].join('\n'),
), ),
'', '',
@@ -119,9 +227,9 @@ function buildFullPrd(map, columnIds) {
'', '',
trimDoc(map['lbl-toolbar']), trimDoc(map['lbl-toolbar']),
'', '',
asSubsection(map['lbl-btn-mark-confirmed']), asSubsection(map['lbl-btn-save-unarchived']),
'', '',
asSubsection(map['lbl-btn-add-row']), asSubsection(map['lbl-btn-set-archived']),
'', '',
'---', '---',
'', '',
@@ -130,8 +238,8 @@ function buildFullPrd(map, columnIds) {
'---', '---',
'', '',
(() => { (() => {
const body = trimDoc(map['lbl-col-status']).replace(/^##\s+状态流转\s*\n+/, ''); const body = trimDoc(map['lbl-col-status']).replace(/^##\s+(状态流转|收款状态)\s*\n+/, '');
return prdSection('6. 状态流转', body); return prdSection('6. 收款状态', body);
})(), })(),
'', '',
'---', '---',
@@ -143,6 +251,9 @@ function buildFullPrd(map, columnIds) {
} }
function ensureMarkdown(id, meta) { function ensureMarkdown(id, meta) {
if (id.startsWith('lbl-col-')) {
return buildColumnFieldMarkdown(id);
}
if (markdownMap[id]?.trim()) return markdownMap[id]; if (markdownMap[id]?.trim()) return markdownMap[id];
const prdFallback = { const prdFallback = {
'lbl-filter': '## 2. 筛选区', 'lbl-filter': '## 2. 筛选区',
@@ -162,8 +273,8 @@ const pageNodeIds = [
'lbl-kpi-count', 'lbl-kpi-count',
'lbl-kpi-unreceived', 'lbl-kpi-unreceived',
'lbl-toolbar', 'lbl-toolbar',
'lbl-btn-mark-confirmed', 'lbl-btn-save-unarchived',
'lbl-btn-add-row', 'lbl-btn-set-archived',
'lbl-table', 'lbl-table',
]; ];
@@ -290,8 +401,8 @@ const source = {
defaultExpanded: false, defaultExpanded: false,
children: [ children: [
docNode('lbl-toolbar'), docNode('lbl-toolbar'),
docNode('lbl-btn-mark-confirmed'), docNode('lbl-btn-save-unarchived'),
docNode('lbl-btn-add-row'), docNode('lbl-btn-set-archived'),
], ],
}, },
{ {

View File

@@ -0,0 +1,226 @@
/**
* 租赁业务台账 — 字段标注规格(数据来源 + 计算方式)
* 供 build-annotation-source.mjs 生成 annotation-source / PRD 字段说明。
*/
export const FIELD_ANNOTATION_SPEC = {
'lbl-col-status': {
source: '收款记录模块中关联到本账单的分配金额、账单应收合计。',
formula: '分配金额 ≥ 应收合计且应收 > 0 → 已收款0 < 分配金额 < 应收 → 部分收款;未关联收款 → 未收款。',
editable: '系统自动计算,只读。',
},
'lbl-col-payment-due': {
source: '账单起始日期(优先)或提车日期作为开始计费日;账单所属年月。',
formula: '首期账单应付款日 = 开始计费日;后续每期 = 账单所属自然月 5 日。',
editable: '系统自动计算,只读。',
},
'lbl-col-plate': {
source: '车辆档案(按车牌匹配品牌、型号)。',
formula: '导入/新增台账时按车牌号反写车辆信息。',
editable: '只读展示(车牌 + 车型两行)。',
},
'lbl-col-customer': {
source: '租赁合同(按账单日期 + 车牌匹配客户、项目)。',
formula: '匹配当前车辆在账期内的有效合同,带出客户名称与项目名称。',
editable: '只读展示(客户 + 项目两行)。',
},
'lbl-col-value-added-service': {
source: '租赁合同签订时勾选的增值服务项目。',
formula: '按车牌匹配合同增值服务配置,多项服务分行展示。',
editable: '只读。',
},
'lbl-col-pickup-date': {
source: '交车管理 / 运维 E 签宝签字完成时间。',
formula: '取该车辆实际交车办结时间。',
editable: '只读。',
},
'lbl-col-contract-start-date': {
source: '提车日期(交车办结时间)。',
formula: '合同生效日期 = 提车日期YYYY-MM-DD系统自动反写。',
editable: '系统自动反写,只读。',
},
'lbl-col-contract-end-date': {
source: '合同租期月数 + 提车日期,或合同约定的到期日。',
formula: '日期模式:取合同存储的 contractEndDate租期模式提车日 + leaseTermMonths 个月。',
editable: '系统自动计算,只读。',
},
'lbl-col-period-start-date': {
source: '账单年月、提车日期。',
formula: '月前已提车 → 当月 1 日;月内提车 → 提车日;月后提车 → 空calcPeriodStartDate。',
editable: '系统自动计算,只读。',
},
'lbl-col-return-date': {
source: '退车管理登记的退车日期。',
formula: '取该车牌退车办结日期;未退车为空。',
editable: '只读。',
},
'lbl-col-avg-days': {
source: '账单月内租赁天数、当月自然日天数。',
formula: '平均天数 = 账单月内租赁天数 ÷ 当月自然日天数保留两位小数calcAvgDays。',
editable: '系统自动计算,只读。',
},
'lbl-col-deposit': {
source: '提车应收款 / 合同保证金。',
formula: '与创建合同时保证金、提车应收款中该车辆保证金一致。',
editable: '只读。',
},
'lbl-col-contract-rent': {
source: '租赁合同月租金标准。',
formula: '签订合同时该车辆的月租金(合同标的租金)。',
editable: '合同带入,只读。',
},
'lbl-col-receivable-total': {
source: '应收租金、保险上浮费、运维费(收入侧)、其他收入、里程/其他减免。',
formula: '应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免 其他减免calcReceivableTotal。',
editable: '系统自动汇总,只读。',
},
'lbl-col-receivable-rent': {
source: '合同标的租金、合同付款周期月数。',
formula: '应收租金 = 合同标的租金 × 付款周期月数1/3/6/12calcReceivableRent。',
editable: '系统自动计算,只读。',
},
'lbl-col-monthly-income': {
source: '月度租金、维保包干、保险上浮费、运维费(收入侧)、其他收入、减免项。',
formula: '月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 里程减免 其他减免calcMonthlyIncome。',
editable: '系统自动计算,只读。',
},
'lbl-col-monthly-rent': {
source: '合同标的租金。',
formula: '月度租金 = 合同标的租金calcMonthlyRent。',
editable: '只读。',
},
'lbl-col-maintenance-income': {
source: '合同维保包干配置、账单里程。',
formula: '勾选维保包干时:单价(元/km× 里程数;未勾选为 0calcMaintenanceIncome。',
editable: '包干未勾选时不可编辑。',
},
'lbl-col-insurance-surcharge': {
source: '事故台账关联的保险上浮费。',
formula: '关联事故单后汇总 linkedAccidentIds 对应上浮费;最新账单未关联时显示警示。',
editable: '关联后自动汇总,支持多选事故单。',
},
'lbl-col-ops-income': {
source: '维修/保养台账(客户承担)、账单月内该车牌完工记录。',
formula: '汇总 bearBy=客户承担 的维保金额;可关联 linkedMaintenanceIdssumMaintenanceForBill。',
editable: '关联后自动汇总;最新账单可关联记录。',
},
'lbl-col-other-income': {
source: '行内其他收入明细(事由、金额、备注)。',
formula: '多条明细金额合计计入应收与月度收入sumLineItems。',
editable: '草稿状态可编辑明细。',
},
'lbl-col-mileage-deduction': {
source: 'BI 里程GPS、里程减免单价。',
formula: '里程减免 = GPS 里程 × 单价;有 GPS 时默认 0.5 元/km无 GPS 为 0calcMileageDeduction。',
editable: '系统自动计算,只读。',
},
'lbl-col-other-deduction': {
source: '行内其他减免明细。',
formula: '多条明细金额合计从应收与月度收入扣减sumLineItems。',
editable: '草稿状态可编辑明细。',
},
'lbl-col-received-amount': {
source: '收款记录模块关联分配金额。',
formula: '关联收款单后按分配计入金额汇总不超过应收合计calcReceivedAmountFromReceipts。',
editable: '通过「关联收款」操作维护。',
},
'lbl-col-unreceived': {
source: '应收合计、实收金额。',
formula: '未收金额 = 应收合计 实收金额calcUnreceived。',
editable: '系统自动计算,不可手工录入。',
},
'lbl-col-invoice-date': {
source: '关联收款记录中的开票信息。',
formula: 'resolveInvoiceDateFromReceipts 取关联收款单开票日期。',
editable: '只读。',
},
'lbl-col-actual-payment': {
source: '关联收款记录的到账日期。',
formula: '有关联收款时取到账记录付款日期;未关联为空。',
editable: '只读。',
},
'lbl-col-payment-method': {
source: '合同付款周期月数。',
formula: '1→月度预付3→季度预付6→半年预付12→年度预付resolvePaymentMethod。',
editable: '只读。',
},
'lbl-col-vehicle-standard-cost': {
source: '车型成本明细 / 车牌标准成本配置PLATE_STANDARD_COST。',
formula: '按车型维护的固定月标准成本。',
editable: '只读。',
},
'lbl-col-vehicle-actual-cost': {
source: '车辆标准成本、账单月内租赁天数。',
formula: '车辆实际成本 = 标准成本 × 租赁天数 / 30calcVehicleActualCost。',
editable: '系统自动计算,只读。',
},
'lbl-col-insurance-fee': {
source: '保险采购金额、保险有效天数、账单月租赁天数。',
formula: '保险费 =(采购金额 / 有效天数)× 租赁天数四舍五入两位calcInsuranceFee。',
editable: '只读。',
},
'lbl-col-hydrogen-fee': {
source: '氢费明细台账、合同氢费承担方式。',
formula: '合同为「我司承担」时汇总账单月内该车牌氢费sumHydrogenFeeForBill客户承担不计入。',
editable: '只读。',
},
'lbl-col-ops-cost': {
source: '维修/保养台账(我司承担)、账单月内该车牌。',
formula: '汇总 bearBy=我司承担 的维保金额sumMaintenanceForBill。',
editable: '只读。',
},
'lbl-col-brokerage-fee': {
source: '行内手工录入。',
formula: '直接存储 brokerageFee 字段。',
editable: '草稿可编辑。',
},
'lbl-col-other-cost': {
source: '行内手工录入。',
formula: '直接存储 otherCost前方有其他收入时本项必填。',
editable: '草稿可编辑。',
},
'lbl-col-gps-mileage': {
source: 'BI 里程数据bi-mileage.json / 车机 GPS。',
formula: 'lookupBiMileage(车牌, 年, 月) 取账单期里程。',
editable: '只读。',
},
'lbl-col-deduction-detail': {
source: '行内手工录入。',
formula: '存储 otherDeductionDetail 文本。',
editable: '草稿可编辑。',
},
'lbl-col-asset-ownership': {
source: '车辆档案资产归属。',
formula: '按车牌从 PLATE_ASSET_OWNER / 车辆档案反写。',
editable: '只读。',
},
'lbl-col-signing-company': {
source: '租赁合同甲方。',
formula: '按车牌匹配签订合同时甲方名称PLATE_SIGNING_COMPANY。',
editable: '只读。',
},
'lbl-col-remark': {
source: '行内手工录入。',
formula: '存储 remark 文本。',
editable: '草稿可编辑。',
},
'lbl-col-archive-status': {
source: '账单归档状态字段 archiveStatus。',
formula: '草稿 / 未归档 / 已归档;工具栏「保存」「设置为归档」批量变更。',
editable: '仅草稿可编辑业务字段。',
},
'lbl-col-maintainer-time': {
source: '台账行维护记录。',
formula: '展示最近一次保存/维护时间maintainerTime。',
editable: '系统记录,只读。',
},
'lbl-col-dept': {
source: '台账行业务部门、业务员字段。',
formula: '导入或维护时写入,列表以「部门 + 业务员」两行展示。',
editable: '导入/草稿可维护。',
},
'lbl-col-payment-date': {
source: '收款记录关联反写。',
formula: '关联租赁订单收款后反显收款日期。',
editable: '只读。',
},
};

View File

@@ -1,33 +1,11 @@
/* 租赁业务台账 — 模块样式(基于 vehicle-management vm-* */ /* 租赁业务台账 — 模块样式(基于 vehicle-management vm-* + ledger-shared 壳层 */
.ldb-page { @import '../../ledger-shared/styles/ledger-page.css';
padding-bottom: 48px;
}
.ldb-page-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--ln-ink);
}
.ldb-date-range {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.ldb-date-range input {
flex: 1;
min-width: 0;
}
.ldb-kpi-row { .ldb-kpi-row {
display: grid; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px; gap: 12px;
margin-bottom: 16px;
} }
@media (max-width: 1280px) { @media (max-width: 1280px) {
@@ -75,6 +53,12 @@
cursor: help; cursor: help;
} }
.ldb-th-tip.ldb-kpi-tip {
position: relative;
top: auto;
right: auto;
}
.ldb-kpi-tooltip { .ldb-kpi-tooltip {
display: none; display: none;
position: absolute; position: absolute;
@@ -93,10 +77,26 @@
} }
.ldb-kpi-tip:hover .ldb-kpi-tooltip, .ldb-kpi-tip:hover .ldb-kpi-tooltip,
.ldb-kpi-tip:focus-within .ldb-kpi-tooltip { .ldb-kpi-tip:focus-within .ldb-kpi-tooltip,
.ldb-th-tip:hover .ldb-kpi-tooltip,
.ldb-th-tip:focus-within .ldb-kpi-tooltip {
display: block; display: block;
} }
.ldb-kpi-tip .ldb-kpi-tooltip {
top: auto;
bottom: calc(100% + 6px);
right: 0;
left: auto;
}
.ldb-th-inner .ldb-kpi-tooltip {
max-width: 280px;
text-align: left;
white-space: normal;
z-index: 50;
}
.ldb-table-toolbar { .ldb-table-toolbar {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@@ -152,15 +152,8 @@
.ldb-asset--leased { background: #fff7ed; color: #c2410c; } .ldb-asset--leased { background: #fff7ed; color: #c2410c; }
.ldb-asset--affiliated { background: #f5f3ff; color: #6d28d9; } .ldb-asset--affiliated { background: #f5f3ff; color: #6d28d9; }
.ldb-table-footer-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ldb-table-wrap { overflow: auto; } .ldb-table-wrap { overflow: auto; }
.ldb-table { min-width: 4200px; table-layout: fixed; } .ldb-table { min-width: 4200px; }
.ldb-table .vm-table thead th, .ldb-table .vm-table thead th,
.ldb-table .vm-table tbody td { .ldb-table .vm-table tbody td {
@@ -176,11 +169,6 @@
background: var(--ln-canvas-soft); background: var(--ln-canvas-soft);
} }
.ldb-table .vm-table tbody tr:hover td.sticky-col-left,
.ldb-table .vm-table tbody tr:hover td.sticky-col-right {
background: var(--ln-canvas-soft);
}
.ldb-cell-stack { .ldb-cell-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -324,52 +312,6 @@
background: color-mix(in srgb, var(--ln-primary) 18%, transparent); background: color-mix(in srgb, var(--ln-primary) 18%, transparent);
} }
.ldb-toolbar-btn {
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}
.ldb-toolbar-btn:hover {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.ldb-toolbar-btn:active {
transform: translateY(1px);
}
.ldb-filter-expand {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease, opacity 0.22s ease;
opacity: 0;
}
.ldb-filter-expand.is-expanded {
grid-template-rows: 1fr;
opacity: 1;
}
.ldb-filter-expand-inner {
overflow: hidden;
}
.ldb-filter-expand-grid {
padding-top: 4px;
}
.ldb-filter-toggle-icon {
transition: transform 0.25s ease;
}
.ldb-filter-toggle-icon.is-open {
transform: rotate(180deg);
}
.ldb-date-range-sep {
flex-shrink: 0;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.ldb-table-skeleton { .ldb-table-skeleton {
padding: 12px 0; padding: 12px 0;
} }
@@ -400,20 +342,6 @@
color: #fff; color: #fff;
} }
.sticky-col-left {
position: sticky;
left: 0;
z-index: 2;
background: var(--ln-surface-card);
}
.sticky-col-right {
position: sticky;
right: 0;
z-index: 2;
background: var(--ln-surface-card);
}
.ldb-status-tag { .ldb-status-tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -425,10 +353,12 @@
border: 1px solid transparent; border: 1px solid transparent;
} }
.ldb-status--draft { background: #ecfdf5; color: #047857; border-color: #a7f3d0; } .ldb-status--unpaid { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.ldb-status--confirmed { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
.ldb-status--partial { background: #fff7ed; color: #c2410c; border-color: #fed7aa; } .ldb-status--partial { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.ldb-status--paid { background: #f0fdf4; color: #15803d; border-color: #bbf7d0; } .ldb-status--paid { background: #f0fdf4; color: #15803d; border-color: #bbf7d0; }
.ldb-status--archived { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
.ldb-status--unarchived { background: #ecfdf5; color: #047857; border-color: #a7f3d0; }
.ldb-status--draft { background: #f8fafc; color: #475569; border-color: #e2e8f0; }
.ldb-confirm-text { .ldb-confirm-text {
margin: 0 0 8px; margin: 0 0 8px;
@@ -486,19 +416,6 @@
.ldb-action-sep { color: var(--ln-hairline); margin: 0 2px; } .ldb-action-sep { color: var(--ln-hairline); margin: 0 2px; }
.ldb-link-danger { color: var(--ln-error) !important; } .ldb-link-danger { color: var(--ln-error) !important; }
.ldb-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.ldb-table-total {
font-size: 0.875rem;
color: var(--ln-muted);
}
.ldb-table .vm-table thead th.ldb-col-money, .ldb-table .vm-table thead th.ldb-col-money,
.ldb-table .vm-table tbody td.ldb-col-money { .ldb-table .vm-table tbody td.ldb-col-money {
text-align: right; text-align: right;
@@ -571,8 +488,38 @@
.ldb-th-tip { .ldb-th-tip {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
color: var(--ln-muted); align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--ln-muted-soft);
cursor: help; cursor: help;
flex-shrink: 0;
vertical-align: middle;
}
.ldb-th-tip:focus-visible {
outline: 2px solid var(--ln-focus-ring, #32a06e);
outline-offset: 2px;
border-radius: 4px;
}
.ldb-kpi-tooltip--portal {
display: block;
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(280px, calc(100vw - 24px));
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);
text-align: left;
white-space: normal;
pointer-events: none;
} }
.ldb-text-input { .ldb-text-input {
@@ -622,3 +569,167 @@
font-size: 0.8125rem; font-size: 0.8125rem;
text-align: right; text-align: right;
} }
.ldb-col-unreceived-warn {
color: #c2410c;
font-weight: 600;
}
.ldb-cell-multiline {
white-space: pre-line;
line-height: 1.45;
}
.ldb-detail-popover-wrap {
position: relative;
display: inline-flex;
justify-content: flex-end;
width: 100%;
}
.ldb-detail-popover-trigger {
border: none;
background: transparent;
padding: 2px 6px;
margin: -2px -6px;
font: inherit;
color: inherit;
text-align: right;
}
.ldb-cell-clickable {
color: #1d4ed8;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(29, 78, 216, 0.35);
text-underline-offset: 2px;
border-radius: 4px;
transition: color 0.15s ease, background 0.15s ease, text-decoration-color 0.15s ease;
}
.ldb-cell-clickable:hover,
.ldb-cell-clickable[aria-expanded='true'] {
color: #1e40af;
text-decoration-color: #1d4ed8;
background: rgba(37, 99, 235, 0.08);
}
.ldb-detail-popover-trigger:hover,
.ldb-detail-popover-trigger[aria-expanded='true'] {
text-decoration-color: #1d4ed8;
}
.ldb-line-items-trigger--editable {
color: var(--ln-ink);
}
.ldb-detail-popover-card {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 30;
min-width: 320px;
max-width: min(520px, 80vw);
padding: 12px;
border-radius: var(--ln-radius-card);
border: 1px solid var(--ln-hairline);
background: var(--ln-surface-card);
box-shadow: var(--ln-shadow-hover);
}
.ldb-detail-popover-title {
margin-bottom: 8px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--ln-ink);
}
.ldb-detail-popover-body {
max-height: 280px;
overflow: auto;
}
.ldb-detail-popover-footer {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--ln-hairline);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.ldb-detail-popover-empty {
margin: 0;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.ldb-detail-popover-table-wrap {
overflow: auto;
}
.ldb-detail-popover-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.ldb-detail-popover-table th,
.ldb-detail-popover-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--ln-hairline);
text-align: left;
vertical-align: top;
}
.ldb-detail-popover-table th {
font-weight: 600;
color: var(--ln-muted);
white-space: nowrap;
}
.ldb-detail-popover-table .text-right {
text-align: right;
}
.ldb-line-items-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.ldb-line-items-row {
display: grid;
grid-template-columns: 1.2fr 0.8fr 1fr;
gap: 6px;
}
.ldb-line-items-input {
padding: 4px 6px;
font-size: 0.75rem;
}
.ldb-line-items-add {
align-self: flex-start;
padding: 4px 8px;
font-size: 0.75rem;
}
.ldb-action-btn--history {
color: #1d4ed8;
text-decoration: underline;
text-underline-offset: 2px;
}
.ldb-action-btn--history:hover {
color: #1e40af;
background: rgba(37, 99, 235, 0.08);
}
.ldb-action-btn--save {
color: var(--ln-success, #15803d);
}
.ldb-action-btn--save:hover {
color: #166534;
}

View File

@@ -1,16 +1,36 @@
export type LeaseDetailStatus = '草稿' | '已确认' | '部分收款' | '已收款'; export type LeaseDetailStatus = '未收款' | '部分收款' | '已收款';
export type BillArchiveStatus = '已归档' | '未归档' | '草稿';
export type AssetOwnership = '自有' | '租赁' | '挂靠'; export type AssetOwnership = '自有' | '租赁' | '挂靠';
export type ListViewMode = 'all' | 'unpaid'; export type ListViewMode = 'all' | 'unpaid';
export type MaintenanceBearBy = '客户承担' | '我司承担';
export type ContractEndMode = 'term' | 'date';
export interface LedgerLineItem {
reason: string;
amount: number;
remark: string;
}
export interface OperationHistoryEntry {
operatedAt: string;
operatorName: string;
field: string;
before: string;
after: string;
}
export interface LeaseLedgerRow { export interface LeaseLedgerRow {
id: string; id: string;
status: LeaseDetailStatus; status: LeaseDetailStatus;
archiveStatus: BillArchiveStatus;
maintainerName: string; maintainerName: string;
maintainerTime?: string;
year: number; year: number;
month: number; month: number;
dept: string; dept: string;
salesman: string; salesman: string;
paymentDate: string; paymentDueDate?: string;
paymentDate?: string;
plateNo: string; plateNo: string;
vehicleBrand: string; vehicleBrand: string;
vehicleModel: string; vehicleModel: string;
@@ -22,9 +42,12 @@ export interface LeaseLedgerRow {
pickupDate: string; pickupDate: string;
contractStartDate: string; contractStartDate: string;
contractEndDate: string; contractEndDate: string;
contractEndMode?: ContractEndMode;
leaseTermMonths?: number;
periodStartDate: string; periodStartDate: string;
returnDate: string; returnDate: string;
avgDays: number; avgDays: number;
actualLeaseDays?: number;
deposit: string | number; deposit: string | number;
contractRent: number; contractRent: number;
paymentCycleMonths: number; paymentCycleMonths: number;
@@ -32,12 +55,18 @@ export interface LeaseLedgerRow {
maintenancePackageEnabled: boolean; maintenancePackageEnabled: boolean;
maintenanceUnitPricePerKm: number; maintenanceUnitPricePerKm: number;
mileage: number; mileage: number;
mileageDeductionRate?: number;
customerBearsMaintenance: boolean; customerBearsMaintenance: boolean;
linkedMaintenanceIds: string[]; linkedMaintenanceIds: string[];
linkedAccidentIds: string[]; linkedAccidentIds: string[];
linkedReceiptIds: string[]; linkedReceiptIds: string[];
hydrogenBorneBy: '我司承担' | '客户承担' | ''; hydrogenBorneBy: '我司承担' | '客户承担' | '';
isLatestBill: boolean; isLatestBill: boolean;
insurancePurchaseAmount?: number;
insuranceEffectiveDays?: number;
otherIncomeItems?: LedgerLineItem[];
otherDeductionItems?: LedgerLineItem[];
operationHistory?: OperationHistoryEntry[];
receivableTotal: number; receivableTotal: number;
receivableRent: number; receivableRent: number;
monthlyIncome: number; monthlyIncome: number;
@@ -108,6 +137,7 @@ export interface ReceiptRecord {
customerName: string; customerName: string;
amount: number; amount: number;
remark: string; remark: string;
invoiceDate?: string;
} }
export interface BiMileageRecord { export interface BiMileageRecord {
@@ -123,6 +153,12 @@ export interface HydrogenFeeRecord {
month: number; month: number;
amount: number; amount: number;
status: string; status: string;
refuelDate: string;
stationName: string;
volume: number;
unitCost: number;
totalCost: number;
borneBy: '我司承担' | '客户承担';
} }
export interface MaintenanceOrder { export interface MaintenanceOrder {
@@ -130,7 +166,7 @@ export interface MaintenanceOrder {
plateNo: string; plateNo: string;
orderNo: string; orderNo: string;
type: string; type: string;
bearBy: string; bearBy: MaintenanceBearBy;
amount: number; amount: number;
completedAt: string; completedAt: string;
} }

View File

@@ -8,7 +8,9 @@ import type {
LeaseLedgerRow, LeaseLedgerRow,
LeaseKpiSummary, LeaseKpiSummary,
ListViewMode, ListViewMode,
MaintenanceBearBy,
MaintenanceOrder, MaintenanceOrder,
ReceiptRecord,
} from '../types'; } from '../types';
/** 原型:车牌主数据(导入/新增时按车牌自动匹配品牌型号) */ /** 原型:车牌主数据(导入/新增时按车牌自动匹配品牌型号) */
@@ -71,6 +73,18 @@ export const PLATE_STANDARD_COST: Record<string, number> = {
沪A50990F: 3900, 沪A50990F: 3900,
}; };
/** 原型:车牌增值服务(签订合同时勾选,一行一项) */
export const PLATE_VALUE_ADDED: Record<string, string[]> = {
A32896F: ['GPS 定位服务', '商业险代办'],
A61121F: ['ETC 办卡'],
A69695F: ['GPS 定位服务'],
AGF4535: ['氢气卡充值代办'],
A65889F: ['GPS 定位服务', '年检代办'],
F08991F: ['GPS 定位服务', 'ETC 办卡', '商业险代办'],
F05580F: ['GPS 定位服务'],
A50990F: ['ETC 办卡', '商业险代办'],
};
export const HYDROGEN_FEE_HEADER_TIP = '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)'; export const HYDROGEN_FEE_HEADER_TIP = '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)';
/** 资产归属类型(内部) */ /** 资产归属类型(内部) */
@@ -87,6 +101,8 @@ export const PLATE_ASSET_OWNERSHIP: Record<string, AssetOwnership> = {
export const IS_SUPERVISOR = false; export const IS_SUPERVISOR = false;
export const CURRENT_STAFF_USER = '刘念念'; export const CURRENT_STAFF_USER = '刘念念';
export const DEFAULT_MAINTAINER_TIME = '2026-01-10 14:30';
export const DEFAULT_MILEAGE_DEDUCTION_RATE = 0.5;
export const LOAD_BATCH_SIZE = 20; export const LOAD_BATCH_SIZE = 20;
@@ -104,6 +120,150 @@ export function isBlankDisplayValue(value: unknown): boolean {
|| value === '/'; || value === '/';
} }
export function formatBillingYearMonth(
year: number | null | undefined,
month: number | null | undefined,
): string {
if (year == null || month == null) return '';
return `${year}-${String(month).padStart(2, '0')}`;
}
function parseDateOnly(raw: string): Date | null {
if (isBlankDisplayValue(raw)) return null;
const datePart = String(raw).trim().split(' ')[0];
const parts = datePart.split('-');
if (parts.length < 3) return null;
const date = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDateOnly(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate();
}
function addMonths(date: Date, months: number): Date {
const next = new Date(date);
next.setMonth(next.getMonth() + months);
return next;
}
function isInBillMonth(dateText: string, year: number, month: number): boolean {
const date = parseDateOnly(dateText);
if (!date) return false;
return date.getFullYear() === year && date.getMonth() + 1 === month;
}
function roundMoney(value: number): number {
return Math.round(value * 100) / 100;
}
function sumLineItems(items: { amount: number }[] | undefined): number {
return roundMoney((items ?? []).reduce((sum, item) => sum + item.amount, 0));
}
/** 账单月内租赁起始日:月前已提车取当月 1 日,月内提车取提车日 */
export function calcPeriodStartDate(
row: Pick<LeaseLedgerRow, 'year' | 'month' | 'pickupDate'>,
): string {
if (!row.year || !row.month) return '';
const monthStart = `${row.year}-${String(row.month).padStart(2, '0')}-01`;
const pickupDate = parseDateOnly(row.pickupDate);
if (!pickupDate) return monthStart;
const billMonthStart = new Date(row.year, row.month - 1, 1);
const billMonthEnd = new Date(row.year, row.month, 0);
if (pickupDate > billMonthEnd) return '';
if (pickupDate <= billMonthStart) return monthStart;
return row.pickupDate.split(' ')[0];
}
/** 合同到期日:按租期月数自提车日起算,或取已存储到期日 */
export function calcContractEndDate(
row: Pick<LeaseLedgerRow, 'pickupDate' | 'contractEndDate' | 'contractEndMode' | 'leaseTermMonths'>,
): string {
if (row.contractEndMode === 'date' && !isBlankDisplayValue(row.contractEndDate)) {
return row.contractEndDate.split(' ')[0];
}
if (row.leaseTermMonths && !isBlankDisplayValue(row.pickupDate)) {
const pickupDate = parseDateOnly(row.pickupDate);
if (pickupDate) {
return formatDateOnly(addMonths(pickupDate, row.leaseTermMonths));
}
}
if (!isBlankDisplayValue(row.contractEndDate)) {
return row.contractEndDate.split(' ')[0];
}
return '';
}
/** 账单月内租赁天数(用于成本分摊与平均天数) */
export function calcLeaseDaysInMonth(
row: Pick<LeaseLedgerRow, 'year' | 'month' | 'pickupDate' | 'returnDate' | 'periodStartDate'>,
): number {
if (!row.year || !row.month) return 0;
const daysInMonth = getDaysInMonth(row.year, row.month);
const periodStartText = row.periodStartDate || calcPeriodStartDate(row);
const periodStart = parseDateOnly(periodStartText);
if (!periodStart) return 0;
const monthEnd = new Date(row.year, row.month, 0);
let periodEnd = monthEnd;
const returnDate = parseDateOnly(row.returnDate);
if (returnDate) {
if (returnDate < periodStart) return 0;
if (returnDate < periodEnd) periodEnd = returnDate;
}
const diffDays = Math.floor((periodEnd.getTime() - periodStart.getTime()) / 86400000) + 1;
return Math.max(0, Math.min(diffDays, daysInMonth));
}
/** 平均天数 = 账单月租赁天数 / 当月自然日天数 */
export function calcAvgDays(leaseDaysInMonth: number, year: number, month: number): number {
const daysInMonth = getDaysInMonth(year, month);
if (!daysInMonth) return 0;
return roundMoney(leaseDaysInMonth / daysInMonth);
}
/** 交车后设置的开始计费日期:优先取账单起始日期,否则取提车日期 */
export function resolveBillingStartDate(
row: Pick<LeaseLedgerRow, 'periodStartDate' | 'pickupDate'>,
): string {
if (row.periodStartDate && !isBlankDisplayValue(row.periodStartDate)) return row.periodStartDate;
if (row.pickupDate && !isBlankDisplayValue(row.pickupDate)) return row.pickupDate;
return '';
}
/** 应付款日:首期取开始计费日,后续每期取账单所属自然月 5 日 */
export function calcPaymentDueDate(
row: Pick<LeaseLedgerRow, 'year' | 'month' | 'periodStartDate' | 'pickupDate'>,
): string {
if (!row.year || !row.month) return '';
const billingStart = resolveBillingStartDate(row);
const billYm = row.year * 100 + row.month;
const startDate = parseDateOnly(billingStart);
if (startDate) {
const startYm = startDate.getFullYear() * 100 + (startDate.getMonth() + 1);
if (billYm === startYm) {
return billingStart.split(' ')[0];
}
}
return `${row.year}-${String(row.month).padStart(2, '0')}-05`;
}
export function resolveLinkedPaymentDate(
row: Pick<LeaseLedgerRow, 'actualPaymentDate' | 'linkedReceiptIds' | 'receivedAmount'>,
): string {
const hasReceipt = row.receivedAmount > 0
&& ((row.linkedReceiptIds?.length ?? 0) > 0 || !isBlankDisplayValue(row.actualPaymentDate));
if (!hasReceipt || isBlankDisplayValue(row.actualPaymentDate)) return '';
return extractReceiptPaymentDate(row.actualPaymentDate);
}
export function displayText(value: unknown): string { export function displayText(value: unknown): string {
if (isBlankDisplayValue(value)) return ''; if (isBlankDisplayValue(value)) return '';
return String(value); return String(value);
@@ -116,7 +276,7 @@ export function displayMoney(value: number | null | undefined, treatZeroAsEmpty
} }
export function calcUnreceived(row: Pick<LeaseLedgerRow, 'receivableTotal' | 'receivedAmount'>): number { export function calcUnreceived(row: Pick<LeaseLedgerRow, 'receivableTotal' | 'receivedAmount'>): number {
return Math.round((row.receivableTotal - row.receivedAmount) * 100) / 100; return roundMoney(row.receivableTotal - row.receivedAmount);
} }
export function isUnpaidRow(row: LeaseLedgerRow): boolean { export function isUnpaidRow(row: LeaseLedgerRow): boolean {
@@ -138,25 +298,49 @@ export function calcMonthlyRent(contractRent: number): number {
} }
export function calcReceivableRent(contractRent: number, cycleMonths: number): number { export function calcReceivableRent(contractRent: number, cycleMonths: number): number {
return Math.round(contractRent * cycleMonths * 100) / 100; return roundMoney(contractRent * cycleMonths);
} }
export function resolveStatusAfterReceipt( /** 根据收款分配金额与应收合计计算账单收款状态 */
export function resolvePaymentStatus(
receivableTotal: number, receivableTotal: number,
receivedAmount: number, receivedAmount: number,
): LeaseDetailStatus { ): LeaseDetailStatus {
if (receivedAmount >= receivableTotal) return '已收款'; if (receivedAmount >= receivableTotal && receivableTotal > 0) return '已收款';
if (receivedAmount > 0) return '部分收款'; if (receivedAmount > 0) return '部分收款';
return '已确认'; return '未收款';
} }
/** @deprecated 使用 resolvePaymentStatus */
export const resolveStatusAfterReceipt = resolvePaymentStatus;
export function filterRecordsByRole(rows: LeaseLedgerRow[]): LeaseLedgerRow[] { export function filterRecordsByRole(rows: LeaseLedgerRow[]): LeaseLedgerRow[] {
if (IS_SUPERVISOR) return rows; if (IS_SUPERVISOR) return rows;
return rows.filter((row) => row.maintainerName === CURRENT_STAFF_USER); return rows.filter((row) => row.maintainerName === CURRENT_STAFF_USER);
} }
export function calcVehicleActualCost(standardCost: number, actualDays: number): number { export function calcVehicleActualCost(standardCost: number, leaseDaysInMonth: number): number {
return Math.round(standardCost * (actualDays / 30) * 100) / 100; return roundMoney(standardCost * (leaseDaysInMonth / 30));
}
export function calcInsuranceFee(
purchaseAmount: number | undefined,
effectiveDays: number | undefined,
leaseDaysInMonth: number,
): number {
if (!purchaseAmount || !effectiveDays || effectiveDays <= 0 || leaseDaysInMonth <= 0) return 0;
return roundMoney((purchaseAmount / effectiveDays) * leaseDaysInMonth);
}
export function calcMileageDeduction(
gpsMileage: string,
rate: number | undefined,
): number {
if (isBlankDisplayValue(gpsMileage)) return 0;
const mileage = Number(gpsMileage);
if (!Number.isFinite(mileage) || mileage <= 0) return 0;
const deductionRate = rate ?? DEFAULT_MILEAGE_DEDUCTION_RATE;
return roundMoney(mileage * deductionRate);
} }
export function lookupBiMileage( export function lookupBiMileage(
@@ -169,14 +353,77 @@ export function lookupBiMileage(
return hit ? String(hit.mileageKm) : ''; return hit ? String(hit.mileageKm) : '';
} }
export function getMaintenanceOrdersForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
orders: MaintenanceOrder[],
bearBy?: MaintenanceBearBy,
): MaintenanceOrder[] {
return orders.filter(
(item) => item.plateNo === row.plateNo
&& isInBillMonth(item.completedAt, row.year, row.month)
&& (!bearBy || item.bearBy === bearBy),
);
}
export function sumMaintenanceForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
orders: MaintenanceOrder[],
bearBy: MaintenanceBearBy,
): number {
return roundMoney(
getMaintenanceOrdersForBill(row, orders, bearBy)
.reduce((sum, item) => sum + item.amount, 0),
);
}
export function getHydrogenFeesForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
records: HydrogenFeeRecord[],
): HydrogenFeeRecord[] {
return records.filter(
(item) => item.plateNo === row.plateNo
&& item.year === row.year
&& item.month === row.month
&& item.borneBy === '我司承担',
);
}
export function sumHydrogenFeeForBill( export function sumHydrogenFeeForBill(
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month' | 'hydrogenBorneBy'>, row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
records: HydrogenFeeRecord[], records: HydrogenFeeRecord[],
): number { ): number {
if (row.hydrogenBorneBy !== '我司承担') return 0; return roundMoney(
return records getHydrogenFeesForBill(row, records)
.filter((item) => item.plateNo === row.plateNo && item.year === row.year && item.month === row.month) .reduce((sum, item) => sum + (item.totalCost ?? item.amount), 0),
.reduce((sum, item) => sum + item.amount, 0); );
}
export function getLinkedReceiptsForRow(
row: Pick<LeaseLedgerRow, 'linkedReceiptIds'>,
receipts: ReceiptRecord[],
): ReceiptRecord[] {
const linkedIds = row.linkedReceiptIds ?? [];
return receipts.filter((item) => linkedIds.includes(item.id));
}
export function resolveInvoiceDateFromReceipts(
row: Pick<LeaseLedgerRow, 'invoiceDate' | 'linkedReceiptIds'>,
receipts: ReceiptRecord[],
): string {
const linked = getLinkedReceiptsForRow(row, receipts);
if (linked.length === 0) {
return isBlankDisplayValue(row.invoiceDate) ? '' : row.invoiceDate.split(' ')[0];
}
const dates = linked
.map((item) => item.invoiceDate || extractReceiptPaymentDate(item.arrivalTime))
.filter(Boolean)
.sort();
return dates[dates.length - 1] ?? '';
}
export function resolveValueAddedService(plateNo: string): string {
const services = PLATE_VALUE_ADDED[plateNo] ?? [];
return services.join('\n');
} }
export function calcReceivedAmountFromReceipts( export function calcReceivedAmountFromReceipts(
@@ -187,18 +434,47 @@ export function calcReceivedAmountFromReceipts(
const total = receipts const total = receipts
.filter((item) => linkedIds.includes(item.id)) .filter((item) => linkedIds.includes(item.id))
.reduce((sum, item) => sum + item.amount, 0); .reduce((sum, item) => sum + item.amount, 0);
return Math.min(Math.round(total * 100) / 100, receivableTotal); return Math.min(roundMoney(total), receivableTotal);
} }
export function calcMaintenanceIncome(row: Pick<LeaseLedgerRow, 'maintenancePackageEnabled' | 'maintenanceUnitPricePerKm' | 'mileage'>): number { export function calcMaintenanceIncome(
row: Pick<LeaseLedgerRow, 'maintenancePackageEnabled' | 'maintenanceUnitPricePerKm' | 'mileage'>,
): number {
if (!row.maintenancePackageEnabled) return 0; if (!row.maintenancePackageEnabled) return 0;
return Math.round(row.maintenanceUnitPricePerKm * row.mileage * 100) / 100; return roundMoney(row.maintenanceUnitPricePerKm * row.mileage);
} }
export function calcMonthlyIncome( export function calcMonthlyIncome(
row: Pick<LeaseLedgerRow, 'monthlyRent' | 'serviceFee' | 'mileageDeduction' | 'otherDeduction'>, row: Pick<
LeaseLedgerRow,
'monthlyRent' | 'maintenanceIncome' | 'insuranceSurcharge' | 'opsFeeIncome' | 'otherIncome' | 'mileageDeduction' | 'otherDeduction'
>,
): number { ): number {
return Math.round((row.monthlyRent + row.serviceFee - row.mileageDeduction - row.otherDeduction) * 100) / 100; return roundMoney(
row.monthlyRent
+ row.maintenanceIncome
+ row.insuranceSurcharge
+ row.opsFeeIncome
+ row.otherIncome
- row.mileageDeduction
- row.otherDeduction,
);
}
export function calcReceivableTotal(
row: Pick<
LeaseLedgerRow,
'receivableRent' | 'insuranceSurcharge' | 'opsFeeIncome' | 'otherIncome' | 'mileageDeduction' | 'otherDeduction'
>,
): number {
return roundMoney(
row.receivableRent
+ row.insuranceSurcharge
+ row.opsFeeIncome
+ row.otherIncome
- row.mileageDeduction
- row.otherDeduction,
);
} }
export function sumLinkedMaintenanceAmount( export function sumLinkedMaintenanceAmount(
@@ -225,7 +501,7 @@ export function needsMaintenanceLinkWarning(
): boolean { ): boolean {
if (!row.isLatestBill || !row.customerBearsMaintenance) return false; if (!row.isLatestBill || !row.customerBearsMaintenance) return false;
if (row.linkedMaintenanceIds.length > 0) return false; if (row.linkedMaintenanceIds.length > 0) return false;
return orders.some((item) => item.plateNo === row.plateNo && item.bearBy === '客户承担'); return getMaintenanceOrdersForBill(row, orders, '客户承担').length > 0;
} }
export function needsAccidentLinkWarning( export function needsAccidentLinkWarning(
@@ -244,6 +520,7 @@ export function enrichLeaseRow(
accidentRecords?: AccidentRecord[]; accidentRecords?: AccidentRecord[];
biMileage?: BiMileageRecord[]; biMileage?: BiMileageRecord[];
hydrogenFees?: HydrogenFeeRecord[]; hydrogenFees?: HydrogenFeeRecord[];
receipts?: ReceiptRecord[];
}, },
): LeaseLedgerRow { ): LeaseLedgerRow {
const vehicle = PLATE_VEHICLE_MAP[row.plateNo]; const vehicle = PLATE_VEHICLE_MAP[row.plateNo];
@@ -266,35 +543,79 @@ export function enrichLeaseRow(
const contractRent = row.contractRent; const contractRent = row.contractRent;
const monthlyRent = calcMonthlyRent(contractRent); const monthlyRent = calcMonthlyRent(contractRent);
const receivableRent = calcReceivableRent(contractRent, cycleMonths); const receivableRent = calcReceivableRent(contractRent, cycleMonths);
const hasReceipt = row.receivedAmount > 0 && row.actualPaymentDate; const linkedReceiptIds = row.linkedReceiptIds ?? [];
const hasReceipt = row.receivedAmount > 0
&& (linkedReceiptIds.length > 0 || !isBlankDisplayValue(row.actualPaymentDate));
const maintenanceOrders = options?.maintenanceOrders ?? []; const maintenanceOrders = options?.maintenanceOrders ?? [];
const accidentRecords = options?.accidentRecords ?? []; const accidentRecords = options?.accidentRecords ?? [];
const biMileage = options?.biMileage ?? []; const biMileage = options?.biMileage ?? [];
const hydrogenFees = options?.hydrogenFees ?? []; const hydrogenFees = options?.hydrogenFees ?? [];
const linkedMaintenanceIds = row.linkedMaintenanceIds ?? []; const receipts = options?.receipts ?? [];
const linkedAccidentIds = row.linkedAccidentIds ?? []; const linkedAccidentIds = row.linkedAccidentIds ?? [];
const linkedReceiptIds = row.linkedReceiptIds ?? [];
const periodStartDate = calcPeriodStartDate(row);
const contractStartDate = isBlankDisplayValue(row.pickupDate) ? '' : row.pickupDate.split(' ')[0];
const contractEndDate = calcContractEndDate(row);
const returnDate = isBlankDisplayValue(row.returnDate) ? '' : row.returnDate.split(' ')[0];
const actualLeaseDays = calcLeaseDaysInMonth({ ...row, periodStartDate });
const avgDays = calcAvgDays(actualLeaseDays, row.year, row.month);
const paymentDueDate = calcPaymentDueDate({ ...row, periodStartDate });
const gpsMileage = lookupBiMileage(row.plateNo, row.year, row.month, biMileage) || row.gpsMileage;
const mileageDeductionRate = row.mileageDeductionRate;
const mileageDeduction = calcMileageDeduction(gpsMileage, mileageDeductionRate);
const otherIncome = sumLineItems(row.otherIncomeItems) || row.otherIncome || 0;
const otherDeduction = sumLineItems(row.otherDeductionItems) || row.otherDeduction || 0;
const maintenanceIncome = calcMaintenanceIncome(row); const maintenanceIncome = calcMaintenanceIncome(row);
const opsFeeIncome = sumLinkedMaintenanceAmount(linkedMaintenanceIds, maintenanceOrders); const opsFeeIncome = sumMaintenanceForBill(row, maintenanceOrders, '客户承担');
const opsFeeCost = opsFeeIncome; const opsFeeCost = sumMaintenanceForBill(row, maintenanceOrders, '我司承担');
const insuranceSurcharge = linkedAccidentIds.length > 0 const insuranceSurcharge = linkedAccidentIds.length > 0
? sumLinkedAccidentSurcharge(linkedAccidentIds, accidentRecords) ? roundMoney(sumLinkedAccidentSurcharge(linkedAccidentIds, accidentRecords))
: row.insuranceSurcharge; : row.insuranceSurcharge;
const serviceFee = row.serviceFee ?? 0;
const monthlyIncome = calcMonthlyIncome({ const monthlyIncome = calcMonthlyIncome({
monthlyRent, monthlyRent,
serviceFee, maintenanceIncome,
mileageDeduction: row.mileageDeduction, insuranceSurcharge,
otherDeduction: row.otherDeduction, opsFeeIncome,
otherIncome,
mileageDeduction,
otherDeduction,
}); });
const receivableTotal = calcReceivableTotal({
receivableRent,
insuranceSurcharge,
opsFeeIncome,
otherIncome,
mileageDeduction,
otherDeduction,
});
const vehicleStandardCost = PLATE_STANDARD_COST[row.plateNo] ?? row.vehicleStandardCost; const vehicleStandardCost = PLATE_STANDARD_COST[row.plateNo] ?? row.vehicleStandardCost;
const vehicleActualCost = calcVehicleActualCost(vehicleStandardCost, row.avgDays || 30); const vehicleActualCost = calcVehicleActualCost(vehicleStandardCost, actualLeaseDays);
const gpsMileage = lookupBiMileage(row.plateNo, row.year, row.month, biMileage) || row.gpsMileage; const hydrogenFee = row.hydrogenBorneBy === '我司承担'
const hydrogenFeeSum = sumHydrogenFeeForBill(row, hydrogenFees); ? sumHydrogenFeeForBill(row, hydrogenFees)
const hydrogenFee = row.hydrogenBorneBy === '我司承担' ? hydrogenFeeSum : row.hydrogenFee; : 0;
const insuranceFee = calcInsuranceFee(
row.insurancePurchaseAmount,
row.insuranceEffectiveDays,
actualLeaseDays,
);
const invoiceDate = resolveInvoiceDateFromReceipts(row, receipts);
const valueAddedService = resolveValueAddedService(row.plateNo);
const maintainerTime = row.maintainerTime || DEFAULT_MAINTAINER_TIME;
const signingCompany = PLATE_SIGNING_COMPANY[row.plateNo] || row.signingCompany; const signingCompany = PLATE_SIGNING_COMPANY[row.plateNo] || row.signingCompany;
const assetOwner = PLATE_ASSET_OWNER[row.plateNo] || row.assetOwner || ''; const assetOwner = PLATE_ASSET_OWNER[row.plateNo] || row.assetOwner || '';
const brokerageFee = row.brokerageFee ?? 0;
const otherCost = row.otherCost ?? 0;
const totalCost = roundMoney(
vehicleActualCost + insuranceFee + hydrogenFee + opsFeeCost + brokerageFee + otherCost,
);
const profitLoss = roundMoney(monthlyIncome - totalCost);
const receivedAmount = row.receivedAmount ?? 0;
const status = resolvePaymentStatus(receivableTotal, receivedAmount);
return { return {
...row, ...row,
vehicleBrand: brand || vehicle?.brand || '', vehicleBrand: brand || vehicle?.brand || '',
@@ -303,35 +624,53 @@ export function enrichLeaseRow(
projectName: row.projectName || contract?.projectName || '', projectName: row.projectName || contract?.projectName || '',
paymentCycleMonths: cycleMonths, paymentCycleMonths: cycleMonths,
paymentMethod: resolvePaymentMethod(cycleMonths), paymentMethod: resolvePaymentMethod(cycleMonths),
linkedReceiptIds,
linkedAccidentIds,
periodStartDate,
contractStartDate,
contractEndDate,
returnDate,
actualLeaseDays,
avgDays,
monthlyRent, monthlyRent,
receivableRent, receivableRent,
serviceFee,
linkedMaintenanceIds,
linkedAccidentIds,
linkedReceiptIds,
maintenanceIncome, maintenanceIncome,
opsFeeIncome, opsFeeIncome,
opsFeeCost, opsFeeCost,
insuranceSurcharge, insuranceSurcharge,
otherIncome,
mileageDeduction,
otherDeduction,
monthlyIncome, monthlyIncome,
receivableTotal,
vehicleStandardCost, vehicleStandardCost,
vehicleActualCost, vehicleActualCost,
gpsMileage, gpsMileage,
hydrogenFee, hydrogenFee,
insuranceFee,
invoiceDate,
valueAddedService,
maintainerTime,
paymentDueDate,
paymentDate: '',
totalCost,
profitLoss,
signingCompany, signingCompany,
assetOwner, assetOwner,
assetOwnership: row.assetOwnership || PLATE_ASSET_OWNERSHIP[row.plateNo] || '自有', assetOwnership: row.assetOwnership || PLATE_ASSET_OWNERSHIP[row.plateNo] || '自有',
actualPaymentDate: hasReceipt ? row.actualPaymentDate : '', actualPaymentDate: hasReceipt ? row.actualPaymentDate : '',
receivedAmount,
status,
archiveStatus: row.archiveStatus ?? '未归档',
}; };
} }
export function canStaffEditRow(row: LeaseLedgerRow): boolean { export function canStaffEditRow(row: LeaseLedgerRow): boolean {
if (row.status === '草稿') return true; return row.archiveStatus === '草稿';
return IS_SUPERVISOR;
} }
export function canStaffDeleteRow(row: LeaseLedgerRow): boolean { export function canStaffDeleteRow(row: LeaseLedgerRow): boolean {
return canStaffEditRow(row); return row.status === '未收款';
} }
export function sumLeaseKpi(rows: LeaseLedgerRow[]): LeaseKpiSummary { export function sumLeaseKpi(rows: LeaseLedgerRow[]): LeaseKpiSummary {
@@ -357,8 +696,8 @@ export function applyLeaseFilters(rows: LeaseLedgerRow[], filters: LeaseLedgerFi
if (filters.plateNo && row.plateNo !== filters.plateNo) return false; if (filters.plateNo && row.plateNo !== filters.plateNo) return false;
if (filters.status && row.status !== filters.status) return false; if (filters.status && row.status !== filters.status) return false;
if (filters.customerName && !row.customerName.includes(filters.customerName)) return false; if (filters.customerName && !row.customerName.includes(filters.customerName)) return false;
if (filters.paymentFrom && row.paymentDate < filters.paymentFrom) return false; if (filters.paymentFrom && (row.paymentDueDate ?? '') < filters.paymentFrom) return false;
if (filters.paymentTo && row.paymentDate > filters.paymentTo) return false; if (filters.paymentTo && (row.paymentDueDate ?? '') > filters.paymentTo) return false;
return true; return true;
}); });
} }

View File

@@ -0,0 +1,328 @@
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": {
"version": 2,
"prototypeName": "lease-business-line-overview",
"pageId": "overview",
"updatedAt": 1783328981487,
"nodes": [
{
"id": "lblo-system-intro",
"index": 1,
"title": "系统介绍",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-system-intro\"]"
],
"fingerprint": "lblo-system-intro",
"path": []
},
"aiPrompt": "ONE-OS 系统介绍、五大业务条线与三大数据底座总览。",
"annotationText": "1 套系统、5 大业务条线闭环 + 3 大数据底座;核心演进为业务闭环基础上打通财务系统,实现业财一体。",
"hasMarkdown": false,
"color": "#60a5fa",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "lblo-system-evolution",
"index": 2,
"title": "业财一体演进",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-system-evolution\"]"
],
"fingerprint": "lblo-system-evolution",
"path": []
},
"aiPrompt": "从业务驱动财务手工维护到业财系统联动一体化。",
"annotationText": "现阶段业务闭环内财务多手工维护;演进为打通财务系统,收付款与业务台账自动联动。",
"hasMarkdown": false,
"color": "#2dd4bf",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "lblo-line-switcher",
"index": 3,
"title": "五大业务条线",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-line-switcher\"]"
],
"fingerprint": "lblo-line-switcher",
"path": []
},
"aiPrompt": "租赁、能源、运维、安全、自营五条业务条线切换。",
"annotationText": "在系统介绍区内切换不同业务条线,查看对应模块说明与闭环路径;五条条线内容均已填充。",
"hasMarkdown": false,
"color": "#60a5fa",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "lblo-evolution",
"index": 4,
"title": "条线演进路径",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-evolution\"]"
],
"fingerprint": "lblo-evolution",
"path": []
},
"aiPrompt": "各业务条线现阶段与演进方向说明。",
"annotationText": "租赁:合同电子化到商城直连;能源:手工维护到加氢站主动上报;运维:使用环节到入库全链路;安全:司机培训到违章事故联动;自营:人工核算到业财闭环。",
"hasMarkdown": false,
"color": "#2dd4bf",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "lblo-hero",
"index": 5,
"title": "页面标题区",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-hero\"]"
],
"fingerprint": "lblo-hero",
"path": []
},
"aiPrompt": "当前条线标题与总述。",
"annotationText": "随条线切换展示对应说明。",
"hasMarkdown": false,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "lblo-nav",
"index": 6,
"title": "模块导航",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-nav\"]"
],
"fingerprint": "lblo-nav",
"path": []
},
"aiPrompt": "模块锚点跳转。",
"annotationText": "点击可跳转到对应模块卡片。",
"hasMarkdown": false,
"color": "#64748b",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "lblo-full-loop",
"index": 7,
"title": "全链路闭环",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-full-loop\"]"
],
"fingerprint": "lblo-full-loop",
"path": []
},
"aiPrompt": "条线闭环总结。",
"annotationText": "当前条线的端到端闭环说明。",
"hasMarkdown": false,
"color": "#059669",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "lblo-data-foundations",
"index": 8,
"title": "三大数据底座",
"pageId": "overview",
"locator": {
"selectors": [
"[data-annotation-id=\"lblo-data-foundations\"]"
],
"fingerprint": "lblo-data-foundations",
"path": []
},
"aiPrompt": "车辆资产、条线盈亏、里程调度三类决策数据底座。",
"annotationText": "五条条线数据向上汇聚;里程调度聚焦补贴里程与客户里程履约,支撑智能调度与人工干预。",
"hasMarkdown": false,
"color": "#2dd4bf",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
}
]
},
"markdownMap": {},
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "folder",
"id": "nav-folder-1782874576229-r8atbu",
"title": "OneOS",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-workbench",
"title": "工作台",
"href": "/prototypes/oneos-web-workbench",
"target": "self"
}
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明(当前)",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-login",
"title": "登录",
"href": "/prototypes/oneos-web-login",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-vehicle-asset",
"title": "车辆管理",
"href": "/prototypes/oneos-web-vehicle-asset",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-contract-template",
"title": "合同模板管理",
"href": "/prototypes/oneos-web-contract-template",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-help-center",
"title": "帮助中心",
"href": "/prototypes/oneos-web-help-center",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-finance",
"title": "财务管理",
"href": "/prototypes/oneos-web-finance",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-procurement",
"title": "采购管理",
"href": "/prototypes/oneos-web-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-lease-contract",
"title": "车辆租赁合同",
"href": "/prototypes/oneos-web-lease-contract",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-h2-station",
"title": "加氢站管理",
"href": "/prototypes/oneos-web-h2-station",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-data-analysis",
"title": "数据分析",
"href": "/prototypes/oneos-web-data-analysis",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ledger-data",
"title": "台账数据",
"href": "/prototypes/oneos-web-ledger-data",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-business",
"title": "业务管理",
"href": "/prototypes/oneos-web-business",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ops",
"title": "运维管理",
"href": "/prototypes/oneos-web-ops",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-requirements",
"title": "需求说明",
"href": "/prototypes/oneos-web-requirements",
"target": "self"
}
]
}
]
},
{
"type": "folder",
"id": "lblo-doc-root",
"title": "业务条线说明",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "lblo-doc-overview",
"title": "模块总览",
"markdown": "# 业务条线说明\n\n系统介绍含「业财一体演进」现阶段业务驱动、财务手工维护 → 打通财务系统、业财联动一体化。另含 5 大业务条线闭环与 3 大数据底座。"
},
{
"type": "link",
"id": "lblo-link-self",
"title": "业务条线说明原型",
"href": "./index.html",
"target": "self"
}
]
}
]
}
}

View File

@@ -0,0 +1,102 @@
import type { LucideIcon } from 'lucide-react';
import { BarChart3, Car, Route } from 'lucide-react';
import type { ModuleAccent } from './lines';
export interface DataFoundationPillar {
id: string;
title: string;
subtitle: string;
accent: ModuleAccent;
icon: LucideIcon;
summary: string;
indicators: string[];
decisions: string[];
sources: string;
prototypeHref?: string;
prototypeLabel?: string;
}
export const DATA_FOUNDATION_INTRO = {
title: '三大数据底座 · 支持运营决策',
tagline: '五条条线数据汇聚,支撑董事长与公司管理层看全局、定方向',
summary:
'租赁、能源、运维、安全、自营五条业务条线在日常运营中产生台账、交还车、氢费、事故违章等结构化数据。三大数据底座将这些数据向上汇总为「资产—盈亏—里程履约」三类管理视图,服务战略投入、资源调配与风险督办。',
};
export const DATA_FOUNDATION_PILLARS: DataFoundationPillar[] = [
{
id: 'asset-ops',
title: '车辆资产运营情况',
subtitle: '看规模 · 看结构 · 看健康度',
accent: 'blue',
icon: Car,
summary:
'从公司视角总览氢能车资产盘子:多少在跑、多少闲置、多少存在合规风险;并提供各品牌型号车辆出勤率分析,出勤率可进一步下钻至省、市层级,支撑「该不该买、该不该卖、该不该调」的资产决策。',
indicators: [
'各品牌型号车辆出勤率分析,支持从汇总下钻至省、市层级',
'营运 / 库存 / 退出运营车辆规模及租赁、自营结构占比',
'区域运力分布、在线率与证照 / 保险异常预警',
'交还车在途、备车就绪与整备完成率',
'从验车入库到处置出库的全生命周期状态分布',
],
decisions: [
'资产利用率是否达标,闲置车辆是否处置或转自营',
'哪些品牌型号、哪些省市出勤率偏低,是否调拨运力或调整投放',
'哪些区域运力过剩 / 不足,是否启动调拨或增购',
'合规风险车辆是否暂停交车、优先整备或续保',
],
sources: '汇聚运维条线(入库—交还—出库)、车辆管理、证照 / 保险、交车 / 备车任务等数据。',
prototypeHref: '/prototypes/vehicle-management',
prototypeLabel: '打开车辆管理',
},
{
id: 'line-pnl',
title: '各业务条线盈亏情况',
subtitle: '看收入 · 看成本 · 看利润',
accent: 'teal',
icon: BarChart3,
summary:
'横向对比租赁、自营、能源及运维 / 安全衍生成本对利润的影响,从条线、项目、单车多层级看清「赚在哪里、亏在哪里」。',
indicators: [
'租赁:应收 / 实收 / 未收、成本与单车 / 项目盈亏',
'自营:项目盈亏表、司机成本与车辆成本占比',
'能源:客户氢费应收、站点与客户余额、对账与收款完成率',
'运维 / 安全:维保、事故赔付、违章代处理等对利润侵蚀',
'业务部 / 公司级汇总利润与同比、环比变化',
],
decisions: [
'哪些条线、项目持续亏损,是否收缩、调价或更换运力',
'未收账款与业财闭环缺口是否需专项督办',
'能源、维保、事故等成本是否异常抬升,需否管理介入',
],
sources: '汇聚租赁业务台账、自营业务台账、氢费账户、收款记录、维修 / 事故 / 违章等业财数据。',
prototypeHref: '/prototypes/business-dept-ledger',
prototypeLabel: '打开业务部台账',
},
{
id: 'mileage-dispatch',
title: '里程调度情况',
subtitle: '里程补贴 · 客户履约 · 智能干预',
accent: 'violet',
icon: Route,
summary:
'聚焦「车辆里程补贴」与「客户里程要求」两个层面基于车机、GPS、人工三类里程数据分析履约进度提前识别无法完成里程的车辆通过与已完成里程任务的客户车辆进行替换确保双方均达标仍无法完成的由运维人工干预处理。',
indicators: [
'车辆里程补贴:单车 / 批次是否达到补贴最小里程数及完成进度',
'客户里程要求:合同 / 项目约定里程的累计完成率与剩余任务量',
'里程数据融合车机、GPS、人工来源的里程对比与可信度标注',
'履约预警:预判无法完成里程任务的车辆与客户清单',
'替换调度:滞后车辆与已完成任务车辆的配对替换及双方履约结果跟踪',
'运维干预:人工处理记录(专项调度、里程补录等)及闭环情况',
],
decisions: [
'判定车辆已无法完成里程时,是否与已完成里程任务的其他客户车辆进行替换',
'替换配对如何安排,确保双方客户里程任务均能顺利达标',
'替换后仍无法完成时,是否启动运维人工干预(补录、专项调度等)',
'车机 / GPS / 人工里程差异过大时,是否复核数据或更换里程认定来源',
],
sources: '汇聚车机里程、GPS 轨迹、人工补录里程,并结合客户里程约定、车辆补贴规则、替换车调度及运维干预记录等数据。',
prototypeHref: '/prototypes/oneos-web-data-analysis',
prototypeLabel: '打开数据分析',
},
];

View File

@@ -0,0 +1,90 @@
import { useCallback, useEffect, useState } from 'react';
/**
* 根据滚动位置判定当前模块,避免 IntersectionObserver 在多段内容重叠时乱跳。
* offset 与模块 scroll-margin-top / 顶栏高度对齐。
*/
export function useScrollSpy(sectionIds: string[], offset = 80) {
const [activeId, setActiveId] = useState(sectionIds[0] ?? '');
useEffect(() => {
if (!sectionIds.length) return undefined;
let frame = 0;
const resolveActiveId = () => {
let current = sectionIds[0];
for (const id of sectionIds) {
const el = document.getElementById(id);
if (!el) continue;
if (el.getBoundingClientRect().top <= offset) {
current = id;
}
}
setActiveId((prev) => (prev === current ? prev : current));
};
const scheduleUpdate = () => {
if (frame) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
resolveActiveId();
});
};
resolveActiveId();
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
return () => {
window.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
if (frame) window.cancelAnimationFrame(frame);
};
}, [offset, sectionIds]);
return activeId;
}
export function useScrolledPast(threshold = 120) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > threshold);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [threshold]);
return scrolled;
}
export function scrollToSection(id: string) {
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
export function usePresentationStep(total: number) {
const [stepIndex, setStepIndex] = useState(0);
const [presentMode, setPresentMode] = useState(false);
const goTo = useCallback((index: number) => {
const next = Math.max(0, Math.min(total - 1, index));
setStepIndex(next);
}, [total]);
const next = useCallback(() => goTo(stepIndex + 1), [goTo, stepIndex]);
const prev = useCallback(() => goTo(stepIndex - 1), [goTo, stepIndex]);
return {
stepIndex,
presentMode,
setPresentMode,
goTo,
next,
prev,
isFirst: stepIndex <= 0,
isLast: stepIndex >= total - 1,
};
}

View File

@@ -0,0 +1,698 @@
/**
* @name 业务条线说明
* ONE-OS 多业务条线运作说明:租赁 / 能源 / 运维 / 安全 / 自营
*/
import './style.css';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowRight,
BarChart3,
CheckCircle2,
ChevronLeft,
ChevronRight,
Flag,
GitBranch,
Layers,
MonitorPlay,
Sparkles,
Target,
Users,
X,
} from 'lucide-react';
import {
scrollToSection,
usePresentationStep,
useScrollSpy,
useScrolledPast,
} from './hooks';
import {
BUSINESS_LINES,
BUSINESS_LINE_MAP,
DEFAULT_BUSINESS_LINE_ID,
LINE_ACCENTS,
LINE_TAB_ICONS,
SYSTEM_INTRO,
type BusinessLineConfig,
type BusinessLineId,
type LineModule,
} from './lines';
import {
DATA_FOUNDATION_INTRO,
DATA_FOUNDATION_PILLARS,
type DataFoundationPillar,
} from './data-foundations';
function parseLineIdFromHash(): BusinessLineId {
const raw = window.location.hash.replace(/^#/, '').split(/[/?]/)[0];
if (raw && raw in BUSINESS_LINE_MAP) return raw as BusinessLineId;
return DEFAULT_BUSINESS_LINE_ID;
}
function outcomeClass(tone: 'ok' | 'warn' | 'danger') {
return `lblo-outcome lblo-outcome--${tone}`;
}
interface ModuleCardProps {
module: LineModule;
active: boolean;
index: number;
moduleCount: number;
lineId: BusinessLineId;
}
function ModuleCard({ module, active, index, moduleCount, lineId }: ModuleCardProps) {
const Icon = module.icon;
const sectionId = `${lineId}-${module.id}`;
return (
<article
className={`lblo-module lblo-module--${module.accent}${active ? ' is-active' : ''}`}
id={sectionId}
data-annotation-id={`lblo-mod-${module.id}`}
style={{ '--lblo-index': index } as React.CSSProperties}
>
<div className="lblo-module-rail" aria-hidden>
<span className="lblo-module-rail-dot" />
{index < moduleCount - 1 && <span className="lblo-module-rail-line" />}
</div>
<div className="lblo-module-panel">
<header className="lblo-module-header">
<div className="lblo-module-icon-wrap">
<Icon size={22} aria-hidden />
</div>
<div className="lblo-module-titles">
<span className="lblo-module-step"> {module.step} </span>
<h2>{module.title}</h2>
<p className="lblo-module-subtitle">{module.subtitle}</p>
</div>
</header>
<div className="lblo-roles" aria-label="责任部门">
{module.roles.map((role) => (
<span key={role} className="lblo-role-tag">{role}</span>
))}
</div>
<div className="lblo-module-body">
<section className="lblo-block lblo-block--start" aria-labelledby={`${sectionId}-start`}>
<h3 className="lblo-block-label" id={`${sectionId}-start`}>
<span className="lblo-block-icon"><Flag size={15} aria-hidden /></span>
</h3>
<p>{module.start}</p>
</section>
<section className="lblo-block lblo-block--process" aria-labelledby={`${sectionId}-process`}>
<h3 className="lblo-block-label" id={`${sectionId}-process`}>
<span className="lblo-block-icon"><GitBranch size={15} aria-hidden /></span>
</h3>
<ol className="lblo-process-steps">
{module.process.map((item, stepIdx) => (
<li key={item}>
<span className="lblo-process-num">{stepIdx + 1}</span>
<span>{item}</span>
</li>
))}
</ol>
{module.outcomes && (
<div className="lblo-outcomes" aria-label="关键结果">
{module.outcomes.map((item) => (
<span key={item.label} className={outcomeClass(item.tone)}>{item.label}</span>
))}
</div>
)}
</section>
<section className="lblo-block lblo-block--closure" aria-labelledby={`${sectionId}-closure`}>
<h3 className="lblo-block-label" id={`${sectionId}-closure`}>
<span className="lblo-block-icon"><CheckCircle2 size={15} aria-hidden /></span>
</h3>
<p>{module.closure}</p>
</section>
</div>
{module.prototypeHref ? (
<footer className="lblo-module-footer">
<p className="lblo-closure-note"></p>
<a className="lblo-link-btn" href={module.prototypeHref}>
{module.prototypeLabel}
<ArrowRight size={16} aria-hidden />
</a>
</footer>
) : (
<footer className="lblo-module-footer lblo-module-footer--muted">
<p className="lblo-closure-note"> ONE-OS </p>
</footer>
)}
</div>
</article>
);
}
function DataFoundationCard({ pillar }: { pillar: DataFoundationPillar }) {
const Icon = pillar.icon;
return (
<article className={`lblo-data-pillar lblo-data-pillar--${pillar.accent}`}>
<header className="lblo-data-pillar-header">
<span className="lblo-data-pillar-icon" aria-hidden>
<Icon size={22} />
</span>
<div>
<h3>{pillar.title}</h3>
<p className="lblo-data-pillar-subtitle">{pillar.subtitle}</p>
</div>
</header>
<p className="lblo-data-pillar-summary">{pillar.summary}</p>
<div className="lblo-data-pillar-block">
<h4>
<Target size={14} aria-hidden />
</h4>
<ul>
{pillar.indicators.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="lblo-data-pillar-block">
<h4>
<BarChart3 size={14} aria-hidden />
</h4>
<ul>
{pillar.decisions.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<p className="lblo-data-pillar-sources">{pillar.sources}</p>
{pillar.prototypeHref && (
<a className="lblo-data-pillar-link" href={pillar.prototypeHref}>
{pillar.prototypeLabel}
<ArrowRight size={16} aria-hidden />
</a>
)}
</article>
);
}
function DataFoundationSection() {
return (
<section className="lblo-data-foundations" id="lblo-data-foundations" data-annotation-id="lblo-data-foundations">
<header className="lblo-data-foundations-header">
<h2>{DATA_FOUNDATION_INTRO.title}</h2>
<p className="lblo-data-foundations-tagline">{DATA_FOUNDATION_INTRO.tagline}</p>
<p className="lblo-data-foundations-summary">{DATA_FOUNDATION_INTRO.summary}</p>
</header>
<div className="lblo-data-pillar-grid">
{DATA_FOUNDATION_PILLARS.map((pillar) => (
<DataFoundationCard key={pillar.id} pillar={pillar} />
))}
</div>
</section>
);
}
function SystemIntro({
activeLineId,
onSelectLine,
}: {
activeLineId: BusinessLineId;
onSelectLine: (id: BusinessLineId) => void;
}) {
return (
<section className="lblo-system-intro" data-annotation-id="lblo-system-intro">
<div className="lblo-system-intro-inner">
<header className="lblo-system-intro-header">
<p className="lblo-system-intro-eyebrow">
<Sparkles size={16} aria-hidden />
{SYSTEM_INTRO.eyebrow}
</p>
<h1>{SYSTEM_INTRO.title}</h1>
<p className="lblo-system-intro-tagline">{SYSTEM_INTRO.tagline}</p>
<p className="lblo-system-intro-summary">{SYSTEM_INTRO.summary}</p>
</header>
{SYSTEM_INTRO.evolution && (
<EvolutionBanner
evolution={SYSTEM_INTRO.evolution}
heading="业财一体演进"
annotationId="lblo-system-evolution"
/>
)}
<div className="lblo-system-hub" aria-hidden>
<span className="lblo-system-hub-core">
<Layers size={22} />
ONE-OS
</span>
<span className="lblo-system-hub-ring" />
</div>
<div
className="lblo-line-grid"
role="tablist"
aria-label="业务条线选择"
data-annotation-id="lblo-line-switcher"
>
{BUSINESS_LINES.map((item) => {
const TabIcon = LINE_TAB_ICONS[item.id];
const accent = LINE_ACCENTS[item.id];
const isActive = item.id === activeLineId;
const moduleCount = item.modules.length;
return (
<button
key={item.id}
type="button"
role="tab"
id={`lblo-line-tab-${item.id}`}
aria-selected={isActive}
aria-controls="lblo-line-detail"
className={`lblo-line-card lblo-line-card--${accent}${isActive ? ' is-active' : ''}${item.placeholder ? ' is-placeholder' : ''}`}
onClick={() => onSelectLine(item.id)}
>
<span className="lblo-line-card-icon" aria-hidden>
<TabIcon size={20} />
</span>
<span className="lblo-line-card-body">
<span className="lblo-line-card-title-row">
<span className="lblo-line-card-title">{item.label}</span>
{item.placeholder && <span className="lblo-line-card-badge"></span>}
</span>
<span className="lblo-line-card-tagline">{item.hero.tagline}</span>
</span>
<span className="lblo-line-card-meta">
{moduleCount > 0 ? (
<span className="lblo-line-card-stat">{moduleCount} </span>
) : (
<span className="lblo-line-card-stat"></span>
)}
<ArrowRight size={16} aria-hidden />
</span>
</button>
);
})}
</div>
<p className="lblo-system-intro-footnote">
线线线
</p>
<DataFoundationSection />
</div>
</section>
);
}
function EvolutionBanner({
evolution,
heading = '条线演进路径',
annotationId = 'lblo-evolution',
}: {
evolution: NonNullable<BusinessLineConfig['evolution']>;
heading?: string;
annotationId?: string;
}) {
return (
<div className="lblo-evolution" data-annotation-id={annotationId}>
<p className="lblo-evolution-heading">{heading}</p>
<div className="lblo-evolution-track">
<div className="lblo-evolution-phase">
<span className="lblo-evolution-label">{evolution.currentLabel}</span>
<p>{evolution.currentText}</p>
</div>
<div className="lblo-evolution-arrow" aria-hidden>
<ChevronRight size={22} />
</div>
<div className="lblo-evolution-phase lblo-evolution-phase--target">
<span className="lblo-evolution-label">{evolution.targetLabel}</span>
<p>{evolution.targetText}</p>
</div>
</div>
</div>
);
}
function PipelineNodeButton({
module,
lineId,
activeId,
compact = false,
}: {
module: LineModule;
lineId: BusinessLineId;
activeId: string;
compact?: boolean;
}) {
const Icon = module.icon;
const sectionId = `${lineId}-${module.id}`;
return (
<button
type="button"
className={`lblo-pipeline-node lblo-pipeline-node--${module.accent}${compact ? ' lblo-pipeline-node--compact' : ''}${activeId === sectionId ? ' is-active' : ''}`}
onClick={() => scrollToSection(sectionId)}
aria-current={activeId === sectionId ? 'step' : undefined}
>
<span className="lblo-pipeline-icon"><Icon size={18} aria-hidden /></span>
<span className="lblo-pipeline-label">{module.shortLabel}</span>
</button>
);
}
function PipelineOverview({
line,
activeId,
}: {
line: BusinessLineConfig;
activeId: string;
}) {
const mainModules = line.modules.filter((m) => m.pipelineTrack === 'main');
const supportModules = line.modules.filter((m) => m.pipelineTrack === 'support');
const useDualLayout = mainModules.length > 0 && supportModules.length > 0;
if (useDualLayout) {
return (
<div className="lblo-pipeline-dual" aria-label="业务链路总览">
<div className="lblo-pipeline-section">
<p className="lblo-pipeline-section-label">线</p>
<div className="lblo-pipeline lblo-pipeline--main">
{mainModules.map((module, idx) => (
<React.Fragment key={module.id}>
<PipelineNodeButton module={module} lineId={line.id} activeId={activeId} />
{idx < mainModules.length - 1 && (
<span className="lblo-pipeline-connector" aria-hidden>
<ChevronRight size={14} />
</span>
)}
</React.Fragment>
))}
</div>
</div>
<div className="lblo-pipeline-section">
<p className="lblo-pipeline-section-label"></p>
<p className="lblo-pipeline-section-note"></p>
<div className="lblo-pipeline-support">
{supportModules.map((module) => (
<PipelineNodeButton
key={module.id}
module={module}
lineId={line.id}
activeId={activeId}
compact
/>
))}
</div>
</div>
</div>
);
}
return (
<div className="lblo-pipeline" aria-label="业务链路总览">
{line.modules.map((module, idx) => (
<React.Fragment key={module.id}>
<PipelineNodeButton module={module} lineId={line.id} activeId={activeId} />
{idx < line.modules.length - 1 && (
<span className="lblo-pipeline-connector" aria-hidden>
<ChevronRight size={14} />
</span>
)}
</React.Fragment>
))}
</div>
);
}
function PlaceholderPanel({ line }: { line: BusinessLineConfig }) {
const Icon = LINE_TAB_ICONS[line.id];
return (
<div className="lblo-placeholder" data-annotation-id={`lblo-placeholder-${line.id}`}>
<div className="lblo-placeholder-icon">
<Icon size={32} aria-hidden />
</div>
<h2></h2>
<p>{line.placeholderHint}</p>
<p className="lblo-placeholder-note">线</p>
</div>
);
}
interface LineContentProps {
line: BusinessLineConfig;
presentMode: boolean;
setPresentMode: React.Dispatch<React.SetStateAction<boolean>>;
}
function LineContent({ line, presentMode, setPresentMode }: LineContentProps) {
const sectionIds = useMemo(
() => line.modules.map((m) => `${line.id}-${m.id}`),
[line.id, line.modules],
);
const activeId = useScrollSpy(sectionIds, 72);
const headerPinned = useScrolledPast(96);
const {
stepIndex,
next,
prev,
isFirst,
isLast,
} = usePresentationStep(line.modules.length);
const activeIndex = line.modules.findIndex((m) => `${line.id}-${m.id}` === activeId);
const progress = line.modules.length
? ((activeIndex >= 0 ? activeIndex : 0) + 1) / line.modules.length
: 0;
useEffect(() => {
if (!presentMode || !line.modules.length) return;
scrollToSection(`${line.id}-${line.modules[stepIndex].id}`);
}, [presentMode, stepIndex, line.id, line.modules]);
const hasModules = line.modules.length > 0;
return (
<>
<header
className={`lblo-topbar lblo-topbar--line${headerPinned ? ' is-pinned' : ''}`}
data-annotation-id="lblo-nav"
>
<div className="lblo-topbar-inner">
<span className="lblo-topbar-brand">
<Layers size={18} aria-hidden />
{line.label}
</span>
{hasModules && (
<div className="lblo-topbar-progress" aria-hidden>
<span className="lblo-topbar-progress-fill" style={{ width: `${progress * 100}%` }} />
</div>
)}
{hasModules && (
<button
type="button"
className={`lblo-present-toggle${presentMode ? ' is-on' : ''}`}
onClick={() => setPresentMode((v) => !v)}
aria-pressed={presentMode}
>
{presentMode ? <X size={16} aria-hidden /> : <MonitorPlay size={16} aria-hidden />}
{presentMode ? '退出演示' : '演示模式'}
</button>
)}
</div>
</header>
<section className="lblo-hero" data-annotation-id="lblo-hero">
<div className="lblo-hero-inner">
<p className="lblo-eyebrow">
<Sparkles size={16} aria-hidden />
ONE-OS · {line.shortLabel}线
</p>
<h1>{line.hero.title}</h1>
<p className="lblo-tagline">{line.hero.tagline}</p>
<p className="lblo-summary">{line.hero.summary}</p>
{line.evolution && <EvolutionBanner evolution={line.evolution} />}
<div className="lblo-stats">
{line.hero.stats.map((stat) => (
<div key={stat.label} className="lblo-stat">
<span className="lblo-stat-value">{stat.value}</span>
<span className="lblo-stat-label">{stat.label}</span>
</div>
))}
</div>
{hasModules && (
<PipelineOverview line={line} activeId={activeId} />
)}
</div>
</section>
{line.placeholder ? (
<div className="lblo-shell lblo-shell--placeholder">
<PlaceholderPanel line={line} />
</div>
) : (
<div className="lblo-shell">
<aside className="lblo-sidebar" aria-label="模块导航">
<p className="lblo-sidebar-title"></p>
<nav className="lblo-sidebar-nav">
{line.modules.map((module) => {
const Icon = module.icon;
const sectionId = `${line.id}-${module.id}`;
const isActive = activeId === sectionId;
return (
<button
key={module.id}
type="button"
className={`lblo-sidebar-link lblo-sidebar-link--${module.accent}${isActive ? ' is-active' : ''}`}
onClick={() => scrollToSection(sectionId)}
aria-current={isActive ? 'step' : undefined}
>
<span className="lblo-sidebar-num">{module.step}</span>
<span className="lblo-sidebar-text">
<Icon size={16} aria-hidden />
{module.title}
</span>
</button>
);
})}
</nav>
</aside>
<main id="lblo-main" className="lblo-main">
{line.modules.map((module, index) => (
<ModuleCard
key={module.id}
module={module}
active={activeId === `${line.id}-${module.id}`}
index={index}
moduleCount={line.modules.length}
lineId={line.id}
/>
))}
<aside className="lblo-loop-card" data-annotation-id="lblo-full-loop" aria-label="全链路闭环">
<div className="lblo-loop-icon" aria-hidden>
<Users size={20} />
<ArrowRight size={16} />
<CheckCircle2 size={20} />
</div>
<div className="lblo-loop-content">
<h3>{line.loopTitle}</h3>
<p>{line.loopSummary} </p>
</div>
<button
type="button"
className="lblo-loop-btn"
onClick={() => scrollToSection(`${line.id}-${line.modules[0].id}`)}
>
</button>
</aside>
</main>
</div>
)}
{hasModules && (
<nav className="lblo-mobile-nav" aria-label="模块快速跳转">
{line.modules.map((module) => (
<button
key={module.id}
type="button"
className={`lblo-mobile-chip${activeId === `${line.id}-${module.id}` ? ' is-active' : ''}`}
onClick={() => scrollToSection(`${line.id}-${module.id}`)}
aria-label={module.title}
>
{module.step}
</button>
))}
</nav>
)}
{presentMode && hasModules && (
<div className="lblo-present-bar" role="toolbar" aria-label="演示控制">
<button type="button" className="lblo-present-btn" disabled={isFirst} onClick={prev}>
<ChevronLeft size={18} aria-hidden />
</button>
<span className="lblo-present-indicator">
{stepIndex + 1}
{' / '}
{line.modules.length}
{' · '}
{line.modules[stepIndex].title}
</span>
<button
type="button"
className="lblo-present-btn lblo-present-btn--primary"
disabled={isLast}
onClick={next}
>
<ChevronRight size={18} aria-hidden />
</button>
</div>
)}
</>
);
}
export default function LeaseBusinessLineOverviewApp() {
const [lineId, setLineId] = useState<BusinessLineId>(parseLineIdFromHash);
const [presentMode, setPresentMode] = useState(false);
const lineDetailRef = useRef<HTMLElement>(null);
const line = BUSINESS_LINE_MAP[lineId];
const switchLine = useCallback((id: BusinessLineId) => {
setLineId(id);
setPresentMode(false);
window.history.replaceState(null, '', `#${id}`);
requestAnimationFrame(() => {
lineDetailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}, []);
useEffect(() => {
const onHashChange = () => {
setLineId(parseLineIdFromHash());
setPresentMode(false);
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
return (
<div className={`lblo-page${presentMode ? ' lblo-page--present' : ''}`} data-annotation-id="lblo-page">
<a className="lblo-skip" href="#lblo-line-detail">线</a>
<div className="lblo-bg" aria-hidden>
<span className="lblo-bg-orb lblo-bg-orb--1" />
<span className="lblo-bg-orb lblo-bg-orb--2" />
<span className="lblo-bg-grid" />
</div>
<SystemIntro activeLineId={lineId} onSelectLine={switchLine} />
<section
ref={lineDetailRef}
id="lblo-line-detail"
className="lblo-line-detail"
role="tabpanel"
aria-labelledby={`lblo-line-tab-${lineId}`}
>
<LineContent
key={lineId}
line={line}
presentMode={presentMode}
setPresentMode={setPresentMode}
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,994 @@
import type { LucideIcon } from 'lucide-react';
import {
AlertTriangle,
Archive,
ArrowLeftRight,
BookOpen,
Building2,
Calendar,
Car,
CircleDollarSign,
ClipboardCheck,
ClipboardList,
FileSignature,
FileText,
FileWarning,
Fuel,
GraduationCap,
Library,
MapPin,
Network,
Package,
RefreshCw,
RotateCcw,
Settings,
Shield,
ShieldAlert,
ShieldCheck,
Siren,
Truck,
UserRound,
Users,
Wallet,
Wrench,
} from 'lucide-react';
export type ModuleAccent = 'blue' | 'violet' | 'indigo' | 'amber' | 'teal' | 'emerald';
/** 运维条线 Hero 链路图main 顺序主线support 并行能力(无先后) */
export type PipelineTrack = 'main' | 'support';
export interface LineModule {
id: string;
step: number;
title: string;
subtitle: string;
shortLabel: string;
accent: ModuleAccent;
icon: LucideIcon;
pipelineTrack?: PipelineTrack;
roles: string[];
start: string;
process: string[];
closure: string;
outcomes?: { label: string; tone: 'ok' | 'warn' | 'danger' }[];
prototypeHref?: string;
prototypeLabel?: string;
}
export type BusinessLineId = 'lease' | 'energy' | 'ops' | 'safety' | 'self-operated';
export interface LineHeroStat {
value: string;
label: string;
}
export interface LineEvolution {
currentLabel: string;
currentText: string;
targetLabel: string;
targetText: string;
}
export interface BusinessLineConfig {
id: BusinessLineId;
label: string;
shortLabel: string;
hero: {
title: string;
tagline: string;
summary: string;
stats: LineHeroStat[];
};
modules: LineModule[];
loopTitle: string;
loopSummary: string;
/** 仅租赁条线展示演进路径 */
evolution?: LineEvolution;
/** 占位条线,模块内容稍后填充 */
placeholder?: boolean;
placeholderHint?: string;
}
export const LEASE_MODULES: LineModule[] = [
{
id: 'customer',
step: 1,
title: '客户管理',
subtitle: '准入评级 · 决定能否签约、走哪条流程',
shortLabel: '客户',
accent: 'blue',
icon: Users,
roles: ['业务管理组', '法务部', '财务部', '安全部'],
start: '业务管理组维护客户基础信息,作为后续合同与账单的统一客户主数据。',
process: [
'法务部每 3 个月评估客户法律风险。',
'财务部实时维护客户财务风险等级。',
'安全部实时维护客户安全风险等级。',
'系统综合三部门结果,形成客户签约策略。',
],
closure:
'未达禁止标准:可签租赁合同;若需偏离常规范式,走「非标准流程」评审。达到禁止标准:禁止新签,并将该客户现有合同标记为「高风险」,通知业务人员跟进处理或终止。',
outcomes: [
{ label: '可标准签约', tone: 'ok' },
{ label: '可签 · 走非标', tone: 'warn' },
{ label: '禁止签约 · 高风险', tone: 'danger' },
],
prototypeHref: '/prototypes/customer-management',
prototypeLabel: '打开客户管理',
},
{
id: 'contract-template',
step: 2,
title: '标准合同管理',
subtitle: '模板配置 · 支撑电子合同一键发起',
shortLabel: '模板',
accent: 'violet',
icon: FileText,
roles: ['法务部', '数智部'],
start: '法务部维护各车型的试用合同、正式合同等标准模板,明确条款边界与风控要求。',
process: [
'数智部按模板配置电子合同,发起时可直选模板。',
'条款可设「触发车型」——仅对应车型可见该条款或附件。',
'「风控红线」被修改时,自动进入非标准流程审批。',
'「锁定区域」内内容在合同中不可被业务随意改动。',
],
closure:
'标准模板就绪后,业务员发起合同时可直接选用;在允许范围内填写即走标准流程,触碰红线或锁定区外修改则转入法务非标审批。',
prototypeHref: '/prototypes/contract-template-management',
prototypeLabel: '打开合同模板管理',
},
{
id: 'lease-contract',
step: 3,
title: '租赁合同',
subtitle: '签约发起 · 标准 / 非标 / 禁止三条路径',
shortLabel: '签约',
accent: 'indigo',
icon: FileSignature,
roles: ['业务员', '法务部'],
start: '业务员选择羚牛签约主体与乙方客户;系统依据客户评级判断可走「标准流程」「非标准流程」或禁止发起。',
process: [
'填写合同要素,内容实时反写进电子合同预览。',
'完成后自动生成电子合同、条款附件、授权委托书等。',
'预览可在标准约束内微调;一旦越界,自动转非标准流程供法务审核。',
'支持续签、转正式合同、转三方合同、添加增值服务、添加授权委托书、主动终止等操作。',
],
closure:
'合同审批通过即进入履约阶段,并与后续提车应收、交车任务、台账账单串联;非标路径须法务与董事长确认法律合规后放行。',
prototypeHref: '/prototypes/lease-contract-management',
prototypeLabel: '打开租赁合同',
},
{
id: 'pickup-receivable',
step: 4,
title: '提车应收款',
subtitle: '应收生成 · 实收对齐 · 触发交车',
shortLabel: '提车',
accent: 'amber',
icon: Wallet,
roles: ['业务员', '法务部', '业务管理组', '运维部'],
start: '业务员提交租赁合同审批时,系统按合同条款自动生成提车应收金额。',
process: [
'法务在「收款记录」导入到账信息(后期对接财务 YS 与银企直联自动获取)。',
'业务员 / 业务管理组将收款记录关联到账单并分配金额。',
'实收与应收对齐后,提车应收款自动标记为完成。',
],
closure:
'应收完成后,按合同约定生成交车任务;运维部对应区域操作员在交车模块查看并执行,车辆正式交付给承租方。',
prototypeHref: '/prototypes/payment-records',
prototypeLabel: '打开收款记录',
},
{
id: 'lease-ledger',
step: 5,
title: '租赁业务台账',
subtitle: '交车后账单 · 周期收费与实收延续',
shortLabel: '台账',
accent: 'teal',
icon: BookOpen,
roles: ['运维部', '业务员', '业务管理组'],
start: '运维交车成功后,租赁业务台账自动生成该车辆的租赁账单,应付款日初始为「待设置」。',
process: [
'业务人员设置账单应付款日后,第一期账单按规则自动生成并计算应收费用。',
'提车应收款实收同步写入首期账单实收。',
'若实收多于首期应收,溢出部分自动并入第二期实收作减免,第二期仅收差额。',
],
closure:
'台账持续记录每期应收、实收与状态,支撑租期中运营管理与后续还车结算的数据基础。',
prototypeHref: '/prototypes/lease-business-ledger',
prototypeLabel: '打开租赁业务台账',
},
{
id: 'return-settlement',
step: 6,
title: '还车应结款',
subtitle: '还车结算 · 多部门会签 · 归档闭环',
shortLabel: '还车',
accent: 'emerald',
icon: RotateCcw,
roles: ['业务管理组', '安全部', '运维部', '业务管理组-能源部'],
start: '运维完成还车后,系统自动生成还车应结款任务。',
process: [
'业务管理组、安全部、运维部、能源部各自完成本部门还车应结款审批。',
'汇总后计算应退还或应补缴金额。',
'应补缴时关联「收款记录」,应退还时关联「付款记录」,结果回写还车应结款。',
],
closure:
'业务管理组发起还车应结款总审批;审批完成后自动归档,租赁单条业务从签约到还车全链路闭环。',
},
];
export const ENERGY_MODULES: LineModule[] = [
{
id: 'energy-supplier',
step: 1,
title: '供应商管理',
subtitle: '加氢/充电站供应商 · 站点绑定与业财直连',
shortLabel: '供应商',
accent: 'blue',
icon: Building2,
roles: ['采购组', '财务部'],
start: '加氢站、充电站作为供应商纳入统一供应商体系,由采购组维护供应商基础信息与结算要素。',
process: [
'创建加氢站 / 充电站站点时绑定对应供应商。',
'氢费、电费对账支持供应商直连付款,付款金额与明细由系统自动生成。',
'付款、收款明细自动关联财务「收款记录」「付款记录」。',
'支撑对加氢站 / 充电站供应商的付款申请、收款申请及流程审批。',
],
closure:
'供应商主数据与站点、对账、财务记录贯通,减少手工对账与重复录入,为能源侧业财闭环打好基础。',
prototypeHref: '/prototypes/supplier-management',
prototypeLabel: '打开供应商管理',
},
{
id: 'h2-station',
step: 2,
title: '加氢站管理',
subtitle: '签约站 / 非签约站 · 账号、余额与价格',
shortLabel: '加氢站',
accent: 'violet',
icon: MapPin,
roles: ['采购部', '加氢站'],
start: '加氢站由采购部统一管理,分为「签约站」与「非签约站」;签约站须关联供应商与系统账号。',
process: [
'签约站使用采购部分配的账号登录 ONE-OS进入加氢订单模块PC / 移动端 / H5。',
'加氢站预付款余额:管理预付给加氢站的余额及使用记录。',
'营业状态管理:维护营业状态与营业时间。',
'价格配置:维护加氢站成本价,自动关联至氢费记录。',
'业务管理组-能源部将加氢记录形成对账单并发起付款申请(后期对接财务 YS 系统)。',
],
closure:
'站点、账号、余额、价格与营业状态集中管理,签约站可自助上报氢费,为对账与付款提供准确数据源。',
prototypeHref: '/prototypes/oneos-web-h2-station-site',
prototypeLabel: '打开站点信息',
},
{
id: 'h2-order',
step: 3,
title: '加氢订单',
subtitle: '主动上报 · PLC 自动采集 · 预约接单',
shortLabel: '订单',
accent: 'indigo',
icon: ClipboardList,
roles: ['签约加氢站', '业务管理组-能源部'],
start: '签约加氢站使用系统账号登录后,通过各渠道上报加氢订单,实现氢费记录主动采集;支持 PLC 接入的站点可自动获取订单。',
process: [
'支持 PC、移动端、移动端 H5 等多端快速识别并记录加氢信息。',
'支持加氢机 PLC 数据接入的站点,通过接入 PLC 数据实现加氢订单自动获取,无需人工补录。',
'联动「小羚羚」,接收车队、司机预约加氢订单并处理;加氢订单最终由司机进行确认。',
'订单数据进入加氢记录,供能源部对账与结算使用。',
],
closure:
'签约站氢费由站点主动上报或 PLC 自动采集,预约订单在线闭环,减少能源部手工补录、提升数据时效性。',
prototypeHref: '/prototypes/oneos-web-h2-station',
prototypeLabel: '打开加氢订单',
},
{
id: 'vehicle-h2-fee',
step: 4,
title: '车辆氢费明细',
subtitle: '非签约站补录 · 对账归档 · 余额关联',
shortLabel: '氢费',
accent: 'amber',
icon: Fuel,
roles: ['业务管理组-能源部'],
start: '非签约加氢站的车辆氢费,由业务管理组-能源部在系统中手动维护明细。',
process: [
'录入车辆加氢明细,与站点、车辆、客户等业务对象关联。',
'对账完成后将记录标记为「已对账」并归档。',
'与加氢站预付款余额、客户能源账户联动,同步更新两侧余额;系统级 OCR 及车辆电子围栏核对,辅助对车辆氢费明细进行核对和提示。',
],
closure:
'签约站与非签约站氢费数据统一归集,对账状态可追踪,余额变动与明细一致可追溯。',
prototypeHref: '/prototypes/vehicle-h2-fee-ledger',
prototypeLabel: '打开车辆氢费明细',
},
{
id: 'h2-account',
step: 5,
title: '氢费账户',
subtitle: '客户预付款 · 对账单 · 业财收款闭环',
shortLabel: '账户',
accent: 'teal',
icon: CircleDollarSign,
roles: ['业务管理组-能源部', '财务部'],
start: '管理客户氢费预付款账户余额,以及面向客户的氢费对账单。',
process: [
'维护客户氢费预付款账户及余额变动。',
'生成客户对账单,汇总应收氢费明细。',
'对账单关联财务「收款记录」,完成氢费侧业财闭环。',
'收款确认后,将对应加氢记录标记为「已付款」。',
'支持充值发起及开票发起,直接到财务,替代现有钉钉审批流程,对接 YS 系统直接形成业财闭环。',
],
closure:
'客户氢费从明细、对账到收款全链路可核对,加氢记录付款状态与财务实收一致。',
prototypeHref: '/prototypes/payment-records',
prototypeLabel: '打开收款记录',
},
{
id: 'energy-extension',
step: 6,
title: '扩展规划',
subtitle: '上游采购 · 下游司机服务',
shortLabel: '扩展',
accent: 'emerald',
icon: Network,
roles: ['采购部', '业务管理组-能源部', '数智部'],
start: '能源业务条线成熟后,向氢气供应链上游与氢能车服务下游延伸。',
process: [
'上游:拓展氢气采购端,支撑用氢环节采购业务场景。',
'下游:拓展外部氢能车司机端,提供加氢及其他氢能车扩展服务。',
'与现有供应商、站点、订单、氢费账户能力衔接,形成更大范围能源生态。',
],
closure:
'在现有加氢站运营与对账闭环基础上,逐步覆盖「采购—站点—用氢—结算—司机服务」全链条,支撑能源业务规模化扩展。',
},
];
export const OPS_MODULES: LineModule[] = [
{
id: 'ops-supplier',
step: 1,
title: '供应商管理',
subtitle: '整车厂 / 三方租赁 · 付款底座',
shortLabel: '供应商',
pipelineTrack: 'support',
accent: 'blue',
icon: Building2,
roles: ['采购组', '财务部'],
start: '整车厂、三方租赁企业作为供应商纳入统一管理体系,维护基础信息与结算要素。',
process: [
'维护整车厂、三方租赁厂商供应商档案与联系方式。',
'为车辆采购、三方租赁合同提供供应商主数据引用。',
'支撑后续财务付款闭环的基础数据底座。',
],
closure: '供应商信息与采购合同、付款记录贯通,减少重复建档与对账差错。',
prototypeHref: '/prototypes/supplier-management',
prototypeLabel: '打开供应商管理',
},
{
id: 'ops-procurement-contract',
step: 2,
title: '车辆采购 / 三方租赁合同',
subtitle: '车型数量 · 付款关联 · 验车任务',
shortLabel: '采购合同',
pipelineTrack: 'support',
accent: 'violet',
icon: FileText,
roles: ['采购部', '法务部', '财务部'],
start: '向整车厂、三方租赁厂商发起车辆采购或三方车辆租赁合同,录入车型、数量、费用等关键条款。',
process: [
'创建并审批车辆采购 / 三方租赁合同。',
'关联财务「付款记录」,按合同条款发起付款。',
'根据合同条款自动生成验车任务,进入验车入库流程。',
],
closure: '合同、付款与验车任务联动,采购来源可追溯,为车辆入库提供合规入口。',
prototypeHref: '/prototypes/lease-contract-management',
prototypeLabel: '打开租赁合同',
},
{
id: 'ops-inspection',
step: 3,
title: '验车入库',
subtitle: '采购 / 三方来源 · 验完自动入库',
shortLabel: '验车入库',
pipelineTrack: 'main',
accent: 'indigo',
icon: ClipboardCheck,
roles: ['运维部', '采购部'],
start: '对采购或三方租赁来源的氢能车执行验车,记录验车过程与结论。',
process: [
'接收合同触发的验车任务,现场核对车辆与合同信息。',
'记录验车过程、问题项与处理结果。',
'验车完成后车辆自动入库,源头数据留痕归档。',
],
closure: '验车结果与合同、库存车辆绑定,入库车辆来源清晰、过程可审计。',
prototypeHref: '/prototypes/vehicle-management',
prototypeLabel: '打开车辆管理',
},
{
id: 'ops-plate',
step: 4,
title: '车辆上牌',
subtitle: '车架号入库 · 完成牌证登记',
shortLabel: '上牌',
pipelineTrack: 'main',
accent: 'amber',
icon: Car,
roles: ['运维部'],
start: '对仅有车架号、尚未上牌的新车执行上牌操作,补齐牌照信息。',
process: [
'从库存中筛选待上牌车辆(仅有车架号)。',
'录入或同步上牌进度与牌照信息。',
'上牌完成后更新库存车辆状态,进入可运营准备。',
],
closure: '库存车辆牌证信息完整,为证照管理与交车交付提供前置条件。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开上牌管理',
},
{
id: 'ops-certificate',
step: 5,
title: '证照管理',
subtitle: '一车一档 · 到期待办推送',
shortLabel: '证照',
pipelineTrack: 'main',
accent: 'teal',
icon: Shield,
roles: ['运维部', '安全部'],
start: '对氢能车行驶证、道路运输证、登记证、特种设备使用登记证 / 标识、加氢卡、安全阀、压力表等实行一车一档管理。',
process: [
'维护各类证照档案与有效期信息。',
'核心证照临近到期时,自动生成年审、等评、换证等待办任务。',
'待办同步推送运维部、安全部等责任部门跟进处理。',
],
closure: '证照全生命周期系统化管理,降低违规与漏办风险,保障车辆合法运营。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开证照管理',
},
{
id: 'ops-prep',
step: 6,
title: '备车管理',
subtitle: '交车前整备 · 随时可交付',
shortLabel: '备车',
pipelineTrack: 'main',
accent: 'emerald',
icon: Package,
roles: ['运维部'],
start: '运维日常对车辆车况提前检查整备,确保交车任务到来时可随时交付。',
process: [
'按交车计划或库存策略发起备车检查。',
'记录车况整备项、完成状态与责任人。',
'作为交车前置任务,缩短交车现场处理时长。',
],
closure: '交车前整备前置完成,提升交车效率与交付质量一致性。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开备车管理',
},
{
id: 'ops-delivery',
step: 7,
title: '交车管理',
subtitle: '司机培训留痕 · 区域派单 · OCR 比对 · 电子签',
shortLabel: '交车',
pipelineTrack: 'main',
accent: 'blue',
icon: Truck,
roles: ['运维部', '业务管理组', '司机'],
start: '承接租赁 / 自营合同形成的交车任务,按交车区域自动分配属地运维人员处理;提车司机须先完成在线培训并留存电子记录。',
process: [
'提车司机在线培训:司机主动上传身份证、驾驶证、从业资格证,完成司机签名与现场拍照,形成可查的电子培训留痕记录。',
'交付已完成整备、证照及交强险 / 商业险有效的车辆。',
'记录交车里程、氢量、电量、交车位置OCR 识别胎纹留档。',
'还车时与交车数据自动比对,计算轮胎磨损费、维修费、保养费等。',
'结合 E 签宝形成电子化签字确认与归档记录。',
],
closure:
'司机培训、证照上传与现场拍照全程电子留痕,交车过程数据完整可查,为还车费用核算与争议处理提供可信依据。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开交车管理',
},
{
id: 'ops-replacement',
step: 8,
title: '替换车管理',
subtitle: '临时 / 永久替换 · 费用自动核算',
shortLabel: '替换',
pipelineTrack: 'support',
accent: 'violet',
icon: RefreshCw,
roles: ['运维部', '业务管理组'],
start: '因客户或车辆原因发起车辆替换,支持临时替换与永久替换两种模式。',
process: [
'发起替换流程,选择替换类型与新旧车辆。',
'完成替换交还操作并记录替换原因与时间。',
'客户原因导致的替换,系统自动计算新旧车费用差异。',
],
closure: '替换过程可追踪、费用可核算,保障租赁 / 自营履约不中断。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开替换车管理',
},
{
id: 'ops-return',
step: 9,
title: '还车管理',
subtitle: '区域派单 · 费用自动计算 · 电子签',
shortLabel: '还车',
pipelineTrack: 'main',
accent: 'indigo',
icon: RotateCcw,
roles: ['运维部', '业务管理组'],
start: '处理业务 / 业管发起的还车任务,按还车区域自动分配属地运维人员。',
process: [
'记录还车时间、里程、氢量、电量、还车位置。',
'OCR 识别胎纹,与交车时数据对比,自动计算胎纹磨损差费用。',
'按交车至还车里程及氢量差、电量差,自动计算相关费用。',
'结合 E 签宝形成电子化签字确认与归档记录。',
],
closure: '还车费用依据交还车对比自动出具,减少人工核算与纠纷成本。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开还车管理',
},
{
id: 'ops-fault',
step: 10,
title: '故障管理',
subtitle: 'AI 助手 + 人工 · 证据链归档',
shortLabel: '故障',
pipelineTrack: 'support',
accent: 'amber',
icon: AlertTriangle,
roles: ['运维部', '司机'],
start: '通过微信 AI 故障助手或人工方式,记录车辆故障发生时的关键信息。',
process: [
'记录故障时间、地点、车辆信息、故障类型与紧急程度。',
'留存图片、视频、聊天记录等证据链材料。',
'运维人员维护处理过程,直至故障关闭归档。',
'形成运维待办任务,统计执行情况来推动运维人员及时处理。',
'最终形成各品牌车型故障类型、严重程度、次数进行统计,为未来车辆资产的管理、车辆采购和三方租赁提供基础论证。',
],
closure: '故障从产生到解决全过程留痕,证据链完整,便于复盘与责任界定。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开故障管理',
},
{
id: 'ops-annual-inspection',
step: 11,
title: '年审记录',
subtitle: '提前 3 个月 · OCR 刷新证照',
shortLabel: '年审',
pipelineTrack: 'support',
accent: 'teal',
icon: Calendar,
roles: ['运维部'],
start: '依据行驶证审验有效期,提前 3 个月为对应车辆生成年审任务。',
process: [
'年审任务按车辆、按月份归集,跟踪办理进度。',
'年审完成后 OCR 识别新上传行驶证,自动刷新车辆证照数据。',
'按每月年审任务数量自动计算执行完成百分比。',
],
closure: '年审任务不遗漏、进度可量化,证照数据与办理结果自动同步。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开年审管理',
},
{
id: 'ops-maintenance',
step: 12,
title: '维修与保养记录',
subtitle: '费用汇总 · 业财一体 · 盈亏核算',
shortLabel: '维保',
pipelineTrack: 'support',
accent: 'emerald',
icon: Settings,
roles: ['运维部', '财务部'],
start: '首期由运维人员手动维护各自负责车辆的维修与保养记录。',
process: [
'记录配件费、人工费等明细并形成费用汇总。',
'按承担方(客户 / 我司)自动生成客户账单或纳入运维成本。',
'维修与保养费用计入车辆盈亏核算,实现业财一体化。',
],
closure: '维保成本可追溯、可分摊,支撑单车盈亏与对客户计费闭环。',
prototypeHref: '/prototypes/oneos-web-ledger-data',
prototypeLabel: '打开车辆维修明细',
},
{
id: 'ops-movement',
step: 13,
title: '车辆异动',
subtitle: '出库维修 / 年审 / 保养 · 成本归集',
shortLabel: '异动',
pipelineTrack: 'support',
accent: 'blue',
icon: MapPin,
roles: ['运维部'],
start: '车辆因当地维修、年审、保养等需要出库异动,须提前审批并记录异动前后状态。',
process: [
'发起异动审批,记录异动前氢量、电量、里程。',
'异动完成后记录结束时的氢量、电量、里程。',
'异动期间产生的加氢记录自动归集为异动成本,计入成本侧。',
],
closure: '异动过程可审批、可计量,异动成本自动归集,避免成本漏记。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开异动管理',
},
{
id: 'ops-transfer',
step: 14,
title: '车辆调拨',
subtitle: '跨区调拨 · 运维权转移',
shortLabel: '调拨',
pipelineTrack: 'support',
accent: 'violet',
icon: ArrowLeftRight,
roles: ['运维部', '业务管理组'],
start: '将车辆从 A 地调拨至 B 地用于运营,须提前审批并记录调拨与接收信息。',
process: [
'记录调拨发起方、费用与运输安排。',
'接收方确认接收,完成车辆交接。',
'接收完成后,车辆运维操作权由 A 地转移至 B 地。',
],
closure: '跨区调拨过程透明,运维责任属地随车辆同步切换,避免管理真空。',
prototypeHref: '/prototypes/oneos-web-ops',
prototypeLabel: '打开调拨管理',
},
{
id: 'ops-disposal',
step: 15,
title: '车辆出售、过户、报废',
subtitle: '资产变更审批 · 过程留痕 · 自动出库',
shortLabel: '处置出库',
pipelineTrack: 'main',
accent: 'indigo',
icon: Archive,
roles: ['运维部', '董事长', '财务部'],
start: '车辆达到出售、过户或报废条件时,发起资产变更流程,录入出售方、出售价格、过户情况、报废残值等关键信息。',
process: [
'提交出售 / 过户 / 报废申请,填写交易方、价格、过户状态或报废残值等要素。',
'流程审批通过后,由董事长确认资产变更。',
'运维部记录出售、过户或报废全过程,形成最终归档。',
'完成后车辆自动标记为退出运营并出库,不再参与租赁 / 自营交付。',
],
closure: '资产处置全程审批留痕,车辆退出运营状态与库存自动同步,生命周期闭环至出库归档。',
prototypeHref: '/prototypes/vehicle-management',
prototypeLabel: '打开车辆管理',
},
];
export const SAFETY_MODULES: LineModule[] = [
{
id: 'safety-driver',
step: 1,
title: '司机管理',
subtitle: '自营内部司机 · 信息证照一体',
shortLabel: '司机',
accent: 'blue',
icon: UserRound,
roles: ['安全部', '业务管理组'],
start: '管理自营业务内部司机,支撑自营场景下的司机快速选取与档案维护。',
process: [
'维护司机基础信息、联系方式与在职状态。',
'管理驾驶证、从业资格证等证照信息与有效期。',
'与自营业务交车、台账等场景联动,支持司机快速选取。',
],
closure: '内部司机主数据统一维护,证照齐全可查,支撑自营运力调度与安全合规。',
},
{
id: 'safety-training',
step: 2,
title: '内部司机培训',
subtitle: '定期安全培训 · 证据链留痕',
shortLabel: '培训',
accent: 'violet',
icon: GraduationCap,
roles: ['安全部', '司机'],
start: '定期对内部司机开展安全培训,并形成可追溯的培训记录。',
process: [
'安全部制定培训计划,组织内部司机参加安全培训。',
'记录培训时间、内容、参训司机与完成情况。',
'形成完整培训证据链,供交管部门查询时出示。',
],
closure: '培训记录电子化归档,随时可调阅完整证据链,满足监管与内控要求。',
},
{
id: 'safety-library',
step: 3,
title: '安全培训资料库',
subtitle: '资料上传 · APP / 小程序学习',
shortLabel: '资料库',
accent: 'indigo',
icon: Library,
roles: ['安全部', '司机'],
start: '安全部上传安全培训及安全运营相关资料,供内部司机在线学习。',
process: [
'安全部维护安全培训课件、制度规范与运营指引等资料。',
'资料分类发布至资料库,支持版本更新与检索。',
'内部司机通过 APP / 小程序快速查看、学习相关资料。',
],
closure: '培训资料集中管理、随时更新,司机移动端可学可查,提升安全运营一致性。',
},
{
id: 'safety-violation-record',
step: 4,
title: '违规记录',
subtitle: '司机违规 · 关联评级',
shortLabel: '违规',
accent: 'amber',
icon: FileWarning,
roles: ['安全部', '业务管理组'],
start: '记录内部司机在运营过程中的违规情况,并与相关主数据联动。',
process: [
'登记司机违规事件、违规类型、发生时间与处理结果。',
'与司机管理档案关联,沉淀司机安全行为记录。',
'与客户安全部评级等安全评估体系联动,影响风险判断。',
],
closure: '司机违规可追踪、可汇总,为司机选用与客户安全评级提供依据。',
},
{
id: 'safety-traffic-violation',
step: 5,
title: '违章管理',
subtitle: '分值罚款 · 还车联动 · 代处理',
shortLabel: '违章',
accent: 'teal',
icon: ShieldAlert,
roles: ['安全部', '财务部', '业务管理组'],
start: '记录司机交通违章的关键信息,并与还车应结款等业务环节联动。',
process: [
'登记违章时间、地点、分值、罚款金额及是否已缴纳等要素。',
'与还车应结款联动,将未处理违章纳入结算考量。',
'违章逾期仍未缴纳时,安全部自动生成代处理费用。',
],
closure: '违章信息完整可查,逾期代处理费用自动形成,还车结算与财务侧口径一致。',
prototypeHref: '/prototypes/vehicle-management',
prototypeLabel: '打开车辆管理',
},
{
id: 'safety-accident',
step: 6,
title: '事故管理',
subtitle: '权责划分 · 保险赔付 · 还车联动',
shortLabel: '事故',
accent: 'emerald',
icon: Siren,
roles: ['安全部', '财务部', '业务管理组'],
start: '记录车辆事故的关键信息,支撑定责、理赔与还车费用结算。',
process: [
'登记事故时间、地点、车辆、司机及事故权责划分情况。',
'记录保险是否介入、赔付情况、保险上浮情况等要素。',
'与还车应结款联动生成相关费用,安全部费用自动写入应结项。',
],
closure: '事故从登记到赔付、上浮与还车应结款全链路贯通,安全费用不遗漏、可追溯。',
prototypeHref: '/prototypes/vehicle-management',
prototypeLabel: '打开车辆管理',
},
];
export const SELF_OPERATED_MODULES: LineModule[] = [
{
id: 'self-contract',
step: 1,
title: '自营合同',
subtitle: '关键信息录入 · 上传合同 · 自动交车',
shortLabel: '合同',
accent: 'blue',
icon: FileSignature,
roles: ['业务管理组', '业务服务组'],
start: '创建自营合同,记录客户方、车型、车辆数、交车时间与地点等关键信息,并上传合同附件。',
process: [
'录入自营项目与客户方、车型、车辆数量等合同要素。',
'填写交车时间、交车地点,上传自营合同扫描件。',
'合同提交后自动生成交车任务,推送运维条线处理交付。',
],
closure: '合同信息与交车任务联动,自营项目从签约起即可进入交付与运营环节。',
prototypeHref: '/prototypes/oneos-web-lease-contract',
prototypeLabel: '打开车辆租赁合同',
},
{
id: 'self-ledger',
step: 2,
title: '自营台账',
subtitle: '导入台账 · 成本利润核算 · 项目盈亏',
shortLabel: '台账',
accent: 'emerald',
icon: BookOpen,
roles: ['业务服务组', '财务部'],
start: '根据导入的自营台账与账单信息,汇总项目侧收入与成本数据。',
process: [
'导入或维护自营运营台账、账单明细。',
'自动计算利润侧数据,以及司机成本、车辆成本侧数据。',
'汇集至项目盈亏表,支撑项目经营分析。',
'演进阶段关联财务「收款记录」,形成业财记录闭环。',
],
closure: '项目收入、司机与车辆成本在同一台账内可核对,盈亏结果可对接财务实收。',
prototypeHref: '/prototypes/self-operated-business-ledger',
prototypeLabel: '打开自营业务台账',
},
];
const PLACEHOLDER_STATS: LineHeroStat[] = [
{ value: '—', label: '业务模块' },
{ value: '—', label: '协同部门' },
{ value: '—', label: '闭环阶段' },
];
export const BUSINESS_LINES: BusinessLineConfig[] = [
{
id: 'lease',
label: '租赁业务条线',
shortLabel: '租赁',
hero: {
title: '租赁业务条线',
tagline: '从合同发起->业财闭环->合同结束归档',
summary:
'六大模块串联客户评级、标准合同、租赁签约、提车收款、租期台账与还车结算。每个模块都有明确责任部门、输入条件与闭环结果。',
stats: [
{ value: '6', label: '业务模块' },
{ value: '8+', label: '协同部门' },
{ value: '1', label: '全链路闭环' },
],
},
modules: LEASE_MODULES,
loopTitle: '一条业务,六个模块,完整闭环',
loopSummary:
'客户评级决定是否可签 → 标准模板支撑签约 → 租赁合同进入履约 → 提车应收对齐后交车 → 台账承接租期账单 → 还车应结款会签归档。',
evolution: {
currentLabel: '现阶段 · 合同电子化 · 业务闭环化',
currentText: '标准合同模板、电子签约、收款关联与台账闭环已在 ONE-OS 内贯通,支撑租赁全生命周期管理。',
targetLabel: '演进方向 · 移动端商城直连',
targetText: '移动端商城可直接发起租赁合同,提车应收、交车、台账与还车结算等后续业务自动打通;业务模式由主动推进转向被动接收与处理,系统自动提醒并流转待办,减少人工催办与漏单。',
},
},
{
id: 'energy',
label: '能源业务条线',
shortLabel: '能源',
hero: {
title: '能源业务条线',
tagline: '从加氢站供应商管理,到加氢站/客户的氢费业财闭环;未来开放独立 SaaS 工具以供加氢站运作和我方采集数据使用',
summary:
'六大模块串联供应商管理、加氢站运营、加氢订单采集、车辆氢费明细、客户氢费账户与上下游扩展规划,覆盖签约站主动上报与非签约站补录两条路径。',
stats: [
{ value: '6', label: '业务模块' },
{ value: '5+', label: '协同部门' },
{ value: '1', label: '业财闭环' },
],
},
modules: ENERGY_MODULES,
loopTitle: '供应商到账户,氢费全链路闭环',
loopSummary:
'供应商主数据支撑站点绑定 → 签约站账号与订单主动上报 → 非签约站氢费补录对账 → 客户对账单关联收款 → 加氢记录标记已付款 → 成熟后向采购端与司机端延伸。',
evolution: {
currentLabel: '现阶段 · 手工维护氢费明细',
currentText:
'业务管理部-能源组在系统中手工维护、记录车辆氢费明细,完成非签约站补录、对账归档及加氢站与客户氢费账户余额管理。',
targetLabel: '演进方向 · 加氢站主动上报',
targetText:
'签约加氢站通过系统账号主动上报加氢记录,业务管理组-能源部由「全量手工录入」转为「核对确认」,提升数据时效、减少重复补录。',
},
},
{
id: 'ops',
label: '运维管理条线',
shortLabel: '运维',
hero: {
title: '运维管理条线',
tagline: '车辆全生命周期留痕、租赁/自营业务的交还车闭环',
summary:
'十五个模块串联供应商、采购合同、验车入库、牌证整备、交还车、故障维保、异动调拨与出售过户报废,覆盖氢能车从进入到退出运营的全生命周期运维场景。',
stats: [
{ value: '15', label: '业务模块' },
{ value: '6+', label: '协同部门' },
{ value: '1', label: '全链路闭环' },
],
},
modules: OPS_MODULES,
loopTitle: '从验车入库到出库归档,运维全链路留痕',
loopSummary:
'供应商与采购合同入库 → 上牌证照与备车整备 → 交车 / 替换 / 还车闭环 → 故障与年审维保 → 异动调拨 → 出售 / 过户 / 报废审批出库,车辆全生命周期数据完整可追溯。',
evolution: {
currentLabel: '现阶段 · 车辆使用环节闭环',
currentText:
'聚焦交还车、证照、故障、年审维保、异动调拨等车辆使用阶段运维能力,支撑租赁与自营业务的现场交付与费用核算。',
targetLabel: '演进方向 · 入库到出库全链路',
targetText:
'从入库 → 运作 → 出库形成完整闭环,贯通采购验车入库、牌证整备、运营交还、维保处置到车辆退出全生命周期管理。',
},
},
{
id: 'safety',
label: '安全管理条线',
shortLabel: '安全',
hero: {
title: '安全管理条线',
tagline: '司机安全全链条 · 培训留痕 · 违章事故联动还车;安全事前的预判,并可与客户安全评级挂钩',
summary:
'六大模块串联司机管理、内部培训、资料库、违规与违章记录及事故管理,覆盖自营内部司机安全合规与还车应结款联动场景。',
stats: [
{ value: '6', label: '业务模块' },
{ value: '4+', label: '协同部门' },
{ value: '1', label: '安全闭环' },
],
},
modules: SAFETY_MODULES,
loopTitle: '从司机档案到违章事故,安全全链路留痕',
loopSummary:
'司机信息与证照管理 → 定期培训与资料库学习 → 违规记录关联评级 → 违章 / 事故登记 → 联动还车应结款与代处理费用,安全证据链完整可追溯。',
},
{
id: 'self-operated',
label: '自营业务条线',
shortLabel: '自营',
hero: {
title: '自营业务条线',
tagline: '自营物流条线合同->账单业财闭环',
summary:
'两大模块串联自营合同与自营台账:合同驱动交车任务,台账导入后自动核算司机、车辆成本与项目盈亏,逐步对接财务收款形成业财闭环。',
stats: [
{ value: '2', label: '业务模块' },
{ value: '4+', label: '协同部门' },
{ value: '1', label: '业财闭环' },
],
},
modules: SELF_OPERATED_MODULES,
loopTitle: '合同到台账,自营经营可核算',
loopSummary:
'自营合同录入并生成交车任务 → 运营数据导入自营台账 → 自动汇总利润与司机、车辆成本 → 汇集项目盈亏表 → 关联收款记录完成业财闭环。',
evolution: {
currentLabel: '现阶段 · 人工核算台账',
currentText:
'人工发起自营合同,人工计算车辆成本、司机成本与自营台账,项目盈亏依赖线下表格汇总与人工核对。',
targetLabel: '演进方向 · 业财记录闭环',
targetText:
'仍人工发起合同;司机在线管理、司机成本自动计算;自营台账人工导入后自动汇总,关联财务「收款记录」模块,形成业财记录闭环。',
},
},
];
export const BUSINESS_LINE_MAP = Object.fromEntries(
BUSINESS_LINES.map((line) => [line.id, line]),
) as Record<BusinessLineId, BusinessLineConfig>;
export const DEFAULT_BUSINESS_LINE_ID: BusinessLineId = 'lease';
export const SYSTEM_INTRO = {
eyebrow: 'ONE-OS',
title: '系统介绍',
tagline: '1 套系统5 大业务条线闭环3 大数据底座决策,走向业财一体',
summary:
'ONE-OS 以统一平台承载租赁、能源、运维、安全、自营五条业务条线,业务侧模块闭环已逐步成型;条线数据向上汇集成车辆资产、条线盈亏、里程履约三大数据底座,支撑管理层看全局、定方向、督风险。系统建设的核心方向,是在业务闭环跑通的基础上打通财务系统,由「业务驱动、财务手工维护」转向业财联动、数据同源。',
evolution: {
currentLabel: '现阶段 · 业务驱动 · 财务手工维护',
currentText:
'租赁、能源、自营等条线的合同、台账、对账、收付款多在 ONE-OS 内完成业务闭环,但财务侧收款、付款、核销仍大量依赖人工录入、导出与核对,业财之间存在时差与重复维护。',
targetLabel: '演进方向 · 业财闭环一体化',
targetText:
'在各业务条线系统闭环成熟的基础上打通财务系统(如 YS业务单据与台账自动关联财务「收款记录」「付款记录」收付款状态双向回写实现业财一体、减少手工维护、账实一致。',
},
};
export const LINE_ACCENTS: Record<BusinessLineId, ModuleAccent> = {
lease: 'blue',
energy: 'teal',
ops: 'violet',
safety: 'amber',
'self-operated': 'emerald',
};
/** @deprecated 使用 BUSINESS_LINE_MAP.lease */
export const LINE_MODULES = LEASE_MODULES;
/** @deprecated 使用 BUSINESS_LINE_MAP.lease.hero */
export const LINE_HERO = BUSINESS_LINE_MAP.lease.hero;
/** @deprecated 使用 BUSINESS_LINE_MAP.lease.loopSummary */
export const LOOP_SUMMARY = BUSINESS_LINE_MAP.lease.loopSummary;
export const LINE_TAB_ICONS: Record<BusinessLineId, LucideIcon> = {
lease: FileSignature,
energy: Fuel,
ops: Wrench,
safety: ShieldCheck,
'self-operated': Truck,
};

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,8 @@
├── 左侧编辑列(可滚动) ├── 左侧编辑列(可滚动)
│ ├── 主体合同信息(完成度徽章) │ ├── 主体合同信息(完成度徽章)
│ ├── 附件1租赁订单 │ ├── 附件1租赁订单
── 授权委托书 ── 授权委托书
│ └── 合同备注(独立卡片)
├── 右侧实时预览V26.6 完整合同 + 审批类型徽章) ├── 右侧实时预览V26.6 完整合同 + 审批类型徽章)
└── 底栏:取消 / 保存草稿 / 提交审核 └── 底栏:取消 / 保存草稿 / 提交审核
``` ```
@@ -30,6 +31,8 @@
| 合同编号 | 是 | 前缀 `HT-ZL-` + 用户输入 | | 合同编号 | 是 | 前缀 `HT-ZL-` + 用户输入 |
| 甲方 | 是 | 出租方签约主体,来自组织机构 | | 甲方 | 是 | 出租方签约主体,来自组织机构 |
| 乙方 | 是 | 客户选择器;仅 A/B 级客户可选 | | 乙方 | 是 | 客户选择器;仅 A/B 级客户可选 |
| 乙方负责人姓名 | 否 | 签约对接负责人,可与档案联系人不同 |
| 乙方负责人手机号 | 否 | 11 位手机号 |
选择甲方/乙方后,下方只读面板自动带出档案: 选择甲方/乙方后,下方只读面板自动带出档案:
@@ -37,6 +40,24 @@
**乙方**:企业名称、开户银行、银行账号、纳税人识别号、企业地址、企业电话、通讯地址、联系人、电话、邮箱;资质证照缩略图可点击放大。 **乙方**:企业名称、开户银行、银行账号、纳税人识别号、企业地址、企业电话、通讯地址、联系人、电话、邮箱;资质证照缩略图可点击放大。
#### 客户资质证照OCR 与有效期)
对以下四类证照做 **OCR 识别**并校验有效期:
| 证照 | 说明 |
|---|---|
| 客户营业执照 | 识别统一社会信用代码与营业期限 |
| 法人身份证(正面) | 识别姓名、证件号与有效期 |
| 法人身份证(反面) | 识别签发机关与有效期 |
| 道路运输许可证 | 识别许可编号与有效期 |
**业务规则:**
1. 上传后自动 OCR 提取有效期证照区展示「OCR 已识别」、有效期及状态(有效 / 即将到期 / 已过期)。
2. 距到期 **≤ 3 个月** 时,系统在工作台提前生成待办任务,推送给业管人员更新客户档案。
3. 若业管尚未更新,业务人员选择该客户后仍可查看证照,但 **禁止提交新合同**;提交时给出明确拦截提示。
4. 原型演示客户「杭州某某租赁有限公司B」含即将到期与已过期证照样例可用于验证提示与拦截。
未选双方时展示空状态引导。 未选双方时展示空状态引导。
### 2.2 里程标准 ### 2.2 里程标准
@@ -55,9 +76,9 @@
| 字段 | 必填 | 说明 | | 字段 | 必填 | 说明 |
|---|---|:---:| |---|---|:---:|
| 付款周期 | 是 | 如月付、季付 | | 付款周期 | 是 | 如月付、季付 |
| 氢费支付方式 | 是 | 后付 / 预付 | | 氢费支付方式 | 是 | 自行解决 / 预付款 / 按月结算(选按月结算时预览切换月结算占位条款) |
| 预付金额 | 条件 | 预付时必填 | | 预付金额 | 条件 | 选择预付时必填 |
| 提前付款工作日 | 条件 | 预付时必填 | | 提前付款工作日 | 条件 | 选择预付时必填 |
| 还车氢量差单价 | 是 | 元 | | 还车氢量差单价 | 是 | 元 |
**完成度**:上述必填项均已填则卡片显示「已完成」。 **完成度**:上述必填项均已填则卡片显示「已完成」。
@@ -85,13 +106,35 @@
支持新增行、删除行。 支持新增行、删除行。
**合同备注**:授权委托书区块最下方提供文本域,支持自定义输入,非必填;列表页不再展示备注列。
**完成度**:至少一行且三项均非空。 **完成度**:至少一行且三项均非空。
--- ---
## 5. 实时预览 ## 5. 合同备注
独立卡片,与授权委托书分离。
提供文本域,支持自定义输入,非必填(最多 500 字);列表页不展示备注列。
---
## 5.1 用章类型(第 6 步)
右侧工作区「用章类型」支持多选,可选范围:
| 用章 | 说明 |
|---|---|
| 合同章 | 合同正文用章 |
| 公章 | 公司公章 |
| 法人章 | 法定代表人章 |
**不含**财务章、发票章(租赁合同电子签章场景不使用)。
默认选中合同章;至少选择一种用章后方可提交审核。
---
## 6. 实时预览
- 基于标准合同模板 V26.6 拼装 HTMLA4 分页展示。 - 基于标准合同模板 V26.6 拼装 HTMLA4 分页展示。
- 支持缩放50%—150%)、连续预览 / 单页预览切换。 - 支持缩放50%—150%)、连续预览 / 单页预览切换。
@@ -108,7 +151,7 @@
--- ---
## 6. 审批类型判定 ## 7. 审批类型判定
| 类型 | 触发条件 | | 类型 | 触发条件 |
|---|---| |---|---|
@@ -121,7 +164,7 @@
--- ---
## 7. 底栏操作 ## 8. 底栏操作
| 按钮 | 行为 | | 按钮 | 行为 |
|---|---| |---|---|
@@ -131,7 +174,7 @@
--- ---
## 8. 与合同模板的关系 ## 9. 与合同模板的关系
- 创建时默认引用 **标准合同模板**(合同模板管理 `isDefault=true` 的已发布版本)。 - 创建时默认引用 **标准合同模板**(合同模板管理 `isDefault=true` 的已发布版本)。
- 模板内风控红线条款、条件条款、锁定区标记在预览中保留。 - 模板内风控红线条款、条件条款、锁定区标记在预览中保留。

View File

@@ -30,7 +30,7 @@
| 合同类型 | 多选下拉 | 全部 / 正式合同 / 试用合同 | | 合同类型 | 多选下拉 | 全部 / 正式合同 / 试用合同 |
| 审批类型 | 多选下拉 | 全部 / 标准合同 / 非标准合同 | | 审批类型 | 多选下拉 | 全部 / 标准合同 / 非标准合同 |
| 创建人 | 多选下拉 | 业务相关人员 | | 创建人 | 多选下拉 | 业务相关人员 |
| 合同结束日期 | 日期范围 | 开始—结束精确至日 | | 合同结束日期 | 日期范围 | 单输入框双日历,中文界面,开始—结束精确至日 |
**交互** **交互**
@@ -46,10 +46,10 @@
| 卡片 | 口径 | | 卡片 | 口径 |
|---|---| |---|---|
| 全部合同 | 不做 KPI 维度过滤 | | 全部合同 | 不做 KPI 维度过滤 |
| 进行中 | 合同状态为「合同进行中」且结束日期 ≥ 今日 | | 进行中 | 至少有一辆租赁车辆已交车且尚未还车 |
| 已终止 | 合同状态为「已终止」或「已结束」 | | 审批中 | 审批状态为「待审批」或「审批中」 |
| 临期合同 | 距离合同结束日期 ≤ 30 天且未过期 | | 临期合同 | 存在在租车辆,且合同签订有效期距今日 ≤ 30 天(以最后一辆在租车辆为准) |
| 过期合同 | 合同状态为「已过期」或「到期合同」 | | 已终止 | 全部车辆已还车;或续签/转三方产生新合同后旧合同自动标记为已终止 |
卡片右上角 `?` 悬停展示口径说明。 卡片右上角 `?` 悬停展示口径说明。
@@ -119,8 +119,8 @@
| 续签合同 | 进行中 / 到期 | 跳转续签页 | | 续签合同 | 进行中 / 到期 | 跳转续签页 |
| 添加被授权人 | 合同进行中 | 弹窗编辑被授权人列表 | | 添加被授权人 | 合同进行中 | 弹窗编辑被授权人列表 |
| 附加费用 | 合同进行中 | 弹窗按车辆录入附加费用 | | 附加费用 | 合同进行中 | 弹窗按车辆录入附加费用 |
| 变更为三方合同 | 合同进行中 | 跳转变更页 | | 三方合同 | 合同进行中 | 弹窗新增三方协议,上传协议附件与对方公函 |
| 转正式合同 | 试用合同 | 跳转转正式页 | | 试用转正式 | 试用合同 | 单步弹窗:自动拉取客户/授权人/车辆订单(车辆可编辑),可选同时转三方 |
| 终止合同 | 合同进行中 | 二次确认,提交终止审批 | | 终止合同 | 合同进行中 | 二次确认,提交终止审批 |
| 上传盖章合同 | 审批通过且法务未上传 | 仅法务部可见;多文件上传后关闭入口 | | 上传盖章合同 | 审批通过且法务未上传 | 仅法务部可见;多文件上传后关闭入口 |
@@ -152,4 +152,6 @@
- **终止**:「是否确认终止合同」→ 提交审核 - **终止**:「是否确认终止合同」→ 提交审核
- **添加被授权人**:姓名、电话、身份证;支持增删行 - **添加被授权人**:姓名、电话、身份证;支持增删行
- **附加费用**:按车辆展示类型/品牌/型号/车牌可填服务项目、费用2 位小数)、生效日期 - **附加费用**:按车辆展示类型/品牌/型号/车牌可填服务项目、费用2 位小数)、生效日期
- **转三方合同**:支持新增多条三方协议;每条可填协议名称、上传协议附件与对方公函
- **试用转正式**:自动带出客户信息、授权人与车辆订单;车辆订单字段可编辑;可勾选「同时转三方合同」并在同一步维护三方协议
- **上传盖章合同**:拖拽多文件;确认后标记已上传 - **上传盖章合同**:拖拽多文件;确认后标记已上传

View File

@@ -1,7 +1,17 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Row, Col, Select, message } from 'antd'; import { Row, Col, Select, message } from 'antd';
import { Info, Check } from 'lucide-react'; import { Info, Check } from 'lucide-react';
import { DEFAULT_MILEAGE_STANDARD, DEFAULT_FEE_INFO } from '../contract-template-management/contract-template-vars.js'; import {
DEFAULT_MILEAGE_STANDARD,
DEFAULT_FEE_INFO,
generateAutoContractCodeSuffix,
getLeaseCustomerById,
} from '../contract-template-management/contract-template-vars.js';
import {
hasBlockingCustomerCredentials,
summarizeCustomerCredentials,
formatCredentialSubmitBlockMessage,
} from './lease-customer-credentials.js';
import { import {
getPublishedContractTemplateOptions, getPublishedContractTemplateOptions,
subscribePublishedContractTemplateOptions, subscribePublishedContractTemplateOptions,
@@ -9,13 +19,13 @@ import {
getContractTypeLabel, getContractTypeLabel,
} from '../contract-template-management/contract-template-catalog.js'; } from '../contract-template-management/contract-template-catalog.js';
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js'; import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState } from './lease-order-vars.js'; import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState, hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js';
import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js'; import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js';
import { import {
resolveContractApprovalType, resolveLeaseContractApprovalType,
STANDARD_CONTRACT_APPROVAL, STANDARD_CONTRACT_APPROVAL,
NONSTANDARD_CONTRACT_APPROVAL, NONSTANDARD_CONTRACT_APPROVAL,
} from './lease-contract-risk-detect.js'; } from './lease-contract-approval-rules.js';
import { import {
isMainContractFormComplete, isMainContractFormComplete,
isLeaseOrderFormComplete, isLeaseOrderFormComplete,
@@ -29,16 +39,23 @@ import LeaseContractPreviewPanel, { PreviewEditableHint } from './LeaseContractP
import LeaseContractEditorForm, { import LeaseContractEditorForm, {
LeaseContractLeaseOrderSection, LeaseContractLeaseOrderSection,
LeaseContractPowerOfAttorneySection, LeaseContractPowerOfAttorneySection,
LeaseContractRemarkSection,
} from './LeaseContractEditorForm.jsx'; } from './LeaseContractEditorForm.jsx';
var SEAL_TYPE_OPTIONS = [ var SEAL_TYPE_OPTIONS = [
{ value: 'contract', label: '合同章' }, { value: 'contract', label: '合同章' },
{ value: 'official', label: '公章' }, { value: 'official', label: '公章' },
{ value: 'legal_person', label: '法人章' }, { value: 'legal_person', label: '法人章' },
{ value: 'finance', label: '财务章' },
{ value: 'invoice', label: '发票章' },
]; ];
function normalizeSealTypes(types) {
var allowed = SEAL_TYPE_OPTIONS.map(function (item) { return item.value; });
var next = (types || ['contract']).filter(function (value) {
return allowed.indexOf(value) >= 0;
});
return next.length ? next : ['contract'];
}
export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord }) { export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord }) {
var contractTypeState = useState(''); var contractTypeState = useState('');
var contractType = contractTypeState[0]; var contractType = contractTypeState[0];
@@ -93,7 +110,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var customerIdState = useState(''); var customerIdState = useState('');
var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD)); var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD));
var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO)); var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO));
var contractCodeState = useState(''); var contractCodeState = useState(function () { return generateAutoContractCodeSuffix(); });
var businessDeptState = useState(''); var businessDeptState = useState('');
var businessOwnerState = useState(''); var businessOwnerState = useState('');
var leaseOrderState = useState(function () { var leaseOrderState = useState(function () {
@@ -140,6 +157,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
var contractRemarkState = useState(''); var contractRemarkState = useState('');
var contractRemark = contractRemarkState[0]; var contractRemark = contractRemarkState[0];
var setContractRemark = contractRemarkState[1]; var setContractRemark = contractRemarkState[1];
var customerPrincipalNameState = useState('');
var customerPrincipalName = customerPrincipalNameState[0];
var setCustomerPrincipalName = customerPrincipalNameState[1];
var customerPrincipalPhoneState = useState('');
var customerPrincipalPhone = customerPrincipalPhoneState[0];
var setCustomerPrincipalPhone = customerPrincipalPhoneState[1];
var sealTypesState = useState(['contract']); var sealTypesState = useState(['contract']);
var sealTypes = sealTypesState[0]; var sealTypes = sealTypesState[0];
var setSealTypes = sealTypesState[1]; var setSealTypes = sealTypesState[1];
@@ -162,12 +185,18 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney]); }, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney]);
var handlePreviewContentChange = useCallback(function (mergedHtml) { var handlePreviewContentChange = useCallback(function (mergedHtml) {
setContractApprovalType(resolveContractApprovalType(previewHtml, mergedHtml)); setContractApprovalType(resolveLeaseContractApprovalType(previewHtml, mergedHtml, {
}, [previewHtml]); leaseOrder: leaseOrder,
}));
}, [previewHtml, leaseOrder]);
useEffect(function () { useEffect(function () {
if (hasLeaseOrderRentBelowMinimum(leaseOrder)) {
setContractApprovalType(NONSTANDARD_CONTRACT_APPROVAL);
return;
}
setContractApprovalType(STANDARD_CONTRACT_APPROVAL); setContractApprovalType(STANDARD_CONTRACT_APPROVAL);
}, [previewHtml]); }, [previewHtml, leaseOrder]);
var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL; var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL;
var isTemplateSelected = Boolean(contractTemplateId); var isTemplateSelected = Boolean(contractTemplateId);
@@ -240,6 +269,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
main: useRef(null), main: useRef(null),
leaseOrder: useRef(null), leaseOrder: useRef(null),
poa: useRef(null), poa: useRef(null),
remark: useRef(null),
seal: useRef(null), seal: useRef(null),
}; };
@@ -282,7 +312,9 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}), }),
}); });
setContractRemark(formState.contractRemark || ''); setContractRemark(formState.contractRemark || '');
setSealTypes((formState.sealTypes || ['contract']).slice()); setCustomerPrincipalName(formState.customerPrincipalName || '');
setCustomerPrincipalPhone(formState.customerPrincipalPhone || '');
setSealTypes(normalizeSealTypes(formState.sealTypes));
}, [editRecord && editRecord.id]); }, [editRecord && editRecord.id]);
var createSteps = useMemo(function () { var createSteps = useMemo(function () {
@@ -315,6 +347,13 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
optional: isPowerOfAttorneyEmpty, optional: isPowerOfAttorneyEmpty,
locked: !isTemplateSelected, locked: !isTemplateSelected,
}, },
{
key: 'remark',
label: '合同备注',
done: Boolean((contractRemark || '').trim()),
optional: true,
locked: !isTemplateSelected,
},
{ {
key: 'seal', key: 'seal',
label: '用章类型', label: '用章类型',
@@ -330,6 +369,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
isPowerOfAttorneyComplete, isPowerOfAttorneyComplete,
isPowerOfAttorneyEmpty, isPowerOfAttorneyEmpty,
isSealTypeComplete, isSealTypeComplete,
contractRemark,
]); ]);
var requiredDoneCount = useMemo(function () { var requiredDoneCount = useMemo(function () {
@@ -512,6 +552,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
lessorId: lessorId, lessorId: lessorId,
customerId: customerId, customerId: customerId,
contractCode: contractCode, contractCode: contractCode,
contractCodeReadonly: !(editRecord && editRecord.id),
businessDept: businessDept, businessDept: businessDept,
businessOwner: businessOwner, businessOwner: businessOwner,
mileage: mileage, mileage: mileage,
@@ -525,6 +566,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
}, },
onMileageChange: setMileage, onMileageChange: setMileage,
onFeeInfoChange: setFeeInfo, onFeeInfoChange: setFeeInfo,
customerPrincipalName: customerPrincipalName,
customerPrincipalPhone: customerPrincipalPhone,
onCustomerPrincipalChange: function (name, phone) {
setCustomerPrincipalName(name);
setCustomerPrincipalPhone(phone);
},
}), }),
), ),
renderSectionLock('main'), renderSectionLock('main'),
@@ -598,11 +645,36 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
React.createElement(LeaseContractPowerOfAttorneySection, { React.createElement(LeaseContractPowerOfAttorneySection, {
powerOfAttorney: powerOfAttorney, powerOfAttorney: powerOfAttorney,
onPowerOfAttorneyChange: setPowerOfAttorney, onPowerOfAttorneyChange: setPowerOfAttorney,
}),
),
renderSectionLock('poa'),
),
React.createElement('section', {
className: 'ct-editor-column-card lc-create-section'
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
'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 步:合同备注'),
React.createElement('span', {
className: 'lc-form-completion-badge '
+ ((contractRemark || '').trim()
? 'lc-form-completion-badge--done'
: 'lc-form-completion-badge--optional'),
role: 'status',
'aria-live': 'polite',
}, (contractRemark || '').trim() ? '已填写' : '选填'),
),
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel' },
React.createElement(LeaseContractRemarkSection, {
contractRemark: contractRemark, contractRemark: contractRemark,
onContractRemarkChange: setContractRemark, onContractRemarkChange: setContractRemark,
}), }),
), ),
renderSectionLock('poa'), renderSectionLock('remark'),
), ),
React.createElement('section', { React.createElement('section', {
className: 'ct-editor-column-card lc-create-section' className: 'ct-editor-column-card lc-create-section'
@@ -616,7 +688,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
className: 'ct-panel-head lc-create-section__head lc-create-section-seal-head', className: 'ct-panel-head lc-create-section__head lc-create-section-seal-head',
}, },
React.createElement('div', { className: 'lc-template-step-card__title-row' }, React.createElement('div', { className: 'lc-template-step-card__title-row' },
React.createElement('div', { className: 'ct-panel-head__title' }, '第 5 步:用章类型'), React.createElement('div', { className: 'ct-panel-head__title' }, '第 6 步:用章类型'),
sealTypes.length sealTypes.length
? React.createElement('span', { ? React.createElement('span', {
className: 'lc-seal-type-head-tags', className: 'lc-seal-type-head-tags',
@@ -733,6 +805,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
message.warning('请先完成左侧必填项'); message.warning('请先完成左侧必填项');
return; return;
} }
var submitCustomer = getLeaseCustomerById(customerId);
if (hasBlockingCustomerCredentials(submitCustomer)) {
message.error(formatCredentialSubmitBlockMessage(summarizeCustomerCredentials(submitCustomer)));
scrollToCreateSection('main');
return;
}
message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)'); message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
}, },
}, '提交审核'), }, '提交审核'),

View File

@@ -14,7 +14,6 @@ import {
MOCK_LEASE_CUSTOMERS, MOCK_LEASE_CUSTOMERS,
DEFAULT_MILEAGE_STANDARD, DEFAULT_MILEAGE_STANDARD,
MILEAGE_PERIOD_OPTIONS, MILEAGE_PERIOD_OPTIONS,
CUSTOMER_CREDENTIAL_ITEMS,
DEFAULT_FEE_INFO, DEFAULT_FEE_INFO,
PAYMENT_PERIOD_OPTIONS, PAYMENT_PERIOD_OPTIONS,
HYDROGEN_PAYMENT_METHOD_OPTIONS, HYDROGEN_PAYMENT_METHOD_OPTIONS,
@@ -23,6 +22,10 @@ import {
getLeaseCustomerRiskLevelLabel, getLeaseCustomerRiskLevelLabel,
isLeaseCustomerSelectable, isLeaseCustomerSelectable,
} from '../contract-template-management/contract-template-vars.js'; } from '../contract-template-management/contract-template-vars.js';
import {
summarizeCustomerCredentials,
CREDENTIAL_EXPIRY_WARN_MONTHS,
} from './lease-customer-credentials.js';
import { import {
createEmptyLeaseOrderRow, createEmptyLeaseOrderRow,
createDefaultLeaseOrderState, createDefaultLeaseOrderState,
@@ -52,6 +55,9 @@ import {
calcLeaseOrderKpis, calcLeaseOrderKpis,
formatKpiAmount, formatKpiAmount,
getExtraServiceNames, getExtraServiceNames,
countNationalInStockByBrandModel,
getLeaseMinRentForBrandModel,
isLeaseRentBelowMinimum,
} from './lease-order-vars.js'; } from './lease-order-vars.js';
import { import {
LEASE_BUSINESS_ORG_TREE, LEASE_BUSINESS_ORG_TREE,
@@ -712,6 +718,10 @@ function LeaseOrderBrandModelField(props) {
var summary = labels.length <= 2 var summary = labels.length <= 2
? labels.join('、') ? labels.join('、')
: (labels.length > 0 ? '已选 ' + labels.length + ' 个型号' : ''); : (labels.length > 0 ? '已选 ' + labels.length + ' 个型号' : '');
var stockHints = selected.map(function (pair) {
var count = countNationalInStockByBrandModel(pair[0], pair[1]);
return formatBrandModelPair(pair[0], pair[1]) + ':全国在库 ' + count + ' 台';
});
var searchText = query.trim(); var searchText = query.trim();
var isSearchMode = searchText.length > 0; var isSearchMode = searchText.length > 0;
var resolvedBrand = activeBrand || LEASE_VEHICLE_BRAND_MODEL_CATALOG[0].brand; var resolvedBrand = activeBrand || LEASE_VEHICLE_BRAND_MODEL_CATALOG[0].brand;
@@ -893,6 +903,11 @@ function LeaseOrderBrandModelField(props) {
React.createElement(ChevronDown, { size: 14, className: 'vm-filter-picker-chevron', 'aria-hidden': true }), React.createElement(ChevronDown, { size: 14, className: 'vm-filter-picker-chevron', 'aria-hidden': true }),
), ),
), ),
stockHints.length > 0 ? React.createElement('p', {
className: 'lc-brand-model-stock-hint',
'data-annotation-id': 'lc-lease-order-brand-model-stock',
role: 'status',
}, stockHints.join('')) : null,
); );
} }
@@ -1208,7 +1223,12 @@ function LeaseOrderEditor(props) {
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__th-subtotal' }, '租金及服务费小计(元/月/辆,含税)'), React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__th-subtotal' }, '租金及服务费小计(元/月/辆,含税)'),
React.createElement('th', { colSpan: 3, scope: 'colgroup', className: 'lc-lease-order-editor__th-group' }, '价格及服务内容'), React.createElement('th', { colSpan: 3, scope: 'colgroup', className: 'lc-lease-order-editor__th-group' }, '价格及服务内容'),
React.createElement('th', { rowSpan: 2, scope: 'col' }, '保证金(元/辆)'), React.createElement('th', { rowSpan: 2, scope: 'col' }, '保证金(元/辆)'),
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__th-period' }, '租赁期限(月)'), React.createElement('th', {
rowSpan: 2,
scope: 'col',
className: 'lc-lease-order-editor__th-period',
'data-annotation-id': 'lc-lease-order-lease-period',
}, '租赁期限(月)'),
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__actions-col' }, '操作'), React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__actions-col' }, '操作'),
), ),
React.createElement('tr', { className: 'lc-lease-order-editor__head-row lc-lease-order-editor__head-row--sub' }, React.createElement('tr', { className: 'lc-lease-order-editor__head-row lc-lease-order-editor__head-row--sub' },
@@ -1224,6 +1244,14 @@ function LeaseOrderEditor(props) {
var plateNos = normalizePlateNos(row); var plateNos = normalizePlateNos(row);
var vehicleCount = getRowVehicleCountForPricing(row, order); var vehicleCount = getRowVehicleCountForPricing(row, order);
var subtotal = calcRentServiceSubtotal(row.rent, row.serviceFee, vehicleCount); var subtotal = calcRentServiceSubtotal(row.rent, row.serviceFee, vehicleCount);
var rentBelowMin = brandModels.some(function (pair) {
return isLeaseRentBelowMinimum(row.rent, pair[0], pair[1]);
});
var minRentHint = brandModels.map(function (pair) {
var minRent = getLeaseMinRentForBrandModel(pair[0], pair[1]);
if (minRent == null) return null;
return formatBrandModelPair(pair[0], pair[1]) + ' 最低 ' + minRent.toLocaleString('zh-CN') + ' 元/月';
}).filter(Boolean).join('');
return React.createElement('tr', { key: row.id }, return React.createElement('tr', { key: row.id },
React.createElement('td', { className: 'lc-lease-order-editor__brand-cell' }, React.createElement('td', { className: 'lc-lease-order-editor__brand-cell' },
React.createElement(LeaseOrderBrandModelField, { React.createElement(LeaseOrderBrandModelField, {
@@ -1253,7 +1281,7 @@ function LeaseOrderEditor(props) {
React.createElement('td', null, React.createElement('td', null,
React.createElement(LeaseOrderAmountField, null, React.createElement(LeaseOrderAmountField, null,
React.createElement(InputNumber, { React.createElement(InputNumber, {
className: 'lc-form-number vm-focus-border', className: 'lc-form-number vm-focus-border' + (rentBelowMin ? ' lc-form-number--warn' : ''),
controls: false, controls: false,
min: 0, min: 0,
step: 0.01, step: 0.01,
@@ -1263,7 +1291,13 @@ function LeaseOrderEditor(props) {
placeholder: '车辆租金', placeholder: '车辆租金',
style: { width: '100%' }, style: { width: '100%' },
'aria-label': '车辆租金', 'aria-label': '车辆租金',
'data-annotation-id': 'lc-lease-order-rent',
}), }),
rentBelowMin ? React.createElement('p', {
className: 'lc-rent-min-warn',
'data-annotation-id': 'lc-lease-order-rent-min-warn',
role: 'alert',
}, '低于系统最低租金,将触发非标准合同审批' + (minRentHint ? '' + minRentHint + '' : '')) : null,
), ),
), ),
React.createElement('td', null, React.createElement('td', null,
@@ -1323,7 +1357,6 @@ function LeaseOrderEditor(props) {
}, },
placeholder: '月数', placeholder: '月数',
'aria-label': '车辆租赁期限', 'aria-label': '车辆租赁期限',
'data-annotation-id': 'lc-lease-order-lease-period',
}), }),
), ),
React.createElement('td', { className: 'lc-lease-order-editor__actions-col' }, React.createElement('td', { className: 'lc-lease-order-editor__actions-col' },
@@ -1488,7 +1521,15 @@ function PowerOfAttorneyEditor(props) {
function CustomerCredentialThumb(props) { function CustomerCredentialThumb(props) {
var label = props.label; var label = props.label;
var previewUrl = props.previewUrl; var previewUrl = props.previewUrl;
return React.createElement('div', { className: 'lc-attach-thumb' }, 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, { React.createElement(Image, {
className: 'lc-attach-thumb__img', className: 'lc-attach-thumb__img',
src: previewUrl, src: previewUrl,
@@ -1498,10 +1539,44 @@ function CustomerCredentialThumb(props) {
}), }),
React.createElement('div', { className: 'lc-attach-thumb__meta' }, React.createElement('div', { className: 'lc-attach-thumb__meta' },
React.createElement('span', { className: 'lc-attach-thumb__label' }, label), 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 CustomerCredentialsAlerts(props) {
var summary = props.summary;
if (!summary || (!summary.hasExpired && !summary.hasExpiring)) return null;
return React.createElement('div', { className: 'lc-credentials-alerts', role: 'status' },
summary.hasExpiring
? React.createElement('p', { className: 'lc-credentials-alert lc-credentials-alert--warn' },
'证照「'
+ summary.expiringItems.map(function (item) { return item.label; }).join('、')
+ '」将在 '
+ CREDENTIAL_EXPIRY_WARN_MONTHS
+ ' 个月内到期,系统已提前在工作台生成待办,请业管人员及时更新客户档案。',
)
: null,
summary.hasExpired
? React.createElement('p', { className: 'lc-credentials-alert lc-credentials-alert--danger' },
'证照「'
+ summary.expiredItems.map(function (item) { return item.label; }).join('、')
+ '」已超过有效期。业管未更新前,禁止提交新合同。',
)
: null,
);
}
function FormFieldLabelHint(props) { function FormFieldLabelHint(props) {
if (!props.text) return null; if (!props.text) return null;
@@ -1613,6 +1688,7 @@ function EditorCard(props) {
className: 'vm-detail-card lc-form-card' + (collapsible && !expanded ? ' is-collapsed' : ''), className: 'vm-detail-card lc-form-card' + (collapsible && !expanded ? ' is-collapsed' : ''),
'aria-labelledby': props.titleId, 'aria-labelledby': props.titleId,
'aria-describedby': hintEl ? hintId : undefined, 'aria-describedby': hintEl ? hintId : undefined,
'data-annotation-id': props.annotationId || undefined,
}, },
collapsible collapsible
? React.createElement('div', { className: 'lc-form-card__header' }, titleRowEl, toggleEl) ? React.createElement('div', { className: 'lc-form-card__header' }, titleRowEl, toggleEl)
@@ -1638,6 +1714,10 @@ export default function LeaseContractEditorForm(props) {
var onBusinessAssignmentChange = props.onBusinessAssignmentChange; var onBusinessAssignmentChange = props.onBusinessAssignmentChange;
var onMileageChange = props.onMileageChange; var onMileageChange = props.onMileageChange;
var onFeeInfoChange = props.onFeeInfoChange; var onFeeInfoChange = props.onFeeInfoChange;
var customerPrincipalName = props.customerPrincipalName || '';
var customerPrincipalPhone = props.customerPrincipalPhone || '';
var onCustomerPrincipalChange = props.onCustomerPrincipalChange;
var contractCodeReadonly = props.contractCodeReadonly !== false;
var lessorCompanies = getLessorCompanies(); var lessorCompanies = getLessorCompanies();
var selectedLessor = getLessorCompanyById(lessorId); var selectedLessor = getLessorCompanyById(lessorId);
@@ -1672,24 +1752,30 @@ export default function LeaseContractEditorForm(props) {
React.createElement(EditorCard, { React.createElement(EditorCard, {
titleId: 'lc-card-signing', titleId: 'lc-card-signing',
title: '签约信息', title: '签约信息',
desc: '填写合同编号并选择甲乙双方;下方将自动带出档案信息。', desc: '系统自动生成合同编号并选择甲乙双方;下方将自动带出档案信息。',
collapsible: true, collapsible: true,
expanded: signingExpanded, expanded: signingExpanded,
onToggle: function () { setSigningExpanded(!signingExpanded); }, onToggle: function () { setSigningExpanded(!signingExpanded); },
bodyId: 'lc-card-signing-body', bodyId: 'lc-card-signing-body',
annotationId: 'lc-card-signing',
}, },
React.createElement('div', { className: 'lc-signing-core' }, React.createElement('div', { className: 'lc-signing-core' },
React.createElement('div', { className: 'lc-form-grid' }, React.createElement('div', { className: 'lc-form-grid' },
React.createElement(FormField, { label: '合同编号', required: true }, React.createElement(FormField, { label: '合同编号', required: true },
React.createElement(Input, { React.createElement('div', {
className: 'lc-form-input lc-contract-code-input vm-focus-border', className: 'lc-contract-code-auto',
addonBefore: CONTRACT_CODE_PREFIX, 'data-annotation-id': 'lc-contract-code-auto',
value: contractCode, },
onChange: function (e) { onContractCodeChange(e.target.value); }, React.createElement('span', { className: 'lc-contract-code-auto__prefix' }, CONTRACT_CODE_PREFIX),
placeholder: '请输入合同编号', React.createElement(ReadonlyValue, {
allowClear: true, value: contractCode || '—',
'aria-label': '合同编号', label: '合同编号',
}), emptyHint: '系统生成中…',
}),
contractCodeReadonly
? React.createElement('span', { className: 'lc-contract-code-auto__tag' }, '系统自动生成')
: null,
),
), ),
React.createElement(FormField, { React.createElement(FormField, {
label: '业务部门 / 业务人员', label: '业务部门 / 业务人员',
@@ -1738,6 +1824,39 @@ export default function LeaseContractEditorForm(props) {
), ),
), ),
), ),
customerId ? React.createElement('div', {
className: 'lc-form-grid lc-form-grid--pair lc-signing-customer-principal',
'data-annotation-id': 'lc-signing-customer-principal',
},
React.createElement(FormField, { label: '乙方负责人姓名' },
React.createElement(Input, {
className: 'lc-form-input vm-focus-border',
value: customerPrincipalName,
maxLength: 32,
placeholder: '请输入乙方负责人姓名',
onChange: function (e) {
if (onCustomerPrincipalChange) {
onCustomerPrincipalChange(e.target.value, customerPrincipalPhone);
}
},
'aria-label': '乙方负责人姓名',
}),
),
React.createElement(FormField, { label: '乙方负责人手机号' },
React.createElement(Input, {
className: 'lc-form-input vm-focus-border',
value: customerPrincipalPhone,
maxLength: 11,
placeholder: '请输入手机号',
onChange: function (e) {
if (onCustomerPrincipalChange) {
onCustomerPrincipalChange(customerPrincipalName, e.target.value);
}
},
'aria-label': '乙方负责人手机号',
}),
),
) : null,
lessorId || customerId lessorId || customerId
? React.createElement('div', { className: 'lc-party-profiles' }, ? React.createElement('div', { className: 'lc-party-profiles' },
lessorId ? React.createElement(ReadonlyPanel, { lessorId ? React.createElement(ReadonlyPanel, {
@@ -1789,25 +1908,39 @@ export default function LeaseContractEditorForm(props) {
React.createElement(FormField, { label: '邮箱' }, React.createElement(ReadonlyValue, { value: customerContact.email, emptyHint: customerEmptyHint })), React.createElement(FormField, { label: '邮箱' }, React.createElement(ReadonlyValue, { value: customerContact.email, emptyHint: customerEmptyHint })),
), ),
) : null, ) : null,
customerId ? React.createElement('div', { className: 'lc-credentials-block' }, customerId ? (function () {
React.createElement('div', { className: 'lc-credentials-block__head' }, var credentialSummary = summarizeCustomerCredentials(selectedCustomer);
React.createElement('h4', { className: 'lc-credentials-block__title' }, '客户资质证照'), return React.createElement('div', {
React.createElement('span', { className: 'lc-credentials-block__hint' }, '点击缩略图可放大预览'), className: 'lc-credentials-block',
), 'data-annotation-id': 'lc-credentials-ocr',
React.createElement('div', {
className: 'lc-attach-gallery',
'aria-label': '客户资质证照预览',
}, },
CUSTOMER_CREDENTIAL_ITEMS.map(function (item) { React.createElement('div', { className: 'lc-credentials-block__head' },
var previewUrl = getCustomerAttachmentPreviewUrl(selectedCustomer, item.key, item.label); React.createElement('h4', { className: 'lc-credentials-block__title' }, '客户资质证照'),
return React.createElement(CustomerCredentialThumb, { React.createElement('span', { className: 'lc-credentials-block__hint' }, 'OCR 识别有效期 · 点击缩略图放大'),
key: item.key, ),
label: item.label, React.createElement('p', { className: 'lc-credentials-block__policy' },
previewUrl: previewUrl, '营业执照、法人身份证正反面及道路运输许可证上传后自动 OCR 识别并校验有效期;'
}); + '距到期 '
}), + CREDENTIAL_EXPIRY_WARN_MONTHS
), + ' 个月内将在工作台生成待办推送给业管;存在已过期证照时禁止提交新合同。',
) : null, ),
React.createElement(CustomerCredentialsAlerts, { summary: credentialSummary }),
React.createElement('div', {
className: 'lc-attach-gallery',
'aria-label': '客户资质证照预览',
},
credentialSummary.items.map(function (item) {
var previewUrl = getCustomerAttachmentPreviewUrl(selectedCustomer, item.key, item.label);
return React.createElement(CustomerCredentialThumb, {
key: item.key,
label: item.label,
previewUrl: previewUrl,
meta: item,
});
}),
),
);
}()) : null,
) )
: React.createElement('div', { className: 'lc-party-profiles-empty', role: 'status' }, : React.createElement('div', { className: 'lc-party-profiles-empty', role: 'status' },
React.createElement('p', { className: 'lc-party-profiles-empty__title' }, '选择签约双方后展示档案'), React.createElement('p', { className: 'lc-party-profiles-empty__title' }, '选择签约双方后展示档案'),
@@ -1878,6 +2011,41 @@ export default function LeaseContractEditorForm(props) {
); );
}), }),
), ),
React.createElement('div', {
className: 'lc-mileage-reduction',
'data-annotation-id': 'lc-card-mileage-reduction',
},
React.createElement(FormField, { label: '租金减免金额', required: true },
React.createElement(InputNumber, {
className: 'lc-form-number vm-focus-border',
controls: false,
min: 0,
step: 100,
precision: 0,
value: mileage.reductionYuan,
onChange: function (v) {
patchMileage({ reductionYuan: v != null ? v : DEFAULT_MILEAGE_STANDARD.reductionYuan });
},
addonAfter: '元/辆',
style: { width: '100%' },
'aria-label': '租金减免金额',
}),
),
React.createElement(FormField, { label: '减免有效期至' },
React.createElement(DatePicker, {
className: 'lc-form-datepicker vm-focus-border',
style: { width: '100%' },
value: mileage.validUntil ? dayjs(mileage.validUntil) : null,
onChange: function (value) {
patchMileage({ validUntil: value ? value.format('YYYY-MM-DD') : null });
},
'aria-label': '租金减免有效期',
}),
),
React.createElement(FieldHint, { annotationId: 'lc-mileage-reduction-hint' },
'与合同正文 2.1.5 租金减免条款联动;附件章节删减后,预览区将自动重排剩余章节序号。',
),
),
) : null, ) : null,
), ),
), ),
@@ -1915,6 +2083,7 @@ export default function LeaseContractEditorForm(props) {
}, },
options: HYDROGEN_PAYMENT_METHOD_OPTIONS, options: HYDROGEN_PAYMENT_METHOD_OPTIONS,
'aria-label': '氢费支付方式', 'aria-label': '氢费支付方式',
'data-annotation-id': 'lc-hydrogen-payment-method',
}), }),
), ),
showPrepayAmount ? React.createElement(FormField, { label: '预付款金额', required: true }, showPrepayAmount ? React.createElement(FormField, { label: '预付款金额', required: true },
@@ -1985,32 +2154,35 @@ export function LeaseContractLeaseOrderSection(props) {
export function LeaseContractPowerOfAttorneySection(props) { export function LeaseContractPowerOfAttorneySection(props) {
var powerOfAttorney = props.powerOfAttorney; var powerOfAttorney = props.powerOfAttorney;
var onPowerOfAttorneyChange = props.onPowerOfAttorneyChange; var onPowerOfAttorneyChange = props.onPowerOfAttorneyChange;
var contractRemark = props.contractRemark;
var onContractRemarkChange = props.onContractRemarkChange;
return React.createElement('div', { className: 'lc-create-editor-scroll lc-create-editor-scroll--section' }, return React.createElement('div', { className: 'lc-create-editor-scroll lc-create-editor-scroll--section' },
React.createElement(PowerOfAttorneyEditor, { React.createElement(PowerOfAttorneyEditor, {
poa: powerOfAttorney || createDefaultPowerOfAttorneyState(), poa: powerOfAttorney || createDefaultPowerOfAttorneyState(),
onChange: onPowerOfAttorneyChange, onChange: onPowerOfAttorneyChange,
}), }),
React.createElement('div', { );
className: 'lc-contract-remark', }
'data-annotation-id': 'lc-create-contract-remark',
}, export function LeaseContractRemarkSection(props) {
React.createElement(FormField, { label: '合同备注' }, var contractRemark = props.contractRemark;
React.createElement(Input.TextArea, { var onContractRemarkChange = props.onContractRemarkChange;
className: 'lc-contract-remark__textarea vm-focus-border',
rows: 4, return React.createElement('div', {
maxLength: 500, className: 'lc-create-editor-scroll lc-create-editor-scroll--section lc-contract-remark-section',
showCount: true, },
value: contractRemark || '', React.createElement(FormField, { label: '合同备注' },
onChange: function (e) { React.createElement(Input.TextArea, {
if (onContractRemarkChange) onContractRemarkChange(e.target.value); className: 'lc-contract-remark__textarea vm-focus-border',
}, rows: 4,
placeholder: '请输入合同备注(选填)', maxLength: 500,
'aria-label': '合同备注', showCount: true,
}), value: contractRemark || '',
), onChange: function (e) {
if (onContractRemarkChange) onContractRemarkChange(e.target.value);
},
placeholder: '请输入合同备注(选填)',
'aria-label': '合同备注',
}),
), ),
); );
} }

View File

@@ -1,4 +1,4 @@
// 在租车辆概览 — 按品牌型号统计 // 在租车辆概览 — 按品牌型号统计KPI 卡片 + 下方车型面板 / 隐藏后弹层)
import { buildOnLeaseFleetByBrandModel } from './lease-contract-list-data.js'; import { buildOnLeaseFleetByBrandModel } from './lease-contract-list-data.js';
function FleetDetailModal(props) { function FleetDetailModal(props) {
@@ -71,18 +71,126 @@ function FleetDetailModal(props) {
); );
} }
function FleetModelCard(props) {
var item = props.item;
var totalOnLease = props.totalOnLease;
var maxCount = props.maxCount;
var variant = props.variant || 'default';
var onOpenDetail = props.onOpenDetail;
var share = totalOnLease ? Math.round((item.count / totalOnLease) * 100) : 0;
var barWidth = maxCount ? Math.max(8, Math.round((item.count / maxCount) * 100)) : 8;
return React.createElement('article', {
className: 'lc-fleet-summary__card' + (variant === 'compact' ? ' lc-fleet-summary__card--compact' : ''),
role: 'listitem',
tabIndex: 0,
onClick: function () { onOpenDetail(item); },
onKeyDown: function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenDetail(item);
}
},
'aria-label': item.brand + ' ' + item.model + '' + item.count + ' 辆在租,点击查看明细',
},
React.createElement('div', { className: 'lc-fleet-summary__card-top' },
React.createElement('span', { className: 'lc-fleet-summary__card-brand' }, item.brand),
React.createElement('span', { className: 'lc-fleet-summary__card-type' }, item.vehicleType),
),
React.createElement('h3', { className: 'lc-fleet-summary__card-model', title: item.model }, item.model),
React.createElement('div', { className: 'lc-fleet-summary__card-metrics' },
React.createElement('span', { className: 'lc-fleet-summary__card-count tabular-nums' }, item.count),
React.createElement('span', { className: 'lc-fleet-summary__card-unit' }, '辆'),
variant !== 'compact'
? React.createElement('span', { className: 'lc-fleet-summary__card-share tabular-nums' }, '占 ' + share + '%')
: null,
),
variant !== 'compact'
? React.createElement('div', { className: 'lc-fleet-summary__card-bar', 'aria-hidden': true },
React.createElement('span', {
className: 'lc-fleet-summary__card-bar-fill',
style: { width: barWidth + '%' },
}),
)
: null,
React.createElement('span', { className: 'lc-fleet-summary__card-hint' }, '点击查看车辆明细'),
);
}
function FleetQuickViewModal(props) {
var Modal = props.Modal;
var open = props.open;
var onClose = props.onClose;
var onPinPanel = props.onPinPanel;
var summary = props.summary;
var onOpenDetail = props.onOpenDetail;
if (!Modal || !open) return null;
var totalOnLease = summary.reduce(function (sum, item) { return sum + item.count; }, 0);
var maxCount = summary.length ? Math.max.apply(null, summary.map(function (item) { return item.count; })) : 1;
return React.createElement(Modal, {
className: 'lc-fleet-quick-modal',
rootClassName: 'lc-fleet-quick-modal-root',
title: React.createElement('div', { className: 'lc-fleet-quick-modal__title' },
React.createElement('span', null, '在租车辆 · 各车型分布'),
React.createElement('span', { className: 'lc-fleet-quick-modal__badge tabular-nums' }, totalOnLease + ' 辆'),
),
open: open,
onCancel: onClose,
footer: React.createElement('div', { className: 'lc-fleet-quick-modal__footer' },
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-ghost',
onClick: onClose,
}, '关闭'),
React.createElement('button', {
type: 'button',
className: 'vm-btn vm-btn-primary',
onClick: function () {
onPinPanel();
onClose();
},
}, '展开固定面板'),
),
width: 720,
centered: true,
destroyOnClose: true,
},
summary.length
? React.createElement('div', { className: 'lc-fleet-summary__grid lc-fleet-summary__grid--quick', role: 'list' },
summary.map(function (item) {
return React.createElement(FleetModelCard, {
key: item.key,
item: item,
totalOnLease: totalOnLease,
maxCount: maxCount,
variant: 'compact',
onOpenDetail: onOpenDetail,
});
}),
)
: React.createElement('div', { className: 'lc-fleet-summary__empty' }, '暂无在租车辆'),
);
}
export default function LeaseContractFleetSummary(props) { export default function LeaseContractFleetSummary(props) {
var records = props.records || []; var records = props.records || [];
var Modal = props.Modal; var Modal = props.Modal;
var renderVehicleNativeTableHeader = props.renderVehicleNativeTableHeader; var renderVehicleNativeTableHeader = props.renderVehicleNativeTableHeader;
var renderVehicleNativeTableRow = props.renderVehicleNativeTableRow; var renderVehicleNativeTableRow = props.renderVehicleNativeTableRow;
var expanded = Boolean(props.expanded);
var panelHidden = Boolean(props.panelHidden);
var quickViewOpen = Boolean(props.quickViewOpen);
var onExpandedChange = props.onExpandedChange;
var onPanelHiddenChange = props.onPanelHiddenChange;
var onQuickViewOpenChange = props.onQuickViewOpenChange;
var useState = React.useState; var useState = React.useState;
var useMemo = React.useMemo; var useMemo = React.useMemo;
var _expanded = useState(false);
var expanded = _expanded[0];
var setExpanded = _expanded[1];
var _brand = useState('全部'); var _brand = useState('全部');
var brandFilter = _brand[0]; var brandFilter = _brand[0];
var setBrandFilter = _brand[1]; var setBrandFilter = _brand[1];
@@ -103,6 +211,17 @@ export default function LeaseContractFleetSummary(props) {
setDetailItem(null); setDetailItem(null);
} }
function handleHidePanel() {
if (onPanelHiddenChange) onPanelHiddenChange(true);
if (onExpandedChange) onExpandedChange(false);
}
function handlePinPanel() {
if (onPanelHiddenChange) onPanelHiddenChange(false);
if (onExpandedChange) onExpandedChange(true);
if (onQuickViewOpenChange) onQuickViewOpenChange(false);
}
var summary = useMemo(function () { var summary = useMemo(function () {
return buildOnLeaseFleetByBrandModel(records); return buildOnLeaseFleetByBrandModel(records);
}, [records]); }, [records]);
@@ -127,112 +246,85 @@ export default function LeaseContractFleetSummary(props) {
if (!totalOnLease) return null; if (!totalOnLease) return null;
return React.createElement('section', { var showBody = expanded && !panelHidden;
className: 'lc-fleet-summary' + (expanded ? ' is-expanded' : ' is-collapsed'),
'data-annotation-id': 'lc-fleet-summary', return React.createElement(React.Fragment, null,
'aria-label': '在租车辆概览', showBody
}, ? React.createElement('section', {
React.createElement('header', { className: 'lc-fleet-summary__header' }, className: 'lc-fleet-summary is-expanded is-embedded',
React.createElement('div', { className: 'lc-fleet-summary__title-block' }, 'aria-label': '在租车辆概览',
React.createElement('div', { className: 'lc-fleet-summary__title-row' }, },
React.createElement('span', { className: 'lc-fleet-summary__icon', 'aria-hidden': true }, React.createElement('div', { className: 'lc-fleet-summary__body' },
React.createElement('svg', { React.createElement('div', { className: 'lc-fleet-summary__body-toolbar' },
xmlns: 'http://www.w3.org/2000/svg', width: 20, height: 20, viewBox: '0 0 24 24', React.createElement('div', { className: 'lc-fleet-summary__brands', role: 'group', 'aria-label': '按品牌筛选' },
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', React.createElement('button', {
}, type: 'button',
React.createElement('path', { d: 'M14 16H9m10 0h3v-3.5a1.5 1.5 0 0 0-1.5-1.5h-15A1.5 1.5 0 0 0 3 16.5V20h3' }), className: 'lc-fleet-summary__brand-chip' + (brandFilter === '全部' ? ' is-active' : ''),
React.createElement('circle', { cx: 7.5, cy: 17.5, r: 2.5 }), onClick: function () { setBrandFilter('全部'); },
React.createElement('circle', { cx: 17.5, cy: 17.5, r: 2.5 }), 'aria-pressed': brandFilter === '全部',
), }, '全部'),
), brandOptions.map(function (brand) {
React.createElement('h2', { className: 'lc-fleet-summary__title' }, '在租车辆'), return React.createElement('button', {
React.createElement('span', { className: 'lc-fleet-summary__badge tabular-nums' }, totalOnLease + ' 辆'), key: brand,
), type: 'button',
), className: 'lc-fleet-summary__brand-chip' + (brandFilter === brand ? ' is-active' : ''),
React.createElement('div', { className: 'lc-fleet-summary__toolbar' }, onClick: function () { setBrandFilter(brandFilter === brand ? '全部' : brand); },
React.createElement('button', { 'aria-pressed': brandFilter === brand,
type: 'button', }, brand);
className: 'lc-fleet-summary__toggle',
onClick: function () { setExpanded(!expanded); },
'aria-expanded': expanded,
'aria-label': expanded ? '收起在租车辆概览' : '查看在租车辆详情',
},
React.createElement('svg', {
xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24',
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
'aria-hidden': true,
className: 'lc-fleet-summary__toggle-icon',
},
React.createElement('path', { d: expanded ? 'm18 15-6-6-6 6' : 'm6 9 6 6 6-6' }),
),
expanded ? '收起' : '查看在租车辆详情',
),
),
),
expanded ? React.createElement('div', { className: 'lc-fleet-summary__body' },
React.createElement('div', { className: 'lc-fleet-summary__brands', role: 'group', 'aria-label': '按品牌筛选' },
React.createElement('button', {
type: 'button',
className: 'lc-fleet-summary__brand-chip' + (brandFilter === '全部' ? ' is-active' : ''),
onClick: function () { setBrandFilter('全部'); },
'aria-pressed': brandFilter === '全部',
}, '全部'),
brandOptions.map(function (brand) {
return React.createElement('button', {
key: brand,
type: 'button',
className: 'lc-fleet-summary__brand-chip' + (brandFilter === brand ? ' is-active' : ''),
onClick: function () { setBrandFilter(brandFilter === brand ? '全部' : brand); },
'aria-pressed': brandFilter === brand,
}, brand);
}),
),
filtered.length ? React.createElement('div', { className: 'lc-fleet-summary__grid', role: 'list' },
filtered.map(function (item) {
var share = Math.round((item.count / totalOnLease) * 100);
var barWidth = Math.max(8, Math.round((item.count / maxCount) * 100));
return React.createElement('article', {
key: item.key,
className: 'lc-fleet-summary__card',
role: 'listitem',
tabIndex: 0,
onClick: function () { openDetail(item); },
onKeyDown: function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openDetail(item);
}
},
'aria-label': item.brand + ' ' + item.model + '' + item.count + ' 辆在租,点击查看明细',
},
React.createElement('div', { className: 'lc-fleet-summary__card-top' },
React.createElement('span', { className: 'lc-fleet-summary__card-brand' }, item.brand),
React.createElement('span', { className: 'lc-fleet-summary__card-type' }, item.vehicleType),
),
React.createElement('h3', { className: 'lc-fleet-summary__card-model', title: item.model }, item.model),
React.createElement('div', { className: 'lc-fleet-summary__card-metrics' },
React.createElement('span', { className: 'lc-fleet-summary__card-count tabular-nums' }, item.count),
React.createElement('span', { className: 'lc-fleet-summary__card-unit' }, '辆'),
React.createElement('span', { className: 'lc-fleet-summary__card-share tabular-nums' }, '占 ' + share + '%'),
),
React.createElement('div', { className: 'lc-fleet-summary__card-bar', 'aria-hidden': true },
React.createElement('span', {
className: 'lc-fleet-summary__card-bar-fill',
style: { width: barWidth + '%' },
}), }),
), ),
React.createElement('span', { className: 'lc-fleet-summary__card-hint' }, '点击查看车辆明细'), React.createElement('button', {
); type: 'button',
}), className: 'lc-fleet-summary__hide-btn',
) : React.createElement('div', { className: 'lc-fleet-summary__empty' }, onClick: handleHidePanel,
React.createElement('p', null, '该品牌暂无在租车型'), 'aria-label': '隐藏在租车辆面板',
React.createElement('button', { },
type: 'button', React.createElement('svg', {
className: 'lc-fleet-summary__empty-reset', xmlns: 'http://www.w3.org/2000/svg', width: 14, height: 14, viewBox: '0 0 24 24',
onClick: function () { setBrandFilter('全部'); }, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
}, '查看全部'), 'aria-hidden': true,
), },
) : null, React.createElement('path', { d: 'M9.88 9.88a3 3 0 1 0 4.24 4.24' }),
React.createElement('path', { d: 'M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68' }),
React.createElement('path', { d: 'M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61' }),
React.createElement('line', { x1: 2, y1: 2, x2: 22, y2: 22 }),
),
'隐藏',
),
),
filtered.length
? React.createElement('div', { className: 'lc-fleet-summary__grid', role: 'list' },
filtered.map(function (item) {
return React.createElement(FleetModelCard, {
key: item.key,
item: item,
totalOnLease: totalOnLease,
maxCount: maxCount,
onOpenDetail: openDetail,
});
}),
)
: React.createElement('div', { className: 'lc-fleet-summary__empty' },
React.createElement('p', null, '该品牌暂无在租车型'),
React.createElement('button', {
type: 'button',
className: 'lc-fleet-summary__empty-reset',
onClick: function () { setBrandFilter('全部'); },
}, '查看全部'),
),
),
)
: null,
React.createElement(FleetQuickViewModal, {
Modal: Modal,
open: panelHidden && quickViewOpen,
onClose: function () {
if (onQuickViewOpenChange) onQuickViewOpenChange(false);
},
onPinPanel: handlePinPanel,
summary: summary,
onOpenDetail: openDetail,
}),
React.createElement(FleetDetailModal, { React.createElement(FleetDetailModal, {
Modal: Modal, Modal: Modal,
item: detailItem, item: detailItem,
@@ -243,3 +335,5 @@ export default function LeaseContractFleetSummary(props) {
}), }),
); );
} }
export { buildOnLeaseFleetByBrandModel };

View File

@@ -7,6 +7,7 @@ import { StatusTag } from '../vehicle-management/components/StatusTag';
import { import {
LEASE_CONTRACT_LIST_RECORDS, LEASE_CONTRACT_LIST_RECORDS,
enrichLeaseContractListRecords, enrichLeaseContractListRecords,
buildOnLeaseFleetByBrandModel,
formatDeliveryRegion, formatDeliveryRegion,
formatPaymentPeriodLabel, formatPaymentPeriodLabel,
formatHydrogenPaymentLabel, formatHydrogenPaymentLabel,
@@ -43,6 +44,8 @@ import {
formatVehicleDeliveryPlanDateLabel, formatVehicleDeliveryPlanDateLabel,
resolveVehicleDeliveryDateTbd, resolveVehicleDeliveryDateTbd,
resolveVehiclePlannedDeliveryDate, resolveVehiclePlannedDeliveryDate,
isContractExpiringForKpi,
isContractTerminatedForKpi,
resolveContractStatusByVehicleLease, resolveContractStatusByVehicleLease,
} from './lease-contract-list-data.js'; } from './lease-contract-list-data.js';
import { getLessorCompanies } from '../contract-template-management/contract-template-vars.js'; import { getLessorCompanies } from '../contract-template-management/contract-template-vars.js';
@@ -83,8 +86,11 @@ const Component = function(props) {
// 筛选展开(默认收起,只显示第一行 4 列,与车辆管理一致) // 筛选展开(默认收起,只显示第一行 4 列,与车辆管理一致)
var _filterExpanded = useState(false); var _filterExpanded = useState(false);
var _kpiTab = useState('all'); var _kpiTab = useState('all');
var _fleetExpanded = useState(false);
var _fleetPanelHidden = useState(false);
var _fleetQuickOpen = useState(false);
var _page = useState(1); var _page = useState(1);
var _pageSize = useState(10); var _pageSize = useState(20);
var _view = useState('list'); var _view = useState('list');
var _createInitialSection = useState(null); var _createInitialSection = useState(null);
var _editContractRecord = useState(null); var _editContractRecord = useState(null);
@@ -140,6 +146,15 @@ const Component = function(props) {
var _stampModalVisible = useState(false); var _stampModalVisible = useState(false);
var _stampModalRecord = useState(null); var _stampModalRecord = useState(null);
var _stampFileList = useState([]); var _stampFileList = useState([]);
var _tripartiteModalVisible = useState(false);
var _tripartiteModalRecord = useState(null);
var _tripartiteAgreements = useState([]);
var _trialToFormalModalVisible = useState(false);
var _trialToFormalRecord = useState(null);
var _trialToFormalDelegates = useState([]);
var _trialToFormalVehicles = useState([]);
var _trialToFormalTripartite = useState(false);
var _trialToFormalAgreements = useState([]);
// 上传盖章合同完成后,按合同 id 记录已上传(原型:与列表 legalStampedContractUploaded 合并判断) // 上传盖章合同完成后,按合同 id 记录已上传(原型:与列表 legalStampedContractUploaded 合并判断)
var _stampedUploadedOverride = useState({}); var _stampedUploadedOverride = useState({});
// 撤回等列表行状态覆盖(原型) // 撤回等列表行状态覆盖(原型)
@@ -238,57 +253,37 @@ const Component = function(props) {
var KPI_CARDS = [ var KPI_CARDS = [
{ key: 'all', title: '全部合同', desc: '显示全部合同' }, { key: 'all', title: '全部合同', desc: '显示全部合同' },
{ key: 'inProgress', title: '进行中', desc: '显示所有进行中的合同;(进行中指合同在有效期内,且合同状态在进行中的合同)' }, { key: 'inProgress', title: '进行中', desc: '显示至少有一辆车辆已交车但尚未还车的合同' },
{ key: 'terminated', title: '已终止', desc: '显示所有合同状态为「已终止」的合同' }, { key: 'inApproval', title: '审批中', desc: '显示审批状态为「待审批」或「审批中」的合同' },
{ key: 'expiring', title: '临期合同', desc: '显示所有离过期日期还有30天的合同' }, { key: 'expiring', title: '临期合同', desc: '显示最后一辆在租车辆对应合同,且距离合同签订有效期不足 30 天的合同' },
{ key: 'expired', title: '过期合同', desc: '显示所有合同状态为「已过期」的合同' } { key: 'terminated', title: '已终止', desc: '显示所有车辆已还车,或因续签/转三方产生新合同而自动终止的合同' }
]; ];
function parseContractDateYmd(str) { function contractHasDeliveredNotReturnedVehicle(record) {
if (!str) return null; var vehicles = record && record.vehicles;
var parts = String(str).split('-'); if (!vehicles || !vehicles.length) return false;
if (parts.length < 3) return null; for (var vi = 0; vi < vehicles.length; vi++) {
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); if (canContractVehicleReturn(vehicles[vi])) return true;
}
return false;
} }
function daysUntilContractEnd(endDateStr) { function isInApprovalStatus(status) {
var end = parseContractDateYmd(endDateStr); return status === '待审批' || status === '审批中';
if (!end) return null;
var today = new Date();
today.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.round((end.getTime() - today.getTime()) / 86400000);
}
function isInProgressContractStatus(status) {
return status === '合同进行中' || status === '进行中';
}
function isTerminatedContractStatus(status) {
return status === '已终止' || status === '已结束';
}
function isExpiredContractStatus(status) {
return status === '已过期' || status === '到期合同';
} }
function matchContractKpi(record, key) { function matchContractKpi(record, key) {
switch (key) { switch (key) {
case 'all': case 'all':
return true; return true;
case 'inProgress': { case 'inProgress':
if (!isInProgressContractStatus(record.contractStatus)) return false; return contractHasDeliveredNotReturnedVehicle(record);
var remain = daysUntilContractEnd(record.contractEndDate); case 'inApproval':
return remain !== null && remain >= 0; return isInApprovalStatus(record.approvalStatus);
} case 'expiring':
return isContractExpiringForKpi(record);
case 'terminated': case 'terminated':
return isTerminatedContractStatus(record.contractStatus); return isContractTerminatedForKpi(record);
case 'expiring': {
var days = daysUntilContractEnd(record.contractEndDate);
return days !== null && days >= 0 && days <= 30;
}
case 'expired':
return isExpiredContractStatus(record.contractStatus);
default: default:
return true; return true;
} }
@@ -309,13 +304,13 @@ const Component = function(props) {
React.createElement('circle', { cx: 12, cy: 12, r: 10 }), React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
React.createElement('polygon', { points: '10 8 16 12 10 16 10 8' }) React.createElement('polygon', { points: '10 8 16 12 10 16 10 8' })
), ),
terminated: React.createElement('svg', { inApproval: React.createElement('svg', {
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24', xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
}, },
React.createElement('circle', { cx: 12, cy: 12, r: 10 }), React.createElement('path', { d: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' }),
React.createElement('path', { d: 'm15 9-6 6' }), React.createElement('path', { d: 'M14 2v4a2 2 0 0 0 2 2h4' }),
React.createElement('path', { d: 'm9 9 6 6' }) React.createElement('path', { d: 'm9 15 2 2 4-4' })
), ),
expiring: React.createElement('svg', { expiring: React.createElement('svg', {
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24', xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
@@ -324,13 +319,13 @@ const Component = function(props) {
React.createElement('circle', { cx: 12, cy: 12, r: 10 }), React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
React.createElement('polyline', { points: '12 6 12 12 16 14' }) React.createElement('polyline', { points: '12 6 12 12 16 14' })
), ),
expired: React.createElement('svg', { terminated: React.createElement('svg', {
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24', xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
}, },
React.createElement('circle', { cx: 12, cy: 12, r: 10 }), React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
React.createElement('line', { x1: 12, y1: 8, x2: 12, y2: 12 }), React.createElement('path', { d: 'm15 9-6 6' }),
React.createElement('line', { x1: 12, y1: 16, x2: 12.01, y2: 16 }) React.createElement('path', { d: 'm9 9 6 6' })
) )
}; };
@@ -362,6 +357,42 @@ const Component = function(props) {
}); });
}, [rawList, _recordOverrides[0]]); }, [rawList, _recordOverrides[0]]);
var fleetOnLeaseSummary = useMemo(function () {
return buildOnLeaseFleetByBrandModel(fleetSourceList);
}, [fleetSourceList]);
var fleetTotalOnLease = useMemo(function () {
return fleetOnLeaseSummary.reduce(function (sum, item) { return sum + item.count; }, 0);
}, [fleetOnLeaseSummary]);
var fleetKpiActive = _fleetPanelHidden[0] ? _fleetQuickOpen[0] : _fleetExpanded[0];
function handleFleetKpiClick() {
if (_fleetPanelHidden[0]) {
_fleetQuickOpen[1](!_fleetQuickOpen[0]);
return;
}
_fleetExpanded[1](!_fleetExpanded[0]);
}
function collapseFleetKpiPanel() {
_fleetExpanded[1](false);
_fleetQuickOpen[1](false);
}
var FLEET_KPI_ICON = React.createElement('svg', {
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
},
React.createElement('path', { d: 'M14 16H9m10 0h3v-3.5a1.5 1.5 0 0 0-1.5-1.5h-15A1.5 1.5 0 0 0 3 16.5V20h3' }),
React.createElement('circle', { cx: 7.5, cy: 17.5, r: 2.5 }),
React.createElement('circle', { cx: 17.5, cy: 17.5, r: 2.5 }),
);
var fleetKpiDesc = '按品牌型号统计在租车辆;点击展开下方车型卡片。隐藏面板后,再次点击将弹出各车型数量卡片。';
var listChromeOffsetPx = (_fleetExpanded[0] && !_fleetPanelHidden[0]) ? 592 : 468;
var filteredList = useMemo(function() { var filteredList = useMemo(function() {
var overrides = _recordOverrides[0] || {}; var overrides = _recordOverrides[0] || {};
var list = rawList.map(function (r) { var list = rawList.map(function (r) {
@@ -688,6 +719,180 @@ const Component = function(props) {
}); });
}, []); }, []);
function mockUploadRequest(opts) {
setTimeout(function() {
if (opts.onSuccess) opts.onSuccess('ok');
}, 200);
}
function createEmptyTripartiteAgreement() {
return {
id: 'tp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
name: '',
agreementFiles: [],
letterFiles: [],
};
}
function mapDelegatesForConversionModal(record) {
var delegates = getAuthorizedDelegates(record);
if (!delegates.length) {
return [{ name: '', phone: '', idCard: '' }];
}
return delegates.map(function(d) {
return {
name: d.name || '',
phone: d.contact || d.phone || '',
idCard: d.idNumber || d.idCard || '',
};
});
}
function mapVehiclesForFormalModal(record) {
return (record.vehicles || []).map(function(v, index) {
return {
key: String(index),
vehicleType: v.vehicleType || '-',
brand: v.brand || '',
model: v.model || '',
plateNo: v.plateNo || '-',
rent: v.rent != null ? v.rent : '',
leasePeriodMonths: v.leasePeriodMonths != null ? v.leasePeriodMonths : '',
};
});
}
function openTripartiteModal(record) {
_tripartiteModalRecord[1](record);
_tripartiteAgreements[1]([createEmptyTripartiteAgreement()]);
_tripartiteModalVisible[1](true);
}
function openTrialToFormalModal(record) {
_trialToFormalRecord[1](record);
_trialToFormalDelegates[1](mapDelegatesForConversionModal(record));
_trialToFormalVehicles[1](mapVehiclesForFormalModal(record));
_trialToFormalTripartite[1](false);
_trialToFormalAgreements[1]([createEmptyTripartiteAgreement()]);
_trialToFormalModalVisible[1](true);
}
function updateTripartiteAgreementList(setter, index, field, value) {
setter(function(prev) {
var list = prev.slice();
var row = list[index] || createEmptyTripartiteAgreement();
var next = {};
next[field] = value;
list[index] = Object.assign({}, row, next);
return list;
});
}
function addTripartiteAgreementRow(setter) {
setter(function(prev) { return prev.concat([createEmptyTripartiteAgreement()]); });
}
function removeTripartiteAgreementRow(setter, index) {
setter(function(prev) {
var list = prev.slice();
list.splice(index, 1);
return list.length ? list : [createEmptyTripartiteAgreement()];
});
}
var updateTrialToFormalDelegate = useCallback(function(index, field, value) {
_trialToFormalDelegates[1](function(prev) {
var list = prev.slice();
var row = list[index] || { name: '', phone: '', idCard: '' };
var next = {};
next[field] = value;
list[index] = Object.assign({}, row, next);
return list;
});
}, []);
var updateTrialToFormalVehicle = useCallback(function(index, field, value) {
_trialToFormalVehicles[1](function(prev) {
var list = prev.slice();
var row = list[index] || {};
var next = {};
next[field] = value;
list[index] = Object.assign({}, row, next);
return list;
});
}, []);
function renderTripartiteAgreementEditor(agreements, setAgreements, annotationId) {
return React.createElement('div', {
className: 'lc-tripartite-agreements',
'data-annotation-id': annotationId || undefined,
},
agreements.map(function(row, index) {
return React.createElement('div', { key: row.id || index, className: 'lc-tripartite-agreement-card' },
React.createElement('div', { className: 'lc-tripartite-agreement-card__head' },
React.createElement('span', { className: 'lc-tripartite-agreement-card__title' }, '三方协议 ' + (index + 1)),
agreements.length > 1
? React.createElement(Button, {
type: 'link',
size: 'small',
danger: true,
onClick: function() { removeTripartiteAgreementRow(setAgreements, index); },
}, '删除')
: null,
),
React.createElement('div', { className: 'lc-tripartite-agreement-card__field' },
React.createElement('label', { className: 'lc-tripartite-agreement-card__label' }, '协议名称'),
React.createElement(Input, {
value: row.name,
placeholder: '请输入三方协议名称',
onChange: function(e) {
updateTripartiteAgreementList(setAgreements, index, 'name', e.target.value);
},
}),
),
React.createElement('div', { className: 'lc-tripartite-agreement-card__field' },
React.createElement('label', { className: 'lc-tripartite-agreement-card__label' }, '三方协议附件'),
React.createElement(Upload, {
fileList: row.agreementFiles || [],
onChange: function(info) {
updateTripartiteAgreementList(setAgreements, index, 'agreementFiles', info.fileList);
},
customRequest: mockUploadRequest,
accept: '.pdf,.doc,.docx,image/*',
},
React.createElement(Button, null, '上传协议'),
),
),
React.createElement('div', { className: 'lc-tripartite-agreement-card__field' },
React.createElement('label', { className: 'lc-tripartite-agreement-card__label' }, '对方公函'),
React.createElement(Upload, {
fileList: row.letterFiles || [],
onChange: function(info) {
updateTripartiteAgreementList(setAgreements, index, 'letterFiles', info.fileList);
},
customRequest: mockUploadRequest,
accept: '.pdf,.doc,.docx,image/*',
},
React.createElement(Button, null, '上传公函'),
),
),
);
}),
React.createElement(Button, {
type: 'dashed',
className: 'lc-tripartite-agreements__add',
onClick: function() { addTripartiteAgreementRow(setAgreements); },
}, '新增三方协议'),
);
}
function renderModalContractCode(record) {
return React.createElement('div', { className: 'lc-flow-modal__meta' },
'合同编码:',
React.createElement('span', { className: 'lc-flow-modal__meta-value' }, record ? record.contractCode : '-'),
);
}
// 租赁/已交车 Popover卡片式车辆明细 // 租赁/已交车 Popover卡片式车辆明细
function renderVehiclePopoverIcon(type) { function renderVehiclePopoverIcon(type) {
@@ -718,6 +923,12 @@ const Component = function(props) {
return plate; return plate;
} }
var VEHICLE_PLATE_UNSELECTED_LABEL = '车辆待选';
function getVehiclePlateDisplayLabel(plateNo) {
return formatVehiclePlateLabel(plateNo) || VEHICLE_PLATE_UNSELECTED_LABEL;
}
function renderVehiclePopoverCard(vehicle, index, variant) { function renderVehiclePopoverCard(vehicle, index, variant) {
var plate = formatVehiclePlateLabel(vehicle.plateNo); var plate = formatVehiclePlateLabel(vehicle.plateNo);
var brandModel = [vehicle.brand, vehicle.model].filter(function (part) { var brandModel = [vehicle.brand, vehicle.model].filter(function (part) {
@@ -732,7 +943,7 @@ const Component = function(props) {
React.createElement('span', { className: 'lc-vehicle-popover-card__type', title: vehicle.vehicleType || '' }, vehicle.vehicleType || '-'), React.createElement('span', { className: 'lc-vehicle-popover-card__type', title: vehicle.vehicleType || '' }, vehicle.vehicleType || '-'),
plate plate
? React.createElement('span', { className: 'lc-vehicle-popover-card__plate tabular-nums' }, plate) ? React.createElement('span', { className: 'lc-vehicle-popover-card__plate tabular-nums' }, plate)
: React.createElement('span', { className: 'lc-vehicle-popover-card__plate lc-vehicle-popover-card__plate--pending' }, '车辆待选'), : React.createElement('span', { className: 'lc-vehicle-popover-card__plate lc-vehicle-popover-card__plate--pending' }, VEHICLE_PLATE_UNSELECTED_LABEL),
), ),
React.createElement('div', { className: 'lc-vehicle-popover-card__body' }, React.createElement('div', { className: 'lc-vehicle-popover-card__body' },
React.createElement('div', { className: 'lc-vehicle-popover-card__field' }, React.createElement('div', { className: 'lc-vehicle-popover-card__field' },
@@ -968,7 +1179,7 @@ const Component = function(props) {
})); }));
_extraFeeModalVisible[1](true); _extraFeeModalVisible[1](true);
} }); } });
items.push({ key: 'toTripartite', label: '变更为三方合同', onClick: function() { message.info('变更为三方合同(原型)'); } }); items.push({ key: 'toTripartite', label: '三方合同', onClick: function() { openTripartiteModal(record); } });
items.push({ key: 'terminate', label: '终止合同', danger: true, onClick: function() { _terminateModalRecord[1](record); _terminateModalVisible[1](true); } }); items.push({ key: 'terminate', label: '终止合同', danger: true, onClick: function() { _terminateModalRecord[1](record); _terminateModalVisible[1](true); } });
} }
if (status === '到期合同') { if (status === '到期合同') {
@@ -981,7 +1192,7 @@ const Component = function(props) {
items.push({ key: 'editReject', label: '编辑', onClick: function() { message.info('编辑(原型)'); } }); items.push({ key: 'editReject', label: '编辑', onClick: function() { message.info('编辑(原型)'); } });
} }
if (type === '试用合同') { if (type === '试用合同') {
items.push({ key: 'toFormal', label: '转正式合同', onClick: function() { message.info('转正式合同(原型)'); } }); items.push({ key: 'toFormal', label: '试用转正式', onClick: function() { openTrialToFormalModal(record); } });
} }
// 审批通过且法务审核环节尚未上传盖章合同附件时,法务部员工可在「更多」中上传(上传完成后入口关闭) // 审批通过且法务审核环节尚未上传盖章合同附件时,法务部员工可在「更多」中上传(上传完成后入口关闭)
if (approval === '审批通过' && isLegalDeptUser) { if (approval === '审批通过' && isLegalDeptUser) {
@@ -1077,7 +1288,7 @@ const Component = function(props) {
return React.createElement('div', { className: 'lc-contract-pickup-payment lc-contract-pickup-payment--empty' }, '—'); return React.createElement('div', { className: 'lc-contract-pickup-payment lc-contract-pickup-payment--empty' }, '—');
} }
var paid = Boolean(vehicle.pickupPaymentPaid); var paid = Boolean(vehicle.pickupPaymentPaid);
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌'; var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
var tag = React.createElement(StatusTag, { var tag = React.createElement(StatusTag, {
label: paid ? '已支付' : '未支付', label: paid ? '已支付' : '未支付',
tone: paid ? 'green' : 'red', tone: paid ? 'green' : 'red',
@@ -1105,7 +1316,7 @@ const Component = function(props) {
var displayLabel = resolveReturnSettlementDisplayLabel(status); var displayLabel = resolveReturnSettlementDisplayLabel(status);
var tone = getReturnSettlementDisplayTone(displayLabel); var tone = getReturnSettlementDisplayTone(displayLabel);
var mode = resolveReturnSettlementCellMode(status); var mode = resolveReturnSettlementCellMode(status);
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌'; var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
var tag = React.createElement(StatusTag, { var tag = React.createElement(StatusTag, {
label: displayLabel, label: displayLabel,
tone: tone, tone: tone,
@@ -1285,7 +1496,7 @@ const Component = function(props) {
if (!canContractVehicleReturn(vehicle)) { if (!canContractVehicleReturn(vehicle)) {
return React.createElement('span', { className: 'lc-contract-vehicle-action-empty' }, '—'); return React.createElement('span', { className: 'lc-contract-vehicle-action-empty' }, '—');
} }
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌'; var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
return React.createElement(Button, { return React.createElement(Button, {
type: 'link', type: 'link',
size: 'small', size: 'small',
@@ -1304,7 +1515,7 @@ const Component = function(props) {
} }
function renderVehicleInfoCell(vehicle) { function renderVehicleInfoCell(vehicle) {
var plateLabel = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌'; var plateLabel = getVehiclePlateDisplayLabel(vehicle.plateNo);
var vinLabel = vehicle.vin && String(vehicle.vin).trim() ? vehicle.vin : '-'; var vinLabel = vehicle.vin && String(vehicle.vin).trim() ? vehicle.vin : '-';
var brandModelLabel = formatVehicleBrandModelLabel(vehicle); var brandModelLabel = formatVehicleBrandModelLabel(vehicle);
return React.createElement('div', { className: 'lc-contract-vehicle-info' }, return React.createElement('div', { className: 'lc-contract-vehicle-info' },
@@ -1353,7 +1564,7 @@ const Component = function(props) {
var timeText = compact var timeText = compact
? formatContractHandoverDateTimeCompact(time) ? formatContractHandoverDateTimeCompact(time)
: timeTextFull; : timeTextFull;
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌'; var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
var jumpTitle = kind === 'delivery' var jumpTitle = kind === 'delivery'
? '点击查看交车管理:' + plate ? '点击查看交车管理:' + plate
: '点击查看还车管理:' + plate; : '点击查看还车管理:' + plate;
@@ -1580,12 +1791,12 @@ const Component = function(props) {
var expanded = _expandedRowKeys[0].indexOf(record.id) >= 0; var expanded = _expandedRowKeys[0].indexOf(record.id) >= 0;
if (!expandable) { if (!expandable) {
return React.createElement('span', { return React.createElement('span', {
className: 'ant-table-row-expand-icon ant-table-row-spaced', className: 'ant-table-row-expand-icon ant-table-row-expand-icon-spaced',
'aria-hidden': true, 'aria-hidden': true,
}); });
} }
return React.createElement('span', { return React.createElement('span', {
className: 'ant-table-row-expand-icon ' + (expanded ? 'ant-table-row-expanded' : 'ant-table-row-collapsed'), className: 'ant-table-row-expand-icon ' + (expanded ? 'ant-table-row-expand-icon-expanded' : 'ant-table-row-expand-icon-collapsed'),
role: 'button', role: 'button',
tabIndex: 0, tabIndex: 0,
'aria-label': expanded ? '收起车辆明细' : '展开车辆明细', 'aria-label': expanded ? '收起车辆明细' : '展开车辆明细',
@@ -1901,7 +2112,7 @@ const Component = function(props) {
var draftDate = _vehicleDeliveryChangeDraftDate[0]; var draftDate = _vehicleDeliveryChangeDraftDate[0];
var draftTbd = _vehicleDeliveryChangeDraftTbd[0]; var draftTbd = _vehicleDeliveryChangeDraftTbd[0];
var draftValue = draftDate && dayjs && !draftTbd ? dayjs(draftDate) : null; var draftValue = draftDate && dayjs && !draftTbd ? dayjs(draftDate) : null;
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌'; var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
return React.createElement('div', { return React.createElement('div', {
className: 'lc-delivery-change__panel', className: 'lc-delivery-change__panel',
role: 'dialog', role: 'dialog',
@@ -2248,6 +2459,7 @@ const Component = function(props) {
role: 'button', role: 'button',
tabIndex: 0, tabIndex: 0,
'aria-label': '更多操作', 'aria-label': '更多操作',
'data-annotation-id': 'lc-list-action-more',
onClick: function (e) { e.stopPropagation(); }, onClick: function (e) { e.stopPropagation(); },
}, renderOperationMoreIcon()), }, renderOperationMoreIcon()),
), ),
@@ -2518,11 +2730,17 @@ const Component = function(props) {
React.createElement('label', { key: 'endDate', className: 'vm-filter-field' }, React.createElement('label', { key: 'endDate', className: 'vm-filter-field' },
React.createElement('span', null, '合同结束日期'), React.createElement('span', null, '合同结束日期'),
React.createElement(RangePicker, { React.createElement(RangePicker, {
className: 'lc-filter-select', className: 'lc-filter-select lc-filter-date-range',
popupClassName: 'lc-filter-date-dropdown',
style: { width: '100%' }, style: { width: '100%' },
placeholder: ['开始日期', '结束日期'], placeholder: ['开始日期', '结束日期'],
separator: '至',
format: 'YYYY-MM-DD',
picker: 'date',
allowClear: true,
inputReadOnly: true,
value: _endDateRange[0], value: _endDateRange[0],
onChange: function(v) { _endDateRange[1](v); } onChange: function(v) { _endDateRange[1](v); },
}) })
) )
]; ];
@@ -2542,7 +2760,10 @@ const Component = function(props) {
} }
return React.createElement(App, null, return React.createElement(App, null,
React.createElement('div', { className: 'vm-page lc-page' }, React.createElement('div', {
className: 'vm-page lc-page lc-page--list-dense',
style: { '--lc-list-chrome-offset': listChromeOffsetPx + 'px' },
},
React.createElement('style', null, ONEOS_ANT_TABLE_GLOBAL_FIX.join('\n')), React.createElement('style', null, ONEOS_ANT_TABLE_GLOBAL_FIX.join('\n')),
React.createElement('section', { className: 'vm-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' }, React.createElement('section', { className: 'vm-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' },
React.createElement('header', { className: 'vm-filter-header' }, React.createElement('header', { className: 'vm-filter-header' },
@@ -2578,13 +2799,19 @@ const Component = function(props) {
) )
) )
), ),
React.createElement('div', { className: 'vm-kpi-row', role: 'group', 'aria-label': '合同统计', 'data-annotation-id': 'lc-list-kpi' }, React.createElement('div', {
className: 'vm-kpi-row' + (fleetTotalOnLease > 0 ? ' vm-kpi-row--with-fleet' : ''),
role: 'group',
'aria-label': '合同统计',
'data-annotation-id': 'lc-list-kpi',
},
KPI_CARDS.map(function(card) { KPI_CARDS.map(function(card) {
return React.createElement('button', { return React.createElement('button', {
key: card.key, key: card.key,
type: 'button', type: 'button',
className: 'vm-kpi-card' + (_kpiTab[0] === card.key ? ' active' : ''), className: 'vm-kpi-card' + (_kpiTab[0] === card.key ? ' active' : ''),
onClick: function() { onClick: function() {
collapseFleetKpiPanel();
_kpiTab[1](card.key); _kpiTab[1](card.key);
_page[1](1); _page[1](1);
}, },
@@ -2607,13 +2834,46 @@ const Component = function(props) {
React.createElement('span', { className: 'vm-kpi-val' }, kpiCounts[card.key] != null ? kpiCounts[card.key] : 0) React.createElement('span', { className: 'vm-kpi-val' }, kpiCounts[card.key] != null ? kpiCounts[card.key] : 0)
) )
); );
}) }),
fleetTotalOnLease > 0
? React.createElement('button', {
key: 'onLeaseFleet',
type: 'button',
className: 'vm-kpi-card lc-fleet-kpi-card' + (fleetKpiActive ? ' active' : ''),
onClick: handleFleetKpiClick,
'aria-pressed': fleetKpiActive,
'data-annotation-id': 'lc-fleet-summary',
},
React.createElement('span', {
className: 'vm-kpi-tip',
tabIndex: 0,
role: 'note',
'aria-label': '在租车辆说明',
onClick: function(e) { e.stopPropagation(); },
onMouseDown: function(e) { e.stopPropagation(); }
},
kpiHelpIcon,
React.createElement('span', { className: 'vm-kpi-tooltip', role: 'tooltip' }, fleetKpiDesc)
),
React.createElement('span', { className: 'vm-kpi-icon', 'aria-hidden': true }, FLEET_KPI_ICON),
React.createElement('span', { className: 'vm-kpi-main' },
React.createElement('span', { className: 'vm-kpi-eyebrow' }, '在租车辆'),
React.createElement('span', { className: 'vm-kpi-val tabular-nums' }, fleetTotalOnLease)
)
)
: null
), ),
React.createElement(LeaseContractFleetSummary, { React.createElement(LeaseContractFleetSummary, {
records: fleetSourceList, records: fleetSourceList,
Modal: Modal, Modal: Modal,
renderVehicleNativeTableHeader: renderContractVehicleNativeTableHeader, renderVehicleNativeTableHeader: renderContractVehicleNativeTableHeader,
renderVehicleNativeTableRow: renderContractVehicleNativeTableRow, renderVehicleNativeTableRow: renderContractVehicleNativeTableRow,
expanded: _fleetExpanded[0],
onExpandedChange: _fleetExpanded[1],
panelHidden: _fleetPanelHidden[0],
onPanelHiddenChange: _fleetPanelHidden[1],
quickViewOpen: _fleetQuickOpen[0],
onQuickViewOpenChange: _fleetQuickOpen[1],
onViewContract: function (record) { onViewContract: function (record) {
message.info('查看合同(原型):' + (record.contractCode || record.projectName || '')); message.info('查看合同(原型):' + (record.contractCode || record.projectName || ''));
}, },
@@ -2659,7 +2919,7 @@ const Component = function(props) {
columns: columns, columns: columns,
dataSource: pagedList, dataSource: pagedList,
tableLayout: 'fixed', tableLayout: 'fixed',
scroll: { x: listTableScrollX, y: 'calc(100vh - 640px)' }, scroll: { x: listTableScrollX, y: 'max(calc(100vh - var(--lc-list-chrome-offset, 472px)), 240px)' },
size: 'small', size: 'small',
pagination: false, pagination: false,
expandable: { expandable: {
@@ -2830,6 +3090,132 @@ const Component = function(props) {
}, },
accept: '.pdf,.doc,.docx,image/*' accept: '.pdf,.doc,.docx,image/*'
}, React.createElement('p', { style: { margin: 0, padding: '20px 0' } }, '点击或拖拽文件到此区域上传;支持一次选择多个附件,可分批添加')) }, React.createElement('p', { style: { margin: 0, padding: '20px 0' } }, '点击或拖拽文件到此区域上传;支持一次选择多个附件,可分批添加'))
)),
React.createElement(Modal, {
title: '转三方合同',
open: _tripartiteModalVisible[0],
className: 'lc-tripartite-modal',
onCancel: function() {
_tripartiteModalVisible[1](false);
_tripartiteModalRecord[1](null);
_tripartiteAgreements[1]([]);
},
width: 640,
footer: [
React.createElement(Button, { key: 'cancel', onClick: function() {
_tripartiteModalVisible[1](false);
_tripartiteModalRecord[1](null);
_tripartiteAgreements[1]([]);
} }, '取消'),
React.createElement(Button, { key: 'ok', type: 'primary', onClick: function() {
message.success('转三方合同已提交审核(原型)');
_tripartiteModalVisible[1](false);
_tripartiteModalRecord[1](null);
_tripartiteAgreements[1]([]);
} }, '提交审核'),
],
}, React.createElement('div', {
className: 'lc-flow-modal lc-flow-modal--tripartite',
'data-annotation-id': 'lc-action-convert-tripartite',
},
renderModalContractCode(_tripartiteModalRecord[0]),
React.createElement('p', { className: 'lc-flow-modal__hint' }, '支持新增多条三方协议,并分别上传协议附件与对方公函。'),
renderTripartiteAgreementEditor(_tripartiteAgreements[0], _tripartiteAgreements[1], 'lc-tripartite-agreements-editor'),
)),
React.createElement(Modal, {
title: '试用转正式',
open: _trialToFormalModalVisible[0],
className: 'lc-trial-to-formal-modal',
onCancel: function() {
_trialToFormalModalVisible[1](false);
_trialToFormalRecord[1](null);
_trialToFormalDelegates[1]([]);
_trialToFormalVehicles[1]([]);
_trialToFormalTripartite[1](false);
_trialToFormalAgreements[1]([]);
},
width: 920,
footer: [
React.createElement(Button, { key: 'cancel', onClick: function() {
_trialToFormalModalVisible[1](false);
_trialToFormalRecord[1](null);
_trialToFormalDelegates[1]([]);
_trialToFormalVehicles[1]([]);
_trialToFormalTripartite[1](false);
_trialToFormalAgreements[1]([]);
} }, '取消'),
React.createElement(Button, { key: 'ok', type: 'primary', onClick: function() {
var withTripartite = _trialToFormalTripartite[0];
message.success(withTripartite
? '试用转正式(含三方协议)已提交审核(原型)'
: '试用转正式已提交审核(原型)');
_trialToFormalModalVisible[1](false);
_trialToFormalRecord[1](null);
_trialToFormalDelegates[1]([]);
_trialToFormalVehicles[1]([]);
_trialToFormalTripartite[1](false);
_trialToFormalAgreements[1]([]);
} }, '提交审核'),
],
}, React.createElement('div', {
className: 'lc-flow-modal lc-flow-modal--trial-formal',
'data-annotation-id': 'lc-action-trial-to-formal',
},
renderModalContractCode(_trialToFormalRecord[0]),
React.createElement('p', { className: 'lc-flow-modal__hint' }, '自动拉取当前合同客户、授权人与车辆订单信息;车辆订单可编辑。转三方可与转正式在同一步完成。'),
React.createElement('section', { className: 'lc-flow-modal__section' },
React.createElement('h4', { className: 'lc-flow-modal__section-title' }, '客户信息'),
React.createElement('div', { className: 'lc-flow-modal__readonly-grid' },
React.createElement('div', null, React.createElement('span', { className: 'lc-flow-modal__readonly-label' }, '客户名称'), React.createElement('span', null, _trialToFormalRecord[0] ? _trialToFormalRecord[0].customerName : '-')),
React.createElement('div', null, React.createElement('span', { className: 'lc-flow-modal__readonly-label' }, '联系人'), React.createElement('span', null, _trialToFormalRecord[0] ? _trialToFormalRecord[0].contactName : '-')),
React.createElement('div', null, React.createElement('span', { className: 'lc-flow-modal__readonly-label' }, '联系电话'), React.createElement('span', null, _trialToFormalRecord[0] ? _trialToFormalRecord[0].contactPhone : '-')),
),
),
React.createElement('section', { className: 'lc-flow-modal__section' },
React.createElement('h4', { className: 'lc-flow-modal__section-title' }, '授权人(可编辑)'),
React.createElement(Table, {
rowKey: function(_, i) { return String(i); },
size: 'small',
columns: [
{ title: '姓名', key: 'name', width: 120, render: function(_, row, index) { return React.createElement(Input, { value: row.name, onChange: function(e) { updateTrialToFormalDelegate(index, 'name', e.target.value); }, placeholder: '请输入' }); } },
{ title: '联系方式', key: 'phone', width: 140, render: function(_, row, index) { return React.createElement(Input, { value: row.phone, onChange: function(e) { updateTrialToFormalDelegate(index, 'phone', e.target.value); }, placeholder: '请输入' }); } },
{ title: '证件号码', key: 'idCard', width: 180, render: function(_, row, index) { return React.createElement(Input, { value: row.idCard, onChange: function(e) { updateTrialToFormalDelegate(index, 'idCard', e.target.value); }, placeholder: '请输入' }); } },
],
dataSource: _trialToFormalDelegates[0],
pagination: false,
}),
),
React.createElement('section', { className: 'lc-flow-modal__section' },
React.createElement('h4', { className: 'lc-flow-modal__section-title' }, '车辆订单(可编辑)'),
React.createElement(Table, {
rowKey: 'key',
size: 'small',
columns: [
{ title: '品牌', key: 'brand', width: 90, render: function(_, row, index) { return React.createElement(Input, { value: row.brand, onChange: function(e) { updateTrialToFormalVehicle(index, 'brand', e.target.value); } }); } },
{ title: '型号', key: 'model', width: 120, render: function(_, row, index) { return React.createElement(Input, { value: row.model, onChange: function(e) { updateTrialToFormalVehicle(index, 'model', e.target.value); } }); } },
{ title: '车牌号', key: 'plateNo', width: 100, render: function(_, row, index) { return React.createElement(Input, { value: row.plateNo, onChange: function(e) { updateTrialToFormalVehicle(index, 'plateNo', e.target.value); } }); } },
{ title: '租金(元/月)', key: 'rent', width: 120, render: function(_, row, index) { return React.createElement(Input, { value: row.rent, onChange: function(e) { updateTrialToFormalVehicle(index, 'rent', e.target.value); }, placeholder: '请输入' }); } },
{ title: '租期(月)', key: 'leasePeriodMonths', width: 100, render: function(_, row, index) { return React.createElement(Input, { value: row.leasePeriodMonths, onChange: function(e) { updateTrialToFormalVehicle(index, 'leasePeriodMonths', e.target.value); }, placeholder: '月数' }); } },
],
dataSource: _trialToFormalVehicles[0],
pagination: false,
scroll: { x: 640 },
}),
),
React.createElement('section', { className: 'lc-flow-modal__section' },
React.createElement(Checkbox, {
checked: _trialToFormalTripartite[0],
onChange: function(e) {
_trialToFormalTripartite[1](e.target.checked);
if (e.target.checked && !_trialToFormalAgreements[0].length) {
_trialToFormalAgreements[1]([createEmptyTripartiteAgreement()]);
}
},
}, '同时转三方合同(可在本步骤新增三方协议)'),
_trialToFormalTripartite[0]
? renderTripartiteAgreementEditor(_trialToFormalAgreements[0], _trialToFormalAgreements[1], 'lc-trial-formal-tripartite-editor')
: null,
),
)) ))
) )
); );

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,9 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import * as antd from 'antd'; import * as antd from 'antd';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { import {
AnnotationViewer, AnnotationViewer,
type AnnotationDirectoryRouteNode, type AnnotationDirectoryRouteNode,
@@ -34,6 +37,8 @@ declare global {
window.React = React; window.React = React;
window.antd = antd; window.antd = antd;
dayjs.locale('zh-cn');
/** 与 vehicle-management / ONE-OS 主题一致 */ /** 与 vehicle-management / ONE-OS 主题一致 */
const vmTheme = { const vmTheme = {
token: { token: {
@@ -55,8 +60,8 @@ const vmTheme = {
headerColor: '#71717a', headerColor: '#71717a',
rowHoverBg: '#f6f7f7', rowHoverBg: '#f6f7f7',
borderColor: '#e5e7eb', borderColor: '#e5e7eb',
cellPaddingBlock: 8, cellPaddingBlock: 6,
cellPaddingInline: 12, cellPaddingInline: 10,
}, },
Card: { Card: {
borderRadiusLG: 12, borderRadiusLG: 12,
@@ -64,6 +69,13 @@ const vmTheme = {
InputNumber: { InputNumber: {
handleVisible: true, handleVisible: true,
}, },
DatePicker: {
cellActiveWithRangeBg: 'rgba(50, 160, 110, 0.12)',
cellHoverWithRangeBg: 'rgba(50, 160, 110, 0.08)',
cellRangeBorderColor: '#32a06e',
activeBorderColor: '#32a06e',
hoverBorderColor: '#3fb87c',
},
}, },
}; };
@@ -96,7 +108,7 @@ export default function LeaseContractManagementPage() {
); );
return ( return (
<ConfigProvider theme={vmTheme}> <ConfigProvider theme={vmTheme} locale={zhCN}>
<LeaseContractApp <LeaseContractApp
navigationRef={navigationRef} navigationRef={navigationRef}
onPageViewChange={setAnnotationPageId} onPageViewChange={setAnnotationPageId}

View File

@@ -0,0 +1,16 @@
import { hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js';
import {
resolveContractApprovalType,
STANDARD_CONTRACT_APPROVAL,
NONSTANDARD_CONTRACT_APPROVAL,
} from './lease-contract-risk-detect.js';
export { STANDARD_CONTRACT_APPROVAL, NONSTANDARD_CONTRACT_APPROVAL };
/** 综合风控红线修改与租金低于下限等规则,判定审批类型 */
export function resolveLeaseContractApprovalType(baselineHtml, currentHtml, form) {
if (form && hasLeaseOrderRentBelowMinimum(form.leaseOrder)) {
return NONSTANDARD_CONTRACT_APPROVAL;
}
return resolveContractApprovalType(baselineHtml, currentHtml);
}

View File

@@ -91,10 +91,14 @@ function buildMileageFromRecord(record) {
} }
function buildFeeInfoFromRecord(record) { function buildFeeInfoFromRecord(record) {
var isPrepay = record.hydrogenPaymentMethod === 'prepay'; var hydrogenPaymentMethod = record.hydrogenPaymentMethod || DEFAULT_FEE_INFO.hydrogenPaymentMethod;
if (record.hydrogenSettlementMode === 'month' && hydrogenPaymentMethod !== 'month') {
hydrogenPaymentMethod = 'month';
}
var isPrepay = hydrogenPaymentMethod === 'prepay';
return Object.assign({}, DEFAULT_FEE_INFO, { return Object.assign({}, DEFAULT_FEE_INFO, {
paymentPeriod: record.paymentPeriod != null ? record.paymentPeriod : DEFAULT_FEE_INFO.paymentPeriod, paymentPeriod: record.paymentPeriod != null ? record.paymentPeriod : DEFAULT_FEE_INFO.paymentPeriod,
hydrogenPaymentMethod: record.hydrogenPaymentMethod || DEFAULT_FEE_INFO.hydrogenPaymentMethod, hydrogenPaymentMethod: hydrogenPaymentMethod,
prepayAmount: record.prepayAmount != null ? record.prepayAmount : (isPrepay ? 50000 : null), prepayAmount: record.prepayAmount != null ? record.prepayAmount : (isPrepay ? 50000 : null),
payAheadWorkdays: record.payAheadWorkdays != null ? record.payAheadWorkdays : DEFAULT_FEE_INFO.payAheadWorkdays, payAheadWorkdays: record.payAheadWorkdays != null ? record.payAheadWorkdays : DEFAULT_FEE_INFO.payAheadWorkdays,
returnHydrogenDiffUnitPrice: record.returnHydrogenDiffUnitPrice != null returnHydrogenDiffUnitPrice: record.returnHydrogenDiffUnitPrice != null
@@ -127,6 +131,8 @@ export function buildLeaseContractEditFormState(record) {
: [createEmptyDelegateRow()], : [createEmptyDelegateRow()],
}, },
contractRemark: record.poaRemark || '', contractRemark: record.poaRemark || '',
customerPrincipalName: record.customerPrincipalName || '',
customerPrincipalPhone: record.customerPrincipalPhone || '',
sealTypes: Array.isArray(record.sealTypes) && record.sealTypes.length sealTypes: Array.isArray(record.sealTypes) && record.sealTypes.length
? record.sealTypes.slice() ? record.sealTypes.slice()
: ['contract'], : ['contract'],

View File

@@ -16,6 +16,7 @@ function isMainContractFieldsComplete(form) {
if (mileage.hasRequirement) { if (mileage.hasRequirement) {
if (!mileage.period) return false; if (!mileage.period) return false;
if (mileage.targetKm == null || mileage.targetKm === '') return false; if (mileage.targetKm == null || mileage.targetKm === '') return false;
if (mileage.reductionYuan == null || mileage.reductionYuan === '') return false;
} }
var feeInfo = form.feeInfo || {}; var feeInfo = form.feeInfo || {};

View File

@@ -30,6 +30,7 @@ export var PAYMENT_PERIOD_LABELS = {
export var HYDROGEN_PAYMENT_LABELS = { export var HYDROGEN_PAYMENT_LABELS = {
self: '自行解决', self: '自行解决',
prepay: '预付款', prepay: '预付款',
month: '按月结算',
}; };
var LEGACY_PAYMENT_PERIOD_MAP = { var LEGACY_PAYMENT_PERIOD_MAP = {
@@ -42,6 +43,7 @@ var LEGACY_PAYMENT_PERIOD_MAP = {
var LEGACY_HYDROGEN_PAYMENT_MAP = { var LEGACY_HYDROGEN_PAYMENT_MAP = {
'自行解决': 'self', '自行解决': 'self',
'预付款': 'prepay', '预付款': 'prepay',
'按月结算': 'month',
}; };
export function formatPaymentPeriodLabel(period) { export function formatPaymentPeriodLabel(period) {
@@ -842,7 +844,9 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏F90010', actualDelivery: '2024-03-02 10:00', returnTime: '2024-12-20 15:00', deliveryPerson: '卫运维', leasePeriodMonths: 12 }, { vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏F90010', actualDelivery: '2024-03-02 10:00', returnTime: '2024-12-20 15:00', deliveryPerson: '卫运维', leasePeriodMonths: 12 },
], ],
approvalStatus: '审批通过', approvalStatus: '审批通过',
contractStatus: '到期合同', contractStatus: '已终止',
terminatedBy: 'renewal',
supersededByContractCode: 'HT-ZL-2025-012',
customerName: '杭州某某租赁有限公司', customerName: '杭州某某租赁有限公司',
signingCompany: '嘉兴羚牛', signingCompany: '嘉兴羚牛',
businessDept: '业务3部', businessDept: '业务3部',
@@ -1148,6 +1152,80 @@ export function canContractVehicleReturn(vehicle) {
return isContractVehicleDelivered(vehicle) && !isContractVehicleReturned(vehicle); return isContractVehicleDelivered(vehicle) && !isContractVehicleReturned(vehicle);
} }
function getVehicleDeliveryTimestamp(vehicle) {
var time = vehicle && (vehicle.deliveryTime || vehicle.actualDelivery);
if (!time || String(time).trim() === '-') return null;
var normalized = String(time).trim().replace(' ', 'T');
var date = new Date(normalized);
if (isNaN(date.getTime())) {
date = parseContractDateOnly(time);
}
return date && !isNaN(date.getTime()) ? date.getTime() : null;
}
/** 合同中最后一辆「已交车但未还车」的车辆(按交车时间最晚) */
export function getLastDeliveredNotReturnedVehicle(record) {
var vehicles = record && record.vehicles;
if (!vehicles || !vehicles.length) return null;
var lastVehicle = null;
var lastTs = -1;
for (var i = 0; i < vehicles.length; i++) {
var vehicle = vehicles[i];
if (!canContractVehicleReturn(vehicle)) continue;
var ts = getVehicleDeliveryTimestamp(vehicle);
if (ts == null) ts = i;
if (ts >= lastTs) {
lastTs = ts;
lastVehicle = vehicle;
}
}
return lastVehicle;
}
export function daysUntilContractValidityEnd(record) {
if (!record || !record.contractEndDate) return null;
var end = parseContractDateOnly(record.contractEndDate);
if (!end) return null;
var today = new Date();
today.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.round((end.getTime() - today.getTime()) / 86400000);
}
/** KPI「临期合同」存在在租车辆且合同签订有效期距今日 ≤ 30 天 */
export function isContractExpiringForKpi(record) {
if (!getLastDeliveredNotReturnedVehicle(record)) return false;
var days = daysUntilContractValidityEnd(record);
return days !== null && days >= 0 && days <= 30;
}
export function isContractMarkedTerminated(record) {
if (!record) return false;
var status = String(record.contractStatus || '').trim();
if (status === '已终止' || status === '已结束') return true;
if (record.supersededByContractId || record.supersededByContractCode) return true;
if (record.terminatedBy === 'renewal' || record.terminatedBy === 'tripartite') return true;
if (record.autoTerminatedByRenewal || record.autoTerminatedByTripartite) return true;
return false;
}
export function contractAllVehiclesDeliveredAndReturned(record) {
var vehicles = record && record.vehicles;
if (!vehicles || !vehicles.length) return false;
for (var i = 0; i < vehicles.length; i++) {
var vehicle = vehicles[i];
if (!isContractVehicleDelivered(vehicle)) return false;
if (!isContractVehicleReturned(vehicle)) return false;
}
return true;
}
/** KPI「已终止」全部车辆已还车或续签/转三方后旧合同自动终止 */
export function isContractTerminatedForKpi(record) {
if (isContractMarkedTerminated(record)) return true;
return contractAllVehiclesDeliveredAndReturned(record);
}
/** 不计入在租统计的合同状态 */ /** 不计入在租统计的合同状态 */
var ON_LEASE_EXCLUDED_CONTRACT_STATUSES = ['草稿', '已结束', '已终止', '未提交', '已提交审批']; var ON_LEASE_EXCLUDED_CONTRACT_STATUSES = ['草稿', '已结束', '已终止', '未提交', '已提交审批'];

View File

@@ -3,7 +3,7 @@
*/ */
import { getContractTemplateBaseHtml } from '../contract-template-management/contract-template-catalog.js'; import { getContractTemplateBaseHtml } from '../contract-template-management/contract-template-catalog.js';
import { getTemplateSectionVehicleBindings } from '../contract-template-management/contract-template-store.js'; import { getTemplateSectionVehicleBindings } from '../contract-template-management/contract-template-store.js';
import { applyAttachmentSectionVehicleFilter } from '../contract-template-management/contract-template-section-filter.js'; import { applyAttachmentSectionVehicleFilter, renumberAttachmentSections } from '../contract-template-management/contract-template-section-filter.js';
import { import {
applyTemplateVars, applyTemplateVars,
buildLeasePreviewVars, buildLeasePreviewVars,
@@ -138,6 +138,27 @@ export function applyPaymentPeriodPatch(html, feeInfo) {
return head + slice + tail; return head + slice + tail;
} }
/** 2.2 氢费支付方式:按月结算时切换为占位条款(正式文案待法务确认) */
export function applyHydrogenSettlementPatch(html, feeInfo) {
if (!html || !feeInfo || feeInfo.hydrogenPaymentMethod !== 'month') return html;
var start = html.indexOf('2.2.1');
if (start < 0) return html;
var end = html.indexOf('2.2.3', start);
if (end < 0) end = html.indexOf('2.3', start);
if (end < 0) end = start + 2800;
var head = html.slice(0, start);
var slice = html.slice(start, end);
var tail = html.slice(end);
var placeholder = '<p class="p6 lc-h2-settlement-prototype" style="margin-left:12px;color:#92400e;background:#fffbeb;padding:8px 10px;border-radius:6px">'
+ '【原型占位 · 待法务确认】氢费按月结算:每月 1 日至当月最后一日为一个结算周期,'
+ '次月 5 日前结清上月氢费;具体条款以法务定稿为准。'
+ '</p>';
if (slice.indexOf('lc-h2-settlement-prototype') < 0) {
slice = slice.replace(/(2\.2\.1[\s\S]*?<\/p>)/, '$1' + placeholder);
}
return head + slice + tail;
}
/** 2.2.1 氢费支付:提前 x 个工作日 */ /** 2.2.1 氢费支付:提前 x 个工作日 */
export function applyFeeInfoPatch(html, feeInfo) { export function applyFeeInfoPatch(html, feeInfo) {
if (!html || !feeInfo) return html; if (!html || !feeInfo) return html;
@@ -418,6 +439,7 @@ export function buildLeaseContractPreviewHtml(form) {
var html = stripUnresolvedPlaceholders(applyTemplateVars(base, vars)); var html = stripUnresolvedPlaceholders(applyTemplateVars(base, vars));
var vehicleKeys = collectVehicleKeysFromLeaseOrder(form); var vehicleKeys = collectVehicleKeysFromLeaseOrder(form);
html = applyAttachmentSectionVehicleFilter(html, sectionBindings, vehicleKeys); html = applyAttachmentSectionVehicleFilter(html, sectionBindings, vehicleKeys);
html = renumberAttachmentSections(html);
html = injectPrototypeVehicleClauses(html); html = injectPrototypeVehicleClauses(html);
var customer = getLeaseCustomerById(form && form.customerId); var customer = getLeaseCustomerById(form && form.customerId);
var lessor = getLessorCompanyById(form && form.lessorId); var lessor = getLessorCompanyById(form && form.lessorId);
@@ -427,6 +449,7 @@ export function buildLeaseContractPreviewHtml(form) {
html = applyMileageStandardPatch(html, form && form.mileage); html = applyMileageStandardPatch(html, form && form.mileage);
html = applyPaymentPeriodPatch(html, form && form.feeInfo); html = applyPaymentPeriodPatch(html, form && form.feeInfo);
html = applyFeeInfoPatch(html, form && form.feeInfo); html = applyFeeInfoPatch(html, form && form.feeInfo);
html = applyHydrogenSettlementPatch(html, form && form.feeInfo);
html = applyLeaseOrderAttachmentPatch(html, form, customer, lessor); html = applyLeaseOrderAttachmentPatch(html, form, customer, lessor);
html = applyPowerOfAttorneyPatch(html, form); html = applyPowerOfAttorneyPatch(html, form);
html = appendCustomerCredentialAttachments(html, customer); html = appendCustomerCredentialAttachments(html, customer);

View File

@@ -0,0 +1,96 @@
/**
* 客户资质证照 OCR / 有效期校验(租赁合同签约信息)
*/
import { CUSTOMER_CREDENTIAL_ITEMS } from '../contract-template-management/contract-template-vars.js';
export var CREDENTIAL_EXPIRY_WARN_MONTHS = 3;
var STATUS_LABELS = {
valid: '有效',
expiring: '即将到期',
expired: '已过期',
unknown: '待识别',
};
function parseYmd(str) {
if (!str) return null;
var parts = String(str).trim().split('-');
if (parts.length < 3) return null;
var y = Number(parts[0]);
var m = Number(parts[1]) - 1;
var d = Number(parts[2]);
if (!y || m < 0 || d < 1) return null;
var date = new Date(y, m, d);
date.setHours(23, 59, 59, 999);
return date;
}
function addMonths(date, months) {
var next = new Date(date.getTime());
next.setMonth(next.getMonth() + months);
return next;
}
export function getCredentialExpiryStatus(expiryDate, refDate) {
var expiry = parseYmd(expiryDate);
if (!expiry) return 'unknown';
var now = refDate ? new Date(refDate) : new Date();
now.setHours(0, 0, 0, 0);
if (expiry.getTime() < now.getTime()) return 'expired';
var warnBefore = addMonths(now, CREDENTIAL_EXPIRY_WARN_MONTHS);
if (expiry.getTime() <= warnBefore.getTime()) return 'expiring';
return 'valid';
}
export function getCredentialStatusLabel(status) {
return STATUS_LABELS[status] || STATUS_LABELS.unknown;
}
export function getCustomerCredentialMeta(customer, key) {
var file = customer && customer.attachments ? customer.attachments[key] : null;
var expiryDate = file && file.expiryDate ? file.expiryDate : '';
var status = getCredentialExpiryStatus(expiryDate);
return {
key: key,
ocrVerified: Boolean(file && file.ocrVerified),
expiryDate: expiryDate,
status: status,
statusLabel: getCredentialStatusLabel(status),
};
}
export function summarizeCustomerCredentials(customer) {
var items = CUSTOMER_CREDENTIAL_ITEMS.map(function (item) {
var meta = getCustomerCredentialMeta(customer, item.key);
return {
key: item.key,
label: item.label,
ocrVerified: meta.ocrVerified,
expiryDate: meta.expiryDate,
status: meta.status,
statusLabel: meta.statusLabel,
};
});
var expired = items.filter(function (item) { return item.status === 'expired'; });
var expiring = items.filter(function (item) { return item.status === 'expiring'; });
return {
items: items,
expiredItems: expired,
expiringItems: expiring,
hasExpired: expired.length > 0,
hasExpiring: expiring.length > 0,
blocksContractSubmit: expired.length > 0,
};
}
export function hasBlockingCustomerCredentials(customer) {
if (!customer) return false;
return summarizeCustomerCredentials(customer).blocksContractSubmit;
}
export function formatCredentialSubmitBlockMessage(summary) {
if (!summary || !summary.hasExpired) return '';
var names = summary.expiredItems.map(function (item) { return item.label; }).join('、');
return '客户资质证照已过期(' + names + '),须由业管更新档案后方可提交新合同。';
}

View File

@@ -175,6 +175,50 @@ export function getRowVehicleCountForPricing(row, order) {
return 0; return 0;
} }
export function countNationalInStockByBrandModel(brand, model) {
if (!brand || !model) return 0;
return vehiclesData.filter(function (vehicle) {
return vehicle.brand === brand
&& vehicle.model === model
&& (vehicle.operateStatus === '可运营' || vehicle.operateStatus === '待运营');
}).length;
}
/** 车型最低租金配置(原型);键为 brand|model */
export var LEASE_VEHICLE_MIN_RENT_BY_MODEL = {
'飞驰|集卡头': 15000,
'飞驰|半挂车': 12000,
'现代|18吨氢燃料电池车': 18000,
'苏龙|9.6米氢燃料电池车': 16000,
};
export function getLeaseMinRentForBrandModel(brand, model) {
if (!brand || !model) return null;
var key = brand + '|' + model;
if (LEASE_VEHICLE_MIN_RENT_BY_MODEL[key] != null) {
return LEASE_VEHICLE_MIN_RENT_BY_MODEL[key];
}
return null;
}
export function isLeaseRentBelowMinimum(rent, brand, model) {
var minRent = getLeaseMinRentForBrandModel(brand, model);
if (minRent == null) return false;
if (rent == null || rent === '') return false;
return Number(rent) < minRent;
}
export function hasLeaseOrderRentBelowMinimum(order) {
var rows = (order && order.rows) || [];
return rows.some(function (row) {
var models = normalizeBrandModels(row);
if (!models.length || row.rent == null || row.rent === '') return false;
return models.some(function (pair) {
return isLeaseRentBelowMinimum(row.rent, pair[0], pair[1]);
});
});
}
export function calcInsuredVehicleCount(order) { export function calcInsuredVehicleCount(order) {
var rows = (order && order.rows) || []; var rows = (order && order.rows) || [];
var autoFromPlates = 0; var autoFromPlates = 0;

View File

@@ -13,13 +13,23 @@ const fullPrd = fs.readFileSync(resourcesPrd, 'utf8');
const NODE_DEFS = [ const NODE_DEFS = [
{ id: 'lc-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项,可展开至 12 项;查询与 KPI 联动。' }, { id: 'lc-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项,可展开至 12 项;查询与 KPI 联动。' },
{ id: 'lc-list-kpi', title: 'KPI 统计卡片', pageId: 'list', color: '#64748b', aiPrompt: '五张 KPI 卡片口径与点击筛选。', annotationText: '全部 / 进行中 / 已终止 / 临期 / 过期。' }, { id: 'lc-list-kpi', title: 'KPI 统计卡片', pageId: 'list', color: '#64748b', aiPrompt: '五张 KPI 卡片口径与点击筛选。', annotationText: '全部 / 进行中 / 审批中 / 临期 / 已终止。' },
{ id: 'lc-fleet-summary', title: '在租车辆概览', pageId: 'list', color: '#32a06e', aiPrompt: '按品牌型号统计在租车辆,支持品牌标签筛选。', annotationText: '顶部展示在租总数;品牌标签筛选车型卡片;点击卡片查看车辆明细。' }, { id: 'lc-fleet-summary', title: '在租车辆概览', pageId: 'list', color: '#32a06e', aiPrompt: '按品牌型号统计在租车辆,支持品牌标签筛选。', annotationText: '顶部展示在租总数;品牌标签筛选车型卡片;点击卡片查看车辆明细。' },
{ id: 'lc-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '费用模板、导出、新增入口。', annotationText: '「新增」进入租赁合同创建页。' }, { id: 'lc-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '费用模板、导出、新增入口。', annotationText: '「新增」进入租赁合同创建页。' },
{ id: 'lc-list-table', title: '合同台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '台账表格列与操作。', annotationText: '' }, { id: 'lc-list-table', title: '合同台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '台账表格列与操作。', annotationText: '' },
{ id: 'lc-list-action-more', title: '更多操作', pageId: 'list', color: '#2563eb', aiPrompt: '列表行更多菜单入口。', annotationText: '' },
{ id: 'lc-action-convert-tripartite', title: '转三方合同', pageId: 'list', color: '#7c3aed', aiPrompt: '进行中合同转三方。', annotationText: '' },
{ id: 'lc-action-trial-to-formal', title: '试用转正式', pageId: 'list', color: '#0f766e', aiPrompt: '试用合同一步转正式。', annotationText: '' },
{ id: 'lc-create-main-contract', title: '主体合同信息', pageId: 'create', color: '#32a06e', aiPrompt: '签约、里程、费用三块表单。', annotationText: '完成度徽章;甲乙档案只读同步。' }, { id: 'lc-create-main-contract', title: '主体合同信息', pageId: 'create', color: '#32a06e', aiPrompt: '签约、里程、费用三块表单。', annotationText: '完成度徽章;甲乙档案只读同步。' },
{ id: 'lc-card-signing', title: '签约信息', pageId: 'create', color: '#0d9488', aiPrompt: '合同编号、甲乙双方与乙方负责人。', annotationText: '' },
{ id: 'lc-signing-customer-principal', title: '乙方负责人', pageId: 'create', color: '#0d9488', aiPrompt: '乙方签约对接负责人。', annotationText: '' },
{ id: 'lc-create-lease-order', title: '附件1租赁订单', pageId: 'create', color: '#2563eb', aiPrompt: '订单车辆与交车信息。', annotationText: '三者责任险等为完成度必填项。' }, { id: 'lc-create-lease-order', title: '附件1租赁订单', pageId: 'create', color: '#2563eb', aiPrompt: '订单车辆与交车信息。', annotationText: '三者责任险等为完成度必填项。' },
{ id: 'lc-lease-order-brand-model', title: '品牌车型与在库', pageId: 'create', color: '#0f766e', aiPrompt: '选择车型后展示全国在库台数。', annotationText: '运营状态为「可运营」「待运营」计为在库;数据来自车辆管理主数据。' },
{ id: 'lc-lease-order-rent', title: '车辆租金与最低租金', pageId: 'create', color: '#c2410c', aiPrompt: '租金低于车型最低值触发非标审批。', annotationText: '最低租金可在后台按品牌·型号配置;原型配置见 lease-order-vars.js。' },
{ id: 'lc-lease-order-service-content', title: '服务项配置', pageId: 'create', color: '#7c3aed', aiPrompt: '服务项后台可配置与计价规则。', annotationText: '' },
{ id: 'lc-lease-order-lease-period', title: '租赁期限与到期计算', pageId: 'create', color: '#0369a1', aiPrompt: '租期两种计算模式与合同到期规则。', annotationText: '' },
{ id: 'lc-create-poa', title: '授权委托书', pageId: 'create', color: '#7c3aed', aiPrompt: '受托人列表编辑。', annotationText: '至少一行且姓名/联系方式/身份证完整。' }, { id: 'lc-create-poa', title: '授权委托书', pageId: 'create', color: '#7c3aed', aiPrompt: '受托人列表编辑。', annotationText: '至少一行且姓名/联系方式/身份证完整。' },
{ id: 'lc-create-contract-remark', title: '合同备注', pageId: 'create', color: '#64748b', aiPrompt: '独立卡片填写合同备注。', annotationText: '选填,最多 500 字;与授权委托书分离。' },
{ id: 'lc-create-preview', title: '实时预览', pageId: 'create', color: '#0ea5e9', aiPrompt: 'Word 版式预览与可编辑正文。', annotationText: '改动风控红线条款将触发非标准合同。' }, { id: 'lc-create-preview', title: '实时预览', pageId: 'create', color: '#0ea5e9', aiPrompt: 'Word 版式预览与可编辑正文。', annotationText: '改动风控红线条款将触发非标准合同。' },
{ id: 'lc-create-footer', title: '底栏操作', pageId: 'create', color: '#64748b', aiPrompt: '取消、保存草稿、提交审核。', annotationText: '保存/提交时展示将走的审批类型。' }, { id: 'lc-create-footer', title: '底栏操作', pageId: 'create', color: '#64748b', aiPrompt: '取消、保存草稿、提交审核。', annotationText: '保存/提交时展示将走的审批类型。' },
]; ];
@@ -63,12 +73,93 @@ const NODE_PRD_MAP = {
'lc-list-table': { prd: 'list', heading: '## 7. 列表字段说明', combine: ['## 7. 列表字段说明', '## 8. 操作与弹窗'] }, 'lc-list-table': { prd: 'list', heading: '## 7. 列表字段说明', combine: ['## 7. 列表字段说明', '## 8. 操作与弹窗'] },
'lc-create-main-contract': { prd: 'create', heading: '## 2. 主体合同信息' }, 'lc-create-main-contract': { prd: 'create', heading: '## 2. 主体合同信息' },
'lc-create-lease-order': { prd: 'create', heading: '## 3. 附件1租赁订单' }, 'lc-create-lease-order': { prd: 'create', heading: '## 3. 附件1租赁订单' },
'lc-lease-order-brand-model': { prd: 'create', heading: '## 3. 附件1租赁订单', sub: '### 3.1 品牌车型' },
'lc-lease-order-rent': { prd: 'create', heading: '## 3. 附件1租赁订单', sub: '### 3.2 租金' },
'lc-lease-order-service-content': { prd: 'create', heading: '## 3. 附件1租赁订单', sub: '### 3.3 服务内容' },
'lc-create-poa': { prd: 'create', heading: '## 4. 授权委托书' }, 'lc-create-poa': { prd: 'create', heading: '## 4. 授权委托书' },
'lc-create-preview': { prd: 'create', heading: '## 5. 实时预览', combine: ['## 5. 实时预览', '## 6. 审批类型判定'] }, 'lc-create-contract-remark': { prd: 'create', heading: '## 5. 合同备注' },
'lc-create-footer': { prd: 'create', heading: '## 7. 底栏操作' }, 'lc-create-preview': { prd: 'create', heading: '## 6. 实时预览', combine: ['## 6. 实时预览', '## 7. 审批类型判定'] },
'lc-create-footer': { prd: 'create', heading: '## 8. 底栏操作' },
}; };
const CUSTOM_NODE_MARKDOWN = {
'lc-lease-order-brand-model': `# 品牌车型与在库
选择品牌 / 型号后,在选框下方展示该车型**全国在库**台数。
- 统计口径:车辆管理中运营状态为「可运营」或「待运营」
- 按品牌 + 型号精确匹配
- 用于签约前评估库存是否满足交付需求`,
'lc-lease-order-rent': `# 车辆租金与最低租金
- 车型最低租金可在后台按「品牌 · 型号」配置(原型见 \`LEASE_VEHICLE_MIN_RENT_BY_MODEL\`
- 录入租金 **低于** 系统最低值时,行内提示并自动判定为 **非标准合同** 审批
- 与风控红线条款修改规则并列生效`,
'lc-lease-order-service-content': `# 服务项配置需求
1. **后台可配置**:支持新增、编辑、自定义服务项名称和计价规则;后续新增服务项可直接在签合同环节选用,无需迭代修改产品。
2. **两类计价逻辑**
- **固定金额类**:如无忧包、尾板服务费,直接录入固定总价,计入合同初始服务费合计
- **动态计费类**如维保费、运保费仅在合同中展示约定单价0.1 元/公里),不计入合同初始总价;后续按每月实际行驶里程核算后合并计入租金账单,首期账单默认不包含该类动态服务费
3. **合同同步**:所有选中的服务项自动同步展示到正式合同对应章节,支持单独添加备注说明,作为合同不可缺失的组成部分。`,
'lc-signing-customer-principal': `# 乙方负责人
选择乙方后,在签约信息中补充填写:
- **乙方负责人姓名**:签约对接负责人,可与客户档案中的联系人不同
- **乙方负责人手机号**:用于履约沟通与通知
选填;用于合同履约联系,不覆盖客户档案只读信息。`,
'lc-lease-order-lease-period': `# 租赁期限与到期计算
租赁到期时间支持两种计算模式:
## 1. 固定日期模式
手动指定合同的开始日期与结束日期;系统到达设定的结束日期时,自动标记合同为过期状态。
## 2. 租期月数模式
无需手动指定起止日期,仅输入租赁总月数。系统自动以 **车辆实际交付时间** 作为租期起始点,向后顺延对应月数,自动计算合同到期日期;到达该日期后自动标记合同为过期。
### 多车合同
当合同下关联多台车辆时,按固定月份规则取当前合同下 **第一辆到期** 的车辆到期时间,作为合同整体到期时间。
> 原型表格列「租赁期限(月)」当前展示租期月数模式输入;固定日期模式在正式产品中于同行或弹层切换。`,
'lc-list-action-more': `# 更多操作
列表操作列「更多」入口,按合同状态与类型展示不同菜单项。
- **合同进行中**:含转三方合同、续签、增车等
- **试用合同**:含试用转正式`,
'lc-action-convert-tripartite': `# 转三方合同
**显示条件**:合同状态为「合同进行中」。
**能力**
1. 支持 **新增多条三方协议**(协议名称)
2. 每条协议可上传 **三方协议附件**
3. 每条协议可上传 **对方公函**
提交后走变更审批(原型演示 Toast`,
'lc-action-trial-to-formal': `# 试用转正式
**显示条件**:合同类型为「试用合同」。
**单步完成**(转正式与转三方可合并,无需分两步):
1. **自动拉取**当前合同的客户信息、授权人、车辆订单
2. **车辆订单可编辑**(品牌、型号、车牌、租金、租期月数等)
3. 可勾选 **同时转三方合同**,在同一步新增三方协议并上传附件/公函
提交后生成正式合同并走审批(原型演示 Toast`,
};
function buildFallbackMarkdown(node) { function buildFallbackMarkdown(node) {
const parts = [`# ${node.title}`, '']; const parts = [`# ${node.title}`, ''];
if (node.aiPrompt) parts.push(node.aiPrompt, ''); if (node.aiPrompt) parts.push(node.aiPrompt, '');
@@ -77,6 +168,9 @@ function buildFallbackMarkdown(node) {
} }
function buildNodeMarkdown(node) { function buildNodeMarkdown(node) {
if (CUSTOM_NODE_MARKDOWN[node.id]) {
return CUSTOM_NODE_MARKDOWN[node.id];
}
const mapping = NODE_PRD_MAP[node.id]; const mapping = NODE_PRD_MAP[node.id];
if (mapping) { if (mapping) {
const prdBody = extractFromPrd(mapping); const prdBody = extractFromPrd(mapping);

View File

@@ -554,6 +554,100 @@
color: var(--ln-muted); color: var(--ln-muted);
} }
.vm-page.lc-create-page .lc-credentials-block__policy {
margin: 0 0 12px;
font-size: 0.75rem;
line-height: 1.5;
color: var(--ln-muted);
}
.vm-page.lc-create-page .lc-credentials-alerts {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.vm-page.lc-create-page .lc-credentials-alert {
margin: 0;
padding: 10px 12px;
font-size: 0.8125rem;
line-height: 1.45;
border-radius: var(--ln-radius-control);
}
.vm-page.lc-create-page .lc-credentials-alert--warn {
color: #92400e;
background: #fffbeb;
border: 1px solid #fcd34d;
}
.vm-page.lc-create-page .lc-credentials-alert--danger {
color: #991b1b;
background: #fef2f2;
border: 1px solid #fca5a5;
}
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb.is-expired {
border-color: #fca5a5;
}
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb.is-expiring {
border-color: #fcd34d;
}
.vm-page.lc-create-page .lc-attach-thumb__ocr {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 1px 6px;
font-size: 0.6875rem;
font-weight: 500;
color: #047857;
background: #ecfdf5;
border-radius: 999px;
}
.vm-page.lc-create-page .lc-attach-thumb__ocr.is-pending {
color: #6b7280;
background: #f3f4f6;
}
.vm-page.lc-create-page .lc-attach-thumb__expiry {
font-size: 0.75rem;
color: var(--ln-muted);
font-variant-numeric: tabular-nums;
}
.vm-page.lc-create-page .lc-attach-thumb__status {
display: inline-flex;
width: fit-content;
padding: 1px 6px;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 999px;
}
.vm-page.lc-create-page .lc-attach-thumb__status--valid {
color: #047857;
background: #d1fae5;
}
.vm-page.lc-create-page .lc-attach-thumb__status--expiring {
color: #b45309;
background: #fef3c7;
}
.vm-page.lc-create-page .lc-attach-thumb__status--expired {
color: #b91c1c;
background: #fee2e2;
}
.vm-page.lc-create-page .lc-attach-thumb__status--unknown {
color: #6b7280;
background: #f3f4f6;
}
.vm-page.lc-create-page .lc-mileage-types-wrap { .vm-page.lc-create-page .lc-mileage-types-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2736,3 +2830,76 @@
min-height: 240px; min-height: 240px;
} }
} }
/* 合同编号:系统自动生成(新建) */
.vm-page.lc-create-page .lc-contract-code-auto {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
min-height: 36px;
padding: 6px 12px;
border: 1px solid var(--ln-hairline);
border-radius: 8px;
background: var(--ln-surface-muted, #f8fafc);
}
.vm-page.lc-create-page .lc-contract-code-auto__prefix {
font-size: 0.875rem;
font-weight: 600;
color: var(--ln-muted);
font-variant-numeric: tabular-nums;
}
.vm-page.lc-create-page .lc-contract-code-auto__tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
line-height: 1.4;
color: #0f766e;
background: rgba(15, 118, 110, 0.1);
}
/* 里程减免金额 */
.vm-page.lc-create-page .lc-mileage-reduction {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed var(--ln-hairline);
}
.vm-page.lc-create-page .lc-mileage-reduction .lc-field-hint {
grid-column: 1 / -1;
}
/* 品牌车型在库提示 */
.vm-page.lc-create-page .lc-brand-model-stock-hint {
margin: 6px 0 0;
font-size: 0.8125rem;
line-height: 1.5;
color: #0f766e;
}
/* 租金低于最低值警告 */
.vm-page.lc-create-page .lc-rent-min-warn {
margin: 4px 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: #c2410c;
}
.vm-page.lc-create-page .lc-form-number--warn .ant-input-number-input {
color: #c2410c;
}
.vm-page.lc-create-page .lc-signing-customer-principal {
margin-top: 12px;
}
.vm-page.lc-create-page .lc-contract-remark-section .lc-contract-remark__textarea {
resize: vertical;
}

View File

@@ -1,5 +1,6 @@
/** /**
* 租赁合同管理 — Ant Design 控件与 vm 布局对齐 * 租赁合同管理 — Ant Design 控件与 vm 布局对齐
* 列表壳层与台账页对齐说明见 ledger-shared/DESIGN.mdldb-page / lc-page
*/ */
/* 筛选区Ant Select / DatePicker 与 vm-input 同高 */ /* 筛选区Ant Select / DatePicker 与 vm-input 同高 */
@@ -26,11 +27,235 @@
padding-bottom: 2px; padding-bottom: 2px;
} }
/* 筛选区:合同结束日期 RangePicker */
.vm-page.lc-page .lc-filter-date-range.ant-picker {
width: 100%;
}
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-input > input {
font-size: 0.875rem;
color: var(--ln-ink);
}
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-input > input::placeholder {
color: var(--ln-muted-soft);
}
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-separator {
color: var(--ln-muted);
font-size: 0.8125rem;
}
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-suffix {
color: var(--ln-muted-soft);
}
.vm-page.lc-page .vm-filter-field .lc-filter-date-range.ant-picker:hover,
.vm-page.lc-page .vm-filter-field .lc-filter-date-range.ant-picker.ant-picker-focused {
border-color: color-mix(in srgb, var(--ln-primary) 42%, var(--ln-hairline-strong)) !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary) 14%, transparent);
}
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-header-view button:hover {
color: var(--ln-primary);
}
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner,
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-range-start .ant-picker-cell-inner,
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-range-end .ant-picker-cell-inner {
background: var(--ln-primary, #32a06e);
}
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-today .ant-picker-cell-inner::before {
border-color: var(--ln-primary, #32a06e);
}
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-footer .ant-btn-primary {
background: var(--ln-primary, #32a06e);
}
/* 列表工具栏:上下间距一致(以下方 16px 为准) */ /* 列表工具栏:上下间距一致(以下方 16px 为准) */
.vm-page.lc-page .vm-filter-card { .vm-page.lc-page .vm-filter-card {
margin-bottom: 16px; margin-bottom: 16px;
} }
/* —— 列表紧凑模式:一屏展示更多行 —— */
.vm-page.lc-page.lc-page--list-dense {
--lc-list-chrome-offset: 468px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 32px);
box-sizing: border-box;
}
.vm-page.lc-page.lc-page--list-dense .vm-filter-card {
padding: 16px 20px;
margin-bottom: 12px;
}
.vm-page.lc-page.lc-page--list-dense .vm-filter-header {
margin-bottom: 10px;
}
.vm-page.lc-page.lc-page--list-dense .vm-filter-title {
font-size: 1rem;
}
.vm-page.lc-page.lc-page--list-dense .vm-filter-grid {
gap: 12px 16px;
}
.vm-page.lc-page.lc-page--list-dense .vm-kpi-row {
gap: 12px;
margin-bottom: 12px;
}
.vm-page.lc-page.lc-page--list-dense .vm-kpi-card {
min-height: 72px;
padding: 12px 30px 12px 14px;
gap: 10px;
}
.vm-page.lc-page.lc-page--list-dense .vm-kpi-icon {
width: 28px;
height: 28px;
}
.vm-page.lc-page.lc-page--list-dense .vm-kpi-icon svg {
width: 16px;
height: 16px;
}
.vm-page.lc-page.lc-page--list-dense .vm-kpi-main {
gap: 2px;
}
.vm-page.lc-page.lc-page--list-dense .vm-kpi-val {
font-size: 1.25rem;
line-height: 1.15;
}
.vm-page.lc-page.lc-page--list-dense .lc-fleet-summary.is-embedded {
margin-bottom: 12px;
}
.vm-page.lc-page.lc-page--list-dense .vm-table-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.vm-page.lc-page.lc-page--list-dense .vm-table-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.vm-page.lc-page.lc-page--list-dense .vm-table-card > .vm-table-wrap {
flex: 1;
min-height: 0;
}
.vm-page.lc-page.lc-page--list-dense .vm-table-section .vm-table-toolbar {
padding-bottom: 12px;
}
.vm-page.lc-page.lc-page--list-dense .vm-table-footer {
padding-top: 10px;
padding-bottom: 10px;
}
.vm-page.lc-page.lc-page--list-dense .vm-list-table .ant-table-thead > tr > th {
padding: 6px 10px !important;
font-size: 0.6875rem !important;
}
.vm-page.lc-page.lc-page--list-dense .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
padding: 5px 10px !important;
font-size: 0.8125rem;
line-height: 1.35;
}
.vm-page.lc-page.lc-page--list-dense .vm-list-table .vm-stack {
gap: 1px;
line-height: 1.3;
}
.vm-page.lc-page.lc-page--list-dense .vm-list-table .vm-stack .sub,
.vm-page.lc-page.lc-page--list-dense .vm-list-table .vm-stack .code {
font-size: 0.75rem;
}
.vm-page.lc-page.lc-page--list-dense .lc-project-info-cell {
gap: 8px;
}
.vm-page.lc-page.lc-page--list-dense .lc-lease-order-counts__item {
min-height: 34px;
min-width: 40px;
padding: 2px 4px;
}
.vm-page.lc-page.lc-page--list-dense .lc-lease-order-counts__value {
font-size: 0.9375rem;
line-height: 1.2;
}
.vm-page.lc-page.lc-page--list-dense .lc-lease-order-counts__label {
font-size: 0.6875rem;
}
.vm-page.lc-page.lc-page--list-dense .lc-fee-info-cell__side {
gap: 3px;
padding: 4px 6px;
}
.vm-page.lc-page.lc-page--list-dense .lc-fee-info-cell__value {
min-height: 20px;
padding: 1px 6px;
font-size: 11px;
}
.vm-page.lc-page.lc-page--list-dense .lc-fee-info-cell__sep {
margin: 4px 0;
}
.vm-page.lc-page.lc-page--list-dense .lc-delivery-cell {
gap: 2px;
}
.vm-page.lc-page.lc-page--list-dense .lc-approval-status-cell__approver {
font-size: 0.75rem;
}
.vm-page.lc-page.lc-page--list-dense .vm-list-table .ant-btn-link {
font-size: 0.75rem;
padding: 0 2px;
}
.vm-page.lc-page.lc-page--list-dense .lc-row-actions {
gap: 2px;
}
.vm-page.lc-page.lc-page--list-dense .lc-action-more-btn {
width: 28px;
height: 28px;
}
@media (max-height: 820px) {
.vm-page.lc-page.lc-page--list-dense {
--lc-list-chrome-offset: 440px;
}
}
@media (min-height: 960px) {
.vm-page.lc-page.lc-page--list-dense {
--lc-list-chrome-offset: 452px;
}
}
.vm-page.lc-page .vm-table-section .vm-table-toolbar { .vm-page.lc-page .vm-table-section .vm-table-toolbar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -68,6 +293,95 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* 转三方 / 试用转正式弹窗 */
.vm-page.lc-page .lc-flow-modal__meta {
margin-bottom: 8px;
font-size: 0.875rem;
color: var(--ln-muted, #64748b);
}
.vm-page.lc-page .lc-flow-modal__meta-value {
color: var(--ln-body, #334155);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.vm-page.lc-page .lc-flow-modal__hint {
margin: 0 0 16px;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--ln-muted, #64748b);
}
.vm-page.lc-page .lc-flow-modal__section {
margin-bottom: 20px;
}
.vm-page.lc-page .lc-flow-modal__section-title {
margin: 0 0 10px;
font-size: 0.875rem;
font-weight: 600;
color: var(--ln-body, #334155);
}
.vm-page.lc-page .lc-flow-modal__readonly-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--ln-hairline, #e2e8f0);
}
.vm-page.lc-page .lc-flow-modal__readonly-label {
display: block;
margin-bottom: 4px;
font-size: 0.75rem;
color: var(--ln-muted, #64748b);
}
.vm-page.lc-page .lc-tripartite-agreements__add {
width: 100%;
margin-top: 8px;
}
.vm-page.lc-page .lc-tripartite-agreement-card {
margin-bottom: 12px;
padding: 12px 14px;
border: 1px solid var(--ln-hairline, #e2e8f0);
border-radius: 8px;
background: #fff;
}
.vm-page.lc-page .lc-tripartite-agreement-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.vm-page.lc-page .lc-tripartite-agreement-card__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--ln-body, #334155);
}
.vm-page.lc-page .lc-tripartite-agreement-card__field {
margin-bottom: 10px;
}
.vm-page.lc-page .lc-tripartite-agreement-card__field:last-child {
margin-bottom: 0;
}
.vm-page.lc-page .lc-tripartite-agreement-card__label {
display: block;
margin-bottom: 6px;
font-size: 0.8125rem;
color: var(--ln-muted, #64748b);
}
/* 列表区Ant Table 嵌入 vm-table-card */ /* 列表区Ant Table 嵌入 vm-table-card */
.vm-page.lc-page .vm-table-card .vm-list-table.ant-table-wrapper, .vm-page.lc-page .vm-table-card .vm-list-table.ant-table-wrapper,
.vm-page.lc-page .vm-table-card .vm-list-table .ant-table-wrapper { .vm-page.lc-page .vm-table-card .vm-list-table .ant-table-wrapper {
@@ -79,9 +393,18 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.vm-page.lc-page .vm-kpi-row.vm-kpi-row--with-fleet {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.vm-page.lc-page .lc-fleet-kpi-card.active {
border-color: var(--ln-primary);
box-shadow: 0 0 0 1px var(--ln-primary-soft, rgba(50, 160, 110, 0.25));
}
.vm-page.lc-page .vm-table-wrap.vm-list-table { .vm-page.lc-page .vm-table-wrap.vm-list-table {
overflow: auto; overflow: auto;
max-height: calc(100vh - 640px); max-height: none;
} }
/* 列表宽表:横向滚动交给 Ant Table 内部,避免外层滚动抢滚动上下文 */ /* 列表宽表:横向滚动交给 Ant Table 内部,避免外层滚动抢滚动上下文 */
@@ -199,18 +522,40 @@
overflow: hidden; overflow: hidden;
} }
/* 主表横向滚动容器:子表宽度以此为基准,不随主表列一起滑走 */
.vm-page.lc-page .lc-list-table--nested .ant-table-body {
container-type: inline-size;
container-name: lc-main-table-scroll;
}
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td { .vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td {
overflow: hidden; overflow: visible;
} }
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row .lc-contract-vehicle-table-wrap { .vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row .lc-contract-vehicle-table-wrap {
position: sticky;
left: 0;
z-index: 2;
box-sizing: border-box;
width: 100%;
max-width: 100%; max-width: 100%;
} }
@supports (width: 100cqw) {
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row .lc-contract-vehicle-table-wrap {
width: 100cqw;
max-width: 100cqw;
}
}
@media (max-width: 1280px) { @media (max-width: 1280px) {
.vm-page.lc-page .vm-kpi-row { .vm-page.lc-page .vm-kpi-row {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.vm-page.lc-page .vm-kpi-row.vm-kpi-row--with-fleet {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
@media (max-width: 960px) { @media (max-width: 960px) {
@@ -1247,6 +1592,7 @@
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td:not(.ant-table-cell-fix-start) { .vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td:not(.ant-table-cell-fix-start) {
padding: 8px 8px 12px; padding: 8px 8px 12px;
background: var(--ln-canvas-soft, #f8fafc); background: var(--ln-canvas-soft, #f8fafc);
overflow: visible;
} }
.vm-page.lc-page .lc-contract-vehicle-table-wrap { .vm-page.lc-page .lc-contract-vehicle-table-wrap {
@@ -1949,6 +2295,94 @@
} }
/* —— 在租车辆概览 —— */ /* —— 在租车辆概览 —— */
.vm-page.lc-page .lc-fleet-summary.is-embedded {
margin-bottom: 16px;
}
.vm-page.lc-page .lc-fleet-summary.is-embedded .lc-fleet-summary__body {
padding: 14px 16px 16px;
}
.vm-page.lc-page .lc-fleet-summary__body-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 12px;
margin-bottom: 12px;
}
.vm-page.lc-page .lc-fleet-summary__body-toolbar .lc-fleet-summary__brands {
flex: 1 1 auto;
min-width: 0;
}
.vm-page.lc-page .lc-fleet-summary__hide-btn {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 10px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control, 8px);
background: var(--ln-canvas);
color: var(--ln-muted);
font-size: 0.8125rem;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.vm-page.lc-page .lc-fleet-summary__hide-btn:hover {
border-color: var(--ln-hairline-strong);
color: var(--ln-ink);
background: var(--ln-surface-pearl);
}
.vm-page.lc-page .lc-fleet-summary__grid--quick {
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 10px;
}
.vm-page.lc-page .lc-fleet-summary__card--compact {
padding: 12px;
}
.vm-page.lc-page .lc-fleet-summary__card--compact .lc-fleet-summary__card-model {
font-size: 0.8125rem;
-webkit-line-clamp: 2;
}
.vm-page.lc-page .lc-fleet-summary__card--compact .lc-fleet-summary__card-hint {
font-size: 0.6875rem;
}
.lc-fleet-quick-modal__title {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.lc-fleet-quick-modal__badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
background: var(--ln-primary-soft);
color: var(--ln-primary-focus);
font-size: 0.75rem;
font-weight: 600;
}
.lc-fleet-quick-modal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
.vm-page.lc-page .lc-fleet-summary { .vm-page.lc-page .lc-fleet-summary {
margin-bottom: 16px; margin-bottom: 16px;
border: 1px solid var(--ln-hairline); border: 1px solid var(--ln-hairline);

View File

@@ -0,0 +1,69 @@
# 台账类列表页 · 统一设计规范
**租赁合同管理**`vm-page lc-page` + `lease-contract.css`)及 **vehicle-management**Linear 主题 token对齐的台账列表壳层规范。
**分页、表格底栏等全局列表交互**见 [`vm-shared/DESIGN.md`](../vm-shared/DESIGN.md)(强制使用 `TablePagination` + `vm-pagination*` 样式)。
## 适用范围
| 模块 | 页面类名 | 样式入口 |
|------|----------|----------|
| 租赁业务台账 | `vm-page ldb-page` | `lease-business-ledger/styles/index.css` |
| 自营业务台账 | `vm-page ldb-page` | 同上(复用租赁台账模块样式) |
| 租赁合同管理 | `vm-page lc-page` | `lease-contract-management/styles/lease-contract.css` |
台账页(`ldb-page`)与合同列表页(`lc-page`)在以下维度保持一致:
- 页面内边距与底部留白
- 筛选卡片 → KPI → 工具栏 → 表格卡片 的 **16px 垂直节奏**
- 工具栏右对齐、按钮高度与 hover 反馈
- 宽表横向滚动 + 左右冻结列阴影
- 空值留白、操作列文字按钮、删除二次确认
## 页面结构
```text
vm-page ldb-page
├── vm-filter-card ldb-filter-card # 模块标题 + 4 列筛选 + 更多筛选
├── ldb-kpi-row # 6 张 KPI 卡片
├── vm-table-section
│ ├── vm-table-toolbar ldb-table-toolbar
│ └── vm-table-card
│ ├── ldb-table-wrap > ldb-table
│ └── vm-table-footer ldb-table-footer # 左侧总条数 + TablePagination见 vm-shared/DESIGN.md
```
## 类名约定
| 前缀 | 用途 |
|------|------|
| `vm-*` | vehicle-management 基础布局与控件 |
| `ldb-*` | 台账模块共享筛选展开、KPI、表格、工具栏、弹窗 |
| `lc-*` | 租赁合同 Ant Design 列表专用 |
## 筛选区
- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区
- 日期区间分隔符使用「**至**」(`ldb-date-range-sep`),不用 em dash
- 重置 / 查询按钮加 `ldb-toolbar-btn`
## KPI
- 使用 `ldb-kpi-row` + `ldb-kpi-card`6 列网格1280/640 断点降列)
- 盈亏类数值:`ldb-kpi-val--profit` / `ldb-kpi-val--loss`
## 表格
- 容器:`ldb-table-wrap` + `ldb-table`
- 底部分页:使用 `src/common/TablePagination.tsx`,布局见 `vm-shared/DESIGN.md`
- 表头排序:`ldb-th-sort`;列宽:`ldb-col-resize-handle`
- 金额列:`ldb-col-money` + `tabular-nums`
- 操作列:`ldb-action-btn--edit` / `--delete`,右冻结 `sticky-col-right`
- 勾选列:左冻结 `sticky-col-left`
## 共享样式文件
- 页面壳层:`ledger-shared/styles/ledger-page.css`
- 台账组件样式:`lease-business-ledger/styles/index.css`import 上述壳层)
新增台账类页面应直接复用 `ldb-page` 与现有组件,避免再建模块专属页面类(如 `sob-page`)。

View File

@@ -0,0 +1,195 @@
/**
* ONE-OS 台账类列表页 — 统一页面壳层
*
* 适用租赁业务台账、自营业务台账vm-page + ldb-page
* 对齐租赁合同管理vm-page + lc-page的间距、卡片层次与工具栏布局
* 依赖vehicle-management/style.cssLinear 主题 token
*/
.vm-page.ldb-page {
padding-bottom: 48px;
}
.vm-page.ldb-page .vm-filter-card.ldb-filter-card {
margin-bottom: 16px;
}
.vm-page.ldb-page .vm-filter-header {
margin-bottom: 16px;
}
.ldb-page-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--ln-ink);
letter-spacing: -0.02em;
}
.vm-page.ldb-page .ldb-kpi-row {
margin-bottom: 16px;
}
.vm-page.ldb-page .vm-table-section {
min-width: 0;
max-width: 100%;
}
.vm-page.ldb-page .vm-table-toolbar.ldb-table-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
min-height: var(--vm-btn-height);
padding: 0 0 16px;
margin-bottom: 0;
}
.vm-page.ldb-page .vm-table-toolbar.ldb-table-toolbar .vm-table-actions {
margin-left: auto;
}
.vm-page.ldb-page .vm-table-card > .ldb-table-wrap {
overflow: auto;
scrollbar-gutter: stable;
max-height: calc(100vh - 640px);
min-width: 0;
}
/* 表头「?」提示需溢出卡片,避免被 vm-table-card 的 overflow:hidden 裁切 */
.vm-page.ldb-page .vm-table-card {
overflow: visible;
}
.ldb-table thead th {
overflow: visible;
}
.ldb-table thead th:has(.ldb-th-tip:hover),
.ldb-table thead th:has(.ldb-th-tip:focus-within) {
z-index: 20;
}
.ldb-table {
table-layout: fixed;
}
.ldb-table .sticky-col-left {
position: sticky;
left: 0;
z-index: 2;
box-shadow: 4px 0 8px -4px rgba(15, 23, 42, 0.06);
}
.ldb-table .sticky-col-right {
position: sticky;
right: 0;
z-index: 2;
box-shadow: -4px 0 8px -4px rgba(15, 23, 42, 0.08);
}
.ldb-table .sticky-col-right::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -12px;
width: 12px;
pointer-events: none;
background: linear-gradient(to left, var(--ln-surface-card) 40%, transparent);
}
.ldb-table thead th.sticky-col-left,
.ldb-table thead th.sticky-col-right {
z-index: 3;
background: var(--ln-canvas-soft);
}
.ldb-table tbody td.sticky-col-left,
.ldb-table tbody td.sticky-col-right {
z-index: 2;
background: var(--ln-surface-card);
}
.ldb-table tbody tr:hover td.sticky-col-left,
.ldb-table tbody tr:hover td.sticky-col-right {
background: var(--ln-canvas-soft);
}
.ldb-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.ldb-table-footer-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.ldb-table-total {
font-size: 0.875rem;
color: var(--ln-muted);
}
.ldb-date-range {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.ldb-date-range input {
flex: 1;
min-width: 0;
}
.ldb-date-range-sep {
flex-shrink: 0;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.ldb-filter-expand {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease, opacity 0.22s ease;
opacity: 0;
}
.ldb-filter-expand.is-expanded {
grid-template-rows: 1fr;
opacity: 1;
}
.ldb-filter-expand-inner {
overflow: hidden;
}
.ldb-filter-expand-grid {
padding-top: 4px;
}
.ldb-filter-toggle-icon {
transition: transform 0.25s ease;
}
.ldb-filter-toggle-icon.is-open {
transform: rotate(180deg);
}
.ldb-toolbar-btn {
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}
.ldb-toolbar-btn:hover {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.ldb-toolbar-btn:active {
transform: translateY(1px);
}

View File

@@ -1,6 +1,6 @@
/** /**
* @name 数据分析 * @name 数据分析
* 自 ONE-OS web端 原稿复刻(9 个页面) * 自 ONE-OS web端 原稿复刻(8 个页面;业务部台账已迁至 OneOS → 数据分析
*/ */
import '../../common/oneosWebLegacy/legacyGlobals'; import '../../common/oneosWebLegacy/legacyGlobals';
import React from 'react'; import React from 'react';
@@ -8,11 +8,10 @@ import Page1 from './pages/01-客户服务部业务统计报表.jsx';
import Page2 from './pages/02-物流业务台账.jsx'; import Page2 from './pages/02-物流业务台账.jsx';
import Page3 from './pages/03-物流业务月度统计.jsx'; import Page3 from './pages/03-物流业务月度统计.jsx';
import Page4 from './pages/04-业务部汇总台账.jsx'; import Page4 from './pages/04-业务部汇总台账.jsx';
import Page5 from './pages/05-业务部台账.jsx'; import Page5 from './pages/06-业务部业绩明细.jsx';
import Page6 from './pages/06-业务部业绩明细.jsx'; import Page6 from './pages/07-业务台账.jsx';
import Page7 from './pages/07-业务台账.jsx'; import Page7 from './pages/08-租赁车辆收入明细.jsx';
import Page8 from './pages/08-租赁车辆收入明细.jsx'; import Page8 from './pages/09-租赁客户氢费台账.jsx';
import Page9 from './pages/09-租赁客户氢费台账.jsx';
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell'; import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
const pages = [ const pages = [
@@ -20,11 +19,10 @@ const pages = [
{ id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u53f0u8', title: '物流业务台账', component: Page2 }, { id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u53f0u8', title: '物流业务台账', component: Page2 },
{ id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u6708u5', title: '物流业务月度统计', component: Page3 }, { id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u6708u5', title: '物流业务月度统计', component: Page3 },
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u6c47u603bu5', title: '业务部汇总台账', component: Page4 }, { id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u6c47u603bu5', title: '业务部汇总台账', component: Page4 },
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u53f0u8d26', title: '业务部台账', component: Page5 }, { id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u4e1au7ee9u6', title: '业务部业绩明细', component: Page5 },
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u4e1au7ee9u6', title: '业务部业绩明细', component: Page6 }, { id: 'u6570u636eu5206u6790-u4e1au52a1u53f0u8d26', title: '业务台账', component: Page6 },
{ id: 'u6570u636eu5206u6790-u4e1au52a1u53f0u8d26', title: '业务台账', component: Page7 }, { id: 'u6570u636eu5206u6790-u79dfu8d41u8f66u8f86u6536u5', title: '租赁车辆收入明细', component: Page7 },
{ id: 'u6570u636eu5206u6790-u79dfu8d41u8f66u8f86u6536u5', title: '租赁车辆收入明细', component: Page8 }, { id: 'u6570u636eu5206u6790-u79dfu8d41u5ba2u6237u6c22u8', title: '租赁客户氢费台账', component: Page8 },
{ id: 'u6570u636eu5206u6790-u79dfu8d41u5ba2u6237u6c22u8', title: '租赁客户氢费台账', component: Page9 },
]; ];
export default function OneosWebDataAnalysis() { export default function OneosWebDataAnalysis() {

View File

@@ -1,5 +1,5 @@
// 【重要】必须使用 const Component 作为组件变量名 // 【重要】必须使用 const Component 作为组件变量名
// 数据分析 - 业务部台账(多业务板块:业绩 / 成本 / 利润,按月汇总) // OneOS · 业务部台账(多业务板块:业绩 / 成本 / 利润,按月汇总)
const Component = function () { const Component = function () {
var useState = React.useState; var useState = React.useState;

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "oneos-web-h2-station-site", "prototypeName": "oneos-web-h2-station-site",
"pageId": "site-info", "pageId": "site-info",
"updatedAt": 1782912426557, "updatedAt": 1783328981488,
"nodes": [ "nodes": [
{ {
"id": "h2-site-filter", "id": "h2-site-filter",
@@ -112,31 +112,10 @@
"title": "ONE-OS 原型导航", "title": "ONE-OS 原型导航",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{
"type": "link",
"id": "nav-link-lease-business-ledger",
"title": "租赁业务台账",
"href": "/prototypes/lease-business-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-h2-fee-ledger",
"title": "车辆氢费明细",
"href": "/prototypes/vehicle-h2-fee-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-self-operated-business-ledger",
"title": "自营业务台账",
"href": "/prototypes/self-operated-business-ledger",
"target": "self"
},
{ {
"type": "folder", "type": "folder",
"id": "nav-folder-prototypes-oneos-web", "id": "nav-folder-1782874576229-r8atbu",
"title": "ONE-OS Web 端", "title": "OneOS",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{ {
@@ -145,7 +124,22 @@
"title": "工作台", "title": "工作台",
"href": "/prototypes/oneos-web-workbench", "href": "/prototypes/oneos-web-workbench",
"target": "self" "target": "self"
}, }
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{ {
"type": "link", "type": "link",
"id": "nav-link-oneos-web-login", "id": "nav-link-oneos-web-login",

View File

@@ -0,0 +1,221 @@
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": {
"version": 2,
"prototypeName": "payment-records",
"pageId": "list",
"updatedAt": 1783328981488,
"nodes": [
{
"id": "pr-filter",
"index": 1,
"title": "筛选条件",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"pr-filter\"]"
],
"fingerprint": "pr-filter",
"path": []
},
"aiPrompt": "客户、收款时间、操作人、未认领筛选。",
"annotationText": "默认展示 4 项筛选;支持重置与查询。",
"hasMarkdown": false,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "pr-list",
"index": 2,
"title": "列表与工具栏",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"pr-list\"]"
],
"fingerprint": "pr-list",
"path": []
},
"aiPrompt": "新增、批量导入、导出与收款列表。",
"annotationText": "列表含收款时间、客户、金额、备注、关联账单、未认领金额、操作人、操作时间;操作列支持关联账单。",
"hasMarkdown": false,
"color": "#0f766e",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "pr-bill-link-modal",
"index": 3,
"title": "关联账单",
"pageId": "list",
"locator": {
"selectors": [
"[data-annotation-id=\"pr-bill-link-modal\"]"
],
"fingerprint": "pr-bill-link-modal",
"path": []
},
"aiPrompt": "关联租赁/自营/氢费账单,支持多选。",
"annotationText": "按账单类型切换;勾选后填写本次认领金额;支持单选及多选。",
"hasMarkdown": false,
"color": "#7c3aed",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
}
]
},
"markdownMap": {},
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "folder",
"id": "nav-folder-1782874576229-r8atbu",
"title": "OneOS",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-workbench",
"title": "工作台",
"href": "/prototypes/oneos-web-workbench",
"target": "self"
}
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-login",
"title": "登录",
"href": "/prototypes/oneos-web-login",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-vehicle-asset",
"title": "车辆管理",
"href": "/prototypes/oneos-web-vehicle-asset",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-contract-template",
"title": "合同模板管理",
"href": "/prototypes/oneos-web-contract-template",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-help-center",
"title": "帮助中心",
"href": "/prototypes/oneos-web-help-center",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-finance",
"title": "财务管理",
"href": "/prototypes/oneos-web-finance",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-procurement",
"title": "采购管理",
"href": "/prototypes/oneos-web-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-lease-contract",
"title": "车辆租赁合同",
"href": "/prototypes/oneos-web-lease-contract",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-h2-station",
"title": "加氢站管理",
"href": "/prototypes/oneos-web-h2-station",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-data-analysis",
"title": "数据分析",
"href": "/prototypes/oneos-web-data-analysis",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ledger-data",
"title": "台账数据",
"href": "/prototypes/oneos-web-ledger-data",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-business",
"title": "业务管理",
"href": "/prototypes/oneos-web-business",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ops",
"title": "运维管理",
"href": "/prototypes/oneos-web-ops",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-requirements",
"title": "需求说明",
"href": "/prototypes/oneos-web-requirements",
"target": "self"
}
]
}
]
},
{
"type": "folder",
"id": "pr-doc-root",
"title": "收款记录说明",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "pr-doc-overview",
"title": "模块总览",
"markdown": "# 收款记录\n\n维护客户到账收款支持关联租赁账单、自营账单、氢费账单。KPI 按收款记录统计:到账总金额 = 收款金额合计;认领总金额 = 已认领金额合计;未关联总金额 = 到账金额 已认领金额。"
}
]
}
]
}
}

View File

@@ -0,0 +1,107 @@
import React, { useEffect, useState } from 'react';
import { DatePicker, Input, InputNumber, Modal } from 'antd';
import dayjs from 'dayjs';
export interface AddPaymentFormValues {
paymentTime: string;
customerName: string;
amount: number;
remark: string;
}
interface AddPaymentModalProps {
open: boolean;
onClose: () => void;
onConfirm: (values: AddPaymentFormValues) => void;
}
const DEFAULT_FORM: AddPaymentFormValues = {
paymentTime: '',
customerName: '',
amount: 0,
remark: '',
};
export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalProps) {
const [form, setForm] = useState<AddPaymentFormValues>(DEFAULT_FORM);
useEffect(() => {
if (!open) setForm(DEFAULT_FORM);
}, [open]);
const handleConfirm = () => {
if (!form.paymentTime || !form.customerName.trim() || !form.amount || form.amount <= 0) return;
onConfirm({
paymentTime: form.paymentTime,
customerName: form.customerName.trim(),
amount: Number(form.amount),
remark: form.remark.trim(),
});
};
const valid = Boolean(form.paymentTime && form.customerName.trim() && form.amount > 0);
return (
<Modal
title="新增收款"
open={open}
onCancel={onClose}
onOk={handleConfirm}
okText="保存"
cancelText="取消"
okButtonProps={{ disabled: !valid }}
destroyOnClose
width={560}
data-annotation-id="pr-add-modal"
>
<div className="pr-form-grid">
<label className="vm-filter-field">
<span> <em className="pr-required">*</em></span>
<DatePicker
showTime
style={{ width: '100%' }}
placeholder="请选择到账时间"
value={form.paymentTime ? dayjs(form.paymentTime) : null}
onChange={(value) => setForm((prev) => ({
...prev,
paymentTime: value ? value.format('YYYY-MM-DD HH:mm:ss') : '',
}))}
/>
</label>
<label className="vm-filter-field">
<span> <em className="pr-required">*</em></span>
<Input
value={form.customerName}
placeholder="请输入客户名称"
aria-label="客户名称"
onChange={(e) => setForm((prev) => ({ ...prev, customerName: e.target.value }))}
/>
</label>
<label className="vm-filter-field">
<span> <em className="pr-required">*</em></span>
<InputNumber
style={{ width: '100%' }}
className="tabular-nums"
min={0}
step={0.01}
precision={2}
value={form.amount || undefined}
placeholder="请输入到账金额"
aria-label="金额"
onChange={(value) => setForm((prev) => ({ ...prev, amount: Number(value) || 0 }))}
/>
</label>
<label className="vm-filter-field pr-form-field--full">
<span></span>
<Input.TextArea
rows={3}
value={form.remark}
placeholder="选填,如银行转账、到账说明等"
aria-label="备注"
onChange={(e) => setForm((prev) => ({ ...prev, remark: e.target.value }))}
/>
</label>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,226 @@
import React, { useEffect, useMemo, useState } from 'react';
import { InputNumber, Modal, Table, Tabs } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { BillCandidate, BillType, LinkedBill, PaymentRecord } from '../types';
import { BILL_TYPE_LABELS } from '../types';
import { calcUnclaimedAmount, filterBillsByTypeAndCustomer, formatMoney } from '../utils/payment';
interface BillLinkModalProps {
open: boolean;
record: PaymentRecord | null;
billPool: BillCandidate[];
onClose: () => void;
onConfirm: (recordId: string, linkedBills: LinkedBill[]) => void;
}
type BillTab = BillType | 'all';
export function BillLinkModal({ open, record, billPool, onClose, onConfirm }: BillLinkModalProps) {
const [activeTab, setActiveTab] = useState<BillTab>('all');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [allocations, setAllocations] = useState<Record<string, number>>({});
useEffect(() => {
if (!open || !record) return;
const initialSelected = new Set<string>();
const initialAlloc: Record<string, number> = {};
record.linkedBills.forEach((bill) => {
const key = `${bill.type}:${bill.id}`;
initialSelected.add(key);
initialAlloc[key] = bill.allocatedAmount;
});
setSelected(initialSelected);
setAllocations(initialAlloc);
setActiveTab('all');
}, [open, record?.id]);
const filteredBills = useMemo(() => {
if (!record) return [];
return filterBillsByTypeAndCustomer(billPool, activeTab, record.customerName);
}, [record, billPool, activeTab]);
const linkedTotal = useMemo(
() => Object.values(allocations).reduce((sum, v) => sum + v, 0),
[allocations],
);
const unclaimedBefore = record ? calcUnclaimedAmount(record) : 0;
const remaining = record
? Math.max(0, Math.round((record.amount - linkedTotal) * 100) / 100)
: 0;
const toggleBill = (bill: BillCandidate, checked: boolean) => {
if (!record) return;
const key = `${bill.type}:${bill.id}`;
if (!checked) {
setSelected((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
setAllocations((prev) => {
const copy = { ...prev };
delete copy[key];
return copy;
});
return;
}
setSelected((prev) => new Set(prev).add(key));
setAllocations((prev) => {
const used = Object.values(prev).reduce((sum, v) => sum + v, 0);
const room = Math.max(0, record.amount - used);
const alloc = Math.min(bill.unreceived, room);
return { ...prev, [key]: alloc };
});
};
const updateAllocation = (key: string, value: number, max: number) => {
setAllocations((prev) => ({ ...prev, [key]: Math.min(Math.max(0, value), max) }));
};
const handleConfirm = () => {
if (!record) return;
const linkedBills: LinkedBill[] = Array.from(selected).map((key) => {
const [type, id] = key.split(':') as [BillType, string];
const bill = billPool.find((b) => b.id === id && b.type === type);
return {
id,
type,
label: bill?.label || id,
period: bill?.period,
customerName: bill?.customerName,
projectName: bill?.projectName,
contractCode: bill?.contractCode,
allocatedAmount: allocations[key] ?? 0,
};
}).filter((b) => b.allocatedAmount > 0);
onConfirm(record.id, linkedBills);
setSelected(new Set());
setAllocations({});
};
const columns = useMemo<ColumnsType<BillCandidate>>(() => {
if (!record) return [];
return [
{
title: '账单类型',
key: 'type',
width: 108,
render: (_, bill) => BILL_TYPE_LABELS[bill.type],
},
{
title: '账单摘要',
dataIndex: 'label',
key: 'label',
width: 220,
ellipsis: true,
},
{
title: '账期',
dataIndex: 'period',
key: 'period',
width: 120,
className: 'tabular-nums',
},
{
title: '未收金额',
key: 'unreceived',
width: 120,
align: 'right',
className: 'tabular-nums',
render: (_, bill) => formatMoney(bill.unreceived),
},
{
title: '本次认领',
key: 'alloc',
width: 140,
align: 'right',
render: (_, bill) => {
const key = `${bill.type}:${bill.id}`;
const checked = selected.has(key);
const alloc = allocations[key] ?? 0;
const maxAlloc = Math.min(bill.unreceived, record.amount);
if (!checked) return '—';
return (
<InputNumber
size="small"
className="tabular-nums"
value={alloc || undefined}
min={0}
max={maxAlloc}
step={0.01}
precision={2}
aria-label={`${bill.label} 认领金额`}
onChange={(value) => updateAllocation(key, Number(value) || 0, maxAlloc)}
/>
);
},
},
];
}, [record, selected, allocations]);
const tabItems = [
{ key: 'all', label: '全部' },
{ key: 'lease', label: BILL_TYPE_LABELS.lease },
{ key: 'self-operated', label: BILL_TYPE_LABELS['self-operated'] },
{ key: 'clearing', label: BILL_TYPE_LABELS.clearing },
];
return (
<Modal
title="关联账单"
open={open}
onCancel={onClose}
onOk={handleConfirm}
okText="确认关联"
cancelText="取消"
okButtonProps={{ disabled: selected.size === 0 }}
width={920}
destroyOnClose
data-annotation-id="pr-bill-link-modal"
>
{record ? (
<>
<div className="pr-bill-summary">
<span> <strong>{record.customerName}</strong></span>
<span> <strong className="tabular-nums">{formatMoney(record.amount)}</strong></span>
<span> <strong className="tabular-nums">{formatMoney(unclaimedBefore)}</strong></span>
<span> <strong className="tabular-nums">{formatMoney(linkedTotal)}</strong></span>
<span>
{' '}
<strong className={`tabular-nums ${remaining > 0 ? 'pr-unclaimed--pending' : ''}`}>
{formatMoney(remaining)}
</strong>
</span>
</div>
<p className="pr-bill-hint">
</p>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as BillTab)}
items={tabItems}
style={{ marginBottom: 12 }}
/>
<Table<BillCandidate>
className="vm-list-table"
rowKey={(bill) => `${bill.type}:${bill.id}`}
columns={columns}
dataSource={filteredBills}
size="small"
pagination={false}
scroll={{ y: 320 }}
rowSelection={{
selectedRowKeys: Array.from(selected),
onSelect: (bill, checked) => toggleBill(bill, checked),
onSelectAll: (checked, _rows, changeRows) => {
changeRows.forEach((bill) => toggleBill(bill, checked));
},
}}
locale={{ emptyText: '暂无匹配的账单' }}
/>
</>
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,131 @@
import React, { useMemo } from 'react';
import { DatePicker, Select } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import type { PaymentRecord, PaymentRecordFilters } from '../types';
import { buildCustomerOptions, buildOperatorOptions } from '../utils/payment';
const { RangePicker } = DatePicker;
const DATE_FORMAT = 'YYYY-MM-DD';
const UNCLAIMED_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'yes', label: '有未关联' },
{ value: 'no', label: '已全部关联' },
];
interface FilterPanelProps {
records: PaymentRecord[];
filters: PaymentRecordFilters;
onChange: (patch: Partial<PaymentRecordFilters>) => void;
onReset: () => void;
onSearch: () => void;
}
export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) {
const customerOptions = useMemo(() => buildCustomerOptions(records), [records]);
const operatorOptions = useMemo(
() => buildOperatorOptions(records).map((name) => ({ value: name, label: name })),
[records],
);
const dateRangeValue = useMemo((): [Dayjs, Dayjs] | null => {
if (!filters.paymentFrom && !filters.paymentTo) return null;
const from = filters.paymentFrom ? dayjs(filters.paymentFrom) : null;
const to = filters.paymentTo ? dayjs(filters.paymentTo) : null;
if (from?.isValid() && to?.isValid()) return [from, to];
return null;
}, [filters.paymentFrom, filters.paymentTo]);
return (
<section className="vm-filter-card" data-annotation-id="pr-filter" aria-label="筛选条件">
<header className="vm-filter-header">
<h2 className="vm-filter-title"></h2>
</header>
<div className="vm-filter-grid">
<label className="vm-filter-field">
<span></span>
<Select
placeholder="请选择或输入客户名称"
style={{ width: '100%' }}
value={filters.customerName || undefined}
onChange={(value) => onChange({ customerName: value || '' })}
allowClear
showSearch
options={customerOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
</label>
<label className="vm-filter-field">
<span></span>
<RangePicker
className="lc-filter-select pr-payment-range"
style={{ width: '100%' }}
picker="date"
showTime={false}
format={DATE_FORMAT}
separator="至"
placeholder={['开始日期', '结束日期']}
allowClear
inputReadOnly
value={dateRangeValue}
onChange={(values) => {
onChange({
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
});
}}
/>
</label>
<label className="vm-filter-field">
<span></span>
<Select
placeholder="全部操作人"
style={{ width: '100%' }}
value={filters.operatorName || undefined}
onChange={(value) => onChange({ operatorName: value || '' })}
allowClear
showSearch
options={operatorOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
</label>
<label className="vm-filter-field">
<span></span>
<Select
placeholder="全部"
style={{ width: '100%' }}
value={filters.unclaimed || undefined}
onChange={(value) => onChange({ unclaimed: (value ?? '') as PaymentRecordFilters['unclaimed'] })}
allowClear
options={UNCLAIMED_OPTIONS.filter((o) => o.value !== '')}
/>
</label>
</div>
<div className="vm-filter-actions">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useMemo } from 'react';
import type { BillCandidate, BillType, EnrichedLinkedBill, LinkedBill } from '../types';
import { BILL_TYPE_LABELS } from '../types';
import { calcBillUnreceivedAmount, enrichLinkedBills, formatMoney } from '../utils/payment';
const BILL_TYPE_SHORT: Record<BillType, string> = {
lease: '租赁',
'self-operated': '自营',
clearing: '氢费',
};
interface LinkedBillsNestedTableProps {
linkedBills: LinkedBill[];
billPool: BillCandidate[];
paymentCustomerName?: string;
}
function renderBillType(type: LinkedBill['type']) {
return (
<span
className={`pr-bill-type-tag pr-bill-type-tag--${type}`}
title={BILL_TYPE_LABELS[type]}
>
{BILL_TYPE_SHORT[type]}
</span>
);
}
function renderBillInfo(row: EnrichedLinkedBill) {
const customerName = row.customerName || '—';
const contractCode = row.contractCode || '—';
const projectName = row.projectName || '—';
const title = [customerName, contractCode, projectName].join(' / ');
return (
<div className="vm-stack pr-linked-bill-info" title={title}>
<span className="primary">{customerName}</span>
<span className="code tabular-nums">{contractCode}</span>
<span className="sub">{projectName}</span>
</div>
);
}
export function LinkedBillsNestedTable({
linkedBills,
billPool,
paymentCustomerName,
}: LinkedBillsNestedTableProps) {
const rows = useMemo(
() => enrichLinkedBills(linkedBills, billPool, paymentCustomerName),
[linkedBills, billPool, paymentCustomerName],
);
if (rows.length === 0) {
return <div className="lc-contract-vehicle-expand__empty"></div>;
}
return (
<div className="lc-contract-vehicle-table-wrap pr-linked-bill-table-wrap">
<table className="lc-contract-vehicle-native-table pr-linked-bill-native-table">
<thead>
<tr>
<th scope="col" className="pr-linked-bill-native-table__type"></th>
<th scope="col" className="pr-linked-bill-native-table__info"></th>
<th scope="col" className="pr-linked-bill-native-table__period"></th>
<th scope="col" className="pr-linked-bill-native-table__money"></th>
<th scope="col" className="pr-linked-bill-native-table__money"></th>
<th scope="col" className="pr-linked-bill-native-table__money"></th>
</tr>
</thead>
<tbody>
{rows.map((row: EnrichedLinkedBill) => {
const unreceived = calcBillUnreceivedAmount(row.receivable, row.allocatedAmount);
return (
<tr key={`${row.type}:${row.id}`}>
<td className="pr-linked-bill-native-table__type">{renderBillType(row.type)}</td>
<td className="pr-linked-bill-native-table__info">{renderBillInfo(row)}</td>
<td className="pr-linked-bill-native-table__period tabular-nums">{row.period || '—'}</td>
<td className="pr-linked-bill-native-table__money tabular-nums">
{row.receivable != null ? formatMoney(row.receivable) : '—'}
</td>
<td className="pr-linked-bill-native-table__money tabular-nums">
{formatMoney(row.allocatedAmount)}
</td>
<td className={`pr-linked-bill-native-table__money tabular-nums${unreceived != null && unreceived < 0 ? ' pr-bill-unreceived--negative' : ''}`}>
{unreceived != null ? formatMoney(unreceived) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React, { useMemo } from 'react';
import { DatePicker, Select } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import type { PaymentKpiDetailFilters } from '../types';
import type { PaymentKpiKey } from '../utils/payment';
const { RangePicker } = DatePicker;
const DATE_FORMAT = 'YYYY-MM-DD';
interface PaymentKpiDetailFilterBarProps {
kpiKey: PaymentKpiKey;
filters: PaymentKpiDetailFilters;
customerOptions: Array<{ value: string; label: string }>;
onChange: (patch: Partial<PaymentKpiDetailFilters>) => void;
onReset: () => void;
onSearch: () => void;
}
export function PaymentKpiDetailFilterBar({
kpiKey,
filters,
customerOptions,
onChange,
onReset,
onSearch,
}: PaymentKpiDetailFilterBarProps) {
const dateRangeValue = useMemo((): [Dayjs, Dayjs] | null => {
if (!filters.paymentFrom && !filters.paymentTo) return null;
const from = filters.paymentFrom ? dayjs(filters.paymentFrom) : null;
const to = filters.paymentTo ? dayjs(filters.paymentTo) : null;
if (from?.isValid() && to?.isValid()) return [from, to];
return null;
}, [filters.paymentFrom, filters.paymentTo]);
const inlineLayout = kpiKey === 'linked' || kpiKey === 'unlinked';
return (
<section
className={`pr-kpi-detail-filter${inlineLayout ? ' pr-kpi-detail-filter--inline' : ''}`}
aria-label="明细筛选"
>
<div className="pr-kpi-detail-filter__grid">
<label className="pr-kpi-detail-filter__field">
<span></span>
<Select
placeholder="全部客户"
style={{ width: '100%' }}
value={filters.customerName || undefined}
onChange={(value) => onChange({ customerName: value || '' })}
allowClear
showSearch
options={customerOptions}
filterOption={(input, option) =>
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
/>
</label>
<label className="pr-kpi-detail-filter__field">
<span></span>
<RangePicker
className="lc-filter-select pr-payment-range"
style={{ width: '100%' }}
picker="date"
showTime={false}
format={DATE_FORMAT}
separator="至"
placeholder={['开始日期', '结束日期']}
allowClear
inputReadOnly
value={dateRangeValue}
onChange={(values) => {
onChange({
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
});
}}
/>
</label>
</div>
<div className="pr-kpi-detail-filter__actions">
<button type="button" className="vm-btn vm-btn-ghost vm-btn-sm" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary vm-btn-sm" onClick={onSearch}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,343 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Table, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
LN_MODAL_DETAIL_TABLE_CLASS,
sumColumnScrollX,
warnMissingModalColumnWidths,
} from '../../../common/modalDetailTable';
import type { PaymentRecord } from '../types';
import { EMPTY_PAYMENT_KPI_DETAIL_FILTERS } from '../types';
import {
buildDetailCustomerOptions,
buildLinkedDetailRows,
buildReceivedDetailRows,
buildUnlinkedDetailRows,
exportKpiDetailCsv,
filterLinkedDetailRows,
filterReceivedDetailRows,
filterUnlinkedDetailRows,
formatMoney,
sumLinkedDetailAmount,
sumReceivedDetailAmount,
sumUnlinkedDetailAmount,
type PaymentKpiKey,
type PaymentLinkedDetailRow,
type PaymentReceivedDetailRow,
type PaymentUnlinkedDetailRow,
} from '../utils/payment';
import { PaymentKpiDetailFilterBar } from './PaymentKpiDetailFilterBar';
interface KpiModalMeta {
title: string;
amountLabel: string;
width: number;
subtitle: string;
tone: 'received' | 'linked' | 'unlinked';
}
const KPI_MODAL_META: Record<PaymentKpiKey, KpiModalMeta> = {
received: {
title: '到账总金额明细',
amountLabel: '到账金额合计',
width: 800,
subtitle: '当前筛选范围内全部收款记录的到账明细',
tone: 'received',
},
linked: {
title: '认领总金额明细',
amountLabel: '认领金额合计',
width: 860,
subtitle: '按收款记录汇总已认领金额(到账金额中已关联账单的部分)',
tone: 'linked',
},
unlinked: {
title: '未关联总金额明细',
amountLabel: '未关联金额合计',
width: 900,
subtitle: '到账金额减去已认领金额后,尚未关联的部分',
tone: 'unlinked',
},
};
function KpiModalIcon({ tone }: { tone: KpiModalMeta['tone'] }) {
if (tone === 'linked') {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
if (tone === 'unlinked') {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
);
}
return (
<svg xmlns="http://www.w3.org/2000/svg" width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 2v20" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
}
function ExportIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
);
}
const RECEIVED_COLUMNS: ColumnsType<PaymentReceivedDetailRow> = [
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 220, ellipsis: true },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 172, ellipsis: true },
];
const LINKED_COLUMNS: ColumnsType<PaymentLinkedDetailRow> = [
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 200, ellipsis: true },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '认领金额', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 160, ellipsis: true },
];
const UNLINKED_COLUMNS: ColumnsType<PaymentUnlinkedDetailRow> = [
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 148, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 168, ellipsis: true },
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '已认领', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
{ title: '未关联金额', dataIndex: 'unlinkedAmount', key: 'unlinkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: (v: number) => (
<span className={v > 0 ? 'pr-unlinked--pending' : undefined}>{formatMoney(v)}</span>
) },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 140, ellipsis: true },
];
warnMissingModalColumnWidths(RECEIVED_COLUMNS, 'PaymentKpiDetailModal.received');
warnMissingModalColumnWidths(LINKED_COLUMNS, 'PaymentKpiDetailModal.linked');
warnMissingModalColumnWidths(UNLINKED_COLUMNS, 'PaymentKpiDetailModal.unlinked');
interface PaymentKpiDetailModalProps {
open: boolean;
kpiKey: PaymentKpiKey | null;
records: PaymentRecord[];
onClose: () => void;
}
function ModalTitle({ meta }: { meta: KpiModalMeta }) {
return (
<div className="pr-kpi-detail-modal__title">
<span className={`pr-kpi-detail-modal__icon pr-kpi-detail-modal__icon--${meta.tone}`} aria-hidden>
<KpiModalIcon tone={meta.tone} />
</span>
<span className="pr-kpi-detail-modal__title-text">
<span className="pr-kpi-detail-modal__title-main">{meta.title}</span>
<span className="pr-kpi-detail-modal__title-sub">{meta.subtitle}</span>
</span>
</div>
);
}
function EmptyDetail({ text }: { text: string }) {
return (
<div className="ln-modal-detail-empty" role="status">
<p>{text}</p>
<p className="ln-modal-detail-empty__hint"></p>
</div>
);
}
export function PaymentKpiDetailModal({
open,
kpiKey,
records,
onClose,
}: PaymentKpiDetailModalProps) {
const meta = kpiKey ? KPI_MODAL_META[kpiKey] : null;
const [pendingFilters, setPendingFilters] = useState(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
const [appliedFilters, setAppliedFilters] = useState(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
useEffect(() => {
if (open) {
setPendingFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
setAppliedFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
}
}, [open, kpiKey]);
const allReceivedRows = useMemo(
() => (kpiKey === 'received' ? buildReceivedDetailRows(records) : []),
[kpiKey, records],
);
const allLinkedRows = useMemo(
() => (kpiKey === 'linked' ? buildLinkedDetailRows(records) : []),
[kpiKey, records],
);
const allUnlinkedRows = useMemo(
() => (kpiKey === 'unlinked' ? buildUnlinkedDetailRows(records) : []),
[kpiKey, records],
);
const receivedRows = useMemo(
() => filterReceivedDetailRows(allReceivedRows, appliedFilters),
[allReceivedRows, appliedFilters],
);
const linkedRows = useMemo(
() => filterLinkedDetailRows(allLinkedRows, appliedFilters),
[allLinkedRows, appliedFilters],
);
const unlinkedRows = useMemo(
() => filterUnlinkedDetailRows(allUnlinkedRows, appliedFilters),
[allUnlinkedRows, appliedFilters],
);
const customerOptions = useMemo(() => {
if (kpiKey === 'received') {
return buildDetailCustomerOptions(allReceivedRows.map((row) => row.customerName));
}
if (kpiKey === 'linked') {
return buildDetailCustomerOptions(allLinkedRows.map((row) => row.customerName));
}
if (kpiKey === 'unlinked') {
return buildDetailCustomerOptions(allUnlinkedRows.map((row) => row.customerName));
}
return [];
}, [kpiKey, allReceivedRows, allLinkedRows, allUnlinkedRows]);
const rowCount = kpiKey === 'received'
? receivedRows.length
: kpiKey === 'linked'
? linkedRows.length
: unlinkedRows.length;
const filteredTotalAmount = kpiKey === 'received'
? sumReceivedDetailAmount(receivedRows)
: kpiKey === 'linked'
? sumLinkedDetailAmount(linkedRows)
: sumUnlinkedDetailAmount(unlinkedRows);
const tableProps = {
className: LN_MODAL_DETAIL_TABLE_CLASS,
rowKey: 'key' as const,
size: 'small' as const,
tableLayout: 'fixed' as const,
pagination: {
pageSize: 8,
showSizeChanger: false,
hideOnSinglePage: true,
showTotal: (total: number) => `${total}`,
},
scroll: { y: 320 },
};
const amountToneClass = meta?.tone === 'linked'
? 'pr-kpi-detail-modal__stat-val--linked'
: meta?.tone === 'unlinked'
? 'pr-kpi-detail-modal__stat-val--warn'
: '';
const handleExport = () => {
if (!kpiKey) return;
const count = exportKpiDetailCsv(kpiKey, receivedRows, linkedRows, unlinkedRows);
message.success(`已导出 ${count} 条明细(原型演示)`);
};
return (
<Modal
className={`pr-kpi-detail-modal pr-kpi-detail-modal--${meta?.tone ?? 'received'}`}
title={meta ? <ModalTitle meta={meta} /> : null}
open={open}
onCancel={onClose}
centered
width={meta?.width ?? 800}
destroyOnClose
maskClosable
footer={null}
>
<div className="pr-kpi-detail-modal__body">
{kpiKey && (
<PaymentKpiDetailFilterBar
kpiKey={kpiKey}
filters={pendingFilters}
customerOptions={customerOptions}
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
onReset={() => {
setPendingFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
setAppliedFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
}}
onSearch={() => setAppliedFilters(pendingFilters)}
/>
)}
<div className="pr-kpi-detail-modal__stats" role="group" aria-label="汇总统计">
<div className={`pr-kpi-detail-modal__stat-card pr-kpi-detail-modal__stat-card--primary pr-kpi-detail-modal__stat-card--${meta?.tone ?? 'received'}`}>
<span className="pr-kpi-detail-modal__stat-label">{meta?.amountLabel}</span>
<strong className={`pr-kpi-detail-modal__stat-val tabular-nums ${amountToneClass}`}>
{formatMoney(filteredTotalAmount)}
</strong>
</div>
<div className="pr-kpi-detail-modal__stat-card">
<span className="pr-kpi-detail-modal__stat-label"></span>
<strong className="pr-kpi-detail-modal__stat-val tabular-nums">{rowCount}</strong>
</div>
</div>
<section className="ln-modal-detail-panel" aria-label="明细列表">
<header className="ln-modal-detail-panel__head pr-kpi-detail-panel__head">
<span className="ln-modal-detail-panel__title"></span>
<div className="pr-kpi-detail-panel__head-actions">
<span className="ln-modal-detail-panel__meta tabular-nums">{rowCount} </span>
<button
type="button"
className="vm-btn vm-btn-ghost vm-btn-sm pr-kpi-detail-panel__export"
onClick={handleExport}
disabled={rowCount === 0}
>
<ExportIcon />
</button>
</div>
</header>
{kpiKey === 'received' && (
<Table<PaymentReceivedDetailRow>
{...tableProps}
columns={RECEIVED_COLUMNS}
dataSource={receivedRows}
scroll={{ ...tableProps.scroll, x: sumColumnScrollX(RECEIVED_COLUMNS) }}
locale={{ emptyText: <EmptyDetail text="当前筛选条件下暂无收款记录" /> }}
/>
)}
{kpiKey === 'linked' && (
<Table<PaymentLinkedDetailRow>
{...tableProps}
columns={LINKED_COLUMNS}
dataSource={linkedRows}
scroll={{ ...tableProps.scroll, x: sumColumnScrollX(LINKED_COLUMNS) }}
locale={{ emptyText: <EmptyDetail text="当前筛选条件下暂无已认领收款记录" /> }}
/>
)}
{kpiKey === 'unlinked' && (
<Table<PaymentUnlinkedDetailRow>
{...tableProps}
columns={UNLINKED_COLUMNS}
dataSource={unlinkedRows}
scroll={{ ...tableProps.scroll, x: sumColumnScrollX(UNLINKED_COLUMNS) }}
locale={{ emptyText: <EmptyDetail text="当前筛选条件下暂无未关联收款记录" /> }}
/>
)}
</section>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,182 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Button, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { BillCandidate, PaymentRecord } from '../types';
import { calcUnclaimedAmount, formatMoney } from '../utils/payment';
import { LinkedBillsNestedTable } from './LinkedBillsNestedTable';
const ONEOS_ANT_TABLE_GLOBAL_FIX = [
'.ant-table-container .ant-table-header { margin-bottom: 0 !important; }',
'.ant-table-container .ant-table-body { margin-top: 0 !important; }',
'.ant-table-container .ant-table-body > table, .ant-table-content table { margin-top: 0 !important; }',
'.ant-table-tbody > tr.ant-table-measure-row, .ant-table-tbody > tr.ant-table-measure-row > td, .ant-table-tbody > tr.ant-table-measure-row > th { display: none !important; height: 0 !important; max-height: 0 !important; min-height: 0 !important; padding: 0 !important; margin: 0 !important; border: none !important; line-height: 0 !important; font-size: 0 !important; overflow: hidden !important; visibility: hidden !important; pointer-events: none !important; }',
'.vm-page.lc-page.pr-page .vm-list-table .ant-table-tbody > tr.ant-table-expanded-row > td { padding: 8px 120px 12px 8px !important; overflow: visible !important; }',
'.vm-list-table .ant-table-thead th,.vm-list-table .ant-table-tbody td{white-space:nowrap;}',
].join('\n');
interface PaymentRecordsTableProps {
records: PaymentRecord[];
billPool: BillCandidate[];
loading?: boolean;
onLinkBills: (record: PaymentRecord) => void;
}
export function PaymentRecordsTable({
records,
billPool,
loading = false,
onLinkBills,
}: PaymentRecordsTableProps) {
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const toggleRowExpand = useCallback((recordId: string) => {
setExpandedRowKeys((prev) => (
prev.includes(recordId)
? prev.filter((key) => key !== recordId)
: [...prev, recordId]
));
}, []);
const columns = useMemo<ColumnsType<PaymentRecord>>(() => [
{
title: '收款时间',
dataIndex: 'paymentTime',
key: 'paymentTime',
width: 168,
className: 'tabular-nums',
render: (value: string) => value || '—',
},
{
title: '客户名称',
dataIndex: 'customerName',
key: 'customerName',
width: 200,
ellipsis: true,
render: (value: string) => value || '—',
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
align: 'right',
className: 'tabular-nums',
render: (value: number) => formatMoney(value),
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
width: 160,
ellipsis: true,
className: 'pr-col-remark',
render: (value: string) => value || '—',
},
{
title: '关联账单',
key: 'linkedBills',
width: 96,
className: 'pr-linked-col',
render: (_, row) => {
const count = row.linkedBills?.length ?? 0;
if (count === 0) {
return <span className="pr-linked-empty"></span>;
}
const expanded = expandedRowKeys.includes(row.id);
return (
<button
type="button"
className="pr-linked-count-btn"
aria-expanded={expanded}
onClick={() => toggleRowExpand(row.id)}
>
{count}
</button>
);
},
},
{
title: '未关联金额',
key: 'unlinked',
width: 120,
align: 'right',
className: 'tabular-nums',
render: (_, row) => {
const unlinked = calcUnclaimedAmount(row);
return (
<span className={unlinked > 0 ? 'pr-unlinked--pending' : undefined}>
{formatMoney(unlinked)}
</span>
);
},
},
{
title: '操作人',
dataIndex: 'operatorName',
key: 'operatorName',
width: 100,
render: (value: string) => value || '—',
},
{
title: '操作时间',
dataIndex: 'operatedAt',
key: 'operatedAt',
width: 168,
className: 'tabular-nums',
render: (value: string) => value || '—',
},
{
title: '操作',
key: 'action',
width: 108,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => onLinkBills(row)}>
</Button>
),
},
], [billPool, expandedRowKeys, onLinkBills, toggleRowExpand]);
const scrollX = columns.reduce((sum, col) => sum + (Number(col.width) || 0), 0) + 48;
return (
<>
<style>{ONEOS_ANT_TABLE_GLOBAL_FIX}</style>
<div className="vm-table-wrap" data-annotation-id="pr-table">
<Table<PaymentRecord>
className="vm-list-table pr-list-table lc-list-table--nested"
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
tableLayout="fixed"
scroll={{ x: scrollX, y: 'calc(100vh - 560px)' }}
size="small"
pagination={false}
expandable={{
columnWidth: 48,
expandedRowKeys,
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys),
expandedRowRender: (record) => (
<LinkedBillsNestedTable
linkedBills={record.linkedBills}
billPool={billPool}
paymentCustomerName={record.customerName}
/>
),
rowExpandable: (record) => (record.linkedBills?.length ?? 0) > 0,
}}
locale={{
emptyText: (
<div className="vm-table-empty" role="status">
<p></p>
<p className="vm-table-empty__hint"> Excel</p>
</div>
),
}}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,17 @@
{
"lease": [
{ "id": "lb-1", "type": "lease", "customerName": "安徽驰远供应链管理有限公司", "projectName": "嘉兴氢能示范项目", "contractCode": "HT-ZL-2026-001", "label": "沪A32896F · 2026年1月", "period": "2026-01", "receivable": 4500, "unreceived": 4500 },
{ "id": "lb-2", "type": "lease", "customerName": "嘉兴港区韵达快递有限公司", "projectName": "嘉兴氢能示范项目", "contractCode": "HT-ZL-2026-002", "label": "粤AGF4535 · 2026年2月", "period": "2026-02", "receivable": 3500, "unreceived": 3500 },
{ "id": "lb-3", "type": "lease", "customerName": "上海朔宇物流有限公司", "projectName": "上海物流租赁项目", "contractCode": "HT-ZL-2025-003", "label": "浙F05580F · 2025年12月", "period": "2025-12", "receivable": 2500, "unreceived": 2500 },
{ "id": "lb-4", "type": "lease", "customerName": "上海利合供应链管理有限公司", "projectName": "上海物流租赁项目", "contractCode": "HT-ZL-2025-004", "label": "浙F08991F · 2025年12月", "period": "2025-12", "receivable": 4000, "unreceived": 0 }
],
"self-operated": [
{ "id": "sb-1", "type": "self-operated", "customerName": "安徽驰远供应链管理有限公司", "projectName": "嘉兴城配自营项目", "contractCode": "ZY-202601-001", "label": "自营出车 · 2026年1月", "period": "2026-01", "receivable": 2800, "unreceived": 2800 },
{ "id": "sb-2", "type": "self-operated", "customerName": "嘉兴港区韵达快递有限公司", "projectName": "嘉兴城配自营项目", "contractCode": "ZY-202602-001", "label": "自营出车 · 2026年2月", "period": "2026-02", "receivable": 1600, "unreceived": 1600 }
],
"clearing": [
{ "id": "cb-1", "type": "clearing", "customerName": "安徽驰远供应链管理有限公司", "projectName": "嘉兴氢能示范项目", "contractCode": "CF-202601-008", "label": "氢费单 CF-202601-008", "period": "2026-01", "receivable": 1200, "unreceived": 1200 },
{ "id": "cb-2", "type": "clearing", "customerName": "上海朔宇物流有限公司", "projectName": "上海物流租赁项目", "contractCode": "CF-202512-021", "label": "氢费单 CF-202512-021", "period": "2025-12", "receivable": 800, "unreceived": 800 },
{ "id": "cb-3", "type": "clearing", "customerName": "苏州望亭远方物流有限公司", "projectName": "苏州望亭城配项目", "contractCode": "CF-202601-015", "label": "氢费单 CF-202601-015", "period": "2026-01", "receivable": 650, "unreceived": 650 }
]
}

View File

@@ -0,0 +1,73 @@
[
{
"id": "pr1",
"paymentTime": "2026-01-19 14:30",
"customerName": "安徽驰远供应链管理有限公司",
"amount": 5700,
"remark": "银行转账",
"linkedBills": [
{ "id": "lb-1", "type": "lease", "label": "沪A32896F · 2026年1月", "period": "2026-01", "allocatedAmount": 4500 },
{ "id": "sb-1", "type": "self-operated", "label": "自营出车 · 2026年1月", "period": "2026-01", "allocatedAmount": 800 },
{ "id": "cb-1", "type": "clearing", "label": "氢费单 CF-202601-008", "period": "2026-01", "allocatedAmount": 400 }
],
"operatorName": "张专员",
"operatedAt": "2026-01-19 15:02"
},
{
"id": "pr2",
"paymentTime": "2026-02-02 10:00",
"customerName": "嘉兴港区韵达快递有限公司",
"amount": 3500,
"remark": "分两笔到账",
"linkedBills": [
{ "id": "lb-2", "type": "lease", "label": "粤AGF4535 · 2026年2月", "allocatedAmount": 2000 }
],
"operatorName": "李主管",
"operatedAt": "2026-02-02 10:18"
},
{
"id": "pr3",
"paymentTime": "2026-02-02 16:20",
"customerName": "嘉兴港区韵达快递有限公司",
"amount": 1500,
"remark": "第二笔到账",
"linkedBills": [],
"operatorName": "李主管",
"operatedAt": "2026-02-02 16:25"
},
{
"id": "pr4",
"paymentTime": "2025-12-25 09:15",
"customerName": "上海朔宇物流有限公司",
"amount": 2500,
"remark": "部分认领",
"linkedBills": [
{ "id": "lb-3", "type": "lease", "label": "浙F05580F · 2025年12月", "period": "2025-12", "allocatedAmount": 1200 },
{ "id": "cb-2", "type": "clearing", "label": "氢费单 CF-202512-021", "period": "2025-12", "allocatedAmount": 300 }
],
"operatorName": "王专员",
"operatedAt": "2025-12-25 09:40"
},
{
"id": "pr5",
"paymentTime": "2025-12-26 15:40",
"customerName": "上海利合供应链管理有限公司",
"amount": 4000,
"remark": "全额到账",
"linkedBills": [
{ "id": "lb-4", "type": "lease", "label": "浙F08991F · 2025年12月", "allocatedAmount": 4000 }
],
"operatorName": "张专员",
"operatedAt": "2025-12-26 16:00"
},
{
"id": "pr6",
"paymentTime": "2026-01-08 11:20",
"customerName": "苏州望亭远方物流有限公司",
"amount": 1800,
"remark": "待关联氢费账单",
"linkedBills": [],
"operatorName": "赵专员",
"operatedAt": "2026-01-08 11:22"
}
]

View File

@@ -0,0 +1,344 @@
/**
* @name 收款记录
* 设计规范oneos-app.css · vm-page lc-page对齐租赁合同管理
*/
import 'antd/dist/reset.css';
import '../vehicle-management/style.css';
import './styles/index.css';
import React, { useMemo, useState } from 'react';
import { App, ConfigProvider, message } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import seedRecords from './data/records.json';
import seedBills from './data/bills.json';
import { FilterPanel } from './components/FilterPanel';
import { PaymentRecordsTable } from './components/PaymentRecordsTable';
import { AddPaymentModal, type AddPaymentFormValues } from './components/AddPaymentModal';
import { BillLinkModal } from './components/BillLinkModal';
import { PaymentKpiDetailModal } from './components/PaymentKpiDetailModal';
import { TablePagination } from '../vehicle-management/components/TablePagination';
import type { BillCandidate, LinkedBill, PaymentRecord, PaymentRecordFilters } from './types';
import { EMPTY_PAYMENT_FILTERS } from './types';
import {
applyPaymentFilters,
flattenBillPool,
formatMoney,
sumLinkedAmount,
sumPaymentAmount,
sumUnclaimedAmount,
type PaymentKpiKey,
} from './utils/payment';
import annotationSourceDocument from './annotation-source.json';
const PAGE_SIZE = 10;
const OPERATOR_NAME = '张专员';
dayjs.locale('zh-cn');
const vmTheme = {
token: {
colorPrimary: '#32a06e',
colorLink: '#32a06e',
colorLinkHover: '#3fb87c',
borderRadius: 8,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
colorText: '#18181b',
colorTextSecondary: '#52525b',
colorBorder: '#e5e7eb',
colorBgContainer: '#ffffff',
},
components: {
Table: {
headerBg: '#f6f7f7',
headerColor: '#71717a',
rowHoverBg: '#f6f7f7',
borderColor: '#e5e7eb',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
Card: {
borderRadiusLG: 12,
},
InputNumber: {
handleVisible: true,
},
},
};
const AMOUNT_KPI_CARDS = [
{ key: 'received', title: '到账总金额', desc: '当前筛选范围内全部收款记录的到账金额合计' },
{ key: 'linked', title: '认领总金额', desc: '当前筛选范围内收款记录已认领金额合计' },
{ key: 'unlinked', title: '未关联总金额', desc: '当前筛选范围内收款记录未关联金额合计(到账金额 已认领金额)' },
] as const;
function ExportIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
);
}
function ImportIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
);
}
function PaymentRecordsContent() {
const [records, setRecords] = useState<PaymentRecord[]>(seedRecords as PaymentRecord[]);
const [pendingFilters, setPendingFilters] = useState<PaymentRecordFilters>(EMPTY_PAYMENT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<PaymentRecordFilters>(EMPTY_PAYMENT_FILTERS);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [tableLoading, setTableLoading] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [linkRow, setLinkRow] = useState<PaymentRecord | null>(null);
const [kpiDetailKey, setKpiDetailKey] = useState<PaymentKpiKey | null>(null);
const billPool = useMemo(
() => flattenBillPool(seedBills as Record<string, BillCandidate[]>),
[],
);
const filtered = useMemo(
() => applyPaymentFilters(records, appliedFilters, billPool),
[records, appliedFilters, billPool],
);
const amountKpis = useMemo(() => ({
received: sumPaymentAmount(filtered),
linked: sumLinkedAmount(filtered),
unlinked: sumUnclaimedAmount(filtered),
}), [filtered]);
const paged = useMemo(() => {
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, page, pageSize]);
const handleSearch = () => {
setTableLoading(true);
window.setTimeout(() => {
setAppliedFilters(pendingFilters);
setPage(1);
setTableLoading(false);
}, 280);
};
const handleReset = () => {
setPendingFilters(EMPTY_PAYMENT_FILTERS);
setAppliedFilters(EMPTY_PAYMENT_FILTERS);
setPage(1);
};
const handleAdd = (values: AddPaymentFormValues) => {
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')}`;
const row: PaymentRecord = {
id: `pr-${Date.now()}`,
paymentTime: values.paymentTime.length === 16 ? `${values.paymentTime}:00` : values.paymentTime,
customerName: values.customerName,
amount: values.amount,
remark: values.remark,
linkedBills: [],
operatorName: OPERATOR_NAME,
operatedAt,
};
setRecords((prev) => [row, ...prev]);
setAddOpen(false);
message.success('收款记录已新增(原型演示)');
};
const handleLinkBills = (recordId: string, linkedBills: LinkedBill[]) => {
const operatedAt = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
setRecords((prev) => prev.map((row) => (
row.id === recordId
? { ...row, linkedBills, operatedAt, operatorName: OPERATOR_NAME }
: row
)));
setLinkRow(null);
message.success('账单关联成功(原型演示)');
};
const resolveAmountKpiValue = (key: typeof AMOUNT_KPI_CARDS[number]['key']) => {
if (key === 'received') return formatMoney(amountKpis.received);
if (key === 'linked') return formatMoney(amountKpis.linked);
return formatMoney(amountKpis.unlinked);
};
const resolveAmountKpiClass = (key: typeof AMOUNT_KPI_CARDS[number]['key']) => {
if (key === 'linked') return 'pr-kpi-val--linked';
if (key === 'unlinked') return 'pr-kpi-val--warn';
return '';
};
return (
<div className="vm-page lc-page pr-page">
<FilterPanel
records={records}
filters={pendingFilters}
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
onReset={handleReset}
onSearch={handleSearch}
/>
<div className="vm-kpi-row" role="group" aria-label="收款金额统计" data-annotation-id="pr-kpi">
{AMOUNT_KPI_CARDS.map((card) => (
<button
key={card.key}
type="button"
className={`vm-kpi-card pr-kpi-card pr-kpi-card--clickable${kpiDetailKey === card.key ? ' pr-kpi-card--active' : ''}`}
title={`${card.desc},点击查看明细`}
aria-label={`${card.title} ${resolveAmountKpiValue(card.key)},点击查看明细`}
onClick={() => setKpiDetailKey(card.key)}
>
<span className="vm-kpi-main">
<span className="vm-kpi-eyebrow">{card.title}</span>
<span className={`vm-kpi-val tabular-nums ${resolveAmountKpiClass(card.key)}`}>
{resolveAmountKpiValue(card.key)}
</span>
</span>
</button>
))}
</div>
<section className="vm-table-section" data-annotation-id="pr-list">
<div className="vm-table-toolbar" data-annotation-id="pr-toolbar">
<div className="vm-table-actions">
<button
type="button"
className="vm-btn vm-btn-ghost"
onClick={() => message.info('批量导入:请下载模板后上传 Excel原型演示')}
>
<ImportIcon />
</button>
<button
type="button"
className="vm-btn vm-btn-ghost"
onClick={() => message.info(`已导出 ${filtered.length} 条收款记录(原型演示)`)}
>
<ExportIcon />
</button>
<button
type="button"
className="vm-btn vm-btn-primary"
onClick={() => setAddOpen(true)}
data-annotation-id="pr-add-btn"
>
</button>
</div>
</div>
<div className="vm-table-card">
<PaymentRecordsTable
records={paged}
billPool={billPool}
loading={tableLoading}
onLinkBills={setLinkRow}
/>
<div className="vm-table-footer">
<TablePagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</div>
</section>
<AddPaymentModal
open={addOpen}
onClose={() => setAddOpen(false)}
onConfirm={handleAdd}
/>
<BillLinkModal
open={Boolean(linkRow)}
record={linkRow}
billPool={billPool}
onClose={() => setLinkRow(null)}
onConfirm={handleLinkBills}
/>
<PaymentKpiDetailModal
open={kpiDetailKey != null}
kpiKey={kpiDetailKey}
records={filtered}
onClose={() => setKpiDetailKey(null)}
/>
</div>
);
}
export default function PaymentRecordsApp() {
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: 'list',
onDirectoryRoute: (_node: AnnotationDirectoryRouteNode) => {},
}),
[],
);
return (
<ConfigProvider theme={vmTheme} locale={zhCN}>
<App>
<PaymentRecordsContent />
</App>
<AnnotationViewer
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</ConfigProvider>
);
}

View File

@@ -0,0 +1,584 @@
/**
* 收款记录 — 对齐租赁合同管理 vm-page lc-page
*/
@import url('../../lease-contract-management/styles/lease-contract.css');
.vm-page.lc-page.pr-page .vm-kpi-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 16px;
}
.vm-page.lc-page.pr-page .pr-kpi-card {
cursor: default;
}
.vm-page.lc-page.pr-page .pr-kpi-card--clickable {
cursor: pointer;
border: 1px solid var(--ln-hairline, #e2e8f0);
background: var(--ln-surface-card, #fff);
font: inherit;
text-align: left;
width: 100%;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.vm-page.lc-page.pr-page .pr-kpi-card--clickable:hover {
border-color: rgba(50, 160, 110, 0.45);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
.vm-page.lc-page.pr-page .pr-kpi-card--clickable:focus-visible {
outline: 2px solid var(--ln-primary, #32a06e);
outline-offset: 2px;
}
.vm-page.lc-page.pr-page .pr-kpi-card--clickable.pr-kpi-card--active {
border-color: var(--ln-primary, #32a06e);
box-shadow: 0 0 0 1px rgba(50, 160, 110, 0.2);
}
.pr-kpi-detail-modal__summary {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 16px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--ln-hairline, #e2e8f0);
font-size: 0.875rem;
color: var(--ln-muted, #64748b);
}
.pr-kpi-detail-modal__summary strong {
font-size: 1.125rem;
color: var(--ln-body, #0f172a);
}
.pr-kpi-detail-modal__count {
margin-left: auto;
font-size: 0.8125rem;
}
/* KPI 明细弹窗 */
.pr-kpi-detail-modal .ant-modal-content {
border-radius: var(--ln-radius-card, 12px);
overflow: hidden;
box-shadow: var(--ln-shadow-modal, 0 20px 40px rgba(24, 24, 27, 0.14));
}
.pr-kpi-detail-modal .ant-modal-header {
margin-bottom: 0;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--ln-hairline, #e2e8f0);
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
.pr-kpi-detail-modal .ant-modal-body {
padding: 16px 20px 12px;
background: var(--ln-canvas-parchment, #f5f6f6);
}
.pr-kpi-detail-modal .ant-modal-footer {
margin-top: 0;
padding: 12px 20px 16px;
border-top: 1px solid var(--ln-hairline, #e2e8f0);
background: #fff;
}
.pr-kpi-detail-modal .ant-modal-close {
top: 14px;
inset-inline-end: 14px;
}
.pr-kpi-detail-modal__title {
display: flex;
align-items: flex-start;
gap: 12px;
padding-right: 28px;
}
.pr-kpi-detail-modal__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
}
.pr-kpi-detail-modal__icon--received {
color: #0f766e;
background: rgba(15, 118, 110, 0.1);
}
.pr-kpi-detail-modal__icon--linked {
color: #1d4ed8;
background: rgba(29, 78, 216, 0.1);
}
.pr-kpi-detail-modal__icon--unlinked {
color: #c2410c;
background: rgba(194, 65, 12, 0.1);
}
.pr-kpi-detail-modal__title-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.pr-kpi-detail-modal__title-main {
font-size: 1.0625rem;
font-weight: 600;
line-height: 1.35;
color: var(--ln-ink, #18181b);
}
.pr-kpi-detail-modal__title-sub {
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.45;
color: var(--ln-muted, #71717a);
}
.pr-kpi-detail-modal__body {
display: flex;
flex-direction: column;
gap: 14px;
}
.pr-kpi-detail-modal__stats {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr);
gap: 12px;
}
.pr-kpi-detail-modal__stat-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--ln-hairline, #e2e8f0);
background: #fff;
}
.pr-kpi-detail-modal__stat-card--primary {
border-color: rgba(50, 160, 110, 0.22);
background: linear-gradient(135deg, rgba(50, 160, 110, 0.06) 0%, #fff 72%);
}
.pr-kpi-detail-modal__stat-card--linked {
border-color: rgba(15, 118, 110, 0.22);
background: linear-gradient(135deg, rgba(15, 118, 110, 0.06) 0%, #fff 72%);
}
.pr-kpi-detail-modal__stat-card--unlinked {
border-color: rgba(194, 65, 12, 0.2);
background: linear-gradient(135deg, rgba(194, 65, 12, 0.05) 0%, #fff 72%);
}
.pr-kpi-detail-modal__stat-label {
font-size: 0.75rem;
color: var(--ln-muted, #71717a);
}
.pr-kpi-detail-modal__stat-val {
font-size: 1.375rem;
font-weight: 700;
line-height: 1.2;
color: var(--ln-ink, #18181b);
}
.pr-kpi-detail-modal__stat-val--linked {
color: #0f766e;
}
.pr-kpi-detail-modal__stat-val--warn {
color: #c2410c;
}
.pr-kpi-detail-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.pr-kpi-detail-modal__footer-hint {
font-size: 0.75rem;
color: var(--ln-muted, #94a3b8);
}
.pr-kpi-detail-filter {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--ln-hairline, #e2e8f0);
background: #fff;
}
.pr-kpi-detail-filter__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.pr-kpi-detail-filter__field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pr-kpi-detail-filter__field > span {
font-size: 0.75rem;
font-weight: 500;
color: var(--ln-muted, #71717a);
}
.pr-kpi-detail-filter__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--ln-hairline, #e2e8f0);
}
/* 认领 / 未关联总金额明细:筛选项与按钮单行排列 */
.pr-kpi-detail-filter--inline {
display: flex;
align-items: flex-end;
gap: 12px;
}
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__grid {
display: flex;
flex: 1;
min-width: 0;
gap: 10px 12px;
grid-template-columns: unset;
}
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__field {
flex: 1;
min-width: 0;
}
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__field:nth-child(2) {
flex: 1.25;
}
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__actions {
margin-top: 0;
padding-top: 0;
border-top: none;
flex-shrink: 0;
}
.pr-kpi-detail-panel__head {
gap: 12px;
}
.pr-kpi-detail-panel__head-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.pr-kpi-detail-panel__export {
display: inline-flex;
align-items: center;
gap: 6px;
}
.pr-kpi-detail-panel__export:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vm-btn-sm {
min-height: 32px;
padding: 0 12px;
font-size: 0.8125rem;
}
@media (max-width: 640px) {
.pr-kpi-detail-filter--inline {
flex-direction: column;
align-items: stretch;
}
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__grid {
flex-direction: column;
}
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__actions {
justify-content: flex-end;
padding-top: 10px;
border-top: 1px solid var(--ln-hairline, #e2e8f0);
}
.pr-kpi-detail-filter__grid {
grid-template-columns: 1fr;
}
.pr-kpi-detail-modal__stats {
grid-template-columns: 1fr;
}
.pr-kpi-detail-modal__footer {
flex-direction: column-reverse;
align-items: stretch;
}
.pr-kpi-detail-modal__footer-hint {
text-align: center;
}
}
.vm-page.lc-page.pr-page .pr-kpi-val--linked {
color: #0f766e;
}
.vm-page.lc-page.pr-page .pr-kpi-val--warn {
color: #c2410c;
}
.pr-col-remark {
max-width: 160px;
}
.pr-linked-empty {
color: var(--ln-muted, #94a3b8);
font-size: 0.8125rem;
}
.pr-linked-col {
min-width: 88px;
}
.pr-linked-count-btn {
padding: 0;
border: none;
background: none;
font-size: 0.8125rem;
font-weight: 600;
color: var(--ln-primary, #32a06e);
cursor: pointer;
}
.pr-linked-count-btn:hover {
color: var(--ln-primary-focus, #2d8f62);
text-decoration: underline;
}
.pr-linked-count-btn:focus-visible {
outline: 2px solid var(--ln-primary, #32a06e);
outline-offset: 2px;
border-radius: 4px;
}
/* 关联账单子表:对齐租赁合同展开行 — 100cqw 贴齐主表可视区,并为右侧冻结操作列留白 */
.vm-page.lc-page.pr-page {
--pr-action-col-width: 108px;
}
.vm-page.lc-page.pr-page .lc-list-table--nested .ant-table-expanded-row > td {
overflow: visible;
}
.vm-page.lc-page.pr-page .lc-list-table--nested .ant-table-expanded-row .pr-linked-bill-table-wrap {
box-sizing: border-box;
min-width: 0;
overflow: visible;
}
@supports (width: 100cqw) {
.vm-page.lc-page.pr-page .lc-list-table--nested .ant-table-expanded-row .pr-linked-bill-table-wrap {
width: calc(100cqw - var(--pr-action-col-width, 108px));
max-width: calc(100cqw - var(--pr-action-col-width, 108px));
}
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table {
width: 100%;
max-width: 100%;
min-width: 0;
table-layout: fixed;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table th,
.vm-page.lc-page.pr-page .pr-linked-bill-native-table td {
padding: 5px 4px;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table thead th {
padding-top: 6px;
padding-bottom: 6px;
font-size: 11px;
white-space: nowrap;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__type {
width: 9%;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info {
width: 36%;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__period {
width: 10%;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__money {
width: 15%;
text-align: right;
white-space: nowrap;
font-size: 11px;
}
.pr-bill-unreceived--negative {
color: #c2410c;
font-weight: 600;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .vm-stack {
min-width: 0;
gap: 1px;
line-height: 1.35;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .primary,
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .sub {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .primary {
font-size: 12px;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .code {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__period {
white-space: nowrap;
font-size: 11px;
}
.vm-page.lc-page.pr-page .pr-linked-bill-native-table .pr-bill-type-tag {
padding: 0 4px;
font-size: 11px;
white-space: nowrap;
line-height: 1.5;
}
.pr-bill-type-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
}
.pr-bill-type-tag--lease {
color: #0f766e;
background: rgba(15, 118, 110, 0.1);
}
.pr-bill-type-tag--self-operated {
color: #1d4ed8;
background: rgba(29, 78, 216, 0.1);
}
.pr-bill-type-tag--clearing {
color: #b45309;
background: rgba(180, 83, 9, 0.1);
}
.pr-unlinked--pending,
.pr-unclaimed--pending {
color: #c2410c;
font-weight: 600;
}
.pr-bill-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--ln-hairline, #e2e8f0);
font-size: 0.8125rem;
color: var(--ln-muted, #64748b);
}
.pr-bill-summary strong {
color: var(--ln-body, #334155);
font-weight: 600;
}
.pr-bill-hint {
margin: 0 0 12px;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--ln-muted, #64748b);
}
.pr-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.pr-form-field--full {
grid-column: 1 / -1;
}
.pr-required {
color: #dc2626;
font-style: normal;
}
/* 收款时间:单输入框 + 弹层双月历(与租赁合同 lc-filter-select 同高) */
.vm-page.lc-page.pr-page .pr-payment-range.ant-picker {
width: 100%;
}
.vm-page.lc-page.pr-page .pr-payment-range.ant-picker .ant-picker-input > input {
font-size: 0.875rem;
}
.vm-page.lc-page.pr-page .pr-payment-range.ant-picker .ant-picker-separator {
color: var(--ln-muted, #64748b);
}
@media (max-width: 640px) {
.pr-form-grid {
grid-template-columns: 1fr;
}
.vm-page.lc-page.pr-page .vm-kpi-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,75 @@
export type BillType = 'lease' | 'self-operated' | 'clearing';
export interface LinkedBill {
id: string;
type: BillType;
label: string;
allocatedAmount: number;
period?: string;
customerName?: string;
projectName?: string;
contractCode?: string;
}
export interface EnrichedLinkedBill extends LinkedBill {
receivable?: number;
}
export interface PaymentRecord {
id: string;
paymentTime: string;
customerName: string;
amount: number;
remark: string;
linkedBills: LinkedBill[];
operatorName: string;
operatedAt: string;
}
export interface BillCandidate {
id: string;
type: BillType;
customerName: string;
projectName: string;
contractCode: string;
label: string;
period: string;
receivable: number;
unreceived: number;
}
export interface PaymentRecordFilters {
customerName: string;
paymentFrom: string;
paymentTo: string;
operatorName: string;
unclaimed: '' | 'yes' | 'no';
}
export const EMPTY_PAYMENT_FILTERS: PaymentRecordFilters = {
customerName: '',
paymentFrom: '',
paymentTo: '',
operatorName: '',
unclaimed: '',
};
export interface PaymentKpiDetailFilters {
customerName: string;
paymentFrom: string;
paymentTo: string;
billType: '' | BillType;
}
export const EMPTY_PAYMENT_KPI_DETAIL_FILTERS: PaymentKpiDetailFilters = {
customerName: '',
paymentFrom: '',
paymentTo: '',
billType: '',
};
export const BILL_TYPE_LABELS: Record<BillType, string> = {
lease: '租赁账单',
'self-operated': '自营账单',
clearing: '氢费账单',
};

View File

@@ -0,0 +1,314 @@
import type { BillCandidate, BillType, EnrichedLinkedBill, LinkedBill, PaymentRecord, PaymentRecordFilters, PaymentKpiDetailFilters } from '../types';
export function formatMoney(value: number): string {
if (value == null || Number.isNaN(value)) return '—';
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export function calcBillUnreceivedAmount(receivable: number | null | undefined, allocatedAmount: number): number | null {
if (receivable == null || Number.isNaN(receivable)) return null;
const allocated = allocatedAmount || 0;
return Math.round((receivable - allocated) * 100) / 100;
}
export function calcLinkedTotal(record: PaymentRecord): number {
return (record.linkedBills || []).reduce((sum, bill) => sum + (bill.allocatedAmount || 0), 0);
}
export function calcUnclaimedAmount(record: PaymentRecord): number {
const linked = calcLinkedTotal(record);
return Math.max(0, Math.round((record.amount - linked) * 100) / 100);
}
export function calcRecordBillUnreceivedAmount(record: PaymentRecord, billPool: BillCandidate[]): number {
const enriched = enrichLinkedBills(record.linkedBills || [], billPool, record.customerName);
return enriched.reduce((sum, bill) => {
const unreceived = calcBillUnreceivedAmount(bill.receivable, bill.allocatedAmount);
return sum + (unreceived ?? 0);
}, 0);
}
export function applyPaymentFilters(
records: PaymentRecord[],
filters: PaymentRecordFilters,
billPool: BillCandidate[],
): PaymentRecord[] {
return records.filter((row) => {
if (filters.customerName.trim()) {
if (row.customerName !== filters.customerName.trim()) return false;
}
if (filters.operatorName.trim()) {
if (row.operatorName !== filters.operatorName.trim()) return false;
}
if (filters.paymentFrom && row.paymentTime.slice(0, 10) < filters.paymentFrom) return false;
if (filters.paymentTo && row.paymentTime.slice(0, 10) > filters.paymentTo) return false;
const unlinked = calcUnclaimedAmount(row);
if (filters.unclaimed === 'yes' && unlinked <= 0) return false;
if (filters.unclaimed === 'no' && unlinked > 0) return false;
return true;
});
}
export function buildOperatorOptions(records: PaymentRecord[]): string[] {
const set = new Set<string>();
records.forEach((row) => {
if (row.operatorName) set.add(row.operatorName);
});
return Array.from(set).sort();
}
export function buildCustomerOptions(records: PaymentRecord[]): Array<{ value: string; label: string }> {
const set = new Set<string>();
records.forEach((row) => {
if (row.customerName) set.add(row.customerName);
});
return Array.from(set).sort().map((name) => ({ value: name, label: name }));
}
export function sumPaymentAmount(records: PaymentRecord[]): number {
return records.reduce((sum, row) => sum + (row.amount || 0), 0);
}
export function sumLinkedAmount(records: PaymentRecord[]): number {
return records.reduce((sum, row) => sum + calcLinkedTotal(row), 0);
}
export function sumUnclaimedAmount(records: PaymentRecord[]): number {
return records.reduce((sum, row) => sum + calcUnclaimedAmount(row), 0);
}
export function sumBillUnreceivedAmount(records: PaymentRecord[], billPool: BillCandidate[]): number {
return records.reduce((sum, record) => sum + calcRecordBillUnreceivedAmount(record, billPool), 0);
}
export type PaymentKpiKey = 'received' | 'linked' | 'unlinked';
export interface PaymentReceivedDetailRow {
key: string;
paymentTime: string;
customerName: string;
amount: number;
remark: string;
}
export interface PaymentLinkedDetailRow {
key: string;
paymentTime: string;
customerName: string;
amount: number;
linkedAmount: number;
remark: string;
}
export interface PaymentUnlinkedDetailRow {
key: string;
paymentTime: string;
customerName: string;
amount: number;
linkedAmount: number;
unlinkedAmount: number;
remark: string;
}
export function buildReceivedDetailRows(records: PaymentRecord[]): PaymentReceivedDetailRow[] {
return records.map((record) => ({
key: record.id,
paymentTime: record.paymentTime,
customerName: record.customerName,
amount: record.amount,
remark: record.remark || '—',
}));
}
export function buildLinkedDetailRows(records: PaymentRecord[]): PaymentLinkedDetailRow[] {
return records
.map((record) => ({
key: record.id,
paymentTime: record.paymentTime,
customerName: record.customerName,
amount: record.amount,
linkedAmount: calcLinkedTotal(record),
remark: record.remark || '—',
}))
.filter((row) => row.linkedAmount > 0);
}
export function buildUnlinkedDetailRows(records: PaymentRecord[]): PaymentUnlinkedDetailRow[] {
return records
.map((record) => {
const linkedAmount = calcLinkedTotal(record);
return {
key: record.id,
paymentTime: record.paymentTime,
customerName: record.customerName,
amount: record.amount,
linkedAmount,
unlinkedAmount: calcUnclaimedAmount(record),
remark: record.remark || '—',
};
})
.filter((row) => row.unlinkedAmount > 0);
}
const BILL_TYPE_SHORT: Record<BillType, string> = {
lease: '租赁',
'self-operated': '自营',
clearing: '氢费',
};
export function enrichLinkedBills(
linkedBills: LinkedBill[],
billPool: BillCandidate[],
paymentCustomerName?: string,
): EnrichedLinkedBill[] {
return (linkedBills || []).map((bill) => {
const candidate = billPool.find((item) => item.id === bill.id && item.type === bill.type);
return {
...bill,
period: bill.period || candidate?.period,
receivable: candidate?.receivable,
customerName: bill.customerName || candidate?.customerName || paymentCustomerName || '—',
projectName: bill.projectName || candidate?.projectName || '—',
contractCode: bill.contractCode || candidate?.contractCode || '—',
};
});
}
export function flattenBillPool(pool: Record<string, BillCandidate[]>): BillCandidate[] {
return Object.values(pool).flat();
}
export function filterBillsByTypeAndCustomer(
bills: BillCandidate[],
type: BillType | 'all',
customerName: string,
): BillCandidate[] {
return bills.filter((bill) => {
if (type !== 'all' && bill.type !== type) return false;
if (customerName && bill.customerName !== customerName) return false;
return bill.unreceived > 0;
});
}
function matchKpiDetailTimeAndCustomer(
paymentTime: string,
customerName: string,
filters: PaymentKpiDetailFilters,
): boolean {
if (filters.customerName && customerName !== filters.customerName) return false;
if (filters.paymentFrom && paymentTime.slice(0, 10) < filters.paymentFrom) return false;
if (filters.paymentTo && paymentTime.slice(0, 10) > filters.paymentTo) return false;
return true;
}
export function filterReceivedDetailRows(
rows: PaymentReceivedDetailRow[],
filters: PaymentKpiDetailFilters,
): PaymentReceivedDetailRow[] {
return rows.filter((row) => matchKpiDetailTimeAndCustomer(row.paymentTime, row.customerName, filters));
}
export function filterLinkedDetailRows(
rows: PaymentLinkedDetailRow[],
filters: PaymentKpiDetailFilters,
): PaymentLinkedDetailRow[] {
return rows.filter((row) => matchKpiDetailTimeAndCustomer(row.paymentTime, row.customerName, filters));
}
export function filterUnlinkedDetailRows(
rows: PaymentUnlinkedDetailRow[],
filters: PaymentKpiDetailFilters,
): PaymentUnlinkedDetailRow[] {
return rows.filter((row) => matchKpiDetailTimeAndCustomer(row.paymentTime, row.customerName, filters));
}
export function sumReceivedDetailAmount(rows: PaymentReceivedDetailRow[]): number {
return rows.reduce((sum, row) => sum + (row.amount || 0), 0);
}
export function sumLinkedDetailAmount(rows: PaymentLinkedDetailRow[]): number {
return rows.reduce((sum, row) => sum + (row.linkedAmount || 0), 0);
}
export function sumUnlinkedDetailAmount(rows: PaymentUnlinkedDetailRow[]): number {
return rows.reduce((sum, row) => sum + (row.unlinkedAmount || 0), 0);
}
export function buildDetailCustomerOptions(names: string[]): Array<{ value: string; label: string }> {
const set = new Set<string>();
names.forEach((name) => {
if (name) set.add(name);
});
return Array.from(set).sort().map((name) => ({ value: name, label: name }));
}
function escapeCsvCell(value: string | number): string {
const str = String(value ?? '');
if (/[",\n\r]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
return str;
}
function downloadCsv(filename: string, headers: string[], rows: Array<Array<string | number>>) {
const bom = '\uFEFF';
const lines = [
headers.map(escapeCsvCell).join(','),
...rows.map((row) => row.map(escapeCsvCell).join(',')),
];
const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
const KPI_EXPORT_FILENAME: Record<PaymentKpiKey, string> = {
received: '到账总金额明细',
linked: '认领总金额明细',
unlinked: '未关联总金额明细',
};
export function exportKpiDetailCsv(
kpiKey: PaymentKpiKey,
receivedRows: PaymentReceivedDetailRow[],
linkedRows: PaymentLinkedDetailRow[],
unlinkedRows: PaymentUnlinkedDetailRow[],
): number {
const stamp = new Date().getTime();
if (kpiKey === 'received') {
downloadCsv(
`${KPI_EXPORT_FILENAME.received}_${stamp}.csv`,
['收款时间', '客户名称', '到账金额', '备注'],
receivedRows.map((row) => [row.paymentTime, row.customerName, row.amount, row.remark]),
);
return receivedRows.length;
}
if (kpiKey === 'linked') {
downloadCsv(
`${KPI_EXPORT_FILENAME.linked}_${stamp}.csv`,
['收款时间', '客户名称', '到账金额', '认领金额', '备注'],
linkedRows.map((row) => [
row.paymentTime,
row.customerName,
row.amount,
row.linkedAmount,
row.remark,
]),
);
return linkedRows.length;
}
downloadCsv(
`${KPI_EXPORT_FILENAME.unlinked}_${stamp}.csv`,
['收款时间', '客户名称', '到账金额', '已认领', '未关联金额', '备注'],
unlinkedRows.map((row) => [
row.paymentTime,
row.customerName,
row.amount,
row.linkedAmount,
row.unlinkedAmount,
row.remark,
]),
);
return unlinkedRows.length;
}

View File

@@ -1,6 +1,7 @@
# 自营业务台账 · 产品需求说明PRD # 自营业务台账 · 产品需求说明PRD
> 参照《2026年业务二部运营台账总表》物流业务台账子表按自营业务场景重新表达行内编辑与导入为原型 Toast 演示。 > 参照《2026年业务二部运营台账总表》物流业务台账子表按自营业务场景重新表达行内编辑与导入为原型 Toast 演示。
> **界面设计规范**与租赁业务台账、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md``vm-page ldb-page`)。
--- ---

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "self-operated-business-ledger", "prototypeName": "self-operated-business-ledger",
"pageId": "list", "pageId": "list",
"updatedAt": 1782871095271, "updatedAt": 1783328981489,
"nodes": [ "nodes": [
{ {
"id": "sob-filter", "id": "sob-filter",
@@ -104,66 +104,10 @@
"title": "ONE-OS 原型导航", "title": "ONE-OS 原型导航",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{
"type": "link",
"id": "nav-link-contract-template-management",
"title": "合同模板管理",
"href": "/prototypes/contract-template-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-insurance-procurement",
"title": "保险采购",
"href": "/prototypes/insurance-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-management",
"title": "车辆管理",
"href": "/prototypes/vehicle-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-contract-management",
"title": "租赁合同管理",
"href": "/prototypes/lease-contract-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-customer-management",
"title": "客户管理",
"href": "/prototypes/customer-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-business-ledger",
"title": "租赁业务台账",
"href": "/prototypes/lease-business-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-h2-fee-ledger",
"title": "车辆氢费明细",
"href": "/prototypes/vehicle-h2-fee-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-self-operated-business-ledger",
"title": "自营业务台账(当前)",
"href": "/prototypes/self-operated-business-ledger",
"target": "self"
},
{ {
"type": "folder", "type": "folder",
"id": "nav-folder-prototypes-oneos-web", "id": "nav-folder-1782874576229-r8atbu",
"title": "ONE-OS Web 端", "title": "OneOS",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{ {
@@ -172,7 +116,22 @@
"title": "工作台", "title": "工作台",
"href": "/prototypes/oneos-web-workbench", "href": "/prototypes/oneos-web-workbench",
"target": "self" "target": "self"
}, }
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{ {
"type": "link", "type": "link",
"id": "nav-link-oneos-web-login", "id": "nav-link-oneos-web-login",

View File

@@ -36,7 +36,7 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
control: ( control: (
<div className="ldb-date-range"> <div className="ldb-date-range">
<input type="date" className="vm-input" value={filters.dispatchFrom} aria-label="出车开始日期" onChange={(e) => onChange({ dispatchFrom: e.target.value })} /> <input type="date" className="vm-input" value={filters.dispatchFrom} aria-label="出车开始日期" onChange={(e) => onChange({ dispatchFrom: e.target.value })} />
<span aria-hidden></span> <span className="ldb-date-range-sep" aria-hidden></span>
<input type="date" className="vm-input" value={filters.dispatchTo} aria-label="出车结束日期" onChange={(e) => onChange({ dispatchTo: e.target.value })} /> <input type="date" className="vm-input" value={filters.dispatchTo} aria-label="出车结束日期" onChange={(e) => onChange({ dispatchTo: e.target.value })} />
</div> </div>
), ),
@@ -85,31 +85,45 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
}); });
} }
const visible = expanded ? fields : fields.slice(0, DEFAULT_VISIBLE); const primaryFields = fields.slice(0, DEFAULT_VISIBLE);
const extraFields = fields.slice(DEFAULT_VISIBLE);
return ( return (
<section className="vm-filter-card" data-annotation-id="sob-filter"> <section className="vm-filter-card ldb-filter-card" data-annotation-id="sob-filter" aria-label="筛选条件">
<header className="vm-filter-header"> <header className="vm-filter-header">
<h1 className="ldb-page-title"></h1> <h1 className="ldb-page-title"></h1>
<p className="ldb-page-desc"></p>
</header> </header>
<div className="vm-filter-grid"> <div className="vm-filter-grid">
{visible.map((field) => ( {primaryFields.map((field) => (
<label key={field.label} className="vm-filter-field"> <label key={field.label} className="vm-filter-field">
<span>{field.label}</span> <span>{field.label}</span>
{field.control} {field.control}
</label> </label>
))} ))}
</div> </div>
{extraFields.length > 0 && (
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}>
<div className="ldb-filter-expand-inner">
<div className="vm-filter-grid ldb-filter-expand-grid">
{extraFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))}
</div>
</div>
</div>
)}
<div className="vm-filter-actions"> <div className="vm-filter-actions">
{fields.length > DEFAULT_VISIBLE && ( {extraFields.length > 0 && (
<button type="button" className="vm-btn vm-btn-link" onClick={() => setExpanded(!expanded)}> <button type="button" className="vm-btn vm-btn-link ldb-filter-toggle" onClick={() => setExpanded(!expanded)} aria-expanded={expanded}>
{expanded ? '收起' : '更多筛选'} {expanded ? '收起' : '更多筛选'}
<ChevronDown size={14} aria-hidden style={{ transform: expanded ? 'rotate(180deg)' : undefined }} /> <ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
</button> </button>
)} )}
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}></button> <button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}> <button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
<Search size={16} aria-hidden /> <Search size={16} aria-hidden />
</button> </button>

View File

@@ -1,85 +1,236 @@
import React from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
import type { SelfOperatedLedgerRow } from '../types'; import type { SelfOperatedLedgerRow } from '../types';
import { formatMoney } from '../utils/ledger'; import { displayText, formatMoney } from '../utils/ledger';
import {
DEFAULT_SELF_COLUMN_WIDTHS,
SELF_TABLE_COLUMNS,
type SelfTableColumn,
} from './tableColumns';
interface SelfLedgerTableProps { interface SelfLedgerTableProps {
records: SelfOperatedLedgerRow[]; records: SelfOperatedLedgerRow[];
selectedIds: Set<string>; selectedIds: Set<string>;
loading?: boolean;
onToggleRow: (id: string, checked: boolean) => void; onToggleRow: (id: string, checked: boolean) => void;
onToggleAll: (checked: boolean) => void; onToggleAll: (checked: boolean) => void;
onEdit: (record: SelfOperatedLedgerRow) => void; onEdit: (record: SelfOperatedLedgerRow) => void;
onDelete: (record: SelfOperatedLedgerRow) => void; onDelete: (record: SelfOperatedLedgerRow) => void;
} }
type SortDir = 'asc' | 'desc';
const CHECK_COL_WIDTH = 48;
const ACTION_COL_WIDTH = 120;
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 renderCell(row: SelfOperatedLedgerRow, col: SelfTableColumn) {
if (col.key === 'profitLoss') {
return (
<span className={row.profitLoss >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss'}>
{formatMoney(row.profitLoss)}
</span>
);
}
const raw = row[col.key as keyof SelfOperatedLedgerRow];
if (col.money && typeof raw === 'number') {
if (col.key === 'quantity') return raw === 0 ? '' : String(raw);
return formatMoney(raw);
}
return displayText(raw);
}
function TableSkeleton() {
return (
<div className="ldb-table-skeleton" aria-busy="true" aria-label="表格加载中">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="ldb-skeleton-row" />
))}
</div>
);
}
export function SelfLedgerTable({ export function SelfLedgerTable({
records, records,
selectedIds, selectedIds,
loading = false,
onToggleRow, onToggleRow,
onToggleAll, onToggleAll,
onEdit, onEdit,
onDelete, onDelete,
}: SelfLedgerTableProps) { }: SelfLedgerTableProps) {
const allSelected = records.length > 0 && records.every((r) => selectedIds.has(r.id)); const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_SELF_COLUMN_WIDTHS }));
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
if (!records.length) { const sortedRecords = useMemo(() => {
if (!sortKey) return records;
const dir = sortDir === 'asc' ? 1 : -1;
return [...records].sort((a, b) => {
const av = a[sortKey as keyof SelfOperatedLedgerRow];
const bv = b[sortKey as keyof SelfOperatedLedgerRow];
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av ?? '').localeCompare(String(bv ?? ''), 'zh-CN') * dir;
});
}, [records, sortKey, sortDir]);
const allSelected = sortedRecords.length > 0 && sortedRecords.every((r) => selectedIds.has(r.id));
const handleSort = useCallback((key: string) => {
setSortKey((prev) => {
if (prev === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
return key;
}
setSortDir('asc');
return key;
});
}, []);
const handleResizeStart = useCallback((key: string, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
resizeRef.current = {
key,
startX: event.clientX,
startWidth: columnWidths[key] ?? DEFAULT_SELF_COLUMN_WIDTHS[key] ?? 100,
};
}, [columnWidths]);
useEffect(() => {
const onMove = (event: MouseEvent) => {
if (!resizeRef.current) return;
const delta = event.clientX - resizeRef.current.startX;
const next = Math.max(56, resizeRef.current.startWidth + delta);
setColumnWidths((prev) => ({ ...prev, [resizeRef.current!.key]: next }));
};
const onUp = () => {
resizeRef.current = null;
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
if (loading) {
return (
<div className="vm-table-wrap ldb-table-wrap" data-annotation-id="sob-table">
<TableSkeleton />
</div>
);
}
if (!sortedRecords.length) {
return <div className="vm-empty" data-annotation-id="sob-table"><p></p></div>; return <div className="vm-empty" data-annotation-id="sob-table"><p></p></div>;
} }
const tableMinWidth = CHECK_COL_WIDTH + ACTION_COL_WIDTH + SELF_TABLE_COLUMNS.reduce(
(sum, col) => sum + (columnWidths[col.key] ?? DEFAULT_SELF_COLUMN_WIDTHS[col.key] ?? 100),
0,
);
return ( return (
<div className="vm-table-wrap ldb-table-wrap" data-annotation-id="sob-table"> <div className="vm-table-wrap ldb-table-wrap" data-annotation-id="sob-table">
<table className="vm-table ldb-table sob-table"> <table className="vm-table ldb-table" style={{ minWidth: tableMinWidth }}>
<colgroup>
<col style={{ width: CHECK_COL_WIDTH }} />
{SELF_TABLE_COLUMNS.map((col) => (
<col key={col.key} style={{ width: columnWidths[col.key] ?? DEFAULT_SELF_COLUMN_WIDTHS[col.key] ?? 100 }} />
))}
<col style={{ width: ACTION_COL_WIDTH }} />
</colgroup>
<thead> <thead>
<tr> <tr>
<th className="ldb-col-check sticky-col-left"> <th className="ldb-col-check sticky-col-left">
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" /> <input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
</th> </th>
<th></th> {SELF_TABLE_COLUMNS.map((col) => (
<th></th> <th
<th></th> key={col.key}
<th></th> className={[
<th></th> col.money ? 'ldb-col-money' : '',
<th></th> col.className,
<th></th> 'ldb-col-resizable',
<th></th> ].filter(Boolean).join(' ')}
<th></th> >
<th className="ldb-col-money"></th> <div className="ldb-th-inner">
<th className="ldb-col-money"></th> <HeaderSort
<th className="ldb-col-money"></th> title={col.title}
<th className="ldb-col-money"></th> sortActive={sortKey === col.key}
<th className="ldb-col-money">ETC费用</th> sortDir={sortDir}
<th className="ldb-col-money"></th> onSort={() => handleSort(col.key)}
<th className="ldb-col-money"></th> />
<th></th> <span
className="ldb-col-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={`调整${col.title}列宽`}
onMouseDown={(e) => handleResizeStart(col.key, e)}
/>
</div>
</th>
))}
<th className="sticky-col-right ldb-col-actions"></th> <th className="sticky-col-right ldb-col-actions"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{records.map((row) => ( {sortedRecords.map((row) => (
<tr key={row.id}> <tr key={row.id}>
<td className="ldb-col-check sticky-col-left"> <td className="ldb-col-check sticky-col-left">
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={(e) => onToggleRow(row.id, e.target.checked)} aria-label={`选择 ${row.plateNo}`} /> <input
type="checkbox"
checked={selectedIds.has(row.id)}
onChange={(e) => onToggleRow(row.id, e.target.checked)}
aria-label={`选择 ${row.plateNo}`}
/>
</td> </td>
<td className="tabular-nums">{row.month}</td> {SELF_TABLE_COLUMNS.map((col) => (
<td className="tabular-nums">{row.dispatchDate}</td> <td
<td className="ldb-col-name" title={row.businessName}>{row.businessName}</td> key={col.key}
<td>{row.driver}</td> className={[
<td className="tabular-nums">{row.phone}</td> col.money ? 'ldb-col-money tabular-nums' : '',
<td>{row.vehicleUndertaker}</td> col.className,
<td className="tabular-nums">{row.plateNo}</td> ].filter(Boolean).join(' ')}
<td>{row.brand}</td> title={col.key === 'businessName' || col.key === 'model' ? String(row[col.key as keyof SelfOperatedLedgerRow] ?? '') : undefined}
<td title={row.model}>{row.model}</td> >
<td className="ldb-col-money tabular-nums">{formatMoney(row.unitPrice)}</td> {renderCell(row, col)}
<td className="ldb-col-money tabular-nums">{row.quantity}</td> </td>
<td className="ldb-col-money tabular-nums">{formatMoney(row.amount)}</td> ))}
<td className="ldb-col-money tabular-nums">{formatMoney(row.hydrogenFee)}</td>
<td className="ldb-col-money tabular-nums">{formatMoney(row.etcFee)}</td>
<td className="ldb-col-money tabular-nums">{formatMoney(row.totalCost)}</td>
<td className={`ldb-col-money tabular-nums ${row.profitLoss >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss'}`}>{formatMoney(row.profitLoss)}</td>
<td>{row.maintainerName}</td>
<td className="sticky-col-right ldb-col-actions"> <td className="sticky-col-right ldb-col-actions">
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(row)}></button> <div className="ldb-row-actions">
<span className="ldb-action-sep" aria-hidden>|</span> <button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}>
<button type="button" className="vm-btn vm-btn-link ldb-link-danger" onClick={() => onDelete(row)}></button>
</button>
<button type="button" className="ldb-action-btn ldb-action-btn--delete" onClick={() => onDelete(row)}>
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -0,0 +1,32 @@
export interface SelfTableColumn {
key: string;
title: string;
money?: boolean;
className?: string;
sortable?: boolean;
defaultWidth?: number;
}
export const SELF_TABLE_COLUMNS: SelfTableColumn[] = [
{ key: 'month', title: '月份', defaultWidth: 72, sortable: true },
{ key: 'dispatchDate', title: '出车日期', defaultWidth: 108, sortable: true },
{ key: 'businessName', title: '业务名称', className: 'ldb-col-name', defaultWidth: 140, sortable: true },
{ key: 'driver', title: '司机', defaultWidth: 88, sortable: true },
{ key: 'phone', title: '电话', defaultWidth: 112, sortable: true },
{ key: 'vehicleUndertaker', title: '车辆承接方', defaultWidth: 108, sortable: true },
{ key: 'plateNo', title: '车牌号码', defaultWidth: 108, sortable: true },
{ key: 'brand', title: '品牌', defaultWidth: 80, sortable: true },
{ key: 'model', title: '型号', className: 'ldb-col-name', defaultWidth: 160, sortable: true },
{ key: 'unitPrice', title: '单价', money: true, defaultWidth: 96, sortable: true },
{ key: 'quantity', title: '数量', money: true, defaultWidth: 72, sortable: true },
{ key: 'amount', title: '金额', money: true, defaultWidth: 104, sortable: true },
{ key: 'hydrogenFee', title: '氢费', money: true, defaultWidth: 96, sortable: true },
{ key: 'etcFee', title: 'ETC费用', money: true, defaultWidth: 96, sortable: true },
{ key: 'totalCost', title: '总成本', money: true, defaultWidth: 104, sortable: true },
{ key: 'profitLoss', title: '盈亏', money: true, defaultWidth: 104, sortable: true },
{ key: 'maintainerName', title: '维护人', defaultWidth: 88, sortable: true },
];
export const DEFAULT_SELF_COLUMN_WIDTHS: Record<string, number> = Object.fromEntries(
SELF_TABLE_COLUMNS.map((col) => [col.key, col.defaultWidth ?? 100]),
);

View File

@@ -1,9 +1,9 @@
/** /**
* @name 自营业务台账 * @name 自营业务台账
* 设计规范ledger-shared/DESIGN.md · vm-page + ldb-page与租赁业务台账、租赁合同列表对齐
*/ */
import '../vehicle-management/style.css'; import '../vehicle-management/style.css';
import '../lease-business-ledger/styles/index.css'; import '../lease-business-ledger/styles/index.css';
import './styles/index.css';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { Download, FileUp, Plus, Trash2 } from 'lucide-react'; import { Download, FileUp, Plus, Trash2 } from 'lucide-react';
import { import {
@@ -17,6 +17,7 @@ import { FilterPanel } from './components/FilterPanel';
import { LedgerKpiRow } from './components/LedgerKpiRow'; import { LedgerKpiRow } from './components/LedgerKpiRow';
import { SelfLedgerTable } from './components/SelfLedgerTable'; import { SelfLedgerTable } from './components/SelfLedgerTable';
import { TablePagination } from '../vehicle-management/components/TablePagination'; import { TablePagination } from '../vehicle-management/components/TablePagination';
import { DeleteConfirmModal } from '../lease-business-ledger/components/DeleteConfirmModal';
import { import {
EMPTY_SELF_FILTERS, EMPTY_SELF_FILTERS,
type SelfOperatedFilters, type SelfOperatedFilters,
@@ -28,13 +29,16 @@ import annotationSourceDocument from './annotation-source.json';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
export default function SelfOperatedBusinessLedgerApp() { export default function SelfOperatedBusinessLedgerApp() {
const [records] = useState<SelfOperatedLedgerRow[]>(seedRows as SelfOperatedLedgerRow[]); const [records, setRecords] = useState<SelfOperatedLedgerRow[]>(seedRows as SelfOperatedLedgerRow[]);
const [pendingFilters, setPendingFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS); const [pendingFilters, setPendingFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS); const [appliedFilters, setAppliedFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZE); const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
const [tableLoading, setTableLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SelfOperatedLedgerRow | null>(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const showToast = useCallback((msg: string) => { const showToast = useCallback((msg: string) => {
setToast(msg); setToast(msg);
@@ -50,9 +54,13 @@ export default function SelfOperatedBusinessLedgerApp() {
}, [filtered, page, pageSize]); }, [filtered, page, pageSize]);
const handleSearch = () => { const handleSearch = () => {
setAppliedFilters(pendingFilters); setTableLoading(true);
setPage(1); window.setTimeout(() => {
setSelectedIds(new Set()); setAppliedFilters(pendingFilters);
setPage(1);
setSelectedIds(new Set());
setTableLoading(false);
}, 320);
}; };
const handleReset = () => { const handleReset = () => {
@@ -62,6 +70,14 @@ export default function SelfOperatedBusinessLedgerApp() {
setSelectedIds(new Set()); setSelectedIds(new Set());
}; };
const handleBulkDeleteClick = () => {
if (selectedIds.size === 0) {
showToast('请先勾选需要删除的记录');
return;
}
setBulkDeleteOpen(true);
};
const annotationOptions = useMemo<AnnotationViewerOptions>( const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({ () => ({
showToolbar: true, showToolbar: true,
@@ -89,28 +105,24 @@ export default function SelfOperatedBusinessLedgerApp() {
<LedgerKpiRow summary={kpi} /> <LedgerKpiRow summary={kpi} />
<section className="vm-table-section"> <section className="vm-table-section">
<div className="vm-table-toolbar" data-annotation-id="sob-toolbar"> <div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="sob-toolbar">
<div className="vm-table-actions"> <div className="vm-table-actions">
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => showToast('打开批量导入弹窗(原型演示)')}> <button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => showToast('打开批量导入弹窗(原型演示)')}>
<FileUp size={16} aria-hidden /> <FileUp size={16} aria-hidden />
</button> </button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => showToast(`导出 ${filtered.length} 条(原型演示)`)}> <button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={() => showToast(`导出 ${filtered.length} 条(原型演示)`)}>
<Download size={16} aria-hidden /> <Download size={16} aria-hidden />
</button> </button>
<button <button
type="button" type="button"
className="vm-btn vm-btn-ghost" className="vm-btn vm-btn-ghost ldb-toolbar-btn"
onClick={() => showToast(`批量删除 ${selectedIds.size} 条(原型演示)`)} onClick={handleBulkDeleteClick}
> >
<Trash2 size={16} aria-hidden /> <Trash2 size={16} aria-hidden />
</button> </button>
<button type="button" className="vm-btn vm-btn-primary" onClick={() => showToast('表格底部新增行(原型演示)')}>
<Plus size={16} aria-hidden />
</button>
</div> </div>
</div> </div>
@@ -118,6 +130,7 @@ export default function SelfOperatedBusinessLedgerApp() {
<SelfLedgerTable <SelfLedgerTable
records={paged} records={paged}
selectedIds={selectedIds} selectedIds={selectedIds}
loading={tableLoading}
onToggleRow={(id, checked) => { onToggleRow={(id, checked) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -130,23 +143,64 @@ export default function SelfOperatedBusinessLedgerApp() {
setSelectedIds(checked ? new Set(paged.map((r) => r.id)) : new Set()); setSelectedIds(checked ? new Set(paged.map((r) => r.id)) : new Set());
}} }}
onEdit={(row) => showToast(`编辑:${row.plateNo} · ${row.dispatchDate}(原型演示)`)} onEdit={(row) => showToast(`编辑:${row.plateNo} · ${row.dispatchDate}(原型演示)`)}
onDelete={(row) => showToast(`删除:${row.plateNo}(原型演示)`)} onDelete={(row) => setDeleteTarget(row)}
/> />
<div className="vm-table-footer ldb-table-footer"> <div className="vm-table-footer ldb-table-footer">
<span className="ldb-table-total"> {filtered.length} </span> <span className="ldb-table-total"> {filtered.length} </span>
<TablePagination <div className="ldb-table-footer-actions">
page={Math.min(page, totalPages)} <TablePagination
pageSize={pageSize} page={Math.min(page, totalPages)}
total={filtered.length} pageSize={pageSize}
onPageChange={setPage} total={filtered.length}
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }} onPageChange={setPage}
/> onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
/>
<button
type="button"
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
data-annotation-id="sob-btn-add-row"
onClick={() => showToast('表格底部新增行(原型演示)')}
>
<Plus size={16} aria-hidden />
</button>
</div>
</div> </div>
</div> </div>
</section> </section>
{toast && <div className="vm-toast" role="status">{toast}</div>} {toast && <div className="vm-toast" role="status">{toast}</div>}
</div> </div>
<DeleteConfirmModal
open={Boolean(deleteTarget)}
plateNo={deleteTarget?.plateNo ?? ''}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => {
if (!deleteTarget) return;
setRecords((prev) => prev.filter((row) => row.id !== deleteTarget.id));
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(deleteTarget.id);
return next;
});
setDeleteTarget(null);
showToast(`已删除 ${deleteTarget.plateNo}(原型)`);
}}
/>
<DeleteConfirmModal
open={bulkDeleteOpen}
meta={`已选 ${selectedIds.size} 条记录`}
onCancel={() => setBulkDeleteOpen(false)}
onConfirm={() => {
const ids = new Set(selectedIds);
setRecords((prev) => prev.filter((row) => !ids.has(row.id)));
setSelectedIds(new Set());
setBulkDeleteOpen(false);
showToast(`已批量删除 ${ids.size} 条(原型)`);
}}
/>
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} /> <AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
</> </>
); );

View File

@@ -1,8 +0,0 @@
.sob-table {
min-width: 1960px;
}
/* 工具栏与上方 KPI、下方表格卡片间距一致各 16px */
.vm-table-toolbar[data-annotation-id='sob-toolbar'] {
padding: 0 0 16px;
}

View File

@@ -4,6 +4,26 @@ export function formatMoney(value: number): string {
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
export function isBlankDisplayValue(value: unknown): boolean {
return value === null
|| value === undefined
|| value === ''
|| value === '—'
|| value === '-'
|| value === '/';
}
export function displayText(value: unknown): string {
if (isBlankDisplayValue(value)) return '';
return String(value);
}
export function displayMoney(value: number | null | undefined, treatZeroAsEmpty = false): string {
if (value === null || value === undefined) return '';
if (treatZeroAsEmpty && value === 0) return '';
return formatMoney(value);
}
export function sumSelfKpi(rows: SelfOperatedLedgerRow[]): SelfOperatedKpiSummary { export function sumSelfKpi(rows: SelfOperatedLedgerRow[]): SelfOperatedKpiSummary {
return rows.reduce( return rows.reduce(
(acc, row) => ({ (acc, row) => ({

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "supplier-management", "prototypeName": "supplier-management",
"pageId": "list", "pageId": "list",
"updatedAt": 1782697342692, "updatedAt": 1783328981489,
"nodes": [ "nodes": [
{ {
"id": "sm-list-filter", "id": "sm-list-filter",
@@ -13,7 +13,9 @@
"title": "筛选条件", "title": "筛选条件",
"pageId": "list", "pageId": "list",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-list-filter\"]"], "selectors": [
"[data-annotation-id=\"sm-list-filter\"]"
],
"fingerprint": "sm-list-filter", "fingerprint": "sm-list-filter",
"path": [] "path": []
}, },
@@ -31,7 +33,9 @@
"title": "统计卡片", "title": "统计卡片",
"pageId": "list", "pageId": "list",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-kpi-all\"]"], "selectors": [
"[data-annotation-id=\"sm-kpi-all\"]"
],
"fingerprint": "sm-kpi-all", "fingerprint": "sm-kpi-all",
"path": [] "path": []
}, },
@@ -49,7 +53,9 @@
"title": "列表工具栏", "title": "列表工具栏",
"pageId": "list", "pageId": "list",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-list-toolbar\"]"], "selectors": [
"[data-annotation-id=\"sm-list-toolbar\"]"
],
"fingerprint": "sm-list-toolbar", "fingerprint": "sm-list-toolbar",
"path": [] "path": []
}, },
@@ -67,7 +73,9 @@
"title": "供应商台账列表", "title": "供应商台账列表",
"pageId": "list", "pageId": "list",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-list-table\"]"], "selectors": [
"[data-annotation-id=\"sm-list-table\"]"
],
"fingerprint": "sm-list-table", "fingerprint": "sm-list-table",
"path": [] "path": []
}, },
@@ -85,7 +93,9 @@
"title": "主体信息", "title": "主体信息",
"pageId": "create", "pageId": "create",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-create-entity\"]"], "selectors": [
"[data-annotation-id=\"sm-create-entity\"]"
],
"fingerprint": "sm-create-entity", "fingerprint": "sm-create-entity",
"path": [] "path": []
}, },
@@ -103,7 +113,9 @@
"title": "开票信息", "title": "开票信息",
"pageId": "create", "pageId": "create",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-create-invoice\"]"], "selectors": [
"[data-annotation-id=\"sm-create-invoice\"]"
],
"fingerprint": "sm-create-invoice", "fingerprint": "sm-create-invoice",
"path": [] "path": []
}, },
@@ -121,7 +133,9 @@
"title": "银行账户", "title": "银行账户",
"pageId": "create", "pageId": "create",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-create-bank\"]"], "selectors": [
"[data-annotation-id=\"sm-create-bank\"]"
],
"fingerprint": "sm-create-bank", "fingerprint": "sm-create-bank",
"path": [] "path": []
}, },
@@ -139,7 +153,9 @@
"title": "资质证照", "title": "资质证照",
"pageId": "create", "pageId": "create",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-create-docs\"]"], "selectors": [
"[data-annotation-id=\"sm-create-docs\"]"
],
"fingerprint": "sm-create-docs", "fingerprint": "sm-create-docs",
"path": [] "path": []
}, },
@@ -157,7 +173,9 @@
"title": "表单提交", "title": "表单提交",
"pageId": "create", "pageId": "create",
"locator": { "locator": {
"selectors": ["[data-annotation-id=\"sm-create-footer\"]"], "selectors": [
"[data-annotation-id=\"sm-create-footer\"]"
],
"fingerprint": "sm-create-footer", "fingerprint": "sm-create-footer",
"path": [] "path": []
}, },
@@ -180,17 +198,154 @@
"id": "list", "id": "list",
"title": "列表页", "title": "列表页",
"pageId": "list", "pageId": "list",
"payload": { "pageId": "list" } "payload": {
"pageId": "list"
}
}, },
{ {
"id": "create", "id": "create",
"title": "新增页", "title": "新增页",
"pageId": "create", "pageId": "create",
"payload": { "pageId": "create" } "payload": {
"pageId": "create"
}
} }
] ]
} }
] ]
} }
},
"directory": {
"nodes": [
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "folder",
"id": "nav-folder-1782874576229-r8atbu",
"title": "OneOS",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-workbench",
"title": "工作台",
"href": "/prototypes/oneos-web-workbench",
"target": "self"
}
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-login",
"title": "登录",
"href": "/prototypes/oneos-web-login",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-vehicle-asset",
"title": "车辆管理",
"href": "/prototypes/oneos-web-vehicle-asset",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-contract-template",
"title": "合同模板管理",
"href": "/prototypes/oneos-web-contract-template",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-help-center",
"title": "帮助中心",
"href": "/prototypes/oneos-web-help-center",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-finance",
"title": "财务管理",
"href": "/prototypes/oneos-web-finance",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-procurement",
"title": "采购管理",
"href": "/prototypes/oneos-web-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-lease-contract",
"title": "车辆租赁合同",
"href": "/prototypes/oneos-web-lease-contract",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-h2-station",
"title": "加氢站管理",
"href": "/prototypes/oneos-web-h2-station",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-data-analysis",
"title": "数据分析",
"href": "/prototypes/oneos-web-data-analysis",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ledger-data",
"title": "台账数据",
"href": "/prototypes/oneos-web-ledger-data",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-business",
"title": "业务管理",
"href": "/prototypes/oneos-web-business",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ops",
"title": "运维管理",
"href": "/prototypes/oneos-web-ops",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-requirements",
"title": "需求说明",
"href": "/prototypes/oneos-web-requirements",
"target": "self"
}
]
}
]
}
]
} }
} }

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "vehicle-h2-fee-ledger", "prototypeName": "vehicle-h2-fee-ledger",
"pageId": "list", "pageId": "list",
"updatedAt": 1782871095271, "updatedAt": 1783328981490,
"nodes": [ "nodes": [
{ {
"id": "vh2-header", "id": "vh2-header",
@@ -258,66 +258,10 @@
"title": "ONE-OS 原型导航", "title": "ONE-OS 原型导航",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{
"type": "link",
"id": "nav-link-contract-template-management",
"title": "合同模板管理",
"href": "/prototypes/contract-template-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-insurance-procurement",
"title": "保险采购",
"href": "/prototypes/insurance-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-management",
"title": "车辆管理",
"href": "/prototypes/vehicle-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-contract-management",
"title": "租赁合同管理",
"href": "/prototypes/lease-contract-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-customer-management",
"title": "客户管理",
"href": "/prototypes/customer-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-business-ledger",
"title": "租赁业务台账",
"href": "/prototypes/lease-business-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-h2-fee-ledger",
"title": "车辆氢费明细(当前)",
"href": "/prototypes/vehicle-h2-fee-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-self-operated-business-ledger",
"title": "自营业务台账",
"href": "/prototypes/self-operated-business-ledger",
"target": "self"
},
{ {
"type": "folder", "type": "folder",
"id": "nav-folder-prototypes-oneos-web", "id": "nav-folder-1782874576229-r8atbu",
"title": "ONE-OS Web 端", "title": "OneOS",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{ {
@@ -326,7 +270,22 @@
"title": "工作台", "title": "工作台",
"href": "/prototypes/oneos-web-workbench", "href": "/prototypes/oneos-web-workbench",
"target": "self" "target": "self"
}, }
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{ {
"type": "link", "type": "link",
"id": "nav-link-oneos-web-login", "id": "nav-link-oneos-web-login",

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "vehicle-management", "prototypeName": "vehicle-management",
"pageId": "list", "pageId": "list",
"updatedAt": 1782871095272, "updatedAt": 1783328981491,
"nodes": [ "nodes": [
{ {
"id": "vm-doc-toolbar", "id": "vm-doc-toolbar",
@@ -674,66 +674,10 @@
"title": "ONE-OS 原型导航", "title": "ONE-OS 原型导航",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{
"type": "link",
"id": "nav-link-contract-template-management",
"title": "合同模板管理",
"href": "/prototypes/contract-template-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-insurance-procurement",
"title": "保险采购",
"href": "/prototypes/insurance-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-management",
"title": "车辆管理(当前)",
"href": "/prototypes/vehicle-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-contract-management",
"title": "租赁合同管理",
"href": "/prototypes/lease-contract-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-customer-management",
"title": "客户管理",
"href": "/prototypes/customer-management",
"target": "self"
},
{
"type": "link",
"id": "nav-link-lease-business-ledger",
"title": "租赁业务台账",
"href": "/prototypes/lease-business-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-vehicle-h2-fee-ledger",
"title": "车辆氢费明细",
"href": "/prototypes/vehicle-h2-fee-ledger",
"target": "self"
},
{
"type": "link",
"id": "nav-link-self-operated-business-ledger",
"title": "自营业务台账",
"href": "/prototypes/self-operated-business-ledger",
"target": "self"
},
{ {
"type": "folder", "type": "folder",
"id": "nav-folder-prototypes-oneos-web", "id": "nav-folder-1782874576229-r8atbu",
"title": "ONE-OS Web 端", "title": "OneOS",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{ {
@@ -742,7 +686,22 @@
"title": "工作台", "title": "工作台",
"href": "/prototypes/oneos-web-workbench", "href": "/prototypes/oneos-web-workbench",
"target": "self" "target": "self"
}, }
]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{ {
"type": "link", "type": "link",
"id": "nav-link-oneos-web-login", "id": "nav-link-oneos-web-login",

View File

@@ -1,101 +1,6 @@
import React, { useMemo } from 'react'; export {
import { ChevronLeft, ChevronRight } from 'lucide-react'; TablePagination,
DEFAULT_PAGE_SIZE,
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; PAGE_SIZE_OPTIONS,
type TablePaginationProps,
interface TablePaginationProps { } from '../../../common/TablePagination';
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
function buildPageItems(current: number, totalPages: number): Array<number | 'ellipsis'> {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const items: Array<number | 'ellipsis'> = [1];
if (current > 3) items.push('ellipsis');
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let page = start; page <= end; page += 1) {
items.push(page);
}
if (current < totalPages - 2) items.push('ellipsis');
if (totalPages > 1) items.push(totalPages);
return items;
}
export function TablePagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]);
if (total === 0) return null;
return (
<div className="vm-pagination" role="navigation" aria-label="表格分页">
<label className="vm-pagination-size">
<span className="vm-pagination-size-label"></span>
<select
className="vm-pagination-select"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
aria-label="每页显示条数"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="vm-pagination-size-label"></span>
</label>
<div className="vm-pagination-pages">
<button
type="button"
className="vm-pagination-nav"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeft size={16} aria-hidden />
</button>
{pageItems.map((item, index) => (
item === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden></span>
) : (
<button
key={item}
type="button"
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
onClick={() => onPageChange(item)}
aria-current={page === item ? 'page' : undefined}
>
{item}
</button>
)
))}
<button
type="button"
className="vm-pagination-nav"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRight size={16} aria-hidden />
</button>
</div>
</div>
);
}

View File

@@ -1,5 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@import "../../common/modal-detail-table.css";
@import "../../common/vm-pagination.css";
/* Visual system aligned with src/themes/linear/DESIGN.md — light (inverse) mode */ /* Visual system aligned with src/themes/linear/DESIGN.md — light (inverse) mode */
:root { :root {
@@ -754,58 +756,6 @@
} }
.vm-empty { padding: 48px 24px; text-align: center; color: var(--ln-muted); } .vm-empty { padding: 48px 24px; text-align: center; color: var(--ln-muted); }
.vm-pagination {
display: flex; justify-content: flex-end; align-items: center;
flex-wrap: wrap; gap: 12px 16px;
width: 100%; padding: 0;
}
.vm-pagination-size {
display: inline-flex; align-items: center; gap: 8px;
font-size: 0.875rem; color: var(--ln-muted);
flex-shrink: 0;
}
.vm-pagination-size-label { white-space: nowrap; }
.vm-pagination-select {
min-width: 72px; height: var(--vm-control-height);
padding: 0 28px 0 10px;
border: 1px solid var(--ln-hairline-strong); border-radius: var(--ln-radius-control);
background: var(--ln-surface-card); color: var(--ln-ink);
font-size: 0.875rem; cursor: pointer;
}
.vm-pagination-select:focus {
outline: none;
border-color: var(--vm-focus-border-color);
}
.vm-pagination-pages {
display: inline-flex; align-items: center; gap: 4px;
flex-shrink: 0;
}
.vm-pagination-nav,
.vm-pagination-page {
display: inline-flex; align-items: center; justify-content: center;
min-width: var(--vm-control-height); height: var(--vm-control-height);
padding: 0 8px;
border: 1px solid var(--ln-hairline-strong); border-radius: var(--ln-radius-control);
background: var(--ln-surface-card); color: var(--ln-ink);
font-size: 0.875rem; font-weight: 500; cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.vm-pagination-nav:disabled { opacity: 0.45; cursor: not-allowed; }
.vm-pagination-nav:hover:not(:disabled),
.vm-pagination-page:hover:not(.active) { background: var(--ln-canvas-soft); border-color: var(--ln-hairline-strong); }
.vm-pagination-page.active {
background: var(--ln-primary); border-color: var(--ln-primary); color: var(--ln-on-primary);
}
.vm-pagination-nav:focus-visible,
.vm-pagination-page:focus-visible {
outline: none;
border-color: var(--vm-focus-border-color);
}
.vm-pagination-ellipsis {
display: inline-flex; align-items: center; justify-content: center;
min-width: 28px; height: var(--vm-control-height);
color: var(--ln-muted); font-size: 0.875rem;
}
.vm-modal-backdrop { .vm-modal-backdrop {
position: fixed; inset: 0; background: rgba(24, 24, 27, 0.4); position: fixed; inset: 0; background: rgba(24, 24, 27, 0.4);
@@ -2181,35 +2131,6 @@
font-size: 0.8125rem; font-size: 0.8125rem;
line-height: 28px; line-height: 28px;
} }
.vm-table-footer--compact .vm-pagination {
width: auto;
flex: 0 1 auto;
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
}
.vm-table-footer--compact .vm-pagination-size {
gap: 4px;
font-size: 0.8125rem;
}
.vm-table-footer--compact .vm-pagination-select {
min-width: 52px;
height: 28px;
padding: 0 22px 0 8px;
font-size: 0.8125rem;
}
.vm-table-footer--compact .vm-pagination-nav,
.vm-table-footer--compact .vm-pagination-page {
min-width: 28px;
height: 28px;
padding: 0 6px;
font-size: 0.8125rem;
}
.vm-table-footer--compact .vm-pagination-ellipsis {
min-width: 20px;
height: 28px;
font-size: 0.8125rem;
}
.vm-table-total { .vm-table-total {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--ln-muted); color: var(--ln-muted);

View File

@@ -0,0 +1,128 @@
# vm-page 列表页 · 全局设计规范
**vehicle-management**Linear 主题 token + `vm-*` 类名)对齐的中后台列表页壳层规范。
**所有新建 `vm-page` 列表页必须遵循本规范**,台账类页面另见 `ledger-shared/DESIGN.md`
## 适用范围
| 类型 | 页面类名示例 | 样式入口 |
|------|--------------|----------|
| 通用列表 | `vm-page` | `vehicle-management/style.css` |
| 台账列表 | `vm-page ldb-page` | 同上 + `ledger-shared/styles/ledger-page.css` |
| 合同列表 | `vm-page lc-page` | 同上 + `lease-contract-management/styles/lease-contract.css` |
## 样式与组件依赖
新建列表页 **必须** 引入:
```typescript
import '../vehicle-management/style.css';
```
`vehicle-management/style.css` 已内联全局分页样式(`src/common/vm-pagination.css`)。
分页组件 **必须** 使用公共实现:
```typescript
import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
```
禁止复制粘贴分页 DOM、手写 Ant Design Pagination、或自建另一套分页类名。
## 表格底部分页(强制)
凡带数据表格的列表页,**一律使用底部分页**,禁止「下拉加载更多」「无限滚动」替代分页(除非产品需求明确排除分页且已在 PRD 写明)。
### 布局结构
```text
vm-table-card
├── vm-table-wrap / ldb-table-wrap # 表格主体
└── vm-table-footer # 表格底栏(上边框分隔)
├── 左侧:总条数文案(可选)
└── 右侧TablePagination
```
标准 DOM 示例:
```tsx
<div className="vm-table-footer">
<span className="vm-table-total"> {total} </span>
<TablePagination
page={Math.min(page, totalPages)}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
```
台账页可在 footer 上加 `ldb-table-footer`,左侧用 `ldb-table-total`,分页仍用同一 `TablePagination` 组件。
### 视觉规格
| 元素 | 类名 | 说明 |
|------|------|------|
| 分页容器 | `vm-pagination` | 右对齐可换行gap 12px / 16px |
| 每页条数 | `vm-pagination-size` + `vm-pagination-select` | 文案「每页 N 条」 |
| 页码区 | `vm-pagination-pages` | 上一页 / 页码 / 下一页 |
| 当前页 | `vm-pagination-page active` | 主色填充 |
| 省略 | `vm-pagination-ellipsis` | 超过 7 页时显示 … |
| 总条数 | `vm-table-total` | 0.875remmuted 色 |
样式源文件:`src/common/vm-pagination.css`(由 `vehicle-management/style.css` 引入)。
### 交互与状态
| 项 | 规则 |
|----|------|
| 默认每页条数 | `DEFAULT_PAGE_SIZE`20 |
| 可选每页条数 | 10 / 20 / 50 / 100`PAGE_SIZE_OPTIONS` |
| 筛选 / 重置 / KPI 切换 | 将 `page` 重置为 1 |
| 切换每页条数 | 将 `page` 重置为 1 |
| 无数据 | `TablePagination` 返回 `null`,不渲染分页 |
| 无障碍 | `role="navigation"``aria-label="表格分页"`、当前页 `aria-current="page"` |
### 数据切片
```typescript
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paged = useMemo(() => {
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, page, pageSize]);
```
表格 `records` 传入 `paged`,分页 `total` 传入筛选后总数 `filtered.length`
### 紧凑变体(详情 Tab 内嵌表格)
详情页 Tab 等小空间场景footer 追加:
```html
<div class="vm-table-footer vm-table-footer--split vm-table-footer--compact">
```
仍使用同一 `TablePagination` 组件;紧凑样式见 `vm-pagination.css``.vm-table-footer--compact` 规则。
参考:`vehicle-management/components/Detail*RecordsTab.tsx`
## 禁止做法
- 不使用 `vm-pagination*` 类名自建分页 UI
- 不在列表主表格使用「下拉显示更多」代替分页
- 不引入与 Linear token 冲突的第三方分页皮肤
- 不在 footer 混用多种分页交互(分页 + 加载更多并存)
## 参考实现
| 页面 | 路径 |
|------|------|
| 车辆管理 | `src/prototypes/vehicle-management/index.tsx` |
| 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` |
| 客户管理 | `src/prototypes/customer-management/index.tsx` |
| 收款记录 | `src/prototypes/payment-records/index.tsx` |