cardbuddy 0.13.3 → 0.14.1
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 +7 -3
- package/dist/cli.js +4 -4
- package/dist/config.js +7 -7
- package/dist/init.js +15 -4
- package/dist/portGuard.js +2 -2
- package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
- package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +14 -6
- package/dist/vendored/card-editor/api.js +6 -6
- package/dist/vendored/mcp/client.mjs +53 -3
- package/dist/vendored/mcp/tools.mjs +17 -1
- package/dist/vendored/server/core/ai-chat.js +1443 -271
- package/dist/vendored/server/core/cardFiles.js +271 -39
- package/dist/vendored/server/core/cardImportMeta.js +16 -0
- package/dist/vendored/server/core/chatHistory.js +122 -0
- package/dist/vendored/server/core/index.js +10 -3
- package/dist/vendored/server/core/turnJournal.js +386 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
- package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
- package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
- package/dist/vendored/tools/finmall-env.mjs +6 -6
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
- package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
|
@@ -19,7 +19,7 @@ import path from 'node:path'
|
|
|
19
19
|
import { fileURLToPath } from 'node:url'
|
|
20
20
|
import { makeMinComponent } from './lib/scaffold.mjs'
|
|
21
21
|
import { T } from './lib/layout-tokens.mjs'
|
|
22
|
-
import { sampleTopBand, sampleRegionColor, sampleRegionEdgeColor, sampleCardBodyColor, crossCheckTextColor, sampleOutlineRing } from './lib/design_sample.mjs'
|
|
22
|
+
import { sampleTopBand, sampleRegionColor, sampleRegionEdgeColor, sampleCardBodyColor, crossCheckTextColor, sampleOutlineRing, sampleBackdropBands } from './lib/design_sample.mjs'
|
|
23
23
|
import { resolveSliceExport, sliceRefPath, sliceFlatName, stripScale } from './lib/slicePath.mjs'
|
|
24
24
|
import { cardsDir, metaDir, slicesDir } from './lib/paths.mjs'
|
|
25
25
|
|
|
@@ -104,17 +104,21 @@ function boundProp(itemKey, expr, defaultValue = '') {
|
|
|
104
104
|
return { itemId: '', itemKey, itemName: itemKey, itemType: '', itemValueFrom: '1', itemValue: expr, itemDefaultValue: defaultValue }
|
|
105
105
|
}
|
|
106
106
|
// v-show 条件渲染指令:showType=01 + condition.expression(如 "$afford != ''")
|
|
107
|
-
function vShowDirective(expression) {
|
|
107
|
+
function vShowDirective(expression, name) {
|
|
108
108
|
const prop = (itemId, itemKey, itemValue) => ({
|
|
109
109
|
itemId, itemKey, itemName: itemKey, itemType: '', itemValueFrom: '0', itemValue, itemDefaultValue: itemValue,
|
|
110
110
|
})
|
|
111
|
+
const expr = String(expression || '')
|
|
112
|
+
// validate_schema.py 铁律:condition.name 必填非空;表达式 <15 字符时 name 与表达式一致,
|
|
113
|
+
// >=15 字符给可读显示名(调用方可传语义名,缺省回退通用名)。
|
|
114
|
+
const displayName = expr.length < 15 ? expr : (String(name || '').trim() || '显示条件')
|
|
111
115
|
return {
|
|
112
116
|
itemId: '', itemKey: 'v-show', itemName: 'v-show', itemType: '', itemValueFrom: '0',
|
|
113
117
|
itemValue: {}, itemDefaultValue: {},
|
|
114
118
|
itemChildren: {
|
|
115
119
|
properties: [
|
|
116
120
|
prop('001', 'showType', '01'),
|
|
117
|
-
prop('002', 'condition', { expression }),
|
|
121
|
+
prop('002', 'condition', { expression: expr, name: displayName }),
|
|
118
122
|
],
|
|
119
123
|
},
|
|
120
124
|
}
|
|
@@ -442,7 +446,11 @@ function isMaskedOnlyText(text) {
|
|
|
442
446
|
* 关键纪律:**不能根据 spec 原始文本写死前缀负号**——同一变量在不同数据下可能正负切换。
|
|
443
447
|
* 必须在表达式里运行时判断 $var 的正负:负数显示 '-¥abs',正数显示 '¥amount'。
|
|
444
448
|
*/
|
|
445
|
-
function
|
|
449
|
+
function currencyPrefixOf(srcText) {
|
|
450
|
+
const curMatch = String(srcText || '').replace(/^[-+]/, '').match(/^[^\d\-(),.\s]{1,4}(?=[\d(])/u)
|
|
451
|
+
return curMatch && /\p{Sc}/u.test(curMatch[0]) ? curMatch[0] : ''
|
|
452
|
+
}
|
|
453
|
+
function amountExpr(varName, srcText, opts = {}) {
|
|
446
454
|
// 尊重设计稿原文:
|
|
447
455
|
// 原文含 ¥/¥ 符号 → '¥'+千分号(如"¥563.80")
|
|
448
456
|
// 纯数字无符号 → 仅千分号(如 "2000" 配合相邻 "万元")
|
|
@@ -454,8 +462,7 @@ function amountExpr(varName, srcText) {
|
|
|
454
462
|
const hasYuanUnit = !!unitMatch
|
|
455
463
|
// 货币前缀取设计稿原文(任意 Unicode 货币符号,含 "HK$"/"US$" 字母+符号组合),不写死某一种
|
|
456
464
|
// 负号不是货币前缀的一部分:先剥离首符号再取前缀("-¥2,000.00" 否则丢 ¥)。
|
|
457
|
-
const
|
|
458
|
-
const curPrefix = curMatch && /\p{Sc}/u.test(curMatch[0]) ? curMatch[0] : null
|
|
465
|
+
const curPrefix = currencyPrefixOf(srcText)
|
|
459
466
|
const hasCurrency = !!curPrefix
|
|
460
467
|
const srcNum = srcText != null ? String(srcText).replace(/[^\d,.-]/g, '') : ''
|
|
461
468
|
const srcHasDecimals = /\.\d/.test(srcNum)
|
|
@@ -470,7 +477,8 @@ function amountExpr(varName, srcText) {
|
|
|
470
477
|
const unit = JSON.stringify(unitMatch[2])
|
|
471
478
|
return `${sign} + ${abs} + ${unit}`
|
|
472
479
|
}
|
|
473
|
-
|
|
480
|
+
// 列表行模板共用一套表达式:货币前缀必须逐行数据驱动($amtCurN),否则首行符号套到全行
|
|
481
|
+
if (opts.prefixVar) return `${sign} + $${opts.prefixVar} + ${abs}`
|
|
474
482
|
return `${sign} + ${abs}`
|
|
475
483
|
}
|
|
476
484
|
|
|
@@ -745,6 +753,13 @@ function ensureVisibleTextColor(textColor, bgColors, fallback) {
|
|
|
745
753
|
}
|
|
746
754
|
return textColor
|
|
747
755
|
}
|
|
756
|
+
/** 颜色调浅:与白色按比例混合(ratio=颜色占比),用于「描边同色 fill」标注缺陷的浅底还原 */
|
|
757
|
+
function tintHex(hex, ratio = 0.1) {
|
|
758
|
+
const h = String(hex || '').replace('#', '')
|
|
759
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return hex
|
|
760
|
+
const mix = (i) => Math.round(parseInt(h.slice(i, i + 2), 16) * ratio + 255 * (1 - ratio))
|
|
761
|
+
return '#' + [0, 2, 4].map((i) => mix(i).toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
762
|
+
}
|
|
748
763
|
function isBg(e) {
|
|
749
764
|
const r = rect(e)
|
|
750
765
|
return (r.width || 0) >= 340 && (r.height || 0) >= 600
|
|
@@ -752,12 +767,363 @@ function isBg(e) {
|
|
|
752
767
|
function sameRow(a, b, tol = 12) { return Math.abs((rect(a).y || 0) - (rect(b).y || 0)) <= tol }
|
|
753
768
|
|
|
754
769
|
|
|
770
|
+
/** 矩形并集 / 包含判定(图表簇几何用) */
|
|
771
|
+
function unionRects(items) {
|
|
772
|
+
const xs = items.map((e) => rect(e).x || 0)
|
|
773
|
+
const ys = items.map((e) => rect(e).y || 0)
|
|
774
|
+
const rs = items.map((e) => (rect(e).x || 0) + (rect(e).width || 0))
|
|
775
|
+
const bs = items.map((e) => (rect(e).y || 0) + (rect(e).height || 0))
|
|
776
|
+
const x = Math.min(...xs); const y = Math.min(...ys)
|
|
777
|
+
return { x, y, width: Math.max(...rs) - x, height: Math.max(...bs) - y }
|
|
778
|
+
}
|
|
779
|
+
function rectInside(outer, r, pad = 0) {
|
|
780
|
+
const pl = typeof pad === 'number' ? { l: pad, r: pad, t: pad, b: pad } : pad
|
|
781
|
+
return (r.x || 0) >= (outer.x || 0) - (pl.l || 0)
|
|
782
|
+
&& (r.y || 0) >= (outer.y || 0) - (pl.t || 0)
|
|
783
|
+
&& ((r.x || 0) + (r.width || 0)) <= (outer.x || 0) + (outer.width || 0) + (pl.r || 0)
|
|
784
|
+
&& ((r.y || 0) + (r.height || 0)) <= (outer.y || 0) + (outer.height || 0) + (pl.b || 0)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** 柱状图矢量簇识别(纯几何,不依赖业务词):
|
|
788
|
+
* 同基线(底沿 ±3)窄柱 ≥4 + 横向近等距 + 网格线/轴标签佐证 → 整簇(柱+网格线+簇内文本)
|
|
789
|
+
* 交给 Canvas 自绘回放。DSL 无图表组件,此前柱体被静默丢弃、网格线被当 Divider 错排。 */
|
|
790
|
+
export function detectChartClusters(content) {
|
|
791
|
+
const shapes = content.filter((e) => e.type === 'shape')
|
|
792
|
+
const texts = content.filter((e) => e.type === 'text' && textOf(e))
|
|
793
|
+
const bars = shapes.filter((e) => {
|
|
794
|
+
const r = rect(e); const fill = e.style?.fill
|
|
795
|
+
if (!fill || (!fill.color && !fill.gradient)) return false
|
|
796
|
+
if (radiusMax(e.style) >= 8) return false
|
|
797
|
+
return (r.width || 0) >= 4 && (r.width || 0) <= 48 && (r.height || 0) >= 4 && (r.height || 0) <= 260
|
|
798
|
+
})
|
|
799
|
+
const byBottom = []
|
|
800
|
+
for (const bar of bars.slice().sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))) {
|
|
801
|
+
const bottom = (rect(bar).y || 0) + (rect(bar).height || 0)
|
|
802
|
+
let group = byBottom.find((item) => Math.abs(item.bottom - bottom) <= 3)
|
|
803
|
+
if (!group) { group = { bottom, members: [] }; byBottom.push(group) }
|
|
804
|
+
group.members.push(bar)
|
|
805
|
+
}
|
|
806
|
+
const clusters = []
|
|
807
|
+
for (const group of byBottom) {
|
|
808
|
+
if (group.members.length < 4) continue
|
|
809
|
+
const xs = group.members.map((m) => rect(m).x || 0)
|
|
810
|
+
const gaps = xs.slice(1).map((x, i) => x - xs[i])
|
|
811
|
+
const gapAvg = gaps.reduce((a, b) => a + b, 0) / Math.max(1, gaps.length)
|
|
812
|
+
if (gaps.some((gap) => Math.abs(gap - gapAvg) > Math.max(6, gapAvg * 0.6))) continue
|
|
813
|
+
const barBox = unionRects(group.members)
|
|
814
|
+
const grid = shapes.filter((e) => {
|
|
815
|
+
const r = rect(e)
|
|
816
|
+
if ((r.height || 0) > 2 || (r.width || 0) < 60) return false
|
|
817
|
+
const overlap = Math.min((r.x || 0) + (r.width || 0), barBox.x + barBox.width) - Math.max(r.x || 0, barBox.x)
|
|
818
|
+
return overlap >= barBox.width * 0.6 && (r.y || 0) >= barBox.y - 260 && (r.y || 0) <= barBox.y + barBox.height + 4
|
|
819
|
+
})
|
|
820
|
+
const seedBox = unionRects([...group.members, ...grid])
|
|
821
|
+
const labels = texts.filter((e) => rectInside(seedBox, rect(e), { l: 48, r: 12, t: 30, b: 36 }))
|
|
822
|
+
if (grid.length < 3 && labels.length < 4) continue
|
|
823
|
+
const members = [...group.members, ...grid]
|
|
824
|
+
const box = unionRects([...members, ...labels])
|
|
825
|
+
clusters.push({ kind: 'bar', members, texts: labels, x: box.x, y: box.y, width: box.width, height: box.height })
|
|
826
|
+
}
|
|
827
|
+
return clusters
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** 柱状图簇 → Canvas onMeasured 绘图代码(坐标相对簇原点;只用代理白名单 API)。
|
|
831
|
+
* 圆角矩形无 roundRect 白名单 → 四角 arc 拼路径;网格线走 stroke;标签走 fillText。 */
|
|
832
|
+
/** 图表绘制层序(铁律 R62):坐标系 / 参考系线条(网格线、轴线、刻度线、雷达环与辐射线、环形轨道)
|
|
833
|
+
* 画在**最底层**,数据标记(柱 / 折线 / 多边形 / 扇区)在其上,文字标签最上层。
|
|
834
|
+
* Canvas 是即时模式绘制——后画的压先画的,所以出码顺序必须 = 底 → 顶。
|
|
835
|
+
* spec 元素顺序**不可照抄**:标注导出常把网格线排在数据图元之后(assetAllocRadar 雷达
|
|
836
|
+
* 34-39 是 #DCDCDC 网格/辐射线、40 才是数据多边形),直出会把参考线盖在数据上。 */
|
|
837
|
+
function chartPaintLayers(members, cluster) {
|
|
838
|
+
const backdrop = []
|
|
839
|
+
const axis = []
|
|
840
|
+
const marks = []
|
|
841
|
+
const cw = cluster?.width || 0
|
|
842
|
+
const ch = cluster?.height || 0
|
|
843
|
+
for (const m of members) {
|
|
844
|
+
const r = rect(m)
|
|
845
|
+
const w = r.width || 0
|
|
846
|
+
const h = r.height || 0
|
|
847
|
+
// 细线(任一维 ≤2px)= 网格线 / 轴线 / 刻度线 → 底层
|
|
848
|
+
if (h <= 2 || w <= 2) { axis.push(m); continue }
|
|
849
|
+
// 大面积填充(覆盖簇 bbox 一半以上)= 绘图区底板 → 更底层
|
|
850
|
+
const fill = m.style?.fill
|
|
851
|
+
if (fill && (fill.color || fill.gradient) && cw && ch && w >= cw * 0.5 && h >= ch * 0.5) { backdrop.push(m); continue }
|
|
852
|
+
marks.push(m)
|
|
853
|
+
}
|
|
854
|
+
return { backdrop, axis, marks }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** 数据标记半透明时把 opacity 乘进颜色 / 渐变(ctx 代理白名单无 globalAlpha),底层网格才透得出来。 */
|
|
858
|
+
function chartPaintColor(color, opacity = 1) {
|
|
859
|
+
const op = Number(opacity)
|
|
860
|
+
const k = Number.isFinite(op) && op > 0 && op < 1 ? op : 1
|
|
861
|
+
const c = String(color || '').trim()
|
|
862
|
+
const m = c.match(/^rgba?\(([^,]+),([^,]+),([^,]+)(?:,([^)]+))?\)$/i)
|
|
863
|
+
if (m) return `rgba(${m[1].trim()},${m[2].trim()},${m[3].trim()},${Number(m[4] ?? 1) * k})`
|
|
864
|
+
return hexToRgba(color, k)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** 柱状图簇 → flex 原生组件树(柱=色块 Column、值/轴/类目标签=真实 Text、网格线=硬停渐变背景)。
|
|
868
|
+
* Canvas 自绘会让标注文本在组件计数中消失(文本召回/组件数被系统性低估),且字体渲染与设计稿偏差大;
|
|
869
|
+
* 渲染器无绝对定位,故用「轴列 + 槽位列」flex 结构按 spec 几何精确回放:
|
|
870
|
+
* 槽位边界取类目标签中心中点,柱列宽=柱宽、值标签靠 overflow:visible 居中溢出。
|
|
871
|
+
* 返回 null 表示该簇不满足柱状图形态(调用方回退 Canvas)。 */
|
|
872
|
+
export function buildFlexBarChart(cluster) {
|
|
873
|
+
const members = cluster.members || []
|
|
874
|
+
const bars = members.filter((m) => {
|
|
875
|
+
const r = rect(m); const h = r.height || 0; const w = r.width || 0
|
|
876
|
+
return m.style?.fill?.color && h >= 4 && w >= 6 && w <= 48
|
|
877
|
+
})
|
|
878
|
+
const gridlines = members.filter((m) => (rect(m).height || 0) <= 2 && (rect(m).width || 0) >= 100)
|
|
879
|
+
const texts = cluster.texts || []
|
|
880
|
+
if (bars.length < 4 || gridlines.length < 2 || !texts.length) return null
|
|
881
|
+
const baseline = Math.max(...bars.map((b) => (rect(b).y || 0) + (rect(b).height || 0)))
|
|
882
|
+
const plotLeft = Math.min(...bars.map((b) => rect(b).x || 0))
|
|
883
|
+
const plotRight = Math.max(...gridlines.map((g) => (rect(g).x || 0) + (rect(g).width || 0)),
|
|
884
|
+
...bars.map((b) => (rect(b).x || 0) + (rect(b).width || 0)))
|
|
885
|
+
const plotTop = Math.min(...gridlines.map((g) => rect(g).y || 0))
|
|
886
|
+
const axisTexts = texts.filter((t) => ((rect(t).x || 0) + (rect(t).width || 0)) <= plotLeft - 4)
|
|
887
|
+
.sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
|
|
888
|
+
const nameTexts = texts.filter((t) => (rect(t).y || 0) >= baseline + 2)
|
|
889
|
+
.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
|
|
890
|
+
const valueTexts = texts.filter((t) => !axisTexts.includes(t) && !nameTexts.includes(t))
|
|
891
|
+
if (!axisTexts.length || !nameTexts.length) return null
|
|
892
|
+
// 槽位:类目标签中心的中点切分绘图区
|
|
893
|
+
const centers = nameTexts.map((t) => (rect(t).x || 0) + (rect(t).width || 0) / 2)
|
|
894
|
+
const bounds = [plotLeft]
|
|
895
|
+
for (let i = 1; i < centers.length; i++) bounds.push(Math.round((centers[i - 1] + centers[i]) / 2))
|
|
896
|
+
bounds.push(plotRight)
|
|
897
|
+
const slots = []
|
|
898
|
+
for (let i = 0; i < nameTexts.length; i++) {
|
|
899
|
+
const slotBars = bars.filter((b) => {
|
|
900
|
+
const cx = (rect(b).x || 0) + (rect(b).width || 0) / 2
|
|
901
|
+
return cx >= bounds[i] - 1 && cx <= bounds[i + 1] + 1
|
|
902
|
+
}).sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
|
|
903
|
+
if (!slotBars.length) return null
|
|
904
|
+
slots.push({ name: nameTexts[i], x: bounds[i], width: bounds[i + 1] - bounds[i], bars: slotBars })
|
|
905
|
+
}
|
|
906
|
+
const plotH = Math.round(baseline - plotTop)
|
|
907
|
+
const axisTop = rect(axisTexts[0]).y || 0
|
|
908
|
+
const axisBottom = Math.max(...axisTexts.map((t) => (rect(t).y || 0) + (rect(t).height || 0)))
|
|
909
|
+
const axisRight = Math.max(...axisTexts.map((t) => (rect(t).x || 0) + (rect(t).width || 0)))
|
|
910
|
+
const axisGap = Math.max(0, Math.round(plotLeft - axisRight))
|
|
911
|
+
const nameTop = Math.min(...nameTexts.map((t) => rect(t).y || 0))
|
|
912
|
+
const nameGap = Math.max(0, Math.round(nameTop - baseline))
|
|
913
|
+
// 网格线硬停渐变(坐标系线条必须在绘制层最底部 → 作背景,柱/标签在其上)
|
|
914
|
+
const gl = gridlines.map((g) => rect(g).y || 0).sort((a, b) => a - b)
|
|
915
|
+
const glColor = (() => {
|
|
916
|
+
const g0 = gridlines[0]
|
|
917
|
+
const stops = g0.style?.fill?.gradient?.stops || g0.style?.fill?.gradient?.colors || null
|
|
918
|
+
if (Array.isArray(stops) && stops[0]?.color) return hexToRgba(stops[0].color)
|
|
919
|
+
if (g0.style?.fill?.color) return hexToRgba(g0.style.fill.color)
|
|
920
|
+
return 'rgba(244,244,247,1)'
|
|
921
|
+
})()
|
|
922
|
+
const gradParts = []
|
|
923
|
+
let prev = 0
|
|
924
|
+
for (const y of gl) {
|
|
925
|
+
const o = Math.max(0, Math.round(y - plotTop))
|
|
926
|
+
if (o > prev) gradParts.push(`rgba(0,0,0,0) ${prev}px`, `${glColor} ${o}px`)
|
|
927
|
+
gradParts.push(`${glColor} ${o}px`, `${glColor} ${o + 1}px`)
|
|
928
|
+
prev = o + 1
|
|
929
|
+
}
|
|
930
|
+
gradParts.push(`rgba(0,0,0,0) ${prev}px`, `rgba(0,0,0,0) ${plotH}px`)
|
|
931
|
+
// —— 组装 ——
|
|
932
|
+
const chartRow = container('Row', { width: '100%', direction: 'row', align: 'flex-start', gap: String(axisGap) })
|
|
933
|
+
const axisCol = container('Column', {
|
|
934
|
+
direction: 'column', justify: 'space-between', align: 'flex-end', flexShrink: 0,
|
|
935
|
+
width: String(Math.round(axisRight - (rect(axisTexts[0]).x || 0))),
|
|
936
|
+
})
|
|
937
|
+
ensureStyleGroup(axisCol, 'rect')
|
|
938
|
+
setStyleChild(axisCol, 'rect', 'height', String(Math.round(axisBottom - axisTop)))
|
|
939
|
+
for (const t of axisTexts) {
|
|
940
|
+
const node = textFromSpec({ ...t, text: textOf(t), color: ts(t).color, fontSize: ts(t).fontSize, fontWeight: ts(t).fontWeight, align: 'right' })
|
|
941
|
+
ensureStyleGroup(node, 'flex')
|
|
942
|
+
setStyleChild(node, 'flex', 'flexShrink', '0')
|
|
943
|
+
axisCol.slots[0].components.push(node)
|
|
944
|
+
}
|
|
945
|
+
chartRow.slots[0].components.push(axisCol)
|
|
946
|
+
const plotCol = container('Column', {
|
|
947
|
+
direction: 'column', align: 'stretch', gap: '0', flexShrink: 0,
|
|
948
|
+
width: String(Math.round(plotRight - plotLeft)),
|
|
949
|
+
padding: `${Math.max(0, Math.round(plotTop - axisTop))} 0 0 0`,
|
|
950
|
+
})
|
|
951
|
+
const gridArea = container('Row', { direction: 'row', align: 'flex-end', gap: '0', width: '100%' })
|
|
952
|
+
ensureStyleGroup(gridArea, 'rect')
|
|
953
|
+
setStyleChild(gridArea, 'rect', 'height', String(plotH))
|
|
954
|
+
ensureStyleGroup(gridArea, 'background')
|
|
955
|
+
setStyleChild(gridArea, 'background', 'backgroundType', '03')
|
|
956
|
+
setStyleChild(gridArea, 'background', 'backgroundGradient', `linear-gradient(to bottom, ${gradParts.join(', ')})`)
|
|
957
|
+
for (const slot of slots) {
|
|
958
|
+
const padLeft = Math.max(0, Math.round((rect(slot.bars[0]).x || 0) - slot.x))
|
|
959
|
+
const slotCol = container('Column', {
|
|
960
|
+
direction: 'column', justify: 'flex-end', align: 'flex-start', flexShrink: 0,
|
|
961
|
+
width: String(slot.width), padding: `0 0 0 ${padLeft}`,
|
|
962
|
+
})
|
|
963
|
+
ensureStyleGroup(slotCol, 'rect')
|
|
964
|
+
setStyleChild(slotCol, 'rect', 'height', String(plotH))
|
|
965
|
+
// 柱列宽 = max(柱宽, 值标签宽):渲染器 fit-content 受父宽限制会把标签折行(h 爆成 3 行);
|
|
966
|
+
// 相邻标签水平重叠时用负 marginLeft 拉回设计中心(flex 行内唯一可重叠手段)。
|
|
967
|
+
const barRow = container('Row', { direction: 'row', align: 'flex-end', gap: '0' })
|
|
968
|
+
const firstBarX = rect(slot.bars[0]).x || 0
|
|
969
|
+
let barCursor = 0
|
|
970
|
+
for (const bar of slot.bars) {
|
|
971
|
+
const br = rect(bar)
|
|
972
|
+
const label = valueTexts.find((t) => {
|
|
973
|
+
const cx = (br.x || 0) + (br.width || 0) / 2
|
|
974
|
+
const tcx = (rect(t).x || 0) + (rect(t).width || 0) / 2
|
|
975
|
+
return Math.abs(tcx - cx) <= Math.max(br.width || 0, (rect(t).width || 0) / 2)
|
|
976
|
+
&& (rect(t).y || 0) + (rect(t).height || 0) <= (br.y || 0) + 2
|
|
977
|
+
})
|
|
978
|
+
const barCenter = (br.x || 0) + (br.width || 0) / 2
|
|
979
|
+
// 渲染字形比标注 rect 略宽(PingFang 度量差):不留余量会折行(h 16→30、整列上移)
|
|
980
|
+
const colW = Math.max(Math.round(br.width || 0), label ? Math.round(rect(label).width || 0) + 4 : 0)
|
|
981
|
+
const desiredLeft = Math.round(barCenter - colW / 2) - firstBarX
|
|
982
|
+
const barCol = container('Column', {
|
|
983
|
+
direction: 'column', justify: 'flex-end', align: 'center', gap: '0', flexShrink: 0,
|
|
984
|
+
width: String(colW),
|
|
985
|
+
})
|
|
986
|
+
if (desiredLeft !== barCursor) {
|
|
987
|
+
ensureStyleGroup(barCol, 'margin')
|
|
988
|
+
setStyleChild(barCol, 'margin', 'marginType', '1')
|
|
989
|
+
setStyleChild(barCol, 'margin', 'marginLeft', String(desiredLeft - barCursor))
|
|
990
|
+
setStyleChild(barCol, 'margin', 'marginTop', '0')
|
|
991
|
+
setStyleChild(barCol, 'margin', 'marginRight', '0')
|
|
992
|
+
setStyleChild(barCol, 'margin', 'marginBottom', '0')
|
|
993
|
+
}
|
|
994
|
+
barCursor = desiredLeft + colW
|
|
995
|
+
if (label) {
|
|
996
|
+
const ln = textFromSpec({ text: textOf(label), color: ts(label).color, fontSize: ts(label).fontSize, fontWeight: ts(label).fontWeight, align: 'center' })
|
|
997
|
+
ensureStyleGroup(ln, 'flex')
|
|
998
|
+
setStyleChild(ln, 'flex', 'flexShrink', '0')
|
|
999
|
+
barCol.slots[0].components.push(ln)
|
|
1000
|
+
}
|
|
1001
|
+
const block = container('Column', { direction: 'column', flexShrink: 0 })
|
|
1002
|
+
ensureStyleGroup(block, 'rect')
|
|
1003
|
+
ensureStyleGroup(block, 'background')
|
|
1004
|
+
ensureStyleGroup(block, 'borderRadius')
|
|
1005
|
+
setStyleChild(block, 'rect', 'width', String(Math.round(br.width || 0)))
|
|
1006
|
+
setStyleChild(block, 'rect', 'height', String(Math.round(br.height || 0)))
|
|
1007
|
+
setStyleChild(block, 'background', 'backgroundType', '01')
|
|
1008
|
+
setStyleChild(block, 'background', 'backgroundColor', hexToRgba(bar.style.fill.color))
|
|
1009
|
+
setStyleChild(block, 'borderRadius', 'borderRadiusType', '1')
|
|
1010
|
+
setStyleChild(block, 'borderRadius', 'borderTopLeftRadius', '2')
|
|
1011
|
+
setStyleChild(block, 'borderRadius', 'borderTopRightRadius', '2')
|
|
1012
|
+
setStyleChild(block, 'borderRadius', 'borderBottomLeftRadius', '0')
|
|
1013
|
+
setStyleChild(block, 'borderRadius', 'borderBottomRightRadius', '0')
|
|
1014
|
+
barCol.slots[0].components.push(block)
|
|
1015
|
+
barRow.slots[0].components.push(barCol)
|
|
1016
|
+
}
|
|
1017
|
+
slotCol.slots[0].components.push(barRow)
|
|
1018
|
+
gridArea.slots[0].components.push(slotCol)
|
|
1019
|
+
}
|
|
1020
|
+
plotCol.slots[0].components.push(gridArea)
|
|
1021
|
+
const nameRow = container('Row', { direction: 'row', align: 'flex-start', gap: '0', width: '100%', padding: `${nameGap} 0 0 0` })
|
|
1022
|
+
for (const slot of slots) {
|
|
1023
|
+
const slotCol = container('Column', { direction: 'column', align: 'flex-start', flexShrink: 0, width: String(slot.width) })
|
|
1024
|
+
// 类目标签可能比柱对宽(日常开销 48 > 柱对 42):居中行按标签宽撑开 + 负 margin 锚柱对中心,
|
|
1025
|
+
// 槽位列 overflow visible 放行溢出(flex 行宽不变,邻槽不受影响)
|
|
1026
|
+
ensureStyleGroup(slotCol, 'other')
|
|
1027
|
+
setStyleChild(slotCol, 'other', 'overflow', 'visible')
|
|
1028
|
+
const pairW = Math.round(((rect(slot.bars[slot.bars.length - 1]).x || 0) + (rect(slot.bars[slot.bars.length - 1]).width || 0)) - (rect(slot.bars[0]).x || 0))
|
|
1029
|
+
const nameW = Math.round(rect(slot.name).width || 0) + 4
|
|
1030
|
+
const rowW = Math.max(pairW, nameW)
|
|
1031
|
+
const pairCenter = Math.round((rect(slot.bars[0]).x || 0) + pairW / 2)
|
|
1032
|
+
const centerRow = container('Row', { direction: 'row', justify: 'center', align: 'center', width: String(rowW) })
|
|
1033
|
+
const rowLeft = pairCenter - Math.round(rowW / 2) - slot.x
|
|
1034
|
+
if (rowLeft !== 0) {
|
|
1035
|
+
ensureStyleGroup(centerRow, 'margin')
|
|
1036
|
+
setStyleChild(centerRow, 'margin', 'marginType', '1')
|
|
1037
|
+
setStyleChild(centerRow, 'margin', 'marginLeft', String(rowLeft))
|
|
1038
|
+
setStyleChild(centerRow, 'margin', 'marginTop', '0')
|
|
1039
|
+
setStyleChild(centerRow, 'margin', 'marginRight', '0')
|
|
1040
|
+
setStyleChild(centerRow, 'margin', 'marginBottom', '0')
|
|
1041
|
+
}
|
|
1042
|
+
const nn = textFromSpec({ text: textOf(slot.name), color: ts(slot.name).color, fontSize: ts(slot.name).fontSize, fontWeight: ts(slot.name).fontWeight, align: 'center' })
|
|
1043
|
+
ensureStyleGroup(nn, 'flex')
|
|
1044
|
+
setStyleChild(nn, 'flex', 'flexShrink', '0')
|
|
1045
|
+
centerRow.slots[0].components.push(nn)
|
|
1046
|
+
slotCol.slots[0].components.push(centerRow)
|
|
1047
|
+
nameRow.slots[0].components.push(slotCol)
|
|
1048
|
+
}
|
|
1049
|
+
plotCol.slots[0].components.push(nameRow)
|
|
1050
|
+
chartRow.slots[0].components.push(plotCol)
|
|
1051
|
+
return chartRow
|
|
1052
|
+
}
|
|
1053
|
+
export function chartBarCanvasCode(cluster) {
|
|
1054
|
+
const ox = cluster.x; const oy = cluster.y
|
|
1055
|
+
const n = (v) => Math.round(Number(v) * 100) / 100
|
|
1056
|
+
const out = []
|
|
1057
|
+
out.push('var self = this;')
|
|
1058
|
+
// event.canvas 是画布包装对象(非 ctx):export-proven 范式先 getContext('2d') 拿代理 ctx
|
|
1059
|
+
out.push('var canvas = event.canvas;')
|
|
1060
|
+
out.push("var cv = canvas.getContext('2d');")
|
|
1061
|
+
out.push('canvas.width = event.width;')
|
|
1062
|
+
out.push('canvas.height = event.height;')
|
|
1063
|
+
out.push('cv.clearRect(0,0,event.width,event.height);')
|
|
1064
|
+
out.push('var rr=function(x,y,w,h,tl,tr,br,bl){cv.beginPath();cv.moveTo(x+tl,y);'
|
|
1065
|
+
+ 'cv.lineTo(x+w-tr,y);cv.arc(x+w-tr,y+tr,tr,Math.PI*1.5,Math.PI*2);'
|
|
1066
|
+
+ 'cv.lineTo(x+w,y+h-br);cv.arc(x+w-br,y+h-br,br,0,Math.PI*0.5);'
|
|
1067
|
+
+ 'cv.lineTo(x+bl,y+h);cv.arc(x+bl,y+h-bl,bl,Math.PI*0.5,Math.PI);'
|
|
1068
|
+
+ 'cv.lineTo(x,y+tl);cv.arc(x+tl,y+tl,tl,Math.PI,Math.PI*1.5);cv.closePath();};')
|
|
1069
|
+
// 出码顺序 = 绘制层序(底 → 顶):绘图区底板 → 网格线/轴线 → 数据标记 → 文字标签
|
|
1070
|
+
const layers = chartPaintLayers(cluster.members, cluster)
|
|
1071
|
+
for (const m of [...layers.backdrop, ...layers.axis, ...layers.marks]) {
|
|
1072
|
+
const r = rect(m)
|
|
1073
|
+
const x = n((r.x || 0) - ox); const y = n((r.y || 0) - oy)
|
|
1074
|
+
const w = n(r.width || 0); const h = n(r.height || 0)
|
|
1075
|
+
const fill = m.style?.fill
|
|
1076
|
+
const border = m.style?.border
|
|
1077
|
+
const opacity = m.style?.opacity ?? 1
|
|
1078
|
+
if (fill && (fill.color || fill.gradient)) {
|
|
1079
|
+
if (fill.gradient && (fill.gradient.stops || []).length) {
|
|
1080
|
+
const stops = fill.gradient.stops
|
|
1081
|
+
out.push(`var g=cv.createLinearGradient(${x},${y},${x},${y + h});`)
|
|
1082
|
+
for (const stop of stops) {
|
|
1083
|
+
out.push(`g.addColorStop(${n(stop.position || 0)},'${chartPaintColor(stop.color, opacity)}');`)
|
|
1084
|
+
}
|
|
1085
|
+
out.push('cv.fillStyle=g;')
|
|
1086
|
+
} else {
|
|
1087
|
+
out.push(`cv.fillStyle='${chartPaintColor(fill.color, opacity)}';`)
|
|
1088
|
+
}
|
|
1089
|
+
const rad = m.style?.radius?.borderRadius
|
|
1090
|
+
const arr = Array.isArray(rad) ? rad.map((v) => n(v) || 0) : [n(rad) || 0, n(rad) || 0, n(rad) || 0, n(rad) || 0]
|
|
1091
|
+
out.push(`rr(${x},${y},${w},${h},${arr[0]},${arr[1]},${arr[2]},${arr[3]});cv.fill();`)
|
|
1092
|
+
} else if (border && border.isBorder) {
|
|
1093
|
+
out.push(`cv.strokeStyle='${chartPaintColor(border.color, opacity)}';`)
|
|
1094
|
+
out.push(`cv.lineWidth=${Math.max(1, n(border.width || r.height || 1))};`)
|
|
1095
|
+
out.push(`cv.beginPath();cv.moveTo(${x},${n(y + h / 2)});cv.lineTo(${n(x + w)},${n(y + h / 2)});cv.stroke();`)
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
for (const t of cluster.texts) {
|
|
1099
|
+
const r = rect(t); const st = ts(t)
|
|
1100
|
+
const align = String(st.align || 'left')
|
|
1101
|
+
const cx = align === 'center' ? n((r.x || 0) - ox + (r.width || 0) / 2)
|
|
1102
|
+
: align === 'right' ? n((r.x || 0) - ox + (r.width || 0)) : n((r.x || 0) - ox)
|
|
1103
|
+
out.push(`cv.fillStyle='${hexToRgba(st.color)}';`)
|
|
1104
|
+
out.push(`cv.font='${Number(st.fontWeight) || 400} ${Number(st.fontSize) || 12}px "PingFang SC","Helvetica Neue",sans-serif';`)
|
|
1105
|
+
out.push(`cv.textAlign='${align === 'center' || align === 'right' ? align : 'left'}';`)
|
|
1106
|
+
out.push("cv.textBaseline='top';")
|
|
1107
|
+
out.push(`cv.fillText(${JSON.stringify(textOf(t))},${cx},${n((r.y || 0) - oy)});`)
|
|
1108
|
+
}
|
|
1109
|
+
return out.join('')
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// 评分仪表盘 Canvas 出码(recipe_snippets/canvas-gauge-score.json 同族:半环灰轨 + 高亮弧 + 中心分数/标签)
|
|
1113
|
+
const SCORE_GAUGE_CANVAS_CODE = "var self = this;\nvar canvas = event.canvas;\nvar ctx = canvas.getContext('2d');\nctx.imageSmoothingEnabled = true;\nctx.imageSmoothingQuality = 'high';\nvar cx = 60;\nvar cy = 60;\nvar r = 56.5;\nfunction toRad(a) { return a * Math.PI / 180; }\nctx.lineWidth = 6;\nctx.lineCap = 'butt';\nctx.strokeStyle = '#F5F5F5';\nctx.beginPath();\nctx.arc(cx, cy, r, toRad(317), toRad(517), false);\nctx.stroke();\nvar score = Number(self.state.score);\nif (isNaN(score)) { score = 0; }\nscore = Math.min(Math.max(score, 0), 100);\nvar fill = score * (160 / 70);\nif (fill > 270) { fill = 270; }\nctx.strokeStyle = '#FCC800';\nctx.beginPath();\nctx.arc(cx, cy, r, toRad(157), toRad(157 + fill), false);\nctx.stroke();\nvar scoreText = String(self.state.score == null ? '' : self.state.score);\nctx.textBaseline = 'alphabetic';\nctx.fillStyle = '#221715';\nctx.font = '500 28px \"Helvetica Neue\", \"PingFang SC\", sans-serif';\nvar wNum = ctx.measureText(scoreText).width;\nctx.font = '500 14px \"PingFang SC\", sans-serif';\nvar wUnit = ctx.measureText('\u5206').width;\nvar startX = cx - (wNum + wUnit + 2) / 2;\nctx.textAlign = 'left';\nctx.font = '500 28px \"Helvetica Neue\", \"PingFang SC\", sans-serif';\nctx.fillText(scoreText, startX, 52);\nctx.font = '500 14px \"PingFang SC\", sans-serif';\nctx.fillText('\u5206', startX + wNum + 2, 52);\nctx.font = '400 12px \"PingFang SC\", sans-serif';\nctx.textAlign = 'center';\nctx.fillStyle = '#575757';\nctx.fillText(String(self.state.scoreLabel || ''), cx, 73);"
|
|
1114
|
+
|
|
755
1115
|
export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
756
1116
|
// 设计稿聚焦图(可选):标注缺陷交叉校验(小 pill 描边环采样等),视觉优先;缺省跳过校验。
|
|
757
1117
|
const _designImage = opts.designImage || null
|
|
758
1118
|
const card = (spec.cards || [])[Number(cardIndex) || 0] || {}
|
|
759
1119
|
const all = (card.elements || []).length ? card.elements : (spec.elements || [])
|
|
760
|
-
|
|
1120
|
+
let content = all.filter((e) => !isBg(e))
|
|
1121
|
+
// 图表矢量簇(柱+网格线+轴/值标签)先整体摘出:下游无图表消费通道,不摘出会被静默丢弃/错排
|
|
1122
|
+
const chartClusters = detectChartClusters(content)
|
|
1123
|
+
if (chartClusters.length) {
|
|
1124
|
+
const absorbed = new Set(chartClusters.flatMap((c) => [...c.members, ...c.texts]))
|
|
1125
|
+
content = content.filter((e) => !absorbed.has(e))
|
|
1126
|
+
}
|
|
761
1127
|
const rawTexts = content
|
|
762
1128
|
.filter((e) => e.type === 'text' && textOf(e))
|
|
763
1129
|
.sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
|
|
@@ -1034,10 +1400,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
1034
1400
|
return !memberTexts.has(text) && bottom <= top && top - bottom <= 48
|
|
1035
1401
|
&& (tr.x || 0) >= left - 24 && (tr.x || 0) <= left + 24
|
|
1036
1402
|
&& (ts(text).fontSize || 0) >= 14
|
|
1037
|
-
}).sort((a, b) => (
|
|
1038
|
-
(top - (rect(b).y || 0) - (rect(b).height || 0)) -
|
|
1039
|
-
(top - (rect(a).y || 0) - (rect(a).height || 0))
|
|
1040
|
-
))
|
|
1403
|
+
}).sort((a, b) => (rect(b).y || 0) - (rect(a).y || 0))
|
|
1041
1404
|
const labelEl = labelCands[0] || null
|
|
1042
1405
|
const labelStyle = labelEl ? ts(labelEl) : null
|
|
1043
1406
|
groups.push({
|
|
@@ -1735,8 +2098,18 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
1735
2098
|
for (const [, group] of labelYGroups) {
|
|
1736
2099
|
if (group.length >= 2) group.forEach((lab) => parallelLabels.add(lab))
|
|
1737
2100
|
}
|
|
2101
|
+
// 与主金额标签同带的并列汇总列(总支出/总收入)不是副金额:留给面板通道整带渲染,
|
|
2102
|
+
// 否则次列被副金额通道甩到卡底(汇总区只剩单列)。
|
|
2103
|
+
const primaryLabelEl = primaryAmountEl ? texts.find((t2) => {
|
|
2104
|
+
const r2 = rect(t2)
|
|
2105
|
+
const dy2 = (primaryRect.y || 0) - ((r2.y || 0) + (r2.height || 0))
|
|
2106
|
+
return dy2 >= -6 && dy2 <= 40 && Math.abs((r2.x || 0) - (primaryRect.x || 0)) <= 40
|
|
2107
|
+
}) || null : null
|
|
1738
2108
|
for (const lab of labels) {
|
|
1739
2109
|
if (!parallelLabels.has(lab)) continue
|
|
2110
|
+
if (primaryLabelEl && Math.abs((rect(lab).y || 0) - (rect(primaryLabelEl).y || 0)) <= 12) continue
|
|
2111
|
+
// 副金额列标签必须位于主金额值之下:与主值同带的是并列汇总列(总支出/总收入),归面板通道。
|
|
2112
|
+
if (primaryRect && (rect(lab).y || 0) <= (primaryRect.y || 0) + (primaryRect.height || 0)) continue
|
|
1740
2113
|
const labR = rect(lab)
|
|
1741
2114
|
const valCands = texts.filter((t) => {
|
|
1742
2115
|
if (isPrimaryFragment(t)) return false
|
|
@@ -1941,6 +2314,24 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
1941
2314
|
const group = clusters[0].sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
|
|
1942
2315
|
const grpY = rect(group[0]).y || 0
|
|
1943
2316
|
const logoSlice = slices.find((s) => Math.abs((rect(s).y || 0) - grpY) < 15 && (rect(s).x || 0) < rect(group[0]).x)
|
|
2317
|
+
// 标签簇下方同列数值行(并行汇总双列「总支出/总收入」下一行的大字号数值):
|
|
2318
|
+
// 每个标签 x±24、位于标签带底沿之下 24-56px、含数字 → 归为该簇的值(随带整块渲染)。
|
|
2319
|
+
const grpBottom = Math.max(...group.map((t) => (rect(t).y || 0) + (rect(t).height || 0)))
|
|
2320
|
+
const clusterValues = group.map((t) => {
|
|
2321
|
+
const tr = rect(t)
|
|
2322
|
+
const cand = texts.find((v) => {
|
|
2323
|
+
if (v === t || group.includes(v)) return false
|
|
2324
|
+
const vr = rect(v)
|
|
2325
|
+
const dy = (vr.y || 0) - grpBottom
|
|
2326
|
+
return /\d/.test(textOf(v)) && dy >= 8 && dy <= 56
|
|
2327
|
+
&& Math.abs((vr.x || 0) - (tr.x || 0)) <= 24 && (vr.width || 0) <= 160
|
|
2328
|
+
})
|
|
2329
|
+
return cand ? {
|
|
2330
|
+
text: textOf(cand), color: ts(cand).color, fontSize: ts(cand).fontSize,
|
|
2331
|
+
fontWeight: ts(cand).fontWeight, x: rect(cand).x || 0, y: rect(cand).y || 0,
|
|
2332
|
+
width: rect(cand).width || 0, height: rect(cand).height || 0,
|
|
2333
|
+
} : null
|
|
2334
|
+
})
|
|
1944
2335
|
return {
|
|
1945
2336
|
logoSlice: logoSlice || null,
|
|
1946
2337
|
items: group.map((t) => ({
|
|
@@ -1948,6 +2339,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
1948
2339
|
fontWeight: ts(t).fontWeight, x: rect(t).x || 0, width: rect(t).width || 0,
|
|
1949
2340
|
y: rect(t).y || 0, height: rect(t).height || 0,
|
|
1950
2341
|
})),
|
|
2342
|
+
values: clusterValues.some(Boolean) ? clusterValues : null,
|
|
1951
2343
|
y: grpY,
|
|
1952
2344
|
}
|
|
1953
2345
|
})()
|
|
@@ -2003,6 +2395,10 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2003
2395
|
const radius = Number(element.style?.radius?.borderRadius)
|
|
2004
2396
|
if (element.type !== 'shape' || !border.isBorder || !(radius >= 4)) return false
|
|
2005
2397
|
const sr = rect(element)
|
|
2398
|
+
// 卡壳自排除:覆盖整卡的描边圆角壳是卡片本体(白描边在浅底上不可见),
|
|
2399
|
+
// 认成段落描边 pill 会在卡中部画出一圈可见圆角框(大段落文本曾把 2×h+20 上限撑穿)。
|
|
2400
|
+
const cR = rect(card)
|
|
2401
|
+
if ((sr.width || 0) >= (cR.width || 0) * 0.9 && (sr.height || 0) >= (cR.height || 0) * 0.9) return false
|
|
2006
2402
|
return (tr.x || 0) >= (sr.x || 0) - 2 && (tr.y || 0) >= (sr.y || 0) - 2
|
|
2007
2403
|
&& ((tr.x || 0) + (tr.width || 0)) <= ((sr.x || 0) + (sr.width || 0)) + 2
|
|
2008
2404
|
&& ((tr.y || 0) + (tr.height || 0)) <= ((sr.y || 0) + (sr.height || 0)) + 2
|
|
@@ -2053,9 +2449,26 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2053
2449
|
const textCenter = (tr.y || 0) + (tr.height || 0) / 2
|
|
2054
2450
|
const gapLeft = (tr.x || 0) - ((sr.x || 0) + (sr.width || 0))
|
|
2055
2451
|
const gapRight = (sr.x || 0) - ((tr.x || 0) + (tr.width || 0))
|
|
2056
|
-
|
|
2452
|
+
// 竖直容差随切片高度放宽:大尺寸主视觉(吉祥物/头像)与多行文本的中心差可达
|
|
2453
|
+
// 十几 px,固定 8px 只认小图标 → 主视觉掉进孤立媒体块单独成行(卡头被拆两行)。
|
|
2454
|
+
const vTol = Math.max(8, Math.min((sr.height || 0) * 0.35, (tr.height || 0) * 0.6))
|
|
2455
|
+
const vOverlap = Math.min((sr.y || 0) + (sr.height || 0), (tr.y || 0) + (tr.height || 0))
|
|
2456
|
+
- Math.max(sr.y || 0, tr.y || 0)
|
|
2457
|
+
return Math.abs(sliceCenter - textCenter) <= vTol && vOverlap > 0
|
|
2057
2458
|
&& ((gapLeft >= -4 && gapLeft <= 24) || (gapRight >= -4 && gapRight <= 24))
|
|
2058
2459
|
}) || null,
|
|
2460
|
+
decoSliceEl: slices.find((slice) => {
|
|
2461
|
+
const sr = rect(slice)
|
|
2462
|
+
const tr = rect(t)
|
|
2463
|
+
const sliceCenterX = (sr.x || 0) + (sr.width || 0) / 2
|
|
2464
|
+
const textCenter = (tr.y || 0) + (tr.height || 0) / 2
|
|
2465
|
+
// 文本底带装饰切片(下划波浪/高亮笔触):水平落在文本宽度内、竖直贴文本下半段
|
|
2466
|
+
// → 随文本行渲染;否则薄切片(高 <20)进不了孤立媒体块被静默丢弃。
|
|
2467
|
+
return (sr.height || 0) >= 4 && (sr.height || 0) <= Math.max(8, (tr.height || 0) * 0.5)
|
|
2468
|
+
&& (sr.width || 0) <= (tr.width || 0)
|
|
2469
|
+
&& (sr.y || 0) >= textCenter && (sr.y || 0) <= (tr.y || 0) + (tr.height || 0) + 8
|
|
2470
|
+
&& sliceCenterX >= (tr.x || 0) && sliceCenterX <= (tr.x || 0) + (tr.width || 0)
|
|
2471
|
+
}) || null,
|
|
2059
2472
|
backgroundShell: backgroundShellFor(t),
|
|
2060
2473
|
outlineShell: outlineShellFor(t),
|
|
2061
2474
|
highlightShell: highlightShellFor(t),
|
|
@@ -2100,6 +2513,111 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2100
2513
|
const primaryButtonShapes = buttonShapes.filter((b) => (rect(b).width || 0) >= 80)
|
|
2101
2514
|
const btnBottom = primaryButtonShapes.length ? Math.max(...primaryButtonShapes.map((b) => (rect(b).y || 0) + (rect(b).height || 0))) : 9999
|
|
2102
2515
|
const formBandTop = mainBottom + 6
|
|
2516
|
+
// —— 对比面板组预抽取(并排小卡:壳内左对齐「标签行 + 值行」,可带圆点/状态 tag)——
|
|
2517
|
+
// 通用「label + 同行右簇 value」配对会跨壳把并排面板的两条标签配成一行
|
|
2518
|
+
// (持仓比例 ↔ 建议比例)、值行同理;壳底色/圆角/圆点/状态 tag 也全部丢失。
|
|
2519
|
+
// 故先按壳几何把面板行摘出(每壳一行)并消费其文本,禁止通用配对再碰。
|
|
2520
|
+
const panelShellCands = shapes.filter((s2) => {
|
|
2521
|
+
const f2 = s2.style?.fill
|
|
2522
|
+
if (!f2 || (!f2.color && !f2.gradient)) return false
|
|
2523
|
+
if (radiusMax(s2.style) < 4) return false
|
|
2524
|
+
const r2 = rect(s2)
|
|
2525
|
+
const w2 = r2.width || 0; const h2 = r2.height || 0
|
|
2526
|
+
if (w2 < 100 || w2 > 260 || h2 < 48 || h2 > 120) return false
|
|
2527
|
+
if ((r2.y || 0) <= formBandTop || (r2.y || 0) >= btnBottom) return false
|
|
2528
|
+
const inside = texts.filter((t2) => rectInside(r2, rect(t2), 0))
|
|
2529
|
+
if (inside.length < 2) return false
|
|
2530
|
+
const lineKeys = new Set(inside.map((t2) => Math.round((rect(t2).y || 0) / 6)))
|
|
2531
|
+
return lineKeys.size >= 2
|
|
2532
|
+
})
|
|
2533
|
+
// 同位双层标注(编组+矩形)与内层装饰壳去重:保留最外层且有实体填充色的 shape
|
|
2534
|
+
const panelShells = panelShellCands.filter((a) => {
|
|
2535
|
+
const ra = rect(a)
|
|
2536
|
+
const aSolid = !!(a.style?.fill?.color)
|
|
2537
|
+
return !panelShellCands.some((b) => {
|
|
2538
|
+
if (b === a) return false
|
|
2539
|
+
const rb = rect(b)
|
|
2540
|
+
if (!rectInside(rb, ra, 1)) return false
|
|
2541
|
+
const sameRect = Math.abs((rb.width || 0) - (ra.width || 0)) <= 2
|
|
2542
|
+
&& Math.abs((rb.height || 0) - (ra.height || 0)) <= 2
|
|
2543
|
+
if (!sameRect) return true
|
|
2544
|
+
const bSolid = !!(b.style?.fill?.color)
|
|
2545
|
+
return !aSolid || (bSolid && panelShellCands.indexOf(b) < panelShellCands.indexOf(a))
|
|
2546
|
+
})
|
|
2547
|
+
})
|
|
2548
|
+
for (const shellEl of panelShells) {
|
|
2549
|
+
const sr2 = rect(shellEl)
|
|
2550
|
+
const insideTexts = texts
|
|
2551
|
+
.filter((t2) => !formConsumed.has(t2) && !enumStatConsumed.has(t2) && rectInside(sr2, rect(t2), 0))
|
|
2552
|
+
.sort((p, q) => (rect(p).y || 0) - (rect(q).y || 0))
|
|
2553
|
+
if (insideTexts.length < 2) continue
|
|
2554
|
+
const labEl = insideTexts[0]
|
|
2555
|
+
const labR = rect(labEl)
|
|
2556
|
+
const restLines = insideTexts.slice(1)
|
|
2557
|
+
const valEl = restLines[0]
|
|
2558
|
+
const valR = rect(valEl)
|
|
2559
|
+
const tagCand = restLines.find((t2) => t2 !== valEl
|
|
2560
|
+
&& (rect(t2).width || 0) <= 60 && (rect(t2).height || 0) <= 24
|
|
2561
|
+
&& (rect(t2).x || 0) > (valR.x || 0) + (valR.width || 0) - 2
|
|
2562
|
+
&& Math.abs((rect(t2).y || 0) - (valR.y || 0)) <= 12) || null
|
|
2563
|
+
const tagShapeOf = (t2) => shapes.find((s3) => {
|
|
2564
|
+
const r3 = rect(s3); const tr3 = rect(t2)
|
|
2565
|
+
return (r3.width || 0) >= (tr3.width || 0) && (r3.width || 0) <= (tr3.width || 0) + 16
|
|
2566
|
+
&& (r3.height || 0) >= (tr3.height || 0) && (r3.height || 0) <= (tr3.height || 0) + 12
|
|
2567
|
+
&& Math.abs((r3.x || 0) - (tr3.x || 0)) <= 8 && Math.abs((r3.y || 0) - (tr3.y || 0)) <= 8
|
|
2568
|
+
}) || null
|
|
2569
|
+
const tagShapeEl = tagCand ? tagShapeOf(tagCand) : null
|
|
2570
|
+
// 无壳短文本按子值处理(第二值),有壳才是状态 tag
|
|
2571
|
+
const tagEl = tagShapeEl ? tagCand : null
|
|
2572
|
+
const subEls2 = restLines.filter((t2) => t2 !== valEl && t2 !== tagEl)
|
|
2573
|
+
const dotEl = shapes.find((s3) => {
|
|
2574
|
+
const f3 = s3.style?.fill
|
|
2575
|
+
if (!f3 || !f3.color) return false
|
|
2576
|
+
const r3 = rect(s3)
|
|
2577
|
+
const w3 = r3.width || 0; const h3 = r3.height || 0
|
|
2578
|
+
if (w3 < 6 || w3 > 14 || h3 < 6 || h3 > 14) return false
|
|
2579
|
+
return rectInside(sr2, r3, 0)
|
|
2580
|
+
&& (r3.x || 0) + w3 <= (labR.x || 0)
|
|
2581
|
+
&& Math.abs(((r3.y || 0) + h3 / 2) - ((labR.y || 0) + (labR.height || 0) / 2)) <= 12
|
|
2582
|
+
}) || null
|
|
2583
|
+
formRows.push({
|
|
2584
|
+
panelShell: {
|
|
2585
|
+
x: sr2.x || 0, y: sr2.y || 0, width: sr2.width || 0, height: sr2.height || 0,
|
|
2586
|
+
fill: shellEl.style?.fill?.color || null,
|
|
2587
|
+
gradient: shellEl.style?.fill?.gradient || null,
|
|
2588
|
+
radius: radiusMax(shellEl.style),
|
|
2589
|
+
opacity: shellEl.style?.opacity ?? 1,
|
|
2590
|
+
},
|
|
2591
|
+
dot: dotEl ? {
|
|
2592
|
+
color: dotEl.style.fill.color,
|
|
2593
|
+
x: rect(dotEl).x || 0, y: rect(dotEl).y || 0,
|
|
2594
|
+
w: Math.round(rect(dotEl).width || 8), h: Math.round(rect(dotEl).height || 8),
|
|
2595
|
+
gap: Math.max(0, Math.round((labR.x || 0) - ((rect(dotEl).x || 0) + (rect(dotEl).width || 0)))),
|
|
2596
|
+
} : null,
|
|
2597
|
+
tag: tagEl ? {
|
|
2598
|
+
text: textOf(tagEl), color: ts(tagEl).color, fontSize: ts(tagEl).fontSize,
|
|
2599
|
+
borderColor: tagShapeEl.style?.border?.isBorder ? tagShapeEl.style.border.color : null,
|
|
2600
|
+
fill: tagShapeEl.style?.fill?.color || null,
|
|
2601
|
+
radius: radiusMax(tagShapeEl.style),
|
|
2602
|
+
w: Math.round(rect(tagShapeEl).width || 24),
|
|
2603
|
+
h: Math.round(rect(tagShapeEl).height || 14),
|
|
2604
|
+
right: Math.round((rect(tagShapeEl).x || 0) + (rect(tagShapeEl).width || 0)),
|
|
2605
|
+
} : null,
|
|
2606
|
+
label: { text: textOf(labEl), color: ts(labEl).color, fontSize: ts(labEl).fontSize, fontWeight: ts(labEl).fontWeight, x: labR.x || 0, y: labR.y || 0, width: labR.width || 0, height: labR.height || (ts(labEl).fontSize || 14) * 1.4 },
|
|
2607
|
+
leftIconSlice: null,
|
|
2608
|
+
value: { text: textOf(valEl), color: ts(valEl).color, fontSize: ts(valEl).fontSize, fontWeight: ts(valEl).fontWeight, x: valR.x || 0, y: valR.y || 0, width: valR.width || 0, height: valR.height || (ts(valEl).fontSize || 14) * 1.4 },
|
|
2609
|
+
subValues: subEls2.map((t2) => ({ text: textOf(t2), color: ts(t2).color, fontSize: ts(t2).fontSize, fontWeight: ts(t2).fontWeight, x: rect(t2).x || 0, y: rect(t2).y || 0, width: rect(t2).width || 0, height: rect(t2).height || (ts(t2).fontSize || 14) * 1.4 })),
|
|
2610
|
+
rightAffordanceSlice: null,
|
|
2611
|
+
isPlaceholder: false,
|
|
2612
|
+
labelEl: labEl,
|
|
2613
|
+
valueEl: valEl,
|
|
2614
|
+
subEls: subEls2,
|
|
2615
|
+
})
|
|
2616
|
+
formConsumed.add(labEl)
|
|
2617
|
+
formConsumed.add(valEl)
|
|
2618
|
+
if (tagEl) formConsumed.add(tagEl)
|
|
2619
|
+
subEls2.forEach((t2) => formConsumed.add(t2))
|
|
2620
|
+
}
|
|
2103
2621
|
// 候选左标签:表单带内、左锚定(x < 120)的文本;枚举统计行文本已排除;
|
|
2104
2622
|
// 另排除「大字号纯数字」头部主数字(如未读消息数 12)——标题区数字块不是表单 label。
|
|
2105
2623
|
const labelCands = texts.filter((t) => {
|
|
@@ -2238,7 +2756,71 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2238
2756
|
&& (sr.x || 0) + sw <= lx + 2 && (sr.x || 0) >= (rect(card).x || 0) - 4
|
|
2239
2757
|
&& Math.abs(vCenter - lCenter) <= 12
|
|
2240
2758
|
}) || null
|
|
2759
|
+
// 对比面板壳/圆点/行内状态 tag:壳承行底色/圆角/高度与内缩,圆点进 label 左簇,tag 随值行
|
|
2760
|
+
const labRect0 = rect(lab)
|
|
2761
|
+
const labCenter0 = { x: (labRect0.x || 0) + (labRect0.width || 0) / 2, y: (labRect0.y || 0) + (labRect0.height || 0) / 2 }
|
|
2762
|
+
const panelShellEl = shapes.find((s2) => {
|
|
2763
|
+
const f2 = s2.style?.fill
|
|
2764
|
+
if (!f2 || (!f2.color && !f2.gradient)) return false
|
|
2765
|
+
if (radiusMax(s2.style) < 4) return false
|
|
2766
|
+
const r2 = rect(s2)
|
|
2767
|
+
const w2 = r2.width || 0; const h2 = r2.height || 0
|
|
2768
|
+
if (w2 < 100 || w2 > 260 || h2 < 48 || h2 > 120) return false
|
|
2769
|
+
return labCenter0.x >= (r2.x || 0) && labCenter0.x <= (r2.x || 0) + w2
|
|
2770
|
+
&& labCenter0.y >= (r2.y || 0) && labCenter0.y <= (r2.y || 0) + h2
|
|
2771
|
+
}) || null
|
|
2772
|
+
const dotEl = panelShellEl ? shapes.find((s2) => {
|
|
2773
|
+
const f2 = s2.style?.fill
|
|
2774
|
+
if (!f2 || !f2.color) return false
|
|
2775
|
+
const r2 = rect(s2)
|
|
2776
|
+
const w2 = r2.width || 0; const h2 = r2.height || 0
|
|
2777
|
+
if (w2 < 6 || w2 > 14 || h2 < 6 || h2 > 14) return false
|
|
2778
|
+
const pr2 = rect(panelShellEl)
|
|
2779
|
+
return (r2.x || 0) >= (pr2.x || 0) && (r2.x || 0) + w2 <= (pr2.x || 0) + (pr2.width || 0)
|
|
2780
|
+
&& (r2.y || 0) >= (pr2.y || 0) && (r2.y || 0) + h2 <= (pr2.y || 0) + (pr2.height || 0)
|
|
2781
|
+
&& (r2.x || 0) + w2 <= (labRect0.x || 0)
|
|
2782
|
+
&& Math.abs(((r2.y || 0) + h2 / 2) - labCenter0.y) <= 12
|
|
2783
|
+
}) || null : null
|
|
2784
|
+
const tagTextEl = panelShellEl ? texts.find((t2) => {
|
|
2785
|
+
if (t2 === lab || t2 === valueEl || subEls.includes(t2)) return false
|
|
2786
|
+
const r2 = rect(t2)
|
|
2787
|
+
const w2 = r2.width || 0; const h2 = r2.height || 0
|
|
2788
|
+
if (w2 < 8 || w2 > 60 || h2 < 8 || h2 > 24) return false
|
|
2789
|
+
const pr2 = rect(panelShellEl)
|
|
2790
|
+
return (r2.x || 0) >= (pr2.x || 0) && (r2.x || 0) + w2 <= (pr2.x || 0) + (pr2.width || 0)
|
|
2791
|
+
&& (r2.y || 0) >= (pr2.y || 0) && (r2.y || 0) + h2 <= (pr2.y || 0) + (pr2.height || 0)
|
|
2792
|
+
}) || null : null
|
|
2793
|
+
const tagShapeEl = tagTextEl ? shapes.find((s2) => {
|
|
2794
|
+
const r2 = rect(s2)
|
|
2795
|
+
const tr2 = rect(tagTextEl)
|
|
2796
|
+
return (r2.width || 0) >= (tr2.width || 0) && (r2.width || 0) <= (tr2.width || 0) + 16
|
|
2797
|
+
&& (r2.height || 0) >= (tr2.height || 0) && (r2.height || 0) <= (tr2.height || 0) + 12
|
|
2798
|
+
&& Math.abs((r2.x || 0) - (tr2.x || 0)) <= 8 && Math.abs((r2.y || 0) - (tr2.y || 0)) <= 8
|
|
2799
|
+
}) || null : null
|
|
2241
2800
|
formRows.push({
|
|
2801
|
+
panelShell: panelShellEl ? {
|
|
2802
|
+
x: rect(panelShellEl).x || 0, y: rect(panelShellEl).y || 0,
|
|
2803
|
+
width: rect(panelShellEl).width || 0, height: rect(panelShellEl).height || 0,
|
|
2804
|
+
fill: panelShellEl.style?.fill?.color || null,
|
|
2805
|
+
gradient: panelShellEl.style?.fill?.gradient || null,
|
|
2806
|
+
radius: radiusMax(panelShellEl.style),
|
|
2807
|
+
opacity: panelShellEl.style?.opacity ?? 1,
|
|
2808
|
+
} : null,
|
|
2809
|
+
dot: dotEl ? {
|
|
2810
|
+
color: dotEl.style.fill.color,
|
|
2811
|
+
x: rect(dotEl).x || 0, y: rect(dotEl).y || 0,
|
|
2812
|
+
w: Math.round(rect(dotEl).width || 8), h: Math.round(rect(dotEl).height || 8),
|
|
2813
|
+
gap: Math.max(0, Math.round((labRect0.x || 0) - ((rect(dotEl).x || 0) + (rect(dotEl).width || 0)))),
|
|
2814
|
+
} : null,
|
|
2815
|
+
tag: tagTextEl ? {
|
|
2816
|
+
text: textOf(tagTextEl), color: ts(tagTextEl).color, fontSize: ts(tagTextEl).fontSize,
|
|
2817
|
+
borderColor: tagShapeEl && tagShapeEl.style?.border?.isBorder ? tagShapeEl.style.border.color : null,
|
|
2818
|
+
fill: tagShapeEl ? (tagShapeEl.style?.fill?.color || null) : null,
|
|
2819
|
+
radius: tagShapeEl ? radiusMax(tagShapeEl.style) : 2,
|
|
2820
|
+
w: Math.round(rect(tagShapeEl || tagTextEl).width || 24),
|
|
2821
|
+
h: Math.round(rect(tagShapeEl || tagTextEl).height || 14),
|
|
2822
|
+
right: Math.round((rect(tagShapeEl || tagTextEl).x || 0) + (rect(tagShapeEl || tagTextEl).width || 0)),
|
|
2823
|
+
} : null,
|
|
2242
2824
|
label: { text: textOf(lab), color: ts(lab).color, fontSize: ts(lab).fontSize, fontWeight: ts(lab).fontWeight, x: lx, y: ly, width: rect(lab).width || 0, height: rect(lab).height || (ts(lab).fontSize || 14) * 1.4 },
|
|
2243
2825
|
leftIconSlice,
|
|
2244
2826
|
value: valueEl ? { text: textOf(valueEl), color: ts(valueEl).color, fontSize: ts(valueEl).fontSize, fontWeight: ts(valueEl).fontWeight, x: rect(valueEl).x || 0, y: rect(valueEl).y || 0, width: rect(valueEl).width || 0, height: rect(valueEl).height || (ts(valueEl).fontSize || 14) * 1.4 } : null,
|
|
@@ -2337,6 +2919,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2337
2919
|
return w >= 10 && w <= 28 && h >= 10 && h <= 28
|
|
2338
2920
|
})
|
|
2339
2921
|
for (const row of formRows) {
|
|
2922
|
+
if (row.panelShell) continue
|
|
2340
2923
|
if (row.trailingIconSlice || row.rightAffordanceSlice || row.rightActionButton) continue
|
|
2341
2924
|
const anchor = row.value
|
|
2342
2925
|
if (!anchor) continue
|
|
@@ -2356,6 +2939,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2356
2939
|
const claimed = new Set(formRows.flatMap((row) =>
|
|
2357
2940
|
[row.rightAffordanceSlice, row.leftIconSlice, row.trailingIconSlice].filter(Boolean)))
|
|
2358
2941
|
for (const row of formRows) {
|
|
2942
|
+
if (row.panelShell) continue
|
|
2359
2943
|
const anchor = row.value
|
|
2360
2944
|
if (!anchor || row.valueLeadSlice) continue
|
|
2361
2945
|
const valueLeft = anchor.x || 0
|
|
@@ -2408,8 +2992,16 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2408
2992
|
// 与选项/面板壳同位的 symbol 是同一视觉的重复标注(如 pill 同时存在 symbol 实例和矩形壳),
|
|
2409
2993
|
// 不剔除会双份渲染(symbol 占位图 + 选项按钮叠在一起)。
|
|
2410
2994
|
const shellCovered = (el) => [...optionShapeSet].some((s) => overlapRatio(el, s) > 0.6)
|
|
2995
|
+
// 行壳重复标注:symbol 与同矩形带填充 shape 共存(shape 承载填充色)→ symbol 侧不得进媒体行,
|
|
2996
|
+
// 否则行内容被占位图认领、列表识别被打散(交易行曾裂成「静态行 + 单行列表」)。
|
|
2997
|
+
const shapeShellDup = (el) => {
|
|
2998
|
+
const er = rect(el)
|
|
2999
|
+
return shapes.some((sh) => Math.abs((rect(sh).x || 0) - (er.x || 0)) <= 2
|
|
3000
|
+
&& Math.abs((rect(sh).y || 0) - (er.y || 0)) <= 2
|
|
3001
|
+
&& Math.abs((rect(sh).width || 0) - (er.width || 0)) <= 2 && Math.abs((rect(sh).height || 0) - (er.height || 0)) <= 2)
|
|
3002
|
+
}
|
|
2411
3003
|
const symbolMediaRows = leafSymbols.filter((symbol) =>
|
|
2412
|
-
!shellCovered(symbol) &&
|
|
3004
|
+
!shellCovered(symbol) && !shapeShellDup(symbol) &&
|
|
2413
3005
|
!buttonShapes.some((button) => overlapRatio(symbol, button) > 0.5) && !(() => {
|
|
2414
3006
|
const sr = rect(symbol)
|
|
2415
3007
|
return (sr.width || 0) >= mediaCardWidth * 0.8 && (sr.height || 0) >= 100
|
|
@@ -2481,12 +3073,53 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2481
3073
|
width: tr.width || 0,
|
|
2482
3074
|
height: tr.height || 0,
|
|
2483
3075
|
trackColor: track.style?.fill?.color,
|
|
3076
|
+
trackEl: track,
|
|
3077
|
+
segmentEls: fills,
|
|
2484
3078
|
segments: fills.map((fill) => ({
|
|
2485
3079
|
ratio: Math.min(1, Math.max(0, (rect(fill).width || 0) / (tr.width || 1))),
|
|
2486
3080
|
color: fill.style?.fill?.color || track.style?.fill?.color,
|
|
2487
3081
|
})),
|
|
2488
3082
|
})
|
|
2489
3083
|
}
|
|
3084
|
+
// 无轨道多段拼接条(如 蓝段+黄段 相邻平铺的比例条):同 y 同高、横向相邻(≤8px)的
|
|
3085
|
+
// bar 形组以「首左沿→末右沿」为轨道合成多段进度条;否则两段被当孤立色块丢弃。
|
|
3086
|
+
const usedBarEls = new Set()
|
|
3087
|
+
for (const bar of progressBars) {
|
|
3088
|
+
if (bar.trackEl) usedBarEls.add(bar.trackEl)
|
|
3089
|
+
for (const el of (bar.segmentEls || [])) usedBarEls.add(el)
|
|
3090
|
+
}
|
|
3091
|
+
const freeBarShapes = barShapes.filter((shape) => !usedBarEls.has(shape))
|
|
3092
|
+
.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
|
|
3093
|
+
const tiledGroups = []
|
|
3094
|
+
for (const shape of freeBarShapes) {
|
|
3095
|
+
const sr = rect(shape)
|
|
3096
|
+
const group = tiledGroups.find((gg) => Math.abs((gg.y || 0) - (sr.y || 0)) <= 2
|
|
3097
|
+
&& Math.abs((gg.h || 0) - (sr.height || 0)) <= 2
|
|
3098
|
+
&& (sr.x || 0) - (gg.right || 0) <= 8 && (sr.x || 0) >= (gg.right || 0) - 2)
|
|
3099
|
+
if (group) {
|
|
3100
|
+
group.members.push(shape)
|
|
3101
|
+
group.right = Math.max(group.right, (sr.x || 0) + (sr.width || 0))
|
|
3102
|
+
} else {
|
|
3103
|
+
tiledGroups.push({ y: sr.y || 0, h: sr.height || 0, x: sr.x || 0,
|
|
3104
|
+
right: (sr.x || 0) + (sr.width || 0), members: [shape] })
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
for (const group of tiledGroups) {
|
|
3108
|
+
if (group.members.length < 2) continue
|
|
3109
|
+
const span = group.right - group.x
|
|
3110
|
+
if (span < layoutCardWidth * 0.4) continue
|
|
3111
|
+
progressBars.push({
|
|
3112
|
+
x: group.x,
|
|
3113
|
+
y: group.y,
|
|
3114
|
+
width: span,
|
|
3115
|
+
height: group.h,
|
|
3116
|
+
trackColor: null,
|
|
3117
|
+
segments: group.members.map((member) => ({
|
|
3118
|
+
ratio: Math.min(1, Math.max(0, (rect(member).width || 0) / (span || 1))),
|
|
3119
|
+
color: member.style?.fill?.color || null,
|
|
3120
|
+
})),
|
|
3121
|
+
})
|
|
3122
|
+
}
|
|
2490
3123
|
|
|
2491
3124
|
// —— 内嵌面板行:外层卡内的几何壳(列表项/入口卡/选择行)必须作为整体渲染。 ——
|
|
2492
3125
|
// 判定只依赖:宽壳 + 圆角 + 内部堆叠文本 + 可选左右媒体;不识别业务文案。
|
|
@@ -2509,12 +3142,38 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2509
3142
|
const containsRepeatBand = repeatRowMode && _repeatBandTops.some((bt) =>
|
|
2510
3143
|
bt >= (sr.y || 0) - 2 && bt <= (sr.y || 0) + (sr.height || 0) + 2)
|
|
2511
3144
|
return !coversCard && !containsRepeatBand
|
|
2512
|
-
&& sr.width >= cardWidth * 0.6 && sr.height
|
|
3145
|
+
&& sr.width >= cardWidth * 0.6 && sr.height <= Math.max(200, cardHeight * 0.5) && radius >= 6
|
|
3146
|
+
// 入口/链接行(高 36-50):单条左齐文本 + 右端小亲和件(箭头/勾选切片或矢量形)。
|
|
3147
|
+
// 高度下限原为 50,44px 白底入口行全部漏判 → 整行底色+文本丢失(像素热区整带)。
|
|
3148
|
+
&& (sr.height >= 50 || (sr.height >= 36 && (() => {
|
|
3149
|
+
const innerTexts = texts.filter((t2) => {
|
|
3150
|
+
const tr2 = rect(t2)
|
|
3151
|
+
return (tr2.x || 0) >= (sr.x || 0) - 2 && (tr2.y || 0) >= (sr.y || 0) - 2
|
|
3152
|
+
&& ((tr2.x || 0) + (tr2.width || 0)) <= (sr.x || 0) + (sr.width || 0) + 2
|
|
3153
|
+
&& ((tr2.y || 0) + (tr2.height || 0)) <= (sr.y || 0) + (sr.height || 0) + 2
|
|
3154
|
+
})
|
|
3155
|
+
if (innerTexts.length !== 1) return false
|
|
3156
|
+
const tr2 = rect(innerTexts[0])
|
|
3157
|
+
const leftAligned = ((tr2.x || 0) - (sr.x || 0)) <= (sr.width || 0) * 0.15
|
|
3158
|
+
const affordance = [...shapes, ...(slices || [])].some((e2) => {
|
|
3159
|
+
if (e2 === shape) return false
|
|
3160
|
+
const er2 = rect(e2)
|
|
3161
|
+
const ew = er2.width || 0
|
|
3162
|
+
const eh = er2.height || 0
|
|
3163
|
+
if (ew < 4 || ew > 32 || eh < 4 || eh > 32) return false
|
|
3164
|
+
return (er2.x || 0) + ew >= (sr.x || 0) + (sr.width || 0) - 28
|
|
3165
|
+
&& Math.abs(((er2.y || 0) + eh / 2) - ((sr.y || 0) + (sr.height || 0) / 2)) <= (sr.height || 0) / 2
|
|
3166
|
+
})
|
|
3167
|
+
return leftAligned && affordance
|
|
3168
|
+
})()))
|
|
2513
3169
|
&& !(ownsPrimaryAmount && sameFillAsCard)
|
|
2514
3170
|
&& !buttonShapes.some((button) => {
|
|
2515
3171
|
// 主尺寸按钮(w>=80,CTA/主操作):壳整体含按钮 → 复合容器(产品卡/CTA 块),
|
|
2516
3172
|
// 交还 主按钮/渐变容器 专用路径(min 面积归一,原语义);小 pill 是行内操作件,
|
|
2517
3173
|
// 落在壳内不排除壳(覆盖率归一,[媒体|文本栈|pill] 复合行仍是面板)。
|
|
3174
|
+
// 壳自身落进 buttonShapes(形状级)但未成为按钮(文本非居中,如入口行)时,不得自排除:
|
|
3175
|
+
// 否则入口行壳永远进不了 panelRows,整行底色+文本丢失。
|
|
3176
|
+
if (button === shape) return false
|
|
2518
3177
|
const isPrimarySized = (rect(button).width || 0) >= 80
|
|
2519
3178
|
return isPrimarySized ? overlapRatio(shape, button) > 0.5 : coverRatio(shape, button) > 0.5
|
|
2520
3179
|
})
|
|
@@ -2538,11 +3197,15 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2538
3197
|
&& ((er.y || 0) + (er.height || 0)) <= bottom + 2
|
|
2539
3198
|
}
|
|
2540
3199
|
const buttonTextEls = new Set(buttons.map((button) => button.sourceEl).filter(Boolean))
|
|
2541
|
-
const paired = texts.filter((text) => !symbolMediaConsumed.has(text) && !subAmountElementSet.has(text)
|
|
3200
|
+
const paired = texts.filter((text) => !symbolMediaConsumed.has(text) && !(subAmountElementSet.has(text) && !inside(text))
|
|
2542
3201
|
&& !optionTextSet.has(text) && !optionLabelSet.has(text)
|
|
2543
3202
|
&& !buttonTextEls.has(text)).filter(inside).sort((a, b) =>
|
|
2544
3203
|
(rect(a).y || 0) - (rect(b).y || 0) || (rect(a).x || 0) - (rect(b).x || 0))
|
|
2545
|
-
|
|
3204
|
+
// 段落壳归富文本通道:壳内含 richBlock 碎片/容器时 panelRows 再认领会把段落链接行
|
|
3205
|
+
// (查看详细建议)抢成面板行插到段落之前,DOM 顺序颠倒且灰底壳重复归属。
|
|
3206
|
+
const richInside = (richBlocks || []).some((blk) => inside(blk.container)
|
|
3207
|
+
|| (blk.fragments || []).some((frag) => inside(frag)))
|
|
3208
|
+
if (!paired.length || richInside) return null
|
|
2546
3209
|
const overlapsExisting = paired.some((t) => {
|
|
2547
3210
|
const value = textOf(t)
|
|
2548
3211
|
return subAmounts.some((item) => item.label === value || item.value === value)
|
|
@@ -2550,7 +3213,8 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2550
3213
|
|| row.value?.text === value
|
|
2551
3214
|
|| (row.subValues || []).some((sub) => sub.text === value))
|
|
2552
3215
|
})
|
|
2553
|
-
const panelSlices = slices.filter(
|
|
3216
|
+
const panelSlices = [...slices, ...symbols.filter((sy) =>
|
|
3217
|
+
!symbolMediaRows.some((row) => row.symbolEl === sy))].filter(inside)
|
|
2554
3218
|
const firstText = paired[0]
|
|
2555
3219
|
const lastText = paired[paired.length - 1]
|
|
2556
3220
|
const leftMedia = panelSlices.find((s) => (rect(s).x || 0) < (rect(firstText).x || 0)) || null
|
|
@@ -2709,6 +3373,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2709
3373
|
const progressShapes = progressBars.flatMap((bar) => [])
|
|
2710
3374
|
const formSliceEls = new Set((formRows || []).flatMap((row) => [row.rightAffordanceSlice, row.leftIconSlice, row.trailingIconSlice, row.valueLeadSlice]).filter(Boolean))
|
|
2711
3375
|
const auxSliceEls = new Set([
|
|
3376
|
+
...auxTexts.map((aux) => aux.decoSliceEl),
|
|
2712
3377
|
...auxTexts.map((aux) => aux.sliceEl),
|
|
2713
3378
|
// 副金额列标签后缀图标(如币种选择器箭头):随标签行渲染,不得落入孤立媒体块
|
|
2714
3379
|
...subAmounts.map((sub) => sub.labelSuffixSlice),
|
|
@@ -2744,6 +3409,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2744
3409
|
const hasInsideContent = (shape) => [...texts, ...slices, ...symbols]
|
|
2745
3410
|
.some((element) => overlapRatio(element, shape) > 0.5)
|
|
2746
3411
|
const isolatedBlocks = [...slices, ...leafSymbols, ...shapes]
|
|
3412
|
+
.filter((el) => !(el.type === 'symbol' && shapeShellDup(el)))
|
|
2747
3413
|
.filter((element) => {
|
|
2748
3414
|
const er = rect(element)
|
|
2749
3415
|
const width = er.width || 0
|
|
@@ -2774,6 +3440,31 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2774
3440
|
}
|
|
2775
3441
|
})
|
|
2776
3442
|
.sort((a, b) => a.y - b.y || a.x - b.x)
|
|
3443
|
+
// 浮动装饰切片回收:isolatedBlocks 先于 orphanSlices 消费切片(卡头月亮等贴缘叠装饰),
|
|
3444
|
+
// 命中「与文本带竖直重叠 + 贴卡缘」者改走绝对定位叠层,不占流式行高。
|
|
3445
|
+
const floatDecos = []
|
|
3446
|
+
{
|
|
3447
|
+
const cRd = rect(card)
|
|
3448
|
+
const isFloatDeco = (el) => {
|
|
3449
|
+
const sr = rect(el)
|
|
3450
|
+
const w = sr.width || 0, h = sr.height || 0
|
|
3451
|
+
if (!w || !h || w > (cRd.width || 375) * 0.5) return false
|
|
3452
|
+
const overlapsText = texts.some((t) => {
|
|
3453
|
+
const tr = rect(t)
|
|
3454
|
+
const ov = Math.min((sr.y || 0) + h, (tr.y || 0) + (tr.height || 0)) - Math.max(sr.y || 0, tr.y || 0)
|
|
3455
|
+
return ov >= h * 0.2 && h >= (tr.height || 0) * 2.2
|
|
3456
|
+
})
|
|
3457
|
+
const edgeAnchored = ((sr.x || 0) + w) >= ((cRd.x || 0) + (cRd.width || 0)) - 24
|
|
3458
|
+
return overlapsText && edgeAnchored
|
|
3459
|
+
}
|
|
3460
|
+
for (let k = isolatedBlocks.length - 1; k >= 0; k -= 1) {
|
|
3461
|
+
const el = isolatedBlocks[k].element
|
|
3462
|
+
if (el && (el.type === 'slice') && isFloatDeco(el)) {
|
|
3463
|
+
floatDecos.push(el)
|
|
3464
|
+
isolatedBlocks.splice(k, 1)
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
2777
3468
|
|
|
2778
3469
|
// 面板/符号媒体行消费文本后,同步回收先生成的 auxTexts,避免同一标注被重复渲染
|
|
2779
3470
|
// (曾仅回收 panelTextConsumed:符号媒体行吞掉的文本又被 auxTexts 渲染一次 → 重影)。
|
|
@@ -2812,6 +3503,32 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2812
3503
|
// 复发护栏(对称版):同一文案在内容区出现 ≥2 次(无论上下方),说明它是重复行内容
|
|
2813
3504
|
// 而非列表标题(名册卡「张三」×3:旧单向护栏漏掉最后一个张三 → 误当标题吞掉整个列表)。
|
|
2814
3505
|
if (_isRepeatedRowText(t)) return false
|
|
3506
|
+
// 金额值不是列表标题:含货币符号或「数字+千分位/小数」的文本是行右金额/主金额,
|
|
3507
|
+
// 误当标题会把真首行抬成 listTitle(首行 desc/amount 无人消费 → 绑定兜底双渲染重影)。
|
|
3508
|
+
if (/\p{Sc}/u.test(textOf(t))) return false
|
|
3509
|
+
if (/^[-+]?[\d.,\s%]+$/.test(textOf(t)) && /\.\d|,\d{3}/.test(textOf(t))) return false
|
|
3510
|
+
// 周期行护栏(结构版):候选下方存在 ≥2 个同列、同字号、同色且等距(±10)的同伴 →
|
|
3511
|
+
// 它是重复行的行标题而非区块标题(交易列表首行被抬成标题会让列表少一行)。
|
|
3512
|
+
// 双向护栏:上方+下方合计 ≥2 个同列、同字号、同色且等距(±12)同伴即判定为周期行
|
|
3513
|
+
// (中间行仅有 1 上 1 下同伴,旧单向护栏漏判把它当标题 → 列表被拦腰截断)。
|
|
3514
|
+
const titlePeersAll = texts.filter((o) => o !== t
|
|
3515
|
+
&& Math.abs((rect(o).x || 0) - (rect(t).x || 0)) <= 2
|
|
3516
|
+
&& (ts(o).fontSize || 0) === (T.fontSize || 0)
|
|
3517
|
+
&& String(ts(o).color || '').toLowerCase() === String(T.color || '').toLowerCase()
|
|
3518
|
+
&& Math.abs((rect(o).y || 0) - y) >= 20 && Math.abs((rect(o).y || 0) - y) <= 240)
|
|
3519
|
+
if (titlePeersAll.length >= 2) {
|
|
3520
|
+
// 步长投票:取最近同伴距离为候选步长,统计匹配该步长整数倍的同伴数;
|
|
3521
|
+
// ≥2 个匹配即判周期行(非周期同伴如汇总标签混入不影响判定)。
|
|
3522
|
+
const peerDys = titlePeersAll.map((o) => (rect(o).y || 0) - y).sort((a, b) => Math.abs(a) - Math.abs(b))
|
|
3523
|
+
const candSteps = [...new Set([Math.abs(peerDys[0]), Math.abs(peerDys[1] || peerDys[0])])].filter((s) => s >= 20)
|
|
3524
|
+
for (const candStep of candSteps) {
|
|
3525
|
+
const matched = peerDys.filter((g) => {
|
|
3526
|
+
const remainder = Math.abs(g) % candStep
|
|
3527
|
+
return Math.min(remainder, candStep - remainder) <= 12
|
|
3528
|
+
}).length
|
|
3529
|
+
if (matched >= 2) return false
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
2815
3532
|
return belowUnconsumed.length >= 2
|
|
2816
3533
|
})
|
|
2817
3534
|
const listTitle = listTitleCands.length ? {
|
|
@@ -2963,6 +3680,30 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
2963
3680
|
textGap: (captionRect.y || 0) - iconBottom,
|
|
2964
3681
|
}
|
|
2965
3682
|
}
|
|
3683
|
+
// 顶部半透明大椭圆装饰(弥散底色):采样硬停色带是矩形平涂,还原不出椭圆曲边,
|
|
3684
|
+
// 像素热带集中在带底沿(实测 y≈106 单带 32%)→ 记 radial-gradient 参数逐颗还原。
|
|
3685
|
+
const topTintBlobs = (() => {
|
|
3686
|
+
const cR = rect(card)
|
|
3687
|
+
return shapes.filter((shape) => {
|
|
3688
|
+
const sr = rect(shape)
|
|
3689
|
+
const opacity = Number(shape.style?.opacity ?? 1)
|
|
3690
|
+
return opacity > 0 && opacity <= 0.5
|
|
3691
|
+
&& (sr.width || 0) >= (cR.width || 0) * 0.4 && (sr.height || 0) >= (cR.height || 0) * 0.4
|
|
3692
|
+
&& (sr.y || 0) <= (cR.y || 0) + 4
|
|
3693
|
+
&& !radiusMax(shape.style)
|
|
3694
|
+
}).map((shape) => {
|
|
3695
|
+
const sr = rect(shape)
|
|
3696
|
+
return {
|
|
3697
|
+
cx: Math.round((sr.x || 0) + (sr.width || 0) / 2 - (cR.x || 0)),
|
|
3698
|
+
cy: Math.round((sr.y || 0) + (sr.height || 0) / 2 - (cR.y || 0)),
|
|
3699
|
+
rx: Math.round((sr.width || 0) / 2),
|
|
3700
|
+
ry: Math.round((sr.height || 0) / 2),
|
|
3701
|
+
// 不透明卡身壳罩在色块之上(磨砂玻璃壳):设计稿里色块被白壳罩淡一层,
|
|
3702
|
+
// 直接按标注 alpha 平涂会过浓(实测像素差 +5.6pp)→ 罩淡系数 0.5 校准。
|
|
3703
|
+
rgba: hexToRgba(shape.style?.fill?.color || '#FFFFFF', Number(shape.style?.opacity ?? 1) * 0.5),
|
|
3704
|
+
}
|
|
3705
|
+
})
|
|
3706
|
+
})()
|
|
2966
3707
|
const topDecoration = (() => {
|
|
2967
3708
|
const cr = rect(card)
|
|
2968
3709
|
const cardTop = cr.y || 0
|
|
@@ -3117,7 +3858,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3117
3858
|
const rowsBelow = (listTitle
|
|
3118
3859
|
? texts.filter((t) => (rect(t).y || 0) > listTitleY)
|
|
3119
3860
|
: texts.filter((t) => (rect(t).y || 0) > mainBottom
|
|
3120
|
-
&& !
|
|
3861
|
+
&& !panelTextConsumed.has(t)
|
|
3121
3862
|
&& !auxConsumedEls.has(t) && !subAmountEls.has(t)
|
|
3122
3863
|
&& !buttons.some((button) => button.sourceEl === t)))
|
|
3123
3864
|
.filter((t) => !optionTextSet.has(t) && !optionLabelSet.has(t))
|
|
@@ -3177,9 +3918,14 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3177
3918
|
// 描述 = 标题正下方 18-36px 的同列文本(上下两行、左对齐:列表项特征)
|
|
3178
3919
|
// 描述行 = 标题正下方 18-36px 且与标题同列(左沿最近)的文本;
|
|
3179
3920
|
// 右侧时间行与描述同 y 时按元素序会抢先命中,把时间当描述、真描述被甩到列表外。
|
|
3180
|
-
|
|
3921
|
+
let descEl = rowTexts
|
|
3181
3922
|
.filter((r) => {
|
|
3182
3923
|
const ry = rect(r).y || 0
|
|
3924
|
+
// 与标题同列、同字号、同色的「描述」其实是下一条等距单行(选项/清单行):
|
|
3925
|
+
// 真描述通常更小/更灰或缩进。误认会把下一行吞成描述、再下一行被甩出列表丢失。
|
|
3926
|
+
if (Math.abs((rect(r).x || 0) - (rect(t).x || 0)) <= 2
|
|
3927
|
+
&& (ts(r).fontSize || 0) === (ts(t).fontSize || 0)
|
|
3928
|
+
&& String(ts(r).color || '').toLowerCase() === String(ts(t).color || '').toLowerCase()) return false
|
|
3183
3929
|
return ry - y >= 18 && ry - y <= 36
|
|
3184
3930
|
})
|
|
3185
3931
|
.sort((a, b) => Math.abs((rect(a).x || 0) - (rect(t).x || 0))
|
|
@@ -3210,6 +3956,57 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3210
3956
|
}
|
|
3211
3957
|
if (pairs.length >= 2) {
|
|
3212
3958
|
compositePairs = pairs.slice(0, 4)
|
|
3959
|
+
// 标签行内联 tag 壳(币种选择 pill 类):贴标签右沿的小填充/描边壳 + 壳内短文本/小切片。
|
|
3960
|
+
// 逐行数据驱动($amtTagN/$amtTagIconN),无 tag 的行 v-show 隐藏,避免静态模板每行滥发。
|
|
3961
|
+
for (const pp of compositePairs) {
|
|
3962
|
+
const lr2 = rect(pp.labelEl)
|
|
3963
|
+
const labelCY = (lr2.y || 0) + (lr2.height || 0) / 2
|
|
3964
|
+
const shell = (shapes || []).find((sh) => {
|
|
3965
|
+
const r = rect(sh)
|
|
3966
|
+
const w = r.width || 0
|
|
3967
|
+
const h = r.height || 0
|
|
3968
|
+
if (w < 28 || w > 120 || h < 12 || h > 28) return false
|
|
3969
|
+
if (!sh.style?.fill?.color && !sh.style?.border?.isBorder) return false
|
|
3970
|
+
if (Math.abs(((r.y || 0) + h / 2) - labelCY) > 8) return false
|
|
3971
|
+
const gx = (r.x || 0) - ((lr2.x || 0) + (lr2.width || 0))
|
|
3972
|
+
return gx >= -2 && gx <= 16
|
|
3973
|
+
}) || null
|
|
3974
|
+
pp.tagShapeEl = shell
|
|
3975
|
+
if (!shell) continue
|
|
3976
|
+
const sr = rect(shell)
|
|
3977
|
+
const inside = (e) => {
|
|
3978
|
+
const er = rect(e)
|
|
3979
|
+
return (er.x || 0) >= (sr.x || 0) - 2 && (er.y || 0) >= (sr.y || 0) - 2
|
|
3980
|
+
&& (er.x || 0) + (er.width || 0) <= (sr.x || 0) + (sr.width || 0) + 2
|
|
3981
|
+
&& (er.y || 0) + (er.height || 0) <= (sr.y || 0) + (sr.height || 0) + 2
|
|
3982
|
+
}
|
|
3983
|
+
const tagTextEl = texts.find((tt2) => inside(tt2)) || null
|
|
3984
|
+
const tagIconEl = (slices || []).find((sl) => inside(sl)) || null
|
|
3985
|
+
const tagExp = tagIconEl
|
|
3986
|
+
? (resolveSliceExport(spec.slices || [], tagIconEl.name) || tagIconEl.annotationDefect?.cropSlice)
|
|
3987
|
+
: null
|
|
3988
|
+
pp.tagText = tagTextEl ? textOf(tagTextEl) : ''
|
|
3989
|
+
pp.tagTextColor = tagTextEl ? ts(tagTextEl).color : undefined
|
|
3990
|
+
pp.tagFontSize = tagTextEl ? ts(tagTextEl).fontSize : undefined
|
|
3991
|
+
pp.tagIcon = tagExp ? sliceRefPath(tagExp) : ''
|
|
3992
|
+
pp.tagFill = shell.style?.fill?.color || null
|
|
3993
|
+
pp.tagOpacity = shell.style?.opacity ?? 1
|
|
3994
|
+
pp.tagBorder = shell.style?.border?.isBorder ? (shell.style?.border?.color || null) : null
|
|
3995
|
+
pp.tagRadius = shell.style?.radius?.borderRadius || 0
|
|
3996
|
+
pp.tagW = Math.round(sr.width || 0)
|
|
3997
|
+
pp.tagH = Math.round(sr.height || 0)
|
|
3998
|
+
pp.tagGap = Math.max(2, Math.round((sr.x || 0) - ((lr2.x || 0) + (lr2.width || 0))))
|
|
3999
|
+
pp.tagPadLeft = Math.max(0, Math.round(((tagTextEl ? rect(tagTextEl).x : sr.x) || 0) - (sr.x || 0)))
|
|
4000
|
+
pp.tagPadRight = Math.max(0, Math.round(((sr.x || 0) + (sr.width || 0))
|
|
4001
|
+
- ((tagIconEl ? (rect(tagIconEl).x || 0) + (rect(tagIconEl).width || 0)
|
|
4002
|
+
: (tagTextEl ? (rect(tagTextEl).x || 0) + (rect(tagTextEl).width || 0)
|
|
4003
|
+
: (sr.x || 0) + (sr.width || 0))))))
|
|
4004
|
+
pp.tagIconGap = tagIconEl && tagTextEl
|
|
4005
|
+
? Math.max(0, Math.round((rect(tagIconEl).x || 0) - ((rect(tagTextEl).x || 0) + (rect(tagTextEl).width || 0))))
|
|
4006
|
+
: 2
|
|
4007
|
+
pp.tagIconW = tagIconEl ? Math.round(rect(tagIconEl).width || 16) : 0
|
|
4008
|
+
pp.tagIconH = tagIconEl ? Math.round(rect(tagIconEl).height || 16) : 0
|
|
4009
|
+
}
|
|
3213
4010
|
const cardRectC = rect(card)
|
|
3214
4011
|
titleRightEl = texts.find((tt) => {
|
|
3215
4012
|
if (tt === t || compositePairs.some((pp) => pp.labelEl === tt || pp.valueEl === tt)) return false
|
|
@@ -3220,17 +4017,81 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3220
4017
|
}) || null
|
|
3221
4018
|
}
|
|
3222
4019
|
}
|
|
3223
|
-
if (!descEl && !compositePairs) {
|
|
4020
|
+
if (!descEl && !compositePairs) {
|
|
4021
|
+
// 周期行兜底:无标准 desc(18-36px)但标题存在 ≥2 个同列等距周期同伴时
|
|
4022
|
+
// (支出占比分类行:类别名+金额+百分比+进度条,百分比仅 0-8px 下方而非标准 18-36px),
|
|
4023
|
+
// 取近邻小字号文本(0-16px 下方)作为 desc,使行仍被识别为列表项。
|
|
4024
|
+
// 双向检测:上下方合计 ≥2 个同列等距同伴即判周期行(中间行 1 上 1 下)。
|
|
4025
|
+
const periodicPeersAll = firstColTexts.filter((o) => o !== t
|
|
4026
|
+
&& Math.abs((rect(o).x || 0) - (rect(t).x || 0)) <= 4
|
|
4027
|
+
&& (ts(o).fontSize || 0) === (ts(t).fontSize || 0)
|
|
4028
|
+
&& String(ts(o).color || '').toLowerCase() === String(ts(t).color || '').toLowerCase()
|
|
4029
|
+
&& Math.abs((rect(o).y || 0) - y) >= 20 && Math.abs((rect(o).y || 0) - y) <= 200)
|
|
4030
|
+
if (periodicPeersAll.length >= 2) {
|
|
4031
|
+
const peerDys = periodicPeersAll.map((o) => (rect(o).y || 0) - y).sort((a, b) => Math.abs(a) - Math.abs(b))
|
|
4032
|
+
const candSteps = [...new Set([Math.abs(peerDys[0]), Math.abs(peerDys[1] || peerDys[0])])].filter((s) => s >= 20)
|
|
4033
|
+
const hasPeriodic = candSteps.some((candStep) => {
|
|
4034
|
+
const matched = peerDys.filter((g) => {
|
|
4035
|
+
const remainder = Math.abs(g) % candStep
|
|
4036
|
+
return Math.min(remainder, candStep - remainder) <= 12
|
|
4037
|
+
}).length
|
|
4038
|
+
return matched >= 2
|
|
4039
|
+
})
|
|
4040
|
+
if (hasPeriodic) {
|
|
4041
|
+
descEl = rowTexts.find((r) => {
|
|
4042
|
+
const ry = rect(r).y || 0
|
|
4043
|
+
return r !== t && ry >= y && ry - y <= 16
|
|
4044
|
+
&& (ts(r).fontSize || 0) < (ts(t).fontSize || 0)
|
|
4045
|
+
&& Math.abs((rect(r).x || 0) - (rect(t).x || 0)) <= (rect(t).width || 0) + 40
|
|
4046
|
+
}) || null
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
if (!descEl) { lastY = y; continue }
|
|
4050
|
+
}
|
|
3224
4051
|
// 时间行:日期分隔符含点/斜杠形态(2025.10.10 14:28:16)。此前只匹配横杠,
|
|
3225
4052
|
// 点分时间行永远不被列表行消费,被 auxTexts 当孤立文本甩到卡底重渲染。
|
|
3226
4053
|
const time = rowTexts.find((r) => r !== descEl
|
|
3227
4054
|
&& /\d{4}[-/.]\d{1,2}[-/.]\d{1,2}/.test(textOf(r))) || null
|
|
3228
|
-
|
|
4055
|
+
// 行右金额候选必须含数字或货币符号:纯币种名(如 pill 标签「美元」)含「元」字但不是金额,误捕会把标签塞进数值表达式(渲染占位文案)并用其右沿算行右内边距挤窄标题行。
|
|
4056
|
+
const isRowAmountText = (v) => /\p{Sc}/u.test(v) || (/\d/.test(v) && /[.元]/.test(v))
|
|
4057
|
+
// 行第二行右侧文本(交易行「余额:¥20,077.00」):与 desc 行同基线、右半锚定,
|
|
4058
|
+
// 且非 desc/time → 行右列第二行,数据驱动 $descRight(先于 amount 计算,
|
|
4059
|
+
// 否则余额行被 amount 通道抢先造成右列双份)。
|
|
4060
|
+
const descRightEl = rowTexts.find((r) => r !== descEl && r !== time && r !== t
|
|
4061
|
+
&& Math.abs((rect(r).y || 0) - (descEl ? (rect(descEl).y || 0) : y + 22)) <= 6
|
|
4062
|
+
&& (rect(r).x || 0) >= (rect(card).x || 0) + (rect(card).width || 375) / 2) || null
|
|
4063
|
+
const amount = rowTexts.find((r) => r !== time && r !== descEl && r !== descRightEl && isRowAmountText(textOf(r)))
|
|
3229
4064
|
// 标题同行后缀(如「张三」右侧灰色小字「(小张)」):同基线、紧随标题右沿
|
|
3230
4065
|
const titleRightEdge = (rect(t).x || 0) + (rect(t).width || 0)
|
|
4066
|
+
// 行内联 affordance(紧跟账号的行尾眼睛图标):不锚行右沿而是紧贴标题右沿,
|
|
4067
|
+
// 逐行挂接后由模板按几何在标题行内联渲染(区别于右对齐行尾件)。
|
|
4068
|
+
const inlineAffordEl = [...(slices || []), ...symbols].find((s2) => {
|
|
4069
|
+
const r2 = rect(s2)
|
|
4070
|
+
const w2 = r2.width || 0
|
|
4071
|
+
const h2 = r2.height || 0
|
|
4072
|
+
if (w2 < 14 || w2 > 32 || h2 < 14 || h2 > 32) return false
|
|
4073
|
+
if (Math.abs((r2.y || 0) - y) > 8) return false
|
|
4074
|
+
const gap2 = (r2.x || 0) - titleRightEdge
|
|
4075
|
+
return gap2 >= 2 && gap2 <= 48
|
|
4076
|
+
}) || null
|
|
4077
|
+
if (inlineAffordEl) {
|
|
4078
|
+
const bi2 = isolatedBlocks.findIndex((b) => b.element === inlineAffordEl)
|
|
4079
|
+
if (bi2 >= 0) isolatedBlocks.splice(bi2, 1)
|
|
4080
|
+
}
|
|
3231
4081
|
const suffixEl = rowTexts.find((r) => r !== t && r !== descEl
|
|
3232
4082
|
&& Math.abs((rect(r).y || 0) - y) <= 6
|
|
3233
4083
|
&& (rect(r).x || 0) >= titleRightEdge - 2 && (rect(r).x || 0) <= titleRightEdge + 16) || null
|
|
4084
|
+
// 标题行同行右侧文本(交易行金额,基线可能比标题高几 px):非复合行下
|
|
4085
|
+
// rowTexts 带不含它(y 略小于标题),不补 titleRight 通道行右金额就丢失。
|
|
4086
|
+
if (!compositePairs) {
|
|
4087
|
+
titleRightEl = texts.find((tt) => {
|
|
4088
|
+
if (tt === t || tt === descEl || tt === suffixEl || tt === descRightEl) return false
|
|
4089
|
+
const tr = rect(tt)
|
|
4090
|
+
return Math.abs((tr.y || 0) - y) <= 8
|
|
4091
|
+
&& (tr.x || 0) >= titleRightEdge + 8
|
|
4092
|
+
&& (tr.x || 0) >= (rect(card).x || 0) + (rect(card).width || 375) * 0.5
|
|
4093
|
+
}) || null
|
|
4094
|
+
}
|
|
3234
4095
|
// 行图标:标题左侧、行带垂直范围内的切片(逐行不同 → 数据驱动)
|
|
3235
4096
|
const rowBandTop = y - 12
|
|
3236
4097
|
const rowBandBottom = (rect(descEl).y || 0) + (rect(descEl).height || 0) + 12
|
|
@@ -3244,6 +4105,16 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3244
4105
|
// 行内操作 pill:右侧锚定的描边/填充小壳(宽 40-120 高 22-48)+ 单文本(如「删除」)
|
|
3245
4106
|
const cardR0 = rect(card)
|
|
3246
4107
|
const cardRight0 = (cardR0.x || 0) + (cardR0.width || 0)
|
|
4108
|
+
// 行右大图标(行壳右沿锚定的 32-72px 切片/符号,如保险产品 48x48):数据驱动 $rightIcon
|
|
4109
|
+
const rightIconEl = ((content || []).filter((e) => e.type === 'slice' || e.type === 'symbol')).find((s) => {
|
|
4110
|
+
if (s === iconEl) return false
|
|
4111
|
+
const r = rect(s)
|
|
4112
|
+
const w = r.width || 0; const h = r.height || 0
|
|
4113
|
+
if (w < 32 || w > 72 || h < 32 || h > 72) return false
|
|
4114
|
+
if ((r.x || 0) <= (rect(t).x || 0)) return false
|
|
4115
|
+
return (r.x || 0) + w >= cardRight0 - 48
|
|
4116
|
+
&& (r.y || 0) >= rowBandTop && (r.y || 0) + h <= rowBandBottom + 8
|
|
4117
|
+
}) || null
|
|
3247
4118
|
const actionShapeEl = (shapes || []).find((s) => {
|
|
3248
4119
|
if (optionShapeSet.has(s)) return false
|
|
3249
4120
|
const r = rect(s)
|
|
@@ -3272,6 +4143,9 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3272
4143
|
title,
|
|
3273
4144
|
// 行内容左右沿(spec 实测):行横向内边距按此驱动,避免通用 12px 与设计 16px 错位
|
|
3274
4145
|
rowLeftEdge: Math.round(Math.min(rect(t).x || 0, iconEl ? (rect(iconEl).x || 0) : Infinity)),
|
|
4146
|
+
// 行内容真实右沿:行壳内边距推导用(全部行文本 + 图标 + 操作件右沿最大值)
|
|
4147
|
+
rowContentRight: Math.round(Math.max(0, ...[...rowTexts, iconEl, rightIconEl, actionShapeEl]
|
|
4148
|
+
.filter(Boolean).map((e) => (rect(e).x || 0) + (rect(e).width || 0)))) || null,
|
|
3275
4149
|
// 只统计右栏内容(金额/时间/行内操作)的右沿;标题行后缀/右侧同行文本属于
|
|
3276
4150
|
// 左栏标题流,混入会把行右内边距算成巨大值挤窄左栏(名册卡曾因此换行回归)。
|
|
3277
4151
|
rowRightEdge: Math.round(Math.max(0, ...[amount, time, actionShapeEl]
|
|
@@ -3287,7 +4161,25 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3287
4161
|
iconEl,
|
|
3288
4162
|
iconW: iconEl ? Math.round(rect(iconEl).width || 0) : 0,
|
|
3289
4163
|
iconH: iconEl ? Math.round(rect(iconEl).height || 0) : 0,
|
|
4164
|
+
rightIcon: rightIconEl
|
|
4165
|
+
? ((resolveSliceExport(spec.slices || [], rightIconEl.name) || rightIconEl.annotationDefect?.cropSlice)
|
|
4166
|
+
? sliceRefPath(resolveSliceExport(spec.slices || [], rightIconEl.name) || rightIconEl.annotationDefect?.cropSlice)
|
|
4167
|
+
: `http://iph.href.lu/${Math.round(rect(rightIconEl).width || 48)}x${Math.round(rect(rightIconEl).height || 48)}?text=${encodeURIComponent('缺素材')}`)
|
|
4168
|
+
: null,
|
|
4169
|
+
rightIconEl,
|
|
4170
|
+
rightIconW: rightIconEl ? Math.round(rect(rightIconEl).width || 0) : 0,
|
|
4171
|
+
rightIconH: rightIconEl ? Math.round(rect(rightIconEl).height || 0) : 0,
|
|
3290
4172
|
action: actionTextEl ? textOf(actionTextEl) : '',
|
|
4173
|
+
affordInline: !!inlineAffordEl,
|
|
4174
|
+
affordInlineGap: inlineAffordEl
|
|
4175
|
+
? Math.max(4, Math.round((rect(inlineAffordEl).x || 0) - titleRightEdge))
|
|
4176
|
+
: undefined,
|
|
4177
|
+
affordanceEl: inlineAffordEl || null,
|
|
4178
|
+
affordanceUrl: inlineAffordEl
|
|
4179
|
+
? ((resolveSliceExport(spec.slices || [], inlineAffordEl.name) || inlineAffordEl.annotationDefect?.cropSlice)
|
|
4180
|
+
? sliceRefPath(resolveSliceExport(spec.slices || [], inlineAffordEl.name) || inlineAffordEl.annotationDefect?.cropSlice)
|
|
4181
|
+
: `http://iph.href.lu/${Math.round(rect(inlineAffordEl).width || 20)}x${Math.round(rect(inlineAffordEl).height || 20)}?text=${encodeURIComponent('缺素材')}`)
|
|
4182
|
+
: '',
|
|
3291
4183
|
actionShapeEl,
|
|
3292
4184
|
actionColor: actionTextEl ? ts(actionTextEl).color : undefined,
|
|
3293
4185
|
actionFontSize: actionTextEl ? ts(actionTextEl).fontSize : undefined,
|
|
@@ -3297,8 +4189,16 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3297
4189
|
actionW: actionShapeEl ? Math.round(rect(actionShapeEl).width || 0) : 0,
|
|
3298
4190
|
actionH: actionShapeEl ? Math.round(rect(actionShapeEl).height || 0) : 0,
|
|
3299
4191
|
desc: compositePairs ? '' : textOf(descEl),
|
|
4192
|
+
descRight: descRightEl ? textOf(descRightEl) : '',
|
|
4193
|
+
descRightColor: descRightEl ? ts(descRightEl).color : undefined,
|
|
4194
|
+
descRightFontSize: descRightEl ? ts(descRightEl).fontSize : undefined,
|
|
3300
4195
|
amounts: compositePairs ? compositePairs.map((pp) => ({
|
|
3301
4196
|
label: textOf(pp.labelEl), value: textOf(pp.valueEl),
|
|
4197
|
+
tagText: pp.tagText || '', tagIcon: pp.tagIcon || '',
|
|
4198
|
+
tagFill: pp.tagFill || null, tagOpacity: pp.tagOpacity ?? 1, tagBorder: pp.tagBorder || null, tagRadius: pp.tagRadius || 0,
|
|
4199
|
+
tagW: pp.tagW || 0, tagH: pp.tagH || 0, tagGap: pp.tagGap, tagPadLeft: pp.tagPadLeft,
|
|
4200
|
+
tagPadRight: pp.tagPadRight, tagIconGap: pp.tagIconGap, tagIconW: pp.tagIconW, tagIconH: pp.tagIconH,
|
|
4201
|
+
tagTextColor: pp.tagTextColor, tagFontSize: pp.tagFontSize,
|
|
3302
4202
|
labelColor: ts(pp.labelEl).color, labelFontSize: ts(pp.labelEl).fontSize,
|
|
3303
4203
|
labelFontWeight: ts(pp.labelEl).fontWeight,
|
|
3304
4204
|
labelX: Math.round(rect(pp.labelEl).x || 0), labelW: Math.round(rect(pp.labelEl).width || 0),
|
|
@@ -3332,10 +4232,13 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3332
4232
|
descFontWeight: descEl ? ts(descEl).fontWeight : undefined,
|
|
3333
4233
|
descHeight: descEl ? (rect(descEl).height || 0) : 0,
|
|
3334
4234
|
descY: descEl ? (rect(descEl).y || 0) : undefined,
|
|
4235
|
+
descX: descEl ? (rect(descEl).x || 0) : undefined,
|
|
3335
4236
|
amountColor: amount ? ts(amount).color : undefined,
|
|
3336
4237
|
amountFontSize: amount ? ts(amount).fontSize : undefined,
|
|
3337
4238
|
amountFontWeight: amount ? ts(amount).fontWeight : undefined,
|
|
3338
4239
|
y,
|
|
4240
|
+
titleX: Math.round(rect(t).x || 0),
|
|
4241
|
+
titleW: Math.round(rect(t).width || 0),
|
|
3339
4242
|
contentHeight: compositePairs
|
|
3340
4243
|
? Math.round(Math.max(...compositePairs.map((pp) => (rect(pp.valueEl).y || 0) + (rect(pp.valueEl).height || 0))) - y)
|
|
3341
4244
|
: Math.round((rect(descEl).y || 0) + (rect(descEl).height || 0) - y),
|
|
@@ -3350,6 +4253,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3350
4253
|
}
|
|
3351
4254
|
if (titleRightEl) itemTextConsumed.add(titleRightEl)
|
|
3352
4255
|
if (descEl) itemTextConsumed.add(descEl)
|
|
4256
|
+
if (descRightEl) itemTextConsumed.add(descRightEl)
|
|
3353
4257
|
if (suffixEl) itemTextConsumed.add(suffixEl)
|
|
3354
4258
|
if (actionTextEl) itemTextConsumed.add(actionTextEl)
|
|
3355
4259
|
if (amount) itemTextConsumed.add(amount)
|
|
@@ -3410,6 +4314,12 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3410
4314
|
let bestDy = Infinity
|
|
3411
4315
|
for (const item of items) {
|
|
3412
4316
|
if (item.affordanceEl) continue
|
|
4317
|
+
// 行右大图标内嵌小徽标(如 icon 角上的 ¥ badge)不是行尾 affordance:重叠即跳过,
|
|
4318
|
+
// 否则徽标挤占左栏宽度导致 desc 换行(保险保障行实测)
|
|
4319
|
+
const riR = item.rightIconEl ? rect(item.rightIconEl) : null
|
|
4320
|
+
if (riR && (er.x || 0) >= (riR.x || 0) - 6 && (er.y || 0) >= (riR.y || 0) - 6
|
|
4321
|
+
&& (er.x || 0) + (er.width || 0) <= (riR.x || 0) + (riR.width || 0) + 6
|
|
4322
|
+
&& (er.y || 0) + (er.height || 0) <= (riR.y || 0) + (riR.height || 0) + 6) continue
|
|
3413
4323
|
const dy = Math.abs((item.y || 0) - (er.y || 0))
|
|
3414
4324
|
if (dy <= tol && dy < bestDy) { bestDy = dy; hitItem = item }
|
|
3415
4325
|
}
|
|
@@ -3444,7 +4354,34 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3444
4354
|
if (itemTextConsumed.has(auxTexts[ai].sourceEl)) auxTexts.splice(ai, 1)
|
|
3445
4355
|
}
|
|
3446
4356
|
}
|
|
4357
|
+
// 同表模板一致性护栏(纯结构签名,不靠业务词):签名不同的行并进同一 ListLayout 会让
|
|
4358
|
+
// 模板字段错位(desc 落 title 槽竖排换行、缺键渲染占位文案),且少数行与静态语义块双份渲染。
|
|
4359
|
+
// 保留多数签名行成表;少数签名行退出列表,交还静态语义通道渲染。
|
|
4360
|
+
if (items.length >= 3) {
|
|
4361
|
+
const itemSig = (it) => [
|
|
4362
|
+
it.iconEl ? 1 : 0,
|
|
4363
|
+
(it.amounts && it.amounts.length) ? 1 : 0,
|
|
4364
|
+
it.rowRightEdge != null ? 1 : 0,
|
|
4365
|
+
it.actionShapeEl ? 1 : 0,
|
|
4366
|
+
].join('')
|
|
4367
|
+
const sigCounts = new Map()
|
|
4368
|
+
for (const it of items) sigCounts.set(itemSig(it), (sigCounts.get(itemSig(it)) || 0) + 1)
|
|
4369
|
+
const majority = [...sigCounts.entries()].sort((a, b) => b[1] - a[1])[0]
|
|
4370
|
+
if (sigCounts.size >= 2 && majority[1] >= 2 && majority[1] < items.length) {
|
|
4371
|
+
const kept = items.filter((it) => itemSig(it) === majority[0])
|
|
4372
|
+
items.splice(0, items.length, ...kept)
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
3447
4375
|
const sampleItems = items.slice(0, 3)
|
|
4376
|
+
// 表单行早于列表识别计算:行内 desc/descRight 被列表接管后必须同步回收,
|
|
4377
|
+
// 否则表单通道把同一批标注再渲染一遍(交易行余额行重影)。
|
|
4378
|
+
if (itemTextConsumed.size && Array.isArray(formRows)) {
|
|
4379
|
+
for (let fi = formRows.length - 1; fi >= 0; fi -= 1) {
|
|
4380
|
+
const ownedEls = [formRows[fi].labelEl, formRows[fi].valueEl, ...(formRows[fi].subEls || [])]
|
|
4381
|
+
.filter(Boolean)
|
|
4382
|
+
if (ownedEls.some((el) => itemTextConsumed.has(el))) formRows.splice(fi, 1)
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
3448
4385
|
// richBlocks 早于列表识别按全量文本计算:tile/段落区域被列表接管后必须同步回收,
|
|
3449
4386
|
// 否则富文本段落路径把同一批标注再渲染一遍(v6 产品 tile 重影,duplicate-content ×23)。
|
|
3450
4387
|
// 双判据:① 碎片被列表消费覆盖率 ≥80%;② 几何落在列表区(与分隔线列表区护栏同族,
|
|
@@ -3499,12 +4436,79 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3499
4436
|
&& width >= shellCardWidth * 0.8 && height >= 32 && height <= 160
|
|
3500
4437
|
&& (elementRect.y || 0) <= items[index].y
|
|
3501
4438
|
&& items[index].y < (elementRect.y || 0) + height
|
|
3502
|
-
&&
|
|
4439
|
+
&& (elementRect.y || 0) + height <= items[index].y + items[index].rowHeight + 4
|
|
4440
|
+
&& Math.abs(height - items[index].rowHeight) <= Math.max(12, items[index].rowHeight * 0.25)
|
|
3503
4441
|
}).sort((left, right) => Math.abs((rect(left).height || 0) - items[index].rowHeight)
|
|
3504
4442
|
- Math.abs((rect(right).height || 0) - items[index].rowHeight))
|
|
4443
|
+
// 有填充的壳优先:同尺寸包裹层(group/symbol)无 fill,选中它行底色/圆角恒丢
|
|
4444
|
+
shellCandidates.sort((left, right) => {
|
|
4445
|
+
const lf = left.style?.fill
|
|
4446
|
+
const rf = right.style?.fill
|
|
4447
|
+
const lNo = !(lf && (lf.color || lf.gradient)) ? 1 : 0
|
|
4448
|
+
const rNo = !(rf && (rf.color || rf.gradient)) ? 1 : 0
|
|
4449
|
+
return lNo - rNo
|
|
4450
|
+
})
|
|
3505
4451
|
const shell = shellCandidates[0]
|
|
3506
|
-
|
|
4452
|
+
// 视觉行壳判定:满宽 + 无圆角 + 无渐变 = 分区背景板(非行壳)。误认领会把
|
|
4453
|
+
// 内边距/底色/分割线全搅乱(我的账户卡实测 padRight 303 把左栏压没、pos 崩 0.33)
|
|
4454
|
+
const shellVisual = !!shell && ((radiusMax(shell.style) || 0) >= 4
|
|
4455
|
+
|| !!shell.style?.fill?.gradient
|
|
4456
|
+
|| (rect(shell).width || 0) <= ((rect(card).width || shellCardWidth) - 8))
|
|
4457
|
+
items[index].rowTop = shell ? (rect(shell).y || 0)
|
|
4458
|
+
: (items[index].iconEl ? Math.min(items[index].y, rect(items[index].iconEl).y || items[index].y) : items[index].y)
|
|
3507
4459
|
items[index].shellHeight = shell ? (rect(shell).height || items[index].rowHeight) : items[index].rowHeight
|
|
4460
|
+
// 行壳样式/内边距传给列表模板(emit 读 first.fill/gradient/radius/pad*,此前从未赋值 → 行底色恒丢)
|
|
4461
|
+
if (shell) {
|
|
4462
|
+
const sr2 = rect(shell)
|
|
4463
|
+
items[index].shellEl = shell
|
|
4464
|
+
items[index].shellVisual = shellVisual
|
|
4465
|
+
if (shellVisual) {
|
|
4466
|
+
items[index].fill = shell.style?.fill?.color || null
|
|
4467
|
+
items[index].gradient = shell.style?.fill?.gradient || null
|
|
4468
|
+
items[index].opacity = shell.style?.opacity ?? 1
|
|
4469
|
+
items[index].radius = radiusMax(shell.style)
|
|
4470
|
+
// 行内容真实右沿(全部行文本 + 左右图标 + 操作件):只按金额/时间右沿会把
|
|
4471
|
+
// 标题行卡的 padRight 算成几百 px 挤垮左栏
|
|
4472
|
+
const contentRight = items[index].rowContentRight || ((sr2.x || 0) + (sr2.width || 0) - 16)
|
|
4473
|
+
items[index].padLeft = Math.max(0, Math.round((items[index].rowLeftEdge || 0) - (sr2.x || 0)))
|
|
4474
|
+
items[index].padRight = Math.min(Math.max(0, Math.round(((sr2.x || 0) + (sr2.width || 0)) - contentRight)), Math.round((sr2.width || 0) / 2))
|
|
4475
|
+
items[index].padTop = Math.max(0, Math.round(((sr2.height || 0) - (items[index].contentHeight || 0)) / 2))
|
|
4476
|
+
}
|
|
4477
|
+
items[index].height = Math.round(sr2.height || items[index].rowHeight)
|
|
4478
|
+
} else {
|
|
4479
|
+
items[index].height = items[index].shellHeight || items[index].rowHeight
|
|
4480
|
+
items[index].padLeft = 16
|
|
4481
|
+
items[index].padRight = 16
|
|
4482
|
+
items[index].padTop = 8
|
|
4483
|
+
}
|
|
4484
|
+
// 进度条归属列表行:进度条 y 落在列表行带内时挂到该列表项(逐行渲染),
|
|
4485
|
+
// 否则作为独立语义块渲染(insertSemantic 全堆在卡底/同一 y 位→重叠)。
|
|
4486
|
+
const _listBarEls = new Set()
|
|
4487
|
+
if (items.length && progressBars.length) {
|
|
4488
|
+
for (const bar of progressBars) {
|
|
4489
|
+
const by = bar.y ?? 0
|
|
4490
|
+
const matched = items.find((item) => {
|
|
4491
|
+
const top = item.rowTop ?? item.y ?? 0
|
|
4492
|
+
const bottom = top + (item.shellHeight || item.rowHeight || item.height || 0)
|
|
4493
|
+
return by >= top && by < bottom
|
|
4494
|
+
})
|
|
4495
|
+
if (matched) {
|
|
4496
|
+
if (!matched.bars) matched.bars = []
|
|
4497
|
+
matched.bars.push(bar)
|
|
4498
|
+
if (bar.trackEl) _listBarEls.add(bar.trackEl)
|
|
4499
|
+
for (const el of (bar.segmentEls || [])) _listBarEls.add(el)
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
if (_listBarEls.size) {
|
|
4503
|
+
for (let bi = progressBars.length - 1; bi >= 0; bi -= 1) {
|
|
4504
|
+
const bar = progressBars[bi]
|
|
4505
|
+
if ((bar.trackEl && _listBarEls.has(bar.trackEl))
|
|
4506
|
+
|| (bar.segmentEls || []).some((el) => _listBarEls.has(el))) {
|
|
4507
|
+
progressBars.splice(bi, 1)
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
3508
4512
|
}
|
|
3509
4513
|
|
|
3510
4514
|
// —— 查看更多 ——
|
|
@@ -3638,6 +4642,9 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3638
4642
|
if (!raw) continue
|
|
3639
4643
|
const verb = (raw.match(verbRe) || [''])[0] || ''
|
|
3640
4644
|
const core = verb ? raw.replace(verbRe, '').trim() : raw
|
|
4645
|
+
// 日期区间/点分单日(2025.11.28-2025.12.04):整串是单一展示期间,splitValueTokens 会把它
|
|
4646
|
+
// 切成「2025.11」等数字段误绑金额表达式(渲染出千分号)。保持字面量。
|
|
4647
|
+
if (/^\d{4}[-./]\d{1,2}[-./]\d{1,2}(\s*[-~—至]\s*\d{4}[-./]\d{1,2}[-./]\d{1,2})?$/.test(core)) continue
|
|
3641
4648
|
const segs = splitValueTokens(core)
|
|
3642
4649
|
// 绑定条件(满足其一):
|
|
3643
4650
|
// ① 含「强值信号」(phone/cardNumber/amount/code/date) —— 实体行(张三 13800138000);
|
|
@@ -3805,6 +4812,9 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3805
4812
|
const bg = (bgRaw || '').toUpperCase()
|
|
3806
4813
|
if (!bg || NEUTRAL_COLORS.has(bg)) continue
|
|
3807
4814
|
const matchingText = texts.find((t) => {
|
|
4815
|
+
// 数值文本(汇总区金额)不与下划/高亮带配对:比例条(h=4)曾被误认为金额层下划,
|
|
4816
|
+
// 吞掉汇总区次列值(300.00 丢失)。
|
|
4817
|
+
if (isAmountish(textOf(t))) return false
|
|
3808
4818
|
const tr = rect(t)
|
|
3809
4819
|
const tBottom = (tr.y || 0) + (tr.height || 0)
|
|
3810
4820
|
const gap = (sr.y || 0) - tBottom
|
|
@@ -3844,13 +4854,215 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3844
4854
|
midCluster?.logoSlice,
|
|
3845
4855
|
primaryAmount?.iconSlice,
|
|
3846
4856
|
].filter(Boolean))
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
4857
|
+
const orphanSlices = slices.filter((s) => {
|
|
4858
|
+
if (orphanConsumed.has(s)) return false
|
|
4859
|
+
if (floatDecos.includes(s)) return false
|
|
4860
|
+
const sr = rect(s)
|
|
4861
|
+
const w = sr.width || 0, h = sr.height || 0
|
|
4862
|
+
const cRf = rect(card)
|
|
4863
|
+
// 浮动装饰切片(卡头月亮/吉祥物光晕等):竖直方向与文本带重叠、水平贴卡缘 →
|
|
4864
|
+
// 不能占流式行高(否则整卡内容被顶下一个装饰高度),改由绝对定位叠层渲染。
|
|
4865
|
+
const overlapsText = texts.some((t) => {
|
|
4866
|
+
const tr = rect(t)
|
|
4867
|
+
const ov = Math.min((sr.y || 0) + h, (tr.y || 0) + (tr.height || 0)) - Math.max(sr.y || 0, tr.y || 0)
|
|
4868
|
+
return ov >= h * 0.2 && h >= (tr.height || 0) * 2.2
|
|
4869
|
+
})
|
|
4870
|
+
const edgeAnchored = ((sr.x || 0) + w) >= ((cRf.x || 0) + (cRf.width || 0)) - 24
|
|
4871
|
+
if (overlapsText && edgeAnchored && w <= (cRf.width || 375) * 0.5
|
|
4872
|
+
&& !buttonShapes.some((b) => coverRatio(s, b) > 0.5)
|
|
4873
|
+
&& !panelCandidates.some((p) => coverRatio(s, p) > 0.5)) {
|
|
4874
|
+
floatDecos.push(s)
|
|
4875
|
+
return false
|
|
4876
|
+
}
|
|
4877
|
+
return w >= 60 && h >= 24 && h <= 140 && w <= (rect(card).width || 375) + 40
|
|
4878
|
+
})
|
|
4879
|
+
|
|
4880
|
+
// —— 背景壳层(section/行/面板壳):带圆角填充的中型 shape,供列表行样式、表单行包裹、段落底色认领 ——
|
|
4881
|
+
// 此前卡宽壳被 isBg 排除、行壳因「壳高 vs 行 pitch 差 12px」超容差漏配,列表行/面板/段落全部丢底色。
|
|
4882
|
+
const shellLayers = (card.elements || []).filter((e) => {
|
|
4883
|
+
if (e.type !== 'shape') return false
|
|
4884
|
+
const f = e.style?.fill
|
|
4885
|
+
if (!f || (!f.color && !f.gradient)) return false
|
|
4886
|
+
if (radiusMax(e.style) < 4) return false
|
|
4887
|
+
const r = rect(e)
|
|
4888
|
+
const cR = rect(card)
|
|
4889
|
+
const w = r.width || 0
|
|
4890
|
+
const h = r.height || 0
|
|
4891
|
+
if (w < (cR.width || 0) * 0.4 || w > (cR.width || 0) + 4) return false
|
|
4892
|
+
if (h < 40 || h > (cR.height || 0) * 0.6) return false
|
|
4893
|
+
return true
|
|
4894
|
+
}).map((e) => ({
|
|
4895
|
+
el: e,
|
|
4896
|
+
x: rect(e).x || 0, y: rect(e).y || 0, width: rect(e).width || 0, height: rect(e).height || 0,
|
|
4897
|
+
fill: e.style?.fill?.color || null,
|
|
4898
|
+
gradient: e.style?.fill?.gradient || null,
|
|
4899
|
+
radius: radiusMax(e.style),
|
|
4900
|
+
opacity: e.style?.opacity ?? 1,
|
|
4901
|
+
}))
|
|
4902
|
+
|
|
4903
|
+
// —— 薄壳通道(行级描边/浅填充窄矩形)——
|
|
4904
|
+
// 容器级壳由 panelRows / gradientPanels 认领,壳层背景由 shellLayers 认领(要求 h>=40 且 radius>=4);
|
|
4905
|
+
// 行级薄壳(h 18..48、radius<=6、带描边或填充)两头落空 → 描边与底色整体丢失:
|
|
4906
|
+
// 表格行只剩裸文本,无文本的分段壳被当孤立媒体块渲染成「缺素材」占位图。
|
|
4907
|
+
const thinShellStyle = (shape) => {
|
|
4908
|
+
const sr = rect(shape)
|
|
4909
|
+
const border = shape.style?.border || {}
|
|
4910
|
+
const fill = shape.style?.fill || {}
|
|
4911
|
+
return {
|
|
4912
|
+
shapeEl: shape,
|
|
4913
|
+
x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
|
|
4914
|
+
fill: fill.color || null,
|
|
4915
|
+
gradient: fill.gradient || null,
|
|
4916
|
+
opacity: shape.style?.opacity ?? 1,
|
|
4917
|
+
radius: radiusMax(shape.style),
|
|
4918
|
+
isBorder: !!border.isBorder,
|
|
4919
|
+
borderWidth: Number(border.width) || 0,
|
|
4920
|
+
borderColor: border.color || null,
|
|
4921
|
+
}
|
|
4922
|
+
}
|
|
4923
|
+
const thinInside = (element, shape) => {
|
|
4924
|
+
const er = rect(element)
|
|
4925
|
+
const sr = rect(shape)
|
|
4926
|
+
return (er.x || 0) >= (sr.x || 0) - 2 && (er.y || 0) >= (sr.y || 0) - 2
|
|
4927
|
+
&& ((er.x || 0) + (er.width || 0)) <= ((sr.x || 0) + (sr.width || 0)) + 2
|
|
4928
|
+
&& ((er.y || 0) + (er.height || 0)) <= ((sr.y || 0) + (sr.height || 0)) + 2
|
|
4929
|
+
}
|
|
4930
|
+
const thinShellShapes = (card.elements || []).filter((element) => {
|
|
4931
|
+
if (element.type !== 'shape') return false
|
|
4932
|
+
const sr = rect(element)
|
|
4933
|
+
const cR = rect(card)
|
|
4934
|
+
const w = sr.width || 0
|
|
4935
|
+
const h = sr.height || 0
|
|
4936
|
+
if (h < 18 || h > 48) return false
|
|
4937
|
+
if (w < (cR.width || 0) * 0.2 || w > (cR.width || 0) + 4) return false
|
|
4938
|
+
if (radiusMax(element.style) > 6) return false
|
|
4939
|
+
const border = element.style?.border || {}
|
|
4940
|
+
const fill = element.style?.fill || {}
|
|
4941
|
+
if (!border.isBorder && !fill.color && !fill.gradient) return false
|
|
4942
|
+
if (panelShapes.includes(element) || optionShapeSet.has(element)) return false
|
|
4943
|
+
if (gradientPanels.some((gp) => gp.shapeEl === element)) return false
|
|
4944
|
+
// 与卡身同色且无描边/渐变 = 卡底延续补片,不是行壳
|
|
4945
|
+
if (!border.isBorder && !fill.gradient
|
|
4946
|
+
&& String(fill.color || '').toUpperCase() === String(card.cardFill || '').toUpperCase()) return false
|
|
4947
|
+
return true
|
|
3852
4948
|
})
|
|
4949
|
+
const thinShellRows = []
|
|
4950
|
+
const thinShellStrips = []
|
|
4951
|
+
{
|
|
4952
|
+
const freeShells = []
|
|
4953
|
+
for (const shape of thinShellShapes) {
|
|
4954
|
+
const inner = texts.filter((t) => thinInside(t, shape))
|
|
4955
|
+
if (inner.length) thinShellRows.push({ ...thinShellStyle(shape), texts: inner })
|
|
4956
|
+
else freeShells.push(shape)
|
|
4957
|
+
}
|
|
4958
|
+
// 无文本薄壳按「同 y 带 + 首尾相接(间隙 <=8)」聚成分段横条(时间轴/进度分段);
|
|
4959
|
+
// 单壳不成条,保持原孤立媒体块兜底,>=2 段才按几何分段还原。
|
|
4960
|
+
const ordered = freeShells.slice().sort((a, b) =>
|
|
4961
|
+
(rect(a).y || 0) - (rect(b).y || 0) || (rect(a).x || 0) - (rect(b).x || 0))
|
|
4962
|
+
const claimed = new Set()
|
|
4963
|
+
for (const shape of ordered) {
|
|
4964
|
+
if (claimed.has(shape)) continue
|
|
4965
|
+
const sr = rect(shape)
|
|
4966
|
+
const members = [shape]
|
|
4967
|
+
claimed.add(shape)
|
|
4968
|
+
let cursor = (sr.x || 0) + (sr.width || 0)
|
|
4969
|
+
for (const other of ordered) {
|
|
4970
|
+
if (claimed.has(other)) continue
|
|
4971
|
+
const or = rect(other)
|
|
4972
|
+
if (Math.abs((or.y || 0) - (sr.y || 0)) > 4) continue
|
|
4973
|
+
if ((or.x || 0) < cursor - 2 || (or.x || 0) - cursor > 8) continue
|
|
4974
|
+
members.push(other)
|
|
4975
|
+
claimed.add(other)
|
|
4976
|
+
cursor = (or.x || 0) + (or.width || 0)
|
|
4977
|
+
}
|
|
4978
|
+
if (members.length < 2) continue
|
|
4979
|
+
members.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
|
|
4980
|
+
const boxes = members.map(thinShellStyle)
|
|
4981
|
+
const left = Math.min(...boxes.map((box) => box.x))
|
|
4982
|
+
const right = Math.max(...boxes.map((box) => box.x + box.width))
|
|
4983
|
+
thinShellStrips.push({
|
|
4984
|
+
x: left,
|
|
4985
|
+
y: Math.min(...boxes.map((box) => box.y)),
|
|
4986
|
+
width: right - left,
|
|
4987
|
+
height: Math.max(...boxes.map((box) => box.height)),
|
|
4988
|
+
boxes,
|
|
4989
|
+
})
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
// 分段横条认领的壳不再作孤立媒体块(否则同一几何渲染成「缺素材」占位图)
|
|
4993
|
+
const thinStripEls = new Set(thinShellStrips.flatMap((strip) => strip.boxes.map((box) => box.shapeEl)))
|
|
4994
|
+
for (let index = isolatedBlocks.length - 1; index >= 0; index -= 1) {
|
|
4995
|
+
if (thinStripEls.has(isolatedBlocks[index].element)) isolatedBlocks.splice(index, 1)
|
|
4996
|
+
}
|
|
4997
|
+
|
|
4998
|
+
// —— 壳内右列文本(两列拓扑)——
|
|
4999
|
+
// 内容壳(面板壳/渐变壳)右半区独立成列的文本:>=2 个文本左边界落在壳宽 45% 之后且互相成列
|
|
5000
|
+
// (x 差 <=8、分属不同行),壳内左侧另有 >=2 个文本 → 该壳是「左列 + 右列」拓扑而非竖排单列。
|
|
5001
|
+
// 此前右列文本或被压成竖排堆叠(panelRows 路径),或整段落进主视觉死区不渲染(渐变壳路径)。
|
|
5002
|
+
const shellButtonTextEls = new Set(buttons.map((button) => button.sourceEl).filter(Boolean))
|
|
5003
|
+
const shellRightColumns = []
|
|
5004
|
+
for (const shape of [...new Set([...panelShapes, ...gradientPanels.map((gp) => gp.shapeEl)])]) {
|
|
5005
|
+
const sr = rect(shape)
|
|
5006
|
+
const inner = texts.filter((t) => thinInside(t, shape) && !shellButtonTextEls.has(t))
|
|
5007
|
+
if (inner.length < 4) continue
|
|
5008
|
+
const topY = Math.min(...inner.map((t) => rect(t).y || 0))
|
|
5009
|
+
// 标题行(与壳内首个文本同 band)整体保留为整行:名称 + 紧邻短标签不是右列成员
|
|
5010
|
+
const headerEls = inner.filter((t) => Math.abs((rect(t).y || 0) - topY) <= 8)
|
|
5011
|
+
const headerSet = new Set(headerEls)
|
|
5012
|
+
const splitX = (sr.x || 0) + (sr.width || 0) * 0.45
|
|
5013
|
+
const columns = []
|
|
5014
|
+
for (const t of inner.filter((item) => !headerSet.has(item) && (rect(item).x || 0) >= splitX)
|
|
5015
|
+
.slice().sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))) {
|
|
5016
|
+
const tx = rect(t).x || 0
|
|
5017
|
+
const column = columns.find((c) => Math.abs(c.x - tx) <= 8)
|
|
5018
|
+
if (column) column.items.push(t)
|
|
5019
|
+
else columns.push({ x: tx, items: [t] })
|
|
5020
|
+
}
|
|
5021
|
+
const best = columns
|
|
5022
|
+
.filter((column) => column.items.length >= 2
|
|
5023
|
+
&& new Set(column.items.map((t) => Math.round(rect(t).y || 0))).size >= 2)
|
|
5024
|
+
.sort((a, b) => b.items.length - a.items.length)[0]
|
|
5025
|
+
if (!best) continue
|
|
5026
|
+
const bestSet = new Set(best.items)
|
|
5027
|
+
const left = inner.filter((t) => !headerSet.has(t) && !bestSet.has(t) && (rect(t).x || 0) < splitX)
|
|
5028
|
+
if (left.length < 2) continue
|
|
5029
|
+
shellRightColumns.push({
|
|
5030
|
+
shapeEl: shape,
|
|
5031
|
+
x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
|
|
5032
|
+
splitX: best.x,
|
|
5033
|
+
headerEls,
|
|
5034
|
+
texts: best.items.slice().sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0)).map((t) => ({
|
|
5035
|
+
text: textOf(t),
|
|
5036
|
+
color: ts(t).color,
|
|
5037
|
+
fontSize: ts(t).fontSize,
|
|
5038
|
+
fontWeight: ts(t).fontWeight,
|
|
5039
|
+
align: ts(t).textAlign,
|
|
5040
|
+
sourceEl: t,
|
|
5041
|
+
x: rect(t).x || 0, y: rect(t).y || 0,
|
|
5042
|
+
width: rect(t).width || 0, height: rect(t).height || 0,
|
|
5043
|
+
})),
|
|
5044
|
+
})
|
|
5045
|
+
}
|
|
5046
|
+
const shellRightColEls = new Set(shellRightColumns.flatMap((column) => column.texts.map((t) => t.sourceEl)))
|
|
5047
|
+
// 右列文本改由两列拓扑通道渲染:从辅助文案 / 副金额对中回收,避免竖排重影或甩到壳外
|
|
5048
|
+
for (let index = auxTexts.length - 1; index >= 0; index -= 1) {
|
|
5049
|
+
if (shellRightColEls.has(auxTexts[index].sourceEl)) auxTexts.splice(index, 1)
|
|
5050
|
+
}
|
|
5051
|
+
for (let index = subAmounts.length - 1; index >= 0; index -= 1) {
|
|
5052
|
+
const item = subAmounts[index]
|
|
5053
|
+
if (shellRightColEls.has(item.labelEl) || shellRightColEls.has(item.valueEl)) subAmounts.splice(index, 1)
|
|
5054
|
+
}
|
|
5055
|
+
// 右列文本 / 面板成员文本不得经 postAmountTexts 双份渲染(面板两列拓扑已整块接管)。
|
|
5056
|
+
// postAmountTexts 早于 shellRightColumns 计算,此前只从 auxTexts 回收——
|
|
5057
|
+
// listTitle 为 null 时 postAmountTexts 上界=∞,面板内文本漏入独立文本行→重影。
|
|
5058
|
+
for (let index = postAmountTexts.length - 1; index >= 0; index -= 1) {
|
|
5059
|
+
if (shellRightColEls.has(postAmountTexts[index].sourceEl)
|
|
5060
|
+
|| panelTextConsumed.has(postAmountTexts[index].sourceEl)) postAmountTexts.splice(index, 1)
|
|
5061
|
+
}
|
|
3853
5062
|
|
|
5063
|
+
// 选项组文本字符串集:midCluster 与选项组命中同一批文本时(图标+标签 chip 行),
|
|
5064
|
+
// midCluster 再渲染一遍即重影(chip 行双份)→ 选项组优先,midCluster 让位。
|
|
5065
|
+
const optionTextStrings = new Set([...optionTextSet].map((t) => textOf(t)))
|
|
3854
5066
|
return {
|
|
3855
5067
|
canvas: spec.size || { width: 375, height: 812 },
|
|
3856
5068
|
artboardType: spec.artboardType || 'card',
|
|
@@ -3858,7 +5070,8 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3858
5070
|
cardFill: card.cardFill || null,
|
|
3859
5071
|
cardRadius: (card.mask && card.mask.radius && card.mask.radius[0]) || null,
|
|
3860
5072
|
tabs, buttons, optionGroups, primaryAmount, subAmounts, auxTexts, postAmountTexts, tabY, badgeRows,
|
|
3861
|
-
|
|
5073
|
+
topTintBlobs,
|
|
5074
|
+
listTitle, sampleItems, allItems, viewMore, collapseBtn, countdown, topDecoration, formRows, midCluster: midCluster && (midCluster.items || []).length >= 2 && !(midCluster.items || []).every((it) => optionTextStrings.has(it.text)) ? midCluster : null, bindings,
|
|
3862
5075
|
enumStatMap, // 枚举统计行预扫描结果(raw 文本 → {items, sep})
|
|
3863
5076
|
cardBackgroundGradient: fullCardGradientShape?.style?.fill?.gradient || null,
|
|
3864
5077
|
symbolMediaRows,
|
|
@@ -3868,9 +5081,12 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
|
|
|
3868
5081
|
panelRows,
|
|
3869
5082
|
titleUnderlines,
|
|
3870
5083
|
subAmountPanel,
|
|
3871
|
-
orphanSlices,
|
|
5084
|
+
orphanSlices, floatDecos,
|
|
5085
|
+
thinShellRows, thinShellStrips, shellRightColumns,
|
|
3872
5086
|
gradientPanels, panelGroup,
|
|
3873
5087
|
richBlocks,
|
|
5088
|
+
chartClusters,
|
|
5089
|
+
shellLayers,
|
|
3874
5090
|
contentBounds: (() => {
|
|
3875
5091
|
const cardWidth = rect(card).width || 0
|
|
3876
5092
|
const cardHeight = rect(card).height || 0
|
|
@@ -4224,7 +5440,20 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4224
5440
|
if (sem.cardBackgroundGradient) {
|
|
4225
5441
|
ensureStyleGroup(card, 'background')
|
|
4226
5442
|
setStyleChild(card, 'background', 'backgroundType', '03')
|
|
4227
|
-
|
|
5443
|
+
// spec 椭圆优先于采样硬停色带:硬停带的带色本就采样自椭圆投射,带底却留矩形硬边
|
|
5444
|
+
// (设计稿是椭圆柔边衰减)。有椭圆标注时改逐颗 radial-gradient 叠层、以采样带身色
|
|
5445
|
+
// 为底,硬边消失且带顶色温与设计稿逐位对齐。
|
|
5446
|
+
const tintBlobs = sem.topTintBlobs || []
|
|
5447
|
+
if (tintBlobs.length) {
|
|
5448
|
+
const bandHexes = sem.cardBackgroundGradient.match(/#[0-9A-Fa-f]{6}/g) || []
|
|
5449
|
+
const bodyC = hexToRgba(bandHexes[bandHexes.length - 1] || sem.cardFill || T.color('cardBg'))
|
|
5450
|
+
const blobLayers = [...tintBlobs].reverse().map((b) =>
|
|
5451
|
+
`radial-gradient(${b.rx}px ${b.ry}px at ${b.cx}px ${b.cy}px, ${b.rgba} 0%, ${b.rgba} 99%, rgba(0, 0, 0, 0) 100%)`)
|
|
5452
|
+
blobLayers.push(`linear-gradient(to bottom, ${bodyC} 0%, ${bodyC} 100%)`)
|
|
5453
|
+
setStyleChild(card, 'background', 'backgroundGradient', blobLayers.join(', '))
|
|
5454
|
+
} else {
|
|
5455
|
+
setStyleChild(card, 'background', 'backgroundGradient', sem.cardBackgroundGradient)
|
|
5456
|
+
}
|
|
4228
5457
|
}
|
|
4229
5458
|
// 高度必须由内容撑开:设计稿只约束卡片宽度与纵向节奏,
|
|
4230
5459
|
// 写死根高度会在语义块估算偏高时裁切真实内容。
|
|
@@ -4292,8 +5521,16 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4292
5521
|
const bodyC = hexToRgba(sem.cardFill || T.color('cardBg'))
|
|
4293
5522
|
ensureStyleGroup(card, 'background')
|
|
4294
5523
|
setStyleChild(card, 'background', 'backgroundType', '03')
|
|
4295
|
-
|
|
4296
|
-
|
|
5524
|
+
if ((sem.topTintBlobs || []).length) {
|
|
5525
|
+
// 椭圆逐颗 radial-gradient(CSS 首层在最上 → spec 序反排),末层补卡身底色
|
|
5526
|
+
const layers = [...sem.topTintBlobs].reverse().map((b) =>
|
|
5527
|
+
`radial-gradient(${b.rx}px ${b.ry}px at ${b.cx}px ${b.cy}px, ${b.rgba} 0%, ${b.rgba} 99%, rgba(0, 0, 0, 0) 100%)`)
|
|
5528
|
+
layers.push(`linear-gradient(to bottom, ${bodyC} 0%, ${bodyC} 100%)`)
|
|
5529
|
+
setStyleChild(card, 'background', 'backgroundGradient', layers.join(', '))
|
|
5530
|
+
} else {
|
|
5531
|
+
setStyleChild(card, 'background', 'backgroundGradient',
|
|
5532
|
+
`linear-gradient(to bottom, ${bandC} 0%, ${bandC} ${pct}%, ${bodyC} ${pct}%, ${bodyC} 100%)`)
|
|
5533
|
+
}
|
|
4297
5534
|
} else if (td.solidColor) {
|
|
4298
5535
|
// 纯色顶带(弥散背景的设计稿采样近似,无切片素材时的纯色兜底):
|
|
4299
5536
|
// 固定高度 + 纯色背景;内容归属沿用几何 y 边界(y < 带底沿的内容进带内容器)。
|
|
@@ -4338,6 +5575,28 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4338
5575
|
const buildCard = sem.cardRect || {}
|
|
4339
5576
|
const buildCardLeft = buildCard.x || 0
|
|
4340
5577
|
const buildCardRight = buildCardLeft + (buildCard.width || sem.canvas?.width || 375)
|
|
5578
|
+
// 背景壳认领:取包含给定 rect 中心的最内层壳(面积最小者)
|
|
5579
|
+
const shellForRect = (r) => {
|
|
5580
|
+
const cx = (r.x || 0) + (r.width || 0) / 2
|
|
5581
|
+
const cy = (r.y || 0) + (r.height || 0) / 2
|
|
5582
|
+
const hits = (sem.shellLayers || []).filter((sh) =>
|
|
5583
|
+
cx >= sh.x && cx <= sh.x + sh.width && cy >= sh.y && cy <= sh.y + sh.height)
|
|
5584
|
+
if (!hits.length) return null
|
|
5585
|
+
return hits.sort((a, b) => (a.width * a.height) - (b.width * b.height))[0]
|
|
5586
|
+
}
|
|
5587
|
+
const applyShellBackground = (node, sh) => {
|
|
5588
|
+
ensureStyleGroup(node, 'background')
|
|
5589
|
+
ensureStyleGroup(node, 'borderRadius')
|
|
5590
|
+
if (sh.gradient) {
|
|
5591
|
+
setStyleChild(node, 'background', 'backgroundType', '03')
|
|
5592
|
+
setStyleChild(node, 'background', 'backgroundGradient', gradientWithOpacity(sh.gradient, sh.opacity ?? 1))
|
|
5593
|
+
} else {
|
|
5594
|
+
setStyleChild(node, 'background', 'backgroundType', '01')
|
|
5595
|
+
setStyleChild(node, 'background', 'backgroundColor', hexToRgba(sh.fill))
|
|
5596
|
+
}
|
|
5597
|
+
setStyleChild(node, 'borderRadius', 'borderRadiusType', '0')
|
|
5598
|
+
setStyleChild(node, 'borderRadius', 'borderRadius', String(Math.round(sh.radius || 8)))
|
|
5599
|
+
}
|
|
4341
5600
|
const semanticBounds = []
|
|
4342
5601
|
const addSemanticBound = (top, bottom, container = false) => {
|
|
4343
5602
|
if (Number.isFinite(Number(top)) && Number.isFinite(Number(bottom)) && bottom > top) {
|
|
@@ -4351,7 +5610,22 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4351
5610
|
// 与 textFromSpec 实际盒高严格同款(estimatedTextBoxHeight):膨胀系数(fs×1.5)
|
|
4352
5611
|
// 会高估盒高 1-3px/行,把下一元素顶距压小、整卡内容逐行累积上移(实测面板/头部行 -3~-5px)。
|
|
4353
5612
|
y + estimatedTextBoxHeight(height, fontSize)
|
|
4354
|
-
|
|
5613
|
+
// 图表矢量簇占位边界:Canvas 块是流内真实高度,登记后下文顶距才不会从更早的底沿起算
|
|
5614
|
+
for (const cluster of sem.chartClusters || []) {
|
|
5615
|
+
addSemanticBound(cluster.y, cluster.y + cluster.height, true)
|
|
5616
|
+
}
|
|
5617
|
+
for (const aux of sem.auxTexts || []) {
|
|
5618
|
+
addSemanticBound(aux.y, textBoundBottom(aux.y, aux.height, aux.fontSize))
|
|
5619
|
+
// 主视觉领行(文本左侧大切片):流式行高取主视觉高度;主视觉溢出卡顶时流式
|
|
5620
|
+
// 只能从卡顶起排 → 登记实际流式占位(卡顶..卡顶+主视觉高),下方行顶距才不重叠。
|
|
5621
|
+
const leadR = aux.sliceEl ? rect(aux.sliceEl) : null
|
|
5622
|
+
if (leadR && (leadR.height || 0) > (aux.height || 0) * 1.6) {
|
|
5623
|
+
// 与渲染端绝对定位带同口径:带顶=卡顶与(主视觉/文本)顶较大者,带底=子元素底沿较大者
|
|
5624
|
+
const bandTop = Math.max((sem.cardRect && sem.cardRect.y) || 0, Math.min(leadR.y || 0, aux.y || 0))
|
|
5625
|
+
const bandBottom = Math.max((leadR.y || 0) + (leadR.height || 0), (aux.y || 0) + (aux.height || 0))
|
|
5626
|
+
addSemanticBound(bandTop, bandBottom)
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
4355
5629
|
// 序号徽章行:壳高为边界(内容多行时以内容底沿为准)
|
|
4356
5630
|
for (const badgeRow of sem.badgeRows || []) {
|
|
4357
5631
|
const shellR = rect(badgeRow.shellEl)
|
|
@@ -4434,6 +5708,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4434
5708
|
addSemanticBound(sem.topDecoration.y, (sem.topDecoration.y || 0) + (sem.topDecoration.height || 0), true)
|
|
4435
5709
|
}
|
|
4436
5710
|
}
|
|
5711
|
+
let mcValueRowHandlesPrimary = false
|
|
4437
5712
|
if (sem.midCluster) {
|
|
4438
5713
|
addSemanticBound(sem.midCluster.y, sem.midCluster.y + Math.max(
|
|
4439
5714
|
...sem.midCluster.items.map((item) => item.height || (item.fontSize || 14) * 1.4),
|
|
@@ -4453,6 +5728,47 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4453
5728
|
for (const block of sem.isolatedBlocks || []) {
|
|
4454
5729
|
addSemanticBound(block.y, block.y + (block.height || rect(block.element).height || 0))
|
|
4455
5730
|
}
|
|
5731
|
+
for (const strip of sem.thinShellStrips || []) {
|
|
5732
|
+
addSemanticBound(strip.y, (strip.y || 0) + (strip.height || 0))
|
|
5733
|
+
}
|
|
5734
|
+
// 行级薄壳(表格行壳 / 条目壳):壳高是几何真值,壳底沿比内部文本盒低 2-4px;
|
|
5735
|
+
// 只登记文本边界会让后续行顶距多算这几像素,逐行累积成整卡竖向漂移。
|
|
5736
|
+
for (const shell of sem.thinShellRows || []) {
|
|
5737
|
+
addSemanticBound(shell.y, (shell.y || 0) + (shell.height || 0))
|
|
5738
|
+
}
|
|
5739
|
+
// allTexts 条目是归一化形状(顶层 fontSize/color + sourceEl 指回 spec 元素):几何/样式双源回退
|
|
5740
|
+
const anyRect = (t) => (t && t.x != null ? { x: t.x, y: t.y, width: t.width, height: t.height } : rect(t && t.sourceEl ? t.sourceEl : t))
|
|
5741
|
+
const anyFs = (t) => (t && t.fontSize) || ts(t).fontSize || ts(t && t.sourceEl).fontSize
|
|
5742
|
+
const anyColor = (t) => (t && t.color) || ts(t).color || ts(t && t.sourceEl).color
|
|
5743
|
+
// 行尾随切片(链接箭头/信息图标):文本右邻小图标随行渲染并消费,避免孤立媒体兜底再占一行
|
|
5744
|
+
const consumedTrailSlices = new Set()
|
|
5745
|
+
const linkConsumed = new Set()
|
|
5746
|
+
const appendInlineTrailSlice = (row, pat) => {
|
|
5747
|
+
const pr = { x: pat.x || 0, y: pat.y || 0, width: pat.width || 0, height: pat.height || (pat.fontSize || 14) * 1.4 }
|
|
5748
|
+
const centerY = pr.y + pr.height / 2
|
|
5749
|
+
const cand = (sem.slices || []).find((sl) => {
|
|
5750
|
+
if (consumedTrailSlices.has(sl)) return false
|
|
5751
|
+
const sr = rect(sl)
|
|
5752
|
+
const w = sr.width || 0
|
|
5753
|
+
const h = sr.height || 0
|
|
5754
|
+
return w >= 8 && w <= 32 && h >= 8 && h <= 32
|
|
5755
|
+
&& (sr.x || 0) >= pr.x + pr.width - 6 && (sr.x || 0) <= pr.x + pr.width + 56
|
|
5756
|
+
&& Math.abs(((sr.y || 0) + h / 2) - centerY) <= Math.max(12, pr.height / 2 + 6)
|
|
5757
|
+
})
|
|
5758
|
+
if (!cand) return null
|
|
5759
|
+
consumedTrailSlices.add(cand)
|
|
5760
|
+
sem.slices = (sem.slices || []).filter((sl) => sl !== cand)
|
|
5761
|
+
sem.isolatedBlocks = (sem.isolatedBlocks || []).filter((bl) => bl.element !== cand)
|
|
5762
|
+
const img = imageOrPlaceholder(cand, sem.slicesExports)
|
|
5763
|
+
if (!img) return null
|
|
5764
|
+
const cr = rect(cand)
|
|
5765
|
+
setStyleChild(img, 'rect', 'width', String(Math.round(cr.width || 16)))
|
|
5766
|
+
setStyleChild(img, 'rect', 'height', String(Math.round(cr.height || 16)))
|
|
5767
|
+
ensureStyleGroup(row, 'flex')
|
|
5768
|
+
setStyleChild(row, 'flex', 'gap', String(Math.max(0, Math.round((cr.x || 0) - (pr.x + pr.width)))))
|
|
5769
|
+
row.slots[0].components.push(img)
|
|
5770
|
+
return img
|
|
5771
|
+
}
|
|
4456
5772
|
const previousSemanticBottom = (y) => {
|
|
4457
5773
|
// 包含 y 的边界按 y 截断:行高按 fs×1.5 膨胀的边界会盖过下一行顶沿,
|
|
4458
5774
|
// 只认 bottom<=y 会漏掉它、把间距算成跨两行(如"最高可贷金额"顶距被算成 44px 而非 0)。
|
|
@@ -4493,6 +5809,14 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4493
5809
|
2 * T.pad('section'))
|
|
4494
5810
|
: (buildCard.y || 0)
|
|
4495
5811
|
|
|
5812
|
+
// 标题下划线查找表:text content → underline info。提前到 tabs 块之前构建,
|
|
5813
|
+
// 卡头 tabs 路径与下方 aux/band/listTitle 包裹逻辑共用同一张表。
|
|
5814
|
+
const underlineByText = new Map()
|
|
5815
|
+
for (const ul of sem.titleUnderlines || []) {
|
|
5816
|
+
underlineByText.set(ul.text, ul)
|
|
5817
|
+
}
|
|
5818
|
+
const headTabLabels = []
|
|
5819
|
+
|
|
4496
5820
|
// —— 1. Tab 头(Row,space-around)——
|
|
4497
5821
|
if (sem.tabs.length >= 2) {
|
|
4498
5822
|
const tabBar = container('Row', { width: '100%', justify: 'flex-start', align: 'center', padding: T.pad('root') })
|
|
@@ -4546,6 +5870,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4546
5870
|
setStyleChild(lbl, 'other', 'opacity', '0')
|
|
4547
5871
|
}
|
|
4548
5872
|
item.slots[0].components.push(lbl)
|
|
5873
|
+
headTabLabels.push({ col: item, idx: item.slots[0].components.length - 1, value: tab.value, fontSize: tab.fontSize })
|
|
4549
5874
|
// 多 Tab 时绑定 activeTab 变量
|
|
4550
5875
|
if (sem.tabs.length > 1) {
|
|
4551
5876
|
const tabVar = 'activeTab'
|
|
@@ -4611,15 +5936,23 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4611
5936
|
tailBlock.consumedByTabs = true
|
|
4612
5937
|
}
|
|
4613
5938
|
}
|
|
5939
|
+
// 卡头 tabs 的标题下划线:设计稿常用半透明色带垫在标题字下(highlighter 效果),
|
|
5940
|
+
// tabs 路径此前不查 titleUnderlines → 色带整条丢失,像素热区集中在标题带。
|
|
5941
|
+
// 包裹必须放在首/末列对齐调整之后:setAlign 与首列 flex 调整按 components[0]
|
|
5942
|
+
// 取节点,提前包裹会打到 AbsoluteLayout 壳上,多余 flex 键让渲染器走 flex 流。
|
|
5943
|
+
if (!segmented) {
|
|
5944
|
+
for (const entry of headTabLabels) {
|
|
5945
|
+
const ul = underlineByText.get(entry.value)
|
|
5946
|
+
if (!ul) continue
|
|
5947
|
+
entry.col.slots[0].components[entry.idx] = wrapTitleWithUnderline(
|
|
5948
|
+
entry.col.slots[0].components[entry.idx],
|
|
5949
|
+
{ text: entry.value, fontSize: ul.textEl?.textStyle?.fontSize || entry.fontSize || 16 })
|
|
5950
|
+
}
|
|
5951
|
+
}
|
|
4614
5952
|
card.slots[0].components.push(tabBar)
|
|
4615
5953
|
}
|
|
4616
5954
|
|
|
4617
5955
|
const groupedAuxElements = new Set()
|
|
4618
|
-
// 标题下划线查找表:text content → underline info
|
|
4619
|
-
const underlineByText = new Map()
|
|
4620
|
-
for (const ul of sem.titleUnderlines || []) {
|
|
4621
|
-
underlineByText.set(ul.text, ul)
|
|
4622
|
-
}
|
|
4623
5956
|
// 将标题文本 + 下划线包裹为 AbsoluteLayout(禁止负 marginTop)
|
|
4624
5957
|
function wrapTitleWithUnderline(textNode, aux) {
|
|
4625
5958
|
const ul = underlineByText.get(aux.text)
|
|
@@ -4790,6 +6123,13 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4790
6123
|
const sr = shell ? rect(shell) : null
|
|
4791
6124
|
return Math.max((aux.x || 0) + (aux.width || 0), sr ? (sr.x || 0) + (sr.width || 0) : -Infinity)
|
|
4792
6125
|
}
|
|
6126
|
+
// 行内标记切片(时间轴上下小箭头、行内小徽标等):竖直落在某成组文本行带内、
|
|
6127
|
+
// 水平落在行跨度内的未消费小切片,随该行一起渲染。<20px 的切片会被孤立媒体通道
|
|
6128
|
+
// 的尺寸下限滤掉、又无文本配对,此前整块静默丢失(设计稿可见标记不渲染)。
|
|
6129
|
+
const rowInlineSlices = new Set()
|
|
6130
|
+
const itemLeft = (it) => (it.aux ? (it.aux.x || 0) : (rect(it.slice).x || 0))
|
|
6131
|
+
const itemRight = (it) => (it.aux ? auxVisualRight(it.aux)
|
|
6132
|
+
: (rect(it.slice).x || 0) + (rect(it.slice).width || 0))
|
|
4793
6133
|
// 已插入宿主的成组 aux 行登记:供「标题+右侧按钮跨行块」重构时拔出吸收(见下方单行循环)。
|
|
4794
6134
|
const auxRowEntries = []
|
|
4795
6135
|
for (let index = 0; index < sem.auxTexts.length;) {
|
|
@@ -4812,8 +6152,16 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4812
6152
|
if (group.length < 2) continue
|
|
4813
6153
|
if (group.some((aux) => sem.bindings.multiToken?.[aux.text.trim()])) continue
|
|
4814
6154
|
if (group.some((aux) => aux._consumed)) continue
|
|
6155
|
+
// 薄壳行(表格行壳 / 条目壳):整组文本落在同一行级薄壳内 → 壳的底色/描边/圆角由行承载。
|
|
6156
|
+
// 外层行只负责把壳定位到 spec 壳边沿(左右 padding = 壳内缩),壳内文本内缩交给内层行,
|
|
6157
|
+
// 否则背景只染到文本宽、描边框不住整行(此前薄壳两头落空,表格行只剩裸文本)。
|
|
6158
|
+
const thinShell = (sem.thinShellRows || []).find((shell) => shell.texts.length === group.length
|
|
6159
|
+
&& group.every((aux) => shell.texts.includes(aux.sourceEl))) || null
|
|
4815
6160
|
const host = hostFor(group[0].y)
|
|
4816
|
-
|
|
6161
|
+
// 顶距锚点用壳顶沿而非文本顶沿:行级薄壳比内部文本高(文本垂直居中留白 2-4px),
|
|
6162
|
+
// 以文本 y 起算会让壳整体下移,逐行累积成整卡竖向漂移。
|
|
6163
|
+
const topAnchorY = thinShell ? (thinShell.y ?? group[0].y) : group[0].y
|
|
6164
|
+
const topPadding = semanticTopGap(topAnchorY) ?? Math.max(0, Math.round(topAnchorY - (previousAuxBottom ?? buildCard.y)))
|
|
4817
6165
|
const left = Math.min(...group.map((aux) => Number.isFinite(auxVisualLeft(aux)) ? auxVisualLeft(aux) : buildCardLeft))
|
|
4818
6166
|
const right = Math.max(...group.map((aux) => auxVisualRight(aux)))
|
|
4819
6167
|
const leftPadding = Math.max(0, Math.round(left - buildCardLeft))
|
|
@@ -4822,33 +6170,58 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4822
6170
|
// 外层 space-between 只负责簇间分布——3+ 子项直接平铺会被 space-between 打散
|
|
4823
6171
|
// (如「我的账单 (元) … 日期」变成 标题/后缀/日期 三点分散)。
|
|
4824
6172
|
const sortedGroup = group.sort((a, b) => (a.x || 0) - (b.x || 0))
|
|
6173
|
+
const bandTop = Math.min(...group.map((a) => a.y || 0))
|
|
6174
|
+
const bandBottom = Math.max(...group.map((a) =>
|
|
6175
|
+
(a.y || 0) + Math.max(a.height || 0, (a.fontSize || 14) * 1.4)))
|
|
6176
|
+
const spanLeft = Math.min(...group.map((a) => a.x || 0))
|
|
6177
|
+
const spanRight = Math.max(...group.map((a) => auxVisualRight(a)))
|
|
6178
|
+
const rowSlices = (sem.slices || []).filter((sl) => {
|
|
6179
|
+
if (rowInlineSlices.has(sl)) return false
|
|
6180
|
+
const sr = rect(sl)
|
|
6181
|
+
const w = sr.width || 0
|
|
6182
|
+
const h = sr.height || 0
|
|
6183
|
+
if (!w || !h || w > 32 || h > 32) return false
|
|
6184
|
+
if (!imageFromSlice(sl, sem.slicesExports)) return false
|
|
6185
|
+
const cy = (sr.y || 0) + h / 2
|
|
6186
|
+
if (cy < bandTop - 6 || cy > bandBottom + 6) return false
|
|
6187
|
+
if ((sr.x || 0) < spanLeft - 8 || (sr.x || 0) + w > spanRight + 8) return false
|
|
6188
|
+
return true
|
|
6189
|
+
})
|
|
6190
|
+
for (const sl of rowSlices) rowInlineSlices.add(sl)
|
|
6191
|
+
const rowItems = [
|
|
6192
|
+
...sortedGroup.map((aux) => ({ aux })),
|
|
6193
|
+
...rowSlices.map((slice) => ({ slice })),
|
|
6194
|
+
].sort((p, q) => itemLeft(p) - itemLeft(q))
|
|
4825
6195
|
const clusters = []
|
|
4826
|
-
for (const
|
|
6196
|
+
for (const item of rowItems) {
|
|
4827
6197
|
const prev = clusters[clusters.length - 1]
|
|
4828
|
-
const prevRight = prev ? Math.max(...prev.map((
|
|
4829
|
-
if (prev && (
|
|
4830
|
-
else clusters.push([
|
|
6198
|
+
const prevRight = prev ? Math.max(...prev.map((it) => itemRight(it))) : null
|
|
6199
|
+
if (prev && itemLeft(item) - prevRight <= 12) prev.push(item)
|
|
6200
|
+
else clusters.push([item])
|
|
4831
6201
|
}
|
|
4832
6202
|
// 单簇行没有簇间分布对象:space-between 退化为 flex-start,此时右 padding 不参与
|
|
4833
6203
|
// 定位却会把内容盒算窄(壳+文本被挤压裁切)——单簇行去掉右 padding、直接左对齐。
|
|
4834
6204
|
const singleCluster = clusters.length === 1
|
|
6205
|
+
// 薄壳行(表格行壳 / 条目壳):整组文本落在同一行级薄壳内 → 壳的底色/描边/圆角由行承载。
|
|
6206
|
+
// 外层行只负责把壳定位到 spec 壳边沿(左右 padding = 壳内缩),壳内文本内缩交给内层行,
|
|
6207
|
+
// 否则背景只染到文本宽、描边框不住整行(此前薄壳两头落空,表格行只剩裸文本)。
|
|
4835
6208
|
const row = container('Row', {
|
|
4836
6209
|
width: '100%',
|
|
4837
|
-
justify: singleCluster ? 'flex-start' : 'space-between',
|
|
6210
|
+
justify: (singleCluster && !thinShell) ? 'flex-start' : 'space-between',
|
|
4838
6211
|
align: 'center',
|
|
4839
|
-
padding: `${topPadding} ${singleCluster ? 0 : rightPadding} 0 ${leftPadding}`,
|
|
6212
|
+
padding: thinShell ? '0' : `${topPadding} ${singleCluster ? 0 : rightPadding} 0 ${leftPadding}`,
|
|
4840
6213
|
})
|
|
4841
6214
|
for (const cluster of clusters) {
|
|
4842
|
-
if (cluster.length === 1) {
|
|
4843
|
-
row.slots[0].components.push(auxContent(cluster[0]))
|
|
6215
|
+
if (cluster.length === 1 && cluster[0].aux) {
|
|
6216
|
+
row.slots[0].components.push(auxContent(cluster[0].aux))
|
|
4844
6217
|
} else {
|
|
4845
6218
|
const inner = container('Row', { justify: 'flex-start', align: 'center', gap: '0' })
|
|
4846
6219
|
let prevRight = null
|
|
4847
|
-
for (const
|
|
6220
|
+
for (const item of cluster) {
|
|
4848
6221
|
// 簇内逐项 spec 间隙用横向 spacer 承担(Row gap 是固定值,表达不了逐项差异;
|
|
4849
6222
|
// paddingLeft 会让元素盒 x 左移,污染位置评分)。前项有壳时按壳右沿测量。
|
|
4850
6223
|
if (prevRight != null) {
|
|
4851
|
-
const gapW = Math.max(0, Math.round((
|
|
6224
|
+
const gapW = Math.max(0, Math.round(itemLeft(item) - prevRight))
|
|
4852
6225
|
if (gapW > 0) {
|
|
4853
6226
|
const spacer = container('Column', { direction: 'column', padding: '0' })
|
|
4854
6227
|
ensureStyleGroup(spacer, 'rect')
|
|
@@ -4859,17 +6232,60 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
4859
6232
|
inner.slots[0].components.push(spacer)
|
|
4860
6233
|
}
|
|
4861
6234
|
}
|
|
4862
|
-
inner.slots[0].components.push(
|
|
4863
|
-
|
|
6235
|
+
inner.slots[0].components.push(item.aux
|
|
6236
|
+
? auxContent(item.aux)
|
|
6237
|
+
: imageFromSlice(item.slice, sem.slicesExports))
|
|
6238
|
+
prevRight = itemRight(item)
|
|
4864
6239
|
}
|
|
4865
6240
|
row.slots[0].components.push(inner)
|
|
4866
6241
|
}
|
|
4867
|
-
for (const
|
|
4868
|
-
}
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
Math.max(
|
|
4872
|
-
|
|
6242
|
+
for (const item of cluster) if (item.aux) groupedAuxElements.add(item.aux)
|
|
6243
|
+
}
|
|
6244
|
+
let auxInsertNode = row
|
|
6245
|
+
if (thinShell) {
|
|
6246
|
+
const shellInsetL = Math.max(0, Math.round((thinShell.x || 0) - buildCardLeft))
|
|
6247
|
+
const shellInsetR = Math.max(0, Math.round(buildCardRight - ((thinShell.x || 0) + (thinShell.width || 0))))
|
|
6248
|
+
const textLeft = Math.min(...group.map((aux) => auxVisualLeft(aux)))
|
|
6249
|
+
const textRight = Math.max(...group.map((aux) => auxVisualRight(aux)))
|
|
6250
|
+
const borderW = thinShell.isBorder ? Math.max(0.5, thinShell.borderWidth || 1) : 0
|
|
6251
|
+
const shellWrap = container('Row', {
|
|
6252
|
+
width: '100%', justify: 'flex-start', align: 'center',
|
|
6253
|
+
padding: `${topPadding} ${shellInsetR} 0 ${shellInsetL}`,
|
|
6254
|
+
})
|
|
6255
|
+
setStyleChild(row, 'padding', 'paddingType', '1')
|
|
6256
|
+
setStyleChild(row, 'padding', 'paddingTop', '0')
|
|
6257
|
+
setStyleChild(row, 'padding', 'paddingBottom', '0')
|
|
6258
|
+
setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round(textLeft - (thinShell.x || 0)))))
|
|
6259
|
+
setStyleChild(row, 'padding', 'paddingRight', String(Math.max(0, Math.round((thinShell.x || 0) + (thinShell.width || 0) - textRight))))
|
|
6260
|
+
// 壳高是几何真值:钉死内层行高(border-box),文本靠 align:center 垂直居中,
|
|
6261
|
+
// 用 padding 反推壳高会因渲染器 1px 取整逐行累积 0.5-1px 漂移。
|
|
6262
|
+
setStyleChild(row, 'rect', 'height', String(Math.round(thinShell.height || 0)))
|
|
6263
|
+
ensureStyleGroup(row, 'background')
|
|
6264
|
+
if (thinShell.gradient) {
|
|
6265
|
+
setStyleChild(row, 'background', 'backgroundType', '03')
|
|
6266
|
+
setStyleChild(row, 'background', 'backgroundGradient', gradientWithOpacity(thinShell.gradient, thinShell.opacity ?? 1))
|
|
6267
|
+
} else if (thinShell.fill) {
|
|
6268
|
+
setStyleChild(row, 'background', 'backgroundType', '01')
|
|
6269
|
+
setStyleChild(row, 'background', 'backgroundColor', hexToRgba(thinShell.fill, thinShell.opacity ?? 1))
|
|
6270
|
+
}
|
|
6271
|
+
if (thinShell.isBorder) {
|
|
6272
|
+
ensureStyleGroup(row, 'border')
|
|
6273
|
+
setStyleChild(row, 'border', 'isBorder', '1')
|
|
6274
|
+
setStyleChild(row, 'border', 'borderWidth', String(borderW))
|
|
6275
|
+
setStyleChild(row, 'border', 'borderStyle', 'solid')
|
|
6276
|
+
setStyleChild(row, 'border', 'borderColor', hexToRgba(thinShell.borderColor || '#E7E7EE'))
|
|
6277
|
+
}
|
|
6278
|
+
ensureStyleGroup(row, 'borderRadius')
|
|
6279
|
+
setStyleChild(row, 'borderRadius', 'borderRadiusType', '0')
|
|
6280
|
+
setStyleChild(row, 'borderRadius', 'borderRadius', String(Math.round(thinShell.radius || 0)))
|
|
6281
|
+
shellWrap.slots[0].components.push(row)
|
|
6282
|
+
auxInsertNode = shellWrap
|
|
6283
|
+
}
|
|
6284
|
+
insertSemantic(host, auxInsertNode, group[0].y)
|
|
6285
|
+
const groupBottom = Math.max(group[0].y + Math.max(...group.map((aux) =>
|
|
6286
|
+
Math.max(aux.height || 0, (aux.fontSize || 14) * 1.4))),
|
|
6287
|
+
thinShell ? (thinShell.y || 0) + (thinShell.height || 0) : 0)
|
|
6288
|
+
auxRowEntries.push({ node: auxInsertNode, host, y0: group[0].y, y1: groupBottom, absorbed: false })
|
|
4873
6289
|
previousAuxBottom = groupBottom
|
|
4874
6290
|
}
|
|
4875
6291
|
for (const aux of sem.auxTexts) {
|
|
@@ -5003,14 +6419,74 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5003
6419
|
insertSemantic(host, row, aux.y)
|
|
5004
6420
|
}
|
|
5005
6421
|
} else {
|
|
5006
|
-
const
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
6422
|
+
const leadR = aux.sliceEl ? rect(aux.sliceEl) : null
|
|
6423
|
+
const decoR = aux.decoSliceEl ? rect(aux.decoSliceEl) : null
|
|
6424
|
+
const heroLead = leadR && (leadR.height || 0) > (aux.height || 0) * 1.6
|
|
6425
|
+
if (heroLead) {
|
|
6426
|
+
// 主视觉领行用 AbsoluteLayout 绝对定位带:主视觉常溢出卡顶(吉祥物骑在卡边上),
|
|
6427
|
+
// 流式行会被 clamp 到卡顶且占满主视觉全高,把下文整带顶偏;绝对定位带只占卡内
|
|
6428
|
+
// 可见高度,子元素按 spec 偏移脱标摆放(top 可为负,溢出部分由卡裁切,与设计裁切同构)。
|
|
6429
|
+
const cardX = buildCard.x || 0
|
|
6430
|
+
const cardTop = buildCard.y || 0
|
|
6431
|
+
const bandTop = Math.max(cardTop, Math.min(leadR.y || 0, aux.y || 0))
|
|
6432
|
+
const bandBottom = Math.max((leadR.y || 0) + (leadR.height || 0),
|
|
6433
|
+
(aux.y || 0) + (aux.height || 0),
|
|
6434
|
+
decoR ? (decoR.y || 0) + (decoR.height || 0) : 0)
|
|
6435
|
+
const band = comp('AbsoluteLayout')
|
|
6436
|
+
ensureStyleGroup(band, 'rect')
|
|
6437
|
+
ensureStyleGroup(band, 'flex')
|
|
6438
|
+
setStyleChild(band, 'rect', 'width', String(Math.round(buildCard.width || 0)))
|
|
6439
|
+
setStyleChild(band, 'rect', 'height', String(Math.max(1, Math.round(bandBottom - bandTop))))
|
|
6440
|
+
setStyleChild(band, 'flex', 'flexShrink', '0')
|
|
6441
|
+
const bandFlex = (band.style.properties || []).find((p) => p.itemKey === 'flex')
|
|
6442
|
+
if (bandFlex) bandFlex.itemChildren.properties = (bandFlex.itemChildren?.properties || [])
|
|
6443
|
+
.filter((c) => c.itemKey === 'flexShrink')
|
|
6444
|
+
band.properties = [constProp('align', 'BottomLeft')]
|
|
6445
|
+
band.slots = [{ slotKey: 'default', slotName: '默认插槽', slotSwitch: '1', components: [] }]
|
|
6446
|
+
// 渲染器无 position 组解析:绝对子元素偏移只能走 margin(静态位置=align 角 + margin 偏移)
|
|
6447
|
+
// 渲染器无 position 组解析(preview 只认 rect/flex/margin/…):绝对子元素偏移走 margin。
|
|
6448
|
+
// 带 align=BottomLeft → 静态位置=带底左角;marginBottom=带底-子元素底 恒为非负
|
|
6449
|
+
// (R38 禁负 marginTop),主视觉溢出卡顶的部分由子元素高于带高自然脱出裁切。
|
|
6450
|
+
const place = (node, elR) => {
|
|
6451
|
+
ensureStyleGroup(node, 'margin')
|
|
6452
|
+
setStyleChild(node, 'margin', 'marginType', '1')
|
|
6453
|
+
setStyleChild(node, 'margin', 'marginTop', '0')
|
|
6454
|
+
setStyleChild(node, 'margin', 'marginLeft', String(Math.round((elR.x || 0) - cardX)))
|
|
6455
|
+
setStyleChild(node, 'margin', 'marginRight', '0')
|
|
6456
|
+
setStyleChild(node, 'margin', 'marginBottom', String(Math.max(0, Math.round(bandBottom - ((elR.y || 0) + (elR.height || 0))))))
|
|
6457
|
+
return node
|
|
6458
|
+
}
|
|
6459
|
+
const leadMedia = imageOrPlaceholder(aux.sliceEl, sem.slicesExports)
|
|
6460
|
+
if (leadMedia) band.slots[0].components.push(place(leadMedia, leadR))
|
|
6461
|
+
band.slots[0].components.push(place(content, aux))
|
|
6462
|
+
if (aux.decoSliceEl) {
|
|
6463
|
+
const decoMedia = imageOrPlaceholder(aux.decoSliceEl, sem.slicesExports)
|
|
6464
|
+
if (decoMedia) band.slots[0].components.push(place(decoMedia, decoR))
|
|
6465
|
+
}
|
|
6466
|
+
insertSemantic(host, band, bandTop)
|
|
6467
|
+
blockBottomOverride = bandBottom
|
|
6468
|
+
} else {
|
|
6469
|
+
const row = container('Row', { width: '100%', justify: 'flex-start', align: 'center', gap: T.gap('tight'), padding: geometryPadding })
|
|
6470
|
+
// 左对齐行同样要带图标(如"信用卡"左侧卡片图标),此前只右/居中分支渲染 → 图标丢失
|
|
6471
|
+
if (aux.sliceEl) {
|
|
6472
|
+
const auxMedia = imageOrPlaceholder(aux.sliceEl, sem.slicesExports)
|
|
6473
|
+
if (auxMedia) row.slots[0].components.push(auxMedia)
|
|
6474
|
+
}
|
|
6475
|
+
if (aux.decoSliceEl) {
|
|
6476
|
+
const col = container('Column', { direction: 'column', align: 'flex-start', gap: T.gap('none') })
|
|
6477
|
+
ensureStyleGroup(col, 'rect')
|
|
6478
|
+
setStyleChild(col, 'rect', 'width', String(Math.round(aux.width || 0)))
|
|
6479
|
+
col.slots[0].components.push(content)
|
|
6480
|
+
const decoRow = container('Row', { width: '100%', justify: 'flex-end', align: 'center', gap: '0' })
|
|
6481
|
+
const decoMedia = imageOrPlaceholder(aux.decoSliceEl, sem.slicesExports)
|
|
6482
|
+
if (decoMedia) decoRow.slots[0].components.push(decoMedia)
|
|
6483
|
+
col.slots[0].components.push(decoRow)
|
|
6484
|
+
row.slots[0].components.push(col)
|
|
6485
|
+
} else {
|
|
6486
|
+
row.slots[0].components.push(content)
|
|
6487
|
+
}
|
|
6488
|
+
insertSemantic(host, row, aux.y)
|
|
5011
6489
|
}
|
|
5012
|
-
row.slots[0].components.push(content)
|
|
5013
|
-
insertSemantic(host, row, aux.y)
|
|
5014
6490
|
}
|
|
5015
6491
|
previousAuxBottom = blockBottomOverride ?? (aux.y + Math.max(aux.height || 0, (aux.fontSize || 14) * 1.4))
|
|
5016
6492
|
}
|
|
@@ -5182,6 +6658,31 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5182
6658
|
// —— 2b. 几何选项组(标签 + 同构短壳行)。 ——
|
|
5183
6659
|
// 每组保持标注几何:标签间距、壳宽/高、列间距都来自 spec;根容器仍为 auto,不写死整体高度。
|
|
5184
6660
|
let previousOptionBottom = previousAuxBottom
|
|
6661
|
+
// chip 壳内图标:与成员文本同壳、位于文本左侧的小方形切片。CeButton 只渲染 value 文本,
|
|
6662
|
+
// 没有子节点通道,图标必须让 chip 降级成 Row[Image, Text](壳样式搬到 Row 上)。
|
|
6663
|
+
const optionChipIcon = (member) => {
|
|
6664
|
+
const shell = member.sourceShapeEl
|
|
6665
|
+
if (!shell) return null
|
|
6666
|
+
const hr = rect(shell)
|
|
6667
|
+
const tr = member.sourceTextEl ? rect(member.sourceTextEl) : null
|
|
6668
|
+
const icon = (sem.slices || []).find((slice) => {
|
|
6669
|
+
const sr = rect(slice)
|
|
6670
|
+
const w = sr.width || 0
|
|
6671
|
+
const h = sr.height || 0
|
|
6672
|
+
if (!w || !h || w > 40 || h > 40) return false
|
|
6673
|
+
if (Math.abs(w - h) > Math.max(8, w * 0.4)) return false
|
|
6674
|
+
if ((sr.x || 0) < (hr.x || 0) - 2 || (sr.y || 0) < (hr.y || 0) - 2) return false
|
|
6675
|
+
if (((sr.x || 0) + w) > ((hr.x || 0) + (hr.width || 0)) + 2) return false
|
|
6676
|
+
if (((sr.y || 0) + h) > ((hr.y || 0) + (hr.height || 0)) + 2) return false
|
|
6677
|
+
return !tr || ((sr.x || 0) + w) <= (tr.x || 0) + 2
|
|
6678
|
+
})
|
|
6679
|
+
if (!icon) return null
|
|
6680
|
+
const media = imageOrPlaceholder(icon, sem.slicesExports)
|
|
6681
|
+
if (!media) return null
|
|
6682
|
+
const ir = rect(icon)
|
|
6683
|
+
const gap = tr ? Math.max(0, Math.round((tr.x || 0) - ((ir.x || 0) + (ir.width || 0)))) : 4
|
|
6684
|
+
return { media, gap, sliceEl: icon }
|
|
6685
|
+
}
|
|
5185
6686
|
for (const group of sem.optionGroups || []) {
|
|
5186
6687
|
// 标题在顶带内、选项在带外:拆开渲染。否则按 labelY 归顶带宿主(固定带高),
|
|
5187
6688
|
// 整个选项组被带高裁掉(选项 pill 全消失)。
|
|
@@ -5272,12 +6773,23 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5272
6773
|
// 会被渲染器忽略、回落到容器默认 gap(曾把 13px 选项间距塌成 4px,右列整体左移 9px)。
|
|
5273
6774
|
if (gap > 0) setStyleChild(chipsRow, 'flex', 'gap', String(Math.round(gap)))
|
|
5274
6775
|
}
|
|
5275
|
-
const
|
|
6776
|
+
const chipIcon = optionChipIcon(member)
|
|
6777
|
+
const chip = chipIcon
|
|
6778
|
+
? container('Row', {
|
|
6779
|
+
width: String(Math.round(member.width || 0)),
|
|
6780
|
+
direction: 'row',
|
|
6781
|
+
justify: 'center',
|
|
6782
|
+
align: 'center',
|
|
6783
|
+
gap: String(chipIcon.gap),
|
|
6784
|
+
padding: '0',
|
|
6785
|
+
flexShrink: 0,
|
|
6786
|
+
})
|
|
6787
|
+
: comp('Button')
|
|
5276
6788
|
ensureStyleGroup(chip, 'rect')
|
|
5277
6789
|
ensureStyleGroup(chip, 'flex')
|
|
5278
6790
|
ensureStyleGroup(chip, 'background')
|
|
5279
6791
|
ensureStyleGroup(chip, 'borderRadius')
|
|
5280
|
-
ensureStyleGroup(chip, 'font')
|
|
6792
|
+
if (!chipIcon) ensureStyleGroup(chip, 'font')
|
|
5281
6793
|
setStyleChild(chip, 'rect', 'width', String(Math.round(member.width || 0)))
|
|
5282
6794
|
setStyleChild(chip, 'rect', 'height', String(Math.round(member.height || 0)))
|
|
5283
6795
|
setStyleChild(chip, 'flex', 'flexShrink', '0')
|
|
@@ -5290,11 +6802,22 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5290
6802
|
}
|
|
5291
6803
|
setStyleChild(chip, 'borderRadius', 'borderRadiusType', '0')
|
|
5292
6804
|
setStyleChild(chip, 'borderRadius', 'borderRadius', String(Math.round(member.radius || 6)))
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
6805
|
+
if (chipIcon) {
|
|
6806
|
+
chip.slots[0].components.push(chipIcon.media)
|
|
6807
|
+
chip.slots[0].components.push(textFromSpec({
|
|
6808
|
+
text: member.text,
|
|
6809
|
+
color: member.color,
|
|
6810
|
+
fontSize: member.fontSize || 14,
|
|
6811
|
+
fontWeight: member.fontWeight || '400',
|
|
6812
|
+
align: 'center',
|
|
6813
|
+
}))
|
|
6814
|
+
} else {
|
|
6815
|
+
setStyleChild(chip, 'font', 'color', hexToRgba(member.color))
|
|
6816
|
+
setStyleChild(chip, 'font', 'fontSize', String(member.fontSize || 14))
|
|
6817
|
+
setStyleChild(chip, 'font', 'fontWeight', String(member.fontWeight || '400'))
|
|
6818
|
+
setStyleChild(chip, 'font', 'textAlign', 'center')
|
|
6819
|
+
chip.properties = [constProp('value', member.text)]
|
|
6820
|
+
}
|
|
5298
6821
|
chip.eventactions = [clickAction(`// ${member.text} 点击`)]
|
|
5299
6822
|
chipsRow.slots[0].components.push(chip)
|
|
5300
6823
|
})
|
|
@@ -5311,6 +6834,15 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5311
6834
|
const width = sr.width || 0
|
|
5312
6835
|
const height = sr.height || 0
|
|
5313
6836
|
const firstItem = sem.sampleItems[0]
|
|
6837
|
+
// 行右大图标内嵌徽标(icon 角标)不是行尾 affordance:落在 rightIcon 盒内即排除,
|
|
6838
|
+
// 否则被当静态行尾箭头逐行渲染,挤占左栏宽度致 desc 换行(保险保障行实测)
|
|
6839
|
+
const insideRightIcon = sem.sampleItems.some((it2) => {
|
|
6840
|
+
const ri = it2.rightIconEl ? rect(it2.rightIconEl) : null
|
|
6841
|
+
return !!ri && (sr.x || 0) >= (ri.x || 0) - 6 && (sr.y || 0) >= (ri.y || 0) - 6
|
|
6842
|
+
&& (sr.x || 0) + width <= (ri.x || 0) + (ri.width || 0) + 6
|
|
6843
|
+
&& (sr.y || 0) + height <= (ri.y || 0) + (ri.height || 0) + 6
|
|
6844
|
+
})
|
|
6845
|
+
if (insideRightIcon) return false
|
|
5314
6846
|
return width >= 12 && width <= 40 && height >= 12 && height <= 40
|
|
5315
6847
|
&& Math.abs(width - height) <= Math.max(10, width * 0.5)
|
|
5316
6848
|
&& ((sr.x || 0) + width) >= buildCardRight - Math.max(56, width * 2)
|
|
@@ -5390,6 +6922,21 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5390
6922
|
if (row) row.push(item)
|
|
5391
6923
|
else panelTextRows.push([item])
|
|
5392
6924
|
}
|
|
6925
|
+
// 标签 + 单位后缀((元)/万)相邻层合并为单文本:否则汇总区行项数不齐(4 vs 2),
|
|
6926
|
+
// 两列拓扑无法成立;单位层色差是可接受代价。
|
|
6927
|
+
for (const rowItems of panelTextRows) {
|
|
6928
|
+
rowItems.sort((a, b) => (a.x || 0) - (b.x || 0))
|
|
6929
|
+
for (let i2 = 0; i2 < rowItems.length - 1; i2 += 1) {
|
|
6930
|
+
const cur2 = rowItems[i2]
|
|
6931
|
+
const nxt2 = rowItems[i2 + 1]
|
|
6932
|
+
const ct = String(cur2.text || '').trim()
|
|
6933
|
+
const nt = String(nxt2.text || '').trim()
|
|
6934
|
+
if (/^[((]?[元万千亿]+[))]?$/.test(nt) && !/\d/.test(ct) && /[一-龥]/.test(ct)
|
|
6935
|
+
&& Math.abs(((cur2.x || 0) + (cur2.width || 0)) - (nxt2.x || 0)) <= 6) {
|
|
6936
|
+
rowItems.splice(i2, 2, { ...cur2, text: ct + ' ' + nt, width: (cur2.width || 0) + (nxt2.width || 0) + 4 })
|
|
6937
|
+
}
|
|
6938
|
+
}
|
|
6939
|
+
}
|
|
5393
6940
|
// 逐行行距预算:行盒高用渲染同款估算(estimatedTextBoxHeight),
|
|
5394
6941
|
// 保证「估算底沿 == 渲染流式底沿」,下一行 paddingTop = 标注行顶 - 上一行渲染底沿。
|
|
5395
6942
|
const panelRowMetrics = panelTextRows.map((items) => {
|
|
@@ -5415,34 +6962,52 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5415
6962
|
gap: T.gap('none'), width: '100%',
|
|
5416
6963
|
})
|
|
5417
6964
|
for (const items of gridColumns) {
|
|
6965
|
+
// 列对齐按标注几何:左沿对齐→flex-start,右沿对齐→flex-end,否则居中
|
|
6966
|
+
const lefts = items.map((it2) => Math.round(it2.x || 0))
|
|
6967
|
+
const rights = items.map((it2) => Math.round((it2.x || 0) + (it2.width || 0)))
|
|
6968
|
+
const colAlign = Math.max(...lefts) - Math.min(...lefts) <= 6
|
|
6969
|
+
? 'flex-start'
|
|
6970
|
+
: (Math.max(...rights) - Math.min(...rights) <= 6 ? 'flex-end' : 'center')
|
|
5418
6971
|
const column = container('Column', {
|
|
5419
|
-
direction: 'column', justify: 'flex-start', align:
|
|
5420
|
-
gap: T.gap('tight'), width: '
|
|
6972
|
+
direction: 'column', justify: 'flex-start', align: colAlign,
|
|
6973
|
+
gap: T.gap('tight'), width: String(Math.floor(100 / gridColumns.length)) + '%',
|
|
5421
6974
|
})
|
|
5422
6975
|
for (const item of items) {
|
|
5423
6976
|
column.slots[0].components.push(textFromSpec(applyLiteralBind({
|
|
5424
6977
|
...item,
|
|
5425
|
-
align: 'center',
|
|
6978
|
+
align: colAlign === 'flex-start' ? 'left' : (colAlign === 'flex-end' ? 'right' : 'center'),
|
|
5426
6979
|
}, sem.bindings.literalBind)))
|
|
5427
6980
|
}
|
|
5428
6981
|
gridRow.slots[0].components.push(column)
|
|
5429
6982
|
}
|
|
5430
6983
|
textColumn.slots[0].components.push(gridRow)
|
|
5431
6984
|
} else {
|
|
5432
|
-
|
|
6985
|
+
const panelRowMetric = (items) => ({
|
|
6986
|
+
top: Math.min(...items.map((item) => item.y || 0)),
|
|
6987
|
+
boxH: Math.max(...items.map((item) => estimatedTextBoxHeight(item.height, item.fontSize))),
|
|
6988
|
+
})
|
|
6989
|
+
const groupPanelRow = (list) => {
|
|
6990
|
+
const rows = []
|
|
6991
|
+
for (const item of list.slice().sort((a, b) => (a.y || 0) - (b.y || 0) || (a.x || 0) - (b.x || 0))) {
|
|
6992
|
+
const found = rows.find((group) => Math.abs((group[0].y || 0) - (item.y || 0)) <= 8)
|
|
6993
|
+
if (found) found.push(item)
|
|
6994
|
+
else rows.push([item])
|
|
6995
|
+
}
|
|
6996
|
+
return rows
|
|
6997
|
+
}
|
|
6998
|
+
const buildPanelTextRow = (items, prevMetric) => {
|
|
5433
6999
|
const textRow = container('Row', {
|
|
5434
7000
|
width: '100%', justify: 'flex-start', align: 'center', gap: T.gap('tight'),
|
|
5435
7001
|
})
|
|
5436
|
-
if (
|
|
5437
|
-
const prev = panelRowMetrics[rowIndex - 1]
|
|
7002
|
+
if (prevMetric) {
|
|
5438
7003
|
const rowGap = Math.max(0, Math.round(
|
|
5439
|
-
|
|
7004
|
+
panelRowMetric(items).top - (prevMetric.top + prevMetric.boxH)))
|
|
5440
7005
|
setStyleChild(textRow, 'padding', 'paddingType', '1')
|
|
5441
7006
|
setStyleChild(textRow, 'padding', 'paddingTop', String(rowGap))
|
|
5442
7007
|
}
|
|
5443
7008
|
// 裸货币符号层 + 紧邻数字层(spec 拆层,x 间隙 ≤6px)合并为一个 Text:
|
|
5444
7009
|
// 分开渲染会被行 gap 撑出缝隙("¥ 519.27"),设计稿是紧贴的。数字层的绑定保留(含 ¥ 前缀表达式)。
|
|
5445
|
-
const sortedItems = items.sort((a, b) => (a.x || 0) - (b.x || 0))
|
|
7010
|
+
const sortedItems = items.slice().sort((a, b) => (a.x || 0) - (b.x || 0))
|
|
5446
7011
|
const mergedItems = []
|
|
5447
7012
|
for (let i = 0; i < sortedItems.length; i++) {
|
|
5448
7013
|
const cur = sortedItems[i]
|
|
@@ -5468,8 +7033,117 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5468
7033
|
align: 'left',
|
|
5469
7034
|
}, sem.bindings.literalBind)))
|
|
5470
7035
|
}
|
|
5471
|
-
|
|
5472
|
-
}
|
|
7036
|
+
return textRow
|
|
7037
|
+
}
|
|
7038
|
+
// 壳内两列拓扑:壳右半区独立成列的文本(sem.shellRightColumns)不与左列竖排堆叠,
|
|
7039
|
+
// 而是 Row[Column(左列), Column(右列)] 并排;标题行整行留在两列之上,
|
|
7040
|
+
// 右列之下的通栏内容仍整行排在两列块之后(此前右列被压成竖排,壳内文本互相挤位)。
|
|
7041
|
+
const splitCol = (sem.shellRightColumns || [])
|
|
7042
|
+
.find((column) => column.shapeEl === panel.shapeEl) || null
|
|
7043
|
+
const splitLayout = (() => {
|
|
7044
|
+
if (!splitCol || panelTextRows.length < 2) return null
|
|
7045
|
+
// 右列成员按 spec 坐标精确对账(不用 x 阈值:标题行右侧的紧邻短标签
|
|
7046
|
+
// 与右列左边界只差几 px,阈值判定会把它误收进右列)
|
|
7047
|
+
const rightKeys = new Set(splitCol.texts.map((item) =>
|
|
7048
|
+
`${Math.round(item.x || 0)},${Math.round(item.y || 0)}`))
|
|
7049
|
+
const isRight = (item) => rightKeys.has(`${Math.round(item.x || 0)},${Math.round(item.y || 0)}`)
|
|
7050
|
+
const topRowY = Math.min(...panelTextRows[0].map((item) => item.y || 0))
|
|
7051
|
+
const headerRows = []
|
|
7052
|
+
const restRows = []
|
|
7053
|
+
for (const items of panelTextRows) {
|
|
7054
|
+
const rowTop = panelRowMetric(items).top
|
|
7055
|
+
if (Math.abs(rowTop - topRowY) <= 8 && !items.some(isRight)) headerRows.push(items)
|
|
7056
|
+
else restRows.push(items)
|
|
7057
|
+
}
|
|
7058
|
+
const rightItems = restRows.flatMap((items) => items.filter(isRight))
|
|
7059
|
+
if (rightItems.length < 2) return null
|
|
7060
|
+
const rightRows = groupPanelRow(rightItems)
|
|
7061
|
+
const rightTop = panelRowMetric(rightRows[0]).top
|
|
7062
|
+
const rightBottom = Math.max(...rightRows.map((items) => {
|
|
7063
|
+
const metric = panelRowMetric(items)
|
|
7064
|
+
return metric.top + metric.boxH
|
|
7065
|
+
}))
|
|
7066
|
+
const footerRows = restRows.filter((items) =>
|
|
7067
|
+
panelRowMetric(items).top > rightBottom + 8 && !items.some(isRight))
|
|
7068
|
+
const footerItems = new Set(footerRows.flat())
|
|
7069
|
+
const leftItems = restRows.flat().filter((item) => !isRight(item) && !footerItems.has(item))
|
|
7070
|
+
if (!leftItems.length) return null
|
|
7071
|
+
const leftRows = groupPanelRow(leftItems)
|
|
7072
|
+
const leftTop = panelRowMetric(leftRows[0]).top
|
|
7073
|
+
const columnHeight = (rows, extraTop) => {
|
|
7074
|
+
let height = extraTop
|
|
7075
|
+
let prev = null
|
|
7076
|
+
for (const items of rows) {
|
|
7077
|
+
const metric = panelRowMetric(items)
|
|
7078
|
+
if (prev) height += Math.max(0, Math.round(metric.top - (prev.top + prev.boxH)))
|
|
7079
|
+
height += metric.boxH
|
|
7080
|
+
prev = metric
|
|
7081
|
+
}
|
|
7082
|
+
return height
|
|
7083
|
+
}
|
|
7084
|
+
const blockTop = Math.min(leftTop, rightTop)
|
|
7085
|
+
return {
|
|
7086
|
+
headerRows, leftRows, rightRows, footerRows, blockTop,
|
|
7087
|
+
leftExtra: leftTop - blockTop,
|
|
7088
|
+
rightExtra: rightTop - blockTop,
|
|
7089
|
+
blockHeight: Math.max(
|
|
7090
|
+
columnHeight(leftRows, leftTop - blockTop),
|
|
7091
|
+
columnHeight(rightRows, rightTop - blockTop)),
|
|
7092
|
+
}
|
|
7093
|
+
})()
|
|
7094
|
+
const fillPanelColumn = (column, rows, extraTop) => {
|
|
7095
|
+
if (extraTop > 0) {
|
|
7096
|
+
setStyleChild(column, 'padding', 'paddingType', '1')
|
|
7097
|
+
setStyleChild(column, 'padding', 'paddingTop', String(Math.round(extraTop)))
|
|
7098
|
+
}
|
|
7099
|
+
let prev = null
|
|
7100
|
+
for (const items of rows) {
|
|
7101
|
+
column.slots[0].components.push(buildPanelTextRow(items, prev))
|
|
7102
|
+
prev = panelRowMetric(items)
|
|
7103
|
+
}
|
|
7104
|
+
}
|
|
7105
|
+
if (splitLayout) {
|
|
7106
|
+
let prev = null
|
|
7107
|
+
for (const items of splitLayout.headerRows) {
|
|
7108
|
+
textColumn.slots[0].components.push(buildPanelTextRow(items, prev))
|
|
7109
|
+
prev = panelRowMetric(items)
|
|
7110
|
+
}
|
|
7111
|
+
const leftW = Math.max(1, Math.round(splitCol.splitX - ((panel.x || 0) + (panel.paddingLeft || 0))))
|
|
7112
|
+
const contentW = Math.max(1, Math.round((panel.width || 0)
|
|
7113
|
+
- (panel.paddingLeft || 0) - (panel.paddingRight || 0)))
|
|
7114
|
+
const twoCol = container('Row', {
|
|
7115
|
+
width: '100%', direction: 'row', justify: 'flex-start', align: 'flex-start',
|
|
7116
|
+
gap: T.gap('none'), padding: '0',
|
|
7117
|
+
})
|
|
7118
|
+
if (prev) {
|
|
7119
|
+
setStyleChild(twoCol, 'padding', 'paddingType', '1')
|
|
7120
|
+
setStyleChild(twoCol, 'padding', 'paddingTop', String(Math.max(0,
|
|
7121
|
+
Math.round(splitLayout.blockTop - (prev.top + prev.boxH)))))
|
|
7122
|
+
}
|
|
7123
|
+
const leftCol = container('Column', {
|
|
7124
|
+
direction: 'column', width: String(leftW), justify: 'flex-start', align: 'flex-start',
|
|
7125
|
+
gap: T.gap('none'), padding: '0', flexShrink: 0,
|
|
7126
|
+
})
|
|
7127
|
+
const rightCol = container('Column', {
|
|
7128
|
+
direction: 'column', width: String(Math.max(1, contentW - leftW)), justify: 'flex-start',
|
|
7129
|
+
align: 'flex-start', gap: T.gap('none'), padding: '0', flexShrink: 0,
|
|
7130
|
+
})
|
|
7131
|
+
fillPanelColumn(leftCol, splitLayout.leftRows, splitLayout.leftExtra)
|
|
7132
|
+
fillPanelColumn(rightCol, splitLayout.rightRows, splitLayout.rightExtra)
|
|
7133
|
+
twoCol.slots[0].components.push(leftCol, rightCol)
|
|
7134
|
+
textColumn.slots[0].components.push(twoCol)
|
|
7135
|
+
prev = { top: splitLayout.blockTop, boxH: splitLayout.blockHeight }
|
|
7136
|
+
for (const items of splitLayout.footerRows) {
|
|
7137
|
+
textColumn.slots[0].components.push(buildPanelTextRow(items, prev))
|
|
7138
|
+
prev = panelRowMetric(items)
|
|
7139
|
+
}
|
|
7140
|
+
} else {
|
|
7141
|
+
let prev = null
|
|
7142
|
+
for (const items of panelTextRows) {
|
|
7143
|
+
textColumn.slots[0].components.push(buildPanelTextRow(items, prev))
|
|
7144
|
+
prev = panelRowMetric(items)
|
|
7145
|
+
}
|
|
7146
|
+
}
|
|
5473
7147
|
}
|
|
5474
7148
|
row.slots[0].components.push(textColumn)
|
|
5475
7149
|
if (panel.rightSlice) {
|
|
@@ -5624,10 +7298,37 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5624
7298
|
if (mcAffordMedia) row.slots[0].components.push(mcAffordMedia)
|
|
5625
7299
|
}
|
|
5626
7300
|
insertSemantic(hostFor(sem.midCluster.y), row, sem.midCluster.y)
|
|
7301
|
+
// 簇值行(并行汇总列「标签 + 下一行数值」的下半行):同一几何/左对齐规则渲染。
|
|
7302
|
+
// 与主金额重合的值(左列大字号)在此整行渲染(含右列 300.00),主金额通道让位,
|
|
7303
|
+
// 否则并列双列被拆成两行(左 2,782.35、右 300.00 应同基线)。
|
|
7304
|
+
if (sem.midCluster.values && sem.midCluster.values.filter(Boolean).length) {
|
|
7305
|
+
const vItems = [...sem.midCluster.values].filter(Boolean).sort((a, b) => (a.x || 0) - (b.x || 0))
|
|
7306
|
+
const primText = sem.primaryAmount ? String(sem.primaryAmount.text) : null
|
|
7307
|
+
if (primText && vItems.some((v) => String(v.text).trim() === primText.trim())) {
|
|
7308
|
+
mcValueRowHandlesPrimary = true
|
|
7309
|
+
}
|
|
7310
|
+
const vRowHost = container('Row', {
|
|
7311
|
+
width: '100%', justify: mcNearCenter ? 'center' : 'flex-start', align: 'center',
|
|
7312
|
+
gap: T.gap('tight'),
|
|
7313
|
+
padding: mcNearCenter ? '0' : `${semanticTopGap(vItems[0].y) ?? mcPad} 0 ${inFixedGradientShell(sem.midCluster.y) ? 0 : mcPad} ${mcLeftPad}`,
|
|
7314
|
+
})
|
|
7315
|
+
if (!mcNearCenter) setStyleChild(vRowHost, 'flex', 'gap', String(T.gap('none')))
|
|
7316
|
+
let _vPrevRight = null
|
|
7317
|
+
for (const v of vItems) {
|
|
7318
|
+
const gapW = _vPrevRight == null ? 0 : Math.max(0, Math.round((v.x || 0) - _vPrevRight))
|
|
7319
|
+
if (gapW > 0) vRowHost.slots[0].components.push(_mcGapSpacer(gapW))
|
|
7320
|
+
vRowHost.slots[0].components.push(textFromSpec(applyLiteralBind({
|
|
7321
|
+
text: v.text, color: v.color, fontSize: v.fontSize, fontWeight: v.fontWeight,
|
|
7322
|
+
height: v.height, align: 'left',
|
|
7323
|
+
}, sem.bindings.literalBind)))
|
|
7324
|
+
_vPrevRight = (v.x || 0) + (v.width || 0)
|
|
7325
|
+
}
|
|
7326
|
+
insertSemantic(hostFor(vItems[0].y), vRowHost, vItems[0].y)
|
|
7327
|
+
}
|
|
5627
7328
|
}
|
|
5628
7329
|
|
|
5629
7330
|
// —— 3. 主金额(大字号居中)→ 抽 inputParam + 千分号表达式 ——
|
|
5630
|
-
if (sem.primaryAmount) {
|
|
7331
|
+
if (sem.primaryAmount && !mcValueRowHandlesPrimary) {
|
|
5631
7332
|
const varName = uniqueKey(primaryAmountVarName(sem), usedTopKeys)
|
|
5632
7333
|
const num = parseAmount(sem.primaryAmount.text)
|
|
5633
7334
|
const amount = sem.primaryAmount
|
|
@@ -5848,11 +7549,296 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5848
7549
|
}
|
|
5849
7550
|
}
|
|
5850
7551
|
|
|
7552
|
+
// —— 2x. 评分仪表盘(半环弧 + 中心分数):Canvas 回放(recipe canvas-gauge-score)——
|
|
7553
|
+
// 弧矢量 shape 无渲染通道(style 解析器不认 arc),分数文本也无归属 → 整块丢失。
|
|
7554
|
+
// 检出「N分」大字号 + 下方小标签且未被任何通道消费时,按 recipe 出码 Canvas 半环。
|
|
7555
|
+
const scoreGauge = (() => {
|
|
7556
|
+
const all = sem.allTexts || []
|
|
7557
|
+
const rendered = new Set([
|
|
7558
|
+
...(sem.postAmountTexts || []).map((t) => t.sourceEl),
|
|
7559
|
+
...(sem.auxTexts || []).map((t) => t.sourceEl),
|
|
7560
|
+
...(sem.buttons || []).map((b) => b.sourceEl),
|
|
7561
|
+
...(sem.richBlocks || []).flatMap((blk) => blk.fragments || []),
|
|
7562
|
+
...(sem.midCluster ? sem.midCluster.items : []),
|
|
7563
|
+
].filter(Boolean))
|
|
7564
|
+
// 标注层常把「70分」拆成 数字+单位 两个文本:认大号纯数字 + 同基线右邻「分」单位
|
|
7565
|
+
const score = all.find((t) => /^\d+$/.test(textOf(t)) && (anyFs(t) || 0) >= 20 && !rendered.has(t))
|
|
7566
|
+
if (!score) return null
|
|
7567
|
+
const sr = anyRect(score)
|
|
7568
|
+
const unit = all.find((t) => t !== score && !rendered.has(t) && textOf(t) === '分'
|
|
7569
|
+
&& Math.abs((anyRect(t).y || 0) - (sr.y || 0)) <= 4
|
|
7570
|
+
&& (anyRect(t).x || 0) >= (sr.x || 0) + (sr.width || 0) - 2
|
|
7571
|
+
&& (anyRect(t).x || 0) <= (sr.x || 0) + (sr.width || 0) + 24)
|
|
7572
|
+
if (!unit) return null
|
|
7573
|
+
const label = all.find((t) => t !== score && t !== unit && !rendered.has(t)
|
|
7574
|
+
&& (anyRect(t).y || 0) >= (sr.y || 0) + 14 && (anyRect(t).y || 0) <= (sr.y || 0) + 60
|
|
7575
|
+
&& Math.abs(((anyRect(t).x || 0) + (anyRect(t).width || 0) / 2) - ((sr.x || 0) + (sr.width || 0) / 2)) <= 40
|
|
7576
|
+
&& (anyFs(t) || 14) <= 13)
|
|
7577
|
+
if (!label) return null
|
|
7578
|
+
return { score, label }
|
|
7579
|
+
})()
|
|
7580
|
+
if (scoreGauge) {
|
|
7581
|
+
const gx = Math.round(((anyRect(scoreGauge.label).x || 0) + (anyRect(scoreGauge.label).width || 0) / 2) - 60)
|
|
7582
|
+
const gy = Math.round((anyRect(scoreGauge.score).y || 0) - 30)
|
|
7583
|
+
const scoreNum = parseAmount(textOf(scoreGauge.score))
|
|
7584
|
+
const scoreVar = uniqueKey('score', usedTopKeys)
|
|
7585
|
+
const labelVar = uniqueKey('scoreLabel', usedTopKeys)
|
|
7586
|
+
// Canvas 出码按 self.state.score/scoreLabel 字面读取:变量被重命名(冲突)时放弃仪表盘
|
|
7587
|
+
if (scoreVar !== 'score' || labelVar !== 'scoreLabel') return
|
|
7588
|
+
addParam(scoreVar, 'number', scoreNum !== null ? scoreNum : 0)
|
|
7589
|
+
addParam(labelVar, 'string', textOf(scoreGauge.label))
|
|
7590
|
+
const gaugeCode = SCORE_GAUGE_CANVAS_CODE
|
|
7591
|
+
const cv = comp('Canvas')
|
|
7592
|
+
ensureStyleGroup(cv, 'rect'); ensureStyleGroup(cv, 'flex'); ensureStyleGroup(cv, 'background')
|
|
7593
|
+
setStyleChild(cv, 'rect', 'width', '120')
|
|
7594
|
+
setStyleChild(cv, 'rect', 'height', '84')
|
|
7595
|
+
setStyleChild(cv, 'flex', 'flexShrink', '0')
|
|
7596
|
+
setStyleChild(cv, 'background', 'backgroundType', '01')
|
|
7597
|
+
setStyleChild(cv, 'background', 'backgroundColor', '')
|
|
7598
|
+
cv.eventactions = [{
|
|
7599
|
+
event: { eventName: '初始化渲染', eventKey: 'onMeasured' },
|
|
7600
|
+
action: { actionType: 'customAction', properties: [constProp('function', { code: gaugeCode, compiled: gaugeCode })] },
|
|
7601
|
+
}]
|
|
7602
|
+
const left = Math.max(0, gx - buildCardLeft)
|
|
7603
|
+
const right = Math.max(0, buildCardRight - (gx + 120))
|
|
7604
|
+
const grow = container('Row', { width: '100%', justify: 'flex-start', align: 'flex-start', gap: '0' })
|
|
7605
|
+
ensureStyleGroup(grow, 'padding')
|
|
7606
|
+
setStyleChild(grow, 'padding', 'paddingType', '1')
|
|
7607
|
+
setStyleChild(grow, 'padding', 'paddingLeft', String(left))
|
|
7608
|
+
setStyleChild(grow, 'padding', 'paddingRight', String(right))
|
|
7609
|
+
// 零流高叠放:marginTop 补到设计顶沿、负 marginBottom 抵消(顶距+画布高),不推偏头部流式行
|
|
7610
|
+
const gaugeTopGap = Math.max(0, semanticTopGap(gy) ?? 0)
|
|
7611
|
+
ensureStyleGroup(grow, 'margin')
|
|
7612
|
+
setStyleChild(grow, 'margin', 'marginType', '1')
|
|
7613
|
+
setStyleChild(grow, 'margin', 'marginTop', String(gaugeTopGap))
|
|
7614
|
+
setStyleChild(grow, 'margin', 'marginBottom', String(-(84 + gaugeTopGap)))
|
|
7615
|
+
grow.slots[0].components.push(cv)
|
|
7616
|
+
insertSemantic(card, grow, gy)
|
|
7617
|
+
addSemanticBound(gy, gy + 84, true)
|
|
7618
|
+
}
|
|
7619
|
+
|
|
7620
|
+
// —— 3a-x. 散点带:中心媒体(雷达/环图裁切图)+ 环绕胶囊簇(pill+金额+状态标签)——
|
|
7621
|
+
// 「五笔钱」类布局:胶囊与金额按三列散点环绕中心图形;通用按钮行会把胶囊压成一行截断、
|
|
7622
|
+
// 金额散落错行(v7 AB04 位置分 0.53 的根因)。检出后按列重建:列宽=成员外接宽,
|
|
7623
|
+
// 列间负 margin 还原设计上的水平重叠,列内行 paddingTop 还原纵向节奏。
|
|
7624
|
+
// 散点带金额/状态标签 → 变量绑定(金额走千分格式化表达式,标签走字符串变量),避免裸字面量
|
|
7625
|
+
const scatterAmountText = (pill, amount) => {
|
|
7626
|
+
const num = parseAmount(amount.text)
|
|
7627
|
+
const varName = uniqueKey(fieldKeyFor(pill && pill.text) || 'amount', usedTopKeys)
|
|
7628
|
+
addParam(varName, 'number', num !== null ? num : 0)
|
|
7629
|
+
return textFromSpec({ ...amount, align: amount.align || 'left', bind: amountExpr(varName, amount.text), defaultValue: amount.text })
|
|
7630
|
+
}
|
|
7631
|
+
const scatterTagText = (tag) => {
|
|
7632
|
+
const varName = uniqueKey((fieldKeyFor(tag.text) || 'status') + 'Tag', usedTopKeys)
|
|
7633
|
+
addParam(varName, 'string', tag.text)
|
|
7634
|
+
return textFromSpec({ ...tag, align: tag.align || 'left', bind: '$' + varName, defaultValue: tag.text })
|
|
7635
|
+
}
|
|
7636
|
+
const scatterBand = (() => {
|
|
7637
|
+
const cardX = (sem.cardRect && sem.cardRect.x) || 0
|
|
7638
|
+
const cardY = (sem.cardRect && sem.cardRect.y) || 0
|
|
7639
|
+
const pills = (sem.buttons || []).filter((b) => (b.radius || 0) >= 10 && (b.height || 0) <= 40 && (b.fillColor || b.gradient))
|
|
7640
|
+
if (pills.length < 4) return null
|
|
7641
|
+
const pillTop = Math.min(...pills.map((b) => b.y || 0))
|
|
7642
|
+
const pillBottom = Math.max(...pills.map((b) => (b.y || 0) + (b.height || 0)))
|
|
7643
|
+
const rowKeys = new Set(pills.map((b) => Math.round((b.y || 0) / 8)))
|
|
7644
|
+
if (rowKeys.size < 2 || pillBottom - pillTop > 260) return null
|
|
7645
|
+
const media = (sem.orphanBlocks || []).find((ob) => {
|
|
7646
|
+
const w = ob.width || 0
|
|
7647
|
+
const h = ob.height || 0
|
|
7648
|
+
return w >= 60 && w <= 200 && Math.abs(w - h) <= 14
|
|
7649
|
+
&& (ob.y || 0) >= pillTop - 80 && (ob.y || 0) + h <= pillBottom + 80
|
|
7650
|
+
})
|
|
7651
|
+
if (!media) return null
|
|
7652
|
+
const mL = (media.x || 0) - cardX
|
|
7653
|
+
const mR = mL + (media.width || 0)
|
|
7654
|
+
const aux = sem.postAmountTexts || []
|
|
7655
|
+
const usedAux = new Set()
|
|
7656
|
+
const clusters = []
|
|
7657
|
+
for (const pill of pills) {
|
|
7658
|
+
const px = (pill.x || 0) - cardX
|
|
7659
|
+
const py = (pill.y || 0) - cardY
|
|
7660
|
+
const pc = px + (pill.width || 0) / 2
|
|
7661
|
+
const amount = aux.find((t) => !usedAux.has(t) && (t.fontSize || 14) >= 12
|
|
7662
|
+
&& (t.y || 0) - cardY >= py + (pill.height || 0) - 8
|
|
7663
|
+
&& (t.y || 0) - cardY <= py + (pill.height || 0) + 56
|
|
7664
|
+
&& Math.abs((((t.x || 0) - cardX) + (t.width || 0) / 2) - pc) <= 70)
|
|
7665
|
+
if (!amount) continue
|
|
7666
|
+
usedAux.add(amount)
|
|
7667
|
+
const ay = (amount.y || 0) - cardY
|
|
7668
|
+
const tag = aux.find((t) => !usedAux.has(t) && (t.fontSize || 14) <= 11
|
|
7669
|
+
&& (t.y || 0) - cardY >= ay - 4 && (t.y || 0) - cardY <= ay + 46
|
|
7670
|
+
&& (t.x || 0) - cardX >= (amount.x || 0) - cardX - 16
|
|
7671
|
+
&& (t.x || 0) - cardX <= (amount.x || 0) - cardX + (amount.width || 0) + 60) || null
|
|
7672
|
+
if (tag) usedAux.add(tag)
|
|
7673
|
+
const side = pc < mL ? 'L' : pc > mR ? 'R' : 'C'
|
|
7674
|
+
// 行尾箭头切片(胶囊内右侧小图标)随胶囊走,避免孤立媒体兜底再渲染一遍
|
|
7675
|
+
const pillRect = { x: (pill.x || 0), y: (pill.y || 0), width: pill.width || 0, height: pill.height || 0 }
|
|
7676
|
+
const arrow = (sem.slices || []).find((sl) => {
|
|
7677
|
+
const sr = rect(sl)
|
|
7678
|
+
const w = sr.width || 0
|
|
7679
|
+
const h = sr.height || 0
|
|
7680
|
+
return w >= 8 && w <= 24 && h >= 8 && h <= 24 && rectInside(pillRect, sr, 2)
|
|
7681
|
+
}) || null
|
|
7682
|
+
const textEl = pill.sourceEl || null
|
|
7683
|
+
const textRight = textEl ? (rect(textEl).x || 0) + (rect(textEl).width || 0) : (pill.x || 0) + (pill.width || 0)
|
|
7684
|
+
clusters.push({ pill, amount, tag, side, px, py, arrow, textRight })
|
|
7685
|
+
}
|
|
7686
|
+
if (clusters.length < 4) return null
|
|
7687
|
+
const sides = new Set(clusters.map((c) => c.side))
|
|
7688
|
+
if (!sides.has('L') || !sides.has('R') || !sides.has('C')) return null
|
|
7689
|
+
sem.buttons = (sem.buttons || []).filter((b) => !clusters.some((c) => c.pill === b))
|
|
7690
|
+
sem.postAmountTexts = aux.filter((t) => !usedAux.has(t))
|
|
7691
|
+
sem.orphanBlocks = (sem.orphanBlocks || []).filter((ob) => ob !== media)
|
|
7692
|
+
const arrowEls = new Set(clusters.map((c) => c.arrow).filter(Boolean))
|
|
7693
|
+
if (arrowEls.size) {
|
|
7694
|
+
sem.slices = (sem.slices || []).filter((sl) => !arrowEls.has(sl))
|
|
7695
|
+
sem.isolatedBlocks = (sem.isolatedBlocks || []).filter((bl) => !arrowEls.has(bl.element))
|
|
7696
|
+
}
|
|
7697
|
+
return { clusters, media, mL, cardX, cardY, pillTop }
|
|
7698
|
+
})()
|
|
7699
|
+
if (scatterBand) {
|
|
7700
|
+
const { clusters, media, cardX, cardY, pillTop } = scatterBand
|
|
7701
|
+
const bySide = { L: [], C: [], R: [] }
|
|
7702
|
+
for (const c of clusters) bySide[c.side].push(c)
|
|
7703
|
+
const colDefs = []
|
|
7704
|
+
for (const side of ['L', 'C', 'R']) {
|
|
7705
|
+
const list = bySide[side]
|
|
7706
|
+
if (!list.length) continue
|
|
7707
|
+
const rects = []
|
|
7708
|
+
for (const c of list) {
|
|
7709
|
+
rects.push({ x: c.px, y: c.py, r: c.px + (c.pill.width || 0) })
|
|
7710
|
+
rects.push({ x: (c.amount.x || 0) - cardX, y: 0, r: (c.amount.x || 0) - cardX + (c.amount.width || 0) })
|
|
7711
|
+
if (c.tag) rects.push({ x: (c.tag.x || 0) - cardX, y: 0, r: (c.tag.x || 0) - cardX + (c.tag.width || 0) })
|
|
7712
|
+
}
|
|
7713
|
+
if (side === 'C') rects.push({ x: scatterBand.mL, y: 0, r: scatterBand.mL + (media.width || 0) })
|
|
7714
|
+
const minX = Math.min(...rects.map((r) => r.x))
|
|
7715
|
+
const maxX = Math.max(...rects.map((r) => r.r))
|
|
7716
|
+
colDefs.push({ side, list, minX, width: maxX - minX })
|
|
7717
|
+
}
|
|
7718
|
+
const bandTopLocal = Math.min(
|
|
7719
|
+
...clusters.map((c) => c.py),
|
|
7720
|
+
...clusters.map((c) => (c.amount.y || 0) - cardY),
|
|
7721
|
+
(media.y || 0) - cardY,
|
|
7722
|
+
)
|
|
7723
|
+
const topGap = semanticTopGap(pillTop) ?? T.pad('section')
|
|
7724
|
+
const bandRow = container('Row', { width: '100%', justify: 'flex-start', align: 'flex-start', gap: '0' })
|
|
7725
|
+
ensureStyleGroup(bandRow, 'padding')
|
|
7726
|
+
setStyleChild(bandRow, 'padding', 'paddingType', '1')
|
|
7727
|
+
setStyleChild(bandRow, 'padding', 'paddingTop', String(Math.max(0, Math.round(topGap))))
|
|
7728
|
+
setStyleChild(bandRow, 'padding', 'paddingLeft', String(Math.max(0, Math.round(colDefs[0].minX))))
|
|
7729
|
+
let cursor = colDefs[0].minX + colDefs[0].width
|
|
7730
|
+
let bandBottomLocal = 0
|
|
7731
|
+
for (const cd of colDefs) {
|
|
7732
|
+
const col = container('Column', { direction: 'column', align: 'flex-start', gap: '0', width: String(Math.round(cd.width)), flexShrink: '0' })
|
|
7733
|
+
ensureStyleGroup(col, 'other')
|
|
7734
|
+
setStyleChild(col, 'other', 'overflow', 'visible')
|
|
7735
|
+
if (cd !== colDefs[0]) {
|
|
7736
|
+
ensureStyleGroup(col, 'margin')
|
|
7737
|
+
setStyleChild(col, 'margin', 'marginType', '1')
|
|
7738
|
+
setStyleChild(col, 'margin', 'marginLeft', String(Math.round(cd.minX - cursor)))
|
|
7739
|
+
}
|
|
7740
|
+
cursor = cd.minX + cd.width
|
|
7741
|
+
const items = []
|
|
7742
|
+
for (const c of cd.list) {
|
|
7743
|
+
items.push({ y: c.py, kind: 'pill', c })
|
|
7744
|
+
const ay = (c.amount.y || 0) - cardY
|
|
7745
|
+
const tagBelow = c.tag && ((c.tag.y || 0) - cardY) - ay > 14
|
|
7746
|
+
items.push({ y: ay, kind: tagBelow ? 'amount' : 'amountTag', c })
|
|
7747
|
+
if (tagBelow) items.push({ y: (c.tag.y || 0) - cardY, kind: 'tag', c })
|
|
7748
|
+
}
|
|
7749
|
+
if (cd.side === 'C') items.push({ y: (media.y || 0) - cardY, kind: 'media' })
|
|
7750
|
+
items.sort((a, b) => a.y - b.y)
|
|
7751
|
+
// 列首行顶距相对「带顶」而非列首成员:左右列首成员比中心列低时也要保留设计偏移
|
|
7752
|
+
let prevBottom = bandTopLocal
|
|
7753
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
7754
|
+
const it = items[i]
|
|
7755
|
+
const row = container('Row', { width: String(Math.round(cd.width)), justify: 'flex-start', align: 'center', gap: '0', flexShrink: '0' })
|
|
7756
|
+
ensureStyleGroup(row, 'padding')
|
|
7757
|
+
setStyleChild(row, 'padding', 'paddingType', '1')
|
|
7758
|
+
setStyleChild(row, 'padding', 'paddingTop', String(Math.max(0, Math.round(it.y - prevBottom))))
|
|
7759
|
+
let rowBottom = it.y
|
|
7760
|
+
if (it.kind === 'pill') {
|
|
7761
|
+
const btn = it.c.pill
|
|
7762
|
+
// 胶囊=Row 壳(底色/圆角/透明度)+ 文本 + 行尾箭头切片:Button 无子节点槽,
|
|
7763
|
+
// 设计稿胶囊普遍带右箭头,纯 Button 会让文本居中偏移半个箭头宽(校正器被迫整带平移)。
|
|
7764
|
+
const b = container('Row', {
|
|
7765
|
+
width: String(Math.round(btn.width || 88)), justify: 'flex-start', align: 'center', gap: '0', flexShrink: '0',
|
|
7766
|
+
})
|
|
7767
|
+
const pillPadL = Math.max(0, Math.round((it.c.textRight ? (rect(it.c.pill.sourceEl).x || 0) : (btn.x || 0)) - (btn.x || 0)))
|
|
7768
|
+
ensureStyleGroup(b, 'padding')
|
|
7769
|
+
setStyleChild(b, 'padding', 'paddingType', '1')
|
|
7770
|
+
setStyleChild(b, 'padding', 'paddingLeft', String(pillPadL))
|
|
7771
|
+
ensureStyleGroup(b, 'rect'); ensureStyleGroup(b, 'background'); ensureStyleGroup(b, 'other')
|
|
7772
|
+
ensureStyleGroup(b, 'borderRadius')
|
|
7773
|
+
setStyleChild(b, 'rect', 'height', String(Math.round(btn.height || 30)))
|
|
7774
|
+
if (btn.gradient) {
|
|
7775
|
+
setStyleChild(b, 'background', 'backgroundType', '03')
|
|
7776
|
+
setStyleChild(b, 'background', 'backgroundGradient', btn.gradient)
|
|
7777
|
+
} else if (btn.fillColor) {
|
|
7778
|
+
setStyleChild(b, 'background', 'backgroundType', '01')
|
|
7779
|
+
setStyleChild(b, 'background', 'backgroundColor', hexToRgba(btn.fillColor, btn.opacity ?? 1))
|
|
7780
|
+
}
|
|
7781
|
+
setStyleChild(b, 'other', 'opacity', '100')
|
|
7782
|
+
setStyleChild(b, 'borderRadius', 'borderRadiusType', '0')
|
|
7783
|
+
setStyleChild(b, 'borderRadius', 'borderRadius', String(btn.radius || 14))
|
|
7784
|
+
const label = textFromSpec({ text: btn.text, color: btn.color, fontSize: btn.fontSize || 14, fontWeight: btn.fontWeight || '400', align: 'center' })
|
|
7785
|
+
b.slots[0].components.push(label)
|
|
7786
|
+
const arrowSlice = it.c.arrow
|
|
7787
|
+
if (arrowSlice) {
|
|
7788
|
+
const ar = rect(arrowSlice)
|
|
7789
|
+
const gapX = Math.max(0, Math.round((ar.x || 0) - ((btn.sourceEl ? 0 : 0) + (it.c.textRight || 0))))
|
|
7790
|
+
setStyleChild(b, 'flex', 'gap', String(gapX))
|
|
7791
|
+
const img = imageOrPlaceholder(arrowSlice, sem.slicesExports)
|
|
7792
|
+
if (img) {
|
|
7793
|
+
setStyleChild(img, 'rect', 'width', String(Math.round(ar.width || 20)))
|
|
7794
|
+
setStyleChild(img, 'rect', 'height', String(Math.round(ar.height || 20)))
|
|
7795
|
+
b.slots[0].components.push(img)
|
|
7796
|
+
}
|
|
7797
|
+
}
|
|
7798
|
+
setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round(it.c.px - cd.minX))))
|
|
7799
|
+
row.slots[0].components.push(b)
|
|
7800
|
+
rowBottom = it.y + (btn.height || 30)
|
|
7801
|
+
} else if (it.kind === 'media') {
|
|
7802
|
+
const img = imageOrPlaceholder(media.sliceEl, sem.slicesExports)
|
|
7803
|
+
if (img) {
|
|
7804
|
+
setStyleChild(img, 'rect', 'width', String(Math.round(media.width || 0)))
|
|
7805
|
+
setStyleChild(img, 'rect', 'height', String(Math.round(media.height || 0)))
|
|
7806
|
+
setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round(scatterBand.mL - cd.minX))))
|
|
7807
|
+
row.slots[0].components.push(img)
|
|
7808
|
+
}
|
|
7809
|
+
rowBottom = it.y + (media.height || 0)
|
|
7810
|
+
} else if (it.kind === 'tag') {
|
|
7811
|
+
const tag = it.c.tag
|
|
7812
|
+
setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round((tag.x || 0) - cardX - cd.minX))))
|
|
7813
|
+
row.slots[0].components.push(scatterTagText(tag))
|
|
7814
|
+
rowBottom = it.y + Math.max(tag.height || 0, (tag.fontSize || 10) * 1.4)
|
|
7815
|
+
} else {
|
|
7816
|
+
const amount = it.c.amount
|
|
7817
|
+
setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round((amount.x || 0) - cardX - cd.minX))))
|
|
7818
|
+
row.slots[0].components.push(scatterAmountText(it.c.pill, amount))
|
|
7819
|
+
rowBottom = it.y + Math.max(amount.height || 0, (amount.fontSize || 14) * 1.4)
|
|
7820
|
+
if (it.kind === 'amountTag' && it.c.tag) {
|
|
7821
|
+
const tag = it.c.tag
|
|
7822
|
+
setStyleChild(row, 'flex', 'gap', String(Math.max(0, Math.round((tag.x || 0) - cardX - ((amount.x || 0) - cardX + (amount.width || 0))))))
|
|
7823
|
+
row.slots[0].components.push(scatterTagText(tag))
|
|
7824
|
+
}
|
|
7825
|
+
}
|
|
7826
|
+
prevBottom = rowBottom
|
|
7827
|
+
bandBottomLocal = Math.max(bandBottomLocal, rowBottom)
|
|
7828
|
+
col.slots[0].components.push(row)
|
|
7829
|
+
}
|
|
7830
|
+
bandRow.slots[0].components.push(col)
|
|
7831
|
+
}
|
|
7832
|
+
insertSemantic(card, bandRow, pillTop)
|
|
7833
|
+
addSemanticBound(pillTop, cardY + bandBottomLocal)
|
|
7834
|
+
}
|
|
7835
|
+
|
|
5851
7836
|
// —— 3b. 金额下方辅助文案:按视觉 y 顺序拆分(表单行之前=mid,按钮之后=footer)——
|
|
5852
7837
|
// 拓扑驱动:先渲染「实时到账」类 mid 文本,表单行随后,footer 文本(温馨提示/最大金额)留到按钮之后。
|
|
5853
7838
|
const formTopY = sem.formRows && sem.formRows.length
|
|
5854
7839
|
? Math.min(...sem.formRows.map((f) => f.label.y))
|
|
5855
|
-
: (sem.buttons.length ? (rect(sem.buttons[0]).y || 0)
|
|
7840
|
+
: (sem.buttons.length ? (rect(sem.buttons[0]).y || 0)
|
|
7841
|
+
: (scatterBand ? scatterBand.pillTop : 9999))
|
|
5856
7842
|
const midAux = (sem.postAmountTexts || []).filter((t) => (t.y || 0) < formTopY)
|
|
5857
7843
|
const footAux = (sem.postAmountTexts || []).filter((t) => (t.y || 0) >= formTopY)
|
|
5858
7844
|
// 辅助文案行垂直间距几何化:paddingTop = 本行 y - 上一行底沿;末行 paddingBottom = 卡片底沿 - 本行底沿。
|
|
@@ -5906,25 +7892,48 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5906
7892
|
const bind = '$' + statVar + ".map(i => i.label + ' ' + i.value).join(" + sepJs + ')'
|
|
5907
7893
|
return textFromSpec({ ...pat, bind, defaultValue: pat.text })
|
|
5908
7894
|
}
|
|
7895
|
+
// midAux 之后是否还有静态块(面板组/列表/孤立块等):只看表单/按钮/footAux 会把
|
|
7896
|
+
// 「后面还有 tile 列表」的 midAux 末行当成全卡末行,paddingBottom 撑到卡底
|
|
7897
|
+
// (实测末行被撑高 297px,后续块全部被挤出裁切区)。语义边界里顶沿在其后的块即为证据。
|
|
7898
|
+
const midAuxLastBottom = midAux.length
|
|
7899
|
+
? (midAux[midAux.length - 1].y || 0) + (midAux[midAux.length - 1].height || (midAux[midAux.length - 1].fontSize || 14) * 1.4)
|
|
7900
|
+
: null
|
|
7901
|
+
const midAuxHasBlocksAfter = (sem.formRows && sem.formRows.length) || sem.buttons.length || footAux.length > 0
|
|
7902
|
+
|| (midAuxLastBottom != null && nextSemanticTop(midAuxLastBottom - 1) != null)
|
|
5909
7903
|
for (let i = 0; i < midAux.length; i++) {
|
|
5910
7904
|
const pat = midAux[i]
|
|
5911
7905
|
const host = hostFor(pat.y)
|
|
7906
|
+
// 行首图标(勾选/圆点等选项符号):文本左侧同行带内的小切片随行渲染并消费,
|
|
7907
|
+
// 否则选项行只剩文字、符号丢失(组件计数与像素都扣)。与 appendInlineTrailSlice 同族反方向。
|
|
7908
|
+
const patH = pat.height || (pat.fontSize || 14) * 1.4
|
|
7909
|
+
const leadSliceIdx = (sem.slices || []).findIndex((sl) => {
|
|
7910
|
+
const sr = rect(sl)
|
|
7911
|
+
const w = sr.width || 0
|
|
7912
|
+
const h = sr.height || 0
|
|
7913
|
+
if (w < 12 || w > 48 || h < 12 || h > 48) return false
|
|
7914
|
+
if ((sr.x || 0) + w > (pat.x || 0) - 2 || (sr.x || 0) < (pat.x || 0) - 56) return false
|
|
7915
|
+
return Math.abs(((sr.y || 0) + h / 2) - ((pat.y || 0) + patH / 2)) <= Math.max(12, h)
|
|
7916
|
+
})
|
|
7917
|
+
const leadSlice = leadSliceIdx >= 0 ? sem.slices[leadSliceIdx] : null
|
|
5912
7918
|
// 渐变容器内的行:间距交给容器的 gap/padding(已在 topDecoration 渲染时统一设置),行自身 padding 归零,
|
|
5913
7919
|
// 否则会叠加出巨大下边距(如标题行被算成卡片末行 → paddingBottom=卡底-行底=169px)。
|
|
5914
7920
|
const inTopHost = host !== card
|
|
5915
7921
|
const isCenter = pat.align !== 'right' && (!pat.align || pat.align === 'center' || pat.x > 100)
|
|
5916
7922
|
// 只有 midAux 是整卡最后一个内容块(其后无 表单/按钮/footAux)时,末行才需贴卡片底计算 paddingBottom
|
|
5917
|
-
const
|
|
5918
|
-
const isLast = i === midAux.length - 1 && !hasBlocksAfter
|
|
7923
|
+
const isLast = i === midAux.length - 1 && !midAuxHasBlocksAfter
|
|
5919
7924
|
const beforeBottom = previousSemanticBottom(pat.y) ??
|
|
5920
7925
|
(i > 0 ? lineBottom(midAux[i - 1]) : (mainBottom ?? previousAuxBottom))
|
|
5921
7926
|
const geom = inTopHost ? null : auxRowPadding(pat, i > 0 ? midAux[i - 1] : null, isLast, beforeBottom)
|
|
5922
|
-
|
|
7927
|
+
// textAlign=right 但几何中心落在卡左 1/3 的标注(添加外部资产类链接文案)按左锚还原:
|
|
7928
|
+
// justify 只认 textAlign 会把左下链接甩到右缘(textAlign 描述盒内对齐,不是行锚点)。
|
|
7929
|
+
const midRelX = (((pat.x || 0) + (pat.width || 0) / 2) - buildCardLeft) / ((buildCard.width || 375))
|
|
7930
|
+
const midJustify = (pat.align === 'right' && midRelX < 0.4) ? 'flex-start'
|
|
7931
|
+
: pat.align === 'right' ? 'flex-end' : isCenter ? 'center' : 'flex-start'
|
|
5923
7932
|
// 左对齐行的水平位置由 spec x 坐标驱动:auxRowPadding 只管垂直节奏,
|
|
5924
7933
|
// 左 padding 必须补上 pat.x - 卡片左缘,否则内容贴到卡片 x=0(曾有说明文本丢 32px 左缩进)。
|
|
5925
7934
|
let midPadding = geom || (inTopHost ? '0' : (isCenter ? '6' : '12'))
|
|
5926
7935
|
if (midJustify === 'flex-start') {
|
|
5927
|
-
const leftPad = Math.max(0, Math.round((pat.x || buildCardLeft) - buildCardLeft))
|
|
7936
|
+
const leftPad = Math.max(0, Math.round(((leadSlice ? rect(leadSlice).x : pat.x) || buildCardLeft) - buildCardLeft))
|
|
5928
7937
|
const parts = String(midPadding).trim().split(/\s+/)
|
|
5929
7938
|
while (parts.length < 4) parts.push('0')
|
|
5930
7939
|
parts[3] = String(leftPad)
|
|
@@ -5943,7 +7952,21 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5943
7952
|
insertSemantic(host, row, pat.y)
|
|
5944
7953
|
continue
|
|
5945
7954
|
}
|
|
7955
|
+
if (leadSlice) {
|
|
7956
|
+
const leadImg = imageOrPlaceholder(leadSlice, sem.slicesExports)
|
|
7957
|
+
if (leadImg) {
|
|
7958
|
+
const lr = rect(leadSlice)
|
|
7959
|
+
setStyleChild(leadImg, 'rect', 'width', String(Math.round(lr.width || 16)))
|
|
7960
|
+
setStyleChild(leadImg, 'rect', 'height', String(Math.round(lr.height || 16)))
|
|
7961
|
+
ensureStyleGroup(row, 'flex')
|
|
7962
|
+
setStyleChild(row, 'flex', 'gap', String(Math.max(0, Math.round((pat.x || 0) - ((lr.x || 0) + (lr.width || 0))))))
|
|
7963
|
+
row.slots[0].components.push(leadImg)
|
|
7964
|
+
sem.slices = (sem.slices || []).filter((sl) => sl !== leadSlice)
|
|
7965
|
+
sem.isolatedBlocks = (sem.isolatedBlocks || []).filter((bl) => bl.element !== leadSlice)
|
|
7966
|
+
}
|
|
7967
|
+
}
|
|
5946
7968
|
row.slots[0].components.push(auxContent(pat))
|
|
7969
|
+
appendInlineTrailSlice(row, pat)
|
|
5947
7970
|
insertSemantic(host, row, pat.y)
|
|
5948
7971
|
}
|
|
5949
7972
|
|
|
@@ -5961,7 +7984,210 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
5961
7984
|
let previousFormBottom
|
|
5962
7985
|
// 每行的 spec 几何顶/底沿(视觉并集)——分割线插入后按排序序列重算 padTop 用
|
|
5963
7986
|
const formGeomByNode = new Map()
|
|
7987
|
+
// 对比面板组:y 带重叠 ≥50% 的并排壳必须合成一行输出;逐壳成行会把并排面板堆成上下两块
|
|
7988
|
+
const panelGroups = []
|
|
7989
|
+
{
|
|
7990
|
+
const seenPanels = new Set()
|
|
7991
|
+
const pRows = sorted.filter((fr2) => fr2.panelShell)
|
|
7992
|
+
for (const pa of pRows) {
|
|
7993
|
+
if (seenPanels.has(pa)) continue
|
|
7994
|
+
const band = [pa]; seenPanels.add(pa)
|
|
7995
|
+
for (const pb of pRows) {
|
|
7996
|
+
if (seenPanels.has(pb)) continue
|
|
7997
|
+
const ar = pa.panelShell; const br = pb.panelShell
|
|
7998
|
+
const ov = Math.min(ar.y + ar.height, br.y + br.height) - Math.max(ar.y, br.y)
|
|
7999
|
+
if (ov >= Math.min(ar.height, br.height) * 0.5) { band.push(pb); seenPanels.add(pb) }
|
|
8000
|
+
}
|
|
8001
|
+
band.sort((p, q) => p.panelShell.x - q.panelShell.x)
|
|
8002
|
+
panelGroups.push(band)
|
|
8003
|
+
}
|
|
8004
|
+
}
|
|
8005
|
+
const buildPanelNode = (p, widthOverride) => {
|
|
8006
|
+
const sh = p.panelShell
|
|
8007
|
+
const labBottom = (p.label.y || 0) + (p.label.height || (p.label.fontSize || 14) * 1.4)
|
|
8008
|
+
const valBottom = p.value
|
|
8009
|
+
? (p.value.y || 0) + (p.value.height || (p.value.fontSize || 14) * 1.4)
|
|
8010
|
+
: labBottom
|
|
8011
|
+
const padTop = Math.max(0, Math.round((p.label.y || 0) - sh.y))
|
|
8012
|
+
const padLeft = Math.max(0, Math.round((p.dot ? p.dot.x : p.label.x) - sh.x))
|
|
8013
|
+
const padBottom = Math.max(0, Math.round((sh.y + sh.height) - Math.max(valBottom, labBottom)))
|
|
8014
|
+
const rowGap = Math.max(0, Math.round((p.value ? p.value.y : labBottom) - labBottom))
|
|
8015
|
+
const col = container('Column', {
|
|
8016
|
+
width: String(Math.round(widthOverride || sh.width)), direction: 'column', align: 'flex-start',
|
|
8017
|
+
gap: String(rowGap), padding: `${padTop} 0 ${padBottom} ${padLeft}`, flexShrink: 0,
|
|
8018
|
+
})
|
|
8019
|
+
setStyleChild(col, 'rect', 'height', String(Math.round(sh.height)))
|
|
8020
|
+
applyShellBackground(col, sh)
|
|
8021
|
+
const line1 = container('Row', {
|
|
8022
|
+
width: '100%', align: 'center', justify: 'flex-start',
|
|
8023
|
+
gap: String(p.dot ? Math.max(0, p.dot.gap) : 0),
|
|
8024
|
+
})
|
|
8025
|
+
if (p.dot) {
|
|
8026
|
+
const dotNode = container('Column', { direction: 'column', flexShrink: 0 })
|
|
8027
|
+
ensureStyleGroup(dotNode, 'background')
|
|
8028
|
+
ensureStyleGroup(dotNode, 'borderRadius')
|
|
8029
|
+
setStyleChild(dotNode, 'rect', 'width', String(p.dot.w || 8))
|
|
8030
|
+
setStyleChild(dotNode, 'rect', 'height', String(p.dot.h || 8))
|
|
8031
|
+
setStyleChild(dotNode, 'background', 'backgroundType', '01')
|
|
8032
|
+
setStyleChild(dotNode, 'background', 'backgroundColor', hexToRgba(p.dot.color || '#CCCCCC'))
|
|
8033
|
+
setStyleChild(dotNode, 'borderRadius', 'borderRadiusType', '0')
|
|
8034
|
+
setStyleChild(dotNode, 'borderRadius', 'borderRadius', String(Math.round((p.dot.w || 8) / 2)))
|
|
8035
|
+
line1.slots[0].components.push(dotNode)
|
|
8036
|
+
}
|
|
8037
|
+
const labNode = textFromSpec({ ...p.label, align: 'left' })
|
|
8038
|
+
ensureStyleGroup(labNode, 'flex')
|
|
8039
|
+
setStyleChild(labNode, 'flex', 'flexShrink', '0')
|
|
8040
|
+
line1.slots[0].components.push(labNode)
|
|
8041
|
+
col.slots[0].components.push(line1)
|
|
8042
|
+
if (p.value || p.tag || (p.subValues || []).length) {
|
|
8043
|
+
// 值行与标签文本左对齐(圆点只缩进行1):缺这段内缩会让金额顶到壳左沿
|
|
8044
|
+
const line2PadLeft = Math.max(0, Math.round(
|
|
8045
|
+
((p.value ? p.value.x : p.label.x) || 0) - sh.x - padLeft))
|
|
8046
|
+
const tagX = p.tag ? (p.tag.right || 0) - (p.tag.w || 0) : null
|
|
8047
|
+
const anchorRight = p.value
|
|
8048
|
+
? (p.value.x || 0) + (p.value.width || 0)
|
|
8049
|
+
: (p.label.x || 0) + (p.label.width || 0)
|
|
8050
|
+
const line2 = container('Row', {
|
|
8051
|
+
width: '100%', align: 'center', justify: 'flex-start',
|
|
8052
|
+
gap: p.tag ? String(Math.max(0, Math.round(tagX - anchorRight))) : T.gap('tight'),
|
|
8053
|
+
padding: `0 0 0 ${line2PadLeft}`,
|
|
8054
|
+
})
|
|
8055
|
+
if (p.value) {
|
|
8056
|
+
const valNode = textFromSpec(applyLiteralBind({ ...p.value, align: 'left' }, sem.bindings.literalBind))
|
|
8057
|
+
ensureStyleGroup(valNode, 'flex')
|
|
8058
|
+
setStyleChild(valNode, 'flex', 'flexShrink', '0')
|
|
8059
|
+
line2.slots[0].components.push(valNode)
|
|
8060
|
+
}
|
|
8061
|
+
for (const sv of (p.subValues || [])) {
|
|
8062
|
+
line2.slots[0].components.push(textFromSpec({ ...sv, align: 'left' }))
|
|
8063
|
+
}
|
|
8064
|
+
if (p.tag) {
|
|
8065
|
+
const tagNode = container('Row', { align: 'center', justify: 'center', flexShrink: 0 })
|
|
8066
|
+
ensureStyleGroup(tagNode, 'rect')
|
|
8067
|
+
setStyleChild(tagNode, 'rect', 'width', String(p.tag.w || 24))
|
|
8068
|
+
setStyleChild(tagNode, 'rect', 'height', String(p.tag.h || 14))
|
|
8069
|
+
ensureStyleGroup(tagNode, 'background')
|
|
8070
|
+
ensureStyleGroup(tagNode, 'borderRadius')
|
|
8071
|
+
// 标注缺陷:tag 壳 fill 常报成描边同色(实为浅底),同亮度会让彩色字隐入同色底
|
|
8072
|
+
let tagFill = p.tag.fill
|
|
8073
|
+
if (p.tag.borderColor && tagFill
|
|
8074
|
+
&& Math.abs(colorLuminance(tagFill) - colorLuminance(p.tag.color)) < 0.08) {
|
|
8075
|
+
tagFill = tintHex(p.tag.borderColor, 0.08)
|
|
8076
|
+
}
|
|
8077
|
+
if (tagFill) {
|
|
8078
|
+
setStyleChild(tagNode, 'background', 'backgroundType', '01')
|
|
8079
|
+
setStyleChild(tagNode, 'background', 'backgroundColor', hexToRgba(tagFill))
|
|
8080
|
+
}
|
|
8081
|
+
if (p.tag.borderColor) {
|
|
8082
|
+
ensureStyleGroup(tagNode, 'border')
|
|
8083
|
+
setStyleChild(tagNode, 'border', 'isBorder', '1')
|
|
8084
|
+
setStyleChild(tagNode, 'border', 'borderWidth', '1')
|
|
8085
|
+
setStyleChild(tagNode, 'border', 'borderStyle', 'solid')
|
|
8086
|
+
setStyleChild(tagNode, 'border', 'borderColor', hexToRgba(p.tag.borderColor))
|
|
8087
|
+
}
|
|
8088
|
+
setStyleChild(tagNode, 'borderRadius', 'borderRadiusType', '0')
|
|
8089
|
+
setStyleChild(tagNode, 'borderRadius', 'borderRadius', String(Math.round(p.tag.radius || 2)))
|
|
8090
|
+
const tagText = textFromSpec({ text: p.tag.text, color: p.tag.color, fontSize: p.tag.fontSize, align: 'center' })
|
|
8091
|
+
ensureStyleGroup(tagText, 'flex')
|
|
8092
|
+
setStyleChild(tagText, 'flex', 'flexShrink', '0')
|
|
8093
|
+
tagNode.slots[0].components.push(tagText)
|
|
8094
|
+
line2.slots[0].components.push(tagNode)
|
|
8095
|
+
}
|
|
8096
|
+
col.slots[0].components.push(line2)
|
|
8097
|
+
}
|
|
8098
|
+
return col
|
|
8099
|
+
}
|
|
5964
8100
|
for (const fr of sorted) {
|
|
8101
|
+
// —— 对比面板行:壳内左对齐两行(标签行 + 值/tag 行),并排壳合成一行绝对定位带 ——
|
|
8102
|
+
if (fr.panelShell) {
|
|
8103
|
+
const group = panelGroups.find((g) => g.includes(fr)) || [fr]
|
|
8104
|
+
if (group[0] !== fr) continue
|
|
8105
|
+
const bandTop = Math.min(...group.map((p) => p.panelShell.y))
|
|
8106
|
+
const bandBottom = Math.max(...group.map((p) => p.panelShell.y + p.panelShell.height))
|
|
8107
|
+
const bandLeft = Math.min(...group.map((p) => p.panelShell.x))
|
|
8108
|
+
const bandRight = Math.max(...group.map((p) => p.panelShell.x + p.panelShell.width))
|
|
8109
|
+
const cmpTop = previousFormBottom == null
|
|
8110
|
+
? (semanticTopGap(bandTop) ?? T.pad('section'))
|
|
8111
|
+
: Math.max(0, Math.round(bandTop - previousFormBottom))
|
|
8112
|
+
// 面板间连接件(横跨壳间隙的小切片,如对比箭头)收进带内,避免孤立块另起一行
|
|
8113
|
+
const claimedByRows = new Set((sem.formRows || [])
|
|
8114
|
+
.flatMap((p2) => [p2.rightAffordanceSlice, p2.leftIconSlice, p2.trailingIconSlice, p2.valueLeadSlice])
|
|
8115
|
+
.filter(Boolean))
|
|
8116
|
+
const connectors = group.length > 1 ? (sem.slices || []).filter((s2) => {
|
|
8117
|
+
const r2 = rect(s2)
|
|
8118
|
+
const w2 = r2.width || 0; const h2 = r2.height || 0
|
|
8119
|
+
if (w2 < 10 || w2 > 44 || h2 < 10 || h2 > 44) return false
|
|
8120
|
+
if (claimedByRows.has(s2)) return false
|
|
8121
|
+
const cx = (r2.x || 0) + w2 / 2; const cy = (r2.y || 0) + h2 / 2
|
|
8122
|
+
if (cx < bandLeft - 4 || cx > bandRight + 4 || cy < bandTop || cy > bandBottom) return false
|
|
8123
|
+
return !group.some((p) => rectInside(p.panelShell, r2, 0))
|
|
8124
|
+
}).sort((p, q) => (rect(p).x || 0) - (rect(q).x || 0)) : []
|
|
8125
|
+
if (connectors.length && Array.isArray(sem.isolatedBlocks)) {
|
|
8126
|
+
sem.isolatedBlocks = sem.isolatedBlocks.filter((b) => !connectors.includes(b.element))
|
|
8127
|
+
}
|
|
8128
|
+
let bandNode
|
|
8129
|
+
if (group.length > 1 || connectors.length) {
|
|
8130
|
+
// 渲染器无绝对定位(position 组被忽略)且组件 overflow 默认 hidden:
|
|
8131
|
+
// 并排壳用 flex Row 精确宽度排布;跨壳连接件(对比箭头)作为**末尾**子节点 +
|
|
8132
|
+
// 负 marginLeft 拉回设计 x(末尾保证它绘制在两侧壳背景之上),竖直居中于带高。
|
|
8133
|
+
bandNode = container('Row', {
|
|
8134
|
+
width: '100%', direction: 'row', align: 'flex-start', justify: 'flex-start', gap: '0',
|
|
8135
|
+
})
|
|
8136
|
+
let cursor = 0
|
|
8137
|
+
for (let gi = 0; gi < group.length; gi++) {
|
|
8138
|
+
const p = group[gi]
|
|
8139
|
+
bandNode.slots[0].components.push(buildPanelNode(p))
|
|
8140
|
+
cursor += Math.round(p.panelShell.width)
|
|
8141
|
+
const next = group[gi + 1]
|
|
8142
|
+
if (next) {
|
|
8143
|
+
const gapW = Math.max(0, Math.round(next.panelShell.x - (p.panelShell.x + p.panelShell.width)))
|
|
8144
|
+
if (gapW > 0) {
|
|
8145
|
+
const spacer = container('Column', { direction: 'column', flexShrink: 0 })
|
|
8146
|
+
ensureStyleGroup(spacer, 'rect')
|
|
8147
|
+
setStyleChild(spacer, 'rect', 'width', String(gapW))
|
|
8148
|
+
setStyleChild(spacer, 'rect', 'height', String(Math.round(bandBottom - bandTop)))
|
|
8149
|
+
bandNode.slots[0].components.push(spacer)
|
|
8150
|
+
cursor += gapW
|
|
8151
|
+
}
|
|
8152
|
+
}
|
|
8153
|
+
}
|
|
8154
|
+
for (const cn of connectors) {
|
|
8155
|
+
const holder = container('Column', {
|
|
8156
|
+
direction: 'column', justify: 'center', align: 'center', flexShrink: 0,
|
|
8157
|
+
})
|
|
8158
|
+
ensureStyleGroup(holder, 'rect')
|
|
8159
|
+
setStyleChild(holder, 'rect', 'width', String(Math.round(rect(cn).width || 0)))
|
|
8160
|
+
setStyleChild(holder, 'rect', 'height', String(Math.round(bandBottom - bandTop)))
|
|
8161
|
+
ensureStyleGroup(holder, 'margin')
|
|
8162
|
+
setStyleChild(holder, 'margin', 'marginType', '1')
|
|
8163
|
+
setStyleChild(holder, 'margin', 'marginLeft', String(Math.round((rect(cn).x || 0) - bandLeft - cursor))
|
|
8164
|
+
)
|
|
8165
|
+
setStyleChild(holder, 'margin', 'marginTop', '0')
|
|
8166
|
+
setStyleChild(holder, 'margin', 'marginRight', '0')
|
|
8167
|
+
setStyleChild(holder, 'margin', 'marginBottom', '0')
|
|
8168
|
+
const media = imageOrPlaceholder(cn, sem.slicesExports)
|
|
8169
|
+
if (media) {
|
|
8170
|
+
setStyleChild(media, 'rect', 'width', String(Math.round(rect(cn).width || 0)))
|
|
8171
|
+
setStyleChild(media, 'rect', 'height', String(Math.round(rect(cn).height || 0)))
|
|
8172
|
+
holder.slots[0].components.push(media)
|
|
8173
|
+
}
|
|
8174
|
+
bandNode.slots[0].components.push(holder)
|
|
8175
|
+
cursor += Math.round(rect(cn).width || 0)
|
|
8176
|
+
}
|
|
8177
|
+
} else {
|
|
8178
|
+
bandNode = buildPanelNode(group[0])
|
|
8179
|
+
}
|
|
8180
|
+
const bandWrap = container('Row', {
|
|
8181
|
+
width: '100%', direction: 'row', justify: 'flex-start', align: 'center',
|
|
8182
|
+
padding: `${cmpTop} ${Math.max(0, Math.round(buildCardRight - bandRight))} 0 ${Math.max(0, Math.round(bandLeft - buildCardLeft))}`,
|
|
8183
|
+
})
|
|
8184
|
+
bandWrap.slots[0].components.push(bandNode)
|
|
8185
|
+
formHost.slots[0].components.push(bandWrap)
|
|
8186
|
+
registerSemanticY(bandWrap, bandTop)
|
|
8187
|
+
formGeomByNode.set(bandWrap, { top: bandTop, bottom: bandBottom })
|
|
8188
|
+
previousFormBottom = bandBottom
|
|
8189
|
+
continue
|
|
8190
|
+
}
|
|
5965
8191
|
// ① 结构 + ② 布局:外层水平容器,左右两端对齐
|
|
5966
8192
|
// 通用布局规则:标签为固定型(flexShrink:0 不被挤压),右簇为撑满型(width:100%+flexShrink:1)
|
|
5967
8193
|
// 行视觉顶/底沿:label/value 文本与行尾件(pill 按钮、箭头、左图标)的几何并集。
|
|
@@ -6343,11 +8569,23 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6343
8569
|
setStyleChild(fill, 'borderRadius', 'borderRadius', String(Math.round((bar.height || 4) / 2)))
|
|
6344
8570
|
track.slots[0].components.push(fill)
|
|
6345
8571
|
}
|
|
6346
|
-
hostFor(bar.y).
|
|
8572
|
+
insertSemantic(hostFor(bar.y), track, bar.y)
|
|
6347
8573
|
}
|
|
6348
8574
|
|
|
6349
8575
|
// —— 4. 副金额对(多组并排)→ 每对金额各自抽 inputParam + 千分号表达式 ——
|
|
6350
|
-
|
|
8576
|
+
const subPanelRects = (sem.panelRows || []).filter(Boolean)
|
|
8577
|
+
const subInPanel = (el) => {
|
|
8578
|
+
if (!el) return false
|
|
8579
|
+
const er = rect(el)
|
|
8580
|
+
const cx = (er.x || 0) + (er.width || 0) / 2
|
|
8581
|
+
const cy = (er.y || 0) + (er.height || 0) / 2
|
|
8582
|
+
return subPanelRects.some((pr) => cx >= (pr.x || 0) && cx <= (pr.x || 0) + (pr.width || 0)
|
|
8583
|
+
&& cy >= (pr.y || 0) && cy <= (pr.y || 0) + (pr.height || 0))
|
|
8584
|
+
}
|
|
8585
|
+
// 副金额对落在面板壳内时由面板行渲染(产品卡右列),全局 space-around 行会把
|
|
8586
|
+
// 标签/金额甩到面板之外(曾致产品卡右列逃逸+第二张卡空壳)。
|
|
8587
|
+
const subsActive = sem.subAmounts.filter((sub) => !subInPanel(sub.labelEl) && !subInPanel(sub.valueEl))
|
|
8588
|
+
if (subsActive.length && !sem._subAmountsConsumed) {
|
|
6351
8589
|
const panel = sem.subAmountPanel
|
|
6352
8590
|
const panelOuter = panel
|
|
6353
8591
|
? container('Column', { width: '100%', direction: 'column', padding: `${semanticTopGap(panel.y) ?? 0} 0 0 0` })
|
|
@@ -6369,8 +8607,8 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6369
8607
|
panelOuter.slots[0].components.push(wrapHost)
|
|
6370
8608
|
insertSemantic(card, panelOuter, panel.y)
|
|
6371
8609
|
}
|
|
6372
|
-
|
|
6373
|
-
|
|
8610
|
+
for (let i = 0; i < subsActive.length; i++) {
|
|
8611
|
+
const sub = subsActive[i]
|
|
6374
8612
|
// 变量名:副金额标签词典(本期应还→currentDueAmount / 最低应还→minimumDueAmount),
|
|
6375
8613
|
// 未命中 → 角色名 amount(uniqueKey 自动加序号区分同构多组)
|
|
6376
8614
|
const varName = uniqueKey(fieldKeyFor(sub.label) || 'amount', usedTopKeys)
|
|
@@ -6505,6 +8743,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6505
8743
|
// 枚举统计行兜底(同 midAux,见 tryRenderEnumStat)
|
|
6506
8744
|
const statT = tryRenderEnumStat(pat)
|
|
6507
8745
|
row.slots[0].components.push(statT || textFromSpec({ ...pat, align: 'left' }))
|
|
8746
|
+
if (!statT) appendInlineTrailSlice(row, pat)
|
|
6508
8747
|
insertSemantic(card, row, pat.y)
|
|
6509
8748
|
}
|
|
6510
8749
|
|
|
@@ -6539,9 +8778,16 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6539
8778
|
// 无壳时 listTop 是首行内容顶沿,渲染行内还有 padTop,需扣除避免重复计数。
|
|
6540
8779
|
const firstHasShell = sem.sampleItems.length
|
|
6541
8780
|
&& sem.sampleItems[0].rowTop != null && sem.sampleItems[0].rowTop < sem.sampleItems[0].y
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
8781
|
+
// 标题与列表之间还夹着其它语义块(对比面板/段落等)时不能把间距桥接进标题行底 padding:
|
|
8782
|
+
// 否则中间块被推离设计位(曾致对比面板下移 200+px),中间块自己的顶距会负责衔接。
|
|
8783
|
+
const nextTopAfterTitle = nextSemanticTop(titleRenderedBottom)
|
|
8784
|
+
const hasIntermediateSection = nextTopAfterTitle != null
|
|
8785
|
+
&& (listTop == null || nextTopAfterTitle < listTop - 2)
|
|
8786
|
+
const titleBottomGap = hasIntermediateSection
|
|
8787
|
+
? 0
|
|
8788
|
+
: listTop == null
|
|
8789
|
+
? (semanticBottomGap(titleRenderedBottom) ?? T.pad('section'))
|
|
8790
|
+
: Math.max(0, Math.round(listTop - titleRenderedBottom) - (firstHasShell ? 0 : listRowPaddingTop))
|
|
6545
8791
|
// 行尾右侧操作件(如标题行眼睛图标):space-between 钉在 spec 右沿
|
|
6546
8792
|
const ltAffordEl = sem.listTitle.rightSliceEl || null
|
|
6547
8793
|
const ltAffordR = ltAffordEl ? rect(ltAffordEl) : null
|
|
@@ -6569,22 +8815,48 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6569
8815
|
if ((sem.listTitle || sem.sampleItems.length >= 2) && sem.sampleItems.length) {
|
|
6570
8816
|
// 列表数据绑定:变量名由列表标题语义推断(明细→detailList),未命中用中性 'list'
|
|
6571
8817
|
const listVar = uniqueKey(listVarName(sem.listTitle), usedTopKeys)
|
|
8818
|
+
// 逐行壳色不同(蓝/橙/绿 tint):模板背景改绑 $rowBg(渐变取 stop 均值近似纯色)
|
|
8819
|
+
const shellRepColor = (it2) => {
|
|
8820
|
+
if (it2.fill) return it2.fill
|
|
8821
|
+
const stops = String(it2.gradient || '').match(/rgba?\([^)]*\)/g) || []
|
|
8822
|
+
if (!stops.length) return ''
|
|
8823
|
+
let r = 0; let g = 0; let b = 0; let a = 0
|
|
8824
|
+
for (const st of stops) {
|
|
8825
|
+
const parts = st.replace(/rgba?\(/, '').replace(/\)/, '').split(',').map((v) => Number(v) || 0)
|
|
8826
|
+
r += parts[0] || 0; g += parts[1] || 0; b += parts[2] || 0; a += parts[3] == null ? 1 : parts[3]
|
|
8827
|
+
}
|
|
8828
|
+
const n2 = stops.length
|
|
8829
|
+
return `rgba(${Math.round(r / n2)}, ${Math.round(g / n2)}, ${Math.round(b / n2)}, ${Math.round((a / n2) * 100) / 100})`
|
|
8830
|
+
}
|
|
6572
8831
|
addParam(listVar, 'array', (sem.allItems || sem.sampleItems).map((it) => ({
|
|
6573
8832
|
title: it.title,
|
|
6574
8833
|
titleSuffix: it.titleSuffix || '',
|
|
6575
8834
|
titleRight: it.titleRight || '',
|
|
6576
8835
|
time: it.time,
|
|
6577
8836
|
desc: it.desc || '',
|
|
8837
|
+
descRight: it.descRight || '',
|
|
6578
8838
|
icon: it.icon || '',
|
|
8839
|
+
rightIcon: it.rightIcon || '',
|
|
8840
|
+
rowBg: shellRepColor(it) || '',
|
|
8841
|
+
// 逐行渐变壳:背景渐变整串下行级绑定($rowGradient),纯色壳回退 $rowBg
|
|
8842
|
+
rowGradient: it.gradient ? gradientWithOpacity(it.gradient, it.opacity ?? 1) : '',
|
|
6579
8843
|
action: it.action || '',
|
|
6580
8844
|
// 行尾 affordance(眼睛/箭头等):逐行数据驱动,无此行级操作件的行绑定空串 + v-show 隐藏
|
|
6581
8845
|
afford: it.affordanceUrl || '',
|
|
6582
8846
|
// 金额作为数字存储(剥千分号/¥符号/负号),渲染时用表达式还原
|
|
6583
8847
|
itemAmount: parseAmount(it.amount) !== null ? parseAmount(it.amount) : it.amount,
|
|
8848
|
+
// 货币前缀逐行存储:列表模板表达式共用,符号差异(首行 $ 次行 ¥)靠数据区分
|
|
8849
|
+
itemAmountCur: currencyPrefixOf(it.amount),
|
|
8850
|
+
// 行内进度条比率(进度条落入列表行 y 带时挂接到行数据)
|
|
8851
|
+
barRatio: it.bars && it.bars[0] && it.bars[0].segments && it.bars[0].segments[0]
|
|
8852
|
+
? it.bars[0].segments[0].ratio : 0,
|
|
6584
8853
|
// 复合行金额列组:扁平字段(amtLabel1/amtValue1…),值存数字由表达式格式化
|
|
6585
8854
|
...Object.fromEntries((it.amounts || []).slice(0, 4).flatMap((pp, pi) => [
|
|
6586
8855
|
['amtLabel' + (pi + 1), pp.label],
|
|
6587
8856
|
['amtValue' + (pi + 1), parseAmount(pp.value) !== null ? parseAmount(pp.value) : pp.value],
|
|
8857
|
+
['amtCur' + (pi + 1), currencyPrefixOf(pp.value)],
|
|
8858
|
+
['amtTag' + (pi + 1), pp.tagText || ''],
|
|
8859
|
+
['amtTagIcon' + (pi + 1), pp.tagIcon || ''],
|
|
6588
8860
|
])),
|
|
6589
8861
|
})))
|
|
6590
8862
|
// 列表项模板(外层 Column:内层 Row[左 Column(标题+时间) 撑开 + 右 Row(金额+箭头) 靠右] + 底部 Divider)
|
|
@@ -6593,9 +8865,49 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6593
8865
|
// 内层 Row 承载原 leftCol + rightRow 保持横向并排,Divider 作为 Column 第二个子节点纵向跟随其后
|
|
6594
8866
|
const firstItem = sem.sampleItems[0] || { title: '', time: '', amount: '' }
|
|
6595
8867
|
const itemAmtVar = 'itemAmount'
|
|
8868
|
+
// 行壳(设计行级圆角填充底):模板承载底色/圆角/内边距/高度,列表按壳宽内缩
|
|
8869
|
+
const rowShell = (firstItem.fill || firstItem.gradient)
|
|
8870
|
+
? { fill: firstItem.fill, gradient: firstItem.gradient, radius: firstItem.radius, opacity: firstItem.opacity }
|
|
8871
|
+
: null
|
|
8872
|
+
// 壳间分离(相邻壳间隙 ≥2):设计用间隙分行而非分割线 → 模板不发 Divider,row-spacing 承间隙
|
|
8873
|
+
const shellsDiffer = !!rowShell
|
|
8874
|
+
&& sem.sampleItems.some((it2) => (it2.fill || it2.gradient) && (it2.fill || it2.gradient) !== (firstItem.fill || firstItem.gradient))
|
|
8875
|
+
const shellsSeparated = !!rowShell && sem.sampleItems.every((it2) => it2.shellEl)
|
|
8876
|
+
&& sem.sampleItems.slice(1).every((it2, i2) => {
|
|
8877
|
+
const prev = rect(sem.sampleItems[i2].shellEl)
|
|
8878
|
+
const cur = rect(it2.shellEl)
|
|
8879
|
+
return ((cur.y || 0) - ((prev.y || 0) + (prev.height || 0))) >= 2
|
|
8880
|
+
})
|
|
6596
8881
|
// 外层 Column:纵向堆叠内容行与分割线(不再用 Row,避免 Divider 并排)
|
|
6597
8882
|
// 关键:container('Column', ...) 不会自动设 direction,必须显式传 direction:'column',否则落到默认 row(横排)
|
|
6598
8883
|
const itemTpl = container('Column', { direction: 'column', width: '100%', gap: T.gap('none') })
|
|
8884
|
+
if (rowShell) {
|
|
8885
|
+
if (shellsDiffer) {
|
|
8886
|
+
// 行级壳色/壳渐变绑定:必须**改写** style-meta 自带的 backgroundColor/backgroundGradient
|
|
8887
|
+
// 子项(追加第二个同名子项会让渲染器取到空值/绑定串原样进 doColor → 背景透明)。
|
|
8888
|
+
ensureStyleGroup(itemTpl, 'background')
|
|
8889
|
+
ensureStyleGroup(itemTpl, 'borderRadius')
|
|
8890
|
+
const grp = (itemTpl.style.properties || []).find((pp) => pp.itemKey === 'background')
|
|
8891
|
+
const setBound = (childKey, expr, fallback) => {
|
|
8892
|
+
const child = (grp.itemChildren?.properties || []).find((cc) => cc.itemKey === childKey)
|
|
8893
|
+
if (!child) return
|
|
8894
|
+
child.itemValue = expr
|
|
8895
|
+
child.itemValueFrom = '1'
|
|
8896
|
+
child.itemDefaultValue = fallback
|
|
8897
|
+
}
|
|
8898
|
+
if (firstItem.gradient) {
|
|
8899
|
+
setStyleChild(itemTpl, 'background', 'backgroundType', '03')
|
|
8900
|
+
setBound('backgroundGradient', '$rowGradient', gradientWithOpacity(firstItem.gradient, firstItem.opacity ?? 1))
|
|
8901
|
+
} else {
|
|
8902
|
+
setStyleChild(itemTpl, 'background', 'backgroundType', '01')
|
|
8903
|
+
setBound('backgroundColor', '$rowBg', shellRepColor(firstItem))
|
|
8904
|
+
}
|
|
8905
|
+
setStyleChild(itemTpl, 'borderRadius', 'borderRadiusType', '0')
|
|
8906
|
+
setStyleChild(itemTpl, 'borderRadius', 'borderRadius', String(Math.round(rowShell.radius || 8)))
|
|
8907
|
+
} else {
|
|
8908
|
+
applyShellBackground(itemTpl, rowShell)
|
|
8909
|
+
}
|
|
8910
|
+
}
|
|
6599
8911
|
let compositeAmountsRow = null
|
|
6600
8912
|
// 内层 Row:承载原 leftCol + rightRow(横向并排)
|
|
6601
8913
|
const rowHeights = sem.sampleItems.map((item) => item.rowHeight || item.contentHeight || 0).filter(Boolean)
|
|
@@ -6612,7 +8924,13 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6612
8924
|
// 行高估算必须与节点实际渲染盒高同度量:模板文本未传 spec 高度 → 自然撑高 ≈ round(fs×1.53)。
|
|
6613
8925
|
// 估算用 ×1.5 会低估 0.5-1px/行,paddingBottom 吸收不足 → 行高超出壳高中位,逐行累计下漂。
|
|
6614
8926
|
const titleLineHeight = Math.max(20, Math.round((firstItem.titleFontSize || T.font('body')) * 1.53))
|
|
6615
|
-
|
|
8927
|
+
// descInline: desc on same baseline as title (dy < fontSize*0.8) and after title right edge
|
|
8928
|
+
const descInline = !!(firstItem.desc
|
|
8929
|
+
&& Number.isFinite(firstItem.descY) && Number.isFinite(firstItem.y)
|
|
8930
|
+
&& Number.isFinite(firstItem.titleX) && Number.isFinite(firstItem.titleW)
|
|
8931
|
+
&& (firstItem.descY - firstItem.y) < Math.round((firstItem.titleFontSize || 14) * 0.8)
|
|
8932
|
+
&& (firstItem.descX || 0) >= (firstItem.titleX || 0) + (firstItem.titleW || 0))
|
|
8933
|
+
const secondaryFontSize = (firstItem.desc && !descInline) ? firstItem.descFontSize : (firstItem.time ? T.font('small') : 0)
|
|
6616
8934
|
const secondaryLineHeight = secondaryFontSize ? Math.max(16, Math.round(secondaryFontSize * 1.53)) : 0
|
|
6617
8935
|
// 标题→副行(desc/time)纵距取标注中位(设计的行距并非常量;统一 tight(4) 会把副行
|
|
6618
8936
|
// 压偏 +2px/项)。纵距锚渲染行盒(fs×1.5 自然行盒,实测渲染度量):descY - (titleY + 盒高)。
|
|
@@ -6624,15 +8942,20 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6624
8942
|
? String(_titleDescGaps.slice().sort((a, b) => a - b)[Math.floor(_titleDescGaps.length / 2)])
|
|
6625
8943
|
: T.gap('tight')
|
|
6626
8944
|
const isCompositeItems = !!(firstItem.amounts && firstItem.amounts.length >= 2)
|
|
8945
|
+
const _barContentH = (firstItem.bars && firstItem.bars[0])
|
|
8946
|
+
? Math.round((firstItem.titleFontSize || 14) * 0.5) * 2 + (firstItem.bars[0].height || 4)
|
|
8947
|
+
: 0
|
|
6627
8948
|
const renderedContentHeight = isCompositeItems && firstItem.contentHeight
|
|
6628
8949
|
? Math.round(firstItem.contentHeight)
|
|
6629
8950
|
: Math.round(titleLineHeight
|
|
6630
|
-
+ (secondaryLineHeight ? (parseInt(titleDescGap, 10) || 0) + secondaryLineHeight : 0)
|
|
8951
|
+
+ (secondaryLineHeight ? (parseInt(titleDescGap, 10) || 0) + secondaryLineHeight : 0)
|
|
8952
|
+
+ _barContentH)
|
|
6631
8953
|
const listRowPaddingBottom = Math.max(0,
|
|
6632
8954
|
listRowHeight - listRowPaddingTop - renderedContentHeight - dividerHeight)
|
|
6633
8955
|
const isComposite = !!(firstItem.amounts && firstItem.amounts.length >= 2)
|
|
6634
8956
|
// 复合行:innerRow 只承载标题行,底距为 0(金额行自带 spec 顶距/底距)
|
|
6635
|
-
const
|
|
8957
|
+
const _hasBar = !!(firstItem.bars && firstItem.bars[0])
|
|
8958
|
+
const innerPadBottom = isComposite ? 0 : (_hasBar ? 0 : listRowPaddingBottom)
|
|
6636
8959
|
// 行图标与标题列的间距取 spec 实测中位(标题 x - 图标右沿),无图标时为 0
|
|
6637
8960
|
const iconGaps = sem.sampleItems.map((item) => item.iconGap).filter((v) => Number.isFinite(v))
|
|
6638
8961
|
const innerGap = iconGaps.length
|
|
@@ -6651,7 +8974,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6651
8974
|
const innerRow = container('Row', {
|
|
6652
8975
|
width: '100%',
|
|
6653
8976
|
align: 'center',
|
|
6654
|
-
padding: `${listRowPaddingTop} ${listPadRight} ${innerPadBottom} ${listPadLeft}`,
|
|
8977
|
+
padding: `${rowShell && firstItem.padTop != null ? firstItem.padTop : listRowPaddingTop} ${rowShell && firstItem.padRight != null ? firstItem.padRight : listPadRight} ${innerPadBottom + (shellsSeparated ? dividerHeight : 0)} ${rowShell && firstItem.padLeft != null ? firstItem.padLeft : listPadLeft}`,
|
|
6655
8978
|
gap: innerGap,
|
|
6656
8979
|
})
|
|
6657
8980
|
const leftCol = container('Column', { direction: 'column', gap: titleDescGap, width: '100%' })
|
|
@@ -6663,6 +8986,8 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6663
8986
|
const withSuffix = sem.sampleItems.some((item) => item.titleSuffix)
|
|
6664
8987
|
// 标题右侧同行文本(卡号等):间距取 spec 几何(标题右沿→文本左沿)
|
|
6665
8988
|
const withTitleRight = sem.sampleItems.some((item) => item.titleRight)
|
|
8989
|
+
// 行内联 affordance(紧跟标题的眼睛图标):模板在标题行内联渲染,逐行 v-show
|
|
8990
|
+
const inlineAffordSample = sem.sampleItems.find((item) => item.affordInline && item.affordanceUrl)
|
|
6666
8991
|
// 行内操作:所有样本行都有操作 pill(如每行「删除」按钮)
|
|
6667
8992
|
const withAction = sem.sampleItems.length > 0 && sem.sampleItems.every((item) => item.action)
|
|
6668
8993
|
const titleText = textFromSpec({
|
|
@@ -6670,16 +8995,50 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6670
8995
|
fontWeight: firstItem.titleFontWeight,
|
|
6671
8996
|
bind: '$title', defaultValue: firstItem.title,
|
|
6672
8997
|
})
|
|
6673
|
-
if (withSuffix
|
|
8998
|
+
if (descInline && !withSuffix && !withTitleRight && !inlineAffordSample) {
|
|
8999
|
+
const _inlineGap = Math.max(2, Math.round((firstItem.descX || 0)
|
|
9000
|
+
- ((firstItem.titleX || 0) + (firstItem.titleW || 0))))
|
|
9001
|
+
const titleRow = container('Row', { direction: 'row', align: 'center', gap: String(_inlineGap) })
|
|
9002
|
+
ensureStyleGroup(titleRow, 'flex')
|
|
9003
|
+
titleRow.slots[0].components.push(titleText)
|
|
9004
|
+
titleRow.slots[0].components.push(textFromSpec({
|
|
9005
|
+
text: '$desc', color: firstItem.descColor, fontSize: firstItem.descFontSize || T.font('small'),
|
|
9006
|
+
fontWeight: firstItem.descFontWeight,
|
|
9007
|
+
bind: '$desc', defaultValue: firstItem.desc,
|
|
9008
|
+
}))
|
|
9009
|
+
leftCol.slots[0].components.push(titleRow)
|
|
9010
|
+
} else if (withSuffix || withTitleRight || inlineAffordSample) {
|
|
6674
9011
|
// 标题+后缀/右侧同行文本(基线对齐):Row[title, suffix?, titleRight?]
|
|
6675
9012
|
const titleRow = container('Row', {
|
|
6676
9013
|
direction: 'row', align: 'center',
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
9014
|
+
justify: (withTitleRight && !withSuffix && !inlineAffordSample) ? 'space-between' : 'flex-start',
|
|
9015
|
+
gap: inlineAffordSample && !withTitleRight && !withSuffix
|
|
9016
|
+
? String(inlineAffordSample.affordInlineGap || 8)
|
|
9017
|
+
: (withTitleRight && !withSuffix)
|
|
9018
|
+
? String(Math.max(4, Math.round(firstItem.titleRightGap || 8)))
|
|
9019
|
+
: T.gap('tight'),
|
|
6680
9020
|
})
|
|
6681
9021
|
ensureStyleGroup(titleRow, 'flex')
|
|
6682
9022
|
titleRow.slots[0].components.push(titleText)
|
|
9023
|
+
// space-between 布局下标题须可收缩(flexShrink=1),否则长标题把 titleRight 挤出右沿溢出。
|
|
9024
|
+
if (withTitleRight && !withSuffix && !inlineAffordSample) {
|
|
9025
|
+
ensureStyleGroup(titleText, 'flex')
|
|
9026
|
+
setStyleChild(titleText, 'flex', 'flexShrink', '1')
|
|
9027
|
+
}
|
|
9028
|
+
if (inlineAffordSample) {
|
|
9029
|
+
const iaR = rect(inlineAffordSample.affordanceEl)
|
|
9030
|
+
const inlineAffordImg = comp('Image')
|
|
9031
|
+
ensureStyleGroup(inlineAffordImg, 'rect')
|
|
9032
|
+
ensureStyleGroup(inlineAffordImg, 'image')
|
|
9033
|
+
ensureStyleGroup(inlineAffordImg, 'flex')
|
|
9034
|
+
setStyleChild(inlineAffordImg, 'rect', 'width', String(Math.round(iaR?.width || 20)))
|
|
9035
|
+
setStyleChild(inlineAffordImg, 'rect', 'height', String(Math.round(iaR?.height || 20)))
|
|
9036
|
+
setStyleChild(inlineAffordImg, 'flex', 'flexShrink', '0')
|
|
9037
|
+
setStyleChild(inlineAffordImg, 'image', 'cropMode', '01')
|
|
9038
|
+
inlineAffordImg.properties = [boundProp('value', '$afford', inlineAffordSample.affordanceUrl || '')]
|
|
9039
|
+
inlineAffordImg.directives = [vShowDirective("$afford != ''")]
|
|
9040
|
+
titleRow.slots[0].components.push(inlineAffordImg)
|
|
9041
|
+
}
|
|
6683
9042
|
if (withSuffix) {
|
|
6684
9043
|
titleRow.slots[0].components.push(textFromSpec({
|
|
6685
9044
|
text: '$titleSuffix', color: firstItem.titleSuffixColor || '#999999',
|
|
@@ -6725,15 +9084,67 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6725
9084
|
const pairCol = container('Column', { direction: 'column', gap: labelValueGap })
|
|
6726
9085
|
ensureStyleGroup(pairCol, 'flex')
|
|
6727
9086
|
setStyleChild(pairCol, 'flex', 'flexShrink', '0')
|
|
6728
|
-
|
|
9087
|
+
const labelRow = container('Row', { direction: 'row', align: 'center', gap: String(pp.tagGap ?? 6) })
|
|
9088
|
+
ensureStyleGroup(labelRow, 'flex')
|
|
9089
|
+
labelRow.slots[0].components.push(textFromSpec({
|
|
6729
9090
|
text: '$amtLabel' + (pi + 1), color: pp.labelColor || '#999999',
|
|
6730
9091
|
fontSize: pp.labelFontSize || T.font('small'), fontWeight: pp.labelFontWeight,
|
|
6731
9092
|
bind: '$amtLabel' + (pi + 1), defaultValue: pp.label,
|
|
6732
9093
|
}))
|
|
9094
|
+
// 内联 tag 壳:样式取首个带 tag 的样本行,内容逐行绑定;空行 v-show 隐藏
|
|
9095
|
+
const tagSrc = sem.sampleItems.map((it2) => (it2.amounts || [])[pi]).find((aa) => aa && (aa.tagText || aa.tagIcon)) || pp
|
|
9096
|
+
if (tagSrc.tagW || tagSrc.tagText || tagSrc.tagIcon) {
|
|
9097
|
+
const tag = container('Row', {
|
|
9098
|
+
direction: 'row', align: 'center', justify: 'center',
|
|
9099
|
+
gap: String(tagSrc.tagIconGap ?? 2),
|
|
9100
|
+
padding: '0 ' + (tagSrc.tagPadRight ?? 4) + ' 0 ' + (tagSrc.tagPadLeft ?? 8),
|
|
9101
|
+
})
|
|
9102
|
+
ensureStyleGroup(tag, 'rect')
|
|
9103
|
+
ensureStyleGroup(tag, 'background')
|
|
9104
|
+
ensureStyleGroup(tag, 'borderRadius')
|
|
9105
|
+
ensureStyleGroup(tag, 'flex')
|
|
9106
|
+
setStyleChild(tag, 'rect', 'width', String(tagSrc.tagW || 54))
|
|
9107
|
+
setStyleChild(tag, 'rect', 'height', String(tagSrc.tagH || 16))
|
|
9108
|
+
setStyleChild(tag, 'flex', 'flexShrink', '0')
|
|
9109
|
+
if (tagSrc.tagFill) {
|
|
9110
|
+
setStyleChild(tag, 'background', 'backgroundType', '01')
|
|
9111
|
+
setStyleChild(tag, 'background', 'backgroundColor', hexToRgba(tagSrc.tagFill, tagSrc.tagOpacity ?? 1))
|
|
9112
|
+
}
|
|
9113
|
+
if (tagSrc.tagBorder) {
|
|
9114
|
+
ensureStyleGroup(tag, 'border')
|
|
9115
|
+
setStyleChild(tag, 'border', 'isBorder', '1')
|
|
9116
|
+
setStyleChild(tag, 'border', 'borderWidth', '1')
|
|
9117
|
+
setStyleChild(tag, 'border', 'borderStyle', 'solid')
|
|
9118
|
+
setStyleChild(tag, 'border', 'borderColor', hexToRgba(tagSrc.tagBorder))
|
|
9119
|
+
}
|
|
9120
|
+
setStyleChild(tag, 'borderRadius', 'borderRadiusType', '0')
|
|
9121
|
+
setStyleChild(tag, 'borderRadius', 'borderRadius', String(tagSrc.tagRadius || 4))
|
|
9122
|
+
tag.slots[0].components.push(textFromSpec({
|
|
9123
|
+
text: '$amtTag' + (pi + 1), color: tagSrc.tagTextColor || '#FF8401',
|
|
9124
|
+
fontSize: tagSrc.tagFontSize || T.font('small'),
|
|
9125
|
+
bind: '$amtTag' + (pi + 1), defaultValue: pp.tagText || '',
|
|
9126
|
+
}))
|
|
9127
|
+
if (tagSrc.tagIconW) {
|
|
9128
|
+
const tagIcon = comp('Image')
|
|
9129
|
+
ensureStyleGroup(tagIcon, 'rect')
|
|
9130
|
+
ensureStyleGroup(tagIcon, 'image')
|
|
9131
|
+
ensureStyleGroup(tagIcon, 'flex')
|
|
9132
|
+
setStyleChild(tagIcon, 'rect', 'width', String(tagSrc.tagIconW))
|
|
9133
|
+
setStyleChild(tagIcon, 'rect', 'height', String(tagSrc.tagIconH))
|
|
9134
|
+
setStyleChild(tagIcon, 'flex', 'flexShrink', '0')
|
|
9135
|
+
setStyleChild(tagIcon, 'image', 'cropMode', '01')
|
|
9136
|
+
tagIcon.properties = [boundProp('value', '$amtTagIcon' + (pi + 1), pp.tagIcon || '')]
|
|
9137
|
+
tagIcon.directives = [vShowDirective("$amtTagIcon" + (pi + 1) + " != ''")]
|
|
9138
|
+
tag.slots[0].components.push(tagIcon)
|
|
9139
|
+
}
|
|
9140
|
+
tag.directives = [vShowDirective("($amtTag" + (pi + 1) + " || $amtTagIcon" + (pi + 1) + ") != ''")]
|
|
9141
|
+
labelRow.slots[0].components.push(tag)
|
|
9142
|
+
}
|
|
9143
|
+
pairCol.slots[0].components.push(labelRow)
|
|
6733
9144
|
pairCol.slots[0].components.push(textFromSpec({
|
|
6734
9145
|
text: pp.value, color: pp.valueColor || '#111111',
|
|
6735
9146
|
fontSize: pp.valueFontSize || T.font('title'), fontWeight: pp.valueFontWeight,
|
|
6736
|
-
bind: amountExpr('amtValue' + (pi + 1), pp.value), defaultValue: pp.value,
|
|
9147
|
+
bind: amountExpr('amtValue' + (pi + 1), pp.value, { prefixVar: 'amtCur' + (pi + 1) }), defaultValue: pp.value,
|
|
6737
9148
|
}))
|
|
6738
9149
|
amountsRow.slots[0].components.push(pairCol)
|
|
6739
9150
|
})
|
|
@@ -6747,12 +9158,27 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6747
9158
|
- titleLineHeight - (firstItem.labelGapTop ?? 8) - _amountsContentH - dividerHeight))
|
|
6748
9159
|
setStyleChild(amountsRow, 'padding', 'paddingBottom', String(_amountsPadBottom))
|
|
6749
9160
|
compositeAmountsRow = amountsRow
|
|
6750
|
-
} else if (firstItem.desc) {
|
|
6751
|
-
|
|
9161
|
+
} else if (firstItem.desc && !descInline) {
|
|
9162
|
+
const _descText = textFromSpec({
|
|
6752
9163
|
text: '$desc', color: firstItem.descColor, fontSize: firstItem.descFontSize || T.font('small'),
|
|
6753
9164
|
fontWeight: firstItem.descFontWeight,
|
|
6754
9165
|
bind: '$desc', defaultValue: firstItem.desc,
|
|
6755
|
-
})
|
|
9166
|
+
})
|
|
9167
|
+
// desc 的 spec x 坐标可能与标题左沿不一致(百分比文本缩进在标题右沿之后),
|
|
9168
|
+
// 用左 padding 补齐差值使 desc 对齐 spec 位置。
|
|
9169
|
+
if (firstItem.descX != null && firstItem.y != null) {
|
|
9170
|
+
const _titleX = firstItem.rowLeftEdge || (rect(card).x || 0) + 16
|
|
9171
|
+
const _descIndent = Math.max(0, Math.round((firstItem.descX || 0) - _titleX))
|
|
9172
|
+
if (_descIndent >= 4) {
|
|
9173
|
+
const _descWrap = container('Row', { width: '100%', justify: 'flex-start', align: 'flex-start', padding: `0 0 0 ${_descIndent}` })
|
|
9174
|
+
_descWrap.slots[0].components.push(_descText)
|
|
9175
|
+
leftCol.slots[0].components.push(_descWrap)
|
|
9176
|
+
} else {
|
|
9177
|
+
leftCol.slots[0].components.push(_descText)
|
|
9178
|
+
}
|
|
9179
|
+
} else {
|
|
9180
|
+
leftCol.slots[0].components.push(_descText)
|
|
9181
|
+
}
|
|
6756
9182
|
} else if (firstItem.time) {
|
|
6757
9183
|
leftCol.slots[0].components.push(textFromSpec({
|
|
6758
9184
|
text: '$time', color: firstItem.descColor || '#999999',
|
|
@@ -6777,7 +9203,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6777
9203
|
text: firstItem.amount, color: firstItem.amountColor || '#111111',
|
|
6778
9204
|
fontSize: firstItem.amountFontSize || T.font('title'),
|
|
6779
9205
|
fontWeight: firstItem.amountFontWeight,
|
|
6780
|
-
bind: amountExpr(itemAmtVar, firstItem.amount),
|
|
9206
|
+
bind: amountExpr(itemAmtVar, firstItem.amount, { prefixVar: 'itemAmountCur' }),
|
|
6781
9207
|
defaultValue: firstItem.amount,
|
|
6782
9208
|
}))
|
|
6783
9209
|
}
|
|
@@ -6790,14 +9216,45 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6790
9216
|
rightRow.slots[0].components.push(rightCol)
|
|
6791
9217
|
}
|
|
6792
9218
|
// 无金额行(如名册行)不渲染金额节点:空文本会占位撑开行高
|
|
6793
|
-
if (firstItem.amount && !withDescTime) {
|
|
6794
|
-
|
|
9219
|
+
if ((firstItem.amount || firstItem.descRight) && !withDescTime) {
|
|
9220
|
+
// 标题行已渲染 $titleRight 时(同一金额元素被 titleRight 通道接管),
|
|
9221
|
+
// 右列不再重复渲染金额:仅保留 descRight 行(交易行余额列),否则同一金额双份重影。
|
|
9222
|
+
const amountText = (!firstItem.titleRight && firstItem.amount) ? textFromSpec({
|
|
6795
9223
|
text: firstItem.amount, color: firstItem.amountColor || '#111111',
|
|
6796
9224
|
fontSize: firstItem.amountFontSize || T.font('title'),
|
|
6797
9225
|
fontWeight: firstItem.amountFontWeight,
|
|
6798
|
-
bind: amountExpr(itemAmtVar, firstItem.amount),
|
|
9226
|
+
bind: amountExpr(itemAmtVar, firstItem.amount, { prefixVar: 'itemAmountCur' }),
|
|
6799
9227
|
defaultValue: firstItem.amount,
|
|
6800
|
-
})
|
|
9228
|
+
}) : null
|
|
9229
|
+
if (firstItem.descRight) {
|
|
9230
|
+
// 行右列第二行:Column[amount, descRight] 右对齐(交易行余额行)
|
|
9231
|
+
const rightCol2 = container('Column', { direction: 'column', align: 'flex-end', gap: titleDescGap })
|
|
9232
|
+
ensureStyleGroup(rightCol2, 'flex')
|
|
9233
|
+
setStyleChild(rightCol2, 'flex', 'flexShrink', '0')
|
|
9234
|
+
// 右列与左列逐行顶对齐(金额行/余额行),垂直居中会让两行挤成一团
|
|
9235
|
+
setStyleChild(rightRow, 'flex', 'alignItems', 'flex-start')
|
|
9236
|
+
if (amountText) rightCol2.slots[0].components.push(amountText)
|
|
9237
|
+
else if (firstItem.titleRight) {
|
|
9238
|
+
// 金额被 titleRight 通道接管时,右列缺首行金额 → 用等高占位撑住间距,
|
|
9239
|
+
// 否则 descRight 被顶到标题行基线(比 spec 偏高约一行)。
|
|
9240
|
+
const amtH = Math.round((firstItem.amountFontSize || T.font('title')) * 1.4)
|
|
9241
|
+
const spacer = container('Column', { direction: 'column', padding: '0' })
|
|
9242
|
+
ensureStyleGroup(spacer, 'rect')
|
|
9243
|
+
setStyleChild(spacer, 'rect', 'height', String(amtH))
|
|
9244
|
+
setStyleChild(spacer, 'rect', 'width', '1')
|
|
9245
|
+
ensureStyleGroup(spacer, 'flex')
|
|
9246
|
+
setStyleChild(spacer, 'flex', 'flexShrink', '0')
|
|
9247
|
+
rightCol2.slots[0].components.push(spacer)
|
|
9248
|
+
}
|
|
9249
|
+
rightCol2.slots[0].components.push(textFromSpec({
|
|
9250
|
+
text: '$descRight', color: firstItem.descRightColor || '#999999',
|
|
9251
|
+
fontSize: firstItem.descRightFontSize || T.font('small'),
|
|
9252
|
+
bind: '$descRight', defaultValue: firstItem.descRight,
|
|
9253
|
+
}))
|
|
9254
|
+
rightRow.slots[0].components.push(rightCol2)
|
|
9255
|
+
} else {
|
|
9256
|
+
rightRow.slots[0].components.push(amountText)
|
|
9257
|
+
}
|
|
6801
9258
|
}
|
|
6802
9259
|
// 右侧操作件:行内操作 pill(描边小按钮,如「删除」)优先;否则按右侧重复小媒体选择
|
|
6803
9260
|
if (withAction) {
|
|
@@ -6829,7 +9286,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6829
9286
|
} else {
|
|
6830
9287
|
// 逐行行尾 affordance(extract 阶段已按行顶沿对齐挂接):$afford 数据驱动,
|
|
6831
9288
|
// 无行级操作件的行(如末行)绑定空串 + v-show 隐藏,避免静态模板每行滥发。
|
|
6832
|
-
const withRowAfford = sem.sampleItems.some((item) => item.affordanceUrl)
|
|
9289
|
+
const withRowAfford = sem.sampleItems.some((item) => item.affordanceUrl && !item.affordInline)
|
|
6833
9290
|
if (withRowAfford) {
|
|
6834
9291
|
const affSample = sem.sampleItems.find((item) => item.affordanceEl)
|
|
6835
9292
|
const affR = affSample ? rect(affSample.affordanceEl) : null
|
|
@@ -6862,6 +9319,20 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6862
9319
|
// 图标与文字列间距取 spec:标题 x - (图标 x + 图标宽)
|
|
6863
9320
|
innerRow.slots[0].components.push(rowIcon)
|
|
6864
9321
|
}
|
|
9322
|
+
// 行右大图标(数据驱动 $rightIcon):所有样本行都有时才带图标位
|
|
9323
|
+
const withRightIcon = sem.sampleItems.length > 0 && sem.sampleItems.every((item) => item.rightIcon)
|
|
9324
|
+
if (withRightIcon) {
|
|
9325
|
+
const rIcon = comp('Image')
|
|
9326
|
+
ensureStyleGroup(rIcon, 'rect')
|
|
9327
|
+
ensureStyleGroup(rIcon, 'image')
|
|
9328
|
+
ensureStyleGroup(rIcon, 'flex')
|
|
9329
|
+
setStyleChild(rIcon, 'rect', 'width', String(firstItem.rightIconW || 48))
|
|
9330
|
+
setStyleChild(rIcon, 'rect', 'height', String(firstItem.rightIconH || 48))
|
|
9331
|
+
setStyleChild(rIcon, 'flex', 'flexShrink', '0')
|
|
9332
|
+
setStyleChild(rIcon, 'image', 'cropMode', '01')
|
|
9333
|
+
rIcon.properties = [boundProp('value', '$rightIcon', firstItem.rightIcon)]
|
|
9334
|
+
rightRow.slots[0].components.push(rIcon)
|
|
9335
|
+
}
|
|
6865
9336
|
innerRow.slots[0].components.push(leftCol, rightRow)
|
|
6866
9337
|
// 分割线(itemTpl 底部 1px Divider,颜色 #E9ECF2,作为 Column 子节点纵向跟在内容行之后)
|
|
6867
9338
|
const divider = comp('Divider')
|
|
@@ -6872,15 +9343,77 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6872
9343
|
setStyleChild(divider, 'background', 'backgroundType', '01')
|
|
6873
9344
|
setStyleChild(divider, 'background', 'backgroundColor', hexToRgba('#E9ECF2'))
|
|
6874
9345
|
if (compositeAmountsRow) {
|
|
6875
|
-
|
|
9346
|
+
if (firstItem.bars && firstItem.bars[0]) {
|
|
9347
|
+
const _bar = firstItem.bars[0]
|
|
9348
|
+
const _barTrack = container('Row', { width: '100%', direction: 'row', justify: 'flex-start', align: 'center', gap: T.gap('none') })
|
|
9349
|
+
const _barLPad = Math.max(0, Math.round(((_bar.x || buildCardLeft) - (rowShell ? (firstItem.rowLeftEdge || buildCardLeft) : buildCardLeft))))
|
|
9350
|
+
const _barRPad = Math.max(0, Math.round(buildCardRight - ((_bar.x || buildCardLeft) + (_bar.width || 0))))
|
|
9351
|
+
setStyleChild(_barTrack, 'padding', 'paddingType', '1')
|
|
9352
|
+
setStyleChild(_barTrack, 'padding', 'paddingTop', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
|
|
9353
|
+
setStyleChild(_barTrack, 'padding', 'paddingBottom', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
|
|
9354
|
+
setStyleChild(_barTrack, 'padding', 'paddingLeft', String(_barLPad))
|
|
9355
|
+
setStyleChild(_barTrack, 'padding', 'paddingRight', String(_barRPad))
|
|
9356
|
+
ensureStyleGroup(_barTrack, 'background')
|
|
9357
|
+
setStyleChild(_barTrack, 'background', 'backgroundType', '01')
|
|
9358
|
+
setStyleChild(_barTrack, 'background', 'backgroundColor', hexToRgba(_bar.trackColor || '#F5F5F5'))
|
|
9359
|
+
ensureStyleGroup(_barTrack, 'borderRadius')
|
|
9360
|
+
setStyleChild(_barTrack, 'borderRadius', 'borderRadiusType', '0')
|
|
9361
|
+
setStyleChild(_barTrack, 'borderRadius', 'borderRadius', String(Math.round((_bar.height || 4) / 2)))
|
|
9362
|
+
const _barSeg = container('Row', { direction: 'row', flexShrink: 0 })
|
|
9363
|
+
const _barSegW = (_barSeg.style.properties.find((g) => g.itemKey === 'rect')?.itemChildren?.properties?.find((p) => p.itemKey === 'width'))
|
|
9364
|
+
if (_barSegW) { _barSegW.itemValueFrom = '1'; _barSegW.itemValue = "Math.round($barRatio * 100) + '%'"; _barSegW.itemDefaultValue = Math.round(((_bar.segments && _bar.segments[0] && _bar.segments[0].ratio) || 0) * 100) + '%' }
|
|
9365
|
+
setStyleChild(_barSeg, 'rect', 'height', String(_bar.height || 4))
|
|
9366
|
+
setStyleChild(_barSeg, 'flex', 'flexShrink', '0')
|
|
9367
|
+
ensureStyleGroup(_barSeg, 'background')
|
|
9368
|
+
setStyleChild(_barSeg, 'background', 'backgroundType', '01')
|
|
9369
|
+
setStyleChild(_barSeg, 'background', 'backgroundColor', hexToRgba((_bar.segments && _bar.segments[0] && _bar.segments[0].color) || '#83B8F1'))
|
|
9370
|
+
ensureStyleGroup(_barSeg, 'borderRadius')
|
|
9371
|
+
setStyleChild(_barSeg, 'borderRadius', 'borderRadiusType', '0')
|
|
9372
|
+
setStyleChild(_barSeg, 'borderRadius', 'borderRadius', String(Math.round((_bar.height || 4) / 2)))
|
|
9373
|
+
_barTrack.slots[0].components.push(_barSeg)
|
|
9374
|
+
itemTpl.slots[0].components.push(innerRow, compositeAmountsRow, _barTrack, ...(shellsSeparated ? [] : [divider]))
|
|
9375
|
+
} else {
|
|
9376
|
+
itemTpl.slots[0].components.push(innerRow, compositeAmountsRow, ...(shellsSeparated ? [] : [divider]))
|
|
9377
|
+
}
|
|
6876
9378
|
} else {
|
|
6877
|
-
|
|
9379
|
+
if (firstItem.bars && firstItem.bars[0]) {
|
|
9380
|
+
const _bar2 = firstItem.bars[0]
|
|
9381
|
+
const _barT2 = container('Row', { width: '100%', direction: 'row', justify: 'flex-start', align: 'center', gap: T.gap('none') })
|
|
9382
|
+
const _lp2 = Math.max(0, Math.round(((_bar2.x || buildCardLeft) - (rowShell ? (firstItem.rowLeftEdge || buildCardLeft) : buildCardLeft))))
|
|
9383
|
+
const _rp2 = Math.max(0, Math.round(buildCardRight - ((_bar2.x || buildCardLeft) + (_bar2.width || 0))))
|
|
9384
|
+
setStyleChild(_barT2, 'padding', 'paddingType', '1')
|
|
9385
|
+
setStyleChild(_barT2, 'padding', 'paddingTop', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
|
|
9386
|
+
setStyleChild(_barT2, 'padding', 'paddingBottom', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
|
|
9387
|
+
setStyleChild(_barT2, 'padding', 'paddingLeft', String(_lp2))
|
|
9388
|
+
setStyleChild(_barT2, 'padding', 'paddingRight', String(_rp2))
|
|
9389
|
+
ensureStyleGroup(_barT2, 'background')
|
|
9390
|
+
setStyleChild(_barT2, 'background', 'backgroundType', '01')
|
|
9391
|
+
setStyleChild(_barT2, 'background', 'backgroundColor', hexToRgba(_bar2.trackColor || '#F5F5F5'))
|
|
9392
|
+
ensureStyleGroup(_barT2, 'borderRadius')
|
|
9393
|
+
setStyleChild(_barT2, 'borderRadius', 'borderRadiusType', '0')
|
|
9394
|
+
setStyleChild(_barT2, 'borderRadius', 'borderRadius', String(Math.round((_bar2.height || 4) / 2)))
|
|
9395
|
+
const _seg2 = container('Row', { direction: 'row', flexShrink: 0 })
|
|
9396
|
+
const _seg2W = (_seg2.style.properties.find((g) => g.itemKey === 'rect')?.itemChildren?.properties?.find((p) => p.itemKey === 'width'))
|
|
9397
|
+
if (_seg2W) { _seg2W.itemValueFrom = '1'; _seg2W.itemValue = "Math.round($barRatio * 100) + '%'"; _seg2W.itemDefaultValue = Math.round(((_bar2.segments && _bar2.segments[0] && _bar2.segments[0].ratio) || 0) * 100) + '%' }
|
|
9398
|
+
setStyleChild(_seg2, 'rect', 'height', String(_bar2.height || 4))
|
|
9399
|
+
setStyleChild(_seg2, 'flex', 'flexShrink', '0')
|
|
9400
|
+
ensureStyleGroup(_seg2, 'background')
|
|
9401
|
+
setStyleChild(_seg2, 'background', 'backgroundType', '01')
|
|
9402
|
+
setStyleChild(_seg2, 'background', 'backgroundColor', hexToRgba((_bar2.segments && _bar2.segments[0] && _bar2.segments[0].color) || '#83B8F1'))
|
|
9403
|
+
ensureStyleGroup(_seg2, 'borderRadius')
|
|
9404
|
+
setStyleChild(_seg2, 'borderRadius', 'borderRadiusType', '0')
|
|
9405
|
+
setStyleChild(_seg2, 'borderRadius', 'borderRadius', String(Math.round((_bar2.height || 4) / 2)))
|
|
9406
|
+
_seg2.slots && _barT2.slots[0].components.push(_seg2)
|
|
9407
|
+
itemTpl.slots[0].components.push(innerRow, _barT2, ...(shellsSeparated ? [] : [divider]))
|
|
9408
|
+
} else {
|
|
9409
|
+
itemTpl.slots[0].components.push(innerRow, ...(shellsSeparated ? [] : [divider]))
|
|
9410
|
+
}
|
|
6878
9411
|
}
|
|
6879
9412
|
// 钉壳高=spec 行高中位:奇数字号自然行盒量化(fs15→22.5)使 paddingBottom 只能取整,
|
|
6880
9413
|
// 每行 ±0.5px 误差在列表纵向堆叠中逐行累积(item2 -0.5、item3 -1)。钉高后空隙落在
|
|
6881
9414
|
// 底部 padding 区(divider 之上),不可见;仅在内容估算不超壳高(有底距余量)且非复合行
|
|
6882
9415
|
// 时钉,防止内容被裁切。
|
|
6883
|
-
if (!isComposite && listRowPaddingBottom > 0) {
|
|
9416
|
+
if (!isComposite && (listRowPaddingBottom > 0 || rowShell)) {
|
|
6884
9417
|
ensureStyleGroup(itemTpl, 'rect')
|
|
6885
9418
|
setStyleChild(itemTpl, 'rect', 'height', String(Math.round(listRowHeight)))
|
|
6886
9419
|
}
|
|
@@ -6891,12 +9424,30 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
6891
9424
|
list.properties = [
|
|
6892
9425
|
boundProp('value', '$' + listVar, sem.sampleItems.map(() => ({}))),
|
|
6893
9426
|
constProp('direction', 'vertical'),
|
|
6894
|
-
constProp('row-spacing',
|
|
9427
|
+
constProp('row-spacing', String(shellsSeparated ? (() => {
|
|
9428
|
+
const gaps2 = sem.sampleItems.slice(1).map((it2, i2) => {
|
|
9429
|
+
const prev = rect(sem.sampleItems[i2].shellEl)
|
|
9430
|
+
const cur = rect(it2.shellEl)
|
|
9431
|
+
return Math.max(0, Math.round((cur.y || 0) - ((prev.y || 0) + (prev.height || 0))))
|
|
9432
|
+
})
|
|
9433
|
+
return gaps2.length ? gaps2.slice().sort((a, b) => a - b)[Math.floor(gaps2.length / 2)] : 0
|
|
9434
|
+
})() : 0)),
|
|
6895
9435
|
constProp('edge-insets', '{0,0,0,0}'),
|
|
6896
9436
|
]
|
|
6897
9437
|
// 按首行 y 语义插入(而非末尾追加):否则「查看更多」等后续语义块按 y 插入时会越过列表,
|
|
6898
9438
|
// 出现入口渲染在列表上方的错序(名册卡实测)。
|
|
6899
|
-
|
|
9439
|
+
// 行壳内缩:壳宽 < 卡宽时列表整体按壳左右沿内缩(壳是行背景,列表须与壳同宽同位)
|
|
9440
|
+
const listAnchorY = sem.sampleItems[0]?.rowTop ?? sem.sampleItems[0]?.y ?? sem.listTitle?.y ?? 0
|
|
9441
|
+
const shellR0 = sem.sampleItems[0]?.shellEl ? rect(sem.sampleItems[0].shellEl) : null
|
|
9442
|
+
const insetLeft = rowShell && shellR0 ? Math.max(0, Math.round((shellR0.x || 0) - buildCardLeft)) : 0
|
|
9443
|
+
const insetRight = rowShell && shellR0 ? Math.max(0, Math.round(buildCardRight - ((shellR0.x || 0) + (shellR0.width || 0)))) : 0
|
|
9444
|
+
if (insetLeft > 0 || insetRight > 0) {
|
|
9445
|
+
const wrapList = container('Row', { width: '100%', padding: `0 ${insetRight} 0 ${insetLeft}` })
|
|
9446
|
+
wrapList.slots[0].components.push(list)
|
|
9447
|
+
insertSemantic(card, wrapList, listAnchorY)
|
|
9448
|
+
} else {
|
|
9449
|
+
insertSemantic(card, list, listAnchorY)
|
|
9450
|
+
}
|
|
6900
9451
|
}
|
|
6901
9452
|
|
|
6902
9453
|
// —— 7b. 重复面板组列表(同构面板:壳背景 + 标题/副标题行 + 金额/单位行 + 说明 + 行内按钮)——
|
|
@@ -7130,14 +9681,98 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
7130
9681
|
col.slots[0].components.push(lineRow)
|
|
7131
9682
|
prevBottom = lineY + lineH
|
|
7132
9683
|
}
|
|
7133
|
-
|
|
7134
|
-
const
|
|
9684
|
+
// 段落壳(灰底圆角容器)认领:底色/圆角/内缩按 spec 壳几何,避免段落裸排在卡底上
|
|
9685
|
+
const rbShell = shellForRect(rect(rb.container))
|
|
9686
|
+
if (rbShell) {
|
|
9687
|
+
applyShellBackground(col, rbShell)
|
|
9688
|
+
ensureStyleGroup(col, 'rect')
|
|
9689
|
+
setStyleChild(col, 'rect', 'width', String(Math.round(rbShell.width)))
|
|
9690
|
+
const shellPadL = Math.max(0, Math.round((rb.x || 0) - rbShell.x))
|
|
9691
|
+
const shellPadT = Math.max(0, Math.round((rb.y || 0) - rbShell.y))
|
|
9692
|
+
const shellPadB = Math.max(0, Math.round((rbShell.y + rbShell.height) - ((rb.y || 0) + (rect(rb.container).height || 0))))
|
|
9693
|
+
ensureStyleGroup(col, 'padding')
|
|
9694
|
+
setStyleChild(col, 'padding', 'paddingType', '1')
|
|
9695
|
+
setStyleChild(col, 'padding', 'paddingLeft', String(shellPadL))
|
|
9696
|
+
setStyleChild(col, 'padding', 'paddingRight', String(Math.max(0, Math.round((rbShell.x + rbShell.width) - ((rb.x || 0) + (rect(rb.container).width || 0))))))
|
|
9697
|
+
setStyleChild(col, 'padding', 'paddingTop', String(shellPadT))
|
|
9698
|
+
setStyleChild(col, 'padding', 'paddingBottom', String(shellPadB))
|
|
9699
|
+
}
|
|
9700
|
+
// 段落壳内的尾部链接行(查看详细建议 >):壳几何涵盖它,富文本碎片只到段落末行,
|
|
9701
|
+
// 不补这行会让链接文案整体丢失(panelRows 曾误领该壳,回收后此处接管)。
|
|
9702
|
+
if (rbShell) {
|
|
9703
|
+
const shellR = { x: rbShell.x, y: rbShell.y, width: rbShell.width, height: rbShell.height }
|
|
9704
|
+
const paraBottom = (rect(rb.container).y || 0) + (rect(rb.container).height || 0)
|
|
9705
|
+
const renderedTextEls = new Set([
|
|
9706
|
+
...(sem.postAmountTexts || []).map((t) => t.sourceEl),
|
|
9707
|
+
...(sem.auxTexts || []).map((t) => t.sourceEl),
|
|
9708
|
+
...(sem.buttons || []).map((b) => b.sourceEl),
|
|
9709
|
+
...(sem.richBlocks || []).flatMap((blk) => blk.fragments || []),
|
|
9710
|
+
].filter(Boolean))
|
|
9711
|
+
const linkEls = (sem.allTexts || []).filter((t) => !renderedTextEls.has(t)
|
|
9712
|
+
&& !linkConsumed.has(t)
|
|
9713
|
+
&& rectInside(shellR, anyRect(t), 0) && (anyRect(t).y || 0) >= paraBottom - 6)
|
|
9714
|
+
.sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
|
|
9715
|
+
for (const lk of linkEls) {
|
|
9716
|
+
linkConsumed.add(lk)
|
|
9717
|
+
const lr = anyRect(lk)
|
|
9718
|
+
const lineRow = container('Row', { width: '100%', justify: 'flex-start', align: 'center', gap: '0', padding: `${Math.max(0, Math.round(lr.y - paraBottom - (prevBottom != null ? 0 : 0)))} 0 0 0` })
|
|
9719
|
+
setStyleChild(lineRow, 'padding', 'paddingLeft', String(Math.max(0, Math.round(lr.x - (rbShell.x || 0)))))
|
|
9720
|
+
lineRow.slots[0].components.push(textFromSpec({
|
|
9721
|
+
text: textOf(lk), color: anyColor(lk), fontSize: anyFs(lk),
|
|
9722
|
+
fontWeight: (lk && lk.fontWeight) || ts(lk).fontWeight || ts(lk && lk.sourceEl).fontWeight,
|
|
9723
|
+
height: lr.height, align: 'left',
|
|
9724
|
+
}))
|
|
9725
|
+
appendInlineTrailSlice(lineRow, { x: lr.x, y: lr.y, width: lr.width, height: lr.height })
|
|
9726
|
+
col.slots[0].components.push(lineRow)
|
|
9727
|
+
prevBottom = lr.y + (lr.height || 0)
|
|
9728
|
+
}
|
|
9729
|
+
if (linkEls.length) {
|
|
9730
|
+
setStyleChild(col, 'padding', 'paddingBottom', String(Math.max(0,
|
|
9731
|
+
Math.round((rbShell.y + rbShell.height) - prevBottom))))
|
|
9732
|
+
}
|
|
9733
|
+
}
|
|
9734
|
+
const richLeft = Math.max(0, Math.round(((rbShell ? rbShell.x : rb.x) || 0) - buildCardLeft))
|
|
9735
|
+
const richTop = semanticTopGap(rbShell ? rbShell.y : rb.y) ?? Math.max(0, Math.round((rbShell ? rbShell.y : rb.y) - (previousAuxBottom ?? buildCard.y)))
|
|
7135
9736
|
const wrapRow = container('Row', { width: '100%', padding: `${richTop} 0 0 ${richLeft}` })
|
|
7136
9737
|
wrapRow.slots[0].components.push(col)
|
|
7137
9738
|
insertSemantic(hostFor(rb.y), wrapRow, rb.y)
|
|
7138
9739
|
previousAuxBottom = rb.y + (rect(rb.container).height || 0)
|
|
7139
9740
|
}
|
|
7140
9741
|
|
|
9742
|
+
// —— 图表矢量簇:Canvas 自绘回放(柱状图/网格线/轴标签),按 spec y 插回主流 ——
|
|
9743
|
+
for (const cluster of sem.chartClusters || []) {
|
|
9744
|
+
const flexChart = buildFlexBarChart(cluster)
|
|
9745
|
+
if (flexChart) {
|
|
9746
|
+
const left = Math.max(0, Math.round(cluster.x - buildCardLeft))
|
|
9747
|
+
const right = Math.max(0, Math.round(buildCardRight - (cluster.x + cluster.width)))
|
|
9748
|
+
const top = semanticTopGap(cluster.y) ?? 0
|
|
9749
|
+
const wrap = container('Row', { width: '100%', padding: `${top} ${right} 0 ${left}` })
|
|
9750
|
+
wrap.slots[0].components.push(flexChart)
|
|
9751
|
+
insertSemantic(card, wrap, cluster.y)
|
|
9752
|
+
continue
|
|
9753
|
+
}
|
|
9754
|
+
const cv = comp('Canvas')
|
|
9755
|
+
ensureStyleGroup(cv, 'rect')
|
|
9756
|
+
ensureStyleGroup(cv, 'flex')
|
|
9757
|
+
ensureStyleGroup(cv, 'background')
|
|
9758
|
+
setStyleChild(cv, 'rect', 'width', String(Math.round(cluster.width)))
|
|
9759
|
+
setStyleChild(cv, 'rect', 'height', String(Math.round(cluster.height)))
|
|
9760
|
+
setStyleChild(cv, 'flex', 'flexShrink', '0')
|
|
9761
|
+
setStyleChild(cv, 'background', 'backgroundType', '01')
|
|
9762
|
+
setStyleChild(cv, 'background', 'backgroundColor', '')
|
|
9763
|
+
const code = chartBarCanvasCode(cluster)
|
|
9764
|
+
cv.eventactions = [{
|
|
9765
|
+
event: { eventName: '初始化渲染', eventKey: 'onMeasured' },
|
|
9766
|
+
action: { actionType: 'customAction', properties: [constProp('function', { code, compiled: code })] },
|
|
9767
|
+
}]
|
|
9768
|
+
const left = Math.max(0, Math.round(cluster.x - buildCardLeft))
|
|
9769
|
+
const right = Math.max(0, Math.round(buildCardRight - (cluster.x + cluster.width)))
|
|
9770
|
+
const top = semanticTopGap(cluster.y) ?? 0
|
|
9771
|
+
const wrap = container('Row', { width: '100%', padding: `${top} ${right} 0 ${left}` })
|
|
9772
|
+
wrap.slots[0].components.push(cv)
|
|
9773
|
+
insertSemantic(card, wrap, cluster.y)
|
|
9774
|
+
}
|
|
9775
|
+
|
|
7141
9776
|
// 孤儿大切片纯色块(无导出素材的按钮/横幅烘焙图 → 采样色块还原占位与底色)
|
|
7142
9777
|
for (const ob of sem.orphanBlocks || []) {
|
|
7143
9778
|
const topGap = semanticTopGap(ob.y) ?? 0
|
|
@@ -7170,6 +9805,88 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
7170
9805
|
insertSemantic(hostFor(ob.y), blockRow, ob.y)
|
|
7171
9806
|
}
|
|
7172
9807
|
|
|
9808
|
+
// 浮动装饰切片(卡头月亮/吉祥物光晕等贴缘叠装饰):零高 AbsoluteLayout 叠层,
|
|
9809
|
+
// 不占流式行高(否则整卡内容被顶下一个装饰高度)。子节点被渲染器 CSS 脱标为
|
|
9810
|
+
// position:absolute,静态位置 = 叠层内容盒左上(= 卡内容原点),再用 margin 偏移到
|
|
9811
|
+
// spec x/y —— 超出常规流式行高模型的贴缘装饰只能靠绝对定位还原。
|
|
9812
|
+
for (const fd of sem.floatDecoBlocks || []) {
|
|
9813
|
+
const media = fd.exportRel ? imageFromSlice(fd.sliceEl, sem.slicesExports) : null
|
|
9814
|
+
if (!media) continue
|
|
9815
|
+
const band = container('AbsoluteLayout', { width: '100%', height: '0' })
|
|
9816
|
+
// 渲染器对每个节点默认 overflow:hidden:零高叠层会把脱标的绝对定位子节点整块裁掉,
|
|
9817
|
+
// 叠层壳必须显式 visible 才能让贴缘装饰溢出零高盒绘制。
|
|
9818
|
+
ensureStyleGroup(band, 'other')
|
|
9819
|
+
setStyleChild(band, 'other', 'overflow', 'visible')
|
|
9820
|
+
ensureStyleGroup(media, 'margin')
|
|
9821
|
+
setStyleChild(media, 'margin', 'marginType', '1')
|
|
9822
|
+
setStyleChild(media, 'margin', 'marginTop', String(Math.max(0, Math.round((fd.y || 0) - (buildCard.y || 0)))))
|
|
9823
|
+
setStyleChild(media, 'margin', 'marginLeft', String(Math.max(0, Math.round((fd.x || 0) - buildCardLeft))))
|
|
9824
|
+
setStyleChild(media, 'margin', 'marginRight', '0')
|
|
9825
|
+
setStyleChild(media, 'margin', 'marginBottom', '0')
|
|
9826
|
+
band.slots[0].components.push(media)
|
|
9827
|
+
insertSemantic(hostFor(fd.y), band, fd.y)
|
|
9828
|
+
}
|
|
9829
|
+
|
|
9830
|
+
|
|
9831
|
+
// 薄壳分段横条(时间轴 / 进度分段):按壳几何逐段还原底色/描边/圆角,段间隙用等宽 spacer
|
|
9832
|
+
// 承担(Row gap 是常量,表达不了逐段差异)。此前分段壳落在孤立媒体块通道,
|
|
9833
|
+
// 渲染成「缺素材」占位图(几何对、视觉全错),其余段直接丢失。
|
|
9834
|
+
for (const strip of sem.thinShellStrips || []) {
|
|
9835
|
+
const boxes = strip.boxes || []
|
|
9836
|
+
if (boxes.length < 2) continue
|
|
9837
|
+
const previousBottom = previousSemanticBottom(strip.y)
|
|
9838
|
+
const row = container('Row', {
|
|
9839
|
+
width: '100%', direction: 'row', justify: 'flex-start', align: 'center',
|
|
9840
|
+
gap: T.gap('none'), padding: T.pad('root'),
|
|
9841
|
+
})
|
|
9842
|
+
setStyleChild(row, 'padding', 'paddingType', '1')
|
|
9843
|
+
setStyleChild(row, 'padding', 'paddingTop', String(previousBottom == null
|
|
9844
|
+
? Math.max(0, Math.round(strip.y - (buildCard.y || 0)))
|
|
9845
|
+
: Math.max(0, Math.round(strip.y - previousBottom))))
|
|
9846
|
+
setStyleChild(row, 'padding', 'paddingBottom', '0')
|
|
9847
|
+
setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round((strip.x || 0) - buildCardLeft))))
|
|
9848
|
+
setStyleChild(row, 'padding', 'paddingRight', String(Math.max(0,
|
|
9849
|
+
Math.round(buildCardRight - ((strip.x || 0) + (strip.width || 0))))))
|
|
9850
|
+
let cursorRight = null
|
|
9851
|
+
for (const box of boxes) {
|
|
9852
|
+
if (cursorRight != null) {
|
|
9853
|
+
const gapW = Math.max(0, Math.round((box.x || 0) - cursorRight))
|
|
9854
|
+
if (gapW > 0) {
|
|
9855
|
+
const spacer = container('Column', { direction: 'column', padding: '0' })
|
|
9856
|
+
setStyleChild(spacer, 'rect', 'width', String(gapW))
|
|
9857
|
+
setStyleChild(spacer, 'rect', 'height', '1')
|
|
9858
|
+
setStyleChild(spacer, 'flex', 'flexShrink', '0')
|
|
9859
|
+
row.slots[0].components.push(spacer)
|
|
9860
|
+
}
|
|
9861
|
+
}
|
|
9862
|
+
const node = container('Column', {
|
|
9863
|
+
width: String(Math.round(box.width || 0)), direction: 'column', padding: '0', flexShrink: 0,
|
|
9864
|
+
})
|
|
9865
|
+
setStyleChild(node, 'rect', 'height', String(Math.round(box.height || 0)))
|
|
9866
|
+
ensureStyleGroup(node, 'background')
|
|
9867
|
+
if (box.gradient) {
|
|
9868
|
+
setStyleChild(node, 'background', 'backgroundType', '03')
|
|
9869
|
+
setStyleChild(node, 'background', 'backgroundGradient', gradientWithOpacity(box.gradient, box.opacity ?? 1))
|
|
9870
|
+
} else if (box.fill) {
|
|
9871
|
+
setStyleChild(node, 'background', 'backgroundType', '01')
|
|
9872
|
+
setStyleChild(node, 'background', 'backgroundColor', hexToRgba(box.fill, box.opacity ?? 1))
|
|
9873
|
+
}
|
|
9874
|
+
if (box.isBorder) {
|
|
9875
|
+
ensureStyleGroup(node, 'border')
|
|
9876
|
+
setStyleChild(node, 'border', 'isBorder', '1')
|
|
9877
|
+
setStyleChild(node, 'border', 'borderWidth', String(Math.max(0.5, box.borderWidth || 1)))
|
|
9878
|
+
setStyleChild(node, 'border', 'borderStyle', 'solid')
|
|
9879
|
+
setStyleChild(node, 'border', 'borderColor', hexToRgba(box.borderColor || '#E7E7EE'))
|
|
9880
|
+
}
|
|
9881
|
+
ensureStyleGroup(node, 'borderRadius')
|
|
9882
|
+
setStyleChild(node, 'borderRadius', 'borderRadiusType', '0')
|
|
9883
|
+
setStyleChild(node, 'borderRadius', 'borderRadius', String(Math.round(box.radius || 0)))
|
|
9884
|
+
row.slots[0].components.push(node)
|
|
9885
|
+
cursorRight = (box.x || 0) + (box.width || 0)
|
|
9886
|
+
}
|
|
9887
|
+
insertSemantic(hostFor(strip.y), row, strip.y)
|
|
9888
|
+
}
|
|
9889
|
+
|
|
7173
9890
|
// 孤立媒体块放在语义布局之后兜底,避免箭头/图标抢占主内容顺序。
|
|
7174
9891
|
for (const block of (sem.isolatedBlocks || []).filter((block) =>
|
|
7175
9892
|
!(sem.progressBars || []).length && !block.consumedByTabs)) {
|
|
@@ -7315,14 +10032,94 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
|
|
|
7315
10032
|
walkDeep(ch)
|
|
7316
10033
|
return found
|
|
7317
10034
|
}
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
10035
|
+
// 壳内两列拓扑(渐变壳):壳右半区存在独立文本列时,把与之纵向重叠的既有子块收进左列,
|
|
10036
|
+
// 右列文本按 spec y 排布。渐变壳只作背景包裹、壳内文本由各自语义通道生成,
|
|
10037
|
+
// 右列文本此前或整段落进主视觉死区不渲染、或被副金额对甩到壳外竖排。
|
|
10038
|
+
// panelRows 已认领的壳走面板行自身的两列分支,这里只处理「纯背景壳」。
|
|
10039
|
+
const gpPadTop = (node) => {
|
|
10040
|
+
const props = (node.style?.properties || []).find((group) => group.itemKey === 'padding')
|
|
10041
|
+
?.itemChildren?.properties
|
|
10042
|
+
return Number(props?.find((child) => child.itemKey === 'paddingTop')?.itemValue) || 0
|
|
10043
|
+
}
|
|
10044
|
+
const gpRightCol = (sem.panelRows || []).some((panel) => panel.shapeEl === gp.shapeEl)
|
|
10045
|
+
? null
|
|
10046
|
+
: ((sem.shellRightColumns || []).find((column) => column.shapeEl === gp.shapeEl) || null)
|
|
10047
|
+
let flowChildren = inside
|
|
10048
|
+
if (gpRightCol && gpRightCol.texts.length >= 2) {
|
|
10049
|
+
const childBottom = (ch) => {
|
|
10050
|
+
const y = semanticYByNode.get(ch)
|
|
10051
|
+
if (y == null) return null
|
|
10052
|
+
const matched = semanticBounds
|
|
10053
|
+
.filter((bound) => !bound.container && bound.top === y)
|
|
10054
|
+
.map((bound) => bound.bottom)
|
|
10055
|
+
return matched.length ? Math.max(...matched) : y + 20
|
|
10056
|
+
}
|
|
10057
|
+
const rcTop = Math.min(...gpRightCol.texts.map((item) => item.y || 0))
|
|
10058
|
+
const rcBottom = Math.max(...gpRightCol.texts.map((item) =>
|
|
10059
|
+
(item.y || 0) + estimatedTextBoxHeight(item.height, item.fontSize)))
|
|
10060
|
+
const overlapping = inside.filter((ch) => {
|
|
10061
|
+
const top = semanticYByNode.get(ch)
|
|
10062
|
+
if (top == null) return false
|
|
10063
|
+
const bottom = childBottom(ch)
|
|
10064
|
+
return (bottom == null ? top + 20 : bottom) > rcTop - 2 && top < rcBottom + 2
|
|
10065
|
+
})
|
|
10066
|
+
if (overlapping.length) {
|
|
10067
|
+
const firstOv = overlapping[0]
|
|
10068
|
+
const lastOv = overlapping[overlapping.length - 1]
|
|
10069
|
+
const firstTop = semanticYByNode.get(firstOv)
|
|
10070
|
+
const lastTop = semanticYByNode.get(lastOv) ?? firstTop
|
|
10071
|
+
const above = inside.filter((ch) => !overlapping.includes(ch)
|
|
10072
|
+
&& (semanticYByNode.get(ch) ?? Number.POSITIVE_INFINITY) < (firstTop ?? 0))
|
|
10073
|
+
const below = inside.filter((ch) => !overlapping.includes(ch)
|
|
10074
|
+
&& (semanticYByNode.get(ch) ?? Number.NEGATIVE_INFINITY) > (lastTop ?? 0))
|
|
10075
|
+
const splitX = gpRightCol.splitX || ((gp.x || 0) + (gp.width || 0) * 0.45)
|
|
10076
|
+
const leftW = Math.max(1, Math.round(splitX - (gp.x || 0)))
|
|
10077
|
+
const rightW = Math.max(1, Math.round(((gp.x || 0) + (gp.width || 0)) - splitX))
|
|
10078
|
+
const twoCol = container('Row', {
|
|
10079
|
+
width: '100%', direction: 'row', justify: 'flex-start', align: 'flex-start',
|
|
10080
|
+
gap: T.gap('none'), padding: '0',
|
|
10081
|
+
})
|
|
10082
|
+
const leftCol = container('Column', {
|
|
10083
|
+
direction: 'column', width: String(leftW), justify: 'flex-start', align: 'flex-start',
|
|
10084
|
+
gap: T.gap('none'), padding: '0', flexShrink: 0,
|
|
10085
|
+
})
|
|
10086
|
+
const rightCol = container('Column', {
|
|
10087
|
+
direction: 'column', width: String(rightW), justify: 'flex-start', align: 'flex-start',
|
|
10088
|
+
gap: T.gap('none'), padding: '0', flexShrink: 0,
|
|
10089
|
+
})
|
|
10090
|
+
// 首个重叠子块的顶距转移到两列块(相对上一块的节奏不变),块内首行顶距归零
|
|
10091
|
+
setPadTop(twoCol, gpPadTop(firstOv))
|
|
10092
|
+
setPadTop(firstOv, 0)
|
|
10093
|
+
overlapping.forEach((ch) => leftCol.slots[0].components.push(ch))
|
|
10094
|
+
setPadTop(rightCol, Math.max(0, rcTop - (firstTop ?? rcTop)))
|
|
10095
|
+
let prevBottom = null
|
|
10096
|
+
for (const item of gpRightCol.texts) {
|
|
10097
|
+
const itemRow = container('Row', {
|
|
10098
|
+
width: '100%', direction: 'row', justify: 'flex-start', align: 'center',
|
|
10099
|
+
gap: T.gap('none'), padding: '0',
|
|
10100
|
+
})
|
|
10101
|
+
if (prevBottom != null) setPadTop(itemRow, Math.max(0, (item.y || 0) - prevBottom))
|
|
10102
|
+
itemRow.slots[0].components.push(textFromSpec(applyLiteralBind({
|
|
10103
|
+
...item,
|
|
10104
|
+
align: 'left',
|
|
10105
|
+
}, sem.bindings.literalBind)))
|
|
10106
|
+
rightCol.slots[0].components.push(itemRow)
|
|
10107
|
+
prevBottom = (item.y || 0) + estimatedTextBoxHeight(item.height, item.fontSize)
|
|
10108
|
+
}
|
|
10109
|
+
twoCol.slots[0].components.push(leftCol, rightCol)
|
|
10110
|
+
registerSemanticY(twoCol, firstTop ?? rcTop)
|
|
10111
|
+
flowChildren = [...above, twoCol, ...below]
|
|
10112
|
+
}
|
|
10113
|
+
}
|
|
10114
|
+
|
|
10115
|
+
const btnCh = flowChildren.find(hasButtonDeep)
|
|
10116
|
+
let hostChildren = flowChildren
|
|
10117
|
+
if (btnCh && flowChildren.length >= 3) {
|
|
7321
10118
|
const btnY = semanticYByNode.get(btnCh) || 0
|
|
7322
|
-
const overlapRows =
|
|
10119
|
+
const overlapRows = flowChildren.filter((ch) => ch !== btnCh
|
|
7323
10120
|
&& Math.abs((semanticYByNode.get(ch) || 0) - btnY) <= 30)
|
|
7324
10121
|
if (overlapRows.length) {
|
|
7325
|
-
const rest =
|
|
10122
|
+
const rest = flowChildren.filter((ch) => ch !== btnCh && !overlapRows.includes(ch))
|
|
7326
10123
|
const groupCol = container('Column', { direction: 'column', gap: T.gap('none'), padding: '0', width: '100%', flexShrink: 1 })
|
|
7327
10124
|
overlapRows.forEach((ch) => groupCol.slots[0].components.push(ch))
|
|
7328
10125
|
const pairRow = container('Row', { width: '100%', justify: 'space-between', align: 'center', gap: T.gap('base'), padding: '0' })
|
|
@@ -7583,6 +10380,28 @@ export function generate(spec, opts = {}) {
|
|
|
7583
10380
|
}
|
|
7584
10381
|
}
|
|
7585
10382
|
}
|
|
10383
|
+
// 分区底色带(视觉优先):设计稿卡体常为「上灰下白」等 flat 分区底色,
|
|
10384
|
+
// spec cardFill 只有单色 → 按设计稿左缘逐行采样出硬停渐变还原分区底色。
|
|
10385
|
+
if (opts.designImage && !sem.cardBackgroundGradient) {
|
|
10386
|
+
const bands = sampleBackdropBands(opts.designImage, Math.round(sem.cardRect?.width || 375))
|
|
10387
|
+
if (bands && bands.length >= 2) {
|
|
10388
|
+
const hx = (hex) => {
|
|
10389
|
+
const h = String(hex || '').replace('#', '')
|
|
10390
|
+
return /^[0-9a-fA-F]{6}$/.test(h) ? [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] : null
|
|
10391
|
+
}
|
|
10392
|
+
const d6 = (a, b) => {
|
|
10393
|
+
const ra = hx(a), rb = hx(b)
|
|
10394
|
+
return ra && rb ? (Math.abs(ra[0] - rb[0]) + Math.abs(ra[1] - rb[1]) + Math.abs(ra[2] - rb[2])) / 3 : 0
|
|
10395
|
+
}
|
|
10396
|
+
const differs = bands.some((bd) => d6(bd.color, sem.cardFill) > 4)
|
|
10397
|
+
const coverage = (bands[bands.length - 1].y1 - bands[0].y0) / Math.max(1, sem.cardRect?.height || 1)
|
|
10398
|
+
if (differs && coverage >= 0.6) {
|
|
10399
|
+
const stops = []
|
|
10400
|
+
for (const bd of bands) stops.push(`${bd.color} ${bd.y0}px`, `${bd.color} ${bd.y1}px`)
|
|
10401
|
+
sem.cardBackgroundGradient = `linear-gradient(to bottom, ${stops.join(', ')})`
|
|
10402
|
+
}
|
|
10403
|
+
}
|
|
10404
|
+
}
|
|
7586
10405
|
// 按钮/pill 填充色交叉验证(视觉优先):标注 fill 与设计稿像素明显不一致时
|
|
7587
10406
|
// (典型:禁用态按钮标注层仍是实色 #FCC800,视觉实为浅黄),以设计稿采样为准。
|
|
7588
10407
|
// 采样走边缘条带(避开中央文字与圆角),阈值 24 只抓显著偏差,不动抗锯齿噪声。
|
|
@@ -7629,6 +10448,14 @@ export function generate(spec, opts = {}) {
|
|
|
7629
10448
|
}
|
|
7630
10449
|
// 弥散背景/浅色顶带纯色近似:仅当 spec 未检出顶部装饰、且调用方提供设计稿聚焦图时采样。
|
|
7631
10450
|
// 颜色取自截图统计(不裁切图片),符合「缺素材用纯色、缺失从截图采样」约束。
|
|
10451
|
+
if (sem.floatDecos?.length) {
|
|
10452
|
+
const fdCardR = sem.cardRect || {}
|
|
10453
|
+
sem.floatDecoBlocks = sem.floatDecos.map((s) => {
|
|
10454
|
+
const sr = rect(s)
|
|
10455
|
+
const exportRel = resolveSliceExport(sem.slicesExports || [], s.name) || s.annotationDefect?.cropSlice || null
|
|
10456
|
+
return { sliceEl: s, exportRel, x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0 }
|
|
10457
|
+
})
|
|
10458
|
+
}
|
|
7632
10459
|
if (!sem.cardBackgroundGradient && opts.designImage) {
|
|
7633
10460
|
const band = sampleTopBand(opts.designImage,
|
|
7634
10461
|
Math.round(sem.cardRect?.width || sem.canvas?.width || 375),
|