@deppon/deppon-prd-mcp 0.1.1 → 2.5.10
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 +53 -26
- package/dist/core/canvas-handoff.d.ts +72 -0
- package/dist/core/canvas-handoff.js +238 -0
- package/dist/core/prd-generator.js +3 -9
- package/dist/core/project-store.d.ts +12 -0
- package/dist/core/project-store.js +17 -0
- package/dist/core/prototype-generator.d.ts +1 -1
- package/dist/core/prototype-generator.js +93 -89
- package/dist/core/prototype-layout.d.ts +13 -0
- package/dist/core/prototype-layout.js +219 -0
- package/dist/core/tldraw-wireframe.d.ts +11 -0
- package/dist/core/tldraw-wireframe.js +205 -0
- package/dist/core/types.d.ts +52 -0
- package/dist/core/types.js +13 -4
- package/dist/http/server.js +12 -1
- package/dist/mcp/server.js +59 -2
- package/package.json +6 -3
- package/templates/examples/README.md +8 -8
- package/templates/examples/app-shell-navigation/layout-spec.md +21 -21
- package/templates/examples/app-shell-navigation/prd.md +56 -56
- package/templates/examples/backend-list/prd.md +32 -32
- package/templates/examples/data-dashboard/prd.md +46 -46
- package/templates/examples/form-edit/prd.md +33 -33
- package/templates/examples/form-preview/prd.md +21 -21
- package/templates/examples/user-frontend/prd.md +19 -19
- package/web/app.js +80 -45
- package/web/index.html +2 -2
|
@@ -1,44 +1,99 @@
|
|
|
1
1
|
import { defaultMockRows } from './types.js';
|
|
2
|
+
import { applyLayout, getDesignModeScript, getDesignModeStyles } from './prototype-layout.js';
|
|
2
3
|
function escapeHtml(text) {
|
|
3
|
-
return text
|
|
4
|
-
.replace(/&/g, '&')
|
|
5
|
-
.replace(/</g, '<')
|
|
6
|
-
.replace(/>/g, '>')
|
|
7
|
-
.replace(/"/g, '"');
|
|
4
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
8
5
|
}
|
|
9
6
|
function filterInputHtml(filter, index) {
|
|
10
7
|
const id = `f${index}`;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
const inner = filter.type.includes('下拉') || filter.type.includes('select')
|
|
9
|
+
? (() => {
|
|
10
|
+
const opts = (filter.limit.match(/[^/、,,\s]+/g) || ['全部', '启用', '停用'])
|
|
11
|
+
.map(v => v.trim())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
const options = opts
|
|
14
|
+
.map((o, i) => `<option value="${i === 0 ? '' : escapeHtml(o)}">${escapeHtml(o)}</option>`)
|
|
15
|
+
.join('');
|
|
16
|
+
return `<label class="block text-xs font-medium text-slate-600">${escapeHtml(filter.name)}
|
|
19
17
|
<select id="${id}" data-filter="${escapeHtml(filter.name)}" class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">${options}</select>
|
|
20
18
|
</label>`;
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
})()
|
|
20
|
+
: `<label class="block text-xs font-medium text-slate-600">${escapeHtml(filter.name)}
|
|
23
21
|
<input type="text" id="${id}" data-filter="${escapeHtml(filter.name)}" class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" placeholder="${escapeHtml(filter.matchRule)}" autocomplete="off" />
|
|
24
22
|
</label>`;
|
|
23
|
+
return `<div class="relative pt-5" data-drag-item data-filter-index="${index}">${inner}<button type="button" class="design-del" title="移除">×</button></div>`;
|
|
25
24
|
}
|
|
26
|
-
|
|
25
|
+
function renderBlocks(project) {
|
|
27
26
|
const listTitle = `${project.pageName.replace(/列表|管理/g, '').trim() || project.pageName}列表`;
|
|
28
|
-
const mockRows = project.mockRows.length ? project.mockRows : defaultMockRows(project.columns);
|
|
29
|
-
const configJson = JSON.stringify({ ...project, mockRows }).replace(/</g, '\\u003c');
|
|
30
27
|
const filterHtml = project.filters.map((f, i) => filterInputHtml(f, i)).join('\n ');
|
|
31
|
-
const columnHeaders = project.columns
|
|
28
|
+
const columnHeaders = project.columns
|
|
29
|
+
.map((c, i) => `<th class="relative px-4 py-3 pt-6" data-drag-item data-column-index="${i}">${escapeHtml(c.name)}<button type="button" class="design-del" title="移除">×</button></th>`)
|
|
30
|
+
.join('\n ');
|
|
32
31
|
const toolbarHtml = project.toolbarButtons
|
|
33
32
|
.map((btn, i) => {
|
|
34
|
-
const primary = i === 0
|
|
33
|
+
const primary = i === 0
|
|
34
|
+
? 'bg-blue-600 text-white shadow hover:bg-blue-700'
|
|
35
|
+
: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50';
|
|
35
36
|
const id = i === 0 && project.enableCrud ? ' id="btnAdd"' : '';
|
|
36
|
-
return `<button type="button"${id} class="rounded-lg ${primary} px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" data-toolbar="${escapeHtml(btn)}">${escapeHtml(btn)}</button>`;
|
|
37
|
+
return `<div class="relative inline-block pt-5" data-drag-item data-toolbar-index="${i}"><button type="button"${id} class="rounded-lg ${primary} px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" data-toolbar="${escapeHtml(btn)}">${escapeHtml(btn)}</button><button type="button" class="design-del" title="移除">×</button></div>`;
|
|
37
38
|
})
|
|
38
39
|
.join('\n ');
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const filterBlock = `<section data-block="filter" class="mb-6 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
41
|
+
<div class="block-drag-handle design-only">⠿ 拖拽区块</div>
|
|
42
|
+
<div class="mb-3 flex flex-wrap items-center gap-2">
|
|
43
|
+
<span class="drag-handle static! relative! mr-1 hidden design-only">⠿</span>
|
|
44
|
+
<h2 class="text-sm font-semibold text-slate-900" data-editable="filterTitle">筛选条件</h2>
|
|
45
|
+
<button type="button" class="anno-trigger" data-anno-key="s31">PRD §3.1</button>
|
|
46
|
+
<button type="button" class="design-del ml-auto design-only" data-block-del="filter" title="隐藏区块">隐藏</button>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" id="filterGrid">${filterHtml}</div>
|
|
49
|
+
<div class="mt-4 flex flex-wrap gap-2">
|
|
50
|
+
<button type="button" id="btnQuery" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-blue-700">查询</button>
|
|
51
|
+
<button type="button" id="btnReset" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">重置</button>
|
|
52
|
+
</div>
|
|
53
|
+
</section>`;
|
|
54
|
+
const tableBlock = `<section data-block="table" class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
55
|
+
<div class="block-drag-handle design-only m-3 mb-0">⠿ 拖拽区块</div>
|
|
56
|
+
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-3">
|
|
57
|
+
<div class="flex items-center gap-2">
|
|
58
|
+
<h2 class="text-sm font-semibold text-slate-900" data-editable="listTitle">${escapeHtml(listTitle)}</h2>
|
|
59
|
+
<button type="button" class="anno-trigger" data-anno-key="s32">PRD §3.2</button>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="flex flex-wrap items-center gap-2" id="toolbar">${toolbarHtml}</div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="overflow-x-auto">
|
|
64
|
+
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
|
|
65
|
+
<thead class="bg-slate-50 text-xs font-medium uppercase tracking-wide text-slate-600"><tr id="columnHeadRow">${columnHeaders}<th class="px-4 py-3 text-right">操作</th></tr></thead>
|
|
66
|
+
<tbody id="tbody" class="divide-y divide-slate-100 bg-white"></tbody>
|
|
67
|
+
</table>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-4 py-3 text-xs text-slate-600">
|
|
70
|
+
<span id="totalHint"></span>
|
|
71
|
+
<div class="flex items-center gap-2">
|
|
72
|
+
<button type="button" id="btnPrev" class="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium hover:bg-slate-50">上一页</button>
|
|
73
|
+
<span id="pageInfo"></span>
|
|
74
|
+
<button type="button" id="btnNext" class="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium hover:bg-slate-50">下一页</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</section>`;
|
|
78
|
+
return { filterBlock, tableBlock };
|
|
79
|
+
}
|
|
80
|
+
function composeMainBlocks(project) {
|
|
81
|
+
const { filterBlock, tableBlock } = renderBlocks(project);
|
|
82
|
+
const hidden = new Set(project.layout?.hiddenBlocks || []);
|
|
83
|
+
const order = project.layout?.blockOrder?.length ? project.layout.blockOrder : ['filter', 'table'];
|
|
84
|
+
const map = { filter: filterBlock, table: tableBlock };
|
|
85
|
+
return order
|
|
86
|
+
.filter(id => map[id] && !hidden.has(id))
|
|
87
|
+
.map(id => map[id])
|
|
88
|
+
.join('\n ');
|
|
89
|
+
}
|
|
90
|
+
export function generatePrototypeHtml(rawProject) {
|
|
91
|
+
const project = applyLayout(rawProject);
|
|
92
|
+
const mockRows = project.mockRows.length ? project.mockRows : defaultMockRows(project.columns);
|
|
93
|
+
const configJson = JSON.stringify({ ...project, mockRows }).replace(/</g, '\\u003c');
|
|
94
|
+
const mainHtml = composeMainBlocks(project);
|
|
95
|
+
const designStyles = getDesignModeStyles();
|
|
96
|
+
const designScript = getDesignModeScript();
|
|
42
97
|
return `<!DOCTYPE html>
|
|
43
98
|
<html lang="zh-CN">
|
|
44
99
|
<head>
|
|
@@ -50,73 +105,37 @@ export function generatePrototypeHtml(project) {
|
|
|
50
105
|
<style>
|
|
51
106
|
.anno-trigger{display:inline-flex;align-items:center;justify-content:center;border-radius:.375rem;border:1px solid rgba(217,119,6,.85);background:rgba(254,243,199,.95);padding:.15rem .5rem;font-size:.625rem;font-weight:700;letter-spacing:.04em;color:rgb(69 26 3);cursor:pointer;white-space:nowrap}
|
|
52
107
|
.anno-trigger:hover{background:rgb(253 230 138)}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
body.edit-mode [data-editable]{outline:1px dashed rgba(59,130,246,.45);outline-offset:2px;cursor:text;border-radius:.25rem}
|
|
56
|
-
body.edit-mode [data-editable]:focus{outline:2px solid rgb(59 130 246);background:rgba(239,246,255,.6)}
|
|
108
|
+
.design-del,.design-only{display:none}
|
|
109
|
+
${designStyles}
|
|
57
110
|
#annoDlgBody .prd-md table{width:100%;border-collapse:collapse;font-size:.75rem}
|
|
58
111
|
#annoDlgBody .prd-md th,#annoDlgBody .prd-md td{border:1px solid rgb(226 232 240);padding:.35rem .5rem}
|
|
59
112
|
#annoDlgBody .prd-md th{background:rgb(248 250 252);font-weight:600;text-align:left}
|
|
60
113
|
</style>
|
|
61
114
|
</head>
|
|
62
115
|
<body class="min-h-screen bg-slate-100 text-slate-800 antialiased">
|
|
63
|
-
<div id="
|
|
64
|
-
<strong
|
|
65
|
-
<span
|
|
66
|
-
<button type="button" id="
|
|
67
|
-
<button type="button" id="
|
|
116
|
+
<div id="designToolbar" role="toolbar" aria-label="设计模式">
|
|
117
|
+
<strong>🎨 设计模式</strong>
|
|
118
|
+
<span>拖拽 ⠿ 调整区块/筛选项/列/按钮顺序;点击文字可改文案;× 可移除</span>
|
|
119
|
+
<button type="button" id="btnSaveDesign" class="rounded bg-indigo-600 px-3 py-1.5 text-white hover:bg-indigo-700">保存布局</button>
|
|
120
|
+
<button type="button" id="btnExitDesign" class="rounded border border-indigo-300 bg-white px-3 py-1.5 hover:bg-indigo-50">退出设计</button>
|
|
68
121
|
</div>
|
|
69
|
-
<div id="toast" class="fixed top-
|
|
122
|
+
<div id="toast" class="fixed top-14 right-4 z-[60] hidden max-w-sm rounded-lg bg-slate-900 px-4 py-3 text-sm text-white shadow-lg"></div>
|
|
70
123
|
<button type="button" id="btnAnnoLegend" class="fixed bottom-5 left-5 z-[65] flex items-center gap-2 rounded-full border border-amber-400 bg-white px-3 py-2 text-xs font-medium text-amber-950 shadow-lg hover:bg-amber-50">? 标注说明</button>
|
|
71
124
|
|
|
72
|
-
<header class="border-b border-slate-200 bg-white">
|
|
125
|
+
<header class="border-b border-slate-200 bg-white" data-block="header">
|
|
73
126
|
<div class="mx-auto flex max-w-6xl items-center px-4 py-4">
|
|
74
127
|
<div class="flex items-start gap-2">
|
|
75
128
|
<div>
|
|
76
129
|
<h1 class="text-lg font-semibold text-slate-900" data-editable="pageName">${escapeHtml(project.pageName)}</h1>
|
|
77
|
-
<p class="mt-0.5 text-xs text-slate-500">原型 ·
|
|
130
|
+
<p class="mt-0.5 text-xs text-slate-500">原型 · deppon-prd-mcp · 加 <code>?design=1</code> 进入设计模式</p>
|
|
78
131
|
</div>
|
|
79
132
|
<button type="button" class="anno-trigger mt-0.5" data-anno-key="s1">PRD §1</button>
|
|
80
133
|
</div>
|
|
81
134
|
</div>
|
|
82
135
|
</header>
|
|
83
136
|
|
|
84
|
-
<main class="mx-auto max-w-6xl px-4 py-6">
|
|
85
|
-
|
|
86
|
-
<div class="mb-3 flex flex-wrap items-center gap-2">
|
|
87
|
-
<h2 class="text-sm font-semibold text-slate-900" data-editable="filterTitle">筛选条件</h2>
|
|
88
|
-
<button type="button" class="anno-trigger" data-anno-key="s31">PRD §3.1</button>
|
|
89
|
-
</div>
|
|
90
|
-
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" id="filterGrid">${filterHtml}</div>
|
|
91
|
-
<div class="mt-4 flex flex-wrap gap-2">
|
|
92
|
-
<button type="button" id="btnQuery" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-blue-700">查询</button>
|
|
93
|
-
<button type="button" id="btnReset" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">重置</button>
|
|
94
|
-
</div>
|
|
95
|
-
</section>
|
|
96
|
-
|
|
97
|
-
<section class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
98
|
-
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-3">
|
|
99
|
-
<div class="flex items-center gap-2">
|
|
100
|
-
<h2 class="text-sm font-semibold text-slate-900" data-editable="listTitle">${escapeHtml(listTitle)}</h2>
|
|
101
|
-
<button type="button" class="anno-trigger" data-anno-key="s32">PRD §3.2</button>
|
|
102
|
-
</div>
|
|
103
|
-
<div class="flex flex-wrap items-center gap-2" id="toolbar">${toolbarHtml}</div>
|
|
104
|
-
</div>
|
|
105
|
-
<div class="overflow-x-auto">
|
|
106
|
-
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
|
|
107
|
-
<thead class="bg-slate-50 text-xs font-medium uppercase tracking-wide text-slate-600"><tr>${columnHeaders}<th class="px-4 py-3 text-right">操作</th></tr></thead>
|
|
108
|
-
<tbody id="tbody" class="divide-y divide-slate-100 bg-white"></tbody>
|
|
109
|
-
</table>
|
|
110
|
-
</div>
|
|
111
|
-
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-4 py-3 text-xs text-slate-600">
|
|
112
|
-
<span id="totalHint"></span>
|
|
113
|
-
<div class="flex items-center gap-2">
|
|
114
|
-
<button type="button" id="btnPrev" class="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium hover:bg-slate-50">上一页</button>
|
|
115
|
-
<span id="pageInfo"></span>
|
|
116
|
-
<button type="button" id="btnNext" class="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium hover:bg-slate-50">下一页</button>
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
</section>
|
|
137
|
+
<main class="mx-auto max-w-6xl px-4 py-6" id="mainBlocks">
|
|
138
|
+
${mainHtml}
|
|
120
139
|
</main>
|
|
121
140
|
|
|
122
141
|
<div id="modalDetail" class="fixed inset-0 z-40 hidden items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
|
|
@@ -237,25 +256,9 @@ export function generatePrototypeHtml(project) {
|
|
|
237
256
|
function slicePrd(key){ if(!prdCache) return '<p>无法加载 prd.md,请用 HTTP 打开。</p>'; const marker=ANNO_KEYS[key]; if(!marker) return '<p>无对应章节</p>'; const i=prdCache.indexOf(marker); if(i<0) return '<p>未找到 '+marker+'</p>'; const rest=prdCache.slice(i); const next=rest.search(/\\n## /); const chunk=next>0?rest.slice(0,next):rest; return marked.parse(chunk); }
|
|
238
257
|
$$('.anno-trigger').forEach(btn=>{ btn.onclick=async()=>{ if(!prdCache) await loadPrd(); $('#annoTitle').textContent='PRD 标注 · '+btn.textContent.trim(); $('.prd-md',$('#annoDlgBody')).innerHTML=slicePrd(btn.dataset.annoKey); showModal($('#modalAnno')); }; });
|
|
239
258
|
$('#btnCloseAnno').onclick=()=>hideModal($('#modalAnno'));
|
|
240
|
-
$('#btnAnnoLegend').onclick=async()=>{ if(!prdCache) await loadPrd(); $('#annoTitle').textContent='需求标注说明'; $('.prd-md',$('#annoDlgBody')).innerHTML='<p
|
|
259
|
+
$('#btnAnnoLegend').onclick=async()=>{ if(!prdCache) await loadPrd(); $('#annoTitle').textContent='需求标注说明'; $('.prd-md',$('#annoDlgBody')).innerHTML='<p>设计模式:<code>?design=1</code> 可拖拽调整布局并保存。</p>'; showModal($('#modalAnno')); };
|
|
241
260
|
[$('#modalDetail'),$('#modalForm'),$('#modalDelete'),$('#modalAnno')].forEach(m=>{ m.onclick=e=>{ if(e.target===m) hideModal(m); }; });
|
|
242
261
|
|
|
243
|
-
const params=new URLSearchParams(location.search);
|
|
244
|
-
const editMode=params.get('edit')==='1';
|
|
245
|
-
if(editMode){
|
|
246
|
-
document.body.classList.add('edit-mode');
|
|
247
|
-
$$('[data-editable]').forEach(el=>{ el.contentEditable='true'; el.spellcheck=false; });
|
|
248
|
-
$('#btnSaveEdit').onclick=()=>{
|
|
249
|
-
const payload={ slug:CFG.slug, overrides:{
|
|
250
|
-
pageName: $('[data-editable=pageName]').textContent.trim(),
|
|
251
|
-
listTitle: $('[data-editable=listTitle]').textContent.trim(),
|
|
252
|
-
filterTitle: $('[data-editable=filterTitle]').textContent.trim(),
|
|
253
|
-
}};
|
|
254
|
-
window.parent.postMessage({ type:'deppon-prd-save-overrides', payload }, '*');
|
|
255
|
-
toast('已发送保存请求');
|
|
256
|
-
};
|
|
257
|
-
$('#btnExitEdit').onclick=()=>{ location.search=''; };
|
|
258
|
-
}
|
|
259
262
|
window.addEventListener('message',e=>{
|
|
260
263
|
if(e.data&&e.data.type==='deppon-prd-apply-config'){
|
|
261
264
|
Object.assign(CFG,e.data.config||{});
|
|
@@ -267,6 +270,7 @@ export function generatePrototypeHtml(project) {
|
|
|
267
270
|
renderTable();
|
|
268
271
|
})();
|
|
269
272
|
<\/script>
|
|
273
|
+
<script>${designScript}<\/script>
|
|
270
274
|
</body>
|
|
271
275
|
</html>`;
|
|
272
276
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PrdProject } from './types.js';
|
|
2
|
+
/** 原型可视化布局(拖拽排序结果,写入 project.config.json) */
|
|
3
|
+
export interface PrototypeLayout {
|
|
4
|
+
blockOrder?: string[];
|
|
5
|
+
filterOrder?: number[];
|
|
6
|
+
columnOrder?: number[];
|
|
7
|
+
toolbarOrder?: number[];
|
|
8
|
+
hiddenBlocks?: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function reorderByIndex<T>(items: T[], order: number[] | undefined): T[];
|
|
11
|
+
export declare function applyLayout(project: PrdProject): PrdProject;
|
|
12
|
+
export declare function getDesignModeStyles(): string;
|
|
13
|
+
export declare function getDesignModeScript(): string;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
export function reorderByIndex(items, order) {
|
|
2
|
+
if (!order?.length || order.length !== items.length)
|
|
3
|
+
return items;
|
|
4
|
+
if (new Set(order).size !== items.length)
|
|
5
|
+
return items;
|
|
6
|
+
return order.map(i => items[i]).filter((x) => x !== undefined);
|
|
7
|
+
}
|
|
8
|
+
export function applyLayout(project) {
|
|
9
|
+
const layout = project.layout;
|
|
10
|
+
if (!layout)
|
|
11
|
+
return project;
|
|
12
|
+
const next = { ...project };
|
|
13
|
+
if (layout.filterOrder?.length === project.filters.length) {
|
|
14
|
+
next.filters = reorderByIndex(project.filters, layout.filterOrder);
|
|
15
|
+
}
|
|
16
|
+
if (layout.columnOrder?.length === project.columns.length) {
|
|
17
|
+
next.columns = reorderByIndex(project.columns, layout.columnOrder);
|
|
18
|
+
// mock 行按新列顺序重排键(若键名匹配)
|
|
19
|
+
next.mockRows = project.mockRows.map(row => {
|
|
20
|
+
const ordered = {};
|
|
21
|
+
for (const col of next.columns) {
|
|
22
|
+
if (row[col.name] !== undefined)
|
|
23
|
+
ordered[col.name] = row[col.name];
|
|
24
|
+
}
|
|
25
|
+
return Object.keys(ordered).length ? ordered : row;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (layout.toolbarOrder?.length === project.toolbarButtons.length) {
|
|
29
|
+
next.toolbarButtons = reorderByIndex(project.toolbarButtons, layout.toolbarOrder);
|
|
30
|
+
}
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
export function getDesignModeStyles() {
|
|
34
|
+
return `
|
|
35
|
+
body.design-mode { padding-top: 3rem; }
|
|
36
|
+
body.design-mode #designToolbar { display: flex; }
|
|
37
|
+
#designToolbar {
|
|
38
|
+
position: fixed; top: 0; left: 0; right: 0; z-index: 90;
|
|
39
|
+
display: none; align-items: center; flex-wrap: wrap; gap: .5rem .75rem;
|
|
40
|
+
border-bottom: 2px solid rgb(99 102 241);
|
|
41
|
+
background: linear-gradient(180deg, rgb(238 242 255), rgb(224 231 255));
|
|
42
|
+
padding: .45rem 1rem; font-size: .75rem; color: rgb(30 27 75);
|
|
43
|
+
box-shadow: 0 4px 12px rgba(79,70,229,.15);
|
|
44
|
+
}
|
|
45
|
+
body.design-mode [data-drag-item] {
|
|
46
|
+
position: relative; cursor: grab;
|
|
47
|
+
outline: 2px dashed rgba(99,102,241,.35); outline-offset: 2px;
|
|
48
|
+
border-radius: .375rem; transition: box-shadow .15s;
|
|
49
|
+
}
|
|
50
|
+
body.design-mode [data-drag-item]:hover {
|
|
51
|
+
outline-color: rgb(99 102 241);
|
|
52
|
+
box-shadow: 0 0 0 3px rgba(99,102,241,.12);
|
|
53
|
+
}
|
|
54
|
+
body.design-mode .sortable-ghost { opacity: .45; background: rgb(224 231 255); }
|
|
55
|
+
body.design-mode .sortable-drag { cursor: grabbing; }
|
|
56
|
+
body.design-mode .drag-handle {
|
|
57
|
+
position: absolute; top: .25rem; left: .25rem; z-index: 2;
|
|
58
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
59
|
+
width: 1.25rem; height: 1.25rem; border-radius: .25rem;
|
|
60
|
+
background: rgb(99 102 241); color: white; font-size: .625rem;
|
|
61
|
+
cursor: grab; user-select: none; line-height: 1;
|
|
62
|
+
}
|
|
63
|
+
body.design-mode section[data-block] { padding-top: 1.75rem; }
|
|
64
|
+
body.design-mode .block-drag-handle {
|
|
65
|
+
display: flex; align-items: center; gap: .35rem;
|
|
66
|
+
margin: -.25rem 0 .5rem; padding: .2rem .5rem; width: fit-content;
|
|
67
|
+
border-radius: .375rem; background: rgb(99 102 241); color: white;
|
|
68
|
+
font-size: .625rem; font-weight: 600; cursor: grab; user-select: none;
|
|
69
|
+
}
|
|
70
|
+
body.design-mode .design-del,
|
|
71
|
+
body.design-mode .design-only { display: inline-flex; }
|
|
72
|
+
body.design-mode [data-editable] { cursor: text; }
|
|
73
|
+
body.design-mode .design-del {
|
|
74
|
+
position: absolute; top: .25rem; right: .25rem; z-index: 2;
|
|
75
|
+
border-radius: .25rem; background: rgb(254 226 226); color: rgb(185 28 28);
|
|
76
|
+
padding: 0 .35rem; font-size: .625rem; cursor: pointer; border: none;
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
export function getDesignModeScript() {
|
|
81
|
+
return `
|
|
82
|
+
(function initDesignMode(){
|
|
83
|
+
const params = new URLSearchParams(location.search);
|
|
84
|
+
if (params.get('design') !== '1' && params.get('edit') !== 'design') return;
|
|
85
|
+
|
|
86
|
+
const CFG = JSON.parse(document.getElementById('deppon-config').textContent);
|
|
87
|
+
const $ = (s,r=document)=>r.querySelector(s);
|
|
88
|
+
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
|
|
89
|
+
|
|
90
|
+
function toast(msg){
|
|
91
|
+
const t=$('#toast'); if(!t) return;
|
|
92
|
+
t.textContent=msg; t.classList.remove('hidden');
|
|
93
|
+
setTimeout(()=>t.classList.add('hidden'),2400);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function loadSortable(cb){
|
|
97
|
+
if(window.Sortable) return cb();
|
|
98
|
+
const s=document.createElement('script');
|
|
99
|
+
s.src='https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js';
|
|
100
|
+
s.onload=cb;
|
|
101
|
+
document.head.appendChild(s);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function addHandles(root){
|
|
105
|
+
$$( '[data-drag-item]', root).forEach(el=>{
|
|
106
|
+
if(el.querySelector('.drag-handle')) return;
|
|
107
|
+
const h=document.createElement('span');
|
|
108
|
+
h.className='drag-handle'; h.title='拖拽排序'; h.textContent='⠿';
|
|
109
|
+
h.onmousedown=e=>e.stopPropagation();
|
|
110
|
+
el.prepend(h);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collectLayout(){
|
|
115
|
+
const blockOrder = $$('#mainBlocks > [data-block]').map(el=>el.dataset.block);
|
|
116
|
+
const filterOrder = $$('#filterGrid > [data-drag-item]').map((_,i)=>{
|
|
117
|
+
const idx = Number(_.dataset.filterIndex);
|
|
118
|
+
return Number.isFinite(idx)?idx:i;
|
|
119
|
+
});
|
|
120
|
+
// 按当前 DOM 顺序收集 filter 的实际索引映射
|
|
121
|
+
const filterDomOrder = $$('#filterGrid > [data-drag-item]').map(el=>Number(el.dataset.filterIndex));
|
|
122
|
+
const columnOrder = $$('#columnHeadRow > [data-drag-item]').map(el=>Number(el.dataset.columnIndex));
|
|
123
|
+
const toolbarOrder = $$('#toolbar > [data-drag-item]').map(el=>Number(el.dataset.toolbarIndex));
|
|
124
|
+
|
|
125
|
+
const overrides = {
|
|
126
|
+
pageName: ($('[data-editable=pageName]')||{}).textContent?.trim(),
|
|
127
|
+
filterTitle: ($('[data-editable=filterTitle]')||{}).textContent?.trim(),
|
|
128
|
+
listTitle: ($('[data-editable=listTitle]')||{}).textContent?.trim(),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
slug: CFG.slug,
|
|
133
|
+
layout: {
|
|
134
|
+
blockOrder,
|
|
135
|
+
filterOrder: filterDomOrder,
|
|
136
|
+
columnOrder,
|
|
137
|
+
toolbarOrder,
|
|
138
|
+
hiddenBlocks: $$('#mainBlocks > [data-block].design-hidden').map(el=>el.dataset.block),
|
|
139
|
+
},
|
|
140
|
+
overrides,
|
|
141
|
+
filters: filterDomOrder.map(i=>CFG.filters[i]).filter(Boolean),
|
|
142
|
+
columns: columnOrder.map(i=>CFG.columns[i]).filter(Boolean),
|
|
143
|
+
toolbarButtons: toolbarOrder.map(i=>CFG.toolbarButtons[i]).filter(Boolean),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function enableEditable(){
|
|
148
|
+
$$('[data-editable]').forEach(el=>{ el.contentEditable='true'; el.spellcheck=false; });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function initSortables(){
|
|
152
|
+
addHandles(document.body);
|
|
153
|
+
const opts = { handle: '.drag-handle', animation: 180, ghostClass: 'sortable-ghost', dragClass: 'sortable-drag' };
|
|
154
|
+
|
|
155
|
+
const main = $('#mainBlocks');
|
|
156
|
+
if(main) Sortable.create(main, { ...opts, draggable: '[data-block]', handle: '.block-drag-handle' });
|
|
157
|
+
|
|
158
|
+
const fg = $('#filterGrid');
|
|
159
|
+
if(fg) Sortable.create(fg, { ...opts, draggable: '[data-drag-item]' });
|
|
160
|
+
|
|
161
|
+
const tb = $('#toolbar');
|
|
162
|
+
if(tb) Sortable.create(tb, { ...opts, draggable: '[data-drag-item]' });
|
|
163
|
+
|
|
164
|
+
const chr = $('#columnHeadRow');
|
|
165
|
+
if(chr) Sortable.create(chr, { ...opts, draggable: '[data-drag-item]' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function bindDelete(){
|
|
169
|
+
document.body.addEventListener('click', e=>{
|
|
170
|
+
const btn = e.target.closest('.design-del');
|
|
171
|
+
if(!btn) return;
|
|
172
|
+
e.preventDefault(); e.stopPropagation();
|
|
173
|
+
const item = btn.closest('[data-drag-item],[data-block]');
|
|
174
|
+
if(!item) return;
|
|
175
|
+
if(item.dataset.block){
|
|
176
|
+
item.classList.toggle('design-hidden');
|
|
177
|
+
item.style.display = item.classList.contains('design-hidden') ? 'none' : '';
|
|
178
|
+
toast(item.classList.contains('design-hidden') ? '已隐藏区块(保存后生效)' : '已显示区块');
|
|
179
|
+
} else {
|
|
180
|
+
item.remove();
|
|
181
|
+
toast('已移除(保存后生效)');
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
loadSortable(()=>{
|
|
187
|
+
document.body.classList.add('design-mode');
|
|
188
|
+
enableEditable();
|
|
189
|
+
initSortables();
|
|
190
|
+
bindDelete();
|
|
191
|
+
|
|
192
|
+
const bar = $('#designToolbar');
|
|
193
|
+
if(bar){
|
|
194
|
+
$('#btnSaveDesign')?.addEventListener('click', ()=>{
|
|
195
|
+
const payload = collectLayout();
|
|
196
|
+
if(window.parent !== window){
|
|
197
|
+
window.parent.postMessage({ type: 'deppon-prd-save-design', payload }, '*');
|
|
198
|
+
} else {
|
|
199
|
+
fetch('/api/projects/'+encodeURIComponent(CFG.slug)+'/design', {
|
|
200
|
+
method:'POST',
|
|
201
|
+
headers:{'Content-Type':'application/json'},
|
|
202
|
+
body: JSON.stringify(payload),
|
|
203
|
+
}).then(r=>r.json()).then(d=>{
|
|
204
|
+
if(d.error) throw new Error(d.error);
|
|
205
|
+
toast('布局已保存');
|
|
206
|
+
setTimeout(()=>location.reload(), 600);
|
|
207
|
+
}).catch(err=>toast(err.message));
|
|
208
|
+
}
|
|
209
|
+
toast('正在保存…');
|
|
210
|
+
});
|
|
211
|
+
$('#btnExitDesign')?.addEventListener('click', ()=>{
|
|
212
|
+
const u=new URL(location.href); u.searchParams.delete('design'); u.searchParams.delete('edit');
|
|
213
|
+
location.href=u.toString();
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
})();
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CanvasHandoff } from './canvas-handoff.js';
|
|
2
|
+
import type { PrdProject } from './types.js';
|
|
3
|
+
type TldrawRecord = Record<string, unknown>;
|
|
4
|
+
export declare function buildTldrawWireframeDocument(project: PrdProject, widgets: CanvasHandoff['widgets']): {
|
|
5
|
+
document: TldrawRecord;
|
|
6
|
+
pageId: string;
|
|
7
|
+
records: TldrawRecord[];
|
|
8
|
+
};
|
|
9
|
+
export declare function buildTldrawFile(project: PrdProject, widgets: CanvasHandoff['widgets']): Record<string, unknown>;
|
|
10
|
+
export declare function buildTldrawPrompt(project: PrdProject, handoff: CanvasHandoff, tldrRelativePath: string): string;
|
|
11
|
+
export {};
|