Compare commits
3 Commits
a01d2ab708
...
ff4b7b67dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4b7b67dc | ||
|
|
cf82f25611 | ||
|
|
b14425da5a |
@@ -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",
|
||||
|
||||
@@ -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 Design(Modal / 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 memo;columns 的 `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 逐任务推进
|
||||
|
||||
你要哪一种?
|
||||
716
docs/superpowers/plans/2026-07-25-autorodo-web.md
Normal file
716
docs/superpowers/plans/2026-07-25-autorodo-web.md
Normal 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/ASR;Cursor 往返靠剪贴板。预留 `agentAdapter` 接口形状供组织 Agent。
|
||||
|
||||
**Tech Stack:** React + TypeScript(Axhub 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 4–7 并行由另一会话做;网页 MVP 以 Task 1–7 为可演示闭环(上报词已含四字段,即使 Skill 暂未写入云效自定义字段,口令仍可用于人工/后续 Skill)。
|
||||
|
||||
---
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-07-25-autorodo-web.md`.
|
||||
|
||||
**两种执行方式:**
|
||||
|
||||
1. **Subagent-Driven(推荐)** — 每任务开新子代理,任务间复核,迭代快
|
||||
2. **Inline Execution** — 本会话按 executing-plans 连续执行,设检查点
|
||||
|
||||
要哪一种?
|
||||
@@ -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:费用明细卡片内单笔转交 |
|
||||
168
docs/superpowers/specs/2026-07-25-autorodo-web-design.md
Normal file
168
docs/superpowers/specs/2026-07-25-autorodo-web-design.md
Normal 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 四字段写入是否与本原型同迭代交付
|
||||
132
src/prototypes/autorodo-web/.spec/confirm-flow.md
Normal file
132
src/prototypes/autorodo-web/.spec/confirm-flow.md
Normal 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 | `@姓名`(2–4 汉字) |
|
||||
| 提交人 | 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。
|
||||
245
src/prototypes/autorodo-web/.spec/requirements-prd.md
Normal file
245
src/prototypes/autorodo-web/.spec/requirements-prd.md
Normal 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 |
|
||||
|
||||
**外部依赖(产品口径)**
|
||||
|
||||
| 依赖方 | 交互方式 | 产品要求 |
|
||||
|--------|----------|----------|
|
||||
| AutoRDO(Cursor Skill) | 用户粘贴清洗提示词后执行 | 产出含标题、描述、「待确认」的 Markdown 初稿 |
|
||||
| YunxiaoPMapp(Cursor 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 落盘。)
|
||||
81
src/prototypes/autorodo-web/AutorodoWebApp.tsx
Normal file
81
src/prototypes/autorodo-web/AutorodoWebApp.tsx
Normal 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;
|
||||
197
src/prototypes/autorodo-web/annotation-source.json
Normal file
197
src/prototypes/autorodo-web/annotation-source.json
Normal file
File diff suppressed because one or more lines are too long
406
src/prototypes/autorodo-web/components/StepConfirm.tsx
Normal file
406
src/prototypes/autorodo-web/components/StepConfirm.tsx
Normal 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;
|
||||
179
src/prototypes/autorodo-web/components/StepExport.tsx
Normal file
179
src/prototypes/autorodo-web/components/StepExport.tsx
Normal 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;
|
||||
171
src/prototypes/autorodo-web/components/StepPaste.tsx
Normal file
171
src/prototypes/autorodo-web/components/StepPaste.tsx
Normal 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;
|
||||
37
src/prototypes/autorodo-web/index.tsx
Normal file
37
src/prototypes/autorodo-web/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/prototypes/autorodo-web/lib/agentAdapter.ts
Normal file
12
src/prototypes/autorodo-web/lib/agentAdapter.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
69
src/prototypes/autorodo-web/lib/applyAnswer.test.ts
Normal file
69
src/prototypes/autorodo-web/lib/applyAnswer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/prototypes/autorodo-web/lib/applyAnswer.ts
Normal file
40
src/prototypes/autorodo-web/lib/applyAnswer.ts
Normal 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 };
|
||||
}
|
||||
18
src/prototypes/autorodo-web/lib/buildCleanPrompt.ts
Normal file
18
src/prototypes/autorodo-web/lib/buildCleanPrompt.ts
Normal 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');
|
||||
}
|
||||
37
src/prototypes/autorodo-web/lib/buildRecordPrompt.test.ts
Normal file
37
src/prototypes/autorodo-web/lib/buildRecordPrompt.test.ts
Normal 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(/提交人/);
|
||||
});
|
||||
});
|
||||
26
src/prototypes/autorodo-web/lib/buildRecordPrompt.ts
Normal file
26
src/prototypes/autorodo-web/lib/buildRecordPrompt.ts
Normal 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');
|
||||
}
|
||||
3
src/prototypes/autorodo-web/lib/clipboard.ts
Normal file
3
src/prototypes/autorodo-web/lib/clipboard.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function copyText(text: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
10
src/prototypes/autorodo-web/lib/inferMeta.test.ts
Normal file
10
src/prototypes/autorodo-web/lib/inferMeta.test.ts
Normal 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('张三');
|
||||
});
|
||||
});
|
||||
29
src/prototypes/autorodo-web/lib/inferMeta.ts
Normal file
29
src/prototypes/autorodo-web/lib/inferMeta.ts
Normal 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 };
|
||||
}
|
||||
39
src/prototypes/autorodo-web/lib/parseDraft.test.ts
Normal file
39
src/prototypes/autorodo-web/lib/parseDraft.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
src/prototypes/autorodo-web/lib/parseDraft.ts
Normal file
52
src/prototypes/autorodo-web/lib/parseDraft.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
30
src/prototypes/autorodo-web/lib/pendingQuestions.ts
Normal file
30
src/prototypes/autorodo-web/lib/pendingQuestions.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
29
src/prototypes/autorodo-web/lib/types.ts
Normal file
29
src/prototypes/autorodo-web/lib/types.ts
Normal 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;
|
||||
826
src/prototypes/autorodo-web/style.css
Normal file
826
src/prototypes/autorodo-web/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
102
src/prototypes/vehicle-return-settlement/.spec/ops-transfer.md
Normal file
102
src/prototypes/vehicle-return-settlement/.spec/ops-transfer.md
Normal 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`、待审批/审批中费用明细可见性 |
|
||||
@@ -0,0 +1,67 @@
|
||||
# 还车应结款 · 运维转交 · 产品需求说明
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 模块名称 | 还车应结款 · 运维转交(还车应结款模块增量) |
|
||||
| 所属系统 | ONE-OS(PC) |
|
||||
| 交互原型 | `/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**。
|
||||
@@ -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');
|
||||
|
||||
@@ -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' }, '暂无转交记录')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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-OS(PC) |\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`、待审批/审批中费用明细可见性 |"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
70
src/prototypes/vehicle-return-settlement/ops-transfer-demo.js
vendored
Normal file
70
src/prototypes/vehicle-return-settlement/ops-transfer-demo.js
vendored
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
245
src/resources/prd/autorodo-web-autoprd.md
Normal file
245
src/resources/prd/autorodo-web-autoprd.md
Normal 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 |
|
||||
|
||||
**外部依赖(产品口径)**
|
||||
|
||||
| 依赖方 | 交互方式 | 产品要求 |
|
||||
|--------|----------|----------|
|
||||
| AutoRDO(Cursor Skill) | 用户粘贴清洗提示词后执行 | 产出含标题、描述、「待确认」的 Markdown 初稿 |
|
||||
| YunxiaoPMapp(Cursor 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 落盘。)
|
||||
67
src/resources/prd/vehicle-return-settlement-autoprd.md
Normal file
67
src/resources/prd/vehicle-return-settlement-autoprd.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 还车应结款 · 运维转交 · 产品需求说明
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 模块名称 | 还车应结款 · 运维转交(还车应结款模块增量) |
|
||||
| 所属系统 | ONE-OS(PC) |
|
||||
| 交互原型 | `/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**。
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user