prd-to-flutter 0.1.0
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/README.md +149 -0
- package/bin/p2f.mjs +18 -0
- package/dist/cli.js +203 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/clean.js +35 -0
- package/dist/commands/clean.js.map +1 -0
- package/dist/commands/doctor.js +148 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/generate.js +120 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.js +46 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/paths.js +58 -0
- package/dist/commands/paths.js.map +1 -0
- package/dist/commands/remove.js +23 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/screenshots-audit.js +39 -0
- package/dist/commands/screenshots-audit.js.map +1 -0
- package/dist/commands/skills-install.js +21 -0
- package/dist/commands/skills-install.js.map +1 -0
- package/dist/commands/sync.js +84 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/update.js +93 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/core/existing-page-detector.js +463 -0
- package/dist/core/existing-page-detector.js.map +1 -0
- package/dist/core/fail-fast.js +50 -0
- package/dist/core/fail-fast.js.map +1 -0
- package/dist/core/feature-coverage-builder.js +667 -0
- package/dist/core/feature-coverage-builder.js.map +1 -0
- package/dist/core/flutter-project-scanner.js +393 -0
- package/dist/core/flutter-project-scanner.js.map +1 -0
- package/dist/core/implementation-plan-writer.js +190 -0
- package/dist/core/implementation-plan-writer.js.map +1 -0
- package/dist/core/logger.js +39 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/package-info.js +33 -0
- package/dist/core/package-info.js.map +1 -0
- package/dist/core/paths.js +37 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/playwright-capture.js +539 -0
- package/dist/core/playwright-capture.js.map +1 -0
- package/dist/core/playwright-cli.js +26 -0
- package/dist/core/playwright-cli.js.map +1 -0
- package/dist/core/playwright-install.js +40 -0
- package/dist/core/playwright-install.js.map +1 -0
- package/dist/core/prd-clone.js +131 -0
- package/dist/core/prd-clone.js.map +1 -0
- package/dist/core/prd-install.js +108 -0
- package/dist/core/prd-install.js.map +1 -0
- package/dist/core/profile.js +149 -0
- package/dist/core/profile.js.map +1 -0
- package/dist/core/project-doc-reader.js +252 -0
- package/dist/core/project-doc-reader.js.map +1 -0
- package/dist/core/report-writer.js +90 -0
- package/dist/core/report-writer.js.map +1 -0
- package/dist/core/route-name.js +60 -0
- package/dist/core/route-name.js.map +1 -0
- package/dist/core/run-context.js +160 -0
- package/dist/core/run-context.js.map +1 -0
- package/dist/core/screenshot-auditor.js +405 -0
- package/dist/core/screenshot-auditor.js.map +1 -0
- package/dist/core/screenshot-exploration-plan-writer.js +200 -0
- package/dist/core/screenshot-exploration-plan-writer.js.map +1 -0
- package/dist/core/semantic-model-builder.js +922 -0
- package/dist/core/semantic-model-builder.js.map +1 -0
- package/dist/core/skill-install.js +78 -0
- package/dist/core/skill-install.js.map +1 -0
- package/dist/core/stage-stub.js +24 -0
- package/dist/core/stage-stub.js.map +1 -0
- package/dist/core/task-index-writer.js +149 -0
- package/dist/core/task-index-writer.js.map +1 -0
- package/dist/core/update-checker.js +155 -0
- package/dist/core/update-checker.js.map +1 -0
- package/dist/core/vue-page-locator.js +748 -0
- package/dist/core/vue-page-locator.js.map +1 -0
- package/dist/core/vue-project-reader.js +116 -0
- package/dist/core/vue-project-reader.js.map +1 -0
- package/docs/artifacts-and-agent.md +203 -0
- package/docs/development.md +118 -0
- package/docs/usage.md +246 -0
- package/package.json +50 -0
- package/skills/p2f/SKILL.md +303 -0
- package/skills/p2f/references/page-layout-patterns.md +120 -0
- package/skills/p2f/references/youfi-flutter-guidelines.md +71 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { failFast } from './fail-fast.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
import { markComplete } from './run-context.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export function buildSemanticModel(ctx, page, capture) {
|
|
8
|
+
let dom;
|
|
9
|
+
let a11y = null;
|
|
10
|
+
try {
|
|
11
|
+
dom = JSON.parse(readFileSync(capture.dom, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
failFast({
|
|
15
|
+
stage: 'analyze',
|
|
16
|
+
action: `read ${capture.dom}`,
|
|
17
|
+
reason: `无法读取 dom.json:${err.message}`,
|
|
18
|
+
completedSteps: [...ctx.completedSteps],
|
|
19
|
+
pendingSteps: [...ctx.pendingSteps],
|
|
20
|
+
suggestions: ['执行 `p2f clean` 清理页面目录后重新执行 `p2f generate`。'],
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
a11y = JSON.parse(readFileSync(capture.accessibility, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// accessibility tree is best-effort; semantic modelling works without it.
|
|
28
|
+
}
|
|
29
|
+
const blocks = summarizeOutline(dom);
|
|
30
|
+
const candidates = collectInteractionCandidates(dom);
|
|
31
|
+
const blockReasons = [];
|
|
32
|
+
if (blocks.length === 0) {
|
|
33
|
+
blockReasons.push('DOM 归纳未产出任何结构块,可能是页面仍处于 loading 状态。');
|
|
34
|
+
}
|
|
35
|
+
const blocked = blockReasons.length > 0;
|
|
36
|
+
const designTokens = extractDesignTokens(capture.computedStyles);
|
|
37
|
+
const pageType = classifyPageType(page, blocks, candidates);
|
|
38
|
+
const outline = {
|
|
39
|
+
pageKey: ctx.pageKey,
|
|
40
|
+
pageName: ctx.options.pageName,
|
|
41
|
+
pageType,
|
|
42
|
+
route: {
|
|
43
|
+
path: page.routeInfo.path,
|
|
44
|
+
...(page.routeInfo.name !== undefined && { name: page.routeInfo.name }),
|
|
45
|
+
...(page.routeInfo.label !== undefined && { label: page.routeInfo.label }),
|
|
46
|
+
kind: page.routeInfo.kind,
|
|
47
|
+
},
|
|
48
|
+
blocks,
|
|
49
|
+
states: capture.states.map((s) => ({
|
|
50
|
+
name: s.name,
|
|
51
|
+
...(s.screenshot ? { screenshot: s.screenshot } : {}),
|
|
52
|
+
captured: s.captured,
|
|
53
|
+
...(s.notes ? { notes: s.notes } : {}),
|
|
54
|
+
})),
|
|
55
|
+
designTokens,
|
|
56
|
+
candidateInteractions: candidates,
|
|
57
|
+
};
|
|
58
|
+
const analysisDir = join(ctx.pageDir, 'analysis');
|
|
59
|
+
mkdirSync(analysisDir, { recursive: true });
|
|
60
|
+
const summaryPath = join(analysisDir, 'page-summary.md');
|
|
61
|
+
writeFileSync(summaryPath, renderSummary(ctx, page, outline, blockReasons, capture), 'utf8');
|
|
62
|
+
const uiStructurePath = join(analysisDir, 'ui-structure.md');
|
|
63
|
+
writeFileSync(uiStructurePath, renderUiStructure(outline), 'utf8');
|
|
64
|
+
const interactionModelPath = join(analysisDir, 'interaction-model.md');
|
|
65
|
+
writeFileSync(interactionModelPath, renderInteractionModel(outline), 'utf8');
|
|
66
|
+
const textStyleMap = buildTextStyleMap(capture.computedStyles);
|
|
67
|
+
assertTextStyleMapAvailable(ctx, capture, textStyleMap);
|
|
68
|
+
const textStyleMapJsonPath = join(analysisDir, 'text-style-map.json');
|
|
69
|
+
writeFileSync(textStyleMapJsonPath, JSON.stringify({
|
|
70
|
+
pageKey: ctx.pageKey,
|
|
71
|
+
pageName: ctx.options.pageName,
|
|
72
|
+
source: {
|
|
73
|
+
computedStyles: relative(ctx.taskDir, capture.computedStyles),
|
|
74
|
+
},
|
|
75
|
+
entries: textStyleMap,
|
|
76
|
+
}, null, 2) + '\n', 'utf8');
|
|
77
|
+
const textStyleMapPath = join(analysisDir, 'text-style-map.md');
|
|
78
|
+
writeFileSync(textStyleMapPath, renderTextStyleMap(textStyleMap, textStyleMapJsonPath, ctx), 'utf8');
|
|
79
|
+
const candidateInteractionsPath = join(capture.runtimeDir, 'interactions.candidate.json');
|
|
80
|
+
writeFileSync(candidateInteractionsPath, JSON.stringify({ pageKey: ctx.pageKey, candidates }, null, 2) + '\n', 'utf8');
|
|
81
|
+
logger.success(`Semantic model → blocks=${blocks.length} candidates=${candidates.length} textStyles=${textStyleMap.length} blocked=${blocked}`);
|
|
82
|
+
markComplete(ctx, 'build-semantic-model');
|
|
83
|
+
// Use a11y only as a signal source — no need to surface it here beyond what
|
|
84
|
+
// we already baked into candidates.
|
|
85
|
+
void a11y;
|
|
86
|
+
return {
|
|
87
|
+
pageKey: ctx.pageKey,
|
|
88
|
+
pageName: ctx.options.pageName,
|
|
89
|
+
pageType,
|
|
90
|
+
blocked,
|
|
91
|
+
blockReasons,
|
|
92
|
+
summaryPath,
|
|
93
|
+
uiStructurePath,
|
|
94
|
+
interactionModelPath,
|
|
95
|
+
textStyleMapPath,
|
|
96
|
+
textStyleMapJsonPath,
|
|
97
|
+
candidateInteractionsPath,
|
|
98
|
+
outline,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Outline (DOM → structural blocks)
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
/**
|
|
105
|
+
* The phone-screen root has a predictable shape:
|
|
106
|
+
* .phone-screen
|
|
107
|
+
* > (nav wrapper)
|
|
108
|
+
* > .phone-screen-inner-container or similar
|
|
109
|
+
* > (actual content tree)
|
|
110
|
+
*
|
|
111
|
+
* We descend until we find a node with multiple meaningful children, then
|
|
112
|
+
* classify them. Anything smaller than 24px on one side is ignored.
|
|
113
|
+
*/
|
|
114
|
+
const MIN_SIDE = 24;
|
|
115
|
+
function summarizeOutline(root) {
|
|
116
|
+
// Walk down the DOM until we hit a level with several visible children.
|
|
117
|
+
let cursor = root;
|
|
118
|
+
while (cursor.children && cursor.children.length === 1) {
|
|
119
|
+
cursor = cursor.children[0];
|
|
120
|
+
}
|
|
121
|
+
const kids = (cursor.children ?? []).filter(isVisible);
|
|
122
|
+
const out = [];
|
|
123
|
+
let idSeq = 1;
|
|
124
|
+
for (const child of kids) {
|
|
125
|
+
const block = classifyBlock(child, () => `b${idSeq++}`);
|
|
126
|
+
if (block)
|
|
127
|
+
out.push(block);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function isVisible(n) {
|
|
132
|
+
if (!n.rect)
|
|
133
|
+
return false;
|
|
134
|
+
if (n.rect.w < MIN_SIDE || n.rect.h < MIN_SIDE)
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
function classifyBlock(node, nextId) {
|
|
139
|
+
if (!node.rect)
|
|
140
|
+
return null;
|
|
141
|
+
// Before classifying, fast-forward past wrapper chains of the form
|
|
142
|
+
// <div><div><div><real content>. Wrappers inflate the outline depth and
|
|
143
|
+
// rarely carry semantic meaning on their own. We stop as soon as there are
|
|
144
|
+
// multiple visible children, or we hit something that is not a plain
|
|
145
|
+
// container.
|
|
146
|
+
let effective = node;
|
|
147
|
+
while (true) {
|
|
148
|
+
const kids = (effective.children ?? []).filter(isVisible);
|
|
149
|
+
if (kids.length !== 1)
|
|
150
|
+
break;
|
|
151
|
+
const only = kids[0];
|
|
152
|
+
const onlyType = guessType(only);
|
|
153
|
+
const currentType = guessType(effective);
|
|
154
|
+
// Only skip when current is a generic container and child fills roughly
|
|
155
|
+
// the same rect (the wrapper contributes no layout semantics).
|
|
156
|
+
const fillsParent = effective.rect && only.rect &&
|
|
157
|
+
only.rect.w >= effective.rect.w * 0.8 &&
|
|
158
|
+
only.rect.h >= effective.rect.h * 0.6;
|
|
159
|
+
if (currentType !== 'container' || !fillsParent)
|
|
160
|
+
break;
|
|
161
|
+
// Don't skip past navigationally interesting wrappers.
|
|
162
|
+
if (onlyType !== 'container' && onlyType !== 'card')
|
|
163
|
+
break;
|
|
164
|
+
effective = only;
|
|
165
|
+
}
|
|
166
|
+
const block = {
|
|
167
|
+
id: nextId(),
|
|
168
|
+
type: guessType(effective),
|
|
169
|
+
rect: effective.rect,
|
|
170
|
+
};
|
|
171
|
+
const txt = cleanText(effective);
|
|
172
|
+
if (txt)
|
|
173
|
+
block.text = txt;
|
|
174
|
+
if (effective.attrs?.role)
|
|
175
|
+
block.role = effective.attrs.role;
|
|
176
|
+
// Recurse into direct children when the block is a container-like node and
|
|
177
|
+
// has several children worth naming.
|
|
178
|
+
const meaningfulKids = (effective.children ?? []).filter(isVisible);
|
|
179
|
+
if ((block.type === 'container' || block.type === 'card' || block.type === 'list') &&
|
|
180
|
+
meaningfulKids.length >= 2 &&
|
|
181
|
+
meaningfulKids.length <= 24) {
|
|
182
|
+
const childBlocks = [];
|
|
183
|
+
for (const k of meaningfulKids) {
|
|
184
|
+
const cb = classifyBlock(k, nextId);
|
|
185
|
+
if (cb)
|
|
186
|
+
childBlocks.push(cb);
|
|
187
|
+
}
|
|
188
|
+
if (childBlocks.length)
|
|
189
|
+
block.children = childBlocks;
|
|
190
|
+
}
|
|
191
|
+
return block;
|
|
192
|
+
}
|
|
193
|
+
function guessType(node) {
|
|
194
|
+
const cls = (node.class ?? '').toLowerCase();
|
|
195
|
+
const tag = node.tag.toLowerCase();
|
|
196
|
+
const role = node.attrs?.role;
|
|
197
|
+
if (role === 'tablist' || cls.includes('tabs') || cls.includes('tab-bar'))
|
|
198
|
+
return 'tab-bar';
|
|
199
|
+
if (cls.includes('segment') || cls.includes('switch-button-group'))
|
|
200
|
+
return 'segmented-control';
|
|
201
|
+
if (cls.includes('nav-bar') || cls.includes('app-bar') || cls.includes('detail-nav'))
|
|
202
|
+
return 'app-bar';
|
|
203
|
+
if (tag === 'button' || role === 'button' || cls.endsWith('-btn') || cls.includes('button'))
|
|
204
|
+
return 'button';
|
|
205
|
+
if (tag === 'input' || tag === 'textarea' || role === 'textbox' || cls.includes('input') || cls.includes('field'))
|
|
206
|
+
return 'form-field';
|
|
207
|
+
if (tag === 'img' || cls.includes('image'))
|
|
208
|
+
return 'image';
|
|
209
|
+
if (tag === 'svg' || cls.includes('icon'))
|
|
210
|
+
return 'icon';
|
|
211
|
+
if (tag === 'canvas' || cls.includes('chart') || cls.includes('kline') || cls.includes('klinecharts'))
|
|
212
|
+
return 'chart';
|
|
213
|
+
if (tag === 'ul' || tag === 'ol' || cls.includes('list') || role === 'list')
|
|
214
|
+
return 'list';
|
|
215
|
+
if (cls.includes('card'))
|
|
216
|
+
return 'card';
|
|
217
|
+
if (tag === 'li' || role === 'listitem')
|
|
218
|
+
return 'list-item';
|
|
219
|
+
if (cleanText(node))
|
|
220
|
+
return 'text';
|
|
221
|
+
return 'container';
|
|
222
|
+
}
|
|
223
|
+
function cleanText(node) {
|
|
224
|
+
return cleanInlineText(node.text);
|
|
225
|
+
}
|
|
226
|
+
function cleanInlineText(value) {
|
|
227
|
+
if (typeof value !== 'string')
|
|
228
|
+
return undefined;
|
|
229
|
+
const t = value.trim();
|
|
230
|
+
if (!t)
|
|
231
|
+
return undefined;
|
|
232
|
+
if (t.length > 120)
|
|
233
|
+
return t.slice(0, 117) + '…';
|
|
234
|
+
return t;
|
|
235
|
+
}
|
|
236
|
+
function hasScrollableChild(root) {
|
|
237
|
+
if (!root.rect)
|
|
238
|
+
return false;
|
|
239
|
+
const { h } = root.rect;
|
|
240
|
+
let maxChildBottom = 0;
|
|
241
|
+
(root.children ?? []).forEach((c) => {
|
|
242
|
+
if (c.rect)
|
|
243
|
+
maxChildBottom = Math.max(maxChildBottom, c.rect.y + c.rect.h);
|
|
244
|
+
});
|
|
245
|
+
return maxChildBottom > h + 40;
|
|
246
|
+
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Candidate interactions
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
function collectInteractionCandidates(root) {
|
|
251
|
+
const out = [];
|
|
252
|
+
let idSeq = 1;
|
|
253
|
+
const nextId = () => `int${idSeq++}`;
|
|
254
|
+
// --- Regex toolbox ------------------------------------------------------
|
|
255
|
+
const EXPAND_RE = /(expand|collapse|toggle|fold|unfold|chevron|arrow-down|arrow-up|more|caret)/i;
|
|
256
|
+
const DROPDOWN_RE = /(dropdown|picker|select|selector|menu|popover)/i;
|
|
257
|
+
const SEGMENT_RE = /(\bpill\b|\bchip\b|segment|switch|mode|option|choice|filter|period-tab)/i;
|
|
258
|
+
const TAB_CONTAINER_RE = /(tabs$|tab-bar|tab-list|tabs-|tab-content|tablist)/i;
|
|
259
|
+
const DIALOG_TEXT_RE = /(确认|提交|取消|删除|详情|更多|filter|筛选|选择|edit|add|new|create|confirm|remove)/i;
|
|
260
|
+
const ACTIVE_RE = /--active\b|\bactive\b|\bselected\b|\bchecked\b/;
|
|
261
|
+
// First pass: find sibling groups that look like segmented control bars —
|
|
262
|
+
// 2+ direct children with at least one carrying an "active" marker.
|
|
263
|
+
const segmentedParents = new Set();
|
|
264
|
+
(function scan(node) {
|
|
265
|
+
const kids = (node.children ?? []).filter((c) => c.rect && c.rect.w > 0 && c.rect.h > 0);
|
|
266
|
+
if (kids.length >= 2) {
|
|
267
|
+
const anyActive = kids.some((k) => {
|
|
268
|
+
const kc = (k.class ?? '').toLowerCase();
|
|
269
|
+
return ACTIVE_RE.test(kc) || k.attrs?.['aria-selected'] === 'true' || k.attrs?.['aria-checked'] === 'true';
|
|
270
|
+
});
|
|
271
|
+
if (anyActive) {
|
|
272
|
+
// Require a reasonably uniform child shape (same tag) to avoid misfiring
|
|
273
|
+
// on heterogeneous trees like "icon + text + icon".
|
|
274
|
+
const firstTag = kids[0].tag;
|
|
275
|
+
const allSameTag = kids.every((k) => k.tag === firstTag);
|
|
276
|
+
if (allSameTag && (firstTag === 'button' || firstTag === 'div' || firstTag === 'span' || firstTag === 'a')) {
|
|
277
|
+
segmentedParents.add(node);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const c of node.children ?? [])
|
|
282
|
+
scan(c);
|
|
283
|
+
})(root);
|
|
284
|
+
// Helper: pick the ancestor (or self) that represents the real action
|
|
285
|
+
// target. We prefer <button>, <a>, role=button, or nodes with an onclick
|
|
286
|
+
// attribute — `cursor: pointer` alone is unreliable because CSS pointer
|
|
287
|
+
// propagates to child spans (so every label inside a button becomes
|
|
288
|
+
// 'clickable' too). We only fall back to the nearest clickable node if no
|
|
289
|
+
// semantic action ancestor exists.
|
|
290
|
+
function nearestClickable(node, parents) {
|
|
291
|
+
const chain = [...parents, node];
|
|
292
|
+
function isActionNode(n) {
|
|
293
|
+
const t = n.tag.toLowerCase();
|
|
294
|
+
const r = n.attrs?.role;
|
|
295
|
+
return t === 'button' || t === 'a' || r === 'button' || r === 'link' ||
|
|
296
|
+
!!n.attrs?.onclick;
|
|
297
|
+
}
|
|
298
|
+
// Outer-most action node wins (i.e. the <button>, not a nested <span>).
|
|
299
|
+
for (let i = 0; i < chain.length; i += 1) {
|
|
300
|
+
if (isActionNode(chain[i]))
|
|
301
|
+
return chain[i];
|
|
302
|
+
}
|
|
303
|
+
// Fall back to inner-most clickable if we only have cursor:pointer hints.
|
|
304
|
+
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
|
305
|
+
if (chain[i].clickable)
|
|
306
|
+
return chain[i];
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
function already(selector, kind, label) {
|
|
311
|
+
return out.some((c) => c.selector === selector && c.kind === kind && (c.label ?? '') === (label ?? ''));
|
|
312
|
+
}
|
|
313
|
+
function walk(node, parents) {
|
|
314
|
+
const cls = (node.class ?? '').toLowerCase();
|
|
315
|
+
const tag = node.tag.toLowerCase();
|
|
316
|
+
const role = node.attrs?.role;
|
|
317
|
+
const text = cleanText(node);
|
|
318
|
+
const parent = parents[parents.length - 1] ?? null;
|
|
319
|
+
const isActive = ACTIVE_RE.test(cls) ||
|
|
320
|
+
node.attrs?.['aria-selected'] === 'true' ||
|
|
321
|
+
node.attrs?.['aria-checked'] === 'true';
|
|
322
|
+
// If this node lives inside a segmented-parent group and has a visible
|
|
323
|
+
// label, register it as tab-change regardless of its class naming.
|
|
324
|
+
const segParent = parents.find((p) => segmentedParents.has(p));
|
|
325
|
+
if (segParent && text && text.length <= 40) {
|
|
326
|
+
// Pick a selector that resolves to THIS button — prefer the nearest
|
|
327
|
+
// clickable node (button itself), not its text span.
|
|
328
|
+
const target = nearestClickable(node, parents) ?? node;
|
|
329
|
+
const selector = selectorFor(target);
|
|
330
|
+
if (!already(selector, 'tab-change', text)) {
|
|
331
|
+
out.push({
|
|
332
|
+
id: nextId(),
|
|
333
|
+
kind: 'tab-change',
|
|
334
|
+
selector,
|
|
335
|
+
label: text,
|
|
336
|
+
...(target.rect !== undefined && { bounds: target.rect }),
|
|
337
|
+
description: `分段按钮候选:${text}`,
|
|
338
|
+
confidence: 0.75,
|
|
339
|
+
group: selectorFor(segParent),
|
|
340
|
+
isActive,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// Stay and allow other matchers to contribute too if they want (e.g.
|
|
344
|
+
// dropdown inside a tab). But in practice that rarely overlaps.
|
|
345
|
+
}
|
|
346
|
+
// ---------- Tab-style candidates (class-based) ----------
|
|
347
|
+
const isTabContainer = role === 'tablist' || TAB_CONTAINER_RE.test(cls);
|
|
348
|
+
const looksLikeTab = (role === 'tab' || (cls.includes('tab') && !isTabContainer && !cls.includes('tabbar'))) ||
|
|
349
|
+
SEGMENT_RE.test(cls);
|
|
350
|
+
if (looksLikeTab && text && text.length <= 40) {
|
|
351
|
+
const target = nearestClickable(node, parents) ?? node;
|
|
352
|
+
const selector = selectorFor(target);
|
|
353
|
+
if (!already(selector, 'tab-change', text)) {
|
|
354
|
+
out.push({
|
|
355
|
+
id: nextId(),
|
|
356
|
+
kind: 'tab-change',
|
|
357
|
+
selector,
|
|
358
|
+
label: text,
|
|
359
|
+
...(target.rect !== undefined && { bounds: target.rect }),
|
|
360
|
+
description: `Tab / 分段候选:${text}`,
|
|
361
|
+
confidence: 0.7,
|
|
362
|
+
...(parent ? { group: selectorFor(parent) } : {}),
|
|
363
|
+
isActive,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// ---------- Dropdown / picker triggers ----------
|
|
369
|
+
const hasCaretChild = (node.children ?? []).some((c) => /(caret|arrow|chevron)/i.test(c.class ?? ''));
|
|
370
|
+
const looksLikeDropdown = DROPDOWN_RE.test(cls) || hasCaretChild;
|
|
371
|
+
if (looksLikeDropdown && node.clickable) {
|
|
372
|
+
// Skip when a clickable descendant will be registered anyway — we prefer
|
|
373
|
+
// the outer trigger to fire once (not once for `.chart-dropdown` and
|
|
374
|
+
// again for its `.chart-dropdown-arrow`).
|
|
375
|
+
const hasClickableChild = (node.children ?? []).some((c) => c.clickable);
|
|
376
|
+
if (!hasClickableChild) {
|
|
377
|
+
const selector = selectorFor(node);
|
|
378
|
+
if (!already(selector, 'dropdown', text)) {
|
|
379
|
+
out.push({
|
|
380
|
+
id: nextId(),
|
|
381
|
+
kind: 'dropdown',
|
|
382
|
+
selector,
|
|
383
|
+
...(text !== undefined && { label: text }),
|
|
384
|
+
...(node.rect !== undefined && { bounds: node.rect }),
|
|
385
|
+
description: `下拉 / 选择器触发:${text ?? node.tag}`,
|
|
386
|
+
confidence: 0.65,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// ---------- Dialog-trigger (text-based) ----------
|
|
393
|
+
const looksLikeButton = tag === 'button' || role === 'button' || cls.endsWith('-btn') || cls.includes('btn-');
|
|
394
|
+
if (looksLikeButton && text && DIALOG_TEXT_RE.test(text)) {
|
|
395
|
+
out.push({
|
|
396
|
+
id: nextId(),
|
|
397
|
+
kind: 'dialog-trigger',
|
|
398
|
+
selector: selectorFor(node),
|
|
399
|
+
label: text,
|
|
400
|
+
...(node.rect !== undefined && { bounds: node.rect }),
|
|
401
|
+
description: `按钮可能触发弹窗 / 抽屉:${text}`,
|
|
402
|
+
confidence: 0.55,
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// ---------- Text inputs ----------
|
|
407
|
+
if (tag === 'input' || tag === 'textarea' || role === 'textbox') {
|
|
408
|
+
out.push({
|
|
409
|
+
id: nextId(),
|
|
410
|
+
kind: 'input-focus',
|
|
411
|
+
selector: selectorFor(node),
|
|
412
|
+
...(text !== undefined && { label: text }),
|
|
413
|
+
...(node.rect !== undefined && { bounds: node.rect }),
|
|
414
|
+
description: `输入控件 focus:${text ?? node.tag}`,
|
|
415
|
+
confidence: 0.9,
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// ---------- Expand / collapse icon buttons ----------
|
|
420
|
+
const looksExpandable = looksLikeButton && EXPAND_RE.test(cls);
|
|
421
|
+
if (looksExpandable) {
|
|
422
|
+
out.push({
|
|
423
|
+
id: nextId(),
|
|
424
|
+
kind: 'expand-toggle',
|
|
425
|
+
selector: selectorFor(node),
|
|
426
|
+
...(text !== undefined && { label: text }),
|
|
427
|
+
...(node.rect !== undefined && { bounds: node.rect }),
|
|
428
|
+
description: `展开 / 折叠按钮:${text ?? node.tag}`,
|
|
429
|
+
confidence: 0.65,
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// ---------- Generic clickable fallback ----------
|
|
434
|
+
if (node.clickable &&
|
|
435
|
+
node.rect &&
|
|
436
|
+
node.rect.w >= 20 && node.rect.h >= 20 &&
|
|
437
|
+
!TAB_CONTAINER_RE.test(cls) &&
|
|
438
|
+
!cls.endsWith('-grid') &&
|
|
439
|
+
!cls.endsWith('-list') &&
|
|
440
|
+
!cls.endsWith('-row') &&
|
|
441
|
+
// Prefer inner-most clickable leaf.
|
|
442
|
+
!(node.children ?? []).some((c) => c.clickable)) {
|
|
443
|
+
out.push({
|
|
444
|
+
id: nextId(),
|
|
445
|
+
kind: hasCaretChild || DROPDOWN_RE.test(cls) ? 'dropdown' :
|
|
446
|
+
EXPAND_RE.test(cls) ? 'expand-toggle' :
|
|
447
|
+
'dialog-trigger',
|
|
448
|
+
selector: selectorFor(node),
|
|
449
|
+
...(text !== undefined && { label: text }),
|
|
450
|
+
...(node.rect !== undefined && { bounds: node.rect }),
|
|
451
|
+
description: `可点击元素(cursor:pointer):${text ?? (cls || node.tag)}`,
|
|
452
|
+
confidence: 0.35,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
(function dfs(node, parents) {
|
|
457
|
+
walk(node, parents);
|
|
458
|
+
const nextParents = [...parents, node];
|
|
459
|
+
for (const c of node.children ?? [])
|
|
460
|
+
dfs(c, nextParents);
|
|
461
|
+
})(root, []);
|
|
462
|
+
// Dedup:
|
|
463
|
+
// tab-change → by (group, label), so different groups with the same label
|
|
464
|
+
// (e.g. two "Monthly" tabs in different bars) both survive.
|
|
465
|
+
// everything else → by (kind, selector, label). Importantly, if the same
|
|
466
|
+
// selector is registered with different labels they'll survive.
|
|
467
|
+
const seen = new Set();
|
|
468
|
+
const kept = [];
|
|
469
|
+
for (const c of out) {
|
|
470
|
+
const key = c.kind === 'tab-change'
|
|
471
|
+
? `tab:${c.group ?? ''}:${c.label ?? ''}`
|
|
472
|
+
: `${c.kind}:${c.selector}:${c.label ?? ''}`;
|
|
473
|
+
if (seen.has(key))
|
|
474
|
+
continue;
|
|
475
|
+
seen.add(key);
|
|
476
|
+
kept.push(c);
|
|
477
|
+
}
|
|
478
|
+
// Final filter: drop candidates without a label when their selector would
|
|
479
|
+
// resolve to multiple elements on the page (we can't execute them safely).
|
|
480
|
+
// We approximate "resolves to many" by checking whether the base selector
|
|
481
|
+
// has already been registered with another label — if so we know there are
|
|
482
|
+
// multiple matches and the unlabeled one can't disambiguate.
|
|
483
|
+
const labeledSelectors = new Set(kept.filter((c) => c.label).map((c) => c.selector));
|
|
484
|
+
return kept.filter((c) => c.label || !labeledSelectors.has(c.selector));
|
|
485
|
+
}
|
|
486
|
+
function walkDomWithParent(node, parent, visit) {
|
|
487
|
+
visit(node, parent);
|
|
488
|
+
for (const c of node.children ?? [])
|
|
489
|
+
walkDomWithParent(c, node, visit);
|
|
490
|
+
}
|
|
491
|
+
function walkDom(node, visit) {
|
|
492
|
+
visit(node);
|
|
493
|
+
for (const c of node.children ?? [])
|
|
494
|
+
walkDom(c, visit);
|
|
495
|
+
}
|
|
496
|
+
function selectorFor(node) {
|
|
497
|
+
if (node.id)
|
|
498
|
+
return `#${CSS.escape(node.id)}`;
|
|
499
|
+
const cls = (node.class ?? '').trim().split(/\s+/).filter(Boolean);
|
|
500
|
+
if (cls.length)
|
|
501
|
+
return `${node.tag}.${cls.slice(0, 2).join('.')}`;
|
|
502
|
+
return node.tag;
|
|
503
|
+
}
|
|
504
|
+
// Node lacks `CSS.escape` in older targets; provide a minimal fallback here.
|
|
505
|
+
const CSS = globalThis.CSS ?? {
|
|
506
|
+
escape(s) {
|
|
507
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch.charCodeAt(0).toString(16)} `);
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
function extractDesignTokens(computedStylesPath) {
|
|
511
|
+
let records;
|
|
512
|
+
try {
|
|
513
|
+
records = JSON.parse(readFileSync(computedStylesPath, 'utf8'));
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return {};
|
|
517
|
+
}
|
|
518
|
+
// Pick the first record whose tag is `body`/`html`/`div.phone-screen` for
|
|
519
|
+
// a representative background + text color.
|
|
520
|
+
const tokens = {};
|
|
521
|
+
for (const r of records) {
|
|
522
|
+
if (tokens.pageBackground === undefined &&
|
|
523
|
+
(r.class ?? '').includes('phone-screen') &&
|
|
524
|
+
typeof r['background-color'] === 'string') {
|
|
525
|
+
tokens.pageBackground = r['background-color'];
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Heuristically pick a few more tokens: most common color / font-size.
|
|
529
|
+
const colorCounts = new Map();
|
|
530
|
+
const fontCounts = new Map();
|
|
531
|
+
for (const r of records) {
|
|
532
|
+
const c = r['color'];
|
|
533
|
+
if (typeof c === 'string')
|
|
534
|
+
colorCounts.set(c, (colorCounts.get(c) ?? 0) + 1);
|
|
535
|
+
const f = r['font-size'];
|
|
536
|
+
if (typeof f === 'string')
|
|
537
|
+
fontCounts.set(f, (fontCounts.get(f) ?? 0) + 1);
|
|
538
|
+
}
|
|
539
|
+
const topColor = topOf(colorCounts);
|
|
540
|
+
const topFont = topOf(fontCounts);
|
|
541
|
+
if (topColor)
|
|
542
|
+
tokens.textPrimary = topColor;
|
|
543
|
+
if (topFont)
|
|
544
|
+
tokens.baseFontSize = topFont;
|
|
545
|
+
return tokens;
|
|
546
|
+
}
|
|
547
|
+
function topOf(counts) {
|
|
548
|
+
let best = null;
|
|
549
|
+
for (const e of counts.entries()) {
|
|
550
|
+
if (!best || e[1] > best[1])
|
|
551
|
+
best = e;
|
|
552
|
+
}
|
|
553
|
+
return best?.[0];
|
|
554
|
+
}
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Text style map (captured CSS → YouFi TextStyleExtension token)
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
const YOUFI_TEXT_STYLES = [
|
|
559
|
+
{ token: 'number1B', fontSize: 34, fontWeight: 700, lineHeight: 46 },
|
|
560
|
+
{ token: 'number2B', fontSize: 30, fontWeight: 700, lineHeight: 40 },
|
|
561
|
+
{ token: 'number3B', fontSize: 24, fontWeight: 700, lineHeight: 32 },
|
|
562
|
+
{ token: 'titleB', fontSize: 20, fontWeight: 700, lineHeight: 28 },
|
|
563
|
+
{ token: 'titleM', fontSize: 20, fontWeight: 500, lineHeight: 28 },
|
|
564
|
+
{ token: 'tabB', fontSize: 18, fontWeight: 700, lineHeight: 24 },
|
|
565
|
+
{ token: 'tabM', fontSize: 18, fontWeight: 500, lineHeight: 24 },
|
|
566
|
+
{ token: 'headlineB', fontSize: 17, fontWeight: 700, lineHeight: 22 },
|
|
567
|
+
{ token: 'headlineM', fontSize: 17, fontWeight: 500, lineHeight: 22 },
|
|
568
|
+
{ token: 'bodyM', fontSize: 15, fontWeight: 500, lineHeight: 20 },
|
|
569
|
+
{ token: 'bodyR', fontSize: 15, fontWeight: 400, lineHeight: 20 },
|
|
570
|
+
{ token: 'small1M', fontSize: 13, fontWeight: 500, lineHeight: 16 },
|
|
571
|
+
{ token: 'small1R', fontSize: 13, fontWeight: 400, lineHeight: 16 },
|
|
572
|
+
{ token: 'small2R', fontSize: 12, fontWeight: 400, lineHeight: 16 },
|
|
573
|
+
{ token: 'small3R', fontSize: 11, fontWeight: 400, lineHeight: 14 },
|
|
574
|
+
{ token: 'small4M', fontSize: 10, fontWeight: 500, lineHeight: 14 },
|
|
575
|
+
{ token: 'small4R', fontSize: 10, fontWeight: 400, lineHeight: 14 },
|
|
576
|
+
];
|
|
577
|
+
function buildTextStyleMap(computedStylesPath) {
|
|
578
|
+
let records = [];
|
|
579
|
+
try {
|
|
580
|
+
records = JSON.parse(readFileSync(computedStylesPath, 'utf8'));
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return [];
|
|
584
|
+
}
|
|
585
|
+
const out = [];
|
|
586
|
+
const seen = new Set();
|
|
587
|
+
for (const style of records) {
|
|
588
|
+
const text = cleanInlineText(style.text);
|
|
589
|
+
if (!text)
|
|
590
|
+
continue;
|
|
591
|
+
const mapped = mapCssToYouFiTextStyle(style);
|
|
592
|
+
const rect = normalizeRect(style.rect);
|
|
593
|
+
const key = [
|
|
594
|
+
text,
|
|
595
|
+
style.tag,
|
|
596
|
+
style.class ?? '',
|
|
597
|
+
rect ? `${rect.x},${rect.y},${rect.w},${rect.h}` : '',
|
|
598
|
+
mapped.token,
|
|
599
|
+
].join('|');
|
|
600
|
+
if (seen.has(key))
|
|
601
|
+
continue;
|
|
602
|
+
seen.add(key);
|
|
603
|
+
out.push({
|
|
604
|
+
id: `txt${out.length + 1}`,
|
|
605
|
+
text,
|
|
606
|
+
tag: style.tag,
|
|
607
|
+
...(style.class !== undefined && { className: style.class }),
|
|
608
|
+
selector: selectorFor(style),
|
|
609
|
+
...(rect !== undefined && { rect }),
|
|
610
|
+
css: {
|
|
611
|
+
...(typeof style['font-size'] === 'string' && { fontSize: style['font-size'] }),
|
|
612
|
+
...(typeof style['font-weight'] === 'string' && { fontWeight: style['font-weight'] }),
|
|
613
|
+
...(typeof style['line-height'] === 'string' && { lineHeight: style['line-height'] }),
|
|
614
|
+
...(typeof style.color === 'string' && { color: style.color }),
|
|
615
|
+
},
|
|
616
|
+
youfiTextStyle: mapped.token,
|
|
617
|
+
confidence: mapped.confidence,
|
|
618
|
+
reason: mapped.reason,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
function assertTextStyleMapAvailable(ctx, capture, entries) {
|
|
624
|
+
if (entries.length > 0)
|
|
625
|
+
return;
|
|
626
|
+
let visibleText = '';
|
|
627
|
+
try {
|
|
628
|
+
visibleText = readFileSync(capture.visibleText, 'utf8').trim();
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// If visible text was not captured, don't block here; other capture checks
|
|
632
|
+
// are responsible for detecting an invalid page load.
|
|
633
|
+
}
|
|
634
|
+
if (!visibleText)
|
|
635
|
+
return;
|
|
636
|
+
failFast({
|
|
637
|
+
stage: 'analyze',
|
|
638
|
+
action: 'build text-style-map',
|
|
639
|
+
reason: '`cli/runtime/computed-styles.json` 未包含文本样式记录,但页面存在可见文本,无法可靠映射 Flutter textStyle。',
|
|
640
|
+
completedSteps: [...ctx.completedSteps],
|
|
641
|
+
pendingSteps: [...ctx.pendingSteps],
|
|
642
|
+
suggestions: [
|
|
643
|
+
'重新执行最新版 `p2f generate`,确保 capture 阶段生成带 `text` / `rect` 字段的 `computed-styles.json`。',
|
|
644
|
+
'如果仍失败,检查原型文本是否由 canvas / 图片渲染,或是否超出 computed style 采集上限。',
|
|
645
|
+
],
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
function normalizeRect(value) {
|
|
649
|
+
if (!value || typeof value !== 'object')
|
|
650
|
+
return undefined;
|
|
651
|
+
const rect = value;
|
|
652
|
+
if (typeof rect.x !== 'number' ||
|
|
653
|
+
typeof rect.y !== 'number' ||
|
|
654
|
+
typeof rect.w !== 'number' ||
|
|
655
|
+
typeof rect.h !== 'number') {
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
return { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
|
|
659
|
+
}
|
|
660
|
+
function mapCssToYouFiTextStyle(style) {
|
|
661
|
+
const fontSize = parseCssPx(style['font-size']);
|
|
662
|
+
const fontWeight = parseCssWeight(style['font-weight']);
|
|
663
|
+
const lineHeight = parseCssPx(style['line-height']);
|
|
664
|
+
let best = null;
|
|
665
|
+
for (const target of YOUFI_TEXT_STYLES) {
|
|
666
|
+
const sizeDiff = fontSize === null ? 0 : Math.abs(fontSize - target.fontSize);
|
|
667
|
+
const weightDiff = fontWeight === null ? 0 : Math.abs(fontWeight - target.fontWeight);
|
|
668
|
+
const lineDiff = lineHeight === null ? null : Math.abs(lineHeight - target.lineHeight);
|
|
669
|
+
const score = sizeDiff * 8 + weightDiff / 80 + (lineDiff ?? 0) * 2;
|
|
670
|
+
if (!best || score < best.score) {
|
|
671
|
+
best = { token: target.token, score, sizeDiff, weightDiff, lineDiff };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!best) {
|
|
675
|
+
return {
|
|
676
|
+
token: 'bodyR',
|
|
677
|
+
confidence: 'low',
|
|
678
|
+
reason: '未能读取 CSS 字体信息,兜底使用 bodyR;实现时需人工复核。',
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const confidence = best.sizeDiff <= 0.4 && best.weightDiff <= 1 && (best.lineDiff === null || best.lineDiff <= 0.6)
|
|
682
|
+
? 'high'
|
|
683
|
+
: best.score <= 8
|
|
684
|
+
? 'medium'
|
|
685
|
+
: 'low';
|
|
686
|
+
const cssText = [
|
|
687
|
+
style['font-size'] ?? '?',
|
|
688
|
+
style['font-weight'] ?? '?',
|
|
689
|
+
style['line-height'] ?? '?',
|
|
690
|
+
].join(' / ');
|
|
691
|
+
return {
|
|
692
|
+
token: best.token,
|
|
693
|
+
confidence,
|
|
694
|
+
reason: `CSS ${cssText} -> themeService.textStyles.${best.token}`,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
function parseCssPx(value) {
|
|
698
|
+
if (typeof value !== 'string')
|
|
699
|
+
return null;
|
|
700
|
+
const trimmed = value.trim();
|
|
701
|
+
if (trimmed === '' || trimmed === 'normal')
|
|
702
|
+
return null;
|
|
703
|
+
const match = /^(-?\d+(?:\.\d+)?)px$/.exec(trimmed);
|
|
704
|
+
if (!match)
|
|
705
|
+
return null;
|
|
706
|
+
const parsed = Number(match[1]);
|
|
707
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
708
|
+
}
|
|
709
|
+
function parseCssWeight(value) {
|
|
710
|
+
if (typeof value !== 'string')
|
|
711
|
+
return null;
|
|
712
|
+
const normalized = value.trim().toLowerCase();
|
|
713
|
+
if (normalized === 'normal')
|
|
714
|
+
return 400;
|
|
715
|
+
if (normalized === 'bold')
|
|
716
|
+
return 700;
|
|
717
|
+
const parsed = Number(normalized);
|
|
718
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
719
|
+
}
|
|
720
|
+
function renderTextStyleMap(entries, jsonPath, ctx) {
|
|
721
|
+
const lines = [];
|
|
722
|
+
lines.push(`# 文本样式映射 — ${ctx.options.pageName}`);
|
|
723
|
+
lines.push('');
|
|
724
|
+
lines.push('> 该文件由新版 `cli/runtime/computed-styles.json` 生成,用于把原型 CSS 字体样式映射到 YouFi `themeService.textStyles.*`。');
|
|
725
|
+
lines.push('> 实现 Flutter 文本时必须优先按本表选择 token,不要凭视觉猜 `bodyM/bodyR`。');
|
|
726
|
+
lines.push('');
|
|
727
|
+
lines.push(`- json: \`${relativeToPage(ctx, jsonPath)}\``);
|
|
728
|
+
lines.push(`- 条目数: ${entries.length}`);
|
|
729
|
+
if (entries.length === 0) {
|
|
730
|
+
lines.push('- 警告:`computed-styles.json` 未包含文本节点样式记录,请重新执行新版 p2f 采集。');
|
|
731
|
+
}
|
|
732
|
+
lines.push('');
|
|
733
|
+
const comboCounts = new Map();
|
|
734
|
+
for (const e of entries) {
|
|
735
|
+
const combo = `${e.css.fontSize ?? '?'} / ${e.css.fontWeight ?? '?'} / ${e.css.lineHeight ?? '?'}`;
|
|
736
|
+
const existing = comboCounts.get(combo);
|
|
737
|
+
if (existing) {
|
|
738
|
+
existing.count += 1;
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
comboCounts.set(combo, {
|
|
742
|
+
count: 1,
|
|
743
|
+
token: e.youfiTextStyle,
|
|
744
|
+
confidence: e.confidence,
|
|
745
|
+
reason: e.reason,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
lines.push('## CSS 组合映射');
|
|
750
|
+
lines.push('');
|
|
751
|
+
lines.push('| CSS font-size / weight / line-height | YouFi textStyle | 置信度 | 数量 |');
|
|
752
|
+
lines.push('| --- | --- | --- | ---: |');
|
|
753
|
+
for (const [combo, data] of [...comboCounts.entries()].sort((a, b) => b[1].count - a[1].count)) {
|
|
754
|
+
lines.push(`| ${escapeMd(combo)} | \`themeService.textStyles.${data.token}\` | ${data.confidence} | ${data.count} |`);
|
|
755
|
+
}
|
|
756
|
+
lines.push('');
|
|
757
|
+
lines.push('## 文本节点映射');
|
|
758
|
+
lines.push('');
|
|
759
|
+
lines.push('| id | 文本 | selector | rect | CSS | YouFi textStyle | 置信度 |');
|
|
760
|
+
lines.push('| --- | --- | --- | --- | --- | --- | --- |');
|
|
761
|
+
for (const e of entries) {
|
|
762
|
+
const rect = e.rect ? `${e.rect.x},${e.rect.y} ${e.rect.w}×${e.rect.h}` : '';
|
|
763
|
+
const css = `${e.css.fontSize ?? '?'} / ${e.css.fontWeight ?? '?'} / ${e.css.lineHeight ?? '?'}`;
|
|
764
|
+
lines.push(`| ${e.id} | ${escapeMd(e.text)} | \`${escapeMd(e.selector)}\` | ${escapeMd(rect)} | ${escapeMd(css)} | \`themeService.textStyles.${e.youfiTextStyle}\` | ${e.confidence} |`);
|
|
765
|
+
}
|
|
766
|
+
lines.push('');
|
|
767
|
+
lines.push('## 使用规则');
|
|
768
|
+
lines.push('');
|
|
769
|
+
lines.push('- 同一文本在本表中有高置信度映射时,Flutter 实现必须使用对应 `themeService.textStyles.*`。');
|
|
770
|
+
lines.push('- 如果最终实现改用其他 token,必须在实现说明中解释原因。');
|
|
771
|
+
lines.push('- 颜色仍按 `themeService.colors.*` 语义映射;涨跌色优先走项目已有行情颜色 helper。');
|
|
772
|
+
return lines.join('\n') + '\n';
|
|
773
|
+
}
|
|
774
|
+
function escapeMd(value) {
|
|
775
|
+
return value.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
776
|
+
}
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// Page archetype classifier
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
function classifyPageType(page, blocks, candidates) {
|
|
781
|
+
const hasForm = blocks.some((b) => b.type === 'form-field' || flatten(b).some((c) => c.type === 'form-field'));
|
|
782
|
+
const hasList = blocks.some((b) => b.type === 'list' || flatten(b).some((c) => c.type === 'list'));
|
|
783
|
+
const hasChart = blocks.some((b) => b.type === 'chart' || flatten(b).some((c) => c.type === 'chart'));
|
|
784
|
+
const hasTabs = candidates.some((c) => c.kind === 'tab-change');
|
|
785
|
+
const routeLabel = (page.routeInfo.label ?? '').toLowerCase();
|
|
786
|
+
if (hasForm && !hasChart)
|
|
787
|
+
return 'form-page';
|
|
788
|
+
if (hasChart || (hasTabs && /详情|detail/i.test(page.routeInfo.label ?? '')))
|
|
789
|
+
return 'detail-page';
|
|
790
|
+
if (hasList && !hasChart)
|
|
791
|
+
return 'list-page';
|
|
792
|
+
if (hasTabs || /首页|home|dashboard/i.test(routeLabel))
|
|
793
|
+
return 'dashboard-page';
|
|
794
|
+
return 'unknown';
|
|
795
|
+
}
|
|
796
|
+
function flatten(b) {
|
|
797
|
+
return [b, ...(b.children ?? []).flatMap(flatten)];
|
|
798
|
+
}
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
// Renderers
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
function renderSummary(ctx, page, outline, blockReasons, capture) {
|
|
803
|
+
const lines = [];
|
|
804
|
+
lines.push(`# 页面摘要 — ${outline.pageName}`);
|
|
805
|
+
lines.push('');
|
|
806
|
+
if (blockReasons.length) {
|
|
807
|
+
lines.push('> **blocked: true**');
|
|
808
|
+
for (const r of blockReasons)
|
|
809
|
+
lines.push(`> - ${r}`);
|
|
810
|
+
lines.push('');
|
|
811
|
+
}
|
|
812
|
+
lines.push(`- 页面目录名:\`${outline.pageKey}\``);
|
|
813
|
+
lines.push(`- 页面类型:\`${outline.pageType}\``);
|
|
814
|
+
lines.push(`- 路由:\`${outline.route.path}\` (${outline.route.kind})`);
|
|
815
|
+
if (outline.route.name)
|
|
816
|
+
lines.push(`- 路由名称:\`${outline.route.name}\``);
|
|
817
|
+
if (outline.route.label)
|
|
818
|
+
lines.push(`- 路由标题:${outline.route.label}`);
|
|
819
|
+
lines.push(`- Vue 入口:\`${page.entryRelative}\``);
|
|
820
|
+
lines.push(`- 原型提交:${ctx.prototypeCommit?.slice(0, 10) ?? '(未知)'}`);
|
|
821
|
+
lines.push(`- 原型来源:${ctx.prototypeSource ?? '(未知)'}`);
|
|
822
|
+
lines.push('');
|
|
823
|
+
lines.push('## 截图');
|
|
824
|
+
lines.push('');
|
|
825
|
+
lines.push('- CLI 不再在 `p2f generate` 阶段截图。');
|
|
826
|
+
lines.push('- agent 必须先执行 `cli/analysis/screenshot-exploration-plan.md` 中的 live-page 探索并补齐 `agent/screenshots/`。');
|
|
827
|
+
if (outline.states.length) {
|
|
828
|
+
for (const s of outline.states) {
|
|
829
|
+
lines.push(`- ${s.screenshot ? `\`${relativeToPage(ctx, s.screenshot)}\`` : '(无截图)'} · ${s.name} · captured=${s.captured}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
lines.push('');
|
|
833
|
+
lines.push('## 关键结构块');
|
|
834
|
+
lines.push('');
|
|
835
|
+
if (outline.blocks.length === 0) {
|
|
836
|
+
lines.push('(未识别到结构块,可能是采集不完整)');
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
for (const b of outline.blocks) {
|
|
840
|
+
lines.push(renderBlockOneLine(b));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
lines.push('');
|
|
844
|
+
lines.push('## 候选交互');
|
|
845
|
+
lines.push('');
|
|
846
|
+
if (outline.candidateInteractions.length === 0) {
|
|
847
|
+
lines.push('(本版采集未识别出交互候选;agent 仍需在 live page 中主动检查可交互区域。)');
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
for (const c of outline.candidateInteractions.slice(0, 30)) {
|
|
851
|
+
lines.push(`- \`${c.selector}\` · ${c.kind}${c.label ? ` · ${c.label}` : ''}(置信度=${c.confidence.toFixed(1)})`);
|
|
852
|
+
}
|
|
853
|
+
if (outline.candidateInteractions.length > 30) {
|
|
854
|
+
lines.push(`- …共 ${outline.candidateInteractions.length} 项(仅展示前 30)`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
lines.push('');
|
|
858
|
+
lines.push('## 设计 Token(启发式抽取)');
|
|
859
|
+
lines.push('');
|
|
860
|
+
if (Object.keys(outline.designTokens).length === 0) {
|
|
861
|
+
lines.push('(未抽到稳定 token)');
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
for (const [k, v] of Object.entries(outline.designTokens)) {
|
|
865
|
+
lines.push(`- \`${k}\`: \`${v}\``);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
lines.push('');
|
|
869
|
+
lines.push('## 未完成事项');
|
|
870
|
+
lines.push('');
|
|
871
|
+
lines.push('- 由 agent 基于本目录数据和 YouFi 项目规范实现 Flutter 页面');
|
|
872
|
+
// keep unused-lint happy
|
|
873
|
+
void capture;
|
|
874
|
+
return lines.join('\n') + '\n';
|
|
875
|
+
}
|
|
876
|
+
function renderUiStructure(outline) {
|
|
877
|
+
const lines = [`# UI 结构 — ${outline.pageName}`, ''];
|
|
878
|
+
if (outline.blocks.length === 0) {
|
|
879
|
+
lines.push('(未识别到结构块)');
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
for (const b of outline.blocks)
|
|
883
|
+
renderBlockTree(b, 0, lines);
|
|
884
|
+
}
|
|
885
|
+
return lines.join('\n') + '\n';
|
|
886
|
+
}
|
|
887
|
+
function renderBlockTree(b, depth, lines) {
|
|
888
|
+
const indent = ' '.repeat(depth);
|
|
889
|
+
lines.push(`${indent}- \`${b.id}\` ${b.type}${b.role ? `(${b.role})` : ''}${b.text ? ` · ${b.text}` : ''} · ${rectStr(b.rect)}`);
|
|
890
|
+
for (const c of b.children ?? [])
|
|
891
|
+
renderBlockTree(c, depth + 1, lines);
|
|
892
|
+
}
|
|
893
|
+
function renderBlockOneLine(b) {
|
|
894
|
+
return `- \`${b.id}\` ${b.type}${b.text ? ` · ${b.text}` : ''} · ${rectStr(b.rect)}`;
|
|
895
|
+
}
|
|
896
|
+
function rectStr(r) {
|
|
897
|
+
return `(${r.x},${r.y} ${r.w}×${r.h})`;
|
|
898
|
+
}
|
|
899
|
+
function renderInteractionModel(outline) {
|
|
900
|
+
const lines = [`# 交互模型 — ${outline.pageName}`, ''];
|
|
901
|
+
lines.push('按候选交互类型分组列出。它们只是 agent live-page 探索的线索,不代表完整交互覆盖。');
|
|
902
|
+
lines.push('');
|
|
903
|
+
const byKind = new Map();
|
|
904
|
+
for (const c of outline.candidateInteractions) {
|
|
905
|
+
if (!byKind.has(c.kind))
|
|
906
|
+
byKind.set(c.kind, []);
|
|
907
|
+
byKind.get(c.kind).push(c);
|
|
908
|
+
}
|
|
909
|
+
for (const [kind, items] of [...byKind.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
910
|
+
lines.push(`## ${kind} (${items.length})`);
|
|
911
|
+
lines.push('');
|
|
912
|
+
for (const it of items) {
|
|
913
|
+
lines.push(`- \`${it.selector}\`${it.label ? ` · ${it.label}` : ''} — ${it.description}`);
|
|
914
|
+
}
|
|
915
|
+
lines.push('');
|
|
916
|
+
}
|
|
917
|
+
return lines.join('\n') + '\n';
|
|
918
|
+
}
|
|
919
|
+
function relativeToPage(ctx, abs) {
|
|
920
|
+
return relative(ctx.taskDir, abs);
|
|
921
|
+
}
|
|
922
|
+
//# sourceMappingURL=semantic-model-builder.js.map
|