扩展氢能站点、加氢记录与台账原型链路,新增工作台、车辆资产 H5、自营物流等原型,并同步导航注册、PRD 资源与交付技能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-21 17:15:39 +08:00
parent e39df1c7c8
commit a01d2ab708
242 changed files with 102296 additions and 4866 deletions

View File

@@ -0,0 +1,297 @@
---
name: user-story-mapping
argument-hint: "[product or workflow]"
description: Create a user story map that lays out activities, steps, tasks, and release slices. Use when planning a workflow, backlog, or MVP around the user journey.
intent: >-
Visualize the user journey by creating a hierarchical map that breaks down high-level activities into steps and tasks, organized left-to-right as a narrative flow. Use this to build shared understanding across product, design, and engineering, prioritize features based on user workflows, and identify gaps or opportunities in the user experience.
type: component
---
## Purpose
Visualize the user journey by creating a hierarchical map that breaks down high-level activities into steps and tasks, organized left-to-right as a narrative flow. Use this to build shared understanding across product, design, and engineering, prioritize features based on user workflows, and identify gaps or opportunities in the user experience.
This is not a backlog—it's a strategic artifact that shows *how* users accomplish their goals, which then informs *what* to build.
## Input
**Works best with:** The product or user workflow being mapped.
**Also useful:** The primary user, the end-to-end narrative as you understand it, existing backlog items to place, and release goals.
Anything supplied with the invocation itself — text after the skill name, a pasted context dump, or an appended `ARGUMENTS:` line — counts as answers already given. Use it and skip whatever it covers; don't re-ask.
**Arriving empty-handed? That works too.** The skill asks whose journey you're mapping and what they're trying to get done, then builds backbone → tasks → slices.
**Example invocation:** `Story map for our expense-reporting flow, from receipt capture to reimbursement, with an MVP slice for the pilot.`
## Key Concepts
### The Jeff Patton Story Mapping Framework
Invented by Jeff Patton, story mapping organizes work into a 2D structure:
**Horizontal axis (left-to-right):** User journey over time
- **Backbone:** High-level activities the user performs
- **Steps:** Specific actions within each activity
- **Tasks:** Detailed work required to complete each step
**Vertical axis (top-to-bottom):** Priority and releases
- **Top rows:** Essential tasks (MVP / Release 1)
- **Lower rows:** Nice-to-have tasks (Future releases)
### Story Map Structure
```
Segment → Persona → Narrative (User's goal)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Activity 1] → [Activity 2] → [Activity 3] → [Activity 4] → [Activity 5]
↓ ↓ ↓ ↓ ↓
[Step 1.1] [Step 2.1] [Step 3.1] [Step 4.1] [Step 5.1]
[Step 1.2] [Step 2.2] [Step 3.2] [Step 4.2] [Step 5.2]
[Step 1.3] [Step 2.3] [Step 3.3] [Step 4.3] [Step 5.3]
↓ ↓ ↓ ↓ ↓
[Task 1.1.1] [Task 2.1.1] [Task 3.1.1] [Task 4.1.1] [Task 5.1.1]
[Task 1.1.2] [Task 2.1.2] [Task 3.1.2] [Task 4.1.2] [Task 5.1.2]
[Task 1.1.3] [Task 2.1.3] [Task 3.1.3] [Task 4.1.3] [Task 5.1.3]
... ... ... ... ...
```
### Why This Works
- **User-centric:** Organizes work around user goals, not engineering modules
- **Shared understanding:** Product, design, engineering all see the same journey
- **Prioritization clarity:** Top tasks = MVP, lower tasks = future iterations
- **Gap identification:** Missing steps or tasks become obvious
- **Release planning:** Draw horizontal "release lines" to define scope
### Anti-Patterns (What This Is NOT)
- **Not a Gantt chart:** This isn't project management—it's user journey visualization
- **Not a feature list:** Activities aren't features—they're user behaviors
- **Not static:** Story maps evolve as you learn more about users
### When to Use This
- Kicking off a new product or major feature
- Aligning stakeholders on user workflow
- Prioritizing backlog based on user needs
- Identifying MVP vs. future releases
- Onboarding new team members to the product vision
### When NOT to Use This
- For trivial features (don't map what you already understand)
- When user workflows are constantly changing (map stabilizes workflows)
- As a replacement for user stories (the map informs stories, doesn't replace them)
---
## Application
### Step 1: Define the Context
Use `template.md` for the full fill-in structure.
#### Segment
Who are you building for?
```markdown
### Segment:
- [Specify the target segment, e.g., "Small business owners using DIY accounting software"]
```
**Quality checks:**
- **Specific:** Not "users" but "enterprise IT admins" or "freelance designers"
---
#### Persona
Provide details about the persona within this segment (reference `skills/proto-persona/SKILL.md`).
```markdown
### Persona:
- [Describe the persona: demographics, behaviors, pains, goals]
```
**Example:**
- "Sarah, 35-year-old freelance graphic designer, manages 5-10 client projects at once, struggles with invoicing and payment tracking, wants to spend less time on admin and more time designing"
---
### Step 2: Define the Narrative
What is the user trying to accomplish? Frame this as a Jobs-to-be-Done statement (reference `skills/jobs-to-be-done/SKILL.md`).
```markdown
### Narrative:
- [Concise narrative of the persona's objective, e.g., "Complete a client project from kickoff to final payment"]
```
**Quality checks:**
- **Outcome-focused:** Not "use the product" but "deliver a client project on time and get paid"
- **One sentence:** If it takes more than one sentence, the scope may be too broad
---
### Step 3: Identify Activities (Backbone)
List 3-5 high-level activities the persona engages in to fulfill the narrative. These form the backbone of your map.
```markdown
### Activities:
1. [Activity 1, e.g., "Negotiate project scope and pricing"]
2. [Activity 2, e.g., "Execute design work"]
3. [Activity 3, e.g., "Deliver final assets to client"]
4. [Activity 4, e.g., "Send invoice and receive payment"]
5. [Activity 5, optional]
```
**Quality checks:**
- **Sequential:** Activities happen in order (left-to-right)
- **User actions:** Describe what the user *does*, not what the product *provides*
- **3-5 activities:** Too few = oversimplified, too many = overwhelming
---
### Step 4: Break Activities into Steps
For each activity, list 3-5 steps that detail how the activity is carried out.
```markdown
### Steps:
**For Activity 1: [Activity Name]**
- Step 1: [Detail step 1, e.g., "Review client brief"]
- Step 2: [Detail step 2, e.g., "Draft project proposal"]
- Step 3: [Detail step 3, e.g., "Negotiate timeline and budget"]
- Step 4: [Optional step 4]
- Step 5: [Optional step 5]
**For Activity 2: [Activity Name]**
- Step 1: [Detail step 1]
- Step 2: [Detail step 2]
...
```
**Quality checks:**
- **Actionable:** Each step is something the user does
- **Observable:** You could watch someone perform this step
- **Logical sequence:** Steps follow a natural order
---
### Step 5: Break Steps into Tasks
For each step, list 5-7 tasks that must be completed.
```markdown
### Tasks:
**For Activity 1, Step 1: [Step Name]**
- Task 1: [Detail task 1, e.g., "Read client brief document"]
- Task 2: [Detail task 2, e.g., "Identify key deliverables"]
- Task 3: [Detail task 3, e.g., "Note budget constraints"]
- Task 4: [Detail task 4, e.g., "Clarify timeline expectations"]
- Task 5: [Detail task 5, e.g., "List open questions for client"]
- Task 6: [Optional task 6]
- Task 7: [Optional task 7]
**For Activity 1, Step 2: [Step Name]**
- Task 1: [Detail task 1]
...
```
**Quality checks:**
- **Granular:** Tasks are small, specific actions
- **User-facing or behind-the-scenes:** Include both (e.g., "Send email" and "Receive confirmation")
- **Prioritizable:** You'll prioritize tasks vertically (top = essential, bottom = nice-to-have)
---
### Step 6: Prioritize Vertically
Arrange tasks top-to-bottom by priority:
- **Top rows:** MVP / Release 1 (must-have)
- **Middle rows:** Release 2 (important but not critical)
- **Bottom rows:** Future / Nice-to-have
Draw horizontal "release lines" to demarcate scope.
---
### Step 7: Identify Gaps and Opportunities
Review the map and ask:
- Are there missing steps or tasks?
- Are there pain points we're not addressing?
- Are there opportunities to delight users?
- Do all activities flow logically?
---
## Examples
See `examples/sample.md` for a full story map example.
---
## Common Pitfalls
### Pitfall 1: Activities Are Features, Not User Behaviors
**Symptom:** "Activity 1: Use the dashboard. Activity 2: Generate reports."
**Consequence:** You've mapped the product, not the user journey.
**Fix:** Reframe as user actions: "Activity 1: Monitor project progress. Activity 2: Summarize work for stakeholders."
---
### Pitfall 2: Too Many Activities
**Symptom:** 10+ activities across the backbone
**Consequence:** Map becomes overwhelming and loses focus.
**Fix:** Consolidate. If you have 10 activities, you're likely mixing activities with steps. Aim for 3-5 high-level activities.
---
### Pitfall 3: Tasks Are Too Vague
**Symptom:** "Task 1: Do the thing"
**Consequence:** Can't prioritize or estimate vague tasks.
**Fix:** Be specific: "Task 1: Enter client email address in the 'Bill To' field."
---
### Pitfall 4: Ignoring Vertical Prioritization
**Symptom:** All tasks at the same level—no MVP vs. future releases defined
**Consequence:** No clarity on what to build first.
**Fix:** Explicitly prioritize. Draw release lines. Force hard choices about what's MVP.
---
### Pitfall 5: Mapping in Isolation
**Symptom:** PM creates the map alone, then presents it to the team
**Consequence:** No shared ownership or understanding.
**Fix:** Map collaboratively. Run a story mapping workshop with product, design, and engineering.
---
## References
### Related Skills
- `skills/proto-persona/SKILL.md` — Defines the persona for the story map
- `skills/jobs-to-be-done/SKILL.md` — Informs the narrative and activities
- `skills/user-story/SKILL.md` — Tasks from the map become user stories
- `skills/problem-statement/SKILL.md` — Problem statement frames the narrative
### External Frameworks
- Jeff Patton, *User Story Mapping* (2014) — Origin of the story mapping technique
- Teresa Torres, *Continuous Discovery Habits* (2021) — Opportunity solution trees (complementary to story maps)
### Dean's Work
- User Story Mapping Prompt (adapted from Jeff Patton's methodology)
### Provenance
- Adapted from `prompts/user-story-mapping.md` in the `https://github.com/deanpeters/product-manager-prompts` repo.
---
**Skill type:** Component
**Suggested filename:** `user-story-mapping.md`
**Suggested placement:** `/skills/components/`
**Dependencies:** References `skills/proto-persona/SKILL.md`, `skills/jobs-to-be-done/SKILL.md`, `skills/user-story/SKILL.md`, `skills/problem-statement/SKILL.md`

View File

@@ -0,0 +1,77 @@
# User Story Mapping Example
## Example: Story Map for Freelance Invoicing Product
```markdown
## User Story Map: Freelance Invoicing
### Who
#### Segment:
- Freelance creative professionals (designers, writers, photographers)
#### Persona:
- Sarah, 35, freelance graphic designer
- Manages 5-10 clients at once
- Struggles with invoicing, payment tracking, and follow-ups
- Wants to spend less time on admin, more time designing
- Currently uses Excel + email, which is error-prone and time-consuming
### Backbone
#### Narrative:
- Complete a client project from kickoff to final payment without admin hassle
#### Activities:
1. Negotiate project scope and pricing
2. Execute design work
3. Deliver final assets
4. Send invoice and receive payment
5. Follow up on late payments
### Steps:
**For Activity 1: Negotiate project scope and pricing**
- Step 1: Review client brief
- Step 2: Draft project proposal
- Step 3: Negotiate timeline and budget
**For Activity 2: Execute design work**
- Step 1: Create initial concepts
- Step 2: Share concepts for feedback
- Step 3: Iterate based on feedback
- Step 4: Finalize design
**For Activity 3: Deliver final assets**
- Step 1: Export final files in client-requested formats
- Step 2: Upload files to shared folder or email
- Step 3: Confirm client receipt
**For Activity 4: Send invoice and receive payment**
- Step 1: Create invoice with project details
- Step 2: Send invoice to client
- Step 3: Track payment status
- Step 4: Confirm payment received
**For Activity 5: Follow up on late payments**
- Step 1: Identify overdue invoices
- Step 2: Send payment reminder
- Step 3: Escalate if still unpaid
### Tasks (Sample for Activity 4, Step 1: Create invoice):
**MVP (Release 1):**
- Task 1: Enter client name and contact info
- Task 2: Add line items (description, hours, rate)
- Task 3: Calculate total automatically
- Task 4: Preview invoice before sending
**Release 2:**
- Task 5: Add logo and custom branding
- Task 6: Save invoice templates for repeat clients
- Task 7: Auto-populate line items from project notes
**Future:**
- Task 8: Generate invoices from time tracking data
- Task 9: Multi-currency support
```

View File

@@ -0,0 +1,41 @@
# User Story Map Template
Use this template to create a complete user story map (segment, persona, backbone, steps, and tasks).
## Provenance
Adapted from `prompts/user-story-mapping.md` in the `https://github.com/deanpeters/product-manager-prompts` repo.
## Template
```markdown
## User Story Map Template
### Who
#### Segment:
- [Specify the target segment]
#### Persona:
- [Describe the persona and their key characteristics]
### Backbone
#### Narrative:
- [Insert the concise narrative of the persona's objective]
#### Activities:
1. [Describe Activity 1]
2. [Describe Activity 2]
3. [Continue as necessary for up to 5 activities]
#### Steps:
For [Activity 1]:
- Step 1: [Detail Step 1 for Activity 1]
- Step 2: [Detail Step 2 for Activity 1]
- Step 3: [Detail Step 3 for Activity 1]
#### Tasks:
For [Activity 1, Step 1]:
- Task 1: [Detail Task 1 for Step 1 of Activity 1]
- Task 2: [Detail Task 2 for Step 1 of Activity 1]
- Task 3: [Detail Task 3 for Step 1 of Activity 1]
```

View File

@@ -1,79 +1,18 @@
{
"version": 1,
"updatedAt": "2026-07-20T07:33:31.296Z",
"updatedAt": "2026-07-21T08:13:15.367Z",
"prototypes": [
{
"id": "folder-1782874576229-r8atbu",
"kind": "folder",
"title": "OneOS",
"children": [
{
"id": "item:prototypes:oneos-prototype-demo",
"kind": "item",
"title": "原型演示",
"itemKey": "prototypes/oneos-prototype-demo"
},
{
"id": "folder-1783875936186-27raza",
"kind": "folder",
"title": "旧ONEOS",
"children": [
{
"id": "item:prototypes:oneos-web-business",
"kind": "item",
"title": "业务管理",
"itemKey": "prototypes/oneos-web-business"
},
{
"id": "item:prototypes:oneos-web-data-analysis",
"kind": "item",
"title": "数据分析",
"itemKey": "prototypes/oneos-web-data-analysis"
},
{
"id": "item:prototypes:oneos-web-finance",
"kind": "item",
"title": "财务管理(合包)",
"itemKey": "prototypes/oneos-web-finance"
},
{
"id": "item:prototypes:oneos-web-help-center",
"kind": "item",
"title": "帮助中心",
"itemKey": "prototypes/oneos-web-help-center"
},
{
"id": "item:prototypes:oneos-web-lease-contract",
"kind": "item",
"title": "车辆租赁合同",
"itemKey": "prototypes/oneos-web-lease-contract"
},
{
"id": "item:prototypes:oneos-web-ledger-data",
"kind": "item",
"title": "台账数据",
"itemKey": "prototypes/oneos-web-ledger-data"
},
{
"id": "item:prototypes:oneos-web-ops",
"kind": "item",
"title": "运维管理",
"itemKey": "prototypes/oneos-web-ops"
},
{
"id": "item:prototypes:oneos-web-procurement",
"kind": "item",
"title": "采购管理",
"itemKey": "prototypes/oneos-web-procurement"
}
]
},
{
"id": "item:prototypes:oneos-prototype-nav",
"kind": "item",
"title": "原型导航",
"itemKey": "prototypes/oneos-prototype-nav"
},
{
"id": "folder-1782874576229-r8atbu",
"kind": "folder",
"title": "OneOS",
"children": [
{
"id": "item:prototypes:oneos-web-workbench-new",
"kind": "item",
@@ -185,12 +124,6 @@
"title": "站点信息",
"itemKey": "prototypes/oneos-web-h2-station-site"
},
{
"id": "item:prototypes:oneos-h5-h2-order",
"kind": "item",
"title": "加氢记录H5",
"itemKey": "prototypes/oneos-h5-h2-order"
},
{
"id": "item:prototypes:oneos-web-h2-station",
"kind": "item",
@@ -204,9 +137,9 @@
"itemKey": "prototypes/oneos-web-h2-station-weekly"
},
{
"id": "item-prototypes-oneos-web-h2-station-analysis",
"id": "item:prototypes:oneos-web-h2-station-analysis",
"kind": "item",
"title": "oneos web h2 station analysis",
"title": "加氢站分析",
"itemKey": "prototypes/oneos-web-h2-station-analysis"
}
]
@@ -328,8 +261,94 @@
"kind": "item",
"title": "业务条线说明",
"itemKey": "prototypes/lease-business-line-overview"
},
{
"id": "item:prototypes:yunxiao-pipeline-handbook",
"kind": "item",
"title": "云效生产线手册",
"itemKey": "prototypes/yunxiao-pipeline-handbook"
}
]
},
{
"id": "folder-1784563868850-xi0guc",
"kind": "folder",
"title": "小羚羚",
"children": [
{
"id": "item:prototypes:oneos-h5-vehicle-assets",
"kind": "item",
"title": "车辆资产H5",
"itemKey": "prototypes/oneos-h5-vehicle-assets"
},
{
"id": "item:prototypes:oneos-h5-h2-order",
"kind": "item",
"title": "加氢记录H5",
"itemKey": "prototypes/oneos-h5-h2-order"
}
]
},
{
"id": "folder-1783875936186-27raza",
"kind": "folder",
"title": "旧ONEOS",
"children": [
{
"id": "item:prototypes:oneos-web-business",
"kind": "item",
"title": "业务管理",
"itemKey": "prototypes/oneos-web-business"
},
{
"id": "item:prototypes:oneos-web-data-analysis",
"kind": "item",
"title": "数据分析",
"itemKey": "prototypes/oneos-web-data-analysis"
},
{
"id": "item:prototypes:oneos-web-finance",
"kind": "item",
"title": "财务管理(合包)",
"itemKey": "prototypes/oneos-web-finance"
},
{
"id": "item:prototypes:oneos-web-help-center",
"kind": "item",
"title": "帮助中心",
"itemKey": "prototypes/oneos-web-help-center"
},
{
"id": "item:prototypes:oneos-web-lease-contract",
"kind": "item",
"title": "车辆租赁合同",
"itemKey": "prototypes/oneos-web-lease-contract"
},
{
"id": "item:prototypes:oneos-web-ledger-data",
"kind": "item",
"title": "台账数据",
"itemKey": "prototypes/oneos-web-ledger-data"
},
{
"id": "item:prototypes:oneos-web-ops",
"kind": "item",
"title": "运维管理",
"itemKey": "prototypes/oneos-web-ops"
},
{
"id": "item:prototypes:oneos-web-procurement",
"kind": "item",
"title": "采购管理",
"itemKey": "prototypes/oneos-web-procurement"
}
]
},
{
"id": "item:prototypes:oneos-prototype-nav",
"kind": "item",
"title": "原型导航",
"itemKey": "prototypes/oneos-prototype-nav"
}
],
"docs": [],

View File

@@ -0,0 +1,139 @@
---
name: AutoVUL
description: >-
Generates OneOS PC version update logs from a Yunxiao (云效) iteration name by
loading all linked requirements, or from a pasted changelog list. Use when testers
or PMs say AutoVUL, 版本更新日志, 按迭代生成更新日志, 发版公告, or give a 迭代名称.
---
# AutoVULOneOS PC 版本更新日志)
面向**测试人员发版**:输入云效**迭代名称** → 拉取该迭代关联需求 → 自动生成对外版本更新日志。
也可手动粘贴变更清单(兜底)。**预计维护时长**由人工单独通知,不写进日志。
## 何时使用
- 测试说:`按 $AutoVUL` / `生成版本更新日志` / `按迭代 xxx 出更新日志`
- 输入含云效「迭代名称」
- 发版前需要统一文案口径
## 主流程(测试 · 按迭代,默认)
完整取数步骤见 [references/yunxiao-sprint.md](references/yunxiao-sprint.md)。
```text
1. 确认云效项目缺省统一运营管理平台PC端可改
2. 向用户索取「迭代名称」(若口令里已带则直接用)
3. 在云效查找该迭代 → 立即反馈成功或失败(见下)
4. 成功:列出迭代内关联「需求」清单(编号 + 标题 + 状态)供用户快速核对
5. 从每条需求提取变更素材(优先级见「素材优先级」)
6. 按本文格式分类、合并、润色 → 输出成稿正文
```
### 迭代读取 · 成功 / 失败反馈(强制)
查找迭代后**必须先反馈**,再继续或停止:
| 结果 | 反馈文案(可微调,语义不变) | 下一步 |
|------|------------------------------|--------|
| 精确命中 1 个迭代 | `✅ 已找到迭代「{名称}」(状态:{状态}),关联需求 {N} 条。` | 继续拉需求并生成 |
| 0 个匹配 | `❌ 未找到迭代「{输入}」。请核对后重新输入迭代名称(可复制云效迭代标题)。` | **停止生成**;等待用户重输 |
| 多个模糊匹配 | `⚠️ 找到多个相似迭代1)… 2)… 请回复序号或完整精确名称。` | 等用户选定后再继续 |
| 登录/权限失败 | `❌ 无法访问云效(未登录或无权限)。请在浏览器登录云效后重试,或改用手动清单。` | 停止;提示登录或兜底 |
| 迭代存在但 0 条需求 | `⚠️ 迭代「{名称}」已找到,但暂无关联需求。请确认规划后再试,或改用手动清单。` | 停止生成对外正文 |
**失败后**:用户只需再发一次迭代名称(或纠正项目名),从步骤 2 重跑;不要要求用户重装 Skill。
### 素材优先级(每条需求)
| 顺序 | 来源 | 用法 |
|------|------|------|
| 1 | 需求描述中的「更新内容」段(非「更新内容·历史」) | 首选,直接改写成日志条目 |
| 2 | AutoPRD「功能变更记录」中可对外部分 | 次选 |
| 3 | 需求标题 + 简短描述摘要 | 仅当前两者皆空时;价值句可合理补全 |
| — | 纯技术债、内部监控、未对用户开放 | **不写入**对外日志 |
Bug 类需求(类型/标签含缺陷修复、或更新内容写「修复」归入【Bug修复】其余归【新功能】。边界不清优先 Bug修复。
### 版本号与更新时间
| 字段 | 规则 |
|------|------|
| 版本号 | 优先从迭代名解析(如含 `V1.1.5` / `1.1.5`);解析不到再问用户 |
| 更新时间 | 用户提供则用;未提供可写迭代计划开始日的 `MM月DD日HH:MM`,或先问 |
| 产品线 | 默认 `OneOS PC版` |
## 兜底流程(手动清单)
无云效权限、或迭代为空时:用 [input-template.md](input-template.md) 粘贴清单,跳过迭代步骤,直接分类润色。
## 固定输出格式
```text
OneOS PC版V{主.次.修订}更新日志:
更新时间:{MM}月{DD}日{HH}:{MM}
----------------------------------------------------
【新功能】
1「{模块名}」{做了什么}{责任部门}可{用户价值 / 解决的问题}
2「{模块名}」{…}
【Bug修复】
3「{模块名}」{修复了什么}{责任部门}可{恢复的能力 / 避免的影响}
```
### 版式硬约束
| 项 | 规则 |
|----|------|
| 标题 | `OneOS PC版V` + 版本号 + `更新日志:`(如 `V1.1.5`,无空格) |
| 更新时间 | 仅开始时刻;`MM月DD日HH:MM`**禁止**「预计x分钟内完成」 |
| 分隔线 | `----------------------------------------------------` |
| 分区 | 仅 `【新功能】` / `【Bug修复】`;空类整段省略 |
| 序号 | 全篇连续,从 1 起 |
| 模块名 | `「」`;与产品菜单正式名一致 |
| 对外正文 | **不写**需求号、缺陷号、迭代 ID核对清单可另附 |
| 结尾 | 无签名、无「感谢使用」、无技术附录 |
## 分类 / 合并 / 文案(成稿规则)
| 归入【新功能】 | 归入【Bug修复】 | 不写进对外日志 |
|----------------|-----------------|----------------|
| 新增能力、新入口、新字段/流程、可感知体验优化 | 原有能力异常、报错、数据错、交互失效 | 技术债、无感性能、未开放灰度、仅内部监控 |
- **同分区同模块**合并为一条,分点用 ``
- **跨分区**同模块分栏各写一条
- 每条含:做什么 + 用户价值 + 责任部门自然嵌入(`{部门}人员可…`
- **禁止**「供…条线…使用」;部门对照 [module-role-mapping.md](module-role-mapping.md)
- 禁止编造迭代内不存在的能力;存疑跳过或标待确认(不进正式成稿)
- 单条建议 ≤100 字
排序:先新功能后修复;同分区全员影响优先;资金/合规修复置修复区最前。
## 输出纪律
1. **先**输出迭代读取反馈(✅/❌/⚠️)
2. 成功时:可先给「本迭代需求清单」短表(含编号,便于测试核对)
3. 再输出**纯文本成稿正文**(默认不要 Markdown 大标题包裹成稿)
4. 用户明确要求时,可在正文后附「分类依据 / 跳过项」
## 发布前自检
- [ ] 已反馈迭代查找结果;失败时未强行编造成稿
- [ ] 成稿条目均可追溯到本迭代关联需求素材
- [ ] 标题、时间行、分隔线正确;无预计时长
- [ ] 同模块合并正确;每条含价值 + 部门自然嵌入
- [ ] 对外正文无需求号/缺陷号/技术黑话;序号连续
## 给测试同事的口令
```text
按 $AutoVUL 生成版本更新日志。
项目统一运营管理平台PC端
迭代名称V1.1.5发版迭代
更新时间07月16日16:00
```
迭代名写错时,按提示改完再发一次即可。
手动兜底见 [input-template.md](input-template.md);成稿范例见 [examples.md](examples.md)。

View File

@@ -0,0 +1,77 @@
# AutoVUL 成稿与交互范例
## 1. 迭代读取失败 → 重输
**用户:**
```text
按 $AutoVUL迭代名称V1.1.5
```
**Agent**
```text
❌ 未找到迭代「V1.1.5」。请核对后重新输入迭代名称(可复制云效迭代标题)。
```
**用户:**
```text
V1.1.5发版迭代
```
**Agent**
```text
✅ 已找到迭代「V1.1.5发版迭代」(进行中),关联需求 3 条:
1. ONEOSP-201 车辆管理运营城市展示优化
2. ONEOSP-202 还车应结款租金计算修复
3. ONEOSP-203 业务管理新增租赁物流统计
OneOS PC版V1.1.5更新日志:
更新时间07月16日16:00
----------------------------------------------------
【新功能】
1「车辆管理」优化车辆「运营城市」展示运维部人员可快速辨认车辆所属运营区域及数据来源
2「业务管理」新增租赁、物流业务统计模块业务管理组、业务员可查看租赁经营数据业务服务组、财务部可跟踪自营物流经营情况
【Bug修复】
3「还车应结款」修复租金费用项重复及计算错误业务管理组、安全部、运维部、能源部人员还车结算金额更准确
```
## 2. 完整成稿风格基准(手动/迭代素材均可)
> 责任部门对照:`module-role-mapping.md`OneOS 业务条线说明)
```text
OneOS PC版V1.1.5更新日志:
更新时间07月16日16:00
----------------------------------------------------
【新功能】
1「加氢站管理」升级新版加氢站模块采购部可统一维护签约站与非签约站信息签约加氢站可便捷管理站点日常运营
2「车辆管理」优化车辆「运营城市」展示运维部人员可快速辨认车辆所属运营区域及数据来源
3「审批中心」支持按客户名称搜索关联合同业务员、法务部审批时可快速定位合同还车应结款添加照片附件后业务管理组、安全部、运维部、能源部会签人员可在审批流中查看完整附件
4「保险采购」支持保比价单按车辆单独勾选并分开提交审批运维部、安全部人员可按车辆分批推进投保审批
5「故障管理」增加「故障来源」字段展示运维部人员可区分上报渠道并跟进处理
6「证照管理」特种设备使用登记证/使用标识照片支持OCR识别运维部、安全部人员可减少手工录入、加快证照建档
7「全局显示」统一各模块列表分页样式与交互各模块操作人员跨页面浏览时体验一致、上手更快
8「交还车管理」支持交车/还车签章文件在线预览,运维部、业务管理组、司机可现场核对签章文件;提交时增加胎纹数据必填校验,减少漏填导致的后续结算争议
9「还车应结款」调整安全部待办区域权限安全部人员仅处理职责范围内的还车应结款审批
10「业务管理」新增租赁、物流业务统计模块业务管理组、业务员可查看租赁经营数据业务服务组、财务部可跟踪自营物流经营情况
【Bug修复】
11「还车管理」修复签字单与还车检查单/还车应结款轮胎数据不一致,运维部、业务管理组人员还车验收与结算依据保持一致
12「租赁合同」修复编辑合同提交时异常提示内容冗余业务员、法务部人员可快速定位真正错误原因
13「交还车管理」修复交车/还车签章客户名称关联错误,避免运维部、业务管理组、司机现场核对时客户信息张冠李戴
14「车辆氢费明细」修复同客户不同生效时段、以及不同客户成本单价显示异常能源部人员可准确核对氢费核算单价
15「还车应结款」修复租金费用项重复及计算错误业务管理组、安全部、运维部、能源部人员还车结算金额更准确
```
## 对照要点
| 规则 | 体现 |
|------|------|
| 先反馈迭代结果 | 失败可重输;成功列出需求再成稿 |
| 无预计时长 | 更新时间仅 `07月16日16:00` |
| 对外无需求号 | 成稿正文不出现 ONEOSP-xxx |
| 部门自然嵌入 | `运维部人员可…`,无「供…条线使用」 |

View File

@@ -0,0 +1,69 @@
# AutoVUL · 输入模板
## A. 测试人员 · 按云效迭代(推荐)
复制下面口令发给 AI改迭代名即可
```text
按 $AutoVUL 生成版本更新日志。
项目统一运营管理平台PC端
迭代名称:
更新时间:
```
### 期望反馈
- 成功:`✅ 已找到迭代「…」,关联需求 N 条` → 随后输出成稿
- 失败:`❌ 未找到迭代「…」。请重新输入迭代名称` → 改名后再发一次即可
**说明**:预计维护时长由人工单独通知,不写入版本更新日志。
---
## B. 手动清单(云效不可用时兜底)
### 版本元信息
| 字段 | 填写 | 示例 |
|------|------|------|
| 产品线 | | OneOS PC版 |
| 版本号 | | V1.1.5 |
| 更新开始时间 | | 07月16日16:00 |
| 发布范围 | 全量 / 灰度(说明) | 全量 |
| 是否对外发布 | 是 / 否 | 是 |
### 新功能(原始材料,可粗糙)
| 模块 | 做了什么 | 解决什么问题 / 用户故事 | 业务条线 | 责任部门 | 是否写入日志 |
|------|----------|-------------------------|----------|----------|--------------|
| 例:加氢站管理 | 升级新版模块 | 统一维护站点 | 能源业务条线 | 采购部、加氢站 | 是 |
| | | | | | |
### Bug修复原始材料可粗糙
| 模块 | 原问题现象 | 修复后表现 | 对用户的影响 | 业务条线 | 责任部门 | 是否写入日志 |
|------|------------|------------|--------------|----------|----------|--------------|
| 例:还车应结款 | 租金费用项重复 | 金额正确 | 避免结算偏差 | 租赁业务条线 | 业务管理组、安全部、运维部、业务管理组-能源部 | 是 |
| | | | | | | |
### 明确不要写入对外日志
- (如:重构、依赖升级、仅内部监控、未上线配置)
### 粘贴给 AI
```text
请按 $AutoVUL 用下方手动清单生成成稿(跳过云效迭代)。
要求:同模块合并;每条含「做什么 + 用户价值」;责任部门自然融入;只输出正文。
【版本元信息】
- 产品线OneOS PC版
- 版本号V
- 更新开始时间:
【新功能】
(粘贴)
【Bug修复】
(粘贴)
```

View File

@@ -0,0 +1,60 @@
# 模块 → 业务条线 / 责任部门对照
> 数据源OneOS「业务条线说明」页责任部门定义租赁 / 能源 / 运维 / 安全 / 自营)
> 生成更新日志时,**适用对象**须从此表取 `roles`,不得自造部门名。
## 五条业务条线
| ID | 条线名称 |
|----|----------|
| lease | 租赁业务条线 |
| energy | 能源业务条线 |
| ops | 运维管理条线 |
| safety | 安全管理条线 |
| self-operated | 自营业务条线 |
## 本版变更模块对照
| 更新日志模块名 | 业务条线 | lines.ts 模块 | 责任部门roles原文 |
|----------------|----------|---------------|-------------------------|
| 加氢站管理 | 能源业务条线 | 加氢站管理 | 采购部、加氢站 |
| 车辆管理 | 运维管理条线 | 验车入库 / 车辆管理(资产底座) | 运维部、采购部 |
| 审批中心 | 跨条线 | 租赁合同 + 还车应结款(审批场景) | 业务员、法务部、业务管理组、安全部、运维部、业务管理组-能源部 |
| 保险采购 | 运维管理条线 | 证照 / 保险(车辆资产关联) | 运维部、安全部 |
| 故障管理 | 运维管理条线 | 故障管理 | 运维部、司机 |
| 租赁合同 | 租赁业务条线 | 租赁合同 | 业务员、法务部 |
| 证照管理 | 运维管理条线 | 证照管理 | 运维部、安全部 |
| 全局显示 | 全系统 | 系统介绍(五条条线) | 业务管理组、运维部、采购部、安全部、法务部、财务部、业务管理组-能源部、业务服务组 |
| 交还车管理 | 运维管理条线 | 交车管理 + 还车管理 | 运维部、业务管理组、司机 |
| 还车应结款 | 租赁业务条线 | 还车应结款 | 业务管理组、安全部、运维部、业务管理组-能源部 |
| 还车管理 | 运维管理条线 | 还车管理 | 运维部、业务管理组 |
| 车辆氢费明细 | 能源业务条线 | 车辆氢费明细 | 业务管理组-能源部 |
| 业务管理 · 租赁统计 | 租赁业务条线 | 租赁业务台账 / 客户管理 | 业务管理组、业务员 |
| 业务管理 · 物流统计 | 自营业务条线 | 自营台账 | 业务服务组、财务部 |
## 写法规则
- 部门名与 `roles` **完全一致**`业务管理组-能源部` 对外可简写「能源部人员」)
- **禁止**「供…业务条线…使用」「供…人员使用」
- **推荐**`{部门}人员可…` / `{部门}可…` / `{部门}审批时可…`
- 同模块合并后,多部门用顿号或分号自然串联
- 找不到模块时:查 `lines.ts`;仍无则标「待产品确认」不进成稿
## 自然表述示例
| 模块 | 生硬(禁止) | 自然(推荐) |
|------|--------------|--------------|
| 车辆管理 | 供运维管理条线运维部使用 | 运维部人员可快速辨认车辆所属运营区域 |
| 加氢站管理 | 供能源业务条线采购部、加氢站使用 | 采购部可统一维护站点信息,加氢站可便捷管理日常运营 |
| 还车应结款 | 供租赁业务条线业务管理组…使用 | 业务管理组、安全部、运维部、能源部还车结算金额更准确 |
## 常见错误(禁止)
| 错误写法 | 正确写法 |
|----------|----------|
| 氢能运营业务人员 | 采购部、加氢站(能源 · 加氢站管理) |
| 车辆运营及资产管理业务人员 | 运维部、采购部(运维 · 车辆管理) |
| 各业务条线审批人员 | 业务员、法务部、…(按审批场景枚举) |
| 保险采购业务人员 | 运维部、安全部 |
| 还车结算业务人员 | 业务管理组、安全部、运维部、业务管理组-能源部 |
| 经营分析及管理部门 | 业务管理组、业务服务组(分租赁 / 自营) |

View File

@@ -0,0 +1,81 @@
# 云效迭代 → 需求清单AutoVUL 取数)
测试人员只提供**迭代名称**Agent 负责在云效定位迭代、拉取关联需求、抽出更新素材。
## 安全边界(强制)
- **禁止**在对话、Skill、日志、成稿中写入 Token、密码、Cookie、私钥。
- 优先复用**已登录浏览器会话**操作云效 Projex`yunxiao-requirement-lifecycle` 一致)。
- 若团队已配置本机环境变量访问 OpenAPI可走 APIToken 只读自环境,永不回显。
建议环境变量(可选,名称可按团队调整):
| 变量 | 用途 |
|------|------|
| `YUNXIAO_TOKEN` | 个人访问令牌(`x-yunxiao-token` |
| `YUNXIAO_DOMAIN` | 服务接入点域名 |
| `YUNXIAO_ORG_ID` | 组织 ID中心版 |
| `YUNXIAO_PROJECT_ID` | 默认项目 ID或运行时按项目名解析 |
## 默认项目
口令未指定时:`统一运营管理平台PC端`。项目名不精确时先问清再查迭代。
## 判定顺序:解析迭代
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | 用户未给迭代名称 | 询问:「请输入云效迭代名称(可复制标题)」;不继续 |
| 2 | 项目未确定 | 询问精确项目名;不继续 |
| 3 | 云效不可访问 | `❌ 无法访问云效…`;不继续 |
| 4 | 名称精确匹配 1 条迭代 | `✅ 已找到迭代…`;进入拉需求 |
| 5 | 0 条 | `❌ 未找到迭代…请重新输入`;不继续 |
| 6 | >1 条相似 | `⚠️ 多个相似…请回复序号或精确名称`;等用户选定 |
| 7 | 命中但关联需求 0 | `⚠️ …暂无关联需求`;不生成对外正文 |
匹配规则:先**全等**(去首尾空格);全等失败再做包含/去空格模糊;模糊多条必须让用户确认,禁止擅自挑一个。
## 拉取关联需求
只收集工作项类型为**需求Req**且正式关联到该迭代的项。
- **浏览器路径**:打开项目 → 迭代 → 该迭代规划/工作项列表 → 筛选类型=需求 → 导出或逐条打开描述。
- **OpenAPI 路径(示意)**
1. `ListSprints`:按项目列迭代,用 `name` 匹配用户输入,得到 `sprintId`
2. `SearchWorkitems``category=Req``spaceId=项目Id``conditions` 中按字段 `sprint` CONTAINS `[sprintId]`(以租户实际 fieldIdentifier 为准;可先在需求列表页过滤后复制 `workitem/list` 的 conditions
3. 分页直到收齐(关注响应头 `x-total`
4. 需要正文时再 `GetWorkitem` 取 description
每条需求最少记录:
| 字段 | 说明 |
|------|------|
| serialNumber | 如 ONEOSP-123仅用于核对清单**不进对外成稿** |
| subject | 标题 |
| status | 状态展示名 |
| description | 用于抽取「更新内容」 |
| category / type / labels | 辅助判断新功能 vs 修复 |
## 从描述抽「更新内容」
1. 定位标题「更新内容」或「## 更新内容」段落
2. **不要**把「更新内容·历史」并入本版素材(历史仅备查)
3. 若无「更新内容」:用标题 + 需求说明摘要;仍无有效产品语义则跳过并在内部备注
## 成功反馈后的核对清单(推荐短表)
在生成成稿前输出,例如:
```text
✅ 已找到迭代「V1.1.5发版迭代」(进行中),关联需求 5 条:
1. ONEOSP-101 保险采购比价分车提交
2. ONEOSP-102 交还车签章预览
正在按 AutoVUL 格式生成更新日志…
```
用户若立刻纠正「某条不要进日志」,从素材中剔除后再出成稿。
## 与发版任务关系
本 Skill **只读**迭代与需求,**不创建**【发版】任务、不改云效状态。发版挂链仍用 `yunxiao-requirement-lifecycle`(如「准备发布」口令 / A08

View File

@@ -0,0 +1,183 @@
---
name: oneos-autoprd
description: >-
Generates and keeps in sync OneOS AutoPRD (PM-facing requirements) plus Axhub
Make annotation directory Markdown/PRD; on 需求定稿/定稿/确认定稿/本次定稿 appends
functional release changelog below the PRD since last baseline. When a Yunxiao
requirement advances to 分析中/设计中/待开发, creates a same-titled linked task
(tag+create-time follow the requirement; 分析中/设计中 assignee=creator, 待开发
assignee=何斐). Use eagerly for prototypes, AutoPRD, or Yunxiao requirement
description/更新内容/stage advance—do not skip annotation sync, 定稿 changelog,
or stage-task creation.
---
# OneOS AutoPRD
为 OneOS 业务模块生成**产品经理可读、可评审、可排期**的需求说明,并**自动挂到 Axhub Make 标注工具 → 原型目录**。
另支持:**需求定稿**时汇总功能/逻辑变更记录;与云效组合时提供「需求说明 + 更新内容」;需求进入**分析中 / 设计中 / 待开发**时**自动创建与需求同名的关联任务**(规则见下,写在本 Skill不改云效 Skill
颗粒度对齐「保险采购」全模块 PRD讲清做什么、谁用、故事点、正逆向、流程图与关键业务逻辑**不写**表结构、接口、字段代码名、文件路径、实现清单。
## 何时使用(含自动触发)
**主动调用时**
- AutoPRD、OneOS 需求说明、整模块 PRD、故事点 + 流程图
**改原型时必须自动跟进(全局规则)**
- 正在修改 `src/prototypes/<id>/` 下页面、交互、文案、判定、验收相关内容
- 同一轮交付内同步更新 PRD Markdown + 标注目录,不得只改代码
**需求定稿时(强制)**
- 用户回复含:`需求定稿` / `定稿` / `确认定稿` / `本次定稿`
- 执行 [references/release-changelog.md](references/release-changelog.md):写第 10 章 + 更新基线
**云效建需求 / 完善需求时(强制组合)**
-`$yunxiao-requirement-lifecycle` 一起使用时:先跑本 Skill再写云效描述
- **需求说明** ← PRD 正文(或浓缩交付口径 + 关键章节)
- **更新内容** ← 第 10 章中**自上次定稿以来**的条目(无增量则「首版定稿 / 本轮无功能增量」)
**云效需求推进至分析中 / 设计中 / 待开发时(强制)**
- 无论口令来自本 Skill 还是云效 Skill只要本轮把需求推到上述状态就执行 [references/yunxiao-stage-tasks.md](references/yunxiao-stage-tasks.md)
- 自动建**与需求同名**的任务并正式关联;标签与创建时间口径沿用需求;分析中/设计中负责人=创建人,待开发负责人=**何斐**
不要用本 Skill 替代:需求探索访谈、设计比稿、纯样式微调(无产品语义变化时可跳过全量重写)。
## 工作流
### 主流程(写/同步 PRD
1. **定模块**:确认 OneOS 模块名与 `src/prototypes/<prototype-id>/`
2. **读上下文(只取产品语义)**
- 用户说明、已确认口径、原型标注、`.spec/`、业务条线说明(`lines.ts`
- 忽略实现细节;字段名/接口改写成业务语言。
3. **收敛边界**:做什么 / 不做什么、外部依赖、与其它模块关系。
4. **按模板成文**:下方「输出结构」;缺关键信息最多问 12 个问题,其余写「假设」。
5. **落盘 + 标注同步(强制)** — 见 [references/annotation-sync.md](references/annotation-sync.md)。
| 顺序 | 动作 |
|------|------|
| A | 写/更新 `src/prototypes/<id>/.spec/requirements-prd.md` |
| B | 写/更新 `src/resources/prd/<id>-autoprd.md` |
| C | 更新 `annotation-source.json` **顶层** `directory.nodes`PRD 全文 + 推荐分章);禁止只写 `data.directory` |
| D | 若存在 `scripts/sync-annotation-directory.mjs`,执行之 |
6. **交付说明**:路径、标注目录入口、故事点合计、开放问题/假设。
### 定稿流程(关键字触发)
见 [references/release-changelog.md](references/release-changelog.md)。摘要:
1. 读 PRD + `.spec/autoprd-baseline.json`
2. 汇总自上次定稿以来的**功能/逻辑**变更(排除样式/UI/表结构)
3. 追加到 `## 10. 功能变更记录`(最新在上)
4. 更新基线 JSON + 标注目录
5. 若同时发云效:本次定稿块 →「更新内容」;旧段 →「更新内容·历史」
### 云效交接格式(给 yunxiao skill 直接粘贴)
```markdown
## 原型链接
<对象存储或预览 URL>
## 需求说明
<AutoPRD 正文或交付口径 + 必要章节>
## 更新内容
<第 10 章本次定稿块;无则「首版定稿」或「本轮无功能/逻辑增量」>
## 更新内容·历史
<以往更新内容倒序,勿删除>
```
### 云效阶段任务(状态推进时强制)
见 [references/yunxiao-stage-tasks.md](references/yunxiao-stage-tasks.md)。摘要:
| 需求状态 | 任务标签 | 负责人 | 标题 |
|----------|----------|--------|------|
| 分析中 | 分析 | 需求创建人 | 与需求同名 |
| 设计中 | 设计 | 需求创建人 | 与需求同名 |
| 待开发 | 交付(或开发) | 何斐 | 与需求同名 |
正式关联需求;创建时间口径沿用需求;同阶段未取消任务不重复建。
## 写作硬约束
**必须写**
- 一句话定位 + 目标 / 非目标
- 模块边界(含 mermaid 总览更好)
- 角色与目标(角色名优先对齐业务条线说明)
- **用户故事**(业务条线说明口径)+ Epic 级**故事点SP**粗估
- 分功能**正向**与**逆向/边界**
- 至少 12 个 **mermaid** 流程图
- **关键业务逻辑**(业务话)
- 验收清单 + 「交付口径」一段话
- 定稿后:**功能变更记录**(第 10 章)
**禁止写**
- 数据库表、字段名、接口路径、代码路径、组件名、存储 key
- 研发实现指令(可写「正式环境由审批中心回写」这类业务依赖)
- 在变更记录里写样式/UI/表结构优化
## 用户故事口径(强制 · 对齐业务条线说明)
真相源:原型 **业务条线说明**`lease-business-line-overview` / `lines.ts`)。
每条能力用「责任部门 → **起点****怎么运作****闭环**」叙述;**不要**用「作为…我希望…」宽表作主叙述。
| 块 | 写什么 |
|----|--------|
| 角色 | 谁负责、谁协同 |
| 起点 | 谁在什么前提下启动 |
| 怎么运作 | 有序步骤,含跨角色协作 |
| 关键结果 | 可选标签 |
| 闭环 | 业务终点与可追溯性 |
可选:`US-xx`、压缩句「作为…我想…以便…」、规模 S/M/L 或 SP仅排期不替代主叙述
## 输出结构
完整模板:[references/template.md](references/template.md)。
```markdown
# <模块名> · 产品需求说明(全模块)
## 1. 一句话与目标
## 2. 模块边界(最重要)
## 3. 用户与角色
## 4. 用户故事与故事点(业务条线说明口径)
## 5. 功能模块说明(正向 / 逆向)
## 6. 关键业务逻辑(必须对齐)
## 7. 总览流程图
## 8. 验收清单
## 9. 交付口径
## 10. 功能变更记录 ← 定稿后维护;日常改原型不强制每改必写
```
## 质量自检
- [ ] 产品经理不看代码也能评审
- [ ] 用户故事为起点 / 怎么运作 / 闭环
- [ ] `.spec/requirements-prd.md` 已更新
- [ ] 标注目录「产品需求说明PRD」已同步且正文一致
- [ ] 定稿时:第 10 章 + `autoprd-baseline.json` 已更新
- [ ] 推进至分析中/设计中/待开发时:同名任务已创建或复用,正式关联,负责人正确
- [ ] 无表结构 / 接口 / 代码路径;变更记录无样式/UI 废话
## 参考
- 定稿变更日志:[references/release-changelog.md](references/release-changelog.md)
- 云效阶段任务:[references/yunxiao-stage-tasks.md](references/yunxiao-stage-tasks.md)
- 标注同步细则:[references/annotation-sync.md](references/annotation-sync.md)
- 章节模板:[references/template.md](references/template.md)
- 故事示例:[references/granularity-example.md](references/granularity-example.md)
- 业务条线:`src/prototypes/lease-business-line-overview/lines.ts`
- 复杂判定规格:配合项目规则 `business-logic-documentation`
- 云效组合:`$yunxiao-requirement-lifecycle`(建需求时先本 Skill

View File

@@ -0,0 +1,98 @@
# Axhub 标注目录同步(强制)
使用 OneOS AutoPRD 时,除资源库归档外,**必须**把 PRD 挂到 Axhub Make 标注工具的「原型目录」里,用户才能在右侧面板直接打开。
## 落点(三份必须一致)
| # | 路径 | 作用 |
|---|------|------|
| 1 | `src/prototypes/<prototype-id>/.spec/requirements-prd.md` | **主真相**:产品可读 PRD 全文 |
| 2 | `src/prototypes/<prototype-id>/annotation-source.json`**顶层** `directory.nodes` | **标注目录入口**:面板里可见的 Markdown/PRD |
| 3 | `src/resources/prd/<prototype-id>-autoprd.md` | 资源库归档(可选但默认写) |
可选:复杂判定另写 `.spec/<topic>.md`,目录里再挂一条,并在 PRD 里链过去(与 `business-logic-documentation` 一致)。
## ⚠️ 目录必须写在文档顶层(常见踩坑)
Wire format`prototype-annotation` 一致):
```json
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": { "version": 2, "prototypeName": "...", "nodes": [] },
"markdownMap": {},
"assetMap": {},
"directory": { "nodes": [] }
}
```
| 正确 | 错误 |
|------|------|
| 顶层 `directory.nodes` | 只写在 `data.directory` |
| `PrototypeAnnotationHost` / Make 读的是 `source.directory` | 写在 `data` 下时右侧「原型目录」为空 |
若误写在 `data.directory`:迁移到顶层 `directory`,并删除 `data.directory`,避免双源。
## 目录节点写法(推荐)
在说明根 `folder``children` **靠前**插入或更新:
```json
{
"type": "markdown",
"id": "<prefix>-doc-prd",
"title": "产品需求说明PRD",
"markdownPath": ".spec/requirements-prd.md",
"markdown": "<与 .spec/requirements-prd.md 相同的全文>"
}
```
### 分章节 PRD推荐便于右侧目录浏览
在「产品需求说明PRD」旁增加文件夹 **「PRD 分章」**,按 PRD 的 `##` 一级标题拆成多个 `markdown` 子节点(标题用章节名,正文为该章全文)。专题规格(`.spec/<topic>.md`)放在 **「专题规格」** 文件夹。
参考结构:
```text
folder <模块>说明
markdown 产品需求说明PRD ← 全文
folder PRD 分章
markdown 1. 一句话与目标
markdown 2. 模块边界
folder 专题规格
markdown <topic 标题>
```
`scripts/sync-annotation-directory.mjs` 的原型:改 PRD / `.spec` 后**必须执行**该脚本再生目录。
定稿后第 10 章「功能变更记录」必须进入 PRD 全文节点若有「PRD 分章」,同步该章节点。详见 [release-changelog.md](release-changelog.md)。
约定:
- `id``<短前缀>-doc-prd`(如 `ipc-doc-prd``wb-doc-prd``h5-va-doc-prd`
- `title`优先「产品需求说明PRD已有「PRD」可保留不改名
- **同时写** `markdownPath` + `markdown`:构建可内联路径,运行时目录也能直接读到正文
- 若已有同 id / 同标题节点:只更新正文与 path不新建重复入口
- 遵守 `prototype-annotation-layout`:目录以 `markdown` 为主,不要用 `link`/`route` 顶替 PRD
## 增量改原型时怎么更新
1. 根据改动的文件路径确定 `<prototype-id>`
2. 读现有 `.spec/requirements-prd.md`(没有则按 AutoPRD 模板新建)。
3. 只改受影响章节(故事、正逆向、关键逻辑、验收);其余保留。
4. 回写 `.spec` + `resources/prd` + **顶层** `annotation-source.json``directory`(正文与文件一致;含分章节点)。
5. 若原型有 `scripts/sync-annotation-directory.mjs`,改完后执行它。
## 可跳过全量重写的情况
纯样式、无文案/无流程/无判定变化的微调:不必重写整份 PRD但若改了用户可见文案或交互结果仍须同步对应段落与目录节点。
## 验收
- [ ] `.spec/requirements-prd.md` 存在且为最新
- [ ] `annotation-source.json` **顶层**有 `directory.nodes`(不是只在 `data` 下)
- [ ] 标注目录能打开「产品需求说明PRD」且内容与文件一致
- [ ] 若有「PRD 分章」:各章可单独打开
- [ ] 用户故事仍为业务条线说明口径(起点 / 怎么运作 / 闭环)

View File

@@ -0,0 +1,58 @@
# 颗粒度标杆与用户故事口径
## 用户故事:对齐「业务条线说明」页面
页面:`/prototypes/lease-business-line-overview`
数据:`src/prototypes/lease-business-line-overview/lines.ts`
每个能力卡片的产品叙述结构:
```text
责任部门roles
→ 起点start
→ 怎么运作process[],有序)
→ 关键结果outcomes可选
→ 闭环closure
```
AutoPRD 第 4 章必须用同一套结构写用户故事,而不是「作为…我希望…」宽表。
### 示例(保险采购 · 交车合规,示意)
#### US · 保险有效才允许交车
- **角色**:采购部、运维部
- **起点**:采购或运营维护车辆交强险与商业险台账,作为交车前置合规数据。
- **怎么运作**
1. 在保险采购维护一车一档险种台账(录入 / 识别 / 导入)。
2. 交车时系统校验交强 + 商业是否均为正常或临期。
3. 不合规则拦截交车并给出可理解原因。
- **关键结果**`允许交车` / `禁止交车 · 保险异常`
- **闭环**:交车合规与台账一致、可追溯;异常车辆不能进入履约运营。
- **排期(可选)**US-17 · 规模 M · 作为采购/运维,我想维护交强+商业险并在交车时校验,以便不合规车辆无法交车。
### 反例(不要作为主叙述)
| 编号 | 用户故事 | SP |
|------|----------|----|
| B1 | 作为运营,我能看到失败原因 | 2 |
↑ 信息太扁,缺少起点、协作步骤与闭环结果。
---
## AutoPRD 其余层次(仍要对齐)
1. 一句话 + 目标/非目标
2. 模块边界(独立业务线写清「不自动同步」)
3. 角色表(命名对齐业务条线)
4. **用户故事:起点 / 怎么运作 / 闭环** + SP/规模
5. 分模块正逆向
6. 关键业务逻辑
7. mermaid 流程图
8. 验收清单
9. 交付口径
## 刻意不写
- 表结构、接口、代码路径
- 用实现指令代替业务闭环描述

View File

@@ -0,0 +1,78 @@
# 功能变更记录(定稿触发)
用户回复**需求定稿关键字**后,必须生成「自上次定稿以来」的功能变更日志,追加到 PRD **最下方**,并更新定稿基线。
## 触发关键字(命中任一)
`需求定稿` · `定稿` · `确认定稿` · `本次定稿`
(可带模块名,如「保险采购需求定稿」。)
## 写什么 / 不写什么
| 要写(产品发版视角) | 不写 |
|----------------------|------|
| 哪个功能做了什么修改 | 布局、页面样式、UI 设计优化 |
| 变更了什么业务逻辑 / 流程 / 验收口径 | 「优化了哪些表」「改了哪些字段/接口」 |
| 用户可感知的交互结果变化 | 纯样式、动效、无语义文案微调 |
条目口吻示例:
- 「识别失败」:失败数可点击查看失败文件与原因;仅失败时也可打开明细
- 「比价单」:审批通过后仍须在保单管理另行录入正式保单(强调不自动同步)
## 落点
1. **PRD 文末章节**(强制)
`src/prototypes/<id>/.spec/requirements-prd.md``## 10. 功能变更记录`
新定稿块插在该章**最上方**(倒序:最新在上);历史块保留。
2. **定稿基线**(强制)
`src/prototypes/<id>/.spec/autoprd-baseline.json`
```json
{
"prototypeId": "insurance-procurement",
"lastConfirmedAt": "2026-07-21T12:00:00+08:00",
"lastConfirmedLabel": "定稿 · 2026-07-21",
"summaryBullets": ["…", "…"]
}
```
3. **标注目录**同步更新「产品需求说明PRD」全文节点若有「PRD 分章」,增加或更新「功能变更记录」分章。
4. **云效交接**(若本轮同时走云效):把**本次定稿块**正文作为需求描述「更新内容」;旧「更新内容」挪到「更新内容·历史」。
## 定稿工作流
1. 确认 `<prototype-id>`(从对话 / 打开文件 / 用户指名推断)。
2. 读现有 PRD 与 `autoprd-baseline.json`(无基线则本轮视为**首版定稿**)。
3. 收集自 `lastConfirmedAt` 以来的产品语义变更:
对话确认口径 → 批注/操作说明 → PRD 新旧差异 →(可选)相关提交说明。
过滤掉样式/表结构类改动。
4. 若无功能增量:仍写定稿块,正文为「本轮无功能/逻辑增量(仅样式或未达产品语义变更)」或「首版定稿」。
5. 追加 `### 定稿 · YYYY-MM-DD` + 条目列表到第 10 章顶部。
6. 更新 `autoprd-baseline.json`;执行 annotation-sync。
7. 向用户回报基线时间、条目数、PRD 路径;若需发云效,提示可用口令。
## PRD 章节格式
```markdown
## 10. 功能变更记录
> 产品经理原型发版记录。仅记功能与业务逻辑变更;不含样式/UI/表结构。
### 定稿 · 2026-07-21
- 「能力名」:做了什么 / 逻辑如何变
-
### 定稿 · 2026-07-10
-
```
## 与日常改原型的关系
- **改原型过程中**:按 AutoPRD 主流程增量更新第 19 章;**不必**每改一次就写第 10 章。
- **用户说定稿时**:才汇总写入第 10 章并刷新基线。

View File

@@ -0,0 +1,163 @@
# OneOS AutoPRD 输出模板
复制下列结构填写。方括号为占位。**第 4 章用户故事必须用业务条线说明口径。**
```markdown
# <模块名> · 产品需求说明(全模块)
## 1. 一句话与目标
**一句话**
<用一句话说明模块解决什么>
**要解决的问题**
-
**本期目标**
1.
2.
**非目标(本期不做)**
-
## 2. 模块边界(最重要)
(可选 mermaid模块与外部系统关系
| 业务线 / 子域 | 做什么 | 不做什么 |
|---------------|--------|----------|
| … | … | … |
**外部依赖(产品口径)**
| 依赖方 | 交互方式 | 产品要求 |
|--------|----------|----------|
| … | … | … |
**关键约束**
-
## 3. 用户与角色
| 角色 | 主要目标 |
|------|----------|
| … | … |
> 角色名优先与「业务条线说明」一致(如业务管理组、采购部、运维部)。
## 4. 用户故事与故事点(业务条线说明口径)
> 故事点 / 规模供排期参考,可按团队基准调整。合计约 **N SP**(或 S/M/L 汇总说明)。
按 Epic 或能力单元分组。**每一条**按下面四块写(对齐业务条线说明页:责任部门 → 起点 → 怎么运作 → 闭环):
### Epic A · <名称>(约 N SP
#### A1 · <能力标题>
- **角色**:…
- **起点**:…
- **怎么运作**
1.
2.
3.
- **关键结果**(可选):`可…` / `禁止…`
- **闭环**:…
- **排期(可选)**US-xx · 规模 S/M/L · 「作为…,我想…,以便…」
#### A2 · <能力标题>
### Epic B · …
## 5. 功能模块说明(正向 / 逆向)
### 5.x <功能名>
**正向**
1.
2.
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| … | … |
## 6. 关键业务逻辑(必须对齐)
### 6.x <规则名>
用编号优先级、表格或短列表写清业务规则。
## 7. 总览流程图
```mermaid
flowchart TB
home[入口] --> a[能力A]
home --> b[能力B]
```
## 8. 验收清单(产品 / 测试)
**子域 A**
- [ ]
## 9. 交付口径
> <一段可贴需求单开头的浓缩描述>
## 10. 功能变更记录
> 产品经理原型发版记录。仅记功能与业务逻辑变更;不含样式/UI/表结构。
> 由「需求定稿」关键字触发追加;日常改原型不强制每改必写。详见 [release-changelog.md](release-changelog.md)。
### 定稿 · YYYY-MM-DD
- 「<能力名>」:<做了什么 / 逻辑如何变>
-
```
成文后**必须**同步 Axhub 标注目录(见 [annotation-sync.md](annotation-sync.md)
1. `src/prototypes/<id>/.spec/requirements-prd.md`
2. `annotation-source.json` → 目录节点「产品需求说明PRD`markdownPath` + `markdown`
3. `src/resources/prd/<id>-autoprd.md`
4. 定稿时另写:`src/prototypes/<id>/.spec/autoprd-baseline.json`
## 用户故事写法(对照业务条线说明)
业务条线说明页字段 → AutoPRD 字段:
| 页面 | AutoPRD |
|------|---------|
| 责任部门标签 | **角色** |
| 起点 | **起点** |
| 怎么运作(有序列表) | **怎么运作** |
| 结果色签 | **关键结果**(可选) |
| 闭环 | **闭环** |
示例口吻(摘自业务条线风格,非照抄某模块):
- **起点**:采购部维护交强与商业险台账,作为交车合规的前置数据。
- **怎么运作**1. 运营录入或识别保单… 2. 运维交车时校验…
- **闭环**:核心险种有效则允许交车;异常则拦截并提示原因,过程可追溯。
**不要**用下面这种宽表作为第 4 章唯一形态:
| 编号 | 作为…我希望… | SP |
|------|--------------|----|
「作为…我想…以便…」只允许作为**排期可选一行**,主叙述必须是起点/怎么运作/闭环。
## 正逆向写法约定
- **正向**:用户成功完成任务的最短路径
- **逆向**:取消、关闭、禁用、冲突状态、不可操作项
- 审批类:写清「本页只读 / 在外部系统办理」
## 规模与故事点
| 规模 | 建议含义 | 约 SP |
|------|----------|-------|
| S | 轻交互 / 列表展示 | 12 |
| M | 标准流程 + 筛选详情 | 35 |
| L | 状态机 / 跨模块 / 批量识别 | 6+ |

View File

@@ -0,0 +1,44 @@
# 云效阶段任务自动创建AutoPRD 负责)
本规则写在 **oneos-autoprd**,不依赖改写 `yunxiao-requirement-lifecycle`
凡本 Skill 参与的云效操作中,需求推进到下列状态时,**同一轮必须**建同名任务并正式关联需求。
## 触发状态
| 需求推进至 | 任务标签(右侧基础字段) | 负责人 | 任务标题 |
|------------|--------------------------|--------|----------|
| **分析中** | `分析` | **需求创建人**姓名 | 与需求标题**完全同名** |
| **设计中** | `设计` | **需求创建人**姓名 | 与需求标题**完全同名** |
| **待开发** | `交付`(若项目无「交付」标签则用 `开发` | **何斐** | 与需求标题**完全同名** |
## 强制字段与关系
1. **正式关联需求**:父子或关联项;只挂标题不算关联。创建后必须在界面上能看到需求 ↔ 任务关系。
2. **标签**:写入任务「标签」基础字段(见上表);标题前缀不能代替标签。
3. **创建时间沿用需求**:任务的创建时间(及标签侧可见的创建时间口径)尽量与**需求创建时间**一致。
- 平台允许改创建时间 / 用 API 指定时:写成需求的创建时间。
- 平台不允许:在任务描述首行注明 `创建时间口径=需求创建时间YYYY-MM-DD HH:mm`,并在回报里说明「平台限制未改系统创建时间」。
4. **幂等**:同一需求、同一阶段标签下,已存在**未取消**且已正式关联的同名任务 → **不新建**,只校验负责人/标签/关联是否正确,缺则补齐。
5. **顺序**:先确认需求已进入目标状态(或本轮动作将写入该状态),再创建/补齐任务;回报时给出任务编号与链接。
## 执行步骤apply 时)
```text
1. 读取需求:标题、创建人、创建时间、当前/目标状态、已关联任务列表
2. 按上表确定本轮阶段标签与负责人
3. 查重需求ID + 阶段标签 + 未取消)
4. 无则创建:标题=需求标题;标签=阶段标签;负责人=上表;创建时间口径=需求创建时间;正式关联需求
5. 有则校验并修补标签/负责人/关联
6. 回报:需求状态、任务编号、负责人、是否新建/复用
```
## 不在本规则内
- 不自动建「测试 / 发版」任务(除非用户另行要求)。
- 不因样式类 PRD 变更触发建任务;仅**需求状态**进入分析中/设计中/待开发时触发。
- 云效生命周期细则、流水线、缺陷闭环仍可由 `$yunxiao-requirement-lifecycle` 配合执行;**本建任务逻辑以 AutoPRD 本文为准**。
## 与定稿 / 需求说明的关系
- 写「需求说明 / 更新内容」仍按 AutoPRD 主流程与定稿流程。
- 推进到待开发且需要描述时:先 AutoPRD 文档,再改状态,再按本文件建任务(负责人何斐)。

View File

@@ -0,0 +1,42 @@
---
description: 改原型跟进 AutoPRD定稿写功能变更云效建需求先跑 AutoPRD状态推进建同名任务
alwaysApply: true
---
# OneOS AutoPRD 全局同步
## 改原型
修改 `src/prototypes/**` 且涉及**行为、文案、流程、判定或验收**时,同一轮必须跟进 **oneos-autoprd**
1. 更新 `.spec/requirements-prd.md`(用户故事:起点 → 怎么运作 → 闭环)。
2. 更新 `annotation-source.json` **顶层** `directory.nodes` 中「产品需求说明PRD」。
3. 同步 `src/resources/prd/<id>-autoprd.md`(若有)。
纯样式且无产品语义变化:可跳过全量重写。
## 需求定稿
用户回复含 `需求定稿` / `定稿` / `确认定稿` / `本次定稿` 时:
1. 按 skill `references/release-changelog.md` 汇总**自上次定稿**的功能/逻辑变更。
2. 追加到 PRD `## 10. 功能变更记录`(最新在上);更新 `.spec/autoprd-baseline.json`。
3. **不要**写布局/UI/表结构类条目。
## 云效建需求
使用 `yunxiao-requirement-lifecycle` **创建或完善**需求描述时,**先**跑 `oneos-autoprd`
- 需求说明 ← AutoPRD 正文
- 更新内容 ← 第 10 章自上次定稿以来的条目(无则首版/无增量)
- 旧更新内容 →「更新内容·历史」
## 云效状态 → 同名任务AutoPRD 规则,不改云效 Skill
需求推进至 **分析中 / 设计中 / 待开发** 时,按 `references/yunxiao-stage-tasks.md`
- 建与需求**同名**任务并正式关联
- 标签:分析 / 设计 / 交付(或开发)
- 创建时间口径沿用需求
- 分析中、设计中负责人=创建人;待开发负责人=**何斐**
- 同阶段已有未取消关联任务则复用,不重复建

View File

@@ -0,0 +1,17 @@
---
description: 云效「记录需求」须先点选优先级/推进至;统一运营管理平台走快路径,禁止默认参数与盲目探测
globs:
alwaysApply: true
---
# 云效记录需求 · 参数门禁与快路径
当用户使用 `$yunxiao-requirement-lifecycle` / 「记录需求」,且涉及优先级、推进至时:
1. **必须先拿到明确选择**:优先级(紧急/高/中/低)、推进至(分析中/设计中/设计完成/开发中)、**标签(从云效标签 catalog 点选,可多选)**。优先 `AskQuestion`;不可用则用 Plan 确认并由用户写明 A+B+C。
2. **禁止**在未选择时默认「中 + 分析中」并自动建单。「批准/Implement 计划」≠ 已完成点选。
3. 项目为**统一运营管理平台**(含 PC 端别名)时:先读
`.cursor/skills/yunxiao-requirement-lifecycle/references/oneos-pc-fast-path.md` 与
`.cursor/skills/yunxiao-requirement-lifecycle/assets/oneos-pc-runtime-ids.json`(标签清单见同目录 `oneos-pc-tag-catalog.md`),按已验证 API/ID 执行;禁止重复探测 create URL。
4. **标签禁止**按 `lines.ts` / 业务条线说明自动推断;只能用用户点选的 catalog 名。API 打标失败时 UI 兜底;仍失败则停下请用户回复标签名,不得声称已成功。
5. 优先 API创建时写入描述标签用 `PATCH … propertyKey=tag`);浏览器仅作标签失败或状态连跳兜底;少截图、同失败不循环重试。

View File

@@ -0,0 +1,139 @@
---
name: AutoVUL
description: >-
Generates OneOS PC version update logs from a Yunxiao (云效) iteration name by
loading all linked requirements, or from a pasted changelog list. Use when testers
or PMs say AutoVUL, 版本更新日志, 按迭代生成更新日志, 发版公告, or give a 迭代名称.
---
# AutoVULOneOS PC 版本更新日志)
面向**测试人员发版**:输入云效**迭代名称** → 拉取该迭代关联需求 → 自动生成对外版本更新日志。
也可手动粘贴变更清单(兜底)。**预计维护时长**由人工单独通知,不写进日志。
## 何时使用
- 测试说:`按 $AutoVUL` / `生成版本更新日志` / `按迭代 xxx 出更新日志`
- 输入含云效「迭代名称」
- 发版前需要统一文案口径
## 主流程(测试 · 按迭代,默认)
完整取数步骤见 [references/yunxiao-sprint.md](references/yunxiao-sprint.md)。
```text
1. 确认云效项目缺省统一运营管理平台PC端可改
2. 向用户索取「迭代名称」(若口令里已带则直接用)
3. 在云效查找该迭代 → 立即反馈成功或失败(见下)
4. 成功:列出迭代内关联「需求」清单(编号 + 标题 + 状态)供用户快速核对
5. 从每条需求提取变更素材(优先级见「素材优先级」)
6. 按本文格式分类、合并、润色 → 输出成稿正文
```
### 迭代读取 · 成功 / 失败反馈(强制)
查找迭代后**必须先反馈**,再继续或停止:
| 结果 | 反馈文案(可微调,语义不变) | 下一步 |
|------|------------------------------|--------|
| 精确命中 1 个迭代 | `✅ 已找到迭代「{名称}」(状态:{状态}),关联需求 {N} 条。` | 继续拉需求并生成 |
| 0 个匹配 | `❌ 未找到迭代「{输入}」。请核对后重新输入迭代名称(可复制云效迭代标题)。` | **停止生成**;等待用户重输 |
| 多个模糊匹配 | `⚠️ 找到多个相似迭代1)… 2)… 请回复序号或完整精确名称。` | 等用户选定后再继续 |
| 登录/权限失败 | `❌ 无法访问云效(未登录或无权限)。请在浏览器登录云效后重试,或改用手动清单。` | 停止;提示登录或兜底 |
| 迭代存在但 0 条需求 | `⚠️ 迭代「{名称}」已找到,但暂无关联需求。请确认规划后再试,或改用手动清单。` | 停止生成对外正文 |
**失败后**:用户只需再发一次迭代名称(或纠正项目名),从步骤 2 重跑;不要要求用户重装 Skill。
### 素材优先级(每条需求)
| 顺序 | 来源 | 用法 |
|------|------|------|
| 1 | 需求描述中的「更新内容」段(非「更新内容·历史」) | 首选,直接改写成日志条目 |
| 2 | AutoPRD「功能变更记录」中可对外部分 | 次选 |
| 3 | 需求标题 + 简短描述摘要 | 仅当前两者皆空时;价值句可合理补全 |
| — | 纯技术债、内部监控、未对用户开放 | **不写入**对外日志 |
Bug 类需求(类型/标签含缺陷修复、或更新内容写「修复」归入【Bug修复】其余归【新功能】。边界不清优先 Bug修复。
### 版本号与更新时间
| 字段 | 规则 |
|------|------|
| 版本号 | 优先从迭代名解析(如含 `V1.1.5` / `1.1.5`);解析不到再问用户 |
| 更新时间 | 用户提供则用;未提供可写迭代计划开始日的 `MM月DD日HH:MM`,或先问 |
| 产品线 | 默认 `OneOS PC版` |
## 兜底流程(手动清单)
无云效权限、或迭代为空时:用 [input-template.md](input-template.md) 粘贴清单,跳过迭代步骤,直接分类润色。
## 固定输出格式
```text
OneOS PC版V{主.次.修订}更新日志:
更新时间:{MM}月{DD}日{HH}:{MM}
----------------------------------------------------
【新功能】
1「{模块名}」{做了什么}{责任部门}可{用户价值 / 解决的问题}
2「{模块名}」{…}
【Bug修复】
3「{模块名}」{修复了什么}{责任部门}可{恢复的能力 / 避免的影响}
```
### 版式硬约束
| 项 | 规则 |
|----|------|
| 标题 | `OneOS PC版V` + 版本号 + `更新日志:`(如 `V1.1.5`,无空格) |
| 更新时间 | 仅开始时刻;`MM月DD日HH:MM`**禁止**「预计x分钟内完成」 |
| 分隔线 | `----------------------------------------------------` |
| 分区 | 仅 `【新功能】` / `【Bug修复】`;空类整段省略 |
| 序号 | 全篇连续,从 1 起 |
| 模块名 | `「」`;与产品菜单正式名一致 |
| 对外正文 | **不写**需求号、缺陷号、迭代 ID核对清单可另附 |
| 结尾 | 无签名、无「感谢使用」、无技术附录 |
## 分类 / 合并 / 文案(成稿规则)
| 归入【新功能】 | 归入【Bug修复】 | 不写进对外日志 |
|----------------|-----------------|----------------|
| 新增能力、新入口、新字段/流程、可感知体验优化 | 原有能力异常、报错、数据错、交互失效 | 技术债、无感性能、未开放灰度、仅内部监控 |
- **同分区同模块**合并为一条,分点用 ``
- **跨分区**同模块分栏各写一条
- 每条含:做什么 + 用户价值 + 责任部门自然嵌入(`{部门}人员可…`
- **禁止**「供…条线…使用」;部门对照 [module-role-mapping.md](module-role-mapping.md)
- 禁止编造迭代内不存在的能力;存疑跳过或标待确认(不进正式成稿)
- 单条建议 ≤100 字
排序:先新功能后修复;同分区全员影响优先;资金/合规修复置修复区最前。
## 输出纪律
1. **先**输出迭代读取反馈(✅/❌/⚠️)
2. 成功时:可先给「本迭代需求清单」短表(含编号,便于测试核对)
3. 再输出**纯文本成稿正文**(默认不要 Markdown 大标题包裹成稿)
4. 用户明确要求时,可在正文后附「分类依据 / 跳过项」
## 发布前自检
- [ ] 已反馈迭代查找结果;失败时未强行编造成稿
- [ ] 成稿条目均可追溯到本迭代关联需求素材
- [ ] 标题、时间行、分隔线正确;无预计时长
- [ ] 同模块合并正确;每条含价值 + 部门自然嵌入
- [ ] 对外正文无需求号/缺陷号/技术黑话;序号连续
## 给测试同事的口令
```text
按 $AutoVUL 生成版本更新日志。
项目统一运营管理平台PC端
迭代名称V1.1.5发版迭代
更新时间07月16日16:00
```
迭代名写错时,按提示改完再发一次即可。
手动兜底见 [input-template.md](input-template.md);成稿范例见 [examples.md](examples.md)。

View File

@@ -0,0 +1,77 @@
# AutoVUL 成稿与交互范例
## 1. 迭代读取失败 → 重输
**用户:**
```text
按 $AutoVUL迭代名称V1.1.5
```
**Agent**
```text
❌ 未找到迭代「V1.1.5」。请核对后重新输入迭代名称(可复制云效迭代标题)。
```
**用户:**
```text
V1.1.5发版迭代
```
**Agent**
```text
✅ 已找到迭代「V1.1.5发版迭代」(进行中),关联需求 3 条:
1. ONEOSP-201 车辆管理运营城市展示优化
2. ONEOSP-202 还车应结款租金计算修复
3. ONEOSP-203 业务管理新增租赁物流统计
OneOS PC版V1.1.5更新日志:
更新时间07月16日16:00
----------------------------------------------------
【新功能】
1「车辆管理」优化车辆「运营城市」展示运维部人员可快速辨认车辆所属运营区域及数据来源
2「业务管理」新增租赁、物流业务统计模块业务管理组、业务员可查看租赁经营数据业务服务组、财务部可跟踪自营物流经营情况
【Bug修复】
3「还车应结款」修复租金费用项重复及计算错误业务管理组、安全部、运维部、能源部人员还车结算金额更准确
```
## 2. 完整成稿风格基准(手动/迭代素材均可)
> 责任部门对照:`module-role-mapping.md`OneOS 业务条线说明)
```text
OneOS PC版V1.1.5更新日志:
更新时间07月16日16:00
----------------------------------------------------
【新功能】
1「加氢站管理」升级新版加氢站模块采购部可统一维护签约站与非签约站信息签约加氢站可便捷管理站点日常运营
2「车辆管理」优化车辆「运营城市」展示运维部人员可快速辨认车辆所属运营区域及数据来源
3「审批中心」支持按客户名称搜索关联合同业务员、法务部审批时可快速定位合同还车应结款添加照片附件后业务管理组、安全部、运维部、能源部会签人员可在审批流中查看完整附件
4「保险采购」支持保比价单按车辆单独勾选并分开提交审批运维部、安全部人员可按车辆分批推进投保审批
5「故障管理」增加「故障来源」字段展示运维部人员可区分上报渠道并跟进处理
6「证照管理」特种设备使用登记证/使用标识照片支持OCR识别运维部、安全部人员可减少手工录入、加快证照建档
7「全局显示」统一各模块列表分页样式与交互各模块操作人员跨页面浏览时体验一致、上手更快
8「交还车管理」支持交车/还车签章文件在线预览,运维部、业务管理组、司机可现场核对签章文件;提交时增加胎纹数据必填校验,减少漏填导致的后续结算争议
9「还车应结款」调整安全部待办区域权限安全部人员仅处理职责范围内的还车应结款审批
10「业务管理」新增租赁、物流业务统计模块业务管理组、业务员可查看租赁经营数据业务服务组、财务部可跟踪自营物流经营情况
【Bug修复】
11「还车管理」修复签字单与还车检查单/还车应结款轮胎数据不一致,运维部、业务管理组人员还车验收与结算依据保持一致
12「租赁合同」修复编辑合同提交时异常提示内容冗余业务员、法务部人员可快速定位真正错误原因
13「交还车管理」修复交车/还车签章客户名称关联错误,避免运维部、业务管理组、司机现场核对时客户信息张冠李戴
14「车辆氢费明细」修复同客户不同生效时段、以及不同客户成本单价显示异常能源部人员可准确核对氢费核算单价
15「还车应结款」修复租金费用项重复及计算错误业务管理组、安全部、运维部、能源部人员还车结算金额更准确
```
## 对照要点
| 规则 | 体现 |
|------|------|
| 先反馈迭代结果 | 失败可重输;成功列出需求再成稿 |
| 无预计时长 | 更新时间仅 `07月16日16:00` |
| 对外无需求号 | 成稿正文不出现 ONEOSP-xxx |
| 部门自然嵌入 | `运维部人员可…`,无「供…条线使用」 |

View File

@@ -0,0 +1,69 @@
# AutoVUL · 输入模板
## A. 测试人员 · 按云效迭代(推荐)
复制下面口令发给 AI改迭代名即可
```text
按 $AutoVUL 生成版本更新日志。
项目统一运营管理平台PC端
迭代名称:
更新时间:
```
### 期望反馈
- 成功:`✅ 已找到迭代「…」,关联需求 N 条` → 随后输出成稿
- 失败:`❌ 未找到迭代「…」。请重新输入迭代名称` → 改名后再发一次即可
**说明**:预计维护时长由人工单独通知,不写入版本更新日志。
---
## B. 手动清单(云效不可用时兜底)
### 版本元信息
| 字段 | 填写 | 示例 |
|------|------|------|
| 产品线 | | OneOS PC版 |
| 版本号 | | V1.1.5 |
| 更新开始时间 | | 07月16日16:00 |
| 发布范围 | 全量 / 灰度(说明) | 全量 |
| 是否对外发布 | 是 / 否 | 是 |
### 新功能(原始材料,可粗糙)
| 模块 | 做了什么 | 解决什么问题 / 用户故事 | 业务条线 | 责任部门 | 是否写入日志 |
|------|----------|-------------------------|----------|----------|--------------|
| 例:加氢站管理 | 升级新版模块 | 统一维护站点 | 能源业务条线 | 采购部、加氢站 | 是 |
| | | | | | |
### Bug修复原始材料可粗糙
| 模块 | 原问题现象 | 修复后表现 | 对用户的影响 | 业务条线 | 责任部门 | 是否写入日志 |
|------|------------|------------|--------------|----------|----------|--------------|
| 例:还车应结款 | 租金费用项重复 | 金额正确 | 避免结算偏差 | 租赁业务条线 | 业务管理组、安全部、运维部、业务管理组-能源部 | 是 |
| | | | | | | |
### 明确不要写入对外日志
- (如:重构、依赖升级、仅内部监控、未上线配置)
### 粘贴给 AI
```text
请按 $AutoVUL 用下方手动清单生成成稿(跳过云效迭代)。
要求:同模块合并;每条含「做什么 + 用户价值」;责任部门自然融入;只输出正文。
【版本元信息】
- 产品线OneOS PC版
- 版本号V
- 更新开始时间:
【新功能】
(粘贴)
【Bug修复】
(粘贴)
```

View File

@@ -0,0 +1,60 @@
# 模块 → 业务条线 / 责任部门对照
> 数据源OneOS「业务条线说明」页责任部门定义租赁 / 能源 / 运维 / 安全 / 自营)
> 生成更新日志时,**适用对象**须从此表取 `roles`,不得自造部门名。
## 五条业务条线
| ID | 条线名称 |
|----|----------|
| lease | 租赁业务条线 |
| energy | 能源业务条线 |
| ops | 运维管理条线 |
| safety | 安全管理条线 |
| self-operated | 自营业务条线 |
## 本版变更模块对照
| 更新日志模块名 | 业务条线 | lines.ts 模块 | 责任部门roles原文 |
|----------------|----------|---------------|-------------------------|
| 加氢站管理 | 能源业务条线 | 加氢站管理 | 采购部、加氢站 |
| 车辆管理 | 运维管理条线 | 验车入库 / 车辆管理(资产底座) | 运维部、采购部 |
| 审批中心 | 跨条线 | 租赁合同 + 还车应结款(审批场景) | 业务员、法务部、业务管理组、安全部、运维部、业务管理组-能源部 |
| 保险采购 | 运维管理条线 | 证照 / 保险(车辆资产关联) | 运维部、安全部 |
| 故障管理 | 运维管理条线 | 故障管理 | 运维部、司机 |
| 租赁合同 | 租赁业务条线 | 租赁合同 | 业务员、法务部 |
| 证照管理 | 运维管理条线 | 证照管理 | 运维部、安全部 |
| 全局显示 | 全系统 | 系统介绍(五条条线) | 业务管理组、运维部、采购部、安全部、法务部、财务部、业务管理组-能源部、业务服务组 |
| 交还车管理 | 运维管理条线 | 交车管理 + 还车管理 | 运维部、业务管理组、司机 |
| 还车应结款 | 租赁业务条线 | 还车应结款 | 业务管理组、安全部、运维部、业务管理组-能源部 |
| 还车管理 | 运维管理条线 | 还车管理 | 运维部、业务管理组 |
| 车辆氢费明细 | 能源业务条线 | 车辆氢费明细 | 业务管理组-能源部 |
| 业务管理 · 租赁统计 | 租赁业务条线 | 租赁业务台账 / 客户管理 | 业务管理组、业务员 |
| 业务管理 · 物流统计 | 自营业务条线 | 自营台账 | 业务服务组、财务部 |
## 写法规则
- 部门名与 `roles` **完全一致**`业务管理组-能源部` 对外可简写「能源部人员」)
- **禁止**「供…业务条线…使用」「供…人员使用」
- **推荐**`{部门}人员可…` / `{部门}可…` / `{部门}审批时可…`
- 同模块合并后,多部门用顿号或分号自然串联
- 找不到模块时:查 `lines.ts`;仍无则标「待产品确认」不进成稿
## 自然表述示例
| 模块 | 生硬(禁止) | 自然(推荐) |
|------|--------------|--------------|
| 车辆管理 | 供运维管理条线运维部使用 | 运维部人员可快速辨认车辆所属运营区域 |
| 加氢站管理 | 供能源业务条线采购部、加氢站使用 | 采购部可统一维护站点信息,加氢站可便捷管理日常运营 |
| 还车应结款 | 供租赁业务条线业务管理组…使用 | 业务管理组、安全部、运维部、能源部还车结算金额更准确 |
## 常见错误(禁止)
| 错误写法 | 正确写法 |
|----------|----------|
| 氢能运营业务人员 | 采购部、加氢站(能源 · 加氢站管理) |
| 车辆运营及资产管理业务人员 | 运维部、采购部(运维 · 车辆管理) |
| 各业务条线审批人员 | 业务员、法务部、…(按审批场景枚举) |
| 保险采购业务人员 | 运维部、安全部 |
| 还车结算业务人员 | 业务管理组、安全部、运维部、业务管理组-能源部 |
| 经营分析及管理部门 | 业务管理组、业务服务组(分租赁 / 自营) |

View File

@@ -0,0 +1,81 @@
# 云效迭代 → 需求清单AutoVUL 取数)
测试人员只提供**迭代名称**Agent 负责在云效定位迭代、拉取关联需求、抽出更新素材。
## 安全边界(强制)
- **禁止**在对话、Skill、日志、成稿中写入 Token、密码、Cookie、私钥。
- 优先复用**已登录浏览器会话**操作云效 Projex`yunxiao-requirement-lifecycle` 一致)。
- 若团队已配置本机环境变量访问 OpenAPI可走 APIToken 只读自环境,永不回显。
建议环境变量(可选,名称可按团队调整):
| 变量 | 用途 |
|------|------|
| `YUNXIAO_TOKEN` | 个人访问令牌(`x-yunxiao-token` |
| `YUNXIAO_DOMAIN` | 服务接入点域名 |
| `YUNXIAO_ORG_ID` | 组织 ID中心版 |
| `YUNXIAO_PROJECT_ID` | 默认项目 ID或运行时按项目名解析 |
## 默认项目
口令未指定时:`统一运营管理平台PC端`。项目名不精确时先问清再查迭代。
## 判定顺序:解析迭代
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | 用户未给迭代名称 | 询问:「请输入云效迭代名称(可复制标题)」;不继续 |
| 2 | 项目未确定 | 询问精确项目名;不继续 |
| 3 | 云效不可访问 | `❌ 无法访问云效…`;不继续 |
| 4 | 名称精确匹配 1 条迭代 | `✅ 已找到迭代…`;进入拉需求 |
| 5 | 0 条 | `❌ 未找到迭代…请重新输入`;不继续 |
| 6 | >1 条相似 | `⚠️ 多个相似…请回复序号或精确名称`;等用户选定 |
| 7 | 命中但关联需求 0 | `⚠️ …暂无关联需求`;不生成对外正文 |
匹配规则:先**全等**(去首尾空格);全等失败再做包含/去空格模糊;模糊多条必须让用户确认,禁止擅自挑一个。
## 拉取关联需求
只收集工作项类型为**需求Req**且正式关联到该迭代的项。
- **浏览器路径**:打开项目 → 迭代 → 该迭代规划/工作项列表 → 筛选类型=需求 → 导出或逐条打开描述。
- **OpenAPI 路径(示意)**
1. `ListSprints`:按项目列迭代,用 `name` 匹配用户输入,得到 `sprintId`
2. `SearchWorkitems``category=Req``spaceId=项目Id``conditions` 中按字段 `sprint` CONTAINS `[sprintId]`(以租户实际 fieldIdentifier 为准;可先在需求列表页过滤后复制 `workitem/list` 的 conditions
3. 分页直到收齐(关注响应头 `x-total`
4. 需要正文时再 `GetWorkitem` 取 description
每条需求最少记录:
| 字段 | 说明 |
|------|------|
| serialNumber | 如 ONEOSP-123仅用于核对清单**不进对外成稿** |
| subject | 标题 |
| status | 状态展示名 |
| description | 用于抽取「更新内容」 |
| category / type / labels | 辅助判断新功能 vs 修复 |
## 从描述抽「更新内容」
1. 定位标题「更新内容」或「## 更新内容」段落
2. **不要**把「更新内容·历史」并入本版素材(历史仅备查)
3. 若无「更新内容」:用标题 + 需求说明摘要;仍无有效产品语义则跳过并在内部备注
## 成功反馈后的核对清单(推荐短表)
在生成成稿前输出,例如:
```text
✅ 已找到迭代「V1.1.5发版迭代」(进行中),关联需求 5 条:
1. ONEOSP-101 保险采购比价分车提交
2. ONEOSP-102 交还车签章预览
正在按 AutoVUL 格式生成更新日志…
```
用户若立刻纠正「某条不要进日志」,从素材中剔除后再出成稿。
## 与发版任务关系
本 Skill **只读**迭代与需求,**不创建**【发版】任务、不改云效状态。发版挂链仍用 `yunxiao-requirement-lifecycle`(如「准备发布」口令 / A08

View File

@@ -0,0 +1,183 @@
---
name: oneos-autoprd
description: >-
Generates and keeps in sync OneOS AutoPRD (PM-facing requirements) plus Axhub
Make annotation directory Markdown/PRD; on 需求定稿/定稿/确认定稿/本次定稿 appends
functional release changelog below the PRD since last baseline. When a Yunxiao
requirement advances to 分析中/设计中/待开发, creates a same-titled linked task
(tag+create-time follow the requirement; 分析中/设计中 assignee=creator, 待开发
assignee=何斐). Use eagerly for prototypes, AutoPRD, or Yunxiao requirement
description/更新内容/stage advance—do not skip annotation sync, 定稿 changelog,
or stage-task creation.
---
# OneOS AutoPRD
为 OneOS 业务模块生成**产品经理可读、可评审、可排期**的需求说明,并**自动挂到 Axhub Make 标注工具 → 原型目录**。
另支持:**需求定稿**时汇总功能/逻辑变更记录;与云效组合时提供「需求说明 + 更新内容」;需求进入**分析中 / 设计中 / 待开发**时**自动创建与需求同名的关联任务**(规则见下,写在本 Skill不改云效 Skill
颗粒度对齐「保险采购」全模块 PRD讲清做什么、谁用、故事点、正逆向、流程图与关键业务逻辑**不写**表结构、接口、字段代码名、文件路径、实现清单。
## 何时使用(含自动触发)
**主动调用时**
- AutoPRD、OneOS 需求说明、整模块 PRD、故事点 + 流程图
**改原型时必须自动跟进(全局规则)**
- 正在修改 `src/prototypes/<id>/` 下页面、交互、文案、判定、验收相关内容
- 同一轮交付内同步更新 PRD Markdown + 标注目录,不得只改代码
**需求定稿时(强制)**
- 用户回复含:`需求定稿` / `定稿` / `确认定稿` / `本次定稿`
- 执行 [references/release-changelog.md](references/release-changelog.md):写第 10 章 + 更新基线
**云效建需求 / 完善需求时(强制组合)**
-`$yunxiao-requirement-lifecycle` 一起使用时:先跑本 Skill再写云效描述
- **需求说明** ← PRD 正文(或浓缩交付口径 + 关键章节)
- **更新内容** ← 第 10 章中**自上次定稿以来**的条目(无增量则「首版定稿 / 本轮无功能增量」)
**云效需求推进至分析中 / 设计中 / 待开发时(强制)**
- 无论口令来自本 Skill 还是云效 Skill只要本轮把需求推到上述状态就执行 [references/yunxiao-stage-tasks.md](references/yunxiao-stage-tasks.md)
- 自动建**与需求同名**的任务并正式关联;标签与创建时间口径沿用需求;分析中/设计中负责人=创建人,待开发负责人=**何斐**
不要用本 Skill 替代:需求探索访谈、设计比稿、纯样式微调(无产品语义变化时可跳过全量重写)。
## 工作流
### 主流程(写/同步 PRD
1. **定模块**:确认 OneOS 模块名与 `src/prototypes/<prototype-id>/`
2. **读上下文(只取产品语义)**
- 用户说明、已确认口径、原型标注、`.spec/`、业务条线说明(`lines.ts`
- 忽略实现细节;字段名/接口改写成业务语言。
3. **收敛边界**:做什么 / 不做什么、外部依赖、与其它模块关系。
4. **按模板成文**:下方「输出结构」;缺关键信息最多问 12 个问题,其余写「假设」。
5. **落盘 + 标注同步(强制)** — 见 [references/annotation-sync.md](references/annotation-sync.md)。
| 顺序 | 动作 |
|------|------|
| A | 写/更新 `src/prototypes/<id>/.spec/requirements-prd.md` |
| B | 写/更新 `src/resources/prd/<id>-autoprd.md` |
| C | 更新 `annotation-source.json` **顶层** `directory.nodes`PRD 全文 + 推荐分章);禁止只写 `data.directory` |
| D | 若存在 `scripts/sync-annotation-directory.mjs`,执行之 |
6. **交付说明**:路径、标注目录入口、故事点合计、开放问题/假设。
### 定稿流程(关键字触发)
见 [references/release-changelog.md](references/release-changelog.md)。摘要:
1. 读 PRD + `.spec/autoprd-baseline.json`
2. 汇总自上次定稿以来的**功能/逻辑**变更(排除样式/UI/表结构)
3. 追加到 `## 10. 功能变更记录`(最新在上)
4. 更新基线 JSON + 标注目录
5. 若同时发云效:本次定稿块 →「更新内容」;旧段 →「更新内容·历史」
### 云效交接格式(给 yunxiao skill 直接粘贴)
```markdown
## 原型链接
<对象存储或预览 URL>
## 需求说明
<AutoPRD 正文或交付口径 + 必要章节>
## 更新内容
<第 10 章本次定稿块;无则「首版定稿」或「本轮无功能/逻辑增量」>
## 更新内容·历史
<以往更新内容倒序,勿删除>
```
### 云效阶段任务(状态推进时强制)
见 [references/yunxiao-stage-tasks.md](references/yunxiao-stage-tasks.md)。摘要:
| 需求状态 | 任务标签 | 负责人 | 标题 |
|----------|----------|--------|------|
| 分析中 | 分析 | 需求创建人 | 与需求同名 |
| 设计中 | 设计 | 需求创建人 | 与需求同名 |
| 待开发 | 交付(或开发) | 何斐 | 与需求同名 |
正式关联需求;创建时间口径沿用需求;同阶段未取消任务不重复建。
## 写作硬约束
**必须写**
- 一句话定位 + 目标 / 非目标
- 模块边界(含 mermaid 总览更好)
- 角色与目标(角色名优先对齐业务条线说明)
- **用户故事**(业务条线说明口径)+ Epic 级**故事点SP**粗估
- 分功能**正向**与**逆向/边界**
- 至少 12 个 **mermaid** 流程图
- **关键业务逻辑**(业务话)
- 验收清单 + 「交付口径」一段话
- 定稿后:**功能变更记录**(第 10 章)
**禁止写**
- 数据库表、字段名、接口路径、代码路径、组件名、存储 key
- 研发实现指令(可写「正式环境由审批中心回写」这类业务依赖)
- 在变更记录里写样式/UI/表结构优化
## 用户故事口径(强制 · 对齐业务条线说明)
真相源:原型 **业务条线说明**`lease-business-line-overview` / `lines.ts`)。
每条能力用「责任部门 → **起点****怎么运作****闭环**」叙述;**不要**用「作为…我希望…」宽表作主叙述。
| 块 | 写什么 |
|----|--------|
| 角色 | 谁负责、谁协同 |
| 起点 | 谁在什么前提下启动 |
| 怎么运作 | 有序步骤,含跨角色协作 |
| 关键结果 | 可选标签 |
| 闭环 | 业务终点与可追溯性 |
可选:`US-xx`、压缩句「作为…我想…以便…」、规模 S/M/L 或 SP仅排期不替代主叙述
## 输出结构
完整模板:[references/template.md](references/template.md)。
```markdown
# <模块名> · 产品需求说明(全模块)
## 1. 一句话与目标
## 2. 模块边界(最重要)
## 3. 用户与角色
## 4. 用户故事与故事点(业务条线说明口径)
## 5. 功能模块说明(正向 / 逆向)
## 6. 关键业务逻辑(必须对齐)
## 7. 总览流程图
## 8. 验收清单
## 9. 交付口径
## 10. 功能变更记录 ← 定稿后维护;日常改原型不强制每改必写
```
## 质量自检
- [ ] 产品经理不看代码也能评审
- [ ] 用户故事为起点 / 怎么运作 / 闭环
- [ ] `.spec/requirements-prd.md` 已更新
- [ ] 标注目录「产品需求说明PRD」已同步且正文一致
- [ ] 定稿时:第 10 章 + `autoprd-baseline.json` 已更新
- [ ] 推进至分析中/设计中/待开发时:同名任务已创建或复用,正式关联,负责人正确
- [ ] 无表结构 / 接口 / 代码路径;变更记录无样式/UI 废话
## 参考
- 定稿变更日志:[references/release-changelog.md](references/release-changelog.md)
- 云效阶段任务:[references/yunxiao-stage-tasks.md](references/yunxiao-stage-tasks.md)
- 标注同步细则:[references/annotation-sync.md](references/annotation-sync.md)
- 章节模板:[references/template.md](references/template.md)
- 故事示例:[references/granularity-example.md](references/granularity-example.md)
- 业务条线:`src/prototypes/lease-business-line-overview/lines.ts`
- 复杂判定规格:配合项目规则 `business-logic-documentation`
- 云效组合:`$yunxiao-requirement-lifecycle`(建需求时先本 Skill

View File

@@ -0,0 +1,98 @@
# Axhub 标注目录同步(强制)
使用 OneOS AutoPRD 时,除资源库归档外,**必须**把 PRD 挂到 Axhub Make 标注工具的「原型目录」里,用户才能在右侧面板直接打开。
## 落点(三份必须一致)
| # | 路径 | 作用 |
|---|------|------|
| 1 | `src/prototypes/<prototype-id>/.spec/requirements-prd.md` | **主真相**:产品可读 PRD 全文 |
| 2 | `src/prototypes/<prototype-id>/annotation-source.json`**顶层** `directory.nodes` | **标注目录入口**:面板里可见的 Markdown/PRD |
| 3 | `src/resources/prd/<prototype-id>-autoprd.md` | 资源库归档(可选但默认写) |
可选:复杂判定另写 `.spec/<topic>.md`,目录里再挂一条,并在 PRD 里链过去(与 `business-logic-documentation` 一致)。
## ⚠️ 目录必须写在文档顶层(常见踩坑)
Wire format`prototype-annotation` 一致):
```json
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": { "version": 2, "prototypeName": "...", "nodes": [] },
"markdownMap": {},
"assetMap": {},
"directory": { "nodes": [] }
}
```
| 正确 | 错误 |
|------|------|
| 顶层 `directory.nodes` | 只写在 `data.directory` |
| `PrototypeAnnotationHost` / Make 读的是 `source.directory` | 写在 `data` 下时右侧「原型目录」为空 |
若误写在 `data.directory`:迁移到顶层 `directory`,并删除 `data.directory`,避免双源。
## 目录节点写法(推荐)
在说明根 `folder``children` **靠前**插入或更新:
```json
{
"type": "markdown",
"id": "<prefix>-doc-prd",
"title": "产品需求说明PRD",
"markdownPath": ".spec/requirements-prd.md",
"markdown": "<与 .spec/requirements-prd.md 相同的全文>"
}
```
### 分章节 PRD推荐便于右侧目录浏览
在「产品需求说明PRD」旁增加文件夹 **「PRD 分章」**,按 PRD 的 `##` 一级标题拆成多个 `markdown` 子节点(标题用章节名,正文为该章全文)。专题规格(`.spec/<topic>.md`)放在 **「专题规格」** 文件夹。
参考结构:
```text
folder <模块>说明
markdown 产品需求说明PRD ← 全文
folder PRD 分章
markdown 1. 一句话与目标
markdown 2. 模块边界
folder 专题规格
markdown <topic 标题>
```
`scripts/sync-annotation-directory.mjs` 的原型:改 PRD / `.spec` 后**必须执行**该脚本再生目录。
定稿后第 10 章「功能变更记录」必须进入 PRD 全文节点若有「PRD 分章」,同步该章节点。详见 [release-changelog.md](release-changelog.md)。
约定:
- `id``<短前缀>-doc-prd`(如 `ipc-doc-prd``wb-doc-prd``h5-va-doc-prd`
- `title`优先「产品需求说明PRD已有「PRD」可保留不改名
- **同时写** `markdownPath` + `markdown`:构建可内联路径,运行时目录也能直接读到正文
- 若已有同 id / 同标题节点:只更新正文与 path不新建重复入口
- 遵守 `prototype-annotation-layout`:目录以 `markdown` 为主,不要用 `link`/`route` 顶替 PRD
## 增量改原型时怎么更新
1. 根据改动的文件路径确定 `<prototype-id>`
2. 读现有 `.spec/requirements-prd.md`(没有则按 AutoPRD 模板新建)。
3. 只改受影响章节(故事、正逆向、关键逻辑、验收);其余保留。
4. 回写 `.spec` + `resources/prd` + **顶层** `annotation-source.json``directory`(正文与文件一致;含分章节点)。
5. 若原型有 `scripts/sync-annotation-directory.mjs`,改完后执行它。
## 可跳过全量重写的情况
纯样式、无文案/无流程/无判定变化的微调:不必重写整份 PRD但若改了用户可见文案或交互结果仍须同步对应段落与目录节点。
## 验收
- [ ] `.spec/requirements-prd.md` 存在且为最新
- [ ] `annotation-source.json` **顶层**有 `directory.nodes`(不是只在 `data` 下)
- [ ] 标注目录能打开「产品需求说明PRD」且内容与文件一致
- [ ] 若有「PRD 分章」:各章可单独打开
- [ ] 用户故事仍为业务条线说明口径(起点 / 怎么运作 / 闭环)

View File

@@ -0,0 +1,58 @@
# 颗粒度标杆与用户故事口径
## 用户故事:对齐「业务条线说明」页面
页面:`/prototypes/lease-business-line-overview`
数据:`src/prototypes/lease-business-line-overview/lines.ts`
每个能力卡片的产品叙述结构:
```text
责任部门roles
→ 起点start
→ 怎么运作process[],有序)
→ 关键结果outcomes可选
→ 闭环closure
```
AutoPRD 第 4 章必须用同一套结构写用户故事,而不是「作为…我希望…」宽表。
### 示例(保险采购 · 交车合规,示意)
#### US · 保险有效才允许交车
- **角色**:采购部、运维部
- **起点**:采购或运营维护车辆交强险与商业险台账,作为交车前置合规数据。
- **怎么运作**
1. 在保险采购维护一车一档险种台账(录入 / 识别 / 导入)。
2. 交车时系统校验交强 + 商业是否均为正常或临期。
3. 不合规则拦截交车并给出可理解原因。
- **关键结果**`允许交车` / `禁止交车 · 保险异常`
- **闭环**:交车合规与台账一致、可追溯;异常车辆不能进入履约运营。
- **排期(可选)**US-17 · 规模 M · 作为采购/运维,我想维护交强+商业险并在交车时校验,以便不合规车辆无法交车。
### 反例(不要作为主叙述)
| 编号 | 用户故事 | SP |
|------|----------|----|
| B1 | 作为运营,我能看到失败原因 | 2 |
↑ 信息太扁,缺少起点、协作步骤与闭环结果。
---
## AutoPRD 其余层次(仍要对齐)
1. 一句话 + 目标/非目标
2. 模块边界(独立业务线写清「不自动同步」)
3. 角色表(命名对齐业务条线)
4. **用户故事:起点 / 怎么运作 / 闭环** + SP/规模
5. 分模块正逆向
6. 关键业务逻辑
7. mermaid 流程图
8. 验收清单
9. 交付口径
## 刻意不写
- 表结构、接口、代码路径
- 用实现指令代替业务闭环描述

View File

@@ -0,0 +1,78 @@
# 功能变更记录(定稿触发)
用户回复**需求定稿关键字**后,必须生成「自上次定稿以来」的功能变更日志,追加到 PRD **最下方**,并更新定稿基线。
## 触发关键字(命中任一)
`需求定稿` · `定稿` · `确认定稿` · `本次定稿`
(可带模块名,如「保险采购需求定稿」。)
## 写什么 / 不写什么
| 要写(产品发版视角) | 不写 |
|----------------------|------|
| 哪个功能做了什么修改 | 布局、页面样式、UI 设计优化 |
| 变更了什么业务逻辑 / 流程 / 验收口径 | 「优化了哪些表」「改了哪些字段/接口」 |
| 用户可感知的交互结果变化 | 纯样式、动效、无语义文案微调 |
条目口吻示例:
- 「识别失败」:失败数可点击查看失败文件与原因;仅失败时也可打开明细
- 「比价单」:审批通过后仍须在保单管理另行录入正式保单(强调不自动同步)
## 落点
1. **PRD 文末章节**(强制)
`src/prototypes/<id>/.spec/requirements-prd.md``## 10. 功能变更记录`
新定稿块插在该章**最上方**(倒序:最新在上);历史块保留。
2. **定稿基线**(强制)
`src/prototypes/<id>/.spec/autoprd-baseline.json`
```json
{
"prototypeId": "insurance-procurement",
"lastConfirmedAt": "2026-07-21T12:00:00+08:00",
"lastConfirmedLabel": "定稿 · 2026-07-21",
"summaryBullets": ["…", "…"]
}
```
3. **标注目录**同步更新「产品需求说明PRD」全文节点若有「PRD 分章」,增加或更新「功能变更记录」分章。
4. **云效交接**(若本轮同时走云效):把**本次定稿块**正文作为需求描述「更新内容」;旧「更新内容」挪到「更新内容·历史」。
## 定稿工作流
1. 确认 `<prototype-id>`(从对话 / 打开文件 / 用户指名推断)。
2. 读现有 PRD 与 `autoprd-baseline.json`(无基线则本轮视为**首版定稿**)。
3. 收集自 `lastConfirmedAt` 以来的产品语义变更:
对话确认口径 → 批注/操作说明 → PRD 新旧差异 →(可选)相关提交说明。
过滤掉样式/表结构类改动。
4. 若无功能增量:仍写定稿块,正文为「本轮无功能/逻辑增量(仅样式或未达产品语义变更)」或「首版定稿」。
5. 追加 `### 定稿 · YYYY-MM-DD` + 条目列表到第 10 章顶部。
6. 更新 `autoprd-baseline.json`;执行 annotation-sync。
7. 向用户回报基线时间、条目数、PRD 路径;若需发云效,提示可用口令。
## PRD 章节格式
```markdown
## 10. 功能变更记录
> 产品经理原型发版记录。仅记功能与业务逻辑变更;不含样式/UI/表结构。
### 定稿 · 2026-07-21
- 「能力名」:做了什么 / 逻辑如何变
-
### 定稿 · 2026-07-10
-
```
## 与日常改原型的关系
- **改原型过程中**:按 AutoPRD 主流程增量更新第 19 章;**不必**每改一次就写第 10 章。
- **用户说定稿时**:才汇总写入第 10 章并刷新基线。

View File

@@ -0,0 +1,163 @@
# OneOS AutoPRD 输出模板
复制下列结构填写。方括号为占位。**第 4 章用户故事必须用业务条线说明口径。**
```markdown
# <模块名> · 产品需求说明(全模块)
## 1. 一句话与目标
**一句话**
<用一句话说明模块解决什么>
**要解决的问题**
-
**本期目标**
1.
2.
**非目标(本期不做)**
-
## 2. 模块边界(最重要)
(可选 mermaid模块与外部系统关系
| 业务线 / 子域 | 做什么 | 不做什么 |
|---------------|--------|----------|
| … | … | … |
**外部依赖(产品口径)**
| 依赖方 | 交互方式 | 产品要求 |
|--------|----------|----------|
| … | … | … |
**关键约束**
-
## 3. 用户与角色
| 角色 | 主要目标 |
|------|----------|
| … | … |
> 角色名优先与「业务条线说明」一致(如业务管理组、采购部、运维部)。
## 4. 用户故事与故事点(业务条线说明口径)
> 故事点 / 规模供排期参考,可按团队基准调整。合计约 **N SP**(或 S/M/L 汇总说明)。
按 Epic 或能力单元分组。**每一条**按下面四块写(对齐业务条线说明页:责任部门 → 起点 → 怎么运作 → 闭环):
### Epic A · <名称>(约 N SP
#### A1 · <能力标题>
- **角色**:…
- **起点**:…
- **怎么运作**
1.
2.
3.
- **关键结果**(可选):`可…` / `禁止…`
- **闭环**:…
- **排期(可选)**US-xx · 规模 S/M/L · 「作为…,我想…,以便…」
#### A2 · <能力标题>
### Epic B · …
## 5. 功能模块说明(正向 / 逆向)
### 5.x <功能名>
**正向**
1.
2.
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| … | … |
## 6. 关键业务逻辑(必须对齐)
### 6.x <规则名>
用编号优先级、表格或短列表写清业务规则。
## 7. 总览流程图
```mermaid
flowchart TB
home[入口] --> a[能力A]
home --> b[能力B]
```
## 8. 验收清单(产品 / 测试)
**子域 A**
- [ ]
## 9. 交付口径
> <一段可贴需求单开头的浓缩描述>
## 10. 功能变更记录
> 产品经理原型发版记录。仅记功能与业务逻辑变更;不含样式/UI/表结构。
> 由「需求定稿」关键字触发追加;日常改原型不强制每改必写。详见 [release-changelog.md](release-changelog.md)。
### 定稿 · YYYY-MM-DD
- 「<能力名>」:<做了什么 / 逻辑如何变>
-
```
成文后**必须**同步 Axhub 标注目录(见 [annotation-sync.md](annotation-sync.md)
1. `src/prototypes/<id>/.spec/requirements-prd.md`
2. `annotation-source.json` → 目录节点「产品需求说明PRD`markdownPath` + `markdown`
3. `src/resources/prd/<id>-autoprd.md`
4. 定稿时另写:`src/prototypes/<id>/.spec/autoprd-baseline.json`
## 用户故事写法(对照业务条线说明)
业务条线说明页字段 → AutoPRD 字段:
| 页面 | AutoPRD |
|------|---------|
| 责任部门标签 | **角色** |
| 起点 | **起点** |
| 怎么运作(有序列表) | **怎么运作** |
| 结果色签 | **关键结果**(可选) |
| 闭环 | **闭环** |
示例口吻(摘自业务条线风格,非照抄某模块):
- **起点**:采购部维护交强与商业险台账,作为交车合规的前置数据。
- **怎么运作**1. 运营录入或识别保单… 2. 运维交车时校验…
- **闭环**:核心险种有效则允许交车;异常则拦截并提示原因,过程可追溯。
**不要**用下面这种宽表作为第 4 章唯一形态:
| 编号 | 作为…我希望… | SP |
|------|--------------|----|
「作为…我想…以便…」只允许作为**排期可选一行**,主叙述必须是起点/怎么运作/闭环。
## 正逆向写法约定
- **正向**:用户成功完成任务的最短路径
- **逆向**:取消、关闭、禁用、冲突状态、不可操作项
- 审批类:写清「本页只读 / 在外部系统办理」
## 规模与故事点
| 规模 | 建议含义 | 约 SP |
|------|----------|-------|
| S | 轻交互 / 列表展示 | 12 |
| M | 标准流程 + 筛选详情 | 35 |
| L | 状态机 / 跨模块 / 批量识别 | 6+ |

View File

@@ -0,0 +1,44 @@
# 云效阶段任务自动创建AutoPRD 负责)
本规则写在 **oneos-autoprd**,不依赖改写 `yunxiao-requirement-lifecycle`
凡本 Skill 参与的云效操作中,需求推进到下列状态时,**同一轮必须**建同名任务并正式关联需求。
## 触发状态
| 需求推进至 | 任务标签(右侧基础字段) | 负责人 | 任务标题 |
|------------|--------------------------|--------|----------|
| **分析中** | `分析` | **需求创建人**姓名 | 与需求标题**完全同名** |
| **设计中** | `设计` | **需求创建人**姓名 | 与需求标题**完全同名** |
| **待开发** | `交付`(若项目无「交付」标签则用 `开发` | **何斐** | 与需求标题**完全同名** |
## 强制字段与关系
1. **正式关联需求**:父子或关联项;只挂标题不算关联。创建后必须在界面上能看到需求 ↔ 任务关系。
2. **标签**:写入任务「标签」基础字段(见上表);标题前缀不能代替标签。
3. **创建时间沿用需求**:任务的创建时间(及标签侧可见的创建时间口径)尽量与**需求创建时间**一致。
- 平台允许改创建时间 / 用 API 指定时:写成需求的创建时间。
- 平台不允许:在任务描述首行注明 `创建时间口径=需求创建时间YYYY-MM-DD HH:mm`,并在回报里说明「平台限制未改系统创建时间」。
4. **幂等**:同一需求、同一阶段标签下,已存在**未取消**且已正式关联的同名任务 → **不新建**,只校验负责人/标签/关联是否正确,缺则补齐。
5. **顺序**:先确认需求已进入目标状态(或本轮动作将写入该状态),再创建/补齐任务;回报时给出任务编号与链接。
## 执行步骤apply 时)
```text
1. 读取需求:标题、创建人、创建时间、当前/目标状态、已关联任务列表
2. 按上表确定本轮阶段标签与负责人
3. 查重需求ID + 阶段标签 + 未取消)
4. 无则创建:标题=需求标题;标签=阶段标签;负责人=上表;创建时间口径=需求创建时间;正式关联需求
5. 有则校验并修补标签/负责人/关联
6. 回报:需求状态、任务编号、负责人、是否新建/复用
```
## 不在本规则内
- 不自动建「测试 / 发版」任务(除非用户另行要求)。
- 不因样式类 PRD 变更触发建任务;仅**需求状态**进入分析中/设计中/待开发时触发。
- 云效生命周期细则、流水线、缺陷闭环仍可由 `$yunxiao-requirement-lifecycle` 配合执行;**本建任务逻辑以 AutoPRD 本文为准**。
## 与定稿 / 需求说明的关系
- 写「需求说明 / 更新内容」仍按 AutoPRD 主流程与定稿流程。
- 推进到待开发且需要描述时:先 AutoPRD 文档,再改状态,再按本文件建任务(负责人何斐)。

View File

@@ -0,0 +1,157 @@
---
name: yunxiao-requirement-lifecycle
description: >-
Audit, design, configure, migrate, test, troubleshoot, and document Alibaba Cloud
Yunxiao/Projex requirement lifecycle automation. Use for short conversational Chinese
commands such as 记录需求、确认需求、需求定稿、开始分析、开始开发、提交测试、测试完成、
发布成功 and 验收通过; OneOS delivery or legacy stage-task model. When creating or
refreshing OneOS product requirements, first call oneos-autoprd for 需求说明 and
更新内容 (functional changelog since last 定稿), then write Yunxiao description.
Also covers role handoffs, Codeup, Testhub, pipelines, release acceptance, migration,
and Chinese runbooks without exposing credentials or changing production pipelines unexpectedly.
---
# Yunxiao Requirement Lifecycle
Use this as the single entry skill. Load only the internal modules needed for the request. Installing the skill does not change Yunxiao; live transitions start only after an authorized `apply` and verified `test` run.
## Route to modules
Read each selected file completely before acting:
| Request scope | Required module |
|---|---|
| OneOS终态模型、`【交付】`主任务、多开发子任务、迭代测试/发版、Y01Y40、A01A10、旧规则迁移 | [references/oneos-terminal-flow.md](references/oneos-terminal-flow.md) |
| Lifecycle statuses, requirement labels, related tasks, R01R12, stage entry/completion | [references/lifecycle-rules.md](references/lifecycle-rules.md) |
| Design/development/test task creation, task states, rollups, TK01TK08 | [references/task-lifecycle.md](references/task-lifecycle.md) |
| Test plans, case results, failed cases, bugs, retest, TC01TC03, DF01DF03 | [references/test-defect-closure.md](references/test-defect-closure.md) |
| Repositories, `dev`/`develop`, branches, commits, MRs, multi-repository gate, CI01 | [references/codeup-integration.md](references/codeup-integration.md) |
| test/prod pipelines, Docker callback, release changes, acceptance, CI02CI03, L01L02 | [references/pipeline-release.md](references/pipeline-release.md) |
| Live audit/apply/test, browser execution, evidence, diagnosis, rollback, governance | [references/live-operations.md](references/live-operations.md) |
| 日常口语化操作,使用“记录需求、开始分析、安排开发、提交测试、复测通过、发布成功”等短口令 | [references/simple-role-commands.md](references/simple-role-commands.md) |
| **统一运营管理平台**日常「记录需求」加速执行(已验证 ID / API / 状态三跳) | [references/oneos-pc-fast-path.md](references/oneos-pc-fast-path.md) + [assets/oneos-pc-runtime-ids.json](assets/oneos-pc-runtime-ids.json) |
| **统一运营管理平台**「记录需求到云效」完整可复制提示词A/B/C 门禁 + 30 标签) | [references/oneos-pc-record-requirement-prompt.md](references/oneos-pc-record-requirement-prompt.md) |
| 需求进入分析中/设计中/待开发时自动建同名任务、关联需求、负责人与时间沿用 | [references/auto-stage-task.md](references/auto-stage-task.md) |
| 产品、分析、设计、开发、评审、测试、缺陷、运维、验收等角色的节点沟通话术和交接回报 | [references/role-trigger-prompts.md](references/role-trigger-prompts.md) |
| Final Chinese report | [references/report-template.md](references/report-template.md) |
## 记录需求 · 参数门禁与加速(强制)
当口令为「记录需求」且项目为统一运营管理平台(含 PC 端别名)时:
1. **先读** [references/oneos-pc-fast-path.md](references/oneos-pc-fast-path.md) 与 [assets/oneos-pc-runtime-ids.json](assets/oneos-pc-runtime-ids.json),按快路径执行,禁止重复探测 create URL / 优先级 ID。
2. 若用户要求 Plan/单选优先级、推进至与标签:必须先拿到明确 **A+B+C****禁止**未选时默认「中 + 分析中」并建单。「批准计划」≠ 已选 A+B+C。完整用户口令见 [references/oneos-pc-record-requirement-prompt.md](references/oneos-pc-record-requirement-prompt.md)。
3. 优先 API 建单(创建时写入 `document`)、同名任务与打标签(`PATCH` `propertyKey=tag`);浏览器仅用于标签 API 失败或状态连跳兜底。统一运营管理平台标签从 `assets/oneos-pc-tag-catalog.md` 选择器点选,禁止按 `lines.ts` 自动映射。
## AutoPRD 组合(强制 · OneOS 产品需求)
当请求涉及**创建产品类需求、写入/刷新需求描述、快轨待开发、完善需求说明/更新内容**,或用户口令含「记录需求(带模块/原型)」「需求已经确定」「需求定稿」并要进云效时:
1. **先**加载并执行项目/全局的 **`oneos-autoprd`** skill勿跳过
2. 用其产出填充云效需求描述:
- **需求说明** ← AutoPRD 正文(产品语言;不写表结构/接口/代码)
- **更新内容** ← AutoPRD 第 10 章「功能变更记录」中自上次定稿以来的条目;无则「首版定稿」或「本轮无功能/逻辑增量」
- 已有「更新内容」时:旧段挪到 **更新内容·历史**(倒序追加,勿删)
3. 原型发版增量以 AutoPRD 功能变更记录为准PC 整包发版公告仍可另用 `AutoVUL`
4. 对象存储链接、主任务、状态推进等仍按 `oneos-terminal-flow.md` / `simple-role-commands.md`
`A02` / `A02B` 在 OneOS 项目上的默认实现方式即为调用 AutoPRD不得只写占位文案。
## 阶段同名任务(强制 · AI apply
凡将需求推进到 **分析中 / 设计中 / 待开发**(含口令「开始分析」「开始设计」「快速开发」「需求已经确定…待开发」等),必须先读并执行 [references/auto-stage-task.md](references/auto-stage-task.md)
| 状态 | 任务 | 负责人 |
|---|---|---|
| 分析中 | 与需求**同名**;标签`分析`;正式关联需求 | 需求**创建人** |
| 设计中 | 与需求**同名**;标签`设计`;正式关联需求 | 需求**创建人** |
| 待开发 | 与需求**同名**;标签`交付`(或阶段模型下`开发`);正式关联需求 | 固定**何斐** |
时间:任务计划开始(及可写的创建时间)**沿用需求**的计划开始/创建时间。查重幂等,禁止同阶段重复建单。
First identify `flow_model`:
- `stage_tasks`: use R01R12 and TK01TK08; read the five domain modules plus `live-operations.md` for a full audit.
- `oneos_delivery`: read `oneos-terminal-flow.md`, `codeup-integration.md`, `test-defect-closure.md`, `pipeline-release.md`, and `live-operations.md`.
For a narrow diagnosis, read the relevant domain module plus `live-operations.md`. Do not blend both models in one project or load unrelated modules merely because they exist.
## Classify authority
- `audit`: inspect and report only.
- `plan`: prepare a project profile and change plan only.
- `apply`: create or modify only explicitly named projects, labels, rules, and test assets.
- `test`: exercise approved rules with isolated artifacts and collect evidence.
- `document`: produce a Chinese runbook from verified facts.
Treat ambiguous requests as `audit` or `plan`. Documentation never authorizes live changes.
When the user uses a write verb defined in `simple-role-commands.md`—for example `记录需求``确认需求``开始分析``开始设计``开始开发``提交测试``复测通过``发布成功` or `验收通过`—treat it as `apply` authorization only for the current exact project and named work item. If the project is not exact, ask only for the project before writing. Query verbs such as `查状态``为什么没流转``下一步` and `给我方案` remain `audit` or `plan`.
Whenever apply changes a requirement status to `分析中``设计中` or `待开发`, also apply [references/auto-stage-task.md](references/auto-stage-task.md) in the same turn (same-name task, formal link, assignee and time rules) before reporting success.
## Build the project profile
1. Choose exactly one template and copy it outside the skill:
- `stage_tasks`: `assets/project-profile.template.json`.
- `oneos_delivery`: `assets/oneos-project-profile.template.json`.
2. Keep one independent object in `projects` for every target project; duplicate the template object when needed.
3. Replace observed statuses, rule instances, repositories, branches, pipelines, test plans, defect workflows, release evidence, and control dispositions.
4. Never add usernames, passwords, cookies, tokens, OTPs, Webhook secrets, or private keys.
5. Validate before applying:
```text
python scripts/profile_tool.py validate <profile.json>
python scripts/profile_tool.py render <profile.json> --output <execution-plan.md>
python scripts/oneos_profile_tool.py validate <oneos-profile.json>
python scripts/oneos_profile_tool.py render <oneos-profile.json> --output <execution-plan.md>
```
Do not apply while placeholders remain or validation fails.
## Completeness contract
For `stage_tasks`, account for every control for every project independently:
- `R01``R12`: requirement lifecycle and manual gates.
- `TK01``TK08`: stage-task creation, task-state transitions, requirement rollups, and idempotency.
- `TC01``TC03`: test-case execution, failed-case/defect association, and retest result update.
- `DF01``DF03`: fixed handoff, tester-pass closure, and tester-fail reopen.
- `CI01``CI03`: multi-repository coverage, callback safeguards, and environment/release separation.
- `L01``L02`: legacy immediate-close rule disposition.
Record the actual R01R12 `rule_instances`, TK01TK08 `task_rule_instances`, and all 31 `control_coverage` entries. Valid modes are `manual`, `native_rule`, `integration_bridge`, `disabled_legacy`, and `not_supported`. `not_supported` needs an owner and fallback and is never equivalent to automated.
Completeness covers the requirement lifecycle, stage-task lifecycle and rollups, Testhub closure, Codeup assets, pipeline/release evidence, and legacy-close controls. Notifications, SLA reminders, generic automatic assignment, and unrelated custom-field synchronization remain outside scope unless explicitly added to `adjunct_automations`.
For `oneos_delivery`, account for all 48 controls independently:
- `Y01``Y16`, `Y20``Y22`, `Y30``Y35`, `Y40`: native/mirrored lifecycle controls.
- `A01`, `A02`, `A02B`, `A03``A10`: AI/Webhook bridge controls.
- `TC01``TC03`, `DF01``DF03`, `CI01``CI03`, `L01``L02`: test, defect, code/release separation, and legacy-close controls.
Do not disable the old stage rules until the OneOS migration readiness gate passes: required requirement statuses exist, the main-task identity is filterable or procedurally exclusive, task workflows are ready, bridge owners and idempotency exist, and rollback evidence is saved. `L01` and `L02` remain disabled because they bypass acceptance.
## Global safety rules
- Reuse the authenticated browser session or approved semantic connector.
- Preserve unrelated rules and user edits.
- Never modify an existing production pipeline unless the user authorizes that exact change.
- Clone a test pipeline before callback experiments and modify only the clone.
- Require the expected current state in every automatic transition.
- Separate observed evidence from proposals and inference.
- Wait 530 seconds for asynchronous events and inspect execution logs.
- Stop at CAPTCHA, OTP, login approval, permission elevation, protected-branch ambiguity, or indistinguishable similarly named resources.
## Required outputs
Return only the applicable artifacts:
1. Observed inventory and current-versus-target matrix.
2. Per-project R01R12 and TK01TK08 rule-instance inventory with a 31-control ledger.
3. Live change log with reopen verification.
4. Test result per transition: `通过``异步通过``未触发``误触发` or `阻塞`.
5. Remaining risks, manual gates, and rollback steps.
6. When requested, a role-and-trigger communication playbook with copyable prompts, required evidence, next owner, and stop conditions.
7. For daily commands, answer briefly with the work item, actual state change, created/linked asset, blocker if any, and the exact next short command.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "云效需求生命周期自动化"
short_description: "使用口语化短口令驱动云效需求开发测试发布闭环并自动核查"
default_prompt: "使用 $yunxiao-requirement-lifecycle把当前项目设为统一运营管理平台PC端然后通过“记录需求、开始开发、提交测试、验收通过”等短口令协作。"

View File

@@ -0,0 +1,209 @@
{
"schema_version": 2,
"verified_at": "2026-07-21",
"verified_by_example": {
"requirement_serial": "ONEOS-88",
"requirement_identifier": "a34e700286d456ee74ddc16e58",
"note": "标签 APIPATCH /workitem/workitem/{id} propertyKey=tag operateType=COVER全量标签来自 space/tag/searchspaceType=Space"
},
"project": {
"name_aliases": ["统一运营管理平台", "统一运营管理平台PC端", "统一运营管理平台 PC 端"],
"spaceIdentifier": "1280be963a5a2cc126a4118dca",
"customCode": "ONEOS",
"spaceType": "Project"
},
"people": {
"wangmian": {
"displayName": "王冕",
"identifier": "6811df000601d2fea60144a9"
},
"hefei": {
"displayName": "何斐",
"identifier": "695f0400562f09713f9c3a93"
}
},
"workitem_types": {
"product_req": {
"name": "产品类需求",
"category": "Req",
"identifier": "9uy29901re573f561d69jn40"
},
"task": {
"name": "任务",
"category": "Task",
"identifier": "ba102e46bc6a8483d9b7f25c"
}
},
"priority": {
"紧急": "646004e97f54bb77fec7b455df",
"高": "95b89e0a524d9693e1f335ffe5",
"中": "fa155d1214f9f8db222d39db3b",
"低": "92924feff9c1085891e7511872"
},
"fields": {
"plan_start": {
"fieldIdentifier": "79",
"name": "计划开始时间",
"value_shape": "epoch_ms_string_china_noon",
"example": "Date.parse('YYYY-MM-DDT12:00:00+08:00') 转 String避免 00:00 显示成前一天"
},
"plan_end": {
"fieldIdentifier": "80",
"name": "计划完成时间"
},
"tag": {
"fieldIdentifier": "tag",
"name": "标签",
"propertyKey": "tag"
}
},
"status_ui_paths": {
"from_待处理": {
"分析中": ["分析中"],
"设计中": ["设计中"],
"设计完成": ["设计完成"],
"开发中": ["设计完成", "待开发", "开发中"],
"note": "待处理下拉无「开发中」;须按路径连跳。状态 identifier 未在 UI 路径中固化,优先 UI 三跳;有可靠 API 后再补 statusIdentifier。"
}
},
"assignee_rules": {
"分析中": "creator",
"设计中": "creator",
"设计完成": "creator",
"开发中": "hefei",
"待开发": "hefei"
},
"api": {
"create_workitem": {
"url": "https://devops.aliyun.com/projex/api/workitem/workitem?_input_charset=utf-8",
"methods": ["POST", "PUT"],
"do_not_use": ["/projex/api/workitem/workitem/create"],
"required_create_fields": [
"subject",
"spaceType",
"spaceIdentifier",
"category",
"categoryIdentifier",
"workitemTypeIdentifier",
"workitemType",
"assignedTo"
],
"document_on_create": {
"supported": true,
"shape": { "content": "<html>", "formatType": "RICHTEXT" },
"note": "创建时一并写入描述,避免再开 Cangjie 编辑器"
}
},
"get_workitem": {
"url_template": "https://devops.aliyun.com/projex/api/workitem/workitem/{identifier}?_input_charset=utf-8"
},
"list_workitem": {
"url": "https://devops.aliyun.com/projex/api/workitem/workitem/list?_input_charset=utf-8",
"method": "POST"
},
"update_field_value": {
"url": "https://devops.aliyun.com/projex/api/workitem/workitem/updateWorkitemFieldValue?_input_charset=utf-8",
"method": "PATCH",
"note": "部分会话返回 InvalidWorkitem.NotFound失败则改用详情页 UI onChange / 下拉点选,勿盲目重试超过 1 次"
},
"update_document": {
"preferred": "create 时写入 document若需补写用详情页 onWorkItemEditorSubmit(html, 'RICHTEXT')",
"avoid": "对 Cangjie 编辑器直接改 DOM / 错误 onChange 形状(曾导致页面崩溃)"
},
"list_tags": {
"url": "https://devops.aliyun.com/projex/api/workspace/space/tag/search?spaceType=Space&spaceIdentifier=1280be963a5a2cc126a4118dca&q=&_input_charset=utf-8",
"method": "GET",
"note": "注意 spaceType=Space不是 Project。刷新全量标签时用此接口覆盖 tags.catalog"
},
"apply_tags": {
"url_template": "https://devops.aliyun.com/projex/api/workitem/workitem/{identifier}?_input_charset=utf-8",
"method": "PATCH",
"body_shape": {
"workitemIdentifier": "{identifier}",
"propertyKey": "tag",
"propertyValue": "{comma_separated_tag_identifiers}",
"operateType": "COVER"
},
"note": "propertyValue 为标签 identifier 逗号拼接COVER=覆盖整组。已验证于 ONEOS-88。"
}
},
"tags": {
"selection_mode": "plan_or_askquestion_selector",
"do_not_match_from": ["lease-business-line-overview/lines.ts", "业务条线说明自动推断"],
"apply_strategy": "用户从 catalog 点选标签名 → 用 identifier 调 apply_tags API失败 1 次再 UI 兜底;仍失败则停并请用户回标签名,禁止假装已打上",
"refresh_hint": "标签库变更时重新 GET list_tags 并更新本 catalog / fetched_at",
"fetched_at": "2026-07-21",
"source": "GET /projex/api/workspace/space/tag/search?spaceType=Space&spaceIdentifier=1280be963a5a2cc126a4118dca&q=",
"count": 30,
"by_name": {
"运维管理条线": "a611b2f2b7c3fd3623ba2d5f52",
"报表中心": "8428d15f45a5b34173202db758",
"能源管理": "3396ebb527358fe6f89fe42849",
"维修站管理": "e7b4da7984801e75630645f437",
"充电站管理": "6ea830485f28a0d8a6e14851a8",
"还车应结款": "4204960ce658c94b17abb7c5f6",
"交车应收款": "6e7a9127d0f70281ca65d335d9",
"加氢站管理": "6cdacae673f9e1fba96296f2ad",
"保险管理": "578ed5ba12bfcf274721e956f2",
"合同管理": "23873be81931cb0dc1bf87a456",
"租赁账单": "3e9433f571b20e80b02626d2de",
"供应商管理": "7b86b7c1f3b3da21b814c5a39d",
"客户管理": "dbf959a70bc979106dc78afa90",
"还车任务": "763de323cefee45e5270e4661e",
"安全培训": "76988b5f73ef515de5930d59b9",
"备件管理": "5d09c864767d898a4f8c0ca804",
"停车场管理": "7c522e2fb29213d7d390e78b97",
"备车管理": "22c6a3ed0e4bf95fd99bf95695",
"异动管理": "84b4333ec7c76d0fa2adcc47bf",
"调拨管理": "fa88c16c152c6fb5978e3b4339",
"上牌管理": "990af2fb716dd85568bc7019bf",
"替换车管理": "8d1decf30259b941016cadc9d2",
"还车管理": "1e0fce2d6929ff4ac13b310e97",
"交车管理": "b6947b5aca82a8c759612ba039",
"车辆管理": "cb71b6db9373d6d8c7aae452a5",
"审批中心": "338add796f2221bbc36dd77d35",
"故障管理": "ceb526a7343995577645317e9a",
"车辆年审": "5193165dad1e91a161502b54c2",
"维修管理": "f5202633870ff409e0ebc16d5f",
"工作台": "b62d21beac55c0b43389915eab"
},
"catalog": [
{ "name": "运维管理条线", "identifier": "a611b2f2b7c3fd3623ba2d5f52", "color": "#49AEAC" },
{ "name": "报表中心", "identifier": "8428d15f45a5b34173202db758", "color": "#49AEAC" },
{ "name": "能源管理", "identifier": "3396ebb527358fe6f89fe42849", "color": "#49AEAC" },
{ "name": "维修站管理", "identifier": "e7b4da7984801e75630645f437", "color": "#49AEAC" },
{ "name": "充电站管理", "identifier": "6ea830485f28a0d8a6e14851a8", "color": "#49AEAC" },
{ "name": "还车应结款", "identifier": "4204960ce658c94b17abb7c5f6", "color": "#49AEAC" },
{ "name": "交车应收款", "identifier": "6e7a9127d0f70281ca65d335d9", "color": "#49AEAC" },
{ "name": "加氢站管理", "identifier": "6cdacae673f9e1fba96296f2ad", "color": "#49AEAC" },
{ "name": "保险管理", "identifier": "578ed5ba12bfcf274721e956f2", "color": "#49AEAC" },
{ "name": "合同管理", "identifier": "23873be81931cb0dc1bf87a456", "color": "#49AEAC" },
{ "name": "租赁账单", "identifier": "3e9433f571b20e80b02626d2de", "color": "#49AEAC" },
{ "name": "供应商管理", "identifier": "7b86b7c1f3b3da21b814c5a39d", "color": "#49AEAC" },
{ "name": "客户管理", "identifier": "dbf959a70bc979106dc78afa90", "color": "#49AEAC" },
{ "name": "还车任务", "identifier": "763de323cefee45e5270e4661e", "color": "#49AEAC" },
{ "name": "安全培训", "identifier": "76988b5f73ef515de5930d59b9", "color": "#49AEAC" },
{ "name": "备件管理", "identifier": "5d09c864767d898a4f8c0ca804", "color": "#49AEAC" },
{ "name": "停车场管理", "identifier": "7c522e2fb29213d7d390e78b97", "color": "#49AEAC" },
{ "name": "备车管理", "identifier": "22c6a3ed0e4bf95fd99bf95695", "color": "#49AEAC" },
{ "name": "异动管理", "identifier": "84b4333ec7c76d0fa2adcc47bf", "color": "#49AEAC" },
{ "name": "调拨管理", "identifier": "fa88c16c152c6fb5978e3b4339", "color": "#49AEAC" },
{ "name": "上牌管理", "identifier": "990af2fb716dd85568bc7019bf", "color": "#49AEAC" },
{ "name": "替换车管理", "identifier": "8d1decf30259b941016cadc9d2", "color": "#49AEAC" },
{ "name": "还车管理", "identifier": "1e0fce2d6929ff4ac13b310e97", "color": "#49AEAC" },
{ "name": "交车管理", "identifier": "b6947b5aca82a8c759612ba039", "color": "#49AEAC" },
{ "name": "车辆管理", "identifier": "cb71b6db9373d6d8c7aae452a5", "color": "#49AEAC" },
{ "name": "审批中心", "identifier": "338add796f2221bbc36dd77d35", "color": "#49AEAC" },
{ "name": "故障管理", "identifier": "ceb526a7343995577645317e9a", "color": "#49AEAC" },
{ "name": "车辆年审", "identifier": "5193165dad1e91a161502b54c2", "color": "#49AEAC" },
{ "name": "维修管理", "identifier": "f5202633870ff409e0ebc16d5f", "color": "#49AEAC" },
{ "name": "工作台", "identifier": "b62d21beac55c0b43389915eab", "color": "#49AEAC" }
]
},
"performance_rules": {
"prefer_api_over_browser": true,
"max_endpoint_probes_per_step": 1,
"screenshot_only_at": ["after_create", "after_final_status"],
"browser_fallback_for": ["tag_apply_when_api_fails", "status_multi_hop_when_api_unknown"]
}
}

View File

@@ -0,0 +1,50 @@
# 统一运营管理平台 · 云效标签清单(选择器用)
来源:`GET /projex/api/workspace/space/tag/search?spaceType=Space&spaceIdentifier=1280be963a5a2cc126a4118dca&q=`
抓取时间2026-07-21
机器可读:同目录 `oneos-pc-runtime-ids.json``tags`
## 使用约定
- **记录需求**时标签由用户用选择器点选(`AskQuestion` / Plan ○),**不要**再按 `lines.ts` 业务条线说明自动推断。
- Agent 用点选到的 `name``tags.by_name``identifier`,再 `PATCH` 打标。
- 完整建单提示词(含 A/B/C 与 30 标签枚举):[references/oneos-pc-record-requirement-prompt.md](../references/oneos-pc-record-requirement-prompt.md)
## 全量标签30
| # | 标签名 | identifier |
|---|---|---|
| 1 | 运维管理条线 | `a611b2f2b7c3fd3623ba2d5f52` |
| 2 | 报表中心 | `8428d15f45a5b34173202db758` |
| 3 | 能源管理 | `3396ebb527358fe6f89fe42849` |
| 4 | 维修站管理 | `e7b4da7984801e75630645f437` |
| 5 | 充电站管理 | `6ea830485f28a0d8a6e14851a8` |
| 6 | 还车应结款 | `4204960ce658c94b17abb7c5f6` |
| 7 | 交车应收款 | `6e7a9127d0f70281ca65d335d9` |
| 8 | 加氢站管理 | `6cdacae673f9e1fba96296f2ad` |
| 9 | 保险管理 | `578ed5ba12bfcf274721e956f2` |
| 10 | 合同管理 | `23873be81931cb0dc1bf87a456` |
| 11 | 租赁账单 | `3e9433f571b20e80b02626d2de` |
| 12 | 供应商管理 | `7b86b7c1f3b3da21b814c5a39d` |
| 13 | 客户管理 | `dbf959a70bc979106dc78afa90` |
| 14 | 还车任务 | `763de323cefee45e5270e4661e` |
| 15 | 安全培训 | `76988b5f73ef515de5930d59b9` |
| 16 | 备件管理 | `5d09c864767d898a4f8c0ca804` |
| 17 | 停车场管理 | `7c522e2fb29213d7d390e78b97` |
| 18 | 备车管理 | `22c6a3ed0e4bf95fd99bf95695` |
| 19 | 异动管理 | `84b4333ec7c76d0fa2adcc47bf` |
| 20 | 调拨管理 | `fa88c16c152c6fb5978e3b4339` |
| 21 | 上牌管理 | `990af2fb716dd85568bc7019bf` |
| 22 | 替换车管理 | `8d1decf30259b941016cadc9d2` |
| 23 | 还车管理 | `1e0fce2d6929ff4ac13b310e97` |
| 24 | 交车管理 | `b6947b5aca82a8c759612ba039` |
| 25 | 车辆管理 | `cb71b6db9373d6d8c7aae452a5` |
| 26 | 审批中心 | `338add796f2221bbc36dd77d35` |
| 27 | 故障管理 | `ceb526a7343995577645317e9a` |
| 28 | 车辆年审 | `5193165dad1e91a161502b54c2` |
| 29 | 维修管理 | `f5202633870ff409e0ebc16d5f` |
| 30 | 工作台 | `b62d21beac55c0b43389915eab` |
## 选择器文案C 段)
已并入 [oneos-pc-record-requirement-prompt.md](../references/oneos-pc-record-requirement-prompt.md)「用户口令」;此处仅作 identifier 对照表。

View File

@@ -0,0 +1,119 @@
{
"schema_version": 1,
"flow_model": "oneos_delivery",
"organization": "请填写组织名称",
"work_item_type": "产品类需求",
"target_lifecycle": [
"待处理", "已确认", "分析中", "分析完成", "设计中", "设计完成",
"待开发", "开发中", "开发完成", "待测试", "测试中", "测试完成",
"发布中", "发布完成", "发布失败", "已关闭"
],
"task_labels": ["交付", "开发", "测试", "发版"],
"policies": {
"require_formal_relations": true,
"require_current_state_condition": true,
"allow_automatic_final_close_without_acceptance": false,
"require_nonzero_development_tasks": true,
"test_success_is_production_release": false,
"bridge_idempotency_key_components": ["项目ID", "需求ID", "动作类型"],
"require_signed_timestamped_replay_protected_callbacks": true
},
"projects": [
{
"name": "请填写项目名称",
"migration_phase": "P0",
"requirement_statuses_observed": ["请填写当前全部需求状态"],
"task_workflow_mode": "B",
"task_identity_mode": "task_label",
"native_rule_can_filter_related_task_identity": false,
"main_task_prefix": "【交付】",
"development_task_prefix": "【开发】",
"test_task_prefix": "【测试】",
"release_task_prefix": "【发版】",
"repositories": [
{
"name": "请填写仓库名称",
"role": "frontend",
"integration_branch": "develop",
"codeup_integrated": true,
"required_for_mr_gate": true,
"evidence": "请填写观察证据"
}
],
"test_plan": {
"name": "请填写测试计划名称",
"iteration_formally_related": true,
"cases_partitioned_by_requirement": true,
"fixed_defect_is_terminal": false,
"tester_retest_required": true,
"evidence": "请填写观察证据"
},
"release": {
"release_task_formally_related_to_iteration": true,
"production_evidence_separate_from_test": true,
"acceptance_required_after_release": true,
"production_pipeline_unchanged": true,
"evidence": "请填写观察证据"
},
"legacy_rules": [
{
"name": "请填写旧规则显示名",
"disposition": "keep_until_ready",
"enabled": true,
"reason": "请填写保留、停用或替换原因",
"evidence": "请填写观察证据"
}
],
"controls": [
{"id":"Y01","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接创建并关联唯一主任务","evidence":"请填写观察证据"},
{"id":"Y02","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步分析完成","evidence":"请填写观察证据"},
{"id":"Y03","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步设计中","evidence":"请填写观察证据"},
{"id":"Y04","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步设计完成","evidence":"请填写观察证据"},
{"id":"Y05","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步待开发","evidence":"请填写观察证据"},
{"id":"Y06","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步开发中","evidence":"请填写观察证据"},
{"id":"Y07","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步开发完成","evidence":"请填写观察证据"},
{"id":"Y08","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步待测试","evidence":"请填写观察证据"},
{"id":"Y09","mode":"not_supported","enabled":false,"owner":"请填写负责人","fallback":"桥接或人工同步测试中","evidence":"请填写观察证据"},
{"id":"Y10","mode":"not_supported","enabled":false,"owner":"请填写测试负责人","fallback":"测试负责人按用例和缺陷闭环同步测试完成","evidence":"请填写观察证据"},
{"id":"Y11","mode":"not_supported","enabled":false,"owner":"请填写发布负责人","fallback":"发版任务提交后人工同步发布中","evidence":"请填写观察证据"},
{"id":"Y12","mode":"not_supported","enabled":false,"owner":"请填写发布负责人","fallback":"核对生产发布证据后人工同步发布完成","evidence":"请填写观察证据"},
{"id":"Y13","mode":"not_supported","enabled":false,"owner":"请填写发布负责人","fallback":"人工记录发布失败及原因","evidence":"请填写观察证据"},
{"id":"Y14","mode":"manual","enabled":true,"owner":"请填写产品负责人","fallback":"产品验收后人工关闭","evidence":"请填写观察证据"},
{"id":"Y15","mode":"not_supported","enabled":false,"owner":"请填写项目管理员","fallback":"只保留一个权威镜像方向","evidence":"请填写观察证据"},
{"id":"Y16","mode":"not_supported","enabled":false,"owner":"请填写产品负责人","fallback":"快轨时桥接同步主任务","evidence":"请填写观察证据"},
{"id":"Y20","mode":"native_rule","enabled":true,"owner":"请填写研发负责人","fallback":"开发负责人人工开始任务和需求","evidence":"请填写观察证据"},
{"id":"Y21","mode":"integration_bridge","enabled":false,"owner":"请填写研发负责人","fallback":"人工核对全部开发任务和MR","evidence":"请填写观察证据"},
{"id":"Y22","mode":"integration_bridge","enabled":false,"owner":"请填写项目管理员","fallback":"人工创建并关联唯一测试任务","evidence":"请填写观察证据"},
{"id":"Y30","mode":"integration_bridge","enabled":false,"owner":"请填写测试负责人","fallback":"人工同步待测试","evidence":"请填写观察证据"},
{"id":"Y31","mode":"manual","enabled":true,"owner":"请填写测试负责人","fallback":"测试负责人开始测试","evidence":"请填写观察证据"},
{"id":"Y32","mode":"integration_bridge","enabled":false,"owner":"请填写测试负责人","fallback":"按需求用例包人工验收","evidence":"请填写观察证据"},
{"id":"Y33","mode":"integration_bridge","enabled":false,"owner":"请填写发布负责人","fallback":"人工提交发版任务并同步范围","evidence":"请填写观察证据"},
{"id":"Y34","mode":"integration_bridge","enabled":false,"owner":"请填写流水线负责人","fallback":"人工核对生产发布成功","evidence":"请填写观察证据"},
{"id":"Y35","mode":"integration_bridge","enabled":false,"owner":"请填写流水线负责人","fallback":"人工记录生产发布失败","evidence":"请填写观察证据"},
{"id":"Y40","mode":"manual","enabled":true,"owner":"请填写产品负责人","fallback":"人工关联迭代","evidence":"请填写观察证据"},
{"id":"A01","mode":"not_supported","enabled":false,"owner":"请填写产品负责人","fallback":"人工上传并回写链接","evidence":"请填写观察证据"},
{"id":"A02","mode":"not_supported","enabled":false,"owner":"请填写产品负责人","fallback":"人工维护更新内容历史","evidence":"请填写观察证据"},
{"id":"A02B","mode":"not_supported","enabled":false,"owner":"请填写产品负责人","fallback":"人工维护需求说明","evidence":"请填写观察证据"},
{"id":"A03","mode":"integration_bridge","enabled":false,"owner":"请填写项目管理员","fallback":"人工建需求和唯一主任务","evidence":"请填写观察证据"},
{"id":"A04","mode":"integration_bridge","enabled":false,"owner":"请填写研发负责人","fallback":"人工拆分开发任务","evidence":"请填写观察证据"},
{"id":"A05","mode":"integration_bridge","enabled":false,"owner":"请填写研发负责人","fallback":"人工核对MR并完成开发任务","evidence":"请填写观察证据"},
{"id":"A06","mode":"integration_bridge","enabled":false,"owner":"请填写项目管理员","fallback":"人工建唯一测试任务","evidence":"请填写观察证据"},
{"id":"A07","mode":"integration_bridge","enabled":false,"owner":"请填写测试负责人","fallback":"人工核对用例与缺陷闭环","evidence":"请填写观察证据"},
{"id":"A08","mode":"integration_bridge","enabled":false,"owner":"请填写发布负责人","fallback":"人工建发版任务和更新说明","evidence":"请填写观察证据"},
{"id":"A09","mode":"integration_bridge","enabled":false,"owner":"请填写流水线负责人","fallback":"人工回写发布成败","evidence":"请填写观察证据"},
{"id":"A10","mode":"manual","enabled":true,"owner":"请填写产品负责人","fallback":"产品验收后人工关闭","evidence":"请填写观察证据"},
{"id":"TC01","mode":"manual","enabled":true,"owner":"请填写测试负责人","fallback":"逐条执行并记录用例结果","evidence":"请填写观察证据"},
{"id":"TC02","mode":"manual","enabled":true,"owner":"请填写测试负责人","fallback":"人工正式关联失败用例和缺陷","evidence":"请填写观察证据"},
{"id":"TC03","mode":"manual","enabled":true,"owner":"请填写测试负责人","fallback":"复测后更新原用例状态","evidence":"请填写观察证据"},
{"id":"DF01","mode":"manual","enabled":true,"owner":"请填写测试负责人","fallback":"已修复继续等待复测","evidence":"请填写观察证据"},
{"id":"DF02","mode":"manual","enabled":true,"owner":"请填写测试负责人","fallback":"复测通过后关闭缺陷并通过用例","evidence":"请填写观察证据"},
{"id":"DF03","mode":"manual","enabled":true,"owner":"请填写测试负责人","fallback":"复测失败后重开缺陷并保持用例失败","evidence":"请填写观察证据"},
{"id":"CI01","mode":"manual","enabled":true,"owner":"请填写研发负责人","fallback":"人工核对全部前后端MR","evidence":"请填写观察证据"},
{"id":"CI02","mode":"not_supported","enabled":false,"owner":"请填写流水线负责人","fallback":"使用test副本评审回调POC","evidence":"请填写观察证据"},
{"id":"CI03","mode":"manual","enabled":true,"owner":"请填写发布负责人","fallback":"人工区分test、生产发布和验收","evidence":"请填写观察证据"},
{"id":"L01","mode":"disabled_legacy","enabled":false,"owner":"请填写项目管理员","fallback":"保持发布完成等待验收","evidence":"请填写观察证据"},
{"id":"L02","mode":"disabled_legacy","enabled":false,"owner":"请填写项目管理员","fallback":"保持发布完成等待验收","evidence":"请填写观察证据"}
]
}
]
}

View File

@@ -0,0 +1,148 @@
{
"schema_version": 3,
"organization": "请填写组织名称",
"work_item_type": "产品类需求",
"lifecycle": [
"待处理",
"已确认",
"分析中",
"分析完成",
"设计中",
"设计完成",
"待开发",
"开发中",
"开发完成",
"测试中",
"测试完成",
"完成发布",
"已完成"
],
"requirement_labels": ["分析", "设计", "开发", "测试"],
"automation_policy": {
"require_current_state_condition": true,
"inspect_rule_order_and_cascades": true,
"allow_automatic_final_close_without_acceptance": false,
"zero_related_items_are_complete": false,
"development_start_event_candidates": ["添加关联分支", "正式关联代码资产"],
"require_code_assets_linked_to_requirement_and_development_task": true,
"stage_task_creation_idempotency_key_components": ["项目ID", "需求ID", "阶段类型"]
},
"test_closure_policy": {
"accepted_case_states": ["已通过"],
"rejected_case_states": ["未执行", "待测试", "阻塞", "未通过"],
"defect_fixed_is_terminal": false,
"require_tester_retest": true
},
"projects": [
{
"name": "请填写项目名称",
"work_item_type": "产品类需求",
"release_status": "完成发布",
"final_status": "已完成",
"labels_observed": ["分析", "设计", "开发", "测试"],
"repositories": [
{
"name": "请填写仓库名称",
"role": "frontend",
"integration_branch": "dev",
"codeup_integrated": true,
"required_for_mr_gate": true,
"evidence": "请填写仓库与需求正式关联的观察证据"
}
],
"test_pipeline": {
"name": "请填写test流水线名称",
"environment": "test",
"is_clone_for_callback_poc": true,
"original_pipeline_unchanged": true,
"docker_success_callback_enabled": false,
"callback_security": {
"signed": false,
"timestamp_checked": false,
"replay_protected": false,
"idempotent_by_execution_id": false,
"failure_retry_tested": false
},
"evidence": "请填写流水线观察证据"
},
"release_evidence": {
"mode": "native_release_change",
"target_environment": "请填写目标发布环境",
"test_success_is_production_release": false,
"evidence": "请填写发布证据"
},
"test_plan": {
"name": "请填写测试计划名称",
"requirement_formally_related": true,
"case_states_observed": ["未执行", "已通过", "未通过", "阻塞"],
"evidence": "请填写测试计划与用例状态证据"
},
"defect_workflow": {
"fixed_status": "已修复",
"fixed_is_terminal": false,
"tester_retest_required": true,
"closed_status": "请填写缺陷真实关闭状态",
"reopen_status": "请填写缺陷复测失败后的状态",
"evidence": "请填写缺陷工作流证据"
},
"rule_instances": [
{"id": "R01", "mode": "manual", "actual_rule_name": "人工门禁:需求确认", "trigger": "负责人确认", "source_status": "待处理", "target_status": "已确认", "conditions": ["范围、负责人和验收口径明确"], "action": "人工变更状态为已确认", "order": 1, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R02", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "添加关联任务工作项", "source_status": "已确认", "target_status": "分析中", "conditions": ["当前状态=已确认", "需求自身标签包含分析"], "action": "变更状态为分析中", "order": 2, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R03", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "关联任务工作项状态变化", "source_status": "分析中", "target_status": "分析完成", "conditions": ["当前状态=分析中", "本阶段分析任务全部完成;若平台不能过滤阶段则记录替代策略"], "action": "变更状态为分析完成", "order": 3, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R04", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "添加关联任务工作项", "source_status": "分析完成", "target_status": "设计中", "conditions": ["当前状态=分析完成", "需求自身标签包含设计"], "action": "变更状态为设计中", "order": 4, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R05", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "关联任务工作项状态变化", "source_status": "设计中", "target_status": "设计完成", "conditions": ["当前状态=设计中", "本阶段设计任务全部完成;若平台不能过滤阶段则记录替代策略"], "action": "变更状态为设计完成", "order": 5, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R06", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "添加关联任务工作项", "source_status": "设计完成", "target_status": "待开发", "conditions": ["当前状态=设计完成", "需求自身标签包含开发"], "action": "变更状态为待开发", "order": 6, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R07", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "添加关联分支或正式关联代码资产", "source_status": "待开发", "target_status": "开发中", "conditions": ["当前状态=待开发", "仓库已集成", "代码资产正式关联需求"], "action": "变更状态为开发中", "order": 7, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R08", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "关联合并请求状态变化", "source_status": "开发中", "target_status": "开发完成", "conditions": ["当前状态=开发中", "全部相关前后端合并请求已合并"], "action": "变更状态为开发完成", "order": 8, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R09", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "添加关联任务工作项", "source_status": "开发完成", "target_status": "测试中", "conditions": ["当前状态=开发完成", "需求自身标签包含测试"], "action": "变更状态为测试中", "order": 9, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R10", "mode": "manual", "actual_rule_name": "人工门禁:测试验收", "trigger": "测试验收任务完成或获批回调", "source_status": "测试中", "target_status": "测试完成", "conditions": ["当前状态=测试中", "范围内用例全部通过", "失败用例已复测通过", "范围内缺陷已由测试复测并关闭"], "action": "变更状态为测试完成", "order": 10, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R11", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "关联发布变更完成或获批回调", "source_status": "测试完成", "target_status": "完成发布", "conditions": ["当前状态=测试完成", "目标环境发布证据满足组织口径"], "action": "变更状态为完成发布", "order": 11, "enabled": false, "has_current_state_condition": true, "evidence": "请填写观察证据"},
{"id": "R12", "mode": "manual", "actual_rule_name": "人工门禁:业务验收", "trigger": "业务验收", "source_status": "完成发布", "target_status": "已完成", "conditions": ["当前状态=完成发布", "正式验收证据有效"], "action": "人工变更状态为已完成", "order": 12, "enabled": true, "has_current_state_condition": true, "evidence": "请填写观察证据"}
],
"task_rule_instances": [
{"id": "TK01", "mode": "manual", "actual_rule_name": "人工门禁:设计任务真实开工", "trigger": "负责人开始处理设计任务", "scope": "设计任务", "source_state": "待处理", "conditions": ["任务标签=设计", "任务正式关联需求"], "actions": ["设计任务变为处理中"], "order": 101, "enabled": true, "evidence": "请填写观察证据"},
{"id": "TK02", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "设计任务状态变为已完成", "scope": "产品需求", "source_state": "设计中", "conditions": ["本阶段设计任务非零且全部完成"], "actions": ["需求变为设计完成"], "order": 102, "enabled": true, "evidence": "请填写观察证据"},
{"id": "TK03", "mode": "integration_bridge", "actual_rule_name": "桥接:设计完成后创建开发任务", "trigger": "需求状态变为设计完成", "scope": "产品需求", "source_state": "设计完成", "conditions": ["不存在同需求同阶段未取消开发任务", "幂等键=项目ID+需求ID+开发"], "actions": ["创建并关联开发任务", "开发任务标签=开发", "开发任务状态=待处理"], "order": 103, "enabled": false, "evidence": "请填写观察证据"},
{"id": "TK04", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "开发任务创建并正式关联需求", "scope": "产品需求", "source_state": "设计完成", "conditions": ["需求标签包含开发", "开发任务关系可见"], "actions": ["需求变为待开发"], "order": 104, "enabled": true, "evidence": "请填写观察证据"},
{"id": "TK05", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "添加正式关联分支或代码资产", "scope": "产品需求和开发任务", "source_state": "需求=待开发;开发任务=待处理", "conditions": ["代码资产同时关联需求和开发任务", "仓库已接入项目"], "actions": ["需求变为开发中", "开发任务变为处理中"], "order": 105, "enabled": true, "evidence": "请填写观察证据"},
{"id": "TK06", "mode": "native_rule", "actual_rule_name": "请填写云效规则显示名", "trigger": "关联合并请求状态变化", "scope": "产品需求和开发任务", "source_state": "需求=开发中;开发任务=处理中", "conditions": ["全部相关前后端MR已合并到真实dev/develop", "MR同时关联需求和开发任务"], "actions": ["需求变为开发完成", "开发任务变为已完成"], "order": 106, "enabled": true, "evidence": "请填写观察证据"},
{"id": "TK07", "mode": "integration_bridge", "actual_rule_name": "桥接test部署成功后创建测试任务", "trigger": "test流水线部署成功", "scope": "产品需求", "source_state": "开发完成", "conditions": ["流水线执行ID未处理", "不存在同需求同阶段未取消测试任务", "回调签名和防重放验证通过"], "actions": ["创建并关联测试任务", "测试任务变为处理中", "关联测试计划和用例", "需求变为测试中"], "order": 107, "enabled": false, "evidence": "请填写观察证据"},
{"id": "TK08", "mode": "manual", "actual_rule_name": "人工门禁:测试任务验收闭环", "trigger": "测试验收完成", "scope": "产品需求和测试任务", "source_state": "需求=测试中;测试任务=处理中", "conditions": ["用例全部通过", "失败用例已复测", "范围内缺陷已由测试关闭"], "actions": ["测试任务变为已完成", "需求变为测试完成"], "order": 108, "enabled": true, "evidence": "请填写观察证据"}
],
"control_coverage": [
{"id": "R01", "mode": "manual", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工确认需求范围与验收口径"},
{"id": "R02", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工进入分析中"},
{"id": "R03", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工核对本阶段分析任务"},
{"id": "R04", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工进入设计中"},
{"id": "R05", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工核对本阶段设计任务"},
{"id": "R06", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工进入待开发"},
{"id": "R07", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "开发人员真实开工时人工开始开发"},
{"id": "R08", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工核对全部相关MR"},
{"id": "R09", "mode": "native_rule", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写负责人", "fallback": "人工进入测试中"},
{"id": "R10", "mode": "manual", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写测试负责人", "fallback": "测试负责人验收任务"},
{"id": "R11", "mode": "native_rule", "enabled": false, "evidence": "请填写观察证据", "owner": "请填写发布负责人", "fallback": "人工核对发布变更"},
{"id": "R12", "mode": "manual", "enabled": true, "evidence": "请填写观察证据", "owner": "请填写产品负责人", "fallback": "产品或业务负责人验收"},
{"id": "TK01", "mode": "manual", "enabled": true, "evidence": "请填写设计任务开工证据", "owner": "请填写设计负责人", "fallback": "负责人真实开工时人工改为处理中"},
{"id": "TK02", "mode": "native_rule", "enabled": true, "evidence": "请填写设计任务完成汇总证据", "owner": "请填写产品负责人", "fallback": "人工核对非零设计任务全部完成"},
{"id": "TK03", "mode": "integration_bridge", "enabled": false, "evidence": "请填写自动创建开发任务能力证据", "owner": "请填写项目管理员", "fallback": "从需求中人工新建并关联唯一开发任务"},
{"id": "TK04", "mode": "native_rule", "enabled": true, "evidence": "请填写开发任务关联证据", "owner": "请填写项目管理员", "fallback": "人工核对关系后将需求改为待开发"},
{"id": "TK05", "mode": "native_rule", "enabled": true, "evidence": "请填写需求和开发任务双对象开工证据", "owner": "请填写研发负责人", "fallback": "开发人员真实开工时人工更新任务和需求"},
{"id": "TK06", "mode": "native_rule", "enabled": true, "evidence": "请填写需求和开发任务双对象完成证据", "owner": "请填写研发负责人", "fallback": "人工核对全部相关MR后完成任务和需求"},
{"id": "TK07", "mode": "integration_bridge", "enabled": false, "evidence": "请填写test部署回调和自动创建测试任务证据", "owner": "请填写流水线负责人", "fallback": "test部署成功后人工创建并关联测试任务"},
{"id": "TK08", "mode": "manual", "enabled": true, "evidence": "请填写测试任务与需求双闭环证据", "owner": "请填写测试负责人", "fallback": "测试负责人验收后同步完成测试任务和需求"},
{"id": "TC01", "mode": "manual", "enabled": true, "evidence": "请填写用例执行状态证据", "owner": "请填写测试负责人", "fallback": "逐条执行并记录用例结果"},
{"id": "TC02", "mode": "manual", "enabled": true, "evidence": "请填写失败用例关联缺陷证据", "owner": "请填写测试负责人", "fallback": "人工核对失败用例与缺陷正式关系"},
{"id": "TC03", "mode": "manual", "enabled": true, "evidence": "请填写用例复测更新证据", "owner": "请填写测试负责人", "fallback": "复测后更新原用例状态"},
{"id": "DF01", "mode": "manual", "enabled": true, "evidence": "请填写已修复交接证据", "owner": "请填写测试负责人", "fallback": "已修复继续等待测试复测"},
{"id": "DF02", "mode": "manual", "enabled": true, "evidence": "请填写复测通过关闭证据", "owner": "请填写测试负责人", "fallback": "测试复测通过后关闭缺陷"},
{"id": "DF03", "mode": "manual", "enabled": true, "evidence": "请填写复测失败重开证据", "owner": "请填写测试负责人", "fallback": "测试复测失败后重开缺陷"},
{"id": "CI01", "mode": "native_rule", "enabled": true, "evidence": "请填写多仓库关联证据", "owner": "请填写研发负责人", "fallback": "人工核对全部前后端MR"},
{"id": "CI02", "mode": "not_supported", "enabled": false, "evidence": "请填写流水线能力证据", "owner": "请填写流水线负责人", "fallback": "复制test流水线后评审回调POC"},
{"id": "CI03", "mode": "manual", "enabled": true, "evidence": "请填写环境与发布证据", "owner": "请填写发布负责人", "fallback": "人工区分test部署、生产发布与业务验收"},
{"id": "L01", "mode": "disabled_legacy", "enabled": false, "evidence": "请填写旧规则盘点证据", "owner": "请填写项目管理员", "fallback": "使用独立业务验收任务"},
{"id": "L02", "mode": "disabled_legacy", "enabled": false, "evidence": "请填写旧规则盘点证据", "owner": "请填写项目管理员", "fallback": "保持发布完成状态等待业务验收"}
],
"adjunct_automations": []
}
]
}

View File

@@ -0,0 +1,62 @@
# 需求进阶段自动建同名任务(强制 · AI 桥接)
当**产品类需求**推进到下列状态时AI`apply`)必须查重后创建/复用**与需求同名**的任务,并**正式关联**该需求。不得只改需求状态却不建任务。
## 触发状态与字段规则
| 需求进入状态 | 任务标题 | 任务标签(右侧基础字段) | 负责人 | 时间字段 |
|---|---|---|---|---|
| **分析中** | = 需求标题(同名) | `分析` | **需求创建人**姓名 | 沿用需求(见下) |
| **设计中** | = 需求标题(同名) | `设计` | **需求创建人**姓名 | 沿用需求(见下) |
| **待开发** | = 需求标题(同名) | `交付`OneOS 主任务);若项目仍用阶段开发标签则用 `开发` | 固定 **何斐** | 沿用需求(见下) |
### 时间沿用(「标签创建时间沿用需求的」)
按平台能力尽量对齐,优先级:
1. 任务 **计划开始时间** = 需求的计划开始时间;若需求无计划开始,则用需求 **创建时间** 的日期。
2. 若平台允许写入任务创建时间:任务创建时间 = 需求创建时间。
3. 若都不能改创建时间:仍写计划开始;并在任务描述首行注明:`创建时间沿用需求YYYY-MM-DD HH:mm`
### 正式关联(强制)
- 使用云效**父子项或关联项**把任务挂到**该产品需求**。
- 仅标题写需求编号、或只关联其他任务而不关联需求 → **不合格**,必须补关联后再回报成功。
- 标题前缀(如 `【分析】`**不是**关联;本规则默认标题与需求**完全同名**;若项目强制标题前缀,允许 `【分析】{需求标题}`,但仍须正式关联 + 正确标签。
## 幂等与查重
幂等键:
```text
项目ID + 需求ID + 阶段标签(分析|设计|交付/开发)
```
执行顺序:
1. 查询该需求下是否已有**未取消**、同阶段标签、同名(或同名前缀)任务。
2. **已存在一条** → 复用:核对正式关联、标签、负责人(待开发须为何斐)、时间字段;缺则补齐;**不新建第二条**。
3. **存在多条冲突** → 停止创建,列出任务编号,请人合并后再继续。
4. **不存在** → 创建并正式关联,再改需求状态(若口令是「推进到某状态」且任务尚未建好,先建任务再改状态,避免有状态无任务)。
快轨直接到 **待开发**(跳过分析/设计):只建/复用 **待开发** 那一条同名任务(负责人何斐),不要为跳过的阶段补建分析/设计任务,除非用户明确要求。
## 与 OneOS 终态模型对齐
- `oneos_delivery`:待开发阶段的同名任务即唯一 **【交付】主任务**(标题优先与需求同名;标签=`交付`)。分析中/设计中另建同名阶段任务(标签分析/设计),**不**再额外建第二条交付主任务。
- `stage_tasks`:分析/设计/开发阶段任务按上表;待开发对应标签=`开发` 的同名任务,负责人何斐(若项目配置了其他默认开发负责人,以用户当次口令为准,口令未写则何斐)。
## AI 回报模板(短)
```text
需求ONEOSP-xxx「标题」→ 状态:分析中/设计中/待开发
任务TASK-xxx「同名」标签负责人计划开始已正式关联需求
查重:新建 / 复用
```
## 负向(不得误做)
- 需求仅到 `已确认` / `待处理`:不自动建分析/设计/待开发任务。
- 不得把样式类口头变更当成建任务触发。
- 不得在未查重时重复创建同阶段同名任务。
- 不得把负责人设错:分析中/设计中≠何斐(除非创建人就是何斐);待开发必须何斐(除非用户当次明确改派)。

View File

@@ -0,0 +1,60 @@
# Codeup研发资产集成
## 目录
1. 仓库覆盖
2. 分支策略
3. 正式关联
4. 提交与合并请求
5. CI01多仓库门禁
6. 验证场景
## 1. 仓库覆盖
前端、后端均可触发研发自动化,前提是仓库接入正确项目,分支/提交/MR正式关联需求规则没有错误限定到单一仓库。
逐仓库记录仓库名、角色、项目集成状态、Webhook、实际集成分支、是否纳入全部MR门禁和观察证据。
## 2. 分支策略
- 检查真实集成分支:存在`dev`则优先`dev`,否则使用存在的`develop`
- 从集成分支创建`feature/<WORK-ITEM-ID>``fix/<WORK-ITEM-ID>`
- 一个需求涉及多个仓库时,每个仓库可有一个同名活动分支。
- 快速迭代仍使用短生命周期分支;合并后是否删除按仓库策略和用户授权处理。
- 不直接推送保护分支,不为自动化方便创建不存在的`dev`/`develop`
## 3. 正式关联
关联优先级:
1. 从需求“代码”区域创建分支并回到需求确认资产数量。
2. 在Codeup创建分支/MR时显式选择工作项存在开发任务时同时关联需求和开发任务。
3. 使用以工作项ID结尾的分支名并在需求页确认自动关联。
4. 提交说明关键字仅作兜底,必须回需求页验证。
需求编号、分支名或MR标题有关键字不等同已正式关联。
## 4. 提交与合并请求
- 提交可带工作项ID增强追溯但不判定开发完成。
- MR目标必须是仓库真实集成分支。
- MR显式关联需求和对应开发任务并完成评审。
- 只有全部相关MR合并后才观察R08。
- 首次提交时间不作为真实开发开始时间;人工“开始开发”为效能主口径。
## 5. CI01多仓库门禁
CI01要求所有相关前后端仓库的分支、提交和MR都正式关联需求与对应开发任务。只遗漏一个仓库或只关联需求平台的“全部MR已合并”就可能只看到部分资产、提前完成需求或完全无法完成开发任务。
如果无法可靠聚合所有仓库保留人工核对门禁不能声称R08已完全自动化。
## 6. 验证场景
| 场景 | 操作 | 期望 |
|---|---|---|
| 正式关联分支 | 从需求代码区创建真实仓库分支 | 异步进入开发中 |
| 非关联分支 | 仅本地创建/推送无关系分支 | 不触发R07 |
| 仅有提交 | 提交代码但不合并MR | 不进入开发完成 |
| 单仓库MR | 所有相关仓库只有一个MR合并 | 仍保持开发中 |
| 多仓库全部合并 | 所有相关MR正式关联并合并 | 进入开发完成 |
| 错误集成分支 | MR指向非真实集成分支 | 阻塞并修正,不伪造通过 |

View File

@@ -0,0 +1,103 @@
# 需求生命周期与项目规则
## 目录
1. 状态参数化
2. R01R12规则矩阵
3. 阶段入口和阶段完成
4. 开发开始与开发完成
5. 标签、任务和关键字
6. 规则实例与限制
## 1. 状态参数化
典型流程为:
```text
待处理 → 已确认 → 分析中 → 分析完成 → 设计中 → 设计完成 → 待开发
→ 开发中 → 开发完成 → 测试中 → 测试完成 → 完成发布/发布完成 → 已完成/已关闭
```
逐项目读取真实状态。`完成发布``发布完成``已完成``已关闭`是候选映射,不能静默互换或创建近义重复状态。
## 2. R01R12规则矩阵
| ID | 流转 | 触发事件 | 必要条件 | 动作 | 方式 |
|---|---|---|---|---|---|
| R01 | 待处理 → 已确认 | 负责人确认 | 范围、负责人、优先级、验收口径明确 | 改为已确认 | 人工 |
| R02 | 已确认 → 分析中 | 添加关联任务工作项 | 当前状态;需求自身标签包含`分析` | 改为分析中 | 原生规则 |
| R03 | 分析中 → 分析完成 | 关联任务状态变化 | 当前状态;本阶段分析任务全部完成 | 改为分析完成 | 原生/条件化 |
| R04 | 分析完成 → 设计中 | 添加关联任务工作项 | 当前状态;需求自身标签包含`设计` | 改为设计中 | 原生规则 |
| R05 | 设计中 → 设计完成 | 关联任务状态变化 | 当前状态;本阶段设计任务全部完成 | 改为设计完成 | 原生/条件化 |
| R06 | 设计完成 → 待开发 | 添加关联任务工作项 | 当前状态;需求自身标签包含`开发` | 改为待开发 | 原生规则 |
| R07 | 待开发 → 开发中 | 添加关联分支或正式关联代码资产 | 当前状态;仓库已集成;资产正式关联 | 改为开发中 | 自动兜底 |
| R08 | 开发中 → 开发完成 | 关联合并请求状态变化 | 当前状态全部相关MR已合并 | 改为开发完成 | 原生规则 |
| R09 | 开发完成 → 测试中 | 添加关联任务工作项 | 当前状态;需求自身标签包含`测试` | 改为测试中 | 原生规则 |
| R10 | 测试中 → 测试完成 | 测试验收任务或获批回调 | 当前状态;用例、失败复测、缺陷均闭环 | 改为测试完成 | 人工/桥接 |
| R11 | 测试完成 → 发布完成状态 | 发布变更完成或获批回调 | 当前状态;目标环境发布证据有效 | 改为真实发布状态 | 原生/桥接 |
| R12 | 发布完成状态 → 项目终态 | 业务验收 | 当前状态;验收证据有效 | 改为真实终态 | 默认人工 |
R05R10与阶段任务自身状态的双向联动见`task-lifecycle.md`R10的用例和缺陷口径见`test-defect-closure.md`R11、R12见`pipeline-release.md`R07、R08的代码资产口径见`codeup-integration.md`
## 3. 阶段入口和阶段完成
R02、R04、R06、R09同时要求
1. 从需求中“新建并关联”或“关联已有”任务工作项。
2. 当前状态等于前一阶段完成状态。
3. 需求自身包含目标阶段标签。
如果规则编辑器不能读取需求自身标签,标签只能用于筛选和报表;保留人工入口或使用当前状态+显式关联事件,不能声称标签已成为技术门槛。
R03、R05首选“本阶段任务全部完成”。若平台只能判断“全部关联任务工作项”选择一种明确策略
- 阶段开始时才创建/关联本阶段任务。
- 使用不同工作项类型,并确认规则能按类型过滤。
- 使用父子需求拆分阶段。
- 保留人工完成动作。
不得把零个关联任务当作全部完成。使用关联项状态变化触发,并执行零关联项负向测试。
## 4. 开发开始与开发完成
R07采用双轨口径
- 效能主口径:开发人员真实开工时执行“开始开发”。
- 自动兜底:创建或正式关联需求分支/代码资产后进入`开发中`
首次提交可能晚于真实开工,不能作为权威开始时间。自动规则必须要求当前状态=`待开发`
R08推荐定义
```text
事件 = 关联合并请求状态变化
且当前状态 = 开发中
且全部相关合并请求 = 已合并
则状态 = 开发完成
```
提交活动不能证明开发完成。全部相关前端、后端MR都必须正式关联并合并到实际集成分支。
## 5. 标签、任务和关键字
`分析``设计``开发``测试`标签加在产品类需求自身。
“添加关联任务工作项”依赖云效正式关系,不依赖标题关键字。有效方式:
- 在需求详情新建并关联任务。
- 在需求详情关联已有任务。
- 在任务详情选择该需求为父项/关联项,并在页面确认关系。
仅写需求编号、粘贴链接或添加同名标签都不能替代正式关联。编号仍建议出现在标题中,方便检索和审计。
## 6. 规则实例与限制
每个项目分别记录R01R12的实际规则显示名/人工门禁、精确触发、源状态、目标状态、全部条件、动作、顺序、启用状态、执行账号和证据。
已知限制:
- 规则通常异步执行530秒内不要过早判失败。
- “全部关联项”只能看到已集成并正式关联的资产。
- 提前创建未来阶段任务可能阻塞“全部任务完成”。
- 需求状态规则不会自动更新阶段任务状态必须同时配置TK01TK08。
- 平台能力因模板、版本和权限不同以当前UI和执行日志为准。

View File

@@ -0,0 +1,133 @@
# 线上实施、验证与治理
## 目录
1. 安全边界
2. 项目盘点
3. 规则应用
4. 隔离验证
5. 故障诊断
6. 回滚
7. 运行治理
## 1. 安全边界
- 只操作明确授权的组织、项目、工作项类型和测试资产。
- 复用已登录会话不记录账号、密码、Cookie、Token、OTP、私钥或签名材料。
- 不修改现有prod流水线回调实验只改test副本。
- 不删除测试需求、分支、MR、流水线或规则除非另有授权。
- 保留无关规则和用户修改。
## 2. 项目盘点
先记录`flow_model=stage_tasks``flow_model=oneos_delivery`。发现两套规则同时启用时判定为迁移中,不按规则名称猜测主模型。
对每个项目独立记录:
| 类别 | 必查内容 |
|---|---|
| 项目 | 精确项目名、项目ID、工作项类型 |
| 工作流 | 全部状态、顺序、发布状态、终态 |
| 阶段任务 | 设计/开发/测试任务类型、状态、标签、父子/关联关系、自动创建能力 |
| 标签 | `分析``设计``开发``测试` |
| 规则 | 名称、事件、条件、动作、顺序、启用、执行账号、后继规则 |
| Codeup | 前后端仓库、集成、Webhook、真实`dev/develop` |
| 流水线 | test/prod名称、环境、部署阶段、副本能力 |
| 发布 | 发布变更关系、目标环境、完成状态 |
| 测试 | 计划范围、用例状态枚举、需求关系 |
| 缺陷 | 已修复、复测、关闭态、重开态、权限 |
两个项目只复制逻辑不复制内部ID、仓库、分支、流水线、规则ID或观察证据。
OneOS终态迁移还要盘点`待测试/发布中/发布失败`状态缺口、主任务身份方式、任务工作流方案A/B、原生规则能否过滤关联任务身份、Y/A控制负责人和幂等键。门禁未齐时保留可用旧规则只停用会绕过验收或造成已确认误触发的规则。
## 3. 规则应用
1. 导出或截图现有规则和启用状态。
2. 识别后继规则和可能的连锁路径。
3. 创建缺失阶段标签。
4. 一次只创建或修改一条规则。
5. 设置精确触发、当前状态、需求标签、其他条件和目标动作。
6. 保存后重新打开,核对全部字段、顺序、启用状态和执行账号。
7. 把真实字段写回该项目`rule_instances`
8. 触发最小事件等待530秒。
9. 查看状态、研发资产、执行日志和二次流转。
10. 记录证据后再处理下一条。
手工推进只能标记为“测试准备动作”,不能伪造规则通过。
## 4. 隔离验证
测试需求命名示例:
```text
[自动化测试] 产品类需求生命周期验证-YYYYMMDD
```
| 用例 | 操作 | 期望 | 负向检查 |
|---|---|---|---|
| T01 | 已确认+分析标签+关联任务 | 分析中 | 无标签不流转 |
| T02 | 完成分析任务 | 分析完成 | 零任务不误触发 |
| T03 | 设计标签+关联任务 | 设计中 | 仅标题关键字不触发 |
| T04 | 完成设计任务 | 设计完成 | 未完成任务阻塞 |
| T04a | 设计完成后触发TK03 | 创建且只创建一个开发任务 | 重复事件不得重复创建 |
| T04b | 开发任务正式关联需求 | 需求待开发、任务待处理 | 仅标题编号不触发 |
| T05 | 开发标签+关联任务 | 待开发 | 错误当前状态不触发 |
| T06 | 正式关联真实仓库分支 | 开发中 | 非关联分支不触发 |
| T06a | 同一代码资产同时关联需求和开发任务 | 需求开发中、开发任务处理中 | 只关联需求不得声称任务已联动 |
| T07 | 前后端各关联MR只合并一个 | 保持开发中 | 不得提前完成 |
| T08 | 全部相关MR合并 | 开发完成 | 仅提交不得完成 |
| T08a | 全部相关MR同时关联开发任务并合并 | 开发任务已完成 | 部分仓库MR未合并时阻塞 |
| T09 | 测试标签+关联测试任务 | 测试中 | 未来任务不应误阻塞 |
| T09a | test流水线部署成功触发TK07 | 创建且只创建一个测试任务并进入处理中 | 流水线失败或重复回调不得创建 |
| T10a | 执行全部用例并记录结果 | 每条有明确状态 | 未执行/阻塞/失败阻塞 |
| T10b | 失败用例关联缺陷,改为已修复 | 保持测试中 | 未复测不得完成 |
| T10c | 复测通过/失败 | 关闭并通过/重开并失败 | 用例缺陷状态一致 |
| T11 | 发布变更完成/获批回调 | 发布完成状态 | 重复回调不重复推进 |
| T12a | 进入发布完成后等待异步窗口 | 停留发布完成 | L01/L02不得误关闭 |
| T12b | 完成正式业务验收 | 项目终态 | test或发布不能替代验收 |
结果只使用:`通过``异步通过``未触发``误触发``阻塞`
## 5. 故障诊断
按顺序检查:
1. 事件是否真实发生,而不是只有相似标题/标签。
2. 当前状态是否精确匹配。
3. 标签是否加在需求自身。
4. 任务、分支、MR、发布变更是否正式关联。
5. 仓库是否接入正确项目Webhook是否有效。
6. 全部相关MR是否合并到真实集成分支。
7. 规则是否启用,执行账号是否有权限。
8. 执行日志是未匹配、失败还是排队。
9. 用例是否仍有未执行、阻塞或失败。
10. 缺陷是否仅到已修复,尚未复测关闭。
11. 目标状态是否触发后继/重复规则。
12. 是否发生`测试完成 → 发布完成 → 终态`瞬时连锁。
13. 是否等待合理异步窗口。
14. 是否误用另一个项目的内部ID或证据。
15. 缺陷与原用例在复测后的状态是否一致。
16. 需求状态变化是否遗漏对应阶段任务状态,或任务变化是否遗漏需求汇总。
17. 自动创建下一阶段任务是否具有需求ID+阶段类型幂等门禁。
18. 项目是否混用R/TK阶段模型与Y/A终态模型。
19. OneOS主任务是否唯一且可被规则可靠识别。
20. 是否缺少`待测试``发布中``发布失败`或重试流转边。
## 6. 回滚
- 先禁用本次新增规则,再恢复修改前条件和动作。
- 保留变更前截图/导出和规则ID。
- 回调POC通过禁用副本回滚删除需单独授权。
- 分支/MR等测试资产按仓库策略清理删除需单独授权。
- 不覆盖与本次变更无关的规则。
## 7. 运行治理
- 每季度复核规则账号、权限、启用状态和失败日志。
- 新增仓库时同步验证项目集成、Webhook和多仓库门禁。
- 状态、标签或工作项类型重命名后逐条复核依赖规则。
- 保护`master/main`和真实`dev/develop`
- 监控超过7天无活动的需求分支未经授权不删除。
- 分开统计自动流转时间与真实业务开始时间。
- 持续检查回调签名、时间戳、防重放、幂等和失败重试。

View File

@@ -0,0 +1,117 @@
# 统一运营管理平台 · 记录需求快路径
真相源运行时 ID[assets/oneos-pc-runtime-ids.json](../assets/oneos-pc-runtime-ids.json)
验证样例ONEOS-84「测试需求」2026-07-21
**用户可复制完整口令A/B/C 门禁 + 30 标签):** [oneos-pc-record-requirement-prompt.md](oneos-pc-record-requirement-prompt.md)
本文件只服务**日常 `记录需求` apply**。全量审计/规则配置仍读 `live-operations.md` 等模块。
## 0. 执行门禁(强制)
用户口令要求 Plan / 单选「优先级」「推进至」「标签」时:
1. **必须**先拿到明确的 A紧急/高/中/低、B分析中/设计中/设计完成/开发中)、**C标签可多选**
2. 标签 **C** 只能从 [assets/oneos-pc-tag-catalog.md](../assets/oneos-pc-tag-catalog.md) / `tags.catalog` 点选;**禁止**按 `lines.ts` 或业务条线说明自动推断。
3. 来源优先:`AskQuestion` 点选 → Plan 批准时写明的 A+B+C → 用户一句话点明。
4. **禁止**:未选时默认「中 + 分析中」并继续建单;**禁止**未选标签时自作主张打标。
5. 「批准/Implement 计划」≠ 已选 A+B+C计划正文若仍是空 ○,必须先问清再执行。
多条需求名称时:每条或整批共用的优先级/推进至/标签仍须用户点选后再写云效。
## 1. 加载配方(禁止重探)
对项目别名含「统一运营管理平台」时:
1. 读取 `assets/oneos-pc-runtime-ids.json`
2. **禁止**再探测 create URL不要用 `/workitem/create`)。
3. **禁止**对优先级 ID 做猜测重试超过配方表。
## 2. 标准执行顺序(目标 13 分钟)
```text
AutoPRD → API 建需求(含描述) → API 建阶段任务 → API 打标签(COVER) → 状态连跳 → 一次校验回报
```
### 2.1 AutoPRD
- 先跑 `$oneos-autoprd`(模块/原型来自口令或对话页)。
- 描述 HTML 最小块:`【原型】` + `【目标】` + `【非目标】` + `【更新内容】`(无变更记录则「首版定稿」)。
### 2.2 API 建产品类需求
`POST`(失败再试一次 `PUT`
`https://devops.aliyun.com/projex/api/workitem/workitem?_input_charset=utf-8`
要点:
- `workitemTypeIdentifier` **与** `workitemType` 都设为产品类需求 ID。
- `document: { content: html, formatType: 'RICHTEXT' }` 创建时写入。
- `fieldValueList``priority``assignedTo`、可选 `79`(计划开始,中国时区中午 epoch 字符串)。
- 开发中:需求负责人用何斐 ID分析中/设计中/设计完成:创建人 ID。
### 2.3 API 建同名阶段任务
同一 create 接口,`category=Task``parentIdentifier=需求ID`,标题与需求**完全同名**。
| 推进至 | 任务标签目标 | 负责人 |
|---|---|---|
| 分析中 | 分析 | 创建人 |
| 设计中 | 设计 | 创建人 |
| 开发中 / 待开发 | 开发或交付 | 何斐 |
查重:同需求 + 同阶段标签 + 未取消 → 复用,不建第二条。详见 [auto-stage-task.md](auto-stage-task.md)。
### 2.4 状态连跳(开发中)
从「待处理」到「开发中」UI 固定路径:
```text
待处理 → 设计完成 → 待开发 → 开发中
```
每跳点开状态按钮选目标;不要在「待处理」下拉里找「开发中」(没有)。
其它推进至:
- 分析中 / 设计中 / 设计完成:待处理下拉通常可直达。
### 2.5 标签(选择器点选 + API
1. 用户已从 `tags.catalog` 点选标签名(见门禁 C`tags.by_name` 得到 identifier。
2. **优先 API**(已验证):
```http
PATCH https://devops.aliyun.com/projex/api/workitem/workitem/{identifier}?_input_charset=utf-8
Content-Type: application/json
{
"workitemIdentifier": "{identifier}",
"propertyKey": "tag",
"propertyValue": "{id1,id2}",
"operateType": "COVER"
}
```
3. API 失败 1 次 → UI 标签选择器勾选 → **确定**;仍失败 → **停止并请用户回复标签名**,禁止报「已打上」。
4. **禁止**对照 `lines.ts` / 业务条线说明自动映射;刷新全量标签:`GET` `api.list_tags``spaceType=Space`)。
### 2.6 校验与回报(只做一次)
核对:编号、链接、状态、优先级、负责人、计划开始、描述含原型、任务父项、标签。
截图最多:建单后、终态后各一次。
## 3. 性能红线
| 允许 | 禁止 |
|---|---|
| 配方内 ID / URL | 每步 3+ 端点盲探 |
| create 带描述 | 默认先开富文本再粘贴 |
| 状态固定三跳 | React fiber 乱调 onChange易白屏 |
| 标签失败问用户 | 标签失败仍声称成功 |
| API 失败 1 次改 UI | 同失败 payload 循环重试 |
## 4. 浏览器会话
- 复用已登录 `devops.aliyun.com`;不记录 Cookie/Token。
- 优先已打开的「统一运营管理平台」项目页;锁 tab 后执行,结束解锁。

View File

@@ -0,0 +1,139 @@
# 统一运营管理平台 · 记录需求到云效(完整提示词)
复制下方「用户口令」整段发给 AgentAgent 须先弹出 Plan / 单选卡让你点选 **A 优先级 + B 推进至 + C 标签****未点选前不得建单**。
---
## 用户口令(复制整段)
```text
使用 $yunxiao-requirement-lifecycle + $oneos-autoprd
记录需求到云效 · 统一运营管理平台
【需求名】
(填写 1 条或多条,例:车辆资产功能优化)
【描述来源】
$oneos-autoprd原型/模块填写oneos-h5-vehicle-assets
【已锁定】
- 项目:统一运营管理平台
- 计划开始:今日
- 负责人:分析中 / 设计中 / 设计完成 → 创建人;开发中 → 何斐
【请你先 Plan 点选,未选完禁止写云效】
A. 优先级(单选)
○ 紧急
○ 高
○ 中
○ 低
B. 推进至(单选)
○ 分析中(建同名「分析」任务,负责人=创建人)
○ 设计中(建同名「设计」任务,负责人=创建人)
○ 设计完成(只改状态,负责人=创建人;不强制建阶段任务)
○ 开发中(建同名开发/交付任务,负责人=何斐;状态连跳:待处理→设计完成→待开发→开发中)
C. 标签(可多选;只能从下列 30 个云效标签中选,禁止自造、禁止按业务条线/lines.ts 自动推断)
○ 运维管理条线
○ 报表中心
○ 能源管理
○ 维修站管理
○ 充电站管理
○ 还车应结款
○ 交车应收款
○ 加氢站管理
○ 保险管理
○ 合同管理
○ 租赁账单
○ 供应商管理
○ 客户管理
○ 还车任务
○ 安全培训
○ 备件管理
○ 停车场管理
○ 备车管理
○ 异动管理
○ 调拨管理
○ 上牌管理
○ 替换车管理
○ 还车管理
○ 交车管理
○ 车辆管理
○ 审批中心
○ 故障管理
○ 车辆年审
○ 维修管理
○ 工作台
【我确认 A+B+C 后你再执行】
批准 / Implement 计划时,我会写明所选值,例如:
优先级:高;推进至:设计完成;标签:运维管理条线
【执行要求Agent
1. 读快路径references/oneos-pc-fast-path.md + assets/oneos-pc-runtime-ids.json
2. 先 $oneos-autoprd 生成描述(【原型】【目标】【非目标】【更新内容】;无增量则「首版定稿」)
3. API 建产品类需求POST /workitem/workitem创建时写入 document
4. 计划开始=今日;按 B 推进状态;按规则建/复用同名阶段任务
5. 标签:用 tags.by_name 查 identifierPATCH propertyKey=tag operateType=COVERAPI 失败 1 次再 UI 兜底
6. 一次校验回报:编号、链接、状态、优先级、负责人、计划开始、标签、描述、阶段任务
7. 禁止默认「中+分析中」;禁止未选标签时自作主张打标;标签失败不得报「已成功」
```
---
## 单条需求示例(填好即用)
```text
使用 $yunxiao-requirement-lifecycle + $oneos-autoprd
记录需求到云效 · 统一运营管理平台
【需求名】
测试需求3
【描述来源】
$oneos-autoprd原型/模块oneos-h5-vehicle-assets
【已锁定】
- 项目:统一运营管理平台
- 计划开始:今日
- 负责人:分析中/设计中/设计完成=创建人;开发中=何斐
请先 Plan 让我点选 A 优先级、B 推进至、C 标签30 项 catalog禁止 lines.ts 推断)。
我确认后再执行快路径建单。
```
确认回复示例:
```text
优先级:高;推进至:设计完成;标签:运维管理条线
```
---
## Agent 执行清单(内部,勿省略)
| 步 | 动作 |
|---|---|
| 0 | 门禁A+B+C 已明确Implement ≠ 已选 |
| 1 | `$oneos-autoprd` → HTML 描述 |
| 2 | `POST …/workitem/workitem` 建需求 + document |
| 3 | 字段priority、assignedTo、79=今日 |
| 4 | 若 B∈{分析中,设计中,开发中} → 同名阶段任务(查重复用) |
| 5 | 状态推进至 B开发中三跳 |
| 6 | `PATCH …/workitem/{id}` 打标签 COVER |
| 7 | 回报 ONEOS-xxx + 链接 + 核对表 |
标签 API 形态:
```json
{
"workitemIdentifier": "{id}",
"propertyKey": "tag",
"propertyValue": "{identifier1},{identifier2}",
"operateType": "COVER"
}
```
参考:`assets/oneos-pc-tag-catalog.md``assets/oneos-pc-runtime-ids.json``tags.by_name`

View File

@@ -0,0 +1,192 @@
# OneOS终态交付模型与迁移规则
## 目录
1. 模型选择与边界
2. 工作项和状态
3. 原生Y规则
4. AI与Webhook桥接
5. 迁移就绪门禁
6. 分阶段实施
7. 验收与负向测试
8. 已知风险和回滚
## 1. 模型选择与边界
OneOS终态模型以需求为阶段看板唯一真相
```text
迭代
├─ 多个产品类需求
├─ 按需求分包的测试计划
└─ 一条【发版】任务
产品类需求
└─ 一条【交付】主任务
├─ 多条【开发】任务
└─ 一条【测试】任务
```
每个项目只能选择一个主模型:
- `stage_tasks`分析、设计、开发、测试各建阶段任务使用R/TK控制。
- `oneos_delivery`一条交付主任务镜像需求多开发任务汇总使用Y/A控制。
不得同时启用两套进场规则。若项目仍有分析/设计标签驱动的R规则先记录为`legacy_active`,完成迁移就绪检查后再停用。
## 2. 工作项和状态
### 2.1 任务身份
| 任务 | 数量 | 必须关系 | 识别优先级 |
|---|---:|---|---|
| `【交付】`主任务 | 每需求1条 | 正式关联需求 | 独立工作项类型 > 任务标签`交付` > 标题前缀流程约束 |
| `【开发】`任务 | 每需求1..N条 | 正式关联需求和主任务 | 独立类型 > 标签`开发` > 标题前缀 |
| `【测试】`任务 | 每需求1条 | 正式关联需求和主任务 | 独立类型 > 标签`测试` > 标题前缀 |
| `【发版】`任务 | 每迭代1条 | 正式关联迭代和本批需求 | 独立类型 > 标签`发版` > 标题前缀 |
标题关键字本身不构成正式关系。规则编辑器不能读取关联任务的类型、标签或标题时,不得声称原生规则能区分主任务和开发任务;使用桥接或人工门禁。
### 2.2 需求状态
目标状态顺序:
```text
待处理 → 已确认 → 分析中 → 分析完成 → 设计中 → 设计完成
→ 待开发 → 开发中 → 开发完成 → 待测试 → 测试中 → 测试完成
→ 发布中 → 发布完成 / 发布失败 → 已关闭
```
允许的快轨至少包括`已确认 → 待开发``设计完成 → 待开发``发布失败 → 发布中`用于重试。已有近义状态先映射,禁止静默创建`完成发布/发布完成``已完成/已关闭`等重复状态。
### 2.3 任务状态
- 主任务:首选与需求同名镜像状态。
- 开发任务:`待处理 → 处理中 → 已完成`,另有取消态。
- 测试任务:`待处理/待测试 → 测试中 → 已完成`
- 发版任务:`待处理 → 发布中 → 发布完成/发布失败`
若任务工作流不能承载主任务全量状态不配置Y02Y16原生镜像由桥接更新需求并记录主任务显示口径。
## 3. 原生Y规则
统一规则前缀:`[OneOS终态] Yxx ...`。保存后重新打开核对事件、条件、动作、顺序、启用状态和执行账号。
### 3.1 主任务镜像
| ID | 触发 | 条件 | 动作 | 默认方式 |
|---|---|---|---|---|
| Y01 | 新增正式关联主任务 | 主任务可可靠识别;需求为待处理/已确认 | 需求与主任务进入分析中或获批快轨 | 原生/桥接 |
| Y02 | 主任务到分析完成 | 正式关联唯一需求 | 需求到分析完成 | 原生 |
| Y03 | 主任务到设计中 | 正式关联唯一需求 | 需求到设计中 | 原生 |
| Y04 | 主任务到设计完成 | 正式关联唯一需求 | 需求到设计完成 | 原生 |
| Y05 | 主任务到待开发 | 正式关联唯一需求 | 需求到待开发;负责人按项目配置 | 原生/桥接 |
| Y06 | 主任务到开发中 | 正式关联唯一需求 | 需求到开发中 | 原生 |
| Y07 | 主任务到开发完成 | 正式关联唯一需求 | 需求到开发完成 | 原生 |
| Y08 | 主任务到待测试 | 正式关联唯一需求 | 需求到待测试 | 原生 |
| Y09 | 主任务到测试中 | 正式关联唯一需求 | 需求到测试中 | 原生 |
| Y10 | 主任务到测试完成 | Testhub门禁通过 | 需求到测试完成 | 桥接优先 |
| Y11 | 主任务到发布中 | 发版任务和迭代范围有效 | 需求到发布中 | 原生/桥接 |
| Y12 | 主任务到发布完成 | 生产发布证据有效 | 需求到发布完成 | 桥接 |
| Y13 | 主任务到发布失败 | 生产发布失败证据有效 | 需求到发布失败并记录原因 | 桥接 |
| Y14 | 主任务到已关闭 | 产品验收证据有效 | 需求到已关闭 | 默认人工 |
| Y15 | 需求状态变化 | 存在唯一主任务且无循环风险 | 主任务同名镜像 | 可选原生 |
| Y16 | 需求快轨到待开发 | 存在唯一主任务 | 主任务到待开发 | 原生/桥接 |
每条双向镜像规则必须有防循环条件。平台无法区分“由本规则写入”的事件时,只保留一个权威方向。
### 3.2 开发任务汇总
| ID | 触发 | 条件 | 动作 | 默认方式 |
|---|---|---|---|---|
| Y20 | 新增第一条开发任务或正式关联开发分支 | 需求=待开发开发任务数量≥1任务已指派 | 需求和主任务到开发中;对应开发任务处理中 | 原生/桥接 |
| Y21 | 开发任务完成 | 非取消开发任务数量≥1且全部完成全部相关MR已合并 | 需求和主任务到开发完成 | 桥接优先 |
| Y22 | 开发完成 | 同需求无未取消测试任务;幂等键未处理 | 创建并关联唯一测试任务,进入待测试 | 桥接 |
现有的“任务添加分支且标签=开发待处理→处理中”和“全部MR已合并且标签=开发处理中→已完成”可作为Y20/Y21的任务侧子规则保留。需求侧“全部MR已合并”只能看到正式关联资产必须执行多仓库负向测试。
### 3.3 测试、发布和迭代
| ID | 触发 | 条件 | 动作 | 默认方式 |
|---|---|---|---|---|
| Y30 | 创建测试任务 | 正式关联需求和主任务 | 需求/主任务到待测试 | 桥接 |
| Y31 | 首条用例执行或测试负责人开工 | 测试任务=待处理/待测试 | 测试任务、需求、主任务到测试中 | 原生/人工 |
| Y32 | 测试验收完成 | 本需求用例包全部通过且缺陷闭环 | 三个对象完成测试阶段 | 桥接/人工 |
| Y33 | 发版任务提交 | 发版任务正式关联迭代与本批需求 | 发版任务和本批需求到发布中 | 原生/桥接 |
| Y34 | 生产发布成功 | 流水线执行、环境、范围、签名和幂等均有效 | 到发布完成 | 桥接 |
| Y35 | 生产发布失败 | 同上;失败证据有效 | 到发布失败并记录原因 | 桥接 |
| Y40 | 需求到待开发 | 未关联迭代 | 通知产品负责人,不自动写入未知迭代 | 通知/人工 |
test部署只能允许测试开始不能触发Y34。发布完成后必须停留等待产品/业务验收;禁止自动进入已关闭。
## 4. AI与Webhook桥接
| ID | 能力 | 最小门禁 |
|---|---|---|
| A01 | 发布原型/附件并回写需求描述 | 外部存储授权;链接可审计 |
| A02 | 生成并保留更新内容历史 | 不覆盖历史段;**OneOS 默认调用 `$oneos-autoprd` 第 10 章「功能变更记录」(自上次定稿以来);旧段挪到「更新内容·历史」** |
| A02B | 生成产品可读需求说明 | 不写实现机密;保留来源;**OneOS 默认调用 `$oneos-autoprd` 全文/交付口径写入「需求说明」** |
| A03 | 建需求和唯一主任务并快轨待开发 | 幂等查询;正式关系可见;**建单写描述前先完成 A02+A02BAutoPRD**;进入待开发时按 [auto-stage-task.md](auto-stage-task.md) 建/复用与需求同名任务,负责人何斐,时间沿用需求 |
| A03B | 需求进入分析中/设计中自动建同名阶段任务 | 标签分析/设计;负责人=需求创建人;正式关联;时间沿用需求;与 A03 同一套查重 |
| A04 | 拆分多条开发任务 | 负责人、范围和父子关系明确 |
| A05 | 按MR证据关闭开发任务 | 所有仓库、目标分支和关联关系完整 |
| A06 | 全部开发完成后建唯一测试任务 | 幂等键=`项目ID+需求ID+create_test_task` |
| A07 | 按Testhub结果完成测试 | 用例与缺陷口径满足TC/DF控制 |
| A08 | 按迭代创建发版任务和更新说明 | 正式关联迭代和本批需求 |
| A09 | 发布成败回写 | 验证环境、签名、时间戳、执行ID和范围 |
| A10 | 产品验收后关闭 | 明确验收人和证据;不得由发布成功替代 |
所有桥接必须具备最小权限服务账号、签名、时间戳、防重放、幂等、失败重试、审计日志和人工回退。不得把未部署的AI能力写成已自动化。
## 5. 迁移就绪门禁
只有全部满足时才停用旧R/TK进场规则
1. 需求状态已包含`待测试``发布中``发布失败`,并配置必要流转边。
2. 主任务能被可靠识别;若只能靠标题前缀,已明确由桥接处理,不使用无过滤原生镜像。
3. 主任务和开发/测试/发版任务的工作流已选定并验证。
4. Y/A控制逐项指定`native_rule``integration_bridge``manual``not_supported`
5. A03、A06、A08、A09具备幂等键、负责人和回退方案。
6. Codeup前后端仓库、真实`dev/develop`、正式关联和多仓库MR门禁已验证。
7. Testhub按需求分包未执行、阻塞、失败用例和仅“已修复”缺陷会阻塞测试完成。
8. test、生产发布、发布变更和产品验收证据已分离。
9. 已保存旧规则名称、条件、顺序、启用状态和执行账号证据。
10. `L01``L02`已停用,发布完成不会瞬时关闭。
未通过门禁时:保留当前可用规则,只停用会绕过验收或造成误触发的规则,并输出迁移计划。
## 6. 分阶段实施
- P0补齐工作流、唯一主任务、快轨待开发、负责人和旧规则备份。
- P1开发任务拆分、分支/MR双关联、多仓库完成门禁、唯一测试任务。
- P2迭代、Testhub按需求分包、用例和缺陷复测闭环。
- P3发版任务、生产流水线成败回写、发布失败重试、产品验收关闭。
每阶段先在隔离测试需求验证,再启用下一阶段。新规则不补触发历史事件;历史数据修正必须单独记录。
## 7. 验收与负向测试
| ID | 操作 | 期望 | 负向检查 |
|---|---|---|---|
| V01 | 创建需求和唯一主任务 | 正式关联且不重复 | 重放不得建第二条主任务 |
| V02 | 未建开发任务 | 保持待开发 | 主任务本身不得被当作开发任务 |
| V03 | 建两条开发任务 | 进入开发中 | 无正式关系或无负责人不触发 |
| V04 | 只完成一条开发任务/MR | 保持开发中 | 不得提前完成 |
| V05 | 所有开发任务和MR完成 | 开发完成并只建一条测试任务 | 重复回调不重复创建 |
| V06 | 同迭代其他需求未测完 | 本需求仍可独立完成测试 | 不按整个计划全绿判断 |
| V07 | 用例失败且缺陷仅已修复 | 保持测试中 | 未复测不得完成 |
| V08 | 复测通过/失败 | 用例通过+缺陷关闭 / 用例失败+缺陷重开 | 双方状态必须一致 |
| V09 | test部署成功 | 允许测试开始 | 不得发布完成 |
| V10 | 生产发布成功 | 发布完成并停留 | 不得自动已关闭 |
| V11 | 生产发布失败后重试 | 发布失败→发布中 | 原因和执行ID可审计 |
| V12 | 产品验收 | 已关闭 | 无验收证据不关闭 |
## 8. 已知风险和回滚
- “全部关联任务完成”若无法按任务身份过滤,会把主任务或测试任务纳入,默认交桥接。
- 仅标题前缀不能替代正式关系,也不能保证原生规则可过滤。
- 需求和主任务双向镜像可能循环,只保留一个权威方向或增加来源防重。
- 分支/MR只关联需求而未关联开发任务会导致任务状态不更新。
- 发布完成自动关闭会绕过产品验收;`L01``L02`默认停用。
回滚顺序先禁用本次新增Y/A入口恢复旧规则启用状态和顺序再恢复状态流转边保留已创建任务、关系和执行日志删除任何资产需单独授权。

View File

@@ -0,0 +1,66 @@
# 流水线、发布与最终验收
## 目录
1. 环境证据边界
2. R11发布完成
3. CI02回调POC
4. CI03证据分离
5. R12最终验收
6. L01L02历史规则
7. 连锁触发验证
## 1. 环境证据边界
分别记录以下事件,未经批准不得互相替代:
- test环境部署成功。
- 生产环境流水线发布成功。
- 云效关联发布变更达到完成状态。
- 产品/业务验收完成。
流水线成功不等于测试用例通过test成功也不默认等于生产发布完成。
## 2. R11发布完成
优先使用“关联发布变更达到项目真实完成状态”作为R11证据。规则要求当前状态=`测试完成`,目标环境和发布证据满足组织口径。
如果组织明确把test部署定义为“完成发布”必须记录该口径的批准证据否则test回调只记录测试部署成功不推进发布完成。
## 3. CI02回调POC
1. 复制现有test流水线并使用明显POC名称。
2. 保留原test与prod流水线不变。
3. 只在副本Docker部署成功路径末尾添加回调。
4. 普通变量传工作项ID、环境、流水线执行ID。
5. 密钥变量保存认证材料,不写入仓库、命令输出或文档。
6. 请求携带时间戳、签名和执行ID幂等键。
7. 服务端校验需求当前状态,只允许预期流转执行一次。
8. 测试成功、重复回调、错误签名、超时、部署失败和安全重试。
9. POC通过后走推广评审不直接修改prod。
## 4. CI03证据分离
CI03要求项目配置分别保存test、prod、发布变更和验收证据。任何桥接都必须记录实现方式、启用状态、负责人、证据和人工回退。
回调失败保留失败日志并可安全重试不得伪造成功。同一执行ID重试不得多次推进需求。
## 5. R12最终验收
发布成功不等于业务验收。默认由产品/业务负责人确认后人工进入`已完成``已关闭`。只有可靠、可审计的验收任务才允许条件化自动关闭。
## 6. L01L02历史规则
- `L01``发布完成 + 全部关联任务完成/关闭 → 项目终态`。默认停用或收紧为独立验收任务,避免早期阶段任务误满足。
- `L02`:监听`测试完成 → 发布完成`状态路径并立即进入项目终态。默认停用,防止发布完成成为瞬时状态。
逐项目记录两条规则是否存在、是否启用、真实条件、顺序和证据;不存在也要有盘点证据。
## 7. 连锁触发验证
- 不按规则名称推断行为,逐条读取触发、条件、动作、顺序和执行账号。
- 每个目标状态都检查后继规则和重复规则。
- R11后至少观察两个时点刚进入发布完成时以及等待530秒后。
- 未经验收自动进入终态时,记录实际后继规则并判定`误触发`
- 跨两级以上流转必须有独立负向测试,不能只验证最终状态。

View File

@@ -0,0 +1,97 @@
# 云效自动化执行报告模板
```markdown
# 云效产品需求全生命周期自动化执行报告
## 1. 基本信息
- 组织:
- 项目:
- 工作项类型:
- 流程模型stage_tasks / oneos_delivery
- 迁移阶段仅oneos_deliveryP0 / P1 / P2 / P3
- 执行模式audit / plan / apply / test / document
- 执行时间:
- 执行账号:仅记录显示名,不记录凭据
## 2. 观察到的当前配置
| 项目 | 生命周期 | 标签 | 仓库/集成分支 | test流水线 | 发布证据 |
|---|---|---|---|---|---|
## 3. 规则变更
| 项目 | 规则 | 变更前 | 变更后 | 启用状态 | 复开核验 |
|---|---|---|---|---|---|
### 3.0 项目级规则实例
| 项目 | 规则ID | 云效规则显示名/人工门禁 | 精确触发事件 | 源状态 | 必要条件 | 动作/目标状态 | 顺序 | 启用 | 观察证据 |
|---|---|---|---|---|---|---|---:|---|---|
每个项目必须分别覆盖`R01``R12``TK01``TK08`。不得把一个项目的规则ID、显示名、顺序或证据复制为另一个项目的观察结果。
### 3.1 完整性台账
| 控制ID | 对象/流转 | 实现方式 | 启用状态 | 观察证据 | 负责人 | 回退方案 | 结论 |
|---|---|---|---|---|---|---|---|
每个项目必须分别覆盖全部31项`R01``R12``TK01``TK08``TC01``TC03``DF01``DF03``CI01``CI03``L01``L02`。不得把“规则编辑器不支持”写成“已完成”;应记录为`not_supported`并给出人工门禁或集成桥接方案。
`oneos_delivery`模型改用48项台账`Y01``Y16``Y20``Y22``Y30``Y35``Y40``A01``A02``A02B``A03``A10``TC01``TC03``DF01``DF03``CI01``CI03``L01``L02`
### 3.1a OneOS迁移就绪度
| 项目 | 目标状态缺口 | 主任务身份方式 | 原生规则能否过滤 | 任务工作流方案 | 桥接幂等/负责人 | 旧规则处置 | 结论 |
|---|---|---|---|---|---|---|---|
旧R/TK规则只有在目标状态、任务身份、任务工作流、桥接和回滚证据全部就绪后才能停用。`L01``L02`不受此等待条件影响,应保持停用以保护产品验收。
### 3.2 规则顺序与连锁触发
| 前置规则 | 目标状态 | 后继规则 | 是否自动二次流转 | 是否绕过验收 | 处理结论 |
|---|---|---|---|---|---|
## 4. 验证结果
| 用例 | 触发事件 | 期望状态 | 实际状态 | 结果分类 | 执行日志/证据 |
|---|---|---|---|---|---|
## 5. 研发资产覆盖
| 仓库 | dev/develop | 分支关联 | 提交关联 | MR关联 | 全部合并判断 |
|---|---|---|---|---|---|
## 6. 流水线与发布
| 流水线 | 环境 | 是否副本 | Docker部署 | 回调 | 是否影响prod |
|---|---|---|---|---|---|
## 7. 测试计划与缺陷闭环
| 测试计划 | 用例总数 | 已通过 | 未执行/待测试 | 阻塞 | 未通过 | 结论 |
|---|---:|---:|---:|---:|---:|---|
| 缺陷 | 关联用例/需求 | 修复状态 | 复测结果 | 最终状态 | 证据 |
|---|---|---|---|---|---|
| 失败用例 | 关联缺陷 | 缺陷已修复后用例状态 | 复测通过后用例状态 | 复测失败后用例状态 | 证据 |
|---|---|---|---|---|---|
## 8. 人工节点与剩余风险
- 真实开发开始时间:
- 业务验收:
- 发布完成是否被后继规则立即关闭:
- 全部关联任务范围:
- 多仓库漏关联风险:
- 其他:
- 范围外通用自动化通知、指派、SLA、字段同步等
## 9. 回滚
- 禁用新增规则:
- 恢复旧规则:
- 停用回调POC
- 测试资产清理(需授权):
```

View File

@@ -0,0 +1,499 @@
# 云效全流程角色与触发节点沟通话术
> 本文是异常处理和精细交接使用的详细版。日常操作优先读取`simple-role-commands.md`,使用“记录需求、开始分析、开始开发、提交测试、测试完成、发布成功、验收通过”等短口令。
## 目录
1. 使用规则
2. 通用话术骨架
3. 项目管理员与流程负责人
4. 产品负责人
5. 需求分析人员
6. 设计人员
7. 交付负责人
8. 技术负责人
9. 开发人员
10. 代码评审人员
11. 测试负责人和测试人员
12. 缺陷修复人员
13. 运维与发布负责人
14. 业务验收人员
15. 集成桥接负责人
16. 异常和催办话术
17. 节点交接清单
## 1. 使用规则
这套话术用于让不同角色把真实业务事件准确告诉Codex再由Codex审计、规划、执行获批修改、验证或形成报告。默认使用`yunxiao-requirement-lifecycle`
每次沟通至少提供:
- 精确项目名和需求编号。
- 当前角色、当前状态和刚刚发生的真实事件。
- 关联任务、仓库、分支、MR、测试计划、缺陷或流水线执行ID没有就明确写“无”。
- 希望Codex采用的权限`audit``plan``apply``test``document`
- 期望停在哪个状态,不要只说“继续流转”。
权限含义:
- `audit`:只核查和报告,不修改云效。
- `plan`:只输出修改方案和影响,不执行。
- `apply`:只修改话术中明确列出的项目、工作项或规则。
- `test`:使用隔离测试资产触发并收集证据。
- `document`:只形成说明、记录或报告。
必须遵守:
- 标题、需求编号、分支名或标签不能代替云效正式关联。
- 首次提交不代表真实开始开发;人工“开始处理”是效能主口径,分支关联只是自动兜底。
- `已修复`只表示交给测试复测,不是缺陷关闭。
- test部署成功不等于测试通过更不等于生产发布完成。
- 发布成功后停留`发布完成`,必须有产品或业务验收才能进入`已关闭`
- 未明确授权时,不改生产流水线、不直接推送保护分支、不删除规则或测试资产。
- 项目仍为`stage_tasks`时使用R/TK规则只有迁移门禁通过后才使用完整`oneos_delivery`链路。
## 2. 通用话术骨架
### 2.1 请求执行
```text
使用 yunxiao-requirement-lifecycle。
权限【audit/plan/apply/test/document】
项目:【精确项目名】
流程模型:【未知/stage_tasks/oneos_delivery】
我的角色:【角色】
需求:【需求编号+标题】
当前状态:【状态】
刚发生的事件:【真实事件】
相关资产:【任务/MR/分支/测试计划/缺陷/流水线执行ID没有写无】
期望结果:【目标状态或需要生成的资产】
请先核查当前状态和正式关联,再执行授权范围内动作;输出实际动作、触发结果、证据、未完成项和下一责任人。不要用手工改状态冒充自动化通过。
```
### 2.2 只核查为什么没有流转
```text
使用 yunxiao-requirement-lifecycle权限=audit。
项目:【项目名】,需求:【编号】,当前状态:【状态】。
我已执行:【事件】,相关对象:【对象编号或链接】;等待了【秒数】仍未流转。
请按事件、当前状态、标签位置、正式关联、规则启用、执行账号、执行日志、后继规则和异步窗口逐项诊断。不要直接替我改状态。
```
### 2.3 请求修改规则
```text
使用 yunxiao-requirement-lifecycle权限=apply。
只允许修改项目【项目名】中的规则【精确规则名/控制ID】目标是【目标】。
修改前备份触发、条件、动作、顺序、启用状态和执行账号;一次只改一条,保存后重新打开核对,并用隔离需求做正向和负向测试。禁止修改生产流水线及无关规则。
```
## 3. 项目管理员与流程负责人
### ADM01 项目首次审计或接管
触发:新项目接入、规则无人维护或准备复制到另一个项目。
```text
使用 yunxiao-requirement-lifecycle权限=audit。
请审计项目【项目名】先判断是stage_tasks、oneos_delivery还是迁移中。盘点工作流状态、标签、工作项类型、全部自动化规则、执行账号、前后端仓库、dev/develop、Testhub、缺陷状态、test/prod流水线、发布证据和验收门禁。
输出当前—目标差距、规则控制ID映射、风险、P0-P3实施顺序和不能自动化的人工门禁。不要修改线上配置。
```
完成证据:项目画像、规则清单、模型判断、缺口和迁移阶段齐全。
### ADM02 迁移到OneOS终态模型
触发:准备从阶段任务切换到交付主任务模型。
```text
使用 yunxiao-requirement-lifecycle权限=plan。
项目【项目名】准备迁移到oneos_delivery。请检查待测试、发布中、发布失败状态交付主任务识别方式开发/测试/发版任务工作流A03/A06/A08/A09幂等与负责人多仓库MR门禁、Testhub分包、生产证据和L01/L02状态。
只有十项迁移门禁全部通过才给出停用旧R/TK规则清单否则保留现网主链只列安全可做项和回滚方案。
```
完成证据:迁移门禁逐项结论,而不是笼统“可以迁移”。
### ADM03 规则变更后回归
触发:新建、修改、启停任何生命周期规则。
```text
使用 yunxiao-requirement-lifecycle权限=test。
项目【项目名】刚修改规则【规则名/控制ID】。请创建或复用隔离测试需求验证事件【事件】、源状态【状态】、目标状态【状态】并增加错误状态、无正式关联、部分MR、重复回调或后继连锁等负向场景。
结果只能记录为通过、异步通过、未触发、误触发或阻塞,并附执行日志和回滚入口。
```
### ADM04 定期治理
触发:季度检查、状态/标签重命名、新增仓库或人员离职。
```text
使用 yunxiao-requirement-lifecycle权限=audit。
请对项目【项目名】执行运行治理检查:规则执行账号和权限、失败日志、状态/标签重命名影响、新增仓库Webhook、多仓库MR覆盖、超过7天无活动分支、回调签名/幂等/重试、L01/L02以及生产和验收证据分离。
输出需要立即修复、计划修复和仅观察三类清单。
```
## 4. 产品负责人
### PO01 新建并确认需求
触发:需求范围、负责人、优先级和验收标准已明确。
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需要建立需求【标题】。范围:【范围】;不包含:【排除项】;负责人:【姓名】;优先级:【级别】;目标迭代:【迭代】;验收标准:【标准】。
请先查重再创建或完善产品类需求。若项目为oneos_delivery校验唯一【交付】主任务若仍为stage_tasks不要擅自创建主任务。完成后告诉我需求编号、正式关系、当前状态和下一责任人。
```
### PO02 批准进入分析或快轨待开发
触发:需求确认后决定正常分析,或低风险小改走快轨。
正常路径:
```text
项目【项目名】需求【编号】已确认,批准进入分析。使用 yunxiao-requirement-lifecycle权限=apply。请核查范围、负责人和验收标准齐全再按当前流程模型触发分析入口不要跳过缺失信息。
```
快轨路径:
```text
项目【项目名】需求【编号】批准快轨到待开发,批准人【姓名】,原因【原因】,验收标准【标准】。使用 yunxiao-requirement-lifecycle权限=apply。请确认快轨边存在、唯一交付主任务已同步且没有循环风险缺少任一条件就停止并报告。
```
### PO03 需求范围变更
触发:开发或测试中修改范围。
```text
使用 yunxiao-requirement-lifecycle权限=plan。
项目【项目名】需求【编号】当前【状态】,拟变更:【新增/删除内容】;原因:【原因】;受影响资产:【开发任务/MR/用例/缺陷/发布范围】。
请先做影响分析列出需要重开或新增的任务、MR、用例和验收项以及是否应回退需求状态。未经我确认不要直接改状态或删除已有证据。
```
### PO04 产品验收通过并关闭
触发:生产发布完成,业务验收通过。
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需求【编号】当前为发布完成。验收人【姓名】,验收时间【时间】,验收环境【生产】,验收结果【通过】,证据【链接/附件/记录】。
请核查生产发布证据与需求范围一致,并确认不存在未关闭验收项;满足后执行已关闭并回报审计证据。不得用流水线成功替代本次验收。
```
### PO05 验收不通过
```text
使用 yunxiao-requirement-lifecycle权限=plan。
项目【项目名】需求【编号】生产验收不通过。问题:【问题】;证据:【证据】;影响:【影响】。
请创建或关联可追踪缺陷/改进任务,建议应回到的真实状态和责任人;未经批准不要直接关闭需求或覆盖原发布记录。
```
## 5. 需求分析人员
### BA01 开始分析
```text
项目【项目名】需求【编号】由我开始分析,实际开始时间【时间】。使用 yunxiao-requirement-lifecycle权限=apply。请核查当前状态和我的关联任务将分析任务改为处理中并确认需求进入分析中如果项目为oneos_delivery则按主任务权威方向同步避免双向循环。
```
### BA02 分析完成
```text
项目【项目名】需求【编号】分析已完成。产物:【需求说明/流程/字段/边界链接】;未决项:【无或列表】;验收口径:【口径】。
使用 yunxiao-requirement-lifecycle权限=apply。请先检查分析任务非零且全部完成、产物可访问、未决项已处理再推进分析完成回报触发的是原生规则、桥接还是人工门禁。
```
## 6. 设计人员
### UX01 开始设计
```text
项目【项目名】需求【编号】开始设计,设计负责人【姓名】,原型地址【地址】,实际开始时间【时间】。使用 yunxiao-requirement-lifecycle权限=apply。请核查正式关联设计任务和当前状态再更新设计任务为处理中并确认需求进入设计中。
```
### UX02 设计完成并交接开发
```text
项目【项目名】需求【编号】设计完成。原型版本【版本】;设计稿【链接】;交互说明【链接】;响应式范围【范围】;已确认人【姓名】;遗留项【无或列表】。
使用 yunxiao-requirement-lifecycle权限=apply。请核查设计任务和交付物后完成设计阶段并检查是否只创建一组正确的开发任务。不要因为标题含“开发”就视为已正式关联。
```
完成证据:设计任务已完成、需求到设计完成;后继开发任务创建成功后才到待开发。
## 7. 交付负责人
### DL01 创建或核查唯一交付主任务
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需求【编号】需要建立【交付】主任务,负责人【姓名】。请先按正式关系和交付标签查重;不存在才创建,存在一条则复用,存在多条则停止并列出冲突。主任务必须正式关联需求,不能只靠标题前缀。
```
### DL02 阶段同步异常
```text
使用 yunxiao-requirement-lifecycle权限=audit。
项目【项目名】需求【编号】状态【需求状态】,交付主任务【任务编号】状态【任务状态】,两者不一致。
请判断权威方向、检查Y01-Y16、来源防重和执行日志说明应补偿哪个对象。不要同时双向写入造成循环。
```
## 8. 技术负责人
### TL01 拆分开发任务
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需求【编号】已到待开发。请按以下范围拆分开发任务:前端【范围/负责人/仓库】,后端【范围/负责人/仓库】,其他【范围】。每条任务必须正式关联需求和交付主任务、标签=开发、状态=待处理并记录目标dev/develop分支。查重后创建禁止重复任务。
```
### TL02 仅前端或仅后端需求
```text
项目【项目名】需求【编号】只涉及【前端/后端】,不涉及【另一端】。使用 yunxiao-requirement-lifecycle权限=apply。请只创建实际需要的开发任务并把不涉及的仓库明确记为不适用不要用缺少另一端MR阻塞也不要把未盘点仓库默认为已完成。
```
### TL03 开发完成门禁核查
```text
使用 yunxiao-requirement-lifecycle权限=audit。
项目【项目名】需求【编号】准备判定开发完成。相关仓库【列表】开发任务【列表】MR【列表及目标分支】。
请确认所有非取消开发任务完成、所有相关MR正式双关联并合并到真实dev/develop任一仓库未完成都保持开发中。输出缺口不要手工改成开发完成。
```
## 9. 开发人员
### DEV01 真实开始开发并创建分支
```text
使用 yunxiao-requirement-lifecycle权限=apply。
我开始处理项目【项目名】需求【编号】的开发任务【任务编号】真实开始时间【时间】仓库【仓库名】基线【dev/develop】拟建分支【feature/编号或fix/编号】。
请先确认基线真实存在且任务为待处理,再从基线创建或指导创建分支,并确保分支同时正式关联需求和开发任务。完成后核查需求=开发中、任务=处理中;不要用首次提交时间覆盖真实开始时间。
```
### DEV02 提交代码并创建MR
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需求【编号】开发任务【任务编号】仓库【仓库】分支【分支】目标分支【dev/develop】。改动摘要【摘要】本地验证【命令和结果】。
请先拉取并检查冲突再按仓库规范提交、推送并创建MRMR必须正式关联需求和开发任务。不要直接推保护分支不要因为提交成功就把开发标为完成。
```
### DEV03 多仓库部分完成
```text
项目【项目名】需求【编号】目前仅仓库【仓库A】的MR【编号】已合并仓库【仓库B】仍在开发。使用 yunxiao-requirement-lifecycle权限=audit。请确认需求和交付主任务仍保持开发中并检查是否存在“部分MR导致提前完成”的误触发规则。
```
## 10. 代码评审人员
### REV01 评审要求修改
```text
项目【项目名】MR【编号】评审不通过需要修改【问题列表】。使用 yunxiao-requirement-lifecycle权限=document。请把评审结论关联到需求【编号】和开发任务【编号】确认MR保持未合并、开发任务保持处理中、需求保持开发中。
```
### REV02 MR合并后核查
```text
使用 yunxiao-requirement-lifecycle权限=audit。
项目【项目名】MR【编号】已合并到【dev/develop】关联需求【编号】、开发任务【编号】。请等待异步窗口后核查任务状态再汇总同需求所有仓库和MR。只有全部相关任务和MR完成才允许需求进入开发完成并确认测试任务只创建一条。
```
## 11. 测试负责人和测试人员
### QA01 建立测试任务和用例范围
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需求【编号】开发已完成,目标迭代【迭代】,测试负责人【姓名】。请查重后创建或复用唯一【测试】任务,并将测试计划按需求编号建立独立用例包。范围:【功能/接口/权限/兼容/回归】;不适用:【列表】。正式关联需求、主任务和测试计划后进入待测试。
```
### QA02 test部署成功开始测试
```text
项目【项目名】需求【编号】的test部署成功流水线【名称】执行ID【ID】环境【test】部署时间【时间】。使用 yunxiao-requirement-lifecycle权限=apply。请校验执行ID、范围和重复回调再允许测试任务和需求进入测试中。明确记录本事件不能推进发布完成。
```
### QA03 用例失败并提交缺陷
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需求【编号】测试计划【计划】用例【用例ID】执行失败。环境【环境】前置条件【条件】步骤【步骤】实际结果【实际】期望结果【期望】证据【截图/日志】;严重程度【级别】。
请查重后创建或关联缺陷,并把缺陷与原用例、需求正式关联。保持用例未通过、测试任务和需求测试中;不要把创建缺陷当作测试完成。
```
### QA04 缺陷复测通过
```text
项目【项目名】缺陷【编号】已在环境【环境】复测原用例【用例ID】重新执行通过证据【证据】复测人【姓名】时间【时间】。
使用 yunxiao-requirement-lifecycle权限=apply。请将原用例更新为通过并把缺陷进入项目真实关闭态随后重新核查本需求是否仍有未执行、阻塞、失败用例或未关闭缺陷。
```
### QA05 缺陷复测失败
```text
项目【项目名】缺陷【编号】复测失败原用例【用例ID】仍未通过。实际结果【结果】证据【证据】。
使用 yunxiao-requirement-lifecycle权限=apply。请重开缺陷或转回项目真实处理中状态保持原用例未通过、需求测试中并通知原修复负责人。不要保留“已修复/已关闭”。
```
### QA06 测试验收完成
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】需求【编号】申请完成测试。测试计划【计划】;用例包【范围】;执行统计【通过/失败/阻塞/未执行】;缺陷清单【编号+状态】;测试负责人【姓名】。
请按TC01-TC03和DF01-DF03核查。只有全部范围用例已执行并通过、失败用例已复测、缺陷由测试关闭时才完成测试任务并推进需求测试完成否则列出阻塞项。
```
## 12. 缺陷修复人员
### BUG01 接收缺陷并开始修复
```text
使用 yunxiao-requirement-lifecycle权限=apply。
我接收项目【项目名】缺陷【编号】关联需求【编号】、原用例【ID】开始修复时间【时间】仓库【仓库】拟用分支【fix/缺陷编号】。
请核查缺陷、需求和用例的正式关联再把缺陷转入真实处理中状态分支和MR同时关联缺陷、需求及对应开发任务。
```
### BUG02 修复完成交给测试
```text
项目【项目名】缺陷【编号】已修复。分支【分支】MR【编号】合并目标【dev/develop】验证结果【结果】部署环境【test/尚未部署】。
使用 yunxiao-requirement-lifecycle权限=apply。请核查代码和部署证据后把缺陷改为已修复并交给测试复测。不要关闭缺陷不要把原失败用例改为通过。
```
## 13. 运维与发布负责人
### OPS01 test部署回报
```text
使用 yunxiao-requirement-lifecycle权限=audit。
项目【项目名】test流水线【名称】执行【成功/失败】执行ID【ID】提交/MR范围【范围】时间【时间】。
请核查本次执行关联的需求和幂等记录。成功只允许测试开始;失败保持原状态并记录原因。不要触发生产发布完成。
```
### OPS02 创建迭代发版任务
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】迭代【迭代名/ID】准备发版计划窗口【时间】负责人【姓名】本批需求【编号列表】生产流水线【名称】。
请查重后创建或复用唯一【发版】任务,正式关联迭代和本批需求,并生成发布范围与回滚项。需求未测试完成或范围不一致时停止,不进入发布中。
```
### OPS03 开始生产发布
```text
项目【项目名】发版任务【编号】批准开始生产发布。审批人【姓名】;窗口【时间】;生产流水线【名称】;本批需求【列表】;回滚方案【链接】。
使用 yunxiao-requirement-lifecycle权限=apply。请核查全部需求测试完成、范围一致和审批证据再进入发布中。只操作明确的生产发布不修改流水线定义。
```
### OPS04 生产发布成功
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】生产流水线【名称】执行成功执行ID【ID】环境【prod】完成时间【时间】发布范围【需求/MR/版本】,证据【链接】。
请验证签名、时间戳、执行ID幂等和范围推进发版任务及本批需求到发布完成并停留通知产品验收。禁止自动进入已关闭。
```
### OPS05 生产发布失败或重试
失败:
```text
项目【项目名】生产流水线【名称】执行失败执行ID【ID】失败阶段【阶段】原因【原因】影响需求【列表】回滚结果【结果】。
使用 yunxiao-requirement-lifecycle权限=apply。请记录失败证据并推进到发布失败不得伪造成功或关闭需求。
```
重试:
```text
项目【项目名】发版任务【编号】批准从发布失败重试批准人【姓名】新执行ID【ID】已修正原因【说明】。
使用 yunxiao-requirement-lifecycle权限=apply。请核查发布失败→发布中流转边、幂等键和范围后开始重试保留原失败记录。
```
## 14. 业务验收人员
### ACC01 验收通过
```text
我是业务验收人【姓名】。项目【项目名】需求【编号】已在生产环境按验收项【列表】验证通过,时间【时间】,证据【链接/附件】。
使用 yunxiao-requirement-lifecycle权限=apply。请核查身份、发布完成状态和证据范围后关闭需求并保留验收记录。
```
### ACC02 验收不通过
```text
我是业务验收人【姓名】。项目【项目名】需求【编号】生产验收不通过,失败项【列表】,实际结果【结果】,证据【证据】。
使用 yunxiao-requirement-lifecycle权限=plan。请建立可追踪问题清单分析应回到测试、开发还是重新发布并指定下一责任人不要关闭需求。
```
## 15. 集成桥接负责人
### INT01 回调未生效或重复
```text
使用 yunxiao-requirement-lifecycle权限=audit。
桥接控制【A03/A06/A08/A09/其他】项目【项目名】需求【编号】事件ID【ID】时间戳【时间】回调结果【未生效/重复/失败】,日志摘要【摘要】。
请核查签名、时间窗、防重放、幂等键、当前状态、权限、重试和审计日志。不要绕过校验直接补写状态;给出安全重试或人工回退方案。
```
### INT02 幂等补偿
```text
使用 yunxiao-requirement-lifecycle权限=apply。
项目【项目名】控制【控制ID】需要补偿原事件ID【ID】期望对象【任务/状态】,当前对象【实际】。已确认原操作未成功且不存在重复对象。
请再次查询幂等键和正式关系只补偿缺失动作完成后输出新旧事件关联、对象ID和重复检查证据。
```
## 16. 异常和催办话术
### 状态长时间不动
```text
项目【项目名】需求/任务【编号】在【状态】停留【时长】,最后真实事件【事件】,负责人【姓名】,关联资产【列表】。使用 yunxiao-requirement-lifecycle权限=audit。请判断是业务未完成、自动化未触发还是异步失败并给出下一责任人和最小恢复动作不要直接跳状态。
```
### 状态被提前推进
```text
项目【项目名】需求【编号】从【源状态】意外进入【目标状态】,时间【时间】。使用 yunxiao-requirement-lifecycle权限=audit。请检查后继规则、全部关联项口径、部分MR、未复测缺陷、test/prod混用和L01/L02定位真实触发规则并给出回滚建议。未经确认不要回退状态。
```
### 任务重复创建
```text
项目【项目名】需求【编号】出现重复【交付/开发/测试】任务:【任务列表】。使用 yunxiao-requirement-lifecycle权限=audit。请检查幂等键、正式关系和重复回调确定保留项及合并/取消方案。未经授权不要删除任务。
```
## 17. 节点交接清单
每次角色交接都让Codex确认以下内容
1. 项目、需求、任务和流程模型准确。
2. 源状态与目标状态明确。
3. 事件真实发生,且正式关联可见。
4. 当前责任人和下一责任人明确。
5. 交付物、代码、用例、缺陷或发布证据可访问。
6. 正向条件全部满足,负向阻塞项为零。
7. 自动化结果与人工准备动作分开记录。
8. 异步等待后重新核查状态和执行日志。
9. 没有误触发后继规则或重复创建。
10. 回滚入口、人工回退和未完成事项已记录。
Codex的节点回报统一使用
```text
项目:
流程模型:
工作项:
源状态 → 实际状态:
本次真实事件:
执行动作:
触发方式:原生规则/桥接/人工门禁/未触发
验证结果:通过/异步通过/未触发/误触发/阻塞
证据:
未完成项:
下一责任角色:
允许的下一动作:
回滚入口:
```

View File

@@ -0,0 +1,417 @@
# 云效日常口语化操作口令
## 1. 先说一次项目
每次新会话先输入:
```text
项目统一运营管理平台PC端
```
之后直接说口令即可。项目没有明确、同一编号可能属于多个项目或用户切换项目时Codex必须先问清项目不能猜。
口令可以自然表达,不要求标点完全一致。例如:
- `记录需求:增加车辆批量导出`
- `帮我记录一个需求,增加车辆批量导出`
两句话含义相同。
## 2. 产品和需求
### 记录需求
产品输入:
```text
记录需求:增加车辆批量导出
```
Codex执行查重后在当前项目创建产品类需求初始状态为`待处理`,返回需求编号。只有标题也可以先记录;云效必填字段缺失时只追问缺少的字段。
**带模块/原型的记录OneOS** 若口令含模块名或原型(如「记录需求:保险采购」),创建前**先调用 `$oneos-autoprd`**,把「需求说明」写入描述;若用户同时定稿,一并写入「更新内容」。
**统一运营管理平台 · 快路径(强制):** 项目为「统一运营管理平台 / PC 端」时,先读 [oneos-pc-fast-path.md](oneos-pc-fast-path.md) 与 [oneos-pc-runtime-ids.json](../assets/oneos-pc-runtime-ids.json)。用已验证 `POST /workitem/workitem` 建单(创建时带 `document`),不要探测 `/workitem/create`。推进至「开发中」时状态连跳:`待处理 → 设计完成 → 待开发 → 开发中`
**优先级 / 推进至 Plan 单选门禁(强制):** 用户要求 Plan 模式单选,或口令含「优先级」「推进至」需点选时:
1. 先用 `AskQuestion`(可用时)或 Plan 确认拿到 A紧急/高/中/低)与 B分析中/设计中/设计完成/开发中)。
2. **禁止**未选时默认「中 + 分析中」并继续建单。
3. 「批准 / Implement 计划」本身不等于已选 A+B计划里仍是空选项时必须先问清。
4. 标签须用户从云效标签 catalog`oneos-pc-tag-catalog.md`)点选;禁止按业务条线说明/`lines.ts` 自动推断。点选后 API 打标,失败则停并请用户回复标签名,禁止假装成功。
**完整可复制提示词(推荐):** 统一运营管理平台建单优先用 [oneos-pc-record-requirement-prompt.md](oneos-pc-record-requirement-prompt.md)——含 `$yunxiao-requirement-lifecycle` + `$oneos-autoprd`、A/B/C 三门禁与 30 项标签枚举。复制该文件「用户口令」整段发给 Agent确认后再执行快路径。
简短版(填需求名与模块后复制):
```text
使用 $yunxiao-requirement-lifecycle + $oneos-autoprd
记录需求到云效 · 统一运营管理平台
【需求名】(填写)
【描述来源】$oneos-autoprd原型/模块:(填写)
请先 Plan 让我点选 A 优先级、B 推进至、C 标签30 项 catalog见 oneos-pc-record-requirement-prompt.md禁止 lines.ts 推断)。
我确认后再执行快路径建单。
```
确认回复示例:`优先级:高;推进至:设计完成;标签:运维管理条线`
### 确认需求
产品输入:
```text
确认需求ONEOSP-123
```
Codex执行检查范围、负责人、优先级和验收标准齐全后把需求改为`已确认`
`确认需求`默认不创建分析任务。确认和真实开始分析是两个事件,防止开始时间失真。由分析人员下一步输入`开始分析`
### 需求定稿(原型 + AutoPRD
产品输入:
```text
保险采购需求定稿
```
或:
```text
需求定稿ONEOSP-123模块=保险采购
```
Codex执行
1. 调用 `$oneos-autoprd` 定稿流程:更新原型 `.spec/requirements-prd.md` 第 10 章「功能变更记录」与 `autoprd-baseline.json`(只记功能/逻辑,不记样式/UI/表结构)。
2. 若已有云效需求编号:刷新描述中的「需求说明」与「更新内容」;旧更新内容 →「更新内容·历史」。
3. 若口令要求进待开发/发对象存储:继续走下方「快速进入开发」或 OneOS 快轨口令,不得跳过 AutoPRD。
### 快速进入开发
产品输入:
```text
快速开发ONEOSP-123原因=小改动;负责人=张三;范围=只改后端
```
Codex执行检查项目是否允许快轨、验收标准是否明确再跳过不需要的分析/设计阶段并建立必要开发任务。条件不齐时不跳状态,只提示缺少什么。
**OneOS 模块已定型进待开发(推荐):**
```text
「保险采购」需求已经确定:先 AutoPRD 写需求说明与更新内容;发布对象存储并写入描述;
推进到待开发;自动建与需求同名的【交付】任务并正式关联;负责人何斐。
```
执行前必须先跑 `$oneos-autoprd`(含定稿变更记录若用户刚定稿)。进入待开发时**必须**执行 [auto-stage-task.md](auto-stage-task.md)(同名任务、标签交付、负责人何斐、时间沿用需求);已有唯一交付任务则复用并改负责人为何斐,禁止建第二条。
### 需求变更
产品输入:
```text
变更需求ONEOSP-123增加导出时间筛选
```
Codex执行先分析受影响的任务、MR、用例、缺陷和发布范围给出应回到哪个阶段产品确认后再修改不直接覆盖历史说明。涉及原型功能变更时同步 `$oneos-autoprd` 更新 PRD用户再说「定稿」时写入第 10 章。
## 3. 分析和设计
### 开始分析
分析人员输入:
```text
开始分析ONEOSP-123负责人=李四
```
Codex执行
1. 将需求推进到`分析中`(或确认已在分析中)。
2. **按 [auto-stage-task.md](auto-stage-task.md)**:查重后创建/复用与需求**同名**任务;标签=`分析`;正式关联需求;负责人默认=需求**创建人**(口令写了负责人则用口令);计划开始/创建时间沿用需求。
3. 任务进入`处理中`(或项目约定的开工态)。
4. `stage_tasks`:需求标签含`分析``oneos_delivery`:不因此再建第二条【交付】主任务。
### 分析完成
分析人员输入:
```text
分析完成ONEOSP-123说明=https://...
```
Codex执行检查分析产物和未决项完成对应任务或主任务阶段核查需求进入`分析完成`
### 开始设计
设计人员输入:
```text
开始设计ONEOSP-123负责人=王五
```
Codex执行
1. 将需求推进到`设计中`
2. **按 auto-stage-task**:同名任务;标签=`设计`;正式关联;负责人默认=需求**创建人**(口令可覆盖);时间沿用需求。
3. 任务开工态同上。
4. `oneos_delivery`:不同时新建交付主任务(交付主任务在待开发时建/复用)。
### 设计完成
设计人员输入:
```text
设计完成ONEOSP-123原型=https://...
```
Codex执行检查原型或设计说明完成设计任务/主任务阶段,核查需求进入`设计完成`
如果项目已经部署并验证自动建开发任务的桥接而且负责人、仓库和范围明确Codex继续查重并创建开发任务否则停在`设计完成`,提示技术负责人输入`安排开发`。不得创建无负责人或重复任务。
**进入待开发时(含快轨):** 必须再跑 auto-stage-task同名任务、标签`交付`(或`开发`)、负责人**何斐**、正式关联、时间沿用需求。
## 4. 开发
### 安排开发
技术负责人输入:
```text
安排开发ONEOSP-123前端=张三/ln-one-os-web后端=李四/ln-cloud
```
只改一端时可以说:
```text
安排开发ONEOSP-123只改后端=李四/ln-cloud
```
Codex执行按实际范围创建一条或多条`【开发】`任务,正式关联需求;`oneos_delivery`还要关联交付主任务。任务初始为`待处理`,需求进入`待开发`。明确“不涉及”的仓库不会成为完成门禁。
### 开始开发
开发人员输入:
```text
开始开发ONEOSP-123任务=TASK-456仓库=ln-cloud
```
Codex执行记录真实开始时间确认真实基线`dev``develop`,创建`feature/ONEOSP-123`并同时正式关联需求和开发任务。任务进入`处理中`,需求进入`开发中`
首次提交只作兜底,不覆盖这次真实开始时间。
### 提交代码
开发人员输入:
```text
提交代码ONEOSP-123任务=TASK-456仓库=ln-cloud
```
Codex执行检查改动和测试先拉取并处理冲突再提交、推送并创建合并到真实`dev/develop`的MRMR同时关联需求和开发任务。提交成功不等于开发完成。
### 开发完成
开发人员或技术负责人输入:
```text
开发完成ONEOSP-123
```
Codex执行汇总该需求所有非取消开发任务、前后端仓库和正式关联MR。只有全部任务完成、全部相关MR已合并才确认需求进入`开发完成`;少一个仓库也不提前完成。
条件满足后,如果项目已配置并验证唯一测试任务桥接,则查重创建测试任务;否则提示测试负责人输入`提交测试`
## 5. 测试和缺陷
### 提交测试
测试负责人或开发负责人输入:
```text
提交测试ONEOSP-123test流水线=oneos-web-test
```
Codex执行确认开发完成触发或核查明确的test流水线。部署成功后查重创建并正式关联唯一测试任务和需求用例包进入`待测试/测试中`的项目真实状态。
test成功只允许开始测试不能变成发布完成。
### 开始测试
测试人员输入:
```text
开始测试ONEOSP-123负责人=赵六
```
Codex执行核查test部署和测试任务把测试任务改为`测试中/处理中`,需求进入`测试中`
### 发现Bug
测试人员输入:
```text
发现BugONEOSP-123用例=CASE-12现象=车辆来源显示0期望=显示中文来源
```
Codex执行查重后创建缺陷正式关联需求、原失败用例和必要开发任务用例保持未通过需求保持`测试中`
### Bug修复完成
开发人员输入:
```text
Bug修复完成BUG-123MR=456已部署test
```
Codex执行核查代码、MR和test部署后把缺陷改为`已修复`并交给测试复测。不会关闭缺陷,也不会把原用例直接改成通过。
### 复测通过
测试人员输入:
```text
复测通过BUG-123用例=CASE-12
```
Codex执行重新记录原用例为通过把缺陷改为项目真实关闭态再检查该需求是否还有失败、阻塞、未执行用例或未关闭缺陷。
### 复测失败
测试人员输入:
```text
复测失败BUG-123用例=CASE-12现象=仍显示0
```
Codex执行原用例保持未通过缺陷重开或回到处理中并通知修复负责人需求保持`测试中`
### 测试完成
测试负责人输入:
```text
测试完成ONEOSP-123
```
Codex执行检查本需求用例包不按整个迭代一刀切。只有没有未执行、阻塞、失败用例且所有缺陷都经过测试复测关闭才完成测试任务并推进需求到`测试完成`
## 6. 发布和验收
### 准备发布
运维或项目负责人输入:
```text
准备发布迭代V1.3;需求=ONEOSP-123,ONEOSP-124窗口=今晚22:00
```
Codex执行检查本批需求均测试完成查重创建或复用唯一`【发版】`任务,正式关联迭代和需求,生成发布范围及回滚清单。
### 开始发布
运维输入:
```text
开始发布:发版任务=TASK-900prod流水线=oneos-prod审批人=王经理
```
Codex执行核查审批、本批范围、生产流水线和回滚方案后进入`发布中`。这句话授权执行明确的本次生产发布,但不授权修改生产流水线定义。
### 发布成功
运维输入:
```text
发布成功执行ID=PIPE-789发版任务=TASK-900
```
Codex执行到生产流水线核查环境、执行ID、范围和成功证据确认后推进本批需求到`发布完成`并停留,等待验收。只说“成功”但没有生产证据时不改状态。
### 发布失败
运维输入:
```text
发布失败执行ID=PIPE-789原因=镜像拉取失败
```
Codex执行核查失败证据记录原因并进入项目真实`发布失败`状态;项目尚无该状态时记录阻塞并给出人工处置,不创建近义重复状态。
### 验收通过
产品或业务输入:
```text
验收通过ONEOSP-123验收人=王经理;证据=https://...
```
Codex执行核查生产发布完成和验收证据后把需求改为`已关闭`。发布成功不能代替本句验收。
### 验收不通过
产品或业务输入:
```text
验收不通过ONEOSP-123问题=导出字段缺失
```
Codex执行创建或关联可追踪问题判断应回到开发、测试还是重新发布先给出处理建议不关闭需求不覆盖原发布记录。
## 7. 随时可用的查询口令
```text
查状态ONEOSP-123
下一步ONEOSP-123
为什么没流转ONEOSP-123
查重复任务ONEOSP-123
查关联代码ONEOSP-123
查测试闭环ONEOSP-123
给我回滚方案:刚才那条规则
```
这些口令默认只查询或出方案,不修改云效。
## 8. Codex的简短回报格式
处理成功:
```text
已处理ONEOSP-123
状态:已确认 → 分析中
任务:已创建并关联分析任务 TASK-456
下一步分析人员输入“分析完成ONEOSP-123说明=链接”
```
条件不足:
```text
暂未处理ONEOSP-123
缺少:开发负责人
请补充安排开发ONEOSP-123只改后端=负责人/仓库
```
自动化未触发:
```text
未触发ONEOSP-123 仍为待开发
原因:分支只写了需求编号,没有在云效正式关联开发任务
下一步:我可以补正式关联,然后再核查一次
```
## 9. Codex执行口令的规则
1. `记录、确认、开始、完成、提交、修复、复测、准备发布、发布、验收`属于当前项目和明确工作项的执行授权。
2. `查、看看、为什么、下一步、给方案`只允许读取或规划。
3. 写操作前必须确认当前项目;项目不清楚只问项目名。
4. 其他必要字段缺失时只追问最少信息,不发送长表单。
5. 先查重、再创建任务幂等键使用项目ID、需求ID和阶段类型。
6. 项目使用`stage_tasks`还是`oneos_delivery`由Codex识别同事不需要记规则编号。
7. 当前平台或桥接不支持的动作要明确说“需要人工一步”,不能伪装成已自动化。
8. 自动规则执行后等待530秒再回看状态、正式关联和执行日志。
9. 不用手工改最终状态冒充自动化通过。
10. 不修改生产流水线定义不删除规则、分支、MR、任务或测试资产除非用户单独明确授权。

View File

@@ -0,0 +1,87 @@
# 阶段任务生命周期与自动创建
## 目录
1. 目标链路
2. TK01TK08规则
3. 关系与代码资产
4. 原生规则和桥接边界
5. 验证与回滚
## 1. 目标链路
```text
需求设计中 + 设计任务处理中
→ 设计任务已完成
→ 需求设计完成
→ 创建并关联开发任务
→ 需求待开发 + 开发任务待处理
→ 正式关联需求分支
→ 需求开发中 + 开发任务处理中
→ 全部相关MR合并到真实dev/develop
→ 需求开发完成 + 开发任务已完成
→ test流水线部署成功
→ 创建并关联测试任务
→ 需求测试中 + 测试任务处理中
→ 用例全部通过且缺陷复测关闭
→ 需求测试完成 + 测试任务已完成
```
分支只能合并到集成分支不能“合并到测试环境”。test环境由流水线部署部署成功用于允许测试开始不用于证明生产发布。
## 2. TK01TK08规则
| ID | 触发 | 必要条件 | 动作 | 推荐实现 |
|---|---|---|---|---|
| TK01 | 需求进入设计中 | 已正式关联设计任务;任务标签=`设计` | 设计任务保持待处理,负责人真实开工后人工改为处理中 | 默认+人工 |
| TK02 | 设计任务变为已完成 | 需求当前状态=设计中;本阶段设计任务非零且全部完成 | 需求改为设计完成 | 原生规则 |
| TK03 | 需求变为设计完成 | 不存在同需求、同阶段、未取消的开发任务 | 创建并正式关联开发任务,标签=`开发`,状态=待处理 | API/Webhook桥接 |
| TK04 | 开发任务创建并关联成功 | 需求当前状态=设计完成;开发任务关系可见 | 需求改为待开发 | 原生规则 |
| TK05 | 正式关联需求分支/代码资产 | 需求=待开发;开发任务=待处理;资产同时关联需求和开发任务 | 需求改为开发中;开发任务改为处理中 | 原生规则 |
| TK06 | 关联合并请求状态变化 | 需求=开发中;开发任务=处理中全部相关前后端MR已合并到真实集成分支 | 需求改为开发完成;开发任务改为已完成 | 原生规则 |
| TK07 | test流水线部署成功 | 需求=开发完成执行ID未处理不存在同需求、同阶段、未取消的测试任务 | 创建并关联测试任务,关联测试计划/用例,任务改为处理中,需求改为测试中 | API/Webhook桥接 |
| TK08 | 测试验收完成 | 用例全部通过;失败用例已复测;范围内缺陷均由测试关闭 | 测试任务改为已完成;需求改为测试完成 | 桥接/人工门禁 |
## 3. 关系与代码资产
- 设计、开发、测试任务必须使用云效正式父子项或关联项关系,不依赖标题关键字。
- 开发分支和MR必须同时关联产品需求与对应开发任务只关联需求不能可靠更新开发任务。
- 一个需求涉及多个仓库时全部相关前端、后端MR都纳入TK06门禁。
- 首次提交可能晚于真实开工。任务`处理中`的权威时间是负责人执行“开始处理”;分支关联仅作自动兜底。
## 4. 原生规则和桥接边界
优先使用云效原生规则处理状态变化、父子项、关联项和Codeup对象。若当前规则编辑器没有“创建工作项”动作TK03和TK07必须标记为`integration_bridge``not_supported`,不得声称已经自动创建。
桥接创建任务时使用幂等键:
```text
项目ID + 需求ID + 阶段类型
```
重复回调先查询是否存在未取消的同阶段任务存在则复用并校验正式关系不重复创建。流水线回调还要校验签名、时间戳、执行ID、防重放和失败重试。
## 5. 验证与回滚
- 正向验证每个TK控制并检查需求和任务两个对象的状态。
- 负向验证零任务、错误标签、错误当前状态、未正式关联分支、只合并部分MR、流水线失败和重复回调。
- 新规则不会补触发历史事件。历史任务仅在核对MR/流水线证据后人工修正,并标记为测试准备或数据修复。
- 回滚时先禁用新增规则或桥接入口,不删除已有任务和关系;恢复前一状态需要单独业务确认。
## 6. 进阶段自动建同名任务(与 OneOS / 口令共用)
完整规则见 [auto-stage-task.md](auto-stage-task.md)。摘要:
| 需求状态 | 同名任务标签 | 负责人 |
|---|---|---|
| 分析中 | `分析` | 需求创建人 |
| 设计中 | `设计` | 需求创建人 |
| 待开发 | `交付``开发` | 何斐 |
必须正式关联需求;计划开始/创建时间沿用需求;同阶段幂等查重。
当创建或修复**分析 / 设计 / 交付(开发)**任务时:
1. **任务标签(右侧基础字段)**:分析 → `分析`;设计 → `设计`;待开发主任务 → `交付`(或 `开发`。Title 同名或前缀 alone 不够。
2. **正式关联需求**:每条阶段任务必须链接到**产品需求**(父子或关联项)。只关联其他任务不算完成。
3. 创建/修复后先核对标签与可见需求关系,再报告成功。

View File

@@ -0,0 +1,62 @@
# 测试用例与缺陷复测闭环
## 目录
1. 测试完成门槛
2. TC01TC03用例控制
3. DF01DF03缺陷控制
4. 实现方式
5. 验证场景
## 1. 测试完成门槛
R10只有同时满足以下条件才能完成
1. 范围内用例均已执行并有明确结果。
2. 不存在`未执行/待测试``阻塞``未通过`用例;获批暂缓/不适用项记录批准人与原因。
3. 每个未通过用例均正式创建或关联缺陷。
4. 修复后重新执行原用例并通过。
5. 范围内缺陷均由测试复测并进入项目真实关闭态。
6. 测试计划或测试验收任务由测试负责人确认。
流水线成功只能证明部署/自动检查成功,不能替代用例结果和缺陷复测。
## 2. TC01TC03用例控制
| ID | 控制 | 完成口径 |
|---|---|---|
| TC01 | 用例执行 | 每条范围内用例有真实执行结果;未执行、待测试、阻塞、未通过均阻塞测试完成 |
| TC02 | 失败用例关联缺陷 | 每条未通过用例正式创建或关联缺陷;仅写编号或链接不算关联 |
| TC03 | 用例复测更新 | 修复后重新执行原用例;通过才改为已通过,失败保持未通过 |
## 3. DF01DF03缺陷控制
| ID | 控制 | 完成口径 |
|---|---|---|
| DF01 | 已修复交接 | `已修复`只表示开发交给测试复测,不是终态,不解除测试门禁 |
| DF02 | 复测通过 | 缺陷进入项目真实关闭态,原失败用例同时更新为已通过 |
| DF03 | 复测失败 | 缺陷进入项目真实重开/处理中状态,原用例保持未通过 |
不得关闭缺陷却不更新原用例,也不得在缺陷仅为`已修复`时把用例标为通过。
## 4. 实现方式
先检查当前规则编辑器是否真的暴露Testhub执行用例和缺陷聚合条件
- 支持时:使用原生聚合规则,并保存条件和执行日志证据。
- 不支持时:使用测试验收任务/人工门禁。
- 需要全自动时单独评审Testhub OpenAPI/Webhook桥接要求签名、幂等、失败重试和审计记录。
不能把`not_supported`写成“已自动化”;必须指定负责人和回退方案。
## 5. 验证场景
| 场景 | 操作 | 期望 | 负向检查 |
|---|---|---|---|
| 用例未执行 | 保留一条未执行用例 | 保持测试中 | 不得完成测试 |
| 用例失败 | 记录未通过并正式关联缺陷 | 保持测试中 | 仅建缺陷不得完成 |
| 缺陷已修复 | 开发改为已修复 | 等待测试复测 | 不得关闭或通过用例 |
| 复测通过 | 重跑原用例并通过 | 用例通过、缺陷关闭 | 两者状态必须一致 |
| 复测失败 | 重跑仍失败 | 用例未通过、缺陷重开 | 不得保持已修复/关闭 |
| 全部闭环 | 全部用例通过且缺陷关闭 | 允许R10推进 | 检查是否误触发后继规则 |

View File

@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""Validate and render a OneOS delivery-model Yunxiao project profile."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
TARGET_LIFECYCLE = [
"待处理", "已确认", "分析中", "分析完成", "设计中", "设计完成",
"待开发", "开发中", "开发完成", "待测试", "测试中", "测试完成",
"发布中", "发布完成", "发布失败", "已关闭",
]
REQUIRED_TASK_LABELS = {"交付", "开发", "测试", "发版"}
REQUIRED_CONTROL_IDS = {
*(f"Y{index:02d}" for index in range(1, 17)),
"Y20", "Y21", "Y22",
*(f"Y{index:02d}" for index in range(30, 36)),
"Y40",
"A01", "A02", "A02B", *(f"A{index:02d}" for index in range(3, 11)),
*(f"TC{index:02d}" for index in range(1, 4)),
*(f"DF{index:02d}" for index in range(1, 4)),
*(f"CI{index:02d}" for index in range(1, 4)),
"L01", "L02",
}
VALID_MODES = {
"manual", "native_rule", "integration_bridge", "disabled_legacy", "not_supported"
}
VALID_PHASES = {"P0", "P1", "P2", "P3"}
VALID_TASK_WORKFLOW_MODES = {"A", "B"}
VALID_TASK_IDENTITY_MODES = {"work_item_type", "task_label", "title_prefix_process_control"}
VALID_LEGACY_DISPOSITIONS = {"keep_until_ready", "disable", "replace", "already_disabled"}
SENSITIVE_FRAGMENTS = (
"password", "passwd", "secret", "token", "cookie", "credential",
"private_key", "otp", "密码", "密钥",
)
def is_text(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def require_text(container: dict[str, Any], key: str, label: str, errors: list[str]) -> None:
if not is_text(container.get(key)):
errors.append(f"{label}.{key} 不能为空")
def walk_sensitive_keys(value: Any, prefix: str = "") -> list[str]:
found: list[str] = []
if isinstance(value, dict):
for key, nested in value.items():
current = f"{prefix}.{key}" if prefix else str(key)
if any(fragment in str(key).lower() for fragment in SENSITIVE_FRAGMENTS):
found.append(current)
found.extend(walk_sensitive_keys(nested, current))
elif isinstance(value, list):
for index, nested in enumerate(value):
found.extend(walk_sensitive_keys(nested, f"{prefix}[{index}]"))
return found
def validate_controls(controls: Any, label: str, errors: list[str]) -> None:
if not isinstance(controls, list):
errors.append(f"{label} 必须是数组")
return
seen: set[str] = set()
for index, control in enumerate(controls):
item_label = f"{label}[{index}]"
if not isinstance(control, dict):
errors.append(f"{item_label} 必须是对象")
continue
control_id = control.get("id")
if not is_text(control_id):
errors.append(f"{item_label}.id 不能为空")
continue
if control_id in seen:
errors.append(f"{label} 控制项重复:{control_id}")
seen.add(control_id)
if control_id not in REQUIRED_CONTROL_IDS:
errors.append(f"{label} 未知控制项:{control_id}")
mode = control.get("mode")
if mode not in VALID_MODES:
errors.append(f"{item_label}.mode 不合法")
if not isinstance(control.get("enabled"), bool):
errors.append(f"{item_label}.enabled 必须是布尔值")
if mode in {"disabled_legacy", "not_supported"} and control.get("enabled") is not False:
errors.append(f"{item_label}{mode} 模式要求 enabled=false")
if control_id in {"L01", "L02"}:
if mode != "disabled_legacy" or control.get("enabled") is not False:
errors.append(f"{item_label} 必须停用,防止发布完成绕过验收")
for key in ("owner", "fallback", "evidence"):
require_text(control, key, item_label, errors)
missing = sorted(REQUIRED_CONTROL_IDS - seen)
if missing:
errors.append(f"{label} 缺少控制项:" + "".join(missing))
def validate_project(project: Any, index: int, errors: list[str]) -> None:
label = f"projects[{index}]"
if not isinstance(project, dict):
errors.append(f"{label} 必须是对象")
return
require_text(project, "name", label, errors)
if project.get("migration_phase") not in VALID_PHASES:
errors.append(f"{label}.migration_phase 必须是 P0/P1/P2/P3")
statuses = project.get("requirement_statuses_observed")
if not isinstance(statuses, list) or not statuses or not all(is_text(v) for v in statuses):
errors.append(f"{label}.requirement_statuses_observed 必须是非空文本数组")
if project.get("task_workflow_mode") not in VALID_TASK_WORKFLOW_MODES:
errors.append(f"{label}.task_workflow_mode 必须是 A 或 B")
if project.get("task_identity_mode") not in VALID_TASK_IDENTITY_MODES:
errors.append(f"{label}.task_identity_mode 不合法")
if not isinstance(project.get("native_rule_can_filter_related_task_identity"), bool):
errors.append(f"{label}.native_rule_can_filter_related_task_identity 必须是布尔值")
for key in (
"main_task_prefix", "development_task_prefix", "test_task_prefix", "release_task_prefix"
):
require_text(project, key, label, errors)
repositories = project.get("repositories")
if not isinstance(repositories, list) or not repositories:
errors.append(f"{label}.repositories 至少包含一个仓库")
else:
for repo_index, repo in enumerate(repositories):
repo_label = f"{label}.repositories[{repo_index}]"
if not isinstance(repo, dict):
errors.append(f"{repo_label} 必须是对象")
continue
for key in ("name", "role", "integration_branch", "evidence"):
require_text(repo, key, repo_label, errors)
if repo.get("integration_branch") not in {"dev", "develop"}:
errors.append(f"{repo_label}.integration_branch 只能是实际存在的 dev/develop")
for key in ("codeup_integrated", "required_for_mr_gate"):
if not isinstance(repo.get(key), bool):
errors.append(f"{repo_label}.{key} 必须是布尔值")
test_plan = project.get("test_plan")
if not isinstance(test_plan, dict):
errors.append(f"{label}.test_plan 必须是对象")
else:
require_text(test_plan, "name", f"{label}.test_plan", errors)
require_text(test_plan, "evidence", f"{label}.test_plan", errors)
for key, required in (
("iteration_formally_related", True),
("cases_partitioned_by_requirement", True),
("fixed_defect_is_terminal", False),
("tester_retest_required", True),
):
if test_plan.get(key) is not required:
errors.append(f"{label}.test_plan.{key} 必须为 {str(required).lower()}")
release = project.get("release")
if not isinstance(release, dict):
errors.append(f"{label}.release 必须是对象")
else:
require_text(release, "evidence", f"{label}.release", errors)
for key in (
"release_task_formally_related_to_iteration",
"production_evidence_separate_from_test",
"acceptance_required_after_release",
"production_pipeline_unchanged",
):
if release.get(key) is not True:
errors.append(f"{label}.release.{key} 必须为 true")
legacy_rules = project.get("legacy_rules")
if not isinstance(legacy_rules, list):
errors.append(f"{label}.legacy_rules 必须是数组")
else:
for legacy_index, rule in enumerate(legacy_rules):
item_label = f"{label}.legacy_rules[{legacy_index}]"
if not isinstance(rule, dict):
errors.append(f"{item_label} 必须是对象")
continue
for key in ("name", "reason", "evidence"):
require_text(rule, key, item_label, errors)
if rule.get("disposition") not in VALID_LEGACY_DISPOSITIONS:
errors.append(f"{item_label}.disposition 不合法")
if not isinstance(rule.get("enabled"), bool):
errors.append(f"{item_label}.enabled 必须是布尔值")
validate_controls(project.get("controls"), f"{label}.controls", errors)
def validate(data: dict[str, Any]) -> list[str]:
errors: list[str] = []
if data.get("schema_version") != 1:
errors.append("schema_version 必须为 1")
if data.get("flow_model") != "oneos_delivery":
errors.append("flow_model 必须为 oneos_delivery")
for key in ("organization", "work_item_type"):
require_text(data, key, "profile", errors)
if data.get("target_lifecycle") != TARGET_LIFECYCLE:
errors.append("target_lifecycle 必须与OneOS终态16状态顺序一致")
labels = data.get("task_labels")
if not isinstance(labels, list) or not REQUIRED_TASK_LABELS.issubset(set(labels)):
errors.append("task_labels 必须包含:交付、开发、测试、发版")
policies = data.get("policies")
if not isinstance(policies, dict):
errors.append("policies 必须是对象")
else:
true_keys = (
"require_formal_relations", "require_current_state_condition",
"require_nonzero_development_tasks",
"require_signed_timestamped_replay_protected_callbacks",
)
false_keys = ("allow_automatic_final_close_without_acceptance", "test_success_is_production_release")
for key in true_keys:
if policies.get(key) is not True:
errors.append(f"policies.{key} 必须为 true")
for key in false_keys:
if policies.get(key) is not False:
errors.append(f"policies.{key} 必须为 false")
parts = policies.get("bridge_idempotency_key_components")
if not isinstance(parts, list) or not parts or not all(is_text(v) for v in parts):
errors.append("policies.bridge_idempotency_key_components 不能为空")
projects = data.get("projects")
if not isinstance(projects, list) or not projects:
errors.append("projects 至少包含一个项目")
else:
for index, project in enumerate(projects):
validate_project(project, index, errors)
sensitive = walk_sensitive_keys(data)
if sensitive:
errors.append("配置中禁止出现敏感字段:" + "".join(sensitive))
if "请填写" in json.dumps(data, ensure_ascii=False):
errors.append("仍存在“请填写”占位符")
return errors
def render(data: dict[str, Any]) -> str:
lines = [
"# OneOS云效终态交付模型执行计划", "",
f"- 组织:{data['organization']}",
f"- 工作项类型:{data['work_item_type']}",
f"- 项目数:{len(data['projects'])}",
"- 完整性口径每项目48项Y/A/测试/缺陷/集成/旧规则控制。",
"- 安全边界:不修改生产流水线;发布完成后保留产品验收门禁。", "",
]
for project in data["projects"]:
missing_statuses = [s for s in TARGET_LIFECYCLE if s not in project["requirement_statuses_observed"]]
lines.extend([
f"## 项目:{project['name']}", "",
f"- 迁移阶段:{project['migration_phase']}",
f"- 任务工作流方案:{project['task_workflow_mode']}",
f"- 任务身份方式:{project['task_identity_mode']}",
f"- 原生规则可过滤关联任务身份:{'' if project['native_rule_can_filter_related_task_identity'] else ''}",
f"- 缺少目标状态:{''.join(missing_statuses) if missing_statuses else ''}", "",
"### 旧规则处置", "",
"| 规则 | 处置 | 启用 | 原因 | 证据 |", "|---|---|---|---|---|",
])
for rule in project["legacy_rules"]:
lines.append(
f"| {rule['name']} | {rule['disposition']} | {'' if rule['enabled'] else ''} | "
f"{rule['reason']} | {rule['evidence']} |"
)
lines.extend([
"", "### 48项控制台账", "",
"| ID | 方式 | 启用 | 负责人 | 回退方案 | 证据 |", "|---|---|---|---|---|---|",
])
for control in sorted(project["controls"], key=lambda item: item["id"]):
lines.append(
f"| {control['id']} | {control['mode']} | {'' if control['enabled'] else ''} | "
f"{control['owner']} | {control['fallback']} | {control['evidence']} |"
)
lines.extend([
"", "### 迁移门禁", "",
"- [ ] 目标状态与流转边已补齐。",
"- [ ] 主任务可可靠识别或已明确交桥接。",
"- [ ] 多仓库需求与开发任务均正式关联分支/MR。",
"- [ ] Testhub按需求分包且缺陷复测闭环。",
"- [ ] test、生产发布和产品验收证据分离。",
"- [ ] L01、L02已停用旧规则已有备份和回滚。", "",
])
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
validate_parser = subparsers.add_parser("validate")
validate_parser.add_argument("profile", type=Path)
render_parser = subparsers.add_parser("render")
render_parser.add_argument("profile", type=Path)
render_parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
try:
with args.profile.open("r", encoding="utf-8-sig") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("配置根节点必须是JSON对象")
errors = validate(data)
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"配置读取失败:{error}", file=sys.stderr)
return 2
if errors:
for error in errors:
print(f"[错误] {error}", file=sys.stderr)
return 1
if args.command == "validate":
print(f"[通过] OneOS配置有效{len(data['projects'])}个项目每项目48项控制。")
return 0
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(render(data), encoding="utf-8")
print(f"[完成] 已生成执行计划:{args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,536 @@
#!/usr/bin/env python3
"""Validate a project-scoped Yunxiao lifecycle profile and render an execution plan."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
REQUIRED_LABELS = {"分析", "设计", "开发", "测试"}
REQUIRED_RULE_IDS = {f"R{index:02d}" for index in range(1, 13)}
REQUIRED_TASK_RULE_IDS = {f"TK{index:02d}" for index in range(1, 9)}
REQUIRED_CONTROL_IDS = {
*REQUIRED_RULE_IDS,
*REQUIRED_TASK_RULE_IDS,
*(f"TC{index:02d}" for index in range(1, 4)),
*(f"DF{index:02d}" for index in range(1, 4)),
*(f"CI{index:02d}" for index in range(1, 4)),
"L01",
"L02",
}
VALID_MODES = {
"manual",
"native_rule",
"integration_bridge",
"disabled_legacy",
"not_supported",
}
VALID_RELEASE_EVIDENCE_MODES = {
"native_release_change",
"pipeline_callback",
"manual_release_confirmation",
}
SENSITIVE_FRAGMENTS = (
"password",
"passwd",
"secret",
"token",
"cookie",
"credential",
"private_key",
"otp",
"密码",
"密钥",
)
def load_profile(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8-sig") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("配置根节点必须是 JSON 对象")
return data
def is_text(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def require_text(container: dict[str, Any], key: str, label: str, errors: list[str]) -> None:
if not is_text(container.get(key)):
errors.append(f"{label}.{key} 不能为空")
def walk_sensitive_keys(value: Any, prefix: str = "") -> list[str]:
found: list[str] = []
if isinstance(value, dict):
for key, nested in value.items():
current = f"{prefix}.{key}" if prefix else str(key)
lower = str(key).lower()
if any(fragment in lower for fragment in SENSITIVE_FRAGMENTS):
found.append(current)
found.extend(walk_sensitive_keys(nested, current))
elif isinstance(value, list):
for index, nested in enumerate(value):
found.extend(walk_sensitive_keys(nested, f"{prefix}[{index}]"))
return found
def validate_control_list(
controls: Any, label: str, errors: list[str]
) -> None:
if not isinstance(controls, list):
errors.append(f"{label} 必须是数组")
return
seen: set[str] = set()
for index, control in enumerate(controls):
item_label = f"{label}[{index}]"
if not isinstance(control, dict):
errors.append(f"{item_label} 必须是对象")
continue
control_id = control.get("id")
if not is_text(control_id):
errors.append(f"{item_label}.id 不能为空")
continue
if control_id in seen:
errors.append(f"{label} 控制项重复:{control_id}")
seen.add(control_id)
if control_id not in REQUIRED_CONTROL_IDS:
errors.append(f"{label} 未知控制项:{control_id}")
mode = control.get("mode")
if mode not in VALID_MODES:
errors.append(f"{item_label}.mode 不合法")
if not isinstance(control.get("enabled"), bool):
errors.append(f"{item_label}.enabled 必须是布尔值")
for key in ("evidence", "owner", "fallback"):
require_text(control, key, item_label, errors)
if mode in {"disabled_legacy", "not_supported"} and control.get("enabled") is not False:
errors.append(f"{item_label}{mode} 模式要求 enabled=false")
if control_id in {"L01", "L02"} and mode not in {
"disabled_legacy",
"native_rule",
"manual",
}:
errors.append(f"{item_label} 必须明确为停用旧规则、获批原生规则或人工处置")
missing = sorted(REQUIRED_CONTROL_IDS - seen)
if missing:
errors.append(f"{label} 缺少控制项:" + "".join(missing))
def validate_rule_instances(
rules: Any, lifecycle: list[str], label: str, errors: list[str]
) -> None:
if not isinstance(rules, list):
errors.append(f"{label} 必须是数组")
return
seen: set[str] = set()
orders: set[int] = set()
expected = {
f"R{index + 1:02d}": (lifecycle[index], lifecycle[index + 1])
for index in range(12)
}
for index, rule in enumerate(rules):
item_label = f"{label}[{index}]"
if not isinstance(rule, dict):
errors.append(f"{item_label} 必须是对象")
continue
rule_id = rule.get("id")
if not is_text(rule_id):
errors.append(f"{item_label}.id 不能为空")
continue
if rule_id in seen:
errors.append(f"{label} 规则实例重复:{rule_id}")
seen.add(rule_id)
if rule_id not in REQUIRED_RULE_IDS:
errors.append(f"{label} 未知规则实例:{rule_id}")
mode = rule.get("mode")
if mode not in {"manual", "native_rule", "integration_bridge", "not_supported"}:
errors.append(f"{item_label}.mode 不合法")
for key in ("actual_rule_name", "trigger", "source_status", "target_status", "action", "evidence"):
require_text(rule, key, item_label, errors)
conditions = rule.get("conditions")
if not isinstance(conditions, list) or not conditions or not all(is_text(v) for v in conditions):
errors.append(f"{item_label}.conditions 必须是非空文本数组")
order = rule.get("order")
if not isinstance(order, int) or order < 1:
errors.append(f"{item_label}.order 必须是正整数")
elif order in orders:
errors.append(f"{label} 规则顺序重复:{order}")
else:
orders.add(order)
if not isinstance(rule.get("enabled"), bool):
errors.append(f"{item_label}.enabled 必须是布尔值")
if rule.get("has_current_state_condition") is not True:
errors.append(f"{item_label}.has_current_state_condition 必须为 true")
if rule_id in expected:
expected_source, expected_target = expected[rule_id]
if rule.get("source_status") != expected_source:
errors.append(f"{item_label}.source_status 应为 {expected_source}")
if rule.get("target_status") != expected_target:
errors.append(f"{item_label}.target_status 应为 {expected_target}")
missing = sorted(REQUIRED_RULE_IDS - seen)
if missing:
errors.append(f"{label} 缺少规则实例:" + "".join(missing))
def validate_task_rule_instances(rules: Any, label: str, errors: list[str]) -> None:
if not isinstance(rules, list):
errors.append(f"{label} 必须是数组")
return
seen: set[str] = set()
orders: set[int] = set()
for index, rule in enumerate(rules):
item_label = f"{label}[{index}]"
if not isinstance(rule, dict):
errors.append(f"{item_label} 必须是对象")
continue
rule_id = rule.get("id")
if not is_text(rule_id):
errors.append(f"{item_label}.id 不能为空")
continue
if rule_id in seen:
errors.append(f"{label} 规则实例重复:{rule_id}")
seen.add(rule_id)
if rule_id not in REQUIRED_TASK_RULE_IDS:
errors.append(f"{label} 未知任务规则实例:{rule_id}")
if rule.get("mode") not in {"manual", "native_rule", "integration_bridge", "not_supported"}:
errors.append(f"{item_label}.mode 不合法")
for key in ("actual_rule_name", "trigger", "scope", "source_state", "evidence"):
require_text(rule, key, item_label, errors)
for key in ("conditions", "actions"):
values = rule.get(key)
if not isinstance(values, list) or not values or not all(is_text(value) for value in values):
errors.append(f"{item_label}.{key} 必须是非空文本数组")
order = rule.get("order")
if not isinstance(order, int) or order < 1:
errors.append(f"{item_label}.order 必须是正整数")
elif order in orders:
errors.append(f"{label} 规则顺序重复:{order}")
else:
orders.add(order)
if not isinstance(rule.get("enabled"), bool):
errors.append(f"{item_label}.enabled 必须是布尔值")
missing = sorted(REQUIRED_TASK_RULE_IDS - seen)
if missing:
errors.append(f"{label} 缺少任务规则实例:" + "".join(missing))
def validate_project(
project: Any, index: int, lifecycle: list[str], errors: list[str]
) -> None:
label = f"projects[{index}]"
if not isinstance(project, dict):
errors.append(f"{label} 必须是对象")
return
for key in ("name", "work_item_type", "release_status", "final_status"):
require_text(project, key, label, errors)
if project.get("release_status") != lifecycle[11]:
errors.append(f"{label}.release_status 必须与 lifecycle[11] 一致")
if project.get("final_status") != lifecycle[12]:
errors.append(f"{label}.final_status 必须与 lifecycle[12] 一致")
labels = project.get("labels_observed")
if not isinstance(labels, list) or not REQUIRED_LABELS.issubset(set(labels)):
errors.append(f"{label}.labels_observed 必须包含:分析、设计、开发、测试")
repositories = project.get("repositories")
if not isinstance(repositories, list) or not repositories:
errors.append(f"{label}.repositories 至少包含一个仓库")
else:
for repo_index, repo in enumerate(repositories):
repo_label = f"{label}.repositories[{repo_index}]"
if not isinstance(repo, dict):
errors.append(f"{repo_label} 必须是对象")
continue
for key in ("name", "role", "integration_branch", "evidence"):
require_text(repo, key, repo_label, errors)
if repo.get("integration_branch") not in {"dev", "develop"}:
errors.append(f"{repo_label}.integration_branch 只能是实际存在的 dev/develop")
for key in ("codeup_integrated", "required_for_mr_gate"):
if not isinstance(repo.get(key), bool):
errors.append(f"{repo_label}.{key} 必须是布尔值")
pipeline = project.get("test_pipeline")
if not isinstance(pipeline, dict):
errors.append(f"{label}.test_pipeline 必须是对象")
else:
for key in ("name", "environment", "evidence"):
require_text(pipeline, key, f"{label}.test_pipeline", errors)
for key in (
"is_clone_for_callback_poc",
"original_pipeline_unchanged",
"docker_success_callback_enabled",
):
if not isinstance(pipeline.get(key), bool):
errors.append(f"{label}.test_pipeline.{key} 必须是布尔值")
security = pipeline.get("callback_security")
if not isinstance(security, dict):
errors.append(f"{label}.test_pipeline.callback_security 必须是对象")
else:
security_keys = (
"signed",
"timestamp_checked",
"replay_protected",
"idempotent_by_execution_id",
"failure_retry_tested",
)
for key in security_keys:
if not isinstance(security.get(key), bool):
errors.append(f"{label}.test_pipeline.callback_security.{key} 必须是布尔值")
if pipeline.get("docker_success_callback_enabled") is True:
if pipeline.get("is_clone_for_callback_poc") is not True:
errors.append(f"{label} 启用回调时必须使用test流水线副本")
if pipeline.get("original_pipeline_unchanged") is not True:
errors.append(f"{label} 启用回调时必须保持原流水线不变")
if any(security.get(key) is not True for key in security_keys):
errors.append(f"{label} 启用回调时必须完成全部安全与重试验证")
release = project.get("release_evidence")
if not isinstance(release, dict):
errors.append(f"{label}.release_evidence 必须是对象")
else:
if release.get("mode") not in VALID_RELEASE_EVIDENCE_MODES:
errors.append(f"{label}.release_evidence.mode 不合法")
for key in ("target_environment", "evidence"):
require_text(release, key, f"{label}.release_evidence", errors)
if release.get("test_success_is_production_release") is not False:
errors.append(f"{label}.release_evidence.test_success_is_production_release 必须为 false")
test_plan = project.get("test_plan")
if not isinstance(test_plan, dict):
errors.append(f"{label}.test_plan 必须是对象")
else:
for key in ("name", "evidence"):
require_text(test_plan, key, f"{label}.test_plan", errors)
if not isinstance(test_plan.get("requirement_formally_related"), bool):
errors.append(f"{label}.test_plan.requirement_formally_related 必须是布尔值")
states = test_plan.get("case_states_observed")
if not isinstance(states, list) or not states or not all(is_text(v) for v in states):
errors.append(f"{label}.test_plan.case_states_observed 必须是非空文本数组")
defect = project.get("defect_workflow")
if not isinstance(defect, dict):
errors.append(f"{label}.defect_workflow 必须是对象")
else:
for key in ("fixed_status", "closed_status", "reopen_status", "evidence"):
require_text(defect, key, f"{label}.defect_workflow", errors)
if defect.get("fixed_is_terminal") is not False:
errors.append(f"{label}.defect_workflow.fixed_is_terminal 必须为 false")
if defect.get("tester_retest_required") is not True:
errors.append(f"{label}.defect_workflow.tester_retest_required 必须为 true")
validate_rule_instances(project.get("rule_instances"), lifecycle, f"{label}.rule_instances", errors)
validate_task_rule_instances(
project.get("task_rule_instances"), f"{label}.task_rule_instances", errors
)
validate_control_list(project.get("control_coverage"), f"{label}.control_coverage", errors)
if not isinstance(project.get("adjunct_automations"), list):
errors.append(f"{label}.adjunct_automations 必须是数组,可为空")
def validate(data: dict[str, Any]) -> list[str]:
errors: list[str] = []
if data.get("schema_version") != 3:
errors.append("schema_version 必须为 3旧版台账不包含阶段任务闭环")
for key in ("organization", "work_item_type"):
require_text(data, key, "profile", errors)
lifecycle = data.get("lifecycle")
if not isinstance(lifecycle, list) or len(lifecycle) != 13 or not all(is_text(v) for v in lifecycle):
errors.append("lifecycle 必须包含 13 个非空有序状态")
lifecycle = [str(index) for index in range(13)]
elif len(set(lifecycle)) != len(lifecycle):
errors.append("lifecycle 状态名称不能重复")
labels = data.get("requirement_labels")
if not isinstance(labels, list) or not REQUIRED_LABELS.issubset(set(labels)):
errors.append("requirement_labels 必须包含:分析、设计、开发、测试")
policy = data.get("automation_policy")
if not isinstance(policy, dict):
errors.append("automation_policy 必须是对象")
else:
for key in ("require_current_state_condition", "inspect_rule_order_and_cascades"):
if policy.get(key) is not True:
errors.append(f"automation_policy.{key} 必须为 true")
for key in ("allow_automatic_final_close_without_acceptance", "zero_related_items_are_complete"):
if policy.get(key) is not False:
errors.append(f"automation_policy.{key} 必须为 false")
events = policy.get("development_start_event_candidates")
if not isinstance(events, list) or not events or not all(is_text(v) for v in events):
errors.append("automation_policy.development_start_event_candidates 不能为空")
if policy.get("require_code_assets_linked_to_requirement_and_development_task") is not True:
errors.append(
"automation_policy.require_code_assets_linked_to_requirement_and_development_task 必须为 true"
)
idempotency = policy.get("stage_task_creation_idempotency_key_components")
if not isinstance(idempotency, list) or not idempotency or not all(is_text(v) for v in idempotency):
errors.append("automation_policy.stage_task_creation_idempotency_key_components 不能为空")
test_policy = data.get("test_closure_policy")
if not isinstance(test_policy, dict):
errors.append("test_closure_policy 必须是对象")
else:
if test_policy.get("defect_fixed_is_terminal") is not False:
errors.append("test_closure_policy.defect_fixed_is_terminal 必须为 false")
if test_policy.get("require_tester_retest") is not True:
errors.append("test_closure_policy.require_tester_retest 必须为 true")
for key in ("accepted_case_states", "rejected_case_states"):
values = test_policy.get(key)
if not isinstance(values, list) or not values or not all(is_text(v) for v in values):
errors.append(f"test_closure_policy.{key} 不能为空")
projects = data.get("projects")
if not isinstance(projects, list) or not projects:
errors.append("projects 至少包含一个项目")
else:
names: set[str] = set()
for index, project in enumerate(projects):
validate_project(project, index, lifecycle, errors)
if isinstance(project, dict) and is_text(project.get("name")):
name = project["name"]
if name in names:
errors.append(f"项目名称重复:{name}")
names.add(name)
sensitive = walk_sensitive_keys(data)
if sensitive:
errors.append("配置中禁止出现敏感字段:" + "".join(sensitive))
if "请填写" in json.dumps(data, ensure_ascii=False):
errors.append("仍存在“请填写”占位符")
return errors
def render(data: dict[str, Any]) -> str:
lines = [
"# 云效需求生命周期自动化执行计划",
"",
f"- 组织:{data['organization']}",
f"- 工作项类型:{data['work_item_type']}",
f"- 项目数:{len(data['projects'])}",
"- 完整性口径每项目12个需求规则实例、8个任务规则实例、31个闭环控制项。",
"- 安全边界:不修改现有生产流水线;不记录任何凭据或密钥。",
"",
]
for project in data["projects"]:
lines.extend([
f"## 项目:{project['name']}",
"",
f"- 发布状态:{project['release_status']}",
f"- 最终状态:{project['final_status']}",
f"- test流水线{project['test_pipeline']['name']}",
f"- 发布证据方式:{project['release_evidence']['mode']}",
"",
"### 仓库覆盖",
"",
"| 仓库 | 角色 | 集成分支 | Codeup已集成 | 纳入全部MR门禁 | 证据 |",
"|---|---|---|---|---|---|",
])
for repo in project["repositories"]:
lines.append(
f"| {repo['name']} | {repo['role']} | {repo['integration_branch']} | "
f"{'' if repo['codeup_integrated'] else ''} | "
f"{'' if repo['required_for_mr_gate'] else ''} | {repo['evidence']} |"
)
lines.extend([
"",
"### R01R12实际规则实例",
"",
"| ID | 实现 | 实际规则/门禁 | 触发 | 流转 | 条件 | 顺序 | 启用 | 证据 |",
"|---|---|---|---|---|---|---:|---|---|",
])
for rule in sorted(project["rule_instances"], key=lambda item: item["order"]):
conditions = "".join(rule["conditions"])
lines.append(
f"| {rule['id']} | {rule['mode']} | {rule['actual_rule_name']} | {rule['trigger']} | "
f"{rule['source_status']}{rule['target_status']} | {conditions} | {rule['order']} | "
f"{'' if rule['enabled'] else ''} | {rule['evidence']} |"
)
lines.extend([
"",
"### TK01TK08任务规则实例",
"",
"| ID | 实现 | 实际规则/门禁 | 触发 | 作用域 | 源状态 | 条件 | 动作 | 顺序 | 启用 | 证据 |",
"|---|---|---|---|---|---|---|---|---:|---|---|",
])
for rule in sorted(project["task_rule_instances"], key=lambda item: item["order"]):
conditions = "".join(rule["conditions"])
actions = "".join(rule["actions"])
lines.append(
f"| {rule['id']} | {rule['mode']} | {rule['actual_rule_name']} | {rule['trigger']} | "
f"{rule['scope']} | {rule['source_state']} | {conditions} | {actions} | {rule['order']} | "
f"{'' if rule['enabled'] else ''} | {rule['evidence']} |"
)
lines.extend([
"",
"### 31项完整性台账",
"",
"| 控制ID | 实现方式 | 启用 | 观察证据 | 负责人 | 回退方案 |",
"|---|---|---|---|---|---|",
])
for control in sorted(project["control_coverage"], key=lambda item: item["id"]):
lines.append(
f"| {control['id']} | {control['mode']} | {'' if control['enabled'] else ''} | "
f"{control['evidence']} | {control['owner']} | {control['fallback']} |"
)
adjunct = project["adjunct_automations"]
lines.extend([
"",
"### 范围外通用自动化盘点",
"",
"- " + ("".join(map(str, adjunct)) if adjunct else "未纳入如需通知、自动指派、SLA、字段同步等应单独授权和设计。"),
"",
])
lines.extend([
"## 执行检查",
"",
"- [ ] 两个或多个项目的规则实例、控制台账和证据已分别填写。",
"- [ ] 已逐条读取真实触发配置、规则顺序、启用状态和执行账号。",
"- [ ] 已检查后继规则、重复规则和跨阶段连锁流转。",
"- [ ] 已验证失败用例、缺陷已修复、复测通过/失败时双方状态一致。",
"- [ ] 已逐仓库确认真实dev/develop分支、正式关联和全部MR门禁。",
"- [ ] 已区分test部署、生产发布、发布变更和业务验收。",
"- [ ] 已执行正向、负向、异步、多仓库、失败重试和旧规则连锁测试。",
"",
])
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
validate_parser = subparsers.add_parser("validate", help="验证项目配置")
validate_parser.add_argument("profile", type=Path)
render_parser = subparsers.add_parser("render", help="生成 Markdown 执行计划")
render_parser.add_argument("profile", type=Path)
render_parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
try:
data = load_profile(args.profile)
errors = validate(data)
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"配置读取失败:{error}", file=sys.stderr)
return 2
if errors:
for error in errors:
print(f"[错误] {error}", file=sys.stderr)
return 1
if args.command == "validate":
print(
f"[通过] 配置有效:{len(data['projects'])} 个项目;"
"每项目12个需求规则实例、8个任务规则实例、31个闭环控制项。"
)
return 0
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(render(data), encoding="utf-8")
print(f"[完成] 已生成执行计划:{args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -25,6 +25,7 @@
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw``canvas-assets/` | 原型画布和画布素材 |
| 业务逻辑规格 | `src/prototypes/<prototype-name>/.spec/<topic>.md` | `rules/business-logic-documentation-guide.md` |
| AutoPRD + 标注目录 | `.spec/requirements-prd.md` + `annotation-source.json`;定稿写第 10 章功能变更 | skill `oneos-autoprd`;规则 `.cursor/rules/oneos-autoprd-sync.mdc` |
| 对象存储发布 URL | `.axhub/make/axhub.config.json``cloudPublishing.s3` | `rules/cloud-publish-url-guide.md` |
| 原型标注布局 | `src/common/prototype-annotation-host.tsx` | `rules/prototype-annotation-layout-guide.md` |

View File

@@ -0,0 +1,65 @@
# 车辆资产 H5 Implementation Plan
> **For agentic workers:** Execute task-by-task. Checkboxes track progress. Prefer inline execution in this repo (React prototype, no separate unit-test harness).
**Goal:** 新建独立 H5 原型 `oneos-h5-vehicle-assets`:只读查车 + KPI/异常筛选 + 详情多 Tab口径对齐 PC `vehicle-management`
**Architecture:** PhoneShell 手机壳 + 列表/详情双视图;数据与 KPI 规则复用 PC `vehicles.json``utils/vehicle.ts`(及各记录 utilsUI 不复用 `vm-page` 宽表 DOM。
**Tech Stack:** React + TypeScript、本地 JSON 种子、Axhub annotation、`nav:sync` 注册导航。
---
## File map
| Path | Responsibility |
|------|----------------|
| `src/prototypes/oneos-h5-vehicle-assets/index.tsx` | 入口:列表/详情状态、筛选、分页 |
| `styles/index.css` | H5 样式ONE-OS 绿) |
| `components/PhoneShell.tsx` | 手机预览壳 |
| `components/VehicleList.tsx` | 搜索、KPI、筛选抽屉、卡片 |
| `components/VehicleCard.tsx` | 单车卡片 |
| `components/FilterSheet.tsx` | 证照/保险状态抽屉 |
| `components/VehicleDetail.tsx` | 详情顶栏 + Tab |
| `components/detailTabs.tsx` | 11 个只读 Tab 内容 |
| `utils/filter.ts` | 列表过滤封装 |
| `.spec/requirements-prd.md` | AutoPRD |
| `annotation-source.json` | 标注目录 |
| `nav-menu.json` + `nav:sync` | 导航注册 |
---
### Task 1: Scaffold + list shell
- [ ] Create prototype files with PhoneShell, list layout, import vehicles
- [ ] Wire KPI via `matchKpi` / `countKpi` / `isLicenseExpired`
- [ ] Search + license/insurance filter + cards + page size 20
### Task 2: Detail multi-tab readonly
- [ ] Detail view with 11 tabs matching PC `DETAIL_TABS`
- [ ] Basic/model/license as field grids; record tabs via PC utils filtered by plate
- [ ] No edit/action buttons
### Task 3: Nav + PRD + annotation
- [ ] Add nav item under 车辆资产 folder as「车辆资产H5
- [ ] Full AutoPRD + annotation-source PRD node
- [ ] Run `npm run nav:sync -- --prototype oneos-h5-vehicle-assets`
### Task 4: Smoke check
- [ ] Open `/prototypes/oneos-h5-vehicle-assets` and verify list → detail → tabs
---
## Spec coverage
| Spec item | Task |
|-----------|------|
| Independent H5 prototype | 1 |
| Search + KPI + anomaly filter + cards | 1 |
| Detail 11 tabs readonly | 2 |
| No edit including admin | 12 |
| Nav + PRD | 3 |
| KPI/anomaly口径 = PC | 1 (shared utils) |

View File

@@ -0,0 +1,189 @@
# 车辆资产 H5 适配 · 设计规格
| 项 | 说明 |
|---|---|
| 日期 | 2026-07-20 |
| 状态 | 已确认2026-07-20 |
| 原型 ID | `oneos-h5-vehicle-assets` |
| 关联 PC | `vehicle-management`(车辆资产) |
| 参考 H5 | `oneos-h5-h2-order`PhoneShell、交互密度 |
---
## 1. 背景与目标
App 除推送外全部接入 H5。PC「车辆资产」为宽表中后台不适合同一页响应式塞进手机。采用**独立 H5 原型**,口径与 PC 对齐,交互按手机重做。
### 成功标准
1. App / 手机浏览器打开 H5 地址,不进入 PC 宽表页。
2. KPI 分类与证照/保险异常口径与 PC 一致。
3. 详情多 Tab 可浏览,**全程无编辑入口**(含 Admin
4. 按车牌搜索可定位车辆。
---
## 2. 已确认产品决策
| 决策 | 结论 |
|---|---|
| 落地方式 | 独立原型 `oneos-h5-vehicle-assets`(方案 1 |
| 角色 | 全角色同一套界面 |
| 编辑 | **手机端不做编辑**(含 Admin改数仅 PC |
| 异常 | 只看:可筛、详情标红/提示;不催办、不跳转业务处理页 |
| 列表 | 搜索 + 横滑 KPI + 筛选抽屉 + 车辆卡片(对齐 PC 信息量) |
| 详情 | 多 Tab 对齐 PC只读 |
---
## 3. 范围
### 3.1 第一期做
| 模块 | 内容 |
|---|---|
| 列表 | 顶栏标题「车辆资产」+ 车牌搜索KPI 横滑;证照/保险等筛选;卡片列表;**触底异步加载更多**(无翻页、不展示总辆数) |
| 详情 | 返回 + 车牌(透明顶栏贴一体背景);浮起白卡摘要(营运状态、标签、里程、证照/保险异常);横向 Tab + 只读内容;**无**底部悬浮 Tab |
| 数据 | 复用 PC `vehicles.json` 及关联记录 JSONKPI/异常规则与 PC 同源工具函数(可抽共享或复制后标注同源) |
| 壳层 | PhoneShell 桌面预览;真机/WebView 全屏内容区 |
| 导航 | 注册到原型导航「运维管理」下,与 PC 车辆资产并列区分如「车辆资产H5 |
### 3.2 第一期不做
- 任意字段编辑、运维负责人修改、运营城市修改
- 批量导入 / 导出真实能力(不做入口,或仅隐藏)
- 催办、抄送、跳转证照管理/保险采购等业务页(不做;外链 Toast 占位也不做,避免误导)
- 在租车型占比弹窗PC 尾部 KPIH5 第一期可不做,避免干扰主路径)
- PC 页响应式改造
---
## 4. 信息架构
```text
列表 List
├── 顶栏:标题 + 搜索(车牌)
├── KPI 横滑条(点选切换分类,计数与 PC 一致)
├── 筛选入口 → 底部抽屉(证照状态、保险状态;可扩展运营状态等)
└── 车辆卡片列表 → 进入详情
详情 Detail
├── 顶栏:返回 + 车牌号(透明,贴一体渐变底;无底栏)
├── 摘要卡:车牌 / 品牌型号 / 营运状态 / 来源·城市·项目标签 / 里程 / 证照·保险异常
└── Tab横滑与 PC DETAIL_TABS 一致
基本信息 / 型号参数 / 证照信息 / 保险记录 / 租赁记录 /
事故记录 / 故障记录 / 违章记录 / 异动记录 / 调拨记录 / 年审记录
```
---
## 5. KPI 与异常口径(与 PC 一致)
| KPI | 口径 |
|---|---|
| 所有营运车辆 | 非「非运营车辆」且非「退出运营」 |
| 租赁车辆 | `operateStatus === '租赁'` |
| 物流车辆 | `operateStatus === '物流'` |
| 库存车辆 | `operateStatus` 为「可运营」或「待运营」 |
| 非运营车辆 | `vehicleLedgerType === '非运营车辆'` |
| 退出运营车辆 | `operateStatus === '退出运营'` |
| 异常筛选 | 口径 |
|---|---|
| 证照异常 | `inspectExpire` 早于今天 |
| 保险异常 | `insuranceStatus === '异常'` |
筛选与 KPI **叠加**:先 KPI再筛选条件。
---
## 6. 列表卡片字段(建议)
Boutique 横卡布局:左侧信息、右侧状态区(无车辆照片)。至少展示:
- 车牌(主标题)
- 品牌·型号
- 营运状态标签(语义色)
- 证照/保险异常角标(有异常时显示)
- 车辆来源 / 运营城市 / 项目或「车辆在库」标签
- 里程(有数据时,右侧次要数字)
点击整卡进入详情。
### 6.1 详情摘要卡(与列表同语义)
浮起白卡(同列表卡片圆角/阴影),**不加**车辆实景大图:
- 车牌(大标题)+ 品牌·型号
- 营运状态(圆点 + 与列表一致的文案)
- 车辆来源 / 运营城市 / 项目或备车等上下文标签
- 里程(有数据时)
- 证照/保险异常角标(有异常时)
Tab 选中态对齐 Chip主色字 + 浅底),内容区为白卡 Section / 记录列表;字段 label 12 / value 15。
---
## 7. 视觉与设计基底
| 项 | 约定 |
|---|---|
| 交互壳 | 对齐 `oneos-h5-h2-order` PhoneShell 结构(桌面预览);真机全屏内容区 |
| 主题 | **SaaS Boutique App 壳**:对齐参考图 A顶栏左标题 + 铃铛/加号、KPI 图标卡、筛选项行、实景横卡、底部五 Tab |
| 色彩 | Primary `#007AFF`;页面灰 `#F2F3F5`;卡片白底;语义绿/橙/红 |
| 风格 | 一体式浅蓝渐变底列表与详情共用顶栏透明不分色带KPI 图标卡 + Chip列表/详情浮起白卡(无车辆照片) |
| 字体 | **苹方 PingFang SC**(回退 Hiragino Sans GB / 微软雅黑 / system |
| 字号阶梯 | 标题 22 / 详情车牌 26 / KPI 数 20 / 列表车牌 17 / 正文 15 / 车型与 Chip 12 / 标签与底栏 11 |
| 字重 | 标题/车牌 800系统会映射为 Semibold/Medium区块标题 700正文 400500标签 600 |
| 壳层约定 | **列表**底部为悬浮胶囊 Tab毛玻璃「工作台/任务/消息/我的」Toast 占位;本页「车辆」选中。**详情**不挂底栏,仅顶栏返回 + 车牌 |
| 触控 | ≥ 44×44pxChip/图标区可视觉更紧,点击热区保留);`safe-area-inset` |
| 动效 | ≤ 240ms尊重 `prefers-reduced-motion` |
---
## 8. 技术约定(实现阶段)
| 项 | 约定 |
|---|---|
| 目录 | `src/prototypes/oneos-h5-vehicle-assets/` |
| 入口 | `index.tsx`;样式独立 `styles/index.css` |
| 标注 | `annotation-source.json` + `.spec/requirements-prd.md`(实现时按 oneos-autoprd 同步) |
| 与 PC 关系 | 逻辑口径同源UI 组件不直接复用 `vm-page` 宽表 DOM |
| App 打开 | 固定 H5 URL发布后 `{baseUrl}/oneos-h5-vehicle-assets/index.html` 形态以云发布规则为准) |
---
## 9. 用户故事(摘要)
1. **作为**任意登录用户,**我想**在手机上按车牌找到车,**以便**外勤快速核对车辆信息。
2. **作为**运维/业务,**我想**用 KPI 与证照/保险异常筛选缩小范围,**以便**发现需跟进车辆(跟进在 PC 或其他模块完成)。
3. **作为**任意用户,**我想**在详情里切换各业务 Tab 只读浏览,**以便**不回 PC 也能看全档案摘要与记录。
---
## 10. 验收清单
- [ ] 独立原型可在导航打开,桌面 PhoneShell / 窄屏可用
- [ ] KPI 六类计数与同数据下 PC 一致
- [ ] 证照异常、保险异常筛选结果与 PC 规则一致
- [ ] 搜索车牌可过滤列表
- [ ] 列表无总辆数、无翻页;触底异步加载更多
- [ ] 列表进详情:一体渐变底连续,无白顶栏色带割裂;摘要为浮起白卡(状态/标签/里程/异常与列表同语义)
- [ ] 详情无底部悬浮 Tab顶栏仅返回 + 车牌
- [ ] 详情 11 个 Tab 均可进入且只读,无编辑按钮
- [ ] 无导入/导出/修改运维负责人等 PC 写操作入口
---
## 11. 修订记录
| 版本 | 日期 | 说明 |
|---|---|---|
| v0.7 | 2026-07-21 | 列表改为触底异步加载;移除翻页与总辆数展示 |
| v0.6 | 2026-07-21 | 对齐参考图 A 结构App 顶栏/底栏、KPI 图标卡、筛选项行、实景横卡;写操作仍 Toast 占位 |
| v0.5 | 2026-07-21 | 视觉切换至 SaaS BoutiqueElectric Blue + KPI 摘要卡 + 横卡列表);产品范围不变 |
| v0.5 | 2026-07-21 | H5 按参考图标注尺寸落地16/8/12/44 栅格) |
| v0.4 | 2026-07-20 | 手机交互增强粘性工具栏、safe-area、系统返回、空态引导 |
| v0.3 | 2026-07-20 | 视觉切换至 `src/themes/apple`Action Blue + utility card |
| v0.2 | 2026-07-20 | 视觉基底补充Premium SaaS Mobile 质感(品牌绿不变) |
| v0.1 | 2026-07-20 | 需求访谈确认后首版设计规格 |

View File

@@ -0,0 +1,16 @@
# H5 车辆卡片 · 里程任务区设计
| 项 | 说明 |
|---|---|
| 日期 | 2026-07-21 |
| 状态 | 已确认 |
| 原型 | `oneos-h5-vehicle-assets` |
| 规格全文 | `src/prototypes/oneos-h5-vehicle-assets/.spec/mileage-task.md` |
## 结论摘要
- 列表每张车卡在标签行下展示「里程任务」区块(全宽)。
- 有任务:标题 + 完成百分比 + 进度条 +「剩余 xx km · 预计 xx 天内完成」。
- 无任务:标题 +「暂无里程任务」。
- 预计天数 = 剩余里程 ÷ 近 7 个活跃日(日里程 > 0平均日里程向上取整无活跃日则「暂无法预计」。
- 业务来源:采购合同关联批次车辆里程要求,经任务工单下发(原型用本地演示种子,未接真实 API

View File

@@ -0,0 +1,110 @@
# 车辆资产 UI · 边距与尺寸标注(估算)
> 来源:参考截图标注
> 基准宽:**390pt**iPhone 常见逻辑宽)
> 方法:按屏内容像素测量后换算,并按 **8pt 栅格**圆整
> 标注图:`docs/superpowers/specs/2026-07-21-vehicle-assets-ui-measure.png`
## 1. 页面骨架
| 元素 | 尺寸 / 边距 | 备注 |
|------|-------------|------|
| 内容区宽度 | **390** | 逻辑宽 |
| 左右页边距 | **16** | 搜索、KPI、列表、Chip 统一 |
| 主内容可用宽 | **358** | 390 16×2 |
| 区块纵向间距 | **1216** | 搜索→KPI、Chip→列表 |
## 2. 顶栏
| 元素 | 尺寸 | 备注 |
|------|------|------|
| 标题行高 | **40** | 「车辆资产」+ 右侧操作 |
| 标题字号 | **≈2224 / Semibold** | SF Pro Display 观感 |
| 通知铃 / 加号 | **36×36** | 触控建议扩到 44 |
| 右侧图标间距 | **8** | |
## 3. 搜索栏
| 元素 | 尺寸 | 备注 |
|------|------|------|
| 高度 | **44** | 满足触控下限 |
| 宽度 | **358** | 满宽 页边距 |
| 圆角 | **12** | |
| 左右内边距 | **12** | 左放大镜 / 右扫码 |
| 占位字号 | **1517** | |
## 4. KPI 横滑卡片
| 元素 | 尺寸 | 备注 |
|------|------|------|
| 卡片宽 × 高 | **90 × 100** | 约露出 4 张 |
| 卡片间距 | **8** | |
| 圆角 | **1216** | |
| 主数字 | **≈2022 / Bold** | tabular-nums |
| 分类标题 | **12** | |
| 底部分页点 | **Ø6 / 间距 6** | |
## 5. 筛选 Chip 行
| 元素 | 尺寸 | 备注 |
|------|------|------|
| Chip 高 | **32** | 若偏难点可提到 36 |
| Chip 宽 | **≈7890** | 随文案 |
| Chip 间距 | **8** | |
| 圆角 | **8** | |
| 字号 | **1314** | 选中态浅蓝底 |
## 6. 车辆列表卡片
| 元素 | 尺寸 | 备注 |
|------|------|------|
| 卡片宽 × 高 | **358 × 118** | |
| 卡片圆角 | **16** | |
| 卡片垂直间距 | **12** | |
| 卡片内边距 | **12** | 四边一致 |
| 车辆缩略图 | **100 × 74** | 圆角 ≈8 |
| 图文间距 | **12** | 缩略图 ↔ 文案 |
| 车牌字号 | **≈1718 / Semibold** | |
| 车型副文 | **1314** | 次要灰 |
| Tag 高 | **≈22** | 圆角 6 |
| Tag 间距 | **68** | |
| 右侧状态区 | **≈72 宽** | 状态点 + 里程 + 异常标 |
| 右箭头 | **1216** | |
## 7. 底栏
| 元素 | 尺寸 | 备注 |
|------|------|------|
| 底栏总高 | **≈83** | 含 Home Indicator ≈34 |
| 可点区域高 | **≈49** | |
| Tab 等分 | **5** | |
| 图标 | **24×24** | |
| 文案 | **10** | |
| 中心「工作台」 | **⌀56** | 略浮起 |
| 消息红点 | **≈1618** | |
## 8. 推荐实现 Token可直接写 CSS
```css
--page-pad: 16px;
--gap-sm: 8px;
--gap-md: 12px;
--gap-lg: 16px;
--radius-chip: 8px;
--radius-control: 12px;
--radius-card: 16px;
--search-h: 44px;
--chip-h: 32px;
--kpi-w: 90px;
--kpi-h: 100px;
--thumb-w: 100px;
--thumb-h: 74px;
--card-pad: 12px;
--touch-min: 44px;
```
## 9. 说明
- 数值为**视觉估算**,不是设计工具导出的精确标注。
- 若要以设计稿为准,请用 Figma / Sketch 再量一次关键项(页边距、卡片高、缩略图)。
- 手机实现时:**触控热区 ≥ 44×44**,即使视觉图标只有 24/36。

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

View File

@@ -0,0 +1,413 @@
import './oneos-app-shell.css';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ChevronDown,
ChevronRight,
LayoutDashboard,
Menu,
Moon,
Sun,
PanelLeftClose,
RefreshCw,
Search,
Maximize,
X,
Sparkles,
type LucideIcon,
FolderOpen,
FileText,
Truck,
Shield,
Wallet,
Fuel,
BatteryCharging,
Users,
Workflow,
Settings,
ClipboardList,
BarChart3,
Wrench,
} from 'lucide-react';
import navMenuJson from '../../prototypes/oneos-prototype-nav/nav-menu.json';
import {
buildShellMenuFromNav,
collectOpenKeys,
findMenuPath,
hrefPathname,
type ShellMenuItem,
} from './nav-from-prototypes';
import {
readStoredOneOsTheme,
setOneOsTheme,
type OneOsTheme,
} from './theme';
import { ShellNoticeCenter } from './ShellNoticeCenter';
import {
isNoticesSync,
postNoticeAction,
type ShellNoticeItem,
} from './notice-bridge';
const AVATAR_URL = 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp';
/** 演示壳自身,不在内容区嵌套打开 */
export const PROTOTYPE_DEMO_HREF = '/prototypes/oneos-prototype-demo';
function iconForLabel(label: string): LucideIcon {
if (/原型演示|工作台/.test(label)) return LayoutDashboard;
if (/审批/.test(label)) return ClipboardList;
if (/车辆|运维|资产/.test(label)) return Truck;
if (/安全/.test(label)) return Shield;
if (/财务|应收|收款|应结/.test(label)) return Wallet;
if (/加氢|氢/.test(label)) return Fuel;
if (/充电/.test(label)) return BatteryCharging;
if (/用户|客户|机构|角色|部门/.test(label)) return Users;
if (/工作流|流程/.test(label)) return Workflow;
if (/系统|菜单|字典|参数|日志/.test(label)) return Settings;
if (/BI|统计|分析|台账|明细|盈亏|回款/.test(label)) return BarChart3;
if (/合同|业务|保险|供应商/.test(label)) return FileText;
if (/任务|工单|调度/.test(label)) return Wrench;
if (/配置|模板/.test(label)) return FolderOpen;
return FolderOpen;
}
export type ShellTab = {
href: string;
title: string;
};
export type OneOsAppShellProps = {
children: React.ReactNode;
activeHref?: string;
pageTitle?: string;
breadcrumb?: string[];
/** 自定义导航:用于演示壳在内容区切换;不传则整页跳转 */
onNavigate?: (item: ShellMenuItem) => void;
/** 多页签(演示壳) */
tabs?: ShellTab[];
onTabSelect?: (href: string) => void;
onTabClose?: (href: string) => void;
onRefresh?: () => void;
brandHref?: string;
/** 原型演示:顶栏「版本更新」图标,打开更新日志 */
onOpenReleaseNotes?: () => void;
/** 是否有未读版本更新(显示角标) */
releaseNotesUnread?: boolean;
};
export function OneOsAppShell({
children,
activeHref = PROTOTYPE_DEMO_HREF,
pageTitle = '原型演示',
breadcrumb,
onNavigate: onNavigateProp,
tabs,
onTabSelect,
onTabClose,
onRefresh,
brandHref = PROTOTYPE_DEMO_HREF,
onOpenReleaseNotes,
releaseNotesUnread = false,
}: OneOsAppShellProps) {
const menu = useMemo(
() => buildShellMenuFromNav(navMenuJson as Parameters<typeof buildShellMenuFromNav>[0]),
[],
);
const path = useMemo(() => findMenuPath(menu, activeHref) || [], [menu, activeHref]);
const [collapsed, setCollapsed] = useState(false);
const [openKeys, setOpenKeys] = useState<string[]>(() => collectOpenKeys(path));
const [theme, setTheme] = useState<OneOsTheme>(() => readStoredOneOsTheme());
const [shellNotices, setShellNotices] = useState<ShellNoticeItem[]>([]);
const [shellUnread, setShellUnread] = useState(0);
useEffect(() => {
setOneOsTheme(theme);
}, [theme]);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
if (!isNoticesSync(e.data)) return;
setShellNotices(e.data.notices);
setShellUnread(e.data.unreadCount);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, []);
useEffect(() => {
setShellNotices([]);
setShellUnread(0);
}, [activeHref]);
const frameWindow = useCallback((): Window | null => {
if (typeof document === 'undefined') return null;
const frame = document.querySelector<HTMLIFrameElement>('.oneos-shell-frame');
return frame?.contentWindow || null;
}, []);
const onNoticeRead = useCallback(
(notice: ShellNoticeItem) => {
setShellNotices((prev) =>
prev.map((n) => (n.id === notice.id ? { ...n, read: true } : n)),
);
setShellUnread((c) => Math.max(0, c - (notice.read ? 0 : 1)));
const win = frameWindow();
if (win) postNoticeAction(win, 'read', notice.id);
},
[frameWindow],
);
const onNoticeOpen = useCallback(
(notice: ShellNoticeItem) => {
const win = frameWindow();
if (win) postNoticeAction(win, 'open', notice.id);
},
[frameWindow],
);
const onNoticeHandle = useCallback(
(notice: ShellNoticeItem) => {
setShellNotices((prev) =>
prev.map((n) => (n.id === notice.id ? { ...n, read: true } : n)),
);
setShellUnread((c) => Math.max(0, c - (notice.read ? 0 : 1)));
const win = frameWindow();
if (win) postNoticeAction(win, 'handle', notice.id);
},
[frameWindow],
);
useEffect(() => {
setOpenKeys((prev) => {
const next = collectOpenKeys(path);
return Array.from(new Set([...prev, ...next]));
});
}, [path]);
const toggleTheme = useCallback(() => {
setTheme((prev) => {
const next: OneOsTheme = prev === 'dark' ? 'light' : 'dark';
setOneOsTheme(next);
return next;
});
}, []);
const crumbs = breadcrumb?.length
? breadcrumb
: path.length
? path.map((p) => p.label)
: ['OneOS', pageTitle];
const selectedKey = path[path.length - 1]?.key;
const showTabs = Array.isArray(tabs);
const handleNavigate = useCallback(
(item: ShellMenuItem) => {
if (onNavigateProp) {
onNavigateProp(item);
return;
}
if (item.href && typeof window !== 'undefined') {
window.location.href = item.href;
}
},
[onNavigateProp],
);
const toggleOpen = useCallback((key: string) => {
setOpenKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}, []);
const renderItems = (items: ShellMenuItem[], level = 0): React.ReactNode =>
items.map((item) => {
const hasChildren = !!item.children?.length;
const opened = openKeys.includes(item.key);
const selected =
item.key === selectedKey ||
(!!item.href && hrefPathname(item.href) === hrefPathname(activeHref));
const Icon = iconForLabel(item.label);
return (
<li key={item.key} className={`oneos-shell-menu__item oneos-shell-menu__item--lv${level}`}>
<button
type="button"
className={`oneos-shell-menu__btn${selected ? ' is-active' : ''}${hasChildren ? ' is-parent' : ''}`}
title={item.label}
aria-expanded={hasChildren ? opened : undefined}
onClick={() => {
if (hasChildren) toggleOpen(item.key);
else handleNavigate(item);
}}
>
<Icon className="oneos-shell-menu__icon" size={16} strokeWidth={1.75} aria-hidden />
{!collapsed && <span className="oneos-shell-menu__label">{item.label}</span>}
{!collapsed && hasChildren && (
<span className="oneos-shell-menu__arrow" aria-hidden>
{opened ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
)}
</button>
{!collapsed && hasChildren && opened && (
<ul className="oneos-shell-menu__sub">{renderItems(item.children!, level + 1)}</ul>
)}
</li>
);
});
return (
<div
className={`oneos-shell${collapsed ? ' oneos-shell--collapsed' : ''}`}
data-oneos-theme={theme}
>
<aside className="oneos-shell-aside" aria-label="羚牛 OneOS 导航">
<div className="oneos-shell-brand">
<a className="oneos-shell-brand__link" href={brandHref} title="羚牛OneOS">
<span className="oneos-shell-brand__mark" aria-hidden>
<svg viewBox="0 0 32 32" width="28" height="28">
<defs>
<linearGradient id="oneosBrandGrad" x1="0" y1="0" x2="32" y2="32">
<stop offset="0%" stopColor="#3FB87C" />
<stop offset="100%" stopColor="#2B9260" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="9" fill="url(#oneosBrandGrad)" />
<path
d="M8 20.5L12.2 9h3.1l4.2 11.5h-3.1l-.8-2.3h-4.7l-.8 2.3H8zm4.2-4.7h3.2L14 11.2h-.1L12.2 15.8zM21 9h2.8v11.5H21V9z"
fill="#fff"
/>
</svg>
</span>
{!collapsed && <span className="oneos-shell-brand__text">OneOS</span>}
</a>
</div>
<nav className="oneos-shell-nav">
<ul className="oneos-shell-menu">{renderItems(menu)}</ul>
</nav>
</aside>
<div className="oneos-shell-main">
<div className="oneos-shell-chrome">
<header className="oneos-shell-header">
<div className="oneos-shell-header__left">
<button
type="button"
className="oneos-shell-icon-btn"
aria-label={collapsed ? '展开侧栏' : '收起侧栏'}
onClick={() => setCollapsed((v) => !v)}
>
{collapsed ? <Menu size={18} /> : <PanelLeftClose size={18} />}
</button>
<button
type="button"
className="oneos-shell-icon-btn"
aria-label="刷新"
onClick={() => (onRefresh ? onRefresh() : window.location.reload())}
>
<RefreshCw size={16} />
</button>
<nav className="oneos-shell-breadcrumb" aria-label="面包屑">
<ol>
{crumbs.map((c, i) => (
<li key={`${c}-${i}`}>
{i > 0 && <span className="oneos-shell-breadcrumb__sep">/</span>}
<span className={i === crumbs.length - 1 ? 'is-current' : undefined}>{c}</span>
</li>
))}
</ol>
</nav>
</div>
<div className="oneos-shell-header__right">
<label className="oneos-shell-search">
<Search size={14} aria-hidden />
<input type="search" placeholder="搜索" aria-label="搜索" />
<kbd> K</kbd>
</label>
{onOpenReleaseNotes ? (
<button
type="button"
className={`oneos-shell-release-btn${releaseNotesUnread ? ' has-unread' : ''}`}
onClick={onOpenReleaseNotes}
aria-label="版本更新"
title="版本更新日志"
data-annotation-id="shell-release-notes"
>
<span className="oneos-shell-release-btn__ring" aria-hidden />
<span className="oneos-shell-release-btn__icon" aria-hidden>
<Sparkles size={16} strokeWidth={2.25} />
</span>
{releaseNotesUnread ? (
<span className="oneos-shell-release-btn__dot" aria-hidden />
) : null}
</button>
) : null}
<button
type="button"
className="oneos-shell-icon-btn"
aria-label={theme === 'dark' ? '切换浅色模式' : '切换暗色模式'}
title={theme === 'dark' ? '浅色模式' : '暗色模式'}
onClick={toggleTheme}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
<button type="button" className="oneos-shell-icon-btn" aria-label="全屏" title="全屏(演示)">
<Maximize size={16} />
</button>
<ShellNoticeCenter
notices={shellNotices}
unreadCount={shellUnread}
onRead={onNoticeRead}
onOpen={onNoticeOpen}
onHandle={onNoticeHandle}
/>
<button type="button" className="oneos-shell-avatar" aria-label="超级管理员">
<img src={AVATAR_URL} alt="" width={28} height={28} />
</button>
</div>
</header>
{showTabs && tabs!.length > 0 && (
<div className="oneos-shell-tabs" role="tablist" aria-label="打开的页签">
{tabs!.map((tab) => {
const active = hrefPathname(tab.href) === hrefPathname(activeHref);
return (
<div
key={tab.href}
className={`oneos-shell-tab${active ? ' is-active' : ''}`}
role="tab"
aria-selected={active}
>
<button
type="button"
className="oneos-shell-tab__label"
onClick={() => onTabSelect?.(tab.href)}
>
{tab.title}
</button>
{onTabClose && (
<button
type="button"
className="oneos-shell-tab__close"
aria-label={`关闭 ${tab.title}`}
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.href);
}}
>
<X size={12} />
</button>
)}
</div>
);
})}
</div>
)}
</div>
<div className="oneos-shell-content">{children}</div>
</div>
</div>
);
}
export default OneOsAppShell;

View File

@@ -0,0 +1,12 @@
# 羚牛 OneOS 应用外壳
> 实现:`src/common/oneos-app-shell/`
> 宿主页:`/prototypes/oneos-prototype-demo`(原型演示)
## 用法
- **带外壳浏览所有原型**:打开「原型演示」,从左侧菜单点选;内容区 iframe 嵌入。
- **单独打开某原型**:访问 `/prototypes/<id>`,无侧栏顶栏。
- **浅色 / 暗色**:顶栏月亮/太阳按钮切换;暗色参照原型导航页风格。对 OneOS 目录下原型生效(含直链),**不包括「旧 ONEOS」**。
菜单数据:`oneos-prototype-nav/nav-menu.json`

View File

@@ -0,0 +1,364 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Bell, BellOff, X } from 'lucide-react';
import type { ShellNoticeItem } from './notice-bridge';
/** 气泡/标签x条新通知请尽快处理阿拉伯数字 */
function formatNewMessageTip(count: number): string {
return `${count}条新通知,请尽快处理`;
}
function compareNoticePriority(a: ShellNoticeItem, b: ShellNoticeItem): number {
const aUrge = a.type === '催办提醒' ? 0 : 1;
const bUrge = b.type === '催办提醒' ? 0 : 1;
if (aUrge !== bUrge) return aUrge - bUrge;
return b.time.localeCompare(a.time);
}
type AllTab = 'unread' | 'read';
type ShellNoticeCenterProps = {
notices: ShellNoticeItem[];
unreadCount: number;
onRead: (notice: ShellNoticeItem) => void;
onOpen: (notice: ShellNoticeItem) => void;
onHandle: (notice: ShellNoticeItem) => void;
};
function NoticeListItem({
notice,
showHandle,
showSummary = false,
onSelect,
onHandle,
}: {
notice: ShellNoticeItem;
showHandle: boolean;
showSummary?: boolean;
onSelect: (notice: ShellNoticeItem) => void;
onHandle: (notice: ShellNoticeItem) => void;
}) {
const canHandle = !!(notice.href || notice.taskId);
return (
<li className={`oneos-shell-notify__item${!notice.read ? ' is-unread' : ''}`}>
<button type="button" className="oneos-shell-notify__item-main" onClick={() => onSelect(notice)}>
<div className="oneos-shell-notify__meta">
<span className="oneos-shell-notify__dot" aria-hidden />
<span className="oneos-shell-notify__time">{notice.time}</span>
{notice.type === '催办提醒' ? (
<span className="oneos-shell-notify__chip oneos-shell-notify__chip--urge"></span>
) : null}
<span className="oneos-shell-notify__chip">{notice.bizTag}</span>
</div>
<p className="oneos-shell-notify__text">{notice.detail || notice.title}</p>
{showSummary && notice.summary ? (
<p className="oneos-shell-notify__summary">{notice.summary}</p>
) : null}
</button>
{showHandle && canHandle ? (
<button
type="button"
className="oneos-shell-notify__handle"
onClick={() => onHandle(notice)}
>
</button>
) : null}
</li>
);
}
/** 顶栏铃铛 + 新消息闪烁提示 + 下拉通知中心(对齐工作台 NoticePanel */
export function ShellNoticeCenter({
notices,
unreadCount,
onRead,
onOpen: _onOpen,
onHandle,
}: ShellNoticeCenterProps) {
const [open, setOpen] = useState(false);
const [allOpen, setAllOpen] = useState(false);
const [allTab, setAllTab] = useState<AllTab>('unread');
const [detailNotice, setDetailNotice] = useState<ShellNoticeItem | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const allModalRef = useRef<HTMLDivElement>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
useEffect(() => {
const shell = wrapRef.current?.closest('.oneos-shell') as HTMLElement | null;
setPortalRoot(shell || document.body);
}, []);
const pool = useMemo(
() => notices.filter((n) => !n.read).slice().sort(compareNoticePriority),
[notices],
);
const unreadList = pool;
const readList = useMemo(
() => notices.filter((n) => n.read).slice().sort(compareNoticePriority),
[notices],
);
const allTabList = allTab === 'unread' ? unreadList : readList;
const closeAll = () => {
setOpen(false);
setAllOpen(false);
setDetailNotice(null);
};
useEffect(() => {
if (!open && !detailNotice && !allOpen) return;
const onDoc = (e: MouseEvent) => {
const target = e.target as Node;
if (allOpen) {
if (allModalRef.current && !allModalRef.current.contains(target)) {
setAllOpen(false);
}
return;
}
if (!wrapRef.current?.contains(target)) closeAll();
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (detailNotice) setDetailNotice(null);
else if (allOpen) setAllOpen(false);
else setOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open, detailNotice, allOpen]);
useEffect(() => {
if (!allOpen) return;
const lockEl = portalRoot || document.body;
const prev = lockEl.style.overflow;
lockEl.style.overflow = 'hidden';
return () => {
lockEl.style.overflow = prev;
};
}, [allOpen, portalRoot]);
const handleSelect = (notice: ShellNoticeItem) => {
onRead(notice);
setDetailNotice(notice);
setOpen(false);
setAllOpen(false);
};
const handleAction = (notice: ShellNoticeItem) => {
onRead(notice);
onHandle(notice);
closeAll();
};
return (
<div className="oneos-shell-notify" ref={wrapRef} data-annotation-id="wb-notice">
{unreadCount > 0 && !open && !detailNotice && !allOpen ? (
<span className="oneos-shell-notify__callout" aria-live="polite">
{formatNewMessageTip(unreadCount)}
</span>
) : null}
<button
type="button"
className={`oneos-shell-icon-btn oneos-shell-icon-btn--notify${unreadCount > 0 ? ' has-unread' : ''}${open || detailNotice || allOpen ? ' is-open' : ''}`}
aria-label={unreadCount > 0 ? formatNewMessageTip(unreadCount) : '通知'}
aria-expanded={open || !!detailNotice || allOpen}
aria-haspopup="dialog"
onClick={() => {
if (detailNotice || allOpen) {
setDetailNotice(null);
setAllOpen(false);
setOpen(false);
return;
}
setOpen((v) => !v);
}}
>
<Bell size={16} />
</button>
{open && !detailNotice && !allOpen ? (
<div className="oneos-shell-notify__panel" role="dialog" aria-label="通知中心">
<div className="oneos-shell-notify__header">
<h3 className="oneos-shell-notify__title">
{unreadCount > 0 ? (
<span className="oneos-shell-notify__tag">{formatNewMessageTip(unreadCount)}</span>
) : null}
</h3>
<button
type="button"
className="oneos-shell-notify__link"
onClick={() => {
setAllTab(unreadCount > 0 ? 'unread' : 'read');
setOpen(false);
setAllOpen(true);
}}
>
</button>
</div>
<div className="oneos-shell-notify__body">
{pool.length ? (
<ul className="oneos-shell-notify__list">
{pool.map((notice) => (
<NoticeListItem
key={notice.id}
notice={notice}
showHandle
onSelect={handleSelect}
onHandle={handleAction}
/>
))}
</ul>
) : (
<div className="oneos-shell-notify__empty" role="status">
<div className="oneos-shell-notify__empty-visual" aria-hidden="true">
<span className="oneos-shell-notify__empty-ring" />
<span className="oneos-shell-notify__empty-icon">
<BellOff size={22} strokeWidth={1.75} />
</span>
</div>
<p className="oneos-shell-notify__empty-title"></p>
<p className="oneos-shell-notify__empty-desc">
<br />
</p>
</div>
)}
</div>
</div>
) : null}
{allOpen && portalRoot
? createPortal(
<div className="oneos-shell-notify__all-overlay" role="presentation">
<div
className="oneos-shell-notify__all-modal"
role="dialog"
aria-modal="true"
aria-label="全部通知"
ref={allModalRef}
>
<div className="oneos-shell-notify__all-head">
<h3 className="oneos-shell-notify__all-title"></h3>
<button
type="button"
className="oneos-shell-notify__all-close"
aria-label="关闭"
onClick={() => setAllOpen(false)}
>
<X size={16} />
</button>
</div>
<div className="oneos-shell-notify__all-tabs" role="tablist" aria-label="通知分类">
<button
type="button"
role="tab"
aria-selected={allTab === 'unread'}
className={`oneos-shell-notify__all-tab${allTab === 'unread' ? ' is-active' : ''}`}
onClick={() => setAllTab('unread')}
>
<span className="oneos-shell-notify__all-count">{unreadList.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={allTab === 'read'}
className={`oneos-shell-notify__all-tab${allTab === 'read' ? ' is-active' : ''}`}
onClick={() => setAllTab('read')}
>
<span className="oneos-shell-notify__all-count">{readList.length}</span>
</button>
</div>
<div className="oneos-shell-notify__all-body" role="tabpanel">
{allTabList.length ? (
<ul className="oneos-shell-notify__list">
{allTabList.map((notice) => (
<NoticeListItem
key={notice.id}
notice={notice}
showHandle={allTab === 'unread'}
showSummary
onSelect={handleSelect}
onHandle={handleAction}
/>
))}
</ul>
) : (
<div className="oneos-shell-notify__empty" role="status">
<div className="oneos-shell-notify__empty-visual" aria-hidden="true">
<span className="oneos-shell-notify__empty-ring" />
<span className="oneos-shell-notify__empty-icon">
<BellOff size={22} strokeWidth={1.75} />
</span>
</div>
<p className="oneos-shell-notify__empty-title">
{allTab === 'unread' ? '暂无未读通知' : '暂无已读通知'}
</p>
<p className="oneos-shell-notify__empty-desc">
{allTab === 'unread'
? '当前通知均已读完,有新催办或业务提醒时会显示在这里'
: '已读通知会显示在这里'}
</p>
</div>
)}
</div>
</div>
</div>,
portalRoot,
)
: null}
{detailNotice ? (
<div className="oneos-shell-notify__detail" role="dialog" aria-modal="true" aria-label="通知详情">
<div className="oneos-shell-notify__detail-head">
<h3 className="oneos-shell-notify__detail-title">
{detailNotice.type === '催办提醒' ? (
<span className="oneos-shell-notify__chip oneos-shell-notify__chip--urge"></span>
) : null}
<span className="oneos-shell-notify__chip">{detailNotice.bizTag}</span>
<span className="oneos-shell-notify__detail-name">{detailNotice.title}</span>
</h3>
<button
type="button"
className="oneos-shell-notify__detail-close"
onClick={() => setDetailNotice(null)}
>
</button>
</div>
<div className="oneos-shell-notify__detail-body">
<span className="oneos-shell-notify__time">{detailNotice.time}</span>
<p className="oneos-shell-notify__detail-text">{detailNotice.detail || detailNotice.title}</p>
{detailNotice.summary ? (
<p className="oneos-shell-notify__detail-summary">{detailNotice.summary}</p>
) : null}
</div>
<div className="oneos-shell-notify__detail-foot">
<button type="button" className="oneos-shell-notify__detail-secondary" onClick={() => setDetailNotice(null)}>
</button>
{(detailNotice.href || detailNotice.taskId) && (
<button
type="button"
className="oneos-shell-notify__detail-primary"
onClick={() => handleAction(detailNotice)}
>
</button>
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,50 @@
export { OneOsAppShell, PROTOTYPE_DEMO_HREF } from './OneOsAppShell';
export type { OneOsAppShellProps, ShellTab } from './OneOsAppShell';
export { ShellNoticeCenter } from './ShellNoticeCenter';
export {
ONEOS_NOTICE_ACTION,
ONEOS_NOTICES_SYNC,
isNoticeAction,
isNoticesSync,
postNoticeAction,
postNoticesSync,
type NoticeActionPayload,
type NoticesSyncPayload,
type ShellNoticeItem,
} from './notice-bridge';
export {
ONEOS_SHELL_NAV,
isShellNav,
requestShellNav,
type ShellNavPayload,
} from './nav-bridge';
export {
buildShellMenuFromNav,
findMenuPath,
hrefPathname,
itemKeyToHref,
type ShellMenuItem,
} from './nav-from-prototypes';
export {
ONEOS_RELEASE_DEMO_ACTION,
RELEASE_SEEN_STORAGE_KEY,
SHELL_CURRENT_RELEASE_VERSION,
clearAllReleaseSeen,
hasUnseenRelease,
isReleaseDemoAction,
postReleaseDemoAction,
postReleaseDemoOpen,
postReleaseDemoReset,
type ReleaseDemoAction,
type ReleaseDemoActionPayload,
} from './release-demo-bridge';
export {
bootstrapOneOsTheme,
broadcastOneOsTheme,
isLegacyOneOsPrototype,
LEGACY_ONEOS_PROTO_IDS,
readStoredOneOsTheme,
setOneOsTheme,
toggleOneOsTheme,
type OneOsTheme,
} from './theme';

View File

@@ -0,0 +1,29 @@
/** iframe 内页面 ↔ 外壳侧栏/页签导航 postMessage 协议 */
export const ONEOS_SHELL_NAV = 'ONEOS_SHELL_NAV';
export type ShellNavPayload = {
type: typeof ONEOS_SHELL_NAV;
href: string;
title?: string;
};
export function isShellNav(data: unknown): data is ShellNavPayload {
return (
!!data &&
typeof data === 'object' &&
(data as ShellNavPayload).type === ONEOS_SHELL_NAV &&
typeof (data as ShellNavPayload).href === 'string' &&
(data as ShellNavPayload).href.startsWith('/prototypes/')
);
}
/** 在 iframe 内跳转时优先通知外壳;直链打开则本页跳转 */
export function requestShellNav(href: string, title?: string): boolean {
if (typeof window === 'undefined' || !href.startsWith('/prototypes/')) return false;
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: ONEOS_SHELL_NAV, href, title } satisfies ShellNavPayload, '*');
return true;
}
return false;
}

View File

@@ -0,0 +1,104 @@
/**
* 将 oneos-prototype-nav/nav-menu.json 转为羚牛 OneOS 侧栏菜单树
*/
export type NavMenuNode = {
id: string;
kind: 'folder' | 'item';
title: string;
itemKey?: string;
children?: NavMenuNode[];
};
export type ShellMenuItem = {
key: string;
label: string;
href?: string;
children?: ShellMenuItem[];
};
type NavMenuFile = {
prototypes?: NavMenuNode[];
};
export function itemKeyToHref(itemKey?: string): string | undefined {
if (!itemKey) return undefined;
if (itemKey.startsWith('/prototypes/')) return itemKey;
if (itemKey.startsWith('prototypes/')) return `/${itemKey}`;
return `/prototypes/${itemKey}`;
}
function convertNode(node: NavMenuNode): ShellMenuItem {
if (node.kind === 'item') {
return {
key: node.id || node.itemKey || node.title,
label: node.title,
href: itemKeyToHref(node.itemKey),
};
}
return {
key: node.id || node.title,
label: node.title,
children: (node.children || []).map(convertNode),
};
}
/** 取「OneOS」根目录下的子树作为侧栏若无则用整棵 prototypes */
export function buildShellMenuFromNav(
nav: NavMenuFile,
options?: { excludeHrefs?: string[] },
): ShellMenuItem[] {
const roots = nav.prototypes || [];
const oneos = roots.find((n) => n.kind === 'folder' && /oneos/i.test(n.title));
const source = oneos?.children?.length ? oneos.children : roots;
const exclude = new Set(
(options?.excludeHrefs ?? ['/prototypes/oneos-prototype-demo']).map((h) => h.replace(/\/$/, '')),
);
function filterTree(items: ShellMenuItem[]): ShellMenuItem[] {
return items
.map((it) => {
if (it.href && exclude.has(it.href.replace(/\/$/, ''))) return null;
if (it.children?.length) {
const children = filterTree(it.children);
if (!it.href && children.length === 0) return null;
return { ...it, children };
}
return it;
})
.filter(Boolean) as ShellMenuItem[];
}
return filterTree(source.map(convertNode));
}
/** 去掉 query/hash 与尾部斜杠,供菜单高亮与路径匹配 */
export function hrefPathname(href: string): string {
return href.split(/[?#]/)[0]!.replace(/\/$/, '') || href;
}
export function findMenuPath(
items: ShellMenuItem[],
activeHref: string,
trail: ShellMenuItem[] = [],
): ShellMenuItem[] | null {
const activePath = hrefPathname(activeHref);
for (const item of items) {
const next = [...trail, item];
if (item.href) {
const itemPath = hrefPathname(item.href);
if (activePath === itemPath || activePath.endsWith(itemPath)) {
return next;
}
}
if (item.children?.length) {
const hit = findMenuPath(item.children, activeHref, next);
if (hit) return hit;
}
}
return null;
}
export function collectOpenKeys(path: ShellMenuItem[]): string[] {
return path.filter((n) => n.children?.length).map((n) => n.key);
}

View File

@@ -0,0 +1,60 @@
/** 工作台 iframe ↔ 外壳顶栏通知中心 postMessage 协议 */
export const ONEOS_NOTICES_SYNC = 'ONEOS_NOTICES_SYNC';
export const ONEOS_NOTICE_ACTION = 'ONEOS_NOTICE_ACTION';
export type ShellNoticeItem = {
id: string;
type: string;
bizTag: string;
title: string;
summary: string;
detail: string;
time: string;
read: boolean;
href?: string;
taskId?: string;
};
export type NoticesSyncPayload = {
type: typeof ONEOS_NOTICES_SYNC;
unreadCount: number;
notices: ShellNoticeItem[];
source?: string;
};
export type NoticeActionPayload = {
type: typeof ONEOS_NOTICE_ACTION;
action: 'read' | 'handle' | 'open';
noticeId: string;
};
export function isNoticesSync(data: unknown): data is NoticesSyncPayload {
return (
!!data &&
typeof data === 'object' &&
(data as NoticesSyncPayload).type === ONEOS_NOTICES_SYNC &&
Array.isArray((data as NoticesSyncPayload).notices)
);
}
export function isNoticeAction(data: unknown): data is NoticeActionPayload {
return (
!!data &&
typeof data === 'object' &&
(data as NoticeActionPayload).type === ONEOS_NOTICE_ACTION &&
typeof (data as NoticeActionPayload).noticeId === 'string'
);
}
export function postNoticesSync(target: Window, payload: Omit<NoticesSyncPayload, 'type'>) {
target.postMessage({ type: ONEOS_NOTICES_SYNC, ...payload }, '*');
}
export function postNoticeAction(
target: Window,
action: NoticeActionPayload['action'],
noticeId: string,
) {
target.postMessage({ type: ONEOS_NOTICE_ACTION, action, noticeId }, '*');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
/**
* OneOS 内容区暗色 token参照原型导航 / VoltAgent 深色画布)
* 由 html[data-oneos-theme="dark"] 驱动;浅色保持各页原有 :root 定义。
*/
html[data-oneos-theme='dark'] {
color-scheme: dark;
--ln-primary: #00d992;
--ln-primary-hover: #2fd6a1;
--ln-primary-focus: #10b981;
--ln-primary-active: #10b981;
--ln-primary-soft: rgba(0, 217, 146, 0.12);
--ln-primary-soft-strong: rgba(0, 217, 146, 0.2);
--ln-ink: #f2f2f2;
--ln-body: #bdbdbd;
--ln-body-strong: #f2f2f2;
--ln-muted: #8b949e;
--ln-muted-soft: #6b7280;
--ln-muted-80: #d1d5db;
--ln-divider-soft: #1a1a1a;
--ln-hairline: #3d3a39;
--ln-hairline-strong: #565656;
--ln-hairline-tertiary: #3d3a39;
--ln-canvas: #101010;
--ln-canvas-parchment: #101010;
--ln-surface-pearl: #1a1a1a;
--ln-surface-card: #1a1a1a;
--ln-surface-strong: #222222;
--ln-on-primary: #101010;
--ln-success: #00d992;
--ln-warning: #f59e0b;
--ln-error: #f87171;
--ln-brand-secure: #2fd6a1;
--ln-shadow-soft: none;
--ln-shadow-hover: none;
--ln-shadow-float: none;
--ln-shadow-modal: 0 20px 40px rgba(0, 0, 0, 0.45);
--ln-link: #00d992;
--ln-canvas-soft: #1a1a1a;
--vm-primary: var(--ln-primary);
--vm-primary-dark: var(--ln-primary-focus);
--vm-primary-soft: var(--ln-primary-soft);
--vm-bg: var(--ln-canvas-parchment);
--vm-surface: var(--ln-surface-card);
--vm-border: var(--ln-hairline);
--vm-text: var(--ln-ink);
--vm-muted: var(--ln-muted);
--vm-danger: var(--ln-error);
--vm-warning: var(--ln-warning);
--vm-link: var(--ln-link);
}
html[data-oneos-theme='dark'],
html[data-oneos-theme='dark'] body {
background-color: #101010 !important;
color: #f2f2f2;
}
html[data-oneos-theme='dark'] #root {
background-color: #101010;
color: #f2f2f2;
min-height: 100%;
}
/* 常见页面根容器 */
html[data-oneos-theme='dark'] .vm-page,
html[data-oneos-theme='dark'] .wb-page,
html[data-oneos-theme='dark'] .page,
html[data-oneos-theme='dark'] .ant-layout,
html[data-oneos-theme='dark'] .ant-layout-content {
background: #101010 !important;
color: #f2f2f2;
}
html[data-oneos-theme='dark'] .ant-card,
html[data-oneos-theme='dark'] .ant-table,
html[data-oneos-theme='dark'] .ant-table-container,
html[data-oneos-theme='dark'] .ant-table-thead > tr > th,
html[data-oneos-theme='dark'] .ant-modal-content,
html[data-oneos-theme='dark'] .ant-drawer-content,
html[data-oneos-theme='dark'] .ant-input,
html[data-oneos-theme='dark'] .ant-input-affix-wrapper,
html[data-oneos-theme='dark'] .ant-select-selector,
html[data-oneos-theme='dark'] .ant-picker {
background: #1a1a1a !important;
border-color: #3d3a39 !important;
color: #f2f2f2 !important;
}
html[data-oneos-theme='dark'] .ant-table-thead > tr > th {
background: #161616 !important;
color: #bdbdbd !important;
}
html[data-oneos-theme='dark'] .ant-table-tbody > tr > td {
border-color: #3d3a39 !important;
color: #f2f2f2 !important;
}
html[data-oneos-theme='dark'] .ant-table-tbody > tr:hover > td {
background: #222222 !important;
}
html[data-oneos-theme='dark'] .ant-btn-default {
background: #1a1a1a !important;
border-color: #3d3a39 !important;
color: #f2f2f2 !important;
}
html[data-oneos-theme='dark'] .ant-btn-primary {
background: #00d992 !important;
border-color: #00d992 !important;
color: #101010 !important;
}
/* 工作台 / 通用卡片:覆盖硬编码浅色渐变与表头 */
html[data-oneos-theme='dark'] .wb-hero--welcome,
html[data-oneos-theme='dark'] .wb-hero--personal,
html[data-oneos-theme='dark'] .wb-panel,
html[data-oneos-theme='dark'] .wb-insights,
html[data-oneos-theme='dark'] .wb-section--flow,
html[data-oneos-theme='dark'] .wb-section--approval,
html[data-oneos-theme='dark'] .wb-kpi-tile,
html[data-oneos-theme='dark'] .wb-notice-card {
background: #1a1a1a !important;
box-shadow: none;
color: #f2f2f2;
}
html[data-oneos-theme='dark'] .wb-kpi-chip {
background: #222222 !important;
box-shadow: none;
color: #f2f2f2;
}
html[data-oneos-theme='dark'] .wb-panel-header,
html[data-oneos-theme='dark'] .wb-section-head,
html[data-oneos-theme='dark'] .wb-todo-table thead th,
html[data-oneos-theme='dark'] .wb-insight-table th,
html[data-oneos-theme='dark'] .wb-insight-metric {
background: #161616 !important;
color: #bdbdbd;
}
html[data-oneos-theme='dark'] .wb-page--layout-personal {
background: #101010 !important;
}
html[data-oneos-theme='dark'] .wb-todo-table tbody tr:hover td,
html[data-oneos-theme='dark'] .wb-kpi-chip:hover {
background: #222222 !important;
}
html[data-oneos-theme='dark'] .wb-tabs,
html[data-oneos-theme='dark'] .wb-drawer,
html[data-oneos-theme='dark'] .wb-modal,
html[data-oneos-theme='dark'] .wb-overlay-panel {
background: #1a1a1a;
color: #f2f2f2;
border-color: #3d3a39;
}

View File

@@ -0,0 +1,64 @@
/** 原型演示外壳 ↔ 工作台 iframe版本更新日志 */
export const ONEOS_RELEASE_DEMO_ACTION = 'ONEOS_RELEASE_DEMO_ACTION';
export type ReleaseDemoAction = 'open' | 'reset';
export type ReleaseDemoActionPayload = {
type: typeof ONEOS_RELEASE_DEMO_ACTION;
action: ReleaseDemoAction;
};
export function isReleaseDemoAction(data: unknown): data is ReleaseDemoActionPayload {
if (!data || typeof data !== 'object') return false;
const payload = data as ReleaseDemoActionPayload;
return (
payload.type === ONEOS_RELEASE_DEMO_ACTION &&
(payload.action === 'open' || payload.action === 'reset')
);
}
export function postReleaseDemoAction(target: Window, action: ReleaseDemoAction) {
target.postMessage(
{ type: ONEOS_RELEASE_DEMO_ACTION, action } satisfies ReleaseDemoActionPayload,
'*',
);
}
/** @deprecated 使用 postReleaseDemoAction(target, 'open') */
export function postReleaseDemoReset(target: Window) {
postReleaseDemoAction(target, 'reset');
}
export function postReleaseDemoOpen(target: Window) {
postReleaseDemoAction(target, 'open');
}
/** 与工作台 `utils/release-seen.ts` 同源 key */
export const RELEASE_SEEN_STORAGE_KEY = 'oneos-wb-release-seen-v1';
/** 当前发布版本(外壳「未读」点用;与 workbench data/release-notes 保持一致) */
export const SHELL_CURRENT_RELEASE_VERSION = '1.1.5';
export function clearAllReleaseSeen(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(RELEASE_SEEN_STORAGE_KEY);
} catch {
/* ignore */
}
}
export function hasUnseenRelease(operatorName?: string): boolean {
if (typeof window === 'undefined') return false;
try {
const raw = window.localStorage.getItem(RELEASE_SEEN_STORAGE_KEY);
if (!raw) return true;
const map = JSON.parse(raw) as Record<string, string>;
if (!map || typeof map !== 'object') return true;
if (operatorName) return map[operatorName] !== SHELL_CURRENT_RELEASE_VERSION;
return !Object.values(map).includes(SHELL_CURRENT_RELEASE_VERSION);
} catch {
return true;
}
}

View File

@@ -0,0 +1,122 @@
/**
* OneOS 浅色 / 暗色主题localStorage + 跨 iframe postMessage
* 暗色参照原型导航页VoltAgent 深色画布)。
* 不对「旧 ONEOS」目录下原型生效。
*/
import './oneos-theme-content.css';
export type OneOsTheme = 'light' | 'dark';
export const ONEOS_THEME_STORAGE_KEY = 'oneos-ui-theme';
export const ONEOS_THEME_ATTR = 'data-oneos-theme';
export const ONEOS_THEME_MESSAGE = 'ONEOS_THEME_CHANGE';
/** sidebar「旧ONEOS」目录下的原型不接入主题切换 */
export const LEGACY_ONEOS_PROTO_IDS = new Set([
'oneos-web-business',
'oneos-web-data-analysis',
'oneos-web-finance',
'oneos-web-help-center',
'oneos-web-lease-contract',
'oneos-web-ledger-data',
'oneos-web-ops',
'oneos-web-procurement',
]);
export function isLegacyOneOsPrototype(id?: string | null): boolean {
if (!id) return false;
return LEGACY_ONEOS_PROTO_IDS.has(id);
}
export function readStoredOneOsTheme(): OneOsTheme {
if (typeof window === 'undefined') return 'light';
try {
const v = window.localStorage.getItem(ONEOS_THEME_STORAGE_KEY);
if (v === 'dark' || v === 'light') return v;
} catch {
/* ignore */
}
return 'light';
}
export function applyOneOsTheme(theme: OneOsTheme, root: HTMLElement = document.documentElement) {
root.setAttribute(ONEOS_THEME_ATTR, theme);
if (root === document.documentElement) {
document.documentElement.style.colorScheme = theme;
}
}
export function persistOneOsTheme(theme: OneOsTheme) {
try {
window.localStorage.setItem(ONEOS_THEME_STORAGE_KEY, theme);
} catch {
/* ignore */
}
}
export function broadcastOneOsTheme(theme: OneOsTheme) {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(ONEOS_THEME_MESSAGE, { detail: { theme } }));
try {
window.parent?.postMessage({ type: ONEOS_THEME_MESSAGE, theme }, '*');
} catch {
/* ignore */
}
const frames = document.querySelectorAll('iframe');
frames.forEach((frame) => {
try {
frame.contentWindow?.postMessage({ type: ONEOS_THEME_MESSAGE, theme }, '*');
} catch {
/* ignore */
}
});
}
export function setOneOsTheme(theme: OneOsTheme) {
applyOneOsTheme(theme);
persistOneOsTheme(theme);
broadcastOneOsTheme(theme);
}
export function toggleOneOsTheme(current?: OneOsTheme): OneOsTheme {
const next: OneOsTheme = (current ?? readStoredOneOsTheme()) === 'dark' ? 'light' : 'dark';
setOneOsTheme(next);
return next;
}
type BootstrapOptions = {
prototypeId?: string;
/** 强制跳过(如旧 ONEOS */
skip?: boolean;
};
/**
* 在原型预览入口调用:同步 localStorage / URL / 父页 postMessage。
*/
export function bootstrapOneOsTheme(options: BootstrapOptions = {}) {
if (typeof window === 'undefined') return;
if (options.skip || isLegacyOneOsPrototype(options.prototypeId)) return;
const fromQuery = new URLSearchParams(window.location.search).get('oneosTheme');
const initial: OneOsTheme =
fromQuery === 'dark' || fromQuery === 'light' ? fromQuery : readStoredOneOsTheme();
applyOneOsTheme(initial);
persistOneOsTheme(initial);
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || data.type !== ONEOS_THEME_MESSAGE) return;
if (data.theme !== 'dark' && data.theme !== 'light') return;
applyOneOsTheme(data.theme);
persistOneOsTheme(data.theme);
});
window.addEventListener('storage', (event) => {
if (event.key !== ONEOS_THEME_STORAGE_KEY) return;
if (event.newValue === 'dark' || event.newValue === 'light') {
applyOneOsTheme(event.newValue);
}
});
}

View File

@@ -0,0 +1,567 @@
/**
* 自营物流闭环桥:自营合同 ↔ 轻量调度任务 ↔ 车辆运营态 ↔ 物流业务明细自动生成
* 原型本地种子 + localStorage未接真实 API。
* 规则见src/resources/self-operated-logistics/assumptions.md
*/
export const SO_CONTRACT_STORAGE_KEY = 'oneos.self-operated.contracts.v1';
export const SO_DISPATCH_STORAGE_KEY = 'oneos.self-operated.dispatch-tasks.v1';
export const SO_LEDGER_AUTO_STORAGE_KEY = 'oneos.self-operated.ledger-auto.v1';
export const SO_VEHICLE_OPS_STORAGE_KEY = 'oneos.self-operated.vehicle-ops.v1';
export const CONTRACT_STATUS = {
draft: { key: 'draft', label: '草稿', color: 'default' },
active: { key: 'active', label: '生效', color: 'processing' },
performing: { key: 'performing', label: '履约中', color: 'success' },
ended: { key: 'ended', label: '已结束', color: 'default' },
terminated: { key: 'terminated', label: '已终止', color: 'error' },
};
export const DISPATCH_STATUS = {
pending_assign: { key: 'pending_assign', label: '待派', color: 'default' },
assigned: { key: 'assigned', label: '已派', color: 'processing' },
in_progress: { key: 'in_progress', label: '执行中', color: 'warning' },
completed: { key: 'completed', label: '已办结', color: 'success' },
cancelled: { key: 'cancelled', label: '已取消', color: 'error' },
};
/** inventory | self_operated */
export const VEHICLE_OPS = {
inventory: 'inventory',
self_operated: 'self_operated',
};
export const SEED_VEHICLES = [
{ plateNo: '粤B·H2001', brandModel: '佛山飞驰·35T', opsStatus: VEHICLE_OPS.inventory },
{ plateNo: '粤B·H2002', brandModel: '佛山飞驰·35T', opsStatus: VEHICLE_OPS.self_operated },
{ plateNo: '粤B·H2003', brandModel: '未势能源·49T', opsStatus: VEHICLE_OPS.inventory },
{ plateNo: '粤B·H2008', brandModel: '未势能源·49T', opsStatus: VEHICLE_OPS.inventory },
];
export const SEED_DRIVERS = [
{ name: '陈志强', phone: '13800138001' },
{ name: '李伟', phone: '13800138002' },
{ name: '王海涛', phone: '13800138003' },
];
function nowStamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function todayDate() {
return nowStamp().slice(0, 10);
}
function readJson(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return parsed == null ? fallback : parsed;
} catch {
return fallback;
}
}
function writeJson(key, value) {
localStorage.setItem(key, JSON.stringify(value));
try {
window.dispatchEvent(new CustomEvent('oneos-self-operated-bridge', { detail: { key } }));
} catch {
/* ignore */
}
}
function roundMoney(value) {
return Math.round(Number(value || 0) * 100) / 100;
}
function computeAmount(unitPrice, quantity) {
return roundMoney(Number(unitPrice || 0) * Number(quantity || 0));
}
function computeTotalCost(row) {
return roundMoney(
Number(row.hydrogenFee || 0)
+ Number(row.manualReimbursement || 0)
+ Number(row.etcFee || 0)
+ Number(row.electricityFee || 0)
+ Number(row.salary || 0)
+ Number(row.vehicleFee || 0),
);
}
function brandModelOf(plateNo) {
const hit = SEED_VEHICLES.find((v) => v.plateNo === plateNo);
return hit ? hit.brandModel : '';
}
// —— 车辆运营态 ——
export function loadVehicleOpsMap() {
const stored = readJson(SO_VEHICLE_OPS_STORAGE_KEY, null);
if (stored && typeof stored === 'object') return stored;
const map = {};
SEED_VEHICLES.forEach((v) => {
map[v.plateNo] = v.opsStatus;
});
writeJson(SO_VEHICLE_OPS_STORAGE_KEY, map);
return map;
}
export function getVehicleOpsStatus(plateNo) {
const map = loadVehicleOpsMap();
return map[plateNo] || VEHICLE_OPS.inventory;
}
export function setVehicleOpsStatus(plateNo, status) {
const map = loadVehicleOpsMap();
map[plateNo] = status;
writeJson(SO_VEHICLE_OPS_STORAGE_KEY, map);
return map;
}
/**
* 模拟运维交车:库存 → 自营
*/
export function simulateHandover(plateNo) {
const plate = String(plateNo || '').trim();
if (!plate) return { ok: false, message: '请选择车牌' };
const current = getVehicleOpsStatus(plate);
if (current === VEHICLE_OPS.self_operated) {
return { ok: true, message: `${plate} 已在自营运营态`, already: true };
}
setVehicleOpsStatus(plate, VEHICLE_OPS.self_operated);
return { ok: true, message: `已模拟交车:${plate} → 自营运营态` };
}
/**
* 模拟运维还车:自营 → 库存;存在未办结任务时拦截
*/
export function simulateReturn(plateNo) {
const plate = String(plateNo || '').trim();
if (!plate) return { ok: false, message: '请选择车牌' };
const open = loadDispatchTasks().filter(
(t) => t.plateNo === plate && !['completed', 'cancelled'].includes(t.status),
);
if (open.length > 0) {
return {
ok: false,
message: `${plate} 仍有 ${open.length} 条未办结调度任务,无法还车`,
};
}
setVehicleOpsStatus(plate, VEHICLE_OPS.inventory);
return { ok: true, message: `已模拟还车:${plate} → 库存` };
}
// —— 合同 ——
function seedContracts() {
return [
{
id: 'soc-seed-001',
contractCode: 'ZY-2026-0001',
customerName: '深圳城配物流有限公司',
businessName: '深圳南山短驳项目',
vehicleType: '35T 氢能重卡',
vehicleCount: 2,
serviceStart: '2026-07-01',
serviceEnd: '2026-12-31',
handoverDate: '2026-07-05',
handoverPlace: '深圳坪山运维基地',
pricingSummary: '线路计价 · 元/趟',
unitPriceDefault: 1800,
status: 'active',
attachmentName: '自营服务合同-南山短驳.pdf',
createdAt: '2026-06-28 10:00',
activatedAt: '2026-06-28 15:20',
remark: '种子合同:已生效,可创建调度任务',
},
{
id: 'soc-seed-002',
contractCode: 'ZY-2026-0002',
customerName: '广州港务运输集团',
businessName: '南沙港区接驳',
vehicleType: '49T 氢能重卡',
vehicleCount: 1,
serviceStart: '2026-08-01',
serviceEnd: '2027-01-31',
handoverDate: '2026-08-03',
handoverPlace: '广州南沙停车场',
pricingSummary: '线路计价 · 元/趟',
unitPriceDefault: 2200,
status: 'draft',
attachmentName: '',
createdAt: '2026-07-10 09:30',
activatedAt: '',
remark: '草稿:需业务确认生效',
},
];
}
export function loadContracts() {
const stored = readJson(SO_CONTRACT_STORAGE_KEY, null);
if (Array.isArray(stored) && stored.length > 0) return stored;
const seed = seedContracts();
writeJson(SO_CONTRACT_STORAGE_KEY, seed);
return seed;
}
export function saveContracts(list) {
writeJson(SO_CONTRACT_STORAGE_KEY, list || []);
}
export function getContractById(id) {
return loadContracts().find((c) => c.id === id) || null;
}
export function getActiveContracts() {
return loadContracts().filter((c) => c.status === 'active' || c.status === 'performing');
}
export function createContract(payload) {
const list = loadContracts();
const n = list.length + 1;
const row = {
id: `soc-${Date.now()}`,
contractCode: payload.contractCode || `ZY-2026-${String(n).padStart(4, '0')}`,
customerName: String(payload.customerName || '').trim(),
businessName: String(payload.businessName || '').trim(),
vehicleType: String(payload.vehicleType || '').trim(),
vehicleCount: Number(payload.vehicleCount) || 1,
serviceStart: payload.serviceStart || '',
serviceEnd: payload.serviceEnd || '',
handoverDate: payload.handoverDate || '',
handoverPlace: payload.handoverPlace || '',
pricingSummary: payload.pricingSummary || '线路计价',
unitPriceDefault: Number(payload.unitPriceDefault) || 0,
status: 'draft',
attachmentName: payload.attachmentName || '',
createdAt: nowStamp(),
activatedAt: '',
remark: payload.remark || '',
};
list.unshift(row);
saveContracts(list);
return row;
}
/**
* 草稿 → 生效;进入可派车;标记可发起交车(原型 toast/handoverHint
*/
export function activateContract(contractId) {
const list = loadContracts();
const idx = list.findIndex((c) => c.id === contractId);
if (idx < 0) return { ok: false, message: '合同不存在' };
const c = list[idx];
if (c.status !== 'draft') {
return { ok: false, message: `当前状态「${CONTRACT_STATUS[c.status]?.label || c.status}」不可生效` };
}
list[idx] = {
...c,
status: 'active',
activatedAt: nowStamp(),
};
saveContracts(list);
return {
ok: true,
contract: list[idx],
message: `合同 ${c.contractCode} 已生效,可创建调度任务;交车请在调度侧「模拟交车」或运维交车办理`,
handoverHint: {
contractCode: c.contractCode,
handoverDate: c.handoverDate,
handoverPlace: c.handoverPlace,
vehicleCount: c.vehicleCount,
},
};
}
export function endContract(contractId) {
const list = loadContracts();
const idx = list.findIndex((c) => c.id === contractId);
if (idx < 0) return { ok: false, message: '合同不存在' };
const open = loadDispatchTasks().filter(
(t) => t.contractId === contractId && !['completed', 'cancelled'].includes(t.status),
);
if (open.length > 0) {
return { ok: false, message: `仍有 ${open.length} 条未办结调度任务,无法结束合同` };
}
list[idx] = { ...list[idx], status: 'ended' };
saveContracts(list);
return { ok: true, message: `合同 ${list[idx].contractCode} 已结束` };
}
// —— 调度任务 ——
function seedDispatchTasks() {
return [
{
id: 'sdt-seed-001',
taskCode: 'DD-2026-0001',
contractId: 'soc-seed-001',
contractCode: 'ZY-2026-0001',
businessName: '深圳南山短驳项目',
customerName: '深圳城配物流有限公司',
planDate: '2026-07-18',
routeDesc: '坪山基地 → 南山仓 A',
multiTrip: '否',
routePricing: '线路计价',
unitPrice: 1800,
quantity: 1,
plateNo: '粤B·H2002',
driver: '陈志强',
phone: '13800138001',
status: 'assigned',
salary: 350,
remark: '种子任务:车辆已在自营态,可直接办结演示自动台账',
createdAt: '2026-07-15 11:00',
assignedAt: '2026-07-15 11:05',
completedAt: '',
ledgerRowId: '',
},
];
}
export function loadDispatchTasks() {
const stored = readJson(SO_DISPATCH_STORAGE_KEY, null);
if (Array.isArray(stored) && stored.length > 0) return stored;
const seed = seedDispatchTasks();
writeJson(SO_DISPATCH_STORAGE_KEY, seed);
return seed;
}
export function saveDispatchTasks(list) {
writeJson(SO_DISPATCH_STORAGE_KEY, list || []);
}
export function createDispatchTask(payload) {
const contract = getContractById(payload.contractId);
if (!contract) return { ok: false, message: '请选择自营合同' };
if (!['active', 'performing'].includes(contract.status)) {
return { ok: false, message: '仅生效/履约中的合同可创建调度任务' };
}
const list = loadDispatchTasks();
const n = list.length + 1;
const plateNo = String(payload.plateNo || '').trim();
const hasPlate = Boolean(plateNo);
const row = {
id: `sdt-${Date.now()}`,
taskCode: `DD-2026-${String(n).padStart(4, '0')}`,
contractId: contract.id,
contractCode: contract.contractCode,
businessName: contract.businessName,
customerName: contract.customerName,
planDate: payload.planDate || todayDate(),
routeDesc: String(payload.routeDesc || '').trim(),
multiTrip: payload.multiTrip === '是' ? '是' : '否',
routePricing: payload.routePricing || contract.pricingSummary || '线路计价',
unitPrice: Number(payload.unitPrice ?? contract.unitPriceDefault) || 0,
quantity: Number(payload.quantity) || 1,
plateNo,
driver: String(payload.driver || '').trim(),
phone: String(payload.phone || '').trim(),
status: hasPlate ? 'assigned' : 'pending_assign',
salary: Number(payload.salary) || 0,
remark: String(payload.remark || '').trim(),
createdAt: nowStamp(),
assignedAt: hasPlate ? nowStamp() : '',
completedAt: '',
ledgerRowId: '',
};
list.unshift(row);
saveDispatchTasks(list);
if (contract.status === 'active') {
const contracts = loadContracts();
const cidx = contracts.findIndex((c) => c.id === contract.id);
if (cidx >= 0) {
contracts[cidx] = { ...contracts[cidx], status: 'performing' };
saveContracts(contracts);
}
}
return { ok: true, task: row, message: `已创建调度任务 ${row.taskCode}` };
}
export function assignDispatchTask(taskId, payload) {
const list = loadDispatchTasks();
const idx = list.findIndex((t) => t.id === taskId);
if (idx < 0) return { ok: false, message: '任务不存在' };
const t = list[idx];
if (['completed', 'cancelled'].includes(t.status)) {
return { ok: false, message: '已办结/已取消任务不可改派' };
}
const plateNo = String(payload.plateNo || '').trim();
if (!plateNo) return { ok: false, message: '请选择车牌' };
list[idx] = {
...t,
plateNo,
driver: String(payload.driver || t.driver || '').trim(),
phone: String(payload.phone || t.phone || '').trim(),
status: 'assigned',
assignedAt: nowStamp(),
};
saveDispatchTasks(list);
return { ok: true, task: list[idx], message: `已派车 ${plateNo}` };
}
export function startDispatchTask(taskId) {
const list = loadDispatchTasks();
const idx = list.findIndex((t) => t.id === taskId);
if (idx < 0) return { ok: false, message: '任务不存在' };
const t = list[idx];
if (t.status !== 'assigned') {
return { ok: false, message: '仅「已派」任务可开始执行' };
}
if (!t.plateNo) return { ok: false, message: '请先派车' };
list[idx] = { ...t, status: 'in_progress' };
saveDispatchTasks(list);
return { ok: true, task: list[idx], message: '任务已进入执行中' };
}
export function cancelDispatchTask(taskId) {
const list = loadDispatchTasks();
const idx = list.findIndex((t) => t.id === taskId);
if (idx < 0) return { ok: false, message: '任务不存在' };
const t = list[idx];
if (t.status === 'completed') {
return { ok: false, message: '已办结任务不可取消(台账行已生成)' };
}
if (t.status === 'cancelled') return { ok: true, task: t, message: '任务已是取消状态' };
list[idx] = { ...t, status: 'cancelled' };
saveDispatchTasks(list);
return { ok: true, task: list[idx], message: `已取消 ${t.taskCode}` };
}
/**
* 办结 → 自动生成台账行(严格:车辆须已交车)
*/
export function completeDispatchTask(taskId, confirmPayload) {
const list = loadDispatchTasks();
const idx = list.findIndex((t) => t.id === taskId);
if (idx < 0) return { ok: false, message: '任务不存在' };
const t = list[idx];
if (t.status === 'completed') {
return { ok: false, message: '任务已办结', ledgerRowId: t.ledgerRowId };
}
if (t.status === 'cancelled') {
return { ok: false, message: '已取消任务不可办结' };
}
if (!t.plateNo) return { ok: false, message: '请先派车再办结' };
const ops = getVehicleOpsStatus(t.plateNo);
if (ops !== VEHICLE_OPS.self_operated) {
return {
ok: false,
code: 'NEED_HANDOVER',
message: `${t.plateNo} 尚未交车(当前非自营运营态),请先「模拟交车」或完成运维交车后再办结`,
};
}
const dispatchDate = (confirmPayload && confirmPayload.dispatchDate) || t.planDate || todayDate();
const quantity = Number(confirmPayload?.quantity ?? t.quantity) || 1;
const unitPrice = Number(confirmPayload?.unitPrice ?? t.unitPrice) || 0;
const salary = Number(confirmPayload?.salary ?? t.salary) || 0;
const remark = String(confirmPayload?.remark ?? t.remark ?? '').trim();
const year = dispatchDate.slice(0, 4);
const month = String(Number(dispatchDate.slice(5, 7)) || 1);
const amount = computeAmount(unitPrice, quantity);
const ledgerDraft = {
id: `auto-${t.id}`,
year,
month,
dispatchDate,
businessName: t.businessName,
driver: t.driver,
phone: t.phone,
plateNo: t.plateNo,
systemVehicleType: brandModelOf(t.plateNo),
unitPrice,
quantity,
amount,
hydrogenFee: 0,
etcFee: 0,
salary,
electricityFee: 0,
manualReimbursement: 0,
dailySocialSecurityFee: 0,
dailyTrailerServiceFee: 0,
dailyTrailerFee: 0,
dailyParkingFee: 0,
dailyTireFee: 0,
vehicleFee: 0,
totalCost: 0,
profitLoss: 0,
multiTrip: t.multiTrip || '否',
routePricing: t.routePricing || '线路计价',
remark: remark ? `[调度自动·${t.taskCode}] ${remark}` : `[调度自动·${t.taskCode}]`,
rowSource: 'auto',
dispatchTaskId: t.id,
dispatchTaskCode: t.taskCode,
contractCode: t.contractCode,
};
ledgerDraft.totalCost = computeTotalCost(ledgerDraft);
ledgerDraft.profitLoss = roundMoney(amount - ledgerDraft.totalCost);
const autoRows = loadAutoLedgerRows();
const withoutDup = autoRows.filter((r) => r.dispatchTaskId !== t.id && r.id !== ledgerDraft.id);
withoutDup.unshift(ledgerDraft);
saveAutoLedgerRows(withoutDup);
list[idx] = {
...t,
status: 'completed',
quantity,
unitPrice,
salary,
remark,
completedAt: nowStamp(),
ledgerRowId: ledgerDraft.id,
};
saveDispatchTasks(list);
return {
ok: true,
task: list[idx],
ledgerRow: ledgerDraft,
message: `已办结 ${t.taskCode},并自动生成物流业务明细 1 行`,
};
}
// —— 台账自动行 ——
export function loadAutoLedgerRows() {
const stored = readJson(SO_LEDGER_AUTO_STORAGE_KEY, null);
return Array.isArray(stored) ? stored : [];
}
export function saveAutoLedgerRows(list) {
writeJson(SO_LEDGER_AUTO_STORAGE_KEY, list || []);
}
export function removeAutoLedgerRow(rowId) {
const next = loadAutoLedgerRows().filter((r) => r.id !== rowId);
saveAutoLedgerRows(next);
return next;
}
/**
* 合并种子/当前列表与自动生成行:自动行按 id 覆盖同 id其余追加到顶部
*/
export function mergeLedgerWithAutoRows(baseRows) {
const auto = loadAutoLedgerRows();
const autoIds = new Set(auto.map((r) => r.id));
const rest = (baseRows || []).filter((r) => !autoIds.has(r.id));
return [...auto, ...rest];
}
export function subscribeSelfOperatedBridge(handler) {
const fn = (e) => handler(e?.detail);
window.addEventListener('oneos-self-operated-bridge', fn);
window.addEventListener('storage', fn);
return () => {
window.removeEventListener('oneos-self-operated-bridge', fn);
window.removeEventListener('storage', fn);
};
}

View File

@@ -0,0 +1,40 @@
# 业务部台账 · 产品需求说明PRD
> 原型路径:`/prototypes/business-dept-ledger`
> 实现页:`src/prototypes/oneos-web-data-analysis/pages/05-业务部台账.jsx`
---
## 1. 模块定位
| 项 | 说明 |
|---|---|
| 目标用户 | 业务部业管 / 运营查看汇总 |
| 核心任务 | 按业务条线汇总查看业绩、成本、盈亏,并支持租赁/氢费下钻 |
| 内容来源 | 各业务明细模块按月汇总(原型本地种子,未接真实 API |
---
## 2. 各业务条线数据统计来源(摘要)
主表分组表头在业务名称后方标注最小颗粒度来源;悬停可查看业绩/成本/盈亏字段口径。
| 业务条线 | 最小颗粒度 | 业绩 | 成本 | 盈亏 |
|---|---|---|---|---|
| 物流业务 | 物流业务明细 | 「金额」列 | 「总成本」列 | 金额 总成本 |
| 租赁业务 | 租赁业务明细 | 「实收金额」列 | 「车辆实际成本」列 | 实收金额 车辆实际成本 |
| 氢费业务 | 车辆氢费明细 | 「加氢总价(元)」列 | 「成本总价(元)」列 | 加氢总价 成本总价 |
| 电费业务 | 充电订单 | 「对客费用(元)」列 | 「成本费用(元)」列 | 对客费用 成本费用 |
| ETC业务 | ETC业务 | 字段口径待补充 | 字段口径待补充 | 字段口径待补充 |
完整判定、前置条件与代码映射见 [sector-data-source.md](./sector-data-source.md)。
---
## 3. 验收重点
- [ ] 主表五条业务线后方分别显示:`物流业务明细` / `租赁业务明细` / `车辆氢费明细` / `充电订单` / `ETC业务`(不再显示「预留明细」)
- [ ] 悬停业务名称或来源标注,可看到该条线最小颗粒度与业绩/成本/盈亏口径
- [ ] 悬停「业绩」「成本」「盈亏」列头,提示对应明细字段
- [ ] 电费来源为「充电订单」ETC 来源为「ETC业务」
- [ ] 文档与页面口径一致:`.spec/sector-data-source.md`、本 PRD

View File

@@ -0,0 +1,60 @@
# 业务部台账 · 各业务条线数据统计来源
> 实现:`src/prototypes/oneos-web-data-analysis/pages/05-业务部台账.jsx`
> 常量:`SECTOR_DATA_SOURCE`、`SECTOR_METRIC_SOURCE`
> UI主表分组表头「业务名称 ← 明细来源」;业绩/成本/盈亏列悬停展示字段口径
## 对照数据源
| 业务条线 | 最小颗粒度数据来源 | 原型本地种子 | 真实 API |
|---|---|---|---|
| 物流业务 | 物流业务明细 | 是(汇总种子) | 未接 |
| 租赁业务 | 租赁业务明细 | 是(汇总种子) | 未接 |
| 氢费业务 | 车辆氢费明细 | 是(汇总种子) | 未接 |
| 电费业务 | 充电订单 | 是(汇总种子) | 未接 |
| ETC业务 | ETC业务 | 是(汇总种子) | 未接 |
主表当前展示的是**按月(或客户/事业部)汇总后的结果**;上表「最小颗粒度」指最终应回溯到的明细模块。
## 业绩 / 成本 / 盈亏口径(判定顺序)
按业务条线取数;同一条线内先取业绩、成本字段,再计算盈亏。
| 优先级 | 指标 | 规则 |
|---|---|---|
| 1 | 业绩 | 取对应明细模块指定金额列(见下表) |
| 2 | 成本 | 取对应明细模块指定成本列(见下表) |
| 3 | 盈亏 | 业绩 成本(见下表公式) |
### 分条线字段映射
| 业务条线 | 业绩来源列 | 成本来源列 | 盈亏公式 |
|---|---|---|---|
| 物流业务 | 物流业务明细「金额」 | 物流业务明细「总成本」 | 金额 总成本 |
| 租赁业务 | 租赁业务明细「实收金额」 | 租赁业务明细「车辆实际成本」 | 实收金额 车辆实际成本 |
| 氢费业务 | 车辆氢费明细「加氢总价(元)」 | 车辆氢费明细「成本总价(元)」 | 加氢总价(元)− 成本总价(元) |
| 电费业务 | 充电订单「对客费用(元)」 | 充电订单「成本费用(元)」 | 对客费用(元)− 成本费用(元) |
| ETC业务 | ETC业务字段口径待补充 | ETC业务字段口径待补充 | ETC业务字段口径待补充 |
## 前置条件
- 仅在业务部台账主表分组列展示来源标注;钻取子页「数据来源」仍使用 `SECTOR_DATA_SOURCE` 短名。
- ETC 业务本期仅确认最小颗粒度模块为「ETC业务」业绩/成本/盈亏字段列名待业务补充后更新本规格与代码常量。
## 用户可见结果
| 位置 | 展示 |
|---|---|
| 分组表头业务名称后方 | `←物流业务明细` / `←租赁业务明细` / `←车辆氢费明细` / `←充电订单` / `←ETC业务` |
| 悬停业务名称或来源标注 | 最小颗粒度 + 业绩/成本/盈亏口径全文 |
| 悬停「业绩」「成本」「盈亏」列头 | 该列对应字段口径 |
## 与代码映射
| 常量 / 函数 | 作用 |
|---|---|
| `SECTOR_DATA_SOURCE` | 表头后方短名;钻取页数据来源短名 |
| `SECTOR_METRIC_SOURCE` | 业绩/成本/盈亏字段口径 |
| `buildSectorSourceTooltip` | 分组表头悬停全文 |
| `renderSectorMetricTitle` | 指标列头悬停 |
| `renderSectorGroupTitle` | 渲染「业务名 ← 来源」 |

View File

@@ -0,0 +1,69 @@
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": {
"version": 2,
"prototypeName": "business-dept-ledger",
"pageId": "main",
"updatedAt": 1783938000000,
"nodes": [
{
"id": "bdl-list-table",
"index": 1,
"title": "主表 · 业务条线数据来源",
"pageId": "main",
"locator": {
"selectors": [
"[data-annotation-id=\"bdl-list-table\"]"
],
"fingerprint": "bdl-list-table",
"path": []
},
"aiPrompt": "业务部台账主表各业务条线业绩/成本/盈亏数据来源口径。",
"annotationText": "## 各业务条线数据统计来源\n\n分组表头业务名称后方标注最小颗粒度来源悬停查看业绩/成本/盈亏字段口径。\n\n| 业务条线 | 最小颗粒度 | 业绩 | 成本 | 盈亏 |\n|---|---|---|---|---|\n| 物流业务 | 物流业务明细 | 金额 | 总成本 | 金额 总成本 |\n| 租赁业务 | 租赁业务明细 | 实收金额 | 车辆实际成本 | 实收金额 车辆实际成本 |\n| 氢费业务 | 车辆氢费明细 | 加氢总价(元) | 成本总价(元) | 加氢总价 成本总价 |\n| 电费业务 | 充电订单 | 对客费用(元) | 成本费用(元) | 对客费用 成本费用 |\n| ETC业务 | ETC业务 | 待补充 | 待补充 | 待补充 |\n\n完整规格见 `.spec/sector-data-source.md`。原型本地种子,未接真实 API。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1783938000000,
"updatedAt": 1783938000000
}
],
"notes": {
"bdl-list-table": "## 各业务条线数据统计来源\n\n分组表头业务名称后方标注最小颗粒度来源悬停查看业绩/成本/盈亏字段口径。\n\n| 业务条线 | 最小颗粒度 | 业绩 | 成本 | 盈亏 |\n|---|---|---|---|---|\n| 物流业务 | 物流业务明细 | 金额 | 总成本 | 金额 总成本 |\n| 租赁业务 | 租赁业务明细 | 实收金额 | 车辆实际成本 | 实收金额 车辆实际成本 |\n| 氢费业务 | 车辆氢费明细 | 加氢总价(元) | 成本总价(元) | 加氢总价 成本总价 |\n| 电费业务 | 充电订单 | 对客费用(元) | 成本费用(元) | 对客费用 成本费用 |\n| ETC业务 | ETC业务 | 待补充 | 待补充 | 待补充 |\n\n完整规格见 `.spec/sector-data-source.md`。原型本地种子,未接真实 API。",
"bdl-doc-sector-data-source": "# 业务部台账 · 各业务条线数据统计来源\n\n见 `.spec/sector-data-source.md`。"
},
"directory": [
{
"type": "group",
"id": "bdl-dir-logic",
"title": "业务逻辑规格",
"children": [
{
"type": "note",
"id": "bdl-doc-sector-data-source",
"title": "各业务条线数据统计来源",
"markdownPath": ".spec/sector-data-source.md"
},
{
"type": "note",
"id": "bdl-doc-prd",
"title": "产品需求说明PRD",
"markdownPath": ".spec/requirements-prd.md"
}
]
},
{
"type": "group",
"id": "bdl-dir-page",
"title": "页面",
"children": [
{
"type": "annotation",
"id": "bdl-list-table",
"title": "主表 · 业务条线数据来源"
}
]
}
]
}
}

View File

@@ -0,0 +1,376 @@
# 保险采购 · 产品需求说明(全模块)
| 项 | 内容 |
|---|---|
| 模块名称 | 保险采购 |
| 所属系统 | ONE-OSPC |
| 交互原型 | `/prototypes/insurance-procurement` |
| 业务条线 | 运维管理条线(证照 / 保险相关) |
| 文档状态 | AutoPRD · 已对齐可运行原型 |
---
## 1. 一句话与目标
**一句话**
在同一模块内管理车队**保单台账**与**采购前比价审批**两条业务线,保证续保不遗漏、变更可追溯,并为交车提供交强险 + 商业险合规校验。
**要解决的问题**
- 保险信息散落在表格、邮件与线下批单中,台账不全、临期易漏
- 停保 / 复驶 / 退保缺少统一留痕
- 采购前多方比价难追溯,审批结果难跟踪
- 交车时无法与车辆管理共用同一套「保险是否有效」口径
**本期目标**
1. 一车一档维护五类险种台账;支持手工新增、批量识别、批量导入
2. 支持停保 / 复驶 / 退保办理与操作历史
3. 输出与车辆管理一致的车辆级保险状态,支撑交车拦截
4. 比价单批次管理:多方报价、最晚付费预警、勾选提交采购审批(本页只读跟踪)
**非目标(本期不做)**
- 比价单与保单台账**自动双向同步**;审批通过后**不自动写入**正式保单
- 在本模块内办理审批(通过 / 驳回 / 撤回均在审批中心)
- 维护行驶证、道路运输证等证照档案(见证照管理)
- 事故赔付、保险上浮等事故链路台账(见事故管理)
---
## 2. 模块边界(最重要)
```mermaid
flowchart LR
IPC[保险采购]
VM[车辆管理]
OPS[交车管理]
APPR[审批中心]
CERT[证照管理]
WB[工作台]
IPC -->|输出保险状态口径| VM
VM -->|交车前置校验| OPS
CERT -.->|并列交车前置| OPS
IPC -->|发起采购审批| APPR
APPR -->|回写采购状态| IPC
WB -->|临期 KPI / 快捷入口| IPC
```
| 业务线 / 子域 | 做什么 | 不做什么 |
|---------------|--------|----------|
| 保单管理 | 一车一档、多通道录入、停保/复驶/退保、状态与交车口径 | 不替代证照管理;不办事故赔付 |
| 比价单 | 批次比价、付费预警、提交采购、只读跟踪审批 | 不自动生成正式保单;不在本页审批 |
| 与车辆 / 交车 | 提供交强 + 商业是否有效的统一口径 | 不在本页完成交车动作 |
**外部依赖(产品口径)**
| 依赖方 | 交互方式 | 产品要求 |
|--------|----------|----------|
| 车辆管理 | 读取保险状态 / 到期日 | 口径与本模块一致:异常则禁止交车 |
| 交车管理 | 交付前校验交强 + 商业有效 | 不合规须可理解拦截原因 |
| 审批中心 | 接收采购申请并回写状态 / 当前审批人 | 本页只读展示,不办理审批 |
| 工作台 | KPI、快捷「发起保险采购」 | 跳转至本模块对应能力 |
| 证照管理 | 并列交车前置 | 证照与保险各自独立维护 |
**关键约束**
- **保单台账 ↔ 比价单互不自动同步**;审批通过后须运营在保单管理侧另行录入正式保单
- **交车只看交强险 + 商业险**;超赔 / 货物 / 驾意仅参与临期预警,不参与交车判定
- **最晚付费临期 / 超期**仅服务比价采购节奏,不写入保单台账
---
## 3. 用户与角色
| 角色 | 主要目标 |
|------|----------|
| 运维部 | 维护保单台账、办理停保/复驶/退保;交车前确认保险有效 |
| 安全部 | 从合规视角关注保险与证照并列的交车前置 |
| 采购部 | 创建比价单、录入报价、提交采购申请;关注临期与付费节奏 |
| 业务管理组(业管) | 在审批中心确认报价等节点(本页只读看到结果) |
> 部门名对齐业务条线说明;`module-role-mapping` 将本模块主责记为运维部、安全部,采购部为比价线主操作用户。
---
## 4. 用户故事与故事点(业务条线说明口径)
> 故事点供排期参考,可按团队基准调整。合计约 **28 SP**。
### Epic A · 保单台账与交车合规(约 15 SP
#### A1 · 一车一档台账与检索(约 3 SP
- **角色**:运维部、采购部
- **起点**:需要按车牌 / VIN / 运营状态 / 保险状态等快速定位车辆保险档案。
- **怎么运作**
1. 在列表筛选或点击 KPI 卡片缩小范围。
2. 查看五类险种到期日与车辆级保险状态。
3. 进入「管理」打开车辆保险档案。
- **关键结果**`可按条件定位车辆` / `空态提示无可匹配台账`
- **闭环**:台账可检索、可下钻,与 KPI 口径一致。
- **排期(可选)**US-IPC-01 · 规模 M · 作为运维/采购,我想快速筛出台账车辆,以便处理续保与异常。
#### A2 · 多通道录入正式保单(约 5 SP
- **角色**:运维部、采购部
- **起点**:拿到新保或续保材料(纸质 / 电子保单、批量表格)。
- **怎么运作**
1. 选择新增、保单批量识别或批量导入。
2. 识别类须人工确认后再落库;导入仅处理新保/续保。
3. 按车牌/VIN或批单场景按保单号匹配车辆并写入对应险种台账。
- **关键结果**`确认后落库` / `识别失败可理解提示` / `车牌不一致则拦截`
- **闭环**:正式保单进入一车一档,列表与车辆管理可见最新到期与状态。
- **排期(可选)**US-IPC-02 · 规模 L · 作为运维/采购,我想用识别或导入补齐台账,以便续保不遗漏。
#### A3 · 停保 / 复驶 / 退保留痕(约 4 SP
- **角色**:运维部
- **起点**:车辆需临时停保、恢复行驶或办理退保。
- **怎么运作**
1. 在车辆保险档案对**当前有效**记录发起对应办理。
2. 填写时间、金额等业务字段并上传批单附件。
3. 写入操作历史;更新险种与车辆状态展示。
- **关键结果**`变更可追溯` / `归档记录只读`
- **闭环**:停保/复驶/退保全程留痕,交车口径随之更新。
- **排期(可选)**US-IPC-03 · 规模 M。
#### A4 · 交强 + 商业决定是否可交车(约 3 SP
- **角色**:运维部、采购部
- **起点**:采购/运维维护交强险与商业险台账,作为交车前置合规数据。
- **怎么运作**
1. 台账维护一车一档核心险种。
2. 交车时按「交强 + 商业均为正常或临期」判定是否放行。
3. 异常(未购买 / 已到期 / 已退保视同无效)则拦截并提示原因。
- **关键结果**`允许交车` / `禁止交车 · 保险异常`
- **闭环**:交车合规与台账一致、可追溯;异常车辆不能进入履约运营。
- **排期(可选)**US-17 · 规模 M · 作为采购/运维,我想维护交强+商业险并在交车时校验,以便不合规车辆无法交车。
### Epic B · 比价采购与审批跟踪(约 13 SP
#### B1 · 比价单批次与付费预警(约 4 SP
- **角色**:采购部
- **起点**:需要按批次组织待购车辆与险种,并盯住最晚付费日。
- **怎么运作**
1. 在比价单管理新建或编辑批次;可从临期预警一键带入。
2. 用全部 / 临期≤3 天)/ 超期看板筛选批次。
3. 保存时校验购买记录、备注与附件。
- **关键结果**`可按付费节奏跟进` / `未满足保存条件则拦截`
- **闭环**:采购节奏可视、可预警;不与保单台账自动混写。
- **排期(可选)**US-IPC-04 · 规模 M。
#### B2 · 多方报价与提交采购(约 5 SP
- **角色**:采购部
- **起点**:同一购买需求需比较多家保司报价后发起采购。
- **怎么运作**
1. 为每行录入多家报价并指定唯一最终比价。
2. 填写最晚付费日,勾选可提交行。
3. 提交采购申请进入审批中心。
- **关键结果**`审批中/已通过行不可再提` / `撤回或驳回后可再提`
- **闭环**:比价过程可追溯;正式保单仍须审批通过后在台账侧补录。
- **排期(可选)**US-IPC-05 · 规模 L。
#### B3 · 审批只读回显(约 4 SP
- **角色**:采购部、业务管理组
- **起点**:采购申请已在审批中心流转。
- **怎么运作**
1. 本页展示采购状态与当前审批人(可为空)。
2. 业管等角色在审批中心办理节点。
3. 结果回写本页;通过后运营去保单管理录入正式保单。
- **关键结果**`本页不可办理审批` / `状态与审批中心一致`
- **闭环**:采购决策可跟踪;台账仍以人工录入为准。
- **排期(可选)**US-IPC-06 · 规模 M。
---
## 5. 功能模块说明(正向 / 逆向)
### 5.1 保险台账列表与 KPI
**正向**
1. 按车牌、VIN、品牌型号、运营状态、保险状态、险种 + 到期区间筛选。
2. 点击 KPI台账车辆、状态正常、核心逾期、核心待购等联动列表或打开预警。
3. 行内「管理」打开车辆保险档案。
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 无匹配数据 | 空态提示无可匹配台账车辆 |
| 退出运营车辆 | 列表可查看但样式弱化 |
| 险种到期筛选未选险种 | 须先选险种再筛到期区间 |
| 车牌与多车牌条件互斥 | 按产品约定互斥,避免条件冲突 |
### 5.2 保单录入(新增 / 识别 / 导入)
**正向**
1. 新增:完整字段手工录入后保存。
2. 批量识别:选业务类型 → 上传 → 识别 → **人工确认** → 落库。
3. 批量导入:下载模板填写 → 上传 → 仅新保/续保入库。
4. 识别任务记录:查看进度,对待确认结果继续确认。
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 识别失败 | 提示清晰度 / 格式 / 损坏等原因 |
| 无成功结果可确认 | 提示暂无识别成功结果 |
| 识别车牌与所选车不一致 | 拦截并提示两边车牌 |
| 导入中的停保/复驶/退保行 | 自动跳过 |
| OCR 确认页 | 不展示承保险种明细、分项保费(手工路径仍可填) |
### 5.3 车辆保险档案 · 停保 / 复驶 / 退保
**正向**
1. 分险种查看历史;对当前有效记录办理停保、复驶或退保。
2. 填写业务时间 / 金额等并上传批单附件。
3. 全周期时间轴与操作历史可追溯。
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 历史归档记录 | 只读,不可再办停保/退保 |
| 已退保 | 仅允许复驶 |
| 已停保 | 可复驶或退保 |
| 新保覆盖已退保险种 | 旧记录归档后再写入新保单 |
### 5.4 比价单管理与编辑
**正向**
1. 新建 / 编辑比价单,添加购买记录与多方报价。
2. 设最终比价、最晚付费日;保存备注与附件。
3. 勾选行提交采购;跟踪审批状态。
4. 可从险种临期预警一键生成比价单(附件须补传)。
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 保存校验不通过 | 拦截并提示缺记录 / 备注 / 附件等 |
| 审批中或已通过行 | 勾选禁用,不可重复提交 |
| 撤回 / 驳回 | 恢复可提交 |
| 修改行内险种 | 清空该行报价与最终比价 |
| 删除比价单 | 二次确认,删除后不可恢复 |
| 一键生成时已有审批中/通过的同车同险 | 跳过该条 |
---
## 6. 关键业务逻辑(必须对齐)
### 6.1 险种范围
| 险种 | 台账 | 临期预警 | 交车判定 |
|------|------|----------|----------|
| 交强险 | 是 | 是 | **是(核心)** |
| 商业险 | 是 | 是 | **是(核心)** |
| 超赔险 | 是 | 是 | 否 |
| 驾意险 | 是 | 是 | 否 |
| 货物险 | 是 | 是 | 否 |
### 6.2 单险种状态(按优先级命中即止)
1. 已退保
2. 无保单号 → 未购买
3. 停保标记 → 已停保
4. 有号但无到期日 → 未购买
5. 到期日 ≤ 今天 → 已到期
6. 到期日 ≤ 今天 + 30 天 → 临期
7. 否则 → 正常
列表到期日取同车同险种**最新一条**记录。
### 6.3 车辆级保险状态(交车联动)
| 条件 | 车辆保险状态 | 交车 |
|------|--------------|------|
| 交强与商业均为正常或临期(仍在有效期内) | 正常 / 临期 | **允许**(临期可提示尽快续保) |
| 交强或商业任一未购买或已到期 | 异常 | **禁止** |
| 已退保 | 视同无有效保障 | **禁止** |
### 6.4 新保 / 续保录入必填(业务口径)
- 车牌与车架号至少填一项
- 必填:险种、保险公司、保单号、生效日、到期日、保险费合计
- 承保险种明细、分项保费:非必填
### 6.5 批单匹配方式
| 业务 | 匹配方式 |
|------|----------|
| 保单录入 | 按车牌 / 车架号匹配车辆 |
| 停保 / 复驶 / 退保 | 按**保单号**在全库匹配已有记录 |
### 6.6 比价保存与提交
**保存须同时满足**:至少 1 条购买记录;每行已选车辆;整单备注非空;整单至少 1 个附件。
**提交采购另须**:至少勾选 1 行;行内已有报价并设最终比价;已填最晚付费日;采购状态为未提交 / 撤回 / 驳回;未保存须先保存。
**报价**:保司 + 金额大于 0同一保司不可对同行重复报价。
**最晚付费预警(仅比价)**:≤ 3 个自然日为临期;已过为超期。
### 6.7 采购状态(购买记录行级)
| 状态 | 可否再提交 | 说明 |
|------|------------|------|
| 未提交 | 可以 | 默认 |
| 审批中 | 不可以 | 本页提交后写入;勾选禁用 |
| 审批通过 | 不可以 | 审批中心回写;勾选禁用 |
| 撤回 / 审批驳回 | 可以 | 审批中心回写后可再提 |
> 审批办理不在本页;当前审批人由工作流回写,可为空。
---
## 7. 总览流程图
```mermaid
flowchart TB
entry[保险采购入口]
entry --> policy[保单管理]
entry --> compare[比价单]
policy --> input[新增 / 识别 / 导入]
input --> confirm[人工确认落库]
confirm --> archive[车辆保险档案]
archive --> change[停保 / 复驶 / 退保]
archive --> status[更新车辆保险状态]
status --> handover[交车校验交强+商业]
compare --> batch[新建或预警生成批次]
batch --> quote[多方报价与最终比价]
quote --> save[保存备注与附件]
save --> submit[勾选提交采购]
submit --> appr[审批中心流转]
appr --> readonly[本页只读回显]
readonly --> manual[审批通过后人工录入正式保单]
manual --> policy
```
---
## 8. 验收清单(产品 / 测试)
**保单台账**
- [ ] 筛选与 KPI 口径一致;空态文案正确
- [ ] 五类险种独立台账;列表到期日取最新一条
- [ ] 新增 / OCR 确认 / 导入(仅新保续保)均可落库
- [ ] OCR 失败、车牌不一致有明确拦截
- [ ] 停保 / 复驶 / 退保须附件并写入操作历史;归档只读
**交车合规**
- [ ] 仅交强 + 商业决定车辆级状态
- [ ] 异常禁止交车;正常/临期允许(临期可提示续保)
- [ ] 超赔 / 货物 / 驾意不参与交车判定
**比价采购**
- [ ] 保存 / 提交校验完整
- [ ] 最晚付费临期 ≤3 天、超期看板正确
- [ ] 审批中 / 通过行不可再提;撤回 / 驳回可再提
- [ ] 审批通过**不**自动写入保单台账
- [ ] 本页不可办理审批,状态只读回显
**边界**
- [ ] 保单与比价互不自动同步
- [ ] 一键生成跳过已审批中/通过的同车同险
---
## 9. 交付口径
> 「保险采购」在 OneOS PC 承载**保单台账**与**比价采购**两条独立业务线:运维部、采购部可一车一档维护交强 / 商业等五类险种并办理停保复驶退保,车辆管理与交车据此校验核心险种有效性;采购部可在比价单中完成多方报价、付费预警与采购审批跟踪,审批通过后须人工补录正式保单——台账与比价互不同步,审批不在本页办理。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,68 @@
# 流程图
> 标注面板支持 Mermaid 渲染。下列流程与当前原型行为一致(本地 Store未接真实 API
---
## 1. 主路径:从打开到上报
```mermaid
flowchart TD
A[打开加氢订单 H5] --> B[查看本站列表与 KPI]
B --> C{前一日手工台账已上传?}
C -->|否| D[提示:手工台账有缺失…]
D --> E[进入手工台账 · 选中昨日]
E --> F[拍照/相册上传并确认]
F --> C
C -->|是| G[点新增]
G --> H[填写时间/车牌/面板/量价]
H --> I{同站同车牌同时间已存在?}
I -->|是| J[拦截提示]
J --> H
I -->|否| K[保存写入 Bridge]
K --> B
```
---
## 2. 手工台账补传
```mermaid
flowchart LR
A[手工台账 Tab] --> B[月历:绿已传 / 橙未传]
B --> C[点选 ≤今日 且 ≥首笔日]
C --> D[上传图片]
D --> E[二次确认]
E --> F[归档不可删 · 可追加]
```
---
## 3. 编辑 / 删除与锁定
```mermaid
flowchart TD
A[打开详情] --> L{羚牛车辆?}
L -->|否| D[可编辑 / 可删除]
L -->|是| B{已核对或已对账?}
B -->|是| C[只读 · 禁改删]
B -->|否| D
D --> E[写回 Bridge]
```
---
## 4. 与 Web / 台账联动(概念)
```mermaid
sequenceDiagram
participant H5 as 加氢订单 H5
participant BR as Bridge Store
participant WEB as 加氢记录 Web
participant LED as 车辆氢费明细
participant SITE as 站点对账单
H5->>BR: 新增/改删流水 · 上传台账
WEB->>BR: 同 Store 读写 · 导出
LED->>BR: 完成核对 → 已核对
SITE->>BR: 提交对账单 → 已对账
```

View File

@@ -0,0 +1,52 @@
# 关键逻辑索引
本页汇总加氢订单H5复杂判定入口细则见各规格全文。原型数据均为本地种子 / 内存 Store**未接真实 API**。
---
## 1. 判定主题一览
| 主题 | 一句话 | 全文 |
|---|---|---|
| 手工台账门禁 | **昨日**未传 → 今日禁新增;固定提示文案并直达台账 | [feature-manual-ledger.md](./feature-manual-ledger.md) |
| 核对 ≠ 对账 | 能源部核对 / 站点对账单提交,触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md) |
| 记录锁定 | **仅羚牛**:已核对或已对账 → 站端不可改删;非羚牛不锁 | [feature-detail.md](./feature-detail.md)、[reconcile-linkage.md](./reconcile-linkage.md)、[fleet-plate-tag.md](./fleet-plate-tag.md) |
| 重复键 | 同站 + 同车牌 + 同加氢时间 | Bridge / [Web record-data-model](../oneos-web-h2-station/.spec/record-data-model.md) |
| 总额计算 | 总额 = 单价 × 量(两位小数) | [feature-create.md](./feature-create.md) |
| 羚牛车辆 | 本地系统车牌集合 | [fleet-plate-tag.md](./fleet-plate-tag.md) |
---
## 2. 手工台账门禁(摘要)
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | 编辑已有记录 | 不校验 |
| 2 | 昨日早于本站首笔加氢日 | 不要求昨日台账,可新增 |
| 3 | 昨日已有 ≥1 张台账图 | 可新增 |
| 4 | 昨日未上传 | 提示并跳转手工台账(选中昨日) |
用户可见文案:`手工台账有缺失,作为对账重要依据,请先上传手工台账`
---
## 3. 核对 / 对账(摘要)
| 状态 | 谁触发 | 站端影响 |
|---|---|---|
| 已核对 | 车辆氢费明细「完成核对」 | **羚牛**不可改删 |
| 已对账 | 站点信息对账单提交 | **羚牛**不可改删;回写对账时间 |
| 非羚牛 | — | 不参与核对/对账展示与锁定 |
H5 列表主要展示核对状态(仅羚牛);对账细节见联动规格。
---
## 4. 代码路径
| 能力 | 路径 |
|---|---|
| Bridge | `src/common/h2VehicleLedgerBridge.js` |
| 门禁工具 | `utils/manual-ledger.ts``isManualLedgerGateBlocked` |
| 订单 CRUD | `utils/orders.ts` |
| 页面 | `index.tsx` / `ManualLedgerPanel` / `CreateWizard` |

View File

@@ -0,0 +1,64 @@
# Prototype Review
- 审查目标:`src/prototypes/oneos-h5-h2-order`(加氢站管理 → **加氢订单** H5入口 `index.tsx`
- 用户资料/参考资料:
- 本轮用户说明:对「加氢订单」执行 Prototype Review / 需求评审(未另附独立需求附件)
- `.spec/``requirements-prd.md`v1.7)、`roles.md``user-stories.md``key-logic.md``flows.md``feature-list.md``feature-create.md``feature-detail.md``feature-manual-ledger.md``fleet-plate-tag.md``reconcile-linkage.md`;早期 `2026-07-14-requirements-design.md`(已标注作废条目)
- `src/resources/``oneos-web-legacy/…/车辆氢费明细-需求文档.md`(文首已注明以 verify-reconcile / reconcile-linkage 为准)
- 关联规格:`oneos-web-h2-station/.spec/fleet-vehicle-verify.md``record-data-model.md``vehicle-h2-fee-ledger/.spec/verify-reconcile.md`
- 源码:`index.tsx``components/*``utils/*``types.ts``annotation-source.json`;共享 Bridge / 品牌 Store
- 生成时间2026-07-18 20:14
- 修复闭环2026-07-18 20:32按 Review 处理全部冲突与 P0P3 项)
- 资料冲突/待确认:**无**(已对齐)
- 独立评估:`full`
## 总体点评
加氢订单 H5 主路径完整可演示。评审指出的冲突已闭环:**昨日门禁文案**、**列表顶栏橙条**、**锁定仅羚牛**(规格与实现一致)、**早期切站稿作废标注**、**删除确认「未锁定」**、历史 resources 权威顺序说明。剩余为原型边界内 MockOCR / 车机 / 无真实 API不构成业务冲突。
## P0-P3 优先级问题
### P1 - 手工台账页文案把「今日上传」当成新增门禁 — **已修复**
- 处理:改为「今日不作为新增门禁,仅要求前一日已上传」;昨日缺失时台账页单独橙条并可选中昨日。
### P2 - 「记录锁定」规格未写明羚牛例外 — **已修复**
- 处理:`feature-detail.md` / `reconcile-linkage.md` / `key-logic.md` / `flows.md` / PRD v1.7 / US-04 统一为「锁定仅羚牛」;与 `isOrderLocked` 实现一致。
### P2 - 门禁提示缺列表顶栏橙条 — **已修复**
- 处理:订单列表顶部增加可点橙条(跳转台账并选中昨日);保留 FAB 旁提示 + Tab 红点。
### P3 - 早期设计文档仍写切站 — **已修复**
- 处理:`2026-07-14-requirements-design.md` 文首与表格标注作废/覆盖项;权威以 PRD 为准。台账规格区分 H5 固定本站 vs Web 多站。
### P3 - 删除确认文案偏「未对账」 — **已修复**
- 处理:确认语改为「确认删除车牌 … 的未锁定记录?」;台账二次确认与 Web 文案对齐。
## 完整性与项目对齐
| 维度 | 结论 |
|------|------|
| 核心用户 / 主流程 | 对齐 PRD v1.7 |
| 范围边界 | 真登录 / 真 OCR / PLC / 原生 App / 后端联调仍不做 |
| 资料冲突 | 已消除resources 旧文档文首指向现行核对≠对账规格 |
| 双端口径 | 门禁昨日、锁定羚牛、Bridge 共享 — 与 Web / 台账一致 |
## 业务逻辑连贯性
- 台账门禁:昨日未传 → 顶栏橙条 / Toast / 跳转台账选中昨日;今日状态 ≠ 门禁。
- 锁定:先判羚牛,再判核对∨对账。
- 总额偏差二次确认、重复键拦截:与 Web 共用规则。
## 状态、异常、边界与恢复
评审所列空态、门禁、必填、重复、脏返回、锁定、归档路径仍成立;本轮仅修正文案/提示位与规格一致性,未改变 Mock 边界。
## 证据与评估说明
- 用户资料优先级:无新附件;以 `.spec` PRD v1.7 为基线。
- 读取范围:见文首;闭环改动见 `ManualLedgerPanel.tsx``index.tsx` 及上述规格文件。
- 独立评估:`full`

View File

@@ -0,0 +1,38 @@
# 需求角色
| 项 | 说明 |
|---|---|
| 适用原型 | 加氢订单H5`oneos-h5-h2-order` |
| 关联 Web | 加氢记录 `oneos-web-h2-station`(同一业务、不同端) |
| 数据 | 原型本地种子 + 共享 Bridge未接真实登录 / API |
---
## 1. 角色一览
| 角色 | 端 | 核心诉求 | 本原型是否为主用户 |
|---|---|---|---|
| **加氢站操作人员** | H5手机浏览器 | 在站场快速上报本站加氢流水;补传手工台账照片 | **是** |
| **加氢站站长 / 多站管理员** | Web 为主H5 可看本站 | 多站台账、导出、手工台账补传与督导 | 间接H5 固定单站演示) |
| **能源部核对人员** | 车辆氢费明细 | 完成核对,回写「已核对」 | 否(触发方在台账) |
| **站点对账人员** | 站点信息 · 对账单 | 提交对账单,回写「已对账」 | 否(触发方在站点信息) |
| **超级管理员** | Web | 查看全部站点流水与台账 | 否Web 标注状态演示) |
---
## 2. 本原型主角色:加氢站操作人员
| 项 | 说明 |
|---|---|
| 场景 | 户外 / 站场,手机竖屏 |
| 权限边界 | 仅本站数据;不可切站(原型固定演示站) |
| 日常动作 | 看列表 KPI → 上传昨日手工台账(若缺失)→ 新增加氢记录 → 查看/编辑未锁定记录 |
| 约束 | 前一日手工台账未上传则今日不可新增;已核对或已对账不可改删 |
---
## 3. 角色边界(本期不做)
- 真登录与组织权限中心对接
- 能源部 / 对账人员在 H5 内操作核对或对账
- 跨站汇总与导出(属 Web「加氢记录」

View File

@@ -0,0 +1,84 @@
# 用户故事
> 以加氢站操作人员为主;验收口径见 [requirements-prd.md](./requirements-prd.md)。
---
## US-01 查看本站加氢订单
**作为** 加氢站操作人员,
**我希望** 打开加氢订单即看到本站列表与 KPI
**以便** 快速了解今日/近期上报情况。
**验收要点**
- 默认已登录本站;无切站
- KPI订单数 / 加氢量 / 加氢金额随列表汇总
- 卡片展示加氢时间、车牌、量、额、核对状态等
---
## US-02 补传缺失的手工台账后再新增
**作为** 加氢站操作人员,
**我希望** 在前一日手工台账未上传时被明确拦截,并直达台账页补传,
**以便** 保证纸质台账作为对账依据不缺失。
**验收要点**
- 顶栏橙条 + 底栏提示:「手工台账有缺失,作为对账重要依据,请先上传手工台账」
- 点「新增」或顶栏橙条跳转手工台账并选中昨日
- 补传昨日确认后可新增
---
## US-03 新增加氢记录
**作为** 加氢站操作人员,
**我希望** 用车牌键盘 + 面板拍照/OCR 快速填单,
**以便** 在站场少打字完成上报。
**验收要点**
- 加氢时间默认=点击新增时刻(到分)
- 站名只读为本站
- 总额 = 单价 × 加氢量(界面不展示公式)
- 同站同车牌同时间重复保存拦截
---
## US-04 查看详情并编辑/删除未锁定记录
**作为** 加氢站操作人员,
**我希望** 打开详情查看字段与识别原图,并对未核对未对账记录改删,
**以便** 纠正录入错误。
**验收要点**
- 羚牛车辆已核对或已对账:不可编辑/删除,有锁定说明;非羚牛不锁定
- 详情展示车牌/面板含水印原图
---
## US-05 按日历管理手工台账
**作为** 加氢站操作人员,
**我希望** 在月历上看已传/未传并补传过往日,
**以便** 多日未操作后仍能补齐缺口。
**验收要点**
- 自首笔加氢日起绿/橙标记;首笔前不要求
- 禁选未来日;已归档图不可删,仅可追加
---
## US-06 识别羚牛车辆
**作为** 加氢站操作人员,
**我希望** 在列表/详情看到是否羚牛车辆,
**以便** 理解后续核对对账是否适用该车。
**验收要点**
- 标签:羚牛车辆 / 非羚牛车辆(本地车辆集合判定)

View File

@@ -16,17 +16,6 @@ import {
import { PhoneShell } from './PhoneShell';
import { PlateKeyboardSheet } from './PlateKeyboardSheet';
function listDispenserBrandHints(): string[] {
const store = (window as unknown as { H2DispenserBrandStore?: { labels?: () => string[] } })
.H2DispenserBrandStore;
if (!store || typeof store.labels !== 'function') return [];
try {
return store.labels().slice(0, 4);
} catch {
return [];
}
}
type Props = {
mode: 'create' | 'edit';
draft: CreateDraft;
@@ -107,7 +96,6 @@ export function CreateWizard({
const [plateOpen, setPlateOpen] = useState(false);
const vehicleTag = useMemo(() => getVehicleTag(draft.plateNo), [draft.plateNo]);
const brandHints = listDispenserBrandHints();
function patchDraft(patch: Partial<CreateDraft>) {
const nextPrice = patch.unitPrice !== undefined ? patch.unitPrice : draft.unitPrice;
@@ -380,12 +368,6 @@ export function CreateWizard({
<p></p>
</div>
</div>
{brandHints.length ? (
<p className="h5-brand-hint" data-annotation-id="h5-dispenser-brands">
OCR {brandHints.join('、')}
{brandHints.length >= 4 ? '…' : ''}
</p>
) : null}
<div
className={`h5-shot h5-shot--compact${draft.panelPreviewUrl ? ' h5-shot--filled' : ''}`}
role="button"

View File

@@ -0,0 +1,38 @@
# 业务记录 · 纵向时间轴
详情中租赁 / 保险历史 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审等业务记录,统一为**近 → 远纵向时间轴**展示(与租赁记录同一视觉与交互骨架)。
## 结构
1. **时间筛选**(事故及右侧 Tab快捷「全部 / 近30天 / 近90天 / 近1年 / 自定义起止」;按各 Tab 关键时间过滤;无匹配可重置
- **自定义起止**:底部原生滚轮日期选择(年 / 月 / 日,精确到日);起日不得晚于止日
- 本模块凡需选日的入口,统一用该原生日期选择器,不使用浏览器原生 `date` 控件
2. **时间节点**:左侧圆点 + 连线;节点日期取该条业务关键时间(近 → 远)
3. **记录卡片**:主标题(多为完整时间)+ 键值行 + 角标状态
4. **未闭环强调**:进行中 / 未还车 / 未解决 / 未处理等节点用强调色圆点与日期
## 各 Tab 关键时间
| Tab | 节点日期 / 筛选字段 | 卡片要点 |
|-----|---------------------|----------|
| 租赁 | 交车/还车较近者 | 合同、项目、客户、交还车;未还车文案;客户/项目搜索 |
| 保险历史 | 采购时间(无则到期) | 保单号、公司、状态、到期;附件预览/下载 |
| 事故 | 事故时间 | 标题为事故时间;下侧:单号、位置、等级、类型、客户、状态 |
| 故障 | 上报时间 | 标题为上报时间;下侧:编号、等级、类型、解决;故障说明用只读文本域 |
| 违章 | 违章时间 | 标题为违章时间;下侧:行为、位置、缴费、处理、客户 |
| 异动 | 开始日 | 标题为开始时间;下侧:类型、目的地、预计结束 |
| 调拨 | 调拨日 | 标题为调拨日期;下侧:出发城市、到达城市、停车场、接收人、接收日 |
| 年审 | 执行时间 | 标题为执行时间;下侧:有效期至、检测站、执行人 |
保险 Tab 仍保留总览 + 五险种索引 + 当前摘要;**采购/批单历史**改为时间轴。
## 数据源
种子优先;不足时 H5 演示兜底(见 [demo-biz-records.md](./demo-biz-records.md))。
## 代码路径
- 通用:`components/RecordTimeline.tsx``TimelineDateFilter.tsx``NativeDatePickerSheet.tsx`
- 租赁:`LeaseRecordsPanel.tsx`
- 保险:`InsuranceRecordsPanel.tsx`
- 其余:`BizTimelinePanels.tsx`

View File

@@ -0,0 +1,25 @@
# 业务记录 · 演示用例数据H5
详情中租赁 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审等业务 Tab在**台账种子无该车记录**时,由 H5 本地按车牌稳定生成演示记录;租赁不足 3 条时补足,便于查看完整时间轴。
## 判定优先级
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | PC 同源种子已有该车牌记录且条数足够 | 使用种子(不覆盖) |
| 2 | 租赁可由车辆台账交还车字段推导 | 使用推导记录,不足 3 条再补历史演示 |
| 3 | 仍为空,或条数不足 3 | 生成 / 补足演示用例(租赁至少 3 条1 条在租 + 2 条已还车历史) |
## 前置条件
- 仅 H5「车辆资产」详情生效不写入真实 API
- 演示字段取自该车台账(客户、项目、城市、停车场、里程等)+ 车牌哈希,换车内容会变、同车刷新稳定
## 空状态
无任何可展示记录时(极少):插画空态 + 标题「暂无记录」+ 引导回 PC 录入。
## 代码路径
- 兜底:`utils/demo-biz-records.ts`
- 空态:`components/RecordsEmpty.tsx`

View File

@@ -0,0 +1,31 @@
# 检验有效期剩余天数 · 沪牌等级评定
## 检验有效期 · 剩余天数标签
| 项 | 口径 |
|----|------|
| 展示位置 | 基本信息「证照 / 保险概览」· 检验有效期;证照信息「行驶证」· 检验有效期 |
| 有有效日期 | 日期后方标签:`剩余x天`;已过期则为 `已过期x天` |
| 无日期 | 文案「未上传」,无标签 |
| 临期着色 | 剩余 ≤ 30 天:警告色;已过期:危险色;其余:主色浅底 |
| 计算 | 到期日相对今天的日历天数差(与 PC 一致) |
## 沪牌等级评定(仅车牌以「沪」开头)
| 字段 | 展示 |
|------|------|
| 等级评定时间 | 日期;空为「未上传」 |
| 剩余天数 | `剩余x天` / `已过期x天`;无日期为「—」 |
- 证照信息 Tab 在行驶证内展示沪牌等级评定(与 PC 一致);基本信息档案区亦有等级评定字段
- 非沪牌不展示
## 数据源
- 检验有效期:台账检验有效期 / 行驶证检验有效期
- 等级评定:台账等级评定时间(空时证照 Tab 可回退行驶证下次评定日)
- 原型本地计算剩余天数,未接实时服务端日历接口
## 代码路径
- `components/detailTabs.tsx``ExpireValue` / `formatRemainDaysLabel` / `isShanghaiPlate`

View File

@@ -0,0 +1,37 @@
# 保险记录 · 与保险采购对齐
详情「保险记录」Tab 数据与险种口径对齐 **保险采购 / PC 车辆资产保险记录**
## 结构
1. **车辆保险档案总览**:整体保险状态(正常 / 临期 / 异常)及说明(交强险 + 商业险判定,与保险采购联动口径一致)
2. **五险种索引**(单行横滑密排):交强险、商业险、超赔险、驾意险、货物险
- 展示险种名 + 简短状态(正常/临期/未购等)+ 历史条数;到期日在下方详情区
3. **当前险种详情**
- 摘要:保单号、保险公司、到期日期、保费
- 历史:纵向时间轴(近→远);节点取采购时间;卡片含保单号、公司、状态、到期、保单附件(预览 / 下载);不展示导入时间与付款时间
手机端保单附件:
- **预览**:壳内底部弹层展示文件名与演示影像(正式环境预览真实 PDF
- **下载**:触发本地下载演示文件并 Toast「已开始下载」
- 无附件显示「无附件」
## 状态口径(摘要)
| 整体状态 | 条件 |
|----------|------|
| 正常 | 交强险、商业险均在有效期内(含临期、停保仍可交车语义与 PC 一致时按 PC |
| 临期 | 交强或商业有效但临期 |
| 异常 | 交强或商业未购买 / 已到期 / 已退保等 |
手机端只读录入;保单附件支持预览与下载(原型演示)。某险种无历史时展示带插画的紧凑空态,引导回 PC「保险采购」录入。
## 数据源
- `resolveVehicleInsuranceOverview`(保险采购记录 + 车辆保险台账种子)
- 原型本地种子,未接正式保险采购写接口
## 代码路径
- UI`components/InsuranceRecordsPanel.tsx`
- 数据:`vehicle-management/utils/vehicleInsuranceRecords.ts`

View File

@@ -0,0 +1,42 @@
# 租赁记录 · 纵向时间轴
详情「租赁记录」以**时间为主轴**展示,由近到远;支持按客户名称 / 项目名称即时搜索。
## 结构
1. **搜索栏**(时间轴上方):提示「搜索客户名称 / 项目名称」;可一键清空
2. **纵向时间轴**:左侧圆点 + 连线;节点日期取该条记录交车/还车中较近的日期
3. **记录卡片**(节点右侧):
- 客户合同编码
- 项目名称
- **客户**(交车上方,始终展示)
- 交车时间
- 还车时间;未还车时显示 **未还车**(强调色)
- 业务类型角标(租赁 / 物流)
## 排序
按节点日期降序(近 → 远)。搜索只过滤可见项,不改变排序规则。
## 搜索匹配
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | 关键词为空 | 展示该车全部租赁记录 |
| 2 | 客户名称或项目名称包含关键词(不区分大小写) | 保留该条 |
| 3 | 均不包含 | 过滤掉;全部无匹配时展示「暂无匹配记录」+ 清空搜索 |
**前置条件**
- 该车无任何租赁记录:只展示「暂无记录」,不展示搜索栏
- 仅匹配客户名称、项目名称;合同编码等不参与搜索(本期)
## 数据源
- `resolveVehicleLeaseRecords`(租赁记录种子;无种子时由车辆台账交还车信息推导)
- 原型本地种子,未接真实租赁合同 API
## 代码路径
- UI`components/LeaseRecordsPanel.tsx`
- 数据:`vehicle-management/utils/leaseRecords.ts`

View File

@@ -0,0 +1,33 @@
# 证照信息 · 与证照管理对齐
详情「证照信息」Tab 证照种类、字段与状态口径对齐 **证照管理 / PC 车辆资产证照 Tab**(八类)。
## 八类证照
| 顺序 | 证照 | 展示内容 | 状态角标 |
|------|------|----------|----------|
| 1 | 行驶证 | 影像 + 注册/发证/强制报废/检验有效期至;沪牌另显下次等级评定时间与剩余天数 | 已上传 / 未上传 |
| 2 | 道路运输证 | 影像 + 经营许可证号、核发时间、证件有效期、审验有效期 | 已上传 / 未上传 |
| 3 | 登记证 | 影像 | 已上传 / 未上传 |
| 4 | 特种设备使用登记证 | 影像 | 已上传 / 未上传 |
| 5 | 特种设备使用标识 | 影像 + 下次检验日期 | 已上传 / 未上传 |
| 6 | 加氢卡 | 卡号/余额/配发信息;未绑定文案「尚未绑定加氢卡」 | 已绑定 / 未绑定 |
| 7 | 安全阀 | 影像 + 本次检验日期、下次检测日期 | 已上传 / 未上传 |
| 8 | 压力表 | 影像 + 本次检验日期、下次检验日期 | 已上传 / 未上传 |
顶部提供八类名称**单行横向滚动**索引;点选后自动锚点滚动到对应证照区块,选中项高亮。
## 日期与剩余天数
有明确到期/检验日的字段,日期后方可展示「剩余 x 天 / 已过期 x 天」(与检验有效期规则一致)。
## 数据源
- 与 PC 车辆资产同一套证照包解析(原型种子 + 台账回填)
- 影像为原型演示样例图;正式环境以证照管理上传为准
- 手机端只读,不提供编辑 / 跳转证照管理
## 代码路径
- UI`components/LicenseCertPanel.tsx`
- 数据:`vehicle-management/utils/vehicleLicenses.ts``LICENSE_CERT_NAV` / `resolveVehicleLicenseBundle`

View File

@@ -0,0 +1,77 @@
# 列表卡片 · 里程任务进度
> 原型本地演示种子:[`utils/mileage-task.ts`](../utils/mileage-task.ts)
> **未接真实采购合同 / 任务工单 API**
## 业务背景
采购部发起车辆采购合同时,合同可关联对应批次车辆的**里程要求**与**时间起止**;里程任务通过**任务工单**下发到车辆。手机列表卡片展示该车当前里程任务完成情况,便于外勤扫一眼进度与是否赶得上截止。
## 展示位置
车辆卡片中,在「来源 / 客户名」等标签行**下方**,全宽分隔区:
1. 标题「里程任务」(有任务时右侧展示完成百分比)
2. 有任务:进度条
3. 有任务:剩余公里 + 按近 7 活跃日推算的预计完成天数
4. 进行中任务:文案后方完成预判标签(对照任务截止剩余日历天)
## 判定 / 展示优先级
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | 车辆未挂接有效里程任务工单 | 展示标题 +「暂无里程任务」(无进度条、无剩余/预计、无预判标签) |
| 2 | 已完成里程 ≥ 目标里程(或进度 ≥ 100% | 进度 100%、成功色进度条与百分比;文案「已完成」;**不**展示预判标签 |
| 3 | 有任务且未完成,且近 7 个活跃日均里程可算 | 进度条 +「剩余 N km · 预计 D 天内完成」+ 预判标签;预判为「无法完成」时进度条/百分比为危险红;「有完成风险」为警告橙 |
| 4 | 有任务且未完成,但无活跃日(近窗内日里程均 ≤ 0 | 进度条(警告橙)+「剩余 N km · 暂无法预计」+ 预判标签「剩余 x 天,预计有完成风险」 |
## 口径公式
| 项 | 口径 |
|----|------|
| 完成百分比 | `min(100, 保留 1 位小数(任务内已完成里程 / 合同目标里程 × 100))` |
| 剩余公里 | `max(0, 目标里程 已完成里程)` |
| 活跃日 | 自然日内该车行驶里程 **> 0 km** |
| 近 7 活跃日均 | 取最近最多 **7** 个活跃日的日里程算术平均;不足 7 个则有几个算几个 |
| 预计天数 D | `ceil(剩余公里 / 近 7 活跃日均)`;均里程为 0 或无活跃日 → 无法预计 |
| 截止剩余日历天 x | 任务/合同时间窗截止日相对今天的剩余自然日(下限 0 |
## 完成预判标签(文案后方)
用预计天数 D近 7 活跃日推算)对照截止剩余日历天 x
| 顺序 | 条件 | 标签文案 | 色调 | 进度条 / 百分比 |
|------|------|----------|------|----------------|
| 1 | D 无法算出(无活跃日) | 剩余 x 天,预计有完成风险 | 警告橙 | 同警告橙 |
| 2 | x ≤ 0 或 D > x | 剩余 x 天,预计无法完成 | 危险红 | 同危险红 |
| 3 | D > x × 85%,或 (x D) ≤ 7 | 剩余 x 天,预计有完成风险 | 警告橙 | 同警告橙 |
| 4 | 其余 | 剩余 x 天,预计可完成 | 成功绿 | 默认主色蓝 |
## 前置条件
- 仅列表卡片只读展示;不提供下发、编辑、跳转工单详情(本期不做)
- 筛选 / KPI / 搜索不影响本区展示规则,只影响哪些车出现在列表
## 数据源(原型)
| 来源 | 说明 |
|------|------|
| 演示种子 | 按车牌稳定 hash约 65% 挂接演示任务(目标里程、已完成、近窗活跃日里程序列、截止剩余日历天);其余为无任务 |
| 台账导表 | 仅借用车牌标识做稳定种子;**不**把导表「当前总里程」直接当作任务进度真相 |
正式环境应由采购合同批次里程要求 + 时间起止 + 任务工单执行回写提供目标 / 已完成 / 日里程明细 / 截止日。
## 用户可见文案
| 场景 | 文案 |
|------|------|
| 无任务 | 暂无里程任务 |
| 进行中 | 剩余 {N} km · 预计 {D} 天内完成 |
| 进行中且无法预计 | 剩余 {N} km · 暂无法预计 |
| 预判标签 | 剩余 {x} 天,预计无法完成 / 预计可完成 / 预计有完成风险 |
| 已完成 | 已完成(无预判标签) |
## 代码路径
- 解析:`utils/mileage-task.ts``resolveMileageTaskCard` / `resolveMileageForecast`
- 展示:`components/VehicleCard.tsx` + `styles/index.css``.h5-va-mileage-task*` / `.h5-va-mileage-forecast`

View File

@@ -0,0 +1,38 @@
# 更多筛选 · 判定规格
「更多筛选」底部面板内的条件与列表筛选项行(品牌车型 / 运营城市 / 车辆状态)叠加生效;任一非「全部」即点亮更多筛选角标。
## 面板项与匹配口径
| 优先级 | 筛选项 | 选项 | 匹配规则 | 数据源 |
|--------|--------|------|----------|--------|
| 1 | 证照状态 | 全部 / 正常 / 异常 | 异常 = 检验有效期早于今天;正常 = 非异常 | 台账证照有效期 |
| 2 | 保险状态 | 全部 / 正常 / 异常 | 异常 = 保险状态为「异常」;正常 = 非异常 | 台账保险状态 |
| 3 | 出库状态 | 全部 / 未出库 / 已出库 | 见下表(由车辆状态推导,无独立出库字段) | 台账车辆状态 |
| 4 | 车辆来源 | 全部 / 自有 / 外租 / 挂靠 | 精确等于台账「车辆来源」 | 台账车辆来源 |
| 5 | 在线状态 | 全部 / 在线 / 离线 | 精确等于台账「在线状态」 | 台账在线状态 |
以上条件与 KPI、搜索、品牌车型、运营城市、车辆状态为 **AND**
## 出库状态推导(原型本地)
| 出库选项 | 车辆状态集合 |
|----------|----------------|
| 未出库 | 待验车、未备车、已备车、待交车 |
| 已出库 | 有明确车辆状态,且不属于「未出库」集合(如已交车、待还车、异动中等) |
**前置条件**
- 车辆状态为空、`-``无`:不匹配「未出库」也不匹配「已出库」(选任一侧时该车被滤掉)
- 正式环境若上线独立「出库」字段,以字段为准,本表仅作原型推导
## 交互与布局
- 面板挂在手机壳操作区内,宽度不超过壳层;内容区可滚动,底部「重置 / 完成」固定
- 「重置」只清更多筛选五项,不清筛选项行与 KPI / 搜索
- 「完成」关闭面板并保留当前选择;列表已随选择即时刷新
## 代码路径(实现对照)
- 匹配:`utils/filter.ts``matchOutboundFilter` / `matchVehicleSourceFilter` / `matchOnlineStatusFilter`
- UI`components/FilterSheet.tsx`

View File

@@ -0,0 +1,276 @@
# 车辆资产H5· 产品需求说明(全模块)
## 1. 一句话与目标
**一句话**
在 App / 手机浏览器提供与 PC「车辆资产」口径一致的只读查车与异常浏览能力。
**要解决的问题**
- 外勤与业务人员无法在手机上快速按车牌查车、看营运分类与证照/保险异常
- PC 宽表页不适合塞进 WebView需要独立手机信息架构
**本期目标**
1. 列表车牌搜索、KPI 分类横滑、证照/保险异常筛选、车辆卡片(含里程任务进度)
2. 详情:多 Tab 对齐 PC全程只读浏览
3. 全角色同一套界面;手机端不做编辑
**非目标(本期不做)**
- 任意字段编辑(含 Admin
- 导入 / 导出、催办、跳转证照管理或保险采购等业务处理页
- 里程任务工单下发 / 编辑、从卡片跳转工单详情、按截止日逾期标红
- 在租车型占比等 PC 尾部分析入口
- 将 PC 页做响应式自适应
## 2. 模块边界
```mermaid
flowchart LR
App[App WebView] --> H5[车辆资产 H5]
H5 -.口径一致.-> PC[车辆资产 PC]
H5 -.只读.-> Data[车辆台账与关联记录]
```
| 业务线 / 子域 | 做什么 | 不做什么 |
|---------------|--------|----------|
| 查车列表 | 搜索、KPI、异常筛选、卡片进详情、卡片里程任务进度 | 批量操作、写操作、下发/编辑工单 |
| 车辆详情 | 11 个 Tab 只读浏览 | 行内编辑、跳转处理 |
**外部依赖(产品口径)**
| 依赖方 | 交互方式 | 产品要求 |
|--------|----------|----------|
| 车辆资产 PC | 同一套台账与统计口径 | KPI / 证照异常 / 保险异常定义一致;台账数据同源(业务导表) |
| 采购合同 / 任务工单 | 合同关联批次里程要求与起止;工单下发后回写进度 | 列表卡片只读展示完成百分比、剩余公里与预计天数;无工单时仍展示「暂无里程任务」 |
| App 壳 | 内嵌打开本 H5 | 固定打开手机页,不打开 PC 宽表地址 |
**关键约束**
- 手机端明确提示:修改请在 PC 完成
- 筛选与 KPI 叠加:先按 KPI再按证照/保险条件
- 列表车辆数据与 PC「车辆资产」共用业务导表台账含真实车牌、客户、运营状态分布
## 3. 用户与角色
| 角色 | 主要目标 |
|------|----------|
| 运维 / 业务 / 管理等全角色 | 同一套界面查车、看异常与档案摘要 |
| Admin | 与普通用户相同;手机端仍无编辑(改数回 PC |
## 4. 用户故事与故事点(业务条线说明口径)
合计约 **15 SP**
### Epic A · 列表查车(约 10 SP
#### A1 · 车牌搜索与卡片列表
- **角色**:全体手机用户
- **起点**:从 App 进入车辆资产 H5
- **怎么运作**
1. 顶栏左对齐「车辆资产」;搜索栏提示「搜索车牌 / VIN / 停车场 / 客户名称」,支持按这些关键词过滤;右侧扫码为占位提示
2. KPI 横滑摘要卡(每屏 3 张、共 2 页;图标 + 计数 + 占比);下方固定筛选项行:品牌车型 / 运营城市(省→市,可只选省)/ 车辆状态 +「更多筛选」(证照 / 保险 / 出库 / 来源 / 在线);前三项为 App 原生底部选择器,选项来自业务导表;更多筛选面板限制在手机操作区内并可滚动
3. 列表横卡含车牌、车型、来源标签、按运营状态变化的次要标签(租赁/自营→客户名省略;库存→未备车警告/已备车正常;非运营→非运营车辆;退出→三方退租/销售/报废)、营运状态圆点、里程与异常角标(不含车辆照片);标签下方展示「里程任务」分隔区(有任务:百分比 + 进度条 + 剩余公里/预计天数 + 完成预判标签;无任务:「暂无里程任务」);列表不展示总车辆数,不提供翻页按钮,滚动触底异步加载更多
4. 底部 App Tab 为单条悬浮胶囊壳层预览:车辆 / 业务 / 工作台(中间圆形仅图标)/ 消息 / 我的;非车辆入口仅提示;点卡片进详情
- **闭环**:找到目标车并进入详情
- **排期**US-01 · M
#### A2 · KPI 分类与异常筛选
- **角色**:运维 / 业务
- **起点**:需要按营运分类或证照/保险异常缩小范围
- **怎么运作**
1. 横滑点选 KPI 摘要卡(全部营运/租赁/物流/库存/非运营/退出;每屏完整显示 3 张,翻页看其余 3 张;显示计数与相对占比)
2. 筛选项行:品牌车型(品牌→车型)、运营城市(省→市,可只选省)、车辆状态(与 PC 字典一致,含调拨中 / 替换中等;无数据时计数为 0 仍可选);点「更多筛选」可再调证照状态、保险状态、出库状态、车辆来源、在线状态
3. 列表随条件刷新并从顶部重新加载;不展示列表总辆数
- **闭环**:得到与 PC 同口径的筛选结果
- **排期**US-02 · M
#### A3 · 卡片里程任务进度
- **角色**:运维 / 业务 / 管理
- **起点**:采购合同已为批次车辆关联里程要求与起止时间,并通过任务工单下发
- **怎么运作**
1. 列表每张卡片固定展示「里程任务」区
2. 有工单:展示完成百分比、进度条、剩余公里;预计完成天数按近 7 个活跃日(日里程 > 0平均日里程推算文案后方展示完成预判标签对照任务截止剩余日历天预计无法完成 / 可完成 / 有完成风险)
3. 无工单:仍保留区块,文案「暂无里程任务」
- **闭环**:不进详情也能判断该车里程任务是否在轨、还差多少
- **排期**US-04 · S
### Epic B · 详情只读(约 5 SP
#### B1 · 多 Tab 档案浏览
- **角色**:全体手机用户
- **起点**:从列表进入某车详情
- **怎么运作**
1. 详情与列表共用一体浅蓝渐变底;顶栏透明,仅返回 + 车牌;不挂底部悬浮 Tab
2. 摘要为浮起白卡:车牌、品牌型号、营运状态(与列表同文案/色点)、来源·城市·项目等标签、里程、证照/保险异常角标(无车辆实景大图)
3. 横滑切换与 PC 一致的 11 个 Tab选中态为浅底主色字对齐列表 Chip
4. 各 Tab 仅展示信息(白卡 Section / 记录列表),无编辑按钮;检验有效期后方展示「剩余 x 天」标签;沪牌车在档案信息额外展示等级评定时间与剩余天数;证照信息 Tab 对齐证照管理八类证照;保险记录 Tab 对齐保险采购五险种与采购历史;租赁记录为纵向时间轴(近→远,未还车标注),并支持按客户名称 / 项目名称搜索;事故等业务 Tab 无种子时展示演示用例;无数据时为空态插画
- **闭环**:不回 PC 也能看完档案与业务记录摘要
- **排期**US-03 · M
## 5. 功能模块说明(正向 / 逆向)
### 5.1 列表
**正向**
1. 默认展示「全部营运」KPI 下的卡片列表(首屏一批,触底异步加载更多;不含退出运营、不含非运营车辆)
2. 搜索、切 KPI、改筛选后列表刷新并从顶部重新加载不展示总车辆数、无翻页按钮
3. 证照/保险异常在卡片上可见
4. 每张卡片标签下方展示「里程任务」:有工单则百分比 + 进度条 + 剩余/预计 + 完成预判标签;无工单则「暂无里程任务」
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 无匹配 | 展示「暂无匹配车辆」+ 引导文案;可清空搜索或重置筛选 |
| 仅筛选异常 | 只保留对应异常车辆 |
| 更多筛选多项叠加 | 与 KPI / 搜索 / 筛选项行条件同时生效AND |
| 已加载全部 | 底部提示「没有更多了」 |
| 里程任务已完成 | 进度 100%,文案「已完成」,无预判标签 |
| 有任务但近窗无活跃日 | 仍显示剩余公里,预计侧「暂无法预计」,预判标签为「剩余 x 天,预计有完成风险」 |
| 按活跃日推算赶不上截止 | 预判标签「剩余 x 天,预计无法完成」 |
### 5.2 详情
**正向**
1. 进入详情后背景与列表一体连续;摘要卡可见状态、标签、里程与异常
2. 11 个 Tab 均可切换;内容区白卡展示字段或记录
3. 检验有效期后方展示剩余/已过期天数标签;沪牌车额外展示等级评定时间与剩余天数
4. 证照信息含八类证照(行驶证、道路运输证、登记证、特种设备使用登记证、特种设备使用标识、加氢卡、安全阀、压力表),字段与证照管理对齐;顶部索引单行横滑点选锚点
5. 保险记录对齐保险采购:五险种卡片 + 当前保单摘要 + 采购/批单历史(纵向时间轴,只读)
6. 租赁 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审均为纵向时间轴(近→远);事故及右侧 Tab 支持时间快捷/自定义筛选(自定义为手机端原生滚轮选日);调拨卡片展示出发城市、到达城市、停车场、接收人、接收日(不展示路线与调出人);租赁可搜客户/项目,未还车显示「未还车」
7. 业务 Tab 无种子时展示演示用例;租赁不足 3 条时补足
8. 无记录时展示美化空态;无底部 App Tab避免挡内容
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 任意角色 | 均无编辑、删除、导入入口 |
| 返回 | 顶栏返回或系统返回回到列表;列表底栏样式不受详情影响 |
| 检验有效期为空 | 显示「未上传」,无剩余天数标签 |
| 非沪牌 | 不展示等级评定时间 / 剩余天数 |
| 未绑定加氢卡 | 显示「尚未绑定加氢卡」 |
| 某险种无采购记录 | 显示「暂无记录」,引导回 PC 保险采购 |
| 租赁未还车 | 还车字段显示「未还车」 |
| 租赁搜索无匹配 | 展示「暂无匹配记录」,可清空搜索 |
| 业务 Tab 无种子 | 展示稳定演示用例(非真实业务单据) |
## 6. 关键业务逻辑
### 6.1 KPI 口径(与 PC 一致)
| KPI | 口径 |
|-----|------|
| 全部营运(原「所有营运车辆」) | 非「非运营车辆」,且运营状态不为「退出运营」 |
| 租赁 / 物流 | 运营状态等于对应值 |
| 库存 | 可运营或待运营 |
| 非运营 | 台账类型为非运营车辆 |
| 退出运营 | 运营状态为退出运营 |
### 6.2 异常口径
| 异常 | 口径 |
|------|------|
| 证照异常 | 检验有效期早于今天 |
| 保险异常 | 保险状态为「异常」 |
### 6.3 更多筛选口径
| 筛选项 | 口径 |
|--------|------|
| 证照 / 保险 | 同 §6.2 |
| 出库状态 | 由车辆状态推导:未出库 = 待验车/未备车/已备车/待交车;已出库 = 其余有状态车辆(原型无独立出库字段) |
| 车辆来源 | 自有 / 外租 / 挂靠,与台账一致 |
| 在线状态 | 在线 / 离线,与台账一致 |
规格全文见 [more-filters.md](./more-filters.md)。
### 6.4 里程任务进度(列表卡片)
| 项 | 口径 |
|----|------|
| 来源 | 采购合同批次里程要求 + 时间起止 + 任务工单下发(原型为本地演示种子,未接真实 API |
| 完成百分比 | 任务内已完成里程 / 目标里程,封顶 100%,展示保留 1 位小数 |
| 剩余公里 | 目标 已完成,下限 0 |
| 活跃日 | 当日行驶里程 > 0 km |
| 预计天数 D | 剩余 ÷ 近最多 7 个活跃日平均日里程,向上取整;无活跃日则「暂无法预计」 |
| 截止剩余日历天 x | 任务时间窗相对今天的剩余自然日 |
| 预判标签 | 用 D 对照 x无法完成 / 有完成风险 / 可完成(见规格全文);无法完成时进度条与百分比为危险红,有完成风险为警告橙 |
| 无工单 | 仍展示区块,文案「暂无里程任务」 |
规格全文见 [mileage-task.md](./mileage-task.md)。
### 6.5 检验有效期剩余天数 · 沪牌等级评定
| 项 | 口径 |
|----|------|
| 检验有效期 | 日期后方标签:剩余 x 天 / 已过期 x 天;空为「未上传」 |
| 沪牌判定 | 车牌以「沪」开头 |
| 等级评定时间 | 仅沪牌展示;空为「未上传」 |
| 剩余天数 | 仅沪牌展示;相对等级评定日计算 |
规格全文见 [expire-remain-shanghai.md](./expire-remain-shanghai.md)。
### 6.6 证照信息(八类)
与证照管理一致:行驶证、道路运输证、登记证、特种设备使用登记证、特种设备使用标识、加氢卡、安全阀、压力表。规格全文见 [license-certs.md](./license-certs.md)。
### 6.7 保险记录(对齐保险采购)
五险种(交强 / 商业 / 超赔 / 驾意 / 货物)+ 当前保单摘要 + 采购历史(纵向时间轴)。规格全文见 [insurance-records.md](./insurance-records.md)。
### 6.8 业务记录纵向时间轴
租赁 / 保险历史 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审统一近→远时间轴。
| 能力 | 口径 |
|------|------|
| 时间筛选 | 事故及右侧 Tab全部 / 近30天 / 近90天 / 近1年 / 自定义;按各 Tab 关键时间过滤;无匹配可重置 |
| 自定义选日 | 手机端底部滚轮(年 / 月 / 日,精确到日);起日不得晚于止日;本模块选日入口统一该交互 |
| 调拨卡片 | 出发城市、到达城市、停车场、接收人、接收日;不展示「路线」合并行与「调出」人 |
规格全文见 [biz-timeline.md](./biz-timeline.md)、[lease-records.md](./lease-records.md)。
### 6.9 业务记录演示用例与空态
无种子时 H5 本地生成演示用例;真正无数据时展示美化空态。规格全文见 [demo-biz-records.md](./demo-biz-records.md)。
## 7. 总览流程图
```mermaid
flowchart TB
entry[App 打开车辆资产 H5] --> list[列表:搜索 / KPI / 筛选]
list --> card[点车辆卡片]
card --> detail[详情摘要]
detail --> tabs[切换只读 Tab]
tabs --> back[返回列表]
```
## 8. 验收清单(产品 / 测试)
- [ ] 独立原型可打开,桌面 PhoneShell / 窄屏可用
- [ ] KPI 六类计数与同数据 PC 一致
- [ ] 证照异常、保险异常筛选结果与 PC 规则一致
- [ ] 「更多筛选」含证照 / 保险 / 出库 / 来源 / 在线;面板不超出手机操作区,内容可滚动;启用后角标点亮
- [ ] 出库:未出库对应待验车/未备车/已备车/待交车;来源与在线与台账字段一致
- [ ] 车辆状态选项与 PC 字典一致,含「调拨中」「替换中」
- [ ] 搜索支持车牌 / VIN / 停车场 / 客户名称过滤列表;可一键清空搜索
- [ ] 无匹配时有引导操作(清空搜索 / 重置筛选)
- [ ] 列表无总辆数、无翻页按钮;滚动触底可异步加载更多,到底提示「没有更多了」
- [ ] 列表进详情:一体渐变底连续,无白顶栏色带;摘要为浮起白卡(状态/标签/里程/异常与列表同语义);详情无底栏
- [ ] 详情检验有效期后方有「剩余 x 天」或「已过期 x 天」标签;空为「未上传」
- [ ] 沪牌详情可见「等级评定时间」「剩余天数」;非沪牌不出现
- [ ] 证照信息 Tab 可见八类证照,与证照管理种类一致
- [ ] 保险记录可见五险种卡片、当前保单摘要与历史时间轴;整体状态口径与保险采购一致
- [ ] 保单附件可预览与下载(原型演示)
- [ ] 租赁 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审为近→远时间轴;租赁未还车显示「未还车」
- [ ] 调拨卡片可见出发城市、到达城市、停车场、接收人、接收日;无「路线」「调出」
- [ ] 事故及右侧业务 Tab 可按时间快捷或自定义区间筛选;自定义起止为底部滚轮选日(精确到日),无匹配可重置
- [ ] 租赁记录可按客户名称 / 项目名称搜索;无匹配可清空搜索
- [ ] 任意车辆打开上述业务 Tab 可见列表(种子或演示用例);空态有插画与说明
- [ ] 详情 11 个 Tab 可进入且只读,无编辑按钮
- [ ] 列表卡片标签下有「里程任务」区:有任务可见百分比、进度条、剩余公里与预计天数;无任务显示「暂无里程任务」
- [ ] 预计天数按近 7 活跃日均里程推算;无活跃日时显示「暂无法预计」
- [ ] 进行中任务文案后方有完成预判标签(剩余 x 天 + 预计无法完成 / 可完成 / 有完成风险)
- [ ] 页脚说明「修改请在 PC」可见
**交付口径**
本期交付可嵌入 App 的车辆资产只读 H5能查、能筛异常、能看详情全 Tab并在列表卡片扫读里程任务进度写操作与异常处理仍回 PC。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,37 @@
import React, { useEffect, useState } from 'react';
type ToastState = { id: number; message: string } | null;
let pushToast: ((message: string) => void) | null = null;
export function showAppToast(message: string) {
pushToast?.(message);
}
export function AppToastHost() {
const [toast, setToast] = useState<ToastState>(null);
useEffect(() => {
let timer = 0;
pushToast = (message: string) => {
const id = Date.now();
setToast({ id, message });
window.clearTimeout(timer);
timer = window.setTimeout(() => {
setToast((current) => (current?.id === id ? null : current));
}, 1800);
};
return () => {
pushToast = null;
window.clearTimeout(timer);
};
}, []);
if (!toast) return null;
return (
<div className="h5-va-toast" role="status" aria-live="polite">
{toast.message}
</div>
);
}

View File

@@ -0,0 +1,283 @@
import React, { useMemo } from 'react';
import type { VehicleRecord } from '../utils/filter';
import { resolveVehicleAccidentRecords } from '../../vehicle-management/utils/accidentRecords';
import { resolveVehicleFaultRecords } from '../../vehicle-management/utils/faultRecords';
import { resolveVehicleViolationRecords } from '../../vehicle-management/utils/violationRecords';
import { resolveVehicleMovementRecords } from '../../vehicle-management/utils/movementRecords';
import { resolveVehicleTransferRecords } from '../../vehicle-management/utils/transferRecords';
import { resolveVehicleAnnualReviewRecords } from '../../vehicle-management/utils/annualReviewRecords';
import {
ensureAccidentRecords,
ensureAnnualReviewRecords,
ensureFaultRecords,
ensureMovementRecords,
ensureTransferRecords,
ensureViolationRecords,
} from '../utils/demo-biz-records';
import { RecordsEmpty } from './RecordsEmpty';
import {
RecordTimeline,
sortByDateDesc,
timelineDatePart,
type TimelineItem,
} from './RecordTimeline';
import {
matchTimelineDate,
useTimelineDateFilter,
} from './TimelineDateFilter';
function PanelShell({
label,
emptyDesc,
items,
dateFieldLabel,
}: {
label: string;
emptyDesc: string;
items: TimelineItem[];
dateFieldLabel: string;
}) {
const { range, filterActive, filterNode, reset } = useTimelineDateFilter(dateFieldLabel);
const filtered = useMemo(
() => items.filter((item) => matchTimelineDate(item.date, range)),
[items, range],
);
if (!items.length) {
return <RecordsEmpty title="暂无记录" desc={emptyDesc} />;
}
return (
<div className="h5-va-tl-panel h5-va-section--records" aria-label={label}>
{filterNode}
{!filtered.length ? (
<div className="h5-va-empty h5-va-empty--inset" role="status">
<p className="h5-va-empty-title"></p>
<p className="h5-va-empty-desc"></p>
{filterActive ? (
<div className="h5-va-empty-actions">
<button type="button" className="h5-btn h5-btn--primary" onClick={reset}>
</button>
</div>
) : null}
</div>
) : (
<RecordTimeline items={filtered} label={`${label}时间轴`} />
)}
</div>
);
}
export function AccidentRecordsPanel({ record }: { record: VehicleRecord }) {
const items = useMemo(() => {
const rows = sortByDateDesc(
ensureAccidentRecords(record, resolveVehicleAccidentRecords(record)),
(row) => timelineDatePart(row.accidentTime),
);
return rows.map((row): TimelineItem => ({
id: row.id,
date: timelineDatePart(row.accidentTime) || '—',
accent: row.status === '处理中',
badge: row.status,
title: row.accidentTime || '—',
meta: [
{ label: '单号', value: row.accidentCode || '—' },
{ label: '位置', value: row.accidentLocation || '—' },
{ label: '等级', value: row.accidentLevel || '—' },
{ label: '类型', value: row.accidentType || '—' },
...(row.customerName ? [{ label: '客户', value: row.customerName }] : []),
],
}));
}, [record]);
return (
<PanelShell
label="事故记录"
emptyDesc="该车辆暂无事故记录,可回 PC 录入后同步查看"
dateFieldLabel="事故时间"
items={items}
/>
);
}
export function FaultRecordsPanel({ record }: { record: VehicleRecord }) {
const items = useMemo(() => {
const rows = sortByDateDesc(
ensureFaultRecords(record, resolveVehicleFaultRecords(record)),
(row) => timelineDatePart(row.reportedAt),
);
return rows.map((row): TimelineItem => {
const open = row.resolutionStatus !== '已解决';
const description = (row.faultDescription || '').trim() || '—';
return {
id: row.id,
date: timelineDatePart(row.reportedAt) || '—',
accent: open,
badge: row.resolutionStatus || '未解决',
title: row.reportedAt || '—',
meta: [
{ label: '编号', value: row.faultNo || '—' },
{ label: '等级', value: row.faultLevel || '—' },
{ label: '类型', value: row.faultType || '—' },
...(row.resolvedAt
? [{ label: '解决', value: row.resolvedAt }]
: [{ label: '解决', value: '未解决', pending: true }]),
],
footer: (
<div className="h5-va-tl-fieldblock">
<div className="h5-va-tl-fieldblock-label"></div>
<textarea
className="h5-va-tl-textarea"
readOnly
rows={3}
value={description}
aria-label="故障说明"
/>
</div>
),
};
});
}, [record]);
return (
<PanelShell
label="故障记录"
emptyDesc="该车辆暂无故障记录,可回 PC 录入后同步查看"
dateFieldLabel="上报时间"
items={items}
/>
);
}
export function ViolationRecordsPanel({ record }: { record: VehicleRecord }) {
const items = useMemo(() => {
const rows = sortByDateDesc(
ensureViolationRecords(record, resolveVehicleViolationRecords(record)),
(row) => timelineDatePart(row.violationTime),
);
return rows.map((row): TimelineItem => ({
id: row.id,
date: timelineDatePart(row.violationTime) || '—',
accent: row.processed === '未处理' || row.paymentStatus === '未缴费',
badge: row.processed,
title: row.violationTime || '—',
meta: [
{ label: '行为', value: row.violationBehavior || '—' },
{ label: '位置', value: row.violationLocation || '—' },
{ label: '缴费', value: row.paymentStatus || '—', pending: row.paymentStatus === '未缴费' },
{ label: '处理', value: row.processed || '—', pending: row.processed === '未处理' },
...(row.customerName ? [{ label: '客户', value: row.customerName }] : []),
],
}));
}, [record]);
return (
<PanelShell
label="违章记录"
emptyDesc="该车辆暂无违章记录,可回 PC 录入后同步查看"
dateFieldLabel="违章时间"
items={items}
/>
);
}
export function MovementRecordsPanel({ record }: { record: VehicleRecord }) {
const items = useMemo(() => {
const rows = sortByDateDesc(
ensureMovementRecords(record, resolveVehicleMovementRecords(record)),
(row) => timelineDatePart(row.startDate),
);
return rows.map((row): TimelineItem => ({
id: row.id,
date: timelineDatePart(row.startDate) || '—',
accent: row.status === '进行中',
badge: row.status,
title: row.startDate || '—',
meta: [
{ label: '类型', value: row.movementType || '—' },
{
label: '目的地',
value: `${row.destinationType || ''} ${row.destinationName || ''}`.trim() || '—',
},
{
label: '预计结束',
value: row.estimatedEndDate || '进行中',
pending: !row.estimatedEndDate,
},
],
}));
}, [record]);
return (
<PanelShell
label="异动记录"
emptyDesc="该车辆暂无异动记录,可回 PC 录入后同步查看"
dateFieldLabel="开始时间"
items={items}
/>
);
}
export function TransferRecordsPanel({ record }: { record: VehicleRecord }) {
const items = useMemo(() => {
const rows = sortByDateDesc(
ensureTransferRecords(record, resolveVehicleTransferRecords(record)),
(row) => timelineDatePart(row.transferDate),
);
return rows.map((row): TimelineItem => ({
id: row.id,
date: timelineDatePart(row.transferDate) || '—',
badge: row.transferMethod || undefined,
title: row.transferDate || '—',
meta: [
{ label: '出发城市', value: row.departureArea || '—' },
{ label: '到达城市', value: row.receivingArea || '—' },
{
label: '停车场',
value: `${row.departureParking || '—'}${row.receivingParking || '—'}`,
},
{ label: '接收人', value: row.recipientPerson || '—' },
{ label: '接收日', value: row.receiveDate || '—' },
],
}));
}, [record]);
return (
<PanelShell
label="调拨记录"
emptyDesc="该车辆暂无调拨记录,可回 PC 录入后同步查看"
dateFieldLabel="调拨日期"
items={items}
/>
);
}
export function AnnualReviewRecordsPanel({ record }: { record: VehicleRecord }) {
const items = useMemo(() => {
const rows = sortByDateDesc(
ensureAnnualReviewRecords(record, resolveVehicleAnnualReviewRecords(record)),
(row) => timelineDatePart(row.executeTime),
);
return rows.map((row): TimelineItem => ({
id: row.id,
date: timelineDatePart(row.executeTime) || timelineDatePart(row.inspectionValidUntil) || '—',
title: row.executeTime || '—',
meta: [
{ label: '有效期至', value: row.inspectionValidUntil || '—' },
{ label: '检测站', value: row.inspectionStation || '—' },
{ label: '执行人', value: row.executor || '—' },
],
}));
}, [record]);
return (
<PanelShell
label="年审记录"
emptyDesc="该车辆暂无年审记录,可回 PC 录入后同步查看"
dateFieldLabel="执行时间"
items={items}
/>
);
}

View File

@@ -0,0 +1,206 @@
import React, { useEffect, useId, useRef } from 'react';
import type {
InsuranceFilter,
LicenseFilter,
OnlineFilter,
OutboundFilter,
} from '../utils/filter';
import { IconX } from './Icons';
type Props = {
open: boolean;
licenseStatus: LicenseFilter;
insuranceStatus: InsuranceFilter;
outboundStatus: OutboundFilter;
vehicleSource: string;
onlineStatus: OnlineFilter;
onLicenseChange: (value: LicenseFilter) => void;
onInsuranceChange: (value: InsuranceFilter) => void;
onOutboundChange: (value: OutboundFilter) => void;
onVehicleSourceChange: (value: string) => void;
onOnlineChange: (value: OnlineFilter) => void;
onReset: () => void;
onClose: () => void;
};
const LICENSE_OPTIONS: Array<{ value: LicenseFilter; label: string }> = [
{ value: '', label: '全部' },
{ value: '正常', label: '正常' },
{ value: '异常', label: '异常' },
];
const INSURANCE_OPTIONS: Array<{ value: InsuranceFilter; label: string }> = [
{ value: '', label: '全部' },
{ value: '正常', label: '正常' },
{ value: '异常', label: '异常' },
];
const OUTBOUND_OPTIONS: Array<{ value: OutboundFilter; label: string }> = [
{ value: '', label: '全部' },
{ value: '未出库', label: '未出库' },
{ value: '已出库', label: '已出库' },
];
const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '', label: '全部' },
{ value: '自有', label: '自有' },
{ value: '外租', label: '外租' },
{ value: '挂靠', label: '挂靠' },
];
const ONLINE_OPTIONS: Array<{ value: OnlineFilter; label: string }> = [
{ value: '', label: '全部' },
{ value: '在线', label: '在线' },
{ value: '离线', label: '离线' },
];
type ChipGroupProps<T extends string> = {
labelId: string;
label: string;
value: T;
options: Array<{ value: T; label: string }>;
onChange: (value: T) => void;
};
function ChipGroup<T extends string>({ labelId, label, value, options, onChange }: ChipGroupProps<T>) {
return (
<div className="h5-va-sheet-section">
<div className="h5-va-sheet-label" id={labelId}>{label}</div>
<div className="h5-va-chip-row" role="group" aria-labelledby={labelId}>
{options.map((option) => (
<button
key={`${labelId}-${option.label}`}
type="button"
className={`h5-va-chip${value === option.value ? ' is-active' : ''}`}
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
);
}
export function FilterSheet({
open,
licenseStatus,
insuranceStatus,
outboundStatus,
vehicleSource,
onlineStatus,
onLicenseChange,
onInsuranceChange,
onOutboundChange,
onVehicleSourceChange,
onOnlineChange,
onReset,
onClose,
}: Props) {
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
closeRef.current?.focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onCloseRef.current();
}
}
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [open]);
if (!open) return null;
return (
<div
className="h5-va-sheet-mask"
role="presentation"
onClick={onClose}
>
<div
className="h5-va-sheet h5-va-sheet--filters"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-annotation-id="h5-va-filter"
onClick={(event) => event.stopPropagation()}
>
<div className="h5-va-sheet-handle" aria-hidden />
<div className="h5-va-sheet-head">
<h2 id={titleId}></h2>
<button
ref={closeRef}
type="button"
className="h5-nav-btn"
onClick={onClose}
aria-label="关闭更多筛选"
>
<IconX />
</button>
</div>
<div className="h5-va-sheet-body">
<ChipGroup
labelId="h5-va-license-label"
label="证照状态"
value={licenseStatus}
options={LICENSE_OPTIONS}
onChange={onLicenseChange}
/>
<ChipGroup
labelId="h5-va-insurance-label"
label="保险状态"
value={insuranceStatus}
options={INSURANCE_OPTIONS}
onChange={onInsuranceChange}
/>
<ChipGroup
labelId="h5-va-outbound-label"
label="出库状态"
value={outboundStatus}
options={OUTBOUND_OPTIONS}
onChange={onOutboundChange}
/>
<ChipGroup
labelId="h5-va-source-label"
label="车辆来源"
value={vehicleSource}
options={SOURCE_OPTIONS}
onChange={onVehicleSourceChange}
/>
<ChipGroup
labelId="h5-va-online-label"
label="在线状态"
value={onlineStatus}
options={ONLINE_OPTIONS}
onChange={onOnlineChange}
/>
</div>
<div className="h5-va-sheet-actions">
<button type="button" className="h5-btn h5-btn--ghost" onClick={onReset}>
</button>
<button type="button" className="h5-btn h5-btn--primary" onClick={onClose}>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,512 @@
import React from 'react';
type Props = {
size?: number;
className?: string;
};
export function IconEmptyFleet({ size = 64, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 80 80" fill="none" aria-hidden>
<circle cx="40" cy="40" r="36" fill="url(#h5-va-empty-glow)" />
<rect x="16" y="28" width="48" height="28" rx="8" fill="#fff" stroke="currentColor" strokeWidth="1.6" />
<path
d="M22 40h10.5l3-6h14l3.5 6H58"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="28" cy="50" r="3.2" fill="currentColor" />
<circle cx="52" cy="50" r="3.2" fill="currentColor" />
<path d="M54 22l8 8M62 22l-8 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" opacity="0.55" />
<defs>
<radialGradient id="h5-va-empty-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 40) rotate(90) scale(36)">
<stop stopColor="currentColor" stopOpacity="0.16" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
);
}
/** 业务记录空态 — 剪贴板 / 单据 */
export function IconEmptyRecords({ size = 64, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 80 80" fill="none" aria-hidden>
<circle cx="40" cy="40" r="36" fill="url(#h5-va-empty-rec-glow)" />
<rect x="24" y="18" width="32" height="44" rx="8" fill="#fff" stroke="currentColor" strokeWidth="1.6" />
<rect x="30" y="14" width="20" height="10" rx="4" fill="#fff" stroke="currentColor" strokeWidth="1.5" />
<path d="M32 36h16M32 44h16M32 52h10" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" opacity="0.55" />
<circle cx="54" cy="54" r="10" fill="#fff" stroke="currentColor" strokeWidth="1.5" />
<path d="M50.5 54h7M54 50.5v7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" opacity="0.45" />
<defs>
<radialGradient id="h5-va-empty-rec-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 40) rotate(90) scale(36)">
<stop stopColor="currentColor" stopOpacity="0.16" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
);
}
export function IconCheck({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M5 12.5l5 5L19 7" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** 列表到底 — 勾选圆点 */
export function IconListEnd({ size = 16, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" opacity="0.35" />
<circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.08" />
<path
d="M8 12.2l2.6 2.6L16.4 9"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function IconChevronLeft({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconChevronRight({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconChevronDown({ size = 16, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconSearch({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
<path d="M20 20l-3.5-3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function IconSliders({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<circle cx="4" cy="14" r="2.5" stroke="currentColor" strokeWidth="2" />
<circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth="2" />
<circle cx="20" cy="16" r="2.5" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function IconFilter({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 5h16l-6.5 7.5V19l-3 1.5v-8L4 5z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
}
export function IconX({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function IconCircleX({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function IconCar({ size = 24, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M5 16l1.2-4.2A2 2 0 018.1 10h7.8a2 2 0 011.9 1.8L19 16"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.5 16h17M7 16v1.5a1.5 1.5 0 003 0V16m4 0v1.5a1.5 1.5 0 003 0V16"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.5 10l1.2-2.4A1.5 1.5 0 019.99 7h4.02a1.5 1.5 0 011.29.6L16.5 10"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function IconBell({ size = 22, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
{/* soft body fill — depth without heavy chrome */}
<path
d="M12 3.2c.48 0 .88.3 1.02.74A5.85 5.85 0 0118.1 9.6c0 2.4.72 4.05 1.4 5.15.18.3-.04.7-.4.7H4.9c-.36 0-.58-.4-.4-.7.68-1.1 1.4-2.75 1.4-5.15a5.85 5.85 0 015.08-5.66C11.12 3.5 11.52 3.2 12 3.2z"
fill="currentColor"
opacity="0.1"
/>
{/* bell body */}
<path
d="M12 3.2c.48 0 .88.3 1.02.74A5.85 5.85 0 0118.1 9.6c0 2.4.72 4.05 1.4 5.15.18.3-.04.7-.4.7H4.9c-.36 0-.58-.4-.4-.7.68-1.1 1.4-2.75 1.4-5.15a5.85 5.85 0 015.08-5.66C11.12 3.5 11.52 3.2 12 3.2z"
stroke="currentColor"
strokeWidth="1.7"
strokeLinejoin="round"
/>
{/* hanger */}
<path
d="M12 3.15V2.15"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
<circle cx="12" cy="1.85" r="0.95" fill="currentColor" />
{/* clapper */}
<path
d="M9.6 18.35a2.55 2.55 0 004.8 0"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
export function IconPlus({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
export function IconScan({ size = 18, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 8V5a1 1 0 011-1h3M16 4h3a1 1 0 011 1v3M20 16v3a1 1 0 01-1 1h-3M8 20H5a1 1 0 01-1-1v-3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export function IconPlay({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M8 5.5v13l11-6.5L8 5.5z" />
</svg>
);
}
export function IconPause({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
);
}
export function IconAlert({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M12 3l10 18H2L12 3z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M12 10v4M12 17h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function IconWallet({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<rect x="3" y="6" width="18" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 10h18" stroke="currentColor" strokeWidth="1.8" />
<circle cx="16.5" cy="14.5" r="1.2" fill="currentColor" />
</svg>
);
}
/** 业务 Tab公文包 */
export function IconBriefcase({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<rect x="3" y="7" width="18" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 7V5.8A1.8 1.8 0 0110.8 4h2.4A1.8 1.8 0 0115 5.8V7" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.8" />
<path d="M10 12v1.5a2 2 0 004 0V12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export function IconChart({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 19V5M4 19h16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M8 15v-4M12 15V8M16 15v-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
/** 工作台中心凸起按钮用的饼图图标 */
export function IconPie({ size = 22, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M12 3a9 9 0 109 9h-9V3z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M13.5 3.2A9 9 0 0120.8 10.5H13.5V3.2z" fill="currentColor" opacity="0.35" />
</svg>
);
}
export function IconMessage({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M5 6.5A2.5 2.5 0 017.5 4h9A2.5 2.5 0 0119 6.5v7A2.5 2.5 0 0116.5 16H10l-4 3.5V6.5z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
</svg>
);
}
export function IconUser({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.5" stroke="currentColor" strokeWidth="1.8" />
<path d="M5 19.5c1.5-3.2 4-4.5 7-4.5s5.5 1.3 7 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export function IconHome({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M4 10.5L12 4l8 6.5V20a1 1 0 01-1 1h-5v-6H10v6H5a1 1 0 01-1-1v-9.5z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
</svg>
);
}
export function IconTasks({ size = 20, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M9 6h11M9 12h11M9 18h11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M5 6.2l1.2 1.2L8.2 5.4M5 12.2l1.2 1.2L8.2 11.4M5 18.2l1.2 1.2L8.2 17.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconTruck({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M3 7h11v10H3V7zM14 10h4l3 3v4h-7v-7z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<circle cx="7" cy="18" r="1.6" fill="currentColor" />
<circle cx="17" cy="18" r="1.6" fill="currentColor" />
</svg>
);
}
export function IconBox({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 8l8-4 8 4v9l-8 4-8-4V8z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M4 8l8 4 8-4M12 12v9" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
export function IconSort({ size = 16, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M8 6v12M8 6l-3 3M8 6l3 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 18V6M16 18l-3-3M16 18l3-3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconBrand({ size = 16, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 16.5l1.4-4.6A2 2 0 017.3 10h9.4a2 2 0 011.9 1.9L20 16.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6.5 16.5h11M8 16.5v1.2a1.2 1.2 0 002.4 0v-1.2m3.2 0v1.2a1.2 1.2 0 002.4 0v-1.2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
<path d="M8 10l1.1-2.2A1.2 1.2 0 0110.2 7h3.6a1.2 1.2 0 011.1.8L16 10" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconStatus({ size = 16, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.7" />
<circle cx="12" cy="12" r="3.2" fill="currentColor" />
</svg>
);
}
export function IconBuilding({ size = 16, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M5 20V6.5A1.5 1.5 0 016.5 5h7A1.5 1.5 0 0115 6.5V20" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
<path d="M15 10h3.5A1.5 1.5 0 0120 11.5V20" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
<path d="M8 8.5h3M8 12h3M8 15.5h3M5 20h15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
/** KPI全部车辆 — 车队轮廓 */
export function IconKpiFleet({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M4.5 15.5h15M7 15.5v1.4a1.1 1.1 0 002.2 0v-1.4m5.6 0v1.4a1.1 1.1 0 002.2 0v-1.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M5.2 15.5l1.1-3.8A1.8 1.8 0 018.05 10h7.9a1.8 1.8 0 011.75 1.7l1.1 3.8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M8 10l1-2.1A1.2 1.2 0 0110.1 7h3.8a1.2 1.2 0 011.1.9L16 10"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="8.2" cy="13.2" r="0.85" fill="currentColor" />
<circle cx="15.8" cy="13.2" r="0.85" fill="currentColor" />
</svg>
);
}
/** KPI租赁 — 钥匙卡片 */
export function IconKpiLease({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="9" cy="10" r="3.2" stroke="currentColor" strokeWidth="1.6" />
<path
d="M11.4 12.2L18 18.8M15.2 16l1.6 1.6M17 17.8l1.5 1.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="9" cy="10" r="1.1" fill="currentColor" />
</svg>
);
}
/** KPI物流 — 厢式货车 */
export function IconKpiLogistics({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M3.5 7.5h10.5v9H3.5v-9z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M14 10.5h3.2L20 13.6v3H14v-6.1z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M3.5 12.2h10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="7" cy="17.6" r="1.55" stroke="currentColor" strokeWidth="1.5" />
<circle cx="17" cy="17.6" r="1.55" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
/** KPI库存 — 立体货箱 */
export function IconKpiStock({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M4.5 9.2L12 5.5l7.5 3.7v7.1L12 20l-7.5-4V9.2z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M4.5 9.2L12 13l7.5-3.8M12 13v7" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
}
/** KPI非运营 — 暂停运营 */
export function IconKpiIdle({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.6" />
<path d="M10 9.2v5.6M14 9.2v5.6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
/** KPI退出运营 — 离场箭头 */
export function IconKpiExit({ size = 14, className }: Props) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M10 5.5H7.2A2.2 2.2 0 005 7.7v8.6A2.2 2.2 0 007.2 18.5H10"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M13 12h7.5M17.2 8.8L20.5 12l-3.3 3.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,368 @@
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { VehicleRecord } from '../utils/filter';
import { daysUntilExpire } from '../../vehicle-management/utils/vehicle';
import {
insuranceStatusTone,
resolveVehicleInsuranceOverview,
type InsuranceHistoryRow,
type InsuranceTypeCoverage,
type VehicleInsuranceTypeKey,
} from '../../vehicle-management/utils/vehicleInsuranceRecords';
import { showAppToast } from './AppToast';
import { IconX } from './Icons';
import { RecordsEmpty } from './RecordsEmpty';
import {
RecordTimeline,
sortByDateDesc,
timelineDatePart,
type TimelineItem,
} from './RecordTimeline';
function ExpireInline({ date }: { date: string }) {
if (!date) return <span></span>;
const days = daysUntilExpire(date);
let tag = '';
let tone: 'ok' | 'warn' | 'danger' = 'ok';
if (days !== null) {
if (days < 0) {
tag = `已过期${Math.abs(days)}`;
tone = 'danger';
} else {
tag = `剩余${days}`;
if (days <= 30) tone = 'warn';
}
}
return (
<span className="h5-va-expire">
<span className="h5-va-expire-date">{date.slice(0, 10)}</span>
{tag ? <span className={`h5-va-expire-tag h5-va-expire-tag--${tone}`}>{tag}</span> : null}
</span>
);
}
function StatusPill({
label,
tone,
}: {
label: string;
tone: 'green' | 'amber' | 'red' | 'gray' | 'blue';
}) {
return <span className={`h5-va-ins-pill h5-va-ins-pill--${tone}`}>{label}</span>;
}
function shortStatusLabel(status: InsuranceTypeCoverage['status']): string {
switch (status) {
case 'not_purchased':
return '未购';
case 'valid':
return '正常';
case 'warning':
return '临期';
case 'expired':
return '到期';
case 'suspended':
return '停保';
case 'cancelled':
return '退保';
default:
return '—';
}
}
function TypeCard({
coverage,
active,
onClick,
}: {
coverage: InsuranceTypeCoverage;
active: boolean;
onClick: () => void;
}) {
const tone = insuranceStatusTone(coverage.status);
return (
<button
type="button"
className={`h5-va-ins-type h5-va-ins-type--${tone}${active ? ' is-active' : ''}`}
aria-pressed={active}
onClick={onClick}
>
<span className="h5-va-ins-type-title">{coverage.label}</span>
<span className={`h5-va-ins-type-dot h5-va-ins-type-dot--${tone}`} aria-hidden />
<span className="h5-va-ins-type-state">{shortStatusLabel(coverage.status)}</span>
{coverage.history.length > 0 ? (
<span className="h5-va-ins-type-count">{coverage.history.length}</span>
) : null}
</button>
);
}
function buildPolicyPreviewUrl(fileName: string): string {
const seed = fileName.replace(/[^a-zA-Z0-9]/g, '').slice(0, 24) || 'policy';
return `https://picsum.photos/seed/${seed}/720/960`;
}
function downloadPolicyFile(fileName: string) {
const content = [
'OneOS 保单附件(原型演示)',
`文件名:${fileName}`,
'说明:正式环境将下载真实保单 PDF本文件仅用于演示下载流程。',
'',
].join('\n');
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const safeName = fileName.replace(/[\\/:*?"<>|]/g, '_');
link.href = url;
link.download = /\.pdf$/i.test(safeName) ? safeName.replace(/\.pdf$/i, '.txt') : `${safeName}.txt`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
showAppToast(`已开始下载:${fileName}`);
}
function PolicyPreviewSheet({
fileName,
onClose,
onDownload,
}: {
fileName: string;
onClose: () => void;
onDownload: () => void;
}) {
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
const host = typeof document !== 'undefined'
? (document.querySelector('.h5-phone') as HTMLElement | null)
: null;
useEffect(() => {
closeRef.current?.focus();
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [onClose]);
const previewUrl = buildPolicyPreviewUrl(fileName);
const sheet = (
<div className="h5-va-sheet-mask h5-va-policy-mask" role="presentation" onClick={onClose}>
<div
className="h5-va-sheet h5-va-sheet--filters h5-va-policy-sheet"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
<div className="h5-va-sheet-handle" aria-hidden />
<div className="h5-va-sheet-head">
<h2 id={titleId}></h2>
<button
ref={closeRef}
type="button"
className="h5-nav-btn"
onClick={onClose}
aria-label="关闭保单预览"
>
<IconX />
</button>
</div>
<div className="h5-va-sheet-body h5-va-policy-body">
<div className="h5-va-policy-meta">
<div className="h5-va-policy-meta-label"></div>
<div className="h5-va-policy-meta-name">{fileName}</div>
<div className="h5-va-policy-meta-hint"> PDF</div>
</div>
<div className="h5-va-policy-frame">
<img src={previewUrl} alt={`保单预览 ${fileName}`} loading="lazy" />
</div>
</div>
<div className="h5-va-sheet-actions">
<button type="button" className="h5-btn h5-btn--ghost" onClick={onClose}>
</button>
<button type="button" className="h5-btn h5-btn--primary" onClick={onDownload}>
</button>
</div>
</div>
</div>
);
if (host) return createPortal(sheet, host);
return sheet;
}
function HistoryTimeline({
rows,
onPreview,
onDownload,
}: {
rows: InsuranceHistoryRow[];
onPreview: (fileName: string) => void;
onDownload: (fileName: string) => void;
}) {
const items = useMemo((): TimelineItem[] => {
const sorted = sortByDateDesc(rows, (row) => {
const purchase = timelineDatePart(row.purchaseTime);
const expire = timelineDatePart(row.expireDate);
return purchase || expire;
});
return sorted.map((row) => {
const open = row.policyStatus === '正常';
return {
id: row.id,
date: timelineDatePart(row.purchaseTime) || timelineDatePart(row.expireDate) || '—',
accent: open && Boolean(row.remainText && !row.remainText.includes('过期')),
badge: row.purchaseType,
title: row.policyNo || '—',
subtitle: row.company || undefined,
meta: [
{ label: '状态', value: row.policyStatus || '—' },
{
label: '到期',
value: row.expireDate ? <ExpireInline date={row.expireDate} /> : '—',
},
],
footer: (
<div className="h5-va-ins-history-file">
{row.policyFileName ? (
<div className="h5-va-ins-file-row">
<div className="h5-va-ins-file-name" title={row.policyFileName}>
{row.policyFileName}
</div>
<div className="h5-va-ins-file-actions">
<button
type="button"
className="h5-va-ins-file-btn"
onClick={() => onPreview(row.policyFileName!)}
>
</button>
<button
type="button"
className="h5-va-ins-file-btn h5-va-ins-file-btn--primary"
onClick={() => onDownload(row.policyFileName!)}
>
</button>
</div>
</div>
) : (
<span className="h5-va-ins-file-empty"></span>
)}
</div>
),
};
});
}, [rows, onPreview, onDownload]);
return <RecordTimeline items={items} label="采购批单历史时间轴" />;
}
export function InsuranceRecordsPanel({ record }: { record: VehicleRecord }) {
const overview = useMemo(() => resolveVehicleInsuranceOverview(record), [record]);
const [activeType, setActiveType] = useState<VehicleInsuranceTypeKey>('compulsory');
const [previewFile, setPreviewFile] = useState<string | null>(null);
const active = overview.types.find((item) => item.key === activeType) ?? overview.types[0];
function handleDownload(fileName: string) {
downloadPolicyFile(fileName);
}
return (
<div className="h5-va-ins-panel">
<section className="h5-va-ins-hero" aria-label="保险总览">
<div className="h5-va-ins-hero-row">
<span className="h5-va-ins-hero-title"></span>
<StatusPill
label={overview.overallLabel}
tone={overview.overallTone}
/>
</div>
{overview.overallTip ? (
<p className="h5-va-ins-hero-tip">{overview.overallTip}</p>
) : null}
</section>
<div className="h5-va-ins-type-grid" role="tablist" aria-label="险种">
{overview.types.map((coverage) => (
<TypeCard
key={coverage.key}
coverage={coverage}
active={active.key === coverage.key}
onClick={() => setActiveType(coverage.key)}
/>
))}
</div>
<section className="h5-va-section" role="tabpanel" aria-label={active.label}>
<div className="h5-va-ins-panel-head">
<h3 className="h5-va-section-title">{active.label}</h3>
<StatusPill label={active.statusLabel} tone={insuranceStatusTone(active.status)} />
</div>
{active.history.length === 0 ? (
<RecordsEmpty
compact
title={`${active.label}暂无记录`}
desc="请在 PC「保险采购」录入或导入保单后手机端即可同步查看"
tip="切换上方险种可查看其他保险档案"
/>
) : (
<>
<div className="h5-va-field-grid h5-va-ins-summary">
<div className="h5-va-field">
<div className="h5-va-field-label"></div>
<div className="h5-va-field-value h5-va-field-value--code">{active.policyNo || '—'}</div>
</div>
<div className="h5-va-field">
<div className="h5-va-field-label"></div>
<div className="h5-va-field-value">{active.company || '—'}</div>
</div>
<div className="h5-va-field">
<div className="h5-va-field-label"></div>
<div className="h5-va-field-value">
{active.expireDate ? <ExpireInline date={active.expireDate} /> : '—'}
</div>
</div>
<div className="h5-va-field">
<div className="h5-va-field-label"></div>
<div className="h5-va-field-value">{active.premium || '—'}</div>
</div>
</div>
<div className="h5-va-ins-history-list">
<div className="h5-va-ins-history-caption"> / </div>
<HistoryTimeline
rows={active.history}
onPreview={setPreviewFile}
onDownload={handleDownload}
/>
</div>
</>
)}
</section>
{previewFile ? (
<PolicyPreviewSheet
fileName={previewFile}
onClose={() => setPreviewFile(null)}
onDownload={() => handleDownload(previewFile)}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,129 @@
import React, { useMemo, useState } from 'react';
import type { VehicleRecord } from '../utils/filter';
import {
resolveVehicleLeaseRecords,
type LeaseRecordRow,
} from '../../vehicle-management/utils/leaseRecords';
import { ensureLeaseRecords } from '../utils/demo-biz-records';
import { IconCircleX, IconSearch } from './Icons';
import { RecordsEmpty } from './RecordsEmpty';
import { RecordTimeline, type TimelineItem } from './RecordTimeline';
function hasReturnDate(row: LeaseRecordRow): boolean {
const value = (row.returnDate || '').trim();
return Boolean(value) && value !== '-' && value !== '—';
}
/** 时间轴排序键:取交车/还车中较近的日期,近→远 */
function timelineSortKey(row: LeaseRecordRow): string {
const delivery = (row.deliveryDate || '').trim();
const ret = hasReturnDate(row) ? row.returnDate.trim() : '';
if (delivery && ret) return delivery > ret ? delivery : ret;
return ret || delivery || '';
}
function formatReturnLabel(row: LeaseRecordRow): string {
return hasReturnDate(row) ? row.returnDate : '未还车';
}
function matchesLeaseSearch(row: LeaseRecordRow, query: string): boolean {
const needle = query.trim().toLowerCase();
if (!needle) return true;
const customer = (row.customerName || '').toLowerCase();
const project = (row.projectName || '').toLowerCase();
return customer.includes(needle) || project.includes(needle);
}
export function LeaseRecordsPanel({ record }: { record: VehicleRecord }) {
const [query, setQuery] = useState('');
const allRows = useMemo(() => {
const list = [...ensureLeaseRecords(record, resolveVehicleLeaseRecords(record))];
return list.sort((a, b) => timelineSortKey(b).localeCompare(timelineSortKey(a)));
}, [record]);
const rows = useMemo(
() => allRows.filter((row) => matchesLeaseSearch(row, query)),
[allRows, query],
);
const items = useMemo(
(): TimelineItem[] =>
rows.map((row) => {
const returned = hasReturnDate(row);
return {
id: row.id,
date: timelineSortKey(row) || '—',
accent: !returned,
badge: row.businessType || undefined,
title: row.contractNo || '—',
subtitle: row.projectName || undefined,
meta: [
{ label: '客户', value: row.customerName || '—' },
{ label: '交车', value: row.deliveryDate || '—' },
{
label: '还车',
value: formatReturnLabel(row),
pending: !returned,
},
],
};
}),
[rows],
);
const hasQuery = Boolean(query.trim());
if (!allRows.length) {
return (
<RecordsEmpty
title="暂无记录"
desc="该车辆暂无租赁交还车记录,可回 PC 录入后同步查看"
/>
);
}
return (
<div className="h5-va-tl-panel h5-va-section--records" aria-label="租赁记录">
<div className="h5-va-tl-search h5-va-search">
<IconSearch size={16} />
<input
type="search"
value={query}
placeholder="搜索客户名称 / 项目名称"
enterKeyHint="search"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
inputMode="search"
onChange={(event) => setQuery(event.target.value)}
aria-label="搜索客户名称或项目名称"
/>
{hasQuery ? (
<button
type="button"
className="h5-va-clear-btn"
onClick={() => setQuery('')}
aria-label="清空搜索"
>
<IconCircleX size={16} />
</button>
) : null}
</div>
{!items.length ? (
<div className="h5-va-empty h5-va-empty--inset" role="status">
<p className="h5-va-empty-title"></p>
<p className="h5-va-empty-desc"></p>
<div className="h5-va-empty-actions">
<button type="button" className="h5-btn h5-btn--primary" onClick={() => setQuery('')}>
</button>
</div>
</div>
) : (
<RecordTimeline items={items} label="租赁记录时间轴" />
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
import React, { useEffect, useRef, useState } from 'react';
import type { VehicleRecord } from '../utils/filter';
import {
daysUntilExpire,
formatDateYmd,
isEmpty,
isShanghaiPlate,
} from '../../vehicle-management/utils/vehicle';
import {
certPhotoStatus,
formatLicenseBalance,
formatLicenseDisplay,
LICENSE_CERT_NAV,
resolveVehicleLicenseBundle,
type VehicleLicenseBundle,
} from '../../vehicle-management/utils/vehicleLicenses';
type CertKey = keyof VehicleLicenseBundle;
function ExpireValue({ date, emptyLabel = '未上传' }: { date: string; emptyLabel?: string }) {
if (isEmpty(date)) return <>{emptyLabel}</>;
const text = formatDateYmd(date) || String(date).slice(0, 10);
const days = daysUntilExpire(date);
let tag: string | null = null;
let tone: 'ok' | 'warn' | 'danger' = 'ok';
if (days !== null) {
if (days < 0) {
tag = `已过期${Math.abs(days)}`;
tone = 'danger';
} else {
tag = `剩余${days}`;
if (days <= 30) tone = 'warn';
}
}
return (
<span className="h5-va-expire">
<span className="h5-va-expire-date">{text}</span>
{tag ? <span className={`h5-va-expire-tag h5-va-expire-tag--${tone}`}>{tag}</span> : null}
</span>
);
}
function formatRemainDaysLabel(date: string): string {
if (isEmpty(date)) return '—';
const days = daysUntilExpire(date);
if (days === null) return '—';
return days < 0 ? `已过期${Math.abs(days)}` : `剩余${days}`;
}
function Field({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="h5-va-field">
<div className="h5-va-field-label">{label}</div>
<div className="h5-va-field-value">{value}</div>
</div>
);
}
function CertStatusBadge({ status }: { status: 'uploaded' | 'empty' | 'bound' | 'unbound' }) {
const label =
status === 'uploaded' ? '已上传'
: status === 'empty' ? '未上传'
: status === 'bound' ? '已绑定'
: '未绑定';
return (
<span className={`h5-va-cert-badge h5-va-cert-badge--${status === 'bound' || status === 'uploaded' ? 'ok' : 'empty'}`}>
{label}
</span>
);
}
function LicensePhotoRow({ photos, alt }: { photos: string[]; alt: string }) {
if (!photos.length) {
return <div className="h5-va-cert-photos-empty"></div>;
}
return (
<div className="h5-va-cert-photos" aria-label={`${alt}影像`}>
{photos.map((url, index) => (
<div key={`${url}-${index}`} className="h5-va-cert-photo">
<img src={url} alt={`${alt}${index + 1}`} loading="lazy" />
</div>
))}
</div>
);
}
function CertSection({
id,
title,
status,
children,
}: {
id: string;
title: string;
status: 'uploaded' | 'empty' | 'bound' | 'unbound';
children: React.ReactNode;
}) {
return (
<section className="h5-va-section h5-va-cert-section" id={id}>
<div className="h5-va-cert-head">
<h3 className="h5-va-section-title">{title}</h3>
<CertStatusBadge status={status} />
</div>
{children}
</section>
);
}
function certAnchorId(key: CertKey): string {
return `h5-va-cert-${key}`;
}
/** 在手机壳 .h5-body 内滚动到证照区块scrollIntoView 会滚错外层容器) */
function scrollCertSectionIntoBody(key: CertKey) {
const body = document.querySelector('.h5-body') as HTMLElement | null;
const target = document.getElementById(certAnchorId(key));
if (!body || !target) return;
const tabs = body.querySelector('.h5-va-tabs') as HTMLElement | null;
const indexBar = body.querySelector('.h5-va-cert-index') as HTMLElement | null;
const stickyOffset = (tabs?.offsetHeight || 0) + (indexBar?.offsetHeight || 0) + 6;
const bodyRect = body.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const nextTop = body.scrollTop + (targetRect.top - bodyRect.top) - stickyOffset;
body.scrollTo({ top: Math.max(0, nextTop), behavior: 'smooth' });
}
function scrollChipIntoIndex(chip: HTMLButtonElement | null | undefined) {
if (!chip) return;
const indexBar = chip.closest('.h5-va-cert-index') as HTMLElement | null;
if (!indexBar) return;
const nextLeft = chip.offsetLeft - (indexBar.clientWidth - chip.offsetWidth) / 2;
indexBar.scrollTo({ left: Math.max(0, nextLeft), behavior: 'smooth' });
}
export function LicenseCertPanel({ record }: { record: VehicleRecord }) {
const bundle = resolveVehicleLicenseBundle(record);
const shanghaiPlate = isShanghaiPlate(record.plateNo);
const shEval = record.ratingTime || bundle.driverLicense.shNextEvaluation || '';
const hasHydrogen = Boolean(bundle.hydrogenCard.cardNo);
const [activeKey, setActiveKey] = useState<CertKey>('driverLicense');
const chipRefs = useRef<Partial<Record<CertKey, HTMLButtonElement | null>>>({});
useEffect(() => {
scrollChipIntoIndex(chipRefs.current[activeKey]);
}, [activeKey]);
function selectCert(key: CertKey) {
setActiveKey(key);
// 等选中态与 sticky 高度稳定后再滚
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollChipIntoIndex(chipRefs.current[key]);
scrollCertSectionIntoBody(key);
});
});
}
return (
<div className="h5-va-cert-panel">
<div className="h5-va-cert-index" role="tablist" aria-label="证照分类">
{LICENSE_CERT_NAV.map((item) => (
<button
key={item.key}
ref={(node) => {
chipRefs.current[item.key] = node;
}}
type="button"
role="tab"
aria-selected={activeKey === item.key}
className={`h5-va-cert-index-chip${activeKey === item.key ? ' is-active' : ''}`}
onClick={() => selectCert(item.key)}
>
{item.label}
</button>
))}
</div>
<CertSection
id={certAnchorId('driverLicense')}
title="行驶证"
status={certPhotoStatus(bundle.driverLicense)}
>
<LicensePhotoRow photos={bundle.driverLicense.photos} alt="行驶证" />
<div className="h5-va-field-grid">
<Field label="注册日期" value={formatLicenseDisplay(bundle.driverLicense.regDate)} />
<Field label="发证日期" value={formatLicenseDisplay(bundle.driverLicense.issueDate)} />
<Field label="强制报废日期" value={formatLicenseDisplay(bundle.driverLicense.scrapDate)} />
<Field
label="检验有效期至"
value={<ExpireValue date={bundle.driverLicense.expireDate || record.inspectExpire} />}
/>
</div>
{shanghaiPlate ? (
<div className="h5-va-cert-sh">
<div className="h5-va-cert-sh-badge"></div>
<div className="h5-va-field-grid">
<Field
label="下次等级评定时间"
value={isEmpty(shEval) ? '未上传' : (formatDateYmd(shEval) || shEval.slice(0, 10))}
/>
<Field label="剩余天数" value={formatRemainDaysLabel(shEval)} />
</div>
</div>
) : null}
</CertSection>
<CertSection
id={certAnchorId('transportLicense')}
title="道路运输证"
status={certPhotoStatus(bundle.transportLicense)}
>
<LicensePhotoRow photos={bundle.transportLicense.photos} alt="道路运输证" />
<div className="h5-va-field-grid">
<Field label="经营许可证号" value={formatLicenseDisplay(bundle.transportLicense.licenseNo)} />
<Field label="核发时间" value={formatLicenseDisplay(bundle.transportLicense.issueDate)} />
<Field label="证件有效期" value={<ExpireValue date={bundle.transportLicense.expireDate} />} />
<Field label="审验有效期" value={<ExpireValue date={bundle.transportLicense.inspectValidUntil} />} />
</div>
</CertSection>
<CertSection
id={certAnchorId('registrationCert')}
title="登记证"
status={certPhotoStatus(bundle.registrationCert)}
>
<LicensePhotoRow photos={bundle.registrationCert.photos} alt="登记证" />
</CertSection>
<CertSection
id={certAnchorId('specialEquipCert')}
title="特种设备使用登记证"
status={certPhotoStatus(bundle.specialEquipCert)}
>
<LicensePhotoRow photos={bundle.specialEquipCert.photos} alt="特种设备使用登记证" />
</CertSection>
<CertSection
id={certAnchorId('specialEquipDecal')}
title="特种设备使用标识"
status={certPhotoStatus(bundle.specialEquipDecal)}
>
<LicensePhotoRow photos={bundle.specialEquipDecal.photos} alt="特种设备使用标识" />
<div className="h5-va-field-grid">
<Field
label="下次检验日期"
value={<ExpireValue date={bundle.specialEquipDecal.nextInspectDate} />}
/>
</div>
</CertSection>
<CertSection
id={certAnchorId('hydrogenCard')}
title="加氢卡"
status={hasHydrogen ? 'bound' : 'unbound'}
>
{hasHydrogen ? (
<div className="h5-va-field-grid">
<Field label="加氢卡号" value={bundle.hydrogenCard.cardNo} />
<Field label="实时余额" value={`¥ ${formatLicenseBalance(bundle.hydrogenCard.balance)}`} />
<Field label="配发时间" value={formatLicenseDisplay(bundle.hydrogenCard.issueDate)} />
<Field label="配发经办人" value={formatLicenseDisplay(bundle.hydrogenCard.issueUser)} />
<Field label="卡类型" value={formatLicenseDisplay(bundle.hydrogenCard.cardType)} />
</div>
) : (
<div className="h5-va-cert-empty-note"></div>
)}
</CertSection>
<CertSection
id={certAnchorId('safetyValve')}
title="安全阀"
status={certPhotoStatus(bundle.safetyValve)}
>
<LicensePhotoRow photos={bundle.safetyValve.photos} alt="安全阀" />
<div className="h5-va-field-grid">
<Field label="本次检验日期" value={formatLicenseDisplay(bundle.safetyValve.inspectDate)} />
<Field
label="下次检测日期"
value={<ExpireValue date={bundle.safetyValve.nextInspectDate} />}
/>
</div>
</CertSection>
<CertSection
id={certAnchorId('pressureGauge')}
title="压力表"
status={certPhotoStatus(bundle.pressureGauge)}
>
<LicensePhotoRow photos={bundle.pressureGauge.photos} alt="压力表" />
<div className="h5-va-field-grid">
<Field label="本次检验日期" value={formatLicenseDisplay(bundle.pressureGauge.inspectDate)} />
<Field
label="下次检验日期"
value={<ExpireValue date={bundle.pressureGauge.nextInspectDate} />}
/>
</div>
</CertSection>
</div>
);
}

View File

@@ -0,0 +1,259 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const ITEM_H = 36;
const VISIBLE = 5;
const PAD = Math.floor(VISIBLE / 2);
function pad2(n: number) {
return String(n).padStart(2, '0');
}
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate();
}
function parseYmd(value?: string): { y: number; m: number; d: number } {
const now = new Date();
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
}
const [ys, ms, ds] = value.split('-');
const y = Number(ys);
const m = Number(ms);
const d = Number(ds);
if (!y || !m || !d) return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
return { y, m, d: Math.min(d, daysInMonth(y, m)) };
}
function formatYmd(y: number, m: number, d: number) {
return `${y}-${pad2(m)}-${pad2(d)}`;
}
function formatDisplay(value?: string) {
if (!value) return '';
const { y, m, d } = parseYmd(value);
return `${y}${pad2(m)}${pad2(d)}`;
}
type WheelProps = {
options: { value: number; label: string }[];
value: number;
onChange: (v: number) => void;
'aria-label': string;
};
function WheelColumn({ options, value, onChange, 'aria-label': ariaLabel }: WheelProps) {
const ref = useRef<HTMLDivElement>(null);
const lock = useRef(false);
const timer = useRef(0);
const idx = Math.max(
0,
options.findIndex((o) => o.value === value),
);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
lock.current = true;
el.scrollTop = idx * ITEM_H;
requestAnimationFrame(() => {
lock.current = false;
});
}, [idx, options.length]);
const settle = () => {
const el = ref.current;
if (!el || lock.current) return;
const next = Math.round(el.scrollTop / ITEM_H);
const clamped = Math.max(0, Math.min(options.length - 1, next));
el.scrollTop = clamped * ITEM_H;
const opt = options[clamped];
if (opt && opt.value !== value) onChange(opt.value);
};
return (
<div className="h5-va-date-wheel-col" aria-label={ariaLabel}>
<div
ref={ref}
className="h5-va-date-wheel-scroll"
onScroll={() => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(settle, 90);
}}
onTouchEnd={settle}
onMouseUp={settle}
>
<div style={{ height: PAD * ITEM_H }} aria-hidden />
{options.map((o) => (
<div
key={o.value}
className={`h5-va-date-wheel-item${o.value === value ? ' is-active' : ''}`}
style={{ height: ITEM_H }}
>
{o.label}
</div>
))}
<div style={{ height: PAD * ITEM_H }} aria-hidden />
</div>
</div>
);
}
type SheetProps = {
open: boolean;
title?: string;
value?: string;
minYear?: number;
maxYear?: number;
onConfirm: (ymd: string) => void;
onClose: () => void;
};
/** iOS 风格底部滚轮日期选择(精确到日) */
export function NativeDatePickerSheet({
open,
title = '选择日期',
value,
minYear = 2015,
maxYear,
onConfirm,
onClose,
}: SheetProps) {
const parsed = parseYmd(value);
const [y, setY] = useState(parsed.y);
const [m, setM] = useState(parsed.m);
const [d, setD] = useState(parsed.d);
const [host, setHost] = useState<HTMLElement | null>(null);
const yearMax = maxYear ?? new Date().getFullYear() + 1;
useLayoutEffect(() => {
if (!open) return;
setHost(document.querySelector('.h5-phone') as HTMLElement | null);
}, [open]);
useEffect(() => {
if (!open) return;
const next = parseYmd(value);
setY(next.y);
setM(next.m);
setD(next.d);
}, [open, value]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [open, onClose]);
const maxDay = daysInMonth(y, m);
useEffect(() => {
if (d > maxDay) setD(maxDay);
}, [d, maxDay]);
const years = useMemo(
() => Array.from({ length: yearMax - minYear + 1 }, (_, i) => minYear + i).map((n) => ({ value: n, label: `${n}` })),
[minYear, yearMax],
);
const months = useMemo(
() => Array.from({ length: 12 }, (_, i) => i + 1).map((n) => ({ value: n, label: `${n}` })),
[],
);
const days = useMemo(
() => Array.from({ length: maxDay }, (_, i) => i + 1).map((n) => ({ value: n, label: `${n}` })),
[maxDay],
);
if (!open || !host) return null;
return createPortal(
<div className="h5-va-sheet-mask h5-va-native-mask" role="presentation" onClick={onClose}>
<div
className="h5-va-sheet h5-va-native-sheet h5-va-date-sheet"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<div className="h5-va-sheet-handle" aria-hidden />
<div className="h5-va-native-head">
<button type="button" className="h5-va-native-head-btn h5-va-native-head-btn--text" onClick={onClose}>
</button>
<h2 className="h5-va-native-title">{title}</h2>
<button
type="button"
className="h5-va-native-head-btn h5-va-native-head-btn--text h5-va-native-head-btn--done"
onClick={() => {
onConfirm(formatYmd(y, m, Math.min(d, daysInMonth(y, m))));
onClose();
}}
>
</button>
</div>
<div className="h5-va-date-wheels" aria-label="年 月 日">
<div className="h5-va-date-wheel-highlight" aria-hidden />
<WheelColumn aria-label="年" options={years} value={y} onChange={setY} />
<WheelColumn aria-label="月" options={months} value={m} onChange={setM} />
<WheelColumn aria-label="日" options={days} value={Math.min(d, maxDay)} onChange={setD} />
</div>
</div>
</div>,
host,
);
}
type FieldProps = {
value?: string;
placeholder?: string;
title?: string;
disabled?: boolean;
onChange: (ymd: string) => void;
className?: string;
'aria-label'?: string;
};
/** 触发器 + 原生滚轮日期选择(精确到日) */
export function NativeDateField({
value,
placeholder = '请选择',
title = '选择日期',
disabled,
onChange,
className,
'aria-label': ariaLabel,
}: FieldProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
className={`h5-va-native-date-field${className ? ` ${className}` : ''}${!value ? ' is-empty' : ''}`}
disabled={disabled}
aria-label={ariaLabel || title}
onClick={() => setOpen(true)}
>
<span>{value ? formatDisplay(value) : placeholder}</span>
</button>
<NativeDatePickerSheet
open={open}
title={title}
value={value}
onConfirm={onChange}
onClose={() => setOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,392 @@
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
import { IconCheck, IconChevronLeft, IconSearch, IconX } from './Icons';
export type PickerOption = {
value: string;
label: string;
meta?: string;
};
type BaseProps = {
open: boolean;
title: string;
options: PickerOption[];
value: string;
onChange: (value: string) => void;
onClose: () => void;
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
/** Show leading back instead of close */
onBack?: () => void;
annotationId?: string;
closeOnSelect?: boolean;
};
function useSheetLock(open: boolean, onClose: () => void) {
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onCloseRef.current();
}
}
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [open]);
}
/** App 原生风格底部列表选择器 */
export function NativePickerSheet({
open,
title,
options,
value,
onChange,
onClose,
searchable = false,
searchPlaceholder = '搜索',
emptyText = '暂无可选项',
onBack,
annotationId,
closeOnSelect = true,
}: BaseProps) {
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState('');
useSheetLock(open, onClose);
useEffect(() => {
if (!open) {
setQuery('');
return;
}
closeRef.current?.focus();
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((item) => (
item.label.toLowerCase().includes(q)
|| item.value.toLowerCase().includes(q)
|| (item.meta || '').toLowerCase().includes(q)
));
}, [options, query]);
if (!open) return null;
return (
<div className="h5-va-sheet-mask h5-va-native-mask" role="presentation" onClick={onClose}>
<div
className="h5-va-sheet h5-va-native-sheet"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-annotation-id={annotationId}
onClick={(event) => event.stopPropagation()}
>
<div className="h5-va-sheet-handle" aria-hidden />
<div className="h5-va-native-head">
{onBack ? (
<button
ref={closeRef}
type="button"
className="h5-va-native-head-btn"
onClick={onBack}
aria-label="返回"
>
<IconChevronLeft size={20} />
</button>
) : (
<button
ref={closeRef}
type="button"
className="h5-va-native-head-btn h5-va-native-head-btn--text"
onClick={onClose}
>
</button>
)}
<h2 id={titleId} className="h5-va-native-title">{title}</h2>
<button type="button" className="h5-va-native-head-btn" onClick={onClose} aria-label="关闭">
<IconX size={18} />
</button>
</div>
{searchable ? (
<div className="h5-va-native-search">
<IconSearch size={16} />
<input
type="search"
value={query}
placeholder={searchPlaceholder}
onChange={(event) => setQuery(event.target.value)}
aria-label={searchPlaceholder}
/>
</div>
) : null}
<div className="h5-va-native-list" role="listbox" aria-label={title}>
{filtered.length === 0 ? (
<div className="h5-va-native-empty">{emptyText}</div>
) : (
filtered.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value || '__all__'}
type="button"
role="option"
aria-selected={selected}
className={`h5-va-native-option${selected ? ' is-selected' : ''}`}
onClick={() => {
onChange(option.value);
if (closeOnSelect) onClose();
}}
>
<span className="h5-va-native-option-main">
<span className="h5-va-native-option-label">{option.label}</span>
{option.meta ? <span className="h5-va-native-option-meta">{option.meta}</span> : null}
</span>
{selected ? (
<span className="h5-va-native-option-check" aria-hidden>
<IconCheck size={18} />
</span>
) : null}
</button>
);
})
)}
</div>
</div>
</div>
);
}
type BrandModelProps = {
open: boolean;
brand: string;
model: string;
brandOptions: Array<{ value: string; count: number }>;
getModelOptions: (brand: string) => Array<{ value: string; count: number }>;
onConfirm: (next: { brand: string; model: string }) => void;
onClose: () => void;
};
/** 品牌 → 车型 两级原生选择 */
export function BrandModelPickerSheet({
open,
brand,
model,
brandOptions,
getModelOptions,
onConfirm,
onClose,
}: BrandModelProps) {
const [step, setStep] = useState<'brand' | 'model'>('brand');
const [draftBrand, setDraftBrand] = useState(brand);
useEffect(() => {
if (!open) return;
setStep('brand');
setDraftBrand(brand);
}, [open, brand]);
const brandPickerOptions = useMemo<PickerOption[]>(() => ([
{ value: '', label: '全部品牌', meta: '不限' },
...brandOptions.map((item) => ({
value: item.value,
label: item.value,
meta: `${item.count}`,
})),
]), [brandOptions]);
const modelPickerOptions = useMemo<PickerOption[]>(() => {
const models = getModelOptions(draftBrand);
return [
{ value: '', label: draftBrand ? `全部「${draftBrand}` : '全部车型', meta: '不限型号' },
...models.map((item) => ({
value: item.value,
label: item.value,
meta: `${item.count}`,
})),
];
}, [draftBrand, getModelOptions]);
if (!open) return null;
if (step === 'brand') {
return (
<NativePickerSheet
open
title="选择品牌"
options={brandPickerOptions}
value={brand}
searchable
searchPlaceholder="搜索品牌"
annotationId="h5-va-brand-picker"
closeOnSelect={false}
onClose={onClose}
onChange={(nextBrand) => {
if (!nextBrand) {
onConfirm({ brand: '', model: '' });
onClose();
return;
}
setDraftBrand(nextBrand);
setStep('model');
}}
/>
);
}
return (
<NativePickerSheet
open
title="选择车型"
options={modelPickerOptions}
value={draftBrand === brand ? model : ''}
searchable
searchPlaceholder="搜索车型"
annotationId="h5-va-model-picker"
onBack={() => setStep('brand')}
onClose={onClose}
onChange={(nextModel) => {
onConfirm({ brand: draftBrand, model: nextModel });
onClose();
}}
/>
);
}
type OperateCityProps = {
open: boolean;
/** 筛选值:空 | 省 | 省-市 */
value: string;
provinceOptions: Array<{ value: string; count: number }>;
getCityOptions: (province: string) => Array<{ value: string; count: number }>;
onConfirm: (next: string) => void;
onClose: () => void;
};
/** 运营城市:省 → 市;支持只选省 */
export function OperateCityPickerSheet({
open,
value,
provinceOptions,
getCityOptions,
onConfirm,
onClose,
}: OperateCityProps) {
const [step, setStep] = useState<'province' | 'city'>('province');
const [draftProvince, setDraftProvince] = useState('');
useEffect(() => {
if (!open) return;
setStep('province');
const raw = String(value || '').trim().replace(/-+/g, '-');
if (!raw) {
setDraftProvince('');
return;
}
if (!raw.includes('-')) {
setDraftProvince(raw);
return;
}
setDraftProvince(raw.slice(0, raw.indexOf('-')));
}, [open, value]);
const provincePickerOptions = useMemo<PickerOption[]>(() => ([
{ value: '', label: '全部省份', meta: '不限' },
...provinceOptions.map((item) => ({
value: item.value,
label: item.value,
meta: `${item.count}`,
})),
]), [provinceOptions]);
const cityPickerOptions = useMemo<PickerOption[]>(() => {
const cities = getCityOptions(draftProvince);
return [
{
value: '',
label: draftProvince ? `全部「${draftProvince}` : '全部城市',
meta: '仅按省筛选',
},
...cities.map((item) => ({
value: item.value,
label: item.value,
meta: `${item.count}`,
})),
];
}, [draftProvince, getCityOptions]);
const selectedCity = useMemo(() => {
const raw = String(value || '').trim().replace(/-+/g, '-');
if (!raw.includes('-')) return '';
const province = raw.slice(0, raw.indexOf('-'));
const city = raw.slice(raw.indexOf('-') + 1);
return province === draftProvince ? city : '';
}, [value, draftProvince]);
if (!open) return null;
if (step === 'province') {
return (
<NativePickerSheet
open
title="选择省份"
options={provincePickerOptions}
value={value ? draftProvince : ''}
searchable
searchPlaceholder="搜索省份"
annotationId="h5-va-city-picker"
closeOnSelect={false}
onClose={onClose}
onChange={(nextProvince) => {
if (!nextProvince) {
onConfirm('');
onClose();
return;
}
setDraftProvince(nextProvince);
setStep('city');
}}
/>
);
}
return (
<NativePickerSheet
open
title="选择城市"
options={cityPickerOptions}
value={selectedCity}
searchable
searchPlaceholder="搜索城市"
annotationId="h5-va-city-picker"
onBack={() => setStep('province')}
onClose={onClose}
onChange={(nextCity) => {
if (!nextCity) {
onConfirm(draftProvince);
} else {
onConfirm(`${draftProvince}-${nextCity}`);
}
onClose();
}}
/>
);
}

View File

@@ -0,0 +1,126 @@
import React from 'react';
import {
IconBell,
IconBriefcase,
IconCar,
IconMessage,
IconPie,
IconUser,
} from './Icons';
import { showAppToast } from './AppToast';
type Props = {
title?: string;
children: React.ReactNode;
/** 弹层等覆盖层,挂在手机壳内,避免超出操作区 */
overlays?: React.ReactNode;
bodyClassName?: string;
showBottomNav?: boolean;
left?: React.ReactNode;
right?: React.ReactNode;
headerMode?: 'app' | 'detail';
};
export function PhoneShell({
title = '车辆资产',
children,
overlays,
bodyClassName,
showBottomNav = false,
left,
right,
headerMode = 'app',
}: Props) {
const now = new Date();
const timeLabel = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const appHeader = (
<header className="h5-nav h5-nav--app">
<h1 className="h5-nav-brand">{title}</h1>
<div className="h5-nav-actions">
<button
type="button"
className="h5-nav-icon-btn"
aria-label="通知"
onClick={() => showAppToast('通知中心开发中,本期仅支持查车')}
>
<IconBell />
<span className="h5-nav-badge" aria-hidden />
</button>
</div>
</header>
);
const detailHeader = (
<header className="h5-nav h5-nav--detail">
<div className="h5-nav-side">{left || <span className="h5-nav-side-spacer" aria-hidden />}</div>
<h1 className="h5-nav-title">{title}</h1>
<div className="h5-nav-side h5-nav-side--end">{right || <span className="h5-nav-side-spacer" aria-hidden />}</div>
</header>
);
return (
<div
className={[
'h5-phone',
showBottomNav ? 'h5-phone--tabs' : '',
headerMode === 'app' || headerMode === 'detail' ? 'h5-phone--immersive' : '',
].filter(Boolean).join(' ')}
>
<div className="h5-status-bar" aria-hidden>
<span>{timeLabel}</span>
<span className="h5-island" />
<span>5G · 86%</span>
</div>
{headerMode === 'app' ? appHeader : detailHeader}
<main className={`h5-body${bodyClassName ? ` ${bodyClassName}` : ''}`}>{children}</main>
{overlays}
{showBottomNav ? (
<nav className="h5-tabbar" aria-label="App 底部导航">
<div className="h5-tabbar-dock">
<button type="button" className="h5-tab is-active" aria-current="page">
<IconCar size={22} />
<span></span>
</button>
<button
type="button"
className="h5-tab"
onClick={() => showAppToast('业务模块未接入本页')}
>
<IconBriefcase size={22} />
<span></span>
</button>
<button
type="button"
className="h5-tab-center"
onClick={() => showAppToast('工作台未接入本页')}
aria-label="工作台"
title="工作台"
>
<IconPie size={20} />
</button>
<button
type="button"
className="h5-tab"
onClick={() => showAppToast('消息模块未接入本页')}
>
<span className="h5-tab-icon-wrap">
<IconMessage size={22} />
<span className="h5-tab-count">12</span>
</span>
<span></span>
</button>
<button
type="button"
className="h5-tab"
onClick={() => showAppToast('我的模块未接入本页')}
>
<IconUser size={22} />
<span></span>
</button>
</div>
</nav>
) : null}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
export type TimelineMetaRow = {
label: string;
value: React.ReactNode;
pending?: boolean;
};
export type TimelineItem = {
id: string;
date: string;
/** 进行中 / 未闭环节点高亮 */
accent?: boolean;
badge?: string;
title: string;
subtitle?: string;
meta?: TimelineMetaRow[];
footer?: React.ReactNode;
};
export function RecordTimeline({
items,
label,
}: {
items: TimelineItem[];
label: string;
}) {
if (!items.length) return null;
return (
<div className="h5-va-tl" aria-label={label}>
{items.map((item, index) => (
<article
key={item.id}
className={`h5-va-tl-item${item.accent ? ' is-open' : ''}`}
>
<div className="h5-va-tl-axis" aria-hidden>
<div className="h5-va-tl-dot" />
{index < items.length - 1 ? <div className="h5-va-tl-line" /> : null}
</div>
<div className="h5-va-tl-main">
<div className="h5-va-tl-date">{item.date || '—'}</div>
<div className="h5-va-tl-card">
<div className="h5-va-tl-title">{item.title || '—'}</div>
{item.subtitle ? <div className="h5-va-tl-subtitle">{item.subtitle}</div> : null}
{item.meta?.length ? (
<div className="h5-va-tl-meta">
{item.meta.map((row) => (
<div key={row.label} className="h5-va-tl-meta-row">
<span className="h5-va-tl-meta-label">{row.label}</span>
<span
className={`h5-va-tl-meta-value${row.pending ? ' is-pending' : ''}`}
>
{row.value}
</span>
</div>
))}
</div>
) : null}
{item.footer}
{item.badge ? <span className="h5-va-tl-badge">{item.badge}</span> : null}
</div>
</div>
</article>
))}
</div>
);
}
/** 取日期时间字符串的排序 / 展示用日期部分 */
export function timelineDatePart(value: string): string {
const text = (value || '').trim();
if (!text || text === '-' || text === '—') return '';
return text.slice(0, 10);
}
export function sortByDateDesc<T>(rows: T[], getDate: (row: T) => string): T[] {
return [...rows].sort((a, b) => getDate(b).localeCompare(getDate(a)));
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { IconEmptyRecords } from './Icons';
export function RecordsEmpty({
title = '暂无记录',
desc = '该车辆暂无此类业务记录,可回 PC 录入后同步查看',
tip = '演示环境已为多数车辆填充用例数据,刷新后仍为空可换车查看',
compact = false,
}: {
title?: string;
desc?: string;
tip?: string;
/** 嵌套在 Tab 区块内时使用更紧凑的空态 */
compact?: boolean;
}) {
return (
<div
className={`h5-va-empty h5-va-empty--inset${compact ? ' h5-va-empty--compact' : ''} h5-va-section--records`}
role="status"
>
<div className="h5-va-empty-visual" aria-hidden>
<span className="h5-va-empty-orb" />
<span className="h5-va-empty-ring" />
<IconEmptyRecords size={compact ? 56 : 68} className="h5-va-empty-icon" />
</div>
<p className="h5-va-empty-title">{title}</p>
{desc ? <p className="h5-va-empty-desc">{desc}</p> : null}
{tip ? <p className="h5-va-empty-tip">{tip}</p> : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More