dsh-design-studio 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.en.md +4 -3
  2. package/README.md +6 -5
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-sm.jpg +0 -0
  5. package/cordis.patch.yml +5 -0
  6. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/_shots/menu.jpg +0 -0
  7. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/app.css +64 -0
  8. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/cart.html +120 -0
  9. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/design.json +56 -0
  10. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/menu.html +200 -0
  11. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/order.html +135 -0
  12. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/tokens.css +43 -0
  13. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/_shots/email.jpg +0 -0
  14. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/design.json +20 -0
  15. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/email.html +268 -0
  16. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/_shots/courses.jpg +0 -0
  17. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/courses.html +364 -0
  18. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/design.json +49 -0
  19. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/lesson.html +196 -0
  20. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/tokens.css +55 -0
  21. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/ui.css +255 -0
  22. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/_shots/poster.jpg +0 -0
  23. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/design.json +21 -0
  24. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/poster.html +165 -0
  25. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/tokens.css +35 -0
  26. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/_shots/home.jpg +0 -0
  27. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/app.css +155 -0
  28. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/booking.html +232 -0
  29. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/design.json +57 -0
  30. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/health.html +221 -0
  31. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/home.html +225 -0
  32. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/tokens.css +54 -0
  33. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/_shots/overview.jpg +0 -0
  34. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/app.css +79 -0
  35. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/design.json +51 -0
  36. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/entries.html +161 -0
  37. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/icons.svg +30 -0
  38. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/overview.html +181 -0
  39. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/tokens.css +50 -0
  40. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/_shots/home.jpg +0 -0
  41. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/book.html +279 -0
  42. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/design.json +21 -0
  43. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/home.html +348 -0
  44. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/style.css +297 -0
  45. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/tokens.css +49 -0
  46. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/01-cover.html +133 -0
  47. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/02-highlights.html +67 -0
  48. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/03-growth.html +89 -0
  49. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/04-milestones.html +92 -0
  50. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/05-next.html +99 -0
  51. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/06-thanks.html +61 -0
  52. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/_shots/01-cover.jpg +0 -0
  53. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/deck.css +67 -0
  54. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/design.json +25 -0
  55. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/tokens.css +30 -0
  56. package/gallery-seed/index.json +1 -0
  57. package/gallery-seed/thumbs/orderg-x__alley-noodles.webp +0 -0
  58. package/gallery-seed/thumbs/orderg-x__autumn-sale.webp +0 -0
  59. package/gallery-seed/thumbs/orderg-x__little-painter.webp +0 -0
  60. package/gallery-seed/thumbs/orderg-x__moon-fair.webp +0 -0
  61. package/gallery-seed/thumbs/orderg-x__paw-diary.webp +0 -0
  62. package/gallery-seed/thumbs/orderg-x__pocket-ledger.webp +0 -0
  63. package/gallery-seed/thumbs/orderg-x__time-bookshop.webp +0 -0
  64. package/gallery-seed/thumbs/orderg-x__year-review.webp +0 -0
  65. package/lib/browser.js +23 -9
  66. package/lib/bundle.js +12 -3
  67. package/lib/client.js +3253 -483
  68. package/lib/core.js +231 -7
  69. package/lib/draft-page.js +763 -0
  70. package/lib/draft.js +151 -0
  71. package/lib/gallery/css.js +230 -0
  72. package/lib/gallery/html.js +313 -0
  73. package/lib/gallery/index.js +22 -0
  74. package/lib/gallery/market-routes.js +306 -0
  75. package/lib/gallery/market.js +623 -0
  76. package/lib/gallery/pack.js +311 -0
  77. package/lib/gallery/policy.js +1560 -0
  78. package/lib/gallery/qrfind.js +181 -0
  79. package/lib/gallery/render.js +148 -0
  80. package/lib/gallery/routes.js +360 -0
  81. package/lib/gallery/tar.js +118 -0
  82. package/lib/gallery/use.js +125 -0
  83. package/lib/gallery/zip.js +212 -0
  84. package/lib/handoff.js +190 -36
  85. package/lib/history.js +3 -1
  86. package/lib/index.js +123 -22
  87. package/lib/inspire.js +142 -0
  88. package/lib/offline.js +17 -0
  89. package/lib/picker.js +260 -1
  90. package/lib/serve.js +264 -11
  91. package/lib/tweaks.js +58 -24
  92. package/lib/works.js +238 -0
  93. package/locale/en.json +2 -1
  94. package/locale/offline/en.json +6 -0
  95. package/locale/offline/zh.json +6 -0
  96. package/locale/zh.json +2 -1
  97. package/package.json +9 -2
  98. package/skills/ui-design/SKILL.md +9 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 极简 zip:打包(只存储不压缩,CRC32 自己算)和解包(存储 / deflate),不加依赖。
3
+ *
4
+ * 解包是给「朋友传来的 zip」用的,按不可信数据处理:
5
+ * - 只收普通文件(目录项跳过);软链接、加密、分卷、zip64 一律拒
6
+ * - 路径:不能是绝对路径、不能有 ..、反斜杠、NUL、控制字符;Mac 打包带的 __MACOSX/、.DS_Store 直接跳过
7
+ * - 个数、单个大小、总大小都有上限,解压时按上限截断(防 zip 炸弹:声明的大小不可信,以实际解出来的为准)
8
+ * 规则(扩展名白名单、内容检查)在 policy.js 的 checkPack 里,这里只管拆包本身安全
9
+ */
10
+
11
+ import zlib from 'node:zlib'
12
+
13
+ const CRC_TABLE = (() => {
14
+ const t = new Uint32Array(256)
15
+ for (let n = 0; n < 256; n++) {
16
+ let c = n
17
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
18
+ t[n] = c >>> 0
19
+ }
20
+ return t
21
+ })()
22
+
23
+ export function crc32(buf) {
24
+ let c = 0xffffffff
25
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
26
+ return (c ^ 0xffffffff) >>> 0
27
+ }
28
+
29
+ /** DOS 时间(zip 里的修改时间) */
30
+ function dosTime(d) {
31
+ const y = Math.max(1980, d.getFullYear())
32
+ return {
33
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
34
+ date: ((y - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
35
+ }
36
+ }
37
+
38
+ /** [{ path, data: Buffer }] → zip 的 Buffer(文件名 UTF-8,只存储) */
39
+ export function writeZip(files, { now = new Date() } = {}) {
40
+ const { time, date } = dosTime(now)
41
+ const locals = []
42
+ const centrals = []
43
+ let offset = 0
44
+ for (const f of files) {
45
+ const name = Buffer.from(String(f.path), 'utf8')
46
+ const data = Buffer.isBuffer(f.data) ? f.data : Buffer.from(String(f.data), 'utf8')
47
+ const crc = crc32(data)
48
+ const lh = Buffer.alloc(30)
49
+ lh.writeUInt32LE(0x04034b50, 0)
50
+ lh.writeUInt16LE(20, 4) // 解压要的版本
51
+ lh.writeUInt16LE(0x0800, 6) // 第 11 位:文件名是 UTF-8
52
+ lh.writeUInt16LE(0, 8) // 只存储
53
+ lh.writeUInt16LE(time, 10)
54
+ lh.writeUInt16LE(date, 12)
55
+ lh.writeUInt32LE(crc, 14)
56
+ lh.writeUInt32LE(data.length, 18)
57
+ lh.writeUInt32LE(data.length, 22)
58
+ lh.writeUInt16LE(name.length, 26)
59
+ lh.writeUInt16LE(0, 28)
60
+ locals.push(lh, name, data)
61
+ const ch = Buffer.alloc(46)
62
+ ch.writeUInt32LE(0x02014b50, 0)
63
+ ch.writeUInt16LE(0x031e, 4) // 做出来的版本:Unix
64
+ ch.writeUInt16LE(20, 6)
65
+ ch.writeUInt16LE(0x0800, 8)
66
+ ch.writeUInt16LE(0, 10)
67
+ ch.writeUInt16LE(time, 12)
68
+ ch.writeUInt16LE(date, 14)
69
+ ch.writeUInt32LE(crc, 16)
70
+ ch.writeUInt32LE(data.length, 20)
71
+ ch.writeUInt32LE(data.length, 24)
72
+ ch.writeUInt16LE(name.length, 28)
73
+ ch.writeUInt16LE(0, 30)
74
+ ch.writeUInt16LE(0, 32)
75
+ ch.writeUInt16LE(0, 34)
76
+ ch.writeUInt16LE(0, 36)
77
+ ch.writeUInt32LE((0o100644 << 16) >>> 0, 38) // 普通文件 rw-r--r--
78
+ ch.writeUInt32LE(offset, 42)
79
+ centrals.push(ch, name)
80
+ offset += 30 + name.length + data.length
81
+ }
82
+ const cd = Buffer.concat(centrals)
83
+ const end = Buffer.alloc(22)
84
+ end.writeUInt32LE(0x06054b50, 0)
85
+ end.writeUInt16LE(files.length, 8)
86
+ end.writeUInt16LE(files.length, 10)
87
+ end.writeUInt32LE(cd.length, 12)
88
+ end.writeUInt32LE(offset, 16)
89
+ return Buffer.concat([...locals, cd, end])
90
+ }
91
+
92
+ export const ZIP_LIMITS = { files: 200, file: 4 * 1024 * 1024, total: 12 * 1024 * 1024, zip: 8 * 1024 * 1024 }
93
+
94
+ class ZipError extends Error {
95
+ constructor(code, x = '') {
96
+ super(`${code}${x ? ': ' + x : ''}`)
97
+ this.code = code
98
+ this.x = x
99
+ }
100
+ }
101
+
102
+ /** Mac / Windows 打包时顺手塞进去的东西:不算设计文件,直接跳过 */
103
+ const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini)(\/|$)|(^|\/)\._/i
104
+
105
+ /** zip 里的路径 → 干净的相对路径;不安全给 null */
106
+ function safeName(raw) {
107
+ if (/[\x00-\x1f\x7f\\]/.test(raw)) return null
108
+ if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) return null
109
+ const parts = raw.split('/').filter((p) => p !== '' && p !== '.')
110
+ if (!parts.length || parts.some((p) => p === '..')) return null
111
+ return parts.join('/')
112
+ }
113
+
114
+ /**
115
+ * zip 的 Buffer → { files: [{ path, data }], skipped: [路径] };不安全 / 坏的抛 ZipError(code:bad / big / count / link / crypt / method / zip64 / path / dup)
116
+ * 包外面套了一层同名文件夹(「晨光咖啡/home.html」)的,自动去掉这一层
117
+ */
118
+ export function readZip(buf, limits = ZIP_LIMITS) {
119
+ if (!Buffer.isBuffer(buf) || buf.length < 22) throw new ZipError('bad')
120
+ if (buf.length > limits.zip) throw new ZipError('big')
121
+ // 找中央目录结尾(最后 64KB + 22 字节内)
122
+ let eocd = -1
123
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
124
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
125
+ }
126
+ if (eocd < 0) throw new ZipError('bad')
127
+ const disk = buf.readUInt16LE(eocd + 4)
128
+ const cdDisk = buf.readUInt16LE(eocd + 6)
129
+ const count = buf.readUInt16LE(eocd + 10)
130
+ const cdSize = buf.readUInt32LE(eocd + 12)
131
+ const cdOff = buf.readUInt32LE(eocd + 16)
132
+ if (disk !== 0 || cdDisk !== 0) throw new ZipError('bad', 'multi-disk')
133
+ if (count === 0xffff || cdOff === 0xffffffff || cdSize === 0xffffffff) throw new ZipError('zip64')
134
+ if (cdOff + cdSize > eocd) throw new ZipError('bad')
135
+ if (count > limits.files * 2) throw new ZipError('count')
136
+
137
+ const entries = []
138
+ let p = cdOff
139
+ for (let n = 0; n < count; n++) {
140
+ if (p + 46 > eocd || buf.readUInt32LE(p) !== 0x02014b50) throw new ZipError('bad')
141
+ const flags = buf.readUInt16LE(p + 8)
142
+ const method = buf.readUInt16LE(p + 10)
143
+ const crc = buf.readUInt32LE(p + 16)
144
+ const csize = buf.readUInt32LE(p + 20)
145
+ const usize = buf.readUInt32LE(p + 24)
146
+ const nlen = buf.readUInt16LE(p + 28)
147
+ const xlen = buf.readUInt16LE(p + 30)
148
+ const clen = buf.readUInt16LE(p + 32)
149
+ const made = buf.readUInt16LE(p + 4) >> 8
150
+ const ext = buf.readUInt32LE(p + 38)
151
+ const loff = buf.readUInt32LE(p + 42)
152
+ if (p + 46 + nlen > eocd) throw new ZipError('bad')
153
+ const nameBuf = buf.subarray(p + 46, p + 46 + nlen)
154
+ // 文件名:标了 UTF-8 的按 UTF-8;没标的也先按 UTF-8 认(Mac 自带的压缩就是这样),认不出来按 latin1 当乱码路径,后面会被拒
155
+ const name = new TextDecoder('utf-8', { fatal: false }).decode(nameBuf)
156
+ p += 46 + nlen + xlen + clen
157
+ const isDir = name.endsWith('/')
158
+ if (JUNK.test(name)) continue
159
+ if (isDir) continue
160
+ if (flags & 1) throw new ZipError('crypt', name)
161
+ if (made === 3) {
162
+ const mode = (ext >>> 16) & 0o170000
163
+ if (mode === 0o120000) throw new ZipError('link', name) // 软链接
164
+ if (mode && mode !== 0o100000) throw new ZipError('link', name) // 设备文件、管道之类
165
+ }
166
+ if (csize === 0xffffffff || usize === 0xffffffff || loff === 0xffffffff) throw new ZipError('zip64')
167
+ if (method !== 0 && method !== 8) throw new ZipError('method', name)
168
+ const clean = safeName(name)
169
+ if (!clean) throw new ZipError('path', name)
170
+ if (usize > limits.file) throw new ZipError('big', clean)
171
+ entries.push({ name: clean, method, crc, csize, usize, loff, flags })
172
+ }
173
+ if (entries.length > limits.files) throw new ZipError('count')
174
+
175
+ const files = []
176
+ const seen = new Set()
177
+ let total = 0
178
+ for (const e of entries) {
179
+ if (e.loff + 30 > buf.length || buf.readUInt32LE(e.loff) !== 0x04034b50) throw new ZipError('bad', e.name)
180
+ const start = e.loff + 30 + buf.readUInt16LE(e.loff + 26) + buf.readUInt16LE(e.loff + 28)
181
+ if (start + e.csize > buf.length) throw new ZipError('bad', e.name)
182
+ const raw = buf.subarray(start, start + e.csize)
183
+ let data
184
+ if (e.method === 0) {
185
+ data = Buffer.from(raw)
186
+ } else {
187
+ try {
188
+ // 以实际解出来的为准:超过单个上限就停(maxOutputLength 超了会抛)
189
+ data = zlib.inflateRawSync(raw, { maxOutputLength: limits.file })
190
+ } catch {
191
+ throw new ZipError('big', e.name)
192
+ }
193
+ }
194
+ if (data.length !== e.usize || crc32(data) !== e.crc) throw new ZipError('bad', e.name)
195
+ total += data.length
196
+ if (total > limits.total) throw new ZipError('big')
197
+ const key = e.name.toLowerCase()
198
+ if (seen.has(key)) throw new ZipError('dup', e.name)
199
+ seen.add(key)
200
+ files.push({ path: e.name, data })
201
+ }
202
+ // 外面套了一层文件夹:所有文件都在同一个顶层目录里 → 去掉这一层
203
+ const tops = new Set(files.map((f) => (f.path.includes('/') ? f.path.split('/')[0] : '')))
204
+ let wrapper = ''
205
+ if (files.length && tops.size === 1 && !tops.has('')) {
206
+ wrapper = [...tops][0]
207
+ for (const f of files) f.path = f.path.slice(wrapper.length + 1)
208
+ }
209
+ return { files, wrapper }
210
+ }
211
+
212
+ export { ZipError }
package/lib/handoff.js CHANGED
@@ -27,9 +27,15 @@ export const FRAMEWORKS = {
27
27
  vue: { label: 'Vue 3 + Vite(TypeScript)', create: 'npm create vite@latest {name} -- --template vue-ts', router: 'vue-router' },
28
28
  next: { label: 'Next.js(App Router,TypeScript)', create: 'npx create-next-app@latest {name} --ts --app --use-npm', router: '' },
29
29
  html: { label: '纯 HTML / CSS(不用框架)', create: '', router: '' },
30
+ // 小程序:uni-app 一份代码出微信小程序和网页版(网页版能用 compare_design 比);原生写 WXML / WXSS(没有脚手架命令,手搭骨架)
31
+ uniapp: { label: 'uni-app(Vue 3 + Vite,TypeScript;出微信小程序和网页版)', create: 'npx degit dcloudio/uni-preset-vue#vite-ts {name}', router: '' },
32
+ weapp: { label: '微信原生小程序(WXML / WXSS)', create: '', router: '' },
30
33
  }
31
34
 
32
- const FW_DEPS = [['next', 'next', 'Next.js'], ['nuxt', 'vue', 'Nuxt'], ['@sveltejs/kit', 'svelte', 'SvelteKit'], ['svelte', 'svelte', 'Svelte'], ['astro', 'astro', 'Astro'], ['@angular/core', 'angular', 'Angular'], ['solid-js', 'solid', 'Solid'], ['@builder.io/qwik', 'qwik', 'Qwik'], ['ember-source', 'ember', 'Ember'], ['preact', 'preact', 'Preact'], ['lit', 'lit', 'Lit'], ['vue', 'vue', 'Vue'], ['react-native', 'react-native', 'React Native'], ['react', 'react', 'React']]
35
+ /** 做成小程序的写法(交接说明按小程序的规矩写:rpx、page 上的变量、view / text、pages.json / app.json 登记页面) */
36
+ const MINI = new Set(['uniapp', 'weapp', 'taro'])
37
+
38
+ const FW_DEPS = [['@dcloudio/uni-app', 'uniapp', 'uni-app'], ['@tarojs/taro', 'taro', 'Taro'], ['next', 'next', 'Next.js'], ['nuxt', 'vue', 'Nuxt'], ['@sveltejs/kit', 'svelte', 'SvelteKit'], ['svelte', 'svelte', 'Svelte'], ['astro', 'astro', 'Astro'], ['@angular/core', 'angular', 'Angular'], ['solid-js', 'solid', 'Solid'], ['@builder.io/qwik', 'qwik', 'Qwik'], ['ember-source', 'ember', 'Ember'], ['preact', 'preact', 'Preact'], ['lit', 'lit', 'Lit'], ['vue', 'vue', 'Vue'], ['react-native', 'react-native', 'React Native'], ['react', 'react', 'React']]
33
39
 
34
40
  // 文件夹里只有这些就算空的
35
41
  const JUNK = /^\.(DS_Store|git|gitignore)$|^(Thumbs\.db|desktop\.ini)$/i
@@ -65,22 +71,66 @@ function monoRoot(dir, pkg) {
65
71
  return !!m && listsOthers([...m[1].matchAll(/['"]?([^'"\s,[\]-][^'"\s,[\]]*)['"]?/g)].map((x) => x[1]))
66
72
  }
67
73
 
74
+ /** 读 JSON 对象:读不了、写坏了、不是对象都返回 null */
75
+ function readObj(file) {
76
+ try { const o = JSON.parse(readText(file)); return o && typeof o === 'object' && !Array.isArray(o) ? o : null } catch { return null }
77
+ }
78
+
79
+ /**
80
+ * 没有 package.json 也认得出的小程序项目:
81
+ * HBuilderX 建的 uni-app(根目录有 manifest.json 和写了 pages 的 pages.json;manifest.json 里 vueVersion 是 3 就是 Vue 3,没写是 Vue 2)、
82
+ * 微信原生小程序(project.config.json,miniprogramRoot 指的目录(默认根目录)里有 app.json)
83
+ */
84
+ /** Taro 的源码目录:config/index 里写死的 sourceRoot(只认项目里面的相对目录),没写或认不出就是默认的 src/ */
85
+ function taroRoot(dir) {
86
+ for (const f of ['index.ts', 'index.js', 'index.mjs', 'index.cjs']) {
87
+ const m = /\bsourceRoot\s*:\s*(['"`])([^'"`$\n]+)\1/.exec(readText(path.join(dir, 'config', f)) || '')
88
+ if (!m) continue
89
+ const r = m[2].trim().replace(/\\/g, '/').replace(/^(\.\/)+|\/+$/g, '')
90
+ return r && r !== '.' && !/(^|\/)\.\.(\/|$)|^\/|^[a-z]:/i.test(r) ? r + '/' : 'src/'
91
+ }
92
+ return 'src/'
93
+ }
94
+
95
+ function miniOf(dir) {
96
+ if (!dir) return null
97
+ const pages = readText(path.join(dir, 'pages.json'))
98
+ if (pages !== null && /"pages"\s*:/.test(pages) && fs.existsSync(path.join(dir, 'manifest.json'))) {
99
+ const v3 = /"vueVersion"\s*:\s*"?3/.test(readText(path.join(dir, 'manifest.json')) || '')
100
+ return { framework: 'uniapp', label: `uni-app(HBuilderX,Vue ${v3 ? 3 : 2})`, hbx: true, pagesAt: '' }
101
+ }
102
+ const cfg = readObj(path.join(dir, 'project.config.json'))
103
+ if (!cfg) return null
104
+ // miniprogramRoot 只认项目里面的相对目录('miniprogram/'),别的当根目录
105
+ const sub = typeof cfg.miniprogramRoot === 'string' && !/(^|[\\/])\.\.([\\/]|$)|^[\\/]|^[a-z]:/i.test(cfg.miniprogramRoot) ? cfg.miniprogramRoot.replace(/\\/g, '/').replace(/^(\.\/)+|\/+$/g, '') : ''
106
+ if (fs.existsSync(path.join(dir, sub, 'app.json'))) return { framework: 'weapp', label: '微信原生小程序', hbx: false, pagesAt: sub ? sub + '/' : '' }
107
+ if (sub && fs.existsSync(path.join(dir, 'app.json'))) return { framework: 'weapp', label: '微信原生小程序', hbx: false, pagesAt: '' }
108
+ return null
109
+ }
110
+
68
111
  /**
69
112
  * 目录是不是个项目、用的什么:{ dir, pkg, site(有 index.html 入口), empty(不存在或没正经东西), mono(像 monorepo 根目录), name, framework, label, tailwind, ts, pm }
70
113
  * 后端、monorepo 根目录、只用 CDN 的纯 HTML 站点也都算项目:是不是前端、页面该放进哪个子包,交接说明里让写代码的模型看清结构自己定(猜不准)
71
114
  */
72
115
  export function detectProject(dir) {
73
- const out = { dir, pkg: false, site: false, empty: true, mono: false, name: path.basename(dir || ''), framework: '', label: '', tailwind: false, ts: false, pm: 'npm' }
116
+ const out = { dir, pkg: false, site: false, empty: true, mono: false, name: path.basename(dir || ''), framework: '', label: '', tailwind: false, ts: false, pm: 'npm', hbx: false, pagesAt: '' }
74
117
  out.site = !!dir && HTML_ENTRIES.some((f) => fs.existsSync(path.join(dir, f)))
75
118
  out.empty = !dir || !hasStuff(dir)
76
- let pkg = null
77
- try { pkg = dir ? JSON.parse(readText(path.join(dir, 'package.json'))) : null } catch { /* 没有或写坏了 */ }
78
- if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) return out
119
+ const mini = miniOf(dir)
120
+ const pkg = dir ? readObj(path.join(dir, 'package.json')) : null
121
+ if (!pkg) {
122
+ if (mini) Object.assign(out, mini, { ts: fs.existsSync(path.join(dir, 'tsconfig.json')) })
123
+ return out
124
+ }
79
125
  out.pkg = true
80
126
  if (typeof pkg.name === 'string' && pkg.name) out.name = pkg.name
81
127
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
82
128
  const hit = FW_DEPS.find(([d]) => deps[d])
83
- if (hit) { out.framework = hit[1]; out.label = hit[2] + (hit[0] === 'react' && deps.vite ? ' + Vite' : hit[0] === 'vue' && deps.vite ? ' + Vite' : '') }
129
+ // HBuilderX 项目、原生小程序也常有 package.json(装 uView、Vant Weapp 这些):没写 uni-app / Taro 的依赖就按目录结构认
130
+ if (mini && !(hit && MINI.has(hit[1]))) Object.assign(out, mini)
131
+ else if (hit && hit[1] === 'uniapp') Object.assign(out, { framework: 'uniapp', label: `uni-app(Vue ${/^\D*2\./.test(String(deps.vue || '')) ? 2 : 3})`, pagesAt: fs.existsSync(path.join(dir, 'src', 'pages.json')) ? 'src/' : '' })
132
+ else if (hit && hit[1] === 'taro') Object.assign(out, { framework: 'taro', label: 'Taro', pagesAt: taroRoot(dir) })
133
+ else if (hit) { out.framework = hit[1]; out.label = hit[2] + (hit[0] === 'react' && deps.vite ? ' + Vite' : hit[0] === 'vue' && deps.vite ? ' + Vite' : '') }
84
134
  out.tailwind = !!deps.tailwindcss
85
135
  out.mono = monoRoot(dir, pkg)
86
136
  out.ts = !!deps.typescript || fs.existsSync(path.join(dir, 'tsconfig.json'))
@@ -88,14 +138,22 @@ export function detectProject(dir) {
88
138
  return out
89
139
  }
90
140
 
91
- /** 是个项目(不用往里面再套一层):有 package.json、index.html 入口或 .git(后端、monorepo 根目录、刚建只有 README 的仓库都算) */
141
+ /** 是个项目(不用往里面再套一层):有 package.json、index.html 入口、.git,或是认得出的小程序(后端、monorepo 根目录、刚建只有 README 的仓库都算) */
92
142
  function isProject(dir) {
93
- return ['package.json', '.git', ...HTML_ENTRIES].some((f) => fs.existsSync(path.join(dir, f)))
143
+ return ['package.json', '.git', ...HTML_ENTRIES].some((f) => fs.existsSync(path.join(dir, f))) || !!miniOf(dir)
94
144
  }
95
145
 
96
- /** 不指定框架时用什么:认得出的项目就跟它一样,纯 HTML 站点(没有 package.json)就纯 HTML,别的用 React(右侧栏和 handoff_design 都按这个) */
97
- export function defaultFramework(project) {
98
- return project && project.pkg && project.framework ? 'same' : project && project.site && !project.pkg ? 'html' : 'react'
146
+ /** 认得出的现有项目:有 package.json,或者是没有 package.json 的小程序(HBuilderX 的 uni-app、微信原生) */
147
+ export const isCode = (p) => !!(p && (p.pkg || p.framework))
148
+
149
+ /**
150
+ * 不指定框架时用什么:认得出框架的项目就跟它一样,纯 HTML 站点(没有 package.json)就纯 HTML,
151
+ * 别的:小程序的设计用 uni-app(能出微信小程序,网页版还能逐像素比),其余用 React(右侧栏和 handoff_design 都按这个)
152
+ */
153
+ export function defaultFramework(project, kind = '') {
154
+ if (project && project.framework) return 'same'
155
+ if (project && project.site && !project.pkg) return 'html'
156
+ return kind === 'miniprogram' ? 'uniapp' : 'react'
99
157
  }
100
158
 
101
159
  /** 真实路径:不存在的部分接在最近一级存在的上级目录的真实路径后面(新建的目录也能按真实位置比) */
@@ -129,23 +187,23 @@ export function within(p, root) {
129
187
  */
130
188
  export function projectFor(dest) {
131
189
  const own = detectProject(dest)
132
- if (own.pkg || !fs.existsSync(dest)) return own
190
+ if (isCode(own) || !fs.existsSync(dest)) return own
133
191
  const home = fold(realish(os.homedir()))
134
192
  for (let cur = path.dirname(realish(dest)); fold(cur) !== home && path.dirname(cur) !== cur; cur = path.dirname(cur)) {
135
- if (!fs.existsSync(path.join(cur, 'package.json'))) continue
193
+ if (!fs.existsSync(path.join(cur, 'package.json')) && !miniOf(cur)) continue
136
194
  const p = detectProject(cur)
137
- if (p.pkg) return own.site && !p.framework ? own : p
195
+ if (isCode(p)) return own.site && !p.framework ? own : p
138
196
  }
139
197
  return own
140
198
  }
141
199
 
142
200
  /**
143
- * 代码默认放哪:会话目录是个项目(有 package.json)、又不在设计目录里 → 就放会话目录;
201
+ * 代码默认放哪:会话目录是个项目(有 package.json,或是认得出的小程序)、又不在设计目录里 → 就放会话目录;
144
202
  * 否则新建 <设计名>-app:设计目录在会话目录下就建在会话目录里,不然建在设计目录旁边(别塞进设计目录)
145
203
  */
146
204
  export function defaultDest(root, cwd, project) {
147
205
  const inside = !!root && within(cwd, root)
148
- if (project && project.pkg && !inside) return cwd
206
+ if (isCode(project) && !inside) return cwd
149
207
  const under = !!root && !inside && within(root, cwd)
150
208
  return path.join(root && !under ? path.dirname(root) : cwd, appDirName(root))
151
209
  }
@@ -168,7 +226,7 @@ export function pickedDest(dir, root) {
168
226
  const beside = { dest: path.join(path.dirname(root || dir), name), why: 'beside' }
169
227
  if (root && within(dir, root)) return beside
170
228
  let out = { dest: dir, why: '' }
171
- if (!isProject(dir) && !projectFor(dir).pkg && path.basename(dir).toLowerCase() !== name) {
229
+ if (!isProject(dir) && !isCode(projectFor(dir)) && path.basename(dir).toLowerCase() !== name) {
172
230
  if (hasStuff(dir)) out = { dest: path.join(dir, name), why: 'nested' } // 还不存在 / 读不了 / 空的:就用它
173
231
  }
174
232
  return root && within(out.dest, root) ? beside : out
@@ -180,6 +238,13 @@ export function routeOf(key) {
180
238
  return b === 'index' || b === 'home' ? '/' : '/' + b.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
181
239
  }
182
240
 
241
+ /** 文件名 → 小程序的页面路径(登记进 pages.json / app.json 的,相对它们所在的目录):index / home → pages/index/index,别的 pages/名字/名字(Taro 是 pages/名字/index) */
242
+ export function pagePathOf(key, target = 'uniapp') {
243
+ const r = routeOf(key)
244
+ const b = r === '/' ? 'index' : r.slice(1) || 'page'
245
+ return `pages/${b}/${target === 'taro' ? 'index' : b}`
246
+ }
247
+
183
248
  const CLASS_TAGS = /^(header|nav|footer|aside|section|div|a|button|ul|ol|li|form|article|main|figure)$/
184
249
 
185
250
  /** 几屏共用的 class(≥ 2 屏都有,多半该抽成组件):[{ cls, n, screens }] */
@@ -278,7 +343,23 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
278
343
  const fwLabel = same ? `${project.label}(跟项目一样)` : fw ? fw.label : framework
279
344
  const name = path.basename(root)
280
345
  const keys = rows.map((r) => r.key)
281
- const route = (k) => routeOf(k)
346
+ // 做成什么:跟项目一样就是项目的框架;小程序的写法(uni-app / 原生 / Taro)按小程序的规矩写
347
+ const target = framework === 'same' ? (project && project.framework) || '' : framework
348
+ const uni = target === 'uniapp'
349
+ const wx = target === 'weapp'
350
+ const taro = target === 'taro'
351
+ const mini = MINI.has(target)
352
+ const known = isCode(project)
353
+ // 目标目录本来就是这种小程序项目(往里加页面,不新建)
354
+ const hasMini = mini && !!project && project.framework === target
355
+ // 目标目录是别的前端项目(React / Vue 网页、别种小程序):不往里硬加,在里面另建子项目
356
+ const mismatch = mini && known && !hasMini && !!project.framework
357
+ // 页面、pages.json 在哪个目录:现有项目照它(命令行建的 uni-app、Taro 在 src/);新建的 uni-app 模板也在 src/
358
+ // Taro 照项目 config/index 的 sourceRoot(默认 src/)
359
+ const at = hasMini ? project.pagesAt || (taro ? 'src/' : '') : uni ? 'src/' : ''
360
+ const cfgFile = uni ? `${at}pages.json` : wx ? `${at}app.json` : `${at}app.config`
361
+ const api = uni ? 'uni' : taro ? 'Taro' : 'wx'
362
+ const route = (k) => (mini ? pagePathOf(k, target) : routeOf(k))
282
363
  const keyOfLink = (p) => String(p || '').replace(/^\/+/, '').split(/[?#]/)[0]
283
364
  const lines = []
284
365
  lines.push(`# 交接:${name} → ${fwLabel}`, '')
@@ -287,20 +368,60 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
287
368
  lines.push(`- 设计目录:\`${root}\`(${rows.length} 屏${more ? `,另有 ${more} 屏没列进来` : ''},${kind} ${size[0]}×${size[1]})`)
288
369
  // 目录里已经有别的东西、又不是 Node 项目(别的语言的仓库、后端……):别在原地或旁边跑脚手架
289
370
  // (纯 HTML 站点再做成纯 HTML 不算:就加在这个站点里,下面第 1 步提醒别覆盖现有页面)
290
- const occupied = !!(project && !project.pkg && project.empty === false) && !(project.site && framework === 'html')
291
- if (project && project.pkg) lines.push(`- 做到:\`${dest || project.dir}\`(现有项目 ${project.name}:${project.label || '没认出框架'}${project.tailwind ? ' + Tailwind' : ''}${project.ts ? ',TypeScript' : ''},包管理 ${project.pm})`)
371
+ const occupied = !!(project && !known && project.empty === false) && !(project.site && framework === 'html')
372
+ if (known) lines.push(`- 做到:\`${dest || project.dir}\`(现有项目 ${project.name}:${project.label || '没认出框架'}${project.tailwind ? ' + Tailwind' : ''}${project.ts ? ',TypeScript' : ''}${project.pkg ? `,包管理 ${project.pm}` : ',没有 package.json'})`)
292
373
  else if (occupied) lines.push(`- 做到:\`${dest}\`(目录里已经有别的东西,不是 Node 项目:先看清结构,前端放进它里面新的子目录)`)
293
374
  else if (fw && fw.create) lines.push(`- 做到:\`${dest}\`(新建项目:\`${fw.create.replace('{name}', path.basename(dest || name + '-app'))}\`,在 \`${path.dirname(dest || root)}\` 里跑)`)
294
375
  else lines.push(`- 做到:\`${dest}\``)
295
376
  lines.push(`- 框架:${fwLabel}`)
296
377
  if (system) lines.push(`- 设计系统:${system}(设计用的是它的 tokens.css / components.css,项目里也照它)`)
297
378
  lines.push(`- 生成时间:${now.toISOString().slice(0, 16).replace('T', ' ')}`, '')
298
- if (kind === 'miniprogram') {
379
+ if (kind === 'miniprogram' && !mini) {
380
+ // 小程序的设计、选的是网页框架:做成手机网页,别混进小程序的写法
381
+ lines.push('## 小程序设计做成网页(H5)', '')
382
+ lines.push(`- 这是微信小程序的设计(${size[0]} 宽)。框架选的「${fwLabel}」,就做成手机网页:样式照设计稿写 px(宽 ${size[0]} 的移动端页面,带 \`<meta name="viewport" content="width=device-width, initial-scale=1">\`),不用 rpx、不用 \`wx.*\`。用户其实要真小程序的话,用 handoff_design 的 framework: uniapp(uni-app)或 weapp(原生)重新出交接说明。`)
383
+ lines.push('- 设计稿顶上画的状态栏、右上角的胶囊是微信自己画的,网页里不做;导航栏做成页面自己的顶栏。底部标签栏做成固定在底部的导航,留出安全区 `env(safe-area-inset-bottom)`。', '')
384
+ }
385
+ if (mini) {
386
+ const cfg = uni ? `${cfgFile} 的 \`globalStyle\` / 页面的 \`style\`` : taro ? `\`${cfgFile}\` / 页面的 \`.config\`` : `${cfgFile} / 页面 json`
299
387
  lines.push('## 小程序要点', '')
300
- lines.push(`- 这是微信小程序的设计(${size[0]} 宽)。要做成真小程序:原生写 WXML / WXSS,或用 Taro、uni-app;框架选的「${fwLabel}」是做成网页的写法,用户要的是哪种先问清。`)
301
- lines.push(`- 尺寸换成 rpx:设计稿 1px = ${Math.round((750 / size[0]) * 100) / 100}rpx(整屏宽 750rpx)。`)
302
- lines.push('- 设计稿顶上画的状态栏不用做,微信自带;右上角的胶囊也是微信画的。导航栏用 app.json / 页面 json 的默认导航栏,或 `navigationStyle: custom` 自己画——自己画时状态栏高用 `wx.getWindowInfo().statusBarHeight`、导航栏高度和右边让开的宽度按 `wx.getMenuButtonBoundingClientRect()` 算(设计稿的 44 / 88 / 101px 只是预览手机的)。顶栏是深色的(预览里胶囊是白的),页面配置写 `navigationBarTextStyle: white`。')
303
- lines.push('- 底部标签栏写进 app.json 的 `tabBar`(样式特殊就用自定义 tabBar),底部留出安全区 `env(safe-area-inset-bottom)`。', '')
388
+ lines.push(taro
389
+ ? `- 尺寸:Taro 会自己把样式里的 px 换成 rpx,按 \`config/index\` 的 \`designWidth\` 算(默认 750)。别改项目的 designWidth(旧页面会跟着变大变小):照它换算——designWidth 是 750 时设计稿 1px 写 ${Math.round((750 / size[0]) * 100) / 100}px,是 ${size[0]} 就照设计稿写。`
390
+ : `- 尺寸换成 rpx:设计稿 1px = ${Math.round((750 / size[0]) * 100) / 100}rpx(整屏宽 750rpx)。`)
391
+ if (kind === 'miniprogram') {
392
+ // 胶囊位置只有小程序里拿得到;uni-app、Taro 的网页版(拿来逐像素比的)要分开写
393
+ const h5Nav = uni ? '(`getMenuButtonBoundingClientRect` 网页版没有:包在 `// #ifdef MP-WEIXIN` … `// #endif` 里,网页版走 `// #ifdef H5`,顶栏高按 44px、状态栏按 0)'
394
+ : taro ? '(`getMenuButtonBoundingClientRect` 网页版拿不到:用 `process.env.TARO_ENV === \'weapp\'` 分开,网页版顶栏高按 44px、状态栏按 0)' : ''
395
+ lines.push(`- 设计稿顶上画的状态栏不用做,微信自带;右上角的胶囊也是微信画的。导航栏用 ${cfg} 里的默认导航栏,或 \`navigationStyle: custom\` 自己画——自己画时状态栏高用 \`${api}.getWindowInfo().statusBarHeight\`、导航栏高度和右边让开的宽度按 \`${api}.getMenuButtonBoundingClientRect()\` 算(设计稿的 44 / 88 / 101px 只是预览手机的)${h5Nav}。顶栏是深色的(预览里胶囊是白的),页面配置写 \`navigationBarTextStyle: white\`。`)
396
+ lines.push(`- 底部标签栏写进 \`${cfgFile}\` 的 \`tabBar\`(样式特殊就用自定义 tabBar),底部留出安全区 \`env(safe-area-inset-bottom)\`。`)
397
+ }
398
+ if (mismatch) lines.push(`- 目标目录 \`${dest || project.dir}\` 是 ${project.label} 项目,不是 ${fwLabel}:按下面第 1 步在里面另建一个子项目,下面说的路径都相对那个子项目。`)
399
+ lines.push('')
400
+ }
401
+ if (uni) {
402
+ lines.push('## 做成 uni-app', '')
403
+ lines.push(`- 页面:每屏一个 \`${at}pages/<名字>/<名字>.vue\`,在 \`${cfgFile}\` 的 \`pages\` 里登记(第一项是首页),标题、导航栏颜色写在那一页的 \`style\` 里。下面表里的「页面路径」就是要登记的 path。`)
404
+ lines.push('- 标签换成 uni-app 的组件:`div` → `view`,文字 → `text`,`img` → `image`(照设计的裁切写 `mode`,比如 `aspectFill`),输入框、按钮用同名组件;点击写 `@tap`;跳转用 `uni.navigateTo`(标签页 `uni.switchTab`),别用 `<a>` 和 vue-router。')
405
+ lines.push('- 设计变量放进 `App.vue` 的全局样式,选择器写 `page`(小程序里没有 `:root`,网页版也认 `page`):`page { --color-primary: …; }`,各处用 `var(--…)`。')
406
+ lines.push('- 别用 `window`、`document`、`localStorage`、`fetch`:换成 `uni.getStorageSync`、`uni.request` 这些。')
407
+ if (hasMini) lines.push(`- 先看清项目现有的写法再动手:Vue ${/Vue 2/.test(project.label) ? '2(选项式)' : '3(`<script setup>` 还是选项式)'}、用没用 uni-ui / uView 这些组件库、\`uni.scss\` 里有没有现成的变量,照它来。`)
408
+ if (hasMini && project.hbx) lines.push(project.pkg ? '- 这是 HBuilderX 建的项目:别跑 npm 脚手架;要的依赖照现有 package.json 装,跑起来让用户在 HBuilderX 里点「运行」。' : '- 这是 HBuilderX 建的项目(没有 package.json):别跑 npm 脚手架、别装依赖;跑起来让用户在 HBuilderX 里点「运行」。')
409
+ lines.push('')
410
+ }
411
+ if (wx) {
412
+ lines.push('## 做成原生小程序', '')
413
+ lines.push(`- 页面:每屏一个目录 \`${at}pages/<名字>/\`,四个文件 \`<名字>.wxml / .wxss / .js / .json\`,在 \`${cfgFile}\` 的 \`pages\` 里登记(第一项是首页);公共样式写 \`${at}app.wxss\`。下面表里的「页面路径」就是要登记的。`)
414
+ if (!hasMini) lines.push('- 这里还没有小程序:先手搭最小骨架——`app.json`、`app.js`(`App({})`)、`app.wxss`、`sitemap.json`,`project.config.json` 里写 `"compileType": "miniprogram"`、`appid` 用户有就填,没有先写 `touristappid`(游客模式)。别找 npm 脚手架。')
415
+ lines.push('- HTML 换成 WXML:`div` → `view`,文字 → `text`,`img` → `image`(照设计的裁切写 `mode`),点击 `bindtap`,列表 `wx:for`,条件 `wx:if`;跳转 `wx.navigateTo`(标签页 `wx.switchTab`);页面数据放 `Page({ data })`,用 `this.setData` 改。设计稿里的脚本照这个改写。')
416
+ lines.push(`- 设计变量写在 \`${at}app.wxss\` 的 \`page { … }\` 上(WXSS 没有 \`:root\`),各页用 \`var(--…)\`。`)
417
+ lines.push('- WXSS 只用常见的选择器(class、id、标签、后代 / 子代、`::before` / `::after`);`*` 通配、属性选择器、`:hover` 别用——按下的样式用组件的 `hover-class`。')
418
+ lines.push('')
419
+ }
420
+ if (taro) {
421
+ lines.push('## 做成 Taro', '')
422
+ lines.push(`- 源码目录是 \`${at}\`(项目 \`config/index\` 里的 \`sourceRoot\`${at === 'src/' ? ',默认 src;那里写的不是 src 就照它' : ''})。页面放 \`${at}pages/<名字>/index\`(\`.tsx\` 还是 \`.vue\` 看项目用 React 还是 Vue),在 \`${cfgFile}\`(\`.ts\` 或 \`.js\`,看项目)的 \`pages\` 里登记;组件用 \`@tarojs/components\` 的 \`View\`、\`Text\`、\`Image\`;跳转 \`Taro.navigateTo\`(标签页 \`Taro.switchTab\`)。`)
423
+ lines.push(`- 设计变量写在 \`${at}app\` 的全局样式里,选择器写 \`page, .taro_page { … }\`:小程序认 \`page\`(没有 \`:root\`),网页版的页面容器是 \`.taro_page\`(\`page\` 在网页版不生效);各处用 \`var(--…)\`。先看清项目现有的写法、组件库(Taro UI、NutUI 之类)再动手。`)
424
+ lines.push('')
304
425
  }
305
426
 
306
427
  if (kind === 'desktop') {
@@ -310,7 +431,7 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
310
431
  lines.push('- 整个窗口不滚,侧栏、列表、详情各自滚(`overflow: auto`);最小窗口尺寸照设计里最小的那种(多尺寸里的 1024×680)设。', '')
311
432
  }
312
433
 
313
- lines.push('## 屏幕和路由', '', '| 屏 | 设计文件 | 建议路由 | 截图(handoff/shots/) | 跳到 |', '| --- | --- | --- | --- | --- |')
434
+ lines.push(mini ? '## 屏幕和页面' : '## 屏幕和路由', '', `| 屏 | 设计文件 | ${mini ? '页面路径' : '建议路由'} | 截图(handoff/shots/) | 跳到 |`, '| --- | --- | --- | --- | --- |')
314
435
  for (const r of rows) {
315
436
  const to = [...new Set((r.links || []).map((l) => keyOfLink(l.path)).filter((k) => k && k !== r.key))]
316
437
  const shots = r.error ? `没截成(${r.error})` : [...r.shots, r.mobile].filter(Boolean).join('、') + (r.more ? `(还有 ${r.more} 段没截)` : '')
@@ -327,7 +448,9 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
327
448
  if (tn.length) {
328
449
  const colors = tn.filter((n) => /^--colou?r-/.test(n))
329
450
  lines.push(`\`tokens.css\` 里 ${tn.length} 个变量(颜色 ${colors.length} 个:${colors.slice(0, 10).map((n) => `\`${n}: ${tok[n]}\``).join('、')}${colors.length > 10 ? ' …' : ''})。`, '')
330
- lines.push(project && project.tailwind
451
+ lines.push(mini
452
+ ? `- 把 tokens.css 里的变量搬进${uni ? ' `App.vue` 全局样式' : wx ? ` \`${at}app.wxss\`` : '全局样式'}的 \`${taro ? 'page, .taro_page' : 'page'} { … }\`(小程序没有 \`:root\`);组件样式里一律用 \`var(--…)\`,别写死色值、字号。`
453
+ : project && project.tailwind
331
454
  ? '- 把 tokens.css 原样放进项目的全局样式并在入口引入;Tailwind 的 theme.extend 里把颜色、圆角、字号、阴影映射到这些变量(如 `primary: \'var(--color-primary)\'`),类名里别写死色值。'
332
455
  : '- 把 tokens.css 原样放进项目的全局样式(如 `src/styles/tokens.css`)并在入口引入;组件样式里一律用 `var(--…)`,别写死色值、字号。')
333
456
  } else if (hasTokens) lines.push('- `tokens.css` 的第一段 `:root` 里没读到变量:先打开看看,把各屏用到的颜色、字号收成 `:root` 上的变量再动手。')
@@ -339,12 +462,19 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
339
462
  // 共用部分
340
463
  const parts = sharedParts(htmlByKey)
341
464
  lines.push('## 几屏共用的部分(做成组件)', '')
342
- if (parts.length) for (const p of parts) lines.push(`- \`.${p.cls}\`:${p.n} 屏都有(${p.screens.map((k) => k.replace(/\.html?$/i, '')).join('、')})→ \`${pascal(p.cls)}\` 组件`)
465
+ // 小程序设计的骨架(技能里的 .mp-status / .mp-nav / .mp-tabbar …)是在模拟微信自己画的东西:照微信的做法,别做成组件
466
+ const tabCfg = `\`${cfgFile}\``
467
+ const chrome = kind !== 'miniprogram' ? {} : mini
468
+ ? { 'mp-status': '预览手机的状态栏,不做(微信自带)', 'mp-top': '状态栏 + 导航栏的外壳:照上面「小程序要点」用默认导航栏,或自己画导航', 'mp-nav': '导航栏:纯色底就用默认导航栏(标题、颜色写进页面配置);渐变、图片、搜索框这种才做自定义导航组件', 'mp-back': '返回按钮:默认导航栏自带', 'mp-tabbar': `底部标签栏:写进 ${tabCfg} 的 \`tabBar\`(样式特殊才做自定义 tabBar)` }
469
+ : { 'mp-status': '预览手机的状态栏,网页里不做', 'mp-top': '→ 页面顶栏组件', 'mp-tabbar': '→ 固定在底部的导航组件' }
470
+ if (parts.length) for (const p of parts) lines.push(chrome[p.cls] ? `- \`.${p.cls}\`:${chrome[p.cls]}` : `- \`.${p.cls}\`:${p.n} 屏都有(${p.screens.map((k) => k.replace(/\.html?$/i, '')).join('、')})→ \`${pascal(p.cls)}\` 组件`)
343
471
  else lines.push('- 没找到几屏共用的结构;按页面拆就行。')
344
472
  if (fs.existsSync(path.join(root, 'icons.svg'))) {
345
473
  let n = 0
346
474
  try { n = (fs.readFileSync(path.join(root, 'icons.svg'), 'utf8').match(/<symbol\b/g) || []).length } catch { n = 0 }
347
- lines.push(`- \`icons.svg\`:${n} 个图标(<symbol id>)→ 一个 \`Icon\` 组件按 id 取,或拆成单个 SVG 组件`)
475
+ lines.push(mini
476
+ ? `- \`icons.svg\`:${n} 个图标(<symbol id>)→ 小程序的模板里不能直接写 \`<svg>\`,本地 .svg 文件用 \`<image>\` 显示也不是每个版本都稳:导出成 PNG(3 倍图)用 \`<image>\`,或者把 SVG 写成 \`data:image/svg+xml\` 放进 \`background-image\`(要换颜色就改里面的 fill),或做成图标字体`
477
+ : `- \`icons.svg\`:${n} 个图标(<symbol id>)→ 一个 \`Icon\` 组件按 id 取,或拆成单个 SVG 组件`)
348
478
  }
349
479
  lines.push('')
350
480
 
@@ -385,7 +515,9 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
385
515
  }
386
516
  walk(root, '', 0)
387
517
  lines.push('## 图片和字体', '')
388
- lines.push(assets.length ? `- ${assets.slice(0, 30).map((a) => `\`${a}\``).join('、')}${assets.length > 30 ? ' …' : ''}:照原样复制进项目的静态资源目录(public/ 或 src/assets/)` : '- 没有单独的图片、字体文件(图形都写在 HTML / SVG 里)。', '')
518
+ lines.push(assets.length ? `- ${assets.slice(0, 30).map((a) => `\`${a}\``).join('、')}${assets.length > 30 ? ' …' : ''}:照原样复制进项目的静态资源目录(${mini ? (uni ? `\`${at}static/\`` : taro ? `\`${at}assets/\`` : `\`${at}images/\` 之类`) : 'public/ 或 src/assets/'})` : '- 没有单独的图片、字体文件(图形都写在 HTML / SVG 里)。')
519
+ if (mini) lines.push(`- 小程序主包不能超过 2MB:大图放服务器用网址;字体文件要用 \`${api}.loadFontFace\` 从网址加载,加载不了就用系统字体。`)
520
+ lines.push('')
389
521
 
390
522
  // 做法
391
523
  // 在认不出前端框架的项目(后端)或 monorepo 里选了个空的新文件夹:就在这里新建前端项目,不用再看结构、另套一层
@@ -395,18 +527,40 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
395
527
  const PLAIN = { react: 'React', vue: 'Vue', next: 'Next.js' }
396
528
  const fwNew = fw || (fresh && same && PLAIN[project.framework] === String(project.label || '').replace(/ \+ Vite$/, '') ? FRAMEWORKS[project.framework] : null)
397
529
  const pm = fresh && project.pm && project.pm !== 'npm' ? project.pm : ''
398
- const create = (!(project && project.pkg) || fresh) && !occupied && fwNew && fwNew.create ? fwNew.create.replace('{name}', path.basename(dest || name + '-app')).replace('--use-npm', pm ? `--use-${pm}` : '--use-npm') : ''
530
+ const create = (!known || fresh) && !occupied && fwNew && fwNew.create ? fwNew.create.replace('{name}', path.basename(dest || name + '-app')).replace('--use-npm', pm ? `--use-${pm}` : '--use-npm') : ''
399
531
  lines.push('## 做法和验收', '')
400
532
  // 现有项目但没认出前端框架(后端、monorepo 根目录、别的语言的仓库)/ 像 monorepo 根目录:页面该放哪让模型看清结构再定,别直接往根目录塞
401
533
  const bare = !create && framework !== 'html' && framework !== 'same' && !!(project && ((project.pkg && !project.framework) || occupied))
402
534
  const sub = fw && fw.create ? fw.create.replace('{name}', 'web') : ''
403
535
  const mono = !create && !fresh && !!(project && project.mono)
404
536
  const keep = project && project.empty === false ? ';目录里已经有文件:别覆盖现有的页面,重名的先问用户' : ''
405
- lines.push(`1. 搭骨架:${create ? `新建项目 \`${create}\`${fresh ? `(在 \`${path.dirname(dest)}\` 里跑)` : ''},${pm ? `用 ${pm} ` : ''}装好依赖${fwNew.router ? `和 ${fwNew.router}` : ''}` : framework === 'html' ? `整理成一个静态站点目录(每屏一个页面,公共样式一份)${keep}` : fresh ? `在这个空文件夹里新建一个 ${fwLabel.replace('(跟项目一样)', '')} 子项目,照仓库里别的子包怎么搭的来(monorepo 记得加进 workspace)${pm ? `,用 ${pm} 装依赖` : ''}` : bare ? `这个目录里已经有东西,但没认出前端框架(可能是后端、monorepo 根目录或别的语言的仓库):先看清它的结构和用途再定放哪——适合直接加页面就加;不适合就在里面另建一个前端(monorepo 放进 apps/ 之类的子包,别的放进新的子目录${sub ? `,比如在里面跑 \`${sub}\`` : ''}),用 ${fw ? fw.label : framework} 搭,别打乱现有代码` : `在现有项目里加页面和路由(看清它现有的目录结构、样式写法、组件库再动手)`}${mono && !bare ? '(这像是 monorepo 根目录:先看根目录和 apps/ 之类的子包里有没有现成的前端应用,有就加进去;没有再新建一个子包,别把页面散放在根目录)' : ''};把 tokens.css${sheets.length ? '(和公共样式表)' : ''}搬进去。`)
406
- lines.push('2. 一屏一个页面,路由照上面的表;先抽几屏共用的组件,再拼页面。')
407
- lines.push('3. 结构、文案、数字、图标照设计稿的 HTML 搬;class 名可以改成项目的习惯,样式值(间距、字号、颜色、圆角、阴影)一律用设计变量。')
408
- lines.push('4. 每做完一屏:把开发服务器跑起来,调 `compare_design`(screen 填设计文件名,url 填这一屏在开发服务器上的地址),照差异图把不一样的地方改掉,直到差异在 3% 以内,或者剩下的差别说得出理由(比如真实数据、系统字体)。')
409
- lines.push('5. 全部做完:每屏再对一遍;告诉用户做了哪些页面、怎么跑起来(命令和地址)、还有哪里和设计稿不一样。')
537
+ const tokTail = mini ? `;tokens.css 里的变量搬进全局样式的 \`${taro ? 'page, .taro_page' : 'page'} { }\`${sheets.length ? ',公共样式表也拆进去' : ''}` : `;把 tokens.css${sheets.length ? '(和公共样式表)' : ''}搬进去`
538
+ const step1 = create ? `新建项目 \`${create}\`${fresh ? `(在 \`${path.dirname(dest)}\` 里跑)` : ''},${pm ? `用 ${pm} ` : ''}装好依赖${fwNew.router ? `和 ${fwNew.router}` : ''}${uni ? '(GitHub 连不上就从 gitee 下载模板压缩包 `https://gitee.com/dcloud/uni-preset-vue/repository/archive/vite-ts.zip`,解开当项目目录)' : ''}`
539
+ : framework === 'html' ? `整理成一个静态站点目录(每屏一个页面,公共样式一份)${keep}`
540
+ : fresh ? `在这个空文件夹里新建一个 ${fwLabel.replace('(跟项目一样)', '')} 子项目,照仓库里别的子包怎么搭的来(monorepo 记得加进 workspace)${pm ? `,用 ${pm} 装依赖` : ''}`
541
+ : bare ? `这个目录里已经有东西,但没认出前端框架(可能是后端、monorepo 根目录或别的语言的仓库):先看清它的结构和用途再定放哪——适合直接加页面就加;不适合就在里面另建一个前端(monorepo 放进 apps/ 之类的子包,别的放进新的子目录${sub ? `,比如在里面跑 \`${sub}\`` : wx ? ',比如 `miniprogram/`,照上面搭骨架' : ''}),用 ${fw ? fw.label : framework} 搭,别打乱现有代码`
542
+ : mismatch ? `这个目录是 ${project.label} 项目,不是 ${fwLabel}:别往里面硬加——在里面另建一个子目录(比如 \`${uni ? 'uni-app' : 'miniprogram'}/\`)${uni ? `,在里面跑 \`${FRAMEWORKS.uniapp.create.replace('{name}', 'uni-app')}\` 新建 uni-app 项目再装依赖` : ',照上面「做成原生小程序」搭骨架'},现有代码别动`
543
+ : wx && !hasMini ? `在 \`${dest}\` 里照上面「做成原生小程序」搭好骨架${keep}`
544
+ : mini ? `在现有项目里加页面:放进 \`${at}pages/\`,登记进 \`${cfgFile}\`(看清它现有的目录结构、写法、组件库再动手)`
545
+ : '在现有项目里加页面和路由(看清它现有的目录结构、样式写法、组件库再动手)'
546
+ lines.push(`1. 搭骨架:${step1}${mono && !bare ? '(这像是 monorepo 根目录:先看根目录和 apps/ 之类的子包里有没有现成的前端应用,有就加进去;没有再新建一个子包,别把页面散放在根目录)' : ''}${tokTail}。`)
547
+ lines.push(mini
548
+ ? `2. 一屏一个页面,照上面的表登记页面路径;先抽几屏共用的组件(${wx ? '做成自定义组件,放 `components/`' : '放 `components/`'}),再拼页面。`
549
+ : '2. 一屏一个页面,路由照上面的表;先抽几屏共用的组件,再拼页面。')
550
+ lines.push(`3. 结构、文案、数字、图标照设计稿的 HTML 搬${mini ? `(标签换成${wx ? ' WXML ' : '小程序'}的组件)` : ''};class 名可以改成项目的习惯,样式值(间距、字号、颜色、圆角、阴影)一律用设计变量${mini && !taro ? ',尺寸按上面换成 rpx' : ''}。`)
551
+ if (wx) {
552
+ lines.push('4. 每做完一屏:原生小程序在浏览器里跑不起来,`compare_design` 比不了——让用户用微信开发者工具「导入项目」打开这个目录(没有 AppID 就用测试号 / 游客模式)看这一屏,截图发回来,和 handoff/shots/ 里的截图对照着改。')
553
+ lines.push('5. 全部做完:告诉用户做了哪些页面、在微信开发者工具里导入哪个目录、还有哪里和设计稿不一样。')
554
+ } else if (mini) {
555
+ const h5 = uni && hasMini && project.hbx ? '让用户在 HBuilderX 里点「运行 → 运行到浏览器」' : '后台跑 `npm run dev:h5`'
556
+ // 现有的命令行项目不一定配了网页版
557
+ const noH5 = hasMini && !project.hbx ? `(package.json 里没有 \`dev:h5\` 就是没配网页版:先问用户要不要加;不加就请用户在微信开发者工具里看这一屏,照截图对)` : ''
558
+ lines.push(`4. 每做完一屏:先跑网页版(${h5})${noH5},调 \`compare_design\`(screen 填设计文件名,url 填这一屏在网页版上的地址,比如 \`http://localhost:5173/#/${route(rows[0] ? rows[0].key : 'index')}\`),照差异图把不一样的地方改掉,直到差异在 3% 以内,或者剩下的差别说得出理由(网页版顶上是 ${uni ? 'uni-app' : 'Taro'} 画的导航栏、没有状态栏和胶囊,这块不一样是正常的)。`)
559
+ lines.push(`5. 全部做完:每屏再对一遍;告诉用户做了哪些页面、网页版怎么跑起来、微信小程序怎么出(${uni && hasMini && project.hbx ? 'HBuilderX「运行 → 运行到小程序模拟器 → 微信开发者工具」' : uni ? '`npm run dev:mp-weixin`,再用微信开发者工具打开 `dist/dev/mp-weixin`' : '`npm run dev:weapp`,再用微信开发者工具打开项目目录'})、还有哪里和设计稿不一样。`)
560
+ } else {
561
+ lines.push('4. 每做完一屏:把开发服务器跑起来,调 `compare_design`(screen 填设计文件名,url 填这一屏在开发服务器上的地址),照差异图把不一样的地方改掉,直到差异在 3% 以内,或者剩下的差别说得出理由(比如真实数据、系统字体)。')
562
+ lines.push('5. 全部做完:每屏再对一遍;告诉用户做了哪些页面、怎么跑起来(命令和地址)、还有哪里和设计稿不一样。')
563
+ }
410
564
  lines.push('', '别做:改设计(配色、文案、布局)、加设计稿里没有的功能、引入新的 UI 组件库(项目已经在用的除外)。', '')
411
565
  return lines.join('\n')
412
566
  }
package/lib/history.js CHANGED
@@ -191,7 +191,7 @@ export function createHistory({ base, now = Date.now, log = () => {} }) {
191
191
  * 那一版之后新加的文件不删;落到目录外的(父目录是指到外面的软链接)不写。
192
192
  * @returns { restored: 文件数, saved: 回退前存的那一版 | null }
193
193
  */
194
- function restore(root, n) {
194
+ function restore(root, n, { before = null } = {}) {
195
195
  const v = load(root).find((x) => x && x.n === Number(n))
196
196
  if (!v) throw new Error(`没有第 ${n} 版`)
197
197
  const objDir = path.join(dirOf(root), 'objects')
@@ -203,6 +203,8 @@ export function createHistory({ base, now = Date.now, log = () => {} }) {
203
203
  if (!buf) throw new Error(`第 ${n} 版的文件不全(${rel}),没有回退`)
204
204
  wanted.push([rel, buf])
205
205
  }
206
+ // 写之前先告诉调用方要退回哪些页面(sha256):装好的别人的设计系统要先记下来,写进去的那一刻起就按别人做的预览;它出错就不回
207
+ if (before) before(wanted.filter(([rel]) => /\.html?$/i.test(rel)).map(([, buf]) => crypto.createHash('sha256').update(buf).digest('hex')))
206
208
  let saved
207
209
  try {
208
210
  saved = take(root, '回退前', true)