表单加「帮我填」,图标一格不再手填
- 表单标题行加「帮我填」:先把用户手上没保存的输入落盘(落盘就是这次自动保存),再把改注册表的指令交给同一个项目会话,改完由每 2 秒的自动重读带回界面; - 指令正文只给整份 `.export/xhs-minitool.json` 的形状示意,值的位置写每一项的要求;不重复当前值、不写项目根——agent 自己会读文件、本来就工作在项目根里; - 示意类型钉在 ts-rs 生成的注册表类型上,`XHSMiniToolExportRegistry` 补 ts-rs 导出:Rust 侧加键、改名,前端这份示意会在 typecheck 里当场对不上; - 图标一格去掉手填入口与空态提示,路径不外露,左边缩略图放大到 24,没选图时不摆一行废话; - 新增 `ICON_IMPORTER_SETTINGS`:本地项目与云端素材库都能选,单张、png/jpg/jpeg、≤5 MiB,与宿主 `validate_icon` 对齐。
This commit is contained in:
@@ -36,8 +36,12 @@ impl XHSMiniToolExportForm {
|
||||
///
|
||||
/// 只有 `form` 一个键——没有 status、没有已适配标记、没有 revision,那些要么是 `package.json`
|
||||
/// 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
///
|
||||
/// 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它
|
||||
/// 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
|
||||
pub(crate) struct XHSMiniToolExportRegistry {
|
||||
pub(crate) form: XHSMiniToolExportForm,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ import type {
|
||||
|
||||
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp'] as const;
|
||||
/**
|
||||
* 平台图标(小红书上传页等)只认 png/jpg/jpeg,webp 会被平台挡回来,所以这里的白名单比
|
||||
* 通用图片素材窄一档,上限也按平台口径收到 5 MiB。
|
||||
*/
|
||||
const ICON_MEDIA_TYPES = ['image/png', 'image/jpeg'] as const;
|
||||
const ICON_EXTENSIONS = ['png', 'jpg', 'jpeg'] as const;
|
||||
const ICON_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const FONT_MEDIA_TYPES = [
|
||||
'font/ttf',
|
||||
'font/otf',
|
||||
@@ -37,6 +44,25 @@ function imageRemoteTypeFilter(asset: RemoteAssetCandidate) {
|
||||
);
|
||||
}
|
||||
|
||||
function iconLocalTypeFilter(asset: LocalAssetCandidate) {
|
||||
return (
|
||||
ICON_MEDIA_TYPES.includes(
|
||||
asset.mediaType.toLowerCase() as (typeof ICON_MEDIA_TYPES)[number],
|
||||
) || /\.(png|jpe?g)$/iu.test(asset.localPath)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 云端素材只有 objectKey / 名称这几个线索。看得出扩展名的按图标白名单筛;看不出扩展名的
|
||||
* 放过去让宿主定夺——这里不是拒绝的唯一一道门。
|
||||
*/
|
||||
function iconRemoteTypeFilter(asset: RemoteAssetCandidate) {
|
||||
if (!imageRemoteTypeFilter(asset)) return false;
|
||||
const key = asset.objectKey || asset.name;
|
||||
if (!/\.[a-z0-9]+$/iu.test(key)) return true;
|
||||
return /\.(png|jpe?g)$/iu.test(key);
|
||||
}
|
||||
|
||||
function fontLocalTypeFilter(asset: LocalAssetCandidate) {
|
||||
return (
|
||||
FONT_MEDIA_TYPES.includes(
|
||||
@@ -101,6 +127,42 @@ export const DESIGN_IMAGE_IMPORTER_SETTINGS: AssetImporterSettings =
|
||||
export const SPRITE_IMPORTER_SETTINGS: AssetImporterSettings =
|
||||
imageImporterSettings(imageRequirements);
|
||||
|
||||
/**
|
||||
* 平台图标(小红书小工具等):本地项目与云端素材库两个来源都要能选,单张、png/jpg/jpeg、≤5 MiB。
|
||||
* 上限与白名单跟宿主 `validate_icon` 对齐,免得选完了保存时才被宿主的硬校验挡回来。
|
||||
*/
|
||||
export const ICON_IMPORTER_SETTINGS: AssetImporterSettings = {
|
||||
title: '选择图标',
|
||||
ariaLabel: '选择图标',
|
||||
icon: createElement(ImageIcon, { size: 16 }),
|
||||
preview: ImageImporterPreview,
|
||||
local: {
|
||||
label: '本地项目',
|
||||
typeFilter: iconLocalTypeFilter,
|
||||
discoverUnregistered: true,
|
||||
fileDialog: {
|
||||
title: '选择图标',
|
||||
filters: [{ name: 'Images', extensions: [...ICON_EXTENSIONS] }],
|
||||
},
|
||||
requirements: { maxItems: 1, maxFileSizeBytes: ICON_MAX_BYTES },
|
||||
localPolicy: {
|
||||
destination: 'assets/uploads',
|
||||
acceptedMediaTypes: ICON_MEDIA_TYPES,
|
||||
acceptedExtensions: ICON_EXTENSIONS,
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
label: '云端素材库',
|
||||
typeFilter: iconRemoteTypeFilter,
|
||||
requirements: { maxItems: 1, maxFileSizeBytes: ICON_MAX_BYTES },
|
||||
remotePolicy: {
|
||||
destination: 'assets/uploads',
|
||||
acceptedMediaTypes: ICON_MEDIA_TYPES,
|
||||
acceptedExtensions: ICON_EXTENSIONS,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FONT_IMPORTER_SETTINGS: AssetImporterSettings = {
|
||||
title: '导入字体',
|
||||
ariaLabel: '导入字体',
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { XHSMiniToolExportForm } from "./XHSMiniToolExportForm";
|
||||
|
||||
/**
|
||||
* 注册表文件形状:`.export/xhs-minitool.json`。
|
||||
*
|
||||
* 只有 `form` 一个键——没有 status、没有已适配标记、没有 revision,那些要么是 `package.json`
|
||||
* 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。
|
||||
*
|
||||
* 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它
|
||||
* 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。
|
||||
*/
|
||||
export type XHSMiniToolExportRegistry = { form: XHSMiniToolExportForm, };
|
||||
+15
-10
@@ -9,39 +9,44 @@ import type { XHSMiniToolExportField } from '../generated/XHSMiniToolExportField
|
||||
export type XhsMinitoolFieldMeta = {
|
||||
field: XHSMiniToolExportField;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
/** 输入框的占位提示。图标不给输入框,所以这一项只有文本字段有。 */
|
||||
placeholder?: string;
|
||||
/** 宿主硬校验的字数上限;icon 不是文本字段,没有这一项。 */
|
||||
maxChars?: number;
|
||||
multiline: boolean;
|
||||
/** 是否给一颗「复制」按钮。图标是文件、不是要粘贴到平台的文字,复制路径没有意义。 */
|
||||
copyable: boolean;
|
||||
};
|
||||
|
||||
/** 表单展示顺序。加字段时只改这里,保存与复制都按字段身份走。 */
|
||||
export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [
|
||||
{
|
||||
field: 'name',
|
||||
label: '名称',
|
||||
placeholder: '最多 14 个字',
|
||||
label: '小工具名称',
|
||||
placeholder: '小红书里显示的名字,最多 14 个字',
|
||||
maxChars: 14,
|
||||
multiline: false,
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
field: 'introduction',
|
||||
label: '简介',
|
||||
placeholder: '最多 14 个字',
|
||||
label: '一句话介绍',
|
||||
placeholder: '小红书里显示的一句话,最多 14 个字',
|
||||
maxChars: 14,
|
||||
multiline: true,
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
field: 'iconPath',
|
||||
label: 'icon 文件',
|
||||
placeholder: '项目内相对路径,例如 assets/icon.png',
|
||||
label: '图标',
|
||||
multiline: false,
|
||||
copyable: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const XHS_MINITOOL_FIELD_LABELS: Record<XHSMiniToolExportField, string> =
|
||||
{
|
||||
name: '名称',
|
||||
introduction: '简介',
|
||||
iconPath: 'icon 文件',
|
||||
name: '小工具名称',
|
||||
introduction: '一句话介绍',
|
||||
iconPath: '图标',
|
||||
};
|
||||
|
||||
+64
-89
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 交给 code agent 的指令正文:首次适配,以及按失败现场修复。
|
||||
* 交给 code agent 的指令正文:首次适配、按失败现场修复、按项目内容帮忙填表。
|
||||
*
|
||||
* 纯函数 + 契约常量:宿主只认「npm 脚本名 + 产物路径 + 注册表形状」,其余(用哪版 vite 配置、
|
||||
* 中间目录叫什么、怎么调 pack.mjs)都留给 agent 在用户项目里实验。
|
||||
@@ -7,10 +7,10 @@
|
||||
* 契约常量是**唯一一份**,正文里一处都不许再写死:写死一处就是多一份副本,改了常量而漏改正文时
|
||||
* 指令会把 agent 领到错路径上。这里是前端这一侧的单一来源,它们的值由
|
||||
* `tests/xhsMinitoolContract.test.ts` 对着 Rust 权威 `src-tauri/src/export/draft/xhs_minitool/layout.rs`
|
||||
* 逐条钉住——所以「值对不对」由那条契约用例负责,「正文有没有用上」由指令用例负责。
|
||||
* 逐条钉住。
|
||||
*/
|
||||
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
|
||||
import { describeXhsMinitoolFailure } from './xhsMinitoolFailure';
|
||||
import type { XHSMiniToolExportRegistry } from '../generated/XHSMiniToolExportRegistry';
|
||||
import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure';
|
||||
|
||||
/** 目标名,与 `model.ts` 的 `TARGET_META` 人话一致。 */
|
||||
export const XHS_MINITOOL_TARGET_LABEL = '小红书小工具';
|
||||
@@ -23,99 +23,74 @@ export const XHS_MINITOOL_ARTIFACT_RELATIVE_PATH = `${XHS_MINITOOL_EXPORT_DIR_RE
|
||||
/** 注册表:宿主读写,形状固定。 */
|
||||
export const XHS_MINITOOL_REGISTRY_RELATIVE_PATH = `${XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH}/xhs-minitool.json`;
|
||||
/** 首次适配要读的 skill 名。 */
|
||||
export const XHS_MINITOOL_SKILL_NAME = 'vite-export-xhs-minitool';
|
||||
|
||||
/** 注册表的规范形状:agent 可以填值,但不许增删键。 */
|
||||
export const XHS_MINITOOL_REGISTRY_SHAPE =
|
||||
'{"form":{"name":"","introduction":"","iconPath":""}}';
|
||||
|
||||
/** 打包脚本落点:适配结论就写在它顶部注释里,修复指令要让 agent 找得到那份上下文。 */
|
||||
export const XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH = `${XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH}/xhs-minitool.pack.mjs`;
|
||||
/** 校验脚本落点。 */
|
||||
export const XHS_MINITOOL_VALIDATE_SCRIPT_RELATIVE_PATH = `${XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH}/xhs-minitool.validate.mjs`;
|
||||
/** vite 配置示例落点。 */
|
||||
export const XHS_MINITOOL_VITE_CONFIG_RELATIVE_PATH = `${XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH}/xhs-minitool.vite.config.mjs`;
|
||||
|
||||
/** skill 示例脚本 → 用户项目里的落点:拷贝是显式要求,落点由宿主约定。 */
|
||||
export const XHS_MINITOOL_COPIED_SCRIPTS: ReadonlyArray<{
|
||||
from: string;
|
||||
to: string;
|
||||
}> = [
|
||||
{ from: 'scripts/pack.mjs', to: XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH },
|
||||
{
|
||||
from: 'scripts/validate.mjs',
|
||||
to: XHS_MINITOOL_VALIDATE_SCRIPT_RELATIVE_PATH,
|
||||
},
|
||||
{
|
||||
from: 'scripts/vite.config.xhs-minitool.mjs',
|
||||
to: XHS_MINITOOL_VITE_CONFIG_RELATIVE_PATH,
|
||||
},
|
||||
];
|
||||
export const XHS_MINITOOL_PACKAGE_SKILL_NAME = 'vite-export-xhs-minitool';
|
||||
|
||||
/**
|
||||
* 首次适配指令。
|
||||
* **整份注册表文件**的形状示意:值的位置放的是那一项的要求,给 agent 读,不是给用户看的文案。
|
||||
*
|
||||
* 三条硬要求按「宿主会拒绝什么」排:脚本名、产物路径、注册表形状;其余是做法与坑。
|
||||
* 结尾要求把结论写回脚本顶部注释——下次改动靠它,不靠聊天记录。
|
||||
* 三条要求各写各的,一个字都不共享:这是给 agent 看的约束,每一项都要能单独读懂——共用一段拼出来
|
||||
* 的句子,改一项就顺手动到另一项,读的人还得回代码里追这句到底约束谁。
|
||||
*
|
||||
* agent 改的是那一份文件,所以示意也得是整份文件(`{ form: {...} }`),不能只给它里面那一层。
|
||||
* 类型钉在 ts-rs 生成的 `XHSMiniToolExportRegistry` 上(Rust 权威是 `dto.rs` 的同名结构):那边
|
||||
* 加键、改名,这里会因为缺字段或类型不符在 `typecheck` 里当场报出来——示意与 DTO 不会各走各的。
|
||||
*/
|
||||
export function buildXhsMinitoolAdaptationInstruction(
|
||||
projectPath: string,
|
||||
): string {
|
||||
const copied = XHS_MINITOOL_COPIED_SCRIPTS.map(
|
||||
(entry) => ` - ${entry.from} → ${entry.to}`,
|
||||
).join('\n');
|
||||
return [
|
||||
`【任务】把当前项目适配成能产出「${XHS_MINITOOL_TARGET_LABEL}」制品的项目,并真的跑通一次。`,
|
||||
'',
|
||||
`项目根:${projectPath}`,
|
||||
'',
|
||||
'宿主按下面三条硬契约校验,请逐条满足(做不到就直接说明卡在哪一条,不要糊一个假产物):',
|
||||
`1. npm 包里声明脚本 \`${XHS_MINITOOL_SCRIPT_NAME}\`。npm 包根 = 声明 package.json 的那个目录(通常是 game/)。`,
|
||||
`2. 跑完这个脚本,\`${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\` 必须存在且非空:这个路径相对**项目根**,而 npm 包根通常是 game/ 子目录,所以落点参数要写成 \`../${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\`(相对路径按脚本的工作目录解析)。`,
|
||||
`3. \`${XHS_MINITOOL_REGISTRY_RELATIVE_PATH}\` 是宿主的注册表,形状只能是 ${XHS_MINITOOL_REGISTRY_SHAPE}:`,
|
||||
' 三个字段都是字符串,不要增删键、不要加状态字段。可以按产物实际情况填 name 与 introduction;',
|
||||
' iconPath 是项目内相对路径,留空由用户在面板里选文件。',
|
||||
'',
|
||||
`读 \`${XHS_MINITOOL_SKILL_NAME}\` skill,并把它的示例脚本**显式拷贝**到 \`${XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH}/\` 下(这份拷贝属于用户项目,允许你按本项目情况改路径、入口与依赖):`,
|
||||
copied,
|
||||
'',
|
||||
'两个必须避开的坑:',
|
||||
'- 打包脚本的 `--out-dir` 只允许指向 vite 的构建输出目录:它会就地删掉该目录里白名单之外的扩展名文件。指向源码目录会删掉用户的代码。',
|
||||
`- \`${XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH}/\` 不进项目快照:换机或恢复版本后适配成果不跟随,需要重新适配。这不用你处理,知道即可。`,
|
||||
'',
|
||||
'最后:在 npm 包根跑一次 `npm run ' +
|
||||
XHS_MINITOOL_SCRIPT_NAME +
|
||||
'`,确认 zip 真的产出;',
|
||||
`然后把这次适配的结论追加到 \`${XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH}\` 顶部的注释里(不超过 20 行:`,
|
||||
'用了哪些文件、zip 里是什么、下次要改什么、哪些只是权宜之计)。那份注释是给下次的你和用户看的。',
|
||||
].join('\n');
|
||||
const XHS_MINITOOL_REGISTRY_SHAPE_HINT: XHSMiniToolExportRegistry = {
|
||||
form: {
|
||||
name: '小工具名称,最多 14 个字,写用户在小红书里看到的名字',
|
||||
introduction: '一句话介绍,最多 14 个字,说清这个小工具是做什么的',
|
||||
iconPath:
|
||||
'项目里真实存在的图片文件路径(相对项目根),优先用 assets/ 下已有的图',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 首次适配(bootstrap)指令:项目里还没有打包脚本时走这条。
|
||||
*
|
||||
* 不要项目根:agent 就工作在项目根里,写它是废话。
|
||||
*/
|
||||
export function buildXhsMinitoolAdaptationInstruction(): string {
|
||||
return `阅读 skill ${XHS_MINITOOL_PACKAGE_SKILL_NAME},把当前项目适配到${XHS_MINITOOL_TARGET_LABEL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按失败现场修复的指令。
|
||||
* 按失败现场修复(error fix)指令:项目已有打包脚本、但构建 / 产物 / 注册表出错时走这条。
|
||||
*
|
||||
* 只带宿主的失败事实与同一份契约,不猜原因:agent 自己能跑脚本、能读日志。
|
||||
* 与 bootstrap 的分工:这条**不重讲一遍适配**,而是把宿主报的原始错误摆在最前面,要求 agent 先
|
||||
* 复现同一个失败再定位修掉。没有脚本(`buildScriptMissing`)时不要走这里——那是首次适配,走
|
||||
* [buildXhsMinitoolAdaptationInstruction]。
|
||||
*
|
||||
* 现场就是宿主那个结构化错误本身:这里只 `JSON.stringify`,不翻译、不摘要、不另抽一份「给 agent 的
|
||||
* 说法」。用户侧的句子由 `xhsMinitoolFailure.ts` 独家负责,两边不共享文案。也不要项目根:agent 就
|
||||
* 工作在项目根里。
|
||||
*/
|
||||
export function buildXhsMinitoolRepairInstruction(
|
||||
projectPath: string,
|
||||
error: XHSMiniToolExportError,
|
||||
export function buildXhsMinitoolErrorFixInstruction(
|
||||
error: XhsMinitoolCardFailure,
|
||||
): string {
|
||||
const notice = describeXhsMinitoolFailure(error);
|
||||
return [
|
||||
`【任务】修复「${XHS_MINITOOL_TARGET_LABEL}」导出失败,修到跑通为止。`,
|
||||
'',
|
||||
`项目根:${projectPath}`,
|
||||
`宿主刚刚报的失败:${notice.message}`,
|
||||
...(notice.detail ? [``, '宿主给的现场:', notice.detail] : []),
|
||||
'',
|
||||
'同一份硬契约(宿主会按它校验):',
|
||||
`1. npm 包根(声明 package.json 的目录,通常是 game/)里声明脚本 \`${XHS_MINITOOL_SCRIPT_NAME}\`。`,
|
||||
`2. 跑完它,\`${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\` 必须存在且非空;它相对项目根,包根在 game/ 时落点要写成 \`../${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\`。`,
|
||||
`3. 打包装脚本的 \`--out-dir\` 只能指向 vite 构建输出目录,绝不能指向源码目录。`,
|
||||
'',
|
||||
`上下文:适配说明与上次的结论在 \`${XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH}\` 顶部注释里,示例脚本来自 \`${XHS_MINITOOL_SKILL_NAME}\` skill。`,
|
||||
'',
|
||||
`请直接动手:在 npm 包根跑 \`npm run ${XHS_MINITOOL_SCRIPT_NAME}\` 复现 → 改脚本 → 再跑通;`,
|
||||
`把这次的修复结论追加到 \`${XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH}\` 顶部注释(不超过 20 行)。`,
|
||||
].join('\n');
|
||||
return `
|
||||
error: ${JSON.stringify(error)}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「帮我填」指令:让 agent **直接改注册表文件**把表单三项填好。
|
||||
*
|
||||
* 表单不在聊天里,它是 `.export/xhs-minitool.json` 里的一个对象:agent 改这个文件,宿主的自动重读
|
||||
* (每 2 秒一次)把新值带回界面;用户改字则由自动保存写回同一个文件。两边只通过这一份文件同步,
|
||||
* 所以这条指令的落点必须是文件——写成「你把值告诉我、我来填」就断了这条链。
|
||||
*
|
||||
* 当前值不在正文里重复:调用方(`fillForm`)已经先把用户手上的输入落盘,agent 自己读那份文件就是
|
||||
* 最新的。项目根也不写:agent 就工作在项目根里,写了是废话。正文只给**整份文件**的形状与限制——
|
||||
* 它要改的是文件,不是聊天里的一段值。
|
||||
*/
|
||||
export function buildXhsMinitoolFormFillInstruction(): string {
|
||||
return `
|
||||
帮我填写用于发布 ${XHS_MINITOOL_TARGET_LABEL} 的表单 ${XHS_MINITOOL_REGISTRY_RELATIVE_PATH}。
|
||||
|
||||
* 直接修改文件,改完不必重述.
|
||||
* 结构和限制:
|
||||
${JSON.stringify(XHS_MINITOOL_REGISTRY_SHAPE_HINT, null, 2)},
|
||||
|
||||
* 对于图标示例图等字段可以看看目前是否已经有合适的, 没有的话可以(征询用户意见后)生图/留给用户自己选择
|
||||
`;
|
||||
}
|
||||
|
||||
+199
-72
@@ -1,76 +1,115 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Images, Sparkles } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { AssetImporter } from '../../../../../components/AssetImporter';
|
||||
import { ICON_IMPORTER_SETTINGS } from '../../../../../components/AssetImporter/settings';
|
||||
import type { FormInvalid } from '../../generated/FormInvalid';
|
||||
import type { XHSMiniToolExportForm } from '../../generated/XHSMiniToolExportForm';
|
||||
import { XHS_MINITOOL_FORM_FIELDS } from '../../state/xhsMinitoolFields';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { IconPreview } from './IconPreview';
|
||||
import { PaneButton } from './PaneButton';
|
||||
|
||||
/**
|
||||
* 平台上传表单。三个字段都能复制,icon 行多一颗「下载文件」。
|
||||
* 小红书上传表单。名称与介绍可复制(它们是要粘贴到上传页的文字),图标只给「下载」——复制一条
|
||||
* 项目内路径没有意义。
|
||||
*
|
||||
* 表单只收集值:校验在宿主(保存时硬校验),这里的红字是宿主回话的投影,不是前端自己判的。
|
||||
* 文案一律说「上传到小红书要填什么」,不暴露脚本名、注册表与 zip 这些实现词。
|
||||
*
|
||||
* 版式对齐发布面板(`GameDistributionPublishPanel`):标签在上、输入独占整行、字段之间留够呼吸,
|
||||
* 焦点用暖色描边而不是浏览器默认蓝框。复制与字数计数放在标签行,避免挤压输入宽度。图标一格左边
|
||||
* 是缩略图、右边是文件名与「选择图标 / 下载」:**图标位置没有输入框**,唯一入口是选择器。
|
||||
*/
|
||||
export function FormCard({
|
||||
form,
|
||||
fieldError,
|
||||
projectPath,
|
||||
onChange,
|
||||
onDownloadIcon,
|
||||
onAskAgentToFill,
|
||||
}: {
|
||||
form: XHSMiniToolExportForm;
|
||||
fieldError: FormInvalid | null;
|
||||
projectPath: string | null;
|
||||
onChange: (field: keyof XHSMiniToolExportForm, value: string) => void;
|
||||
onDownloadIcon: () => void;
|
||||
onAskAgentToFill: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-xl border border-[#e2cbb8] bg-[#fffaf5] p-3"
|
||||
aria-label="平台上传表单"
|
||||
className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4"
|
||||
aria-label="上传到小红书时要填的信息"
|
||||
>
|
||||
<h3 className="mb-2 text-sm font-semibold text-[#3d1f10]">
|
||||
平台上传表单
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-[#988476]">
|
||||
这三个值不进 zip:打包脚本不读它们。复制到小红书上传页填一次即可。
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{XHS_MINITOOL_FORM_FIELDS.map((meta) => (
|
||||
<FieldRow
|
||||
key={meta.field}
|
||||
label={meta.label}
|
||||
placeholder={meta.placeholder}
|
||||
maxChars={meta.maxChars}
|
||||
multiline={meta.multiline}
|
||||
value={form[meta.field]}
|
||||
error={fieldError?.field === meta.field ? fieldError.reason : null}
|
||||
action={
|
||||
meta.field === 'iconPath' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下载 icon 文件"
|
||||
title="下载 icon 文件"
|
||||
disabled={!form.iconPath.trim()}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-[#e2cbb8] bg-[#fffaf5] px-2 py-1 text-xs text-[#6f5848] transition hover:border-[#c7653d] hover:text-[#c7653d] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={onDownloadIcon}
|
||||
>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
<span>下载</span>
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
onChange={(value) => onChange(meta.field, value)}
|
||||
/>
|
||||
))}
|
||||
{/*
|
||||
「帮我填」放在标题行:这三项的内容要看项目(叫什么名、做什么用、图标在哪),陶泥儿比用户更
|
||||
清楚,所以给一个当场叫它的入口。它走的是文件而不是聊天——指令让 agent 直接改注册表,宿主
|
||||
的自动重读再把值带回来,用户自己打的字先由自动保存落盘。
|
||||
*/}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-sm font-bold text-[#3d1f10]">
|
||||
上传到小红书时要填的信息
|
||||
</h3>
|
||||
<PaneButton
|
||||
variant="ghost"
|
||||
ariaLabel="帮我填"
|
||||
title="让陶泥儿读完项目后把这三项填好;你已经填好的字保留"
|
||||
disabled={!projectPath}
|
||||
onClick={onAskAgentToFill}
|
||||
>
|
||||
<Sparkles size={13} aria-hidden="true" />
|
||||
<span>帮我填</span>
|
||||
</PaneButton>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4">
|
||||
{XHS_MINITOOL_FORM_FIELDS.map((meta) => {
|
||||
const error =
|
||||
fieldError?.field === meta.field ? fieldError.reason : null;
|
||||
if (meta.field === 'iconPath') {
|
||||
return (
|
||||
<IconField
|
||||
key={meta.field}
|
||||
value={form.iconPath}
|
||||
error={error}
|
||||
projectPath={projectPath}
|
||||
onChange={(value) => onChange('iconPath', value)}
|
||||
onDownloadIcon={onDownloadIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FieldRow
|
||||
key={meta.field}
|
||||
label={meta.label}
|
||||
placeholder={meta.placeholder ?? ''}
|
||||
maxChars={meta.maxChars}
|
||||
multiline={meta.multiline}
|
||||
copyable={meta.copyable}
|
||||
value={form[meta.field]}
|
||||
error={error}
|
||||
onChange={(value) => onChange(meta.field, value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表单控件的公共皮:与发布面板的输入同宽、同圆角、同暖色焦点环。 */
|
||||
function fieldInputClass(error: string | null) {
|
||||
return `box-border w-full rounded-[10px] border bg-white/80 px-3 py-2.5 text-sm leading-[1.45] text-[#3d1f10] outline-none transition placeholder:text-[#b9a494] focus-visible:border-[#c7653d] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#c7653d]/25 ${
|
||||
error ? 'border-[#c7653d]' : 'border-[#e2cbb8]'
|
||||
}`;
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
label,
|
||||
placeholder,
|
||||
maxChars,
|
||||
multiline,
|
||||
copyable,
|
||||
value,
|
||||
error,
|
||||
action,
|
||||
@@ -80,54 +119,142 @@ function FieldRow({
|
||||
placeholder: string;
|
||||
maxChars?: number;
|
||||
multiline: boolean;
|
||||
copyable: boolean;
|
||||
value: string;
|
||||
error: string | null;
|
||||
action?: ReactNode;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const used = value.trim().length;
|
||||
const overLimit = maxChars !== undefined && used > maxChars;
|
||||
const inputClass = `min-w-0 flex-1 rounded-lg border bg-white px-2 py-1.5 text-sm text-[#3d1f10] outline-none transition focus:border-[#c7653d] ${
|
||||
error ? 'border-[#c7653d]' : 'border-[#e2cbb8]'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-[#6f5848]">
|
||||
<span>{label}</span>
|
||||
{maxChars !== undefined ? (
|
||||
<span className={overLimit ? 'text-[#c7653d]' : 'text-[#988476]'}>
|
||||
{used}/{maxChars}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
{multiline ? (
|
||||
<textarea
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
rows={2}
|
||||
className={`${inputClass} resize-none`}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
className={inputClass}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<CopyButton value={value} label={label} />
|
||||
{action}
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label htmlFor={id} className="text-xs font-bold text-[#3d1f10]">
|
||||
{label}
|
||||
</label>
|
||||
<span className="flex items-center gap-2">
|
||||
{maxChars !== undefined ? (
|
||||
<span
|
||||
className={`text-[11px] font-medium tabular-nums ${
|
||||
overLimit ? 'text-[#c7653d]' : 'text-[#988476]'
|
||||
}`}
|
||||
>
|
||||
{used}/{maxChars}
|
||||
</span>
|
||||
) : null}
|
||||
{copyable ? <CopyButton value={value} label={label} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
rows={2}
|
||||
className={`${fieldInputClass(error)} resize-y`}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
className={fieldInputClass(error)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{action ? <div className="flex">{action}</div> : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-[#c7653d]">
|
||||
<p role="alert" className="text-xs font-medium text-[#c7653d]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图标一格:左边缩略图、右边文件名与两颗按钮。**没有输入框**——图标位置不接受手填。
|
||||
*
|
||||
* 位置必须是一条已登记、可预览的项目内路径:手打出来的路径宿主预览不到、校验也会拒,给一个能打字
|
||||
* 的框等于请用户去犯一个必然犯的错。唯一入口是「选择图标」——它开公共 `AssetImporter`(左边是
|
||||
* 本地项目、右边是云端素材库),选中后把图登记到 `assets/uploads/` 再回一条项目内相对路径。
|
||||
*
|
||||
* 连那条路径也不显示:用户要认的是那张图,不是它在磁盘上的位置。文件名只用来在几张图之间认人。
|
||||
*/
|
||||
function IconField({
|
||||
value,
|
||||
error,
|
||||
projectPath,
|
||||
onChange,
|
||||
onDownloadIcon,
|
||||
}: {
|
||||
value: string;
|
||||
error: string | null;
|
||||
projectPath: string | null;
|
||||
onChange: (value: string) => void;
|
||||
onDownloadIcon: () => void;
|
||||
}) {
|
||||
const [importerOpen, setImporterOpen] = useState(false);
|
||||
const fileName = value.trim().split(/[\\/]/u).at(-1) ?? '';
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-xs font-bold text-[#3d1f10]">图标</span>
|
||||
<div className="flex items-start gap-3">
|
||||
<IconPreview projectPath={projectPath} relativePath={value} />
|
||||
<div className="grid min-w-0 flex-1 gap-2">
|
||||
{/* 没选图标时不摆一行字:缩略图里的占位符已经说了这件事,右侧直接给唯一那颗按钮。 */}
|
||||
{fileName ? (
|
||||
<p className="truncate text-sm leading-[1.45] text-[#6f5848]">
|
||||
已选:{fileName}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PaneButton
|
||||
variant="ghost"
|
||||
ariaLabel="选择图标"
|
||||
title="从云端素材库或本地项目选一张图"
|
||||
disabled={!projectPath}
|
||||
onClick={() => setImporterOpen(true)}
|
||||
>
|
||||
<Images size={13} aria-hidden="true" />
|
||||
<span>选择图标</span>
|
||||
</PaneButton>
|
||||
<PaneButton
|
||||
variant="ghost"
|
||||
ariaLabel="下载图标文件"
|
||||
title="下载图标文件"
|
||||
disabled={!value.trim()}
|
||||
onClick={onDownloadIcon}
|
||||
>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
<span>下载图标文件</span>
|
||||
</PaneButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs font-medium text-[#c7653d]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<AssetImporter
|
||||
open={importerOpen}
|
||||
projectPath={projectPath ?? ''}
|
||||
settings={ICON_IMPORTER_SETTINGS}
|
||||
onClose={() => setImporterOpen(false)}
|
||||
onImport={(assets) => {
|
||||
const picked = assets.at(-1);
|
||||
if (picked) onChange(picked.localPath);
|
||||
setImporterOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { ImageOff } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../../../../app/tauri';
|
||||
import {
|
||||
cancelLocalProjectResourcePreviewScope,
|
||||
createProjectResourcePreviewRequestId,
|
||||
createProjectResourcePreviewScopeId,
|
||||
} from '../../../../../services/projectResourcePreviewTransport';
|
||||
|
||||
type ImagePreviewResponse = { dataUrl: string };
|
||||
|
||||
/**
|
||||
* 图标缩略图:把项目里那张图读成 dataUrl 直接显示。
|
||||
*
|
||||
* 走 UI Editor 同一套 `read_local_project_image_preview`——越界、符号链接、敏感路径的门禁在宿主
|
||||
* 那边,而且宿主只允许预览「已登记资源」。用户手填一条没登记过的路径时这里读不到,退回占位符并
|
||||
* 在 title 里说清楚,别让人以为图坏了。
|
||||
*/
|
||||
export function IconPreview({
|
||||
projectPath,
|
||||
relativePath,
|
||||
}: {
|
||||
projectPath: string | null;
|
||||
relativePath: string;
|
||||
}) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const path = relativePath.trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath || !path) {
|
||||
setDataUrl(null);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setDataUrl(null);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
// 每次读取独占一个 scope:路径一变,上一次的读取连 scope 一起取消,迟到的旧图不会盖住新图。
|
||||
const scopeId = createProjectResourcePreviewScopeId();
|
||||
let cancelled = false;
|
||||
setDataUrl(null);
|
||||
setFailed(false);
|
||||
void invoke<ImagePreviewResponse>('read_local_project_image_preview', {
|
||||
projectPath,
|
||||
relativePath: path,
|
||||
scopeId,
|
||||
requestId: createProjectResourcePreviewRequestId(),
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setDataUrl(result.dataUrl);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setDataUrl(null);
|
||||
setFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelLocalProjectResourcePreviewScope(scopeId);
|
||||
};
|
||||
}, [path, projectPath]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-xl border border-[#e2cbb8] bg-white/80"
|
||||
title={
|
||||
failed ? '读不到这张图,用「选择图标」导入一张就能预览' : undefined
|
||||
}
|
||||
role={dataUrl ? undefined : 'img'}
|
||||
aria-label={dataUrl ? undefined : failed ? '图标读不到' : '还没有选图标'}
|
||||
>
|
||||
{dataUrl ? (
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="图标预览"
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<ImageOff
|
||||
size={18}
|
||||
className={failed ? 'text-[#c7653d]' : 'text-[#b9a494]'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DESIGN_IMAGE_IMPORTER_SETTINGS } from '../src/components/AssetImporter/settings';
|
||||
import {
|
||||
DESIGN_IMAGE_IMPORTER_SETTINGS,
|
||||
ICON_IMPORTER_SETTINGS,
|
||||
} from '../src/components/AssetImporter/settings';
|
||||
|
||||
describe('UI design image importer limits', () => {
|
||||
it('limits design references to four files and five MiB each', () => {
|
||||
@@ -15,3 +18,35 @@ describe('UI design image importer limits', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform icon importer limits', () => {
|
||||
// 小红书上传页只认 png/jpg/jpeg、单张、≤5 MiB;这里的白名单与上限要跟宿主
|
||||
// `validate_icon` 对齐,别让用户选完才在保存时被宿主的硬校验挡回来。
|
||||
const expected = { maxItems: 1, maxFileSizeBytes: 5 * 1024 * 1024 };
|
||||
|
||||
it('allows one icon from either the local project or the cloud library', () => {
|
||||
expect(ICON_IMPORTER_SETTINGS.local.requirements).toEqual(expected);
|
||||
expect(ICON_IMPORTER_SETTINGS.remote?.requirements).toEqual(expected);
|
||||
expect(ICON_IMPORTER_SETTINGS.local.localPolicy.acceptedExtensions).toEqual(
|
||||
['png', 'jpg', 'jpeg'],
|
||||
);
|
||||
expect(
|
||||
ICON_IMPORTER_SETTINGS.remote?.remotePolicy.acceptedExtensions,
|
||||
).toEqual(['png', 'jpg', 'jpeg']);
|
||||
});
|
||||
|
||||
it('drops formats the platform rejects instead of importing them', () => {
|
||||
const filter = ICON_IMPORTER_SETTINGS.local.typeFilter;
|
||||
const candidate = (name: string, mediaType: string) => ({
|
||||
name,
|
||||
localPath: `assets/${name}`,
|
||||
mediaType,
|
||||
registered: false,
|
||||
});
|
||||
|
||||
expect(filter(candidate('icon.png', 'image/png'))).toBe(true);
|
||||
expect(filter(candidate('icon.JPG', 'image/jpeg'))).toBe(true);
|
||||
expect(filter(candidate('icon.webp', 'image/webp'))).toBe(false);
|
||||
expect(filter(candidate('icon.gif', 'image/gif'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user