Compare commits

...

8 Commits

Author SHA1 Message Date
王冕
9b6244ea11 merge: 合并远程客户管理与供应商管理迭代,保留风险标签复核逻辑与统一操作按钮规范。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 09:31:57 +08:00
王冕
962e83597f 迭代 ONE-OS 多原型:提车应收款收款/合同页增强,租赁明细标注同步,导航注册表更新;新增 Git 协作技能并忽略 .local 临时目录。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 09:31:28 +08:00
王冕
9a4a4a4f6a 修复租赁合同合包页面语法错误并补全对象存储发布依赖。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 22:52:37 +08:00
王冕
131b963e4a 迭代 ONE-OS 多原型:统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步导航与合包页面。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 22:44:21 +08:00
王冕
824512a224 迭代 ONE-OS 原型导航:VoltAgent 视觉改版、密码门禁与侧栏按业务线切换;车辆氢费明细标注目录同步至 v1.5。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:34:31 +08:00
王冕
6ccbbef86a 迭代 ONE-OS 多原型:原型导航与版本追踪上线;车辆氢费明细成本单价校验增强;多业务台账/财务/工作台页面迭代与标注目录同步。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 17:52:53 +08:00
王冕
6d14c58744 迭代 ONE-OS 多原型:租赁台账增强、合同模板 Word 导入,统一全局分页与筛选规范。
- 租赁业务台账:运维费汇总、明细钻取弹层、其他收入/减免批量导入
- 合同模板:Word 导入与 docx 预览,模板编辑增强
- 全局规范:TablePagination 统一为「共 x 条 → 页码 → 每页 N 条」,vm-checkbox 与筛选面板
- 新增车辆维修明细、还车应结款原型;车辆管理、收款记录、工作台、加氢站等同步对齐
- 更新侧栏、Legacy Shell、标注源与 to-prd 技能

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 11:44:53 +08:00
王冕
d0d7458989 迭代 ONE-OS 多原型:租赁/自营台账增强、租赁合同与条线说明,统一台账壳层与侧栏导航
- 新增业务部台账、收款记录、业务条线说明及 ledger-shared / vm-shared 公共壳层
- 租赁业务台账:字段计算、宽表交互、标注 PRD 与表头说明修复
- 租赁合同:列表/表单增强、客户资质与审批规则
- 侧栏调整:工作台移至 OneOS 根目录,台账与数据分析分组更新
- 补充 S3 批量发布脚本与弹窗明细表规范

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:18:23 +08:00
429 changed files with 113327 additions and 14313 deletions

View File

@@ -0,0 +1,75 @@
---
name: to-prd
description: Turn the current conversation into a PRD and publish it to the project issue tracker — no interview, just synthesis of what you've already discussed.
disable-model-invocation: true
---
This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know.
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
## Process
1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching.
2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can. The fewer seams across the codebase, the better - the ideal number is one.
Check with the user that these seams match their expectations.
3. Write the PRD using the template below, then publish it to the project issue tracker. Apply the `ready-for-agent` triage label - no need for additional triage.
<prd-template>
## Problem Statement
The problem that the user is facing, from the user's perspective.
## Solution
The solution to the problem, from the user's perspective.
## User Stories
A LONG, numbered list of user stories. Each user story should be in the format of:
1. As an <actor>, I want a <feature>, so that <benefit>
<user-story-example>
1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending
</user-story-example>
This list of user stories should be extremely extensive and cover all aspects of the feature.
## Implementation Decisions
A list of implementation decisions that were made. This can include:
- The modules that will be built/modified
- The interfaces of those modules that will be modified
- Technical clarifications from the developer
- Architectural decisions
- Schema changes
- API contracts
- Specific interactions
Do NOT include specific file paths or code snippets. They may end up being outdated very quickly.
Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
## Testing Decisions
A list of testing decisions that were made. Include:
- A description of what makes a good test (only test external behavior, not implementation details)
- Which modules will be tested
- Prior art for the tests (i.e. similar types of tests in the codebase)
## Out of Scope
A description of the things that are out of scope for this PRD.
## Further Notes
Any further notes about the feature.
</prd-template>

View File

@@ -28,18 +28,22 @@
"productionBranch": "main"
},
"s3": {
"accessKeyId": "",
"secretAccessKey": "",
"accessKeyId": "LTAI5t9EV2UByZDBcNSgmm2q",
"secretAccessKey": "pozhgZ58J0XJ2pqKyM3pY1tZLClata",
"region": "cn-hangzhou",
"bucket": "model-lnoneos",
"prefix": "",
"baseUrl": "http://prototype.lnoneos.com/",
"endpoint": ""
"baseUrl": "https://prototype.lnoneos.com",
"endpoint": "https://model-lnoneos.oss-cn-hangzhou.aliyuncs.com",
"pathAliases": {
"oneos-prototype-nav": "oneos-prototype-nav"
}
},
"githubPages": {
"repository": "",
"branch": "gh-pages",
"sourceDirectory": "/"
"sourceDirectory": "/",
"pathPrefix": ""
},
"publishSettings": {
"includeSource": true,

View File

@@ -1,12 +1,91 @@
{
"version": 1,
"updatedAt": "2026-07-01T09:58:50.712Z",
"updatedAt": "2026-07-12T17:06:54.186Z",
"prototypes": [
{
"id": "folder-1783875936186-27raza",
"kind": "folder",
"title": "旧ONEOS",
"children": [
{
"id": "item:prototypes:oneos-web-business",
"kind": "item",
"title": "业务管理",
"itemKey": "prototypes/oneos-web-business"
},
{
"id": "item:prototypes:oneos-web-contract-template",
"kind": "item",
"title": "合同模板管理",
"itemKey": "prototypes/oneos-web-contract-template"
},
{
"id": "item:prototypes:oneos-web-data-analysis",
"kind": "item",
"title": "数据分析",
"itemKey": "prototypes/oneos-web-data-analysis"
},
{
"id": "item:prototypes:oneos-web-finance",
"kind": "item",
"title": "财务管理(合包)",
"itemKey": "prototypes/oneos-web-finance"
},
{
"id": "item:prototypes:oneos-web-h2-station",
"kind": "item",
"title": "加氢站管理",
"itemKey": "prototypes/oneos-web-h2-station"
},
{
"id": "item:prototypes:oneos-web-help-center",
"kind": "item",
"title": "帮助中心",
"itemKey": "prototypes/oneos-web-help-center"
},
{
"id": "item:prototypes:oneos-web-lease-contract",
"kind": "item",
"title": "车辆租赁合同",
"itemKey": "prototypes/oneos-web-lease-contract"
},
{
"id": "item:prototypes:oneos-web-ledger-data",
"kind": "item",
"title": "台账数据",
"itemKey": "prototypes/oneos-web-ledger-data"
},
{
"id": "item:prototypes:oneos-web-ops",
"kind": "item",
"title": "运维管理",
"itemKey": "prototypes/oneos-web-ops"
},
{
"id": "item:prototypes:oneos-web-procurement",
"kind": "item",
"title": "采购管理",
"itemKey": "prototypes/oneos-web-procurement"
}
]
},
{
"id": "item:prototypes:oneos-prototype-nav",
"kind": "item",
"title": "原型导航",
"itemKey": "prototypes/oneos-prototype-nav"
},
{
"id": "folder-1782874576229-r8atbu",
"kind": "folder",
"title": "OneOS",
"children": [
{
"id": "item:prototypes:oneos-web-workbench",
"kind": "item",
"title": "工作台",
"itemKey": "prototypes/oneos-web-workbench"
},
{
"id": "folder-1782874599304-roj8fd",
"kind": "folder",
@@ -76,115 +155,106 @@
"itemKey": "prototypes/oneos-web-h2-station-site"
}
]
}
]
},
{
"id": "item:prototypes:lease-business-ledger",
"kind": "item",
"title": "租赁业务台账",
"itemKey": "prototypes/lease-business-ledger"
},
{
"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",
"kind": "folder",
"title": "ONE-OS Web 端",
"children": [
{
"id": "item:prototypes:oneos-web-workbench",
"kind": "item",
"title": "工作台",
"itemKey": "prototypes/oneos-web-workbench"
},
{
"id": "item:prototypes:oneos-web-login",
"kind": "item",
"title": "登录",
"itemKey": "prototypes/oneos-web-login"
},
{
"id": "item:prototypes:oneos-web-vehicle-asset",
"kind": "item",
"title": "车辆管理",
"itemKey": "prototypes/oneos-web-vehicle-asset"
},
{
"id": "item:prototypes:oneos-web-contract-template",
"kind": "item",
"title": "合同模板管理",
"itemKey": "prototypes/oneos-web-contract-template"
},
{
"id": "item:prototypes:oneos-web-help-center",
"kind": "item",
"title": "帮助中心",
"itemKey": "prototypes/oneos-web-help-center"
},
{
"id": "item:prototypes:oneos-web-finance",
"kind": "item",
"title": "财务管理",
"itemKey": "prototypes/oneos-web-finance"
},
{
"id": "item:prototypes:oneos-web-procurement",
"kind": "item",
"title": "采购管理",
"itemKey": "prototypes/oneos-web-procurement"
},
{
"id": "item:prototypes:oneos-web-lease-contract",
"kind": "item",
"title": "车辆租赁合同",
"itemKey": "prototypes/oneos-web-lease-contract"
},
{
"id": "item:prototypes:oneos-web-h2-station",
"kind": "item",
"title": "加氢站管理",
"itemKey": "prototypes/oneos-web-h2-station"
},
{
"id": "item:prototypes:oneos-web-data-analysis",
"kind": "item",
"id": "folder-oneos-data-analysis",
"kind": "folder",
"title": "数据分析",
"itemKey": "prototypes/oneos-web-data-analysis"
"children": [
{
"id": "item:prototypes:business-dept-ledger",
"kind": "item",
"title": "业务部台账",
"itemKey": "prototypes/business-dept-ledger"
}
]
},
{
"id": "item:prototypes:oneos-web-ledger-data",
"kind": "item",
"title": "台账数据",
"itemKey": "prototypes/oneos-web-ledger-data"
"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:lease-business-detail",
"kind": "item",
"title": "租赁业务明细",
"itemKey": "prototypes/lease-business-detail"
},
{
"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": "item:prototypes:vehicle-maintenance-ledger",
"kind": "item",
"title": "维修保养台账",
"itemKey": "prototypes/vehicle-maintenance-ledger"
}
]
},
{
"id": "item:prototypes:oneos-web-business",
"kind": "item",
"title": "务管理",
"itemKey": "prototypes/oneos-web-business"
"id": "folder-1783314610587-rwspc1",
"kind": "folder",
"title": "务管理",
"children": [
{
"id": "item:prototypes:vehicle-pickup-receivable",
"kind": "item",
"title": "提车应收款",
"itemKey": "prototypes/vehicle-pickup-receivable"
},
{
"id": "item:prototypes:payment-records",
"kind": "item",
"title": "收款记录",
"itemKey": "prototypes/payment-records"
},
{
"id": "item:prototypes:vehicle-return-settlement",
"kind": "item",
"title": "还车应结款",
"itemKey": "prototypes/vehicle-return-settlement"
},
{
"id": "item:prototypes:vehicle-return-settlement-v2",
"kind": "item",
"title": "还车应结款-新",
"itemKey": "prototypes/vehicle-return-settlement-v2"
}
]
},
{
"id": "item:prototypes:oneos-web-ops",
"kind": "item",
"title": "运维管理",
"itemKey": "prototypes/oneos-web-ops"
"id": "folder-1783875880945-4r6tq1",
"kind": "folder",
"title": "任务工单",
"children": [
{
"id": "item:prototypes:task-work-order",
"kind": "item",
"title": "任务工单",
"itemKey": "prototypes/task-work-order"
}
]
},
{
"id": "item:prototypes:oneos-web-requirements",
"id": "item:prototypes:lease-business-line-overview",
"kind": "item",
"title": "需求说明",
"itemKey": "prototypes/oneos-web-requirements"
"title": "业务条线说明",
"itemKey": "prototypes/lease-business-line-overview"
}
]
}

View File

@@ -0,0 +1,162 @@
---
name: git-repo-beginner-guide
description: "面向不熟悉 Git 的产品经理、设计师、运营、文档作者等非技术用户,引导 GitHub/GitLab/Gitee/Bitbucket/自建 Git 平台的仓库初始化、授权、克隆、提交同步、冲突处理、版本恢复和协作交接。"
---
# Git 仓库新手引导
## Overview
帮助非技术用户安全、低焦虑地使用 Git 仓库协作。默认由 AI 代做可执行操作,并把必要概念翻译成用户能理解的决策点。
## 核心原则
- 先问目标,不先教术语。用户通常要的是“保存一版”“发给团队”“拿到项目”“回到昨天”,不是学习 Git。
- 先保护现场。任何会丢弃、覆盖、重写历史、删除远程内容的动作,都先解释影响并确认。
- 默认代办。能由 AI 检查、执行、验证的,不要让用户自己照抄命令;用户只负责授权、选择平台、确认风险。
- 渐进解释。只有当概念会影响用户决策时才解释例如远程仓库、提交、同步、冲突。分支、rebase、stash 等先不主动引入。
- 平台中立。不要默认 GitHub先识别或询问 GitHub、GitLab、Gitee、Bitbucket、自建 Git、公司内网平台等。
- 用生活化比喻,但保留必要名词。第一次出现可写“提交 commit给当前文件拍一张可回退的快照”。
- 先小步闭环。每次只完成一个可验证状态:已登录、已连接远程、已保存本地版本、已同步到远程、已恢复文件。
## 交互流程
### 1. 先识别用户处境
先用自然语言复述用户目标,然后做最少量澄清。
常见处境:
- “我有一个本地文件夹,想放到代码平台上” → 初始化/连接远程/首次同步
- “别人给我一个链接,我要拿下来” → 克隆/权限/打开项目
- “我改完了,想保存并发给别人” → 查看改动/提交/推送
- “我怕改坏,想留个版本” → 本地提交或备份分支,按复杂度选择
- “提示要登录/没有权限” → 平台授权引导
- “提示冲突/推不上去” → 解释有人也改了同一处,先保护本地改动再合并
- “我要回到之前” → 明确是恢复文件、撤销未提交改动、还是回到某个历史版本
如果上下文已经足够,不要继续追问。直接说“我先帮你检查当前状态”。
### 2. 明确 AI 能帮什么
在开始前用一句话降低用户负担:
> 我可以帮你检查当前文件状态、创建版本记录、连接远程仓库、同步到平台;你只需要在需要网页登录授权时确认账号,或者告诉我目标仓库链接。
避免说“你运行以下命令”。除非当前环境无法代办,才给用户步骤。
### 3. 检查当前状态并翻译结果
检查本地是否是仓库、是否有远程地址、当前有无未保存改动、是否已登录相关平台。回复用户时不要直接堆 `git status` 原文,先给可行动摘要:
- “这个文件夹还不是仓库,我可以把它变成一个可记录版本的文件夹。”
- “它已经连接到远程平台,地址是 X。”
- “现在有 6 个文件改动了,还没有保存成版本。”
- “远程平台需要登录授权,接下来会打开/提供平台自己的登录流程。”
需要展示命令输出时,只摘关键含义。
### 4. 授权引导要平台灵活
先识别远程 URL 或用户指定平台,再选择授权方式:
- GitHub优先 `gh auth status` / `gh auth login`;必要时解释浏览器 OAuth、SSH、token 的差异。
- GitLab优先平台 CLI 或 HTTPS token/SSH自建 GitLab 注意域名和公司 SSO。
- Gitee常见为 HTTPS 凭证、私人令牌或 SSH key不要套用 GitHub 文案。
- Bitbucket注意 workspace、app password、SSH。
- 自建平台:先问/识别平台地址,按 HTTPS token、SSH key、公司 SSO 三类引导。
授权沟通规范:
- 不要求用户把密码、token、私钥粘贴到聊天里。
- 需要 token 时,引导用户在平台页面生成,并说明只保存到系统凭据/CLI不在聊天中泄露。
- 需要 SSH key 时,先检查是否已有可用 key没有再创建并指导用户把公钥加到平台。
- 遇到 SSO/组织权限时,告诉用户“这是账号权限问题,不是你的文件坏了”。
## 复杂度控制
### 不主动引入分支
如果用户只想把个人文件夹放到远程、保存一版、拉取项目或同步改动,不主动讲分支。
只有在以下情况才引入分支:
- 用户明确要多人协作或不想影响主版本
- 当前仓库已有分支策略或保护规则
- 需要在不动主线的情况下尝试修改
- 提交到主分支被拒绝,需要通过 PR/MR
第一次解释分支时保持短句:
> 分支可以理解成一份独立草稿。你可以在草稿里改,确认没问题后再合到正式版本。
不要一开始讲 rebase、cherry-pick、worktree、detached HEAD。
### 冲突处理面向结果
冲突不是“报错”,而是“同一处内容有两份修改,系统不知道该选哪份”。
处理前先说明:
- 你的本地改动还在
- 远程也有别人/另一台设备的改动
- 我会先帮你看冲突位置,再让你决定保留哪部分或合并两边
如果用户不懂代码,优先生成“冲突内容对照摘要”,不要让用户读冲突标记。
### 版本恢复先问恢复粒度
用户说“还原/回退/恢复”时,先判断是哪一种:
- 只撤销还没保存成版本的本地修改
- 找回某个被删文件
- 查看之前某个版本
- 把整个项目回到某个历史版本
- 远程也要同步回退
远程回退、强推、删除历史前必须明确风险并取得确认。对非技术用户可说:
> 这会改变团队平台上看到的历史版本,可能影响别人。我先给你一个不改远程历史的恢复方案。
## 推荐话术
### 初始化/首次同步
> 我先帮你确认这个文件夹是不是已经有版本记录。如果没有,我会把它变成一个仓库,然后再连接你选择的平台。这个过程不会改变文件内容,只是增加一套版本记录。
### 提交/保存版本
> 现在可以把这次改动保存成一个版本记录。提交信息我会写成人能看懂的一句话,方便以后知道这一版改了什么。
### 推送/同步
> 本地版本已经保存好了。下一步是同步到远程平台,这样团队或另一台电脑才能看到。
### 授权
> 这里需要你用平台账号授权。我不会让你把密码发给我;我们走平台自己的登录流程,完成后我再继续。
### 不确定平台
> 你用的是 GitHub、GitLab、Gitee、Bitbucket还是公司自己的代码平台如果你有仓库链接直接发链接最省事。
## 操作规范
- 执行前先看 `git status`、远程地址和当前分支/默认线索;不要盲目提交或推送。
- 提交前给用户一个简短变更摘要,确认是否包含敏感文件、临时文件、大文件、设计源文件。
- 提交信息用用户能理解的语言,但保留常见规范。示例:`docs: update product requirements draft``feat: add onboarding flow mockup`
- 不把 `.env`、密钥、token、账号导出文件、个人隐私文件提交到仓库发现时先提醒并处理忽略规则。
- 不擅自执行 `git reset --hard``git clean -fd``git push --force`、删除远程分支、覆盖远程历史。
- 如果工作区已有别人未说明的改动,不要回滚;先说明发现了哪些现有改动,并只处理用户当前目标相关内容。
- 对新手用户优先给“下一步我来做什么”和“你需要确认什么”,少给完整命令列表。
## 交付要求
完成后用简短摘要告诉用户:
- 当前状态:本地已保存/已连接远程/已同步/仍需授权/遇到冲突
- 做了什么:初始化、克隆、提交、推送、恢复、创建 issue/PR/MR 等
- 远程位置:仓库链接或远程地址(如果可用)
- 用户下一步:打开链接确认、邀请协作者、继续修改、等待权限、选择冲突保留内容
如果没有完成,说明卡在“权限、网络、平台限制、冲突选择、缺少仓库链接”中的哪一类,并给一个最短可继续步骤。

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Git 仓库新手引导"
short_description: "用非技术用户听得懂的方式引导 Git/GitHub/GitLab/Gitee 仓库协作。"
default_prompt: "帮我用面向非技术用户的方式完成仓库初始化、授权、提交、同步或版本恢复。"

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

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

View File

@@ -0,0 +1,57 @@
---
description: 修改任意原型后自动同步版本与变更日志到「原型导航」页面
globs: src/prototypes/**/*
alwaysApply: false
---
# 原型导航自动同步
修改 `src/prototypes/` 下任意原型(不含 `oneos-prototype-nav` 自身)后,**必须**同步导航注册表,使「原型导航」页展示最新版本号与变更日志。
## 何时执行
完成以下任一类型的改动后执行同步:
- 页面逻辑、组件、样式(`.tsx` / `.jsx` / `.css`
- `annotation-source.json`、PRD、`.spec` 文档
- 新增或调整菜单相关原型
## 命令
**单个原型**(推荐,附变更说明):
```bash
npm run nav:sync -- --prototype <prototype-id> --note "一句话变更摘要"
```
**全量检查**(菜单结构调整、批量改动后):
```bash
npm run nav:sync
```
也可通过环境变量传说明:
```bash
CHANGELOG_NOTE="修复筛选联动" npm run nav:sync -- --prototype customer-management
```
## 行为说明
- 脚本路径:`scripts/sync-prototype-nav-registry.mjs`
- 检测原型目录内容哈希;有变化则版本递增(`v1.0` → `v1.1` → …)
- 写入 `src/prototypes/oneos-prototype-nav/prototype-registry.json`
- 同步 `nav-menu.json`,并清理各原型 `annotation-source.json` 中的 `oneos-project-nav` 节点(`npm run project-nav:sync`
- 同步小羚羚小程序目录(`npm run nav:xll-sync`,读取 `make-project-20260629` 项目标注目录)
- 本地 `npm run dev` 时Vite 插件会在保存文件后自动触发同步(无需手动跑命令)
## Agent 收尾清单
1. 完成原型代码/文档修改
2. 运行 `npm run nav:sync -- --prototype <id> --note "<摘要>"`
3. 提醒用户刷新「原型导航」页查看版本与「最近更新」
## 不要
- 不要手改 `prototype-registry.json` 里的版本号或 changelog
- 不要把 `oneos-prototype-nav` 的变更记入主业务原型版本(导航页自身变更可忽略)

View File

@@ -0,0 +1,162 @@
---
name: git-repo-beginner-guide
description: "面向不熟悉 Git 的产品经理、设计师、运营、文档作者等非技术用户,引导 GitHub/GitLab/Gitee/Bitbucket/自建 Git 平台的仓库初始化、授权、克隆、提交同步、冲突处理、版本恢复和协作交接。"
---
# Git 仓库新手引导
## Overview
帮助非技术用户安全、低焦虑地使用 Git 仓库协作。默认由 AI 代做可执行操作,并把必要概念翻译成用户能理解的决策点。
## 核心原则
- 先问目标,不先教术语。用户通常要的是“保存一版”“发给团队”“拿到项目”“回到昨天”,不是学习 Git。
- 先保护现场。任何会丢弃、覆盖、重写历史、删除远程内容的动作,都先解释影响并确认。
- 默认代办。能由 AI 检查、执行、验证的,不要让用户自己照抄命令;用户只负责授权、选择平台、确认风险。
- 渐进解释。只有当概念会影响用户决策时才解释例如远程仓库、提交、同步、冲突。分支、rebase、stash 等先不主动引入。
- 平台中立。不要默认 GitHub先识别或询问 GitHub、GitLab、Gitee、Bitbucket、自建 Git、公司内网平台等。
- 用生活化比喻,但保留必要名词。第一次出现可写“提交 commit给当前文件拍一张可回退的快照”。
- 先小步闭环。每次只完成一个可验证状态:已登录、已连接远程、已保存本地版本、已同步到远程、已恢复文件。
## 交互流程
### 1. 先识别用户处境
先用自然语言复述用户目标,然后做最少量澄清。
常见处境:
- “我有一个本地文件夹,想放到代码平台上” → 初始化/连接远程/首次同步
- “别人给我一个链接,我要拿下来” → 克隆/权限/打开项目
- “我改完了,想保存并发给别人” → 查看改动/提交/推送
- “我怕改坏,想留个版本” → 本地提交或备份分支,按复杂度选择
- “提示要登录/没有权限” → 平台授权引导
- “提示冲突/推不上去” → 解释有人也改了同一处,先保护本地改动再合并
- “我要回到之前” → 明确是恢复文件、撤销未提交改动、还是回到某个历史版本
如果上下文已经足够,不要继续追问。直接说“我先帮你检查当前状态”。
### 2. 明确 AI 能帮什么
在开始前用一句话降低用户负担:
> 我可以帮你检查当前文件状态、创建版本记录、连接远程仓库、同步到平台;你只需要在需要网页登录授权时确认账号,或者告诉我目标仓库链接。
避免说“你运行以下命令”。除非当前环境无法代办,才给用户步骤。
### 3. 检查当前状态并翻译结果
检查本地是否是仓库、是否有远程地址、当前有无未保存改动、是否已登录相关平台。回复用户时不要直接堆 `git status` 原文,先给可行动摘要:
- “这个文件夹还不是仓库,我可以把它变成一个可记录版本的文件夹。”
- “它已经连接到远程平台,地址是 X。”
- “现在有 6 个文件改动了,还没有保存成版本。”
- “远程平台需要登录授权,接下来会打开/提供平台自己的登录流程。”
需要展示命令输出时,只摘关键含义。
### 4. 授权引导要平台灵活
先识别远程 URL 或用户指定平台,再选择授权方式:
- GitHub优先 `gh auth status` / `gh auth login`;必要时解释浏览器 OAuth、SSH、token 的差异。
- GitLab优先平台 CLI 或 HTTPS token/SSH自建 GitLab 注意域名和公司 SSO。
- Gitee常见为 HTTPS 凭证、私人令牌或 SSH key不要套用 GitHub 文案。
- Bitbucket注意 workspace、app password、SSH。
- 自建平台:先问/识别平台地址,按 HTTPS token、SSH key、公司 SSO 三类引导。
授权沟通规范:
- 不要求用户把密码、token、私钥粘贴到聊天里。
- 需要 token 时,引导用户在平台页面生成,并说明只保存到系统凭据/CLI不在聊天中泄露。
- 需要 SSH key 时,先检查是否已有可用 key没有再创建并指导用户把公钥加到平台。
- 遇到 SSO/组织权限时,告诉用户“这是账号权限问题,不是你的文件坏了”。
## 复杂度控制
### 不主动引入分支
如果用户只想把个人文件夹放到远程、保存一版、拉取项目或同步改动,不主动讲分支。
只有在以下情况才引入分支:
- 用户明确要多人协作或不想影响主版本
- 当前仓库已有分支策略或保护规则
- 需要在不动主线的情况下尝试修改
- 提交到主分支被拒绝,需要通过 PR/MR
第一次解释分支时保持短句:
> 分支可以理解成一份独立草稿。你可以在草稿里改,确认没问题后再合到正式版本。
不要一开始讲 rebase、cherry-pick、worktree、detached HEAD。
### 冲突处理面向结果
冲突不是“报错”,而是“同一处内容有两份修改,系统不知道该选哪份”。
处理前先说明:
- 你的本地改动还在
- 远程也有别人/另一台设备的改动
- 我会先帮你看冲突位置,再让你决定保留哪部分或合并两边
如果用户不懂代码,优先生成“冲突内容对照摘要”,不要让用户读冲突标记。
### 版本恢复先问恢复粒度
用户说“还原/回退/恢复”时,先判断是哪一种:
- 只撤销还没保存成版本的本地修改
- 找回某个被删文件
- 查看之前某个版本
- 把整个项目回到某个历史版本
- 远程也要同步回退
远程回退、强推、删除历史前必须明确风险并取得确认。对非技术用户可说:
> 这会改变团队平台上看到的历史版本,可能影响别人。我先给你一个不改远程历史的恢复方案。
## 推荐话术
### 初始化/首次同步
> 我先帮你确认这个文件夹是不是已经有版本记录。如果没有,我会把它变成一个仓库,然后再连接你选择的平台。这个过程不会改变文件内容,只是增加一套版本记录。
### 提交/保存版本
> 现在可以把这次改动保存成一个版本记录。提交信息我会写成人能看懂的一句话,方便以后知道这一版改了什么。
### 推送/同步
> 本地版本已经保存好了。下一步是同步到远程平台,这样团队或另一台电脑才能看到。
### 授权
> 这里需要你用平台账号授权。我不会让你把密码发给我;我们走平台自己的登录流程,完成后我再继续。
### 不确定平台
> 你用的是 GitHub、GitLab、Gitee、Bitbucket还是公司自己的代码平台如果你有仓库链接直接发链接最省事。
## 操作规范
- 执行前先看 `git status`、远程地址和当前分支/默认线索;不要盲目提交或推送。
- 提交前给用户一个简短变更摘要,确认是否包含敏感文件、临时文件、大文件、设计源文件。
- 提交信息用用户能理解的语言,但保留常见规范。示例:`docs: update product requirements draft``feat: add onboarding flow mockup`
- 不把 `.env`、密钥、token、账号导出文件、个人隐私文件提交到仓库发现时先提醒并处理忽略规则。
- 不擅自执行 `git reset --hard``git clean -fd``git push --force`、删除远程分支、覆盖远程历史。
- 如果工作区已有别人未说明的改动,不要回滚;先说明发现了哪些现有改动,并只处理用户当前目标相关内容。
- 对新手用户优先给“下一步我来做什么”和“你需要确认什么”,少给完整命令列表。
## 交付要求
完成后用简短摘要告诉用户:
- 当前状态:本地已保存/已连接远程/已同步/仍需授权/遇到冲突
- 做了什么:初始化、克隆、提交、推送、恢复、创建 issue/PR/MR 等
- 远程位置:仓库链接或远程地址(如果可用)
- 用户下一步:打开链接确认、邀请协作者、继续修改、等待权限、选择冲突保留内容
如果没有完成,说明卡在“权限、网络、平台限制、冲突选择、缺少仓库链接”中的哪一类,并给一个最短可继续步骤。

1
.gitignore vendored
View File

@@ -133,6 +133,7 @@ storybook-static/
# Temporary folders
tmp/
temp/
.local/
# Git version management temporary files
.git-versions/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
[folder] ONE-OS 原型导航
[link] 合同模板管理 → /prototypes/contract-template-management
[link] 保险采购 → /prototypes/insurance-procurement
[link] 车辆管理 → /prototypes/vehicle-management
[link] 租赁合同管理 → /prototypes/lease-contract-management
[link] 客户管理 → /prototypes/customer-management
[link] 租赁业务台账(当前) → /prototypes/lease-business-ledger
[link] 车辆氢费明细 → /prototypes/vehicle-h2-fee-ledger
[link] 自营业务台账 → /prototypes/self-operated-business-ledger
[folder] ONE-OS Web 端
[link] 工作台 → /prototypes/oneos-web-workbench
[link] 登录 → /prototypes/oneos-web-login
[link] 车辆管理 → /prototypes/oneos-web-vehicle-asset
[link] 合同模板管理 → /prototypes/oneos-web-contract-template
[link] 帮助中心 → /prototypes/oneos-web-help-center
[link] 财务管理 → /prototypes/oneos-web-finance
[link] 采购管理 → /prototypes/oneos-web-procurement
[link] 车辆租赁合同 → /prototypes/oneos-web-lease-contract
[link] 加氢站管理 → /prototypes/oneos-web-h2-station
[link] 数据分析 → /prototypes/oneos-web-data-analysis
[link] 台账数据 → /prototypes/oneos-web-ledger-data
[link] 业务管理 → /prototypes/oneos-web-business
[link] 运维管理 → /prototypes/oneos-web-ops
[link] 需求说明 → /prototypes/oneos-web-requirements
[folder] 租赁业务台账说明
[markdown] 模块总览
[link] 租赁业务台账原型 → ./index.html
[markdown] PRD 全文 (src/prototypes/lease-business-ledger/.spec/requirements-prd.md)
[folder] 页面标注
[markdown] 筛选条件
[folder] KPI 统计
[markdown] KPI 统计
[markdown] 台账条数
[markdown] 未收总计
[folder] 列表工具栏
[markdown] 列表工具栏
[markdown] 标记为已确认
[markdown] 添加行
[folder] 租赁台账宽表
[markdown] 租赁台账宽表
[markdown] 状态流转
[markdown] 业务部门
[markdown] 车牌号码
[markdown] 客户名称
[markdown] 提车日期
[markdown] 保证金
[markdown] 合同标的租金
[markdown] 应收租金
[markdown] 月度收入
[markdown] 月度租金
[markdown] 维保包干收入
[markdown] 保险上浮费
[markdown] 运维费(收入侧)
[markdown] 其他收入
[markdown] 实收金额
[markdown] 未收金额
[markdown] 开票日期
[markdown] 实际付款日期
[markdown] 付款方式
[markdown] 车辆标准成本
[markdown] 车辆实际成本
[markdown] 保险费
[markdown] 氢费
[markdown] 运维费(成本侧)
[markdown] 居间费
[markdown] 其他(成本侧)
[markdown] GPS里程
[markdown] 其他减免金额明细内容
[markdown] 资产归属
[markdown] 签约公司
[markdown] 备注

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,9 @@ const root = '/Users/sylvawong/oneos1.2';
const prdPath = join(root, 'src/resources/vehicle-h2-fee-ledger/PRD.md');
const prd = readFileSync(prdPath, 'utf8');
const specPrdPath = join(root, 'src/prototypes/vehicle-h2-fee-ledger/.spec/PRD.md');
writeFileSync(specPrdPath, prd, 'utf8');
const resourcesAlias = join(root, 'src/resources/vehicle-h2-fee-ledger/车辆氢费明细-需求文档.md');
writeFileSync(resourcesAlias, prd, 'utf8');
@@ -30,7 +33,7 @@ const docRoot = ann.directory.nodes[0];
const prdNode = findChild(docRoot.children, 'vh2-doc-prd');
if (prdNode) {
prdNode.markdown = prd;
prdNode.markdownPath = 'src/resources/vehicle-h2-fee-ledger/PRD.md';
prdNode.markdownPath = '.spec/PRD.md';
}
const pageAnn = findChild(docRoot.children, 'vh2-doc-page-annotations');
@@ -86,5 +89,6 @@ page = page.slice(0, startIdx + startMarker.length) + prdForJs + page.slice(endI
writeFileSync(pagePath, page, 'utf8');
console.log('Synced PRD to:', prdPath);
console.log('Synced spec:', specPrdPath);
console.log('Synced alias:', resourcesAlias);
console.log('Synced annotation-source.json and H2LedgerPage.jsx');

View File

@@ -16,9 +16,11 @@
| 产物/场景 | 位置 | 参考文档 |
|-----------|------|----------|
| vm-page 列表分页 | `src/common/TablePagination.tsx` + `vm-pagination.css` | `src/prototypes/vm-shared/DESIGN.md` |
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw``canvas-assets/` | 原型画布和画布素材 |
| 原型标注布局 | `src/common/prototype-annotation-host.tsx` | `rules/prototype-annotation-layout-guide.md` |
## ⚠️ 重要原则

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/resources/` | `rules/resource-management-guide.md` |
| UI Review 结论 | `src/prototypes/<prototype-name>/.spec/ui-review.md` | `rules/ui-review-guide.md` |

3484
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,10 @@
"start": "npm run dev",
"metadata:sync": "node scripts/sync-project-metadata.mjs",
"project-nav:sync": "node scripts/sync-project-prototype-directory.mjs",
"nav:sync": "node scripts/sync-prototype-nav-registry.mjs",
"nav:enrich-git": "node scripts/enrich-prototype-registry-from-git.mjs",
"release:changelog": "node scripts/apply-release-changelog.mjs",
"nav:xll-sync": "node scripts/sync-xll-nav-menu.mjs",
"oneos-web:sync": "node scripts/sync-oneos-web-prototypes.mjs && node scripts/scan-entries.js && npm run metadata:sync",
"build": "node scripts/sync-vendor-if-present.mjs && node scripts/scan-entries.js && node scripts/build-all.js",
"preview": "vite preview",
@@ -26,12 +30,15 @@
"font:subset:beginner-guide": "node scripts/subset-beginner-guide-fonts.mjs"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.1085.0",
"@mermaid-js/mermaid-cli": "^11.16.0",
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "4.0.16",
"@vitest/ui": "4.0.16",
"extract-zip": "^2.0.1",
"iconv-lite": "^0.7.1",
"jsdom": "^29.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"subset-font": "^2.5.0",
@@ -43,7 +50,11 @@
},
"dependencies": {
"@axhub/annotation": "^1.0.10",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"mammoth": "^1.12.0",
"mermaid": "^11.16.0",
"xlsx": "^0.18.5"
}
}

419
rules/global-design-spec.md Normal file
View File

@@ -0,0 +1,419 @@
# ONE-OS 全局设计规范(开发版)
| 项 | 说明 |
|---|---|
| 文档版本 | v1.0 |
| 适用范围 | Axhub Make 客户端内所有中后台列表页、台账页、运营配置页 |
| 视觉基底 | Linear 主题 **浅色 inverse** 模式 + ONE-OS 品牌绿主色 |
| 事实源 CSS | `src/prototypes/vehicle-management/style.css` |
| 列表页强制规范 | `src/prototypes/vm-shared/DESIGN.md` |
| 台账扩展规范 | `src/prototypes/ledger-shared/DESIGN.md` |
---
## 1. 设计原则
1. **一致性**:新建页面优先复用 `vm-*` 类名与公共组件,不另起一套视觉语言。
2. **信息密度适中**:列表首屏给筛选 + KPI + 表格,不在正文重复侧栏已有的大标题。
3. **可扫描**:表头弱对比、正文强对比;金额/数量用等宽数字 `tabular-nums`
4. **可访问**:可见 focus 态、按钮/卡片可键盘操作;尊重 `prefers-reduced-motion`
5. **左对齐数字**:金额、数量列与文本列统一左对齐,禁止右对齐金额列。
---
## 2. Design Tokens
定义于 `vehicle-management/style.css``:root`Ant Design 页面通过 `ConfigProvider` 同步主色。
### 2.1 色彩
| Token | 值 | 用途 |
|---|---|---|
| `--ln-primary` | `#32a06e` | 主色:主按钮、选中 KPI、链接、当前页码 |
| `--ln-primary-hover` | `#3fb87c` | 主色悬停 |
| `--ln-primary-focus` | `#2b9260` | 主色按下 / 选中文字 |
| `--ln-primary-soft` | `rgba(50,160,110,0.1)` | 浅色选中底、选项高亮 |
| `--ln-ink` | `#18181b` | 标题、强调正文 |
| `--ln-body` | `#52525b` | 正文 |
| `--ln-muted` | `#71717a` | 次要文字、表头 |
| `--ln-muted-soft` | `#a1a1aa` | 占位符、弱图标 |
| `--ln-canvas-parchment` | `#f5f6f6` | 页面背景 |
| `--ln-surface-card` | `#ffffff` | 卡片、输入框、表格容器 |
| `--ln-canvas-soft` | `#f6f7f7` | 表头底、行 hover、次级表面 |
| `--ln-hairline` | `#e5e7eb` | 卡片边框、分割线 |
| `--ln-hairline-strong` | `#d4d4d8` | 输入框边框、分页按钮边框 |
| `--ln-success` | `#27a644` | 成功态 |
| `--ln-warning` | `#d97706` | 警告态 |
| `--ln-error` | `#e5484d` | 错误、删除、欠费强调 |
| `--ln-on-primary` | `#ffffff` | 主色按钮文字 |
### 2.2 字体
| 角色 | 值 |
|---|---|
| 正文字体 `--vm-font` | Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif |
| 等宽 `--vm-font-mono` | ui-monospace, JetBrains Mono, SFMono-Regular, Menlo, Consolas, monospace |
| 数字/金额 | 使用 `--vm-font-mono` + `tabular-nums`(全局见 `src/common/ln-numeric.css`,对齐 `.vm-expire-date` |
| 金额格式 | `src/common/format-number.js`(千分位 + 2 位小数Legacy 页可用 `window.__oneosFormatMoney` |
| 页面基础字号 | `0.875rem`14px |
| 筛选标签 | `0.75rem` / 500 |
| 表头 | `0.75rem` / 500 |
| 表格单元格 | `0.8125rem`13px |
| 筛选卡片标题 | `1.125rem` / 600 |
### 2.3 圆角
| Token | 值 | 用途 |
|---|---|---|
| `--ln-radius-xs` | 4px | 小提示、图标按钮 |
| `--ln-radius-sm` | 6px | 多选框 |
| `--ln-radius-control` | 8px | 按钮、输入框、分页 |
| `--ln-radius-card` | 12px | 筛选卡、表格卡、KPI 卡 |
| `--ln-radius-xl` | 16px | 大面板 |
| `--ln-radius-pill` | 9999px | 徽章、胶囊 |
### 2.4 间距与尺寸
| Token | 值 | 用途 |
|---|---|---|
| 页面内边距 | `24px 32px 32px`≤640px 为 16px |
| 筛选卡片内边距 | `24px`(紧凑模式 1620px |
| 筛选栅格间距 | `16px 20px` |
| 区块垂直节奏 | 筛选 / KPI / 工具栏 / 表格之间约 **1620px** |
| `--vm-control-height` | `32px` | 筛选输入、分页控件 |
| `--vm-btn-height` | `36px` | 按钮最小高度 |
### 2.5 阴影
| Token | 用途 |
|---|---|
| `--ln-shadow-soft` | 卡片默认 |
| `--ln-shadow-hover` | 下拉、Tooltip |
| `--ln-shadow-float` | 浮层 |
| `--ln-shadow-modal` | 弹窗 |
### 2.6 Focus 规则
- 使用 **边框变色**`border-color: var(--vm-focus-border-color)`),不用外扩 outline 环(按钮、输入框统一)。
- 类名辅助:`.vm-focus-border:focus-visible`
---
## 3. 页面壳层
### 3.1 标准列表页结构
```text
.vm-page # 页面根(必选)
├── .vm-filter-card # 筛选区
├── .vm-kpi-row # KPI可选
└── .vm-table-section
├── .vm-table-toolbar # 工具栏(右对齐操作)
└── .vm-table-card
├── .vm-table-wrap # 表格滚动区
└── .vm-table-footer # 底部分页
└── <TablePagination />
```
### 3.2 页面类名扩展
| 类名 | 场景 |
|---|---|
| `vm-page` | 所有列表页基类 |
| `vm-page ldb-page` | 台账列表(租赁/维修等) |
| `vm-page lc-page` | 租赁合同 Ant Design 列表 |
### 3.3 必须引入的样式
```typescript
import '../vehicle-management/style.css';
// 台账页另加模块 styles合同页另加 lease-contract.css
```
### 3.4 禁止
- 标准列表页在正文再放与侧栏重复的 `h1` 大标题 + 长说明段落。
- 工具栏在已有 KPI + 底部分页时重复写「共 N 条」。
- 列表主表格用「加载更多」代替分页(除非 PRD 明确排除分页)。
---
## 4. 组件规范
### 4.1 筛选区 `.vm-filter-card`
| 元素 | 类名 | 规格 |
|---|---|---|
| 容器 | `vm-filter-card` | 白底、12px 圆角、1px 边框、轻阴影 |
| 标题 | `vm-filter-title` | 「筛选条件」1.125rem / 600 |
| 栅格 | `vm-filter-grid` | 默认 4 列1280px 以下 2 列640px 以下 1 列 |
| 字段 | `vm-filter-field` | 标签在上、控件在下;标签 12px / muted |
| 操作区 | `vm-filter-actions` | 顶部分割线;右对齐「重置」「查询」 |
**日期区间(强制)**
- 使用 `DateRangeFilterField``vehicle-management/components/DateRangeFilterField`)。
- 展示分隔符为中文「**至**」。
- 类名:`vm-date-range-field``vm-filter-picker-control`
- **禁止**筛选区用 Ant `RangePicker`、双 `<input type="date">` 等替代方案。
**折叠筛选**
- 超过 4 项时其余放入 `vm-filter-expand`(见 `src/common/vm-filter-panel.ts`)。
- 切换按钮:`vm-filter-toggle` + 角标 `vm-filter-toggle-badge`
### 4.2 输入框 `.vm-input`
| 场景 | 高度 | 字号 |
|---|---|---|
| 筛选区内 | 32px | 0.875rem |
| 表单页 | min 44px | 1rem移动端友好 |
- 边框:`--ln-hairline-strong`focus 时主题色边框。
- 占位符:`--ln-muted-soft`
### 4.3 下拉选择 `.vm-filter-picker-*`
| 元素 | 说明 |
|---|---|
| `vm-filter-picker-control` | 触发器,与输入框同高 |
| `vm-filter-picker-popover` | 下拉面板,卡片圆角 + hover 阴影 |
| `vm-filter-picker-option.checked` | 主色浅底 + 深绿字 |
### 4.4 按钮 `.vm-btn`
| 变体 | 类名 | 用途 |
|---|---|---|
| 主按钮 | `vm-btn vm-btn-primary` | 查询、保存、提交 |
| 次要 | `vm-btn vm-btn-secondary` | 带边框白底 |
| 幽灵 | `vm-btn vm-btn-ghost` | 取消、重置、工具栏次要操作 |
| 文字链 | `vm-btn vm-btn-link` | 表格内操作、行内链接 |
| 返回 | `vm-btn vm-btn-back` | 表单/详情/编辑顶栏左上角Chevron 图标 + 白底描边hover 主色字/边框 + 浅绿底 |
**规格**:高度 36px圆角 8px字号 0.875rem / 500禁用 opacity 0.5。
**弹窗底部**:次要操作用 `vm-btn-ghost`,主操作用 `vm-btn-primary`(或 Ant `Button type="primary"` 且主题色对齐)。
### 4.5 KPI 卡片 `.vm-kpi-row` / `.vm-kpi-card`
| 元素 | 说明 |
|---|---|
| 布局 | 默认 8 列网格1600px→4 列960px→2 列640px→1 列 |
| 卡片 | 最小高度 88px左图标 + 标题 + 数值 |
| 标题 | `vm-kpi-eyebrow` |
| 数值 | `vm-kpi-val`,等宽数字 |
| 说明 | 右上角 `vm-kpi-tip` + `vm-kpi-tooltip` |
| 选中 | `.active`:绿色描边 + 轻阴影;数值变 `--ln-primary-focus` |
台账扩展:`ldb-kpi-row` / `ldb-kpi-val--profit` / `ldb-kpi-val--loss`
### 4.6 工具栏 `.vm-table-toolbar`
- 右对齐:`.vm-table-actions { margin-left: auto }`
- 仅放:筛选开关、批量操作、新建、导入导出等。
- 不放:与分页重复的条数文案。
### 4.7 表格
#### 原生表格 `.vm-table`
| 项 | 规则 |
|---|---|
| 容器 | `vm-table-card` > `vm-table-wrap` > `vm-table` |
| 表头 | 粘性顶;背景 `--ln-canvas-soft`;文字 muted |
| 行 hover | 浅灰底;冻结列同步变色 |
| 冻结列 | `sticky-col` / `sticky-right`;台账用 `sticky-col-left` / `sticky-col-right` |
| 金额列 | `ldb-col-money``ln-modal-detail-table__money` + `tabular-nums`**左对齐** |
| 空值 | 显示「—」或留白,不显示 undefined |
#### Ant Design Table
- 列表页类名:`vm-list-table`(随模块)。
- 弹窗内明细:`ln-modal-detail-table` + `tableLayout="fixed"` + 每列显式 `width`
- 样式源:`src/common/modal-detail-table.css`
- 通过全局规则覆盖 `cell-align-right` 为左对齐。
### 4.8 分页 `TablePagination`
**组件**`src/common/TablePagination.tsx`(强制,禁止手写分页 DOM
**布局顺序**(从左到右):
```text
共 {total} 条 → 上一页 / 页码 / 下一页 → 每页 N 条
```
| 项 | 值 |
|---|---|
| 默认每页 | `DEFAULT_PAGE_SIZE` = 20 |
| 标准选项 | 10 / 20 / 50 / 100 |
| 紧凑选项 | 5 / 10 / 20 / 50`COMPACT_PAGE_SIZE_OPTIONS` |
| 样式类 | `vm-pagination*``src/common/vm-pagination.css` |
| 当前页 | `vm-pagination-page active` 主色填充 |
| 无数据 | 仍显示「共 0 条」,隐藏页码控件 |
**交互**:筛选/重置/KPI 切换/改每页条数时 `page` 重置为 1。
**紧凑 footer**:追加 `vm-table-footer--compact`(详情 Tab 内嵌表)。
### 4.9 多选框 `.vm-checkbox`
样式源:`src/common/vm-checkbox.css`
| 场景 | 用法 |
|---|---|
| 工具栏/表单 | `input.vm-checkbox` |
| 表格勾选列 | `vm-col-check` / `ldb-col-check`;列宽 48px 居中 |
| 下拉多选面板内 | `vm-ops-picker-option` 内用原生紧凑样式(例外) |
| 状态 | 表现 |
|---|---|
| 默认 | 18×18白底细边框 |
| 选中 | 主色底 + 白色对勾 |
| 半选 | 主色底 + 白色横线(表头全选) |
| 禁用 | opacity 45% |
### 4.10 链接与标签
| 元素 | 类名 / 说明 |
|---|---|
| 文本链接 | `vm-link`;颜色 `--ln-link` |
| VIN/编码 | `vm-vin` + mono 字体 |
| 状态标签 | Ant `Tag`;签约等用模块类如 `lc-station-signed-tag` |
| 操作列 | 文字按钮组,左对齐;删除前 `Modal.confirm` |
### 4.11 弹窗 / 抽屉
| 项 | 规则 |
|---|---|
| Ant Modal | `centered: true`;圆角 812pxbody `maxHeight: 78vh` + 滚动 |
| 次要按钮 | `vm-btn-ghost` 或 Ant 默认 + 模块封装 `renderH2GhostButton` 等 |
| 主按钮 | 主题绿 `#32a06e` |
| 表单 | `layout="vertical"`;标签 13px / 600模块内表单 |
| 明细表 | `ln-modal-detail-table`;见 4.7 |
### 4.12 统计卡(下钻/对账等)
模块内常用模式(非全局类名,保持视觉一致即可):
- 白底 + 1px 边框 + 12px 圆角。
- 标签 1213px muted数值 加粗 + `tabular-nums`
- 三列横排统计卡间距 1216px。
---
## 5. Ant Design 接入
使用 Ant Design 的页面(如加氢站、租赁合同)须通过 `ConfigProvider` 对齐 token
```typescript
const vmTheme = {
token: {
colorPrimary: '#32a06e',
colorLink: '#32a06e',
colorLinkHover: '#3fb87c',
borderRadius: 8,
fontFamily: 'Inter, -apple-system, ...',
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 },
},
};
```
**筛选区 Ant 控件**`lc-page` 示例):`Select` / `DatePicker` 高度与 `vm-input` 对齐32px圆角 8px边框 `--ln-hairline-strong`
---
## 6. 响应式断点
| 断点 | 主要变化 |
|---|---|
| ≤1600px | KPI 4 列 |
| ≤1280px | 筛选 2 列;台账 KPI 6→4 列 |
| ≤960px | KPI 2 列 |
| ≤640px | 页面/筛选 padding 缩小;筛选/KPI 1 列 |
---
## 7. 动效与无障碍
| 项 | 规则 |
|---|---|
| 过渡 | 按钮/卡片 0.2s;筛选展开 0.28s |
| 减少动效 | `@media (prefers-reduced-motion: reduce)` 关闭过渡与分页缩放 |
| 分页 | `role="navigation"``aria-label="表格分页"`、当前页 `aria-current="page"` |
| KPI/表头提示 | 支持 focus 显示 tooltip |
| 触控 | 可点击区域建议 ≥ 44×44px表单页输入 44px 高) |
| 图标 | 使用 Lucide / Heroicons 等 SVG**不用 emoji 作图标** |
| 对比度 | 正文对比度 ≥ 4.5:1 |
---
## 8. 禁止做法(汇总)
- 不自建分页 UI不用 Ant `Pagination` 替代 `TablePagination`
- 不在 `vm-page` 内自定义 checkbox 皮肤。
- 不用 RangePicker 做筛选区日期区间。
- 不对金额列 `align: right`
- 不在列表工具栏重复总条数。
- 不引入与 Linear/vm token 冲突的第三方分页/表格皮肤。
- 弹窗内表格须 `tableLayout="fixed"`,避免列宽错位。
---
## 9. 文件与参考实现索引
| 类型 | 路径 |
|---|---|
| 全局样式入口 | `src/prototypes/vehicle-management/style.css` |
| 分页组件 | `src/common/TablePagination.tsx` |
| 分页样式 | `src/common/vm-pagination.css` |
| 多选框样式 | `src/common/vm-checkbox.css` |
| 弹窗表格样式 | `src/common/modal-detail-table.css` |
| 日期区间组件 | `src/prototypes/vehicle-management/components/DateRangeFilterField` |
| 筛选折叠逻辑 | `src/common/vm-filter-panel.ts` |
| 列表页规范 | `src/prototypes/vm-shared/DESIGN.md` |
| 台账扩展规范 | `src/prototypes/ledger-shared/DESIGN.md` |
| 合同 Ant 对齐 | `src/prototypes/lease-contract-management/styles/lease-contract.css` |
| 参考:车辆管理 | `src/prototypes/vehicle-management/index.tsx` |
| 参考:租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` |
| 参考:加氢站站点 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |
| 主题 token 来源 | `src/themes/linear/DESIGN.md`inverse 浅色用于 vm |
---
## 10. 新页面开发检查清单
- [ ] 根节点使用 `vm-page`(及必要的 `ldb-page` / `lc-page`
- [ ]`import` `vehicle-management/style.css`
- [ ] 筛选区使用 `vm-filter-card` + `vm-filter-grid` + `vm-filter-actions`
- [ ] 日期区间使用 `DateRangeFilterField`
- [ ] 按钮使用 `vm-btn-*` 变体
- [ ] 表格底部分页使用 `TablePagination` + `vm-table-footer`
- [ ] 金额列左对齐 + `tabular-nums`
- [ ] 多选使用 `vm-checkbox` 或勾选列约定
- [ ] Ant 页面已配置 `ConfigProvider` 主题色
- [ ] 无重复页面大标题、无重复总条数文案
- [ ] Focus 态、键盘操作、减少动效已验证
---
## 修订记录
| 版本 | 日期 | 说明 |
|---|---|---|
| v1.0 | 2026-07-09 | 首版:汇总 vm-shared、ledger-shared、vehicle-management token 与组件规范 |

View File

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

View File

@@ -38,6 +38,15 @@ src/prototypes/<name>/
- 只有 Make 批注/编辑工具启用、且当前选中的是带安全本地 `markdownPath` 的目录 Markdown 正文子节点时,批注气泡卡片才显示“文档编辑”按钮。
- 导出/发布时会构建期内联 `markdownPath` 正文,不依赖运行时请求 `.md` 文件。
## 原型标注与 PRD 布局(强制)
跨原型跳转与 PRD 阅读方式已全局统一,详见 `rules/prototype-annotation-layout-guide.md`
- **禁止** 在 `annotation-source.json``directory` 中维护 `oneos-project-nav`ONE-OS 原型导航);跨原型入口统一使用 `/prototypes/oneos-prototype-nav`
- **必须** 通过 `PrototypeAnnotationHost``src/common/prototype-annotation-host.tsx`)挂载标注,不在各原型直接使用裸 `AnnotationViewer`
- PRD / 模块说明写在 `directory``markdown` 节点中,由右侧「原型标注」目录分章节展示;**不在**页面正文下方内联 PRD
- 目录内不展示 `link` / `route` 跳转节点;多页面原型通过 `options.currentPageId` 控制右侧 PRD 章节与当前页匹配
每个原型的 `index.tsx` 顶部建议包含面向用户的中文 `@name`,用于预览列表展示名:
```typescript
@@ -86,6 +95,16 @@ export default function MyApp() {
- 使用主题 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,293 @@
/**
* 将版本发布的功能变更说明写入 prototype-registry.json覆盖 commit 131b963 对应条目)。
*
* 用法node scripts/apply-release-changelog.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json');
const RELEASE_COMMIT = '131b963';
const RELEASE_DATE = '2026-07-12';
const RELEASE_TIME = '22:45';
const MAX_RECENT = 40;
/** @type {Record<string, { summary: string, details: string[] }>} */
const RELEASE_NOTES = {
'oneos-prototype-nav': {
summary: '统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步合包页面',
details: [
'【平台】全项目接入 PrototypeAnnotationHostPRD 迁入右侧标注目录;列表统一 OperationActions 与工具栏图标规范',
'【租赁业务明细】收款状态列、47 列宽表、氢费钻取、字段校验、变更记录、导入导出与主管解锁',
'【租赁合同】KPI/状态收敛、查看页、线上/线下签署、交付变更、附加费用与提车应收联动',
'【提车应收款】独立原型上线finance 合包仅保留租赁账单;工作台快捷入口改跳独立原型',
'【任务工单】新增业管发起端:合同/独立双入口、六类任务类型与督办催办',
'【合包】business/ops/finance/ledger-data/lease-contract/h2-station 等移除面包屑并标准化操作区',
'【下线】移除 login/requirements/vehicle-asset 合包;侧栏新增任务工单与提车应收款',
'【发布】导航页固定发布至 /oneos-prototype-nav/index.html',
],
},
'lease-business-detail': {
summary: '租赁业务明细大改版收款状态、47 列宽表、字段校验与变更记录',
details: [
'新增收款状态列(未收款/部分收款/已结清);月份、部门+业务员合并展示;表尾 23 项金额汇总',
'氢费可钻取 Popover编辑/删除/变更记录;公式列(应收合计、未收、盈亏、氢费等)自动重算',
'客户/提车日期/租金等与车辆/客户/合同交叉校验,支持一键替换建议值',
'批量导入 36 列手工字段、导出 47 列;部分成功 + 错误日志;已对账锁定与主管解锁',
],
},
'lease-contract-management': {
summary: '租赁合同增强KPI/状态收敛、查看页、签署方式与提车应收联动',
details: [
'列表 KPI全部/草稿/进行中/审批中/已终止;合同状态收敛;在租车队 KPI 迁至车辆管理',
'筛选:合同模板类型 + 标准合同名称二级联动;统一流程入口(新增/续签/转正式/增车等)',
'新增只读查看页;线上电子签章 / 线下人工上传主动终止与交付安排变更Cascader/TBD',
'附加费用拆分已有/新增服务项;红线条款 + 新增条款检测;先付后用/先用后付',
'提交审批与交付计划变更同步提车应收 bridge',
],
},
'lease-business-ledger': {
summary: '租赁业务台账:操作区统一为 OperationActions标注壳切换',
details: [
'行操作:编辑 / 操作记录 / 保存(编辑态)',
'关联收款、事故/维保/收据链接弹窗保留',
'接入 PrototypeAnnotationHost移除内嵌原型导航目录',
],
},
'self-operated-business-ledger': {
summary: '物流业务明细:更名、列对齐 Excel、批量导入与自动计算',
details: [
'模块更名:物流业务台账 → 物流业务明细',
'列对齐 Excel 1.2:薪资、电费、日社保/挂车/停车/轮胎、线路计价等;系统车型列',
'新增批量导入:模板下载、错误日志、导入后自动计算金额/总成本/盈亏',
'移除行勾选与批量删除',
],
},
'business-dept-ledger': {
summary: '业务部台账:分组表头与表格可读性增强',
details: [
'统一边框 token、分组表头双层样式业务板块 + 业绩/成本/盈亏)',
'冻结列滚动修复;金额列防裁切;盈亏钻取链接样式调整',
],
},
'vehicle-management': {
summary: '车辆管理:在租车队 KPI 迁入、车型分布弹窗与列表样式优化',
details: [
'在租车队 KPI 从租赁合同列表迁入;按品牌型号统计在租数量',
'车型分布弹窗:品牌筛选、车型卡片、占比进度条、车辆明细',
'详情 Tab 与列表筛选/导入/KPI 操作区统一;表格样式优化',
],
},
'customer-management': {
summary: '客户管理OperationActions 统一操作列,移除面包屑',
details: [
'列表操作:详情 / 编辑 / 管理标签 / 删除',
'风险标签、批量打标等业务逻辑不变',
],
},
'contract-template-management': {
summary: '合同模板:启用/停用、先付后用/先用后付变量与操作区重构',
details: [
'行操作:编辑 + 更多(启用/停用、版本日志、删除);启用中不可编辑/删除',
'模板变量新增 paymentMethod付款周期与氢费结算文案更新',
],
},
'insurance-procurement': {
summary: '保险采购OperationActions 统一操作列',
details: [
'保单列表:编辑 / 预览 / 下载 / 更多',
'标注壳切换',
],
},
'vehicle-pickup-receivable': {
summary: '提车应收款独立原型上线:自动生成/手动办理与合同双向联动',
details: [
'收款情况列:租车数 / 已提车Popover 钻取)',
'合同提交审批即生成主表;有交付时间则提前 2 天生成应收',
'手动办理勾选品牌型号与交付时间15 日前/后分档计费规则',
'与租赁合同 bridge 联动finance 合包剥离;工作台入口改跳本原型',
],
},
'task-work-order': {
summary: '任务工单新原型:业管发起端(合同/独立双入口)',
details: [
'任务类型:里程履约、维保履约、政策兑现、付款/交付节点、条款跟进、通用临时',
'列表 KPI全部/我发布的/我督办的 + 状态视角;详情含进度、反馈、催办',
'执行人不可见合同原文与金额(第一版仅业管端)',
],
},
'payment-records': {
summary: '收款记录OperationActions 与关联账单操作',
details: [
'操作列标准化;表格新增「关联账单」',
'标注壳切换',
],
},
'supplier-management': {
summary: '供应商管理:工具栏与操作区图标标准化',
details: [
'新增供应商按钮图标统一;删除收入 OperationActions',
],
},
'oneos-web-business': {
summary: '业务管理合包18 页移除面包屑OperationActions 统一',
details: [
'保险采购、交车任务、客户/供应商、租赁账单、ETC 管理等页操作区标准化',
],
},
'oneos-web-ops': {
summary: '运维管理合包45 页操作列与工具栏按钮标准化',
details: [
'备件/调拨/交还车/故障/异动/证件/型号参数等页 OperationActions 与 data-vm-icon 图标',
],
},
'oneos-web-finance': {
summary: '财务管理合包:提车应收款剥离,仅保留租赁账单',
details: [
'提车应收款 6 页移除;合包默认页改为租赁账单',
'提车流程由 vehicle-pickup-receivable 独立原型承接',
],
},
'oneos-web-ledger-data': {
summary: '台账数据合包5 页操作区标准化',
details: [
'保险分摊、氢费、维修明细、氢费采购端汇总、租赁业务台账等页 OperationActions',
],
},
'oneos-web-lease-contract': {
summary: '租赁合同合包8 子页同步签署/流程字段与 OperationActions',
details: [
'新增、续签、转正式、三方变更、附加费用等子页操作区标准化',
'修复移除面包屑后的 JSX 括号语法',
],
},
'oneos-web-data-analysis': {
summary: '数据分析合包:业务部台账列宽与盈亏着色优化',
details: [
'9 页操作区标准化;业务部台账列宽加大;盈亏负值着色',
],
},
'oneos-web-h2-station': {
summary: '加氢站合包:站点信息编辑提升为主操作',
details: [
'加氢订单/记录/站点信息 3 页 OperationActions站点编辑不再藏在「更多」',
],
},
'oneos-web-workbench': {
summary: '工作台:提车应收款快捷入口改跳独立原型',
details: [
'快捷入口指向 /prototypes/vehicle-pickup-receivable',
'protoNav 支持直接 location 跳转原型 URL',
],
},
'oneos-web-contract-template': {
summary: '合同模板合包:操作区与独立原型对齐',
details: ['模板管理页 OperationActions 统一'],
},
'oneos-web-procurement': {
summary: '采购管理合包:三方退租车页操作区标准化',
details: ['退租申请与管理页 OperationActions'],
},
'oneos-web-h2-station-site': {
summary: '站点信息:标注目录清理',
details: ['移除内嵌原型导航节点;标注壳切换'],
},
'lease-business-line-overview': {
summary: '业务条线说明:标注目录微调',
details: ['条线数据更新;移除内嵌导航节点'],
},
'vehicle-h2-fee-ledger': {
summary: '车辆氢费明细:标注壳与操作区统一',
details: ['OperationActions标注目录规范化'],
},
'vehicle-maintenance-ledger': {
summary: '车辆维修明细:标注壳与操作区统一',
details: ['OperationActions标注目录规范化'],
},
'vehicle-return-settlement-v2': {
summary: '还车应结算 V2OperationActions 统一为详情主操作',
details: ['列表操作列标准化'],
},
};
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function applyReleaseNotes(registry) {
let updated = 0;
for (const [prototypeId, notes] of Object.entries(RELEASE_NOTES)) {
const record = registry.prototypes?.[prototypeId];
if (!record) continue;
const changelog = Array.isArray(record.changelog) ? [...record.changelog] : [];
let targetIndex = changelog.findIndex((entry) => entry.commit === RELEASE_COMMIT);
if (targetIndex < 0 && changelog.length > 0) targetIndex = 0;
const base = targetIndex >= 0 ? changelog[targetIndex] : {
version: record.version,
date: RELEASE_DATE,
time: RELEASE_TIME,
files: record.trackedFiles?.slice(0, 12) || [],
};
const nextEntry = {
...base,
date: RELEASE_DATE,
time: RELEASE_TIME,
summary: notes.summary,
details: notes.details,
commit: RELEASE_COMMIT,
};
if (targetIndex >= 0) {
changelog[targetIndex] = nextEntry;
} else {
changelog.unshift(nextEntry);
}
record.changelog = changelog.slice(0, 30);
record.lastUpdated = `${RELEASE_DATE}T14:45:00.000Z`;
updated += 1;
}
const recent = [];
for (const [prototypeId, notes] of Object.entries(RELEASE_NOTES)) {
const record = registry.prototypes?.[prototypeId];
if (!record) continue;
const entry = record.changelog?.[0];
if (!entry) continue;
recent.push({
prototypeId,
title: record.title || prototypeId,
version: entry.version || record.version,
date: entry.date || RELEASE_DATE,
time: entry.time || RELEASE_TIME,
summary: notes.summary,
details: notes.details,
files: entry.files || [],
});
}
recent.sort((a, b) => {
const ta = new Date(`${a.date}T${a.time}:00+08:00`).getTime();
const tb = new Date(`${b.date}T${b.time}:00+08:00`).getTime();
return tb - ta;
});
registry.recentUpdates = recent.slice(0, MAX_RECENT);
registry.updatedAt = `${RELEASE_DATE}T14:45:00.000Z`;
return updated;
}
const registry = readJson(REGISTRY_PATH);
const count = applyReleaseNotes(registry);
writeJson(REGISTRY_PATH, registry);
console.log(`完成:已写入 ${count} 个原型的版本发布变更说明commit ${RELEASE_COMMIT}`);

View File

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

View File

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

View File

@@ -0,0 +1,293 @@
#!/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,
pathAliases: s3.pathAliases && typeof s3.pathAliases === 'object' ? s3.pathAliases : {},
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, pathAliases = {}) {
const alias = pathAliases[entry.id];
if (alias) return String(alias).replace(/^\/+|\/+$/gu, '');
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
if (prototypeMatch?.[1]) return `prototypes/${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, pathAliases = {}) {
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 }, pathAliases),
});
}
}
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, s3Config.pathAliases);
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

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

View File

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

View File

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

View File

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

View File

@@ -20,18 +20,43 @@ const XLL_MODULES = [
{ slug: 'oneos-web-safety-training', title: '业务-司机安全培训', skipCodegen: true },
];
/** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */
const STANDALONE_MODULES = [
{ slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true },
{ slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true },
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
{ slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true },
{ slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true },
];
const MODULES = [
{ slug: 'oneos-web-workbench', title: '工作台', files: ['工作台.jsx'] },
{ slug: 'oneos-web-login', title: '登录', files: ['登录.jsx'] },
{ slug: 'oneos-web-vehicle-asset', title: '车辆管理', files: ['车辆管理.jsx', '车辆管理-查看.jsx'] },
{ slug: 'oneos-web-contract-template', title: '合同模板管理', files: ['合同模板管理.jsx'] },
{ slug: 'oneos-web-help-center', title: '帮助中心', dir: '帮助中心' },
{ slug: 'oneos-web-finance', title: '财务管理', dir: '财务管理', excludeDirs: ['文档'] },
{
slug: 'oneos-web-finance',
title: '财务管理',
dir: '财务管理',
excludeDirs: ['文档'],
excludeFiles: [
'还车应结款.jsx',
'还车应结款-查看.jsx',
'还车应结款-费用明细.jsx',
'提车应收款.jsx',
'提车应收款-查看.jsx',
'提车应收款-开票信息.jsx',
'提车应收款-审核.jsx',
'提车应收款-提车收款单.jsx',
'提车收款单-编辑.jsx',
],
},
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },
{ slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'] },
{ slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析' },
{ slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'] },
{ slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'], excludeFiles: ['站点信息.jsx'] },
{ slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx'] },
{ slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'], excludeFiles: ['车辆维修明细.jsx'] },
{ slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] },
{ slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' },
{ slug: 'oneos-web-requirements', title: '需求说明', dir: '需求说明', docsOnly: true },
@@ -81,6 +106,7 @@ function collectJsxFiles(module) {
continue;
}
if (!entry.name.endsWith('.jsx')) continue;
if (module.excludeFiles?.includes(entry.name)) continue;
results.push({
rel,
abs: full,
@@ -223,6 +249,15 @@ function toComponentName(slug) {
.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) {
return {
id: `item:prototypes:${module.slug}`,
@@ -232,6 +267,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) {
const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8'));
const prefix = 'item:prototypes:oneos-web-';
@@ -244,6 +290,10 @@ function updateSidebar(webModules, xllModules) {
&& !String(item.itemKey).startsWith('prototypes/oneos-web-'),
);
ensureWorkbenchInOneOsRoot(existing);
const webModulesForFolder = webModules.filter((module) => module.slug !== 'oneos-web-workbench');
const xllFolder = {
id: 'folder-prototypes-xll-miniapp',
kind: 'folder',
@@ -256,7 +306,7 @@ function updateSidebar(webModules, xllModules) {
kind: 'folder',
title: 'ONE-OS Web 端',
defaultExpanded: true,
children: webModules.map(toSidebarItem),
children: webModulesForFolder.map(toSidebarItem),
};
sidebar.prototypes = [...existing, xllFolder, oneosFolder];
@@ -270,7 +320,7 @@ function main() {
process.exit(1);
}
const allModules = [...XLL_MODULES, ...MODULES];
const allModules = [...XLL_MODULES, ...STANDALONE_MODULES, ...MODULES];
for (const module of allModules) {
if (module.skipCodegen) {

View File

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

View File

@@ -0,0 +1,358 @@
/**
* 原型导航注册表:检测原型变更、递增版本、写入变更日志,并同步 nav-menu。
*
* 用法:
* node scripts/sync-prototype-nav-registry.mjs
* node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger
* node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger --note "成本单价校验"
* CHANGELOG_NOTE="修复筛选" node scripts/sync-prototype-nav-registry.mjs --prototype customer-management
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NAV_PROTOTYPE_ID = 'oneos-prototype-nav';
const REGISTRY_RELATIVE = 'src/prototypes/oneos-prototype-nav/prototype-registry.json';
const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']);
const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'coverage']);
const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']);
const MAX_CHANGELOG_PER_PROTOTYPE = 30;
const MAX_RECENT_UPDATES = 40;
function parseArgs(argv) {
const args = {
prototype: '',
projectRoot: '',
note: process.env.CHANGELOG_NOTE?.trim() || '',
quiet: false,
skipDirectorySync: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--prototype' && argv[i + 1]) {
args.prototype = argv[++i].trim();
} else if (arg === '--project-root' && argv[i + 1]) {
args.projectRoot = path.resolve(argv[++i].trim());
} else if (arg === '--note' && argv[i + 1]) {
args.note = argv[++i].trim();
} else if (arg === '--quiet') {
args.quiet = true;
} else if (arg === '--skip-directory-sync') {
args.skipDirectorySync = true;
}
}
return args;
}
function readJson(filePath, fallback = null) {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function log(message, quiet) {
if (!quiet) console.log(message);
}
function prototypeIdFromItemKey(itemKey) {
const normalized = String(itemKey || '').trim().replace(/^\/+/u, '');
const match = normalized.match(/^prototypes\/(.+)$/u);
return match ? match[1] : '';
}
function loadSidebarPrototypes(projectRoot) {
const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json');
if (!fs.existsSync(sidebarPath)) {
throw new Error(`缺少侧边栏配置:${sidebarPath}`);
}
const sidebar = readJson(sidebarPath, {});
return Array.isArray(sidebar.prototypes) ? sidebar.prototypes : [];
}
function collectNavPrototypeTitles(sidebarItems) {
const titles = new Map();
const walk = (items) => {
for (const item of items || []) {
if (item?.kind === 'item') {
const prototypeId = prototypeIdFromItemKey(item.itemKey);
if (prototypeId && prototypeId !== NAV_PROTOTYPE_ID) {
titles.set(prototypeId, item.title || prototypeId);
}
}
if (Array.isArray(item?.children)) walk(item.children);
}
};
walk(sidebarItems);
return titles;
}
function shouldTrackFile(relativePath) {
const base = path.basename(relativePath);
if (IGNORED_FILE_NAMES.has(base)) return false;
if (relativePath === 'annotation-source.json') return false;
const ext = path.extname(relativePath).toLowerCase();
return TRACKED_EXTENSIONS.has(ext);
}
function listTrackedFiles(prototypeDir) {
const files = [];
const walk = (dir, prefix = '') => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORED_DIR_NAMES.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(abs, rel);
continue;
}
if (shouldTrackFile(rel)) files.push(rel);
}
};
walk(prototypeDir);
return files.sort((a, b) => a.localeCompare(b));
}
function hashPrototypeDir(prototypeDir) {
const files = listTrackedFiles(prototypeDir);
const hash = crypto.createHash('sha256');
hash.update(`files:${files.length}\n`);
for (const rel of files) {
const abs = path.join(prototypeDir, rel);
const stat = fs.statSync(abs);
hash.update(`${rel}\n${stat.mtimeMs}\n${stat.size}\n`);
hash.update(fs.readFileSync(abs));
hash.update('\n');
}
return { hash: hash.digest('hex'), files };
}
function formatNowParts(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return {
iso: date.toISOString(),
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
label: `${date.getMonth() + 1}${date.getDate()}`,
};
}
function summarizeChange(changedFiles, note) {
if (note) return note;
const names = changedFiles.map((f) => path.basename(f)).join(' ');
if (/PRD|requirements|需求/i.test(names) || changedFiles.some((f) => f.includes('.spec/') && f.endsWith('.md'))) {
return '更新需求说明与标注';
}
if (changedFiles.some((f) => f.endsWith('.css'))) {
return '更新页面样式';
}
if (changedFiles.some((f) => /\.(tsx|jsx)$/.test(f) || f.includes('/pages/'))) {
return '更新页面逻辑与交互';
}
if (changedFiles.length === 1) {
return `更新 ${path.basename(changedFiles[0])}`;
}
return `更新 ${changedFiles.length} 个文件`;
}
function diffChangedFiles(previousFiles, currentFiles) {
const prevSet = new Set(previousFiles || []);
return currentFiles.filter((file) => !prevSet.has(file));
}
/** 仅标注/批注文件变动时不自动递增版本(避免 dev 保存刷屏) */
function isAnnotationOnlyChange(changedFiles) {
if (!changedFiles.length) return false;
return changedFiles.every((file) => (
file === 'annotation-source.json'
|| file.endsWith('/annotation-source.json')
|| file.includes('prototype-comments')
|| (file.includes('.spec/') && file.endsWith('.md') && !file.includes('requirements-prd'))
));
}
function bumpVersion(revision) {
return `v1.${revision}`;
}
function createRegistrySkeleton() {
return {
version: 1,
updatedAt: new Date().toISOString(),
prototypes: {},
recentUpdates: [],
};
}
function upsertPrototypeRecord(registry, prototypeId, title, contentHash, files, options) {
const existing = registry.prototypes[prototypeId] || {
title,
version: 'v1.0',
revision: 0,
lastUpdated: null,
contentHash: '',
trackedFiles: [],
changelog: [],
};
const changed = existing.contentHash && existing.contentHash !== contentHash;
const now = formatNowParts();
if (!existing.contentHash) {
existing.title = title;
existing.contentHash = contentHash;
existing.trackedFiles = files;
existing.lastUpdated = now.iso;
registry.prototypes[prototypeId] = existing;
return { changed: false, entry: null };
}
if (!changed) {
existing.title = title;
existing.trackedFiles = files;
registry.prototypes[prototypeId] = existing;
return { changed: false, entry: null };
}
const changedFiles = diffChangedFiles(existing.trackedFiles, files);
if (!options.note && isAnnotationOnlyChange(changedFiles.length ? changedFiles : files)) {
existing.title = title;
existing.contentHash = contentHash;
existing.trackedFiles = files;
registry.prototypes[prototypeId] = existing;
return { changed: false, entry: null };
}
const summary = summarizeChange(changedFiles.length ? changedFiles : files, options.note);
const nextRevision = (existing.revision || 0) + 1;
const version = bumpVersion(nextRevision);
const entry = {
version,
date: now.date,
time: now.time,
summary,
files: (changedFiles.length ? changedFiles : files).slice(0, 12),
};
existing.title = title;
existing.version = version;
existing.revision = nextRevision;
existing.lastUpdated = now.iso;
existing.contentHash = contentHash;
existing.trackedFiles = files;
existing.changelog = [entry, ...(existing.changelog || [])].slice(0, MAX_CHANGELOG_PER_PROTOTYPE);
registry.prototypes[prototypeId] = existing;
return { changed: true, entry: { prototypeId, title, ...entry } };
}
function pushRecentUpdate(registry, update) {
if (!update) return;
registry.recentUpdates = [
update,
...(registry.recentUpdates || []).filter((item) => !(
item.prototypeId === update.prototypeId && item.version === update.version
)),
].slice(0, MAX_RECENT_UPDATES);
}
function syncNavMenuAndDirectories(projectRoot, quiet) {
const script = path.join(projectRoot, 'scripts/sync-project-prototype-directory.mjs');
const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], {
cwd: projectRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
const err = result.stderr || result.stdout || 'unknown error';
throw new Error(`同步原型目录失败:${err}`);
}
if (!quiet && result.stdout?.trim()) {
console.log(result.stdout.trim());
}
}
function syncXllNavMenu(projectRoot, quiet) {
const script = path.join(projectRoot, 'scripts/sync-xll-nav-menu.mjs');
if (!fs.existsSync(script)) return false;
const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], {
cwd: projectRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
const err = result.stderr || result.stdout || 'unknown error';
throw new Error(`同步小羚羚导航失败:${err}`);
}
if (!quiet && result.stdout?.trim()) {
console.log(result.stdout.trim());
}
return true;
}
export function syncPrototypeNavRegistry(options = {}) {
const projectRoot = options.projectRoot || path.resolve(__dirname, '..');
const registryPath = path.join(projectRoot, REGISTRY_RELATIVE);
const sidebarItems = loadSidebarPrototypes(projectRoot);
const titleMap = collectNavPrototypeTitles(sidebarItems);
const registry = readJson(registryPath, createRegistrySkeleton()) || createRegistrySkeleton();
if (!options.skipDirectorySync) {
syncNavMenuAndDirectories(projectRoot, options.quiet);
syncXllNavMenu(projectRoot, options.quiet);
}
const targetIds = options.prototype
? [options.prototype]
: [...titleMap.keys()];
let bumped = 0;
for (const prototypeId of targetIds) {
if (prototypeId === NAV_PROTOTYPE_ID && options.prototype !== NAV_PROTOTYPE_ID) continue;
const title = titleMap.get(prototypeId) || prototypeId;
const prototypeDir = path.join(projectRoot, 'src/prototypes', prototypeId);
if (!fs.existsSync(prototypeDir)) {
log(`[skip] ${prototypeId}: 目录不存在`, options.quiet);
continue;
}
const { hash, files } = hashPrototypeDir(prototypeDir);
const result = upsertPrototypeRecord(registry, prototypeId, title, hash, files, {
note: options.note && options.prototype === prototypeId ? options.note : '',
});
if (result.changed) {
bumped += 1;
pushRecentUpdate(registry, result.entry);
log(`[version] ${prototypeId}: ${result.entry.version}${result.entry.summary}`, options.quiet);
}
}
registry.updatedAt = new Date().toISOString();
writeJson(registryPath, registry);
return { bumped, registryPath, prototypeCount: targetIds.length };
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
const result = syncPrototypeNavRegistry({
projectRoot,
prototype: args.prototype,
note: args.note,
quiet: args.quiet,
skipDirectorySync: args.skipDirectorySync,
});
log(`完成:检查 ${result.prototypeCount} 个原型,新增版本 ${result.bumped}`, args.quiet);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main();
}

View File

@@ -0,0 +1,130 @@
/**
* 从小羚羚「小程序」项目同步页面目录到 oneos 原型导航。
*
* 用法:
* node scripts/sync-xll-nav-menu.mjs
* node scripts/sync-xll-nav-menu.mjs --project-root /path/to/oneos1.2
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROUTE_TO_PAGE = {
todo: 'todo',
business: 'business',
map: 'map',
mine: 'mine',
audit: 'audit',
delivery: 'delivery',
inspection: 'inspection',
vehicle: 'vehicle',
thirdReturn: 'third-return',
'third-return': 'third-return',
replace: 'replace',
'audit-return': 'audit-return',
};
function parseArgs(argv) {
const args = { projectRoot: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--project-root' && argv[i + 1]) {
args.projectRoot = path.resolve(argv[++i].trim());
}
}
return args;
}
function readJson(filePath, fallback = null) {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function resolvePageId(route) {
const key = String(route || '').trim();
return ROUTE_TO_PAGE[key] || key;
}
function buildLinksFromFolder(folder) {
const links = [];
for (const child of folder.children || []) {
if (child?.type !== 'route' || !child.route) continue;
const pageId = resolvePageId(child.route);
links.push({
id: child.id || `xll-route-${pageId}`,
title: child.title || pageId,
pageId,
route: child.route,
});
}
return links;
}
export function syncXllNavMenu(projectRoot) {
const oneosRoot = projectRoot || path.resolve(__dirname, '..');
const sourceConfigPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-source.json');
const outputPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-menu.json');
const sourceConfig = readJson(sourceConfigPath);
if (!sourceConfig?.projectRoot) {
throw new Error(`缺少小羚羚同步配置:${sourceConfigPath}`);
}
const xllRoot = path.resolve(sourceConfig.projectRoot);
const annotationPath = path.join(xllRoot, sourceConfig.annotationSource);
const annotation = readJson(annotationPath);
if (!annotation) {
throw new Error(`无法读取小羚羚标注目录:${annotationPath}`);
}
const includeIds = new Set(sourceConfig.includeDirectoryIds || ['directory-main', 'directory-modules']);
const groups = [];
for (const node of annotation.directory?.nodes || []) {
if (node?.type !== 'folder' || !includeIds.has(node.id)) continue;
const links = buildLinksFromFolder(node);
if (!links.length) continue;
groups.push({
id: node.id,
title: node.title || '分组',
defaultExpanded: node.defaultExpanded !== false,
links,
});
}
const devInfoPath = path.join(xllRoot, sourceConfig.devServerInfo || '.axhub/make/.dev-server-info.json');
const devInfo = readJson(devInfoPath, {});
const runtimeOrigin = String(devInfo.origin || '').trim() || 'http://localhost:51721';
const prototypeId = sourceConfig.prototypeId || 'xll-miniapp';
const menu = {
version: 1,
updatedAt: new Date().toISOString(),
title: '小羚羚',
sectionId: 'folder-prototypes-xll-miniapp',
description: '氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。',
prototypeId,
runtimeOrigin,
hrefPrefix: `${runtimeOrigin.replace(/\/$/u, '')}/prototypes/${prototypeId}`,
groups,
};
writeJson(outputPath, menu);
return { outputPath, groupCount: groups.length, linkCount: groups.reduce((n, g) => n + g.links.length, 0) };
}
function main() {
const args = parseArgs(process.argv.slice(2));
const result = syncXllNavMenu(args.projectRoot);
console.log(`[sync] xll-nav-menu: ${result.groupCount} 个分组,${result.linkCount} 个页面入口`);
console.log(`[sync] 写入 ${result.outputPath}`);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main();
}

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
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 const COMPACT_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const;
export interface TablePaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
/** 每页条数下拉选项,默认 PAGE_SIZE_OPTIONS */
pageSizeOptions?: readonly number[];
}
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,
pageSizeOptions = PAGE_SIZE_OPTIONS,
}: TablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]);
const sizeOptions = pageSizeOptions.length > 0 ? pageSizeOptions : PAGE_SIZE_OPTIONS;
return (
<div className="vm-pagination" role="navigation" aria-label="表格分页">
<span className="vm-pagination-total">
<span className="vm-pagination-total-num tabular-nums">{total}</span>
</span>
{total > 0 && (
<>
<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>
<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="每页显示条数"
>
{sizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="vm-pagination-size-label"></span>
</label>
</>
)}
</div>
);
}

View File

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

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

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

View File

@@ -0,0 +1,179 @@
/**
* 弹窗 / 抽屉内 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 {
text-align: left;
font-variant-numeric: tabular-nums;
}
.ln-modal-detail-table .ant-table-thead > tr > th.ant-table-cell-align-right,
.ln-modal-detail-table .ant-table-tbody > tr > td.ant-table-cell-align-right,
.ln-modal-detail-table .ant-table-summary .ant-table-cell-align-right {
text-align: left !important;
}
/* 左侧冻结列(收款时间等) */
.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

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import { ConfigProvider, Layout, Menu, Typography } from 'antd';
import { defineHashPageRoute, useHashPage } from '../useHashPage';
import { clearHostPrototypeRouteInfo, defineHashPageRoute, useHashPage } from '../useHashPage';
import { ensureOneosWebLegacyGlobals } from './legacyGlobals';
import '../../resources/oneos-web-legacy/styles/oneos-app.css';
@@ -28,13 +28,79 @@ const oneosTheme = {
},
};
export function OneosWebLegacyShell({
function OneosWebLegacyPageLayout({
children,
moduleTitle,
pages,
activePageId,
onSelectPage,
}: {
children: React.ReactNode;
moduleTitle: string;
pages: OneosWebLegacyPage[];
activePageId?: string;
onSelectPage?: (pageId: string) => void;
}) {
const showMenu = pages.length > 1 && activePageId && onSelectPage;
return (
<ConfigProvider theme={oneosTheme}>
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
{showMenu ? (
<Layout.Sider
width={220}
theme="light"
style={{ borderRight: '1px solid #e5e7eb' }}
>
<div style={{ padding: '16px 16px 8px', fontWeight: 600 }}>{moduleTitle}</div>
<Menu
mode="inline"
selectedKeys={[activePageId]}
items={pages.map((item) => ({
key: item.id,
label: item.title,
onClick: () => onSelectPage(item.id),
}))}
/>
</Layout.Sider>
) : null}
<Layout.Content style={{ minHeight: '100vh', overflow: 'auto' }}>
{children}
</Layout.Content>
</Layout>
</ConfigProvider>
);
}
function OneosWebLegacySinglePageShell({ pages }: OneosWebLegacyShellProps) {
const active = pages[0];
useEffect(() => {
clearHostPrototypeRouteInfo();
}, []);
if (!active) {
return (
<Typography.Text type="secondary">
</Typography.Text>
);
}
const ActivePage = active.component;
return (
<OneosWebLegacyPageLayout moduleTitle="" pages={pages}>
<ActivePage />
</OneosWebLegacyPageLayout>
);
}
function OneosWebLegacyMultiPageShell({
moduleTitle,
pages,
defaultPageId,
}: OneosWebLegacyShellProps) {
ensureOneosWebLegacyGlobals();
const route = useMemo(
() => defineHashPageRoute(
pages.map((page) => ({ id: page.id, title: page.title })),
@@ -54,33 +120,25 @@ export function OneosWebLegacyShell({
}
const ActivePage = active.component;
const showMenu = pages.length > 1;
return (
<ConfigProvider theme={oneosTheme}>
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
{showMenu ? (
<Layout.Sider
width={220}
theme="light"
style={{ borderRight: '1px solid #e5e7eb' }}
>
<div style={{ padding: '16px 16px 8px', fontWeight: 600 }}>{moduleTitle}</div>
<Menu
mode="inline"
selectedKeys={[active.id]}
items={pages.map((item) => ({
key: item.id,
label: item.title,
onClick: () => setPage(item.id),
}))}
/>
</Layout.Sider>
) : null}
<Layout.Content style={{ minHeight: '100vh', overflow: 'auto' }}>
<ActivePage />
</Layout.Content>
</Layout>
</ConfigProvider>
<OneosWebLegacyPageLayout
moduleTitle={moduleTitle}
pages={pages}
activePageId={active.id}
onSelectPage={setPage}
>
<ActivePage />
</OneosWebLegacyPageLayout>
);
}
export function OneosWebLegacyShell(props: OneosWebLegacyShellProps) {
ensureOneosWebLegacyGlobals();
if (props.pages.length <= 1) {
return <OneosWebLegacySinglePageShell {...props} />;
}
return <OneosWebLegacyMultiPageShell {...props} />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
/**
* 品牌·型号主数据:优先来自车辆管理种子数据,并与租赁合同演示枚举合并。
*/
import vehicles from '../prototypes/vehicle-management/data/vehicles.json';
/** 租赁合同/条件条款演示用补充车型(车辆台账中可能尚未收录) */
var LEASE_DEMO_EXTRA_CATALOG = [
{ brand: '现代', models: ['18吨氢燃料电池车', '9.6米厢式货车', '帕力安牌4.5吨冷链车'] },
{ brand: '苏龙', models: ['9.6米氢燃料电池车', '海格牌18吨双飞翼货车'] },
{ brand: '飞驰', models: ['集卡头', '半挂车'] },
{ brand: '跃进', models: ['4.2米冷链车', '4.5吨冷链车'] },
{ brand: '宇通', models: ['氢燃料电池客车'] },
];
function mergeCatalogs() {
var map = new Map();
var sources = arguments;
for (var s = 0; s < sources.length; s++) {
var list = sources[s] || [];
list.forEach(function (item) {
if (!item || !item.brand) return;
if (!map.has(item.brand)) map.set(item.brand, new Set());
var models = item.models || [];
models.forEach(function (model) {
if (model) map.get(item.brand).add(model);
});
});
}
return Array.from(map.entries())
.map(function (entry) {
return {
brand: entry[0],
models: Array.from(entry[1]).sort(function (a, b) {
return String(a).localeCompare(String(b), 'zh-CN');
}),
};
})
.sort(function (a, b) {
return a.brand.localeCompare(b.brand, 'zh-CN');
});
}
function buildFromVehicleManagement() {
var map = new Map();
(vehicles || []).forEach(function (row) {
if (!row || !row.brand || !row.model) return;
if (!map.has(row.brand)) map.set(row.brand, new Set());
map.get(row.brand).add(row.model);
});
return Array.from(map.entries()).map(function (entry) {
return {
brand: entry[0],
models: Array.from(entry[1]).sort(function (a, b) {
return String(a).localeCompare(String(b), 'zh-CN');
}),
};
});
}
var cachedCatalog = null;
export function getVehicleBrandModelCatalog() {
if (!cachedCatalog) {
cachedCatalog = mergeCatalogs(buildFromVehicleManagement(), LEASE_DEMO_EXTRA_CATALOG);
}
return cachedCatalog;
}

View File

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

118
src/common/vm-checkbox.css Normal file
View File

@@ -0,0 +1,118 @@
/**
* ONE-OS vm-page 多选框 — 全局样式
* 规范src/prototypes/vm-shared/DESIGN.md
*/
.vm-checkbox,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
display: inline-grid;
place-content: center;
width: 18px;
height: 18px;
margin: 0;
flex-shrink: 0;
border: 1.5px solid var(--ln-hairline-strong);
border-radius: 6px;
background-color: var(--ln-surface-card);
background-repeat: no-repeat;
background-position: center;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.05);
cursor: pointer;
vertical-align: middle;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.14s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.vm-checkbox:hover:not(:disabled),
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--ln-primary) 55%, var(--ln-hairline-strong));
box-shadow:
inset 0 1px 2px rgba(15, 23, 42, 0.04),
0 0 0 3px color-mix(in srgb, var(--ln-primary) 14%, transparent);
}
.vm-checkbox:active:not(:disabled),
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:active:not(:disabled) {
transform: scale(0.92);
}
.vm-checkbox:checked,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:checked {
border-color: var(--ln-primary-focus);
background-color: var(--ln-primary);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.75 8.25 6.5 11 12.25 5'/%3E%3C/svg%3E");
background-size: 13px 13px;
box-shadow:
0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.vm-checkbox:indeterminate,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:indeterminate {
border-color: var(--ln-primary-focus);
background-color: var(--ln-primary);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round'%3E%3Cpath d='M4.25 8h7.5'/%3E%3C/svg%3E");
background-size: 13px 13px;
box-shadow:
0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.vm-checkbox:focus-visible,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:focus-visible {
outline: none;
box-shadow:
inset 0 1px 2px rgba(15, 23, 42, 0.04),
0 0 0 3px color-mix(in srgb, var(--ln-primary) 28%, transparent);
}
.vm-checkbox:checked:focus-visible,
.vm-checkbox:indeterminate:focus-visible,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:checked:focus-visible,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:indeterminate:focus-visible {
box-shadow:
0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent),
0 0 0 3px color-mix(in srgb, var(--ln-primary) 28%, transparent);
}
.vm-checkbox:disabled,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 表格勾选列 */
.vm-col-check,
.ldb-col-check,
.cm-col-check {
width: 48px;
text-align: center;
vertical-align: middle;
}
.vm-col-check .vm-checkbox,
.ldb-col-check .vm-checkbox,
.cm-col-check .vm-checkbox,
.vm-col-check > input[type="checkbox"],
.ldb-col-check > input[type="checkbox"],
.cm-col-check > input[type="checkbox"] {
display: inline-grid;
}
@media (prefers-reduced-motion: reduce) {
.vm-checkbox,
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"] {
transition: none;
}
.vm-checkbox:active:not(:disabled),
.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:active:not(:disabled) {
transform: none;
}
}

View File

@@ -0,0 +1,29 @@
/**
* 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致)
*
* 规则:筛选项超过 1 行(默认 4 项)时,其余收入折叠区,通过「更多筛选」展开。
*/
export const VM_FILTER_COLUMNS_PER_ROW = 4;
/** 默认首行展示的筛选项数量1 行) */
export const VM_FILTER_PRIMARY_VISIBLE_COUNT = VM_FILTER_COLUMNS_PER_ROW;
export function splitFilterFields<T>(
fields: T[],
primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT,
): { primary: T[]; extra: T[] } {
if (fields.length <= primaryCount) {
return { primary: fields, extra: [] };
}
return {
primary: fields.slice(0, primaryCount),
extra: fields.slice(primaryCount),
};
}
export function shouldShowFilterExpand(
fieldCount: number,
primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT,
): boolean {
return fieldCount > primaryCount;
}

View File

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

View File

@@ -0,0 +1,181 @@
/**
* ONE-OS vm-page 列表分页 — 全局样式
* 规范src/prototypes/vm-shared/DESIGN.md
* 组件src/common/TablePagination.tsx
* 元素顺序:共 x 条 → 页码 → 每页 N 条
*/
.vm-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
width: auto;
flex: 0 1 auto;
margin-left: auto;
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-total {
font-size: 0.875rem;
color: var(--ln-muted);
white-space: nowrap;
flex-shrink: 0;
}
.vm-pagination-total-num {
color: var(--ln-ink);
font-weight: 600;
}
.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;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease;
}
.vm-pagination-nav:disabled {
opacity: 0.4;
cursor: not-allowed;
background: var(--ln-canvas-soft);
}
.vm-pagination-nav:hover:not(:disabled),
.vm-pagination-page:hover:not(.active) {
background: var(--ln-canvas-soft);
border-color: var(--ln-primary);
color: var(--ln-primary-focus);
}
.vm-pagination-nav:active:not(:disabled),
.vm-pagination-page:active:not(.active) {
transform: scale(0.96);
}
.vm-pagination-page.active {
background: var(--ln-primary);
border-color: var(--ln-primary);
color: var(--ln-on-primary);
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.28);
}
.vm-pagination-nav:focus-visible,
.vm-pagination-page:focus-visible {
outline: none;
border-color: var(--vm-focus-border-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
}
.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;
letter-spacing: 0.08em;
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.vm-pagination-nav,
.vm-pagination-page {
transition: none;
}
.vm-pagination-nav:active:not(:disabled),
.vm-pagination-page:active:not(.active) {
transform: none;
}
}
/* 详情 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;
}
.vm-table-footer--compact .vm-pagination-total {
font-size: 0.8125rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
/**
* @name 业务部台账
* 自 ONE-OS Web 端 / 数据分析 / 业务部台账 独立预览OneOS 菜单入口)
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import '../vehicle-management/style.css';
import '../ledger-shared/styles/ledger-page.css';
import './styles/index.css';
import React, { useEffect } from 'react';
import * as antd from 'antd';
import dayjs from 'dayjs';
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;
window.dayjs = dayjs;
export default function BusinessDeptLedger() {
useEffect(() => {
clearHostPrototypeRouteInfo();
}, []);
return (
<ConfigProvider>
<BusinessDeptLedgerPage />
</ConfigProvider>
);
}

View File

@@ -0,0 +1,438 @@
/**
* 业务部台账 — vm-page + bdl-page
* 列表壳层对齐租赁合同管理lc-page、台账列表ldb-page
*/
.vm-page.bdl-page {
padding-bottom: 48px;
}
.vm-page.bdl-page.bdl-page--list-dense .vm-filter-card {
padding: 16px 20px 20px;
margin-bottom: 16px;
}
.vm-page.bdl-page.bdl-page--list-dense .vm-filter-header {
margin-bottom: 12px;
}
.vm-page.bdl-page.bdl-page--list-dense .vm-filter-title {
font-size: 1rem;
}
.vm-page.bdl-page .vm-filter-card.ldb-filter-card {
margin-bottom: 16px;
overflow: visible;
}
.vm-page.bdl-page .vm-filter-grid.ldb-filter-grid--primary {
margin-bottom: 0;
}
.vm-page.bdl-page .vm-filter-field .ant-select,
.vm-page.bdl-page .vm-filter-field .ant-picker {
width: 100%;
}
.vm-page.bdl-page .vm-filter-field .ant-select .ant-select-selector,
.vm-page.bdl-page .vm-filter-field .ant-picker {
min-height: var(--vm-control-height);
height: var(--vm-control-height);
border-radius: var(--ln-radius-sm);
border-color: var(--ln-hairline-strong);
font-size: 13px;
}
.vm-page.bdl-page .vm-filter-field .ant-select-multiple .ant-select-selector {
min-height: var(--vm-control-height);
height: auto;
padding-block: 2px;
}
.vm-page.bdl-page .bdl-filter-radio.ant-radio-group {
display: flex;
width: 100%;
gap: 0;
}
.vm-page.bdl-page .bdl-filter-radio .ant-radio-button-wrapper {
flex: 1 1 0;
min-width: 0;
height: var(--vm-control-height);
line-height: calc(var(--vm-control-height) - 2px);
padding-inline: 10px;
text-align: center;
font-size: 13px;
}
.vm-page.bdl-page .vm-filter-actions.ldb-filter-actions {
margin-top: 16px;
}
.vm-page.bdl-page .vm-table-section {
min-width: 0;
max-width: 100%;
}
.vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 16px;
min-height: var(--vm-btn-height);
padding: 0 0 16px;
}
.vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar .vm-table-actions {
margin-left: auto;
}
.vm-page.bdl-page .bdl-report-meta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.vm-page.bdl-page .bdl-report-caption {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--ln-ink);
letter-spacing: -0.01em;
}
.vm-page.bdl-page .bdl-report-scope {
margin: 0;
font-size: 13px;
color: var(--ln-muted);
line-height: 1.5;
}
.vm-page.bdl-page .bdl-report-hint {
margin: 0;
font-size: 12px;
color: var(--ln-muted-soft);
line-height: 1.45;
}
/* 列表宽表:横向滚动交给 Ant Table外层不抢滚动上下文对齐 lc-page */
.vm-page.bdl-page .vm-table-card {
overflow: visible;
}
.vm-page.bdl-page .vm-table-card > .vm-table-wrap {
overflow: hidden;
max-height: none;
min-width: 0;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
background: var(--ln-surface-card);
}
.vm-page.bdl-page .bdl-list-table.ant-table-wrapper,
.vm-page.bdl-page .bdl-list-table .ant-table-wrapper {
min-width: 0;
max-width: 100%;
}
/* —— 表格边框与表头(使用 Linear token避免无效变量 —— */
.vm-page.bdl-page .bdl-list-table .ant-table-container {
border-radius: var(--ln-radius-card);
border: none;
}
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-container {
border: none;
}
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
white-space: nowrap;
font-variant-numeric: tabular-nums;
padding: 8px 12px !important;
font-size: 13px;
border-color: var(--ln-hairline) !important;
box-sizing: border-box;
overflow: visible;
text-overflow: clip;
}
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-thead > tr > th,
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
.vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-summary > tr > td {
border-inline-end: 1px solid var(--ln-hairline) !important;
border-bottom: 1px solid var(--ln-hairline) !important;
}
/* 分组表:第一行业务板块 */
.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:first-child > th {
background: var(--ln-surface-strong) !important;
color: var(--ln-ink) !important;
font-size: 13px !important;
font-weight: 600 !important;
text-align: center;
border-bottom: 2px solid var(--ln-hairline-strong) !important;
}
/* 分组表:第二行 业绩/成本/盈亏 */
.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:nth-child(2) > th {
background: var(--ln-canvas-soft) !important;
color: var(--ln-muted) !important;
font-size: 12px !important;
font-weight: 500 !important;
text-align: center;
}
/* 单层表头(按客户视图等) */
.vm-page.bdl-page .bdl-list-table:not(.bdl-list-table--grouped) .ant-table-thead > tr > th {
background: var(--ln-canvas-soft) !important;
color: var(--ln-muted) !important;
font-weight: 500 !important;
font-size: 12px !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
color: var(--ln-body);
background: var(--ln-surface-card);
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td {
background: var(--ln-canvas-soft) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td,
.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
font-weight: 700;
background: var(--ln-surface-strong) !important;
color: var(--ln-ink) !important;
border-top: 2px solid var(--ln-hairline-strong) !important;
}
/* 冻结列(对齐 lc-list-table--nested */
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-start,
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-left {
background: var(--ln-surface-card) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.ant-table-cell-fix-start,
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.ant-table-cell-fix-left {
z-index: 3;
background: var(--ln-canvas-soft) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td.ant-table-cell-fix-start,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td.ant-table-cell-fix-left {
z-index: 2;
background: var(--ln-surface-card) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-start,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-left {
background: var(--ln-canvas-soft) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-start,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-left,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-end,
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.ant-table-cell-fix-right {
background: var(--ln-surface-strong) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-end:not(.ant-table-cell-scrollbar),
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) {
background: var(--ln-surface-card) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar),
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) {
z-index: 4;
background: var(--ln-canvas-soft) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar),
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) {
z-index: 3;
background: var(--ln-surface-card) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar),
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) {
background: var(--ln-canvas-soft) !important;
}
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-left-last::after {
box-shadow: inset 10px 0 8px -8px rgba(15, 23, 42, 0.12);
}
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-right-last::after,
.vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-end-shadow-show:not(.ant-table-cell-scrollbar)::after {
box-shadow: inset -10px 0 8px -8px rgba(15, 23, 42, 0.12);
}
/* 纵向滚动条占位缝:防止表头/表体右侧冻结列错位(对齐 lc-list-table--nested */
.vm-page.bdl-page .bdl-list-table--nested .ant-table-thead > tr > th.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar)::before,
.vm-page.bdl-page .bdl-list-table--nested .ant-table-tbody > tr > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar)::before,
.vm-page.bdl-page .bdl-list-table--nested .ant-table-thead > tr > th.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar)::before,
.vm-page.bdl-page .bdl-list-table--nested .ant-table-tbody > tr > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar)::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: -17px;
width: 17px;
background: inherit;
pointer-events: none;
}
.vm-page.bdl-page .bdl-list-table--nested .ant-table-cell-scrollbar {
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
padding: 0 !important;
border: none !important;
visibility: hidden;
}
.vm-page.bdl-page .bdl-list-table--nested .ant-table-container {
overflow: hidden;
}
.vm-page.bdl-page .bdl-list-table--nested .ant-table-content {
overflow: auto hidden;
}
/* 金额列最小宽度,避免合计行大额被裁切 */
.vm-page.bdl-page .bdl-list-table .col-amount,
.vm-page.bdl-page .bdl-list-table .col-summary {
min-width: 128px;
}
.vm-page.bdl-page .bdl-list-table .col-summary {
min-width: 148px;
}
.vm-page.bdl-page .bdl-list-table .bdl-amount-cell {
display: inline-block;
max-width: 100%;
text-align: right;
}
.vm-page.bdl-page .bdl-list-table .biz-standbook-perf-link,
.vm-page.bdl-page .biz-standbook-perf-link {
cursor: pointer;
color: var(--ln-primary);
padding: 0;
margin: 0;
border: none;
background: transparent;
font: inherit;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
max-width: none;
overflow: visible;
text-align: inherit;
white-space: nowrap;
}
/* 钻取子页表格:复用主表边框规范 */
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-thead > tr > th,
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td,
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-summary > tr > td {
border-color: var(--ln-hairline) !important;
border-inline-end: 1px solid var(--ln-hairline) !important;
border-bottom: 1px solid var(--ln-hairline) !important;
padding: 8px 10px !important;
font-size: 13px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table .ant-table-thead > tr > th {
background: var(--ln-canvas-soft) !important;
color: var(--ln-muted) !important;
font-weight: 500 !important;
}
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table-wrap,
.vm-page.bdl-page .bdl-drill-card .biz-standbook-table {
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
overflow: hidden;
}
.vm-page.bdl-page .biz-standbook-perf-link:hover {
color: var(--ln-primary-focus);
background: var(--ln-primary-soft);
}
.vm-page.bdl-page .biz-standbook-perf-link:focus {
outline: 2px solid var(--ln-primary);
outline-offset: 2px;
}
.vm-page.bdl-page .bdl-amount-neg {
color: #b91c1c;
font-weight: 600;
}
/* 钻取子页 */
.vm-page.bdl-page .bdl-drill-card {
padding: 16px 20px 20px;
}
.vm-page.bdl-page .bdl-drill-section {
margin-top: 0;
}
.vm-page.bdl-page .bdl-drill-toolbar {
align-items: center;
padding-bottom: 16px;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px 16px;
}
.vm-page.bdl-page .bdl-drill-toolbar-spacer {
width: 88px;
}
.vm-page.bdl-page .bdl-drill-toolbar--inline {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
padding-bottom: 16px;
}
.vm-page.bdl-page .bdl-drill-toolbar--inline .bdl-drill-title {
margin: 0;
flex: 1;
min-width: 0;
}
.vm-page.bdl-page .bdl-drill-heading {
flex: 1;
text-align: center;
padding: 0 12px;
}
.vm-page.bdl-page .bdl-drill-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--ln-ink);
}
.vm-page.bdl-page .bdl-drill-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ln-muted);
}
@media (prefers-reduced-motion: reduce) {
.vm-page.bdl-page .biz-standbook-perf-link {
transition: none;
}
}

View File

@@ -2,53 +2,107 @@
- 审查目标:`src/prototypes/contract-template-management`
- 用户资料/参考资料:
- 本轮用户任务:处理评审结论中 P1P3 共 5 条问题
- 本轮用户任务:对「合同模板管理」原型执行 Prototype Review / 需求评审
- `src/resources/contract-template-management/PRD.md`v1.2
- `src/prototypes/contract-template-management/annotation-source.json`
- `contract-template-seed.js``contract-template-section-html.js``contract-template-catalog.js``contract-template-store.js`
- 关联下游:`lease-contract-preview-build.js``LeaseContractCreate.jsx`
- 生成时间2026-06-29复审
- `src/prototypes/contract-template-management/index.tsx``ContractTemplate.jsx`
- `contract-template-store.js``contract-template-catalog.js``contract-template-section-html.js``contract-template-section-filter.js``contract-template-seed.js`
- 下游消费:`lease-contract-management/lease-contract-preview-build.js``LeaseContractCreate.jsx`
- `annotation-source.json``.spec/prototype-comments.json`
- 生成时间2026-07-07
## 总体点评
上一轮评审中的 **5 条优先级问题均已落地**。合同模板管理原型现已具备:
合同模板管理原型在**主业务链路上已基本成立**:列表筛选与嵌套章节子表、草稿/发布/启用停用、版本日志与快照查看、编辑页 9 章节 Tab、锁定/风控/条件条款工具、离开未保存确认,以及经 `contract-template-store` 向租赁合同创建贯通预览,均能在同浏览器 `localStorage` 会话内端到端演示。这与 PRD v1.2 对「Word 文档级模型 + 启用态控制 + 跨模块消费」的定位一致。
- 租赁创建预览优先消费 store 中 `sectionContents`(静态 HTML 仅兜底)
- 冷启动种子:直接进入租赁创建即可选用已启用模板
- PRD / 标注与实现对齐(`contractType`、列表筛选、子表列、章节 Tab 交互)
- `localStorage` 持久化(兼写 `sessionStorage`
- 移除 `isDefault` /「标准模板」残留逻辑
当前最影响「按真实法务作业验收」的缺口集中在 **Word 导入仍为文件名驱动的 Mock**,无法证明 PRD §9 对任意 `.doc/.docx` 的拆分策略;其次是 **标注/内嵌 PRD 与 v1.2 字段口径仍有残留不一致**,以及租赁预览侧 **条件条款存在模板 HTML 与硬编码注入双轨**。权限、存储失败、正式 API 与异常分支属原型已知边界,应在研发阶段补齐,但不阻断当前演示型原型价值。
原型可作为研发与测试的**端到端演示基线**;正式环境仍需 API 持久化、权限与异常分支。
## P0-P3 优先级问题
## P0-P3 优先级问题(复审)
### P1 - Word 导入未实现真实解析与章节拆分
| 级别 | 问题 | 状态 |
|---|---|---|
| P1 | 租赁预览未消费 store 章节正文 | **已修复**`getContractTemplateBaseHtml` 优先 `mergeMonolithicHtml(doc.sectionContents)` |
| P1 | PRD / 标注与实现字段口径不一致 | **已修复** — PRD v1.2 + `build-annotation-source.mjs` 同步 |
| P1 | 冷启动租赁创建无模板 | **已修复**`catalog.js` 调用 `ensureContractTemplateStore(buildInitialWordTemplates)` |
| P2 | 仅 sessionStorage 持久化 | **已缓解** — store 读写 `localStorage` 优先 |
| P3 | `isDefault` / 标准模板残留 | **已修复** — 种子、store 默认逻辑、编辑区文案已清理 |
- 证据:`ContractTemplate.jsx``handleImportWord` 仅调用 `mockWordBlocks(file.name, categoryFilter)`,按文件名关键字返回内置 `v266LeaseBlocks` / 试用合同块或通用占位块,未读取上传文件内容,也未调用 PRD §9 所述 `sanitizeWordExportHtml` + 标记切分流程。
- 影响:法务无法在本原型中验证「上传任意 Word → 自动拆成 9 章」这一核心维护路径;验收标准 §16.2「导入 Word 后 9 章节正确拆分」仅对少数种子文件名成立,与 PRD 表述的业务成立条件存在明显差距。
- 修复方向:原型层至少接入 `.docx → HTML` 解析(或明确标注为演示 Mock 并下调 PRD 验收项);研发按 §9.2§9.4 实现服务端/前端拆分,并在导入失败时给出可恢复提示(重试、保留原稿、查看错误原因)。
### P2 - 标注与内嵌 PRD 仍使用「合同名称」,与 v1.2「合同类型」冲突
- 证据:`annotation-source.json` 列表筛选节点 `ct-list-filter``annotationText``markdownMap.ct-list-filter` 仍写「合同名称 / 选项来自模板名称」;而 `PRD.md` v1.2 §7.2、§7.4 与实现中筛选字段 `keyword`、列表列 `contractType`、编辑区「合同类型」均已统一为 **合同类型**
- 影响:评审、测试与业务方按标注操作时会对筛选对象产生误解,造成需求口径分裂;与 PRD §16.4「标注与 PRD 一致」未完全满足。
- 修复方向:运行或更新 `scripts/build-annotation-source.mjs`,将筛选说明改为「合同类型(可搜索下拉,精确匹配 `contractType`)」;同步目录内嵌 PRD 片段版本号至 v1.2。
### P2 - 租赁预览条件条款存在「模板 HTML」与硬编码注入双轨
- 证据:`lease-contract-preview-build.js` 在读取 store 合并 HTML 后仍调用 `injectPrototypeVehicleClauses`;该函数在 `lease-contract-vehicle-clause.js` 中按 `PROTOTYPE_VEHICLE_CLAUSE_DEFS` 与固定 `paragraphMarker` 包裹段落,注释写明「正式环境由模板管理产出」。模板管理侧条件条款则通过编辑区「条件」写入 `data-vehicle-clause` / `data-vehicle-bindings`
- 影响:法务在模板管理中新设或调整条件条款后,租赁预览对标准 V26.6 类合同可能仍部分依赖硬编码注入点;与 PRD「创建合同时按订单车辆过滤条件条款」的单一数据源目标不完全一致增加联调与回归风险。
- 修复方向:租赁预览改为**仅**解析模板 HTML 中的条件条款标记;硬编码注入降级为种子数据初始化脚本或 feature flag并在跨模块验收用例中覆盖「仅改模板、不改租赁代码」场景。
### P2 - 车型主数据未与车辆管理模块打通
- 证据PRD §11 要求品牌·型号与车辆管理一致;实现中 `ContractTemplate.jsx` / `VehicleBrandModelPicker.jsx` 使用 `LEASE_VEHICLE_BRAND_MODEL_CATALOG``lease-order-vars.js` 内置枚举),未读取 `vehicle-management` 主数据或接口。
- 影响:章节「适用车型」绑定与预览模拟车型的选项集可能与正式车辆台账漂移;绑定存的是字符串,正式环境若车型更名会导致历史模板命中失败。
- 修复方向:原型可保留内置枚举但需在 PRD/标注标明;研发对接车辆主数据 API并约定品牌型号唯一键与变更策略。
### P3 - 权限边界与存储异常未在原型中表达
- 证据PRD §13 建议法务可编辑/发布、客服只读等角色能力;原型无角色切换或操作拦截。`contract-template-store.js``writeToStorage``localStorage` 失败时静默忽略,无用户提示或降级策略。
- 影响:无法演示「启用中不可编辑」之外的组织级权限;极端环境下用户以为已保存但实际未持久化,造成跨页面数据丢失误解。
- 修复方向:原型可增加「法务 / 客服只读」演示开关;存储失败时 `message.error` 并阻止离开编辑页;正式环境对接权限服务与后端持久化。
## 完整性与项目对齐
| 维度 | 评估 |
| 维度 | 结论 |
|---|---|
| 跨模块正文 | 模板管理编辑 ↔ 租赁创建预览:**已贯通**(同会话 / localStorage 内) |
| 冷启动 | 无需先打开模板管理:**已支持** |
| 资料一致性 | PRD、标注、列表筛选/列、子表、§8.3 Tab**已对齐** |
| 正式环境差距 | API 持久化、角色权限、Word 解析失败等异常分支仍待研发 |
| 核心用户 | 法务为主、客服/风控间接用户 — 与 PRD §2 一致 |
| 主流程 | 列表 → 新增/编辑 → 保存/发布 → 启用/停用 → 租赁创建选用 — **可演示** |
| 入口/出口 | `index.tsx` + `ContractTemplate.jsx` 列表/编辑双视图;`navigationRef` 与标注目录可切换 — 对齐 |
| 范围边界 | 仅 `lease` 分类业务闭环;`purchase`/`service` 代码预留未暴露 — 符合非目标 |
| 跨模块 | `contract-template-catalog.js` 冷启动种子、`getContractTemplateBaseHtml` 优先 store — 与 PRD §12、§16.3 对齐 |
| 资料冲突 | **待确认**`annotation-source.json` 筛选字段仍称「合同名称」,与 PRD v1.2 / 源码「合同类型」冲突(见 P2 |
| 项目 metadata | 路由 `/prototypes/contract-template-management`;与 `lease-contract-management` PRD 关联描述一致 |
## 验收建议(请用户在预览中确认)
**已覆盖且与 PRD 一致的主要能力(摘要)**:嵌套列表 9 章节子表及风控/条件条款计数;启用中锁定编辑删除、停用二次确认;发布递增版本号、已发布再发布写 `versionLogs` 且默认停用;合同主体非空与合同类型校验;未保存离开确认;版本快照只读查看;附件章节车型绑定与租赁侧 `applyAttachmentSectionVehicleFilter` 联动。
1. **租赁预览贯通**:在模板管理编辑某章节并保存 → 打开租赁创建 → 选用同一模板 → 预览正文应反映修改。
2. **冷启动**:清空站点 localStorage 后**直接**打开租赁创建 → 应能选择已启用模板(如「正式合同」「试用合同」)。
3. **列表筛选**:列表页「合同类型」筛选与主表「合同类型」列一致。
4. **持久化**:刷新浏览器后模板停用/发布状态应保留localStorage
## 业务逻辑连贯性
## 修订记录
- **状态迁移**:草稿(无版本号)→ 发布(有版本号、默认启用)→ 停用(可编辑)→ 再发布(版本递增、写快照、默认停用)— 与 PRD §6.2§6.3 一致,源码 `handleSaveTemplate` / `toggleTemplateEnabled` 可印证。
- **启用与下游**`getPublishedEnabledLeaseOptions` 过滤 `published && enabled`;停用后 `LeaseContractCreate` 订阅 store 更新选项 — 连贯。
- **版本号生成**`generateNextVersionNo``category === lease` 下全部已发布模板取最大 minor 递增 — 符合 PRD §6.1「同分类下递增」字面规则;若业务期望「每份 Word 文件独立版本序列」,需产品另行确认(当前无冲突证据,记为待确认项)。
- **条件条款命中**:模板编辑预览与租赁预览均遵循「订单车辆任意命中则展示」;模板管理预览未选模拟车型时隐藏条件段落,与 PRD §8.7.3 一致。
- **数据生命周期**`creator/updater/createTime/updateTime` 在保存时更新;删除仅草稿或已发布且停用 — 符合 §6.5。
| 日期 | 说明 |
## 状态、异常、边界与恢复
| 类别 | 审查结论 |
|---|---|
| 2026-06-29 | 复审5 条评审问题全部处理完毕 |
| 输入校验 | 保存/发布校验合同主体非空、合同类型必填;条件条款弹窗要求至少一个车型 — 已覆盖 |
| 空状态 | 列表无数据、筛选无结果、版本日志为空、编辑区无内容预览引导 — 已覆盖 |
| 操作确认 | 停用、删除、未保存离开 — 有 Modal 确认 |
| 权限失败 | 未实现角色权限 — 见 P3 |
| 导入失败 | 非真实 Word 解析,无格式错误/拆分失败分支 — 见 P1 |
| 存储失败 | `localStorage` 写入失败静默 — 见 P3 |
| 并发/重复点击 | 保存无 loading 锁,快速连点可能重复写入(原型风险低)— P3 |
| 跨页面恢复 | 刷新后 `localStorage` 恢复模板;清空存储后 `ensureContractTemplateStore` 重新种子 — 可恢复 |
| 下游一致性 | 停用模板后租赁侧选项实时收缩;预览读 store `sectionContents` — 已检查通过 |
## 证据与评估说明
- **用户资料优先级**:本轮无额外用户附件;以 PRD v1.2 为业务基线,与 `annotation-source.json` 冲突处已标「待确认」。
- **读取范围**
- 规范:`rules/prototype-review-guide.md`
- 资料:`src/resources/contract-template-management/PRD.md`
- 源码:`index.tsx``ContractTemplate.jsx`(列表/编辑/保存发布/导入/版本日志)、`contract-template-store.js``contract-template-catalog.js``contract-template-section-filter.js`
- 下游:`lease-contract-preview-build.js``lease-contract-vehicle-clause.js``LeaseContractCreate.jsx`
- 标注:`annotation-source.json`;未展开阅读 `canvas.excalidraw` 全量
- **独立评估**`degraded`(未使用子代理分拆「业务基线」与「原型证据」两轮独立评估,在同一审查中顺序完成)
---
## 修复记录2026-07-07 后续)
| 优先级 | 问题 | 状态 | 说明 |
|---|---|---|---|
| P1 | Word 导入 Mock | **已修复** | `contract-template-word-import.js` + mammoth 解析 `.docx`;失败回退演示模板并提示 |
| P2 | 标注「合同名称」口径 | **已修复** | `build-annotation-source.mjs` 同步 `annotationText` / `annotationsByElementId` / `markdownMap` |
| P2 | 租赁预览条件条款双轨 | **已修复** | `injectPrototypeVehicleClauses` 在 HTML 已有条件标记时跳过 |
| P2 | 车型主数据未打通 | **已修复(原型)** | `src/common/vehicle-brand-model-catalog.js` 读取车辆管理种子并合并演示车型 |
| P3 | 权限与存储异常 | **已修复(原型)** | `?role=cs` 只读演示;存储失败 `message.error` 且阻止离开编辑页 |

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,32 @@
/**
* docx-preview 高保真 Word 渲染(预览区)
* 使用动态 import避免阻塞原型入口模块加载。
*/
var DEFAULT_RENDER_OPTIONS = {
className: 'ct-docx-preview',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
trimXmlDeclaration: true,
useBase64URL: true,
};
export async function renderDocxPreview(container, arrayBuffer, options) {
if (!container || !arrayBuffer) return;
container.innerHTML = '';
var mod = await import('docx-preview');
var renderAsync = mod.renderAsync;
if (typeof renderAsync !== 'function') {
throw new Error('docx-preview 模块未正确加载');
}
await renderAsync(
arrayBuffer,
container,
null,
Object.assign({}, DEFAULT_RENDER_OPTIONS, options || {})
);
}

View File

@@ -93,3 +93,24 @@ export function applyAttachmentSectionVehicleFilter(html, sectionVehicleBindings
}
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

@@ -0,0 +1,173 @@
/**
* 从 Word HTML 中按「标题」段落提取章节 Tab并拆分/合并章节内容。
*/
import { DOCUMENT_SECTION_TABS } from './contract-template-section-html.js';
var DEFAULT_MAIN_TAB = { key: 'main', label: '合同主体' };
var HEADING_TAGS = { H1: true, H2: true, H3: true, H4: true, H5: true, H6: true };
export function getDefaultSectionTabs() {
return DOCUMENT_SECTION_TABS.slice();
}
export function emptySectionMapForTabs(tabs) {
var map = {};
(tabs || []).forEach(function (tab) {
map[tab.key] = '';
});
return map;
}
function normalizeHeadingText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function slugifySectionKey(label, index) {
var slug = String(label || '')
.toLowerCase()
.replace(/[^\u4e00-\u9fa5a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 24);
return slug ? 'section_' + slug + '_' + index : 'section_' + index;
}
function isSectionTitleElement(node) {
if (!node || node.nodeType !== 1) return false;
if (!node.classList || !node.classList.contains('ct-word-section-title')) return false;
if (node.closest && node.closest('table')) return false;
var text = normalizeHeadingText(node.textContent);
return text.length > 0 && text.length <= 80;
}
function isHeadingElement(node) {
if (!node || node.nodeType !== 1) return false;
if (isSectionTitleElement(node)) return true;
if (!HEADING_TAGS[node.nodeName]) return false;
if (node.closest && node.closest('table')) return false;
var text = normalizeHeadingText(node.textContent);
return text.length > 0 && text.length <= 80;
}
function collectHeadingElements(root) {
var headings = [];
if (!root) return headings;
var seen = new Set();
function push(node) {
if (!node || seen.has(node)) return;
if (!isHeadingElement(node)) return;
seen.add(node);
headings.push(node);
}
root.querySelectorAll('.ct-word-section-title,h1,h2,h3,h4,h5,h6').forEach(push);
return headings;
}
/** 从 Word HTML 提取章节 Tab无标题时返回 null调用方回退默认 Tab */
export function extractSectionTabsFromHtml(html) {
if (typeof document === 'undefined') return null;
var source = String(html || '').trim();
if (!source) return null;
var div = document.createElement('div');
div.innerHTML = source;
var headings = collectHeadingElements(div);
if (!headings.length) return null;
var tabs = [Object.assign({}, DEFAULT_MAIN_TAB)];
var usedKeys = { main: true };
headings.forEach(function (heading, index) {
var label = normalizeHeadingText(heading.textContent);
if (!label) return;
var key = slugifySectionKey(label, index + 1);
while (usedKeys[key]) {
key = key + '_' + index;
}
usedKeys[key] = true;
tabs.push({ key: key, label: label, headingText: label });
});
return tabs.length > 1 ? tabs : null;
}
function isWordBlockNode(node) {
if (!node) return false;
if (node.nodeType === 3) return !!String(node.textContent || '').trim();
return node.nodeType === 1 && node.nodeName !== 'STYLE';
}
function detectDynamicSectionStartKey(node, tabs) {
if (!node || node.nodeType !== 1 || !tabs || !tabs.length) return null;
if (!isHeadingElement(node)) return null;
var text = normalizeHeadingText(node.textContent);
for (var i = 1; i < tabs.length; i++) {
if (tabs[i].label === text || tabs[i].headingText === text) {
return tabs[i].key;
}
}
return null;
}
/** 按章节 Tab 将整份 HTML 拆为 section map */
export function splitHtmlBySectionTabs(html, tabs) {
var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs();
var source = String(html || '');
var result = emptySectionMapForTabs(sectionTabs);
if (!source.trim()) return result;
if (typeof document === 'undefined') {
return result;
}
var div = document.createElement('div');
div.innerHTML = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
var root = div.querySelector('.ct-word-doc') || div;
var children = [];
root.childNodes.forEach(function (node) {
if (isWordBlockNode(node)) children.push(node);
});
if (!children.length) {
result.main = source;
return result;
}
var holders = {};
var currentKey = 'main';
children.forEach(function (child) {
var startKey = detectDynamicSectionStartKey(child, sectionTabs);
if (startKey) currentKey = startKey;
if (!holders[currentKey]) holders[currentKey] = document.createElement('div');
holders[currentKey].appendChild(child.cloneNode(true));
});
sectionTabs.forEach(function (tab) {
result[tab.key] = holders[tab.key] ? holders[tab.key].innerHTML : '';
});
return result;
}
/** 将章节 map 合并为整份 HTML */
export function mergeHtmlBySectionTabs(sectionMap, tabs) {
var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs();
var map = sectionMap || {};
var out = String(map.main || '');
sectionTabs.slice(1).forEach(function (tab) {
var chunk = map[tab.key];
if (chunk) out += String(chunk);
});
return out;
}
export function resolveSectionTabs(record) {
if (record && Array.isArray(record.sectionTabs) && record.sectionTabs.length) {
return record.sectionTabs;
}
return getDefaultSectionTabs();
}
export function getSectionTabLabel(tabs, key) {
var list = tabs || getDefaultSectionTabs();
var tab = list.find(function (item) { return item.key === key; });
return tab ? tab.label : key;
}

View File

@@ -37,7 +37,8 @@ function getTrialAgreementSectionMap(fileName) {
function createWordDocument(params) {
return Object.assign({
category: 'lease',
contractType: '',
contractName: '',
contractType: '合同',
fileName: '未命名合同.docx',
versionName: '未命名合同',
versionNo: null,
@@ -62,7 +63,8 @@ export function buildInitialWordTemplates() {
return [
createWordDocument({
id: 'doc-1',
contractType: '现代18吨正式合同',
contractName: '现代18吨正式合同',
contractType: '合同',
fileName: '现代18吨车型-数智中心版.docx',
versionName: '现代18吨车型-数智中心版',
versionNo: 'v1.00',
@@ -99,7 +101,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-2',
contractType: '正式合同',
contractName: '正式合同',
contractType: '合同',
fileName: '2026年标准商用车租赁合同.docx',
versionName: '2026年标准商用车租赁合同',
versionNo: 'v1.01',
@@ -115,7 +118,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-3',
contractType: '正式合同',
contractName: '正式合同',
contractType: '合同',
fileName: '2026年二季度草案.docx',
versionName: '2026年二季度草案',
versionNo: null,
@@ -130,7 +134,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-4',
contractType: '试用合同',
contractName: '试用合同',
contractType: '合同',
fileName: '商用车试用协议.docx',
versionName: '商用车试用协议',
versionNo: 'v1.00',
@@ -146,7 +151,8 @@ export function buildInitialWordTemplates() {
}),
createWordDocument({
id: 'doc-5',
contractType: '现代18吨试用合同',
contractName: '现代18吨试用合同',
contractType: '合同',
fileName: '商用车试用协议-现代18吨.docx',
versionName: '商用车试用协议-现代18吨',
versionNo: 'v1.00',

View File

@@ -4,14 +4,16 @@
*/
import {
getContractTypeLabel,
getContractNameLabel,
normalizeContractType,
resolveContractName,
resolveContractType,
} from './contract-template-types.js';
var STORAGE_KEY = 'oneos_contract_templates_v1';
var templates = null;
var listeners = new Set();
var lastWriteSucceeded = true;
function readFromStorage() {
if (typeof localStorage !== 'undefined') {
@@ -37,22 +39,33 @@ function readFromStorage() {
}
function writeToStorage() {
if (!templates) return;
if (!templates) {
lastWriteSucceeded = true;
return true;
}
var payload = JSON.stringify(templates);
var localOk = true;
var sessionOk = true;
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, payload);
} catch (err) {
/* 原型忽略存储失败 */
localOk = false;
}
}
if (typeof sessionStorage !== 'undefined') {
try {
sessionStorage.setItem(STORAGE_KEY, payload);
} catch (err) {
/* 原型忽略存储失败 */
sessionOk = false;
}
}
lastWriteSucceeded = localOk || sessionOk;
return lastWriteSucceeded;
}
export function wasLastContractTemplateWriteOk() {
return lastWriteSucceeded;
}
function notify() {
@@ -117,7 +130,7 @@ var VEHICLE_HINT_BY_ID = {
};
function inferKind(doc) {
return resolveContractType(doc);
return resolveContractName(doc);
}
function inferVehicleHint(doc) {
@@ -126,14 +139,14 @@ function inferVehicleHint(doc) {
}
export function mapTemplateToLeaseOption(doc) {
var contractType = resolveContractType(doc);
var contractName = resolveContractName(doc);
return {
id: doc.id,
title: doc.versionName || stripExtension(doc.fileName) || doc.id,
fileName: doc.fileName || doc.versionName || '',
versionNo: doc.versionNo || '—',
contractType: contractType,
contractTypeLabel: getContractTypeLabel(contractType),
contractType: contractName,
contractTypeLabel: getContractNameLabel(contractName),
kind: inferKind(doc),
vehicleHint: inferVehicleHint(doc),
};

View File

@@ -1,23 +1,78 @@
/**
* 合同类型 — 用户自定义文本,合同模板管理与租赁合同创建共用
* 合同模板字段口径:
* - contractName合同名称用户自定义文本
* - contractType类型枚举合同 / 授权委托书 / 转三方协议 / 其他)
*/
export function normalizeContractType(value) {
export var CONTRACT_TYPE_ENUM_OPTIONS = ['合同', '授权委托书', '转三方协议', '其他'];
export var DEFAULT_DOCUMENT_CONTRACT_TYPE = '合同';
export function isDocumentContractTypeEnum(value) {
return CONTRACT_TYPE_ENUM_OPTIONS.indexOf(String(value || '').trim()) >= 0;
}
export function normalizeContractName(value) {
return String(value || '').trim();
}
export function getContractTypeLabel(value) {
var text = normalizeContractType(value);
export function normalizeDocumentContractType(value) {
var text = String(value || '').trim();
return isDocumentContractTypeEnum(text) ? text : DEFAULT_DOCUMENT_CONTRACT_TYPE;
}
export function getContractNameLabel(value) {
var text = normalizeContractName(value);
return text || '—';
}
/** 无 contractType 字段时,按文件名推断(兼容旧数据) */
export function inferContractTypeFromDoc(doc) {
if (doc && doc.contractType) return normalizeContractType(doc.contractType);
export function getDocumentContractTypeLabel(value) {
var text = normalizeDocumentContractType(value);
return text || '—';
}
/** 无 contractName 时,按文件名推断(兼容旧数据) */
export function inferContractNameFromDoc(doc) {
if (doc && doc.contractName) return normalizeContractName(doc.contractName);
var legacy = doc && doc.contractType;
if (legacy && !isDocumentContractTypeEnum(legacy)) {
return normalizeContractName(legacy);
}
var name = String((doc && (doc.fileName || doc.versionName)) || '');
return /试用/.test(name) ? '试用合同' : '正式合同';
}
export function resolveContractType(doc) {
return inferContractTypeFromDoc(doc);
export function resolveContractName(doc) {
return inferContractNameFromDoc(doc);
}
export function resolveDocumentContractType(doc) {
if (doc && doc.contractName != null && isDocumentContractTypeEnum(doc.contractType)) {
return normalizeDocumentContractType(doc.contractType);
}
if (doc && isDocumentContractTypeEnum(doc.contractType)) {
return normalizeDocumentContractType(doc.contractType);
}
return DEFAULT_DOCUMENT_CONTRACT_TYPE;
}
/** @deprecated 租赁合同等下游仍按「合同名称」消费,保留别名 */
export function normalizeContractType(value) {
return normalizeContractName(value);
}
/** @deprecated 保留别名,语义为合同名称 */
export function getContractTypeLabel(value) {
return getContractNameLabel(value);
}
/** @deprecated 保留别名,语义为合同名称 */
export function resolveContractType(doc) {
return resolveContractName(doc);
}
export function getDocumentContractTypeSelectOptions() {
return CONTRACT_TYPE_ENUM_OPTIONS.map(function (value) {
return { value: value, label: value };
});
}

View File

@@ -68,10 +68,10 @@ export var MOCK_LEASE_CUSTOMERS = [
bankAccount: '6222021234567890123',
taxId: '91330400MA2XXXXX1',
attachments: {
businessLicense: { name: '嘉兴某某物流-营业执照.pdf' },
legalIdFront: { name: '法人身份证-正面.jpg' },
legalIdBack: { name: '法人身份证-反面.jpg' },
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
businessLicense: { name: '嘉兴某某物流-营业执照.pdf', ocrVerified: true, expiryDate: '2028-06-30' },
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2027-12-31' },
},
},
{
@@ -91,10 +91,10 @@ export var MOCK_LEASE_CUSTOMERS = [
bankAccount: '6217001234567890123',
taxId: '91310000MA2XXXXX2',
attachments: {
businessLicense: { name: '上海某某运输-营业执照.pdf' },
legalIdFront: { name: '法人身份证-正面.jpg' },
legalIdBack: { name: '法人身份证-反面.jpg' },
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
businessLicense: { name: '上海某某运输-营业执照.pdf', ocrVerified: true, expiryDate: '2027-03-01' },
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-01-10' },
},
},
{
@@ -114,10 +114,10 @@ export var MOCK_LEASE_CUSTOMERS = [
bankAccount: '6228481234567890123',
taxId: '91330100MA2XXXXX3',
attachments: {
businessLicense: { name: '杭州某某租赁-营业执照.pdf' },
legalIdFront: { name: '法人身份证-正面.jpg' },
legalIdBack: { name: '法人身份证-反面.jpg' },
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
businessLicense: { name: '杭州某某租赁-营业执照.pdf', ocrVerified: true, expiryDate: '2026-09-20' },
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-02-01' },
},
},
];
@@ -207,6 +207,7 @@ export var MILEAGE_PERIOD_OPTIONS = [
export var CONTRACT_CODE_PREFIX = 'LNZLHT';
export var DEFAULT_FEE_INFO = {
paymentMethod: 'advance',
paymentPeriod: 1,
hydrogenPaymentMethod: 'self',
prepayAmount: null,
@@ -214,16 +215,32 @@ export var DEFAULT_FEE_INFO = {
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 = [
{ value: 1, label: '1个月' },
{ value: 3, label: '3个月' },
{ value: 6, label: '6个月' },
{ value: 12, label: '12个月' },
{ value: 1, label: '月' },
{ value: 3, label: '季付' },
{ value: 6, label: '半年付' },
{ value: 12, label: '年付' },
];
export var PAYMENT_METHOD_OPTIONS = [
{ value: 'advance', label: '先付后用' },
{ value: 'postpay', label: '先用后付' },
];
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
{ value: 'self', label: '自行解决' },
{ value: 'prepay', label: '预付款' },
{ value: 'month', label: '按月结算' },
];
export function getLessorCompanies() {

View File

@@ -0,0 +1,341 @@
/**
* Word (.doc / .docx) → HTML 导入
*/
import mammoth from 'mammoth';
import JSZip from 'jszip';
var MAMMOTH_STYLE_MAP = [
"p[style-name='Title'] => h1.ct-word-doc-title:fresh",
"p[style-name='Heading 1'] => p.ct-word-section-title:fresh",
"p[style-name='Heading 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='Heading 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"p[style-name='标题'] => p.ct-word-section-title:fresh",
"p[style-name='标题 1'] => p.ct-word-section-title:fresh",
"p[style-name='标题1'] => p.ct-word-section-title:fresh",
"p[style-name='标题 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='标题2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='标题 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"p[style-name='标题3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"highlight => mark.ct-word-highlight",
"r[style-name='Highlight'] => mark.ct-word-highlight",
"r[style-name='高亮强调'] => mark.ct-word-highlight",
];
var OLE_METADATA_PATTERNS = [
/Root Entry/i,
/SummaryInformation/i,
/DocumentSummaryInformation/i,
/\bWordDocument\b/,
/WpsCustomData/i,
/MsoDataStore/i,
/PKSOProductBuildVer/i,
/KSOProductBuildVer/i,
/\bICV\b/,
/\bFormText\b/,
/MERGEFORMAT/i,
/OLE_LINK/i,
];
var LEGACY_DOC_JUNK_EXACT = {
'Root Entry': true,
'SummaryInformation': true,
'DocumentSummaryInformation': true,
'WordDocument': true,
'Word.Document': true,
'Data': true,
'1Table': true,
'WpsCustomData': true,
'MsoDataStore': true,
'Normal': true,
'Default Paragraph Font': true,
'Table Normal': true,
};
export var IMPORTED_WORD_STYLE_ELEMENT_ID = 'ct-imported-word-styles';
export function buildImportedWordBaselineCss() {
return [
'.ct-word-doc--imported{font-family:SimSun,"宋体","Songti SC",STSong,serif;font-size:11pt;line-height:1.15;color:#000;letter-spacing:normal;word-break:normal;overflow-wrap:break-word}',
'.ct-word-doc--imported p,.ct-word-doc--imported li{margin:0;text-align:inherit;text-indent:inherit}',
'.ct-word-doc--imported h1,.ct-word-doc--imported h2,.ct-word-doc--imported h3,.ct-word-doc--imported h4,.ct-word-doc--imported h5,.ct-word-doc--imported h6{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit}',
'.ct-word-doc--imported .ct-word-section-title{font-weight:700;margin:0;text-align:justify}',
'.ct-word-doc--imported .ct-word-doc-title{text-align:center;font-weight:700;font-size:18pt;margin:0 0 6pt}',
'.ct-word-doc--imported mark.ct-word-highlight,.ct-word-doc--imported .ct-word-highlight{background-color:#ffff00;color:inherit;padding:0}',
'.ct-word-doc--imported table{border-collapse:collapse;width:auto;max-width:100%;table-layout:auto}',
'.ct-word-doc--imported td,.ct-word-doc--imported th{vertical-align:top}',
'.ct-word-doc--imported img{max-width:100%;height:auto}',
'.ct-word-doc--imported strong,.ct-word-doc--imported b{font-weight:700}',
].join('\n');
}
function scopeCssToImportedRoot(cssText) {
var scoped = String(cssText || '').trim();
if (!scoped) return '';
if (scoped.indexOf('.ct-word-doc--imported') >= 0) return scoped;
return scoped.replace(/(^|\})([^{@]+)\{/g, function (match, prefix, selector) {
var parts = selector.split(',').map(function (part) {
var trimmed = part.trim();
if (!trimmed || trimmed.indexOf('@') === 0) return trimmed;
return '.ct-word-doc--imported ' + trimmed;
});
return prefix + parts.join(',') + '{';
});
}
export function extractInlineStylesFromImportedHtml(html) {
var content = String(html || '');
var cssParts = [];
content = content.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, function (_, css) {
if (css && css.trim()) cssParts.push(css.trim());
return '';
});
var cssText = cssParts.map(scopeCssToImportedRoot).filter(Boolean).join('\n');
return { html: content.trim(), cssText: cssText };
}
export async function extractDocxThemeStyles(arrayBuffer) {
try {
var zip = await JSZip.loadAsync(arrayBuffer);
var stylesFile = zip.file('word/styles.xml');
if (!stylesFile) return '';
var stylesXml = await stylesFile.async('text');
var fontFamily = '宋体';
var fontSize = '11pt';
var docDefaultsMatch = stylesXml.match(/<w:docDefaults[\s\S]*?<\/w:docDefaults>/);
if (docDefaultsMatch) {
var block = docDefaultsMatch[0];
var eastAsia = block.match(/w:eastAsia="([^"]+)"/);
if (eastAsia && eastAsia[1]) fontFamily = eastAsia[1];
var ascii = block.match(/w:ascii="([^"]+)"/);
if (!eastAsia && ascii && ascii[1]) fontFamily = ascii[1];
var sz = block.match(/<w:sz[^>]*w:val="(\d+)"/);
if (sz && sz[1]) fontSize = (parseInt(sz[1], 10) / 2) + 'pt';
}
return '.ct-word-doc--imported{font-family:"' + fontFamily + '",SimSun,"宋体","Songti SC",serif;font-size:' + fontSize + ';line-height:1.15;color:#000}';
} catch (e) {
return '';
}
}
export function injectImportedWordStyles(cssText) {
if (typeof document === 'undefined') return;
var merged = [buildImportedWordBaselineCss(), String(cssText || '').trim()].filter(Boolean).join('\n');
var styleEl = document.getElementById(IMPORTED_WORD_STYLE_ELEMENT_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = IMPORTED_WORD_STYLE_ELEMENT_ID;
document.head.appendChild(styleEl);
}
if (styleEl.textContent !== merged) styleEl.textContent = merged;
}
export function prepareImportedWordHtml(html, extraCss) {
var source = sanitizeImportedWordHtml(String(html || ''));
if (!isImportedWordDocHtml(source)) return source;
var extracted = extractInlineStylesFromImportedHtml(source);
injectImportedWordStyles([extracted.cssText, extraCss].filter(Boolean).join('\n'));
var body = extracted.html.trim();
if (!body) return '';
if (body.indexOf('ct-word-doc--imported') >= 0) return body;
return wrapWordHtml(body);
}
function wrapWordHtml(html) {
var body = String(html || '').trim();
if (!body) return '';
if (body.indexOf('ct-word-doc') >= 0) return body;
return '<div class="ct-word-doc ct-word-doc--imported">' + body + '</div>';
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectWordFileFormat(arrayBuffer) {
var view = new Uint8Array(arrayBuffer || []);
if (view.length >= 4 && view[0] === 0x50 && view[1] === 0x4b) return 'docx';
if (view.length >= 8 && view[0] === 0xd0 && view[1] === 0xcf && view[2] === 0x11 && view[3] === 0xe0) return 'doc';
return 'unknown';
}
export function isImportedWordDocHtml(html) {
return /ct-word-doc--imported/.test(String(html || ''));
}
export function isCorruptWordImportHtml(html) {
var source = String(html || '');
if (!source.trim()) return true;
var plain = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, '');
if (!plain.replace(/\s/g, '')) return true;
var hits = 0;
OLE_METADATA_PATTERNS.forEach(function (pattern) {
if (pattern.test(plain)) hits += 1;
});
if (hits >= 2) return true;
if (/[\u4e00-\u9fff]/.test(plain)) return false;
if (/合同|甲方|乙方|租赁|附件/.test(plain)) return false;
return hits >= 1 && plain.length < 500;
}
export function sanitizeImportedWordHtml(html) {
var content = String(html || '').trim();
if (!content) return '';
content = content
.replace(/<\/p>\s*n\s*<p/gi, '</p><p')
.replace(/<\/table>\s*n\s*<p/gi, '</table><p')
.replace(/<\/table>\s*n\s*<table/gi, '</table><table');
if (content.indexOf('ct-word-doc--imported') < 0 && content.indexOf('ct-word-doc') >= 0) {
content = content.replace('ct-word-doc', 'ct-word-doc ct-word-doc--imported');
}
return content;
}
function looksLikeHeadingLine(line) {
var text = String(line || '').trim();
if (!text || text.length > 80) return false;
if (/^附件\s*\d+/i.test(text)) return true;
if (/^第[一二三四五六七八九十\d]+[章节部分]/i.test(text)) return true;
if (/^[一二三四五六七八九十\d]+[、.:]/.test(text) && text.length <= 40) return true;
return text.length <= 24 && !/[。;,,;]/.test(text);
}
function isLegacyDocJunkLine(line) {
var text = String(line || '').trim();
if (!text) return true;
if (LEGACY_DOC_JUNK_EXACT[text]) return true;
if (/^Text\d+$/i.test(text)) return true;
if (/^P\s*\d+$/i.test(text)) return true;
if (/^[\d\s*桰漀]+$/.test(text)) return true;
if (text.length <= 28 && !/[\u4e00-\u9fff]/.test(text) && /^[\x00-\x7F\s]+$/.test(text)) return true;
if (OLE_METADATA_PATTERNS.some(function (pattern) { return pattern.test(text); })) return true;
return false;
}
function plainTextToHtml(text) {
var lines = String(text || '')
.replace(/\r/g, '\n')
.split('\n')
.map(function (line) { return line.trim(); })
.filter(function (line) { return line && !isLegacyDocJunkLine(line); });
if (!lines.length) return '';
return lines.map(function (line) {
if (looksLikeHeadingLine(line)) {
return '<p class="ct-word-section-title">' + escapeHtml(line) + '</p>';
}
return '<p class="ct-word-body">' + escapeHtml(line) + '</p>';
}).join('');
}
/** 从旧版 .doc 二进制中粗提取可读文本(原型层兜底) */
function extractTextFromLegacyDoc(arrayBuffer) {
var view = new Uint8Array(arrayBuffer);
var chunks = [];
var current = '';
function flush() {
var text = current.replace(/\s+/g, ' ').trim();
if (text.length >= 4 && !isLegacyDocJunkLine(text)) chunks.push(text);
current = '';
}
for (var i = 0; i < view.length - 1; i += 2) {
var code = view[i] | (view[i + 1] << 8);
if (
(code >= 0x20 && code <= 0x7e)
|| (code >= 0x4e00 && code <= 0x9fff)
|| code === 0x0a
|| code === 0x0d
|| code === 0x3001
|| code === 0x3002
|| code === 0xff1a
|| code === 0xff1b
) {
current += String.fromCharCode(code);
} else {
flush();
}
}
flush();
var merged = chunks.join('\n');
if (!merged.trim()) {
var latin = new TextDecoder('latin1').decode(view);
merged = latin.replace(/[^\x20-\x7E\u4e00-\u9fff\r\n]+/g, '\n');
}
return merged.replace(/\n{3,}/g, '\n\n').trim();
}
export async function convertDocxArrayBufferToHtml(arrayBuffer) {
if (detectWordFileFormat(arrayBuffer) !== 'docx') {
throw new Error('不是有效的 .docx 文件');
}
var themeCss = await extractDocxThemeStyles(arrayBuffer);
var result = await mammoth.convertToHtml(
{ arrayBuffer: arrayBuffer },
{
styleMap: MAMMOTH_STYLE_MAP,
includeDefaultStyleMap: true,
includeEmbeddedStyleMap: true,
ignoreEmptyParagraphs: false,
}
);
var html = String(result.value || '').trim();
if (!html || isCorruptWordImportHtml(html)) {
var messages = (result.messages || []).map(function (m) { return m.message; }).filter(Boolean);
throw new Error(messages[0] || 'Word 文件解析结果无效');
}
return prepareImportedWordHtml(wrapWordHtml(html), themeCss);
}
export async function convertDocxFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
return convertDocxArrayBufferToHtml(arrayBuffer);
}
export async function convertLegacyDocFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
if (detectWordFileFormat(arrayBuffer) !== 'doc') {
throw new Error('不是有效的 .doc 文件');
}
var text = extractTextFromLegacyDoc(arrayBuffer);
if (!text || !/[\u4e00-\u9fff]/.test(text)) {
throw new Error('未能从 .doc 文件中提取正文,请另存为 .docx 后重试');
}
var html = plainTextToHtml(text);
if (!html || isCorruptWordImportHtml(html)) {
throw new Error('未能从 .doc 文件中生成可用内容,请另存为 .docx 后重试');
}
return prepareImportedWordHtml(wrapWordHtml(html), '');
}
export async function convertWordFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
var format = detectWordFileFormat(arrayBuffer);
var name = String(file.name || '').toLowerCase();
if (format === 'docx' || (/\.docx$/i.test(name) && format !== 'doc')) {
return convertDocxArrayBufferToHtml(arrayBuffer);
}
if (format === 'doc' || /\.doc$/i.test(name)) {
return convertLegacyDocFileToHtml(file);
}
throw new Error('无法识别 Word 文件格式,请使用 .doc 或 .docx');
}
export function detectWordFileFormatFromName(name, arrayBuffer) {
if (arrayBuffer) return detectWordFileFormat(arrayBuffer);
var lower = String(name || '').toLowerCase();
if (/\.docx$/i.test(lower)) return 'docx';
if (/\.doc$/i.test(lower)) return 'doc';
return 'unknown';
}
export function isDocxArrayBuffer(arrayBuffer) {
return detectWordFileFormat(arrayBuffer) === 'docx';
}

View File

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

View File

@@ -40,6 +40,8 @@ const overviewMd = `## 模块定位
| 目标用户 | 法务人员 |
| 核心任务 | 维护 Word 版商用车租赁合同模板,按章节编辑,发布版本并控制启用态 |
| 数据模型 | 一份 Word 文件 = 一条记录,内含 9 个章节内容 |
| 车型主数据 | 品牌·型号选项来自 [车辆管理](/prototypes/vehicle-management) 种子数据,并与租赁演示车型合并 |
| 角色演示 | 法务默认可编辑URL 加 ?role=cs 或 ?role=readonly 进入客服只读模式 |
| PRD 文档 | 见目录「PRD 全文 → PRD 完整文档」 |
| 资源文件 | \`src/resources/contract-template-management/PRD.md\` |
@@ -66,8 +68,8 @@ const DOC_MARKDOWN = {
'ct-doc-prd-acceptance': extractSection(fullPrd, '## 16. 验收标准') ?? '## 16. 验收标准',
'ct-doc-node-filter': extractSection(fullPrd, '### 7.2 筛选区') ?? '',
'ct-doc-node-table': combineSections(['### 7.4 主表字段', '### 7.5 主表操作', '### 7.6 子表(展开行)']),
'ct-doc-node-tabs': combineSections(['### 8.3 章节 Tab', '### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)', '### 8.5 适用车型绑定(仅附件章节 Tab']),
'ct-doc-node-workspace': combineSections(['### 8.6 编辑区(左侧)', '### 8.7 实时预览(右侧)']),
'ct-doc-node-tabs': combineSections(['### 8.3 章节 Tab', '### 8.5 条件条款说明(合同主体及租赁订单~安全责任协议)', '### 8.6 适用车型绑定(仅附件章节 Tab']),
'ct-doc-node-workspace': combineSections(['### 8.7 编辑区(左侧)', '### 8.8 实时预览(右侧)']),
'ct-doc-annotation-hint': '## 标注查看提示\n\n右侧标注工具栏可浏览目录、切换主题与颜色筛选。列表页与编辑页标注按当前页面自动显隐。\n\n**PRD 同步**目录「PRD 全文」已与 `src/resources/contract-template-management/PRD.md` 对齐;修改 PRD 后运行 `node src/prototypes/contract-template-management/scripts/build-annotation-source.mjs` 重新生成 `annotation-source.json`。',
};
@@ -76,10 +78,10 @@ const MARKDOWN_MAP = {
'ct-list-table': combineSections(['### 7.4 主表字段', '### 7.5 主表操作', '### 7.6 子表(展开行)']),
'ct-editor-topbar': combineSections(['### 8.2 顶栏', '### 6.2 保存与发布']),
'ct-section-tabs': extractSection(fullPrd, '### 8.3 章节 Tab') ?? '',
'ct-contract-type': source.markdownMap?.['ct-contract-type'] ?? '',
'ct-vehicle-binding': extractSection(fullPrd, '### 8.5 适用车型绑定(仅附件章节 Tab') ?? source.markdownMap?.['ct-vehicle-binding'] ?? '',
'ct-editor-panel': combineSections(['### 8.6 编辑区(左侧)']),
'ct-preview-panel': extractSection(fullPrd, '### 8.7 实时预览(右侧)') ?? '',
'ct-contract-type': extractSection(fullPrd, '### 8.4 合同主体配置(仅合同主体 Tab') ?? '',
'ct-vehicle-binding': extractSection(fullPrd, '### 8.6 适用车型绑定(仅附件章节 Tab') ?? source.markdownMap?.['ct-vehicle-binding'] ?? '',
'ct-editor-panel': combineSections(['### 8.7 编辑区(左侧)']),
'ct-preview-panel': extractSection(fullPrd, '### 8.8 实时预览(右侧)') ?? '',
};
function patchDirectoryMarkdown(nodes) {
@@ -97,6 +99,44 @@ function patchDirectoryMarkdown(nodes) {
const ctDocRoot = source.directory?.nodes?.find((n) => n.id === 'ct-doc-root');
if (ctDocRoot) patchDirectoryMarkdown(ctDocRoot.children);
function patchListNodes(nodes) {
for (const node of nodes ?? []) {
if (node.id === 'ct-list-filter') {
node.annotationText = '支持按合同名称(可搜索下拉)、类型、状态、创建人、启用状态筛选;超过 4 项时默认折叠扩展筛选项。';
node.updatedAt = Date.now();
}
if (node.id === 'ct-list-toolbar') {
node.annotationText = '进入新增页;未导入 Word 前不展示章节 Tab。';
node.updatedAt = Date.now();
}
if (node.id === 'ct-section-tabs') {
node.aiPrompt = '由 Word「标题」样式自动生成的章节 Tab。';
node.annotationText = '导入 Word 后,按文档中标记为「标题」的段落文本自动生成章节 Tab新增模式未导入前整段隐藏含合同主体。';
node.updatedAt = Date.now();
}
if (node.id === 'ct-contract-type') {
node.title = '合同主体配置';
node.aiPrompt = '合同主体章节下的合同名称与类型配置。';
node.annotationText = '仅当存在「合同主体」Tab 且选中时展示;合同名称与类型均为必填,同步至列表页。';
node.updatedAt = Date.now();
}
}
}
patchListNodes(source.data?.nodes);
if (source.data?.annotationsByElementId && MARKDOWN_MAP['ct-list-filter']) {
source.data.annotationsByElementId['ct-list-filter'] = MARKDOWN_MAP['ct-list-filter'];
if (MARKDOWN_MAP['ct-list-table']) {
source.data.annotationsByElementId['ct-list-table'] = MARKDOWN_MAP['ct-list-table'];
}
if (MARKDOWN_MAP['ct-contract-type']) {
source.data.annotationsByElementId['ct-contract-type'] = MARKDOWN_MAP['ct-contract-type'];
}
if (MARKDOWN_MAP['ct-section-tabs']) {
source.data.annotationsByElementId['ct-section-tabs'] = MARKDOWN_MAP['ct-section-tabs'];
}
}
source.markdownMap = { ...source.markdownMap, ...MARKDOWN_MAP };
source.data.updatedAt = Date.now();

View File

@@ -803,21 +803,6 @@
box-shadow: inset 0 0 0 1px var(--ln-primary);
}
.vm-page.ct-page .ct-editor-page__back-btn.ant-btn {
padding: 4px 12px;
height: var(--vm-control-height);
font-size: 0.875rem;
color: var(--ln-muted);
border-color: var(--ln-hairline-strong);
background: var(--ln-surface-card);
}
.vm-page.ct-page .ct-editor-page__back-btn.ant-btn:hover {
color: var(--ln-primary);
border-color: var(--ln-primary);
background: var(--ln-primary-soft);
}
.vm-page.ct-page .ct-editor-page__sections {
flex-shrink: 0;
margin-bottom: 12px;
@@ -1572,10 +1557,123 @@
table-layout: auto;
}
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--imported,
.vm-page.ct-page .ct-preview-page__body.ct-word-doc--imported,
.vm-page.ct-page .ct-preview-doc .ct-word-doc--imported {
display: block;
width: 100%;
min-width: 0;
padding: 0 !important;
font-size: unset;
line-height: unset;
color: #000;
letter-spacing: normal;
word-break: normal;
overflow-wrap: break-word;
}
.vm-page.ct-page .ct-word-doc--imported p,
.vm-page.ct-page .ct-word-doc--imported li {
margin: 0;
text-align: inherit;
text-indent: inherit;
}
.vm-page.ct-page .ct-word-doc--imported h1,
.vm-page.ct-page .ct-word-doc--imported h2,
.vm-page.ct-page .ct-word-doc--imported h3,
.vm-page.ct-page .ct-word-doc--imported h4,
.vm-page.ct-page .ct-word-doc--imported h5,
.vm-page.ct-page .ct-word-doc--imported h6 {
margin: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
.vm-page.ct-page .ct-word-doc--imported .ct-word-section-title {
font-weight: 700;
margin: 0;
}
.vm-page.ct-page .ct-word-doc--imported mark.ct-word-highlight,
.vm-page.ct-page .ct-word-doc--imported .ct-word-highlight {
background-color: #ffff00;
color: inherit;
padding: 0;
}
.vm-page.ct-page .ct-word-doc--imported table {
border-collapse: collapse;
width: auto;
max-width: 100%;
table-layout: auto;
}
.vm-page.ct-page .ct-word-doc--imported td,
.vm-page.ct-page .ct-word-doc--imported th {
vertical-align: top;
}
.vm-page.ct-page .ct-word-doc--imported img {
max-width: 100%;
height: auto;
}
.vm-page.ct-page .ct-preview-page__body.ct-word-doc--imported {
overflow: visible;
word-break: normal;
}
.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 style {
display: none !important;
}
.vm-page.ct-page .ct-preview-pages--docx-fidelity {
align-items: center;
width: 100%;
}
.vm-page.ct-page .ct-docx-fidelity-banner {
width: 100%;
max-width: 210mm;
margin: 0 auto 12px;
padding: 8px 12px;
font-size: 12px;
line-height: 1.5;
color: #334155;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-sizing: border-box;
}
.vm-page.ct-page .ct-docx-fidelity-root {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.vm-page.ct-page .ct-docx-fidelity-root .docx-wrapper {
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
margin: 0 auto;
}
.vm-page.ct-page .ct-docx-fidelity-fallback {
width: 210mm;
max-width: calc(100% - 10px);
padding: 24px;
text-align: center;
color: #64748b;
font-size: 13px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.vm-page.ct-page .ct-rich-wrap--word-a4 .ct-editor-a4-shell {
position: relative;
flex: 1;
@@ -2047,10 +2145,36 @@
}
.vm-page.ct-page .ct-contract-type-card__form {
max-width: 360px;
max-width: none;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-end;
gap: 16px;
}
.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-input {
.vm-page.ct-page .ct-contract-type-card__field {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
flex: 1 1 0;
min-width: 0;
}
.vm-page.ct-page .ct-contract-type-card__field--type {
flex: 0 0 200px;
max-width: 220px;
}
.vm-page.ct-page .ct-contract-type-card__label {
font-size: 12px;
font-weight: 500;
color: #64748b;
}
.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-input,
.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-select {
width: 100%;
}

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "customer-management",
"pageId": "list",
"updatedAt": 1782970453061,
"updatedAt": 1783800844964,
"nodes": [
{
"id": "cm-list-filter",
@@ -282,7 +282,7 @@
"type": "link",
"id": "cm-link-self",
"title": "客户管理原型",
"href": "/prototypes/customer-management",
"href": "./index.html",
"target": "self"
},
{

View File

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

View File

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

View File

@@ -175,7 +175,7 @@ export function FilterPanel({
return (
<section className="vm-filter-card cm-filter-card" data-annotation-id="cm-list-filter">
<header className="vm-filter-header">
<h1 className="cm-page-title"></h1>
<h2 className="vm-filter-title"></h2>
</header>
<div className="vm-filter-grid">
{visibleFields.map((field) => (
@@ -191,19 +191,12 @@ export function FilterPanel({
type="button"
className="vm-btn vm-btn-link"
onClick={() => setExpanded(!expanded)}
>
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{expanded ? '收起' : '更多筛选'}
<ChevronDown
size={14}
aria-hidden
style={{ transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }}
/>
</button>
</button>
)}
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
<Search size={16} aria-hidden />
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw"></button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
</button>
</div>
</section>

View File

@@ -68,6 +68,7 @@ export function MultiSelectField({
<label key={option} className="cm-multi-option">
<input
type="checkbox"
className="vm-checkbox"
checked={values.includes(option)}
onChange={() => toggle(option)}
/>

View File

@@ -476,7 +476,7 @@ export function RiskTagDrawer({
<footer className="cm-drawer-footer">
{tab === 'add' && canAddRiskTags(currentUser) ? (
<>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => { setTab('tags'); resetAddForm(); }}>
<button type="button" className="vm-btn vm-btn-ghost" data-vm-icon="x" onClick={() => { setTab('tags'); resetAddForm(); }}>
</button>
{confirmHighRisk ? (
@@ -484,13 +484,13 @@ export function RiskTagDrawer({
<ShieldAlert size={14} aria-hidden />
</button>
) : (
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply}>
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply} data-vm-icon="send">
</button>
)}
</>
) : (
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
)}
</footer>
@@ -516,8 +516,8 @@ export function RiskTagDrawer({
onChange={(e) => setRemoveReason(e.target.value)}
/>
<div className="cm-drawer-overlay-actions">
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setRemoveTargetId(null)}></button>
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove}>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setRemoveTargetId(null)} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove} data-vm-icon="send">
</button>
</div>
@@ -540,9 +540,9 @@ export function RiskTagDrawer({
onChange={(e) => setRejectReason(e.target.value)}
/>
<div className="cm-drawer-overlay-actions">
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setApproveTargetId(null)}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => handleConfirmApprove(false)}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={() => handleConfirmApprove(true)}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setApproveTargetId(null)} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => handleConfirmApprove(false)} data-vm-icon="x"></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={() => handleConfirmApprove(true)} data-vm-icon="send"></button>
</div>
</div>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,94 @@
# Prototype Review
- 审查目标:`src/prototypes/lease-business-detail`(页面 `list`
- 用户资料/参考资料:
- 对话中已确认的 Excel《2026年业务二部运营台账总表6.17 - 新版》「2.租赁车辆收入明细表」字段顺序、23 项汇总、6 项公式列与 7 项 KPI 口径
- `src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx`
- `src/prototypes/oneos-web-data-analysis/pages/08-租赁车辆收入明细.jsx`
- `src/prototypes/oneos-web-data-analysis/pages/05-业务部台账.jsx`(租赁业绩数据来源说明)
- `src/prototypes/lease-business-ledger/.spec/requirements-prd.md`(对照模块)
- 原型源码、`annotation-source.json``data/rows.json`
- 生成时间2026-07-09 16:18
## 总体点评
原型在「租赁运营明细宽表」主路径上已具备可运行闭环:筛选 → 7 项 KPI → 47 列分页列表 → 23 项表尾汇总 → 批量导入/导出,且公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏)与 Excel SUBTOTAL 逻辑一致553 条 2026 年样例数据汇总与台账样表核对无偏差。列表字段、双运维费列、左冻结年份/车牌、日期列排序等近期决策在实现中落地较完整。
主要缺口集中在**模块边界与资料体系**:相对 `src/resources` 中「租赁车辆收入明细」双 Tab 结构,当前仅实现明细列表,缺少「租赁业务盈亏月度汇总」;相对 `lease-business-ledger`,本原型未说明「明细维护」与「台账收款关联」的分工,实收仍为导入手工列。导入流程可完成演示,但缺少重复数据与行级校验策略,长期批量维护存在数据质量风险。整体适合作为 Excel 明细表的交互原型,尚不足以单独支撑数据分析侧完整用户旅程。
## P0-P3 优先级问题
### P1 - 缺少「租赁业务盈亏月度汇总」Tab / 页面
- 证据:`src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx``src/prototypes/oneos-web-data-analysis/pages/08-租赁车辆收入明细.jsx` 均包含「租赁业务明细」+「租赁业务盈亏月度汇总」两个 Tab当前 `index.tsx` 仅单页 `list``annotation-source.json` 目录也只有一条路由。
- 影响:需要按月查看盈亏汇总、对比年度趋势的用户无法在本原型完成核心分析任务,与既有产品资料表达不一致。
- 修复方向:在 `.spec` 明确是否纳入本原型范围;若纳入,增加第二 Tab 或子路由,按年份聚合月份级押金/应收/实收/未收/收入/成本/盈亏,并保留导出。
### P1 - 与「租赁业务台账」边界不清,实收口径未闭环
- 证据:`lease-business-ledger` PRD 定义收款关联、`calcReceivedAmountFromReceipts`、明细状态;本原型 `receivedAmount` 为导入手工列(`batch-io.ts`),无关联收款、无「未收款/部分收款/已收款」状态;`oneos-web-data-analysis/05-业务部台账.jsx` 注明租赁业绩来自「租赁业务明细」。
- 影响:业务方可能混淆两个模块职责;实收可任意填写导致未收/盈亏与真实收款脱节,下游汇总分析可信度下降。
- 修复方向:在 `.spec/requirements-prd.md` 写清「明细表 = 业管 Excel 维护」「台账 = 收款闭环」或统一为一条链路;至少增加实收 ≤ 应收合计校验,并标注与台账/收款模块的集成计划。
### P2 - 批量导入仅追加,无重复键与覆盖策略
- 证据:`index.tsx` 导入成功后 `setRecords((prev) => [...imported, ...prev])``parseDetailImportFile` 仅以「年份 + 月份 + 车牌号码」判空跳过,不检测同键已存在行。
- 影响业管重复导入同月同车数据会产生重复行KPI 与表尾汇总被放大,难以恢复。
- 修复方向:定义业务主键(建议 `year + month + plateNo`,是否加业务员待确认);导入时支持跳过/覆盖/报错三种策略,并返回行级结果摘要。
### P2 - 导入与计算缺少行级校验反馈
- 证据:导入失败仅 Toast`未解析到有效数据` / `导入失败`);无字段格式、数值范围、必填组合(如平均天数为 0 时成本为 0的逐行报告`receivedAmount` 可大于 `receivableTotal` 产生负未收且无提示。
- 影响:大批量维护时难以定位问题行,错误数据静默进入列表。
- 修复方向:导入结果弹层展示成功/跳过/失败行数与原因;对实收超额、非法日期、押金格式等做行级校验清单。
### P3 - 缺少正式需求文档annotation 未覆盖字段与公式说明
- 证据:`.spec/` 仅有空的 `prototype-comments.json`,无 `requirements-prd.md``annotation-source.json` 仅 4 个区块简述,未像 `lease-business-ledger` 那样标注各列口径、公式列只读规则、与 Excel 样表映射。
- 影响:评审、测试与后续开发缺少单一事实来源,跨模块对齐成本高。
- 修复方向:补充 `.spec/requirements-prd.md`(用户、范围、不做项、验收清单);扩展 annotation 对公式列、导入模板列、KPI/汇总口径的说明。
## 完整性与项目对齐
| 维度 | 结论 |
|------|------|
| 核心用户 | 隐含为业务二部业管/运营人员(样例数据均为「业务二部」),未在 `.spec` 明文写出 |
| 核心任务 | **已覆盖**:按条件查询明细、查看 KPI 与表尾汇总、Excel 模板导入与导出 |
| 主流程入口 | 侧边栏 `prototypes/lease-business-detail``sidebar-tree.json` |
| 主流程出口 | 导出当前筛选结果;无保存至服务端、无返回上游分析页导航 |
| 范围边界 | **已对齐**47 列宽表、6 公式列自动计算、7+23 汇总结构、2026 样例 16 月 |
| 范围缺口 | **未覆盖**:盈亏月度汇总 Tab行内编辑/删除;持久化;收款关联 |
| 与 ledger 关系 | **待确认**:两模块均处理租赁运营金额,字段模型不同(明细 47 列 vs 台账账单字段),需产品定稿分工 |
| 与数据分析关系 | `05-业务部台账` 声明租赁业绩取自本明细;当前原型未暴露 API/聚合接口,仅本地 JSON**集成待确认** |
| 资料冲突 | Legacy 数据分析页为简化列(应收/实收/自然月收入等 ~20 列),本原型已升级为 Excel 运营台账 47 列——**以对话与 Excel 样表为准**Legacy 作交互参考而非字段真相 |
## 业务逻辑连贯性
- **筛选逻辑**`statMonth``YYYY-MM`)、业务部门、业务员精确匹配,客户名称模糊匹配,车牌多选;查询按钮触发 `appliedFilters`,与 KPI/汇总联动,逻辑自洽。
- **汇总口径**KPI 7 项与表尾 23 项均基于**全量筛选结果**(非当前页),与 Excel SUBTOTAL 语义一致;分页仅影响表格 body不影响汇总合理。
- **公式链路**`calc-detail.ts` 与 Excel 公式一致(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏);加载种子数据与导入后均调用 `applyDetailCalculations`,避免公式列脏读。
- **状态迁移**:无「草稿/已提交/归档」;导入即入库(内存),无版本与审计轨迹。
- **角色权限**:未实现员工/主管数据范围(`lease-business-ledger``filterRecordsByRole`),本原型全员可见全部 553 条。
- **跨页一致性**:单页原型,无跨路由状态问题。
## 状态、异常、边界与恢复
| 场景 | 现状 | 评价 |
|------|------|------|
| 空数据 | 表格区展示空状态文案 + 导入引导 | 已覆盖 |
| 加载 | 筛选查询 280ms 骨架屏 | 已覆盖(模拟) |
| 导入成功 | Toast + 关闭弹窗 + 重置页码 | 已覆盖 |
| 导入无有效行 | Toast 提示检查模板 | 已覆盖,但无行级原因 |
| 导入异常 | 通用「导入失败」Toast | 偏弱 |
| 导出 | 导出筛选结果 + Toast | 已覆盖 |
| 重复导入 | 直接追加 | **缺恢复路径** |
| 实收 > 应收 | 允许,未收为负 | **缺业务提示** |
| 刷新页面 | 恢复种子数据,导入丢失 | 原型可接受,需标注 |
| 宽表横向滚动 | `lbd-table-wrap` overflow 修复 | 已覆盖 |
| 并发/权限 | 未涉及 | 不适用 |
## 证据与评估说明
- **用户资料优先级**:本轮无新的用户附件;以对话中 Excel 对齐决策为主。与 Legacy 简化列模型存在差异,已在「完整性与项目对齐」标记,**不视为 P0 冲突**。
- **读取范围**`rules/prototype-review-guide.md``index.tsx``types.ts``utils/calc-detail.ts``utils/batch-io.ts``utils/detail.ts``components/*``data/rows.json``annotation-source.json``src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx``lease-business-ledger/.spec/requirements-prd.md``oneos-web-data-analysis/pages/05-业务部台账.jsx`(片段);`.axhub/make/sidebar-tree.json`
- **独立评估**`degraded`(未使用双子代理;在同一审查内先建立业务基线再对照源码,结论仍基于双源交叉验证)。

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
import React, { useRef } from 'react';
import { Download, Upload, X } from 'lucide-react';
interface DetailImportModalProps {
open: boolean;
onClose: () => void;
onDownloadTemplate: () => void;
onImport: (file: File) => void;
failedCount?: number;
onDownloadErrorLog?: () => void;
}
export function DetailImportModal({
open,
onClose,
onDownloadTemplate,
onImport,
failedCount = 0,
onDownloadErrorLog,
}: DetailImportModalProps) {
const inputRef = useRef<HTMLInputElement>(null);
if (!open) return null;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-lg ldb-import-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lbd-import-title"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="lbd-import-title" className="vm-modal-title"></h2>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="vm-import-body">
<section className="vm-import-step">
<div className="vm-import-step-head">
<span className="vm-step-badge">1</span>
<span></span>
</div>
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">
</button>
</section>
<section className="vm-import-step">
<div className="vm-import-step-head">
<span className="vm-step-badge">2</span>
<span> Excel </span>
</div>
<button
type="button"
className="vm-upload-zone"
onClick={() => inputRef.current?.click()}
>
<Upload size={28} aria-hidden />
<p></p>
<input
ref={inputRef}
type="file"
accept=".xls,.xlsx"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = '';
}}
/>
</button>
</section>
{failedCount > 0 && onDownloadErrorLog ? (
<section className="vm-import-step lbd-import-error-step">
<p className="lbd-import-error-summary">
<strong>{failedCount}</strong>
</p>
<button type="button" className="vm-btn vm-btn-secondary" onClick={onDownloadErrorLog} data-vm-icon="download">
</button>
</section>
) : null}
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose} data-vm-icon="x"></button>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import React from 'react';
import {
CircleHelp,
CircleDollarSign,
Coins,
MinusCircle,
TrendingUp,
Wallet,
type LucideIcon,
} from 'lucide-react';
import type { LeaseDetailKpiSummary } from '../types';
import { formatMoney } from '../utils/detail';
type KpiTone =
| 'neutral'
| 'receivable'
| 'received'
| 'outstanding'
| 'deduction'
| 'profit';
const CARDS: Array<{
key: keyof LeaseDetailKpiSummary;
title: string;
desc: string;
tone: KpiTone;
icon: LucideIcon;
}> = [
{
key: 'receivableTotal',
title: '应收总计',
desc: '当前筛选范围内应收合计之和',
tone: 'receivable',
icon: CircleDollarSign,
},
{
key: 'deposit',
title: '押金总计',
desc: '当前筛选范围内押金合计(数值型)',
tone: 'neutral',
icon: Coins,
},
{
key: 'receivedAmount',
title: '实收总计',
desc: '当前筛选范围内实收金额之和',
tone: 'received',
icon: Wallet,
},
{
key: 'mileageDeduction',
title: '里程减免总计',
desc: '当前筛选范围内里程减免金额合计',
tone: 'deduction',
icon: MinusCircle,
},
{
key: 'otherDeduction',
title: '其他减免总计',
desc: '当前筛选范围内其他减免金额合计',
tone: 'deduction',
icon: MinusCircle,
},
{
key: 'outstanding',
title: '未收总计',
desc: '当前筛选范围内未收金额合计',
tone: 'outstanding',
icon: CircleDollarSign,
},
{
key: 'profitLoss',
title: '盈亏总计',
desc: '当前筛选范围内盈亏合计',
tone: 'profit',
icon: TrendingUp,
},
];
interface DetailKpiRowProps {
summary: LeaseDetailKpiSummary;
}
function valueClassName(key: keyof LeaseDetailKpiSummary, summary: LeaseDetailKpiSummary): string {
if (key === 'profitLoss') {
if (summary.profitLoss > 0) return 'lbd-kpi-val--profit';
if (summary.profitLoss < 0) return 'lbd-kpi-val--loss';
}
if (key === 'outstanding' && summary.outstanding > 0) return 'lbd-kpi-val--warn';
return '';
}
export function DetailKpiRow({ summary }: DetailKpiRowProps) {
return (
<div className="lbd-kpi-row" data-annotation-id="lbd-kpi" aria-label="关键指标汇总">
{CARDS.map((card) => {
const raw = summary[card.key];
const value = formatMoney(raw);
const Icon = card.icon;
const isOutstandingWarn = card.key === 'outstanding' && summary.outstanding > 0;
return (
<article
key={card.key}
className={[
'lbd-kpi-card',
`lbd-kpi-card--${card.tone}`,
isOutstandingWarn ? 'lbd-kpi-card--warn' : '',
].filter(Boolean).join(' ')}
>
<div className="lbd-kpi-card-accent" aria-hidden />
<div className="lbd-kpi-card-body">
<div className="lbd-kpi-card-head">
<span className="lbd-kpi-icon" aria-hidden>
<Icon size={15} strokeWidth={2.25} />
</span>
<span className="lbd-kpi-label">{card.title}</span>
<span className="lbd-kpi-tip" tabIndex={0} aria-label={`${card.title}说明`}>
<CircleHelp size={12} aria-hidden />
<span className="lbd-kpi-tooltip" role="tooltip">{card.desc}</span>
</span>
</div>
<p className={['lbd-kpi-val tabular-nums', valueClassName(card.key, summary)].filter(Boolean).join(' ')}>
{value}
</p>
</div>
</article>
);
})}
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,192 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
import type { LeaseBusinessDetailRow, LeaseDetailFilters } from '../types';
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail';
import { DETAIL_PAYMENT_STATUS_OPTIONS } from '../utils/payment-status';
const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName', 'paymentStatus'] as const;
const EXTRA_KEYS = ['plateNos'] as const;
type FieldKey = (typeof PRIMARY_KEYS)[number] | (typeof EXTRA_KEYS)[number];
interface FilterFieldDef {
key: FieldKey;
label: string;
control: React.ReactNode;
}
interface FilterPanelProps {
records: LeaseBusinessDetailRow[];
filters: LeaseDetailFilters;
onChange: (patch: Partial<LeaseDetailFilters>) => void;
onReset: () => void;
onSearch: () => void;
}
function hasExtraFiltersActive(filters: LeaseDetailFilters): boolean {
return filters.plateNos.length > 0;
}
export function FilterPanel({
records,
filters,
onChange,
onReset,
onSearch,
}: FilterPanelProps) {
const [expanded, setExpanded] = useState(() => hasExtraFiltersActive(filters));
useEffect(() => {
if (hasExtraFiltersActive(filters)) setExpanded(true);
}, [filters.plateNos]);
const monthOptions = useMemo(() => buildDetailMonthOptions(records), [records]);
const deptOptions = useMemo(() => buildDetailOptions(records, 'businessDept'), [records]);
const salespersonOptions = useMemo(() => buildDetailOptions(records, 'salesperson'), [records]);
const fieldMap = useMemo<Record<FieldKey, FilterFieldDef>>(() => ({
statMonth: {
key: 'statMonth',
label: '统计月份',
control: (
<FilterPickerField
value={filters.statMonth}
options={monthOptions}
placeholder="全部月份"
ariaLabel="统计月份"
onChange={(statMonth) => onChange({ statMonth })}
/>
),
},
businessDept: {
key: 'businessDept',
label: '业务部门',
control: (
<FilterPickerField
value={filters.businessDept}
options={deptOptions}
placeholder="全部部门"
ariaLabel="业务部门"
onChange={(businessDept) => onChange({ businessDept })}
/>
),
},
salesperson: {
key: 'salesperson',
label: '业务员',
control: (
<FilterPickerField
value={filters.salesperson}
options={salespersonOptions}
placeholder="全部业务员"
ariaLabel="业务员"
onChange={(salesperson) => onChange({ salesperson })}
/>
),
},
customerName: {
key: 'customerName',
label: '客户名称',
control: (
<input
className="vm-input"
value={filters.customerName}
placeholder="输入客户名称(模糊匹配)"
aria-label="客户名称"
onChange={(e) => onChange({ customerName: e.target.value })}
/>
),
},
paymentStatus: {
key: 'paymentStatus',
label: '状态',
control: (
<FilterPickerField
value={filters.paymentStatus}
options={DETAIL_PAYMENT_STATUS_OPTIONS}
placeholder="全部状态"
ariaLabel="状态"
onChange={(paymentStatus) => onChange({ paymentStatus })}
/>
),
},
plateNos: {
key: 'plateNos',
label: '车牌号码',
control: (
<MultiPlateFilterField
value={filters.plateNos}
placeholder="点击输入多个车牌"
ariaLabel="车牌号码"
onChange={(plateNos) => onChange({ plateNos })}
/>
),
},
}), [filters, onChange, monthOptions, deptOptions, salespersonOptions]);
const primaryFields = PRIMARY_KEYS.map((key) => fieldMap[key]);
const extraFields = EXTRA_KEYS.map((key) => fieldMap[key]);
const extraActiveCount = filters.plateNos.length > 0 ? 1 : 0;
const renderField = (field: FilterFieldDef) => (
<label key={field.key} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
);
return (
<section
className="vm-filter-card ldb-filter-card"
data-annotation-id="lbd-filter"
aria-label="筛选条件"
>
<header className="vm-filter-header">
<h2 className="vm-filter-title"></h2>
</header>
<div className="ldb-filter-body">
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
{primaryFields.map(renderField)}
</div>
<div
className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}
aria-hidden={!expanded}
id="lbd-filter-expand-panel"
>
<div className="ldb-filter-expand-inner">
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
{expanded ? extraFields.map(renderField) : null}
</div>
</div>
</div>
</div>
<div className="vm-filter-actions ldb-filter-actions">
<button
type="button"
className="vm-btn vm-btn-link ldb-filter-toggle"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls="lbd-filter-expand-panel"
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
{expanded ? '收起' : '更多筛选'}
{extraActiveCount > 0 && !expanded ? (
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
{extraActiveCount}
</span>
) : null}
</button>
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
</button>
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
</button>
</div>
</section>
);
}

View File

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

View File

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

View File

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

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