Compare commits

...

3 Commits

Author SHA1 Message Date
王冕
ff4b7b67dc feat(autorodo-web): add AutoRDO paste-confirm-export workbench
Enable PM clipboard round-trip: multimodal paste → AutoRDO clean prompt → pending Q&A with meta → Yunxiao multi-record prompt copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 01:06:22 +08:00
王冕
cf82f25611 checkpoint before checking out feat/message-hub 2026-07-25 00:39:20 +08:00
王冕
b14425da5a feat(vehicle-return-settlement): 运维办理人转交与审批中费用明细只读
支持办理人/主管转交运维段任务并记录原因;待审批与审批中可进费用明细但运维费用只读,审批完成不可进入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 17:52:40 +08:00
41 changed files with 5143 additions and 45 deletions

View File

@@ -262,6 +262,12 @@
"title": "业务条线说明",
"itemKey": "prototypes/lease-business-line-overview"
},
{
"id": "item:prototypes:autorodo-web",
"kind": "item",
"title": "AutoRDO 需求清洗工作台",
"itemKey": "prototypes/autorodo-web"
},
{
"id": "item:prototypes:yunxiao-pipeline-handbook",
"kind": "item",

View File

@@ -0,0 +1,414 @@
# 还车应结款 · 运维转交 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在还车应结款运维段增加「转交办理人」(办理人主动转 + 主管代转),并放开待审批/审批中费用明细只读查看。
**Architecture:** 列表仍在 `03-还车应结款.jsx`;「去处理」进入 `VehicleReturnSettlementHandle.jsx``VehicleReturnSettlementApp.jsx` 持有 `opsHandlerByKey` / `opsTransferLogsByKey` 覆盖层,转交后回写列表办理人展示。演示身份用 Handle 页顶栏切换(办理人 / 主管 / 其他人)。
**Tech Stack:** React + Ant DesignModal / Select / Input.TextArea / Button / Drawer本地种子人员清单无真实 API。
**Spec:** [docs/superpowers/specs/2026-07-22-vehicle-return-settlement-ops-transfer-design.md](../specs/2026-07-22-vehicle-return-settlement-ops-transfer-design.md)
---
## File map
| Path | Responsibility |
|------|----------------|
| `src/prototypes/vehicle-return-settlement/ops-transfer-demo.js` | 运维同事种子、演示身份、转交校验纯函数 |
| `src/prototypes/vehicle-return-settlement/VehicleReturnSettlementApp.jsx` | 覆盖层状态;把 row + callbacks 传给 Handle |
| `src/prototypes/vehicle-return-settlement/VehicleReturnSettlementHandle.jsx` | 转交弹窗、记录抽屉、审批中运维只读、身份切换 |
| `src/prototypes/oneos-web-finance/pages/03-还车应结款.jsx` | `opsHandler` 种子、`resolveDeptHandler`、费用明细入口规则 |
| `src/prototypes/vehicle-return-settlement/.spec/ops-transfer.md` | 业务判定规格 |
| `src/prototypes/vehicle-return-settlement/.spec/requirements-prd.md` | AutoPRD |
| `src/prototypes/vehicle-return-settlement/annotation-source.json` | 标注目录 PRD 节点 |
| `src/resources/prd/vehicle-return-settlement-autoprd.md` | 资源侧 PRD 副本 |
---
### Task 1: 转交校验与演示数据模块
**Files:**
- Create: `src/prototypes/vehicle-return-settlement/ops-transfer-demo.js`
- [ ] **Step 1: 新增种子与纯函数**
```js
/** 运维转交 · 原型演示数据与校验(未接真实 API */
export var OPS_COLLEAGUES = [
{ id: 'ops-chen', name: '陈还车', active: true },
{ id: 'ops-zhou', name: '周还车', active: true },
{ id: 'ops-liu', name: '刘还车', active: true },
{ id: 'ops-wang', name: '王五', active: true },
{ id: 'ops-left', name: '已离职-赵工', active: false }
];
export var DEMO_IDENTITIES = [
{ id: 'handler', label: '当前办理人' },
{ id: 'supervisor', label: '运维主管' },
{ id: 'other', label: '其他运维(无权限)' }
];
export function canInitiateOpsTransfer(identityId, currentHandlerName, viewerName) {
if (identityId === 'supervisor') return true;
if (identityId === 'handler') return true;
return false;
}
export function isWholeOrderTransferBlocked(approvalStatus) {
return String(approvalStatus || '') === '审批完成';
}
/** 整单待审批/审批中:运维费用只读(不可保存/提交/撤回) */
export function isOpsFeeReadOnly(approvalStatus) {
var st = String(approvalStatus || '');
return st === '待审批' || st === '审批中';
}
export function listTransferCandidates(colleagues, currentHandlerName) {
return (colleagues || []).filter(function (p) {
return p.active && p.name !== currentHandlerName;
});
}
export function validateOpsTransfer(input) {
var errors = [];
if (!input || !input.toName) errors.push('请选择新办理人');
if (!input || !String(input.reason || '').trim()) errors.push('请填写转交原因');
if (input && input.toName && input.toName === input.fromName) errors.push('不能转交给自己');
if (input && isWholeOrderTransferBlocked(input.approvalStatus)) errors.push('审批完成后不可转交');
return errors;
}
export function buildTransferLog(input) {
return {
at: input.at || new Date().toISOString().slice(0, 16).replace('T', ' '),
operator: input.operator,
fromName: input.fromName,
toName: input.toName,
reason: String(input.reason || '').trim(),
opsStatus: input.opsStatus || '',
approvalStatus: input.approvalStatus || ''
};
}
```
- [ ] **Step 2: 手动核验校验函数Node 一行)**
Run:
```bash
node -e "
const m = require('./src/prototypes/vehicle-return-settlement/ops-transfer-demo.js');
console.log(m.validateOpsTransfer({ fromName:'陈还车', toName:'周还车', reason:'离职', approvalStatus:'审批中' }));
console.log(m.validateOpsTransfer({ fromName:'陈还车', toName:'陈还车', reason:'x', approvalStatus:'待提交' }));
console.log(m.isOpsFeeReadOnly('审批中'), m.isWholeOrderTransferBlocked('审批完成'));
"
```
Expected: 第一行 `[]`;第二行含「不能转交给自己」;第三行 `true true`
若该包为 ESM 无 `require`,改为在文件末尾临时加 `typeof module !== 'undefined' && (module.exports = {...})` 双端导出,或用 `node --input-type=module -e "import(...)"`
- [ ] **Step 3: Commit仅当用户要求提交时执行**
```bash
git add src/prototypes/vehicle-return-settlement/ops-transfer-demo.js
git commit -m "$(cat <<'EOF'
feat(vehicle-return-settlement): add ops transfer demo helpers
EOF
)"
```
---
### Task 2: 列表 — opsHandler 与费用明细入口
**Files:**
- Modify: `src/prototypes/oneos-web-finance/pages/03-还车应结款.jsx`
- [ ] **Step 1: 列表组件接收覆盖层 props**
在列表 `Component` 顶部读取:
```js
var opsHandlerByKey = (props && props.opsHandlerByKey) || {};
```
- [ ] **Step 2: 种子行增加 `opsHandler`**
每条 mock 行增加:
```js
opsHandler: '<与该行 returnPerson 相同的姓名>',
opsTransferLogs: []
```
例如 `returnPerson: '陈还车'` 的行写 `opsHandler: '陈还车'`
- [ ] **Step 3: 改 `resolveDeptHandler`**
```js
case 'submitOperation':
return displayCellText(
(row.key && opsHandlerByKey[row.key]) || row.opsHandler || row.returnPerson
);
```
注意:`resolveDeptHandler` 若在 `useMemo`/闭包外,需能读到最新 `opsHandlerByKey`(不要写死进仅挂载一次的 columns memocolumns 的 `useMemo` 依赖补上 `opsHandlerByKey`)。
- [ ] **Step 4: 改费用明细入口可见性**
将操作列中:
```js
var showFeeDetail = !(st === '待审批' || st === '审批中' || st === '审批完成');
```
改为:
```js
var showFeeDetail = st !== '审批完成';
```
同步改文件内嵌需求注释/说明字符串里「待审批、审批中、审批完成时不显示费用明细」为:「审批完成时不显示费用明细;待审批/审批中可进入查看,运维段不可编辑」。
- [ ] **Step 5: 浏览器验收列表**
打开 `/prototypes/vehicle-return-settlement`
1. 找审批状态为「待审批」或「审批中」的行 → 操作里应有「去处理」
2. 「审批完成」行 → 无「去处理」
3. 运维组标签办理人显示为 `opsHandler` / `returnPerson`
- [ ] **Step 6: Commit用户要求时**
---
### Task 3: App 覆盖层打通列表 ↔ Handle
**Files:**
- Modify: `src/prototypes/vehicle-return-settlement/VehicleReturnSettlementApp.jsx`
- [ ] **Step 1: 增加覆盖层 state 并下发**
```jsx
var _handlers = useState({});
var opsHandlerByKey = _handlers[0];
var setOpsHandlerByKey = _handlers[1];
var _logs = useState({});
var opsTransferLogsByKey = _logs[0];
var setOpsTransferLogsByKey = _logs[1];
function applyOpsTransfer(rowKey, payload) {
setOpsHandlerByKey(function (p) {
var n = {}; for (var k in p) n[k] = p[k];
n[rowKey] = payload.toName;
return n;
});
setOpsTransferLogsByKey(function (p) {
var n = {}; for (var k in p) n[k] = p[k];
var prev = n[rowKey] ? n[rowKey].slice() : [];
prev.unshift(payload.log);
n[rowKey] = prev;
return n;
});
}
// list:
React.createElement(ReturnSettlementListPage, {
opsHandlerByKey: opsHandlerByKey,
onGoHandle: function (row) {
setSelectedRow(row || null);
setView('handle');
}
});
// handle:
React.createElement(VehicleReturnSettlementHandle, {
row: selectedRow,
opsHandler: selectedRow && (opsHandlerByKey[selectedRow.key] || selectedRow.opsHandler || selectedRow.returnPerson),
opsTransferLogs: selectedRow ? (opsTransferLogsByKey[selectedRow.key] || selectedRow.opsTransferLogs || []) : [],
onOpsTransfer: function (payload) {
if (!selectedRow) return;
applyOpsTransfer(selectedRow.key, payload);
},
onBack: function () {
setSelectedRow(null);
setView('list');
}
});
```
- [ ] **Step 2: 确认进入 Handle 后顶栏能读到办理人姓名**(下一 Task 做 UI
---
### Task 4: Handle 页 — 转交 / 只读 / 记录
**Files:**
- Modify: `src/prototypes/vehicle-return-settlement/VehicleReturnSettlementHandle.jsx`
- Modify: `src/prototypes/vehicle-return-settlement/styles/index.css`(弹窗/记录样式按需)
- [ ] **Step 1: 引入 demo 模块与本地状态**
```js
import {
OPS_COLLEAGUES,
DEMO_IDENTITIES,
canInitiateOpsTransfer,
isWholeOrderTransferBlocked,
isOpsFeeReadOnly,
listTransferCandidates,
validateOpsTransfer,
buildTransferLog
} from './ops-transfer-demo';
```
State`demoIdentity`(默认 `handler`)、`transferOpen``transferTo``transferReason``logsOpen`
`props.row``approvalStatus`;运维段本地 `opsStatus`(待提交/已提交,可由 deptData 初始);`opsHandler` 以 props 为准。
- [ ] **Step 2: 顶栏演示身份切换**
在页头增加 Select`当前办理人` / `运维主管` / `其他运维(无权限)`,便于验收权限。
- [ ] **Step 3: 运维区操作按钮按规则渲染**
```js
var approvalStatus = (props.row && props.row.approvalStatus) || '待提交';
var feeReadOnly = isOpsFeeReadOnly(approvalStatus);
var transferBlocked = isWholeOrderTransferBlocked(approvalStatus);
var canTransfer = !transferBlocked && canInitiateOpsTransfer(demoIdentity);
// header actions:
// - feeReadOnly: 无保存/提交/撤回canTransfer 则显示转交
// - !feeReadOnly && opsStatus===已提交: 撤回 + 转交
// - !feeReadOnly && opsStatus===待提交: 保存 + 提交 + 转交
// 另:始终提供「转交记录」文字按钮(有日志或可空状态)
```
保存/提交在 `feeReadOnly` 时不渲染;表格金额列只读(禁用 Input 或纯文本)。
- [ ] **Step 4: 转交 Modal**
字段:当前办理人只读、新办理人 Select`listTransferCandidates(OPS_COLLEAGUES, opsHandler)`)、原因 TextArea 必填。
确认时:
```js
var errors = validateOpsTransfer({
fromName: opsHandler,
toName: transferTo,
reason: transferReason,
approvalStatus: approvalStatus
});
if (errors.length) { message.error(errors[0]); return; }
var log = buildTransferLog({
operator: demoIdentity === 'supervisor' ? '运维主管' : opsHandler,
fromName: opsHandler,
toName: transferTo,
reason: transferReason,
opsStatus: opsStatus,
approvalStatus: approvalStatus
});
if (typeof props.onOpsTransfer === 'function') {
props.onOpsTransfer({ toName: transferTo, log: log });
}
message.success('转交成功');
setTransferOpen(false);
setTransferTo(undefined);
setTransferReason('');
```
二次确认可用 `Modal.confirm` 包一层,文案:`确认后将由【{transferTo}】继续处理本单运维段`
- [ ] **Step 5: 转交记录 Drawer**
列表展示 `props.opsTransferLogs`:时间、操作人、原→新、原因、运维状态、整单状态。
- [ ] **Step 6: 浏览器验收(对照规格 §7**
1. 身份=办理人,待提交单:转交成功 → 返回列表运维办理人变新人
2. 已提交:可转;转后身份切主管/新人可撤(非审批中时)
3. 身份=其他人:无转交按钮
4. 待审批/审批中:可进明细;运维费用不可编;仍可转交
5. 审批完成:列表无「去处理」
6. 原因空 / 转给自己:报错
- [ ] **Step 7: Commit用户要求时**
---
### Task 5: 业务规格 + AutoPRD + 标注
**Files:**
- Create: `src/prototypes/vehicle-return-settlement/.spec/ops-transfer.md`
- Create: `src/prototypes/vehicle-return-settlement/.spec/requirements-prd.md`
- Create: `src/prototypes/vehicle-return-settlement/annotation-source.json`(若尚无则按 sibling 原型模板)
- Create: `src/resources/prd/vehicle-return-settlement-autoprd.md`
- Modify: Handle/index 挂 `PrototypeAnnotationHost`(若入口尚未挂标注)
- [ ] **Step 1: 写 `.spec/ops-transfer.md`**
从设计规格摘判定表:发起人、接收人、状态、只读、入口、字段 `opsHandler` / `opsTransferLogs`、未接真实 API。含优先级表。
- [ ] **Step 2: 写 `requirements-prd.md`**
按 oneos-autoprd 结构:背景、用户、故事(忙不过来 / 离职代转 / 审批中只读查看)、正逆向、验收;链到 `ops-transfer.md`。**不写**表结构/接口字段代码名(规格文件可写字段名)。
- [ ] **Step 3: annotation-source + resources PRD 同步**
目录节点「产品需求说明PRD」markdown 与 `.spec/requirements-prd.md` 一致;`src/resources/prd/vehicle-return-settlement-autoprd.md` 同文。
- [ ] **Step 4: 导航同步**
```bash
npm run nav:sync -- --prototype vehicle-return-settlement --note "运维部办理人转交;审批中费用明细只读可进"
```
- [ ] **Step 5: Commit用户要求时**
---
### Task 6: 不纳入本期(显式跳过)
- `03-还车应结款.jsx``ComponentFeeDetail` 历史残留页:不接转交(入口未使用)
- 列表批量转交、操作列转交
- 其他部门转交
---
## Spec coverage
| Spec 节 | Task |
|---------|------|
| §3.1 发起人 | 1 `canInitiateOpsTransfer`, 4 身份切换 |
| §3.2 接收人 | 1 `listTransferCandidates` |
| §3.3 / 3.3.1 状态与只读入口 | 2 列表入口, 4 `isOpsFeeReadOnly` |
| §3.4 转交效果 | 3 覆盖层, 4 Modal |
| §3.5 原因必填 | 1 `validateOpsTransfer`, 4 |
| §4 弹窗与记录 | 4 |
| §5 数据模型 | 2 `opsHandler`, 3 logs |
| §6 文档 | 5 |
| §7 验收 | 2 Step5, 4 Step6 |
## Placeholder / 一致性自检
- 无 TBD字段名统一 `opsHandler` / `opsTransferLogs` / `onOpsTransfer`
- 主路径为 Handle`ComponentFeeDetail`
- 提交仅在用户明确要求时执行
---
## Execution handoff
Plan complete and saved to `docs/superpowers/plans/2026-07-22-vehicle-return-settlement-ops-transfer.md`.
**两种执行方式:**
1. **Subagent-Driven推荐** — 每任务派生子代理,任务间复审
2. **Inline Execution** — 本会话按 executing-plans 逐任务推进
你要哪一种?

View File

@@ -0,0 +1,716 @@
# AutoRDO Web 工作台 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 落地 Axhub 原型 `autorodo-web`:粘贴素材 → 复制 AutoRDO 清洗提示词 → 粘回初稿 → 按需求分页确认待确认并回填描述 → 填写优先级/标签/提交部门/提交人 → 一键复制 YunxiaoPMapp 多条记录需求提示词。
**Architecture:** 纯函数放在原型内 `lib/`(解析初稿、出题回填、拼提示词、识别部门/提交人UI 为三步工作台React + OneOS V2 Token。MVP 不连云效、不嵌 OCR/ASRCursor 往返靠剪贴板。预留 `agentAdapter` 接口形状供组织 Agent。
**Tech Stack:** React + TypeScriptAxhub Make 原型、Vitest、OneOS V2`oneos-ds-tokens.css` + `UIComponents` 按需)、`PrototypeAnnotationHost`
**Spec:** [docs/superpowers/specs/2026-07-25-autorodo-web-design.md](../specs/2026-07-25-autorodo-web-design.md)
---
## 文件结构(锁定)
| 路径 | 职责 |
|------|------|
| `src/prototypes/autorodo-web/lib/types.ts` | 需求稿、待确认点、元数据、步骤状态类型 |
| `src/prototypes/autorodo-web/lib/parseDraft.ts` | 解析粘回的 AutoRDO Markdown → `RequirementDraft[]` |
| `src/prototypes/autorodo-web/lib/parseDraft.test.ts` | 解析单测 |
| `src/prototypes/autorodo-web/lib/applyAnswer.ts` | 选择题/补充 → 回填描述、消待确认 |
| `src/prototypes/autorodo-web/lib/applyAnswer.test.ts` | 回填单测 |
| `src/prototypes/autorodo-web/lib/buildCleanPrompt.ts` | 步骤 1 清洗提示词 |
| `src/prototypes/autorodo-web/lib/buildRecordPrompt.ts` | 步骤 3 YunxiaoPMapp 多条口令 |
| `src/prototypes/autorodo-web/lib/buildRecordPrompt.test.ts` | 上报词单测 |
| `src/prototypes/autorodo-web/lib/inferMeta.ts` | 从原文推断提交部门/提交人 |
| `src/prototypes/autorodo-web/lib/inferMeta.test.ts` | 推断单测 |
| `src/prototypes/autorodo-web/lib/agentAdapter.ts` | 预留 `clean` / `record` 接口类型MVP 空实现) |
| `src/prototypes/autorodo-web/lib/clipboard.ts` | `navigator.clipboard.writeText` 封装 |
| `src/prototypes/autorodo-web/lib/pendingQuestions.ts` | 从「待确认」文案生成选择题选项(词典摘要常量) |
| `src/prototypes/autorodo-web/components/StepPaste.tsx` | 步骤 1 UI |
| `src/prototypes/autorodo-web/components/StepConfirm.tsx` | 步骤 2 UI |
| `src/prototypes/autorodo-web/components/StepExport.tsx` | 步骤 3 UI |
| `src/prototypes/autorodo-web/AutorodoWebApp.tsx` | 三步状态机主壳 |
| `src/prototypes/autorodo-web/index.tsx` | 入口 `@name` + AnnotationHost |
| `src/prototypes/autorodo-web/style.css` | 布局与触控样式 |
| `src/prototypes/autorodo-web/annotation-source.json` | 标注目录 |
| `src/prototypes/autorodo-web/.spec/requirements-prd.md` | AutoPRD |
| `src/prototypes/autorodo-web/.spec/confirm-flow.md` | 确认与回填规则全文 |
| `src/resources/prd/autorodo-web-autoprd.md` | PRD 副本(若项目惯例需要) |
| 修改 `.axhub/make/project.json` | 注册原型(若 Make 未自动发现) |
| 修改 `~/.cursor/skills/YunxiaoPMapp` + 项目内副本 | 记录需求解析四字段Task 8可同迭代或紧随 |
**不放入 `src/common/`**:逻辑仅本原型使用,避免污染公共层。
---
### Task 1: 类型 + parseDraft + 单测
**Files:**
- Create: `src/prototypes/autorodo-web/lib/types.ts`
- Create: `src/prototypes/autorodo-web/lib/parseDraft.ts`
- Create: `src/prototypes/autorodo-web/lib/parseDraft.test.ts`
- [ ] **Step 1: 写入类型**
```ts
// src/prototypes/autorodo-web/lib/types.ts
export type Priority = '紧急' | '高' | '中' | '低';
export type RequirementMeta = {
priority: Priority | '';
tags: string[];
submitDept: string;
submitter: string;
titleKind: '新增' | '优化' | '';
};
export type PendingItem = {
id: string;
raw: string;
question: string;
options: string[];
resolved: boolean;
answer?: string;
};
export type RequirementDraft = {
index: number;
title: string;
description: string;
pendings: PendingItem[];
meta: RequirementMeta;
confirmed: boolean;
};
export type WizardStep = 1 | 2 | 3;
```
- [ ] **Step 2: 写失败单测(多条 + 待确认)**
```ts
// src/prototypes/autorodo-web/lib/parseDraft.test.ts
import { describe, it, expect } from 'vitest';
import { parseAutorodoDraft } from './parseDraft';
const SAMPLE = `共 2 条
### 1
## 原始诉求AutoRDO
**标题**:合同台账批量导出
**描述**
在租赁合同台账支持批量导出
待确认:
- 导出范围未说明
### 2
## 原始诉求AutoRDO
**标题**:证照到期提醒
**描述**
调整证照到期提醒口径
待确认:
`;
describe('parseAutorodoDraft', () => {
it('splits multiple requirements and pending items', () => {
const list = parseAutorodoDraft(SAMPLE);
expect(list).toHaveLength(2);
expect(list[0].title).toContain('合同台账');
expect(list[0].pendings).toHaveLength(1);
expect(list[0].pendings[0].raw).toContain('导出范围');
expect(list[1].pendings).toHaveLength(0);
expect(list[1].confirmed).toBe(true);
});
});
```
- [ ] **Step 3: 跑测确认失败**
Run: `npx vitest run src/prototypes/autorodo-web/lib/parseDraft.test.ts`
Expected: FAIL模块不存在或 `parseAutorodoDraft` 未定义)
- [ ] **Step 4: 实现解析**
```ts
// src/prototypes/autorodo-web/lib/parseDraft.ts
import type { RequirementDraft, PendingItem, RequirementMeta } from './types';
const emptyMeta = (): RequirementMeta => ({
priority: '',
tags: [],
submitDept: '',
submitter: '',
titleKind: '',
});
function parsePendings(block: string, reqIndex: number): PendingItem[] {
const m = block.match(/待确认[:]\s*([\s\S]*?)(?=\n###|\n##\s*原始诉求|$)/);
if (!m) return [];
const body = m[1].trim();
if (!body || body === '无') return [];
const lines = body
.split('\n')
.map((l) => l.replace(/^[-*•\d.、)\s]+/, '').trim())
.filter(Boolean);
return lines.map((raw, i) => ({
id: `r${reqIndex}-p${i}`,
raw,
question: raw,
options: [],
resolved: false,
}));
}
export function parseAutorodoDraft(md: string): RequirementDraft[] {
const parts = md.split(/(?=^###\s*\d+)/m).filter((p) => /原始诉求AutoRDO/.test(p) || /\*\*标题\*\*/.test(p));
const chunks =
parts.length > 0
? parts
: md.includes('**标题**')
? [md]
: [];
return chunks.map((chunk, index) => {
const title = (chunk.match(/\*\*标题\*\*[:]\s*(.+)/)?.[1] ?? '').trim();
const descMatch = chunk.match(/\*\*描述\*\*[:]\s*\n?([\s\S]*?)(?=\n待确认|$)/);
const description = (descMatch?.[1] ?? '').trim();
const pendings = parsePendings(chunk, index);
return {
index,
title,
description,
pendings,
meta: emptyMeta(),
confirmed: pendings.length === 0,
};
});
}
```
- [ ] **Step 5: 跑测确认通过**
Run: `npx vitest run src/prototypes/autorodo-web/lib/parseDraft.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**(仅当用户要求提交时执行;否则跳过所有 Commit 步骤)
```bash
git add src/prototypes/autorodo-web/lib/types.ts src/prototypes/autorodo-web/lib/parseDraft.ts src/prototypes/autorodo-web/lib/parseDraft.test.ts
git commit -m "$(cat <<'EOF'
feat(autorodo-web): add AutoRDO draft parser and types
EOF
)"
```
---
### Task 2: applyAnswer + pendingQuestions
**Files:**
- Create: `src/prototypes/autorodo-web/lib/applyAnswer.ts`
- Create: `src/prototypes/autorodo-web/lib/applyAnswer.test.ts`
- Create: `src/prototypes/autorodo-web/lib/pendingQuestions.ts`
- [ ] **Step 1: 写回填单测**
```ts
import { describe, it, expect } from 'vitest';
import { applyPendingAnswer } from './applyAnswer';
import type { RequirementDraft } from './types';
const base: RequirementDraft = {
index: 0,
title: '合同台账批量导出',
description: '在租赁合同台账支持批量导出\n导出范围待确认',
pendings: [
{
id: 'r0-p0',
raw: '导出范围未说明',
question: '导出范围指什么?',
options: ['当前筛选结果', '全部合同', '仅勾选行'],
resolved: false,
},
],
meta: { priority: '', tags: [], submitDept: '', submitter: '', titleKind: '' },
confirmed: false,
};
describe('applyPendingAnswer', () => {
it('merges answer into description and marks pending resolved', () => {
const next = applyPendingAnswer(base, 'r0-p0', '当前筛选结果');
expect(next.pendings[0].resolved).toBe(true);
expect(next.pendings[0].answer).toBe('当前筛选结果');
expect(next.description).toContain('当前筛选结果');
expect(next.description).not.toMatch(/导出范围:待确认/);
expect(next.confirmed).toBe(true);
});
});
```
- [ ] **Step 2: 跑测确认失败**
Run: `npx vitest run src/prototypes/autorodo-web/lib/applyAnswer.test.ts`
Expected: FAIL
- [ ] **Step 3: 实现 applyAnswer + 简易出题**
```ts
// applyAnswer.ts
import type { RequirementDraft } from './types';
export function applyPendingAnswer(
draft: RequirementDraft,
pendingId: string,
answer: string,
): RequirementDraft {
const pendings = draft.pendings.map((p) =>
p.id === pendingId ? { ...p, resolved: true, answer: answer.trim() } : p,
);
const hit = draft.pendings.find((p) => p.id === pendingId);
let description = draft.description;
if (hit) {
const label = hit.raw.replace(/未说明|待确认|不清楚/g, '').trim() || hit.raw;
if (/待确认/.test(description)) {
description = description.replace(/待确认/g, answer.trim());
} else {
description = `${description.replace(/\s+$/, '')}\n${label}${answer.trim()}`;
}
}
const confirmed = pendings.every((p) => p.resolved);
return { ...draft, description, pendings, confirmed };
}
// pendingQuestions.ts — MVP关键词启发式无匹配则「是 / 否 / 需补充」
export function buildOptionsForPending(raw: string): { question: string; options: string[] } {
if (/导出范围|范围/.test(raw)) {
return {
question: '「导出范围」指什么?',
options: ['当前筛选结果', '全部数据', '仅勾选行'],
};
}
if (/模块|归属/.test(raw)) {
return {
question: '归属哪个业务模块?',
options: ['租赁合同管理', '车辆管理', '证照管理', '还车应结款'],
};
}
return {
question: raw.endsWith('') || raw.endsWith('?') ? raw : `${raw}`,
options: ['确认按原文理解', '暂不纳入本需求', '需文字补充'],
};
}
export function enrichPendingsWithQuestions(draft: RequirementDraft): RequirementDraft {
return {
...draft,
pendings: draft.pendings.map((p) => {
const q = buildOptionsForPending(p.raw);
return { ...p, question: q.question, options: q.options };
}),
};
}
```
`parseAutorodoDraft` 返回前对每条调用 `enrichPendingsWithQuestions`,或在 UI 粘回后统一 enrich二选一推荐 UI 粘回后调用,保持 parse 纯净)。
- [ ] **Step 4: 跑测确认通过**
Run: `npx vitest run src/prototypes/autorodo-web/lib/applyAnswer.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**(用户要求时)
---
### Task 3: inferMeta + buildCleanPrompt + buildRecordPrompt
**Files:**
- Create: `src/prototypes/autorodo-web/lib/inferMeta.ts`
- Create: `src/prototypes/autorodo-web/lib/inferMeta.test.ts`
- Create: `src/prototypes/autorodo-web/lib/buildCleanPrompt.ts`
- Create: `src/prototypes/autorodo-web/lib/buildRecordPrompt.ts`
- Create: `src/prototypes/autorodo-web/lib/buildRecordPrompt.test.ts`
- Create: `src/prototypes/autorodo-web/lib/clipboard.ts`
- Create: `src/prototypes/autorodo-web/lib/agentAdapter.ts`
- [ ] **Step 1: inferMeta 单测**
```ts
import { describe, it, expect } from 'vitest';
import { inferSubmitMeta } from './inferMeta';
describe('inferSubmitMeta', () => {
it('detects dept and person from free text', () => {
const r = inferSubmitMeta('业务管理组张三反馈:合同导出不好用');
expect(r.submitDept).toMatch(/业务/);
expect(r.submitter).toContain('张三');
});
});
```
- [ ] **Step 2: 实现 inferMeta**
```ts
const DEPT_PATTERNS = [
/(?:提交部门|部门)[:]\s*([^\s,;]+)/,
/(业务管理组|产品部|安全部|运营部|财务部|技术部)/,
];
const PERSON_PATTERNS = [
/(?:提交人|反馈人|提出人)[:]\s*([^\s,;]+)/,
/@([\u4e00-\u9fa5]{2,4})/,
/([\u4e00-\u9fa5]{2,4})反馈/,
];
export function inferSubmitMeta(text: string): { submitDept: string; submitter: string } {
let submitDept = '';
let submitter = '';
for (const re of DEPT_PATTERNS) {
const m = text.match(re);
if (m?.[1]) {
submitDept = m[1];
break;
}
}
for (const re of PERSON_PATTERNS) {
const m = text.match(re);
if (m?.[1]) {
submitter = m[1];
break;
}
}
return { submitDept, submitter };
}
```
- [ ] **Step 3: buildRecordPrompt 单测 + 实现**
```ts
// buildRecordPrompt.test.ts
import { describe, it, expect } from 'vitest';
import { buildYunxiaoRecordPrompt } from './buildRecordPrompt';
import type { RequirementDraft } from './types';
const one: RequirementDraft = {
index: 0,
title: '合同台账批量导出',
description: '在租赁合同台账支持按筛选结果批量导出 Excel',
pendings: [],
meta: {
priority: '中',
tags: ['租赁合同'],
submitDept: '产品部',
submitter: '王冕',
titleKind: '新增',
},
confirmed: true,
};
describe('buildYunxiaoRecordPrompt', () => {
it('includes priority tags dept submitter', () => {
const text = buildYunxiaoRecordPrompt([one]);
expect(text).toContain('YunxiaoPMapp');
expect(text).toContain('记录需求:【新增】合同台账批量导出');
expect(text).toContain('优先级=中');
expect(text).toContain('标签=租赁合同');
expect(text).toContain('提交部门=产品部');
expect(text).toContain('提交人=王冕');
expect(text).toContain('推进至=暂不推进');
});
it('throws when meta incomplete', () => {
expect(() =>
buildYunxiaoRecordPrompt([{ ...one, meta: { ...one.meta, submitter: '' } }]),
).toThrow(/提交人/);
});
});
```
```ts
// buildRecordPrompt.ts
import type { RequirementDraft } from './types';
export function metaComplete(d: RequirementDraft): boolean {
const m = d.meta;
return Boolean(
m.priority && m.tags.length > 0 && m.submitDept.trim() && m.submitter.trim() && m.titleKind,
);
}
export function buildYunxiaoRecordPrompt(drafts: RequirementDraft[]): string {
for (const d of drafts) {
if (!d.confirmed) throw new Error(`需求 ${d.index + 1} 仍有未确认项`);
if (!metaComplete(d)) throw new Error(`需求 ${d.index + 1} 缺少优先级/标签/提交部门/提交人/类型`);
}
const lines = drafts.map((d) => {
const title = `${d.meta.titleKind}${d.title.replace(/^【(?:新增|优化)】/, '')}`;
const tags = d.meta.tags.join('、');
return `记录需求:${title};描述=${d.description.replace(/\n/g, ' ')};优先级=${d.meta.priority};标签=${tags};提交部门=${d.meta.submitDept};提交人=${d.meta.submitter};推进至=暂不推进`;
});
return [
'YunxiaoPMapp',
'请按下列条目依次记录需求;每条保持独立,不要合并。',
'',
...lines,
].join('\n');
}
```
```ts
// buildCleanPrompt.ts
export function buildCleanPrompt(payload: {
text: string;
attachmentNames: string[];
}): string {
const files =
payload.attachmentNames.length > 0
? `附件:${payload.attachmentNames.join('、')}(请用多模态识读图/语音内容)`
: '附件:无';
return [
'AutoRDO',
'请按 AutoRDO 规则清洗下列素材:多条独立诉求拆成多份;提炼标题与书面描述;不确定处标「待确认」;描述无结尾句号。',
files,
'',
'—— 素材开始 ——',
payload.text.trim() || '(仅附件,无正文)',
'—— 素材结束 ——',
].join('\n');
}
```
```ts
// clipboard.ts
export async function copyText(text: string): Promise<void> {
await navigator.clipboard.writeText(text);
}
// agentAdapter.ts
export type CleanRequest = { text: string; attachmentNames: string[] };
export type RecordRequest = { drafts: import('./types').RequirementDraft[] };
/** MVP未接组织 Agent后续替换实现即可 */
export const agentAdapter = {
async clean(_req: CleanRequest): Promise<null> {
return null;
},
async record(_req: RecordRequest): Promise<null> {
return null;
},
};
```
- [ ] **Step 4: 跑全部 lib 单测**
Run: `npx vitest run src/prototypes/autorodo-web/lib`
Expected: 全部 PASS
- [ ] **Step 5: Commit**(用户要求时)
---
### Task 4: 原型壳 + 步骤 1 UI
**Files:**
- Create: `src/prototypes/autorodo-web/index.tsx`
- Create: `src/prototypes/autorodo-web/AutorodoWebApp.tsx`
- Create: `src/prototypes/autorodo-web/style.css`
- Create: `src/prototypes/autorodo-web/components/StepPaste.tsx`
- Create: `src/prototypes/autorodo-web/annotation-source.json`(先最小目录)
- Modify: `.axhub/make/project.json`(若启动后未出现在列表则补注册)
前置Read `src/resources/design-system/DESIGN.md`Token、触控 ≥44px、禁止原生 select
- [ ] **Step 1: 入口与主壳骨架**
```tsx
// index.tsx
/**
* @name AutoRDO 需求清洗工作台
*/
import './style.css';
import '../../../resources/design-system/oneos-ds-tokens.css';
import React from 'react';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import type { AnnotationSourceDocument } from '@axhub/annotation';
import annotationSourceDocument from './annotation-source.json';
import { AutorodoWebApp } from './AutorodoWebApp';
export default function AutorodoWebEntry() {
return (
<>
<AutorodoWebApp />
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={{}}
/>
</>
);
}
```
`AutorodoWebApp`:左侧步骤 1/2/3`step` state步骤 1 渲染 `StepPaste`
- [ ] **Step 2: StepPaste**
- contentEditable 或 textarea 接收粘贴;`onPaste` 收集 `clipboardData.files`image/*、audio/*)为附件名列表(原型阶段可用 File.name不上传
- 按钮「确认并复制清洗提示词」→ `buildCleanPrompt` + `copyText`toast「已复制请粘到 Cursor 跑 AutoRDO」
- 可选 checkbox「粘贴后自动复制」默认关
- 控件:优先 `V2` 封装;按钮 min-height 44px
- [ ] **Step 3: 本地预览**
Run: 打开 Make 预览 `/prototypes/autorodo-web`
Expected: 能粘贴、点确认后剪贴板为 AutoRDO 提示词
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 5: 步骤 2 确认 UI
**Files:**
- Create: `src/prototypes/autorodo-web/components/StepConfirm.tsx`
- Modify: `src/prototypes/autorodo-web/AutorodoWebApp.tsx`
- [ ] **Step 1: 粘回区 + 解析**
- textarea「粘回 AutoRDO 初稿」
- 按钮「解析初稿」→ `parseAutorodoDraft``enrichPendingsWithQuestions` → 对每条 `inferSubmitMeta(description + title)` 预填 meta
- Pill 切换需求 index
- [ ] **Step 2: 左右分栏确认**
- 左:标题(可编辑)+ 描述(只读展示实时稿)
- 右:当前未解决 pending单选 options补充 input「确认并下一题」调用 `applyPendingAnswer`
- 元数据:优先级四选一;标签用可增删 chip自由输入 MVP提交部门/提交人 input类型 新增/优化
- 全部 confirmed 且 `metaComplete` 后可进步骤 3
- [ ] **Step 3: 手动验收路径**
1. 粘贴 Task1 SAMPLE 初稿
2. 答完题后描述含答案且无「待确认」
3. 填齐元数据
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 6: 步骤 3 导出 + 样式打磨
**Files:**
- Create: `src/prototypes/autorodo-web/components/StepExport.tsx`
- Modify: `style.css``AutorodoWebApp.tsx`
- [ ] **Step 1: StepExport**
- 预览 `buildYunxiaoRecordPrompt(drafts)`
- 「一键复制上报提示词」
- 「复制定稿 Markdown」输出多份已确认 `## 原始诉求AutoRDO`,待确认:无)
- 错误态:捕获 throw页面内提示缺哪条哪字段
- [ ] **Step 2: 响应式**
- ≤767px步骤条顶置确认区改为上下堆叠触控 ≥44px正文 ≥14px
- [ ] **Step 3: 预览验收**
全路径走通:粘贴 → 复制清洗词 →(模拟)粘回 SAMPLE → 确认 → 复制上报词,粘贴到文本编辑器检查字段齐全。
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 7: AutoPRD + 标注 + 导航同步
**Files:**
- Create: `src/prototypes/autorodo-web/.spec/confirm-flow.md`
- Create: `src/prototypes/autorodo-web/.spec/requirements-prd.md`
- Create: `src/resources/prd/autorodo-web-autoprd.md`(与 PRD 同步)
- Modify: `annotation-source.json`(产品需求说明节点)
- [ ] **Step 1: 写 confirm-flow.md**
含:判定顺序表、前置条件、数据源(粘回 Markdown / 本地启发式选项)、用户可见结果、标明未接真 API。
- [ ] **Step 2: 写 requirements-prd.md**
按 oneos-autoprd 结构:总览/目标/边界/角色/用户故事(起点→运作→闭环)/验收;链到 `confirm-flow.md`
- [ ] **Step 3: 同步标注与导航**
```bash
npm run nav:sync -- --prototype autorodo-web --note "新增 AutoRDO 网页清洗与云效上报提示词工作台"
```
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 8: YunxiaoPMapp 消费四字段Skill 扩展)
**Files:**
- Modify: `~/.claude/skills/YunxiaoPMapp/references/commands.md`(及项目 `.cursor` / `.claude` 同步副本)
- Modify: YunxiaoPMapp `SKILL.md` 记录需求参数说明
- 可选:`references/record-meta-fields.md` 新文件
- [ ] **Step 1: 文档约定口令字段**
在口令面增加:
```text
记录需求:…;优先级=紧急|高|中|低;标签=…;提交部门=…;提交人=…;推进至=…
```
说明:网页上报词已带齐时,**禁止**再 Plan 重复问这四项(除非缺省);缺省则仍问。
- [ ] **Step 2: 实现侧**
若现有 live 脚本已支持 priority/tag把提交部门/提交人映射到云效字段(查 `runtime-ids.json`;未验证字段则先写入描述尾部 `提交部门/提交人`,并在文档标明「字段 ID 待验证」)。
- [ ] **Step 3: 与网页联调**
用步骤 3 复制出的提示词在 Cursor 跑一次「记录需求」(测试项目或 dry-run 策略按团队惯例)。
- [ ] **Step 4: Commit**(用户要求时)
---
## 规格覆盖自检
| Spec 要求 | Task |
|-----------|------|
| 粘贴文/图/语音 + 复制清洗词 | 4 |
| 粘回初稿 + 分页确认 + 回填 | 1,2,5 |
| 优先级/标签/部门/提交人 | 3,5,6 |
| 一键 Yunxiao 多条口令 | 3,6 |
| 预留组织 Agent | 3 `agentAdapter` |
| AutoPRD / 业务规则文档 | 7 |
| Yunxiao 消费四字段 | 8 |
| 不直连云效 / 无 OCR | 全程遵守 |
---
## 执行说明
- 所有 **Commit 步骤默认跳过**,除非用户明确要求提交。
- Task 8 可与 Task 47 并行由另一会话做;网页 MVP 以 Task 17 为可演示闭环(上报词已含四字段,即使 Skill 暂未写入云效自定义字段,口令仍可用于人工/后续 Skill
---
Plan complete and saved to `docs/superpowers/plans/2026-07-25-autorodo-web.md`.
**两种执行方式:**
1. **Subagent-Driven推荐** — 每任务开新子代理,任务间复核,迭代快
2. **Inline Execution** — 本会话按 executing-plans 连续执行,设检查点
要哪一种?

View File

@@ -0,0 +1,187 @@
# 还车应结款 · 运维部转交 设计规格
| 项 | 说明 |
|---|---|
| 日期 | 2026-07-22 |
| 状态 | 已确认 |
| 方案 | 方案 A费用明细「运维部」卡片内单笔转交 |
| 关联原型 | `vehicle-return-settlement`;业务主实现复用 `oneos-web-finance` 还车应结款费用明细 |
| 主改文件 | `src/prototypes/oneos-web-finance/pages/03-还车应结款.jsx` |
---
## 1. 问题与目标
运维部人员处理还车应结款运维段时,存在两类卡住场景:
1. **无法及时处理**:当前办理人短期无法完成填写/提交,需把任务交给同事继续。
2. **人员离职**:原办理人账号停用或人已离开后,运维段无法由他人合法撤回、编辑,任务悬挂。
**本期目标**
1. 在运维部费用明细段提供 **转交办理人** 能力。
2. **当前办理人**可主动转交;**运维主管**可代转(覆盖离职/无人处理)。
3. 转交只换办理人,不改已填费用与整单审批流;改内容仍走既有撤回规则。
4. 转交原因必填,并留下可查操作记录。
**非目标(本期不做)**
- 列表批量转交、操作列快捷转交
- 独立「转交台 / 待处理转交」页面
- 其他部门(业管 / 能源 / 安全)转交
- 真实组织人事同步、账号停用自动触发转交
- 真实消息通知(可本地 Toast + 记录演示)
---
## 2. 方案选择
| 方案 | 说明 | 结论 |
|------|------|------|
| A. 费用明细运维卡片内转交 | 与保存/提交/撤回同工作面 | **采用** |
| B. 列表批量 + 明细单笔 | 离职清多笔快,交互与权限更重 | 二期候选 |
| C. 主管独立转交台 | 主管视角清晰,新产品面 | 二期候选 |
---
## 3. 权限与状态规则
### 3.1 谁可发起
| 角色 | 能力 |
|------|------|
| 当前办理人(`opsHandler` | 可将本单运维段转给其他运维同事 |
| 运维主管 | 可代转任意未禁止状态下的运维段(含原办理人已非在职) |
| 其他角色 / 非办理人非主管 | 无「转交」入口 |
### 3.2 转交对象
-**运维部在职同事**
- 排除:自己、已停用账号、非运维人员
- 选择器支持姓名模糊搜索
### 3.3 何时可转
| 维度 | 允许 | 禁止 |
|------|------|------|
| 运维段状态 | 待提交、已提交 | — |
| 整单审批状态 | 待提交、待审批、审批中、审批驳回、撤回 | **审批完成** |
说明:待审批 / 审批中仍可转交——**只换办理人**,不改已填内容、不改整单审批节点。
### 3.3.1 费用明细入口与运维段可编辑性(相对现网调整)
相对现网「待审批 / 审批中 / 审批完成不显示费用明细」:
| 顺序 | 条件 | 费用明细入口 | 运维段费用编辑 | 运维转交 |
|------|------|--------------|----------------|----------|
| 1 | 整单待提交 / 审批驳回 / 撤回等(费用明细本就可进) | 显示 | 按运维段状态:待提交可编;已提交须先撤回再编 | 办理人/主管可转 |
| 2 | 整单 **待审批 / 审批中** | **显示,不限角色**(任何人可点进查看) | **不可编辑**(保存/提交/撤回隐藏或禁用;表格只读) | 办理人/主管仍可转交 |
| 3 | 整单 **审批完成** | 不显示(或只读查看若产品另有查看页) | — | **无转交** |
要改审批中的运维费用内容:须先按现有规则 **整单撤回**,回到可编辑的整单状态后,再由当前办理人编辑。
### 3.4 转交效果与改内容
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | 转交成功 | 更新 `opsHandler` 为新办理人写转交记录Toast 成功 |
| 2 | 转交时运维段已提交 | **不**自动撤回status / 历史提交人姓名保持 |
| 3 | 整单待审批 / 审批中 | 转交可用;运维费用只读;**不可**部门撤回改数 |
| 4 | 新办理人要改费用(整单未在审批中) | 若段状态为已提交 → 先走运维部 **撤回**,再编辑保存/提交 |
| 5 | 整单在审批中要改费用 | 先 **整单撤回**,再进入费用明细编辑 |
| 6 | 整单审批完成 | 无转交按钮 |
### 3.5 原因
- **必填**转交原因(多行文本)
- 空原因不可确认转交
---
## 4. 信息架构与交互
### 4.1 入口
费用明细页 → **运维部**卡片标题栏右侧:
| 场景 | 可见操作(在权限内) |
|------|----------------------|
| 整单可编 + 运维待提交 | 保存、提交、**转交** |
| 整单可编 + 运维已提交 | 撤回、**转交** |
| 整单待审批 / 审批中 | 费用只读;无保存/提交/撤回;办理人/主管可见 **转交** |
| 整单审批完成 | 无转交 |
列表页:
- 待审批 / 审批中:**显示「费用明细」**(不限角色),与现网「隐藏」不同,以本规格为准
- 运维组办理人展示随 `opsHandler` 更新
- **不**增加列表「转交」操作、**不**做批量
### 4.2 转交弹窗
1. 标题:转交运维办理人
2. 当前办理人:只读
3. 新办理人:必选下拉(运维在职、可搜索、排除自己)
4. 转交原因:必填多行
5. 取消 / 确认转交
6. 确认前二次说明:确认后将由【新办理人】继续处理本单运维段
成功后:关闭弹窗;头部办理人即时更新;若当前用户已不再是办理人且非主管,操作区按权限收敛。
### 4.3 转交记录
每笔至少记录:时间、操作人、原办理人、新办理人、原因、当时运维段状态、当时整单审批状态。
费用明细运维卡片提供「转交记录」入口(时间线或抽屉即可)。
---
## 5. 数据模型
| 字段 | 说明 |
|------|------|
| `opsHandler` | **当前办理人**(权威)。初始可由还车人 `returnPerson` 带入;转交只改本字段,**不改**历史还车人 |
| `submitOperation.status` | 运维段提交状态(待提交 / 已提交等既有口径) |
| `submitOperation.name` | **最近一次提交人**;转交不覆盖;新人再次提交时再更新 |
| `opsTransferLogs[]` | 转交记录数组(见 4.3 |
列表「运维组」办理人解析:读 `opsHandler`,不再直接绑死 `returnPerson`
原型演示:本地状态/种子即可,**未接真实 API**;组织人员用本地运维同事清单模拟。
---
## 6. 落地范围
| 项 | 路径 / 说明 |
|----|-------------|
| 主实现 | `src/prototypes/oneos-web-finance/pages/03-还车应结款.jsx`(费用明细运维段 + 列表 `resolveDeptHandler` |
| 薄壳对齐 | `src/prototypes/vehicle-return-settlement/` 办理预览若展示运维操作,对齐转交入口(轻量) |
| 业务规格 | `src/prototypes/vehicle-return-settlement/.spec/ops-transfer.md` |
| AutoPRD | `.spec/requirements-prd.md` + `annotation-source.json` + `src/resources/prd/vehicle-return-settlement-autoprd.md`(若约定存在则同步) |
---
## 7. 验收要点
1. 办理人可把待提交单转给同事,原因必填,列表办理人变为新人。
2. 已提交单可转交;新人可撤回后编辑再提交。
3. 主管可代转「非本人」的运维段任务。
4. 整单待审批 / 审批中:列表可进费用明细(不限角色);运维费用只读不可编;办理人/主管仍可转交;费用与审批流不变。
5. 审批完成后无转交按钮。
6. 不可转给自己或非运维人员;原因为空不可提交。
7. 转交记录可查(时间、人、原因、状态快照)。
---
## 8. 决策摘要(对齐记录)
| 决策点 | 结论 |
|--------|------|
| 发起人 | 当前办理人 + 运维主管 |
| 可转状态(部门段) | 待提交 + 已提交 |
| 整单审批中 | 仍可转,只换人;费用明细可进(不限角色);运维段不可编辑 |
| 接收人 | 仅运维部在职同事 |
| 原因 | 必填 |
| 交互方案 | A费用明细卡片内单笔转交 |

View File

@@ -0,0 +1,168 @@
# AutoRDO Web 工作台 · 设计规格
**日期**2026-07-25
**状态**已确认2026-07-25
**范围**:网页粘贴与确认交互 + AutoRDO Skill 对齐 + YunxiaoPMapp 多条上报提示词生成
---
## 1. 目标
为产品经理提供网页工作台:直接粘贴**文本 / 图片 / 语音**,经 Cursor + AutoRDO 清洗后,在网页内对待确认项做选择题确认,答案自动并入需求描述,最后**一键复制**可喂给 Cursor + **YunxiaoPMapp** 的多条「记录需求」提示词,实现快速批量建单。
不替代 AutoRDO / YunxiaoPMapp Skill网页负责编排与确认交互清洗与写云效仍由 Cursor Agent 执行MVP
---
## 2. 已确认决策
| 项 | 决策 |
|---|---|
| 形态 | 网页 + Skill **双轨** |
| Agent | MVP = **Cursor Agent**;预留组织自建 Agent 接口 |
| 多模态 | 网页粘贴展示;图/语音识读由 **Cursor 多模态**完成 |
| 清洗往返 | Cursor 洗完后,把带「待确认」的初稿 **粘回网页** 再出题 |
| 确认结构 | 按**需求条**分页;条内**逐点**选择题;选项外可输入补充 |
| 回填 | 每确认一点 → **自动写入**该条描述,消掉对应待确认 |
| 云效 | **不**直连云效;一键复制上报提示词 → Cursor 调 YunxiaoPMapp |
| 元数据 | 每条含 **优先级、标签、提交部门、提交人**(部门/人可内容预填,可改) |
---
## 3. 端到端流程
```text
① 网页文本域粘贴 文/图/语音(支持多行、编号列表、表格行、空行分段)
↓ 确认(可配置为粘贴后自动)
② 剪贴板 = AutoRDO 清洗提示词 → 粘到 Cursor 跑 AutoRDO
③ 将初稿(含待确认)粘回网页步骤 2
④ 按需求 1/N 分页;条内逐点选择题 + 可选补充输入
→ 答案实时并入描述;填写优先级/标签/提交部门/提交人
↓ 全部确认且元数据齐全
⑤ 一键复制「YunxiaoPMapp 多条记录需求」提示词
⑥ 粘到 Cursor → YunxiaoPMapp 按条创建多条云效需求
```
---
## 4. 信息架构(三步工作台)
### 步骤 1 · 粘贴素材
- 统一粘贴区:文本 + 图片/语音附件芯片预览
- 主操作:「确认并复制清洗提示词」(可选:粘贴后自动复制)
- 清洗提示词须明示:调用 AutoRDO、多条拆解规则、图/语音由模型识读
### 步骤 2 · 确认待确认
- 粘回区:粘贴 Cursor 返回的 `## 原始诉求AutoRDO` 多份稿
- 解析为需求列表;顶部 Pill需求 1/N
- 左:当前标题 + 描述(确认后实时回填高亮)
- 右:当前待确认题(单选)+「选项不含时自行补充」输入框
- 同页元数据(每条必填):
- 优先级:紧急 / 高 / 中 / 低
- 标签:云效模块标签(可多选;与 YunxiaoPMapp 标签口径对齐)
- 提交部门:输入;若原文可识别则预填
- 提交人:输入;若原文可识别则预填
- 缺任一元数据 → 禁止进入步骤 3 的一键复制
### 步骤 3 · 上报云效
- 展示已生成的上报提示词预览
- 主按钮:一键复制上报提示词
- 次按钮:复制定稿 Markdown可选
- 默认 `推进至=暂不推进`
---
## 5. 提示词契约
### 5.1 清洗提示词(步骤 1 → Cursor
要点:
- 点名使用 `$AutoRDO`
- 输入含粘贴的文本摘要 + 附件说明(图/语音文件名)
- 要求:多条独立诉求拆成多份;不确定标「待确认」;标题+描述;无结尾句号
- 输出格式对齐 AutoRDO Skill 的 `## 原始诉求AutoRDO`
### 5.2 上报提示词(步骤 3 → Cursor
```text
YunxiaoPMapp
请按下列条目依次记录需求;每条保持独立,不要合并。
记录需求:【新增|优化】{标题};描述={已确认描述};优先级={紧急|高|中|低};标签={标签};提交部门={部门};提交人={提交人};推进至=暂不推进
记录需求:…(下一条)
```
约束:
- 一条需求一行(或一块)口令,便于 Skill 拆条
- 描述已无「待确认」、无结尾句号
- 标题含 `【新增】` / `【优化】`(确认阶段或清洗结果已定)
- 网页不写云效;由 YunxiaoPMapp 在 Cursor 侧执行建单
> 注:当前 YunxiaoPMapp 口令面以标题/描述/推进至为主;网页侧**先行产出**优先级/标签/提交部门/提交人字段,便于 Skill 后续消费。若 Skill 尚未落这四字段的写入逻辑,实现阶段需同步扩展 YunxiaoPMapp「记录需求」解析另项
---
## 6. 与现有 Skill 对齐
| 能力 | 来源 | 网页职责 |
|---|---|---|
| 多条拆解、标题描述、待确认 | AutoRDO | 解析初稿、出题、回填 |
| 选择题确认 | AutoRDO `confirm-pending` | 在网页复现同等交互,替代 Cursor Plan 点选 |
| 模块/部门词典 | AutoRDO `oneos-domain` | 生成选项与标签候选时参照(实现时可内嵌摘要或提示词约束) |
| 建单写云效 | YunxiaoPMapp | 仅生成口令;不直连 API |
预留接口(组织 Agent 阶段):
- `POST /clean` ← 素材 → 初稿(替代步骤 ② 人工往返)
- `POST /record` ← 已确认多条 → 建单(替代步骤 ⑥)
MVP 不实现上述 API只在代码边界留 adapter 形状。
---
## 7. 交付物(实现阶段)
1. Axhub 原型页(建议 id`autorodo-web` 或等价命名)
- OneOS V2 设计规范PC + H5
- 三步工作台 + 粘贴往返 + 确认 + 元数据 + 复制口令
2. AutoRDO Skill可选补充「网页往返」口令说明不改变清洗规则
3. YunxiaoPMapp扩展记录需求解析以消费 `优先级/标签/提交部门/提交人`(若尚未支持)
4. 本规格对应实现计划writing-plans
---
## 8. 非目标MVP
- 网页直连云效 API
- 网页内嵌 OCR/ASR
- localhost 桥接 Cursor自动少粘贴
- 完整 AutoPRD 十章生成
---
## 9. 验收要点
1. 粘贴多行/编号/表格/空行分段素材后,能复制出可用的 AutoRDO 清洗提示词
2. 粘回含多条「待确认」的初稿后,按需求分页、条内逐题确认,描述实时更新
3. 选项外补充可替换本题答案并回填描述
4. 每条可填/预填提交部门、提交人,以及优先级、标签;缺一不可复制上报词
5. 一键复制的上报提示词可直接粘到 Cursor驱动 YunxiaoPMapp 多条建单(在 Skill 已支持对应字段的前提下)
6. 页面不发起云效写操作
---
## 10. 开放项(实现前可再定)
- 原型正式目录名 / 导航挂载位置
- 标签候选项是写死 catalog 摘要,还是自由输入 + Skill 侧校验
- 「粘贴后自动复制清洗提示词」默认开或关
- YunxiaoPMapp 四字段写入是否与本原型同迭代交付

View File

@@ -0,0 +1,132 @@
# AutoRDO 需求清洗工作台 · 粘回确认与上报门禁
> 实现:`lib/parseDraft.ts`、`lib/pendingQuestions.ts`、`lib/applyAnswer.ts`、`lib/inferMeta.ts`、`lib/buildRecordPrompt.ts`
> UI`components/StepConfirm.tsx`、`components/StepExport.tsx`
本规格覆盖步骤 2「确认待确认」的解析 / 答题 / 元数据补齐,以及进入步骤 3「上报云效」前的门禁判定。
**原型本地逻辑;未接真实云效 API、未接 AutoRDO 服务端。**
---
## 前置条件
| 场景 | 是否校验 / 是否执行 |
|------|---------------------|
| 粘回区为空就点「解析初稿」 | 拦截:提示「请先粘贴 AutoRDO 初稿」;不改草稿列表 |
| 粘回文不含 `**标题**` /「原始诉求AutoRDO」可识别块 | 拦截:提示未能解析;不改草稿列表 |
| 尚未解析出任何草稿 | 不上报;步骤 3 导航禁用(无 drafts步骤 2 出口提示「请先粘回并解析」 |
| 某条仍有未答「待确认」 | 该条 `confirmed=false`;不可进入上报 |
| 某条元数据不齐(类型 / 优先级 / 标签 / 提交部门 / 提交人) | `metaComplete=false`;不可进入上报 / 不可复制上报提示词 |
| 答题时选项与补充均为空 | 「确认并下一题」禁用;不调用 `applyPendingAnswer` |
| 补充文案非空 | **优先**用补充文案作为答案(覆盖单选) |
---
## 判定优先级顺序
### A. 解析初稿(`parseAutorodoDraft`
按块拆分后,对每个需求块提取字段;**命中即采用,不再用更弱规则覆盖已提取值**。
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | Markdown 能按 `### N` 切出多块且块内含「原始诉求AutoRDO」或 `**标题**` | 多条 `RequirementDraft` |
| 2 | 无法切块,但全文含 `**标题**` | 整篇视为 1 条草稿 |
| 3 | 以上皆否 | 返回空数组 → UI 报解析失败 |
| 4 | 块内匹配 `**标题**:…` | 写入 `title` |
| 5 | 块内匹配 `**描述**` 至「待确认」之前 | 写入 `description` |
| 6 | 「待确认:」后为「无」或空 | `pendings=[]``confirmed=true` |
| 7 | 「待确认:」后有列表行 | 每行一条 `PendingItem`(去列表前缀),`confirmed=false` |
解析成功后流水线(`StepConfirm.handleParse`
1. `enrichPendingsWithQuestions` — 为每条待确认生成题干与选项
2. `inferSubmitMeta(标题+描述)` — 仅在提交部门 / 提交人为空时填入启发式结果
### B. 待确认选项启发式(`buildOptionsForPending`
| 优先级 | 原文匹配 | 题干 / 选项 |
|--------|----------|-------------|
| 1 | 含「导出范围」或「范围」 | 「导出范围」指什么?→ 当前筛选结果 / 全部数据 / 仅勾选行 |
| 2 | 含「模块」或「归属」 | 归属哪个业务模块?→ 租赁合同管理 / 车辆管理 / 证照管理 / 还车应结款 |
| 3 | 其它 | 原文(补「?」)→ 确认按原文理解 / 暂不纳入本需求 / 需文字补充 |
### C. 答题写回描述(`applyPendingAnswer`
| 优先级 | 条件 | 描述写回 |
|--------|------|----------|
| 0 | 答案 trim 为空,或找不到 `pendingId` | 原样返回,不改 |
| 1 | 描述中存在 `{标签}:待确认` 占位 | 替换为 `{标签}{答案}` |
| 2 | 描述中含任意「待确认」 | 替换**首个**「待确认」为答案 |
| 3 | 以上皆否 | 在描述末尾追加 `{标签}{答案}` |
答完后:该 pending `resolved=true`;若全部 resolved → `confirmed=true`
### D. 提交部门 / 提交人启发式(`inferSubmitMeta`
仅作预填,用户可改;**不**自动填类型 / 优先级 / 标签。
| 字段 | 优先级 | 匹配 |
|------|--------|------|
| 提交部门 | 1 | `提交部门|部门:…` |
| 提交部门 | 2 | 固定词:业务管理组 / 产品部 / 安全部 / 运营部 / 财务部 / 技术部 |
| 提交人 | 1 | `提交人|反馈人|提出人:…` |
| 提交人 | 2 | `@姓名`24 汉字) |
| 提交人 | 3 | `姓名反馈` |
### E. 进入上报门禁(`StepConfirm` + `metaComplete` + `buildYunxiaoRecordPrompt`
全部需求须同时满足:
| 优先级 | 条件 | 用户可见结果 |
|--------|------|--------------|
| 1 | `drafts.length > 0` | 否则提示先粘回解析 |
| 2 | 每条 `confirmed === true`(无未答待确认) | 「需求 N还有 K 项待确认」 |
| 3 | 每条 `metaComplete`类型、优先级、≥1 标签、提交部门、提交人 | 「需求 N缺少 …」 |
| 4 | 全部通过 | 显示「进入上报」;步骤 3 可复制 YunxiaoPMapp 多条记录提示词 |
`buildYunxiaoRecordPrompt` 在复制时再次校验;未通过则抛错,步骤 3 预览区展示错误与缺失清单。
---
## 数据源
| 来源 | 类型 | 说明 |
|------|------|------|
| 粘回 Markdown | 用户粘贴 | Cursor 跑完 AutoRDO 后的初稿;**本机剪贴板,非 API** |
| 本地启发式选项 | 代码常量 | `pendingQuestions.ts` / `inferMeta.ts` 正则与枚举;**未接字典服务** |
| 用户答题与元数据 | 会话内存 | 刷新丢失;**未持久化、未接云效** |
| 清洗 / 上报提示词 | 本地拼接 | `buildCleanPrompt` / `buildYunxiaoRecordPrompt`;用户自行粘到 Cursor 执行 Skill |
**明确非数据源**:云效 OpenAPI、AutoRDO 远程清洗、需求库回写。
---
## 用户可见结果
| 动作 | 通过 | 失败 / 拦截 |
|------|------|-------------|
| 解析初稿 | 出现需求分页胶囊;展示标题、实时描述、待确认卡、元数据区 | 红字错误提示;草稿不变 |
| 确认并下一题 | 描述实时更新;下一题或「本需求待确认项已全部答完」 | 按钮禁用(无答案) |
| 补齐元数据 | 分段按钮 / 标签芯片即时反映 | — |
| 进入上报 | 主按钮可点 → 步骤 3 | 「尚不能进入上报」+ 缺失列表 |
| 复制上报提示词 | Toast已复制请粘到 Cursor 跑 YunxiaoPMapp | 预览区错误 +「请返回步骤 2」 |
| 复制定稿 Markdown | Toast已复制定稿 Markdown | 无草稿时禁用 |
标题写入上报口令时格式:`【新增|优化】` + 标题(若标题已带同前缀则去重)。每条 `推进至=暂不推进`
---
## 代码路径
| 能力 | 路径 |
|------|------|
| 解析粘回稿 | `lib/parseDraft.ts``parseAutorodoDraft` |
| 待确认题干与选项 | `lib/pendingQuestions.ts``buildOptionsForPending` / `enrichPendingsWithQuestions` |
| 答题写回 | `lib/applyAnswer.ts``applyPendingAnswer` |
| 部门 / 人预填 | `lib/inferMeta.ts``inferSubmitMeta` |
| 元数据齐全 & 上报提示词 | `lib/buildRecordPrompt.ts``metaComplete` / `buildYunxiaoRecordPrompt` |
| 确认步 UI 与门禁 | `components/StepConfirm.tsx` |
| 上报步复制 | `components/StepExport.tsx` |
关联产品说明:见 [requirements-prd.md](./requirements-prd.md) §6 / §8。

View File

@@ -0,0 +1,245 @@
# AutoRDO 需求清洗工作台 · 产品需求说明(全模块)
## 1. 一句话与目标
**一句话**
给产品经理一个网页工作台:粘贴聊天/截图/语音素材 → 复制 AutoRDO 清洗提示词 → 粘回初稿确认待确认并补齐上报字段 → 复制 YunxiaoPMapp 多条「记录需求」提示词,在 Cursor 侧完成入库。
**要解决的问题**
- 碎片素材难直接写成可入库的标题与书面描述
- 多条诉求混在一起,容易漏拆或合并错
- 「待确认」项靠聊天来回补,缺少结构化答题与门禁
- 上报云效前缺少类型、优先级、标签、提交部门/人的齐套校验
- 不想在网页直连云效,只希望生成可粘贴执行的口令
**本期目标**
1. 三步工作台:粘贴素材 → 确认待确认 → 上报云效(复制口令)
2. 支持文本 + 图片/语音附件芯片(不上传;由 Cursor 多模态识读)
3. 一键复制 AutoRDO 清洗提示词;可选「粘贴后自动复制」
4. 粘回初稿后解析多条需求、分页答题、补齐元数据
5. 齐套后一键复制 YunxiaoPMapp 多条记录需求提示词(及定稿 Markdown
**非目标(本期不做)**
- 不直连云效 OpenAPI / 不在网页内建单
- 不在网页内跑 AutoRDO 模型清洗(只生成提示词)
- 不做账号登录、权限、草稿云端持久化
- 不替代 YunxiaoPMapp / AutoRDO Skill 本体规则
## 2. 模块边界(最重要)
```mermaid
flowchart LR
subgraph web [AutoRDO 网页工作台]
P[粘贴素材]
C[确认待确认]
E[上报口令]
end
Cursor[Cursor + AutoRDO / YunxiaoPMapp]
YX[云效需求]
P -->|复制清洗提示词| Cursor
Cursor -->|粘回初稿 Markdown| C
C --> E
E -->|复制记录需求提示词| Cursor
Cursor -->|Skill 执行| YX
```
| 业务线 / 子域 | 做什么 | 不做什么 |
|---------------|--------|----------|
| 素材采集 | 粘贴文本与图/语音文件名芯片,生成清洗提示词 | 不上传附件、不识读媒体二进制 |
| 确认补齐 | 解析初稿、答待确认、编辑标题/描述、补元数据 | 不自动推进云效状态 |
| 上报出口 | 预览并复制多条「记录需求」口令 / 定稿 Markdown | 不调用云效 API |
**外部依赖(产品口径)**
| 依赖方 | 交互方式 | 产品要求 |
|--------|----------|----------|
| AutoRDOCursor Skill | 用户粘贴清洗提示词后执行 | 产出含标题、描述、「待确认」的 Markdown 初稿 |
| YunxiaoPMappCursor Skill | 用户粘贴上报提示词后执行 | 按条独立记录需求;本工作台默认「暂不推进」 |
| 剪贴板 | 浏览器复制 / 粘贴 | 复制失败时明确提示,可重试 |
**关键约束**
- 一条上报口令可含多条「记录需求」,**禁止合并为一条**
- 未答完待确认或元数据不齐 → **禁止**进入可复制的上报态
- 标题类型仅「新增 / 优化」;写入口令时带 `【新增】` / `【优化】` 前缀
## 3. 用户与角色
| 角色 | 主要目标 |
|------|----------|
| 产品经理(如王冕) | 快速把碎片诉求洗成可入库描述,并复制口令交给 YunxiaoPMapp |
| 兼岗业管 / 业务接口人 | 粘贴业务反馈素材,确认模糊项后交产品入库 |
| 评审 / 培训 | 演示「清洗 → 确认 → 上报口令」闭环,不改真实云效 |
## 4. 用户故事与故事点(业务条线说明口径)
> 合计约 **13 SP**(可按团队基准调整)。
### Epic A · 粘贴与清洗提示词(约 4 SP
#### A1 · 多模态素材粘贴与复制清洗提示词
- **角色**:产品经理
- **起点**:手里有聊天记录、口述笔记、截图或语音文件名
- **怎么运作**
1. 在步骤 1 粘贴正文;粘贴图片/语音时显示附件芯片(可移除)
2. 点「确认并复制清洗提示词」,或开启「粘贴后自动复制」
3. 将提示词粘到 Cursor按 AutoRDO 规则清洗
- **关键结果**`可`无正文仅附件时仍复制;`禁止`正文与附件皆空时复制
- **闭环**:剪贴板拿到可执行的 AutoRDO 提示词Toast 提示去 Cursor 执行
- **排期**US-01 · M
### Epic B · 粘回确认与元数据(约 6 SP
#### B1 · 解析初稿并分页答待确认
- **角色**:产品经理
- **起点**Cursor 已产出 AutoRDO 初稿 Markdown
- **怎么运作**
1. 粘回步骤 2点「解析初稿」拆成多条需求分页
2. 对每条未确认项选题或填写补充,确认后描述实时写回
3. 可手改标题与描述
- **关键结果**`可`跳过已全部确认的需求;`禁止`空答案提交
- **闭环**:各需求「待确认」清零或标明无待确认
- **排期**US-02 · L
#### B2 · 补齐上报元数据并门禁放行
- **角色**:产品经理
- **起点**:待确认已处理或无需处理
- **怎么运作**
1. 选择类型(新增/优化)、优先级;添加标签;确认提交部门与提交人(可预填)
2. 系统列出缺失项;全部齐套后出现「进入上报」
- **关键结果**`禁止`缺项时进入可复制上报
- **闭环**:全部需求确认且元数据齐套,进入步骤 3
- **排期**US-03 · M
> 判定顺序、启发式选项与写回规则全文见 **[confirm-flow.md](./confirm-flow.md)**。
### Epic C · 云效上报口令(约 3 SP
#### C1 · 复制多条记录需求提示词
- **角色**:产品经理
- **起点**:步骤 2 已放行
- **怎么运作**
1. 在步骤 3 预览 YunxiaoPMapp 多条「记录需求」口令
2. 一键复制;可选复制定稿 Markdown 存档
3. 粘到 Cursor 执行 YunxiaoPMapp本页不直连云效
- **关键结果**`禁止`未齐套时复制上报口令;每条独立、暂不推进
- **闭环**:口令已复制,可在 Cursor 侧完成入库
- **排期**US-04 · M
## 5. 功能模块说明(正向 / 逆向)
### 5.1 粘贴素材
**正向**
1. 输入或粘贴文本;粘贴图/语音出现附件芯片
2. 确认复制清洗提示词;成功 Toast
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 无正文且无附件 | 主按钮禁用 |
| 复制失败 | 错误提示,可重试 |
| 关闭自动复制 | 仅手动确认时复制 |
### 5.2 确认待确认
**正向**
1. 粘回初稿 → 解析 → 分页答题 → 补元数据 → 进入上报
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 空粘回 / 无法解析 | 错误提示,不覆盖已有草稿(解析失败时) |
| 仍有待确认或缺元数据 | 「尚不能进入上报」+ 清单 |
| 补充文案与单选并存 | 以补充文案为准 |
### 5.3 上报云效(口令)
**正向**
1. 预览完整提示词 → 一键复制 → 去 Cursor 执行
2. 可另复制定稿 Markdown待确认
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 草稿未齐套 | 预览区错误 + 缺失列表;复制上报禁用 |
| 无草稿 | 提示先完成步骤 2 |
| 本页 | **不**调用云效 API |
## 6. 关键业务逻辑(必须对齐)
### 6.1 粘回确认与上报门禁
摘要(全文见 [confirm-flow.md](./confirm-flow.md)
| 优先级 | 规则 | 说明 |
|--------|------|------|
| 1 | 先能解析出 ≥1 条需求 | 否则无法确认/上报 |
| 2 | 每条待确认须答完或初稿标明「无」 | 否则不可上报 |
| 3 | 类型、优先级、标签、提交部门、提交人齐套 | 否则不可上报 |
| 4 | 上报口令按条独立 + 暂不推进 | 不合并、不在本页推进状态 |
**数据源(产品口径)**
- 粘回 Markdown用户从 Cursor 粘贴)
- 本地启发式选项与部门/人预填(原型本地,未接真 API
- 用户当场填写的答案与元数据(会话内)
**用户可见结果**
- 通过:进入上报并可复制口令
- 拦截:缺失清单文案;复制上报不可用
## 7. 总览流程图
```mermaid
flowchart TD
A[粘贴文本/图/语音芯片] --> B[复制 AutoRDO 清洗提示词]
B --> C[Cursor 执行 AutoRDO]
C --> D[粘回初稿 Markdown]
D --> E{解析成功?}
E -->|否| D
E -->|是| F[分页答待确认]
F --> G[补齐元数据]
G --> H{全部齐套?}
H -->|否| F
H -->|是| I[复制 YunxiaoPMapp 多条记录提示词]
I --> J[Cursor 执行入库]
J --> K[云效出现需求 · 本页结束]
```
## 8. 验收清单(产品 / 测试)
**粘贴与清洗**
- [ ] 可粘贴文本;粘贴图片/语音显示附件芯片且可移除
- [ ] 「确认并复制清洗提示词」生成含 AutoRDO 指令与素材区的提示词
- [ ] 「粘贴后自动复制」开启时,粘贴文本会自动复制
- [ ] 正文与附件皆空时不可复制
**确认与门禁**
- [ ] 粘回合法初稿可解析出多条并分页
- [ ] 待确认可选题或补充;描述实时更新
- [ ] 未答完或元数据不齐时不可进入上报,并列出缺失项
- [ ] 部门/人可预填且可改;类型仅新增/优化
**上报口令**
- [ ] 齐套后可复制多条「记录需求」口令,含优先级/标签/部门/人,推进至=暂不推进
- [ ] 可复制定稿 Markdown待确认
- [ ] 页面无云效直连写单行为
**文档**
- [ ] 标注目录可打开「产品需求说明PRD」与「粘回确认与上报门禁」
- [ ] 规格与页面行为一致(见 confirm-flow.md
## 9. 交付口径
交付可运行的 **AutoRDO 需求清洗工作台** 原型:产品经理在网页完成「粘贴素材 → 复制清洗提示词 → 粘回确认待确认与元数据 → 复制 YunxiaoPMapp 多条记录需求提示词」;实际清洗与云效入库均在 Cursor Skill 侧完成,**网页不直连云效**。复杂确认与门禁规则以 `.spec/confirm-flow.md` 为准。
预览路径:`/prototypes/autorodo-web`;对象存储发布形态:`{baseUrl}/autorodo-web/index.html`
## 10. 功能变更记录
> 产品经理原型发版记录。仅记功能与业务逻辑变更;不含样式/UI/表结构。
> 由「需求定稿」关键字触发追加;日常改原型不强制每改必写。
(尚无定稿版本;首轮 AutoPRD 落盘。)

View File

@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import type { RequirementDraft, WizardStep } from './lib/types';
import { StepPaste } from './components/StepPaste';
import { StepConfirm } from './components/StepConfirm';
import { StepExport } from './components/StepExport';
const STEPS: Array<{ id: WizardStep; title: string; desc: string }> = [
{ id: 1, title: '粘贴素材', desc: '复制清洗提示词' },
{ id: 2, title: '确认待确认', desc: '粘回初稿并答题' },
{ id: 3, title: '上报云效', desc: '复制记录需求口令' },
];
export function AutorodoWebApp() {
const [step, setStep] = useState<WizardStep>(1);
const [drafts, setDrafts] = useState<RequirementDraft[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [pasteBack, setPasteBack] = useState('');
const canGoStep = (id: WizardStep): boolean => {
if (id === 1) return true;
// 步骤 2始终可进便于粘回初稿有 drafts 时同样可进
if (id === 2) return true;
// 步骤 3有草稿后可预览完整校验在 StepConfirm / StepExport
if (id === 3) return drafts.length > 0;
return false;
};
return (
<div className="arw-page" data-annotation-id="arw-page">
<aside className="arw-rail" aria-label="工作台步骤">
<div className="arw-rail-brand">
<h1>AutoRDO </h1>
<p> · · </p>
</div>
{STEPS.map((item) => {
const active = step === item.id;
const enabled = canGoStep(item.id);
return (
<button
key={item.id}
type="button"
className={`arw-step-btn${active ? ' is-active' : ''}${!enabled ? ' is-disabled' : ''}`}
aria-current={active ? 'step' : undefined}
aria-disabled={!enabled}
disabled={!enabled}
onClick={() => {
if (enabled) setStep(item.id);
}}
>
<span className="arw-step-idx" aria-hidden>
{item.id}
</span>
<span className="arw-step-meta">
<span className="arw-step-title">{item.title}</span>
<span className="arw-step-desc">{item.desc}</span>
</span>
</button>
);
})}
</aside>
<main className="arw-main">
{step === 1 && <StepPaste />}
{step === 2 && (
<StepConfirm
pasteBack={pasteBack}
onPasteBackChange={setPasteBack}
drafts={drafts}
onDraftsChange={setDrafts}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
onGoExport={() => setStep(3)}
/>
)}
{step === 3 && <StepExport drafts={drafts} />}
</main>
</div>
);
}
export default AutorodoWebApp;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,406 @@
import React, { useMemo, useState } from 'react';
import type { Priority, RequirementDraft, RequirementMeta } from '../lib/types';
import { parseAutorodoDraft } from '../lib/parseDraft';
import { enrichPendingsWithQuestions } from '../lib/pendingQuestions';
import { applyPendingAnswer } from '../lib/applyAnswer';
import { inferSubmitMeta } from '../lib/inferMeta';
import { metaComplete } from '../lib/buildRecordPrompt';
const TITLE_KINDS: Array<NonNullable<RequirementMeta['titleKind']>> = ['新增', '优化'];
const PRIORITIES: Priority[] = ['紧急', '高', '中', '低'];
export type StepConfirmProps = {
pasteBack: string;
onPasteBackChange: (value: string) => void;
drafts: RequirementDraft[];
onDraftsChange: (drafts: RequirementDraft[]) => void;
activeIndex: number;
onActiveIndexChange: (index: number) => void;
onGoExport: () => void;
};
function patchDraft(
drafts: RequirementDraft[],
index: number,
patch: (d: RequirementDraft) => RequirementDraft,
): RequirementDraft[] {
return drafts.map((d, i) => (i === index ? patch(d) : d));
}
function missingHints(drafts: RequirementDraft[]): string[] {
if (drafts.length === 0) return ['请先粘回并解析 AutoRDO 初稿'];
const hints: string[] = [];
drafts.forEach((d, i) => {
const label = `需求 ${i + 1}`;
if (!d.confirmed) {
const left = d.pendings.filter((p) => !p.resolved).length;
hints.push(`${label}:还有 ${left} 项待确认`);
}
if (!metaComplete(d)) {
const m = d.meta;
const miss: string[] = [];
if (!m.titleKind) miss.push('类型');
if (!m.priority) miss.push('优先级');
if (m.tags.length === 0) miss.push('标签');
if (!m.submitDept.trim()) miss.push('提交部门');
if (!m.submitter.trim()) miss.push('提交人');
if (miss.length) hints.push(`${label}:缺少 ${miss.join('、')}`);
}
});
return hints;
}
export function StepConfirm({
pasteBack,
onPasteBackChange,
drafts,
onDraftsChange,
activeIndex,
onActiveIndexChange,
onGoExport,
}: StepConfirmProps) {
const [parseError, setParseError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState('');
const [supplement, setSupplement] = useState('');
const [tagInput, setTagInput] = useState('');
const safeIndex = drafts.length === 0 ? 0 : Math.min(activeIndex, drafts.length - 1);
const current = drafts[safeIndex] ?? null;
const pending = current?.pendings.find((p) => !p.resolved) ?? null;
const allReady = useMemo(
() => drafts.length > 0 && drafts.every((d) => d.confirmed && metaComplete(d)),
[drafts],
);
const hints = useMemo(() => missingHints(drafts), [drafts]);
const handleParse = () => {
const md = pasteBack.trim();
if (!md) {
setParseError('请先粘贴 AutoRDO 初稿');
return;
}
const parsed = parseAutorodoDraft(md);
if (parsed.length === 0) {
setParseError('未能解析出需求,请检查初稿格式(需含 **标题** / 原始诉求)');
return;
}
const next = parsed.map((draft) => {
const enriched = enrichPendingsWithQuestions(draft);
const inferred = inferSubmitMeta(`${enriched.title}\n${enriched.description}`);
return {
...enriched,
meta: {
...enriched.meta,
submitDept: enriched.meta.submitDept || inferred.submitDept,
submitter: enriched.meta.submitter || inferred.submitter,
},
};
});
setParseError(null);
setSelectedOption('');
setSupplement('');
setTagInput('');
onDraftsChange(next);
onActiveIndexChange(0);
};
const updateCurrent = (patch: (d: RequirementDraft) => RequirementDraft) => {
if (!current) return;
onDraftsChange(patchDraft(drafts, safeIndex, patch));
};
const updateMeta = (partial: Partial<RequirementMeta>) => {
updateCurrent((d) => ({ ...d, meta: { ...d.meta, ...partial } }));
};
const handleConfirmAnswer = () => {
if (!current || !pending) return;
const answer = supplement.trim() || selectedOption.trim();
if (!answer) return;
const next = applyPendingAnswer(current, pending.id, answer);
onDraftsChange(patchDraft(drafts, safeIndex, () => next));
setSelectedOption('');
setSupplement('');
};
const addTag = () => {
const tag = tagInput.trim();
if (!tag || !current) return;
if (current.meta.tags.includes(tag)) {
setTagInput('');
return;
}
updateMeta({ tags: [...current.meta.tags, tag] });
setTagInput('');
};
const removeTag = (tag: string) => {
if (!current) return;
updateMeta({ tags: current.meta.tags.filter((t) => t !== tag) });
};
const switchDraft = (index: number) => {
onActiveIndexChange(index);
setSelectedOption('');
setSupplement('');
setTagInput('');
};
return (
<section className="arw-panel arw-panel--wide" data-annotation-id="arw-step-confirm" aria-labelledby="arw-confirm-title">
<header className="arw-panel-header">
<h2 id="arw-confirm-title"></h2>
<p> AutoRDO 稿</p>
</header>
<div className="arw-field">
<label className="arw-label" htmlFor="arw-paste-back">
AutoRDO 稿
</label>
<textarea
id="arw-paste-back"
className="arw-textarea"
value={pasteBack}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onPasteBackChange(e.target.value)}
placeholder="将 Cursor 跑完 AutoRDO 后的初稿 Markdown 粘到此处…"
spellCheck={false}
/>
</div>
<div className="arw-actions">
<button type="button" className="arw-btn arw-btn--primary" onClick={handleParse}>
稿
</button>
</div>
{parseError ? (
<p className="arw-status is-error" role="alert">
{parseError}
</p>
) : null}
{drafts.length > 0 ? (
<>
<div className="arw-pills" role="tablist" aria-label="需求分页">
{drafts.map((d, i) => {
const active = i === safeIndex;
return (
<button
key={d.index}
type="button"
role="tab"
aria-selected={active}
className={`arw-pill${active ? ' is-active' : ''}${d.confirmed ? ' is-done' : ''}`}
onClick={() => switchDraft(i)}
>
{i + 1}/{drafts.length}
</button>
);
})}
</div>
{current ? (
<>
<div className="arw-confirm-grid">
<div className="arw-confirm-col">
<div className="arw-field">
<label className="arw-label" htmlFor="arw-draft-title">
</label>
<input
id="arw-draft-title"
className="arw-input"
type="text"
value={current.title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateCurrent((d) => ({ ...d, title: e.target.value }))
}
/>
</div>
<div className="arw-field">
<span className="arw-label">稿</span>
<pre className="arw-desc-live">{current.description || '(空)'}</pre>
</div>
</div>
<div className="arw-confirm-col">
<div className="arw-field">
<span className="arw-label"></span>
{pending ? (
<div className="arw-pending-card">
<p className="arw-pending-q">{pending.question}</p>
<div className="arw-radio-list" role="radiogroup" aria-label="待确认选项">
{pending.options.map((opt) => (
<label key={opt} className="arw-radio">
<input
type="radio"
name={`arw-pending-${pending.id}`}
checked={selectedOption === opt}
onChange={() => setSelectedOption(opt)}
/>
<span>{opt}</span>
</label>
))}
</div>
<div className="arw-field" style={{ marginBottom: 0 }}>
<label className="arw-label" htmlFor="arw-pending-supplement">
</label>
<input
id="arw-pending-supplement"
className="arw-input"
type="text"
value={supplement}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSupplement(e.target.value)}
placeholder="可直接填写答案,将优先于单选"
/>
</div>
<div className="arw-actions">
<button
type="button"
className="arw-btn arw-btn--primary"
onClick={handleConfirmAnswer}
disabled={!supplement.trim() && !selectedOption.trim()}
>
</button>
</div>
</div>
) : (
<p className="arw-empty-hint"></p>
)}
</div>
</div>
</div>
<fieldset className="arw-meta">
<legend className="arw-label"></legend>
<div className="arw-meta-row">
<span className="arw-label"></span>
<div className="arw-seg" role="group" aria-label="需求类型">
{TITLE_KINDS.map((kind) => (
<button
key={kind}
type="button"
className={`arw-seg-btn${current.meta.titleKind === kind ? ' is-active' : ''}`}
onClick={() => updateMeta({ titleKind: kind })}
>
{kind}
</button>
))}
</div>
</div>
<div className="arw-meta-row">
<span className="arw-label"></span>
<div className="arw-seg" role="group" aria-label="优先级">
{PRIORITIES.map((p) => (
<button
key={p}
type="button"
className={`arw-seg-btn${current.meta.priority === p ? ' is-active' : ''}`}
onClick={() => updateMeta({ priority: p })}
>
{p}
</button>
))}
</div>
</div>
<div className="arw-field">
<span className="arw-label"></span>
<div className="arw-chips">
{current.meta.tags.map((tag) => (
<span key={tag} className="arw-chip">
<span className="arw-chip-name">{tag}</span>
<button
type="button"
className="arw-chip-remove"
aria-label={`移除标签 ${tag}`}
onClick={() => removeTag(tag)}
>
×
</button>
</span>
))}
</div>
<div className="arw-tag-add">
<input
className="arw-input"
type="text"
value={tagInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
placeholder="输入标签后添加"
aria-label="新增标签"
/>
<button type="button" className="arw-btn" onClick={addTag} disabled={!tagInput.trim()}>
</button>
</div>
</div>
<div className="arw-meta-grid">
<div className="arw-field">
<label className="arw-label" htmlFor="arw-submit-dept">
</label>
<input
id="arw-submit-dept"
className="arw-input"
type="text"
value={current.meta.submitDept}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateMeta({ submitDept: e.target.value })
}
/>
</div>
<div className="arw-field">
<label className="arw-label" htmlFor="arw-submitter">
</label>
<input
id="arw-submitter"
className="arw-input"
type="text"
value={current.meta.submitter}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateMeta({ submitter: e.target.value })
}
/>
</div>
</div>
</fieldset>
</>
) : null}
</>
) : null}
<div className="arw-export-gate">
{allReady ? (
<button type="button" className="arw-btn arw-btn--primary" onClick={onGoExport}>
</button>
) : (
<div className="arw-missing" role="status">
<p className="arw-missing-title"></p>
<ul>
{hints.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</div>
)}
</div>
</section>
);
}
export default StepConfirm;

View File

@@ -0,0 +1,179 @@
import React, { useCallback, useMemo, useState } from 'react';
import type { RequirementDraft } from '../lib/types';
import { buildYunxiaoRecordPrompt, metaComplete } from '../lib/buildRecordPrompt';
import { copyText } from '../lib/clipboard';
const TOAST_MS = 2800;
export type StepExportProps = {
drafts: RequirementDraft[];
};
/** 定稿 Markdown多份已确认 ## 原始诉求AutoRDO待确认无 */
export function buildConfirmedMarkdown(drafts: RequirementDraft[]): string {
const blocks = drafts.map((d, i) =>
[
`### ${i + 1}`,
'## 原始诉求AutoRDO',
'',
`**标题**${d.title}`,
'',
'**描述**',
d.description,
'',
'待确认:',
'无',
].join('\n'),
);
return [`${drafts.length}`, '', blocks.join('\n\n')].join('\n');
}
function missingFieldHints(drafts: RequirementDraft[]): string[] {
if (drafts.length === 0) return ['暂无需求草稿,请先完成步骤 2'];
const hints: string[] = [];
drafts.forEach((d, i) => {
const label = `需求 ${i + 1}`;
if (!d.confirmed) {
const left = d.pendings.filter((p) => !p.resolved).length;
hints.push(`${label}:还有 ${left} 项待确认`);
}
if (!metaComplete(d)) {
const m = d.meta;
const miss: string[] = [];
if (!m.titleKind) miss.push('类型');
if (!m.priority) miss.push('优先级');
if (m.tags.length === 0) miss.push('标签');
if (!m.submitDept.trim()) miss.push('提交部门');
if (!m.submitter.trim()) miss.push('提交人');
if (miss.length) hints.push(`${label}:缺少 ${miss.join('、')}`);
}
});
return hints;
}
export function StepExport({ drafts }: StepExportProps) {
const [toast, setToast] = useState<string | null>(null);
const [status, setStatus] = useState<{ kind: 'ok' | 'error'; message: string } | null>(null);
const [copying, setCopying] = useState<'prompt' | 'markdown' | null>(null);
const preview = useMemo(() => {
try {
return { ok: true as const, text: buildYunxiaoRecordPrompt(drafts), hints: [] as string[] };
} catch (err) {
const message = err instanceof Error ? err.message : '生成上报提示词失败';
return { ok: false as const, text: '', message, hints: missingFieldHints(drafts) };
}
}, [drafts]);
const showToast = useCallback((message: string) => {
setToast(message);
window.setTimeout(() => {
setToast((cur: string | null) => (cur === message ? null : cur));
}, TOAST_MS);
}, []);
const handleCopyPrompt = useCallback(async () => {
if (!preview.ok) {
setStatus({ kind: 'error', message: preview.message });
return;
}
setCopying('prompt');
try {
await copyText(preview.text);
const message = '已复制上报提示词,请粘到 Cursor 跑 YunxiaoPMapp';
setStatus({ kind: 'ok', message });
showToast(message);
} catch {
setStatus({ kind: 'error', message: '复制失败,请检查剪贴板权限后重试' });
setToast(null);
} finally {
setCopying(null);
}
}, [preview, showToast]);
const handleCopyMarkdown = useCallback(async () => {
if (drafts.length === 0) {
setStatus({ kind: 'error', message: '暂无需求草稿可复制' });
return;
}
setCopying('markdown');
try {
await copyText(buildConfirmedMarkdown(drafts));
const message = '已复制定稿 Markdown';
setStatus({ kind: 'ok', message });
showToast(message);
} catch {
setStatus({ kind: 'error', message: '复制失败,请检查剪贴板权限后重试' });
setToast(null);
} finally {
setCopying(null);
}
}, [drafts, showToast]);
return (
<section className="arw-panel arw-panel--wide" data-annotation-id="arw-step-export" aria-labelledby="arw-export-title">
<header className="arw-panel-header">
<h2 id="arw-export-title"></h2>
<p> YunxiaoPMapp 稿 Markdown </p>
</header>
<div className="arw-field">
<span className="arw-label" id="arw-export-preview-label">
</span>
{preview.ok ? (
<pre className="arw-export-preview" aria-labelledby="arw-export-preview-label" tabIndex={0}>
{preview.text}
</pre>
) : (
<div className="arw-export-error" role="alert">
<p className="arw-export-error-title">{preview.message}</p>
{preview.hints.length > 0 ? (
<ul>
{preview.hints.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
) : null}
<p className="arw-export-error-hint"> 2 </p>
</div>
)}
</div>
<div className="arw-actions">
<button
type="button"
className="arw-btn arw-btn--primary"
onClick={() => void handleCopyPrompt()}
disabled={copying !== null || !preview.ok}
>
{copying === 'prompt' ? '复制中…' : '一键复制上报提示词'}
</button>
<button
type="button"
className="arw-btn"
onClick={() => void handleCopyMarkdown()}
disabled={copying !== null || drafts.length === 0}
>
{copying === 'markdown' ? '复制中…' : '复制定稿 Markdown'}
</button>
</div>
{status ? (
<p className={`arw-status${status.kind === 'error' ? ' is-error' : ''}`} role="status">
{status.message}
</p>
) : (
<p className="arw-status" aria-live="polite" />
)}
{toast ? (
<div className="arw-toast" role="status" aria-live="polite">
{toast}
</div>
) : null}
</section>
);
}
export default StepExport;

View File

@@ -0,0 +1,171 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { buildCleanPrompt } from '../lib/buildCleanPrompt';
import { copyText } from '../lib/clipboard';
const TOAST_MS = 2800;
const MEDIA_RE = /^(image|audio)\//;
function collectMediaNames(fileList: FileList | null | undefined): string[] {
if (!fileList || fileList.length === 0) return [];
return Array.from(fileList)
.filter((f) => MEDIA_RE.test(f.type))
.map((f) => f.name)
.filter(Boolean);
}
export function StepPaste() {
const [text, setText] = useState('');
const [attachmentNames, setAttachmentNames] = useState<string[]>([]);
const [autoCopyOnPaste, setAutoCopyOnPaste] = useState(false);
const [status, setStatus] = useState<{ kind: 'ok' | 'error'; message: string } | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [copying, setCopying] = useState(false);
const autoCopyRef = useRef(autoCopyOnPaste);
const attachmentNamesRef = useRef(attachmentNames);
useEffect(() => {
autoCopyRef.current = autoCopyOnPaste;
}, [autoCopyOnPaste]);
useEffect(() => {
attachmentNamesRef.current = attachmentNames;
}, [attachmentNames]);
const showCopiedFeedback = useCallback(() => {
const message = '已复制,请粘到 Cursor 跑 AutoRDO';
setStatus({ kind: 'ok', message });
setToast(message);
window.setTimeout(() => {
setToast((cur: string | null) => (cur === message ? null : cur));
}, TOAST_MS);
}, []);
const runCopy = useCallback(
async (payload: { text: string; attachmentNames: string[] }) => {
const prompt = buildCleanPrompt(payload);
setCopying(true);
try {
await copyText(prompt);
showCopiedFeedback();
} catch {
setStatus({ kind: 'error', message: '复制失败,请检查剪贴板权限后重试' });
setToast(null);
} finally {
setCopying(false);
}
},
[showCopiedFeedback],
);
const handleConfirmCopy = useCallback(() => {
void runCopy({ text, attachmentNames });
}, [attachmentNames, runCopy, text]);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const mediaNames = collectMediaNames(e.clipboardData.files);
let nextAttachments = attachmentNamesRef.current;
if (mediaNames.length > 0) {
nextAttachments = [...attachmentNamesRef.current];
for (const name of mediaNames) {
if (!nextAttachments.includes(name)) nextAttachments.push(name);
}
setAttachmentNames(nextAttachments);
}
const pastedText = e.clipboardData.getData('text/plain');
if (!autoCopyRef.current || !pastedText) return;
const el = e.currentTarget;
window.setTimeout(() => {
const nextText = el.value;
void runCopy({ text: nextText, attachmentNames: nextAttachments });
}, 0);
},
[runCopy],
);
const removeAttachment = useCallback((name: string) => {
setAttachmentNames((prev: string[]) => prev.filter((n: string) => n !== name));
}, []);
return (
<section className="arw-panel" data-annotation-id="arw-step-paste" aria-labelledby="arw-paste-title">
<header className="arw-panel-header">
<h2 id="arw-paste-title"></h2>
<p> AutoRDO </p>
</header>
<div className="arw-field">
<label className="arw-label" htmlFor="arw-paste-input">
</label>
<textarea
id="arw-paste-input"
className="arw-textarea"
value={text}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
onPaste={handlePaste}
placeholder="在此粘贴聊天记录、口述笔记、表格行等…也可粘贴图片/语音(将显示为附件芯片)"
spellCheck={false}
/>
</div>
{attachmentNames.length > 0 ? (
<div className="arw-field" aria-label="附件列表">
<span className="arw-label"></span>
<div className="arw-chips">
{attachmentNames.map((name: string) => (
<span key={name} className="arw-chip" title={name}>
<span className="arw-chip-name">{name}</span>
<button
type="button"
className="arw-chip-remove"
aria-label={`移除附件 ${name}`}
onClick={() => removeAttachment(name)}
>
×
</button>
</span>
))}
</div>
</div>
) : null}
<div className="arw-actions">
<button
type="button"
className="arw-btn arw-btn--primary"
onClick={handleConfirmCopy}
disabled={copying || (!text.trim() && attachmentNames.length === 0)}
>
{copying ? '复制中…' : '确认并复制清洗提示词'}
</button>
<label className="arw-check">
<input
type="checkbox"
checked={autoCopyOnPaste}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAutoCopyOnPaste(e.target.checked)}
/>
</label>
</div>
{status ? (
<p className={`arw-status${status.kind === 'error' ? ' is-error' : ''}`} role="status">
{status.message}
</p>
) : (
<p className="arw-status" aria-live="polite" />
)}
{toast ? (
<div className="arw-toast" role="status" aria-live="polite">
{toast}
</div>
) : null}
</section>
);
}
export default StepPaste;

View File

@@ -0,0 +1,37 @@
/**
* @name AutoRDO 需求清洗工作台
* 粘贴素材 · 确认待确认 · 复制云效上报提示词
*/
import './style.css';
import React, { useMemo } from 'react';
import {
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import annotationSourceDocument from './annotation-source.json';
import { AutorodoWebApp } from './AutorodoWebApp';
export default function AutorodoWebEntry() {
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: 'main',
}),
[],
);
return (
<>
<AutorodoWebApp />
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</>
);
}

View File

@@ -0,0 +1,12 @@
export type CleanRequest = { text: string; attachmentNames: string[] };
export type RecordRequest = { drafts: import('./types').RequirementDraft[] };
/** MVP未接组织 Agent后续替换实现即可 */
export const agentAdapter = {
async clean(_req: CleanRequest): Promise<null> {
return null;
},
async record(_req: RecordRequest): Promise<null> {
return null;
},
};

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { applyPendingAnswer } from './applyAnswer';
import type { RequirementDraft } from './types';
const base: RequirementDraft = {
index: 0,
title: '合同台账批量导出',
description: '在租赁合同台账支持批量导出\n导出范围待确认',
pendings: [
{
id: 'r0-p0',
raw: '导出范围未说明',
question: '导出范围指什么?',
options: ['当前筛选结果', '全部合同', '仅勾选行'],
resolved: false,
},
],
meta: { priority: '', tags: [], submitDept: '', submitter: '', titleKind: '' },
confirmed: false,
};
describe('applyPendingAnswer', () => {
it('merges answer into description and marks pending resolved', () => {
const next = applyPendingAnswer(base, 'r0-p0', '当前筛选结果');
expect(next.pendings[0].resolved).toBe(true);
expect(next.pendings[0].answer).toBe('当前筛选结果');
expect(next.description).toContain('当前筛选结果');
expect(next.description).not.toMatch(/导出范围:待确认/);
expect(next.confirmed).toBe(true);
});
it('leaves draft unchanged when answer is empty', () => {
const next = applyPendingAnswer(base, 'r0-p0', ' ');
expect(next).toBe(base);
expect(next.pendings[0].resolved).toBe(false);
expect(next.description).toBe(base.description);
expect(next.confirmed).toBe(false);
});
it('replaces only the scoped placeholder when multiple 待确认 lines exist', () => {
const multi: RequirementDraft = {
...base,
description: '导出范围:待确认\n导出格式待确认',
pendings: [
{
id: 'r0-p0',
raw: '导出范围未说明',
question: '导出范围指什么?',
options: ['当前筛选结果', '全部合同', '仅勾选行'],
resolved: false,
},
{
id: 'r0-p1',
raw: '导出格式未说明',
question: '导出格式指什么?',
options: ['Excel', 'CSV', 'PDF'],
resolved: false,
},
],
confirmed: false,
};
const next = applyPendingAnswer(multi, 'r0-p0', '当前筛选结果');
expect(next.description).toBe('导出范围:当前筛选结果\n导出格式待确认');
expect(next.pendings[0].resolved).toBe(true);
expect(next.pendings[1].resolved).toBe(false);
expect(next.confirmed).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import type { RequirementDraft } from './types';
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function applyPendingAnswer(
draft: RequirementDraft,
pendingId: string,
answer: string,
): RequirementDraft {
const trimmed = answer.trim();
if (!trimmed) {
return draft;
}
const hit = draft.pendings.find((p) => p.id === pendingId);
if (!hit) {
return draft;
}
const pendings = draft.pendings.map((p) =>
p.id === pendingId ? { ...p, resolved: true, answer: trimmed } : p,
);
const label = hit.raw.replace(/未说明|待确认|不清楚/g, '').trim() || hit.raw;
let description = draft.description;
const labelPlaceholder = new RegExp(`${escapeRegExp(label)}\\s*[:]\\s*待确认`);
if (labelPlaceholder.test(description)) {
description = description.replace(labelPlaceholder, `${label}${trimmed}`);
} else if (description.includes('待确认')) {
description = description.replace('待确认', trimmed);
} else {
description = `${description.replace(/\s+$/, '')}\n${label}${trimmed}`;
}
const confirmed = pendings.every((p) => p.resolved);
return { ...draft, description, pendings, confirmed };
}

View File

@@ -0,0 +1,18 @@
export function buildCleanPrompt(payload: {
text: string;
attachmentNames: string[];
}): string {
const files =
payload.attachmentNames.length > 0
? `附件:${payload.attachmentNames.join('、')}(请用多模态识读图/语音内容)`
: '附件:无';
return [
'AutoRDO',
'请按 AutoRDO 规则清洗下列素材:多条独立诉求拆成多份;提炼标题与书面描述;不确定处标「待确认」;描述无结尾句号。',
files,
'',
'—— 素材开始 ——',
payload.text.trim() || '(仅附件,无正文)',
'—— 素材结束 ——',
].join('\n');
}

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { buildYunxiaoRecordPrompt } from './buildRecordPrompt';
import type { RequirementDraft } from './types';
const one: RequirementDraft = {
index: 0,
title: '合同台账批量导出',
description: '在租赁合同台账支持按筛选结果批量导出 Excel',
pendings: [],
meta: {
priority: '中',
tags: ['租赁合同'],
submitDept: '产品部',
submitter: '王冕',
titleKind: '新增',
},
confirmed: true,
};
describe('buildYunxiaoRecordPrompt', () => {
it('includes priority tags dept submitter', () => {
const text = buildYunxiaoRecordPrompt([one]);
expect(text).toContain('YunxiaoPMapp');
expect(text).toContain('记录需求:【新增】合同台账批量导出');
expect(text).toContain('优先级=中');
expect(text).toContain('标签=租赁合同');
expect(text).toContain('提交部门=产品部');
expect(text).toContain('提交人=王冕');
expect(text).toContain('推进至=暂不推进');
});
it('throws when meta incomplete', () => {
expect(() =>
buildYunxiaoRecordPrompt([{ ...one, meta: { ...one.meta, submitter: '' } }]),
).toThrow(/提交人/);
});
});

View File

@@ -0,0 +1,26 @@
import type { RequirementDraft } from './types';
export function metaComplete(d: RequirementDraft): boolean {
const m = d.meta;
return Boolean(
m.priority && m.tags.length > 0 && m.submitDept.trim() && m.submitter.trim() && m.titleKind,
);
}
export function buildYunxiaoRecordPrompt(drafts: RequirementDraft[]): string {
for (const d of drafts) {
if (!d.confirmed) throw new Error(`需求 ${d.index + 1} 仍有未确认项`);
if (!metaComplete(d)) throw new Error(`需求 ${d.index + 1} 缺少优先级/标签/提交部门/提交人/类型`);
}
const lines = drafts.map((d) => {
const title = `${d.meta.titleKind}${d.title.replace(/^【(?:新增|优化)】/, '')}`;
const tags = d.meta.tags.join('、');
return `记录需求:${title};描述=${d.description.replace(/\n/g, ' ')};优先级=${d.meta.priority};标签=${tags};提交部门=${d.meta.submitDept};提交人=${d.meta.submitter};推进至=暂不推进`;
});
return [
'YunxiaoPMapp',
'请按下列条目依次记录需求;每条保持独立,不要合并。',
'',
...lines,
].join('\n');
}

View File

@@ -0,0 +1,3 @@
export async function copyText(text: string): Promise<void> {
await navigator.clipboard.writeText(text);
}

View File

@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
import { inferSubmitMeta } from './inferMeta';
describe('inferSubmitMeta', () => {
it('detects dept and person from free text', () => {
const r = inferSubmitMeta('业务管理组张三反馈:合同导出不好用');
expect(r.submitDept).toMatch(/业务/);
expect(r.submitter).toContain('张三');
});
});

View File

@@ -0,0 +1,29 @@
const DEPT_PATTERNS = [
/(?:提交部门|部门)[:]\s*([^\s,;]+)/,
/(业务管理组|产品部|安全部|运营部|财务部|技术部)/,
];
const PERSON_PATTERNS = [
/(?:提交人|反馈人|提出人)[:]\s*([^\s,;]+)/,
/@([\u4e00-\u9fa5]{2,4})/,
/([\u4e00-\u9fa5]{2,4})反馈/,
];
export function inferSubmitMeta(text: string): { submitDept: string; submitter: string } {
let submitDept = '';
let submitter = '';
for (const re of DEPT_PATTERNS) {
const m = text.match(re);
if (m?.[1]) {
submitDept = m[1];
break;
}
}
for (const re of PERSON_PATTERNS) {
const m = text.match(re);
if (m?.[1]) {
submitter = m[1];
break;
}
}
return { submitDept, submitter };
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { parseAutorodoDraft } from './parseDraft';
const SAMPLE = `共 2 条
### 1
## 原始诉求AutoRDO
**标题**:合同台账批量导出
**描述**
在租赁合同台账支持批量导出
待确认:
- 导出范围未说明
### 2
## 原始诉求AutoRDO
**标题**:证照到期提醒
**描述**
调整证照到期提醒口径
待确认:
`;
describe('parseAutorodoDraft', () => {
it('splits multiple requirements and pending items', () => {
const list = parseAutorodoDraft(SAMPLE);
expect(list).toHaveLength(2);
expect(list[0].title).toContain('合同台账');
expect(list[0].pendings).toHaveLength(1);
expect(list[0].pendings[0].raw).toContain('导出范围');
expect(list[1].pendings).toHaveLength(0);
expect(list[1].confirmed).toBe(true);
});
});

View File

@@ -0,0 +1,52 @@
import type { RequirementDraft, PendingItem, RequirementMeta } from './types';
const emptyMeta = (): RequirementMeta => ({
priority: '',
tags: [],
submitDept: '',
submitter: '',
titleKind: '',
});
function parsePendings(block: string, reqIndex: number): PendingItem[] {
const m = block.match(/待确认[:]\s*([\s\S]*?)(?=\n###|\n##\s*原始诉求|$)/);
if (!m) return [];
const body = m[1].trim();
if (!body || body === '无') return [];
const lines = body
.split('\n')
.map((l) => l.replace(/^[-*•\d.、)\s]+/, '').trim())
.filter(Boolean);
return lines.map((raw, i) => ({
id: `r${reqIndex}-p${i}`,
raw,
question: raw,
options: [],
resolved: false,
}));
}
export function parseAutorodoDraft(md: string): RequirementDraft[] {
const parts = md.split(/(?=^###\s*\d+)/m).filter((p) => /原始诉求AutoRDO/.test(p) || /\*\*标题\*\*/.test(p));
const chunks =
parts.length > 0
? parts
: md.includes('**标题**')
? [md]
: [];
return chunks.map((chunk, index) => {
const title = (chunk.match(/\*\*标题\*\*[:]\s*(.+)/)?.[1] ?? '').trim();
const descMatch = chunk.match(/\*\*描述\*\*[:]\s*\n?([\s\S]*?)(?=\n待确认|$)/);
const description = (descMatch?.[1] ?? '').trim();
const pendings = parsePendings(chunk, index);
return {
index,
title,
description,
pendings,
meta: emptyMeta(),
confirmed: pendings.length === 0,
};
});
}

View File

@@ -0,0 +1,30 @@
import type { RequirementDraft } from './types';
export function buildOptionsForPending(raw: string): { question: string; options: string[] } {
if (/导出范围|范围/.test(raw)) {
return {
question: '「导出范围」指什么?',
options: ['当前筛选结果', '全部数据', '仅勾选行'],
};
}
if (/模块|归属/.test(raw)) {
return {
question: '归属哪个业务模块?',
options: ['租赁合同管理', '车辆管理', '证照管理', '还车应结款'],
};
}
return {
question: raw.endsWith('') || raw.endsWith('?') ? raw : `${raw}`,
options: ['确认按原文理解', '暂不纳入本需求', '需文字补充'],
};
}
export function enrichPendingsWithQuestions(draft: RequirementDraft): RequirementDraft {
return {
...draft,
pendings: draft.pendings.map((p) => {
const q = buildOptionsForPending(p.raw);
return { ...p, question: q.question, options: q.options };
}),
};
}

View File

@@ -0,0 +1,29 @@
export type Priority = '紧急' | '高' | '中' | '低';
export type RequirementMeta = {
priority: Priority | '';
tags: string[];
submitDept: string;
submitter: string;
titleKind: '新增' | '优化' | '';
};
export type PendingItem = {
id: string;
raw: string;
question: string;
options: string[];
resolved: boolean;
answer?: string;
};
export type RequirementDraft = {
index: number;
title: string;
description: string;
pendings: PendingItem[];
meta: RequirementMeta;
confirmed: boolean;
};
export type WizardStep = 1 | 2 | 3;

View File

@@ -0,0 +1,826 @@
/* AutoRDO 需求清洗工作台 — ONE-OS Linear green tokens (DESIGN.md) */
.arw-page {
--arw-primary: #32a06e;
--arw-primary-hover: #3fb87c;
--arw-primary-focus: #2b9260;
--arw-primary-soft: rgba(50, 160, 110, 0.1);
--arw-ink: #18181b;
--arw-body: #52525b;
--arw-muted: #71717a;
--arw-muted-soft: #a1a1aa;
--arw-canvas: #f5f6f6;
--arw-surface: #ffffff;
--arw-hairline: #e5e7eb;
--arw-hairline-strong: #d4d4d8;
--arw-success: #27a644;
--arw-on-primary: #ffffff;
--arw-radius-control: 8px;
--arw-radius-card: 12px;
--arw-font: Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC",
"Microsoft YaHei", sans-serif;
--arw-touch: 44px;
box-sizing: border-box;
min-height: 100vh;
margin: 0;
display: flex;
background: var(--arw-canvas);
color: var(--arw-ink);
font-family: var(--arw-font);
font-size: 14px;
line-height: 1.5;
}
.arw-page *,
.arw-page *::before,
.arw-page *::after {
box-sizing: border-box;
}
.arw-rail {
width: 220px;
flex-shrink: 0;
padding: 24px 16px;
border-right: 1px solid var(--arw-hairline);
background: var(--arw-surface);
display: flex;
flex-direction: column;
gap: 8px;
}
.arw-rail-brand {
margin: 0 0 16px;
padding: 0 8px;
}
.arw-rail-brand h1 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-rail-brand p {
margin: 0;
font-size: 12px;
color: var(--arw-muted);
}
.arw-step-btn {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
min-height: var(--arw-touch);
padding: 10px 12px;
border: 1px solid transparent;
border-radius: var(--arw-radius-control);
background: transparent;
color: var(--arw-body);
text-align: left;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.arw-step-btn:hover {
background: var(--arw-primary-soft);
}
.arw-step-btn:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-step-btn.is-active {
background: var(--arw-primary-soft);
border-color: var(--arw-primary);
color: var(--arw-primary-focus);
}
.arw-step-idx {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
background: var(--arw-hairline);
color: var(--arw-muted);
}
.arw-step-btn.is-active .arw-step-idx {
background: var(--arw-primary);
color: var(--arw-on-primary);
}
.arw-step-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.arw-step-title {
font-size: 14px;
font-weight: 600;
color: inherit;
}
.arw-step-desc {
font-size: 12px;
color: var(--arw-muted);
}
.arw-main {
flex: 1;
min-width: 0;
padding: 24px 32px 32px;
}
.arw-panel {
max-width: 720px;
background: var(--arw-surface);
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-card);
padding: 24px;
box-shadow: 0 1px 2px rgba(24, 24, 27, 0.06);
}
.arw-panel--wide {
max-width: 960px;
}
.arw-step-btn.is-disabled,
.arw-step-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.arw-step-btn.is-disabled:hover,
.arw-step-btn:disabled:hover {
background: transparent;
}
.arw-input {
width: 100%;
min-height: var(--arw-touch);
padding: 0 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
font-family: inherit;
font-size: 14px;
line-height: 1.4;
}
.arw-input:focus,
.arw-input:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 20px 0 16px;
}
.arw-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--arw-touch);
padding: 0 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: 9999px;
background: var(--arw-surface);
color: var(--arw-body);
font-family: inherit;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.arw-pill:hover {
background: var(--arw-primary-soft);
}
.arw-pill:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-pill.is-active {
border-color: var(--arw-primary);
background: var(--arw-primary-soft);
color: var(--arw-primary-focus);
}
.arw-pill.is-done:not(.is-active) {
border-color: rgba(39, 166, 68, 0.45);
color: var(--arw-success);
}
.arw-confirm-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 8px;
}
.arw-confirm-col {
min-width: 0;
}
.arw-desc-live {
margin: 0;
min-height: 120px;
max-height: 280px;
overflow: auto;
padding: 12px 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.arw-pending-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #fafafa;
}
.arw-pending-q {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-radio-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.arw-radio {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--arw-touch);
padding: 0 4px;
color: var(--arw-body);
font-size: 14px;
cursor: pointer;
}
.arw-radio input {
width: 18px;
height: 18px;
accent-color: var(--arw-primary);
cursor: pointer;
}
.arw-empty-hint {
margin: 0;
padding: 16px;
border: 1px dashed var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
color: var(--arw-muted);
font-size: 14px;
text-align: center;
background: #f6f7f7;
}
.arw-meta {
margin: 16px 0 0;
padding: 16px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #fafafa;
}
.arw-meta legend {
padding: 0 4px;
}
.arw-meta-row {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.arw-seg {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.arw-seg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--arw-touch);
min-width: var(--arw-touch);
padding: 0 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-body);
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.arw-seg-btn:hover {
background: var(--arw-primary-soft);
}
.arw-seg-btn:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-seg-btn.is-active {
border-color: var(--arw-primary);
background: var(--arw-primary-soft);
color: var(--arw-primary-focus);
font-weight: 600;
}
.arw-tag-add {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.arw-tag-add .arw-input {
flex: 1 1 160px;
}
.arw-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.arw-meta-grid .arw-field {
margin-bottom: 0;
}
.arw-export-gate {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--arw-hairline);
}
.arw-missing {
padding: 12px 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-body);
}
.arw-missing-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-missing ul {
margin: 0;
padding-left: 18px;
}
.arw-missing li {
margin: 4px 0;
font-size: 13px;
}
.arw-panel-header {
margin-bottom: 20px;
}
.arw-panel-header h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-panel-header p {
margin: 0;
font-size: 14px;
color: var(--arw-body);
}
.arw-stub {
margin: 0;
padding: 24px;
color: var(--arw-muted);
font-size: 14px;
text-align: center;
border: 1px dashed var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
}
.arw-export-preview {
margin: 0;
min-height: 160px;
max-height: 420px;
overflow: auto;
padding: 12px 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.arw-export-error {
padding: 14px 16px;
border: 1px solid rgba(229, 72, 77, 0.35);
border-radius: var(--arw-radius-control);
background: rgba(229, 72, 77, 0.06);
color: var(--arw-body);
}
.arw-export-error-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #e5484d;
}
.arw-export-error ul {
margin: 0 0 8px;
padding-left: 18px;
}
.arw-export-error li {
margin: 4px 0;
font-size: 14px;
}
.arw-export-error-hint {
margin: 0;
font-size: 13px;
color: var(--arw-muted);
}
.arw-field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.arw-label {
font-size: 12px;
font-weight: 500;
color: var(--arw-muted);
}
.arw-textarea {
width: 100%;
min-height: 180px;
padding: 12px 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
font-family: inherit;
font-size: 14px;
line-height: 1.55;
resize: vertical;
}
.arw-textarea::placeholder {
color: var(--arw-muted-soft);
}
.arw-textarea:focus,
.arw-textarea:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 0;
}
.arw-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 6px 10px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-body);
font-size: 12px;
}
.arw-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arw-chip-remove {
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--arw-muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.arw-chip-remove:hover {
color: var(--arw-ink);
background: var(--arw-hairline);
}
.arw-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin-top: 8px;
}
.arw-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: var(--arw-touch);
padding: 0 18px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.arw-btn:hover {
background: #f6f7f7;
}
.arw-btn:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.arw-btn--primary {
border-color: var(--arw-primary);
background: var(--arw-primary);
color: var(--arw-on-primary);
}
.arw-btn--primary:hover:not(:disabled) {
background: var(--arw-primary-hover);
border-color: var(--arw-primary-hover);
}
.arw-check {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--arw-touch);
font-size: 14px;
color: var(--arw-body);
cursor: pointer;
user-select: none;
}
.arw-check input {
width: 18px;
height: 18px;
accent-color: var(--arw-primary);
cursor: pointer;
}
.arw-status {
margin: 12px 0 0;
min-height: 20px;
font-size: 13px;
color: var(--arw-success);
}
.arw-status.is-error {
color: #e5484d;
}
.arw-toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--arw-touch);
padding: 10px 16px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
box-shadow: 0 12px 32px rgba(24, 24, 27, 0.12);
font-size: 14px;
}
@media (max-width: 767px) {
.arw-page {
flex-direction: column;
font-size: 14px;
}
.arw-rail {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--arw-hairline);
padding: 12px 16px 16px;
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
gap: 8px;
order: -1;
}
.arw-rail-brand {
width: 100%;
margin: 0 0 4px;
padding: 0;
}
.arw-rail-brand h1 {
font-size: 16px;
}
.arw-rail-brand p {
font-size: 13px;
}
.arw-step-btn {
flex: 1 1 calc(33.33% - 8px);
min-width: 0;
min-height: var(--arw-touch);
padding: 10px 8px;
flex-direction: column;
align-items: center;
text-align: center;
gap: 6px;
}
.arw-step-meta {
align-items: center;
}
.arw-step-title {
font-size: 13px;
}
.arw-step-desc {
display: none;
}
.arw-main {
padding: 16px;
}
.arw-panel {
padding: 16px;
max-width: none;
}
.arw-panel-header h2 {
font-size: 17px;
}
.arw-panel-header p,
.arw-textarea,
.arw-input,
.arw-btn,
.arw-check,
.arw-radio,
.arw-pending-q,
.arw-empty-hint,
.arw-seg-btn,
.arw-pill,
.arw-desc-live,
.arw-export-preview,
.arw-export-error-title,
.arw-export-error li,
.arw-status,
.arw-toast {
font-size: 14px;
}
.arw-label {
font-size: 13px;
}
.arw-textarea,
.arw-input {
min-height: var(--arw-touch);
}
.arw-textarea {
min-height: 160px;
}
.arw-confirm-grid,
.arw-meta-grid,
.arw-tag-add {
grid-template-columns: 1fr;
flex-direction: column;
}
.arw-tag-add .arw-input,
.arw-tag-add .arw-btn {
width: 100%;
flex: none;
}
.arw-actions {
flex-direction: column;
align-items: stretch;
}
.arw-actions .arw-btn {
width: 100%;
}
.arw-chip-remove {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
}
.arw-chip {
min-height: var(--arw-touch);
padding: 8px 10px;
font-size: 14px;
}
.arw-toast {
left: 16px;
right: 16px;
bottom: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.arw-step-btn,
.arw-btn,
.arw-pill,
.arw-seg-btn {
transition: none;
}
}

View File

@@ -260,6 +260,12 @@
"title": "业务条线说明",
"itemKey": "prototypes/lease-business-line-overview"
},
{
"id": "item:prototypes:autorodo-web",
"kind": "item",
"title": "AutoRDO 需求清洗工作台",
"itemKey": "prototypes/autorodo-web"
},
{
"id": "item:prototypes:yunxiao-pipeline-handbook",
"kind": "item",

View File

@@ -1,6 +1,6 @@
{
"version": 1,
"updatedAt": "2026-07-21T09:00:04.911Z",
"updatedAt": "2026-07-24T16:58:00.385198Z",
"prototypes": {
"business-dept-ledger": {
"title": "项目盈亏情况",
@@ -9829,9 +9829,62 @@
]
}
]
},
"autorodo-web": {
"title": "AutoRDO 需求清洗工作台",
"version": "v1.0",
"revision": 0,
"lastUpdated": "2026-07-24T16:57:31.632Z",
"contentHash": "1e818e7e7e6f8768211aed867ed789aaaf7625e2131ff988f63fbe7fc53dd0a2",
"trackedFiles": [
".spec/confirm-flow.md",
".spec/requirements-prd.md",
"AutorodoWebApp.tsx",
"components/StepConfirm.tsx",
"components/StepExport.tsx",
"components/StepPaste.tsx",
"index.tsx",
"lib/agentAdapter.ts",
"lib/applyAnswer.test.ts",
"lib/applyAnswer.ts",
"lib/buildCleanPrompt.ts",
"lib/buildRecordPrompt.test.ts",
"lib/buildRecordPrompt.ts",
"lib/clipboard.ts",
"lib/inferMeta.test.ts",
"lib/inferMeta.ts",
"lib/parseDraft.test.ts",
"lib/parseDraft.ts",
"lib/pendingQuestions.ts",
"lib/types.ts",
"style.css"
],
"changelog": [
{
"version": "v1.0",
"date": "2026-07-25",
"time": "00:57",
"summary": "新增 AutoRDO 网页清洗与云效上报提示词工作台",
"details": [],
"files": [
".spec/confirm-flow.md",
".spec/requirements-prd.md",
"annotation-source.json"
]
}
],
"summary": "新增 AutoRDO 网页清洗与云效上报提示词工作台"
}
},
"recentUpdates": [
{
"prototypeId": "autorodo-web",
"title": "AutoRDO 需求清洗工作台",
"version": "v1.0",
"date": "2026-07-25",
"time": "00:57",
"summary": "新增 AutoRDO 网页清洗与云效上报提示词工作台"
},
{
"prototypeId": "oneos-web-workbench-new",
"title": "工作台",
@@ -10624,27 +10677,6 @@
"simulation.ts",
"style.css"
]
},
{
"prototypeId": "yunxiao-pipeline-handbook",
"title": "云效生产线手册",
"version": "v1.128",
"date": "2026-07-21",
"time": "13:32",
"summary": "更新需求说明与标注",
"files": [
".spec/acp/conversations.json",
".spec/prototype-comments.json",
".spec/requirements-prd.md",
"colloquial-commands.ts",
"content.ts",
"docs/yunxiao-colloquial-commands-onepage.md",
"docs/yunxiao-config-handbook.md",
"index.tsx",
"pm-skills.ts",
"simulation.ts",
"style.css"
]
}
]
}

View File

@@ -56,6 +56,7 @@ function SearchIconSvg() {
const Component = function (props) {
var useState = React.useState;
var useMemo = React.useMemo;
var opsHandlerByKey = (props && props.opsHandlerByKey) || {};
var antd = window.antd;
var App = antd.App;
@@ -153,7 +154,9 @@ const Component = function (props) {
case 'submitEnergy':
return '许诗琪';
case 'submitOperation':
return displayCellText(row.returnPerson);
return displayCellText(
(row.key && opsHandlerByKey[row.key]) || row.opsHandler || row.returnPerson
);
case 'submitSafety':
return isGuangzhouReturn(row) ? '罗日可' : '黄际聪';
default:
@@ -195,13 +198,22 @@ const Component = function (props) {
return s || '—';
}
function canEnterFeeDetail(row) {
return String((row && row.approvalStatus) || '') !== '审批完成';
}
function renderProjectInfoCell(_, r) {
var contractText = displayCellText(r.contractCode);
var allowEnter = canEnterFeeDetail(r);
return React.createElement('button', {
type: 'button',
className: 'lc-project-info-btn',
title: '进入还车应结款处理',
onClick: function () { handleFeeDetail(r); }
title: allowEnter ? '进入还车应结款处理' : '审批完成后不可进入费用明细',
disabled: !allowEnter,
onClick: function () {
if (!allowEnter) return;
handleFeeDetail(r);
}
},
React.createElement('div', { className: 'vm-stack' },
React.createElement('span', { className: 'primary', title: displayCellText(r.customerName) }, displayCellText(r.customerName)),
@@ -346,7 +358,9 @@ const Component = function (props) {
deliveryLocation: '广州市番禺区羚牛停车场',
returnTime: '2026-02-27 16:20',
returnPerson: '陈还车',
returnLocation: '嘉兴市南湖区客户仓库'
returnLocation: '嘉兴市南湖区客户仓库',
opsHandler: '陈还车',
opsTransferLogs: []
},
{
key: 'r2',
@@ -370,7 +384,9 @@ const Component = function (props) {
deliveryLocation: '上海市浦东新区交付中心',
returnTime: '2026-02-20 18:05',
returnPerson: '周还车',
returnLocation: '上海市嘉定区客户园区'
returnLocation: '上海市嘉定区客户园区',
opsHandler: '周还车',
opsTransferLogs: []
},
{
key: 'r3',
@@ -394,7 +410,9 @@ const Component = function (props) {
deliveryLocation: '北京市大兴区交付站',
returnTime: '2026-02-02 11:15',
returnPerson: '刘还车',
returnLocation: '北京市通州区客户仓库'
returnLocation: '北京市通州区客户仓库',
opsHandler: '刘还车',
opsTransferLogs: []
},
{
key: 'r4',
@@ -418,7 +436,9 @@ const Component = function (props) {
deliveryLocation: '苏州市吴中区交付点',
returnTime: '2026-02-26 09:10',
returnPerson: '孙还车',
returnLocation: '苏州市相城区客户基地'
returnLocation: '苏州市相城区客户基地',
opsHandler: '孙还车',
opsTransferLogs: []
},
{
key: 'r5',
@@ -442,7 +462,9 @@ const Component = function (props) {
deliveryLocation: '杭州市萧山区交付中心',
returnTime: '2026-02-25',
returnPerson: '吴还车',
returnLocation: '杭州市余杭区港区还车点'
returnLocation: '杭州市余杭区港区还车点',
opsHandler: '吴还车',
opsTransferLogs: []
},
{
key: 'r6',
@@ -466,7 +488,9 @@ const Component = function (props) {
deliveryLocation: '宁波市北仑区交付站',
returnTime: '2026-02-18',
returnPerson: '郑还车',
returnLocation: '宁波市镇海区客户仓库'
returnLocation: '宁波市镇海区客户仓库',
opsHandler: '郑还车',
opsTransferLogs: []
},
{
key: 'r7',
@@ -490,7 +514,9 @@ const Component = function (props) {
deliveryLocation: '合肥市蜀山区交付点',
returnTime: '2026-02-28',
returnPerson: '许还车',
returnLocation: '合肥市包河区客户园区'
returnLocation: '合肥市包河区客户园区',
opsHandler: '许还车',
opsTransferLogs: []
},
{
key: 'r8',
@@ -514,7 +540,9 @@ const Component = function (props) {
deliveryLocation: '深圳市宝安区交付中心',
returnTime: '2026-02-22',
returnPerson: '彭还车',
returnLocation: '东莞市虎门镇客户仓库'
returnLocation: '东莞市虎门镇客户仓库',
opsHandler: '彭还车',
opsTransferLogs: []
},
{
key: 'r9',
@@ -538,7 +566,9 @@ const Component = function (props) {
deliveryLocation: '天津市滨海新区交付站',
returnTime: '2026-02-27',
returnPerson: '韩还车',
returnLocation: '天津市西青区客户基地'
returnLocation: '天津市西青区客户基地',
opsHandler: '韩还车',
opsTransferLogs: []
},
{
key: 'r10',
@@ -562,7 +592,9 @@ const Component = function (props) {
deliveryLocation: '武汉市东西湖区交付点',
returnTime: '2026-02-16',
returnPerson: '蒋还车',
returnLocation: '武汉市江夏区客户园区'
returnLocation: '武汉市江夏区客户园区',
opsHandler: '蒋还车',
opsTransferLogs: []
}
]);
var tableDataAll = tableDataState[0];
@@ -699,7 +731,7 @@ var requirementDocContent = useMemo(function () {
2.12.操作:查看、生成账单、费用明细、撤回;
2.12.1.查看:点击跳转还车应结款-查看页面;
2.12.2.生成账单:点击生成账单,弹框显示账单界面,审批状态为待审批的记录才可生成账单,其他状态生成账单隐藏;
2.12.3.费用明细:点击跳转还车应结款-费用明细页面,审批状态为待审批、审批中、审批完成时不显示费用明细
2.12.3.费用明细:点击跳转还车应结款-费用明细页面,审批状态为审批完成时不显示;待审批/审批中可进入查看(运维段不可编辑)
2.12.4.撤回:审批状态为待审批、审批中时,不显示费用明细点击二次确认,提示:是否确认撤回,点击确定后,提示:撤回成功,同时审批状态修改为撤回;
2.13.右下角为分页符,支持单页查看数据条数;
`);
@@ -794,6 +826,10 @@ var requirementDocContent = useMemo(function () {
message.info('跳转:还车应结款-查看(原型) 合同编号:' + (row && row.contractCode));
}
function handleFeeDetail(row) {
if (!canEnterFeeDetail(row)) {
message.warning('审批完成后不可进入费用明细');
return;
}
if (props && typeof props.onGoHandle === 'function') {
props.onGoHandle(row);
return;
@@ -897,7 +933,7 @@ var requirementDocContent = useMemo(function () {
fixed: 'right',
render: function (_, r) {
var st = String(r && r.approvalStatus);
var showFeeDetail = !(st === '待审批' || st === '审批中' || st === '审批完成');
var showFeeDetail = st !== '审批完成';
var showRevoke = (st === '待审批' || st === '审批中');
var showGenerateBill = String(r && r.approvalStatus) === '待审批';
var more = [];
@@ -917,7 +953,7 @@ var requirementDocContent = useMemo(function () {
}
}
];
}, []);
}, [opsHandlerByKey]);
var listTableScrollX = columns.reduce(function (sum, col) {
if (col.children && col.children.length) {

View File

@@ -0,0 +1,102 @@
# 还车应结款 · 运维转交
> 实现:`ops-transfer-demo.js` · `VehicleReturnSettlementHandle.jsx` · `VehicleReturnSettlementApp.jsx`
> 主页面:`src/prototypes/oneos-web-finance/pages/03-还车应结款.jsx`(费用明细运维段 + 列表 `resolveDeptHandler`
> 数据:本地状态/种子;**未接真实 API**
## 1. 谁可发起(优先级)
| 顺序 | 角色 | 能力 |
|------|------|------|
| 1 | 当前办理人(`opsHandler` | 可将本单运维段转给其他运维同事 |
| 2 | 运维主管 | 可代转任意未禁止状态下的运维段(含原办理人已非在职) |
| 3 | 其他角色 / 非办理人非主管 | 无「转交」入口 |
代码入口:`canInitiateOpsTransfer``ops-transfer-demo.js`
## 2. 转交对象
| 顺序 | 规则 |
|------|------|
| 1 | 仅 **运维部在职同事**`active: true` |
| 2 | 排除:自己、已停用账号、非运维人员 |
| 3 | 选择器支持姓名模糊搜索 |
代码入口:`listTransferCandidates``ops-transfer-demo.js`);人员清单 `OPS_COLLEAGUES`
## 3. 何时可转
### 3.1 运维段状态
| 运维段状态 | 允许转交 |
|------------|----------|
| 待提交 | 是 |
| 已提交 | 是 |
### 3.2 整单审批状态
| 整单审批状态 | 允许转交 | 说明 |
|--------------|----------|------|
| 待提交 / 审批驳回 / 撤回等 | 是 | 费用明细本就可进 |
| 待审批 / 审批中 | 是 | 只换办理人,不改费用与审批流 |
| **审批完成** | **否** | 唯一整单级禁止条件 |
代码入口:`isWholeOrderTransferBlocked``ops-transfer-demo.js`
## 4. 待审批 / 审批中:费用明细与只读
| 顺序 | 条件 | 费用明细入口 | 运维段费用编辑 | 运维转交 |
|------|------|--------------|----------------|----------|
| 1 | 整单待提交 / 审批驳回 / 撤回等 | 显示 | 待提交可编;已提交须先撤回再编 | 办理人/主管可转 |
| 2 | 整单 **待审批 / 审批中** | **显示,不限角色** | **不可编辑**(保存/提交/撤回隐藏或禁用;表格只读) | 办理人/主管仍可转交 |
| 3 | 整单 **审批完成** | 不显示(或只读查看若另有查看页) | — | **无转交** |
代码入口:`isOpsFeeReadOnly``ops-transfer-demo.js`
## 5. 转交效果(不自动撤回)
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | 转交成功 | 更新 `opsHandler` 为新办理人写转交记录Toast 成功 |
| 2 | 转交时运维段已提交 | **不**自动撤回;`submitOperation.status` / 历史提交人姓名保持 |
| 3 | 整单待审批 / 审批中 | 转交可用;运维费用只读;**不可**部门撤回改数 |
| 4 | 新办理人要改费用(整单未在审批中) | 若段状态为已提交 → 先走运维部 **撤回**,再编辑保存/提交 |
| 5 | 整单在审批中要改费用 | 先 **整单撤回**,再进入费用明细编辑 |
转交**只改** `opsHandler`;列表「运维组」办理人解析读 `opsHandler``resolveDeptHandler` in `03-还车应结款.jsx`)。
状态提升:`VehicleReturnSettlementApp.jsx``applyOpsTransfer`;办理预览:`VehicleReturnSettlementHandle.jsx`
## 6. 转交原因(必填)
| 顺序 | 规则 |
|------|------|
| 1 | 多行文本,空原因不可确认转交 |
| 2 | 确认前二次说明:确认后将由【新办理人】继续处理本单运维段 |
代码入口:`validateOpsTransfer``ops-transfer-demo.js`
## 7. 转交记录字段
每笔至少记录(`opsTransferLogs[]` / `buildTransferLog`
| 字段 | 说明 |
|------|------|
| `at` | 操作时间 |
| `operator` | 操作人 |
| `fromName` | 原办理人 |
| `toName` | 新办理人 |
| `reason` | 转交原因 |
| `opsStatus` | 当时运维段状态快照 |
| `approvalStatus` | 当时整单审批状态快照 |
费用明细运维卡片提供「转交记录」入口(时间线或抽屉)。
## 8. 代码路径
| 文件 | 职责 |
|------|------|
| `ops-transfer-demo.js` | 人员清单、权限/状态校验、转交记录构建 |
| `VehicleReturnSettlementHandle.jsx` | 办理预览页:转交弹窗、只读态、转交记录展示 |
| `VehicleReturnSettlementApp.jsx` | 列表/办理视图切换;`opsHandlerByKey` / `opsTransferLogsByKey` 状态 overlay |
| `03-还车应结款.jsx` | 主实现:费用明细运维卡片转交入口、列表 `resolveDeptHandler`、待审批/审批中费用明细可见性 |

View File

@@ -0,0 +1,67 @@
# 还车应结款 · 运维转交 · 产品需求说明
| 项 | 内容 |
|---|---|
| 模块名称 | 还车应结款 · 运维转交(还车应结款模块增量) |
| 所属系统 | ONE-OSPC |
| 交互原型 | `/prototypes/vehicle-return-settlement` |
| 业务条线 | 财务管理 · 还车应结款 |
| 文档状态 | AutoPRD · 首版 |
| 关联规格 | [ops-transfer.md](./ops-transfer.md) · 设计 `docs/superpowers/specs/2026-07-22-vehicle-return-settlement-ops-transfer-design.md` |
---
## 1. 一句话与目标
**一句话**
运维人员在还车应结款费用明细的运维段,可把办理任务转交给在职同事;主管可代转离职或无人处理的单;审批进行中仍可转交,费用只读不改流。
**要解决的问题**
- 办理人短期忙不过来,任务无法及时跟进
- 人员离职后运维段无人合法接手,任务悬挂
- 现网审批中隐藏费用明细,主管/协作方无法查看运维段进度
**本期目标**
1. 费用明细运维部卡片内提供「转交办理人」
2. 当前办理人可自转;运维主管可代转
3. 转交只换办理人,不改已填费用与整单审批流
4. 转交原因必填,并留可查操作记录
5. 整单待审批/审批中:费用明细人人可进、运维段只读、仍可转交
**非目标**
- 列表批量转交、操作列快捷转交
- 独立转交台 / 待处理转交页
- 业管 / 能源 / 安全等其他部门转交
- 真实组织人事同步、账号停用自动转交
- 真实消息通知(本地 Toast + 记录演示即可)
---
## 2. 用户与故事
| 角色 | 故事 |
|------|------|
| 运维办理人 | 忙不过来时把待提交或已提交单转给同事,填原因留痕 |
| 运维主管 | 原办理人离职或失联时代转,让任务继续可被处理 |
| 任意查看者(审批中) | 待审批/审批中可进费用明细查看运维段;办理人/主管仍可转交 |
**用户故事(起点 → 运作 → 闭环)**
1. As a 运维办理人, I want 在费用明细运维卡片把本单转给在职同事并必填原因, so that 同事能接手继续填写或提交。
2. As a 运维主管, I want 代转非本人负责的运维段(含原办理人已不在职), so that 离职场景任务不悬挂。
3. As a 运维办理人/主管, I want 整单审批中运维费用只读但仍可转交, so that 只换办理人不打断审批流。
4. As a 接手同事, I want 转交后列表与明细头部办理人即时更新, so that 我知道谁在处理。
5. As a 任意角色, I want 在运维卡片查看转交记录(时间、人、原因、状态快照), so that 转交过程可追溯。
---
## 3. 验收重点
1. 办理人可把待提交单转给同事,原因必填,列表办理人变为新人。
2. 已提交单可转交;新人可撤回后编辑再提交。
3. 主管可代转「非本人」的运维段任务。
4. 整单待审批 / 审批中:列表可进费用明细(不限角色);运维费用只读不可编;办理人/主管仍可转交;费用与审批流不变。
5. 审批完成后无转交按钮。
6. 不可转给自己或非运维人员;原因为空不可提交。
7. 转交记录可查(时间、人、原因、状态快照)。
数据:本地种子 + 页面状态;**未接真实 API**。

View File

@@ -9,10 +9,43 @@ const Component = function (props) {
var _selectedRow = useState(null);
var selectedRow = _selectedRow[0];
var setSelectedRow = _selectedRow[1];
var _handlers = useState({});
var opsHandlerByKey = _handlers[0];
var setOpsHandlerByKey = _handlers[1];
var _logs = useState({});
var opsTransferLogsByKey = _logs[0];
var setOpsTransferLogsByKey = _logs[1];
function applyOpsTransfer(rowKey, payload) {
setOpsHandlerByKey(function (previous) {
var next = {};
for (var key in previous) next[key] = previous[key];
next[rowKey] = payload.toName;
return next;
});
setOpsTransferLogsByKey(function (previous) {
var next = {};
for (var key in previous) next[key] = previous[key];
var logs = next[rowKey] ? next[rowKey].slice() : [];
logs.unshift(payload.log);
next[rowKey] = logs;
return next;
});
}
if (view === 'handle') {
return React.createElement(VehicleReturnSettlementHandle, {
row: selectedRow,
opsHandler: selectedRow && (
opsHandlerByKey[selectedRow.key] || selectedRow.opsHandler || selectedRow.returnPerson
),
opsTransferLogs: selectedRow
? (opsTransferLogsByKey[selectedRow.key] || selectedRow.opsTransferLogs || [])
: [],
onOpsTransfer: function (payload) {
if (!selectedRow) return;
applyOpsTransfer(selectedRow.key, payload);
},
onBack: function () {
setSelectedRow(null);
setView('list');
@@ -21,6 +54,7 @@ const Component = function (props) {
}
return React.createElement(ReturnSettlementListPage, {
opsHandlerByKey: opsHandlerByKey,
onGoHandle: function (row) {
setSelectedRow(row || null);
setView('handle');

View File

@@ -1,4 +1,14 @@
import { StatusTag } from '../vehicle-management/components/StatusTag';
import {
OPS_COLLEAGUES,
DEMO_IDENTITIES,
canInitiateOpsTransfer,
isWholeOrderTransferBlocked,
isOpsFeeReadOnly,
listTransferCandidates,
validateOpsTransfer,
buildTransferLog
} from './ops-transfer-demo';
var DEPT_ROLE_OPTIONS = [
{ value: 'biz', label: '客户服务组(全量)' },
@@ -141,13 +151,47 @@ const Component = function (props) {
var Table = antd.Table;
var Button = antd.Button;
var Select = antd.Select;
var Modal = antd.Modal;
var Drawer = antd.Drawer;
var Input = antd.Input;
var message = antd.message;
var _role = useState(readDeptRole());
var role = _role[0];
var setRole = _role[1];
var _demoIdentity = useState('handler');
var demoIdentity = _demoIdentity[0];
var setDemoIdentity = _demoIdentity[1];
var _transferOpen = useState(false);
var transferOpen = _transferOpen[0];
var setTransferOpen = _transferOpen[1];
var _transferTo = useState(undefined);
var transferTo = _transferTo[0];
var setTransferTo = _transferTo[1];
var _transferReason = useState('');
var transferReason = _transferReason[0];
var setTransferReason = _transferReason[1];
var _logsOpen = useState(false);
var logsOpen = _logsOpen[0];
var setLogsOpen = _logsOpen[1];
var vehicle = useMemo(function () { return buildVehicleFromRow(props && props.row); }, [props && props.row]);
var deptData = useMemo(buildDeptData, []);
var approvalStatus = (props && props.row && props.row.approvalStatus) || '待提交';
var _opsStatus = useState(function () {
var rowDept = props && props.row && props.row.submitOperation && props.row.submitOperation.status;
if (rowDept === '待提交' || !rowDept) {
return String(deptData.ops.status || '').indexOf('已提交') >= 0 ? '已提交' : '待提交';
}
// 列表种子里运维段可能写「审批中/审批完成」等,均视为部门段已提交
return '已提交';
});
var opsStatus = _opsStatus[0];
var setOpsStatus = _opsStatus[1];
var opsHandler = (props && props.opsHandler) || (props && props.row && (props.row.opsHandler || props.row.returnPerson)) || '—';
var opsTransferLogs = (props && props.opsTransferLogs) || [];
var feeReadOnly = isOpsFeeReadOnly(approvalStatus);
var canTransfer = !isWholeOrderTransferBlocked(approvalStatus) && canInitiateOpsTransfer(demoIdentity);
var transferCandidates = listTransferCandidates(OPS_COLLEAGUES, opsHandler);
var stats = useMemo(function () {
return {
deposit: '10000.00',
@@ -162,6 +206,61 @@ const Component = function (props) {
writeDeptRole(next);
}
function closeTransferModal() {
setTransferOpen(false);
setTransferTo(undefined);
setTransferReason('');
}
function confirmOpsTransfer() {
var errors = validateOpsTransfer({
fromName: opsHandler,
toName: transferTo,
reason: transferReason,
approvalStatus: approvalStatus
});
if (errors.length) {
message.error(errors[0]);
return;
}
Modal.confirm({
title: '确认转交',
content: '确认后将由【' + transferTo + '】继续处理本单运维段',
okText: '确认转交',
cancelText: '取消',
onOk: function () {
var log = buildTransferLog({
operator: demoIdentity === 'supervisor' ? '运维主管' : opsHandler,
fromName: opsHandler,
toName: transferTo,
reason: transferReason,
opsStatus: opsStatus,
approvalStatus: approvalStatus
});
if (props && typeof props.onOpsTransfer === 'function') {
props.onOpsTransfer({ toName: transferTo, log: log });
}
message.success('转交成功');
closeTransferModal();
}
});
}
function saveOpsFees() {
setOpsStatus('待提交');
message.success('运维费用已保存');
}
function submitOpsFees() {
setOpsStatus('已提交');
message.success('运维费用已提交');
}
function revokeOpsFees() {
setOpsStatus('待提交');
message.success('已撤回运维费用');
}
function canSeeSection(sectionKey) {
if (role === 'biz') return true;
if (sectionKey === 'customer') return false;
@@ -196,7 +295,9 @@ const Component = function (props) {
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) })
dept.handler ? React.createElement('span', null, '办理人:', dept.handler) : null,
React.createElement(StatusTag, { label: dept.status, tone: approvalTone(dept.status) }),
dept.actions || null
)
);
}
@@ -249,8 +350,30 @@ const Component = function (props) {
function renderOpsSection() {
var d = deptData.ops;
var actions = React.createElement('div', { className: 'vrs-handle-inline-actions' },
!feeReadOnly && opsStatus === '已提交'
? React.createElement(Button, { size: 'small', onClick: revokeOpsFees }, '撤回')
: null,
!feeReadOnly && opsStatus !== '已提交'
? React.createElement(Button, { size: 'small', onClick: saveOpsFees }, '保存')
: null,
!feeReadOnly && opsStatus !== '已提交'
? React.createElement(Button, { type: 'primary', size: 'small', onClick: submitOpsFees }, '提交')
: null,
canTransfer
? React.createElement(Button, { size: 'small', onClick: function () { setTransferOpen(true); } }, '转交')
: null,
React.createElement(Button, { type: 'link', size: 'small', onClick: function () { setLogsOpen(true); } }, '转交记录')
);
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 }),
renderDeptHeader({
title: '运维组',
total: d.total,
submitter: d.submitter,
handler: opsHandler,
status: opsStatus,
actions: actions
}),
React.createElement('div', { className: 'vm-table-wrap' },
React.createElement(Table, {
className: 'vm-list-table',
@@ -267,10 +390,6 @@ const Component = function (props) {
{ 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' }, '提交')
)
);
}
@@ -373,6 +492,15 @@ const Component = function (props) {
style: { width: 200 },
options: DEPT_ROLE_OPTIONS,
onChange: handleRoleChange
}),
React.createElement('span', { className: 'vrs-handle-role-switch__label' }, '演示身份'),
React.createElement(Select, {
value: demoIdentity,
style: { width: 170 },
options: DEMO_IDENTITIES.map(function (identity) {
return { value: identity.id, label: identity.label };
}),
onChange: setDemoIdentity
})
)
),
@@ -410,6 +538,57 @@ const Component = function (props) {
role === 'biz'
? React.createElement(Button, { type: 'primary', disabled: true }, '14天12小时后可提交审核')
: null
),
React.createElement(Modal, {
title: '转交运维办理人',
open: transferOpen,
okText: '确认转交',
cancelText: '取消',
onCancel: closeTransferModal,
onOk: confirmOpsTransfer
},
React.createElement('div', { className: 'vrs-handle-transfer-form' },
React.createElement('label', null, '当前办理人'),
React.createElement(Input, { value: opsHandler, readOnly: true }),
React.createElement('label', null, '新办理人'),
React.createElement(Select, {
value: transferTo,
placeholder: '请选择新办理人',
showSearch: true,
optionFilterProp: 'label',
options: transferCandidates.map(function (person) {
return { value: person.name, label: person.name };
}),
onChange: setTransferTo
}),
React.createElement('label', null, '转交原因'),
React.createElement(Input.TextArea, {
value: transferReason,
rows: 3,
placeholder: '请填写转交原因',
onChange: function (event) { setTransferReason(event.target.value); }
})
)
),
React.createElement(Drawer, {
title: '运维转交记录',
open: logsOpen,
width: 520,
onClose: function () { setLogsOpen(false); }
},
opsTransferLogs.length
? React.createElement('div', { className: 'vrs-handle-transfer-log-list' },
opsTransferLogs.map(function (log, index) {
return React.createElement('article', { className: 'vrs-handle-transfer-log', key: (log.at || '') + index },
React.createElement('strong', null, log.fromName || '—', ' → ', log.toName || '—'),
React.createElement('span', null, '时间:', log.at || '—'),
React.createElement('span', null, '操作人:', log.operator || '—'),
React.createElement('span', null, '原因:', log.reason || '—'),
React.createElement('span', null, '运维状态:', log.opsStatus || '—', ';整单状态:', log.approvalStatus || '—')
);
})
)
: React.createElement('div', { className: 'vrs-handle-empty' }, '暂无转交记录')
)
)
);

View File

@@ -0,0 +1,39 @@
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": {
"version": 2,
"prototypeName": "vehicle-return-settlement",
"pageId": "list",
"updatedAt": 1784712340993,
"nodes": []
},
"markdownMap": {},
"assetMap": {},
"directory": {
"nodes": [
{
"type": "folder",
"id": "vrs-doc-root",
"title": "还车应结款说明",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "vrs-doc-prd",
"title": "产品需求说明PRD",
"markdownPath": ".spec/requirements-prd.md",
"markdown": "# 还车应结款 · 运维转交 · 产品需求说明\n\n| 项 | 内容 |\n|---|---|\n| 模块名称 | 还车应结款 · 运维转交(还车应结款模块增量) |\n| 所属系统 | ONE-OSPC |\n| 交互原型 | `/prototypes/vehicle-return-settlement` |\n| 业务条线 | 财务管理 · 还车应结款 |\n| 文档状态 | AutoPRD · 首版 |\n| 关联规格 | [ops-transfer.md](./ops-transfer.md) · 设计 `docs/superpowers/specs/2026-07-22-vehicle-return-settlement-ops-transfer-design.md` |\n\n---\n\n## 1. 一句话与目标\n\n**一句话** \n运维人员在还车应结款费用明细的运维段可把办理任务转交给在职同事主管可代转离职或无人处理的单审批进行中仍可转交费用只读不改流。\n\n**要解决的问题** \n- 办理人短期忙不过来,任务无法及时跟进 \n- 人员离职后运维段无人合法接手,任务悬挂 \n- 现网审批中隐藏费用明细,主管/协作方无法查看运维段进度 \n\n**本期目标** \n1. 费用明细运维部卡片内提供「转交办理人」 \n2. 当前办理人可自转;运维主管可代转 \n3. 转交只换办理人,不改已填费用与整单审批流 \n4. 转交原因必填,并留可查操作记录 \n5. 整单待审批/审批中:费用明细人人可进、运维段只读、仍可转交 \n\n**非目标** \n- 列表批量转交、操作列快捷转交 \n- 独立转交台 / 待处理转交页 \n- 业管 / 能源 / 安全等其他部门转交 \n- 真实组织人事同步、账号停用自动转交 \n- 真实消息通知(本地 Toast + 记录演示即可) \n\n---\n\n## 2. 用户与故事\n\n| 角色 | 故事 |\n|------|------|\n| 运维办理人 | 忙不过来时把待提交或已提交单转给同事,填原因留痕 |\n| 运维主管 | 原办理人离职或失联时代转,让任务继续可被处理 |\n| 任意查看者(审批中) | 待审批/审批中可进费用明细查看运维段;办理人/主管仍可转交 |\n\n**用户故事(起点 → 运作 → 闭环)** \n1. As a 运维办理人, I want 在费用明细运维卡片把本单转给在职同事并必填原因, so that 同事能接手继续填写或提交。 \n2. As a 运维主管, I want 代转非本人负责的运维段(含原办理人已不在职), so that 离职场景任务不悬挂。 \n3. As a 运维办理人/主管, I want 整单审批中运维费用只读但仍可转交, so that 只换办理人不打断审批流。 \n4. As a 接手同事, I want 转交后列表与明细头部办理人即时更新, so that 我知道谁在处理。 \n5. As a 任意角色, I want 在运维卡片查看转交记录(时间、人、原因、状态快照), so that 转交过程可追溯。 \n\n---\n\n## 3. 验收重点\n\n1. 办理人可把待提交单转给同事,原因必填,列表办理人变为新人。 \n2. 已提交单可转交;新人可撤回后编辑再提交。 \n3. 主管可代转「非本人」的运维段任务。 \n4. 整单待审批 / 审批中:列表可进费用明细(不限角色);运维费用只读不可编;办理人/主管仍可转交;费用与审批流不变。 \n5. 审批完成后无转交按钮。 \n6. 不可转给自己或非运维人员;原因为空不可提交。 \n7. 转交记录可查(时间、人、原因、状态快照)。 \n\n数据本地种子 + 页面状态;**未接真实 API**。"
},
{
"type": "markdown",
"id": "vrs-doc-ops-transfer",
"title": "运维转交规则",
"markdownPath": ".spec/ops-transfer.md",
"markdown": "# 还车应结款 · 运维转交\n\n> 实现:`ops-transfer-demo.js` · `VehicleReturnSettlementHandle.jsx` · `VehicleReturnSettlementApp.jsx` \n> 主页面:`src/prototypes/oneos-web-finance/pages/03-还车应结款.jsx`(费用明细运维段 + 列表 `resolveDeptHandler` \n> 数据:本地状态/种子;**未接真实 API**\n\n## 1. 谁可发起(优先级)\n\n| 顺序 | 角色 | 能力 |\n|------|------|------|\n| 1 | 当前办理人(`opsHandler` | 可将本单运维段转给其他运维同事 |\n| 2 | 运维主管 | 可代转任意未禁止状态下的运维段(含原办理人已非在职) |\n| 3 | 其他角色 / 非办理人非主管 | 无「转交」入口 |\n\n代码入口`canInitiateOpsTransfer``ops-transfer-demo.js`\n\n## 2. 转交对象\n\n| 顺序 | 规则 |\n|------|------|\n| 1 | 仅 **运维部在职同事**`active: true` |\n| 2 | 排除:自己、已停用账号、非运维人员 |\n| 3 | 选择器支持姓名模糊搜索 |\n\n代码入口`listTransferCandidates``ops-transfer-demo.js`);人员清单 `OPS_COLLEAGUES`\n\n## 3. 何时可转\n\n### 3.1 运维段状态\n\n| 运维段状态 | 允许转交 |\n|------------|----------|\n| 待提交 | 是 |\n| 已提交 | 是 |\n\n### 3.2 整单审批状态\n\n| 整单审批状态 | 允许转交 | 说明 |\n|--------------|----------|------|\n| 待提交 / 审批驳回 / 撤回等 | 是 | 费用明细本就可进 |\n| 待审批 / 审批中 | 是 | 只换办理人,不改费用与审批流 |\n| **审批完成** | **否** | 唯一整单级禁止条件 |\n\n代码入口`isWholeOrderTransferBlocked``ops-transfer-demo.js`\n\n## 4. 待审批 / 审批中:费用明细与只读\n\n| 顺序 | 条件 | 费用明细入口 | 运维段费用编辑 | 运维转交 |\n|------|------|--------------|----------------|----------|\n| 1 | 整单待提交 / 审批驳回 / 撤回等 | 显示 | 待提交可编;已提交须先撤回再编 | 办理人/主管可转 |\n| 2 | 整单 **待审批 / 审批中** | **显示,不限角色** | **不可编辑**(保存/提交/撤回隐藏或禁用;表格只读) | 办理人/主管仍可转交 |\n| 3 | 整单 **审批完成** | 不显示(或只读查看若另有查看页) | — | **无转交** |\n\n代码入口`isOpsFeeReadOnly``ops-transfer-demo.js`\n\n## 5. 转交效果(不自动撤回)\n\n| 顺序 | 条件 | 结果 |\n|------|------|------|\n| 1 | 转交成功 | 更新 `opsHandler` 为新办理人写转交记录Toast 成功 |\n| 2 | 转交时运维段已提交 | **不**自动撤回;`submitOperation.status` / 历史提交人姓名保持 |\n| 3 | 整单待审批 / 审批中 | 转交可用;运维费用只读;**不可**部门撤回改数 |\n| 4 | 新办理人要改费用(整单未在审批中) | 若段状态为已提交 → 先走运维部 **撤回**,再编辑保存/提交 |\n| 5 | 整单在审批中要改费用 | 先 **整单撤回**,再进入费用明细编辑 |\n\n转交**只改** `opsHandler`;列表「运维组」办理人解析读 `opsHandler``resolveDeptHandler` in `03-还车应结款.jsx`)。\n\n状态提升`VehicleReturnSettlementApp.jsx` 的 `applyOpsTransfer`;办理预览:`VehicleReturnSettlementHandle.jsx`\n\n## 6. 转交原因(必填)\n\n| 顺序 | 规则 |\n|------|------|\n| 1 | 多行文本,空原因不可确认转交 |\n| 2 | 确认前二次说明:确认后将由【新办理人】继续处理本单运维段 |\n\n代码入口`validateOpsTransfer``ops-transfer-demo.js`\n\n## 7. 转交记录字段\n\n每笔至少记录`opsTransferLogs[]` / `buildTransferLog`\n\n| 字段 | 说明 |\n|------|------|\n| `at` | 操作时间 |\n| `operator` | 操作人 |\n| `fromName` | 原办理人 |\n| `toName` | 新办理人 |\n| `reason` | 转交原因 |\n| `opsStatus` | 当时运维段状态快照 |\n| `approvalStatus` | 当时整单审批状态快照 |\n\n费用明细运维卡片提供「转交记录」入口时间线或抽屉。\n\n## 8. 代码路径\n\n| 文件 | 职责 |\n|------|------|\n| `ops-transfer-demo.js` | 人员清单、权限/状态校验、转交记录构建 |\n| `VehicleReturnSettlementHandle.jsx` | 办理预览页:转交弹窗、只读态、转交记录展示 |\n| `VehicleReturnSettlementApp.jsx` | 列表/办理视图切换;`opsHandlerByKey` / `opsTransferLogsByKey` 状态 overlay |\n| `03-还车应结款.jsx` | 主实现:费用明细运维卡片转交入口、列表 `resolveDeptHandler`、待审批/审批中费用明细可见性 |"
}
]
}
]
}
}

View File

@@ -7,12 +7,18 @@ 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 React, { useEffect, useMemo } from 'react';
import {
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import 'antd/dist/reset.css';
import '../vehicle-management/style.css';
import './styles/index.css';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import ReturnSettlementApp from './VehicleReturnSettlementApp.jsx';
import annotationSourceDocument from './annotation-source.json';
declare global {
interface Window {
@@ -70,9 +76,25 @@ export default function VehicleReturnSettlement() {
clearHostPrototypeRouteInfo();
}, []);
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: 'list',
}),
[],
);
return (
<ConfigProvider theme={vmTheme} locale={zhCN}>
<ReturnSettlementApp />
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</ConfigProvider>
);
}

View File

@@ -0,0 +1,70 @@
/** 运维转交 · 原型演示数据与校验(未接真实 API */
export var OPS_COLLEAGUES = [
{ id: 'ops-chen', name: '陈还车', active: true },
{ id: 'ops-zhou', name: '周还车', active: true },
{ id: 'ops-liu', name: '刘还车', active: true },
{ id: 'ops-wang', name: '王五', active: true },
{ id: 'ops-left', name: '已离职-赵工', active: false }
];
export var DEMO_IDENTITIES = [
{ id: 'handler', label: '当前办理人' },
{ id: 'supervisor', label: '运维主管' },
{ id: 'other', label: '其他运维(无权限)' }
];
export function canInitiateOpsTransfer(identityId, currentHandlerName, viewerName) {
if (identityId === 'supervisor') return true;
if (identityId === 'handler') return true;
return false;
}
export function isWholeOrderTransferBlocked(approvalStatus) {
return String(approvalStatus || '') === '审批完成';
}
/** 整单待审批/审批中/审批完成:运维费用只读(不可保存/提交/撤回) */
export function isOpsFeeReadOnly(approvalStatus) {
var st = String(approvalStatus || '');
return st === '待审批' || st === '审批中' || st === '审批完成';
}
export function listTransferCandidates(colleagues, currentHandlerName) {
return (colleagues || []).filter(function (p) {
return p.active && p.name !== currentHandlerName;
});
}
export function validateOpsTransfer(input) {
var errors = [];
if (!input || !input.toName) errors.push('请选择新办理人');
if (!input || !String(input.reason || '').trim()) errors.push('请填写转交原因');
if (input && input.toName && input.toName === input.fromName) errors.push('不能转交给自己');
if (input && isWholeOrderTransferBlocked(input.approvalStatus)) errors.push('审批完成后不可转交');
return errors;
}
export function buildTransferLog(input) {
return {
at: input.at || new Date().toISOString().slice(0, 16).replace('T', ' '),
operator: input.operator,
fromName: input.fromName,
toName: input.toName,
reason: String(input.reason || '').trim(),
opsStatus: input.opsStatus || '',
approvalStatus: input.approvalStatus || ''
};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
OPS_COLLEAGUES: OPS_COLLEAGUES,
DEMO_IDENTITIES: DEMO_IDENTITIES,
canInitiateOpsTransfer: canInitiateOpsTransfer,
isWholeOrderTransferBlocked: isWholeOrderTransferBlocked,
isOpsFeeReadOnly: isOpsFeeReadOnly,
listTransferCandidates: listTransferCandidates,
validateOpsTransfer: validateOpsTransfer,
buildTransferLog: buildTransferLog
};
}

View File

@@ -257,6 +257,45 @@
color: var(--ln-muted);
}
.vrs-handle-inline-actions {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.vrs-handle-transfer-form {
display: grid;
gap: 8px;
}
.vrs-handle-transfer-form label {
margin-top: 4px;
color: var(--ln-body);
font-size: 0.875rem;
font-weight: 500;
}
.vrs-handle-transfer-log-list {
display: grid;
gap: 12px;
}
.vrs-handle-transfer-log {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid var(--ln-hairline);
border-radius: 8px;
color: var(--ln-body);
font-size: 0.8125rem;
}
.vrs-handle-transfer-log strong {
color: var(--ln-ink);
font-size: 0.875rem;
}
.vrs-handle-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -0,0 +1,245 @@
# AutoRDO 需求清洗工作台 · 产品需求说明(全模块)
## 1. 一句话与目标
**一句话**
给产品经理一个网页工作台:粘贴聊天/截图/语音素材 → 复制 AutoRDO 清洗提示词 → 粘回初稿确认待确认并补齐上报字段 → 复制 YunxiaoPMapp 多条「记录需求」提示词,在 Cursor 侧完成入库。
**要解决的问题**
- 碎片素材难直接写成可入库的标题与书面描述
- 多条诉求混在一起,容易漏拆或合并错
- 「待确认」项靠聊天来回补,缺少结构化答题与门禁
- 上报云效前缺少类型、优先级、标签、提交部门/人的齐套校验
- 不想在网页直连云效,只希望生成可粘贴执行的口令
**本期目标**
1. 三步工作台:粘贴素材 → 确认待确认 → 上报云效(复制口令)
2. 支持文本 + 图片/语音附件芯片(不上传;由 Cursor 多模态识读)
3. 一键复制 AutoRDO 清洗提示词;可选「粘贴后自动复制」
4. 粘回初稿后解析多条需求、分页答题、补齐元数据
5. 齐套后一键复制 YunxiaoPMapp 多条记录需求提示词(及定稿 Markdown
**非目标(本期不做)**
- 不直连云效 OpenAPI / 不在网页内建单
- 不在网页内跑 AutoRDO 模型清洗(只生成提示词)
- 不做账号登录、权限、草稿云端持久化
- 不替代 YunxiaoPMapp / AutoRDO Skill 本体规则
## 2. 模块边界(最重要)
```mermaid
flowchart LR
subgraph web [AutoRDO 网页工作台]
P[粘贴素材]
C[确认待确认]
E[上报口令]
end
Cursor[Cursor + AutoRDO / YunxiaoPMapp]
YX[云效需求]
P -->|复制清洗提示词| Cursor
Cursor -->|粘回初稿 Markdown| C
C --> E
E -->|复制记录需求提示词| Cursor
Cursor -->|Skill 执行| YX
```
| 业务线 / 子域 | 做什么 | 不做什么 |
|---------------|--------|----------|
| 素材采集 | 粘贴文本与图/语音文件名芯片,生成清洗提示词 | 不上传附件、不识读媒体二进制 |
| 确认补齐 | 解析初稿、答待确认、编辑标题/描述、补元数据 | 不自动推进云效状态 |
| 上报出口 | 预览并复制多条「记录需求」口令 / 定稿 Markdown | 不调用云效 API |
**外部依赖(产品口径)**
| 依赖方 | 交互方式 | 产品要求 |
|--------|----------|----------|
| AutoRDOCursor Skill | 用户粘贴清洗提示词后执行 | 产出含标题、描述、「待确认」的 Markdown 初稿 |
| YunxiaoPMappCursor Skill | 用户粘贴上报提示词后执行 | 按条独立记录需求;本工作台默认「暂不推进」 |
| 剪贴板 | 浏览器复制 / 粘贴 | 复制失败时明确提示,可重试 |
**关键约束**
- 一条上报口令可含多条「记录需求」,**禁止合并为一条**
- 未答完待确认或元数据不齐 → **禁止**进入可复制的上报态
- 标题类型仅「新增 / 优化」;写入口令时带 `【新增】` / `【优化】` 前缀
## 3. 用户与角色
| 角色 | 主要目标 |
|------|----------|
| 产品经理(如王冕) | 快速把碎片诉求洗成可入库描述,并复制口令交给 YunxiaoPMapp |
| 兼岗业管 / 业务接口人 | 粘贴业务反馈素材,确认模糊项后交产品入库 |
| 评审 / 培训 | 演示「清洗 → 确认 → 上报口令」闭环,不改真实云效 |
## 4. 用户故事与故事点(业务条线说明口径)
> 合计约 **13 SP**(可按团队基准调整)。
### Epic A · 粘贴与清洗提示词(约 4 SP
#### A1 · 多模态素材粘贴与复制清洗提示词
- **角色**:产品经理
- **起点**:手里有聊天记录、口述笔记、截图或语音文件名
- **怎么运作**
1. 在步骤 1 粘贴正文;粘贴图片/语音时显示附件芯片(可移除)
2. 点「确认并复制清洗提示词」,或开启「粘贴后自动复制」
3. 将提示词粘到 Cursor按 AutoRDO 规则清洗
- **关键结果**`可`无正文仅附件时仍复制;`禁止`正文与附件皆空时复制
- **闭环**:剪贴板拿到可执行的 AutoRDO 提示词Toast 提示去 Cursor 执行
- **排期**US-01 · M
### Epic B · 粘回确认与元数据(约 6 SP
#### B1 · 解析初稿并分页答待确认
- **角色**:产品经理
- **起点**Cursor 已产出 AutoRDO 初稿 Markdown
- **怎么运作**
1. 粘回步骤 2点「解析初稿」拆成多条需求分页
2. 对每条未确认项选题或填写补充,确认后描述实时写回
3. 可手改标题与描述
- **关键结果**`可`跳过已全部确认的需求;`禁止`空答案提交
- **闭环**:各需求「待确认」清零或标明无待确认
- **排期**US-02 · L
#### B2 · 补齐上报元数据并门禁放行
- **角色**:产品经理
- **起点**:待确认已处理或无需处理
- **怎么运作**
1. 选择类型(新增/优化)、优先级;添加标签;确认提交部门与提交人(可预填)
2. 系统列出缺失项;全部齐套后出现「进入上报」
- **关键结果**`禁止`缺项时进入可复制上报
- **闭环**:全部需求确认且元数据齐套,进入步骤 3
- **排期**US-03 · M
> 判定顺序、启发式选项与写回规则全文见 **[confirm-flow.md](./confirm-flow.md)**。
### Epic C · 云效上报口令(约 3 SP
#### C1 · 复制多条记录需求提示词
- **角色**:产品经理
- **起点**:步骤 2 已放行
- **怎么运作**
1. 在步骤 3 预览 YunxiaoPMapp 多条「记录需求」口令
2. 一键复制;可选复制定稿 Markdown 存档
3. 粘到 Cursor 执行 YunxiaoPMapp本页不直连云效
- **关键结果**`禁止`未齐套时复制上报口令;每条独立、暂不推进
- **闭环**:口令已复制,可在 Cursor 侧完成入库
- **排期**US-04 · M
## 5. 功能模块说明(正向 / 逆向)
### 5.1 粘贴素材
**正向**
1. 输入或粘贴文本;粘贴图/语音出现附件芯片
2. 确认复制清洗提示词;成功 Toast
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 无正文且无附件 | 主按钮禁用 |
| 复制失败 | 错误提示,可重试 |
| 关闭自动复制 | 仅手动确认时复制 |
### 5.2 确认待确认
**正向**
1. 粘回初稿 → 解析 → 分页答题 → 补元数据 → 进入上报
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 空粘回 / 无法解析 | 错误提示,不覆盖已有草稿(解析失败时) |
| 仍有待确认或缺元数据 | 「尚不能进入上报」+ 清单 |
| 补充文案与单选并存 | 以补充文案为准 |
### 5.3 上报云效(口令)
**正向**
1. 预览完整提示词 → 一键复制 → 去 Cursor 执行
2. 可另复制定稿 Markdown待确认
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 草稿未齐套 | 预览区错误 + 缺失列表;复制上报禁用 |
| 无草稿 | 提示先完成步骤 2 |
| 本页 | **不**调用云效 API |
## 6. 关键业务逻辑(必须对齐)
### 6.1 粘回确认与上报门禁
摘要(全文见 [confirm-flow.md](./confirm-flow.md)
| 优先级 | 规则 | 说明 |
|--------|------|------|
| 1 | 先能解析出 ≥1 条需求 | 否则无法确认/上报 |
| 2 | 每条待确认须答完或初稿标明「无」 | 否则不可上报 |
| 3 | 类型、优先级、标签、提交部门、提交人齐套 | 否则不可上报 |
| 4 | 上报口令按条独立 + 暂不推进 | 不合并、不在本页推进状态 |
**数据源(产品口径)**
- 粘回 Markdown用户从 Cursor 粘贴)
- 本地启发式选项与部门/人预填(原型本地,未接真 API
- 用户当场填写的答案与元数据(会话内)
**用户可见结果**
- 通过:进入上报并可复制口令
- 拦截:缺失清单文案;复制上报不可用
## 7. 总览流程图
```mermaid
flowchart TD
A[粘贴文本/图/语音芯片] --> B[复制 AutoRDO 清洗提示词]
B --> C[Cursor 执行 AutoRDO]
C --> D[粘回初稿 Markdown]
D --> E{解析成功?}
E -->|否| D
E -->|是| F[分页答待确认]
F --> G[补齐元数据]
G --> H{全部齐套?}
H -->|否| F
H -->|是| I[复制 YunxiaoPMapp 多条记录提示词]
I --> J[Cursor 执行入库]
J --> K[云效出现需求 · 本页结束]
```
## 8. 验收清单(产品 / 测试)
**粘贴与清洗**
- [ ] 可粘贴文本;粘贴图片/语音显示附件芯片且可移除
- [ ] 「确认并复制清洗提示词」生成含 AutoRDO 指令与素材区的提示词
- [ ] 「粘贴后自动复制」开启时,粘贴文本会自动复制
- [ ] 正文与附件皆空时不可复制
**确认与门禁**
- [ ] 粘回合法初稿可解析出多条并分页
- [ ] 待确认可选题或补充;描述实时更新
- [ ] 未答完或元数据不齐时不可进入上报,并列出缺失项
- [ ] 部门/人可预填且可改;类型仅新增/优化
**上报口令**
- [ ] 齐套后可复制多条「记录需求」口令,含优先级/标签/部门/人,推进至=暂不推进
- [ ] 可复制定稿 Markdown待确认
- [ ] 页面无云效直连写单行为
**文档**
- [ ] 标注目录可打开「产品需求说明PRD」与「粘回确认与上报门禁」
- [ ] 规格与页面行为一致(见 confirm-flow.md
## 9. 交付口径
交付可运行的 **AutoRDO 需求清洗工作台** 原型:产品经理在网页完成「粘贴素材 → 复制清洗提示词 → 粘回确认待确认与元数据 → 复制 YunxiaoPMapp 多条记录需求提示词」;实际清洗与云效入库均在 Cursor Skill 侧完成,**网页不直连云效**。复杂确认与门禁规则以 `.spec/confirm-flow.md` 为准。
预览路径:`/prototypes/autorodo-web`;对象存储发布形态:`{baseUrl}/autorodo-web/index.html`
## 10. 功能变更记录
> 产品经理原型发版记录。仅记功能与业务逻辑变更;不含样式/UI/表结构。
> 由「需求定稿」关键字触发追加;日常改原型不强制每改必写。
(尚无定稿版本;首轮 AutoPRD 落盘。)

View File

@@ -0,0 +1,67 @@
# 还车应结款 · 运维转交 · 产品需求说明
| 项 | 内容 |
|---|---|
| 模块名称 | 还车应结款 · 运维转交(还车应结款模块增量) |
| 所属系统 | ONE-OSPC |
| 交互原型 | `/prototypes/vehicle-return-settlement` |
| 业务条线 | 财务管理 · 还车应结款 |
| 文档状态 | AutoPRD · 首版 |
| 关联规格 | [ops-transfer.md](./ops-transfer.md) · 设计 `docs/superpowers/specs/2026-07-22-vehicle-return-settlement-ops-transfer-design.md` |
---
## 1. 一句话与目标
**一句话**
运维人员在还车应结款费用明细的运维段,可把办理任务转交给在职同事;主管可代转离职或无人处理的单;审批进行中仍可转交,费用只读不改流。
**要解决的问题**
- 办理人短期忙不过来,任务无法及时跟进
- 人员离职后运维段无人合法接手,任务悬挂
- 现网审批中隐藏费用明细,主管/协作方无法查看运维段进度
**本期目标**
1. 费用明细运维部卡片内提供「转交办理人」
2. 当前办理人可自转;运维主管可代转
3. 转交只换办理人,不改已填费用与整单审批流
4. 转交原因必填,并留可查操作记录
5. 整单待审批/审批中:费用明细人人可进、运维段只读、仍可转交
**非目标**
- 列表批量转交、操作列快捷转交
- 独立转交台 / 待处理转交页
- 业管 / 能源 / 安全等其他部门转交
- 真实组织人事同步、账号停用自动转交
- 真实消息通知(本地 Toast + 记录演示即可)
---
## 2. 用户与故事
| 角色 | 故事 |
|------|------|
| 运维办理人 | 忙不过来时把待提交或已提交单转给同事,填原因留痕 |
| 运维主管 | 原办理人离职或失联时代转,让任务继续可被处理 |
| 任意查看者(审批中) | 待审批/审批中可进费用明细查看运维段;办理人/主管仍可转交 |
**用户故事(起点 → 运作 → 闭环)**
1. As a 运维办理人, I want 在费用明细运维卡片把本单转给在职同事并必填原因, so that 同事能接手继续填写或提交。
2. As a 运维主管, I want 代转非本人负责的运维段(含原办理人已不在职), so that 离职场景任务不悬挂。
3. As a 运维办理人/主管, I want 整单审批中运维费用只读但仍可转交, so that 只换办理人不打断审批流。
4. As a 接手同事, I want 转交后列表与明细头部办理人即时更新, so that 我知道谁在处理。
5. As a 任意角色, I want 在运维卡片查看转交记录(时间、人、原因、状态快照), so that 转交过程可追溯。
---
## 3. 验收重点
1. 办理人可把待提交单转给同事,原因必填,列表办理人变为新人。
2. 已提交单可转交;新人可撤回后编辑再提交。
3. 主管可代转「非本人」的运维段任务。
4. 整单待审批 / 审批中:列表可进费用明细(不限角色);运维费用只读不可编;办理人/主管仍可转交;费用与审批流不变。
5. 审批完成后无转交按钮。
6. 不可转给自己或非运维人员;原因为空不可提交。
7. 转交记录可查(时间、人、原因、状态快照)。
数据:本地种子 + 页面状态;**未接真实 API**。

View File

@@ -200,6 +200,7 @@ export default defineConfig(({ command }) => {
'scripts/**/*.test.ts',
'scripts/**/*.test.mjs',
'vite-plugins/**/*.test.ts',
'src/prototypes/autorodo-web/lib/**/*.test.ts',
],
root: '.',
coverage: {