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:
@@ -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",
|
||||||
|
|||||||
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": "业务条线说明",
|
"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",
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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 落盘。)
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user