feat(autorodo-web): add AutoRDO paste-confirm-export workbench

Enable PM clipboard round-trip: multimodal paste → AutoRDO clean prompt → pending Q&A with meta → Yunxiao multi-record prompt copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-25 01:06:22 +08:00
parent cf82f25611
commit ff4b7b67dc
27 changed files with 2980 additions and 22 deletions

View File

@@ -262,6 +262,12 @@
"title": "业务条线说明", "title": "业务条线说明",
"itemKey": "prototypes/lease-business-line-overview" "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", "id": "item:prototypes:yunxiao-pipeline-handbook",
"kind": "item", "kind": "item",

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -260,6 +260,12 @@
"title": "业务条线说明", "title": "业务条线说明",
"itemKey": "prototypes/lease-business-line-overview" "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", "id": "item:prototypes:yunxiao-pipeline-handbook",
"kind": "item", "kind": "item",

View File

@@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-07-21T09:00:04.911Z", "updatedAt": "2026-07-24T16:58:00.385198Z",
"prototypes": { "prototypes": {
"business-dept-ledger": { "business-dept-ledger": {
"title": "项目盈亏情况", "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": [ "recentUpdates": [
{
"prototypeId": "autorodo-web",
"title": "AutoRDO 需求清洗工作台",
"version": "v1.0",
"date": "2026-07-25",
"time": "00:57",
"summary": "新增 AutoRDO 网页清洗与云效上报提示词工作台"
},
{ {
"prototypeId": "oneos-web-workbench-new", "prototypeId": "oneos-web-workbench-new",
"title": "工作台", "title": "工作台",
@@ -10624,27 +10677,6 @@
"simulation.ts", "simulation.ts",
"style.css" "style.css"
] ]
},
{
"prototypeId": "yunxiao-pipeline-handbook",
"title": "云效生产线手册",
"version": "v1.128",
"date": "2026-07-21",
"time": "13:32",
"summary": "更新需求说明与标注",
"files": [
".spec/acp/conversations.json",
".spec/prototype-comments.json",
".spec/requirements-prd.md",
"colloquial-commands.ts",
"content.ts",
"docs/yunxiao-colloquial-commands-onepage.md",
"docs/yunxiao-config-handbook.md",
"index.tsx",
"pm-skills.ts",
"simulation.ts",
"style.css"
]
} }
] ]
} }

View File

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

View File

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