表单加「帮我填」,图标一格不再手填

- 表单标题行加「帮我填」:先把用户手上没保存的输入落盘(落盘就是这次自动保存),再把改注册表的指令交给同一个项目会话,改完由每 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:
2026-10-06 14:31:53 +08:00
parent 0f9c3beb8e
commit 809ce30c1a
8 changed files with 488 additions and 173 deletions
@@ -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: '导入字体',
@@ -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, };
@@ -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: '图标',
};
@@ -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)},
* 对于图标示例图等字段可以看看目前是否已经有合适的, 没有的话可以(征询用户意见后)生图/留给用户自己选择
`;
}
@@ -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>
);
}
@@ -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);
});
});