Files
superwork/vscode/src/panel/handlers/profileAssets.ts
T
cruldra cace658ac1 ✨ feat(vscode): profile 值编辑器改为多行+图片粘贴(图片落 .spx/profile-assets)
双击 profile 值单元格改为打开富编辑弹窗:多行 textarea + Ctrl+V 粘贴截图,
⌘/Ctrl+Enter 或点遮罩保存、Esc 取消。值仍是单个字符串,图片存磁盘
(内容寻址 sha1 命名,去重),值里只留 markdown 引用,不撑大 profiles.json。
折叠单元格显示首行 + 含图时加 🖼 徽标。key/列头仍走原内联输入。

- 抽公用 lib/imagePaste:同步探测(preventDefault) + 异步读取,新建工单改用它、行为不变
- 新增扩展侧 profileAssets handler:存图(image/* 校验)/读图(挡路径穿越)
- 新增 useProfileAssets(requestId 关联并发) 与 ProfileCellEditor 弹窗
- 消息两侧同步 profile-asset/save|load|saved|loaded
2026-07-03 16:55:40 +08:00

112 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* profile 值编辑器粘贴图片的磁盘存取。
*
* 图片落到 `<workspace>/.spx/profile-assets/<sha1前16>.<ext>`,profile 值里只留
* markdown 引用 `![](.spx/profile-assets/xxx.png)`——把 base64 塞进 profiles.json
* 会把配置撑成几百 KB 且不可读,故只存引用、内容单独落盘。
*
* 内容寻址(sha1 前 16 位命名)让同一张图重复粘贴自动去重、复用同一文件。
* 移除引用只改 profile 值字符串,磁盘孤儿文件本期不做回收(低频、无害)。
*/
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { createHash } from 'node:crypto'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
import { workspace } from 'vscode'
/** 相对工作区根的资源目录;也是路径穿越校验的白名单根。 */
const ASSET_DIR = '.spx/profile-assets'
/** mediaType → 落盘扩展名;未知一律按 png(前面已保证是 image/*)。 */
function extForMedia(mediaType: string): string {
switch (mediaType) {
case 'image/jpeg': return 'jpg'
case 'image/gif': return 'gif'
case 'image/webp': return 'webp'
case 'image/svg+xml': return 'svg'
case 'image/png': default: return 'png'
}
}
/** 扩展名 → mediaType,用于读盘时拼 data URL;未知按 png。 */
function mediaForExt(ext: string): string {
switch (ext.toLowerCase()) {
case 'jpg': case 'jpeg': return 'image/jpeg'
case 'gif': return 'image/gif'
case 'webp': return 'image/webp'
case 'svg': return 'image/svg+xml'
case 'png': default: return 'image/png'
}
}
/**
* 把 base64 图片写盘并回传其工作区相对路径。非图片 mediaType 直接拒绝,避免被
* 当成任意文件写入通道。写盘失败兜底回 error,让编辑器提示而非静默丢图。
*/
export async function handleSaveProfileAsset(
panel: KanbanWebviewPanel,
args: { requestId: string, base64: string, mediaType: string },
): Promise<void> {
try {
const root = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!root) {
panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, error: '未打开工作区' })
return
}
if (!args.mediaType.startsWith('image/')) {
panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, error: '仅支持图片' })
return
}
const hash = createHash('sha1').update(args.base64).digest('hex').slice(0, 16)
const rel = `${ASSET_DIR}/${hash}.${extForMedia(args.mediaType)}`
const abs = path.join(root, rel)
await fsp.mkdir(path.dirname(abs), { recursive: true })
await fsp.writeFile(abs, Buffer.from(args.base64, 'base64'))
panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, assetPath: rel })
}
catch (err) {
panel.postMessage({
type: 'profile-asset/saved',
requestId: args.requestId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 读回资源文件并拼成 data URL 供编辑器渲染缩略图。
*
* 安全:assetPath 规范化后必须仍落在 `<root>/.spx/profile-assets/` 内,挡 `..`
* 路径穿越——这个路径来自 profile 值字符串,用户可编辑,不能信。
*/
export async function handleLoadProfileAsset(
panel: KanbanWebviewPanel,
args: { requestId: string, assetPath: string },
): Promise<void> {
try {
const root = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!root) {
panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, error: '未打开工作区' })
return
}
const baseDir = path.resolve(root, ASSET_DIR)
const abs = path.resolve(root, args.assetPath)
if (abs !== baseDir && !abs.startsWith(baseDir + path.sep)) {
panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, error: '非法资源路径' })
return
}
const buf = await fsp.readFile(abs)
const ext = path.extname(abs).slice(1)
const dataUrl = `data:${mediaForExt(ext)};base64,${buf.toString('base64')}`
panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, dataUrl })
}
catch (err) {
panel.postMessage({
type: 'profile-asset/loaded',
requestId: args.requestId,
error: err instanceof Error ? err.message : String(err),
})
}
}