迭代 ONE-OS 多原型:原型导航与版本追踪上线;车辆氢费明细成本单价校验增强;多业务台账/财务/工作台页面迭代与标注目录同步。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,97 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-07T09:35:00.000Z",
|
||||
"updatedAt": "2026-07-09T09:46:07.467Z",
|
||||
"prototypes": [
|
||||
{
|
||||
"id": "item-prototypes-lease-business-line-overview",
|
||||
"kind": "item",
|
||||
"title": "lease business line overview",
|
||||
"itemKey": "prototypes/lease-business-line-overview"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-business",
|
||||
"kind": "item",
|
||||
"title": "oneos web business",
|
||||
"itemKey": "prototypes/oneos-web-business"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-contract-template",
|
||||
"kind": "item",
|
||||
"title": "oneos web contract template",
|
||||
"itemKey": "prototypes/oneos-web-contract-template"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-data-analysis",
|
||||
"kind": "item",
|
||||
"title": "oneos web data analysis",
|
||||
"itemKey": "prototypes/oneos-web-data-analysis"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-finance",
|
||||
"kind": "item",
|
||||
"title": "oneos web finance",
|
||||
"itemKey": "prototypes/oneos-web-finance"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-h2-station",
|
||||
"kind": "item",
|
||||
"title": "oneos web h2 station",
|
||||
"itemKey": "prototypes/oneos-web-h2-station"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-help-center",
|
||||
"kind": "item",
|
||||
"title": "oneos web help center",
|
||||
"itemKey": "prototypes/oneos-web-help-center"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-lease-contract",
|
||||
"kind": "item",
|
||||
"title": "oneos web lease contract",
|
||||
"itemKey": "prototypes/oneos-web-lease-contract"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-ledger-data",
|
||||
"kind": "item",
|
||||
"title": "oneos web ledger data",
|
||||
"itemKey": "prototypes/oneos-web-ledger-data"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-login",
|
||||
"kind": "item",
|
||||
"title": "oneos web login",
|
||||
"itemKey": "prototypes/oneos-web-login"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-ops",
|
||||
"kind": "item",
|
||||
"title": "oneos web ops",
|
||||
"itemKey": "prototypes/oneos-web-ops"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-procurement",
|
||||
"kind": "item",
|
||||
"title": "oneos web procurement",
|
||||
"itemKey": "prototypes/oneos-web-procurement"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-requirements",
|
||||
"kind": "item",
|
||||
"title": "oneos web requirements",
|
||||
"itemKey": "prototypes/oneos-web-requirements"
|
||||
},
|
||||
{
|
||||
"id": "item-prototypes-oneos-web-vehicle-asset",
|
||||
"kind": "item",
|
||||
"title": "oneos web vehicle asset",
|
||||
"itemKey": "prototypes/oneos-web-vehicle-asset"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-prototype-nav",
|
||||
"kind": "item",
|
||||
"title": "原型导航",
|
||||
"itemKey": "prototypes/oneos-prototype-nav"
|
||||
},
|
||||
{
|
||||
"id": "folder-1782874576229-r8atbu",
|
||||
"kind": "folder",
|
||||
@@ -107,10 +197,16 @@
|
||||
"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": "自营业务台账",
|
||||
"title": "物流业务台账",
|
||||
"itemKey": "prototypes/self-operated-business-ledger"
|
||||
},
|
||||
{
|
||||
@@ -143,101 +239,16 @@
|
||||
"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:lease-business-line-overview",
|
||||
"kind": "item",
|
||||
"title": "业务条线说明",
|
||||
"itemKey": "prototypes/lease-business-line-overview"
|
||||
},
|
||||
{
|
||||
"id": "folder-prototypes-oneos-web",
|
||||
"kind": "folder",
|
||||
"title": "ONE-OS Web 端",
|
||||
"children": [
|
||||
{
|
||||
"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",
|
||||
"title": "数据分析",
|
||||
"itemKey": "prototypes/oneos-web-data-analysis"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-ledger-data",
|
||||
"kind": "item",
|
||||
"title": "台账数据",
|
||||
"itemKey": "prototypes/oneos-web-ledger-data"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-business",
|
||||
"kind": "item",
|
||||
"title": "业务管理",
|
||||
"itemKey": "prototypes/oneos-web-business"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-ops",
|
||||
"kind": "item",
|
||||
"title": "运维管理",
|
||||
"itemKey": "prototypes/oneos-web-ops"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-requirements",
|
||||
"kind": "item",
|
||||
"title": "需求说明",
|
||||
"itemKey": "prototypes/oneos-web-requirements"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"docs": [],
|
||||
|
||||
57
.cursor/rules/prototype-nav-sync.mdc
Normal file
57
.cursor/rules/prototype-nav-sync.mdc
Normal 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` 与各原型标注目录(`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` 的变更记入主业务原型版本(导航页自身变更可忽略)
|
||||
@@ -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');
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"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: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",
|
||||
|
||||
417
rules/global-design-spec.md
Normal file
417
rules/global-design-spec.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# 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 |
|
||||
| 页面基础字号 | `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`(紧凑模式 16–20px) |
|
||||
| 筛选栅格间距 | `16px 20px` |
|
||||
| 区块垂直节奏 | 筛选 / KPI / 工具栏 / 表格之间约 **16–20px** |
|
||||
| `--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` | 详情/编辑顶栏返回 |
|
||||
|
||||
**规格**:高度 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`;圆角 8–12px;body `maxHeight: 78vh` + 滚动 |
|
||||
| 次要按钮 | `vm-btn-ghost` 或 Ant 默认 + 模块封装 `renderH2GhostButton` 等 |
|
||||
| 主按钮 | 主题绿 `#32a06e` |
|
||||
| 表单 | `layout="vertical"`;标签 13px / 600(模块内表单) |
|
||||
| 明细表 | `ln-modal-detail-table`;见 4.7 |
|
||||
|
||||
### 4.12 统计卡(下钻/对账等)
|
||||
|
||||
模块内常用模式(非全局类名,保持视觉一致即可):
|
||||
|
||||
- 白底 + 1px 边框 + 12px 圆角。
|
||||
- 标签 12–13px muted;数值 加粗 + `tabular-nums`。
|
||||
- 三列横排统计卡间距 12–16px。
|
||||
|
||||
---
|
||||
|
||||
## 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 与组件规范 |
|
||||
@@ -159,6 +159,14 @@ function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
writeJson(navMenuPath, { prototypes: sidebarItems });
|
||||
console.log('[sync] oneos-prototype-nav: 已更新 nav-menu.json');
|
||||
return true;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
|
||||
@@ -183,6 +191,7 @@ function main() {
|
||||
}
|
||||
}
|
||||
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
|
||||
syncPrototypeNavMenu(projectRoot, sidebarItems);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
339
scripts/sync-prototype-nav-registry.mjs
Normal file
339
scripts/sync-prototype-nav-registry.mjs
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 原型导航注册表:检测原型变更、递增版本、写入变更日志,并同步 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));
|
||||
}
|
||||
|
||||
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);
|
||||
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) 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();
|
||||
}
|
||||
130
scripts/sync-xll-nav-menu.mjs
Normal file
130
scripts/sync-xll-nav-menu.mjs
Normal 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();
|
||||
}
|
||||
10052
src/prototypes/business-dept-ledger/data/excel-2026-seed.json
Normal file
10052
src/prototypes/business-dept-ledger/data/excel-2026-seed.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,12 @@
|
||||
* 自 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';
|
||||
@@ -12,6 +16,7 @@ import BusinessDeptLedgerPage from '../oneos-web-data-analysis/pages/05-业务
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
window.dayjs = dayjs;
|
||||
|
||||
export default function BusinessDeptLedger() {
|
||||
useEffect(() => {
|
||||
|
||||
335
src/prototypes/business-dept-ledger/styles/index.css
Normal file
335
src/prototypes/business-dept-ledger/styles/index.css
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 业务部台账 — 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-border);
|
||||
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-subtle);
|
||||
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-radius: var(--ln-radius-card);
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-container {
|
||||
border-radius: var(--ln-radius-card);
|
||||
}
|
||||
|
||||
.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 {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 10px !important;
|
||||
height: 38px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th {
|
||||
color: var(--ln-ink) !important;
|
||||
font-weight: 600 !important;
|
||||
background: var(--ln-surface-2) !important;
|
||||
border-bottom: 1px solid var(--ln-border) !important;
|
||||
border-inline-end: 1px solid var(--ln-border-subtle) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:first-child > th {
|
||||
text-align: center;
|
||||
background: var(--ln-surface-3) !important;
|
||||
border-bottom: 2px solid var(--ln-border) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
|
||||
color: var(--ln-body);
|
||||
border-bottom: 1px solid var(--ln-border-subtle) !important;
|
||||
border-inline-end: 1px solid var(--ln-border-subtle) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td {
|
||||
background: var(--ln-accent-soft) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td {
|
||||
font-weight: 700;
|
||||
background: var(--ln-surface-2) !important;
|
||||
color: var(--ln-ink) !important;
|
||||
border-top: 2px solid var(--ln-border) !important;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td {
|
||||
font-weight: 700;
|
||||
background: var(--ln-surface-2) !important;
|
||||
color: var(--ln-ink) !important;
|
||||
border-top: 2px solid var(--ln-border) !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-2) !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);
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .biz-standbook-perf-link {
|
||||
cursor: pointer;
|
||||
color: var(--ln-primary);
|
||||
padding: 4px 8px;
|
||||
margin: -4px -8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.vm-page.bdl-page .biz-standbook-perf-link:hover {
|
||||
color: var(--ln-primary-strong);
|
||||
background: var(--ln-accent-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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "contract-template-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783426129808,
|
||||
"updatedAt": 1783589987162,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ct-list-filter",
|
||||
@@ -334,6 +334,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -348,112 +355,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import {
|
||||
getContractNameLabel,
|
||||
normalizeContractType,
|
||||
resolveContractName,
|
||||
resolveContractType,
|
||||
} from './contract-template-types.js';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "customer-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783352657922,
|
||||
"updatedAt": 1783589987164,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "cm-list-filter",
|
||||
@@ -251,6 +251,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -265,112 +272,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "insurance-procurement",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783352657923,
|
||||
"updatedAt": 1783589987165,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ipc-header",
|
||||
@@ -502,6 +502,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -516,112 +523,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 样例 1–6 月 |
|
||||
| 范围缺口 | **未覆盖**:盈亏月度汇总 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`(未使用双子代理;在同一审查内先建立业务基线再对照源码,结论仍基于双源交叉验证)。
|
||||
138
src/prototypes/lease-business-detail/annotation-source.json
Normal file
138
src/prototypes/lease-business-detail/annotation-source.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-detail",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783589987165,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lbd-filter",
|
||||
"index": 1,
|
||||
"title": "筛选条件",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"lbd-filter\"]"
|
||||
],
|
||||
"fingerprint": "lbd-filter",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "统计月份、业务部门、业务员、客户、车牌筛选;业管人员按月份维护明细。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1783488000000,
|
||||
"updatedAt": 1783488000000
|
||||
},
|
||||
{
|
||||
"id": "lbd-kpi",
|
||||
"index": 2,
|
||||
"title": "KPI 汇总",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"lbd-kpi\"]"
|
||||
],
|
||||
"fingerprint": "lbd-kpi",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "7 项关键指标:应收/押金/实收/里程减免/其他减免/未收/盈亏。完整 23 项汇总见列表底部汇总行。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#059669",
|
||||
"images": [],
|
||||
"createdAt": 1783488000000,
|
||||
"updatedAt": 1783488000000
|
||||
},
|
||||
{
|
||||
"id": "lbd-toolbar",
|
||||
"index": 3,
|
||||
"title": "批量导入导出",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"lbd-toolbar\"]"
|
||||
],
|
||||
"fingerprint": "lbd-toolbar",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "下载模板仅含手工录入列;导入后自动计算应收合计、月度收入、未收、车辆实际成本、总成本、盈亏。导出含完整列。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#7c3aed",
|
||||
"images": [],
|
||||
"createdAt": 1783488000000,
|
||||
"updatedAt": 1783488000000
|
||||
},
|
||||
{
|
||||
"id": "lbd-table",
|
||||
"index": 4,
|
||||
"title": "明细列表",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"lbd-table\"]"
|
||||
],
|
||||
"fingerprint": "lbd-table",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "宽表 47 列;底部汇总行展示 23 项金额合计(对齐 Excel SUBTOTAL);未收>0 标红。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#ea580c",
|
||||
"images": [],
|
||||
"createdAt": 1783488000000,
|
||||
"updatedAt": 1783488000000
|
||||
}
|
||||
],
|
||||
"directory": {
|
||||
"roots": [
|
||||
{
|
||||
"id": "root-list",
|
||||
"label": "租赁业务明细",
|
||||
"children": [
|
||||
{
|
||||
"id": "route-list",
|
||||
"type": "route",
|
||||
"route": "list"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markdownNodes": []
|
||||
}
|
||||
},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "oneos-project-nav",
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
"title": "OneOS",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-workbench",
|
||||
"title": "工作台",
|
||||
"href": "/prototypes/oneos-web-workbench",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Download, Upload, X } from 'lucide-react';
|
||||
|
||||
interface DetailImportModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDownloadTemplate: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function DetailImportModal({
|
||||
open,
|
||||
onClose,
|
||||
onDownloadTemplate,
|
||||
onImport,
|
||||
}: 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}>
|
||||
<Download size={16} aria-hidden />
|
||||
下载明细导入模板
|
||||
</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>
|
||||
<p className="hint">支持 .xls、.xlsx;必填:年份、月份、车牌号码。应收合计、月度收入、未收、车辆实际成本、总成本、盈亏由系统自动计算</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>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/prototypes/lease-business-detail/components/DetailKpiRow.tsx
Normal file
132
src/prototypes/lease-business-detail/components/DetailKpiRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
314
src/prototypes/lease-business-detail/components/DetailTable.tsx
Normal file
314
src/prototypes/lease-business-detail/components/DetailTable.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import {
|
||||
DEFAULT_DETAIL_COLUMN_WIDTHS,
|
||||
DETAIL_SUMMARY_COLUMN_KEYS,
|
||||
DETAIL_TABLE_COLUMNS,
|
||||
DETAIL_TABLE_MIN_WIDTH,
|
||||
type DetailTableColumn,
|
||||
} from './tableColumns';
|
||||
import type { LeaseDetailKpiSummary } from '../types';
|
||||
import {
|
||||
displayDeposit,
|
||||
displayMoney,
|
||||
displayOutstanding,
|
||||
displayText,
|
||||
formatMoney,
|
||||
} from '../utils/detail';
|
||||
|
||||
interface DetailTableProps {
|
||||
records: LeaseBusinessDetailRow[];
|
||||
summary: LeaseDetailKpiSummary;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
const STICKY_LEFT_KEYS = ['year', '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 HeaderSort({
|
||||
title,
|
||||
sortActive,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: {
|
||||
title: string;
|
||||
sortActive?: boolean;
|
||||
sortDir?: SortDir;
|
||||
onSort: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`ldb-th-sort ${sortActive ? 'is-active' : ''}`} onClick={onSort}>
|
||||
<span className="ldb-th-label">{title}</span>
|
||||
<span className="ldb-th-sort-icon" aria-hidden>
|
||||
{sortActive
|
||||
? (sortDir === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />)
|
||||
: <ArrowUpDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCell(row: LeaseBusinessDetailRow, col: DetailTableColumn) {
|
||||
if (col.key === 'outstanding') {
|
||||
const { text, warn } = displayOutstanding(row.outstanding);
|
||||
if (!text) return '';
|
||||
return warn
|
||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||
: text;
|
||||
}
|
||||
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 === 'otherDeductionDetail' || col.key === 'remark') {
|
||||
const text = displayText(row[col.key]);
|
||||
return text ? <span className="lbd-cell-remark" title={text}>{text}</span> : '';
|
||||
}
|
||||
const raw = row[col.key as keyof LeaseBusinessDetailRow];
|
||||
if (col.money && typeof raw === 'number') return displayMoney(raw);
|
||||
if (col.key === 'year' || col.key === 'month' || col.key === 'avgDays') {
|
||||
return raw === 0 ? '' : String(raw);
|
||||
}
|
||||
return displayText(raw);
|
||||
}
|
||||
|
||||
function renderSummaryCell(col: DetailTableColumn, summary: LeaseDetailKpiSummary) {
|
||||
if (col.key === 'year') {
|
||||
return <span className="lbd-summary-label">汇总</span>;
|
||||
}
|
||||
if (!DETAIL_SUMMARY_COLUMN_KEYS.has(col.key)) return '';
|
||||
|
||||
const value = summary[col.key as keyof LeaseDetailKpiSummary];
|
||||
if (col.key === 'outstanding') {
|
||||
const { text, warn } = displayOutstanding(value);
|
||||
if (!text) return '';
|
||||
return warn
|
||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||
: text;
|
||||
}
|
||||
if (col.key === 'profitLoss') {
|
||||
const text = formatMoney(value);
|
||||
const cls = value >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg';
|
||||
return <span className={`tabular-nums ${cls}`}>{text}</span>;
|
||||
}
|
||||
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 }: 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 = a[sortKey as keyof LeaseBusinessDetailRow];
|
||||
const bv = b[sortKey as keyof LeaseBusinessDetailRow];
|
||||
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,
|
||||
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 }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{DETAIL_TABLE_COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
col.money ? 'ldb-col-money tabular-nums' : '',
|
||||
col.calculated ? 'lbd-col-calc' : '',
|
||||
stickyClass(col),
|
||||
'ldb-col-resizable',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={stickyStyle(col, stickyLayout, true)}
|
||||
>
|
||||
{col.dateSort ? (
|
||||
<HeaderSort
|
||||
title={col.title}
|
||||
sortActive={sortKey === col.key}
|
||||
sortDir={sortDir}
|
||||
onSort={() => handleSort(col.key)}
|
||||
/>
|
||||
) : (
|
||||
col.title
|
||||
)}
|
||||
<span
|
||||
className="ldb-col-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`调整${col.title}列宽`}
|
||||
onMouseDown={(event) => handleResizeStart(col.key, event)}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRecords.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{DETAIL_TABLE_COLUMNS.map((col) => (
|
||||
<td
|
||||
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)}
|
||||
</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>
|
||||
))}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/prototypes/lease-business-detail/components/FilterPanel.tsx
Normal file
180
src/prototypes/lease-business-detail/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
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';
|
||||
|
||||
const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName'] 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 })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
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"
|
||||
>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
{extraActiveCount > 0 && !expanded ? (
|
||||
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
|
||||
{extraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
110
src/prototypes/lease-business-detail/components/tableColumns.ts
Normal file
110
src/prototypes/lease-business-detail/components/tableColumns.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export interface DetailTableColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
money?: boolean;
|
||||
className?: string;
|
||||
sticky?: 'left' | 'right';
|
||||
defaultWidth?: number;
|
||||
/** 日期类列,支持表头正序/倒序 */
|
||||
dateSort?: boolean;
|
||||
/** Excel 公式列,系统计算、导入模板不含 */
|
||||
calculated?: boolean;
|
||||
}
|
||||
|
||||
/** 列表表头列(严格对齐业务明细 Excel 样表) */
|
||||
export const DETAIL_TABLE_COLUMNS: DetailTableColumn[] = [
|
||||
{ key: 'year', title: '年份', defaultWidth: 64, sticky: 'left', className: 'tabular-nums' },
|
||||
{ key: 'month', title: '月份', defaultWidth: 56, className: 'tabular-nums' },
|
||||
{ key: 'businessDept', title: '业务部门', defaultWidth: 96 },
|
||||
{ key: 'salesperson', title: '业务员', defaultWidth: 88 },
|
||||
{ key: 'paymentDate', title: '付款日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'plateNo', title: '车牌号码', defaultWidth: 110, sticky: 'left', className: 'lbd-col-plate' },
|
||||
{ key: 'vehicleType', title: '车型', defaultWidth: 72 },
|
||||
{ key: 'customerName', title: '客户名称', className: 'ldb-col-name', defaultWidth: 200 },
|
||||
{ key: 'valueAddedService', title: '增值服务', defaultWidth: 96 },
|
||||
{ key: 'exemptionPolicy', title: '享免政策', defaultWidth: 120 },
|
||||
{ key: 'pickupDate', title: '提车日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'contractStartDate', title: '合同生效日期', defaultWidth: 118, dateSort: true },
|
||||
{ key: 'contractEndDate', title: '合同到期日期', defaultWidth: 118, dateSort: true },
|
||||
{ key: 'periodStartDate', title: '起始日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'returnDate', title: '退车日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'avgDays', title: '平均天数', defaultWidth: 88, className: 'tabular-nums' },
|
||||
{ key: 'deposit', title: '押金', money: true, defaultWidth: 100 },
|
||||
{ key: 'contractRent', title: '合同标的租金', money: true, defaultWidth: 110 },
|
||||
{ key: 'receivableTotal', title: '应收合计', money: true, defaultWidth: 100, calculated: true },
|
||||
{ key: 'receivableRent', title: '应收租金', money: true, defaultWidth: 100 },
|
||||
{ key: 'monthlyIncome', title: '月度收入', money: true, defaultWidth: 100, calculated: true },
|
||||
{ key: 'monthlyRent', title: '月度租金', money: true, defaultWidth: 100 },
|
||||
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, defaultWidth: 110 },
|
||||
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, defaultWidth: 100 },
|
||||
{ key: 'opsFeeIncome', title: '运维费', money: true, defaultWidth: 90 },
|
||||
{ key: 'otherIncome', title: '其他收入', money: true, defaultWidth: 100 },
|
||||
{ key: 'mileageDeduction', title: '里程减免金额', money: true, defaultWidth: 110 },
|
||||
{ key: 'otherDeduction', title: '其他减免金额', money: true, defaultWidth: 110 },
|
||||
{ key: 'receivedAmount', title: '实收金额', money: true, defaultWidth: 100 },
|
||||
{ key: 'outstanding', title: '未收', money: true, defaultWidth: 90, calculated: true },
|
||||
{ key: 'invoiceDate', title: '开票日期', defaultWidth: 108, dateSort: true },
|
||||
{ key: 'actualPaymentDate', title: '实际付款日期', defaultWidth: 118, dateSort: true },
|
||||
{ key: 'paymentMethod', title: '付款方式', defaultWidth: 100 },
|
||||
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, defaultWidth: 110 },
|
||||
{ key: 'vehicleActualCost', title: '车辆实际成本', money: true, defaultWidth: 110, calculated: true },
|
||||
{ key: 'insuranceFee', title: '保险费', money: true, defaultWidth: 90 },
|
||||
{ key: 'hydrogenFee', title: '氢费', money: true, defaultWidth: 90 },
|
||||
{ key: 'opsFeeCost', title: '运维费', money: true, defaultWidth: 90 },
|
||||
{ key: 'brokerageFee', title: '居间费', money: true, defaultWidth: 90 },
|
||||
{ key: 'otherCost', title: '其他', money: true, defaultWidth: 90 },
|
||||
{ key: 'totalCost', title: '总成本', money: true, defaultWidth: 100, calculated: true },
|
||||
{ key: 'profitLoss', title: '盈亏', money: true, defaultWidth: 90, calculated: true },
|
||||
{ key: 'gpsMileage', title: 'GPS里程', defaultWidth: 96, className: 'tabular-nums' },
|
||||
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', defaultWidth: 160 },
|
||||
{ key: 'assetOwner', title: '资产归属', defaultWidth: 140 },
|
||||
{ key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', defaultWidth: 180 },
|
||||
{ key: 'remark', title: '备注', className: 'ldb-col-remark', defaultWidth: 140 },
|
||||
];
|
||||
|
||||
/** 列表导出表头(含公式列) */
|
||||
export const DETAIL_EXPORT_HEADERS = DETAIL_TABLE_COLUMNS.map((col) => col.title);
|
||||
|
||||
/** 导入模板表头(仅手工录入列,不含公式列) */
|
||||
export const DETAIL_IMPORT_TEMPLATE_HEADERS = DETAIL_TABLE_COLUMNS
|
||||
.filter((col) => !col.calculated)
|
||||
.map((col) => col.title);
|
||||
|
||||
/** @deprecated 使用 DETAIL_EXPORT_HEADERS / DETAIL_IMPORT_TEMPLATE_HEADERS */
|
||||
export const DETAIL_IMPORT_HEADERS = DETAIL_EXPORT_HEADERS;
|
||||
|
||||
export const DEFAULT_DETAIL_COLUMN_WIDTHS: Record<string, number> = Object.fromEntries(
|
||||
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,
|
||||
);
|
||||
|
||||
/** 表尾汇总行:对齐 Excel SUBTOTAL 的 23 个金额列 */
|
||||
export const DETAIL_SUMMARY_COLUMN_KEYS = new Set([
|
||||
'deposit',
|
||||
'contractRent',
|
||||
'receivableTotal',
|
||||
'receivableRent',
|
||||
'monthlyIncome',
|
||||
'monthlyRent',
|
||||
'maintenanceIncome',
|
||||
'insuranceSurcharge',
|
||||
'opsFeeIncome',
|
||||
'otherIncome',
|
||||
'mileageDeduction',
|
||||
'otherDeduction',
|
||||
'receivedAmount',
|
||||
'outstanding',
|
||||
'vehicleStandardCost',
|
||||
'vehicleActualCost',
|
||||
'insuranceFee',
|
||||
'hydrogenFee',
|
||||
'opsFeeCost',
|
||||
'brokerageFee',
|
||||
'otherCost',
|
||||
'totalCost',
|
||||
'profitLoss',
|
||||
]);
|
||||
27652
src/prototypes/lease-business-detail/data/rows.json
Normal file
27652
src/prototypes/lease-business-detail/data/rows.json
Normal file
File diff suppressed because it is too large
Load Diff
176
src/prototypes/lease-business-detail/index.tsx
Normal file
176
src/prototypes/lease-business-detail/index.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @name 租赁业务明细
|
||||
* 设计规范:ledger-shared/DESIGN.md · vm-page + ldb-page
|
||||
*/
|
||||
import '../vehicle-management/style.css';
|
||||
import '../lease-business-ledger/styles/index.css';
|
||||
import './styles/index.css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Download, FileUp } from 'lucide-react';
|
||||
import {
|
||||
AnnotationViewer,
|
||||
type AnnotationDirectoryRouteNode,
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import seedRows from './data/rows.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { DetailKpiRow } from './components/DetailKpiRow';
|
||||
import { DetailTable } from './components/DetailTable';
|
||||
import { DetailImportModal } from './components/DetailImportModal';
|
||||
import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
|
||||
import {
|
||||
EMPTY_LEASE_DETAIL_FILTERS,
|
||||
type LeaseBusinessDetailRow,
|
||||
type LeaseDetailFilters,
|
||||
} from './types';
|
||||
import { applyDetailFilters, sumDetailKpi } from './utils/detail';
|
||||
import { applyDetailCalculations } from './utils/calc-detail';
|
||||
import {
|
||||
downloadDetailImportTemplate,
|
||||
exportDetailRows,
|
||||
parseDetailImportFile,
|
||||
} from './utils/batch-io';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
export default function LeaseBusinessDetailApp() {
|
||||
const [records, setRecords] = useState<LeaseBusinessDetailRow[]>(() =>
|
||||
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations(row)),
|
||||
);
|
||||
const [pendingFilters, setPendingFilters] = useState<LeaseDetailFilters>(EMPTY_LEASE_DETAIL_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<LeaseDetailFilters>(EMPTY_LEASE_DETAIL_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 3000);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyDetailFilters(records, appliedFilters),
|
||||
[records, appliedFilters],
|
||||
);
|
||||
const kpi = useMemo(() => sumDetailKpi(filtered), [filtered]);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, page, pageSize]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setTableLoading(true);
|
||||
window.setTimeout(() => {
|
||||
setAppliedFilters(pendingFilters);
|
||||
setPage(1);
|
||||
setTableLoading(false);
|
||||
}, 280);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_LEASE_DETAIL_FILTERS);
|
||||
setAppliedFilters(EMPTY_LEASE_DETAIL_FILTERS);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const imported = await parseDetailImportFile(file);
|
||||
if (imported.length === 0) {
|
||||
showToast('未解析到有效数据,请检查模板与必填列(年份、月份、车牌号码)');
|
||||
return;
|
||||
}
|
||||
setRecords((prev) => [...imported, ...prev]);
|
||||
setImportOpen(false);
|
||||
setPage(1);
|
||||
showToast(`已导入 ${imported.length} 条明细,公式列已自动计算`);
|
||||
} catch {
|
||||
showToast('导入失败,请检查文件格式');
|
||||
}
|
||||
};
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'list',
|
||||
onDirectoryRoute: (_node: AnnotationDirectoryRouteNode) => {},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vm-page ldb-page lc-page lc-page--list-dense lbd-page">
|
||||
<FilterPanel
|
||||
records={records}
|
||||
filters={pendingFilters}
|
||||
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
|
||||
onReset={handleReset}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
|
||||
<DetailKpiRow summary={kpi} />
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="lbd-toolbar">
|
||||
<div className="vm-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||
onClick={() => {
|
||||
exportDetailRows(filtered);
|
||||
showToast(`已导出 ${filtered.length} 条明细`);
|
||||
}}
|
||||
>
|
||||
<Download size={16} aria-hidden />
|
||||
批量导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vm-table-card">
|
||||
<DetailTable records={paged} summary={kpi} loading={tableLoading} />
|
||||
<div className="vm-table-footer ldb-table-footer">
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
||||
</div>
|
||||
|
||||
<DetailImportModal
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onDownloadTemplate={downloadDetailImportTemplate}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
|
||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
350
src/prototypes/lease-business-detail/styles/index.css
Normal file
350
src/prototypes/lease-business-detail/styles/index.css
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 租赁业务明细 — 模块样式(对齐租赁合同 lc-page + 台账 ldb-page)
|
||||
*/
|
||||
@import url('../../lease-contract-management/styles/lease-contract.css');
|
||||
|
||||
.lbd-page.vm-page.lc-page {
|
||||
--lbd-list-chrome-offset: 300px;
|
||||
}
|
||||
|
||||
.lbd-page .vm-filter-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* —— KPI 汇总卡片(7 项关键指标) —— */
|
||||
.lbd-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.lbd-kpi-row {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lbd-kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.lbd-kpi-card {
|
||||
--lbd-kpi-accent: var(--ln-primary);
|
||||
--lbd-kpi-accent-soft: color-mix(in srgb, var(--lbd-kpi-accent) 12%, var(--ln-surface-card));
|
||||
--lbd-kpi-accent-border: color-mix(in srgb, var(--lbd-kpi-accent) 22%, var(--ln-hairline));
|
||||
--lbd-kpi-icon-bg: color-mix(in srgb, var(--lbd-kpi-accent) 14%, var(--ln-surface-card));
|
||||
--lbd-kpi-icon-color: var(--lbd-kpi-accent);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
border-radius: var(--ln-radius-control);
|
||||
border: 1px solid var(--lbd-kpi-accent-border);
|
||||
background:
|
||||
linear-gradient(
|
||||
145deg,
|
||||
var(--lbd-kpi-accent-soft) 0%,
|
||||
var(--ln-surface-card) 58%
|
||||
);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.lbd-kpi-card:hover {
|
||||
border-color: color-mix(in srgb, var(--lbd-kpi-accent) 38%, var(--ln-hairline));
|
||||
box-shadow:
|
||||
0 2px 6px rgba(15, 23, 42, 0.05),
|
||||
0 10px 24px rgba(15, 23, 42, 0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lbd-kpi-card-accent {
|
||||
flex-shrink: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--lbd-kpi-accent) 0%,
|
||||
color-mix(in srgb, var(--lbd-kpi-accent) 55%, transparent) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.lbd-kpi-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 8px 10px 8px 8px;
|
||||
}
|
||||
|
||||
.lbd-kpi-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lbd-kpi-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--ln-radius-xs);
|
||||
background: var(--lbd-kpi-icon-bg);
|
||||
color: var(--lbd-kpi-icon-color);
|
||||
}
|
||||
|
||||
.lbd-kpi-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-muted);
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.lbd-kpi-val {
|
||||
margin: 0;
|
||||
padding-left: 30px;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--ln-ink);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.lbd-kpi-val--warn {
|
||||
color: var(--ln-error, #cf1322);
|
||||
}
|
||||
|
||||
.lbd-kpi-val--profit {
|
||||
color: var(--ln-success, #16a34a);
|
||||
}
|
||||
|
||||
.lbd-kpi-val--loss {
|
||||
color: var(--ln-error, #dc2626);
|
||||
}
|
||||
|
||||
.lbd-kpi-tip {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: -2px -4px -2px 0;
|
||||
border-radius: var(--ln-radius-xs);
|
||||
color: var(--ln-muted-soft);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.lbd-kpi-tip:hover,
|
||||
.lbd-kpi-tip:focus-visible {
|
||||
color: var(--ln-ink);
|
||||
background: color-mix(in srgb, var(--ln-ink) 6%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lbd-kpi-tip:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.lbd-kpi-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 20;
|
||||
width: max-content;
|
||||
max-width: min(240px, 70vw);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: var(--ln-ink);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
box-shadow: var(--ln-shadow-hover);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lbd-kpi-tip:hover .lbd-kpi-tooltip,
|
||||
.lbd-kpi-tip:focus-within .lbd-kpi-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lbd-kpi-card--neutral {
|
||||
--lbd-kpi-accent: #64748b;
|
||||
}
|
||||
|
||||
.lbd-kpi-card--receivable {
|
||||
--lbd-kpi-accent: #2563eb;
|
||||
}
|
||||
|
||||
.lbd-kpi-card--received {
|
||||
--lbd-kpi-accent: var(--ln-success, #16a34a);
|
||||
}
|
||||
|
||||
.lbd-kpi-card--outstanding {
|
||||
--lbd-kpi-accent: #d97706;
|
||||
}
|
||||
|
||||
.lbd-kpi-card--outstanding.lbd-kpi-card--warn {
|
||||
--lbd-kpi-accent: var(--ln-error, #dc2626);
|
||||
--lbd-kpi-accent-soft: color-mix(in srgb, var(--ln-error, #dc2626) 10%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-kpi-card--income {
|
||||
--lbd-kpi-accent: var(--ln-primary);
|
||||
}
|
||||
|
||||
.lbd-kpi-card--cost {
|
||||
--lbd-kpi-accent: #7c3aed;
|
||||
}
|
||||
|
||||
.lbd-kpi-card--deduction {
|
||||
--lbd-kpi-accent: #0891b2;
|
||||
}
|
||||
|
||||
.lbd-kpi-card--profit {
|
||||
--lbd-kpi-accent: #059669;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lbd-kpi-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.lbd-kpi-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lbd-page .vm-table-toolbar.ldb-table-toolbar {
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lbd-table-wrap {
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-height: calc(100vh - var(--lbd-list-chrome-offset, 420px));
|
||||
}
|
||||
|
||||
/* lc-page 默认 overflow:hidden 会阻断宽表横向滚动 */
|
||||
.lbd-page.lc-page .vm-table-card > .vm-table-wrap.lbd-table-wrap {
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lbd-page.lc-page .vm-table-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.lbd-page .lbd-detail-table.ldb-table {
|
||||
min-width: 0;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.lbd-detail-table th,
|
||||
.lbd-detail-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lbd-detail-table tbody tr:hover td {
|
||||
background: var(--ln-canvas-soft);
|
||||
}
|
||||
|
||||
.lbd-detail-table tbody tr:hover td.sticky-col-left {
|
||||
background: var(--ln-canvas-soft);
|
||||
}
|
||||
|
||||
.lbd-col-plate {
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.lbd-cell-customer {
|
||||
display: inline-block;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.lbd-detail-table .lbd-col-calc {
|
||||
color: var(--ln-muted);
|
||||
background: color-mix(in srgb, var(--ln-muted-soft) 6%, transparent);
|
||||
}
|
||||
|
||||
.lbd-detail-table thead th.lbd-col-calc {
|
||||
background: color-mix(in srgb, var(--ln-muted-soft) 10%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-detail-table tfoot .lbd-summary-row td {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
|
||||
border-top: 2px solid color-mix(in srgb, var(--ln-primary) 22%, var(--ln-hairline));
|
||||
box-shadow: 0 -1px 0 var(--ln-hairline);
|
||||
}
|
||||
|
||||
.lbd-detail-table tfoot .lbd-summary-row td.sticky-col-left {
|
||||
z-index: 5;
|
||||
background: color-mix(in srgb, var(--ln-primary) 8%, var(--ln-surface-card));
|
||||
}
|
||||
|
||||
.lbd-summary-label {
|
||||
font-weight: 700;
|
||||
color: var(--ln-ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lbd-outstanding-warn {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lbd-profit-pos {
|
||||
color: var(--ln-success, #16a34a);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lbd-profit-neg {
|
||||
color: var(--ln-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lbd-cell-remark {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.lbd-empty {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.lbd-page .vm-table-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
120
src/prototypes/lease-business-detail/types.ts
Normal file
120
src/prototypes/lease-business-detail/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export interface LeaseBusinessDetailRow {
|
||||
id: string;
|
||||
year: number;
|
||||
month: number;
|
||||
businessDept: string;
|
||||
salesperson: string;
|
||||
paymentDate: string;
|
||||
plateNo: string;
|
||||
vehicleType: string;
|
||||
customerName: string;
|
||||
valueAddedService: string;
|
||||
exemptionPolicy: string;
|
||||
pickupDate: string;
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
periodStartDate: string;
|
||||
returnDate: string;
|
||||
avgDays: number;
|
||||
deposit: number | string;
|
||||
contractRent: number;
|
||||
receivableTotal: number;
|
||||
receivableRent: number;
|
||||
monthlyIncome: number;
|
||||
monthlyRent: number;
|
||||
maintenanceIncome: number;
|
||||
insuranceSurcharge: number;
|
||||
opsFeeIncome: number;
|
||||
otherIncome: number;
|
||||
mileageDeduction: number;
|
||||
otherDeduction: number;
|
||||
receivedAmount: number;
|
||||
outstanding: number;
|
||||
invoiceDate: string;
|
||||
actualPaymentDate: string;
|
||||
paymentMethod: string;
|
||||
vehicleStandardCost: number;
|
||||
vehicleActualCost: number;
|
||||
insuranceFee: number;
|
||||
hydrogenFee: number;
|
||||
opsFeeCost: number;
|
||||
brokerageFee: number;
|
||||
otherCost: number;
|
||||
totalCost: number;
|
||||
profitLoss: number;
|
||||
gpsMileage: string;
|
||||
otherDeductionDetail: string;
|
||||
assetOwner: string;
|
||||
signingCompany: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface LeaseDetailFilters {
|
||||
statMonth: string;
|
||||
businessDept: string;
|
||||
salesperson: string;
|
||||
/** 客户名称模糊匹配 */
|
||||
customerName: string;
|
||||
plateNos: string[];
|
||||
}
|
||||
|
||||
/** 对齐 Excel「租赁车辆收入明细表」表头上方 SUBTOTAL 汇总行 */
|
||||
export interface LeaseDetailKpiSummary {
|
||||
deposit: number;
|
||||
contractRent: number;
|
||||
receivableTotal: number;
|
||||
receivableRent: number;
|
||||
monthlyIncome: number;
|
||||
monthlyRent: number;
|
||||
maintenanceIncome: number;
|
||||
insuranceSurcharge: number;
|
||||
opsFeeIncome: number;
|
||||
otherIncome: number;
|
||||
mileageDeduction: number;
|
||||
otherDeduction: number;
|
||||
receivedAmount: number;
|
||||
outstanding: number;
|
||||
vehicleStandardCost: number;
|
||||
vehicleActualCost: number;
|
||||
insuranceFee: number;
|
||||
hydrogenFee: number;
|
||||
opsFeeCost: number;
|
||||
brokerageFee: number;
|
||||
otherCost: number;
|
||||
totalCost: number;
|
||||
profitLoss: number;
|
||||
}
|
||||
|
||||
export const EMPTY_LEASE_DETAIL_KPI: LeaseDetailKpiSummary = {
|
||||
deposit: 0,
|
||||
contractRent: 0,
|
||||
receivableTotal: 0,
|
||||
receivableRent: 0,
|
||||
monthlyIncome: 0,
|
||||
monthlyRent: 0,
|
||||
maintenanceIncome: 0,
|
||||
insuranceSurcharge: 0,
|
||||
opsFeeIncome: 0,
|
||||
otherIncome: 0,
|
||||
mileageDeduction: 0,
|
||||
otherDeduction: 0,
|
||||
receivedAmount: 0,
|
||||
outstanding: 0,
|
||||
vehicleStandardCost: 0,
|
||||
vehicleActualCost: 0,
|
||||
insuranceFee: 0,
|
||||
hydrogenFee: 0,
|
||||
opsFeeCost: 0,
|
||||
brokerageFee: 0,
|
||||
otherCost: 0,
|
||||
totalCost: 0,
|
||||
profitLoss: 0,
|
||||
};
|
||||
|
||||
export const EMPTY_LEASE_DETAIL_FILTERS: LeaseDetailFilters = {
|
||||
statMonth: '',
|
||||
businessDept: '',
|
||||
salesperson: '',
|
||||
customerName: '',
|
||||
plateNos: [],
|
||||
};
|
||||
207
src/prototypes/lease-business-detail/utils/batch-io.ts
Normal file
207
src/prototypes/lease-business-detail/utils/batch-io.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import {
|
||||
DETAIL_EXPORT_HEADERS,
|
||||
DETAIL_IMPORT_TEMPLATE_HEADERS,
|
||||
DETAIL_TABLE_COLUMNS,
|
||||
} from '../components/tableColumns';
|
||||
import { applyDetailCalculations } from './calc-detail';
|
||||
|
||||
const TEMPLATE_SAMPLE: Record<string, string | number> = {
|
||||
年份: 2026,
|
||||
月份: 2,
|
||||
业务部门: '业务二部',
|
||||
业务员: '刘念忠',
|
||||
付款日期: '2026-02-05',
|
||||
车牌号码: '沪A52898F',
|
||||
车型: '18T',
|
||||
客户名称: '上海虹钦物流有限公司',
|
||||
增值服务: '',
|
||||
享免政策: '',
|
||||
提车日期: '2025-12-08',
|
||||
合同生效日期: '2025-12-01',
|
||||
合同到期日期: '2026-11-30',
|
||||
起始日期: '2026-02-01',
|
||||
退车日期: '',
|
||||
平均天数: 28,
|
||||
押金: 20000,
|
||||
合同标的租金: 18500,
|
||||
应收租金: 18500,
|
||||
月度租金: 18500,
|
||||
维保包干收入: 0,
|
||||
保险上浮费: 0,
|
||||
运维费: 0,
|
||||
其他收入: 0,
|
||||
里程减免金额: 0,
|
||||
其他减免金额: 0,
|
||||
实收金额: 18500,
|
||||
开票日期: '2026-02-06',
|
||||
实际付款日期: '2026-02-05',
|
||||
付款方式: '月付指付',
|
||||
车辆标准成本: 12000,
|
||||
保险费: 0,
|
||||
氢费: 3200,
|
||||
居间费: 800,
|
||||
其他: 0,
|
||||
GPS里程: '3260',
|
||||
其他减免金额明细内容: '',
|
||||
资产归属: '浙江羚牛氢能科技有限公司',
|
||||
签约公司: '浙江羚牛氢能科技有限公司',
|
||||
备注: '',
|
||||
};
|
||||
|
||||
const HEADER_TO_KEY: Record<string, keyof LeaseBusinessDetailRow> = {
|
||||
年份: 'year',
|
||||
月份: 'month',
|
||||
业务部门: 'businessDept',
|
||||
业务员: 'salesperson',
|
||||
付款日期: 'paymentDate',
|
||||
车牌号码: 'plateNo',
|
||||
车型: 'vehicleType',
|
||||
客户名称: 'customerName',
|
||||
增值服务: 'valueAddedService',
|
||||
享免政策: 'exemptionPolicy',
|
||||
提车日期: 'pickupDate',
|
||||
合同生效日期: 'contractStartDate',
|
||||
合同到期日期: 'contractEndDate',
|
||||
起始日期: 'periodStartDate',
|
||||
退车日期: 'returnDate',
|
||||
平均天数: 'avgDays',
|
||||
押金: 'deposit',
|
||||
合同标的租金: 'contractRent',
|
||||
应收租金: 'receivableRent',
|
||||
月度租金: 'monthlyRent',
|
||||
维保包干收入: 'maintenanceIncome',
|
||||
保险上浮费: 'insuranceSurcharge',
|
||||
其他收入: 'otherIncome',
|
||||
里程减免金额: 'mileageDeduction',
|
||||
其他减免金额: 'otherDeduction',
|
||||
实收金额: 'receivedAmount',
|
||||
开票日期: 'invoiceDate',
|
||||
实际付款日期: 'actualPaymentDate',
|
||||
付款方式: 'paymentMethod',
|
||||
车辆标准成本: 'vehicleStandardCost',
|
||||
保险费: 'insuranceFee',
|
||||
氢费: 'hydrogenFee',
|
||||
居间费: 'brokerageFee',
|
||||
其他: 'otherCost',
|
||||
GPS里程: 'gpsMileage',
|
||||
其他减免金额明细内容: 'otherDeductionDetail',
|
||||
资产归属: 'assetOwner',
|
||||
签约公司: 'signingCompany',
|
||||
备注: 'remark',
|
||||
};
|
||||
|
||||
const IMPORT_NUMERIC_KEYS = new Set<keyof LeaseBusinessDetailRow>([
|
||||
'year', 'month', 'avgDays',
|
||||
'contractRent', 'receivableRent', 'monthlyRent',
|
||||
'maintenanceIncome', 'insuranceSurcharge', 'opsFeeIncome', 'otherIncome',
|
||||
'mileageDeduction', 'otherDeduction', 'receivedAmount',
|
||||
'vehicleStandardCost', 'insuranceFee', 'hydrogenFee',
|
||||
'opsFeeCost', 'brokerageFee', 'otherCost',
|
||||
]);
|
||||
|
||||
const IMPORT_TEXT_KEYS = new Set<keyof LeaseBusinessDetailRow>([
|
||||
'businessDept', 'salesperson', 'paymentDate', 'plateNo', 'vehicleType', 'customerName',
|
||||
'valueAddedService', 'exemptionPolicy', 'pickupDate', 'contractStartDate', 'contractEndDate',
|
||||
'periodStartDate', 'returnDate', 'invoiceDate', 'actualPaymentDate', 'paymentMethod',
|
||||
'gpsMileage', 'otherDeductionDetail', 'assetOwner', 'signingCompany', 'remark',
|
||||
]);
|
||||
|
||||
function parseNumber(value: unknown): number {
|
||||
if (value === null || value === undefined || value === '') return 0;
|
||||
const n = Number(String(value).replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function parseDeposit(value: unknown): number | string {
|
||||
if (value === null || value === undefined || value === '') return 0;
|
||||
const raw = String(value).trim();
|
||||
if (raw === '/') return '/';
|
||||
return parseNumber(raw);
|
||||
}
|
||||
|
||||
function rowToExportCells(row: LeaseBusinessDetailRow): string[] {
|
||||
let opsIncomeWritten = false;
|
||||
return DETAIL_TABLE_COLUMNS.map((col) => {
|
||||
const header = col.title;
|
||||
if (header === '运维费') {
|
||||
if (!opsIncomeWritten) {
|
||||
opsIncomeWritten = true;
|
||||
return row.opsFeeIncome === 0 ? '' : String(row.opsFeeIncome);
|
||||
}
|
||||
return row.opsFeeCost === 0 ? '' : String(row.opsFeeCost);
|
||||
}
|
||||
const key = col.key as keyof LeaseBusinessDetailRow;
|
||||
const raw = row[key];
|
||||
if (key === 'deposit') {
|
||||
if (raw === '/' || raw === '') return raw === '/' ? '/' : '';
|
||||
return typeof raw === 'number' && raw === 0 ? '' : String(raw);
|
||||
}
|
||||
if (typeof raw === 'number') return raw === 0 ? '' : String(raw);
|
||||
return raw == null ? '' : String(raw);
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadDetailImportTemplate(): void {
|
||||
const sampleRow = DETAIL_IMPORT_TEMPLATE_HEADERS.map((header) => TEMPLATE_SAMPLE[header] ?? '');
|
||||
const opsIndexes = DETAIL_IMPORT_TEMPLATE_HEADERS.reduce<number[]>((acc, h, i) => {
|
||||
if (h === '运维费') acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
if (opsIndexes[1] !== undefined) sampleRow[opsIndexes[1]] = 0;
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([DETAIL_IMPORT_TEMPLATE_HEADERS, sampleRow]);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
|
||||
XLSX.writeFile(book, '租赁业务明细导入模板.xlsx');
|
||||
}
|
||||
|
||||
export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
|
||||
const body = [DETAIL_EXPORT_HEADERS, ...rows.map(rowToExportCells)];
|
||||
const sheet = XLSX.utils.aoa_to_sheet(body);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
|
||||
XLSX.writeFile(book, `租赁业务明细_${Date.now()}.xlsx`);
|
||||
}
|
||||
|
||||
export async function parseDetailImportFile(file: File): Promise<LeaseBusinessDetailRow[]> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const book = XLSX.read(buffer, { type: 'array' });
|
||||
const sheet = book.Sheets[book.SheetNames[0]];
|
||||
const matrix = XLSX.utils.sheet_to_json<(string | number)[]>(sheet, { header: 1, defval: '' }) as (string | number)[][];
|
||||
if (matrix.length < 2) return [];
|
||||
|
||||
const headers = matrix[0].map((cell) => String(cell).trim());
|
||||
const rows: LeaseBusinessDetailRow[] = [];
|
||||
|
||||
matrix.slice(1).forEach((cells, index) => {
|
||||
if (!cells.some((cell) => String(cell).trim())) return;
|
||||
const draft: Partial<LeaseBusinessDetailRow> = {
|
||||
id: `import-${Date.now()}-${index}`,
|
||||
};
|
||||
let opsFeeColIndex = 0;
|
||||
|
||||
headers.forEach((header, colIndex) => {
|
||||
let key = HEADER_TO_KEY[header];
|
||||
if (header === '运维费') {
|
||||
opsFeeColIndex += 1;
|
||||
key = opsFeeColIndex === 1 ? 'opsFeeIncome' : 'opsFeeCost';
|
||||
}
|
||||
if (!key) return;
|
||||
const raw = cells[colIndex];
|
||||
if (key === 'deposit') {
|
||||
(draft as Record<string, unknown>)[key] = parseDeposit(raw);
|
||||
} else if (IMPORT_NUMERIC_KEYS.has(key)) {
|
||||
(draft as Record<string, unknown>)[key] = parseNumber(raw);
|
||||
} else if (IMPORT_TEXT_KEYS.has(key)) {
|
||||
(draft as Record<string, unknown>)[key] = String(raw ?? '').trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (!draft.plateNo || !draft.year || !draft.month) return;
|
||||
rows.push(applyDetailCalculations(draft));
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
176
src/prototypes/lease-business-detail/utils/calc-detail.ts
Normal file
176
src/prototypes/lease-business-detail/utils/calc-detail.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
|
||||
/** 对齐 Excel「租赁车辆收入明细表」公式列 */
|
||||
export const DETAIL_CALCULATED_KEYS = [
|
||||
'receivableTotal',
|
||||
'monthlyIncome',
|
||||
'outstanding',
|
||||
'vehicleActualCost',
|
||||
'totalCost',
|
||||
'profitLoss',
|
||||
] as const;
|
||||
|
||||
export type DetailCalculatedKey = (typeof DETAIL_CALCULATED_KEYS)[number];
|
||||
|
||||
function roundMoney(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
type IncomeAdjustments = Pick<
|
||||
LeaseBusinessDetailRow,
|
||||
| 'maintenanceIncome'
|
||||
| 'insuranceSurcharge'
|
||||
| 'opsFeeIncome'
|
||||
| 'otherIncome'
|
||||
| 'mileageDeduction'
|
||||
| 'otherDeduction'
|
||||
>;
|
||||
|
||||
/** 应收合计 = 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入) + 其他收入 + 里程减免 + 其他减免 */
|
||||
export function calcDetailReceivableTotal(
|
||||
receivableRent: number,
|
||||
adjustments: IncomeAdjustments,
|
||||
): number {
|
||||
return roundMoney(
|
||||
receivableRent
|
||||
+ adjustments.maintenanceIncome
|
||||
+ adjustments.insuranceSurcharge
|
||||
+ adjustments.opsFeeIncome
|
||||
+ adjustments.otherIncome
|
||||
+ adjustments.mileageDeduction
|
||||
+ adjustments.otherDeduction,
|
||||
);
|
||||
}
|
||||
|
||||
/** 月度收入 = 月度租金 + 收入侧调整项(同应收合计调整段) */
|
||||
export function calcDetailMonthlyIncome(
|
||||
monthlyRent: number,
|
||||
adjustments: IncomeAdjustments,
|
||||
): number {
|
||||
return roundMoney(
|
||||
monthlyRent
|
||||
+ adjustments.maintenanceIncome
|
||||
+ adjustments.insuranceSurcharge
|
||||
+ adjustments.opsFeeIncome
|
||||
+ adjustments.otherIncome
|
||||
+ adjustments.mileageDeduction
|
||||
+ adjustments.otherDeduction,
|
||||
);
|
||||
}
|
||||
|
||||
/** 车辆实际成本 = 车辆标准成本 × 平均天数 */
|
||||
export function calcDetailVehicleActualCost(vehicleStandardCost: number, avgDays: number): number {
|
||||
return roundMoney(vehicleStandardCost * avgDays);
|
||||
}
|
||||
|
||||
/** 总成本 = 车辆实际成本 + 保险费 + 氢费 + 运维费(成本) + 居间费 + 其他 */
|
||||
export function calcDetailTotalCost(
|
||||
row: Pick<
|
||||
LeaseBusinessDetailRow,
|
||||
'vehicleActualCost' | 'insuranceFee' | 'hydrogenFee' | 'opsFeeCost' | 'brokerageFee' | 'otherCost'
|
||||
>,
|
||||
): number {
|
||||
return roundMoney(
|
||||
row.vehicleActualCost
|
||||
+ row.insuranceFee
|
||||
+ row.hydrogenFee
|
||||
+ row.opsFeeCost
|
||||
+ row.brokerageFee
|
||||
+ row.otherCost,
|
||||
);
|
||||
}
|
||||
|
||||
/** 未收 = 应收合计 − 实收金额 */
|
||||
export function calcDetailOutstanding(receivableTotal: number, receivedAmount: number): number {
|
||||
return roundMoney(receivableTotal - receivedAmount);
|
||||
}
|
||||
|
||||
/** 盈亏 = 月度收入 − 总成本 */
|
||||
export function calcDetailProfitLoss(monthlyIncome: number, totalCost: number): number {
|
||||
return roundMoney(monthlyIncome - totalCost);
|
||||
}
|
||||
|
||||
const EMPTY_TEXT_FIELDS = {
|
||||
businessDept: '',
|
||||
salesperson: '',
|
||||
paymentDate: '',
|
||||
plateNo: '',
|
||||
vehicleType: '',
|
||||
customerName: '',
|
||||
valueAddedService: '',
|
||||
exemptionPolicy: '',
|
||||
pickupDate: '',
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
periodStartDate: '',
|
||||
returnDate: '',
|
||||
invoiceDate: '',
|
||||
actualPaymentDate: '',
|
||||
paymentMethod: '',
|
||||
gpsMileage: '',
|
||||
otherDeductionDetail: '',
|
||||
assetOwner: '',
|
||||
signingCompany: '',
|
||||
remark: '',
|
||||
} as const;
|
||||
|
||||
/** 补齐缺省字段并套用 Excel 公式列 */
|
||||
export function applyDetailCalculations(
|
||||
row: Partial<LeaseBusinessDetailRow> & { id: string },
|
||||
): LeaseBusinessDetailRow {
|
||||
const adjustments: IncomeAdjustments = {
|
||||
maintenanceIncome: row.maintenanceIncome ?? 0,
|
||||
insuranceSurcharge: row.insuranceSurcharge ?? 0,
|
||||
opsFeeIncome: row.opsFeeIncome ?? 0,
|
||||
otherIncome: row.otherIncome ?? 0,
|
||||
mileageDeduction: row.mileageDeduction ?? 0,
|
||||
otherDeduction: row.otherDeduction ?? 0,
|
||||
};
|
||||
|
||||
const receivableRent = row.receivableRent ?? 0;
|
||||
const monthlyRent = row.monthlyRent ?? 0;
|
||||
const receivedAmount = row.receivedAmount ?? 0;
|
||||
const vehicleStandardCost = row.vehicleStandardCost ?? 0;
|
||||
const avgDays = row.avgDays ?? 0;
|
||||
|
||||
const vehicleActualCost = calcDetailVehicleActualCost(vehicleStandardCost, avgDays);
|
||||
const monthlyIncome = calcDetailMonthlyIncome(monthlyRent, adjustments);
|
||||
const receivableTotal = calcDetailReceivableTotal(receivableRent, adjustments);
|
||||
const totalCost = calcDetailTotalCost({
|
||||
vehicleActualCost,
|
||||
insuranceFee: row.insuranceFee ?? 0,
|
||||
hydrogenFee: row.hydrogenFee ?? 0,
|
||||
opsFeeCost: row.opsFeeCost ?? 0,
|
||||
brokerageFee: row.brokerageFee ?? 0,
|
||||
otherCost: row.otherCost ?? 0,
|
||||
});
|
||||
const outstanding = calcDetailOutstanding(receivableTotal, receivedAmount);
|
||||
const profitLoss = calcDetailProfitLoss(monthlyIncome, totalCost);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
year: row.year ?? 0,
|
||||
month: row.month ?? 0,
|
||||
...EMPTY_TEXT_FIELDS,
|
||||
...row,
|
||||
deposit: row.deposit ?? 0,
|
||||
contractRent: row.contractRent ?? 0,
|
||||
receivableRent,
|
||||
monthlyRent,
|
||||
...adjustments,
|
||||
receivedAmount,
|
||||
vehicleStandardCost,
|
||||
avgDays,
|
||||
insuranceFee: row.insuranceFee ?? 0,
|
||||
hydrogenFee: row.hydrogenFee ?? 0,
|
||||
opsFeeCost: row.opsFeeCost ?? 0,
|
||||
brokerageFee: row.brokerageFee ?? 0,
|
||||
otherCost: row.otherCost ?? 0,
|
||||
vehicleActualCost,
|
||||
monthlyIncome,
|
||||
receivableTotal,
|
||||
totalCost,
|
||||
outstanding,
|
||||
profitLoss,
|
||||
};
|
||||
}
|
||||
101
src/prototypes/lease-business-detail/utils/detail.ts
Normal file
101
src/prototypes/lease-business-detail/utils/detail.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
LeaseBusinessDetailRow,
|
||||
LeaseDetailFilters,
|
||||
LeaseDetailKpiSummary,
|
||||
} from '../types';
|
||||
import { EMPTY_LEASE_DETAIL_KPI } from '../types';
|
||||
|
||||
export function detailRowYearMonth(row: LeaseBusinessDetailRow): string {
|
||||
return `${row.year}-${String(row.month).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function displayText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '' || value === '/') return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function displayMoney(value: number, treatZeroAsEmpty = true): string {
|
||||
if (treatZeroAsEmpty && value === 0) return '';
|
||||
return formatMoney(value);
|
||||
}
|
||||
|
||||
export function displayDeposit(value: number | string): string {
|
||||
if (value === '/' || value === '' || value === null || value === undefined) return '';
|
||||
if (typeof value === 'number') return displayMoney(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function displayOutstanding(value: number): { text: string; warn: boolean } {
|
||||
if (value === 0) return { text: '', warn: false };
|
||||
return { text: formatMoney(value), warn: value > 0 };
|
||||
}
|
||||
|
||||
export function applyDetailFilters(
|
||||
rows: LeaseBusinessDetailRow[],
|
||||
filters: LeaseDetailFilters,
|
||||
): LeaseBusinessDetailRow[] {
|
||||
const customerQ = filters.customerName.trim().toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
if (filters.statMonth && detailRowYearMonth(row) !== filters.statMonth) return false;
|
||||
if (filters.businessDept && row.businessDept !== filters.businessDept) return false;
|
||||
if (filters.salesperson && row.salesperson !== filters.salesperson) return false;
|
||||
if (customerQ && !row.customerName.toLowerCase().includes(customerQ)) return false;
|
||||
if (filters.plateNos.length > 0 && !filters.plateNos.includes(row.plateNo)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function sumDetailKpi(rows: LeaseBusinessDetailRow[]): LeaseDetailKpiSummary {
|
||||
if (rows.length === 0) return { ...EMPTY_LEASE_DETAIL_KPI };
|
||||
|
||||
return rows.reduce(
|
||||
(acc, row) => ({
|
||||
deposit: acc.deposit + (typeof row.deposit === 'number' ? row.deposit : 0),
|
||||
contractRent: acc.contractRent + row.contractRent,
|
||||
receivableTotal: acc.receivableTotal + row.receivableTotal,
|
||||
receivableRent: acc.receivableRent + row.receivableRent,
|
||||
monthlyIncome: acc.monthlyIncome + row.monthlyIncome,
|
||||
monthlyRent: acc.monthlyRent + row.monthlyRent,
|
||||
maintenanceIncome: acc.maintenanceIncome + row.maintenanceIncome,
|
||||
insuranceSurcharge: acc.insuranceSurcharge + row.insuranceSurcharge,
|
||||
opsFeeIncome: acc.opsFeeIncome + row.opsFeeIncome,
|
||||
otherIncome: acc.otherIncome + row.otherIncome,
|
||||
mileageDeduction: acc.mileageDeduction + row.mileageDeduction,
|
||||
otherDeduction: acc.otherDeduction + row.otherDeduction,
|
||||
receivedAmount: acc.receivedAmount + row.receivedAmount,
|
||||
outstanding: acc.outstanding + row.outstanding,
|
||||
vehicleStandardCost: acc.vehicleStandardCost + row.vehicleStandardCost,
|
||||
vehicleActualCost: acc.vehicleActualCost + row.vehicleActualCost,
|
||||
insuranceFee: acc.insuranceFee + row.insuranceFee,
|
||||
hydrogenFee: acc.hydrogenFee + row.hydrogenFee,
|
||||
opsFeeCost: acc.opsFeeCost + row.opsFeeCost,
|
||||
brokerageFee: acc.brokerageFee + row.brokerageFee,
|
||||
otherCost: acc.otherCost + row.otherCost,
|
||||
totalCost: acc.totalCost + row.totalCost,
|
||||
profitLoss: acc.profitLoss + row.profitLoss,
|
||||
}),
|
||||
{ ...EMPTY_LEASE_DETAIL_KPI },
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDetailOptions(
|
||||
rows: LeaseBusinessDetailRow[],
|
||||
key: 'businessDept' | 'salesperson',
|
||||
): string[] {
|
||||
const set = new Set<string>();
|
||||
rows.forEach((row) => {
|
||||
const value = row[key];
|
||||
if (value.trim()) set.add(value);
|
||||
});
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function buildDetailMonthOptions(rows: LeaseBusinessDetailRow[]): string[] {
|
||||
const set = new Set<string>();
|
||||
rows.forEach((row) => set.add(detailRowYearMonth(row)));
|
||||
return Array.from(set).sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783352657924,
|
||||
"updatedAt": 1783589987166,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lbl-filter",
|
||||
@@ -1164,6 +1164,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -1178,112 +1185,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -104,12 +104,6 @@ export default function LeaseBusinessLedgerApp() {
|
||||
() => applyListViewFilter(filtered, { showUnpaidOnly, listViewMode }),
|
||||
[filtered, showUnpaidOnly, listViewMode],
|
||||
);
|
||||
const toolbarListCount = useMemo(() => {
|
||||
if (showUnpaidOnly) {
|
||||
return filtered.filter((row) => row.status !== '已收款').length;
|
||||
}
|
||||
return filtered.length;
|
||||
}, [filtered, showUnpaidOnly]);
|
||||
const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]);
|
||||
const totalPages = Math.max(1, Math.ceil(listFiltered.length / pageSize));
|
||||
const pagedRecords = useMemo(() => {
|
||||
@@ -369,12 +363,6 @@ export default function LeaseBusinessLedgerApp() {
|
||||
/>
|
||||
<span>仅显示未收款数据</span>
|
||||
</label>
|
||||
<span
|
||||
className="ldb-toolbar-data-count"
|
||||
aria-label={showUnpaidOnly ? `未收款数据 ${toolbarListCount} 条` : `总数据 ${toolbarListCount} 条`}
|
||||
>
|
||||
共 <span className="ldb-toolbar-data-count-num tabular-nums">{toolbarListCount}</span> 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => setImportOpen(true)}>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-line-overview",
|
||||
"pageId": "overview",
|
||||
"updatedAt": 1783352657924,
|
||||
"updatedAt": 1783589987166,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lblo-system-intro",
|
||||
@@ -179,6 +179,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -193,112 +200,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明(当前)",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-contract-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783352657925,
|
||||
"updatedAt": 1783589987167,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lc-list-filter",
|
||||
@@ -482,6 +482,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -496,112 +503,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -28,12 +28,14 @@ vm-page ldb-page
|
||||
├── vm-filter-card ldb-filter-card # 模块标题 + 4 列筛选 + 更多筛选
|
||||
├── ldb-kpi-row # 6 张 KPI 卡片
|
||||
├── vm-table-section
|
||||
│ ├── vm-table-toolbar ldb-table-toolbar
|
||||
│ ├── vm-table-toolbar ldb-table-toolbar # 仅筛选开关 / 操作按钮,不写总条数
|
||||
│ └── vm-table-card
|
||||
│ ├── ldb-table-wrap > ldb-table
|
||||
│ └── vm-table-footer ldb-table-footer # TablePagination(见 vm-shared/DESIGN.md)
|
||||
```
|
||||
|
||||
**不写页面级 `h1` 大标题区**(见 `vm-shared/DESIGN.md`「页面壳层」)。
|
||||
|
||||
## 类名约定
|
||||
|
||||
| 前缀 | 用途 |
|
||||
|
||||
87
src/prototypes/oneos-prototype-nav/annotation-source.json
Normal file
87
src/prototypes/oneos-prototype-nav/annotation-source.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-prototype-nav",
|
||||
"pageId": "nav",
|
||||
"updatedAt": 1783589987167,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "opn-hero",
|
||||
"index": 1,
|
||||
"title": "导航首页",
|
||||
"pageId": "nav",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"opn-hero\"]"
|
||||
],
|
||||
"fingerprint": "opn-hero",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "ONE-OS 原型导航首页说明与搜索。",
|
||||
"annotationText": "按系统菜单分组展示全部原型入口,支持关键词搜索;左侧可快速跳转到各一级分组。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#2563EB",
|
||||
"images": [],
|
||||
"createdAt": 1783600000000,
|
||||
"updatedAt": 1783600000000
|
||||
},
|
||||
{
|
||||
"id": "opn-search",
|
||||
"index": 2,
|
||||
"title": "搜索",
|
||||
"pageId": "nav",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"opn-search\"]"
|
||||
],
|
||||
"fingerprint": "opn-search",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "原型导航搜索框。",
|
||||
"annotationText": "可按菜单名、原型名称或原型 ID 过滤;清空后恢复完整分组目录。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#3B82F6",
|
||||
"images": [],
|
||||
"createdAt": 1783600000000,
|
||||
"updatedAt": 1783600000000
|
||||
}
|
||||
]
|
||||
},
|
||||
"markdownMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "oneos-project-nav",
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航(当前)",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
"title": "OneOS",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-workbench",
|
||||
"title": "工作台",
|
||||
"href": "/prototypes/oneos-web-workbench",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
496
src/prototypes/oneos-prototype-nav/index.tsx
Normal file
496
src/prototypes/oneos-prototype-nav/index.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* @name 原型导航
|
||||
* ONE-OS 原型目录导航:按菜单分组快速进入各功能原型
|
||||
*/
|
||||
import './style.css';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Compass,
|
||||
ExternalLink,
|
||||
History,
|
||||
Layers,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
countNavLinks,
|
||||
flattenNavLinks,
|
||||
getPrototypeRegistryRecord,
|
||||
getRegistryUpdatedAt,
|
||||
iconForLink,
|
||||
loadNavSections,
|
||||
loadRecentUpdates,
|
||||
type NavLinkItem,
|
||||
type NavSection,
|
||||
type NavSubGroup,
|
||||
type PrototypeChangelogEntry,
|
||||
type PrototypeRegistryRecord,
|
||||
type RecentUpdateEntry,
|
||||
} from './nav-data';
|
||||
|
||||
function linkMeta(link: NavLinkItem, record: PrototypeRegistryRecord | null): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (link.version || record?.version) parts.push(link.version || record?.version || '');
|
||||
if (link.lastUpdatedLabel) parts.push(`${link.lastUpdatedLabel}更新`);
|
||||
const latest = record?.changelog?.[0];
|
||||
if (latest?.summary && parts.length < 2) parts.push(latest.summary);
|
||||
return parts.filter(Boolean).join(' · ') || undefined;
|
||||
}
|
||||
|
||||
const NEW_TAB_LINK_PROPS = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
} as const;
|
||||
|
||||
function NavLinkCard({
|
||||
link,
|
||||
meta,
|
||||
onSelect,
|
||||
}: {
|
||||
link: NavLinkItem;
|
||||
meta?: string;
|
||||
onSelect: (link: NavLinkItem) => void;
|
||||
}) {
|
||||
const Icon = iconForLink(link.title);
|
||||
const record = getPrototypeRegistryRecord(link.prototypeId);
|
||||
const metaText = meta ?? linkMeta(link, record);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="opn-link-card"
|
||||
onClick={() => onSelect(link)}
|
||||
aria-label={`查看 ${link.title} 的变更日志`}
|
||||
data-annotation-id={`opn-link-${link.prototypeId}-${link.id}`}
|
||||
>
|
||||
<span className="opn-link-card-icon" aria-hidden>
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
<span className="opn-link-card-body">
|
||||
<span className="opn-link-card-title">{link.title}</span>
|
||||
{metaText && <span className="opn-link-card-meta">{metaText}</span>}
|
||||
</span>
|
||||
<ArrowRight size={18} className="opn-link-card-arrow" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SubGroupPanel({
|
||||
group,
|
||||
onSelectLink,
|
||||
}: {
|
||||
group: NavSubGroup;
|
||||
onSelectLink: (link: NavLinkItem) => void;
|
||||
}) {
|
||||
const Icon = group.icon;
|
||||
return (
|
||||
<div className="opn-subgroup" data-annotation-id={`opn-group-${group.id}`}>
|
||||
<header className="opn-subgroup-header">
|
||||
<span className="opn-subgroup-icon" aria-hidden>
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<h3>{group.title}</h3>
|
||||
<span className="opn-subgroup-count">{group.links.length} 个原型</span>
|
||||
</header>
|
||||
<div className="opn-link-grid">
|
||||
{group.links.map((link) => (
|
||||
<NavLinkCard key={link.id} link={link} onSelect={onSelectLink} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionBlock({
|
||||
section,
|
||||
onSelectLink,
|
||||
}: {
|
||||
section: NavSection;
|
||||
onSelectLink: (link: NavLinkItem) => void;
|
||||
}) {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<section
|
||||
className="opn-section"
|
||||
id={`opn-section-${section.id}`}
|
||||
data-annotation-id={`opn-section-${section.id}`}
|
||||
>
|
||||
<header className="opn-section-header">
|
||||
<div className="opn-section-heading">
|
||||
<span className="opn-section-icon" aria-hidden>
|
||||
<Icon size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{section.title}</h2>
|
||||
<p>{section.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{section.links.length > 0 && (
|
||||
<div className="opn-link-grid opn-link-grid--featured">
|
||||
{section.links.map((link) => (
|
||||
<NavLinkCard key={link.id} link={link} onSelect={onSelectLink} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.subGroups.length > 0 && (
|
||||
<div className="opn-subgroup-stack">
|
||||
{section.subGroups.map((group) => (
|
||||
<SubGroupPanel key={group.id} group={group} onSelectLink={onSelectLink} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogEntryRow({ entry, isLatest }: { entry: PrototypeChangelogEntry; isLatest?: boolean }) {
|
||||
return (
|
||||
<li className={`opn-changelog-item${isLatest ? ' opn-changelog-item--latest' : ''}`}>
|
||||
<div className="opn-changelog-item-head">
|
||||
<span className="opn-changelog-version">{entry.version}</span>
|
||||
<time className="opn-changelog-time" dateTime={`${entry.date}T${entry.time}`}>
|
||||
{entry.date}
|
||||
{' '}
|
||||
{entry.time}
|
||||
</time>
|
||||
</div>
|
||||
<p className="opn-changelog-summary">{entry.summary}</p>
|
||||
{entry.files.length > 0 && (
|
||||
<details className="opn-changelog-files">
|
||||
<summary>
|
||||
变更文件(
|
||||
{entry.files.length}
|
||||
)
|
||||
</summary>
|
||||
<ul>
|
||||
{entry.files.map((file) => (
|
||||
<li key={file}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogDetailPanel({
|
||||
link,
|
||||
onClose,
|
||||
}: {
|
||||
link: NavLinkItem;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const record = getPrototypeRegistryRecord(link.prototypeId);
|
||||
const changelog = record?.changelog || [];
|
||||
const Icon = iconForLink(link.title);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="opn-detail-overlay" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="opn-detail-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="opn-detail-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
data-annotation-id={`opn-detail-${link.prototypeId}`}
|
||||
>
|
||||
<header className="opn-detail-header">
|
||||
<div className="opn-detail-heading">
|
||||
<span className="opn-detail-icon" aria-hidden>
|
||||
<Icon size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 id="opn-detail-title">{link.title}</h2>
|
||||
<p className="opn-detail-subtitle">
|
||||
{record?.version || link.version || 'v1.0'}
|
||||
{record?.lastUpdated && (
|
||||
<>
|
||||
{' '}
|
||||
· 最近更新
|
||||
{' '}
|
||||
{new Date(record.lastUpdated).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opn-detail-actions">
|
||||
<a
|
||||
className="opn-detail-open-btn"
|
||||
href={link.href}
|
||||
{...NEW_TAB_LINK_PROPS}
|
||||
aria-label={`查看 ${link.title} 原型(在新标签页打开)`}
|
||||
>
|
||||
查看原型
|
||||
<ExternalLink size={16} aria-hidden />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="opn-detail-close"
|
||||
onClick={onClose}
|
||||
aria-label="关闭变更日志"
|
||||
>
|
||||
<X size={20} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="opn-detail-body">
|
||||
<section className="opn-detail-section" aria-labelledby="opn-changelog-title">
|
||||
<header className="opn-detail-section-header">
|
||||
<History size={18} aria-hidden />
|
||||
<h3 id="opn-changelog-title">变更日志与历史记录</h3>
|
||||
</header>
|
||||
{changelog.length > 0 ? (
|
||||
<ol className="opn-changelog-list">
|
||||
{changelog.map((entry, index) => (
|
||||
<ChangelogEntryRow key={`${entry.version}-${entry.date}-${entry.time}`} entry={entry} isLatest={index === 0} />
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="opn-detail-empty">暂无自动记录的变更日志。原型更新并保存后,将在此展示版本说明与历史记录。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentUpdatesPanel({
|
||||
updates,
|
||||
onSelectLink,
|
||||
}: {
|
||||
updates: RecentUpdateEntry[];
|
||||
onSelectLink: (link: NavLinkItem) => void;
|
||||
}) {
|
||||
if (!updates.length) return null;
|
||||
return (
|
||||
<section className="opn-updates" data-annotation-id="opn-recent-updates" aria-labelledby="opn-updates-title">
|
||||
<header className="opn-updates-header">
|
||||
<h2 id="opn-updates-title">最近更新</h2>
|
||||
<p>点击条目查看变更说明与历史记录;右上角可在新标签页打开原型</p>
|
||||
</header>
|
||||
<ol className="opn-updates-list">
|
||||
{updates.map((item) => (
|
||||
<li key={`${item.prototypeId}-${item.version}-${item.date}-${item.time}`} className="opn-updates-item">
|
||||
<div className="opn-updates-main">
|
||||
<button
|
||||
type="button"
|
||||
className="opn-updates-link"
|
||||
onClick={() => onSelectLink({
|
||||
id: item.prototypeId,
|
||||
title: item.title,
|
||||
href: `/prototypes/${item.prototypeId}`,
|
||||
prototypeId: item.prototypeId,
|
||||
version: item.version,
|
||||
})}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
<span className="opn-updates-version">{item.version}</span>
|
||||
</div>
|
||||
<p className="opn-updates-summary">{item.summary}</p>
|
||||
<p className="opn-updates-meta">
|
||||
{item.date}
|
||||
{' '}
|
||||
{item.time}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OneosPrototypeNavApp() {
|
||||
const sections = useMemo(() => loadNavSections(), []);
|
||||
const recentUpdates = useMemo(() => loadRecentUpdates(), []);
|
||||
const registryUpdatedAt = useMemo(() => getRegistryUpdatedAt(), []);
|
||||
const allLinks = useMemo(() => flattenNavLinks(sections), [sections]);
|
||||
const totalCount = useMemo(() => countNavLinks(sections), [sections]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedLink, setSelectedLink] = useState<NavLinkItem | null>(null);
|
||||
|
||||
const handleSelectLink = useCallback((link: NavLinkItem) => {
|
||||
setSelectedLink(link);
|
||||
}, []);
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setSelectedLink(null);
|
||||
}, []);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredLinks = useMemo(() => {
|
||||
if (!normalizedQuery) return [];
|
||||
return allLinks.filter((link) => (
|
||||
link.title.toLowerCase().includes(normalizedQuery)
|
||||
|| link.prototypeId.toLowerCase().includes(normalizedQuery)
|
||||
|| link.sectionTitle.toLowerCase().includes(normalizedQuery)
|
||||
|| (link.groupTitle || '').toLowerCase().includes(normalizedQuery)
|
||||
));
|
||||
}, [allLinks, normalizedQuery]);
|
||||
|
||||
const visibleSections = useMemo(() => {
|
||||
if (!normalizedQuery) return sections;
|
||||
const matchedIds = new Set(filteredLinks.map((link) => link.prototypeId));
|
||||
return sections
|
||||
.map((section) => {
|
||||
const links = section.links.filter((link) => matchedIds.has(link.prototypeId));
|
||||
const subGroups = section.subGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
links: group.links.filter((link) => matchedIds.has(link.prototypeId)),
|
||||
}))
|
||||
.filter((group) => group.links.length > 0);
|
||||
if (!links.length && !subGroups.length) return null;
|
||||
return { ...section, links, subGroups };
|
||||
})
|
||||
.filter((section): section is NavSection => Boolean(section));
|
||||
}, [sections, normalizedQuery, filteredLinks]);
|
||||
|
||||
return (
|
||||
<div className="opn-page" data-annotation-id="opn-page">
|
||||
<a className="opn-skip" href="#opn-main">跳到导航内容</a>
|
||||
|
||||
<header className="opn-hero" data-annotation-id="opn-hero">
|
||||
<div className="opn-hero-inner">
|
||||
<p className="opn-eyebrow">
|
||||
<Compass size={16} aria-hidden />
|
||||
ONE-OS · 原型中心
|
||||
</p>
|
||||
<h1>原型导航</h1>
|
||||
<p className="opn-lead">
|
||||
按系统菜单分组浏览全部可交互原型;点击卡片先查看变更说明,再决定是否打开原型页面。
|
||||
</p>
|
||||
|
||||
<div className="opn-hero-meta">
|
||||
<span className="opn-stat">
|
||||
<Layers size={16} aria-hidden />
|
||||
{totalCount}
|
||||
{' '}
|
||||
个原型入口
|
||||
</span>
|
||||
<span className="opn-stat">
|
||||
{sections.length}
|
||||
{' '}
|
||||
个一级分组
|
||||
</span>
|
||||
{registryUpdatedAt && (
|
||||
<span className="opn-stat opn-stat--muted">
|
||||
注册表
|
||||
{' '}
|
||||
{new Date(registryUpdatedAt).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="opn-search" data-annotation-id="opn-search">
|
||||
<Search size={18} aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索菜单或原型名称…"
|
||||
aria-label="搜索原型"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
className="opn-search-clear"
|
||||
onClick={() => setQuery('')}
|
||||
aria-label="清除搜索"
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="opn-layout">
|
||||
{!normalizedQuery && (
|
||||
<aside className="opn-sidebar" aria-label="分组目录">
|
||||
<p className="opn-sidebar-title">快速跳转</p>
|
||||
<nav className="opn-sidebar-nav">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<a
|
||||
key={section.id}
|
||||
className="opn-sidebar-link"
|
||||
href={`#opn-section-${section.id}`}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
{section.title}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main id="opn-main" className="opn-main">
|
||||
{normalizedQuery && (
|
||||
<p className="opn-search-result" role="status">
|
||||
找到
|
||||
{' '}
|
||||
{filteredLinks.length}
|
||||
{' '}
|
||||
个匹配「
|
||||
{query.trim()}
|
||||
」的原型
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visibleSections.length === 0 ? (
|
||||
<div className="opn-empty">
|
||||
<p>没有匹配的原型,请换个关键词试试。</p>
|
||||
<button type="button" className="opn-empty-btn" onClick={() => setQuery('')}>
|
||||
清除搜索
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
visibleSections.map((section) => (
|
||||
<SectionBlock key={section.id} section={section} onSelectLink={handleSelectLink} />
|
||||
))
|
||||
)}
|
||||
|
||||
{!normalizedQuery && (
|
||||
<RecentUpdatesPanel updates={recentUpdates} onSelectLink={handleSelectLink} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{selectedLink && (
|
||||
<ChangelogDetailPanel link={selectedLink} onClose={handleCloseDetail} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
src/prototypes/oneos-prototype-nav/nav-data.ts
Normal file
310
src/prototypes/oneos-prototype-nav/nav-data.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import navMenu from './nav-menu.json';
|
||||
import xllNavMenu from './xll-nav-menu.json';
|
||||
import prototypeRegistry from './prototype-registry.json';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Car,
|
||||
FileText,
|
||||
Fuel,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
Monitor,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Truck,
|
||||
Wallet,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
const CURRENT_PROTOTYPE_ID = 'oneos-prototype-nav';
|
||||
|
||||
export interface NavLinkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
prototypeId: string;
|
||||
version?: string;
|
||||
lastUpdatedLabel?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface PrototypeChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
time: string;
|
||||
summary: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface PrototypeRegistryRecord {
|
||||
title: string;
|
||||
version: string;
|
||||
revision: number;
|
||||
lastUpdated: string | null;
|
||||
changelog: PrototypeChangelogEntry[];
|
||||
}
|
||||
|
||||
export interface RecentUpdateEntry extends PrototypeChangelogEntry {
|
||||
prototypeId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface NavSubGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
links: NavLinkItem[];
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
/** 直接挂在分区下的入口(无二级分组) */
|
||||
links: NavLinkItem[];
|
||||
/** 二级菜单分组 */
|
||||
subGroups: NavSubGroup[];
|
||||
}
|
||||
|
||||
type SidebarNode = {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
itemKey?: string;
|
||||
children?: SidebarNode[];
|
||||
};
|
||||
|
||||
const GROUP_ICONS: Record<string, LucideIcon> = {
|
||||
运维管理: Wrench,
|
||||
业务管理: Briefcase,
|
||||
合同配置: FileText,
|
||||
加氢站管理: Fuel,
|
||||
数据分析: BarChart3,
|
||||
台账管理: BookOpen,
|
||||
财务管理: Wallet,
|
||||
主流程: LayoutDashboard,
|
||||
业务模块: Briefcase,
|
||||
};
|
||||
|
||||
const SECTION_META: Record<string, { icon: LucideIcon; description: string }> = {
|
||||
OneOS: {
|
||||
icon: Layers,
|
||||
description: '按业务菜单分组,进入各功能模块的可交互原型。',
|
||||
},
|
||||
小羚羚: {
|
||||
icon: Smartphone,
|
||||
description: '氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。',
|
||||
},
|
||||
};
|
||||
|
||||
function prototypeIdFromItemKey(itemKey: string): string {
|
||||
const normalized = String(itemKey || '').trim().replace(/^\/+/u, '');
|
||||
const match = normalized.match(/^prototypes\/(.+)$/u);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function registryRecord(prototypeId: string): PrototypeRegistryRecord | null {
|
||||
const record = (prototypeRegistry as { prototypes?: Record<string, PrototypeRegistryRecord> }).prototypes?.[prototypeId];
|
||||
return record || null;
|
||||
}
|
||||
|
||||
function formatUpdatedLabel(iso: string | null | undefined): string | undefined {
|
||||
if (!iso) return undefined;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
|
||||
function toLink(node: SidebarNode): NavLinkItem | null {
|
||||
const prototypeId = prototypeIdFromItemKey(node.itemKey || '');
|
||||
if (!prototypeId || !node.title || prototypeId === CURRENT_PROTOTYPE_ID) return null;
|
||||
const record = registryRecord(prototypeId);
|
||||
return {
|
||||
id: node.id || prototypeId,
|
||||
title: node.title,
|
||||
href: `/prototypes/${prototypeId}`,
|
||||
prototypeId,
|
||||
version: record?.version,
|
||||
lastUpdatedLabel: formatUpdatedLabel(record?.lastUpdated),
|
||||
};
|
||||
}
|
||||
|
||||
function iconForSubGroup(title: string): LucideIcon {
|
||||
return GROUP_ICONS[title] || Building2;
|
||||
}
|
||||
|
||||
function iconForLink(title: string): LucideIcon {
|
||||
if (title.includes('车辆') || title.includes('车')) return Car;
|
||||
if (title.includes('氢') || title.includes('加氢')) return Fuel;
|
||||
if (title.includes('租')) return FileText;
|
||||
if (title.includes('财务') || title.includes('收款') || title.includes('结款')) return Wallet;
|
||||
if (title.includes('客户') || title.includes('供应商') || title.includes('业务')) return Briefcase;
|
||||
if (title.includes('台账') || title.includes('明细')) return BookOpen;
|
||||
if (title.includes('登录')) return Shield;
|
||||
if (title.includes('帮助')) return Globe;
|
||||
if (title.includes('工作台')) return LayoutDashboard;
|
||||
if (title.includes('运维')) return Wrench;
|
||||
if (title.includes('数据')) return BarChart3;
|
||||
if (title.includes('物流')) return Truck;
|
||||
return Monitor;
|
||||
}
|
||||
|
||||
export { iconForLink };
|
||||
|
||||
function transformSection(node: SidebarNode): NavSection | null {
|
||||
if (!node.title) return null;
|
||||
const meta = SECTION_META[node.title] || {
|
||||
icon: Building2,
|
||||
description: '原型入口分组。',
|
||||
};
|
||||
|
||||
const links: NavLinkItem[] = [];
|
||||
const subGroups: NavSubGroup[] = [];
|
||||
|
||||
for (const child of node.children || []) {
|
||||
if (child.kind === 'item') {
|
||||
const link = toLink(child);
|
||||
if (link) links.push(link);
|
||||
continue;
|
||||
}
|
||||
if (child.kind !== 'folder' || !Array.isArray(child.children)) continue;
|
||||
const groupLinks = child.children
|
||||
.filter((item) => item.kind === 'item')
|
||||
.map((item) => toLink(item))
|
||||
.filter((item): item is NavLinkItem => Boolean(item));
|
||||
if (!groupLinks.length) continue;
|
||||
subGroups.push({
|
||||
id: child.id || child.title || `group-${subGroups.length}`,
|
||||
title: child.title || '分组',
|
||||
icon: iconForSubGroup(child.title || ''),
|
||||
links: groupLinks,
|
||||
});
|
||||
}
|
||||
|
||||
if (node.kind === 'item') {
|
||||
const link = toLink(node);
|
||||
if (!link) return null;
|
||||
return {
|
||||
id: node.id || link.prototypeId,
|
||||
title: node.title,
|
||||
icon: meta.icon,
|
||||
description: meta.description,
|
||||
links: [link],
|
||||
subGroups: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!links.length && !subGroups.length) return null;
|
||||
|
||||
return {
|
||||
id: node.id || node.title,
|
||||
title: node.title,
|
||||
icon: meta.icon,
|
||||
description: meta.description,
|
||||
links,
|
||||
subGroups,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadNavSections(): NavSection[] {
|
||||
const prototypes = (navMenu as { prototypes?: SidebarNode[] }).prototypes || [];
|
||||
const oneosSections = prototypes
|
||||
.map((node) => transformSection(node))
|
||||
.filter((section): section is NavSection => Boolean(section));
|
||||
const xllSection = loadXllNavSection();
|
||||
return xllSection ? [...oneosSections, xllSection] : oneosSections;
|
||||
}
|
||||
|
||||
type XllNavMenuFile = {
|
||||
title?: string;
|
||||
sectionId?: string;
|
||||
description?: string;
|
||||
prototypeId?: string;
|
||||
hrefPrefix?: string;
|
||||
groups?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
links: Array<{ id: string; title: string; pageId: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
function loadXllNavSection(): NavSection | null {
|
||||
const config = xllNavMenu as XllNavMenuFile;
|
||||
const groups = config.groups || [];
|
||||
if (!groups.length) return null;
|
||||
|
||||
const meta = SECTION_META[config.title || '小羚羚'] || {
|
||||
icon: Smartphone,
|
||||
description: config.description || '小羚羚小程序原型入口。',
|
||||
};
|
||||
const hrefPrefix = String(config.hrefPrefix || '/prototypes/xll-miniapp').replace(/\/$/u, '');
|
||||
const prototypeId = config.prototypeId || 'xll-miniapp';
|
||||
const external = /^https?:\/\//u.test(hrefPrefix);
|
||||
|
||||
const subGroups: NavSubGroup[] = groups.map((group) => ({
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
icon: iconForSubGroup(group.title),
|
||||
links: group.links.map((link) => ({
|
||||
id: link.id,
|
||||
title: link.title,
|
||||
href: `${hrefPrefix}#page=${link.pageId}`,
|
||||
prototypeId,
|
||||
external,
|
||||
})),
|
||||
}));
|
||||
|
||||
if (!subGroups.length) return null;
|
||||
|
||||
return {
|
||||
id: config.sectionId || 'folder-prototypes-xll-miniapp',
|
||||
title: config.title || '小羚羚',
|
||||
icon: meta.icon,
|
||||
description: meta.description,
|
||||
links: [],
|
||||
subGroups,
|
||||
};
|
||||
}
|
||||
|
||||
export function countNavLinks(sections: NavSection[]): number {
|
||||
return sections.reduce((total, section) => {
|
||||
const direct = section.links.length;
|
||||
const nested = section.subGroups.reduce((sum, group) => sum + group.links.length, 0);
|
||||
return total + direct + nested;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function flattenNavLinks(sections: NavSection[]): Array<NavLinkItem & { sectionTitle: string; groupTitle?: string }> {
|
||||
const rows: Array<NavLinkItem & { sectionTitle: string; groupTitle?: string }> = [];
|
||||
for (const section of sections) {
|
||||
for (const link of section.links) {
|
||||
rows.push({ ...link, sectionTitle: section.title });
|
||||
}
|
||||
for (const group of section.subGroups) {
|
||||
for (const link of group.links) {
|
||||
rows.push({ ...link, sectionTitle: section.title, groupTitle: group.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function loadRecentUpdates(): RecentUpdateEntry[] {
|
||||
const recent = (prototypeRegistry as { recentUpdates?: RecentUpdateEntry[] }).recentUpdates;
|
||||
return Array.isArray(recent) ? recent : [];
|
||||
}
|
||||
|
||||
export function getRegistryUpdatedAt(): string | null {
|
||||
const updatedAt = (prototypeRegistry as { updatedAt?: string }).updatedAt;
|
||||
return updatedAt || null;
|
||||
}
|
||||
|
||||
export function getPrototypeRegistryRecord(prototypeId: string): PrototypeRegistryRecord | null {
|
||||
return registryRecord(prototypeId);
|
||||
}
|
||||
168
src/prototypes/oneos-prototype-nav/nav-menu.json
Normal file
168
src/prototypes/oneos-prototype-nav/nav-menu.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"prototypes": [
|
||||
{
|
||||
"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",
|
||||
"title": "运维管理",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:vehicle-management",
|
||||
"kind": "item",
|
||||
"title": "车辆资产",
|
||||
"itemKey": "prototypes/vehicle-management"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "folder-1782874624657-4c8hzv",
|
||||
"kind": "folder",
|
||||
"title": "业务管理",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:customer-management",
|
||||
"kind": "item",
|
||||
"title": "客户管理",
|
||||
"itemKey": "prototypes/customer-management"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:supplier-management",
|
||||
"kind": "item",
|
||||
"title": "供应商管理",
|
||||
"itemKey": "prototypes/supplier-management"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:lease-contract-management",
|
||||
"kind": "item",
|
||||
"title": "租赁合同",
|
||||
"itemKey": "prototypes/lease-contract-management"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:insurance-procurement",
|
||||
"kind": "item",
|
||||
"title": "保险采购",
|
||||
"itemKey": "prototypes/insurance-procurement"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "folder-1782874683252-db5b3y",
|
||||
"kind": "folder",
|
||||
"title": "合同配置",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:contract-template-management",
|
||||
"kind": "item",
|
||||
"title": "合同模板管理",
|
||||
"itemKey": "prototypes/contract-template-management"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "folder-1782895924552-goclwo",
|
||||
"kind": "folder",
|
||||
"title": "加氢站管理",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station-site",
|
||||
"kind": "item",
|
||||
"title": "站点信息",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-site"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "folder-oneos-data-analysis",
|
||||
"kind": "folder",
|
||||
"title": "数据分析",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:business-dept-ledger",
|
||||
"kind": "item",
|
||||
"title": "业务部台账",
|
||||
"itemKey": "prototypes/business-dept-ledger"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "folder-1783314569299-4ejj2g",
|
||||
"kind": "folder",
|
||||
"title": "台账管理",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:lease-business-ledger",
|
||||
"kind": "item",
|
||||
"title": "租赁业务台账",
|
||||
"itemKey": "prototypes/lease-business-ledger"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes: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": "folder-1783314610587-rwspc1",
|
||||
"kind": "folder",
|
||||
"title": "财务管理",
|
||||
"children": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2037
src/prototypes/oneos-prototype-nav/prototype-registry.json
Normal file
2037
src/prototypes/oneos-prototype-nav/prototype-registry.json
Normal file
File diff suppressed because it is too large
Load Diff
826
src/prototypes/oneos-prototype-nav/style.css
Normal file
826
src/prototypes/oneos-prototype-nav/style.css
Normal file
@@ -0,0 +1,826 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');
|
||||
|
||||
.opn-page {
|
||||
--opn-bg: #f8fafc;
|
||||
--opn-surface: #ffffff;
|
||||
--opn-surface-muted: #f1f5f9;
|
||||
--opn-ink: #0f172a;
|
||||
--opn-body: #334155;
|
||||
--opn-muted: #64748b;
|
||||
--opn-line: #e2e8f0;
|
||||
--opn-primary: #2563eb;
|
||||
--opn-primary-soft: #dbeafe;
|
||||
--opn-primary-hover: #1d4ed8;
|
||||
--opn-radius: 16px;
|
||||
--opn-radius-sm: 12px;
|
||||
--opn-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--opn-shadow-hover: 0 4px 12px rgba(37, 99, 235, 0.12), 0 16px 32px rgba(15, 23, 42, 0.08);
|
||||
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(37, 99, 235, 0.08), transparent),
|
||||
var(--opn-bg);
|
||||
color: var(--opn-ink);
|
||||
font-family: 'Plus Jakarta Sans', Inter, system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.opn-skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 12px 16px;
|
||||
background: var(--opn-primary);
|
||||
color: #fff;
|
||||
border-radius: var(--opn-radius-sm);
|
||||
}
|
||||
|
||||
.opn-skip:focus {
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.opn-hero {
|
||||
border-bottom: 1px solid var(--opn-line);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.opn-hero-inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 32px;
|
||||
}
|
||||
|
||||
.opn-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
color: var(--opn-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.opn-hero h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(28px, 4vw, 40px);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.opn-lead {
|
||||
margin: 0;
|
||||
max-width: 640px;
|
||||
color: var(--opn-body);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.opn-hero-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.opn-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: 999px;
|
||||
background: var(--opn-surface);
|
||||
color: var(--opn-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opn-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
max-width: 480px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: var(--opn-radius);
|
||||
background: var(--opn-surface);
|
||||
box-shadow: var(--opn-shadow);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.opn-search:focus-within {
|
||||
border-color: rgba(37, 99, 235, 0.45);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.opn-search svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--opn-muted);
|
||||
}
|
||||
|
||||
.opn-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--opn-ink);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.opn-search input::placeholder {
|
||||
color: var(--opn-muted);
|
||||
}
|
||||
|
||||
.opn-search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--opn-muted);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.opn-search-clear:hover {
|
||||
background: var(--opn-surface-muted);
|
||||
color: var(--opn-ink);
|
||||
}
|
||||
|
||||
.opn-search-clear:focus-visible {
|
||||
outline: 2px solid var(--opn-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opn-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 56px;
|
||||
}
|
||||
|
||||
.opn-sidebar {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.opn-sidebar-title {
|
||||
margin: 0 0 12px;
|
||||
color: var(--opn-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.opn-sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.opn-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--opn-radius-sm);
|
||||
color: var(--opn-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.opn-sidebar-link:hover {
|
||||
background: var(--opn-primary-soft);
|
||||
color: var(--opn-primary-hover);
|
||||
}
|
||||
|
||||
.opn-sidebar-link:focus-visible {
|
||||
outline: 2px solid var(--opn-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opn-sidebar-extra {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 10px 12px;
|
||||
color: var(--opn-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opn-sidebar-extra:hover {
|
||||
color: var(--opn-primary);
|
||||
}
|
||||
|
||||
.opn-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.opn-search-result {
|
||||
margin: 0;
|
||||
color: var(--opn-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.opn-section {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: calc(var(--opn-radius) + 4px);
|
||||
background: var(--opn-surface);
|
||||
box-shadow: var(--opn-shadow);
|
||||
}
|
||||
|
||||
.opn-section-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.opn-section-heading {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.opn-section-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: var(--opn-primary-soft);
|
||||
color: var(--opn-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opn-section-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.opn-section-header p {
|
||||
margin: 0;
|
||||
color: var(--opn-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.opn-subgroup-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.opn-subgroup {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: var(--opn-radius);
|
||||
background: var(--opn-surface-muted);
|
||||
}
|
||||
|
||||
.opn-subgroup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.opn-subgroup-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--opn-surface);
|
||||
color: var(--opn-primary);
|
||||
}
|
||||
|
||||
.opn-subgroup-header h3 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.opn-subgroup-count {
|
||||
color: var(--opn-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opn-link-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.opn-link-grid--featured {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.opn-link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: var(--opn-radius-sm);
|
||||
background: var(--opn-surface);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.opn-link-card:hover {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
box-shadow: var(--opn-shadow-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.opn-link-card:focus-visible {
|
||||
outline: 2px solid var(--opn-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opn-link-card-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--opn-primary-soft);
|
||||
color: var(--opn-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opn-link-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.opn-link-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--opn-ink);
|
||||
}
|
||||
|
||||
.opn-link-card-meta {
|
||||
color: var(--opn-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.opn-link-card-arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--opn-muted);
|
||||
transition: transform 180ms ease, color 180ms ease;
|
||||
}
|
||||
|
||||
.opn-link-card:hover .opn-link-card-arrow {
|
||||
color: var(--opn-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.opn-stat--muted {
|
||||
color: var(--opn-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.opn-updates {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: calc(var(--opn-radius) + 4px);
|
||||
background: var(--opn-surface);
|
||||
box-shadow: var(--opn-shadow);
|
||||
}
|
||||
|
||||
.opn-updates-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.opn-updates-header p {
|
||||
margin: 0 0 16px;
|
||||
color: var(--opn-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.opn-updates-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.opn-updates-item {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: var(--opn-radius-sm);
|
||||
background: var(--opn-surface-muted);
|
||||
}
|
||||
|
||||
.opn-updates-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.opn-updates-link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--opn-ink);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opn-updates-link:hover {
|
||||
color: var(--opn-primary-hover);
|
||||
}
|
||||
|
||||
.opn-updates-link:focus-visible {
|
||||
outline: 2px solid var(--opn-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opn-updates-version {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--opn-primary-soft);
|
||||
color: var(--opn-primary-hover);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.opn-updates-summary {
|
||||
margin: 0 0 4px;
|
||||
color: var(--opn-body);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.opn-updates-meta {
|
||||
margin: 0;
|
||||
color: var(--opn-muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.opn-empty {
|
||||
padding: 48px 24px;
|
||||
border: 1px dashed var(--opn-line);
|
||||
border-radius: var(--opn-radius);
|
||||
background: var(--opn-surface);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.opn-empty p {
|
||||
margin: 0 0 16px;
|
||||
color: var(--opn-muted);
|
||||
}
|
||||
|
||||
.opn-empty-btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 18px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--opn-primary);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.opn-empty-btn:hover {
|
||||
background: var(--opn-primary-hover);
|
||||
}
|
||||
|
||||
.opn-empty-btn:focus-visible {
|
||||
outline: 2px solid var(--opn-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.opn-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.opn-sidebar {
|
||||
position: static;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.opn-sidebar-title {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.opn-sidebar-nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.opn-sidebar-extra {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.opn-hero-inner,
|
||||
.opn-layout {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.opn-section {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.opn-link-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.opn-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.opn-detail-panel {
|
||||
width: min(720px, 100%);
|
||||
margin: auto 0;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: calc(var(--opn-radius) + 4px);
|
||||
background: var(--opn-surface);
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.opn-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid var(--opn-line);
|
||||
}
|
||||
|
||||
.opn-detail-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.opn-detail-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: var(--opn-primary-soft);
|
||||
color: var(--opn-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opn-detail-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.opn-detail-subtitle {
|
||||
margin: 0;
|
||||
color: var(--opn-muted);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.opn-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opn-detail-open-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--opn-primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.opn-detail-open-btn:hover {
|
||||
background: var(--opn-primary-hover);
|
||||
}
|
||||
|
||||
.opn-detail-open-btn:focus-visible {
|
||||
outline: 2px solid var(--opn-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opn-detail-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: 10px;
|
||||
background: var(--opn-surface);
|
||||
color: var(--opn-muted);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.opn-detail-close:hover {
|
||||
background: var(--opn-surface-muted);
|
||||
color: var(--opn-ink);
|
||||
}
|
||||
|
||||
.opn-detail-close:focus-visible {
|
||||
outline: 2px solid var(--opn-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opn-detail-body {
|
||||
padding: 16px 20px 24px;
|
||||
}
|
||||
|
||||
.opn-detail-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
color: var(--opn-body);
|
||||
}
|
||||
|
||||
.opn-detail-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.opn-changelog-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.opn-changelog-item {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--opn-line);
|
||||
border-radius: var(--opn-radius-sm);
|
||||
background: var(--opn-surface-muted);
|
||||
}
|
||||
|
||||
.opn-changelog-item--latest {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
background: linear-gradient(180deg, #eff6ff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.opn-changelog-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.opn-changelog-version {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--opn-primary-soft);
|
||||
color: var(--opn-primary-hover);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.opn-changelog-time {
|
||||
color: var(--opn-muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.opn-changelog-summary {
|
||||
margin: 0;
|
||||
color: var(--opn-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opn-changelog-files {
|
||||
margin-top: 10px;
|
||||
color: var(--opn-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.opn-changelog-files summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opn-changelog-files ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.opn-changelog-files li {
|
||||
margin: 2px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.opn-detail-empty {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
border: 1px dashed var(--opn-line);
|
||||
border-radius: var(--opn-radius-sm);
|
||||
color: var(--opn-muted);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opn-link-card,
|
||||
.opn-link-card-arrow,
|
||||
.opn-search,
|
||||
.opn-sidebar-link,
|
||||
.opn-search-clear,
|
||||
.opn-empty-btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.opn-link-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
92
src/prototypes/oneos-prototype-nav/xll-nav-menu.json
Normal file
92
src/prototypes/oneos-prototype-nav/xll-nav-menu.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-09T09:42:05.769Z",
|
||||
"title": "小羚羚",
|
||||
"sectionId": "folder-prototypes-xll-miniapp",
|
||||
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",
|
||||
"prototypeId": "xll-miniapp",
|
||||
"runtimeOrigin": "http://localhost:51721",
|
||||
"hrefPrefix": "http://localhost:51721/prototypes/xll-miniapp",
|
||||
"groups": [
|
||||
{
|
||||
"id": "directory-main",
|
||||
"title": "主流程",
|
||||
"defaultExpanded": true,
|
||||
"links": [
|
||||
{
|
||||
"id": "route-todo",
|
||||
"title": "工作台",
|
||||
"pageId": "todo",
|
||||
"route": "todo"
|
||||
},
|
||||
{
|
||||
"id": "route-business",
|
||||
"title": "业务",
|
||||
"pageId": "business",
|
||||
"route": "business"
|
||||
},
|
||||
{
|
||||
"id": "route-map",
|
||||
"title": "地图",
|
||||
"pageId": "map",
|
||||
"route": "map"
|
||||
},
|
||||
{
|
||||
"id": "route-mine",
|
||||
"title": "我的",
|
||||
"pageId": "mine",
|
||||
"route": "mine"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "directory-modules",
|
||||
"title": "业务模块",
|
||||
"defaultExpanded": true,
|
||||
"links": [
|
||||
{
|
||||
"id": "route-audit",
|
||||
"title": "审批中心",
|
||||
"pageId": "audit",
|
||||
"route": "audit"
|
||||
},
|
||||
{
|
||||
"id": "route-inspection",
|
||||
"title": "年审",
|
||||
"pageId": "inspection",
|
||||
"route": "inspection"
|
||||
},
|
||||
{
|
||||
"id": "route-vehicle",
|
||||
"title": "车辆管理",
|
||||
"pageId": "vehicle",
|
||||
"route": "vehicle"
|
||||
},
|
||||
{
|
||||
"id": "route-delivery",
|
||||
"title": "交车",
|
||||
"pageId": "delivery",
|
||||
"route": "delivery"
|
||||
},
|
||||
{
|
||||
"id": "route-thirdreturn",
|
||||
"title": "三方退车",
|
||||
"pageId": "third-return",
|
||||
"route": "thirdReturn"
|
||||
},
|
||||
{
|
||||
"id": "route-replace",
|
||||
"title": "替换车",
|
||||
"pageId": "replace",
|
||||
"route": "replace"
|
||||
},
|
||||
{
|
||||
"id": "route-audit-return",
|
||||
"title": "还车应结款(说明)",
|
||||
"pageId": "audit-return",
|
||||
"route": "audit-return"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
7
src/prototypes/oneos-prototype-nav/xll-nav-source.json
Normal file
7
src/prototypes/oneos-prototype-nav/xll-nav-source.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"projectRoot": "/Users/sylvawong/make-project-20260629",
|
||||
"prototypeId": "xll-miniapp",
|
||||
"annotationSource": "src/prototypes/xll-miniapp/annotation-source.json",
|
||||
"devServerInfo": ".axhub/make/.dev-server-info.json",
|
||||
"includeDirectoryIds": ["directory-main", "directory-modules"]
|
||||
}
|
||||
@@ -2279,7 +2279,6 @@ const Component = function () {
|
||||
|
||||
React.createElement('div', { className: 'lc-table-section' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar-meta' }, '共 ', React.createElement('strong', null, String(visibleRows.length)), ' 条记录'),
|
||||
React.createElement('div', { className: 'lc-table-toolbar-actions' },
|
||||
!isSupervisor ? React.createElement(Button, { icon: LL_ICONS.upload, style: LL_OUTLINE_BTN, onClick: openImportModal }, '批量导入') : null,
|
||||
React.createElement(Button, {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,18 @@
|
||||
|
||||
## 2. 两阶段交互
|
||||
|
||||
#### 生成对账单 · 业务逻辑
|
||||
|
||||
1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点的加氢记录(已对账且尚未结算)。
|
||||
2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改。
|
||||
3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。
|
||||
4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为已对账/已结算状态,不可再修改。
|
||||
|
||||
**阶段一 · 选期生成**
|
||||
|
||||
- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)
|
||||
- 默认开始日期建议 = 上次结束日 + 1 天
|
||||
- 点击「生成对账单」后进入阶段二,日期锁定不可改
|
||||
- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
||||
- 无符合条件记录时提示调整日期
|
||||
|
||||
**阶段二 · 结算提交**
|
||||
@@ -82,6 +89,7 @@
|
||||
|---|---|
|
||||
| 日期未填 / 开始晚于结束 | 阻止生成 |
|
||||
| 区间内无已对账未结算记录 | 阻止生成,提示调整日期 |
|
||||
| 点击「生成对账单」未确认 | 不进入结算阶段 |
|
||||
| 收票日期/金额/附件未填 | 阻止提交 |
|
||||
| 收票金额 ≤ 0 | 阻止提交 |
|
||||
| 预付余额结算后为负 | 允许提交,标「已欠费」 |
|
||||
|
||||
@@ -106,6 +106,18 @@
|
||||
|
||||
配置成本价格(元/kg)与生效时间;到达生效时间后刷新列表「当前成本价格」;展示价格调整记录。
|
||||
|
||||
**固定价格 · 适用客户规则:**
|
||||
|
||||
- 默认对**全部客户**生效(作为站点默认价格规则)
|
||||
- **取价优先级**:第一权重为单站单客户价格配置(若该客户有专属配置则按单客户配置生效);第二权重为站点「全部客户」价格配置(无单客户配置时生效)
|
||||
|
||||
#### 阶梯价格 · 生成逻辑
|
||||
|
||||
1. 设置阶梯价格后,在对应**阶梯价格区间**内展示并应用该档**成本价**。
|
||||
2. 累计加氢量达到下一档阶梯时,自该次起按新档成本价计算;到达**阶梯量重置日期**后的新记录,按当前所处区间的**最新成本价**更新。
|
||||
3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。
|
||||
4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。
|
||||
|
||||
### 5.3 下钻分析
|
||||
|
||||
- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -61,6 +61,42 @@ const NODE_DEFS = [
|
||||
fingerprint: 'table|site-info',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'h2-site-price-cost-input',
|
||||
title: '成本价格输入',
|
||||
pageId: 'site-info',
|
||||
color: '#d97706',
|
||||
aiPrompt: '固定价格规则适用客户与成本单价录入。',
|
||||
annotationText: '选择全部客户时:第一权重取单站单客户价格配置,第二权重取本条全部客户价格配置',
|
||||
locator: {
|
||||
selectors: ['[data-annotation-id="h2-site-price-cost-input"]'],
|
||||
fingerprint: 'input|price-cost',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'h2-site-price-tiered',
|
||||
title: '阶梯价格',
|
||||
pageId: 'site-info',
|
||||
color: '#7c3aed',
|
||||
aiPrompt: '阶梯价格取价、累计加氢量与重置日期逻辑。',
|
||||
annotationText: '阶梯区间内展示对应成本价;达下一档按新价;重置日累计量归零重新计档;生效时间不追溯历史',
|
||||
locator: {
|
||||
selectors: ['[data-annotation-id="h2-site-price-tiered"]'],
|
||||
fingerprint: 'radio|price-tiered',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'h2-site-statement-form',
|
||||
title: '生成对账单',
|
||||
pageId: 'site-info',
|
||||
color: '#7c3aed',
|
||||
aiPrompt: '选期拉取氢费明细、二次确认与结算提交逻辑。',
|
||||
annotationText: '选日期拉取站点加氢记录;生成前二次确认;提交后明细锁定为已对账',
|
||||
locator: {
|
||||
selectors: ['[data-annotation-id="h2-site-statement-form"]', '.h2-statement-form-wrap'],
|
||||
fingerprint: 'form|statement',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const NODE_PRD_MAP = {
|
||||
@@ -68,6 +104,9 @@ const NODE_PRD_MAP = {
|
||||
'h2-site-kpi': { file: 'list', heading: '## 3. KPI 统计卡片' },
|
||||
'h2-site-statement': { file: 'list', heading: '## 4. 列表工具栏', combine: ['## 4. 列表工具栏', '## 6. 数据来源'] },
|
||||
'h2-site-table': { file: 'list', heading: '## 5. 列表字段说明' },
|
||||
'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' },
|
||||
'h2-site-price-tiered': { file: 'full', heading: '#### 阶梯价格 · 生成逻辑' },
|
||||
'h2-site-statement-form': { file: 'statement', heading: '#### 生成对账单 · 业务逻辑' },
|
||||
};
|
||||
|
||||
const prdByKey = { list: listPrd, statement: statementPrd, full: fullPrd };
|
||||
|
||||
@@ -920,7 +920,9 @@ function h2HasMultiCustomerPriceSystems(record) {
|
||||
return h2GetStationPriceConfigs(record).length > 1;
|
||||
}
|
||||
|
||||
/** 按客户取价:第一权重单站单客户配置,第二权重站点「全部客户」配置 */
|
||||
function h2GetCustomerPriceConfig(record, customerId) {
|
||||
if (!customerId) return h2GetDefaultAllCustomerPriceConfig(record);
|
||||
var configs = h2GetStationPriceConfigs(record);
|
||||
var i;
|
||||
for (i = 0; i < configs.length; i++) {
|
||||
@@ -929,6 +931,12 @@ function h2GetCustomerPriceConfig(record, customerId) {
|
||||
return h2GetDefaultAllCustomerPriceConfig(record);
|
||||
}
|
||||
|
||||
function h2ResolveCostPriceForCustomer(record, customerId) {
|
||||
var cfg = h2GetCustomerPriceConfig(record, customerId);
|
||||
if (!cfg) return null;
|
||||
return h2ResolveConfigCurrentPrice(cfg);
|
||||
}
|
||||
|
||||
function h2GetAssignedCustomerIds(priceConfigs, exceptConfigId) {
|
||||
var ids = {};
|
||||
var i;
|
||||
@@ -1020,10 +1028,10 @@ function h2GetTierRuleDisplayLines(row) {
|
||||
|
||||
function h2SyncRecordPriceFields(record) {
|
||||
var configs = h2GetStationPriceConfigs(record);
|
||||
var primaryCfg = h2GetPrimaryPriceConfig(record);
|
||||
var resolved = primaryCfg ? h2ResolveConfigCurrentPrice(primaryCfg) : null;
|
||||
var legacyLogs = primaryCfg && primaryCfg.logs && primaryCfg.logs.length
|
||||
? primaryCfg.logs.slice()
|
||||
var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record);
|
||||
var resolved = defaultCfg ? h2ResolveConfigCurrentPrice(defaultCfg) : null;
|
||||
var legacyLogs = defaultCfg && defaultCfg.logs && defaultCfg.logs.length
|
||||
? defaultCfg.logs.slice()
|
||||
: (record.costPriceLogs || []);
|
||||
return Object.assign({}, record, {
|
||||
priceConfigs: configs,
|
||||
@@ -1132,9 +1140,9 @@ function h2FormatReceiptAmountInput(text) {
|
||||
}
|
||||
|
||||
function h2ResolveCurrentCostPrice(record) {
|
||||
var primaryCfg = h2GetPrimaryPriceConfig(record);
|
||||
if (primaryCfg) {
|
||||
var resolved = h2ResolveConfigCurrentPrice(primaryCfg);
|
||||
var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record);
|
||||
if (defaultCfg) {
|
||||
var resolved = h2ResolveConfigCurrentPrice(defaultCfg);
|
||||
if (resolved != null) return resolved;
|
||||
}
|
||||
var logs = (record && record.costPriceLogs) || [];
|
||||
@@ -4196,10 +4204,16 @@ flowchart LR
|
||||
**入口**:更多 → **价格配置**
|
||||
|
||||
1. 填写 **成本价格(元/kg)** 与 **生效时间**。
|
||||
2. 保存后:
|
||||
2. **固定价格**选择**全部客户**时:第一权重取单站单客户价格配置(有则按单客户配置生效),第二权重取站点全部客户价格配置。
|
||||
3. **阶梯价格**生成逻辑:
|
||||
- 设置后,在对应阶梯区间内展示并应用该档成本价
|
||||
- 累计加氢量达下一档时按新档计价;到达**阶梯量重置日期**后的记录按当前区间最新成本价更新(重置日配置见表单「阶梯量重置日期」)
|
||||
- 到达重置日后累计加氢量从零重新计数,用于判断所处价格区间
|
||||
- **生效时间**自表单配置起对新数据生效,不追溯历史;生效前的数据仍按当时已达的最高阶梯价结算
|
||||
4. 保存后:
|
||||
- 若已到生效时间 → 列表「当前成本价格」**立即更新**
|
||||
- 若未到生效时间 → 到点后自动更新
|
||||
3. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。
|
||||
4. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。
|
||||
|
||||
---
|
||||
|
||||
@@ -4267,7 +4281,7 @@ flowchart TD
|
||||
|------|------|
|
||||
| 1 | 选择 **账单开始日期**、**账单结束日期** |
|
||||
| 2 | 参考「上次对账单结束时间」(无历史显示「暂无」) |
|
||||
| 3 | 点击 **生成对账单** |
|
||||
| 3 | 点击 **生成对账单**(弹出二次确认:对应车辆氢费明细记录将标记为已对账且无法修改) |
|
||||
| 4 | 成功后进入阶段二,**日期锁定不可改** |
|
||||
|
||||
#### 阶段二:结算提交
|
||||
@@ -4514,6 +4528,8 @@ flowchart TB
|
||||
### 5.2 价格配置
|
||||
|
||||
- 配置**成本价格(元/kg)**与**生效时间**
|
||||
- **固定价格**选择**全部客户**时:第一权重取单站单客户价格配置(有则按单客户配置生效),第二权重取站点全部客户价格配置
|
||||
- **阶梯价格**生成逻辑:设置后在对应阶梯区间内展示并应用该档成本价;累计加氢量达下一档时按新档计价;到达**阶梯量重置日期**后累计加氢量从零重新计数(配置见表单「阶梯量重置日期」),新记录按当前区间最新成本价更新;**生效时间**自表单配置起对新数据生效,不追溯历史,生效前数据仍按当时已达的最高阶梯价结算
|
||||
- 到达生效时间后自动刷新列表「当前成本价格」
|
||||
- 下方展示价格调整记录(操作人、调整前后价格、生效时间)
|
||||
|
||||
@@ -4561,7 +4577,7 @@ flowchart TD
|
||||
|
||||
- 日期区下方展示:**上次对账单结束时间 YYYY-MM-DD**(无历史则「暂无」)
|
||||
- 默认开始日期建议 = 上次结束日 + 1 天
|
||||
- 点击「生成对账单」后进入阶段二,日期锁定不可改
|
||||
- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改
|
||||
|
||||
**阶段二 · 结算提交**
|
||||
|
||||
@@ -4900,6 +4916,16 @@ var H2_PRIMARY_BTN_STYLE = {
|
||||
border: 'none'
|
||||
};
|
||||
|
||||
function renderH2GhostButton(label, onClick, options) {
|
||||
options = options || {};
|
||||
return React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost' + (options.className ? ' ' + options.className : ''),
|
||||
onClick: onClick,
|
||||
disabled: !!options.disabled
|
||||
}, label);
|
||||
}
|
||||
|
||||
function h2ResolveRecordSupplierMode(record) {
|
||||
if (!record) return 'link';
|
||||
if (record.supplierMode === 'none') return 'none';
|
||||
@@ -6601,6 +6627,13 @@ const Component = function () {
|
||||
message.warning('所选日期范围内暂无「已对账」且未结算的加氢记录,请调整日期后重试');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认生成对账单?',
|
||||
content: '生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改,是否确认?',
|
||||
okText: '确认生成',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: function () {
|
||||
var summary = h2CalcStatementSummary(rows);
|
||||
var balanceAfter = Math.round((h2NumOrZero(statementModal.record.prepaidBalance) - summary.totalCost) * 100) / 100;
|
||||
setStatementModal(function (m) {
|
||||
@@ -6614,6 +6647,8 @@ const Component = function () {
|
||||
});
|
||||
});
|
||||
message.success('已生成对账记录,共 ' + summary.count + ' 笔,请填写结算信息并提交');
|
||||
}
|
||||
});
|
||||
}, [statementModal, ledgerStore]);
|
||||
|
||||
var handleSubmitStatement = useCallback(function () {
|
||||
@@ -8122,7 +8157,7 @@ const Component = function () {
|
||||
isDraft ? '对账记录 · 待提交' : '选择账单日期 · 生成对账记录'
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'h2-statement-form-wrap' },
|
||||
React.createElement('div', { className: 'h2-statement-form-wrap', 'data-annotation-id': 'h2-site-statement-form' },
|
||||
React.createElement('div', { className: 'h2-statement-date-grid' },
|
||||
formItem('账单开始日期', true, React.createElement(Input, {
|
||||
type: 'date',
|
||||
@@ -8556,10 +8591,10 @@ const Component = function () {
|
||||
];
|
||||
|
||||
var renderCurrentCostPriceCell = function (record) {
|
||||
var primaryCfg = h2GetPrimaryPriceConfig(record);
|
||||
var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record);
|
||||
var price = h2ResolveCurrentCostPrice(record);
|
||||
var multiPrice = h2HasMultiCustomerPriceSystems(record);
|
||||
var isTiered = primaryCfg && primaryCfg.priceType === 'tiered';
|
||||
var isTiered = defaultCfg && defaultCfg.priceType === 'tiered';
|
||||
var typeTag = React.createElement(Tag, {
|
||||
className: 'h2-price-type-tag',
|
||||
color: isTiered ? 'purple' : 'processing',
|
||||
@@ -8876,7 +8911,7 @@ const Component = function () {
|
||||
? allCustomerOptions.filter(function (opt) { return selectedCustomerIds.indexOf(opt.value) >= 0; }).map(function (opt) { return opt.label; }).join('、')
|
||||
: (priceType === 'fixed' && (priceModal.customerScope === 'all' || !priceModal.editingConfigId) ? '全部客户' : '');
|
||||
var customerPickerHint = priceType === 'fixed'
|
||||
? '默认对全部客户生效;已为某客户单独配置的,将自动从本规则中排除'
|
||||
? '选择全部客户时:第一权重取单站单客户价格配置,第二权重取本条全部客户价格配置'
|
||||
: undefined;
|
||||
var resetDayOptions = [];
|
||||
var di;
|
||||
@@ -8959,7 +8994,7 @@ const Component = function () {
|
||||
}
|
||||
},
|
||||
React.createElement(Radio.Button, { value: 'fixed' }, '固定价格'),
|
||||
React.createElement(Radio.Button, { value: 'tiered' }, '阶梯价格')
|
||||
React.createElement(Radio.Button, { value: 'tiered', 'data-annotation-id': 'h2-site-price-tiered' }, '阶梯价格')
|
||||
))
|
||||
),
|
||||
React.createElement('div', { className: 'h2-price-config-type-row__col' },
|
||||
@@ -9041,6 +9076,7 @@ const Component = function () {
|
||||
React.createElement('div', { className: 'h2-price-tier-rule-field' },
|
||||
React.createElement('label', null, '成本价格(元/kg)'),
|
||||
React.createElement(Input, {
|
||||
'data-annotation-id': 'h2-site-price-cost-input',
|
||||
value: priceModal.costUnitPrice,
|
||||
placeholder: '请输入成本价格,保留两位小数',
|
||||
style: { borderRadius: 8 },
|
||||
@@ -9653,7 +9689,7 @@ const Component = function () {
|
||||
open: businessModal.open,
|
||||
onCancel: closeBusinessModal,
|
||||
footer: React.createElement(Space, null,
|
||||
React.createElement(Button, { onClick: closeBusinessModal, style: { borderRadius: 8 } }, '取消'),
|
||||
renderH2GhostButton('取消', closeBusinessModal),
|
||||
React.createElement(Button, { type: 'primary', onClick: handleSaveBusinessSetting, style: H2_PRIMARY_BTN_STYLE }, '保存')
|
||||
),
|
||||
centered: true,
|
||||
@@ -9732,7 +9768,7 @@ const Component = function () {
|
||||
open: statementModal.open,
|
||||
onCancel: closeStatementModal,
|
||||
footer: React.createElement(Space, null,
|
||||
React.createElement(Button, { onClick: closeStatementModal, style: { borderRadius: 8 } }, '取消'),
|
||||
renderH2GhostButton('取消', closeStatementModal),
|
||||
statementModal.phase === 'draft'
|
||||
? React.createElement(Button, {
|
||||
type: 'primary',
|
||||
@@ -9788,10 +9824,10 @@ const Component = function () {
|
||||
onCancel: closePriceModal,
|
||||
footer: priceModal.priceFormVisible
|
||||
? React.createElement(Space, null,
|
||||
React.createElement(Button, { onClick: cancelPriceFormEdit, style: { borderRadius: 8 } }, '取消'),
|
||||
renderH2GhostButton('取消', cancelPriceFormEdit),
|
||||
React.createElement(Button, { type: 'primary', onClick: handleSavePriceConfig, style: H2_PRIMARY_BTN_STYLE }, '保存')
|
||||
)
|
||||
: React.createElement(Button, { onClick: closePriceModal, style: H2_PRIMARY_BTN_STYLE }, '关闭'),
|
||||
: renderH2GhostButton('关闭', closePriceModal),
|
||||
centered: true,
|
||||
width: 920,
|
||||
destroyOnClose: true,
|
||||
@@ -9805,7 +9841,7 @@ const Component = function () {
|
||||
open: balanceAlertModal.open,
|
||||
onCancel: closeBalanceAlertModal,
|
||||
footer: React.createElement(Space, null,
|
||||
React.createElement(Button, { onClick: closeBalanceAlertModal, style: { borderRadius: 8 } }, '取消'),
|
||||
renderH2GhostButton('取消', closeBalanceAlertModal),
|
||||
React.createElement(Button, { type: 'primary', onClick: handleSaveBalanceAlert, style: H2_PRIMARY_BTN_STYLE }, '保存')
|
||||
),
|
||||
centered: true,
|
||||
@@ -9866,7 +9902,7 @@ const Component = function () {
|
||||
open: globalBalanceAlertModal.open,
|
||||
onCancel: closeGlobalBalanceAlertModal,
|
||||
footer: React.createElement(Space, null,
|
||||
React.createElement(Button, { onClick: closeGlobalBalanceAlertModal, style: { borderRadius: 8 } }, '取消'),
|
||||
renderH2GhostButton('取消', closeGlobalBalanceAlertModal),
|
||||
React.createElement(Button, { type: 'primary', onClick: handleSaveGlobalBalanceAlert, style: H2_PRIMARY_BTN_STYLE }, '保存')
|
||||
),
|
||||
centered: true,
|
||||
@@ -9921,7 +9957,7 @@ const Component = function () {
|
||||
open: rechargeModal.open,
|
||||
onCancel: closeRechargeModal,
|
||||
footer: React.createElement(Space, null,
|
||||
React.createElement(Button, { onClick: closeRechargeModal, style: { borderRadius: 8 } }, '取消'),
|
||||
renderH2GhostButton('取消', closeRechargeModal),
|
||||
React.createElement(Button, { type: 'primary', onClick: handleSubmitRecharge, style: H2_PRIMARY_BTN_STYLE }, '发起付款流程')
|
||||
),
|
||||
centered: true,
|
||||
|
||||
@@ -3002,8 +3002,7 @@ const Component = function () {
|
||||
React.createElement('span', { className: 'lc-table-legend-dot', style: { background: '#ecfdf5', border: '1px solid #a7f3d0' } }),
|
||||
'审批通过'
|
||||
),
|
||||
React.createElement('span', { style: { color: '#94a3b8', marginLeft: 4 } }, '表头 ? 可查看字段说明与计算公式'),
|
||||
React.createElement('span', { style: { color: '#94a3b8' } }, '共 ' + visibleRows.length + ' 条')
|
||||
React.createElement('span', { style: { color: '#94a3b8', marginLeft: 4 } }, '表头 ? 可查看字段说明与计算公式')
|
||||
),
|
||||
React.createElement('div', { className: 'lc-table-toolbar-actions' },
|
||||
!isSupervisor ? React.createElement(Button, {
|
||||
|
||||
@@ -3010,9 +3010,6 @@ const Component = function () {
|
||||
|
||||
React.createElement('div', { className: 'lc-table-section' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar-meta' },
|
||||
'共 ', React.createElement('strong', null, String(listTotal)), ' 条记录'
|
||||
),
|
||||
React.createElement('div', { className: 'lc-table-toolbar-actions' },
|
||||
React.createElement(Button, { icon: VM_ICONS.export, onClick: handleExport, style: { borderRadius: 8 } }, '导出'),
|
||||
React.createElement(Button, { icon: VM_ICONS.upload, onClick: handleBatchImport, style: { borderRadius: 8 } }, '批量导入')
|
||||
|
||||
@@ -54,6 +54,21 @@ const Component = function () {
|
||||
var textPrimary = '#1e293b';
|
||||
var textSecondary = '#64748b';
|
||||
|
||||
var todoActionMeta = {
|
||||
approve: { label: '需审批', color: 'purple' },
|
||||
fill: { label: '需填报', color: 'geekblue' },
|
||||
initiate: { label: '需发起', color: 'orange' },
|
||||
execute: { label: '需执行', color: 'processing' },
|
||||
follow: { label: '需跟进', color: 'default' },
|
||||
notify: { label: '仅知会', color: 'default' }
|
||||
};
|
||||
var todoRiskMeta = {
|
||||
high: { label: '高', color: 'error' },
|
||||
medium: { label: '中', color: 'warning' },
|
||||
low: { label: '低', color: 'success' },
|
||||
none: { label: '无', color: 'default' }
|
||||
};
|
||||
|
||||
// 工作台卡片内待办 / 通知默认展示条数
|
||||
var wbDashPreviewMax = 5;
|
||||
var wbChartHeight = 168;
|
||||
@@ -214,7 +229,7 @@ const Component = function () {
|
||||
var todoMoreStatus = todoMoreStatusState[0];
|
||||
var setTodoMoreStatus = todoMoreStatusState[1];
|
||||
|
||||
var todoBoardFilterState = useState('all');
|
||||
var todoBoardFilterState = useState('handle');
|
||||
var todoBoardFilter = todoBoardFilterState[0];
|
||||
var setTodoBoardFilter = todoBoardFilterState[1];
|
||||
|
||||
@@ -222,6 +237,26 @@ const Component = function () {
|
||||
var warningDeptKey = warningDeptState[0];
|
||||
var setWarningDeptKey = warningDeptState[1];
|
||||
|
||||
// KPI 展示方案比稿:legacy=部门 Tab 平铺;radar=角色预警台
|
||||
var warningKpiSchemeState = useState('legacy');
|
||||
var warningKpiScheme = warningKpiSchemeState[0];
|
||||
var setWarningKpiScheme = warningKpiSchemeState[1];
|
||||
|
||||
var warningRadarFoldOpenState = useState(false);
|
||||
var warningRadarFoldOpen = warningRadarFoldOpenState[0];
|
||||
var setWarningRadarFoldOpen = warningRadarFoldOpenState[1];
|
||||
|
||||
var roleToWarningDeptKey = useMemo(function () {
|
||||
return {
|
||||
ops: 'ops',
|
||||
ye: 'business',
|
||||
yeEnergy: 'energy',
|
||||
finance: 'finance',
|
||||
safety: 'safety',
|
||||
legal: 'public'
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 顶部警告卡片:按部门切换(逻辑见需求脑图「警告卡片」,联调接接口)
|
||||
var warningDeptOrder = useMemo(function () {
|
||||
return [
|
||||
@@ -289,6 +324,10 @@ const Component = function () {
|
||||
return warningCardsByDept[warningDeptKey] || warningCardsByDept.ops;
|
||||
}, [warningCardsByDept, warningDeptKey]);
|
||||
|
||||
var warningRadarRoleDeptKey = useMemo(function () {
|
||||
return roleToWarningDeptKey[roleTab] || 'ops';
|
||||
}, [roleToWarningDeptKey, roleTab]);
|
||||
|
||||
// 顶部警告卡标题旁提示:脑图「警告卡片」末级说明原文(与 assets 脑图截图一致)
|
||||
var workbenchWarningMetricHintByKey = useMemo(function () {
|
||||
return {
|
||||
@@ -401,32 +440,122 @@ const Component = function () {
|
||||
];
|
||||
}, []);
|
||||
|
||||
// 待办任务表(原型:任务类型、任务名称、状态、任务时间、操作)
|
||||
// 待办任务表(行动语义:类型、风险、责任链、分区)
|
||||
var dashboardTodoRows = useMemo(function () {
|
||||
return [
|
||||
{ id: '1', taskType: '交车', taskName: '顺通运输租赁苏龙18T(交车数:5)', genDate: '2026-03-26 20:08', path: 'web端/运维管理/车辆业务/交车管理.jsx', status: 'pending' },
|
||||
{ id: '2', taskType: '异动', taskName: '异动任务-魏山(异动车数:9)', genDate: '2026-03-27 12:26', path: 'web端/运维管理/车辆业务/异动管理-结束异动.jsx', status: 'overdue' },
|
||||
{ id: '3', taskType: '年审', taskName: '年审任务(沪A62261F)', genDate: '2026-03-27 16:32', path: 'web端/运维管理/车辆业务/异动管理.jsx', status: 'pending' },
|
||||
{ id: '4', taskType: '租赁账单', taskName: '御盛合-租赁苏龙18T(账单编号xxxxxxx)', genDate: '2026-03-28 00:00', path: 'web端/业务管理/租赁账单.jsx', status: 'pending' },
|
||||
{ id: '5', taskType: '还车应结款', taskName: '云通-租赁帕力安18T(粤AGP3649)', genDate: '2026-03-28 11:53', path: 'web端/财务管理/提车应收款.jsx', status: 'pending' }
|
||||
{ id: '1', taskType: '还车应结款', taskName: '云通-租赁帕力安18T(粤AGP3649)· 财务终审', genDate: '2026-03-28 11:53', path: 'web端/审批中心.jsx', status: 'pending', zone: 'today', actionType: 'approve', riskLevel: 'high', riskHint: '高金额应退 ¥4,072.50', nextOwner: '赵敏', nextAction: '执行退款(2 工作日内)', slaRemain: '剩余 6h' },
|
||||
{ id: '2', taskType: '交车', taskName: '顺通运输租赁苏龙18T(交车数:5)', genDate: '2026-03-26 20:08', path: 'web端/运维管理/车辆业务/交车管理.jsx', status: 'pending', zone: 'today', actionType: 'execute', riskLevel: 'medium', riskHint: '交车窗口 6h 内', nextOwner: '客户', nextAction: '签署交车单', slaRemain: '剩余 6h' },
|
||||
{ id: '3', taskType: '异动', taskName: '异动任务-魏山(异动车数:9)', genDate: '2026-03-27 12:26', path: 'web端/运维管理/车辆业务/异动管理-结束异动.jsx', status: 'overdue', zone: 'handle', actionType: 'approve', riskLevel: 'high', riskHint: '已超时,阻塞车辆调度', nextOwner: '运维一部', nextAction: '现场确认异动结束', slaRemain: '已超时 18h' },
|
||||
{ id: '4', taskType: '年审', taskName: '年审任务(沪A62261F)', genDate: '2026-03-27 16:32', path: 'web端/运维管理/车辆业务/异动管理.jsx', status: 'pending', zone: 'handle', actionType: 'execute', riskLevel: 'medium', riskHint: '证照即将到期', nextOwner: '张明辉', nextAction: '完成年审办理', slaRemain: '剩余 2 天' },
|
||||
{ id: '5', taskType: '租赁账单', taskName: '御盛合-租赁苏龙18T(账单编号xxxxxxx)', genDate: '2026-03-28 00:00', path: 'web端/业务管理/租赁账单.jsx', status: 'pending', zone: 'handle', actionType: 'approve', riskLevel: 'low', riskHint: '账单金额无异常', nextOwner: '财务', nextAction: '确认收款', slaRemain: '剩余 3 天' },
|
||||
{ id: '6', taskType: '还车应结款', taskName: '苏E12345F · 四部门已通过,待发起主流程', genDate: '2026-03-28 09:10', path: 'web端/审批中心.jsx', status: 'pending', zone: 'follow', actionType: 'initiate', riskLevel: 'medium', riskHint: '四部门已全部通过,待你发起', nextOwner: '财务审核', nextAction: '主流程审批', slaRemain: '建议今日发起' },
|
||||
{ id: '7', taskType: '租赁账单', taskName: '嘉兴某某物流 · 2026年6月账单(抄送)', genDate: '2026-03-28 10:20', path: 'web端/审批中心.jsx', status: 'pending', zone: 'notify', actionType: 'notify', riskLevel: 'none', riskHint: '无需操作,知悉即可', nextOwner: '—', nextAction: '—', slaRemain: '—' }
|
||||
];
|
||||
}, []);
|
||||
|
||||
var todoSummary = useMemo(function () {
|
||||
var p = 0, o = 0;
|
||||
var p = 0, o = 0, today = 0, handle = 0, follow = 0, notify = 0, actionable = 0;
|
||||
dashboardTodoRows.forEach(function (r) {
|
||||
if (r.status === 'pending') p++;
|
||||
else if (r.status === 'overdue') o++;
|
||||
if (r.zone === 'today') today++;
|
||||
if (r.zone === 'handle') handle++;
|
||||
if (r.zone === 'follow') follow++;
|
||||
if (r.zone === 'notify') notify++;
|
||||
if (['approve', 'fill', 'initiate', 'execute'].indexOf(r.actionType) >= 0) actionable++;
|
||||
});
|
||||
return { pending: p, overdue: o };
|
||||
return { pending: p, overdue: o, today: today, handle: handle, follow: follow, notify: notify, actionable: actionable };
|
||||
}, [dashboardTodoRows]);
|
||||
|
||||
var warningRadarSummary = useMemo(function () {
|
||||
var focus = 0;
|
||||
var high = 0;
|
||||
var overdue = 0;
|
||||
Object.keys(warningCardsByDept).forEach(function (dept) {
|
||||
(warningCardsByDept[dept] || []).forEach(function (c) {
|
||||
var v = typeof c.value === 'number' ? c.value : 0;
|
||||
if (v > 0) focus += v;
|
||||
if (v >= 3) high += 1;
|
||||
if (c.key && String(c.key).indexOf('od') >= 0 && v > 0) overdue += v;
|
||||
});
|
||||
});
|
||||
if (overdue === 0) overdue = 3;
|
||||
return {
|
||||
focus: focus || 12,
|
||||
high: high || 4,
|
||||
overdue: overdue,
|
||||
mine: todoSummary.actionable || 6
|
||||
};
|
||||
}, [warningCardsByDept, todoSummary.actionable]);
|
||||
|
||||
var warningRadarPrimaryCards = useMemo(function () {
|
||||
var deptKey = warningRadarRoleDeptKey;
|
||||
if (deptKey === 'finance') return [];
|
||||
var cards = (warningCardsByDept[deptKey] || []).slice();
|
||||
cards.sort(function (a, b) {
|
||||
return (b.value || 0) - (a.value || 0);
|
||||
});
|
||||
return cards.filter(function (c) {
|
||||
if (c.key === 'w_en_h2_import_today' && energyH2ImportTodayCount === 0) return false;
|
||||
return (c.value || 0) > 0;
|
||||
}).slice(0, 4);
|
||||
}, [warningCardsByDept, warningRadarRoleDeptKey, energyH2ImportTodayCount]);
|
||||
|
||||
var warningFinanceAggCards = useMemo(function () {
|
||||
return [
|
||||
{
|
||||
key: 'fin_agg_lease', title: '租赁 / 提车回款', risk: 'high',
|
||||
stats: '超期 5 · 未全款 3', amount: '¥128,400', owner: '财务部',
|
||||
action: '查看回款明细', value: 8
|
||||
},
|
||||
{
|
||||
key: 'fin_agg_return', title: '还车应结款回款', risk: 'high',
|
||||
stats: '超期 4 · 未全款 2', amount: '—', owner: '财务部',
|
||||
action: '查看回款明细', value: 6
|
||||
},
|
||||
{
|
||||
key: 'fin_agg_energy', title: '氢费 / 能源', risk: 'medium',
|
||||
stats: '超期 1 · 未全款 1', amount: formatWorkbenchFinanceYuan(financeEnergyRechargeYuan), owner: '财务部',
|
||||
action: '查看能源账单', value: 2
|
||||
}
|
||||
];
|
||||
}, [financeEnergyRechargeYuan]);
|
||||
|
||||
var warningRadarMonitorItems = useMemo(function () {
|
||||
var items = [];
|
||||
if (energyH2ImportTodayCount === 0) {
|
||||
items.push({
|
||||
key: 'mon_h2_import',
|
||||
title: '本日导入加氢明细',
|
||||
copy: '今日还未导入任何加氢记录,请确认是否有加氢明细需要导入'
|
||||
});
|
||||
}
|
||||
if (financeEnergyRechargeYuan == null || financeEnergyRechargeYuan === 0) {
|
||||
items.push({
|
||||
key: 'mon_energy_recharge',
|
||||
title: '能源账户充值金额',
|
||||
copy: '请确认是否有能源账户充值记录'
|
||||
});
|
||||
}
|
||||
if (warningRadarRoleDeptKey === 'public') {
|
||||
items.push({ key: 'mon_audit_types', title: '流程类型分布', copy: '异动 3 · 调拨 2 · 还车应结款 4' });
|
||||
}
|
||||
return items;
|
||||
}, [energyH2ImportTodayCount, financeEnergyRechargeYuan, warningRadarRoleDeptKey]);
|
||||
|
||||
var dashboardTodoBoardRows = useMemo(function () {
|
||||
var rows = dashboardTodoRows.filter(function (r) {
|
||||
if (todoBoardFilter === 'all') return true;
|
||||
return r.status === todoBoardFilter;
|
||||
if (todoBoardFilter === 'actionable') return r.actionType !== 'notify' && r.actionType !== 'follow';
|
||||
if (todoBoardFilter === 'pending') return r.status === 'pending';
|
||||
if (todoBoardFilter === 'overdue') return r.status === 'overdue';
|
||||
return r.zone === todoBoardFilter;
|
||||
});
|
||||
rows.sort(function (a, b) {
|
||||
var riskOrder = { high: 0, medium: 1, low: 2, none: 3 };
|
||||
var ra = riskOrder[a.riskLevel] != null ? riskOrder[a.riskLevel] : 9;
|
||||
var rb = riskOrder[b.riskLevel] != null ? riskOrder[b.riskLevel] : 9;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return String(b.genDate || '').localeCompare(String(a.genDate || ''));
|
||||
});
|
||||
return rows;
|
||||
@@ -1576,6 +1705,31 @@ const Component = function () {
|
||||
} }
|
||||
];
|
||||
|
||||
function renderTodoActionCell(_, r) {
|
||||
var meta = todoActionMeta[r.actionType] || todoActionMeta.execute;
|
||||
return React.createElement(Tag, { color: meta.color, style: { margin: 0 } }, meta.label);
|
||||
}
|
||||
|
||||
function renderTodoRiskCell(_, r) {
|
||||
var meta = todoRiskMeta[r.riskLevel] || todoRiskMeta.none;
|
||||
return React.createElement(Space, { size: 4, direction: 'vertical', style: { lineHeight: 1.35 } },
|
||||
React.createElement(Tag, { color: meta.color, style: { margin: 0 } }, '风险' + meta.label),
|
||||
r.riskHint ? React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, r.riskHint) : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderTodoChainCell(_, r) {
|
||||
if (!r.nextOwner || r.actionType === 'notify') {
|
||||
return React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '—');
|
||||
}
|
||||
return React.createElement('div', { style: { fontSize: 12, lineHeight: 1.45, color: 'rgba(0,0,0,0.65)' } },
|
||||
React.createElement('span', null, '→ '),
|
||||
React.createElement('strong', { style: { color: 'rgba(0,0,0,0.88)', fontWeight: 500 } }, r.nextOwner),
|
||||
React.createElement('span', null, ' · ' + (r.nextAction || '—')),
|
||||
r.slaRemain ? React.createElement('div', { style: { marginTop: 2, color: r.slaRemain.indexOf('超时') >= 0 ? '#f5222d' : 'rgba(0,0,0,0.45)' } }, r.slaRemain) : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderTodoStatusCell(_, r) {
|
||||
if (r.status === 'pending') return React.createElement(Tag, { color: 'processing' }, '待处理');
|
||||
if (r.status === 'overdue') return React.createElement(Tag, { color: 'error' }, '已超时');
|
||||
@@ -1608,8 +1762,11 @@ const Component = function () {
|
||||
|
||||
var todoTableColumns = useMemo(function () {
|
||||
return [
|
||||
{ title: '任务类型', dataIndex: 'taskType', key: 'taskType', width: 100 },
|
||||
{ title: '行动', dataIndex: 'actionType', key: 'actionType', width: 88, render: renderTodoActionCell },
|
||||
{ title: '风险', key: 'risk', width: 148, render: renderTodoRiskCell },
|
||||
{ title: '任务类型', dataIndex: 'taskType', key: 'taskType', width: 96 },
|
||||
{ title: '任务名称', dataIndex: 'taskName', key: 'taskName', ellipsis: true },
|
||||
{ title: '责任链', key: 'chain', width: 188, render: renderTodoChainCell },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
@@ -1620,7 +1777,7 @@ const Component = function () {
|
||||
title: '任务时间',
|
||||
dataIndex: 'genDate',
|
||||
key: 'genDate',
|
||||
width: 160,
|
||||
width: 148,
|
||||
showSorterTooltip: false,
|
||||
defaultSortOrder: 'descend',
|
||||
sorter: function (a, b) {
|
||||
@@ -1634,7 +1791,8 @@ const Component = function () {
|
||||
align: 'right',
|
||||
render: function (_, r) {
|
||||
var isDone = r.status === 'done';
|
||||
var urgeBtn = !isDone
|
||||
var isNotify = r.actionType === 'notify';
|
||||
var urgeBtn = !isDone && !isNotify
|
||||
? React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
@@ -1642,7 +1800,7 @@ const Component = function () {
|
||||
onClick: function () { pushUrgeNotice(r); }
|
||||
}, '催办')
|
||||
: null;
|
||||
var primaryLabel = isDone ? '查看' : '处理';
|
||||
var primaryLabel = isDone ? '查看' : (isNotify ? '查看' : (r.actionType === 'approve' ? '去审批' : (r.actionType === 'initiate' ? '去发起' : '处理')));
|
||||
return React.createElement(Space, { size: 4 },
|
||||
React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: function () { protoNav(r.path); } }, primaryLabel),
|
||||
urgeBtn
|
||||
@@ -1654,8 +1812,11 @@ const Component = function () {
|
||||
|
||||
var todoMoreModalColumns = useMemo(function () {
|
||||
return [
|
||||
{ title: '任务类型', dataIndex: 'taskType', key: 'taskType', width: 100 },
|
||||
{ title: '行动', dataIndex: 'actionType', key: 'actionType', width: 88, render: renderTodoActionCell },
|
||||
{ title: '风险', key: 'risk', width: 148, render: renderTodoRiskCell },
|
||||
{ title: '任务类型', dataIndex: 'taskType', key: 'taskType', width: 96 },
|
||||
{ title: '任务名称', dataIndex: 'taskName', key: 'taskName', ellipsis: true },
|
||||
{ title: '责任链', key: 'chain', width: 188, render: renderTodoChainCell },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
@@ -1666,7 +1827,7 @@ const Component = function () {
|
||||
title: '任务时间',
|
||||
dataIndex: 'genDate',
|
||||
key: 'genDate',
|
||||
width: 160,
|
||||
width: 148,
|
||||
showSorterTooltip: false,
|
||||
defaultSortOrder: 'descend',
|
||||
sorter: function (a, b) {
|
||||
@@ -1680,7 +1841,8 @@ const Component = function () {
|
||||
align: 'right',
|
||||
render: function (_, r) {
|
||||
var isDone = r.status === 'done';
|
||||
var urgeBtn = !isDone
|
||||
var isNotify = r.actionType === 'notify';
|
||||
var urgeBtn = !isDone && !isNotify
|
||||
? React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
@@ -1688,7 +1850,7 @@ const Component = function () {
|
||||
onClick: function () { pushUrgeNotice(r); }
|
||||
}, '催办')
|
||||
: null;
|
||||
var primaryLabel = isDone ? '查看' : '处理';
|
||||
var primaryLabel = isDone ? '查看' : (isNotify ? '查看' : (r.actionType === 'approve' ? '去审批' : (r.actionType === 'initiate' ? '去发起' : '处理')));
|
||||
return React.createElement(Space, { size: 4 },
|
||||
React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: function () { protoNav(r.path); } }, primaryLabel),
|
||||
urgeBtn
|
||||
@@ -1899,6 +2061,226 @@ const Component = function () {
|
||||
return renderVehicleMetricCard(cardProps);
|
||||
}
|
||||
|
||||
function getWarningRiskMeta(value) {
|
||||
var v = typeof value === 'number' ? value : 0;
|
||||
if (v >= 5) return { level: 'high', label: '高', color: '#f5222d', bg: 'rgba(245,34,45,0.08)' };
|
||||
if (v >= 2) return { level: 'medium', label: '中', color: '#fa8c16', bg: 'rgba(250,140,22,0.1)' };
|
||||
return { level: 'low', label: '低', color: '#1677ff', bg: 'rgba(22,119,255,0.08)' };
|
||||
}
|
||||
|
||||
function renderWarningSchemeSwitcher() {
|
||||
return React.createElement('div', { className: 'workbench-kpi-scheme-bar' },
|
||||
React.createElement('div', { className: 'workbench-kpi-scheme-copy' },
|
||||
React.createElement('span', { className: 'workbench-kpi-scheme-kicker' }, 'KPI 方案比稿'),
|
||||
React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } },
|
||||
warningKpiScheme === 'legacy'
|
||||
? '方案 A:按部门 Tab 切换,指标平铺(现状)'
|
||||
: '方案 B:按登录角色推送,首屏主预警 + 财务聚合 + 跨部门进度'
|
||||
)
|
||||
),
|
||||
React.createElement(Space, { size: 8, wrap: true },
|
||||
React.createElement(Button, {
|
||||
size: 'small',
|
||||
type: warningKpiScheme === 'legacy' ? 'primary' : 'default',
|
||||
onClick: function () { setWarningKpiScheme('legacy'); }
|
||||
}, '方案 A · 部门 Tab'),
|
||||
React.createElement(Button, {
|
||||
size: 'small',
|
||||
type: warningKpiScheme === 'radar' ? 'primary' : 'default',
|
||||
onClick: function () {
|
||||
setWarningKpiScheme('radar');
|
||||
setWarningDeptKey(warningRadarRoleDeptKey);
|
||||
}
|
||||
}, '方案 B · 角色预警台')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderWarningRadarSummaryBar() {
|
||||
return React.createElement('div', { className: 'workbench-warning-radar-summary' },
|
||||
React.createElement('button', { type: 'button', className: 'workbench-warning-radar-chip workbench-warning-radar-chip--focus' },
|
||||
React.createElement('b', null, warningRadarSummary.focus),
|
||||
' 今日需关注'
|
||||
),
|
||||
React.createElement('button', { type: 'button', className: 'workbench-warning-radar-chip workbench-warning-radar-chip--high' },
|
||||
React.createElement('b', null, warningRadarSummary.high),
|
||||
' 高风险'
|
||||
),
|
||||
React.createElement('button', { type: 'button', className: 'workbench-warning-radar-chip workbench-warning-radar-chip--overdue' },
|
||||
React.createElement('b', null, warningRadarSummary.overdue),
|
||||
' 已超期'
|
||||
),
|
||||
React.createElement('button', { type: 'button', className: 'workbench-warning-radar-chip' },
|
||||
React.createElement('b', null, warningRadarSummary.mine),
|
||||
' 我主责'
|
||||
),
|
||||
React.createElement('div', { className: 'workbench-warning-radar-perspective' },
|
||||
React.createElement(Text, { type: 'secondary', style: { fontSize: 12, marginRight: 8 } }, '预警视角'),
|
||||
React.createElement(Select, {
|
||||
size: 'small',
|
||||
style: { minWidth: 132 },
|
||||
value: roleTab,
|
||||
options: quickRoleKeys.map(function (k) {
|
||||
return { value: k, label: quickByRole[k] ? quickByRole[k].label : k };
|
||||
}),
|
||||
onChange: function (val) {
|
||||
if (!val) return;
|
||||
setRoleTab(val);
|
||||
setWarningDeptKey(roleToWarningDeptKey[val] || 'ops');
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderWarningRadarEnhancedCard(s, opts) {
|
||||
opts = opts || {};
|
||||
var risk = getWarningRiskMeta(opts.value != null ? opts.value : s.value);
|
||||
if (opts.risk === 'high') risk = getWarningRiskMeta(9);
|
||||
if (opts.risk === 'medium') risk = getWarningRiskMeta(3);
|
||||
var owner = opts.owner || (quickByRole[roleTab] ? quickByRole[roleTab].label : '运维');
|
||||
var actionLabel = opts.action || '查看明细';
|
||||
var onClick = opts.onClick || function () {
|
||||
message.info('「' + (opts.title || s.title) + '」明细(方案 B 原型)');
|
||||
};
|
||||
return React.createElement(Card, {
|
||||
key: opts.key || s.key,
|
||||
className: 'workbench-warning-radar-card workbench-metric-card workbench-metric-card--interactive',
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
style: Object.assign({}, cardStyle, { flex: '1 1 240px', minWidth: 240, minHeight: 112, cursor: 'pointer' }),
|
||||
bodyStyle: { padding: '14px 16px' },
|
||||
onClick: onClick,
|
||||
tabIndex: 0,
|
||||
role: 'button'
|
||||
},
|
||||
React.createElement('div', { className: 'workbench-warning-radar-card-top' },
|
||||
React.createElement(Tag, { color: risk.level === 'high' ? 'error' : (risk.level === 'medium' ? 'warning' : 'processing'), style: { margin: 0 } }, '风险' + risk.label),
|
||||
React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '主责 · ' + owner)
|
||||
),
|
||||
React.createElement('div', { className: 'workbench-warning-radar-card-title' }, opts.title || s.title),
|
||||
React.createElement('div', { className: 'workbench-warning-radar-card-value' }, opts.stats || s.value),
|
||||
opts.amount ? React.createElement('div', { className: 'workbench-warning-radar-card-sub' }, opts.amount) : null,
|
||||
React.createElement('div', { className: 'workbench-warning-radar-card-action' }, '→ ' + actionLabel)
|
||||
);
|
||||
}
|
||||
|
||||
function renderReturnSettleProgressCard() {
|
||||
var steps = [
|
||||
{ key: 'ops', label: '运维', state: 'done' },
|
||||
{ key: 'biz', label: '业管', state: 'done' },
|
||||
{ key: 'energy', label: '能源', state: 'active' },
|
||||
{ key: 'safe', label: '安全', state: 'todo' },
|
||||
{ key: 'fin', label: '财务', state: 'todo' }
|
||||
];
|
||||
return React.createElement(Card, {
|
||||
key: 'return-settle-progress',
|
||||
className: 'workbench-warning-progress-card workbench-metric-card',
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
style: Object.assign({}, cardStyle, { flex: '1 1 100%', minWidth: 0, width: '100%' }),
|
||||
bodyStyle: { padding: '14px 16px' }
|
||||
},
|
||||
React.createElement('div', { className: 'workbench-warning-radar-card-top' },
|
||||
React.createElement(Tag, { color: 'warning', style: { margin: 0 } }, '跨部门协同'),
|
||||
React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '3 单阻塞中 · 最近粤B58888F')
|
||||
),
|
||||
React.createElement('div', { className: 'workbench-warning-radar-card-title' }, '还车应结款 · 部门进度'),
|
||||
React.createElement('div', { className: 'workbench-warning-progress-track' },
|
||||
steps.map(function (step) {
|
||||
return React.createElement('div', {
|
||||
key: step.key,
|
||||
className: 'workbench-warning-progress-step workbench-warning-progress-step--' + step.state
|
||||
},
|
||||
React.createElement('span', { className: 'workbench-warning-progress-dot' }),
|
||||
React.createElement('span', { className: 'workbench-warning-progress-label' }, step.label)
|
||||
);
|
||||
})
|
||||
),
|
||||
React.createElement('div', { className: 'workbench-warning-radar-card-action' }, '→ 查看阻塞单据(能源部待核对 2 单)')
|
||||
);
|
||||
}
|
||||
|
||||
function renderWarningRadarMonitorFold() {
|
||||
if (!warningRadarMonitorItems.length) return null;
|
||||
return React.createElement('div', { className: 'workbench-warning-radar-fold' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'workbench-warning-radar-fold-head',
|
||||
onClick: function () { setWarningRadarFoldOpen(function (v) { return !v; }); }
|
||||
},
|
||||
React.createElement('span', null, '其他监控指标(', warningRadarMonitorItems.length, ')'),
|
||||
React.createElement('span', null, warningRadarFoldOpen ? '收起 ▴' : '展开 ▾')
|
||||
),
|
||||
warningRadarFoldOpen
|
||||
? React.createElement('div', { className: 'workbench-warning-radar-fold-body' },
|
||||
warningRadarMonitorItems.map(function (it) {
|
||||
return React.createElement('div', { key: it.key, className: 'workbench-warning-radar-fold-item' },
|
||||
React.createElement('strong', null, it.title),
|
||||
React.createElement('span', null, it.copy)
|
||||
);
|
||||
})
|
||||
)
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function renderWarningKpiLegacyBlock() {
|
||||
return React.createElement('div', { className: 'workbench-top-metrics', style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, alignItems: 'stretch' } },
|
||||
React.createElement('div', {
|
||||
className: 'workbench-warning-cards' + (warningDeptKey === 'finance' ? ' workbench-warning-cards--finance' : ''),
|
||||
style: warningDeptKey === 'finance'
|
||||
? { flex: '1 1 100%', display: 'flex', flexDirection: 'column', gap: layoutGutter, alignItems: 'stretch', minWidth: 0, width: '100%' }
|
||||
: { flex: '1 1 280px', display: 'flex', flexWrap: 'wrap', gap: layoutGutter, alignItems: 'stretch', minWidth: 0 }
|
||||
},
|
||||
warningDeptKey === 'finance'
|
||||
? [
|
||||
React.createElement('div', {
|
||||
key: 'wb-fin-row1',
|
||||
style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, width: '100%', alignItems: 'stretch' }
|
||||
}, warningCardsVisible.slice(0, 5).map(function (s) { return renderWarningMetricCardItem(s); })),
|
||||
React.createElement('div', {
|
||||
key: 'wb-fin-row2',
|
||||
style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, width: '100%', alignItems: 'stretch' }
|
||||
}, warningCardsVisible.slice(5).map(function (s) { return renderWarningMetricCardItem(s); }))
|
||||
]
|
||||
: warningCardsVisible.map(function (s) { return renderWarningMetricCardItem(s); })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderWarningKpiRadarBlock() {
|
||||
var primaryCards = warningRadarRoleDeptKey === 'finance'
|
||||
? warningFinanceAggCards.map(function (c) { return renderWarningRadarEnhancedCard({}, c); })
|
||||
: warningRadarPrimaryCards.map(function (s) {
|
||||
return renderWarningRadarEnhancedCard(s, {
|
||||
owner: quickByRole[roleTab] ? quickByRole[roleTab].label : '运维',
|
||||
onClick: function () {
|
||||
if (s.key === 'w_ops_delivery') { openOverdueDeliveryModal(); return; }
|
||||
if (s.key === 'w_ops_return_cnt') { openOverdueReturnModal(); return; }
|
||||
if (s.key === 'w_ops_inspect') { setInspectModalOpen(true); return; }
|
||||
message.info('「' + s.title + '」明细(方案 B)');
|
||||
}
|
||||
});
|
||||
});
|
||||
return React.createElement('div', { className: 'workbench-warning-radar-wrap' },
|
||||
renderWarningRadarSummaryBar(),
|
||||
React.createElement('div', { className: 'workbench-warning-radar-primary', style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter } }, primaryCards),
|
||||
(warningRadarRoleDeptKey === 'business' || warningRadarRoleDeptKey === 'energy' || warningRadarRoleDeptKey === 'safety' || warningRadarRoleDeptKey === 'finance')
|
||||
? renderReturnSettleProgressCard()
|
||||
: null,
|
||||
renderWarningRadarMonitorFold(),
|
||||
React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', width: '100%' } },
|
||||
React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
style: { padding: 0 },
|
||||
onClick: function () { message.info('全部预警抽屉(方案 B 原型)'); }
|
||||
}, '查看全部预警')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderNoticePanelCard() {
|
||||
var preview = (noticeList || []).slice(0, wbDashPreviewMax);
|
||||
var titleNode = React.createElement(Space, { size: 8, wrap: true, align: 'center' },
|
||||
@@ -2235,7 +2617,41 @@ const Component = function () {
|
||||
'.workbench-report-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:var(--wb-text);font-weight:600}' +
|
||||
'.workbench-dash-pair-head.ant-card .ant-card-head-title,.workbench-dash-pair-head-title{font-size:17px;font-weight:700;line-height:26px;color:var(--wb-text);letter-spacing:-0.01em}' +
|
||||
'.workbench-report-slot .ant-card .ant-card-head{min-height:52px;padding:0 18px;border-bottom:1px solid rgba(0,0,0,0.06)}' +
|
||||
'.workbench-report-slot .ant-card .ant-card-head-title{font-size:16px;font-weight:600;color:rgba(0,0,0,0.88)}';
|
||||
'.workbench-report-slot .ant-card .ant-card-head-title{font-size:16px;font-weight:600;color:rgba(0,0,0,0.88)}' +
|
||||
'.workbench-kpi-scheme-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px;padding:12px 14px;border-radius:12px;border:1px dashed rgba(37,99,235,0.28);background:linear-gradient(135deg,#f8fbff 0%,#eff6ff 100%)}' +
|
||||
'.workbench-kpi-scheme-kicker{display:inline-flex;align-items:center;font-size:12px;font-weight:700;color:#1d4ed8;letter-spacing:0.03em;text-transform:uppercase;margin-right:8px}' +
|
||||
'.workbench-kpi-scheme-copy{flex:1;min-width:220px}' +
|
||||
'.workbench-warning-radar-wrap{display:flex;flex-direction:column;gap:12px;width:100%}' +
|
||||
'.workbench-warning-radar-summary{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:4px 2px 2px}' +
|
||||
'.workbench-warning-radar-chip{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:0 12px;border-radius:999px;border:1px solid var(--wb-border);background:var(--wb-surface);font-size:12px;color:var(--wb-text-secondary);cursor:pointer}' +
|
||||
'.workbench-warning-radar-chip b{font-size:14px;font-weight:700;color:var(--wb-text);font-variant-numeric:tabular-nums}' +
|
||||
'.workbench-warning-radar-chip--focus{border-color:rgba(37,99,235,0.25);background:#eff6ff}' +
|
||||
'.workbench-warning-radar-chip--high{border-color:rgba(245,34,45,0.22);background:#fff1f0}' +
|
||||
'.workbench-warning-radar-chip--high b{color:#cf1322}' +
|
||||
'.workbench-warning-radar-chip--overdue{border-color:rgba(250,140,22,0.25);background:#fff7e6}' +
|
||||
'.workbench-warning-radar-perspective{margin-left:auto;display:flex;align-items:center;gap:8px}' +
|
||||
'.workbench-warning-radar-primary{width:100%}' +
|
||||
'.workbench-warning-radar-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}' +
|
||||
'.workbench-warning-radar-card-title{font-size:14px;font-weight:600;line-height:1.45;color:var(--wb-text);margin-bottom:6px}' +
|
||||
'.workbench-warning-radar-card-value{font-size:24px;font-weight:700;line-height:1.2;color:var(--wb-accent);font-variant-numeric:tabular-nums}' +
|
||||
'.workbench-warning-radar-card-sub{margin-top:4px;font-size:12px;color:var(--wb-text-secondary);font-variant-numeric:tabular-nums}' +
|
||||
'.workbench-warning-radar-card-action{margin-top:10px;font-size:12px;font-weight:600;color:var(--wb-primary)}' +
|
||||
'.workbench-warning-progress-card .workbench-warning-radar-card-title{margin-bottom:10px}' +
|
||||
'.workbench-warning-progress-track{display:flex;align-items:flex-start;justify-content:space-between;gap:6px;margin:4px 0 10px}' +
|
||||
'.workbench-warning-progress-step{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:6px;position:relative}' +
|
||||
'.workbench-warning-progress-step:not(:last-child)::after{content:"";position:absolute;top:9px;left:calc(50% + 10px);right:calc(-50% + 10px);height:2px;background:#e2e8f0;z-index:0}' +
|
||||
'.workbench-warning-progress-step--done:not(:last-child)::after{background:#86efac}' +
|
||||
'.workbench-warning-progress-step--active:not(:last-child)::after{background:linear-gradient(90deg,#86efac,#fbbf24)}' +
|
||||
'.workbench-warning-progress-dot{width:18px;height:18px;border-radius:50%;border:2px solid #cbd5e1;background:#fff;position:relative;z-index:1}' +
|
||||
'.workbench-warning-progress-step--done .workbench-warning-progress-dot{border-color:#22c55e;background:#22c55e}' +
|
||||
'.workbench-warning-progress-step--active .workbench-warning-progress-dot{border-color:#f59e0b;background:#fff7ed;box-shadow:0 0 0 4px rgba(245,158,11,0.18)}' +
|
||||
'.workbench-warning-progress-label{font-size:11px;line-height:1.3;color:var(--wb-text-secondary);text-align:center}' +
|
||||
'.workbench-warning-progress-step--active .workbench-warning-progress-label{color:#b45309;font-weight:600}' +
|
||||
'.workbench-warning-radar-fold{border:1px solid var(--wb-border);border-radius:12px;background:#fafafa;overflow:hidden}' +
|
||||
'.workbench-warning-radar-fold-head{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;border:none;background:transparent;font-size:13px;font-weight:600;color:var(--wb-text-secondary);cursor:pointer}' +
|
||||
'.workbench-warning-radar-fold-body{padding:0 14px 12px;display:flex;flex-direction:column;gap:8px}' +
|
||||
'.workbench-warning-radar-fold-item{padding:10px 12px;border-radius:10px;background:#fff;border:1px solid rgba(0,0,0,0.04);font-size:12px;line-height:1.5;color:var(--wb-text-secondary)}' +
|
||||
'.workbench-warning-radar-fold-item strong{display:block;margin-bottom:4px;color:var(--wb-text);font-weight:600}';
|
||||
|
||||
return React.createElement(App, null,
|
||||
React.createElement('div', {
|
||||
@@ -2264,12 +2680,16 @@ const Component = function () {
|
||||
React.createElement('em', null, mockOperatorDisplayName)
|
||||
),
|
||||
React.createElement('p', { className: 'workbench-hero-sub' },
|
||||
'集中查看待办、预警指标与业务通知,快速进入常用功能。'
|
||||
'按行动类型、风险与责任链查看待办,快速判断是否需要你处理。'
|
||||
),
|
||||
React.createElement('div', { className: 'workbench-hero-stats' },
|
||||
React.createElement('span', { className: 'workbench-hero-stat workbench-hero-stat--info' },
|
||||
React.createElement('b', null, todoSummary.pending + todoSummary.overdue),
|
||||
' 条待办'
|
||||
React.createElement('b', null, todoSummary.actionable),
|
||||
' 条需处理'
|
||||
),
|
||||
React.createElement('span', { className: 'workbench-hero-stat workbench-hero-stat--warn' },
|
||||
React.createElement('b', null, todoSummary.today),
|
||||
' 条今日必办'
|
||||
),
|
||||
noticeNewCount > 0
|
||||
? React.createElement('span', { className: 'workbench-hero-stat workbench-hero-stat--warn' },
|
||||
@@ -2280,7 +2700,8 @@ const Component = function () {
|
||||
)
|
||||
),
|
||||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', minWidth: 0 } },
|
||||
React.createElement(Tabs, {
|
||||
warningKpiScheme === 'legacy'
|
||||
? React.createElement(Tabs, {
|
||||
className: 'workbench-header-warning-dept-tabs',
|
||||
activeKey: warningDeptKey,
|
||||
onChange: setWarningDeptKey,
|
||||
@@ -2288,31 +2709,13 @@ const Component = function () {
|
||||
items: warningDeptTabItems,
|
||||
tabBarGutter: 4
|
||||
})
|
||||
: React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '预警视角已移至 KPI 区')
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement('div', { style: { flexShrink: 0, marginBottom: 12, boxSizing: 'border-box' } },
|
||||
React.createElement('div', { className: 'workbench-top-metrics', style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, alignItems: 'stretch' } },
|
||||
React.createElement('div', {
|
||||
className: 'workbench-warning-cards' + (warningDeptKey === 'finance' ? ' workbench-warning-cards--finance' : ''),
|
||||
style: warningDeptKey === 'finance'
|
||||
? { flex: '1 1 100%', display: 'flex', flexDirection: 'column', gap: layoutGutter, alignItems: 'stretch', minWidth: 0, width: '100%' }
|
||||
: { flex: '1 1 280px', display: 'flex', flexWrap: 'wrap', gap: layoutGutter, alignItems: 'stretch', minWidth: 0 }
|
||||
},
|
||||
warningDeptKey === 'finance'
|
||||
? [
|
||||
React.createElement('div', {
|
||||
key: 'wb-fin-row1',
|
||||
style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, width: '100%', alignItems: 'stretch' }
|
||||
}, warningCardsVisible.slice(0, 5).map(function (s) { return renderWarningMetricCardItem(s); })),
|
||||
React.createElement('div', {
|
||||
key: 'wb-fin-row2',
|
||||
style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, width: '100%', alignItems: 'stretch' }
|
||||
}, warningCardsVisible.slice(5).map(function (s) { return renderWarningMetricCardItem(s); }))
|
||||
]
|
||||
: warningCardsVisible.map(function (s) { return renderWarningMetricCardItem(s); })
|
||||
)
|
||||
)
|
||||
renderWarningSchemeSwitcher(),
|
||||
warningKpiScheme === 'legacy' ? renderWarningKpiLegacyBlock() : renderWarningKpiRadarBlock()
|
||||
),
|
||||
React.createElement(Row, { gutter: [layoutGutter, layoutGutter], align: 'stretch', style: { flex: 1, minHeight: 0, overflow: 'hidden' } },
|
||||
React.createElement(Col, {
|
||||
@@ -2340,10 +2743,11 @@ const Component = function () {
|
||||
className: 'workbench-dash-pair-head workbench-surface-card',
|
||||
title: React.createElement(Space, { size: 8, wrap: true, align: 'center' },
|
||||
React.createElement('span', { className: 'workbench-dash-pair-head-title' }, '待办任务'),
|
||||
React.createElement(Badge, { count: todoSummary.pending + todoSummary.overdue, style: { backgroundColor: accentBlue } }),
|
||||
renderTodoStatChip('all', '全部', dashboardTodoRows.length, 'rgba(0,0,0,0.04)', '#595959'),
|
||||
renderTodoStatChip('pending', '待处理', todoSummary.pending, 'rgba(22,119,255,0.06)', accentBlue),
|
||||
renderTodoStatChip('overdue', '已超时', todoSummary.overdue, 'rgba(245,34,45,0.06)', '#f5222d')
|
||||
React.createElement(Badge, { count: todoSummary.actionable, style: { backgroundColor: accentBlue } }),
|
||||
renderTodoStatChip('today', '今日必办', todoSummary.today, 'rgba(245,34,45,0.06)', '#f5222d'),
|
||||
renderTodoStatChip('handle', '待我处理', todoSummary.handle, 'rgba(22,119,255,0.06)', accentBlue),
|
||||
renderTodoStatChip('follow', '待我跟进', todoSummary.follow, 'rgba(0,0,0,0.04)', '#595959'),
|
||||
renderTodoStatChip('all', '全部', dashboardTodoRows.length, 'rgba(0,0,0,0.04)', '#595959')
|
||||
),
|
||||
bordered: false,
|
||||
style: Object.assign({}, cardStyle, { flex: 1, width: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }),
|
||||
@@ -2359,7 +2763,7 @@ const Component = function () {
|
||||
columns: todoTableColumns,
|
||||
dataSource: dashboardTodoBoardPreviewRows,
|
||||
pagination: false,
|
||||
scroll: { x: 704 }
|
||||
scroll: { x: 980 }
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -2556,7 +2960,7 @@ const Component = function () {
|
||||
columns: todoMoreModalColumns,
|
||||
dataSource: todoMoreFilteredRows,
|
||||
pagination: { pageSize: 10, showSizeChanger: false, showTotal: function (t) { return '共 ' + t + ' 条'; } },
|
||||
scroll: { x: 780, y: 360 }
|
||||
scroll: { x: 980, y: 360 }
|
||||
})
|
||||
)
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "payment-records",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783401000000,
|
||||
"updatedAt": 1783589987167,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "pr-filter",
|
||||
@@ -99,6 +99,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -113,112 +120,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "self-operated-business-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783352657926,
|
||||
"updatedAt": 1783589987168,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sob-filter",
|
||||
@@ -104,6 +104,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -118,112 +125,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @name 自营业务台账
|
||||
* @name 物流业务台账
|
||||
* 设计规范:ledger-shared/DESIGN.md · vm-page + ldb-page(与租赁业务台账、租赁合同列表对齐)
|
||||
*/
|
||||
import '../vehicle-management/style.css';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "supplier-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783352657926,
|
||||
"updatedAt": 1783589987168,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sm-list-filter",
|
||||
@@ -223,6 +223,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -237,112 +244,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
489
src/prototypes/vehicle-h2-fee-ledger/.spec/PRD.md
Normal file
489
src/prototypes/vehicle-h2-fee-ledger/.spec/PRD.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# 车辆氢费明细 — 产品需求说明(PRD)
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 文档版本 | v2.3(业务版) |
|
||||
| 产品模块 | 台账数据 → 车辆氢费明细 |
|
||||
| 文档类型 | 产品需求说明 |
|
||||
| 适用读者 | 产品、业务、运营、测试、项目 |
|
||||
| 修订说明 | 同步成本单价校验图标与一键替换、固定/阶梯定价标签及与加氢站价格配置取价逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么做这件事
|
||||
|
||||
### 1.1 业务痛点
|
||||
|
||||
- 车辆加氢费用分散在业务同事手工记录中,缺少统一台账,难以按客户、站点、时间核对。
|
||||
- 录入后需区分「是否已正式提交」「是否已完成对账」,避免未核实数据进入对账与结算环节。
|
||||
- 加氢量偏大、单价与公司在该站该时段的标准价不一致等情况,需要**提前暴露**,减少后续财务纠纷。
|
||||
|
||||
### 1.2 产品价值
|
||||
|
||||
| 价值点 | 说明 |
|
||||
|--------|------|
|
||||
| 统一台账 | 一条记录对应一次加氢业务,关联车辆、客户、站点与金额 |
|
||||
| 流程可控 | 待保存 → 未对账 → 已对账,责任边界清晰 |
|
||||
| 效率提升 | 支持批量导入、批量改价、复制同类记录 |
|
||||
| 风险可见 | 异常数据标黄,可一键只看有问题记录 |
|
||||
| 可追溯 | 关键修改留痕,便于主管复核与客诉查证 |
|
||||
|
||||
### 1.3 本期目标
|
||||
|
||||
建设 Web 端「车辆氢费明细」页面,支撑业务人员**查询、录入、保存、对账、导入导出**全流程;财务相关字段(开票、付款等)由系统按客户等信息自动带出,减少手工填写错误。
|
||||
|
||||
### 1.4 本期不做
|
||||
|
||||
- 多级审批流、与 ERP 自动过账(可后续迭代)。
|
||||
- 按组织架构的复杂数据权限(本期按「业务员 / 主管」两类角色描述,上线后对接权限中心)。
|
||||
- 导入文件格式的完整兼容方案以业务验收为准(需支持模板化批量录入)。
|
||||
|
||||
---
|
||||
|
||||
## 二、谁在用、用来干什么
|
||||
|
||||
### 2.1 用户角色
|
||||
|
||||
| 角色 | 典型诉求 |
|
||||
|------|----------|
|
||||
| **业务员** | 日常录入加氢记录;批量导入历史数据;保存后推进对账;导出给财务或客户核对 |
|
||||
| **主管** | 复核异常数据;必要时修正或删除**已对账**记录;查看变更记录 |
|
||||
|
||||
### 2.2 核心使用场景(用户故事)
|
||||
|
||||
1. **单笔补录**
|
||||
业务员得知某车在某站加氢后,在页面新增一行,填写时间、站点、车牌、加氢量与单价,保存后进入「未对账」列表等待勾选完成对账。
|
||||
|
||||
2. **批量导入历史**
|
||||
业务员下载模板,在 Excel 中整理多笔记录后上传,系统生成多条状态为「未对账」数据。
|
||||
|
||||
3. **日常对账**
|
||||
业务员按客户或时间筛选「未对账」记录,勾选确认无误后点击「完成对账」,数据变为「已对账」,本人不可再随意修改。
|
||||
|
||||
4. **异常排查**
|
||||
业务员勾选「仅显示异常数据」,优先处理加氢量过大(>60kg)或单价与标准不一致的记录,修正后再保存或对账。
|
||||
|
||||
5. **主管纠错**
|
||||
已对账数据发现错误,主管进入编辑后修改;业务员侧编辑/删除按钮不可用,需联系主管处理。
|
||||
|
||||
6. **导出报送**
|
||||
业务员按筛选条件导出「未对账 + 已对账」正式数据(不含尚未保存的草稿),自选列字段生成表格文件。
|
||||
|
||||
---
|
||||
|
||||
## 三、业务主流程
|
||||
|
||||
### 3.1 端到端流程(推荐操作顺序)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[录入/复制] --> B[待保存]
|
||||
B --> C[点击保存]
|
||||
C[保存/导入] --> D[未对账]
|
||||
D --> E[勾选并完成对账]
|
||||
E --> F[已对账]
|
||||
```
|
||||
|
||||
**说明:**
|
||||
|
||||
- **待保存**:草稿态,可反复修改;尚未进入对账池。
|
||||
- **未对账**:已保存的正式数据,可「编辑」「删除」勾选「完成对账」。
|
||||
- **已对账**:对账完成,业务员不可改删;主管可特殊处理「编辑」「删除」。
|
||||
|
||||
### 3.2 「保存」与「完成对账」的区别
|
||||
|
||||
| 操作 | 业务含义 | 对象 | 结果 |
|
||||
|------|----------|------|------|
|
||||
| **保存** | 将草稿确认为可参与对账的正式记录 | 当前页所有「待保存」记录 | 全部变为「未对账」 |
|
||||
| **完成对账** | 确认与客户/内部核对无误,结案 | 用户勾选的「未对账」记录 | 变为「已对账」 |
|
||||
|
||||
**保存前校验:** 必填项未填完整时,在对应格子标红提示,**不弹出成功提示**,也不变更状态。
|
||||
|
||||
**完成对账前确认:** 系统提示「完成对账后只能联系主管修改」,用户确认后执行。
|
||||
|
||||
---
|
||||
|
||||
## 四、页面功能说明(业务视角)
|
||||
|
||||
### 4.1 页面组成
|
||||
|
||||
用户进入「台账数据 → 车辆氢费明细」后,自上而下为:
|
||||
|
||||
1. **查询条件区** — 按时间、订单、车辆、客户等筛选
|
||||
2. **数据列表区** — 展示氢费明细、合计、操作按钮
|
||||
3. **弹窗** — 批量导入、批量导出、变更日志
|
||||
|
||||
列表下方提供 **「新增一行」**,用于手工补录。
|
||||
|
||||
### 4.2 查询条件
|
||||
|
||||
**交互原则:** 修改条件后不会立刻刷新列表,需点击 **「查询」** 才生效;**「重置」** 清空条件并恢复全量展示。
|
||||
|
||||
**默认展示:** 第一行条件(加氢时间、订单编号、查询按钮);点击 **「展开」** 可看到更多条件。
|
||||
|
||||
| 查询项 | 业务说明 |
|
||||
|--------|----------|
|
||||
| 加氢时间 | 起止日期范围 |
|
||||
| 订单编号 | 支持模糊搜索 |
|
||||
| 车牌号 | 从公司车辆库选择 |
|
||||
| 客户名称 | 从客户库选择 |
|
||||
| 加氢站名称 | 从加氢站库选择 |
|
||||
| 业务员 | 按客户归属业务员筛选 |
|
||||
| 承担方式 | 客户承担 / 我司承担 / 客户自行结算 / 其他结算 |
|
||||
| 客户收款状态 | 已付款 / 未付款(系统带出,用于筛选) |
|
||||
| 开票公司 | 从开票主体选择(系统带出字段对应公司) |
|
||||
|
||||
查询成功给予简短成功提示。
|
||||
|
||||
**与列表的关系:**
|
||||
|
||||
- 列表默认展示符合查询条件的记录。
|
||||
- **例外:** 本人刚新增、尚未保存的记录,即使暂时不符合筛选条件,也会显示在列表中,避免「点了新增却找不到行」。
|
||||
- **「仅显示异常数据」** 开启时,只展示有风险提示的记录;本人待保存的新增行仍会显示,以免录入中断。
|
||||
|
||||
### 4.3 列表展示规则
|
||||
|
||||
**分区展示(便于扫读):**
|
||||
|
||||
| 区域顺序(上→下) | 记录状态 | 排序习惯 |
|
||||
|-------------------|----------|----------|
|
||||
| 第一区 | 已对账 | 加氢时间新的在前 |
|
||||
| 第二区 | 未对账 | 加氢时间新的在前 |
|
||||
| 第三区 | 待保存 | 先录入的在前,新加的靠下 |
|
||||
|
||||
不同区域之间有明显分隔,已对账区背景略灰、待保存区背景略黄,降低误操作概率。
|
||||
|
||||
**顶部合计条:** 表格上方独立展示 **加氢量(kg)、成本总价(元)、加氢总价(元)** 三项合计。
|
||||
|
||||
- **统计范围:** 与当前列表可见行一致。
|
||||
- **随查询重算:** 点击「查询」应用筛选条件后,合计立即按新结果重新汇总。
|
||||
- **随「仅显示异常数据」变化:** 开关开启时,仅对当前可见的异常记录(及本人待保存行)合计。
|
||||
- **说明:** 合计条含「待保存 / 未对账 / 已对账」等当前列表中的全部可见记录,与导出范围(仅未对账+已对账)不同。
|
||||
|
||||
**列表列顺序(左→右,业务视角):**
|
||||
序号 → 加氢日期 → 加氢时间 → 加氢站名称 → 客户名称 → 车牌号 → 加氢量 → 成本单价 → 成本总价 → 加氢单价 → 加氢总价 → 行驶里程 → 备注 → 业务员 → 承担方式 → 对账日期 → **收票日期** → **加氢站付款状态** → **开票日期** → **客户收款状态** → **开票公司** → 状态 → 订单编号 → 操作(操作列固定右侧)。
|
||||
|
||||
**系统带出字段(不可手工维护):** 收票日期、加氢站付款状态、开票日期、客户收款状态、开票公司;选择/变更客户等信息后由系统自动刷新,单元格为浅灰底只读样式。
|
||||
|
||||
**列表勾选:** 仅 **未对账** 记录可勾选,用于批量「完成对账」。
|
||||
|
||||
### 4.4 工具栏操作
|
||||
|
||||
从左到右、从业务使用频率排列:
|
||||
|
||||
| 按钮 | 业务作用 |
|
||||
|------|----------|
|
||||
| 仅显示异常数据 | 开关,快速聚焦有风险提示的记录 |
|
||||
| 批量导入 | 按模板上传多条待保存记录 |
|
||||
| 批量导出 | 按当前查询条件导出正式数据 |
|
||||
| 保存 | 将全部待保存记录提交为未对账 |
|
||||
| 完成对账 | 将勾选的未对账记录结案为已对账 |
|
||||
|
||||
---
|
||||
|
||||
## 五、单条记录:填什么、怎么算
|
||||
|
||||
### 5.1 用户可见字段说明
|
||||
|
||||
| 信息项 | 用户是否填写 | 业务说明 |
|
||||
|--------|--------------|----------|
|
||||
| 加氢时间 | 是* | 精确到秒,必填 |
|
||||
| 加氢日期 | 否 | 由加氢时间自动得出,只读 |
|
||||
| 加氢站名称 | 是* | 必选 |
|
||||
| 客户名称 | 是* | 必选,位于加氢站右侧;决定业务员与系统带出信息 |
|
||||
| 车牌号 | 是* | 必选,须为公司登记车辆 |
|
||||
| 加氢量(kg) | 是* | 必填,用于算总价 |
|
||||
| 成本单价 | 是* | 必填;单价前展示「固定/阶梯」标签;与站点标准成本价校验(含一键替换) |
|
||||
| 成本总价 | 否 | 加氢量 × 成本单价,自动计算 |
|
||||
| 加氢单价 | 是* | 必填,对客户的销售单价 |
|
||||
| 加氢总价 | 否 | 加氢量 × 加氢单价,自动计算 |
|
||||
| 行驶里程 | 否 | 选填,位于加氢总价之后 |
|
||||
| 备注 | 否 | 自由文本 |
|
||||
| 业务员 | 否 | 选客户后自动带出 |
|
||||
| 承担方式 | 是* | 客户承担 / 我司承担 / 客户自行结算 / 其他结算 |
|
||||
| 对账日期 | 否 | 完成对账等流程带出,只读 |
|
||||
| 收票日期 | 否 | 由财务/收票模块自动带出,不可编辑 |
|
||||
| 加氢站付款状态 | 否 | 已付款 / 未付款,由加氢站打款管理带出 |
|
||||
| 开票日期 | 否 | 由开票模块自动带出 |
|
||||
| 客户收款状态 | 否 | 已付款 / 未付款,由客户收款模块带出 |
|
||||
| 开票公司 | 否 | 显示开票公司名称,系统自动带出 |
|
||||
| 状态 | 否 | 展示「未对账」「已对账」;待保存不显示标签;列在列表末尾 |
|
||||
| 订单编号 | 否 | 系统按规则自动生成,不可改;列在列表末尾 |
|
||||
|
||||
\* 保存时校验,缺失则标红提示。
|
||||
|
||||
### 5.2 订单编号规则(业务口径)
|
||||
|
||||
便于业务与财务对齐、按站按日追溯:
|
||||
|
||||
- 编号以 **JQ** 开头,后接 **加氢站编码**、**加氢日期(6 位)**、**当日流水号(5 位)**。
|
||||
- 同一加氢站、同一天内,按加氢时间先后依次编号,每日从 1 号起。
|
||||
- 用户无需手填,保存或数据重算时由系统生成。
|
||||
|
||||
### 5.3 金额计算
|
||||
|
||||
- **成本总价** = 加氢量 × 成本单价(保留两位小数)
|
||||
- **加氢总价** = 加氢量 × 加氢单价(保留两位小数)
|
||||
|
||||
任一项未填齐时,对应总价为空,填齐后自动更新。
|
||||
|
||||
### 5.4 成本单价与站点定价方式
|
||||
|
||||
成本单价来源于加氢站管理中的价格配置,列表中单价的展示与校验规则如下:
|
||||
|
||||
- 单价输入框左侧展示 **固定** 或 **阶梯** 小标签,对应站点对该客户(或默认「全部客户」)的定价方式。
|
||||
- **固定价格**:标准成本价为配置的单一单价(在生效时间内)。
|
||||
- **阶梯价格**:标准成本价按当前阶梯周期内**累计加氢量**匹配档位;悬浮「阶梯」标签可查看**当前阶梯量**(kg)与**对应单价**(元/kg)。
|
||||
- 录入单价与标准成本价不一致时,单元格标黄并显示校验图标;未对账数据可由创建人或主管 **一键替换为标准成本单价**。
|
||||
|
||||
**取价优先级**(与加氢站管理「站点信息」一致):第一权重为**单站单客户**成本价配置;第二权重为站点**全部客户**成本价配置。
|
||||
|
||||
关联模块:加氢站管理 → 站点信息(`/prototypes/oneos-web-h2-station-site`)的固定价 / 阶梯价配置。
|
||||
|
||||
---
|
||||
|
||||
## 六、谁能改、谁能删
|
||||
|
||||
### 6.1 按状态的权限(业务员 vs 主管)
|
||||
|
||||
| 数据状态 | 业务员 | 主管 |
|
||||
|----------|--------|------|
|
||||
| 待保存(本人录入) | 可直接改表格;可复制、删除 | 同业务员(本期不单独区分待保存) |
|
||||
| 待保存(他人录入) | 不可见或不可改(按账号权限,上线后明确) | — |
|
||||
| 未对账 | 需点「编辑」后才能改;可复制、删除;可勾选完成对账 | 同业务员 |
|
||||
| 已对账 | 不可编辑、不可删除(按钮灰显+提示联系主管) | 可点「编辑」修改;可删除 |
|
||||
|
||||
### 6.2 行内操作说明
|
||||
|
||||
| 操作 | 适用情况 | 业务说明 |
|
||||
|------|----------|----------|
|
||||
| 编辑 | 未对账、已对账(主管) | 进入编辑模式后方可改单元格;待保存无需点编辑 |
|
||||
| 复制 | 未对账、本人待保存 | 可一次复制 1~100 条相似记录,生成新的待保存行 |
|
||||
| 删除 | 未对账、本人待保存;已对账仅主管 | 二次确认后删除 |
|
||||
| 更多 → 变更日志 | 未对账、已对账 | 查看该条历史修改记录 |
|
||||
|
||||
---
|
||||
|
||||
## 七、异常数据 — 业务规则
|
||||
|
||||
系统在列表中提示以下异常情况(**不影响保存**,但需业务留意);可开启「仅显示异常数据」集中处理。
|
||||
|
||||
### 7.1 单元格标黄(字段级)
|
||||
|
||||
| 异常类型 | 判定逻辑 | 用户看到的提示 |
|
||||
|----------|----------|----------------|
|
||||
| 加氢量偏大 | 单次加氢量超过 60 kg | 加氢量疑似有误 |
|
||||
| 成本单价异常 | 所填成本单价与「该站该时段公司标准成本价」不一致 | 成本单价与系统该时间段不一致 |
|
||||
| 加氢单价异常 | 所填加氢单价与「该站该时段公司标准对客户价」不一致 | 加氢单价与系统该时间段不一致 |
|
||||
|
||||
标准成本价随 **加氢站 + 加氢时间 + 客户** 匹配(固定价取配置单价;阶梯价按周期累计加氢量匹配档位,规则与加氢站管理一致);标准客户价仍按 **加氢站 + 加氢时间** 匹配。若未配置对应价格,则不提示单价类异常。
|
||||
|
||||
### 7.2 成本单价列 — 定价标签与校验
|
||||
|
||||
**定价方式标签**(已选加氢站且可解析价格配置时,在单价左侧展示):
|
||||
|
||||
| 标签 | 含义 |
|
||||
|------|------|
|
||||
| 固定 | 站点对该客户(或站点默认「全部客户」)为**固定成本价** |
|
||||
| 阶梯 | 站点对该客户为**阶梯成本价**;悬浮显示**当前阶梯量**(kg)与**对应单价**(元/kg) |
|
||||
|
||||
**成本单价列状态图标**(**所有登录用户均可见**,不因业务员/主管角色隐藏):
|
||||
|
||||
| 校验结果 | 图标 | 悬浮提示 |
|
||||
|----------|------|----------|
|
||||
| 与标准成本价一致 | 绿色 ✓ | 「成本单价正确」+ 标准价说明(含固定价或阶梯价口径) |
|
||||
| 与标准成本价不一致 | 红色 ⚠ | 「成本单价异常」+ 标准价说明 |
|
||||
| 无法校验(未填单价、未选站点、未配置标准价等) | 无 | — |
|
||||
|
||||
**一键替换**(仅与标准成本价不一致且数据**未对账**):
|
||||
|
||||
- 错误提示框内提供「**一键替换为标准成本单价**」
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以标准成本价覆盖录入值,并写入变更日志(字段:**成本单价(一键替换)**)
|
||||
|
||||
### 7.3 整行标红(客户名称异常)
|
||||
|
||||
按 **车牌 + 加氢日期** 比对车辆运营态(交还车/库存/异动):
|
||||
|
||||
| 场景 | 判定 | 界面表现 |
|
||||
|------|------|----------|
|
||||
| 租赁/自营,已交车未还车 | 录入客户与系统登记客户不一致 | 整行淡红底;各单元格水印「客户疑似有误」 |
|
||||
| 库存车(可运营/待运营) | 不应填写客户名称 | 同上 |
|
||||
| 异动期内 | 客户名称应为「车辆异动」 | 同上(**未对账**时) |
|
||||
|
||||
**已对账结案后(仅客户名称类异常):**
|
||||
|
||||
- 整行淡红底与各格「客户疑似有误」水印**去除**,恢复普通已对账行样式
|
||||
- 客户名称列 **红色 ⚠ / 绿色 ✓** 及悬浮说明**仍保留**,便于事后查阅
|
||||
- **不参与**「仅显示异常数据」筛选(单元格标黄类异常规则不变)
|
||||
|
||||
**客户名称列状态图标**(**所有登录用户均可见**,不因业务员/主管角色隐藏):
|
||||
|
||||
| 校验结果 | 图标 | 悬浮提示 |
|
||||
|----------|------|----------|
|
||||
| 与系统登记一致,或库存/异动规则符合 | 绿色 ✓ | 「客户名称正确」+ 系统客户、交车时间等说明 |
|
||||
| 疑似有误 | 红色 ⚠ | 「客户疑似有误」+ 场景说明(系统客户、交车时间等) |
|
||||
| 无法校验(缺车牌、加氢时间或未填客户等) | 无 | — |
|
||||
|
||||
**一键替换**(仅「与系统登记客户不一致」且数据**未对账**):
|
||||
|
||||
- 错误提示框内提供「一键替换为系统客户」
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以系统登记客户覆盖录入值,并写入变更日志(字段:客户名称(一键替换))
|
||||
|
||||
### 7.4 「仅显示异常数据」
|
||||
|
||||
- 默认关闭,展示符合查询条件的全部记录(含待保存例外规则)。
|
||||
- 开启后只显示至少有一项异常(标黄或客户整行标红)的记录,方便集中核对。
|
||||
- **导出不受此开关影响**(见下文)。
|
||||
|
||||
---
|
||||
|
||||
## 八、批量能力
|
||||
|
||||
### 8.1 表头批量改价 / 改结算
|
||||
|
||||
- 在 **成本单价**、**加氢单价** 列表头可批量填入统一单价,作用于当前可编辑的所有记录(待保存 + 未对账)。
|
||||
- 在 **承担方式** 列表头可批量改为四种承担方式之一。
|
||||
- **已对账** 记录不参与批量修改。
|
||||
|
||||
### 8.2 批量导入
|
||||
|
||||
**适用:** 历史数据、线下表格整理后的批量录入。
|
||||
|
||||
**流程:**
|
||||
|
||||
1. 点击「批量导入」→ 下载标准模板
|
||||
2. 按模板填写(无需填对账日期及收票/开票/付款等系统带出字段)
|
||||
3. 上传文件 → 系统生成多条 **待保存** 记录
|
||||
4. 业务核对列表 → 点击 **保存** 进入未对账
|
||||
|
||||
**模板包含列:** 加氢时间、加氢站名称、车牌号、加氢量、成本单价、客户名称、加氢单价、承担方式、备注(行驶里程可在备注前按业务需要填写,导入模板列顺序以页面下载为准)。
|
||||
|
||||
**导入后状态:** 均为待保存,与手工新增一致,须保存后才参与对账与导出。
|
||||
|
||||
### 8.3 批量导出
|
||||
|
||||
**适用:** 向财务、客户或内部汇报正式台账。
|
||||
|
||||
**规则:**
|
||||
|
||||
- 导出范围 = **当前查询条件下** 的 **未对账 + 已对账** 数据。
|
||||
- **不包含待保存**(草稿不对外报送)。
|
||||
- **不受**「仅显示异常数据」开关限制。
|
||||
- 弹窗文案:「请选择导出的列」;支持全选/取消全选;默认勾选全部可导出列。
|
||||
- 无符合条件数据时提示:暂无未对账或已对账数据可导出。
|
||||
|
||||
**可导出列(业务名称):** 序号、状态、订单编号、加氢时间、加氢日期、加氢站名称、车牌号、加氢量、成本单价、成本总价、客户名称、加氢单价、加氢总价、行驶里程、业务员、承担方式、对账日期、备注、收票日期、加氢站付款状态、开票日期、客户收款状态、开票公司。
|
||||
|
||||
---
|
||||
|
||||
## 九、变更日志
|
||||
|
||||
### 9.1 记录什么
|
||||
|
||||
满足审计与纠纷查证,以下变化需留痕:
|
||||
|
||||
- 保存(待保存 → 未对账)
|
||||
- 完成对账(未对账 → 已对账)
|
||||
- 各业务字段修改(前后值不同)
|
||||
- **成本单价(一键替换)**、**客户名称(一键替换)**
|
||||
- 新增、复制、批量导入产生记录
|
||||
|
||||
### 9.2 怎么查看
|
||||
|
||||
在操作列点击 **「更多」→ 变更日志**,弹窗展示:
|
||||
|
||||
- 修改时间、修改人、修改字段、修改前(红)、修改后(绿)
|
||||
- 按时间倒序排列
|
||||
|
||||
---
|
||||
|
||||
## 十、业务规则汇总(验收必读)
|
||||
|
||||
### 10.1 保存
|
||||
|
||||
- 一次保存处理页面上**全部**待保存记录。
|
||||
- 必填:加氢时间、加氢站、车牌(须为登记车辆)、客户、加氢量、成本单价、加氢单价、承担方式。
|
||||
- 校验失败:仅格子标红,无「保存成功」类提示。
|
||||
|
||||
### 10.2 完成对账
|
||||
|
||||
- 必须先勾选未对账记录。
|
||||
- 仅未对账可勾选。
|
||||
- 确认文案强调:完成后需主管才能改。
|
||||
|
||||
### 10.3 筛选与列表
|
||||
|
||||
- 查询点击后生效;重置恢复。
|
||||
- **顶部合计条** 对当前列表可见行实时汇总;修改筛选并点击「查询」后重新计算;「仅显示异常数据」开启时仅统计可见行。
|
||||
- 本人待保存新增行在筛选后仍可见(合计亦包含这些行,若其在列表中展示)。
|
||||
|
||||
### 10.4 导出
|
||||
|
||||
- 仅未对账、已对账;遵循查询条件;可自选列。
|
||||
|
||||
### 10.5 复制
|
||||
|
||||
- 单次最多 100 条;生成待保存记录。
|
||||
|
||||
---
|
||||
|
||||
## 十一、业务验收场景(UAT 建议)
|
||||
|
||||
| 编号 | 场景 | 预期结果 |
|
||||
|------|------|----------|
|
||||
| U1 | 新增一行并填齐必填项后保存 | 变为未对账,出现订单编号 |
|
||||
| U2 | 必填项缺一项点保存 | 对应格标红,状态仍为待保存 |
|
||||
| U3 | 勾选多条未对账并完成对账 | 变为已对账,业务员不可改删 |
|
||||
| U4 | 业务员打开已对账记录 | 编辑/删除不可用,有主管联系提示 |
|
||||
| U5 | 加氢量填 65kg | 单元格标黄,提示加氢量疑似有误 |
|
||||
| U6 | 单价填与标准价不一致 | 标黄并提示与系统该时段不一致 |
|
||||
| U7 | 开启「仅显示异常数据」 | 仅异常记录可见,本人新加待保存仍可见 |
|
||||
| U8 | 设置筛选后导出 | 仅导出未对账+已对账且符合筛选的数据 |
|
||||
| U9 | 导入模板 5 条后保存 | 5 条均变未对账 |
|
||||
| U10 | 未对账记录点编辑后改客户 | 业务员、开票信息等随客户更新,变更日志有记录 |
|
||||
| U11 | 租赁车客户与系统不一致 | 整行标红、水印「客户疑似有误」,红色 ⚠;创建人或主管可一键替换并留痕 |
|
||||
| U12 | 库存车误填客户 | 整行标红,红色 ⚠,提示不应填写客户名称 |
|
||||
| U13 | 租赁车客户与系统一致 | 客户名称后显示绿色 ✓,悬浮「客户名称正确」 |
|
||||
| U14 | 非创建人查看客户不一致行 | 可见红色 ⚠ 与说明,无「一键替换」按钮 |
|
||||
| U15 | 异动期客户为「车辆异动」 | 不标红,客户名称后绿色 ✓,悬浮说明异动期规则 |
|
||||
| U16 | 异动期客户填其他名称 | 整行标红、水印「客户疑似有误」,红色 ⚠,提示应为「车辆异动」 |
|
||||
| U17 | 客户名称异常行完成对账 | 整行标红/水印消失,红色 ⚠ 仍可见;「仅显示异常数据」筛不出该行 |
|
||||
| U18 | 成本单价与标准价一致 | 成本单价后显示绿色 ✓,悬浮「成本单价正确」 |
|
||||
| U19 | 成本单价与标准价不一致 | 单元格标黄 + 红色 ⚠;创建人或主管可一键替换为标准成本单价并留痕 |
|
||||
| U20 | 非创建人查看成本单价不一致行 | 可见红色 ⚠ 与说明,无「一键替换」按钮 |
|
||||
| U21 | 固定价站点行 | 成本单价前显示「固定」标签 |
|
||||
| U22 | 阶梯价客户行 | 显示「阶梯」标签;悬浮可见当前阶梯量与对应单价 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、名词与枚举(业务词典)
|
||||
|
||||
| 名词 | 含义 |
|
||||
|------|------|
|
||||
| 待保存 | 草稿,未进入对账流程 |
|
||||
| 未对账 | 已保存,等待业务确认并完成对账 |
|
||||
| 已对账 | 对账完成,业务员不可随意改动 |
|
||||
| 客户承担 | 氢费由客户承担 |
|
||||
| 我司承担 | 氢费由我司承担 |
|
||||
| 客户自行结算 | 由客户自行与加氢站等方结算 |
|
||||
| 其他结算 | 其他结算方式 |
|
||||
| 加氢站付款状态 | 加氢站侧是否已付款,已付款 / 未付款 |
|
||||
| 客户收款状态 | 客户侧是否已收款,已付款 / 未付款 |
|
||||
| 收票日期 | 财务收票日期,系统带出 |
|
||||
| 标准价 | 公司维护的、按加氢站与生效时段(及客户)确定的参考单价 |
|
||||
| 固定价格 | 加氢站对全部客户或指定客户配置的单一成本单价 |
|
||||
| 阶梯价格 | 按阶梯周期累计加氢量分档确定成本单价的规则 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、版本与协作说明
|
||||
|
||||
- 本文档描述的是 **车辆氢费明细** 页面当前已对齐的产品行为,供评审、培训与 UAT 使用。
|
||||
- 研发实现细节、接口定义、字段编码见单独的技术设计文档(如有)。
|
||||
- 需求变更请更新本文档版本号,并在评审纪要中注明影响章节。
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
@@ -7,11 +7,11 @@ var H2_LEDGER_REQUIREMENT_DOC = `# 车辆氢费明细 — 产品需求说明(P
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 文档版本 | v2.2(业务版) |
|
||||
| 文档版本 | v2.3(业务版) |
|
||||
| 产品模块 | 台账数据 → 车辆氢费明细 |
|
||||
| 文档类型 | 产品需求说明 |
|
||||
| 适用读者 | 产品、业务、运营、测试、项目 |
|
||||
| 修订说明 | 同步客户名称校验图标(正确/错误)、一键替换权限、标注目录 PRD 全文与页面需求说明 |
|
||||
| 修订说明 | 同步成本单价校验图标与一键替换、固定/阶梯定价标签及与加氢站价格配置取价逻辑 |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +198,7 @@ flowchart LR
|
||||
| 客户名称 | 是* | 必选,位于加氢站右侧;决定业务员与系统带出信息 |
|
||||
| 车牌号 | 是* | 必选,须为公司登记车辆 |
|
||||
| 加氢量(kg) | 是* | 必填,用于算总价 |
|
||||
| 成本单价 | 是* | 必填,向站点的采购成本价 |
|
||||
| 成本单价 | 是* | 必填;单价前展示「固定/阶梯」标签;与站点标准成本价校验(含一键替换) |
|
||||
| 成本总价 | 否 | 加氢量 × 成本单价,自动计算 |
|
||||
| 加氢单价 | 是* | 必填,对客户的销售单价 |
|
||||
| 加氢总价 | 否 | 加氢量 × 加氢单价,自动计算 |
|
||||
@@ -232,6 +232,19 @@ flowchart LR
|
||||
|
||||
任一项未填齐时,对应总价为空,填齐后自动更新。
|
||||
|
||||
### 5.4 成本单价与站点定价方式
|
||||
|
||||
成本单价来源于加氢站管理中的价格配置,列表中单价的展示与校验规则如下:
|
||||
|
||||
- 单价输入框左侧展示 **固定** 或 **阶梯** 小标签,对应站点对该客户(或默认「全部客户」)的定价方式。
|
||||
- **固定价格**:标准成本价为配置的单一单价(在生效时间内)。
|
||||
- **阶梯价格**:标准成本价按当前阶梯周期内**累计加氢量**匹配档位;悬浮「阶梯」标签可查看**当前阶梯量**(kg)与**对应单价**(元/kg)。
|
||||
- 录入单价与标准成本价不一致时,单元格标黄并显示校验图标;未对账数据可由创建人或主管 **一键替换为标准成本单价**。
|
||||
|
||||
**取价优先级**(与加氢站管理「站点信息」一致):第一权重为**单站单客户**成本价配置;第二权重为站点**全部客户**成本价配置。
|
||||
|
||||
关联模块:加氢站管理 → 站点信息(\`/prototypes/oneos-web-h2-station-site\`)的固定价 / 阶梯价配置。
|
||||
|
||||
---
|
||||
|
||||
## 六、谁能改、谁能删
|
||||
@@ -268,9 +281,32 @@ flowchart LR
|
||||
| 成本单价异常 | 所填成本单价与「该站该时段公司标准成本价」不一致 | 成本单价与系统该时间段不一致 |
|
||||
| 加氢单价异常 | 所填加氢单价与「该站该时段公司标准对客户价」不一致 | 加氢单价与系统该时间段不一致 |
|
||||
|
||||
标准价随 **加氢站 + 加氢时间** 匹配;若公司未配置该站该时段价格,则不提示单价类异常。
|
||||
标准成本价随 **加氢站 + 加氢时间 + 客户** 匹配(固定价取配置单价;阶梯价按周期累计加氢量匹配档位,规则与加氢站管理一致);标准客户价仍按 **加氢站 + 加氢时间** 匹配。若未配置对应价格,则不提示单价类异常。
|
||||
|
||||
### 7.2 整行标红(客户名称异常)
|
||||
### 7.2 成本单价列 — 定价标签与校验
|
||||
|
||||
**定价方式标签**(已选加氢站且可解析价格配置时,在单价左侧展示):
|
||||
|
||||
| 标签 | 含义 |
|
||||
|------|------|
|
||||
| 固定 | 站点对该客户(或站点默认「全部客户」)为**固定成本价** |
|
||||
| 阶梯 | 站点对该客户为**阶梯成本价**;悬浮显示**当前阶梯量**(kg)与**对应单价**(元/kg) |
|
||||
|
||||
**成本单价列状态图标**(**所有登录用户均可见**,不因业务员/主管角色隐藏):
|
||||
|
||||
| 校验结果 | 图标 | 悬浮提示 |
|
||||
|----------|------|----------|
|
||||
| 与标准成本价一致 | 绿色 ✓ | 「成本单价正确」+ 标准价说明(含固定价或阶梯价口径) |
|
||||
| 与标准成本价不一致 | 红色 ⚠ | 「成本单价异常」+ 标准价说明 |
|
||||
| 无法校验(未填单价、未选站点、未配置标准价等) | 无 | — |
|
||||
|
||||
**一键替换**(仅与标准成本价不一致且数据**未对账**):
|
||||
|
||||
- 错误提示框内提供「**一键替换为标准成本单价**」
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以标准成本价覆盖录入值,并写入变更日志(字段:**成本单价(一键替换)**)
|
||||
|
||||
### 7.3 整行标红(客户名称异常)
|
||||
|
||||
按 **车牌 + 加氢日期** 比对车辆运营态(交还车/库存/异动):
|
||||
|
||||
@@ -300,7 +336,7 @@ flowchart LR
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以系统登记客户覆盖录入值,并写入变更日志(字段:客户名称(一键替换))
|
||||
|
||||
### 7.3 「仅显示异常数据」
|
||||
### 7.4 「仅显示异常数据」
|
||||
|
||||
- 默认关闭,展示符合查询条件的全部记录(含待保存例外规则)。
|
||||
- 开启后只显示至少有一项异常(标黄或客户整行标红)的记录,方便集中核对。
|
||||
@@ -356,6 +392,7 @@ flowchart LR
|
||||
- 保存(待保存 → 未对账)
|
||||
- 完成对账(未对账 → 已对账)
|
||||
- 各业务字段修改(前后值不同)
|
||||
- **成本单价(一键替换)**、**客户名称(一键替换)**
|
||||
- 新增、复制、批量导入产生记录
|
||||
|
||||
### 9.2 怎么查看
|
||||
@@ -418,6 +455,11 @@ flowchart LR
|
||||
| U15 | 异动期客户为「车辆异动」 | 不标红,客户名称后绿色 ✓,悬浮说明异动期规则 |
|
||||
| U16 | 异动期客户填其他名称 | 整行标红、水印「客户疑似有误」,红色 ⚠,提示应为「车辆异动」 |
|
||||
| U17 | 客户名称异常行完成对账 | 整行标红/水印消失,红色 ⚠ 仍可见;「仅显示异常数据」筛不出该行 |
|
||||
| U18 | 成本单价与标准价一致 | 成本单价后显示绿色 ✓,悬浮「成本单价正确」 |
|
||||
| U19 | 成本单价与标准价不一致 | 单元格标黄 + 红色 ⚠;创建人或主管可一键替换为标准成本单价并留痕 |
|
||||
| U20 | 非创建人查看成本单价不一致行 | 可见红色 ⚠ 与说明,无「一键替换」按钮 |
|
||||
| U21 | 固定价站点行 | 成本单价前显示「固定」标签 |
|
||||
| U22 | 阶梯价客户行 | 显示「阶梯」标签;悬浮可见当前阶梯量与对应单价 |
|
||||
|
||||
---
|
||||
|
||||
@@ -435,7 +477,9 @@ flowchart LR
|
||||
| 加氢站付款状态 | 加氢站侧是否已付款,已付款 / 未付款 |
|
||||
| 客户收款状态 | 客户侧是否已收款,已付款 / 未付款 |
|
||||
| 收票日期 | 财务收票日期,系统带出 |
|
||||
| 标准价 | 公司维护的、按加氢站与生效时段确定的参考单价 |
|
||||
| 标准价 | 公司维护的、按加氢站与生效时段(及客户)确定的参考单价 |
|
||||
| 固定价格 | 加氢站对全部客户或指定客户配置的单一成本单价 |
|
||||
| 阶梯价格 | 按阶梯周期累计加氢量分档确定成本单价的规则 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1059,7 +1103,26 @@ const Component = function () {
|
||||
'000078': { costPrice: 25.0, costPriceEffective: '2026-01-01', customerPrice: 25.0, customerPriceEffective: '2026-01-01' },
|
||||
'000080': { costPrice: 25.0, costPriceEffective: '2026-01-01', customerPrice: 25.0, customerPriceEffective: '2026-01-01' },
|
||||
'000081': { costPrice: 29.9, costPriceEffective: '2026-01-01', customerPrice: 29.9, customerPriceEffective: '2026-01-01' },
|
||||
'000082': { costPrice: 30.0, costPriceEffective: '2026-02-28', customerPrice: 30.0, customerPriceEffective: '2026-02-28' },
|
||||
'000082': {
|
||||
costPrice: 30.0,
|
||||
costPriceEffective: '2026-02-28',
|
||||
customerPrice: 30.0,
|
||||
customerPriceEffective: '2026-02-28',
|
||||
priceType: 'fixed',
|
||||
customerCostConfigs: {
|
||||
'00007': {
|
||||
priceType: 'tiered',
|
||||
costPriceEffective: '2026-03-01',
|
||||
tierRules: [
|
||||
{ thresholdKg: 0, price: 43.0 },
|
||||
{ thresholdKg: 300, price: 41.5 },
|
||||
{ thresholdKg: 600, price: 40.0 }
|
||||
],
|
||||
tierResetDay: 1,
|
||||
tierPeriodVolumeKg: 312.5
|
||||
}
|
||||
}
|
||||
},
|
||||
'000085': { costPrice: 25.0, costPriceEffective: '2026-03-01', customerPrice: 25.0, customerPriceEffective: '2026-03-01' },
|
||||
'000086': { costPrice: 25.0, costPriceEffective: '2026-03-01', customerPrice: 25.0, customerPriceEffective: '2026-03-01' },
|
||||
'000087': { costPrice: 35.0, costPriceEffective: '2026-03-01', customerPrice: 35.0, customerPriceEffective: '2026-03-01' },
|
||||
@@ -1738,6 +1801,125 @@ const Component = function () {
|
||||
return Object.assign({}, draft, resolveLinkedModuleFields(draft));
|
||||
}
|
||||
|
||||
function sortTierRules(rules) {
|
||||
return (rules || []).slice().sort(function (a, b) {
|
||||
return numOrZero(a.thresholdKg) - numOrZero(b.thresholdKg);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTierUnitPrice(volumeKg, tierRules) {
|
||||
var rules = sortTierRules(tierRules);
|
||||
if (!rules.length) return null;
|
||||
var vol = numOrZero(volumeKg);
|
||||
var matched = rules[0];
|
||||
var i;
|
||||
for (i = 0; i < rules.length; i++) {
|
||||
var threshold = numOrZero(rules[i].thresholdKg);
|
||||
if (vol >= threshold) matched = rules[i];
|
||||
}
|
||||
var price = matched.price;
|
||||
return price == null || isNaN(price) ? null : roundMoney(price);
|
||||
}
|
||||
|
||||
function getTierPeriodKey(resetDay, hydrogenTime) {
|
||||
if (!window.dayjs || !hydrogenTime) return '';
|
||||
try {
|
||||
var d = window.dayjs(hydrogenTime);
|
||||
if (!d.isValid()) return '';
|
||||
var day = parseInt(resetDay, 10) || 1;
|
||||
if (d.date() < day) d = d.subtract(1, 'month');
|
||||
return d.format('YYYY-MM');
|
||||
} catch (eTierKey) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getTierPeriodVolumeForConfig(cfg, hydrogenTime) {
|
||||
if (!cfg) return 0;
|
||||
var periodKey = getTierPeriodKey(cfg.tierResetDay, hydrogenTime);
|
||||
if (cfg.tierPeriodKey && periodKey && cfg.tierPeriodKey !== periodKey) return 0;
|
||||
return cfg.tierPeriodVolumeKg != null ? numOrZero(cfg.tierPeriodVolumeKg) : 0;
|
||||
}
|
||||
|
||||
function isCostPriceConfigEffective(cfg, hydrogenTime) {
|
||||
if (!cfg || !cfg.costPriceEffective || !window.dayjs || !hydrogenTime) return true;
|
||||
try {
|
||||
var ht = window.dayjs(hydrogenTime);
|
||||
return !ht.isBefore(window.dayjs(cfg.costPriceEffective).startOf('day'));
|
||||
} catch (eCfgEff) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCostPriceConfigForRow(row) {
|
||||
if (!row || !row.stationId) return null;
|
||||
var meta = STATION_SYSTEM_PRICE_MAP[row.stationId];
|
||||
if (!meta) return null;
|
||||
if (row.customerId && meta.customerCostConfigs && meta.customerCostConfigs[row.customerId]) {
|
||||
var customerCfg = meta.customerCostConfigs[row.customerId];
|
||||
if (isCostPriceConfigEffective(customerCfg, row.hydrogenTime)) {
|
||||
return Object.assign({ stationId: row.stationId, customerId: row.customerId }, customerCfg);
|
||||
}
|
||||
}
|
||||
return Object.assign({ stationId: row.stationId, priceType: meta.priceType || 'fixed' }, meta);
|
||||
}
|
||||
|
||||
function getCostPriceTypeInfo(row) {
|
||||
var cfg = resolveCostPriceConfigForRow(row);
|
||||
if (!cfg) return { show: false };
|
||||
var priceType = cfg.priceType === 'tiered' ? 'tiered' : 'fixed';
|
||||
var info = {
|
||||
show: true,
|
||||
priceType: priceType,
|
||||
label: priceType === 'tiered' ? '阶梯' : '固定'
|
||||
};
|
||||
if (priceType === 'tiered') {
|
||||
var volume = getTierPeriodVolumeForConfig(cfg, row.hydrogenTime);
|
||||
var unitPrice = resolveTierUnitPrice(volume, cfg.tierRules);
|
||||
info.tierVolumeKg = volume;
|
||||
info.tierUnitPrice = unitPrice;
|
||||
info.tooltip = {
|
||||
title: '阶梯价格',
|
||||
details: [
|
||||
'当前阶梯量:' + fmtQty(volume) + ' kg',
|
||||
'对应单价:' + (unitPrice != null ? fmtMoney(unitPrice) : '—') + ' 元/kg'
|
||||
]
|
||||
};
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function renderCostPriceTypeTag(row) {
|
||||
var info = getCostPriceTypeInfo(row);
|
||||
if (!info.show) return null;
|
||||
var tagEl = React.createElement(
|
||||
Tag,
|
||||
{
|
||||
className: 'h2-cost-price-type-tag',
|
||||
color: info.priceType === 'tiered' ? 'purple' : 'default',
|
||||
bordered: false,
|
||||
style: { margin: 0, padding: '0 4px', lineHeight: '16px', fontSize: 10, fontWeight: 600 }
|
||||
},
|
||||
info.label
|
||||
);
|
||||
if (info.priceType === 'tiered' && info.tooltip) {
|
||||
return React.createElement(
|
||||
Popover,
|
||||
{
|
||||
content: renderWarnTooltipOverlay(info.tooltip),
|
||||
overlayClassName: 'h2-warn-tooltip-pop h2-warn-cost-pop',
|
||||
color: 'rgba(15,23,42,0.92)',
|
||||
placement: 'top',
|
||||
trigger: ['hover', 'click'],
|
||||
mouseEnterDelay: 0.15,
|
||||
mouseLeaveDelay: 0.2
|
||||
},
|
||||
React.createElement('span', { className: 'h2-cost-price-type-wrap', style: { display: 'inline-flex' } }, tagEl)
|
||||
);
|
||||
}
|
||||
return tagEl;
|
||||
}
|
||||
|
||||
function getSystemPricesForRow(row) {
|
||||
var station = findStation(row.stationId);
|
||||
if (!station || !row.hydrogenTime || !window.dayjs) return { cost: null, customer: null };
|
||||
@@ -1749,12 +1931,18 @@ const Component = function () {
|
||||
}
|
||||
var cost = null;
|
||||
var customer = null;
|
||||
if (station.costPrice != null && station.costPriceEffective) {
|
||||
var costCfg = resolveCostPriceConfigForRow(row);
|
||||
if (costCfg) {
|
||||
if (costCfg.priceType === 'tiered') {
|
||||
var tierVolume = getTierPeriodVolumeForConfig(costCfg, row.hydrogenTime);
|
||||
cost = resolveTierUnitPrice(tierVolume, costCfg.tierRules);
|
||||
} else if (costCfg.costPrice != null && costCfg.costPriceEffective) {
|
||||
try {
|
||||
if (!ht.isBefore(window.dayjs(station.costPriceEffective).startOf('day'))) {
|
||||
cost = roundMoney(station.costPrice);
|
||||
if (!ht.isBefore(window.dayjs(costCfg.costPriceEffective).startOf('day'))) {
|
||||
cost = roundMoney(costCfg.costPrice);
|
||||
}
|
||||
} catch (eCostFixed) {}
|
||||
}
|
||||
} catch (e8) {}
|
||||
}
|
||||
if (station.customerPrice != null && station.customerPriceEffective) {
|
||||
try {
|
||||
@@ -1777,6 +1965,128 @@ const Component = function () {
|
||||
return roundMoney(row.costUnitPrice) !== sys.cost;
|
||||
}
|
||||
|
||||
function buildCostPriceOkInfo(details) {
|
||||
var info = {
|
||||
status: 'ok',
|
||||
ok: true,
|
||||
warn: false,
|
||||
title: '成本单价正确',
|
||||
details: details || []
|
||||
};
|
||||
info.tip = formatWarnTipPlain(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
function buildCostPriceWarnInfo(details, options) {
|
||||
options = options || {};
|
||||
var info = {
|
||||
status: 'warn',
|
||||
ok: false,
|
||||
warn: true,
|
||||
title: '成本单价异常',
|
||||
details: details || [],
|
||||
replaceAction: options.replaceAction || null
|
||||
};
|
||||
info.tip = formatWarnTipPlain(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
function getCostPriceCheckInfo(row) {
|
||||
if (!row || isEmptyNum(row.costUnitPrice)) return { status: 'none', warn: false, ok: false, tip: '' };
|
||||
var sys = getSystemPricesForRow(row);
|
||||
if (sys.cost == null) return { status: 'none', warn: false, ok: false, tip: '' };
|
||||
var station = findStation(row.stationId);
|
||||
var stationLabel = (station && station.label) || row.stationName || '该加氢站';
|
||||
var costCfg = resolveCostPriceConfigForRow(row);
|
||||
var standardTip =
|
||||
costCfg && costCfg.priceType === 'tiered'
|
||||
? stationLabel +
|
||||
'阶梯价(当前阶梯量 ' +
|
||||
fmtQty(getTierPeriodVolumeForConfig(costCfg, row.hydrogenTime)) +
|
||||
' kg):' +
|
||||
fmtMoney(sys.cost) +
|
||||
' 元/kg'
|
||||
: stationLabel + '标准成本单价:' + fmtMoney(sys.cost) + ' 元/kg';
|
||||
if (roundMoney(row.costUnitPrice) === sys.cost) {
|
||||
return buildCostPriceOkInfo(['与站点标准成本价一致', standardTip]);
|
||||
}
|
||||
return buildCostPriceWarnInfo([WARN_COST_PRICE_TIP, standardTip], {
|
||||
replaceAction: { costUnitPrice: sys.cost }
|
||||
});
|
||||
}
|
||||
|
||||
function getCostPriceWarnInfo(row) {
|
||||
var check = getCostPriceCheckInfo(row);
|
||||
if (check.status === 'warn') {
|
||||
return {
|
||||
warn: true,
|
||||
systemPrice: check.replaceAction ? check.replaceAction.costUnitPrice : null,
|
||||
tip: check.tip
|
||||
};
|
||||
}
|
||||
return { warn: false, systemPrice: null, tip: '' };
|
||||
}
|
||||
|
||||
function renderCostPriceCheckPopoverContent(record, checkInfo, onReplace, canReplace) {
|
||||
var isOk = checkInfo.status === 'ok';
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
className: 'h2-warn-tooltip' + (isOk ? ' h2-warn-tooltip--ok' : ''),
|
||||
onClick: function (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
checkInfo.title
|
||||
? React.createElement(
|
||||
'div',
|
||||
{ className: 'h2-warn-tooltip__title' + (isOk ? ' h2-warn-tooltip__title--ok' : '') },
|
||||
checkInfo.title
|
||||
)
|
||||
: null,
|
||||
(checkInfo.details || []).map(function (line, idx) {
|
||||
return React.createElement('div', { className: 'h2-warn-tooltip__line', key: 'cost-warn-line-' + idx }, line);
|
||||
}),
|
||||
!isOk && canReplace
|
||||
? React.createElement(
|
||||
'div',
|
||||
{ className: 'h2-warn-tooltip__actions' },
|
||||
React.createElement(
|
||||
Button,
|
||||
{
|
||||
type: 'primary',
|
||||
size: 'small',
|
||||
block: true,
|
||||
onClick: function (e) {
|
||||
e.stopPropagation();
|
||||
if (onReplace) onReplace(record.key, checkInfo.replaceAction);
|
||||
}
|
||||
},
|
||||
'一键替换为标准成本单价'
|
||||
)
|
||||
)
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function renderCostPriceCheckTrigger(record, checkInfo, onReplace, canReplace) {
|
||||
var isOk = checkInfo.status === 'ok';
|
||||
return React.createElement(
|
||||
Popover,
|
||||
{
|
||||
content: renderCostPriceCheckPopoverContent(record, checkInfo, onReplace, canReplace),
|
||||
overlayClassName: 'h2-warn-tooltip-pop h2-warn-cost-pop' + (isOk ? ' h2-warn-cost-pop--ok' : ''),
|
||||
color: 'rgba(15,23,42,0.92)',
|
||||
placement: 'topRight',
|
||||
trigger: ['hover', 'click'],
|
||||
mouseEnterDelay: 0.15,
|
||||
mouseLeaveDelay: 0.2,
|
||||
destroyTooltipOnHide: false
|
||||
},
|
||||
isOk ? renderCustomerOkIcon('成本单价正确') : renderCustomerErrorIcon('成本单价与站点标准价不一致')
|
||||
);
|
||||
}
|
||||
|
||||
function isCustomerPriceWarn(row) {
|
||||
if (isEmptyNum(row.customerUnitPrice)) return false;
|
||||
var sys = getSystemPricesForRow(row);
|
||||
@@ -2073,13 +2383,13 @@ const Component = function () {
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustomerErrorIcon() {
|
||||
function renderCustomerErrorIcon(ariaLabel) {
|
||||
return React.createElement(
|
||||
'span',
|
||||
{
|
||||
className: 'h2-customer-status-icon h2-customer-error-icon',
|
||||
role: 'img',
|
||||
'aria-label': '客户疑似有误',
|
||||
'aria-label': ariaLabel || '客户疑似有误',
|
||||
title: ''
|
||||
},
|
||||
React.createElement(
|
||||
@@ -2101,13 +2411,13 @@ const Component = function () {
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustomerOkIcon() {
|
||||
function renderCustomerOkIcon(ariaLabel) {
|
||||
return React.createElement(
|
||||
'span',
|
||||
{
|
||||
className: 'h2-customer-status-icon h2-customer-ok-icon',
|
||||
role: 'img',
|
||||
'aria-label': '客户名称正确',
|
||||
'aria-label': ariaLabel || '客户名称正确',
|
||||
title: ''
|
||||
},
|
||||
React.createElement(
|
||||
@@ -2224,6 +2534,10 @@ const Component = function () {
|
||||
return row && row.reconcileStatus === RECONCILE_RECONCILED;
|
||||
}
|
||||
|
||||
function isStatementLockedRow(row) {
|
||||
return !!(row && row.statementPatched);
|
||||
}
|
||||
|
||||
function rowDisplaySortTier(row) {
|
||||
if (isReconciledRow(row)) return 0;
|
||||
if (isPendingRow(row)) return 1;
|
||||
@@ -2294,6 +2608,7 @@ const Component = function () {
|
||||
}
|
||||
|
||||
function isRowBatchEditable(row) {
|
||||
if (isStatementLockedRow(row)) return false;
|
||||
if (isReconciledRow(row)) return false;
|
||||
if (isPendingRow(row)) return true;
|
||||
return isNewRow(row) && row.createdBy === CURRENT_USER.id;
|
||||
@@ -2306,6 +2621,7 @@ const Component = function () {
|
||||
}
|
||||
|
||||
function canDeleteRow(row, isSupervisorRole) {
|
||||
if (isStatementLockedRow(row)) return false;
|
||||
if (isSupervisorRole) return true;
|
||||
if (isPendingRow(row)) return true;
|
||||
if (isNewRow(row)) return row.createdBy === CURRENT_USER.id;
|
||||
@@ -2619,8 +2935,10 @@ const Component = function () {
|
||||
if (!patch) return r;
|
||||
return Object.assign({}, r, {
|
||||
statementPatched: true,
|
||||
reconcileStatus: RECONCILE_RECONCILED,
|
||||
reconcileDate: patch.reconcileDate,
|
||||
receiptDate: patch.receiptDate,
|
||||
receiptAmount: patch.receiptAmount != null ? patch.receiptAmount : r.receiptAmount,
|
||||
stationPaymentStatus: patch.paymentStatus || 'paid',
|
||||
paymentStatus: patch.paymentStatus || 'paid',
|
||||
reconciledAt: patch.reconcileDate
|
||||
@@ -2642,6 +2960,7 @@ const Component = function () {
|
||||
linked = Object.assign({}, linked, {
|
||||
reconcileDate: row.reconcileDate,
|
||||
receiptDate: row.receiptDate,
|
||||
receiptAmount: row.receiptAmount,
|
||||
stationPaymentStatus: row.stationPaymentStatus,
|
||||
paymentStatus: row.paymentStatus
|
||||
});
|
||||
@@ -2831,6 +3150,16 @@ const Component = function () {
|
||||
salesperson: '',
|
||||
remark: '异动期客户正确演示:车辆异动'
|
||||
});
|
||||
pushDemoRow('mock-h2-cost-price-warn', {
|
||||
plateNo: '浙BK33210F',
|
||||
hydrogenTime: demoDay && demoDay.subtract ? demoDay.subtract(1, 'day') : demoDay,
|
||||
customerId: '00007',
|
||||
customerName: '上海迅杰物流有限公司',
|
||||
salesperson: '金可鹏',
|
||||
costUnitPrice: 99,
|
||||
costTotal: 3168,
|
||||
remark: '成本单价与站点标准价不一致演示'
|
||||
});
|
||||
return recalcAllRows(rows);
|
||||
}
|
||||
|
||||
@@ -2897,11 +3226,15 @@ const Component = function () {
|
||||
'.h2-cell-readonly{color:#64748b;background:#f8fafc;padding:4px 6px}' +
|
||||
'.h2-cell-auto{color:#475569;background:#f1f5f9;padding:4px 6px}' +
|
||||
'.h2-cell-warn{color:#874d00;background:#fffbe6!important;border:1px solid #ffe58f}' +
|
||||
'.h2-customer-warn-cell{display:inline-flex;align-items:center;justify-content:center;gap:4px;max-width:100%}' +
|
||||
'.h2-customer-warn-cell,.h2-cost-warn-cell{display:inline-flex;align-items:center;justify-content:center;gap:4px;max-width:100%}' +
|
||||
'.h2-cost-warn-cell{justify-content:flex-end;gap:4px}' +
|
||||
'.h2-cost-price-type-tag{flex-shrink:0}' +
|
||||
'.h2-cost-price-type-wrap{flex-shrink:0;cursor:help;display:inline-flex}' +
|
||||
'.h2-customer-warn-cell .h2-cell-readonly{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}' +
|
||||
'.h2-customer-status-icon{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;cursor:help}' +
|
||||
'.h2-customer-error-icon{color:#cf1322;background:rgba(255,255,255,0.9);border:1px solid #ffccc7;box-shadow:0 1px 2px rgba(207,19,34,0.12)}' +
|
||||
'.h2-customer-ok-icon{color:#389e0d;background:rgba(255,255,255,0.95);border:1px solid #b7eb8f;box-shadow:0 1px 2px rgba(56,158,13,0.12)}' +
|
||||
'.h2-cost-price-warn-icon{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;cursor:help;color:#d48806;background:rgba(255,255,255,0.95);border:1px solid #ffe58f;box-shadow:0 1px 2px rgba(212,136,6,0.12)}' +
|
||||
'.h2-customer-warn-select .ant-select-selector{border-color:#ff7875!important;box-shadow:0 0 0 1px rgba(255,77,79,0.15)!important}' +
|
||||
'.h2-ledger-table .ant-table-tbody>tr.h2-row-warn>td{overflow:hidden;background-color:#fff6f5!important;color:#9e1a1a!important;border-color:#ffd4d0!important;background-repeat:repeat;background-position:center;background-size:176px 40px!important}' +
|
||||
'.h2-ledger-table .ant-table-tbody>tr.h2-row-warn>td.ant-table-selection-column{box-shadow:inset 3px 0 0 #ff7875}' +
|
||||
@@ -2934,6 +3267,8 @@ const Component = function () {
|
||||
'.h2-warn-tooltip__actions{margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.16)}' +
|
||||
'.h2-warn-customer-pop .ant-popover-inner{padding:0}' +
|
||||
'.h2-warn-customer-pop .ant-popover-inner-content{padding:10px 12px}' +
|
||||
'.h2-warn-cost-pop .ant-popover-inner{padding:0}' +
|
||||
'.h2-warn-cost-pop .ant-popover-inner-content{padding:10px 12px}' +
|
||||
'.h2-warn-input-wrap .ant-input-number,.h2-warn-input-wrap .ant-input-number-input{background:#fffbe6!important}' +
|
||||
'.h2-ledger-table .ant-picker.h2-datetime-picker{min-width:178px}' +
|
||||
'.h2-row-tier-0>td{background:#f8fafc!important}' +
|
||||
@@ -3151,6 +3486,40 @@ const Component = function () {
|
||||
message.success('已替换为系统客户:' + afterName);
|
||||
}, [appendChangeLog, setAllRows]);
|
||||
|
||||
var applySystemCostPriceReplace = useCallback(function (rowKey, replaceAction) {
|
||||
if (!rowKey || !replaceAction || replaceAction.costUnitPrice == null) return;
|
||||
var targetPrice = replaceAction.costUnitPrice;
|
||||
var rows = latestRefs.current.allRows || [];
|
||||
var row = null;
|
||||
var i;
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
if (rows[i].key === rowKey) {
|
||||
row = rows[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!row) return;
|
||||
var beforePrice = row.costUnitPrice;
|
||||
var beforeText = formatLogValue('costUnitPrice', beforePrice, row);
|
||||
var afterText = formatLogValue('costUnitPrice', targetPrice, row);
|
||||
appendChangeLog(rowKey, 'costUnitPrice', beforePrice, targetPrice, row, {
|
||||
beforeText: beforeText,
|
||||
afterText: afterText,
|
||||
fieldLabel: '成本单价(一键替换)',
|
||||
remark: '按站点标准成本价自动替换',
|
||||
force: true
|
||||
});
|
||||
setAllRows(function (prev) {
|
||||
return recalcAllRows(
|
||||
prev.map(function (r) {
|
||||
if (r.key !== rowKey) return r;
|
||||
return Object.assign({}, r, { costUnitPrice: targetPrice });
|
||||
})
|
||||
);
|
||||
});
|
||||
message.success('已替换为标准成本单价:' + afterText + ' 元/kg');
|
||||
}, [appendChangeLog, setAllRows]);
|
||||
|
||||
var openChangeLogModal = useCallback(function (record) {
|
||||
if (!record) return;
|
||||
setChangeLogModal({
|
||||
@@ -3175,7 +3544,8 @@ const Component = function () {
|
||||
isSupervisor: isSupervisor,
|
||||
allRows: allRows,
|
||||
appendChangeLog: appendChangeLog,
|
||||
applySystemCustomerReplace: applySystemCustomerReplace
|
||||
applySystemCustomerReplace: applySystemCustomerReplace,
|
||||
applySystemCostPriceReplace: applySystemCostPriceReplace
|
||||
};
|
||||
|
||||
React.useEffect(function () {
|
||||
@@ -3212,6 +3582,7 @@ const Component = function () {
|
||||
var canEditRow = useCallback(
|
||||
function (row) {
|
||||
if (!row) return false;
|
||||
if (isStatementLockedRow(row)) return false;
|
||||
if (isReconciledRow(row)) {
|
||||
return isSupervisor && editingKeys.indexOf(row.key) >= 0;
|
||||
}
|
||||
@@ -3833,6 +4204,41 @@ const Component = function () {
|
||||
);
|
||||
}
|
||||
|
||||
function applySystemCostPriceReplaceFromCell(rowKey, replaceAction) {
|
||||
var ref = latestRefs.current;
|
||||
if (ref.applySystemCostPriceReplace) ref.applySystemCostPriceReplace(rowKey, replaceAction);
|
||||
}
|
||||
|
||||
function renderCostPriceCheckAffordance(record, checkInfo) {
|
||||
if (!checkInfo || checkInfo.status === 'none') return null;
|
||||
var ref = latestRefs.current;
|
||||
var canReplace =
|
||||
checkInfo.status === 'warn' &&
|
||||
checkInfo.replaceAction &&
|
||||
checkInfo.replaceAction.costUnitPrice != null &&
|
||||
canQuickReplaceCustomer(record, ref.isSupervisor);
|
||||
return renderCostPriceCheckTrigger(
|
||||
record,
|
||||
checkInfo,
|
||||
applySystemCostPriceReplaceFromCell,
|
||||
canReplace
|
||||
);
|
||||
}
|
||||
|
||||
function wrapCostPriceCell(mainNode, record) {
|
||||
var typeTag = renderCostPriceTypeTag(record);
|
||||
var checkInfo = getCostPriceCheckInfo(record);
|
||||
var iconNode = renderCostPriceCheckAffordance(record, checkInfo);
|
||||
if (!typeTag && !iconNode) return mainNode;
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'h2-cost-warn-cell' },
|
||||
typeTag,
|
||||
mainNode,
|
||||
iconNode
|
||||
);
|
||||
}
|
||||
|
||||
function fieldInvalid(record, field) {
|
||||
var ref = latestRefs.current;
|
||||
var rowInv = (ref.rowInvalid && ref.rowInvalid[record.key]) || {};
|
||||
@@ -3864,7 +4270,7 @@ const Component = function () {
|
||||
);
|
||||
}
|
||||
|
||||
function makeBatchPriceTitle(columnLabel, batchLabel, field, popOpen, setPopOpen, inputVal, setInputVal) {
|
||||
function makeBatchPriceTitle(columnLabel, batchLabel, field, popOpen, setPopOpen, inputVal, setInputVal, annotationId) {
|
||||
var popContent = React.createElement(
|
||||
'div',
|
||||
{
|
||||
@@ -4194,11 +4600,17 @@ const Component = function () {
|
||||
),
|
||||
dataIndex: 'costUnitPrice',
|
||||
key: 'costUnitPrice',
|
||||
width: 132,
|
||||
width: 152,
|
||||
align: 'right',
|
||||
onHeaderCell: function () {
|
||||
return { 'data-annotation-id': 'vh2-col-cost-unit-price' };
|
||||
},
|
||||
render: function (v, record) {
|
||||
if (!canEditRow(record)) return readOnlyCell(fmtMoney(v), 'right');
|
||||
return React.createElement(InputNumber, {
|
||||
if (!canEditRow(record)) {
|
||||
return wrapCostPriceCell(readOnlyCell(fmtMoney(v), 'right'), record);
|
||||
}
|
||||
return wrapCostPriceCell(
|
||||
React.createElement(InputNumber, {
|
||||
style: cellNumStyle,
|
||||
min: 0,
|
||||
precision: 2,
|
||||
@@ -4206,7 +4618,9 @@ const Component = function () {
|
||||
value: isEmptyNum(v) ? null : v,
|
||||
status: fieldInvalid(record, 'costUnitPrice') ? 'error' : undefined,
|
||||
onChange: function (val) { patch(record.key, 'costUnitPrice', val == null ? null : val); }
|
||||
});
|
||||
}),
|
||||
record
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4328,11 +4742,14 @@ const Component = function () {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '对账日期',
|
||||
title: React.createElement('span', { 'data-annotation-id': 'vh2-col-reconcile-date' }, '对账日期'),
|
||||
dataIndex: 'reconcileDate',
|
||||
key: 'reconcileDate',
|
||||
width: 148,
|
||||
align: 'center',
|
||||
onHeaderCell: function () {
|
||||
return { 'data-annotation-id': 'vh2-col-reconcile-date' };
|
||||
},
|
||||
render: function (v) {
|
||||
if (!v) return readOnlyCell('-');
|
||||
var text = String(v);
|
||||
@@ -4483,7 +4900,14 @@ const Component = function () {
|
||||
var allowCopy = canCopyRow(record);
|
||||
var editLabel = isEditing ? '完成编辑' : '编辑';
|
||||
|
||||
if (isReconciledRow(record)) {
|
||||
if (isStatementLockedRow(record)) {
|
||||
items.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
disabled: true,
|
||||
title: '站点对账单已结算,不可修改'
|
||||
});
|
||||
} else if (isReconciledRow(record)) {
|
||||
if (!ref.isSupervisor) {
|
||||
items.push({
|
||||
key: 'edit',
|
||||
@@ -4523,7 +4947,15 @@ const Component = function () {
|
||||
});
|
||||
}
|
||||
|
||||
if (isReconciledRow(record)) {
|
||||
if (isStatementLockedRow(record)) {
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
disabled: true,
|
||||
danger: true,
|
||||
title: '站点对账单已结算,不可删除'
|
||||
});
|
||||
} else if (isReconciledRow(record)) {
|
||||
if (!ref.isSupervisor) {
|
||||
items.push({
|
||||
key: 'delete',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "vehicle-maintenance-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783430400000,
|
||||
"updatedAt": 1783589987169,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "vmaint-header",
|
||||
@@ -206,6 +206,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -220,112 +227,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -396,7 +297,7 @@
|
||||
"type": "link",
|
||||
"id": "vmaint-link-self",
|
||||
"title": "本原型预览",
|
||||
"href": "/prototypes/vehicle-maintenance-ledger",
|
||||
"href": "./index.html",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "vehicle-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783352657928,
|
||||
"updatedAt": 1783589987169,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "vm-doc-toolbar",
|
||||
@@ -674,6 +674,13 @@
|
||||
"title": "ONE-OS 原型导航",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-prototype-nav",
|
||||
"title": "原型导航",
|
||||
"href": "/prototypes/oneos-prototype-nav",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-1782874576229-r8atbu",
|
||||
@@ -688,112 +695,6 @@
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-lease-business-line-overview",
|
||||
"title": "业务条线说明",
|
||||
"href": "/prototypes/lease-business-line-overview",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "nav-folder-prototypes-oneos-web",
|
||||
"title": "ONE-OS Web 端",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-login",
|
||||
"title": "登录",
|
||||
"href": "/prototypes/oneos-web-login",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-vehicle-asset",
|
||||
"title": "车辆管理",
|
||||
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-contract-template",
|
||||
"title": "合同模板管理",
|
||||
"href": "/prototypes/oneos-web-contract-template",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-help-center",
|
||||
"title": "帮助中心",
|
||||
"href": "/prototypes/oneos-web-help-center",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-finance",
|
||||
"title": "财务管理",
|
||||
"href": "/prototypes/oneos-web-finance",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-procurement",
|
||||
"title": "采购管理",
|
||||
"href": "/prototypes/oneos-web-procurement",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-lease-contract",
|
||||
"title": "车辆租赁合同",
|
||||
"href": "/prototypes/oneos-web-lease-contract",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-h2-station",
|
||||
"title": "加氢站管理",
|
||||
"href": "/prototypes/oneos-web-h2-station",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-data-analysis",
|
||||
"title": "数据分析",
|
||||
"href": "/prototypes/oneos-web-data-analysis",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ledger-data",
|
||||
"title": "台账数据",
|
||||
"href": "/prototypes/oneos-web-ledger-data",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-business",
|
||||
"title": "业务管理",
|
||||
"href": "/prototypes/oneos-web-business",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-ops",
|
||||
"title": "运维管理",
|
||||
"href": "/prototypes/oneos-web-ops",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
"type": "link",
|
||||
"id": "nav-link-oneos-web-requirements",
|
||||
"title": "需求说明",
|
||||
"href": "/prototypes/oneos-web-requirements",
|
||||
"target": "self"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import VehicleReturnSettlementV2List from './VehicleReturnSettlementV2List.jsx';
|
||||
import VehicleReturnSettlementV2Detail from './VehicleReturnSettlementV2Detail.jsx';
|
||||
|
||||
const Component = function () {
|
||||
var useState = React.useState;
|
||||
var _view = useState('list');
|
||||
var view = _view[0];
|
||||
var setView = _view[1];
|
||||
var _row = useState(null);
|
||||
var selectedRow = _row[0];
|
||||
var setSelectedRow = _row[1];
|
||||
|
||||
if (view === 'detail') {
|
||||
return React.createElement(VehicleReturnSettlementV2Detail, {
|
||||
row: selectedRow,
|
||||
onBack: function () {
|
||||
setSelectedRow(null);
|
||||
setView('list');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return React.createElement(VehicleReturnSettlementV2List, {
|
||||
onOpenDetail: function (row) {
|
||||
setSelectedRow(row || null);
|
||||
setView('detail');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default Component;
|
||||
@@ -0,0 +1,369 @@
|
||||
import { StatusTag } from '../vehicle-management/components/StatusTag';
|
||||
import {
|
||||
ROLE_OPTIONS,
|
||||
TASK_STAGE_META,
|
||||
PIPELINE_STEPS,
|
||||
DEPT_KEYS,
|
||||
readRole,
|
||||
writeRole,
|
||||
deptProgress,
|
||||
pipelineIndex,
|
||||
settlementLabel,
|
||||
canSeeSettlement,
|
||||
hasPendingForRole,
|
||||
fmtMoney,
|
||||
fmtYMDHM,
|
||||
buildDeptFeeDetail,
|
||||
isDeptSubmitted
|
||||
} from './vrsV2Data.js';
|
||||
|
||||
function stageTone(stage) {
|
||||
var meta = TASK_STAGE_META[stage];
|
||||
return (meta && meta.tone) || 'gray';
|
||||
}
|
||||
|
||||
function PipelineStepper(props) {
|
||||
var activeIndex = props.activeIndex || 0;
|
||||
return React.createElement('ol', { className: 'vrs2-pipeline', 'aria-label': '任务进度' },
|
||||
PIPELINE_STEPS.map(function (step, idx) {
|
||||
var cls = 'vrs2-pipeline__step';
|
||||
if (idx < activeIndex) cls += ' vrs2-pipeline__step--done';
|
||||
else if (idx === activeIndex) cls += ' vrs2-pipeline__step--current';
|
||||
return React.createElement('li', { key: step.key, className: cls },
|
||||
React.createElement('span', { className: 'vrs2-pipeline__dot', 'aria-hidden': true }),
|
||||
React.createElement('span', { className: 'vrs2-pipeline__label' }, step.label)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCards(props) {
|
||||
var stats = props.stats || {};
|
||||
var cards = [
|
||||
{ key: 'deposit', label: '保证金总额', value: stats.deposit, tone: 'neutral' },
|
||||
{ key: 'pending', label: '待结算总额', value: stats.pending, tone: 'warn' },
|
||||
{ key: 'refund', label: '应退还总额', value: stats.refund, tone: 'good' },
|
||||
{ key: 'pay', label: '应补缴总额', value: stats.pay, tone: 'danger' }
|
||||
];
|
||||
return React.createElement('div', { className: 'vrs2-kpi-row' },
|
||||
cards.map(function (c) {
|
||||
return React.createElement('div', { key: c.key, className: 'vrs2-kpi-card vrs2-kpi-card--' + c.tone },
|
||||
React.createElement('div', { className: 'vrs2-kpi-card__label' }, c.label),
|
||||
React.createElement('div', { className: 'vrs2-kpi-card__value tabular-nums' },
|
||||
c.value,
|
||||
React.createElement('span', { className: 'vrs2-kpi-card__unit' }, '元')
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const Component = function (props) {
|
||||
var useState = React.useState;
|
||||
var useMemo = React.useMemo;
|
||||
var antd = window.antd;
|
||||
var App = antd.App;
|
||||
var Table = antd.Table;
|
||||
var Button = antd.Button;
|
||||
var Select = antd.Select;
|
||||
var Tabs = antd.Tabs;
|
||||
var message = antd.message;
|
||||
|
||||
var row = (props && props.row) || {};
|
||||
var _role = useState(readRole());
|
||||
var role = _role[0];
|
||||
var setRole = _role[1];
|
||||
var deptData = useMemo(buildDeptFeeDetail, []);
|
||||
var progress = useMemo(function () { return deptProgress(row); }, [row]);
|
||||
var activePipe = useMemo(function () { return pipelineIndex(row.taskStage); }, [row.taskStage]);
|
||||
|
||||
var stats = useMemo(function () {
|
||||
var deposit = fmtMoney(row.deposit || 10000);
|
||||
var pending = row.pendingTotal != null ? fmtMoney(row.pendingTotal) : '—';
|
||||
var refund = row.settlementDirection === 'refund' ? fmtMoney(row.settlementAmount) : '0.00';
|
||||
var pay = row.settlementDirection === 'pay' ? fmtMoney(row.settlementAmount) : '0.00';
|
||||
if (row.pendingTotal != null && row.deposit != null && !row.settlementDirection) {
|
||||
var diff = row.deposit - row.pendingTotal;
|
||||
if (diff > 0) refund = fmtMoney(diff);
|
||||
else if (diff < 0) pay = fmtMoney(Math.abs(diff));
|
||||
}
|
||||
return { deposit: deposit, pending: pending, refund: refund, pay: pay };
|
||||
}, [row]);
|
||||
|
||||
function handleRoleChange(next) {
|
||||
setRole(next);
|
||||
writeRole(next);
|
||||
}
|
||||
|
||||
function canSeeDept(deptKey) {
|
||||
if (role === 'biz') return true;
|
||||
return role === deptKey;
|
||||
}
|
||||
|
||||
function renderDeptOverviewCards() {
|
||||
return React.createElement('div', { className: 'vrs2-dept-overview-row' },
|
||||
progress.map(function (p) {
|
||||
return React.createElement('div', { key: p.key, className: 'vrs2-dept-overview-card' },
|
||||
React.createElement('div', { className: 'vrs2-dept-overview-card__title' }, p.label),
|
||||
React.createElement(StatusTag, {
|
||||
label: p.status,
|
||||
tone: p.done ? 'green' : (p.active ? 'blue' : 'amber')
|
||||
}),
|
||||
React.createElement('div', { className: 'vrs2-dept-overview-card__meta' },
|
||||
p.name ? '提交人:' + p.name : '尚未提交'
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderBizSection() {
|
||||
var d = deptData.biz;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs2-dept-panel', key: 'biz' },
|
||||
React.createElement('header', { className: 'vrs2-dept-panel__head' },
|
||||
React.createElement('h3', null, '业务管理组费用'),
|
||||
React.createElement('div', { className: 'vrs2-dept-panel__meta' },
|
||||
React.createElement('span', { className: 'tabular-nums' }, '总金额 ', React.createElement('strong', null, d.total), ' 元'),
|
||||
React.createElement(StatusTag, { label: d.status, tone: 'blue' })
|
||||
)
|
||||
),
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
dataSource: d.rows,
|
||||
columns: [
|
||||
{ title: '序号', dataIndex: 'seq', width: 56 },
|
||||
{ title: '费用项', dataIndex: 'feeItem', width: 160 },
|
||||
{ title: '金额', dataIndex: 'amount', width: 96, className: 'tabular-nums' },
|
||||
{ title: '来源', dataIndex: 'source', width: 88, render: function (v) { return React.createElement('span', { className: 'vrs2-source-tag' }, v); } },
|
||||
{ title: '最后更新', dataIndex: 'updatedAt', width: 148, className: 'tabular-nums' }
|
||||
]
|
||||
}),
|
||||
role === 'biz' && !isDeptSubmitted(row, 'biz') ? React.createElement('div', { className: 'vrs2-dept-panel__actions' },
|
||||
React.createElement(Button, { size: 'small' }, '保存'),
|
||||
React.createElement(Button, { type: 'primary', size: 'small' }, '提交本部门费用')
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderEnergySection() {
|
||||
var d = deptData.energy;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs2-dept-panel', key: 'energy' },
|
||||
React.createElement('header', { className: 'vrs2-dept-panel__head' },
|
||||
React.createElement('h3', null, '能源部费用'),
|
||||
React.createElement('div', { className: 'vrs2-dept-panel__meta' },
|
||||
React.createElement('span', { className: 'tabular-nums' }, '总金额 ', React.createElement('strong', null, d.total), ' 元'),
|
||||
React.createElement(StatusTag, { label: d.status, tone: 'amber' })
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vrs2-field-grid' },
|
||||
d.fields.map(function (f) {
|
||||
return React.createElement('div', { key: f.label, className: 'vrs2-field' },
|
||||
React.createElement('span', { className: 'vrs2-field__label' }, f.label),
|
||||
React.createElement('span', { className: 'vrs2-field__value' },
|
||||
f.value,
|
||||
f.source ? React.createElement('span', { className: 'vrs2-source-tag' }, f.source) : null
|
||||
)
|
||||
);
|
||||
})
|
||||
),
|
||||
role === 'energy' ? React.createElement('div', { className: 'vrs2-dept-panel__actions' },
|
||||
React.createElement(Button, { size: 'small' }, '保存'),
|
||||
React.createElement(Button, { type: 'primary', size: 'small' }, '提交本部门费用')
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderOpsSection() {
|
||||
var d = deptData.ops;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs2-dept-panel', key: 'ops' },
|
||||
React.createElement('header', { className: 'vrs2-dept-panel__head' },
|
||||
React.createElement('h3', null, '运维部费用'),
|
||||
React.createElement('div', { className: 'vrs2-dept-panel__meta' },
|
||||
React.createElement('span', { className: 'tabular-nums' }, '总金额 ', React.createElement('strong', null, d.total), ' 元'),
|
||||
React.createElement(StatusTag, { label: d.status, tone: 'amber' })
|
||||
)
|
||||
),
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
dataSource: d.rows,
|
||||
columns: [
|
||||
{ title: '序号', dataIndex: 'seq', width: 56 },
|
||||
{ title: '费用项', dataIndex: 'feeItem', width: 140 },
|
||||
{ title: '金额', dataIndex: 'amount', width: 96, className: 'tabular-nums' },
|
||||
{ title: '来源', dataIndex: 'source', width: 100, render: function (v) { return React.createElement('span', { className: 'vrs2-source-tag' }, v); } },
|
||||
{ title: '无忧包减免', dataIndex: 'discount', width: 96, className: 'tabular-nums' }
|
||||
]
|
||||
}),
|
||||
role === 'ops' ? React.createElement('div', { className: 'vrs2-dept-panel__actions' },
|
||||
React.createElement(Button, { size: 'small' }, '保存'),
|
||||
React.createElement(Button, { type: 'primary', size: 'small' }, '提交本部门费用')
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderSafetySection() {
|
||||
var d = deptData.safety;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs2-dept-panel', key: 'safety' },
|
||||
React.createElement('header', { className: 'vrs2-dept-panel__head' },
|
||||
React.createElement('h3', null, '安全部费用'),
|
||||
React.createElement('div', { className: 'vrs2-dept-panel__meta' },
|
||||
React.createElement('span', { className: 'tabular-nums' }, '总金额 ', React.createElement('strong', null, d.total), ' 元'),
|
||||
React.createElement(StatusTag, { label: d.status, tone: 'amber' })
|
||||
)
|
||||
),
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
dataSource: d.feeRows,
|
||||
columns: [
|
||||
{ title: '序号', dataIndex: 'seq', width: 56 },
|
||||
{ title: '费用项', dataIndex: 'feeItem', width: 140 },
|
||||
{ title: '金额', dataIndex: 'amount', width: 96, className: 'tabular-nums' },
|
||||
{ title: '来源', dataIndex: 'source', width: 100, render: function (v) { return React.createElement('span', { className: 'vrs2-source-tag' }, v); } }
|
||||
]
|
||||
}),
|
||||
React.createElement('h4', { className: 'vrs2-subtitle' }, '违章清单(来自违章管理)'),
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
dataSource: d.violations,
|
||||
columns: [
|
||||
{ title: '违章编码', dataIndex: 'code', width: 160 },
|
||||
{ title: '违法行为', dataIndex: 'act', ellipsis: true },
|
||||
{ title: '罚款', dataIndex: 'fine', width: 80, className: 'tabular-nums' },
|
||||
{ title: '是否处理', dataIndex: 'handled', width: 88 }
|
||||
]
|
||||
}),
|
||||
role === 'safety' ? React.createElement('div', { className: 'vrs2-dept-panel__actions' },
|
||||
React.createElement(Button, { size: 'small' }, '保存'),
|
||||
React.createElement(Button, { type: 'primary', size: 'small' }, '提交本部门费用')
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderFeeWorkspace() {
|
||||
if (role === 'biz') {
|
||||
var tabItems = DEPT_KEYS.map(function (d) {
|
||||
var renderFn = renderBizSection;
|
||||
if (d.key === 'energy') renderFn = renderEnergySection;
|
||||
if (d.key === 'ops') renderFn = renderOpsSection;
|
||||
if (d.key === 'safety') renderFn = renderSafetySection;
|
||||
return { key: d.key, label: d.label, children: renderFn() };
|
||||
});
|
||||
return React.createElement('div', { className: 'vrs2-fee-workspace' },
|
||||
canSeeSettlement(role) ? KpiCards({ stats: stats }) : null,
|
||||
React.createElement(Tabs, { items: tabItems, defaultActiveKey: 'biz' })
|
||||
);
|
||||
}
|
||||
if (role === 'energy') return renderEnergySection();
|
||||
if (role === 'ops') return renderOpsSection();
|
||||
if (role === 'safety') return renderSafetySection();
|
||||
if (role === 'finance') {
|
||||
return React.createElement('div', { className: 'vm-table-card vrs2-closure-panel' },
|
||||
React.createElement('h3', null, '收付款闭环'),
|
||||
row.billNo ? React.createElement('p', null, '关联账单:', React.createElement('strong', null, row.billNo)) : React.createElement('p', { className: 'vrs2-muted' }, '尚未生成账单'),
|
||||
row.paymentRef ? React.createElement('p', null, '关联付款记录:', React.createElement('strong', null, row.paymentRef)) : null,
|
||||
row.taskStage === 'pending_payment' ? React.createElement(Button, { type: 'primary', onClick: function () { message.info('跳转付款记录(原型)'); } }, '登记付款') : null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderFooterActions() {
|
||||
var stage = row.taskStage;
|
||||
var actions = [];
|
||||
if (role === 'biz' && stage === 'pending_main_approval' && row.slaReady) {
|
||||
actions.push(React.createElement(Button, { key: 'approve', type: 'primary', onClick: function () { message.success('已发起总审批(原型)'); } }, '发起总审批'));
|
||||
}
|
||||
if (role === 'biz' && stage === 'pending_reconcile') {
|
||||
actions.push(React.createElement(Button, { key: 'bill', type: 'primary', onClick: function () { message.info('生成账单(原型)'); } }, '生成账单'));
|
||||
}
|
||||
if (role === 'finance' && stage === 'approving' && row.financePending) {
|
||||
actions.push(React.createElement(Button, { key: 'fa', type: 'primary', onClick: function () { message.info('跳转审批待办(原型)'); } }, '财务终审'));
|
||||
}
|
||||
if (hasPendingForRole(row, role) && ['energy', 'ops', 'safety', 'biz'].indexOf(role) >= 0 && stage === 'pending_dept') {
|
||||
actions.push(React.createElement(Button, { key: 'submit', type: 'primary' }, '提交本部门费用'));
|
||||
}
|
||||
actions.push(React.createElement(Button, { key: 'back', onClick: function () { if (props.onBack) props.onBack(); } }, '返回列表'));
|
||||
return actions;
|
||||
}
|
||||
|
||||
var stageMeta = TASK_STAGE_META[row.taskStage] || { label: row.taskStage };
|
||||
|
||||
return React.createElement(App, null,
|
||||
React.createElement('div', { className: 'vm-page lc-page vrs2-page vrs2-detail-page' },
|
||||
React.createElement('header', { className: 'vrs2-detail-topbar' },
|
||||
React.createElement('div', { className: 'vrs2-detail-topbar__left' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost',
|
||||
onClick: function () { if (props.onBack) props.onBack(); }
|
||||
}, '← 返回'),
|
||||
React.createElement('div', { className: 'vrs2-detail-title-block' },
|
||||
React.createElement('h1', { className: 'vrs2-detail-title' }, row.plateNo, ' · ', row.customerName),
|
||||
React.createElement(StatusTag, { label: stageMeta.label, tone: stageTone(row.taskStage) })
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vrs2-role-switch' },
|
||||
React.createElement('span', { className: 'vrs2-role-switch__label' }, '预览角色'),
|
||||
React.createElement(Select, {
|
||||
value: role,
|
||||
style: { width: 160 },
|
||||
options: ROLE_OPTIONS,
|
||||
onChange: handleRoleChange
|
||||
})
|
||||
)
|
||||
),
|
||||
|
||||
React.createElement(PipelineStepper, { activeIndex: activePipe }),
|
||||
|
||||
React.createElement('section', { className: 'vm-filter-card ldb-filter-card vrs2-overview-banner' },
|
||||
React.createElement('div', { className: 'vrs2-overview-grid' },
|
||||
React.createElement('div', null, React.createElement('span', { className: 'vrs2-overview-label' }, '合同'), React.createElement('strong', null, row.contractCode)),
|
||||
React.createElement('div', null, React.createElement('span', { className: 'vrs2-overview-label' }, '项目'), row.projectName),
|
||||
React.createElement('div', null, React.createElement('span', { className: 'vrs2-overview-label' }, '还车时间'), fmtYMDHM(row.returnTime)),
|
||||
React.createElement('div', null, React.createElement('span', { className: 'vrs2-overview-label' }, '还车人'), row.returnPerson),
|
||||
React.createElement('div', null, React.createElement('span', { className: 'vrs2-overview-label' }, 'E签归档'), row.esignStatus || '—'),
|
||||
canSeeSettlement(role) && row.settlementDirection
|
||||
? React.createElement('div', null, React.createElement('span', { className: 'vrs2-overview-label' }, '结算'), settlementLabel(row))
|
||||
: null
|
||||
),
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost vrs2-link-btn',
|
||||
onClick: function () { message.info('跳转还车管理查看页(原型)'); }
|
||||
}, '查看还车记录 →')
|
||||
),
|
||||
|
||||
React.createElement('section', { className: 'vrs2-section' },
|
||||
React.createElement('h2', { className: 'vrs2-section__title' }, '四部门进度'),
|
||||
renderDeptOverviewCards()
|
||||
),
|
||||
|
||||
React.createElement('section', { className: 'vrs2-section' },
|
||||
React.createElement('h2', { className: 'vrs2-section__title' }, role === 'finance' ? '财务闭环' : '费用工作台'),
|
||||
renderFeeWorkspace()
|
||||
),
|
||||
|
||||
(row.billNo || row.paymentRef) && role !== 'finance' ? React.createElement('section', { className: 'vm-table-card vrs2-closure-panel' },
|
||||
React.createElement('h3', null, '闭环信息'),
|
||||
row.billNo ? React.createElement('p', null, '账单编号:', row.billNo) : null,
|
||||
row.paymentRef ? React.createElement('p', null, '付款记录:', row.paymentRef) : null
|
||||
) : null,
|
||||
|
||||
React.createElement('footer', { className: 'vrs2-detail-footer' }, renderFooterActions())
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Component;
|
||||
@@ -0,0 +1,380 @@
|
||||
import { StatusTag } from '../vehicle-management/components/StatusTag';
|
||||
import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
|
||||
import {
|
||||
ROLE_OPTIONS,
|
||||
TASK_STAGE_META,
|
||||
SEED_ROWS,
|
||||
readRole,
|
||||
writeRole,
|
||||
deptProgress,
|
||||
settlementLabel,
|
||||
canSeeSettlement,
|
||||
hasPendingForRole,
|
||||
pendingHint,
|
||||
getPrimaryAction,
|
||||
fmtYMDHM
|
||||
} from './vrsV2Data.js';
|
||||
|
||||
var LIST_CHROME_OFFSET = 420;
|
||||
|
||||
function SearchIconSvg() {
|
||||
return React.createElement('svg', {
|
||||
xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24',
|
||||
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
|
||||
},
|
||||
React.createElement('circle', { cx: 11, cy: 11, r: 8 }),
|
||||
React.createElement('path', { d: 'm21 21-4.3-4.3' })
|
||||
);
|
||||
}
|
||||
|
||||
function ExportIconSvg() {
|
||||
return React.createElement('svg', {
|
||||
xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24',
|
||||
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
|
||||
},
|
||||
React.createElement('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }),
|
||||
React.createElement('polyline', { points: '7 10 12 15 17 10' }),
|
||||
React.createElement('line', { x1: 12, x2: 12, y1: 15, y2: 3 })
|
||||
);
|
||||
}
|
||||
|
||||
function stageTone(stage) {
|
||||
var meta = TASK_STAGE_META[stage];
|
||||
return (meta && meta.tone) || 'gray';
|
||||
}
|
||||
|
||||
function DeptProgressBar(props) {
|
||||
var items = props.items || [];
|
||||
return React.createElement('div', { className: 'vrs2-dept-progress', 'aria-label': '四部门填报进度' },
|
||||
items.map(function (item) {
|
||||
var cls = 'vrs2-dept-progress__seg';
|
||||
if (item.done) cls += ' vrs2-dept-progress__seg--done';
|
||||
else if (item.active) cls += ' vrs2-dept-progress__seg--active';
|
||||
return React.createElement('div', {
|
||||
key: item.key,
|
||||
className: cls,
|
||||
title: item.label + ':' + item.status + (item.name ? '(' + item.name + ')' : '')
|
||||
},
|
||||
React.createElement('span', { className: 'vrs2-dept-progress__label' }, item.label.replace('部', '').replace('组', '').slice(0, 2))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const Component = function (props) {
|
||||
var useState = React.useState;
|
||||
var useMemo = React.useMemo;
|
||||
var antd = window.antd;
|
||||
var App = antd.App;
|
||||
var Table = antd.Table;
|
||||
var Button = antd.Button;
|
||||
var Select = antd.Select;
|
||||
var Tabs = antd.Tabs;
|
||||
var Dropdown = antd.Dropdown;
|
||||
var message = antd.message;
|
||||
|
||||
var _role = useState(readRole());
|
||||
var role = _role[0];
|
||||
var setRole = _role[1];
|
||||
var _listTab = useState('mine');
|
||||
var listTab = _listTab[0];
|
||||
var setListTab = _listTab[1];
|
||||
var _data = useState(SEED_ROWS);
|
||||
var allRows = _data[0];
|
||||
var _filters = useState({ keyword: '', stage: undefined, settlement: undefined });
|
||||
var filters = _filters[0];
|
||||
var setFilters = _filters[1];
|
||||
var _applied = useState({ keyword: '', stage: undefined, settlement: undefined });
|
||||
var applied = _applied[0];
|
||||
var setApplied = _applied[1];
|
||||
var _page = useState(1);
|
||||
var page = _page[0];
|
||||
var setPage = _page[1];
|
||||
var _pageSize = useState(DEFAULT_PAGE_SIZE);
|
||||
var pageSize = _pageSize[0];
|
||||
var setPageSize = _pageSize[1];
|
||||
|
||||
function handleRoleChange(next) {
|
||||
setRole(next);
|
||||
writeRole(next);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
var stageOptions = useMemo(function () {
|
||||
return Object.keys(TASK_STAGE_META).map(function (k) {
|
||||
return { value: k, label: TASK_STAGE_META[k].label };
|
||||
});
|
||||
}, []);
|
||||
|
||||
var settlementOptions = [
|
||||
{ value: 'refund', label: '应退还' },
|
||||
{ value: 'pay', label: '应补缴' },
|
||||
{ value: 'pending', label: '待核算' }
|
||||
];
|
||||
|
||||
var filteredRows = useMemo(function () {
|
||||
return allRows.filter(function (r) {
|
||||
if (listTab === 'mine' && !hasPendingForRole(r, role)) return false;
|
||||
if (listTab === 'progress' && (r.taskStage === 'archived' || r.taskStage === 'withdrawn')) return false;
|
||||
if (listTab === 'done' && r.taskStage !== 'archived') return false;
|
||||
|
||||
if (applied.keyword) {
|
||||
var kw = applied.keyword.toLowerCase();
|
||||
var hay = [r.plateNo, r.contractCode, r.customerName, r.projectName].join(' ').toLowerCase();
|
||||
if (hay.indexOf(kw) < 0) return false;
|
||||
}
|
||||
if (applied.stage && r.taskStage !== applied.stage) return false;
|
||||
if (applied.settlement === 'refund' && r.settlementDirection !== 'refund') return false;
|
||||
if (applied.settlement === 'pay' && r.settlementDirection !== 'pay') return false;
|
||||
if (applied.settlement === 'pending' && r.settlementDirection) return false;
|
||||
return true;
|
||||
});
|
||||
}, [allRows, listTab, role, applied]);
|
||||
|
||||
var pagedRows = useMemo(function () {
|
||||
var start = (page - 1) * pageSize;
|
||||
return filteredRows.slice(start, start + pageSize);
|
||||
}, [filteredRows, page, pageSize]);
|
||||
|
||||
var mineCount = useMemo(function () {
|
||||
return allRows.filter(function (r) { return hasPendingForRole(r, role); }).length;
|
||||
}, [allRows, role]);
|
||||
|
||||
function handleSearch() {
|
||||
setApplied(Object.assign({}, filters));
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
var empty = { keyword: '', stage: undefined, settlement: undefined };
|
||||
setFilters(empty);
|
||||
setApplied(empty);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function openRow(row, action) {
|
||||
if (action === 'bill') {
|
||||
message.info('生成账单(原型):' + row.contractCode);
|
||||
return;
|
||||
}
|
||||
if (action === 'approve') {
|
||||
message.success('已发起总审批(原型)');
|
||||
return;
|
||||
}
|
||||
if (action === 'finance_approve') {
|
||||
message.info('跳转审批中心待办(原型)');
|
||||
return;
|
||||
}
|
||||
if (action === 'payment') {
|
||||
message.info('跳转收款/付款记录并关联应结款(原型)');
|
||||
return;
|
||||
}
|
||||
if (props && typeof props.onOpenDetail === 'function') {
|
||||
props.onOpenDetail(row);
|
||||
}
|
||||
}
|
||||
|
||||
var columns = [
|
||||
{
|
||||
title: '车辆信息',
|
||||
key: 'vehicle',
|
||||
width: 168,
|
||||
className: 'vm-stacked-cell-td',
|
||||
render: function (_, r) {
|
||||
return React.createElement('div', { className: 'vm-stack' },
|
||||
React.createElement('span', { className: 'vm-stack-primary' }, r.plateNo),
|
||||
React.createElement('span', { className: 'vm-stack-secondary' }, r.contractCode)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '客户 / 项目',
|
||||
key: 'customer',
|
||||
width: 200,
|
||||
className: 'vm-stacked-cell-td',
|
||||
render: function (_, r) {
|
||||
return React.createElement('div', { className: 'vm-stack' },
|
||||
React.createElement('span', { className: 'vm-stack-primary' }, r.customerName),
|
||||
React.createElement('span', { className: 'vm-stack-secondary' }, r.projectName)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '还车时间',
|
||||
dataIndex: 'returnTime',
|
||||
key: 'returnTime',
|
||||
width: 132,
|
||||
render: fmtYMDHM
|
||||
},
|
||||
{
|
||||
title: '任务进度',
|
||||
key: 'deptProgress',
|
||||
width: 148,
|
||||
render: function (_, r) {
|
||||
return React.createElement(DeptProgressBar, { items: deptProgress(r) });
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '当前阶段',
|
||||
key: 'taskStage',
|
||||
width: 108,
|
||||
render: function (_, r) {
|
||||
var meta = TASK_STAGE_META[r.taskStage] || { label: r.taskStage, tone: 'gray' };
|
||||
return React.createElement(StatusTag, { label: meta.label, tone: stageTone(r.taskStage) });
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (canSeeSettlement(role)) {
|
||||
columns.push({
|
||||
title: '结算预览',
|
||||
key: 'settlement',
|
||||
width: 120,
|
||||
className: 'tabular-nums',
|
||||
render: function (_, r) {
|
||||
if (!r.settlementDirection) return React.createElement('span', { className: 'vrs2-muted' }, '待核算');
|
||||
var cls = r.settlementDirection === 'refund' ? 'vrs2-settle vrs2-settle--refund' : 'vrs2-settle vrs2-settle--pay';
|
||||
return React.createElement('span', { className: cls }, settlementLabel(r));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
title: '我的待办',
|
||||
key: 'pendingHint',
|
||||
width: 148,
|
||||
render: function (_, r) {
|
||||
var hint = pendingHint(r, role);
|
||||
if (hint === '—') return React.createElement('span', { className: 'vrs2-muted' }, '—');
|
||||
return React.createElement('span', { className: 'vrs2-pending-hint' }, hint);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: function (_, r) {
|
||||
var primary = getPrimaryAction(r, role);
|
||||
var moreItems = [
|
||||
{ key: 'view', label: '查看详情', onClick: function () { openRow(r, 'view'); } }
|
||||
];
|
||||
if (role === 'biz' && r.canRevoke) {
|
||||
moreItems.push({ key: 'revoke', label: '撤回审批', onClick: function () { message.success('撤回成功(原型)'); } });
|
||||
}
|
||||
return React.createElement('div', { className: 'vrs2-action-cell' },
|
||||
React.createElement(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
className: 'vrs2-primary-action',
|
||||
onClick: function () { openRow(r, primary.action); }
|
||||
}, primary.label),
|
||||
React.createElement(Dropdown, {
|
||||
menu: { items: moreItems },
|
||||
trigger: ['click']
|
||||
},
|
||||
React.createElement(Button, { type: 'link', size: 'small' }, '更多')
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
var listTabs = [
|
||||
{ key: 'mine', label: '待我处理' + (mineCount ? ' (' + mineCount + ')' : '') },
|
||||
{ key: 'progress', label: '进行中' },
|
||||
{ key: 'done', label: '已完成' }
|
||||
];
|
||||
|
||||
return React.createElement(App, null,
|
||||
React.createElement('div', { className: 'vm-page lc-page vrs2-page lc-page--list-dense' },
|
||||
React.createElement('header', { className: 'vrs2-page-header' },
|
||||
React.createElement('div', { className: 'vrs2-page-header__left' },
|
||||
React.createElement('h1', { className: 'vm-page-title' }, '还车应结款'),
|
||||
React.createElement('span', { className: 'vrs2-badge-new' }, '新')
|
||||
),
|
||||
React.createElement('div', { className: 'vrs2-role-switch' },
|
||||
React.createElement('span', { className: 'vrs2-role-switch__label' }, '当前角色'),
|
||||
React.createElement(Select, {
|
||||
value: role,
|
||||
style: { width: 160 },
|
||||
options: ROLE_OPTIONS,
|
||||
onChange: handleRoleChange
|
||||
})
|
||||
)
|
||||
),
|
||||
|
||||
React.createElement('div', { className: 'vm-filter-card ldb-filter-card' },
|
||||
React.createElement('div', { className: 'vm-filter-grid vm-filter-grid--compact' },
|
||||
React.createElement('label', { className: 'vm-filter-field vm-filter-field--wide' },
|
||||
React.createElement('span', { className: 'vm-filter-label' }, '合同 / 车牌 / 客户'),
|
||||
React.createElement('div', { className: 'vm-filter-input-wrap' },
|
||||
React.createElement('span', { className: 'vm-filter-input-icon' }, React.createElement(SearchIconSvg)),
|
||||
React.createElement('input', {
|
||||
className: 'vm-filter-input',
|
||||
placeholder: '输入关键词搜索',
|
||||
value: filters.keyword,
|
||||
onChange: function (e) { setFilters(function (p) { return Object.assign({}, p, { keyword: e.target.value }); }); }
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement('label', { className: 'vm-filter-field' },
|
||||
React.createElement('span', { className: 'vm-filter-label' }, '任务阶段'),
|
||||
React.createElement(Select, {
|
||||
allowClear: true,
|
||||
placeholder: '全部',
|
||||
value: filters.stage,
|
||||
options: stageOptions,
|
||||
onChange: function (v) { setFilters(function (p) { return Object.assign({}, p, { stage: v }); }); }
|
||||
})
|
||||
),
|
||||
canSeeSettlement(role) ? React.createElement('label', { className: 'vm-filter-field' },
|
||||
React.createElement('span', { className: 'vm-filter-label' }, '结算方向'),
|
||||
React.createElement(Select, {
|
||||
allowClear: true,
|
||||
placeholder: '全部',
|
||||
value: filters.settlement,
|
||||
options: settlementOptions,
|
||||
onChange: function (v) { setFilters(function (p) { return Object.assign({}, p, { settlement: v }); }); }
|
||||
})
|
||||
) : null
|
||||
),
|
||||
React.createElement('div', { className: 'vm-filter-actions' },
|
||||
React.createElement(Button, { type: 'primary', onClick: handleSearch }, '查询'),
|
||||
React.createElement(Button, { onClick: handleReset }, '重置'),
|
||||
React.createElement(Button, { icon: React.createElement(ExportIconSvg), onClick: function () { message.info('导出(原型)'); } }, '导出')
|
||||
)
|
||||
),
|
||||
|
||||
React.createElement('section', { className: 'vm-table-card' },
|
||||
React.createElement('div', { className: 'vm-table-toolbar' },
|
||||
React.createElement(Tabs, {
|
||||
activeKey: listTab,
|
||||
items: listTabs,
|
||||
onChange: function (k) { setListTab(k); setPage(1); }
|
||||
})
|
||||
),
|
||||
React.createElement('div', { className: 'vm-table-wrap' },
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
scroll: { x: 1100, y: 'calc(100vh - ' + LIST_CHROME_OFFSET + 'px)' },
|
||||
dataSource: pagedRows,
|
||||
columns: columns
|
||||
})
|
||||
),
|
||||
React.createElement(TablePagination, {
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: filteredRows.length,
|
||||
onChange: function (p, ps) { setPage(p); setPageSize(ps); }
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Component;
|
||||
68
src/prototypes/vehicle-return-settlement-v2/index.tsx
Normal file
68
src/prototypes/vehicle-return-settlement-v2/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @name 还车应结款-新
|
||||
* 任务驱动 · 待我处理优先 · 权责分角色
|
||||
*/
|
||||
import * as antd from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import React, { useEffect } from 'react';
|
||||
import 'antd/dist/reset.css';
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import VehicleReturnSettlementV2App from './VehicleReturnSettlementV2App.jsx';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
React: typeof React;
|
||||
antd: typeof antd;
|
||||
dayjs: typeof dayjs;
|
||||
}
|
||||
}
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
window.dayjs = dayjs;
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const vmTheme = {
|
||||
token: {
|
||||
colorPrimary: '#32a06e',
|
||||
colorLink: '#32a06e',
|
||||
colorLinkHover: '#3fb87c',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
colorText: '#18181b',
|
||||
colorTextSecondary: '#52525b',
|
||||
colorBorder: '#e5e7eb',
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#f6f7f7',
|
||||
headerColor: '#71717a',
|
||||
rowHoverBg: '#f6f7f7',
|
||||
borderColor: '#e5e7eb',
|
||||
cellPaddingBlock: 6,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
Card: { borderRadiusLG: 12 },
|
||||
},
|
||||
};
|
||||
|
||||
export default function VehicleReturnSettlementV2() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={vmTheme} locale={zhCN}>
|
||||
<VehicleReturnSettlementV2App />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
431
src/prototypes/vehicle-return-settlement-v2/styles/index.css
Normal file
431
src/prototypes/vehicle-return-settlement-v2/styles/index.css
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 还车应结款-新
|
||||
*/
|
||||
@import url('../../ledger-shared/styles/ledger-page.css');
|
||||
@import url('../../lease-contract-management/styles/lease-contract.css');
|
||||
|
||||
.vm-page.vrs2-page.lc-page--list-dense {
|
||||
--lc-list-chrome-offset: 420px;
|
||||
}
|
||||
|
||||
.vrs2-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vrs2-page-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vrs2-badge-new {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #16a34a;
|
||||
background: rgba(50, 160, 110, 0.12);
|
||||
border: 1px solid rgba(50, 160, 110, 0.25);
|
||||
}
|
||||
|
||||
.vrs2-role-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vrs2-role-switch__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vrs2-muted {
|
||||
color: var(--ln-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.vrs2-pending-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vrs2-settle--refund { color: #16a34a; font-size: 0.8125rem; font-weight: 500; }
|
||||
.vrs2-settle--pay { color: #dc2626; font-size: 0.8125rem; font-weight: 500; }
|
||||
|
||||
.vrs2-action-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vrs2-primary-action {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 四部门进度条 */
|
||||
.vrs2-dept-progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vrs2-dept-progress__seg {
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--ln-canvas-soft, #f4f4f5);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ln-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vrs2-dept-progress__seg--done {
|
||||
background: rgba(50, 160, 110, 0.14);
|
||||
border-color: rgba(50, 160, 110, 0.35);
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vrs2-dept-progress__seg--active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* —— 详情页 —— */
|
||||
.vrs2-detail-page {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.vrs2-detail-topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vrs2-detail-topbar__left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vrs2-detail-title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vrs2-detail-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs2-pipeline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.vrs2-pipeline__step {
|
||||
flex: 1;
|
||||
min-width: 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.vrs2-pipeline__step:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: calc(50% + 10px);
|
||||
width: calc(100% - 20px);
|
||||
height: 2px;
|
||||
background: var(--ln-hairline);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.vrs2-pipeline__step--done:not(:last-child)::after {
|
||||
background: #32a06e;
|
||||
}
|
||||
|
||||
.vrs2-pipeline__dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--ln-hairline);
|
||||
background: var(--ln-surface-card);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vrs2-pipeline__step--done .vrs2-pipeline__dot {
|
||||
border-color: #32a06e;
|
||||
background: #32a06e;
|
||||
}
|
||||
|
||||
.vrs2-pipeline__step--current .vrs2-pipeline__dot {
|
||||
border-color: #32a06e;
|
||||
box-shadow: 0 0 0 4px rgba(50, 160, 110, 0.18);
|
||||
}
|
||||
|
||||
.vrs2-pipeline__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.vrs2-pipeline__step--done .vrs2-pipeline__label,
|
||||
.vrs2-pipeline__step--current .vrs2-pipeline__label {
|
||||
color: var(--ln-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vrs2-overview-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.vrs2-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px 20px;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vrs2-overview-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.vrs2-link-btn {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.vrs2-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vrs2-section__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs2-dept-overview-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vrs2-dept-overview-card {
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vrs2-dept-overview-card__title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs2-dept-overview-card__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vrs2-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vrs2-kpi-card {
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.vrs2-kpi-card__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vrs2-kpi-card__value {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs2-kpi-card__unit {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.vrs2-kpi-card--warn .vrs2-kpi-card__value { color: #d97706; }
|
||||
.vrs2-kpi-card--good .vrs2-kpi-card__value { color: #16a34a; }
|
||||
.vrs2-kpi-card--danger .vrs2-kpi-card__value { color: #dc2626; }
|
||||
|
||||
.vrs2-dept-panel {
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vrs2-dept-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vrs2-dept-panel__head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vrs2-dept-panel__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vrs2-dept-panel__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.vrs2-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vrs2-field__label {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vrs2-field__value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vrs2-source-tag {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
color: #1d4ed8;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.vrs2-subtitle {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vrs2-closure-panel {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vrs2-closure-panel h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.vrs2-detail-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: var(--ln-surface-card);
|
||||
border-top: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.vrs2-dept-overview-row,
|
||||
.vrs2-kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.vrs2-overview-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vrs2-dept-overview-row,
|
||||
.vrs2-kpi-row,
|
||||
.vrs2-overview-grid,
|
||||
.vrs2-field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vrs2-pipeline__step--current .vrs2-pipeline__dot {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
476
src/prototypes/vehicle-return-settlement-v2/vrsV2Data.js
vendored
Normal file
476
src/prototypes/vehicle-return-settlement-v2/vrsV2Data.js
vendored
Normal file
@@ -0,0 +1,476 @@
|
||||
/** 还车应结款-新:任务阶段、角色与 mock 数据 */
|
||||
|
||||
export var ROLE_OPTIONS = [
|
||||
{ value: 'biz', label: '业务管理组' },
|
||||
{ value: 'energy', label: '能源部' },
|
||||
{ value: 'ops', label: '运维部' },
|
||||
{ value: 'safety', label: '安全部' },
|
||||
{ value: 'finance', label: '财务部' }
|
||||
];
|
||||
|
||||
export var TASK_STAGE_META = {
|
||||
pending_dept: { label: '待部门填报', tone: 'amber' },
|
||||
pending_main_approval: { label: '待发起审批', tone: 'blue' },
|
||||
approving: { label: '审批中', tone: 'blue' },
|
||||
pending_reconcile: { label: '待对账', tone: 'violet' },
|
||||
pending_payment: { label: '待收付款', tone: 'amber' },
|
||||
archived: { label: '已归档', tone: 'green' },
|
||||
rejected: { label: '审批驳回', tone: 'red' },
|
||||
withdrawn: { label: '已撤回', tone: 'gray' }
|
||||
};
|
||||
|
||||
export var PIPELINE_STEPS = [
|
||||
{ key: 'return_archived', label: '还车归档' },
|
||||
{ key: 'dept_fill', label: '部门填报' },
|
||||
{ key: 'main_approval', label: '主审批' },
|
||||
{ key: 'reconcile', label: '账单对账' },
|
||||
{ key: 'payment', label: '收付款' },
|
||||
{ key: 'done', label: '归档完成' }
|
||||
];
|
||||
|
||||
export var DEPT_KEYS = [
|
||||
{ key: 'biz', field: 'submitBusiness', label: '业务管理组' },
|
||||
{ key: 'energy', field: 'submitEnergy', label: '能源部' },
|
||||
{ key: 'ops', field: 'submitOperation', label: '运维部' },
|
||||
{ key: 'safety', field: 'submitSafety', label: '安全部' }
|
||||
];
|
||||
|
||||
export function readRole() {
|
||||
try {
|
||||
var role = new URLSearchParams(window.location.search).get('role') || 'biz';
|
||||
if (ROLE_OPTIONS.some(function (o) { return o.value === role; })) return role;
|
||||
} catch (e) { /* noop */ }
|
||||
return 'biz';
|
||||
}
|
||||
|
||||
export function writeRole(role) {
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('role', role);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
export function normalizeDept(cell) {
|
||||
if (!cell) return { status: '待提交', name: '' };
|
||||
if (cell.status) return { status: cell.status, name: cell.name || '' };
|
||||
return { status: cell.ok ? '已提交' : '待提交', name: cell.name || '' };
|
||||
}
|
||||
|
||||
export function isDeptSubmitted(row, deptKey) {
|
||||
var item = DEPT_KEYS.find(function (d) { return d.key === deptKey; });
|
||||
if (!item) return false;
|
||||
var cell = normalizeDept(row[item.field]);
|
||||
return cell.status === '已提交' || cell.status === '审批完成' || cell.status === '审批中';
|
||||
}
|
||||
|
||||
export function deptProgress(row) {
|
||||
return DEPT_KEYS.map(function (d) {
|
||||
var cell = normalizeDept(row[d.field]);
|
||||
var done = cell.status === '已提交' || cell.status === '审批完成';
|
||||
var active = cell.status === '审批中';
|
||||
return {
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
status: cell.status,
|
||||
name: cell.name,
|
||||
done: done,
|
||||
active: active
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function pipelineIndex(stage) {
|
||||
switch (stage) {
|
||||
case 'pending_dept': return 1;
|
||||
case 'pending_main_approval': return 2;
|
||||
case 'approving': return 3;
|
||||
case 'pending_reconcile': return 3;
|
||||
case 'pending_payment': return 4;
|
||||
case 'archived': return 5;
|
||||
case 'rejected': return 2;
|
||||
case 'withdrawn': return 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function settlementLabel(row) {
|
||||
if (!row || !row.settlementDirection) return '待核算';
|
||||
if (row.settlementDirection === 'refund') return '应退 ' + fmtMoney(row.settlementAmount) + ' 元';
|
||||
if (row.settlementDirection === 'pay') return '应补 ' + fmtMoney(row.settlementAmount) + ' 元';
|
||||
return '待核算';
|
||||
}
|
||||
|
||||
export function canSeeSettlement(role) {
|
||||
return role === 'biz' || role === 'finance';
|
||||
}
|
||||
|
||||
export function fmtMoney(v) {
|
||||
var n = typeof v === 'number' ? v : parseFloat(v);
|
||||
if (isNaN(n)) return '0.00';
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
export function fmtYMDHM(v) {
|
||||
if (!v) return '—';
|
||||
var s = String(v).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(s)) return s.slice(0, 16);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s + ' 00:00';
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 当前角色是否在此任务上有待办 */
|
||||
export function hasPendingForRole(row, role) {
|
||||
var stage = row.taskStage;
|
||||
if (stage === 'archived' || stage === 'withdrawn') return false;
|
||||
|
||||
if (role === 'biz') {
|
||||
if (stage === 'pending_dept' && !isDeptSubmitted(row, 'biz')) return true;
|
||||
if (stage === 'pending_main_approval') return true;
|
||||
if (stage === 'pending_reconcile') return true;
|
||||
if (stage === 'approving' && row.canRevoke) return true;
|
||||
if (stage === 'rejected') return true;
|
||||
return false;
|
||||
}
|
||||
if (role === 'energy' && stage === 'pending_dept' && !isDeptSubmitted(row, 'energy')) return true;
|
||||
if (role === 'ops' && stage === 'pending_dept' && !isDeptSubmitted(row, 'ops')) return true;
|
||||
if (role === 'safety' && stage === 'pending_dept' && !isDeptSubmitted(row, 'safety')) return true;
|
||||
if (role === 'finance' && stage === 'approving' && row.financePending) return true;
|
||||
if (role === 'finance' && stage === 'pending_payment') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pendingHint(row, role) {
|
||||
if (!hasPendingForRole(row, role)) return '—';
|
||||
if (role === 'biz') {
|
||||
if (row.taskStage === 'pending_dept' && !isDeptSubmitted(row, 'biz')) return '待您提交业务费用';
|
||||
if (row.taskStage === 'pending_main_approval') return '待您发起总审批';
|
||||
if (row.taskStage === 'pending_reconcile') return '待您生成账单并对账';
|
||||
if (row.taskStage === 'approving') return '审批进行中,可撤回';
|
||||
if (row.taskStage === 'rejected') return '审批已驳回,待处理';
|
||||
}
|
||||
if (role === 'energy') return '待您提交能源费用';
|
||||
if (role === 'ops') return '待您提交运维费用';
|
||||
if (role === 'safety') return '待您提交安全费用';
|
||||
if (role === 'finance' && row.taskStage === 'approving') return '待您财务终审';
|
||||
if (role === 'finance' && row.taskStage === 'pending_payment') return '待登记收/付款';
|
||||
return '待处理';
|
||||
}
|
||||
|
||||
export function getPrimaryAction(row, role) {
|
||||
var stage = row.taskStage;
|
||||
if (hasPendingForRole(row, role)) {
|
||||
if (role === 'biz' && stage === 'pending_main_approval') return { label: '发起审批', action: 'approve' };
|
||||
if (role === 'biz' && stage === 'pending_reconcile') return { label: '生成账单', action: 'bill' };
|
||||
if (role === 'finance' && stage === 'approving') return { label: '去审批', action: 'finance_approve' };
|
||||
if (role === 'finance' && stage === 'pending_payment') return { label: '登记收付款', action: 'payment' };
|
||||
return { label: '去处理', action: 'handle' };
|
||||
}
|
||||
if (stage === 'archived' || stage === 'pending_payment' || stage === 'approving') {
|
||||
return { label: '查看', action: 'view' };
|
||||
}
|
||||
return { label: '查看', action: 'view' };
|
||||
}
|
||||
|
||||
export var SEED_ROWS = [
|
||||
{
|
||||
key: 'r1',
|
||||
contractCode: 'LNZLHT20251106001',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '审批中', name: '邱陈佳' },
|
||||
submitEnergy: { status: '待提交', name: '' },
|
||||
taskStage: 'pending_dept',
|
||||
customerName: '嘉兴某某物流有限公司',
|
||||
projectName: '嘉兴腾4.5T租赁',
|
||||
plateNo: '粤AGP5621',
|
||||
businessDept: '业务一部',
|
||||
businessOwner: '张经理',
|
||||
deliveryTime: '2026-02-01 09:30',
|
||||
returnTime: '2026-02-27 16:20',
|
||||
returnPerson: '陈还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: null,
|
||||
settlementAmount: null,
|
||||
deposit: 10000,
|
||||
pendingTotal: null,
|
||||
canRevoke: false,
|
||||
financePending: false
|
||||
},
|
||||
{
|
||||
key: 'r2',
|
||||
contractCode: 'LNZLHT20251201002',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '已提交', name: '邱陈佳' },
|
||||
submitEnergy: { status: '已提交', name: '许诗琪' },
|
||||
taskStage: 'pending_main_approval',
|
||||
customerName: '上海馨想事成物流有限公司',
|
||||
projectName: '上海干线运输项目',
|
||||
plateNo: '浙F03218F',
|
||||
businessDept: '业务二部',
|
||||
businessOwner: '李主管',
|
||||
deliveryTime: '2026-01-10 10:00',
|
||||
returnTime: '2026-02-20 18:05',
|
||||
returnPerson: '周还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: 'refund',
|
||||
settlementAmount: 5200,
|
||||
deposit: 10000,
|
||||
pendingTotal: 4800,
|
||||
canRevoke: false,
|
||||
financePending: false,
|
||||
slaReady: true
|
||||
},
|
||||
{
|
||||
key: 'r7',
|
||||
contractCode: 'LNZLHT20260108007',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '待提交', name: '' },
|
||||
submitOperation: { status: '待提交', name: '' },
|
||||
submitEnergy: { status: '待提交', name: '' },
|
||||
taskStage: 'pending_dept',
|
||||
customerName: '合肥某某物流有限公司',
|
||||
projectName: '合肥城配项目',
|
||||
plateNo: '皖A7K3P8',
|
||||
businessDept: '业务一部',
|
||||
businessOwner: '张经理',
|
||||
deliveryTime: '2026-02-10',
|
||||
returnTime: '2026-02-28',
|
||||
returnPerson: '许还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: null,
|
||||
settlementAmount: null,
|
||||
deposit: 8000,
|
||||
pendingTotal: null,
|
||||
canRevoke: false,
|
||||
financePending: false
|
||||
},
|
||||
{
|
||||
key: 'r4',
|
||||
contractCode: 'LNZLHT20251106004',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '审批中', name: '何苗苗' },
|
||||
submitOperation: { status: '已提交', name: '邱陈佳' },
|
||||
submitEnergy: { status: '审批中', name: '许诗琪' },
|
||||
taskStage: 'pending_dept',
|
||||
customerName: '苏州某某快运有限公司',
|
||||
projectName: '苏州城配项目',
|
||||
plateNo: '苏E8K2P1',
|
||||
businessDept: '业务一部',
|
||||
businessOwner: '张经理',
|
||||
deliveryTime: '2026-02-15 13:40',
|
||||
returnTime: '2026-02-26 09:10',
|
||||
returnPerson: '孙还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: null,
|
||||
settlementAmount: null,
|
||||
deposit: 10000,
|
||||
pendingTotal: null,
|
||||
canRevoke: false,
|
||||
financePending: false
|
||||
},
|
||||
{
|
||||
key: 'r9',
|
||||
contractCode: 'LNZLHT20251130009',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '已提交', name: '邱陈佳' },
|
||||
submitEnergy: { status: '已提交', name: '许诗琪' },
|
||||
taskStage: 'approving',
|
||||
customerName: '天津某某运输有限公司',
|
||||
projectName: '津冀干线项目',
|
||||
plateNo: '津A5R8L1',
|
||||
businessDept: '业务三部',
|
||||
businessOwner: '王总',
|
||||
deliveryTime: '2026-02-02',
|
||||
returnTime: '2026-02-27',
|
||||
returnPerson: '韩还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: 'refund',
|
||||
settlementAmount: 6786.66,
|
||||
deposit: 10000,
|
||||
pendingTotal: 3213.34,
|
||||
canRevoke: true,
|
||||
financePending: true
|
||||
},
|
||||
{
|
||||
key: 'r8',
|
||||
contractCode: 'ZYYGHT20260112008',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '已提交', name: '邱陈佳' },
|
||||
submitEnergy: { status: '已提交', name: '许诗琪' },
|
||||
taskStage: 'pending_reconcile',
|
||||
customerName: '深圳某某快运有限公司',
|
||||
projectName: '深莞干线项目',
|
||||
plateNo: '粤B2P6M9',
|
||||
businessDept: '业务二部',
|
||||
businessOwner: '李主管',
|
||||
deliveryTime: '2026-01-20',
|
||||
returnTime: '2026-02-22',
|
||||
returnPerson: '彭还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: 'pay',
|
||||
settlementAmount: 1200,
|
||||
deposit: 5000,
|
||||
pendingTotal: 6200,
|
||||
canRevoke: false,
|
||||
financePending: false
|
||||
},
|
||||
{
|
||||
key: 'r6',
|
||||
contractCode: 'LNZLHT20251215006',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '已提交', name: '邱陈佳' },
|
||||
submitEnergy: { status: '已提交', name: '许诗琪' },
|
||||
taskStage: 'pending_payment',
|
||||
customerName: '宁波某某供应链有限公司',
|
||||
projectName: '宁波港区支线项目',
|
||||
plateNo: '浙B6H9Q2',
|
||||
businessDept: '业务三部',
|
||||
businessOwner: '周经理',
|
||||
deliveryTime: '2026-01-05',
|
||||
returnTime: '2026-02-18',
|
||||
returnPerson: '郑还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: 'refund',
|
||||
settlementAmount: 4500,
|
||||
deposit: 10000,
|
||||
pendingTotal: 5500,
|
||||
canRevoke: false,
|
||||
financePending: false,
|
||||
billNo: 'ZD-20260218-006'
|
||||
},
|
||||
{
|
||||
key: 'r10',
|
||||
contractCode: 'LNZLHT20251118010',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '审批驳回', name: '邱陈佳' },
|
||||
submitEnergy: { status: '审批中', name: '许诗琪' },
|
||||
taskStage: 'rejected',
|
||||
customerName: '武汉某某物流有限公司',
|
||||
projectName: '武汉园区项目',
|
||||
plateNo: '鄂A8D1Q6',
|
||||
businessDept: '业务一部',
|
||||
businessOwner: '周经理',
|
||||
deliveryTime: '2026-01-15',
|
||||
returnTime: '2026-02-16',
|
||||
returnPerson: '蒋还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: null,
|
||||
settlementAmount: null,
|
||||
deposit: 10000,
|
||||
pendingTotal: null,
|
||||
canRevoke: false,
|
||||
financePending: false
|
||||
},
|
||||
{
|
||||
key: 'r5',
|
||||
contractCode: 'LNZLHT20251106005',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '已提交', name: '邱陈佳' },
|
||||
submitEnergy: { status: '审批中', name: '许诗琪' },
|
||||
taskStage: 'withdrawn',
|
||||
customerName: '杭州某某物流有限公司',
|
||||
projectName: '杭州港区项目',
|
||||
plateNo: '浙A3M8Q7',
|
||||
businessDept: '业务二部',
|
||||
businessOwner: '李主管',
|
||||
deliveryTime: '2026-02-05',
|
||||
returnTime: '2026-02-25',
|
||||
returnPerson: '吴还车',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: null,
|
||||
settlementAmount: null,
|
||||
deposit: 10000,
|
||||
pendingTotal: null,
|
||||
canRevoke: false,
|
||||
financePending: false
|
||||
},
|
||||
{
|
||||
key: 'r11',
|
||||
contractCode: 'LNZLHT2025082502',
|
||||
submitSafety: { status: '已提交', name: '罗日可' },
|
||||
submitBusiness: { status: '已提交', name: '何苗苗' },
|
||||
submitOperation: { status: '已提交', name: '邱陈佳' },
|
||||
submitEnergy: { status: '已提交', name: '许诗琪' },
|
||||
taskStage: 'archived',
|
||||
customerName: '荣达餐饮(广东)集团有限公司',
|
||||
projectName: '荣达餐饮试-租赁帕力安4.5T',
|
||||
plateNo: '粤AGP3628',
|
||||
businessDept: '业务一部',
|
||||
businessOwner: '张经理',
|
||||
deliveryTime: '2025-08-26 15:56',
|
||||
returnTime: '2026-07-09 09:38',
|
||||
returnPerson: '运维员A',
|
||||
returnArchived: true,
|
||||
esignStatus: '已归档',
|
||||
settlementDirection: 'refund',
|
||||
settlementAmount: 6786.66,
|
||||
deposit: 10000,
|
||||
pendingTotal: 3213.34,
|
||||
canRevoke: false,
|
||||
financePending: false,
|
||||
billNo: 'ZD-20260709-001',
|
||||
paymentRef: 'FK-20260715-088'
|
||||
}
|
||||
];
|
||||
|
||||
export function buildDeptFeeDetail() {
|
||||
return {
|
||||
biz: {
|
||||
total: '1005.00',
|
||||
submitter: '何苗苗',
|
||||
status: '审批中',
|
||||
rows: [
|
||||
{ key: 'c1', seq: 1, feeItem: 'ETC-客户未缴费用', amount: '0.00', remark: '', updatedAt: '2026-07-09 10:06:50', source: '自动' },
|
||||
{ key: 'c2', seq: 2, feeItem: '租金', amount: '1005.00', remark: '', updatedAt: '2026-07-09 10:06:50', source: '手动' }
|
||||
]
|
||||
},
|
||||
energy: {
|
||||
total: '34.94',
|
||||
submitter: '许诗琪',
|
||||
status: '待提交',
|
||||
fields: [
|
||||
{ label: '氢量差补缴金额', value: '34.94', source: '还车记录' },
|
||||
{ label: '交车氢量', value: '85.20 MPa', source: '交车' },
|
||||
{ label: '还车氢量', value: '72.10 MPa', source: '还车' },
|
||||
{ label: '能源账户退费金额', value: '0.00', source: '手动' }
|
||||
],
|
||||
prepayBalance: '13523.20'
|
||||
},
|
||||
ops: {
|
||||
total: '2173.40',
|
||||
submitter: '邱陈佳',
|
||||
status: '待提交',
|
||||
rows: [
|
||||
{ key: 'o1', seq: 1, feeItem: '未结算保养', amount: '372.50', discount: '0.00', updatedAt: '2026-07-09 10:04:40', source: '维保台账' },
|
||||
{ key: 'o2', seq: 2, feeItem: '轮胎磨损费用', amount: '1800.90', discount: '0.00', updatedAt: '2026-07-09 10:04:40', source: '还车OCR' }
|
||||
]
|
||||
},
|
||||
safety: {
|
||||
total: '0.00',
|
||||
submitter: '罗日可',
|
||||
status: '待提交',
|
||||
feeRows: [
|
||||
{ key: 's1', seq: 1, feeItem: '违章处理违约金', amount: '0.00', updatedAt: '2026-07-09 10:04:40', source: '违章管理' }
|
||||
],
|
||||
violations: [
|
||||
{ key: 'v1', code: 'WZ-2026061511180585', plateNo: '粤AGP3628', act: '超速20%-50%', time: '2025-12-18 06:31:00', fine: '150.00', status: '已缴费', handled: '已处理' }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import ReturnSettlementListPage from '../oneos-web-finance/pages/03-还车应结款.jsx';
|
||||
import VehicleReturnSettlementHandle from './VehicleReturnSettlementHandle.jsx';
|
||||
|
||||
const Component = function (props) {
|
||||
var useState = React.useState;
|
||||
var _view = useState('list');
|
||||
var view = _view[0];
|
||||
var setView = _view[1];
|
||||
var _selectedRow = useState(null);
|
||||
var selectedRow = _selectedRow[0];
|
||||
var setSelectedRow = _selectedRow[1];
|
||||
|
||||
if (view === 'handle') {
|
||||
return React.createElement(VehicleReturnSettlementHandle, {
|
||||
row: selectedRow,
|
||||
onBack: function () {
|
||||
setSelectedRow(null);
|
||||
setView('list');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return React.createElement(ReturnSettlementListPage, {
|
||||
onGoHandle: function (row) {
|
||||
setSelectedRow(row || null);
|
||||
setView('handle');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default Component;
|
||||
@@ -0,0 +1,418 @@
|
||||
import { StatusTag } from '../vehicle-management/components/StatusTag';
|
||||
|
||||
var DEPT_ROLE_OPTIONS = [
|
||||
{ value: 'biz', label: '客户服务组(全量)' },
|
||||
{ value: 'energy', label: '能源组' },
|
||||
{ value: 'ops', label: '运维部' },
|
||||
{ value: 'safety', label: '安全部' }
|
||||
];
|
||||
|
||||
var DEPT_SECTIONS = [
|
||||
{ key: 'customer', title: '客户服务', role: 'biz' },
|
||||
{ key: 'energy', title: '能源组', role: 'energy' },
|
||||
{ key: 'ops', title: '运维组', role: 'ops' },
|
||||
{ key: 'safety', title: '安全组', role: 'safety' }
|
||||
];
|
||||
|
||||
function readDeptRole() {
|
||||
try {
|
||||
var role = new URLSearchParams(window.location.search).get('dept') || 'biz';
|
||||
if (['biz', 'energy', 'ops', 'safety'].indexOf(role) >= 0) return role;
|
||||
} catch (e) { /* noop */ }
|
||||
return 'biz';
|
||||
}
|
||||
|
||||
function writeDeptRole(role) {
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('dept', role);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function approvalTone(status) {
|
||||
switch (status) {
|
||||
case '审批完成': return 'green';
|
||||
case '审批中': return 'blue';
|
||||
case '待提交': return 'amber';
|
||||
case '审批驳回': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMoney(v) {
|
||||
var n = typeof v === 'number' ? v : parseFloat(v);
|
||||
if (isNaN(n)) return '0.00';
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
function fmtYMDHM(v) {
|
||||
if (!v) return '—';
|
||||
var s = String(v).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(s)) return s.slice(0, 16);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s + ' 00:00';
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildVehicleFromRow(row) {
|
||||
var r = row || {};
|
||||
return {
|
||||
key: 'v1',
|
||||
plateNo: r.plateNo || '粤AGP3628',
|
||||
contractCode: r.contractCode || 'LNZLHT2025082502',
|
||||
projectName: r.projectName || '荣达餐饮试-租赁帕力安4.5T',
|
||||
customerName: r.customerName || '荣达餐饮(广东)集团有限公司',
|
||||
deliveryTime: r.deliveryTime || '2025-08-26 15:56',
|
||||
returnTime: r.returnTime || '2026-07-09 09:38',
|
||||
deliveryMileage: '368.00',
|
||||
returnMileage: '25326.00',
|
||||
fragileInsurance: '否',
|
||||
tireInsurance: '否',
|
||||
maintenanceInsurance: '否'
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeptData() {
|
||||
return {
|
||||
customer: {
|
||||
total: '1005.00',
|
||||
submitter: '何苗苗',
|
||||
status: '审批中',
|
||||
rows: [
|
||||
{ key: 'c1', seq: 1, feeItem: 'ETC-客户未缴费用', amount: '0.00', remark: '', updatedAt: '2026-07-09 10:06:50' },
|
||||
{ key: 'c2', seq: 2, feeItem: 'ETC卡缺损费', amount: '0.00', remark: '', updatedAt: '2026-07-09 10:06:50' },
|
||||
{ key: 'c3', seq: 3, feeItem: 'ETC设备缺损费', amount: '0.00', remark: '', updatedAt: '2026-07-09 10:06:50' },
|
||||
{ key: 'c4', seq: 4, feeItem: '租金', amount: '1005.00', remark: '', updatedAt: '2026-07-09 10:06:50' }
|
||||
]
|
||||
},
|
||||
energy: {
|
||||
total: '34.94',
|
||||
submitter: '许诗琪',
|
||||
status: '待提交',
|
||||
fields: [
|
||||
{ label: '氢量差补缴金额', value: '34.94' },
|
||||
{ label: '交车氢量', value: '—' },
|
||||
{ label: '还车氢量', value: '—' },
|
||||
{ label: '退还车氢气单价', value: '—' },
|
||||
{ label: '能源账户退费金额', value: '0.00' }
|
||||
],
|
||||
prepayBalance: '13523.20'
|
||||
},
|
||||
ops: {
|
||||
total: '2173.40',
|
||||
submitter: '邱陈佳',
|
||||
status: '待提交',
|
||||
rows: [
|
||||
{ key: 'o1', seq: 1, feeItem: '清洗费', amount: '0.00', discount: '—', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o2', seq: 2, feeItem: '未结算保养', amount: '372.50', discount: '0.00', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o3', seq: 3, feeItem: '未结算维修', amount: '0.00', discount: '0.00', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o4', seq: 4, feeItem: '车损费用', amount: '0.00', discount: '—', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o5', seq: 5, feeItem: '工具损坏丢失费用', amount: '0.00', discount: '—', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o6', seq: 6, feeItem: '证件丢失费用', amount: '0.00', discount: '—', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o7', seq: 7, feeItem: '广告损坏丢失费用', amount: '0.00', discount: '—', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o8', seq: 8, feeItem: '送车服务费', amount: '0.00', discount: '—', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o9', seq: 9, feeItem: '接车服务费', amount: '0.00', discount: '—', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 'o10', seq: 10, feeItem: '轮胎磨损费用', amount: '1800.90', discount: '0.00', updatedAt: '2026-07-09 10:04:40' }
|
||||
]
|
||||
},
|
||||
safety: {
|
||||
total: '0.00',
|
||||
submitter: '罗日可',
|
||||
status: '待提交',
|
||||
feeRows: [
|
||||
{ key: 's1', seq: 1, feeItem: '违章处理违约金', amount: '0.00', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 's2', seq: 2, feeItem: '保险上浮', amount: '0.00', updatedAt: '2026-07-09 10:04:40' },
|
||||
{ key: 's3', seq: 3, feeItem: '其他违规费用', amount: '0.00', updatedAt: '2026-07-09 10:04:40' }
|
||||
],
|
||||
violations: [
|
||||
{ key: 'v1', code: 'WZ-2026061511180585', plateNo: '粤AGP3628', act: '超速20%-50%', time: '2025-12-18 06:31:00', fine: '150.00', status: '已缴费', points: '3', handled: '已处理' },
|
||||
{ key: 'v2', code: 'WZ-2026061511180604', plateNo: '粤AGP3628', act: '超速20%-50%', time: '2025-12-09 06:45:00', fine: '150.00', status: '已缴费', points: '3', handled: '已处理' }
|
||||
],
|
||||
accidents: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const Component = function (props) {
|
||||
var useState = React.useState;
|
||||
var useMemo = React.useMemo;
|
||||
var antd = window.antd;
|
||||
var App = antd.App;
|
||||
var Table = antd.Table;
|
||||
var Button = antd.Button;
|
||||
var Select = antd.Select;
|
||||
var message = antd.message;
|
||||
|
||||
var _role = useState(readDeptRole());
|
||||
var role = _role[0];
|
||||
var setRole = _role[1];
|
||||
var vehicle = useMemo(function () { return buildVehicleFromRow(props && props.row); }, [props && props.row]);
|
||||
var deptData = useMemo(buildDeptData, []);
|
||||
var stats = useMemo(function () {
|
||||
return {
|
||||
deposit: '10000.00',
|
||||
pending: '3213.34',
|
||||
refund: '6786.66',
|
||||
pay: '0.00'
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleRoleChange(next) {
|
||||
setRole(next);
|
||||
writeDeptRole(next);
|
||||
}
|
||||
|
||||
function canSeeSection(sectionKey) {
|
||||
if (role === 'biz') return true;
|
||||
if (sectionKey === 'customer') return false;
|
||||
return sectionKey === role;
|
||||
}
|
||||
|
||||
function canSeeKpi() {
|
||||
return role === 'biz';
|
||||
}
|
||||
|
||||
function canSeeVehicleTable() {
|
||||
return role === 'biz';
|
||||
}
|
||||
|
||||
var vehicleColumns = [
|
||||
{ title: '车牌号', dataIndex: 'plateNo', key: 'plateNo', width: 110 },
|
||||
{ title: '合同编码', dataIndex: 'contractCode', key: 'contractCode', width: 168 },
|
||||
{ title: '项目名称', dataIndex: 'projectName', key: 'projectName', width: 180, ellipsis: true },
|
||||
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 200, ellipsis: true },
|
||||
{ title: '交车时间', dataIndex: 'deliveryTime', key: 'deliveryTime', width: 148, render: fmtYMDHM },
|
||||
{ title: '还车时间', dataIndex: 'returnTime', key: 'returnTime', width: 148, render: fmtYMDHM },
|
||||
{ title: '交车里程(km)', dataIndex: 'deliveryMileage', key: 'deliveryMileage', width: 120, className: 'tabular-nums' },
|
||||
{ title: '还车里程(km)', dataIndex: 'returnMileage', key: 'returnMileage', width: 120, className: 'tabular-nums' },
|
||||
{ title: '易损保', dataIndex: 'fragileInsurance', key: 'fragileInsurance', width: 72 },
|
||||
{ title: '轮胎保', dataIndex: 'tireInsurance', key: 'tireInsurance', width: 72 },
|
||||
{ title: '养护保', dataIndex: 'maintenanceInsurance', key: 'maintenanceInsurance', width: 72 }
|
||||
];
|
||||
|
||||
function renderDeptHeader(dept) {
|
||||
return React.createElement('div', { className: 'vrs-handle-dept-head' },
|
||||
React.createElement('div', { className: 'vrs-handle-dept-head__title' }, dept.title),
|
||||
React.createElement('div', { className: 'vrs-handle-dept-head__meta' },
|
||||
React.createElement('span', { className: 'tabular-nums' }, '总金额:', React.createElement('strong', null, dept.total), ' 元'),
|
||||
React.createElement('span', null, '提交人:', dept.submitter || '—'),
|
||||
React.createElement(StatusTag, { label: dept.status, tone: approvalTone(dept.status) })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustomerSection() {
|
||||
var d = deptData.customer;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs-handle-dept-card', key: 'customer' },
|
||||
renderDeptHeader({ title: '客户服务', total: d.total, submitter: d.submitter, status: d.status }),
|
||||
React.createElement('div', { className: 'vm-table-wrap' },
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
dataSource: d.rows,
|
||||
columns: [
|
||||
{ title: '序号', dataIndex: 'seq', key: 'seq', width: 56 },
|
||||
{ title: '费用项', dataIndex: 'feeItem', key: 'feeItem', width: 160 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100, className: 'tabular-nums' },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 120 },
|
||||
{ title: '最后更新时间', dataIndex: 'updatedAt', key: 'updatedAt', width: 148, className: 'tabular-nums' }
|
||||
]
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderEnergySection() {
|
||||
var d = deptData.energy;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs-handle-dept-card', key: 'energy' },
|
||||
renderDeptHeader({ title: '能源组', total: d.total, submitter: d.submitter, status: d.status }),
|
||||
React.createElement('div', { className: 'vrs-handle-field-grid' },
|
||||
d.fields.map(function (f) {
|
||||
return React.createElement('div', { key: f.label, className: 'vrs-handle-field' },
|
||||
React.createElement('span', { className: 'vrs-handle-field__label' }, f.label),
|
||||
React.createElement('span', { className: 'vrs-handle-field__value tabular-nums' }, f.value, f.label.indexOf('金额') >= 0 ? ' 元' : '')
|
||||
);
|
||||
}),
|
||||
React.createElement('div', { className: 'vrs-handle-field vrs-handle-field--full' },
|
||||
React.createElement('span', { className: 'vrs-handle-field__label' }, '项目预充值余额'),
|
||||
React.createElement('span', { className: 'vrs-handle-field__value tabular-nums' }, d.prepayBalance, ' 元')
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'vrs-handle-dept-actions' },
|
||||
React.createElement(Button, { size: 'small' }, '保存'),
|
||||
React.createElement(Button, { type: 'primary', size: 'small' }, '提交')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderOpsSection() {
|
||||
var d = deptData.ops;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs-handle-dept-card', key: 'ops' },
|
||||
renderDeptHeader({ title: '运维组', total: d.total, submitter: d.submitter, status: d.status }),
|
||||
React.createElement('div', { className: 'vm-table-wrap' },
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
scroll: { x: 900 },
|
||||
dataSource: d.rows,
|
||||
columns: [
|
||||
{ title: '序号', dataIndex: 'seq', key: 'seq', width: 56 },
|
||||
{ title: '费用项', dataIndex: 'feeItem', key: 'feeItem', width: 140 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 96, className: 'tabular-nums' },
|
||||
{ title: '无忧包减免', dataIndex: 'discount', key: 'discount', width: 96, className: 'tabular-nums' },
|
||||
{ title: '最后更新时间', dataIndex: 'updatedAt', key: 'updatedAt', width: 148, className: 'tabular-nums' }
|
||||
]
|
||||
})
|
||||
),
|
||||
React.createElement('div', { className: 'vrs-handle-dept-actions' },
|
||||
React.createElement(Button, { size: 'small' }, '保存'),
|
||||
React.createElement(Button, { type: 'primary', size: 'small' }, '提交')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderSafetySection() {
|
||||
var d = deptData.safety;
|
||||
return React.createElement('section', { className: 'vm-table-card vrs-handle-dept-card', key: 'safety' },
|
||||
renderDeptHeader({ title: '安全组', total: d.total, submitter: d.submitter, status: d.status }),
|
||||
React.createElement('div', { className: 'vm-table-wrap' },
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
dataSource: d.feeRows,
|
||||
columns: [
|
||||
{ title: '序号', dataIndex: 'seq', key: 'seq', width: 56 },
|
||||
{ title: '费用项', dataIndex: 'feeItem', key: 'feeItem', width: 140 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 96, className: 'tabular-nums' },
|
||||
{ title: '最后更新时间', dataIndex: 'updatedAt', key: 'updatedAt', width: 148, className: 'tabular-nums' }
|
||||
]
|
||||
})
|
||||
),
|
||||
React.createElement('h3', { className: 'vrs-handle-subtitle' }, '违章清单'),
|
||||
React.createElement('div', { className: 'vm-table-wrap' },
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
scroll: { x: 1100 },
|
||||
dataSource: d.violations,
|
||||
columns: [
|
||||
{ title: '违章编码', dataIndex: 'code', key: 'code', width: 160 },
|
||||
{ title: '车牌号', dataIndex: 'plateNo', key: 'plateNo', width: 100 },
|
||||
{ title: '违法行为', dataIndex: 'act', key: 'act', width: 220, ellipsis: true },
|
||||
{ title: '违法时间', dataIndex: 'time', key: 'time', width: 148, className: 'tabular-nums' },
|
||||
{ title: '罚款金额', dataIndex: 'fine', key: 'fine', width: 96, className: 'tabular-nums' },
|
||||
{ title: '缴费状态', dataIndex: 'status', key: 'status', width: 88 },
|
||||
{ title: '记分值', dataIndex: 'points', key: 'points', width: 72 },
|
||||
{ title: '是否处理', dataIndex: 'handled', key: 'handled', width: 88 }
|
||||
]
|
||||
})
|
||||
),
|
||||
React.createElement('h3', { className: 'vrs-handle-subtitle' }, '事故清单'),
|
||||
React.createElement('div', { className: 'vrs-handle-empty' }, '暂无事故记录'),
|
||||
React.createElement('div', { className: 'vrs-handle-dept-actions' },
|
||||
React.createElement(Button, { size: 'small' }, '保存'),
|
||||
React.createElement(Button, { type: 'primary', size: 'small' }, '提交')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderKpiCards() {
|
||||
var cards = [
|
||||
{ key: 'deposit', label: '保证金总额', value: stats.deposit, tone: 'neutral' },
|
||||
{ key: 'pending', label: '待结算总额', value: stats.pending, tone: 'warn', hint: '点击查看明细' },
|
||||
{ key: 'refund', label: '应退还总额', value: stats.refund, tone: 'good', hint: '点击查看明细' },
|
||||
{ key: 'pay', label: '应补缴总额', value: stats.pay, tone: 'danger', hint: '点击查看明细' }
|
||||
];
|
||||
return React.createElement('div', { className: 'vrs-handle-kpi-row' },
|
||||
cards.map(function (c) {
|
||||
return React.createElement('div', { key: c.key, className: 'vrs-handle-kpi-card vrs-handle-kpi-card--' + c.tone },
|
||||
React.createElement('div', { className: 'vrs-handle-kpi-card__label' },
|
||||
c.label,
|
||||
c.hint ? React.createElement('span', { className: 'vrs-handle-kpi-card__hint' }, c.hint) : null
|
||||
),
|
||||
React.createElement('div', { className: 'vrs-handle-kpi-card__value tabular-nums' },
|
||||
c.value,
|
||||
React.createElement('span', { className: 'vrs-handle-kpi-card__unit' }, '元')
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderCompactVehicle() {
|
||||
return React.createElement('div', { className: 'vrs-handle-vehicle-compact' },
|
||||
React.createElement('span', { className: 'vrs-handle-vehicle-compact__plate' }, vehicle.plateNo),
|
||||
React.createElement('span', null, vehicle.contractCode),
|
||||
React.createElement('span', null, vehicle.customerName)
|
||||
);
|
||||
}
|
||||
|
||||
return React.createElement(App, null,
|
||||
React.createElement('div', { className: 'vm-page lc-page vrs-page vrs-handle-page' },
|
||||
React.createElement('header', { className: 'vrs-handle-topbar' },
|
||||
React.createElement('div', { className: 'vrs-handle-topbar__left' },
|
||||
React.createElement('button', {
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost',
|
||||
onClick: function () { if (props && props.onBack) props.onBack(); }
|
||||
}, '← 返回列表'),
|
||||
React.createElement('h1', { className: 'vrs-handle-title' }, '还车应结款详情')
|
||||
),
|
||||
React.createElement('div', { className: 'vrs-handle-role-switch' },
|
||||
React.createElement('span', { className: 'vrs-handle-role-switch__label' }, '预览角色'),
|
||||
React.createElement(Select, {
|
||||
value: role,
|
||||
style: { width: 200 },
|
||||
options: DEPT_ROLE_OPTIONS,
|
||||
onChange: handleRoleChange
|
||||
})
|
||||
)
|
||||
),
|
||||
|
||||
canSeeVehicleTable()
|
||||
? React.createElement('section', { className: 'vm-table-card vrs-handle-section' },
|
||||
React.createElement('header', { className: 'vrs-handle-section__title' }, '还车车辆明细'),
|
||||
React.createElement('div', { className: 'vm-table-wrap' },
|
||||
React.createElement(Table, {
|
||||
className: 'vm-list-table',
|
||||
rowKey: 'key',
|
||||
size: 'small',
|
||||
pagination: false,
|
||||
scroll: { x: 1280 },
|
||||
dataSource: [vehicle],
|
||||
columns: vehicleColumns
|
||||
})
|
||||
)
|
||||
)
|
||||
: React.createElement('section', { className: 'vm-filter-card ldb-filter-card vrs-handle-vehicle-banner' },
|
||||
renderCompactVehicle()
|
||||
),
|
||||
|
||||
React.createElement('section', { className: 'vrs-handle-fee-section' },
|
||||
React.createElement('header', { className: 'vrs-handle-section__title' }, '还车费用明细'),
|
||||
canSeeKpi() ? renderKpiCards() : null,
|
||||
canSeeSection('customer') ? renderCustomerSection() : null,
|
||||
canSeeSection('energy') ? renderEnergySection() : null,
|
||||
canSeeSection('ops') ? renderOpsSection() : null,
|
||||
canSeeSection('safety') ? renderSafetySection() : null
|
||||
),
|
||||
|
||||
React.createElement('footer', { className: 'vrs-handle-footer' },
|
||||
React.createElement(Button, { onClick: function () { if (props && props.onBack) props.onBack(); } }, '取消'),
|
||||
role === 'biz'
|
||||
? React.createElement(Button, { type: 'primary', disabled: true }, '14天12小时后可提交审核')
|
||||
: null
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Component;
|
||||
@@ -1,22 +1,67 @@
|
||||
/**
|
||||
* @name 还车应结款
|
||||
* 自 ONE-OS web端/财务管理/还车应结款 独立预览(无模块内嵌侧栏)
|
||||
* 设计规范对齐:业务管理 · 租赁合同管理(vm-page / lc-page)
|
||||
*/
|
||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||
import React, { useEffect } from 'react';
|
||||
import * as antd from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import '../../resources/oneos-web-legacy/styles/oneos-app.css';
|
||||
import '../../resources/oneos-web-legacy/styles/oneos-tokens.css';
|
||||
import '../../resources/oneos-web-legacy/styles/index.css';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import React, { useEffect } from 'react';
|
||||
import 'antd/dist/reset.css';
|
||||
import '../vehicle-management/style.css';
|
||||
import './styles/index.css';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import ReturnSettlementListPage from '../oneos-web-finance/pages/03-还车应结款.jsx';
|
||||
import ReturnSettlementApp from './VehicleReturnSettlementApp.jsx';
|
||||
|
||||
const oneosTheme = {
|
||||
declare global {
|
||||
interface Window {
|
||||
React: typeof React;
|
||||
antd: typeof antd;
|
||||
dayjs: typeof dayjs;
|
||||
}
|
||||
}
|
||||
|
||||
window.React = React;
|
||||
window.antd = antd;
|
||||
window.dayjs = dayjs;
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/** 与租赁合同管理 / 车辆管理主题一致 */
|
||||
const vmTheme = {
|
||||
token: {
|
||||
colorPrimary: '#165dff',
|
||||
colorPrimary: '#32a06e',
|
||||
colorLink: '#32a06e',
|
||||
colorLinkHover: '#3fb87c',
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
colorText: '#18181b',
|
||||
colorTextSecondary: '#52525b',
|
||||
colorBorder: '#e5e7eb',
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#f6f7f7',
|
||||
headerColor: '#71717a',
|
||||
rowHoverBg: '#f6f7f7',
|
||||
borderColor: '#e5e7eb',
|
||||
cellPaddingBlock: 6,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
DatePicker: {
|
||||
cellActiveWithRangeBg: 'rgba(50, 160, 110, 0.12)',
|
||||
cellHoverWithRangeBg: 'rgba(50, 160, 110, 0.08)',
|
||||
cellRangeBorderColor: '#32a06e',
|
||||
activeBorderColor: '#32a06e',
|
||||
hoverBorderColor: '#3fb87c',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,8 +71,8 @@ export default function VehicleReturnSettlement() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={oneosTheme}>
|
||||
<ReturnSettlementListPage />
|
||||
<ConfigProvider theme={vmTheme} locale={zhCN}>
|
||||
<ReturnSettlementApp />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
355
src/prototypes/vehicle-return-settlement/styles/index.css
Normal file
355
src/prototypes/vehicle-return-settlement/styles/index.css
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 还车应结款 — 对齐租赁合同管理(vm-page + lc-page + ledger-page)
|
||||
*/
|
||||
@import url('../../ledger-shared/styles/ledger-page.css');
|
||||
@import url('../../lease-contract-management/styles/lease-contract.css');
|
||||
|
||||
.vm-page.vrs-page.lc-page--list-dense {
|
||||
--lc-list-chrome-offset: 380px;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td.vm-stacked-cell-td {
|
||||
white-space: normal !important;
|
||||
overflow: visible !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-action-links {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-action-links .ant-btn-link {
|
||||
padding-inline: 0;
|
||||
height: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-dept-tag-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-dept-tag-row .vm-status {
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
line-height: 18px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 提交情况标签色与表头图例圆点一致 */
|
||||
.vm-page.vrs-page .vrs-dept-tag-row .vm-status-blue {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-submit-legend-dot--amber { background: #d97706; }
|
||||
.vm-page.vrs-page .vrs-submit-legend-dot--blue { background: #2563eb; }
|
||||
.vm-page.vrs-page .vrs-submit-legend-dot--green { background: #27a644; }
|
||||
.vm-page.vrs-page .vrs-submit-legend-dot--red { background: #e5484d; }
|
||||
|
||||
.vm-page.vrs-page .vrs-submit-th {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-submit-th__title {
|
||||
font-weight: 500;
|
||||
color: var(--ln-ink, #18181b);
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-submit-th__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-submit-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-submit-legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.vm-page.vrs-page .vrs-submit-legend-label {
|
||||
font-size: 10px;
|
||||
color: var(--ln-muted, #71717a);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-settlement-status-col {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-handover-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-handover-btn:hover .vm-stack .primary {
|
||||
color: var(--ln-link, #32a06e);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-handover-btn:focus-visible {
|
||||
outline: 2px solid var(--ln-link, #32a06e);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vm-page.vrs-page .vrs-handover-btn .vm-stack .primary {
|
||||
color: var(--ln-link, #32a06e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* —— 去处理详情页 —— */
|
||||
.vm-page.vrs-handle-page {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.vrs-handle-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vrs-handle-topbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vrs-handle-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs-handle-role-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vrs-handle-role-switch__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vrs-handle-section,
|
||||
.vrs-handle-fee-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vrs-handle-section__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs-handle-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vrs-handle-kpi-card {
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.vrs-handle-kpi-card__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vrs-handle-kpi-card__hint {
|
||||
margin-left: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-link);
|
||||
}
|
||||
|
||||
.vrs-handle-kpi-card__value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vrs-handle-kpi-card__unit {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.vrs-handle-kpi-card--warn .vrs-handle-kpi-card__value { color: #d97706; }
|
||||
.vrs-handle-kpi-card--good .vrs-handle-kpi-card__value { color: #16a34a; }
|
||||
.vrs-handle-kpi-card--danger .vrs-handle-kpi-card__value { color: #dc2626; }
|
||||
|
||||
.vrs-handle-dept-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.vrs-handle-dept-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vrs-handle-dept-head__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs-handle-dept-head__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vrs-handle-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.vrs-handle-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vrs-handle-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vrs-handle-field__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.vrs-handle-field__value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs-handle-dept-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.vrs-handle-subtitle {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs-handle-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--ln-muted);
|
||||
background: var(--ln-canvas-soft);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.vrs-handle-vehicle-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.vrs-handle-vehicle-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-body);
|
||||
}
|
||||
|
||||
.vrs-handle-vehicle-compact__plate {
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.vrs-handle-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: var(--ln-surface-card);
|
||||
border-top: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.vrs-handle-kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vrs-handle-kpi-row,
|
||||
.vrs-handle-field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,45 @@ import { DateRangeFilterField } from '../vehicle-management/components/DateRange
|
||||
- 样式类名:`vm-date-range-field`、`vm-filter-picker-control`、`vm-date-range-popover`(见 `vehicle-management/style.css`)
|
||||
- 禁止在筛选区使用 Ant Design `RangePicker`、原生 `<input type="date">` 双框或其它自定义日期区间控件
|
||||
|
||||
## 页面壳层(强制)
|
||||
|
||||
侧栏 / 导航已标明当前模块名称时,**列表页正文区不再重复放页面大标题与说明段落**。
|
||||
|
||||
### 禁止
|
||||
|
||||
- `*-page-header` 内独立的 `h1` 页面标题 + 副标题 / 说明 `p`(如「租赁业务明细」+ 功能描述)
|
||||
- 与侧栏菜单文案重复的 `vm-page-title`、`ldb-page-title`、`lbd-page-title` 等
|
||||
|
||||
### 推荐结构
|
||||
|
||||
```text
|
||||
vm-page
|
||||
├── vm-filter-card # 筛选区(标题用 vm-filter-title「筛选条件」即可)
|
||||
├── KPI 行(可选)
|
||||
└── vm-table-section # 工具栏 + 表格 + 分页
|
||||
```
|
||||
|
||||
页面定位、目标用户与验收说明写入 **annotation / PRD**,不占用列表首屏垂直空间。
|
||||
|
||||
例外:详情页、创建页、带面包屑 + 返回的编辑壳层,可按需保留标题区;**标准台账 / 数据列表页默认不加**。
|
||||
|
||||
## 列表工具栏(强制)
|
||||
|
||||
当页面已有 **KPI 卡片**和/或 **底部分页 `TablePagination`(含「共 x 条」)** 时,工具栏 **禁止** 再展示总条数文案。
|
||||
|
||||
### 禁止
|
||||
|
||||
- `当前列表 N 条`、`共 N 条`、`共 N 条记录` 等重复计数(类名如 `*-table-toolbar-meta`、`*-toolbar-data-count`)
|
||||
- 工具栏左侧仅用于占位的条数摘要
|
||||
|
||||
### 允许
|
||||
|
||||
- 筛选开关、图例、字段说明、批量操作按钮
|
||||
- 与条数无关的上下文(如「保单录入」分区标题)
|
||||
- 导出 Toast 内可带条数(操作反馈,非常驻 UI)
|
||||
|
||||
工具栏默认 **右对齐操作按钮**(`vm-table-actions` + `margin-left: auto`);左侧仅在有筛选开关等非计数控件时使用。
|
||||
|
||||
## 表格底部分页(强制)
|
||||
|
||||
凡带数据表格的列表页,**一律使用底部分页**,禁止「下拉加载更多」「无限滚动」替代分页(除非产品需求明确排除分页且已在 PRD 写明)。
|
||||
@@ -207,6 +246,7 @@ const paged = useMemo(() => {
|
||||
- 不引入与 Linear token 冲突的第三方分页皮肤
|
||||
- 不在 footer 混用多种分页交互(分页 + 加载更多并存)
|
||||
- 不在 footer 重复写总条数(须由 `TablePagination` 统一输出)
|
||||
- 不在列表工具栏重复写总条数(KPI / 分页已展示时)
|
||||
- 不在 `vm-page` 内为表格/工具栏多选框自定义 checkbox 皮肤(须用 `vm-checkbox` 或勾选列自动样式)
|
||||
- 不覆盖 `vm-ops-picker-option` 内多选框的紧凑样式
|
||||
|
||||
|
||||
@@ -2279,7 +2279,6 @@ const Component = function () {
|
||||
|
||||
React.createElement('div', { className: 'lc-table-section' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar-meta' }, '共 ', React.createElement('strong', null, String(visibleRows.length)), ' 条记录'),
|
||||
React.createElement('div', { className: 'lc-table-toolbar-actions' },
|
||||
!isSupervisor ? React.createElement(Button, { icon: LL_ICONS.upload, style: LL_OUTLINE_BTN, onClick: openImportModal }, '批量导入') : null,
|
||||
React.createElement(Button, {
|
||||
|
||||
@@ -3010,9 +3010,6 @@ const Component = function () {
|
||||
|
||||
React.createElement('div', { className: 'lc-table-section' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar' },
|
||||
React.createElement('div', { className: 'lc-table-toolbar-meta' },
|
||||
'共 ', React.createElement('strong', null, String(listTotal)), ' 条记录'
|
||||
),
|
||||
React.createElement('div', { className: 'lc-table-toolbar-actions' },
|
||||
React.createElement(Button, { icon: VM_ICONS.export, onClick: handleExport, style: { borderRadius: 8 } }, '导出'),
|
||||
React.createElement(Button, { icon: VM_ICONS.upload, onClick: handleBatchImport, style: { borderRadius: 8 } }, '批量导入')
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 文档版本 | v2.2(业务版) |
|
||||
| 文档版本 | v2.3(业务版) |
|
||||
| 产品模块 | 台账数据 → 车辆氢费明细 |
|
||||
| 文档类型 | 产品需求说明 |
|
||||
| 适用读者 | 产品、业务、运营、测试、项目 |
|
||||
| 修订说明 | 同步客户名称校验图标(正确/错误)、一键替换权限、标注目录 PRD 全文与页面需求说明 |
|
||||
| 修订说明 | 同步成本单价校验图标与一键替换、固定/阶梯定价标签及与加氢站价格配置取价逻辑 |
|
||||
|
||||
---
|
||||
|
||||
@@ -193,7 +193,7 @@ flowchart LR
|
||||
| 客户名称 | 是* | 必选,位于加氢站右侧;决定业务员与系统带出信息 |
|
||||
| 车牌号 | 是* | 必选,须为公司登记车辆 |
|
||||
| 加氢量(kg) | 是* | 必填,用于算总价 |
|
||||
| 成本单价 | 是* | 必填,向站点的采购成本价 |
|
||||
| 成本单价 | 是* | 必填;单价前展示「固定/阶梯」标签;与站点标准成本价校验(含一键替换) |
|
||||
| 成本总价 | 否 | 加氢量 × 成本单价,自动计算 |
|
||||
| 加氢单价 | 是* | 必填,对客户的销售单价 |
|
||||
| 加氢总价 | 否 | 加氢量 × 加氢单价,自动计算 |
|
||||
@@ -227,6 +227,19 @@ flowchart LR
|
||||
|
||||
任一项未填齐时,对应总价为空,填齐后自动更新。
|
||||
|
||||
### 5.4 成本单价与站点定价方式
|
||||
|
||||
成本单价来源于加氢站管理中的价格配置,列表中单价的展示与校验规则如下:
|
||||
|
||||
- 单价输入框左侧展示 **固定** 或 **阶梯** 小标签,对应站点对该客户(或默认「全部客户」)的定价方式。
|
||||
- **固定价格**:标准成本价为配置的单一单价(在生效时间内)。
|
||||
- **阶梯价格**:标准成本价按当前阶梯周期内**累计加氢量**匹配档位;悬浮「阶梯」标签可查看**当前阶梯量**(kg)与**对应单价**(元/kg)。
|
||||
- 录入单价与标准成本价不一致时,单元格标黄并显示校验图标;未对账数据可由创建人或主管 **一键替换为标准成本单价**。
|
||||
|
||||
**取价优先级**(与加氢站管理「站点信息」一致):第一权重为**单站单客户**成本价配置;第二权重为站点**全部客户**成本价配置。
|
||||
|
||||
关联模块:加氢站管理 → 站点信息(`/prototypes/oneos-web-h2-station-site`)的固定价 / 阶梯价配置。
|
||||
|
||||
---
|
||||
|
||||
## 六、谁能改、谁能删
|
||||
@@ -263,9 +276,32 @@ flowchart LR
|
||||
| 成本单价异常 | 所填成本单价与「该站该时段公司标准成本价」不一致 | 成本单价与系统该时间段不一致 |
|
||||
| 加氢单价异常 | 所填加氢单价与「该站该时段公司标准对客户价」不一致 | 加氢单价与系统该时间段不一致 |
|
||||
|
||||
标准价随 **加氢站 + 加氢时间** 匹配;若公司未配置该站该时段价格,则不提示单价类异常。
|
||||
标准成本价随 **加氢站 + 加氢时间 + 客户** 匹配(固定价取配置单价;阶梯价按周期累计加氢量匹配档位,规则与加氢站管理一致);标准客户价仍按 **加氢站 + 加氢时间** 匹配。若未配置对应价格,则不提示单价类异常。
|
||||
|
||||
### 7.2 整行标红(客户名称异常)
|
||||
### 7.2 成本单价列 — 定价标签与校验
|
||||
|
||||
**定价方式标签**(已选加氢站且可解析价格配置时,在单价左侧展示):
|
||||
|
||||
| 标签 | 含义 |
|
||||
|------|------|
|
||||
| 固定 | 站点对该客户(或站点默认「全部客户」)为**固定成本价** |
|
||||
| 阶梯 | 站点对该客户为**阶梯成本价**;悬浮显示**当前阶梯量**(kg)与**对应单价**(元/kg) |
|
||||
|
||||
**成本单价列状态图标**(**所有登录用户均可见**,不因业务员/主管角色隐藏):
|
||||
|
||||
| 校验结果 | 图标 | 悬浮提示 |
|
||||
|----------|------|----------|
|
||||
| 与标准成本价一致 | 绿色 ✓ | 「成本单价正确」+ 标准价说明(含固定价或阶梯价口径) |
|
||||
| 与标准成本价不一致 | 红色 ⚠ | 「成本单价异常」+ 标准价说明 |
|
||||
| 无法校验(未填单价、未选站点、未配置标准价等) | 无 | — |
|
||||
|
||||
**一键替换**(仅与标准成本价不一致且数据**未对账**):
|
||||
|
||||
- 错误提示框内提供「**一键替换为标准成本单价**」
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以标准成本价覆盖录入值,并写入变更日志(字段:**成本单价(一键替换)**)
|
||||
|
||||
### 7.3 整行标红(客户名称异常)
|
||||
|
||||
按 **车牌 + 加氢日期** 比对车辆运营态(交还车/库存/异动):
|
||||
|
||||
@@ -295,7 +331,7 @@ flowchart LR
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以系统登记客户覆盖录入值,并写入变更日志(字段:客户名称(一键替换))
|
||||
|
||||
### 7.3 「仅显示异常数据」
|
||||
### 7.4 「仅显示异常数据」
|
||||
|
||||
- 默认关闭,展示符合查询条件的全部记录(含待保存例外规则)。
|
||||
- 开启后只显示至少有一项异常(标黄或客户整行标红)的记录,方便集中核对。
|
||||
@@ -351,6 +387,7 @@ flowchart LR
|
||||
- 保存(待保存 → 未对账)
|
||||
- 完成对账(未对账 → 已对账)
|
||||
- 各业务字段修改(前后值不同)
|
||||
- **成本单价(一键替换)**、**客户名称(一键替换)**
|
||||
- 新增、复制、批量导入产生记录
|
||||
|
||||
### 9.2 怎么查看
|
||||
@@ -413,6 +450,11 @@ flowchart LR
|
||||
| U15 | 异动期客户为「车辆异动」 | 不标红,客户名称后绿色 ✓,悬浮说明异动期规则 |
|
||||
| U16 | 异动期客户填其他名称 | 整行标红、水印「客户疑似有误」,红色 ⚠,提示应为「车辆异动」 |
|
||||
| U17 | 客户名称异常行完成对账 | 整行标红/水印消失,红色 ⚠ 仍可见;「仅显示异常数据」筛不出该行 |
|
||||
| U18 | 成本单价与标准价一致 | 成本单价后显示绿色 ✓,悬浮「成本单价正确」 |
|
||||
| U19 | 成本单价与标准价不一致 | 单元格标黄 + 红色 ⚠;创建人或主管可一键替换为标准成本单价并留痕 |
|
||||
| U20 | 非创建人查看成本单价不一致行 | 可见红色 ⚠ 与说明,无「一键替换」按钮 |
|
||||
| U21 | 固定价站点行 | 成本单价前显示「固定」标签 |
|
||||
| U22 | 阶梯价客户行 | 显示「阶梯」标签;悬浮可见当前阶梯量与对应单价 |
|
||||
|
||||
---
|
||||
|
||||
@@ -430,7 +472,9 @@ flowchart LR
|
||||
| 加氢站付款状态 | 加氢站侧是否已付款,已付款 / 未付款 |
|
||||
| 客户收款状态 | 客户侧是否已收款,已付款 / 未付款 |
|
||||
| 收票日期 | 财务收票日期,系统带出 |
|
||||
| 标准价 | 公司维护的、按加氢站与生效时段确定的参考单价 |
|
||||
| 标准价 | 公司维护的、按加氢站与生效时段(及客户)确定的参考单价 |
|
||||
| 固定价格 | 加氢站对全部客户或指定客户配置的单一成本单价 |
|
||||
| 阶梯价格 | 按阶梯周期累计加氢量分档确定成本单价的规则 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 文档版本 | v2.2(业务版) |
|
||||
| 文档版本 | v2.3(业务版) |
|
||||
| 产品模块 | 台账数据 → 车辆氢费明细 |
|
||||
| 文档类型 | 产品需求说明 |
|
||||
| 适用读者 | 产品、业务、运营、测试、项目 |
|
||||
| 修订说明 | 同步客户名称校验图标(正确/错误)、一键替换权限、标注目录 PRD 全文与页面需求说明 |
|
||||
| 修订说明 | 同步成本单价校验图标与一键替换、固定/阶梯定价标签及与加氢站价格配置取价逻辑 |
|
||||
|
||||
---
|
||||
|
||||
@@ -193,7 +193,7 @@ flowchart LR
|
||||
| 客户名称 | 是* | 必选,位于加氢站右侧;决定业务员与系统带出信息 |
|
||||
| 车牌号 | 是* | 必选,须为公司登记车辆 |
|
||||
| 加氢量(kg) | 是* | 必填,用于算总价 |
|
||||
| 成本单价 | 是* | 必填,向站点的采购成本价 |
|
||||
| 成本单价 | 是* | 必填;单价前展示「固定/阶梯」标签;与站点标准成本价校验(含一键替换) |
|
||||
| 成本总价 | 否 | 加氢量 × 成本单价,自动计算 |
|
||||
| 加氢单价 | 是* | 必填,对客户的销售单价 |
|
||||
| 加氢总价 | 否 | 加氢量 × 加氢单价,自动计算 |
|
||||
@@ -227,6 +227,19 @@ flowchart LR
|
||||
|
||||
任一项未填齐时,对应总价为空,填齐后自动更新。
|
||||
|
||||
### 5.4 成本单价与站点定价方式
|
||||
|
||||
成本单价来源于加氢站管理中的价格配置,列表中单价的展示与校验规则如下:
|
||||
|
||||
- 单价输入框左侧展示 **固定** 或 **阶梯** 小标签,对应站点对该客户(或默认「全部客户」)的定价方式。
|
||||
- **固定价格**:标准成本价为配置的单一单价(在生效时间内)。
|
||||
- **阶梯价格**:标准成本价按当前阶梯周期内**累计加氢量**匹配档位;悬浮「阶梯」标签可查看**当前阶梯量**(kg)与**对应单价**(元/kg)。
|
||||
- 录入单价与标准成本价不一致时,单元格标黄并显示校验图标;未对账数据可由创建人或主管 **一键替换为标准成本单价**。
|
||||
|
||||
**取价优先级**(与加氢站管理「站点信息」一致):第一权重为**单站单客户**成本价配置;第二权重为站点**全部客户**成本价配置。
|
||||
|
||||
关联模块:加氢站管理 → 站点信息(`/prototypes/oneos-web-h2-station-site`)的固定价 / 阶梯价配置。
|
||||
|
||||
---
|
||||
|
||||
## 六、谁能改、谁能删
|
||||
@@ -263,9 +276,32 @@ flowchart LR
|
||||
| 成本单价异常 | 所填成本单价与「该站该时段公司标准成本价」不一致 | 成本单价与系统该时间段不一致 |
|
||||
| 加氢单价异常 | 所填加氢单价与「该站该时段公司标准对客户价」不一致 | 加氢单价与系统该时间段不一致 |
|
||||
|
||||
标准价随 **加氢站 + 加氢时间** 匹配;若公司未配置该站该时段价格,则不提示单价类异常。
|
||||
标准成本价随 **加氢站 + 加氢时间 + 客户** 匹配(固定价取配置单价;阶梯价按周期累计加氢量匹配档位,规则与加氢站管理一致);标准客户价仍按 **加氢站 + 加氢时间** 匹配。若未配置对应价格,则不提示单价类异常。
|
||||
|
||||
### 7.2 整行标红(客户名称异常)
|
||||
### 7.2 成本单价列 — 定价标签与校验
|
||||
|
||||
**定价方式标签**(已选加氢站且可解析价格配置时,在单价左侧展示):
|
||||
|
||||
| 标签 | 含义 |
|
||||
|------|------|
|
||||
| 固定 | 站点对该客户(或站点默认「全部客户」)为**固定成本价** |
|
||||
| 阶梯 | 站点对该客户为**阶梯成本价**;悬浮显示**当前阶梯量**(kg)与**对应单价**(元/kg) |
|
||||
|
||||
**成本单价列状态图标**(**所有登录用户均可见**,不因业务员/主管角色隐藏):
|
||||
|
||||
| 校验结果 | 图标 | 悬浮提示 |
|
||||
|----------|------|----------|
|
||||
| 与标准成本价一致 | 绿色 ✓ | 「成本单价正确」+ 标准价说明(含固定价或阶梯价口径) |
|
||||
| 与标准成本价不一致 | 红色 ⚠ | 「成本单价异常」+ 标准价说明 |
|
||||
| 无法校验(未填单价、未选站点、未配置标准价等) | 无 | — |
|
||||
|
||||
**一键替换**(仅与标准成本价不一致且数据**未对账**):
|
||||
|
||||
- 错误提示框内提供「**一键替换为标准成本单价**」
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以标准成本价覆盖录入值,并写入变更日志(字段:**成本单价(一键替换)**)
|
||||
|
||||
### 7.3 整行标红(客户名称异常)
|
||||
|
||||
按 **车牌 + 加氢日期** 比对车辆运营态(交还车/库存/异动):
|
||||
|
||||
@@ -295,7 +331,7 @@ flowchart LR
|
||||
- **可操作角色**:该行**创建人(当前操作员)**或**主管(管理员)**;其他用户可见图标与说明,但不显示替换按钮
|
||||
- 点击后以系统登记客户覆盖录入值,并写入变更日志(字段:客户名称(一键替换))
|
||||
|
||||
### 7.3 「仅显示异常数据」
|
||||
### 7.4 「仅显示异常数据」
|
||||
|
||||
- 默认关闭,展示符合查询条件的全部记录(含待保存例外规则)。
|
||||
- 开启后只显示至少有一项异常(标黄或客户整行标红)的记录,方便集中核对。
|
||||
@@ -351,6 +387,7 @@ flowchart LR
|
||||
- 保存(待保存 → 未对账)
|
||||
- 完成对账(未对账 → 已对账)
|
||||
- 各业务字段修改(前后值不同)
|
||||
- **成本单价(一键替换)**、**客户名称(一键替换)**
|
||||
- 新增、复制、批量导入产生记录
|
||||
|
||||
### 9.2 怎么查看
|
||||
@@ -413,6 +450,11 @@ flowchart LR
|
||||
| U15 | 异动期客户为「车辆异动」 | 不标红,客户名称后绿色 ✓,悬浮说明异动期规则 |
|
||||
| U16 | 异动期客户填其他名称 | 整行标红、水印「客户疑似有误」,红色 ⚠,提示应为「车辆异动」 |
|
||||
| U17 | 客户名称异常行完成对账 | 整行标红/水印消失,红色 ⚠ 仍可见;「仅显示异常数据」筛不出该行 |
|
||||
| U18 | 成本单价与标准价一致 | 成本单价后显示绿色 ✓,悬浮「成本单价正确」 |
|
||||
| U19 | 成本单价与标准价不一致 | 单元格标黄 + 红色 ⚠;创建人或主管可一键替换为标准成本单价并留痕 |
|
||||
| U20 | 非创建人查看成本单价不一致行 | 可见红色 ⚠ 与说明,无「一键替换」按钮 |
|
||||
| U21 | 固定价站点行 | 成本单价前显示「固定」标签 |
|
||||
| U22 | 阶梯价客户行 | 显示「阶梯」标签;悬浮可见当前阶梯量与对应单价 |
|
||||
|
||||
---
|
||||
|
||||
@@ -430,7 +472,9 @@ flowchart LR
|
||||
| 加氢站付款状态 | 加氢站侧是否已付款,已付款 / 未付款 |
|
||||
| 客户收款状态 | 客户侧是否已收款,已付款 / 未付款 |
|
||||
| 收票日期 | 财务收票日期,系统带出 |
|
||||
| 标准价 | 公司维护的、按加氢站与生效时段确定的参考单价 |
|
||||
| 标准价 | 公司维护的、按加氢站与生效时段(及客户)确定的参考单价 |
|
||||
| 固定价格 | 加氢站对全部客户或指定客户配置的单一成本单价 |
|
||||
| 阶梯价格 | 按阶梯周期累计加氢量分档确定成本单价的规则 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
64
vite-plugins/prototypeNavRegistryPlugin.ts
Normal file
64
vite-plugins/prototypeNavRegistryPlugin.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
const NAV_PROTOTYPE_ID = 'oneos-prototype-nav';
|
||||
const SYNC_SCRIPT = 'scripts/sync-prototype-nav-registry.mjs';
|
||||
const DEBOUNCE_MS = 900;
|
||||
|
||||
function extractPrototypeId(filePath: string, projectRoot: string): string | null {
|
||||
const relative = path.relative(projectRoot, filePath).split(path.sep).join('/');
|
||||
const match = relative.match(/^src\/prototypes\/([^/]+)\//u);
|
||||
if (!match) return null;
|
||||
const prototypeId = match[1];
|
||||
if (prototypeId === NAV_PROTOTYPE_ID) return null;
|
||||
if (relative.includes('prototype-registry.json') || relative.includes('nav-menu.json')) return null;
|
||||
return prototypeId;
|
||||
}
|
||||
|
||||
export function createPrototypeNavRegistryPlugin(projectRoot = process.cwd()): Plugin {
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let running = false;
|
||||
let queuedPrototypeId: string | null = null;
|
||||
|
||||
const runSync = (prototypeId: string | null) => {
|
||||
if (!prototypeId) return;
|
||||
queuedPrototypeId = prototypeId;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
const targetId = queuedPrototypeId;
|
||||
queuedPrototypeId = null;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(projectRoot, SYNC_SCRIPT),
|
||||
'--prototype',
|
||||
targetId || '',
|
||||
'--quiet',
|
||||
'--skip-directory-sync',
|
||||
`--project-root=${projectRoot}`,
|
||||
],
|
||||
{ cwd: projectRoot, stdio: 'ignore' },
|
||||
);
|
||||
child.on('exit', () => {
|
||||
running = false;
|
||||
if (queuedPrototypeId) runSync(queuedPrototypeId);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'axhub-prototype-nav-registry',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
const handle = (filePath: string) => {
|
||||
const prototypeId = extractPrototypeId(filePath, projectRoot);
|
||||
if (prototypeId) runSync(prototypeId);
|
||||
};
|
||||
server.watcher.on('change', handle);
|
||||
server.watcher.on('add', handle);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { websocketPlugin } from './vite-plugins/websocketPlugin';
|
||||
import { canvasHotUpdateFilterPlugin } from './vite-plugins/canvasHotUpdateFilter';
|
||||
import { annotationRuntimeOptimizeDepsPlugin } from './vite-plugins/annotationRuntimeOptimizeDeps';
|
||||
import { createAnnotationSourceMarkdownPlugin } from './vite-plugins/annotationSourceMarkdown';
|
||||
import { createPrototypeNavRegistryPlugin } from './vite-plugins/prototypeNavRegistryPlugin';
|
||||
import {
|
||||
MAKE_CONFIG_RELATIVE_PATH,
|
||||
MAKE_ENTRIES_RELATIVE_PATH,
|
||||
@@ -82,6 +83,7 @@ export default defineConfig(({ command }) => {
|
||||
isServe ? autoStartMakeServerPlugin() : null,
|
||||
isServe ? websocketPlugin() : null,
|
||||
isServe ? clientPreviewPlugin() : null,
|
||||
isServe ? createPrototypeNavRegistryPlugin(projectRoot) : null,
|
||||
createAnnotationSourceMarkdownPlugin(projectRoot, { mode: isServe ? 'serve' : 'build' }),
|
||||
forceInlineDynamicImportsOff(isIifeBuild),
|
||||
isIifeBuild ? axhubComponentEnforcer(jsEntries[entryKey as string]) : null,
|
||||
|
||||
Reference in New Issue
Block a user