dsh-design-studio 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.js CHANGED
@@ -109,7 +109,10 @@ export function fromBashPath(p, platform = process.platform) {
109
109
  * @throws Error 带中文说明
110
110
  */
111
111
  export function parseTarget(input, cwd = os.homedir()) {
112
- const s = String(input || '').trim()
112
+ let s = String(input || '').trim()
113
+ // Windows「复制为路径」会带一对引号:"C:\a\b.html"
114
+ const q = /^(["'“])(.*)(["'”])$/.exec(s)
115
+ if (q && (q[1] === q[3] || (q[1] === '“' && q[3] === '”'))) s = q[2].trim()
113
116
  if (!s) throw new Error('地址是空的')
114
117
  if (/^\d{2,5}$/.test(s)) return proxyTarget(Number(s), '/')
115
118
  const m = LOCAL_URL.exec(s)
@@ -118,21 +121,31 @@ export function parseTarget(input, cwd = os.homedir()) {
118
121
  let p = null
119
122
  // 标准的 file:// 地址交给 Node 转(Windows 上是 file:///C:/…,直接去掉前缀会变成 /C:/…)
120
123
  if (/^file:\/\//i.test(s)) try { p = fileURLToPath(s) } catch { /* file://~/… 之类的写法,按下面的老办法 */ }
124
+ const full = (x) => {
125
+ x = fromBashPath(x)
126
+ if (x === '~' || x.startsWith('~/') || x.startsWith('~\\')) x = path.join(os.homedir(), x.slice(1))
127
+ return path.resolve(cwd, x)
128
+ }
121
129
  if (p === null) {
122
- p = s.replace(/^file:\/\//i, '')
123
- if (p.includes('%')) {
124
- try { p = decodeURIComponent(p) } catch { /* 原样用 */ }
130
+ const raw = s.replace(/^file:\/\//i, '')
131
+ p = raw
132
+ // %20 之类按网址解开;但文件名里本来就带 %(「浏览…」选的、拖进来的都是原样路径)且原样存在时,别解
133
+ if (raw.includes('%') && !fs.existsSync(full(raw))) {
134
+ try { p = decodeURIComponent(raw) } catch { /* 原样用 */ }
125
135
  }
126
136
  }
127
- p = fromBashPath(p)
128
- if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) p = path.join(os.homedir(), p.slice(1))
129
- p = path.resolve(cwd, p)
137
+ p = full(p)
130
138
  let st
131
139
  try { st = fs.statSync(p) } catch { throw new Error(`找不到:${p}`) }
132
140
  if (st.isDirectory()) {
141
+ // 有 index.html 开它;没有就开最先做的那屏(设计目录一屏一个文件,画布上会把同目录的几屏都排出来)
133
142
  const index = path.join(p, 'index.html')
134
- if (!fs.existsSync(index)) throw new Error(`这个目录里没有 index.html:${p}`)
135
- p = index
143
+ const first = fs.existsSync(index) ? 'index.html' : listScreens(p)[0]
144
+ if (!first) {
145
+ try { fs.readdirSync(p) } catch (e) { throw new Error(`读不了这个文件夹(${e.code === 'EACCES' || e.code === 'EPERM' ? '没有权限' : e.code || e.message}):${p}`) }
146
+ throw new Error(`这个文件夹里没有 HTML 文件:${p}`)
147
+ }
148
+ p = path.join(p, first)
136
149
  } else if (!HTML_EXT.test(p)) {
137
150
  throw new Error(`只能预览 .html 文件:${p}`)
138
151
  }
package/lib/handoff.js CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import fs from 'node:fs'
13
+ import os from 'node:os'
13
14
  import path from 'node:path'
14
15
  import { pathToFileURL } from 'node:url'
15
16
  import { prepare } from './look.js'
@@ -28,24 +29,75 @@ export const FRAMEWORKS = {
28
29
  html: { label: '纯 HTML / CSS(不用框架)', create: '', router: '' },
29
30
  }
30
31
 
31
- 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'], ['vue', 'vue', 'Vue'], ['react', 'react', 'React']]
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']]
32
33
 
33
- /** 目录是不是个前端项目、用的什么:{ dir, pkg, name, framework, label, tailwind, ts, pm } */
34
+ // 文件夹里只有这些就算空的
35
+ const JUNK = /^\.(DS_Store|git|gitignore)$|^(Thumbs\.db|desktop\.ini)$/i
36
+
37
+ /** 文件夹里有没有正经东西(不存在 / 读不了算没有) */
38
+ function hasStuff(dir) {
39
+ try { return fs.readdirSync(dir).some((f) => !JUNK.test(f)) } catch { return false }
40
+ }
41
+
42
+ // 有这些就算网页入口(没有 package.json 的纯 HTML 站点也算个项目)
43
+ const HTML_ENTRIES = ['index.html', 'src/index.html', 'public/index.html', 'app/index.html']
44
+
45
+ /** workspaces 列表里有本目录以外的(只写 '.' 的不算 monorepo;'!' 开头是排除) */
46
+ const listsOthers = (xs) => xs.some((x) => typeof x === 'string' && !/^!|^\.\/?$/.test(x.trim()))
47
+
48
+ /** 读文本:去掉 BOM、换行统一成 \n(Windows 检出的文件常是 CRLF);读不了返回 null */
49
+ function readText(file) {
50
+ try { return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n') } catch { return null }
51
+ }
52
+
53
+ /** 像是 monorepo 根目录:package.json 的 workspaces、lerna / rush / Nx 集成式(根上没有 project.json)、pnpm-workspace.yaml 的 packages 里列了别的包(只用来提醒模型放进子包,不拿来改放哪) */
54
+ function monoRoot(dir, pkg) {
55
+ const ws = pkg && pkg.workspaces
56
+ const list = Array.isArray(ws) ? ws : ws && Array.isArray(ws.packages) ? ws.packages : null
57
+ if (list && listsOthers(list)) return true
58
+ const has = (f) => fs.existsSync(path.join(dir, f))
59
+ if (has('lerna.json') || has('rush.json') || (has('nx.json') && !has('project.json'))) return true
60
+ let y = readText(path.join(dir, 'pnpm-workspace.yaml'))
61
+ if (y === null) return false
62
+ y = y.replace(/#.*$/gm, '')
63
+ // packages: ['a/*', 'b'] 或者下面一行一个 - a/*
64
+ const m = y.match(/^packages[ \t]*:(.*(?:\n(?:[ \t]+.*|-.*|[ \t]*))*)/m)
65
+ return !!m && listsOthers([...m[1].matchAll(/['"]?([^'"\s,[\]-][^'"\s,[\]]*)['"]?/g)].map((x) => x[1]))
66
+ }
67
+
68
+ /**
69
+ * 目录是不是个项目、用的什么:{ dir, pkg, site(有 index.html 入口), empty(不存在或没正经东西), mono(像 monorepo 根目录), name, framework, label, tailwind, ts, pm }
70
+ * 后端、monorepo 根目录、只用 CDN 的纯 HTML 站点也都算项目:是不是前端、页面该放进哪个子包,交接说明里让写代码的模型看清结构自己定(猜不准)
71
+ */
34
72
  export function detectProject(dir) {
35
- const out = { dir, pkg: false, name: path.basename(dir || ''), framework: '', label: '', tailwind: false, ts: false, pm: 'npm' }
36
- let pkg
37
- try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) } catch { return out }
73
+ const out = { dir, pkg: false, site: false, empty: true, mono: false, name: path.basename(dir || ''), framework: '', label: '', tailwind: false, ts: false, pm: 'npm' }
74
+ out.site = !!dir && HTML_ENTRIES.some((f) => fs.existsSync(path.join(dir, f)))
75
+ 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
38
79
  out.pkg = true
39
80
  if (typeof pkg.name === 'string' && pkg.name) out.name = pkg.name
40
81
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
41
82
  const hit = FW_DEPS.find(([d]) => deps[d])
42
83
  if (hit) { out.framework = hit[1]; out.label = hit[2] + (hit[0] === 'react' && deps.vite ? ' + Vite' : hit[0] === 'vue' && deps.vite ? ' + Vite' : '') }
43
84
  out.tailwind = !!deps.tailwindcss
85
+ out.mono = monoRoot(dir, pkg)
44
86
  out.ts = !!deps.typescript || fs.existsSync(path.join(dir, 'tsconfig.json'))
45
87
  out.pm = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : fs.existsSync(path.join(dir, 'yarn.lock')) ? 'yarn' : fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock')) ? 'bun' : 'npm'
46
88
  return out
47
89
  }
48
90
 
91
+ /** 是个项目(不用往里面再套一层):有 package.json、index.html 入口或 .git(后端、monorepo 根目录、刚建只有 README 的仓库都算) */
92
+ function isProject(dir) {
93
+ return ['package.json', '.git', ...HTML_ENTRIES].some((f) => fs.existsSync(path.join(dir, f)))
94
+ }
95
+
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'
99
+ }
100
+
49
101
  /** 真实路径:不存在的部分接在最近一级存在的上级目录的真实路径后面(新建的目录也能按真实位置比) */
50
102
  export function realish(p) {
51
103
  let cur = path.resolve(p)
@@ -70,16 +122,56 @@ export function within(p, root) {
70
122
  }
71
123
 
72
124
  /**
73
- * 代码默认放哪:会话目录是个前端项目、又不在设计目录里 → 就放会话目录;
125
+ * 代码放进 dest 时算哪个项目:dest 自己就是项目就用它;不是、但已经在某个项目里面(比如选了它的 src/pages)→ 用往上最近的那个
126
+ * (往上找到家目录为止,家目录本身不算:有人在家目录放 package.json;还不存在的目录不往上找:那是要新建的项目)
127
+ * dest 里有 index.html 时:上面的项目认得出框架才算它的(Angular、webpack 项目的 src/index.html);
128
+ * 认不出的(Node 库仓库里的 docs/ 静态站点)还是当纯 HTML 站点
129
+ */
130
+ export function projectFor(dest) {
131
+ const own = detectProject(dest)
132
+ if (own.pkg || !fs.existsSync(dest)) return own
133
+ const home = fold(realish(os.homedir()))
134
+ 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
136
+ const p = detectProject(cur)
137
+ if (p.pkg) return own.site && !p.framework ? own : p
138
+ }
139
+ return own
140
+ }
141
+
142
+ /**
143
+ * 代码默认放哪:会话目录是个项目(有 package.json)、又不在设计目录里 → 就放会话目录;
74
144
  * 否则新建 <设计名>-app:设计目录在会话目录下就建在会话目录里,不然建在设计目录旁边(别塞进设计目录)
75
145
  */
76
146
  export function defaultDest(root, cwd, project) {
77
147
  const inside = !!root && within(cwd, root)
78
148
  if (project && project.pkg && !inside) return cwd
79
- const base = (root ? path.basename(root) : 'app').replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'app'
80
- const name = base === 'app' || base.endsWith('-app') ? `${base}-code` : `${base}-app`
81
149
  const under = !!root && !inside && within(root, cwd)
82
- return path.join(root && !under ? path.dirname(root) : cwd, name)
150
+ return path.join(root && !under ? path.dirname(root) : cwd, appDirName(root))
151
+ }
152
+
153
+ /** 新建的代码目录叫什么:<设计名>-app */
154
+ export function appDirName(root) {
155
+ const base = (root ? path.basename(root) : 'app').replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'app'
156
+ return base === 'app' || base.endsWith('-app') ? `${base}-code` : `${base}-app`
157
+ }
158
+
159
+ /**
160
+ * 用户在右侧栏选了个文件夹 → { dest, why }:
161
+ * 是个项目(有 package.json、index.html 或 .git,后端、monorepo 根目录也算)、在某个项目里面(比如它的 src/pages)、空文件夹、还不存在、
162
+ * 或者本来就叫 <设计名>-app(上回放过的)→ 就放这里(why 空);
163
+ * 别的有东西的文件夹(比如 ~/Projects)→ 在它里面新建 <设计名>-app(why 'nested');
164
+ * 选到设计目录里面了 → 放到设计目录旁边(why 'beside':设计稿要留着做对比,handoff_design 也不收设计目录里的地方)
165
+ */
166
+ export function pickedDest(dir, root) {
167
+ const name = appDirName(root)
168
+ const beside = { dest: path.join(path.dirname(root || dir), name), why: 'beside' }
169
+ if (root && within(dir, root)) return beside
170
+ let out = { dest: dir, why: '' }
171
+ if (!isProject(dir) && !projectFor(dir).pkg && path.basename(dir).toLowerCase() !== name) {
172
+ if (hasStuff(dir)) out = { dest: path.join(dir, name), why: 'nested' } // 还不存在 / 读不了 / 空的:就用它
173
+ }
174
+ return root && within(out.dest, root) ? beside : out
83
175
  }
84
176
 
85
177
  /** 文件名 → 建议路由:index / home → /,别的 /名字 */
@@ -193,7 +285,11 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
193
285
  lines.push(`> 给写代码的模型看:照这份把设计稿做成真页面。设计稿就是源码——每屏一个 HTML,样式在 tokens.css 等样式表里。先读源文件,截图只用来对照,别凭截图猜数值。`, '')
194
286
  lines.push('## 做什么', '')
195
287
  lines.push(`- 设计目录:\`${root}\`(${rows.length} 屏${more ? `,另有 ${more} 屏没列进来` : ''},${kind} ${size[0]}×${size[1]})`)
288
+ // 目录里已经有别的东西、又不是 Node 项目(别的语言的仓库、后端……):别在原地或旁边跑脚手架
289
+ // (纯 HTML 站点再做成纯 HTML 不算:就加在这个站点里,下面第 1 步提醒别覆盖现有页面)
290
+ const occupied = !!(project && !project.pkg && project.empty === false) && !(project.site && framework === 'html')
196
291
  if (project && project.pkg) lines.push(`- 做到:\`${dest || project.dir}\`(现有项目 ${project.name}:${project.label || '没认出框架'}${project.tailwind ? ' + Tailwind' : ''}${project.ts ? ',TypeScript' : ''},包管理 ${project.pm})`)
292
+ else if (occupied) lines.push(`- 做到:\`${dest}\`(目录里已经有别的东西,不是 Node 项目:先看清结构,前端放进它里面新的子目录)`)
197
293
  else if (fw && fw.create) lines.push(`- 做到:\`${dest}\`(新建项目:\`${fw.create.replace('{name}', path.basename(dest || name + '-app'))}\`,在 \`${path.dirname(dest || root)}\` 里跑)`)
198
294
  else lines.push(`- 做到:\`${dest}\``)
199
295
  lines.push(`- 框架:${fwLabel}`)
@@ -292,9 +388,21 @@ export function handoffMd({ root, rows, kind, size, framework, dest, project, sy
292
388
  lines.push(assets.length ? `- ${assets.slice(0, 30).map((a) => `\`${a}\``).join('、')}${assets.length > 30 ? ' …' : ''}:照原样复制进项目的静态资源目录(public/ 或 src/assets/)` : '- 没有单独的图片、字体文件(图形都写在 HTML / SVG 里)。', '')
293
389
 
294
390
  // 做法
295
- const create = !(project && project.pkg) && fw && fw.create ? fw.create.replace('{name}', path.basename(dest || name + '-app')) : ''
391
+ // 在认不出前端框架的项目(后端)或 monorepo 里选了个空的新文件夹:就在这里新建前端项目,不用再看结构、另套一层
392
+ const fresh = !!(project && project.pkg && (!project.framework || project.mono) && dest && fold(realish(dest)) !== fold(realish(project.dir)) && !hasStuff(dest))
393
+ // 「跟项目一样」新建子包时照项目的框架;装依赖用项目的包管理器(create-next-app 自己装依赖,换成对应的 --use-xxx)
394
+ // 只认得出是普通 React / Vue / Next.js 时才套现成的脚手架命令(Nuxt 也算 vue,但不能用 Vue + Vite 的命令搭)
395
+ const PLAIN = { react: 'React', vue: 'Vue', next: 'Next.js' }
396
+ const fwNew = fw || (fresh && same && PLAIN[project.framework] === String(project.label || '').replace(/ \+ Vite$/, '') ? FRAMEWORKS[project.framework] : null)
397
+ 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') : ''
296
399
  lines.push('## 做法和验收', '')
297
- lines.push(`1. 搭骨架:${create ? `新建项目 \`${create}\`,装好依赖${fw.router ? `和 ${fw.router}` : ''}` : framework === 'html' ? '整理成一个静态站点目录(每屏一个页面,公共样式一份)' : `在现有项目里加页面和路由(看清它现有的目录结构、样式写法、组件库再动手)`};把 tokens.css${sheets.length ? '(和公共样式表)' : ''}搬进去。`)
400
+ // 现有项目但没认出前端框架(后端、monorepo 根目录、别的语言的仓库)/ 像 monorepo 根目录:页面该放哪让模型看清结构再定,别直接往根目录塞
401
+ const bare = !create && framework !== 'html' && framework !== 'same' && !!(project && ((project.pkg && !project.framework) || occupied))
402
+ const sub = fw && fw.create ? fw.create.replace('{name}', 'web') : ''
403
+ const mono = !create && !fresh && !!(project && project.mono)
404
+ 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 ? '(和公共样式表)' : ''}搬进去。`)
298
406
  lines.push('2. 一屏一个页面,路由照上面的表;先抽几屏共用的组件,再拼页面。')
299
407
  lines.push('3. 结构、文案、数字、图标照设计稿的 HTML 搬;class 名可以改成项目的习惯,样式值(间距、字号、颜色、圆角、阴影)一律用设计变量。')
300
408
  lines.push('4. 每做完一屏:把开发服务器跑起来,调 `compare_design`(screen 填设计文件名,url 填这一屏在开发服务器上的地址),照差异图把不一样的地方改掉,直到差异在 3% 以内,或者剩下的差别说得出理由(比如真实数据、系统字体)。')
package/lib/index.js CHANGED
@@ -56,7 +56,7 @@ import { createBrowser } from './browser.js'
56
56
  import { designOf, metaOfFile, sizeOf, pickScreens, measureAll, shootAll, cropIssues, formatMeasure, issueSnapshot, formatShots, whereOf, MAX_SCREENS, MAX_SIZES, MAX_SHOTS, MAX_CROPS } from './look.js'
57
57
  import { SYSTEMS_DIR, extractSystem, listSystems, setDefault, skillSection } from './system.js'
58
58
  import { readTweaks, applyTweaks, lintTweaks } from './tweaks.js'
59
- import { FRAMEWORKS, detectProject, buildHandoff, compareScreens, implUrl, defaultDest, within } from './handoff.js'
59
+ import { FRAMEWORKS, detectProject, projectFor, defaultFramework, buildHandoff, compareScreens, implUrl, defaultDest, pickedDest, within } from './handoff.js'
60
60
 
61
61
  export const name = 'dsh-design-studio'
62
62
  export const inject = ['webServer']
@@ -440,10 +440,10 @@ export const handoffTool = {
440
440
  description:
441
441
  '把右侧「设计工作台」里的设计(本地 HTML 那几屏)整理成交接包,准备做成真项目的代码:写好 <设计目录>/handoff/HANDOFF.md(每屏的文件、建议路由、跳转、设计变量、几屏共用该抽成组件的部分、交互和状态、调节滑块、素材、做法和验收),和每屏从上到下的截图。' +
442
442
  '用户说「转成代码」「做成真的项目」「用 React / Vue 做出来」时先调它,读 HANDOFF.md 照着做;每做完一屏用 compare_design 和设计稿比一遍,差异降到 3% 以下再做下一屏。' +
443
- 'framework:same 跟目标目录现有项目一样(有 package.json 时默认)、react、vue、next、html;dest:代码放哪个目录(不填放在会话目录里新建)。要先用 preview_page 打开过设计。',
443
+ 'framework:same 跟目标目录现有项目一样(认得出框架时默认)、react、vue、next、html;dest:代码放哪个目录(不填:会话目录是项目就放进去,不是就新建 <设计名>-app)。要先用 preview_page 打开过设计。',
444
444
  parameters: specToJsonSchema({
445
- framework: { type: 'string', enum: ['same', ...Object.keys(FRAMEWORKS)], description: 'same 跟现有项目一样;react(React + Vite)、vue(Vue 3 + Vite)、next(Next.js)、html(纯 HTML / CSS)。不填:目标目录有项目就 same,没有就 react' },
446
- dest: { type: 'string', description: '代码放哪:现有项目的目录,或要新建的项目目录(绝对路径或相对会话目录)。不填:会话目录有项目就放进去,没有就在会话目录下新建' },
445
+ framework: { type: 'string', enum: ['same', ...Object.keys(FRAMEWORKS)], description: 'same 跟现有项目一样;react(React + Vite)、vue(Vue 3 + Vite)、next(Next.js)、html(纯 HTML / CSS)。不填:目标目录(或它所在的项目)认得出框架就 same,没有 package.json 的纯 HTML 站点 html,别的 react' },
446
+ dest: { type: 'string', description: '代码放哪:现有项目的目录(或它里面的子目录),或要新建的项目目录(绝对路径或相对会话目录)。不填:会话目录有 package.json 就放进去,没有就新建 <设计名>-app(设计目录在会话目录下就建在会话目录里,不然建在设计目录旁边)' },
447
447
  screens: { type: 'array', items: { type: 'string' }, description: '只转哪几屏(文件名);不填全部' },
448
448
  }),
449
449
  output: {
@@ -469,8 +469,8 @@ export const handoffTool = {
469
469
  // 先定下最终放哪(没给就和右侧栏建议的一样),再按真实路径查是不是在设计目录里
470
470
  const dest = typeof a.dest === 'string' && a.dest.trim() ? resolvePath(a.dest, cwd) : defaultDest(t.root, cwd, detectProject(cwd))
471
471
  if (within(dest, t.root)) return { text: `代码别放进设计目录(${t.root})里:设计稿留着做对比。换一个目录。` }
472
- const project = detectProject(dest)
473
- const framework = typeof a.framework === 'string' && (a.framework === 'same' || FRAMEWORKS[a.framework]) ? a.framework : project.pkg && project.framework ? 'same' : 'react'
472
+ const project = projectFor(dest)
473
+ const framework = typeof a.framework === 'string' && (a.framework === 'same' || FRAMEWORKS[a.framework]) ? a.framework : defaultFramework(project)
474
474
  if (framework === 'same' && !(project.pkg && project.framework)) return { text: `${dest} 里没有认得出的前端项目(package.json 里没有 React / Vue / Next …):framework 选 react、vue、next 或 html。` }
475
475
  let r
476
476
  try {
@@ -808,14 +808,19 @@ export function apply(ctx) {
808
808
  /** 转成代码前:会话目录是不是个前端项目(右侧栏默认选「跟项目一样」、填好放哪) */
809
809
  function projectRoute(req, res) {
810
810
  if (!admit(req, res)) return
811
- const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
811
+ const q = new URL(req.url, 'http://x').searchParams
812
+ const session = q.get('session') || ''
812
813
  const cwd = tracker.cwdOf(session)
813
814
  const p = detectProject(cwd)
814
815
  const t = tracker.stateOf(session).target
815
816
  const root = t && t.kind === 'file' ? t.root : ''
816
817
  // root 一起回去:右侧栏按它认这份建议是不是给现在这套设计的;project 按最终放的目录认(默认框架跟着它选)
817
- const dest = defaultDest(root, cwd, p)
818
- json(res, 200, { cwd, root, project: dest === cwd ? p : detectProject(dest), dest, frameworks: Object.fromEntries(Object.entries(FRAMEWORKS).map(([k, v]) => [k, v.label])) })
818
+ // 带 dest:用户刚选了个文件夹,按它定最终放哪
819
+ const picked = (q.get('dest') || '').trim()
820
+ const pk = picked ? pickedDest(resolvePath(picked, cwd), root) : null
821
+ const dest = pk ? pk.dest : defaultDest(root, cwd, p)
822
+ const project = projectFor(dest)
823
+ json(res, 200, { cwd, root, project, fw: defaultFramework(project), dest, why: pk ? pk.why : '', frameworks: Object.fromEntries(Object.entries(FRAMEWORKS).map(([k, v]) => [k, v.label])) })
819
824
  }
820
825
 
821
826
  async function checkRoute(req, res) {
@@ -1063,6 +1068,8 @@ export function apply(ctx) {
1063
1068
  const ext = imageExt(buf)
1064
1069
  if (!ext) return json(res, 400, { error: '不是图片' })
1065
1070
  const st = tracker.stateOf(String(body.session || ''))
1071
+ // 画笔标注带着画的那份设计的目录来:这期间换了设计,别把旧图存进新设计
1072
+ if (typeof body.root === 'string' && !(st.target && st.target.kind === 'file' && st.target.root === body.root)) return json(res, 409, { error: 'moved' })
1066
1073
  const dir = st.target && st.target.kind === 'file' ? path.join(st.target.root, 'refs') : REFS_DIR
1067
1074
  const base = String(body.name || 'ref').replace(/\.[a-z0-9]{2,5}$/i, '').replace(/[^\w\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'ref'
1068
1075
  try {