prd-workflow-cli 1.3.0 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/workflows/prd-a2ui-guide.md +33 -36
- package/a2ui-viewer/index.html +17 -1
- package/bin/prd-cli.js +4 -4
- package/commands/a2ui-server.js +27 -5
- package/package.json +1 -1
- package/templates/a2ui-standalone.html +127 -0
|
@@ -179,54 +179,51 @@ description: A2UI 可视化指南 - 架构图与界面原型
|
|
|
179
179
|
📁 位置:02_迭代记录/第01轮迭代/C1_UI原型/"
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
-
### HTML
|
|
182
|
+
### 🚀 生成独立预览文件 (HTML)
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
2. 包含完整的渲染引擎 JS 代码
|
|
186
|
-
3. 内嵌 JSON 数据(不依赖外部文件)
|
|
187
|
-
4. 顶部显示需求编号、界面名称、确认时间
|
|
188
|
-
5. 可以脱离服务器,双击直接打开
|
|
184
|
+
当 PM 确认原型后,AI 必须生成一个**独立 HTML 文件**,该文件可脱离环境直接打开,方便分享给相关方。
|
|
189
185
|
|
|
190
|
-
|
|
186
|
+
**生成步骤**:
|
|
187
|
+
1. 读取下方的 **[HTML 独立文件模板]** 代码
|
|
188
|
+
2. 替换以下占位符:
|
|
189
|
+
- `{{TITLE}}` -> 需求编号+名称(如 "#REQ-001 用户登录")
|
|
190
|
+
- `{{REQ_ID}}` -> 需求编号
|
|
191
|
+
- `{{NAME}}` -> 界面名称
|
|
192
|
+
- `{{DATE}}` -> 当前日期 (YYYY-MM-DD)
|
|
193
|
+
- `{{JSON_DATA}}` -> 完整的 A2UI JSON 数据(注意:不要加引号,直接作为 JS 对象插入)
|
|
194
|
+
3. **关键:必须保存两份文件**:
|
|
195
|
+
- 📄 **原始数据**:保存为 `.json` (如 `REQ-001-登录页.json`) -> **用于后续溯源和修改**
|
|
196
|
+
- 👁️ **预览页面**:保存为 `.html` (如 `REQ-001-登录页.html`) -> **用于交付和查看**
|
|
197
|
+
- 路径统一为:`02_迭代记录/第XX轮迭代/C1_UI原型/`
|
|
191
198
|
|
|
192
|
-
|
|
199
|
+
**[HTML 独立文件模板]**:
|
|
193
200
|
|
|
194
|
-
|
|
201
|
+
请读取文件:`prd-cli/templates/a2ui-standalone.html` 获取完整模板代码(包含 HTML/CSS/React/AntD 逻辑)。
|
|
195
202
|
|
|
196
|
-
|
|
197
|
-
1. PM 描述结构/界面
|
|
198
|
-
↓
|
|
199
|
-
2. AI 识别关键词("系统模块"、"页面"、"表单"等)
|
|
200
|
-
↓
|
|
201
|
-
3. AI 主动提议:"让我生成一个可视化的图..."
|
|
202
|
-
↓
|
|
203
|
-
4. AI 生成 JSON 并写入 `.a2ui/current.json`
|
|
204
|
-
↓
|
|
205
|
-
5. AI 提示:👉 请刷新浏览器 (http://localhost:3333) 查看
|
|
206
|
-
↓
|
|
207
|
-
6. PM 反馈 → AI 迭代修改 → PM 确认 → AI 正式保存
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
### 在 Markdown 中嵌入预览
|
|
203
|
+
⚠️ **注意**:AI 在生成文件时,必须先读取上述模板文件的内容,然后执行替换操作。不要自己编造 HTML 结构。
|
|
211
204
|
|
|
212
|
-
|
|
213
|
-
## 系统架构图
|
|
205
|
+
### 👀 历史溯源与多文件预览
|
|
214
206
|
|
|
215
|
-
|
|
207
|
+
**方法 A:命令行指定文件**
|
|
208
|
+
```bash
|
|
209
|
+
prd ui ./path/to/specific.json
|
|
210
|
+
```
|
|
216
211
|
|
|
217
|
-
|
|
218
|
-
|
|
212
|
+
**方法 B:浏览器 URL 参数(推荐)**
|
|
213
|
+
启动服务后,直接在浏览器地址栏添加 `?file=` 参数:
|
|
214
|
+
```
|
|
215
|
+
http://localhost:3333/?file=02_迭代记录/第01轮迭代/C1_UI原型/REQ-001.json
|
|
219
216
|
```
|
|
220
217
|
|
|
221
|
-
|
|
218
|
+
> 💡 **提示**:你可以在 Markdown 文档中直接复制文件的相对路径,粘贴到 `file=` 后面。
|
|
222
219
|
|
|
223
|
-
|
|
224
|
-
# UI 原型索引
|
|
220
|
+
### 📂 多原型文件管理指南
|
|
225
221
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
一个项目中通常会有多个需求点,建议按以下方式管理:
|
|
223
|
+
|
|
224
|
+
1. **命名规范**:`REQ-{编号}-{名称}.html` (如 `REQ-003-用户反馈.html`)
|
|
225
|
+
2. **索引文件**:务必更新 `index.md`,提供所有原型的入口列表
|
|
226
|
+
3. **版本控制**:如果需求变更,直接覆盖旧文件,或另存为 `_v2.html`
|
|
230
227
|
|
|
231
228
|
---
|
|
232
229
|
|
package/a2ui-viewer/index.html
CHANGED
|
@@ -399,7 +399,8 @@
|
|
|
399
399
|
|
|
400
400
|
async function checkUpdate() {
|
|
401
401
|
try {
|
|
402
|
-
|
|
402
|
+
// 获取数据
|
|
403
|
+
const res = await fetch('/ui.json' + window.location.search);
|
|
403
404
|
if (res.ok) {
|
|
404
405
|
const data = await res.json();
|
|
405
406
|
const dataStr = JSON.stringify(data);
|
|
@@ -412,6 +413,21 @@
|
|
|
412
413
|
|
|
413
414
|
document.getElementById('status-text').textContent = '已连接';
|
|
414
415
|
document.querySelector('.status-dot').style.background = '#52c41a';
|
|
416
|
+
|
|
417
|
+
// 如果有 file 参数,显示当前文件
|
|
418
|
+
const params = new URLSearchParams(window.location.search);
|
|
419
|
+
if (params.get('file')) {
|
|
420
|
+
document.title = `预览: ${params.get('file')} - PRD A2UI`;
|
|
421
|
+
// 可以在页面上也显示一下
|
|
422
|
+
const statusBar = document.querySelector('.status-bar');
|
|
423
|
+
if (!document.getElementById('status-file-info')) {
|
|
424
|
+
const fileInfo = document.createElement('span');
|
|
425
|
+
fileInfo.id = 'status-file-info';
|
|
426
|
+
fileInfo.style.marginLeft = '10px';
|
|
427
|
+
fileInfo.textContent = `| 文件: ${params.get('file')}`;
|
|
428
|
+
statusBar.appendChild(fileInfo);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
415
431
|
}
|
|
416
432
|
} catch (e) {
|
|
417
433
|
document.getElementById('status-text').textContent = '连接失败';
|
package/bin/prd-cli.js
CHANGED
|
@@ -102,12 +102,12 @@ program
|
|
|
102
102
|
|
|
103
103
|
// A2UI 预览服务
|
|
104
104
|
program
|
|
105
|
-
.command('ui')
|
|
106
|
-
.description('启动 A2UI 界面预览服务')
|
|
105
|
+
.command('ui [file]')
|
|
106
|
+
.description('启动 A2UI 界面预览服务 [可指定 .json 文件]')
|
|
107
107
|
.option('-p, --port <number>', '指定端口号', '3333')
|
|
108
|
-
.action((options) => {
|
|
108
|
+
.action((file, options) => {
|
|
109
109
|
const A2UIServer = require('../commands/a2ui-server');
|
|
110
|
-
const server = new A2UIServer(options.port);
|
|
110
|
+
const server = new A2UIServer(options.port, file);
|
|
111
111
|
server.start();
|
|
112
112
|
});
|
|
113
113
|
|
package/commands/a2ui-server.js
CHANGED
|
@@ -5,8 +5,9 @@ const chalk = require('chalk');
|
|
|
5
5
|
const { exec } = require('child_process');
|
|
6
6
|
|
|
7
7
|
class A2UIServer {
|
|
8
|
-
constructor(port = 3333) {
|
|
8
|
+
constructor(port = 3333, targetFile = null) {
|
|
9
9
|
this.port = port;
|
|
10
|
+
this.targetFile = targetFile;
|
|
10
11
|
this.viewerPath = path.join(__dirname, '../a2ui-viewer');
|
|
11
12
|
this.projectPath = process.cwd();
|
|
12
13
|
}
|
|
@@ -18,11 +19,32 @@ class A2UIServer {
|
|
|
18
19
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
19
20
|
|
|
20
21
|
// 路由处理
|
|
21
|
-
|
|
22
|
+
const parsedUrl = new URL(req.url, `http://localhost:${this.port}`);
|
|
23
|
+
const pathname = parsedUrl.pathname;
|
|
24
|
+
|
|
25
|
+
if (pathname === '/') {
|
|
22
26
|
this.serveFile(res, path.join(this.viewerPath, 'index.html'), 'text/html');
|
|
23
|
-
} else if (
|
|
24
|
-
//
|
|
25
|
-
|
|
27
|
+
} else if (pathname === '/ui.json') {
|
|
28
|
+
// Priority: 1. URL Query Param (?file=...) 2. CLI Argument 3. Default
|
|
29
|
+
const queryFile = parsedUrl.searchParams.get('file');
|
|
30
|
+
let jsonPath = path.join(this.projectPath, '.a2ui/current.json');
|
|
31
|
+
|
|
32
|
+
if (queryFile) {
|
|
33
|
+
// Prevent directory traversal attacks (basic check)
|
|
34
|
+
// Allow relative paths from project root
|
|
35
|
+
jsonPath = path.resolve(this.projectPath, queryFile);
|
|
36
|
+
if (!jsonPath.startsWith(this.projectPath)) {
|
|
37
|
+
res.writeHead(403);
|
|
38
|
+
res.end('Access denied: File outside project directory');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
} else if (this.targetFile) {
|
|
42
|
+
jsonPath = path.isAbsolute(this.targetFile)
|
|
43
|
+
? this.targetFile
|
|
44
|
+
: path.join(this.projectPath, this.targetFile);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.serveFile(res, jsonPath, 'application/json');
|
|
26
48
|
} else {
|
|
27
49
|
res.writeHead(404);
|
|
28
50
|
res.end('Not found');
|
package/package.json
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>{{TITLE}} - PRD UI 原型</title>
|
|
8
|
+
<!-- React -->
|
|
9
|
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
|
10
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
|
11
|
+
<!-- Ant Design -->
|
|
12
|
+
<link rel="stylesheet" href="https://unpkg.com/antd@5/dist/reset.css">
|
|
13
|
+
<script src="https://unpkg.com/dayjs@1/dayjs.min.js"></script>
|
|
14
|
+
<script src="https://unpkg.com/antd@5/dist/antd.min.js"></script>
|
|
15
|
+
<!-- Icons -->
|
|
16
|
+
<script src="https://unpkg.com/@ant-design/icons@5/dist/index.umd.min.js"></script>
|
|
17
|
+
<style>
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 24px;
|
|
21
|
+
background: #f5f5f5;
|
|
22
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#root {
|
|
26
|
+
max-width: 1400px;
|
|
27
|
+
margin: 0 auto;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.page-header {
|
|
31
|
+
margin-bottom: 16px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.page-title {
|
|
35
|
+
font-size: 20px;
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
color: rgba(0, 0, 0, 0.88);
|
|
38
|
+
margin: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.meta-info {
|
|
42
|
+
background: #fff;
|
|
43
|
+
border: 1px solid #d9d9d9;
|
|
44
|
+
border-radius: 6px;
|
|
45
|
+
padding: 12px 16px;
|
|
46
|
+
margin-bottom: 16px;
|
|
47
|
+
font-size: 13px;
|
|
48
|
+
color: rgba(0, 0, 0, 0.65);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.meta-info span {
|
|
52
|
+
margin-right: 24px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.meta-info strong {
|
|
56
|
+
color: rgba(0, 0, 0, 0.88);
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
|
|
61
|
+
<body>
|
|
62
|
+
<div class="meta-info">
|
|
63
|
+
<span><strong>需求编号:</strong>{{REQ_ID}}</span>
|
|
64
|
+
<span><strong>界面名称:</strong>{{NAME}}</span>
|
|
65
|
+
<span><strong>确认时间:</strong>{{DATE}}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div id="root"></div>
|
|
68
|
+
|
|
69
|
+
<script>
|
|
70
|
+
// UI 数据(内嵌)
|
|
71
|
+
const UI_DATA = {{JSON_DATA}};
|
|
72
|
+
|
|
73
|
+
// A2UI 渲染器
|
|
74
|
+
const { ConfigProvider, Card, Button, Input, Select, Table, Tabs, Tag, Badge, Space, Row, Col, Typography, Divider, Alert, Upload, Form } = antd;
|
|
75
|
+
const { Title, Text } = Typography;
|
|
76
|
+
const { TextArea } = Input;
|
|
77
|
+
const { PlusOutlined, InboxOutlined } = icons;
|
|
78
|
+
const { Dragger } = Upload;
|
|
79
|
+
|
|
80
|
+
const A2UIRenderer = ({ data }) => {
|
|
81
|
+
const renderNode = (node) => {
|
|
82
|
+
if (!node) return null;
|
|
83
|
+
const { type, children, ...props } = node;
|
|
84
|
+
|
|
85
|
+
switch (type) {
|
|
86
|
+
case 'Page': return React.createElement('div', { key: props.id }, props.title && React.createElement('div', { className: 'page-header' }, React.createElement('h1', { className: 'page-title' }, props.title)), children && children.map((child, i) => renderNode({ ...child, key: i })));
|
|
87
|
+
case 'Panel': return React.createElement(Card, { key: props.key, title: props.title, extra: props.extra && React.createElement(Space, null, props.extra.map((btn, i) => React.createElement(Button, { key: i, type: btn.variant === 'primary' ? 'primary' : 'default' }, btn.text || btn))), style: { marginBottom: 16 } }, children && children.map((child, i) => renderNode({ ...child, key: i })));
|
|
88
|
+
case 'Row': return React.createElement(Row, { key: props.key, gutter: 16 }, children && children.map((child, i) => renderNode({ ...child, key: i })));
|
|
89
|
+
case 'Col': return React.createElement(Col, { key: props.key, flex: 1 }, children && children.map((child, i) => renderNode({ ...child, key: i })));
|
|
90
|
+
case 'Input': return React.createElement(Form.Item, { key: props.key, label: props.label, required: props.required, style: { marginBottom: 16 } }, React.createElement(Input, { placeholder: props.placeholder }));
|
|
91
|
+
case 'Textarea': return React.createElement(Form.Item, { key: props.key, label: props.label, style: { marginBottom: 16 } }, React.createElement(TextArea, { placeholder: props.placeholder, rows: props.rows || 4 }));
|
|
92
|
+
case 'Select': return React.createElement(Form.Item, { key: props.key, label: props.label, style: { marginBottom: 16 } }, React.createElement(Select, { placeholder: '请选择', options: (props.options || []).map(opt => ({ value: typeof opt === 'string' ? opt : opt.value, label: typeof opt === 'string' ? opt : opt.label })), style: { width: '100%' } }));
|
|
93
|
+
case 'Button': return React.createElement(Button, { key: props.key, type: props.variant === 'secondary' ? 'default' : props.variant === 'danger' ? 'primary' : 'primary', danger: props.variant === 'danger', style: { marginRight: 8 } }, props.text);
|
|
94
|
+
case 'Text': return React.createElement(Text, { key: props.key, style: { display: 'block', marginBottom: 8 } }, props.content);
|
|
95
|
+
case 'Tabs': return React.createElement(Tabs, { key: props.key, items: (props.items || []).map((item, i) => ({ key: String(i), label: item })), style: { marginBottom: 16 } });
|
|
96
|
+
case 'Table':
|
|
97
|
+
const columns = (props.columns || []).map(col => {
|
|
98
|
+
const column = { key: col.key || col, dataIndex: col.key || col, title: col.title || col };
|
|
99
|
+
if (col.type === 'link') column.render = (text) => React.createElement('a', null, text);
|
|
100
|
+
else if (col.type === 'badge') column.render = (text) => React.createElement(Tag, { color: col.variantMap?.[text] === 'success' ? 'green' : col.variantMap?.[text] === 'warning' ? 'orange' : col.variantMap?.[text] === 'danger' ? 'red' : 'blue' }, text);
|
|
101
|
+
else if (col.type === 'status') column.render = (text) => React.createElement(Badge, { status: text === '已发布' ? 'success' : 'default', text });
|
|
102
|
+
else if (col.type === 'actions') column.render = () => React.createElement(Space, null, React.createElement('a', null, '编辑'), React.createElement('a', null, '复制'), React.createElement('a', { style: { color: '#ff4d4f' } }, '删除'));
|
|
103
|
+
return column;
|
|
104
|
+
});
|
|
105
|
+
return React.createElement(Table, { key: props.key, columns, dataSource: (props.data || []).map((row, i) => ({ ...row, key: i })), pagination: false, size: 'middle' });
|
|
106
|
+
case 'Badge': return React.createElement(Tag, { key: props.key, color: props.variant === 'success' ? 'green' : props.variant === 'warning' ? 'orange' : props.variant === 'danger' ? 'red' : 'blue' }, props.text);
|
|
107
|
+
case 'Card': return React.createElement(Card, { key: props.key, size: 'small', style: { marginBottom: 12 } }, React.createElement(Row, { justify: 'space-between', align: 'middle' }, React.createElement(Col, null, React.createElement(Space, { direction: 'vertical', size: 0 }, React.createElement(Text, { strong: true }, props.title), props.status && React.createElement(Badge, { status: props.status === '已发布' ? 'success' : 'default', text: props.status }))), props.actions && React.createElement(Col, null, React.createElement(Space, null, props.actions.map((action, i) => React.createElement(Button, { key: i, size: 'small' }, action.text || action))))));
|
|
108
|
+
case 'Upload': return React.createElement(Dragger, { key: props.key }, React.createElement('p', { className: 'ant-upload-drag-icon' }, React.createElement(InboxOutlined)), React.createElement('p', { className: 'ant-upload-text' }, props.text || '点击或拖拽文件上传'));
|
|
109
|
+
case 'Divider': return React.createElement(Divider, { key: props.key });
|
|
110
|
+
case 'Alert': return React.createElement(Alert, { key: props.key, type: props.variant === 'danger' ? 'error' : props.variant || 'info', message: props.content || props.text, showIcon: true, style: { marginBottom: 16 } });
|
|
111
|
+
case 'Diagram': return React.createElement('div', { key: props.key, style: { background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)', borderRadius: 8, padding: 32, minHeight: 300 } }, props.title && React.createElement('div', { style: { color: 'white', fontSize: 18, fontWeight: 600, textAlign: 'center', marginBottom: 24 } }, props.title), React.createElement('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 } }, children && children.map((child, i) => renderNode({ ...child, key: i }))));
|
|
112
|
+
case 'Box': return React.createElement(Card, { key: props.key, size: 'small', style: { minWidth: 120, textAlign: 'center', borderLeft: props.color ? `3px solid ${props.color}` : undefined } }, React.createElement(Text, { strong: true }, props.title), props.desc && React.createElement('div', null, React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, props.desc)));
|
|
113
|
+
case 'Arrow': return React.createElement('div', { key: props.key, style: { color: 'white', fontSize: 24, textAlign: 'center' } }, (props.direction === 'up' ? '↑' : props.direction === 'left' ? '←' : props.direction === 'right' ? '→' : '↓'), props.label && React.createElement('span', { style: { fontSize: 12, marginLeft: 8 } }, props.label));
|
|
114
|
+
case 'Layer': return React.createElement('div', { key: props.key, style: { display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', width: '100%' } }, props.title && React.createElement('div', { style: { width: '100%', textAlign: 'center', color: 'rgba(255,255,255,0.8)', fontSize: 12, marginBottom: 8 } }, props.title), children && children.map((child, i) => renderNode({ ...child, key: i })));
|
|
115
|
+
case 'DiagramGroup': return React.createElement('div', { key: props.key, style: { background: 'rgba(255,255,255,0.1)', border: '1px dashed rgba(255,255,255,0.3)', borderRadius: 8, padding: 16, width: '100%' } }, props.title && React.createElement('div', { style: { color: 'rgba(255,255,255,0.9)', fontSize: 14, marginBottom: 12 } }, props.title), React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center' } }, children && children.map((child, i) => renderNode({ ...child, key: i })));
|
|
116
|
+
default: return React.createElement(Alert, { key: props.key, type: 'warning', message: `未知组件: ${type}` });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return React.createElement(ConfigProvider, { theme: { token: { colorPrimary: '#1677ff', borderRadius: 6 } } }, renderNode(data));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
123
|
+
root.render(React.createElement(A2UIRenderer, { data: UI_DATA }));
|
|
124
|
+
</script>
|
|
125
|
+
</body>
|
|
126
|
+
|
|
127
|
+
</html>
|