From a6974f3ca49fcdd15504e9e7a3c36d9b298a81c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 22:25:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8F=AA=E7=95=99=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存命令不再校验表单:`save_form` 只判 baseHash 冲突并写盘,删掉 `validation` 模块与它的必填 / 字数 / icon 约束 - `FormInvalid` 与 `XHSMiniToolExportField` 从 Rust 失败表和 DTO 退役,ts-rs 同步删掉两个生成类型 - 前端新增 `validateXhsMinitoolForm`(必填 + 14 字上限,按码点算),`persist` 先过它再落盘 - `fieldError` 改用本地 `XhsMinitoolFieldError`,`save` 守卫与失败卡片分流同步移除 `formInvalid` - 测试:新增 `xhsMinitoolFields.test.ts`,改写两处依赖宿主 `formInvalid` 的用例为前端校验 --- .../src/export/draft/xhs_minitool/commands.rs | 9 +- .../src/export/draft/xhs_minitool/dto.rs | 14 +- .../src/export/draft/xhs_minitool/error.rs | 26 +- .../src/export/draft/xhs_minitool/mod.rs | 7 +- .../src/export/draft/xhs_minitool/registry.rs | 6 +- .../export/draft/xhs_minitool/validation.rs | 231 ------------------ .../export/generated/FormInvalid.ts | 4 - .../generated/XHSMiniToolExportField.ts | 6 - .../generated/XHSMiniToolExportSaveError.ts | 3 +- .../export/state/useXhsMinitoolExport.ts | 37 ++- .../export/state/xhsMinitoolFailure.ts | 14 +- .../export/state/xhsMinitoolFields.ts | 64 ++++- .../export/tabs/xiaohongshu/FormCard.tsx | 17 +- .../tests/xhsMinitoolExport.test.tsx | 29 ++- .../tests/xhsMinitoolFields.test.ts | 58 +++++ 15 files changed, 190 insertions(+), 335 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts create mode 100644 apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs index c3daf01df..6bf86b987 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs @@ -23,7 +23,6 @@ use super::error::{ XHSMiniToolExportSaveError, }; use super::registry::{self, RegistrySnapshot}; -use super::validation::validate_form; use super::{build, script}; /// 读内容:注册表(缺则补一份空表单)+ 脚本是否存在。自动刷新反复调的就是这一条。 @@ -56,10 +55,11 @@ pub(crate) async fn read_xhs_minitool_export_hash( result } -/// 保存表单:先硬校验(首个错误即返回),再按 `base_hash` 判冲突;成功后回新的指纹。 +/// 保存表单:只按 `base_hash` 判冲突,成功后回新的指纹。 /// -/// 校验挡在保存之前是刻意的——平台字数与 icon 限制是硬约束,让它在用户输入时就被看见,比等到 -/// 人工上传被平台打回便宜。 +/// **不校验表单内容**——必填与字数上限由界面在提交前挡住(用户最终面对的是界面,这份表单也只是 +/// 一份草稿),宿主只负责把它原样写进注册表。`iconPath` 这类路径事实在真正读它的那条路(预览、 +/// 下载)由统一的路径门禁复核,不在这里预支。 #[tauri::command] pub(crate) async fn save_xhs_minitool_export_form( project_path: String, @@ -163,7 +163,6 @@ fn save_form( base_hash: &str, ) -> Result { let root = project_root(project_path)?; - validate_form(&root, form)?; Ok(registry::save(&root, form, base_hash)?.content_hash) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs index 1ed418fbc..f9bb9da18 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs @@ -21,8 +21,8 @@ pub(crate) struct XHSMiniToolExportForm { impl XHSMiniToolExportForm { /// 落盘形态:三个字段都去掉首尾空白。 /// - /// 校验按去空白后的文本算(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白,否则 - /// 「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。 + /// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白, + /// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。 pub(crate) fn normalized(&self) -> Self { Self { name: self.name.trim().to_string(), @@ -46,16 +46,6 @@ pub(crate) struct XHSMiniToolExportRegistry { pub(crate) form: XHSMiniToolExportForm, } -/// 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] -pub(crate) enum XHSMiniToolExportField { - Name, - Introduction, - IconPath, -} - /// 面板读到的现场内容。 /// /// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs index c0caad70e..7c86b6869 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs @@ -6,8 +6,9 @@ //! - [`XHSMiniToolExportReadError`]:读注册表(`read_xhs_minitool_export` / //! `read_xhs_minitool_export_hash`); //! - [`XHSMiniToolExportSaveError`]:写表单(`save_xhs_minitool_export_form`,自动保存与「打包」 -//! 前的 flush 走同一条)。`formInvalid` 与 `saveConflict` **只**出现在这里——它们是「这份输入 -//! 还没落盘」的两种结果,不是构建失败; +//! 前的 flush 走同一条)。`saveConflict` 只出现在这里——它是「这份输入还没落盘」的结果,不是 +//! 构建失败。表单内容的必填与字数由界面在提交前校验,宿主这一层不做业务校验,所以这张表里没有 +//! 「字段不合法」这一类; //! - [`XHSMiniToolExportBuildError`]:跑构建(`run_xhs_minitool_export_build`)。构建不是表单的 //! 消费者,所以这张表里没有表单类变体。 //! @@ -20,7 +21,7 @@ use serde::Serialize; use ts_rs::TS; -use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; +use super::dto::XHSMiniToolExportForm; /// 读注册表可能怎么失败。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] @@ -48,8 +49,6 @@ pub(crate) enum XHSMiniToolExportReadError { pub(crate) enum XHSMiniToolExportSaveError { /// 注册表存在但不是本目标期望的形状。 RegistryMalformed(RegistryMalformed), - /// 表单字段不合法。只回**首个**出错字段。 - FormInvalid(FormInvalid), /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 SaveConflict(SaveConflict), /// 项目根不可用或其它宿主侧事实故障。 @@ -85,14 +84,6 @@ pub(crate) struct RegistryMalformed { pub(crate) cause: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] -pub(crate) struct FormInvalid { - pub(crate) field: XHSMiniToolExportField, - pub(crate) reason: String, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] @@ -157,15 +148,6 @@ impl ExportUnavailable { } } -impl XHSMiniToolExportSaveError { - pub(crate) fn form_invalid(field: XHSMiniToolExportField, reason: impl Into) -> Self { - Self::FormInvalid(FormInvalid { - field, - reason: reason.into(), - }) - } -} - /// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。 impl From for XHSMiniToolExportReadError { fn from(payload: ExportUnavailable) -> Self { diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs index 6d43c4951..3c58814d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs @@ -6,8 +6,10 @@ //! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 //! //! 分层:`dto` 是线上形状,`error` 是命令边界失败表,`layout` 是契约常量,`registry` 管注册表, -//! `script` 管 npm 脚本发现,`validation` 管表单硬约束,`build` 跑脚本并确认产物, -//! `commands` 是唯一对外的命令面。 +//! `script` 管 npm 脚本发现,`build` 跑脚本并确认产物,`commands` 是唯一对外的命令面。 +//! +//! 表单**不在宿主侧做业务校验**:命令只把用户那份草稿原样读出来、写回去,必填与字数由界面在 +//! 提交前挡住。宿主要挡的只有「这份注册表文件写得对不对」这类 IO 事实(见 `registry`)。 pub mod commands; @@ -17,4 +19,3 @@ mod error; mod layout; mod registry; mod script; -mod validation; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs index 6dc886e2a..ac8739bcf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs @@ -5,9 +5,9 @@ //! //! 已知取舍(评审提出、当前决定不改):这里用共享的 `load_or_create` / `save`,**不**额外检查 //! 注册表文件与 `.export` 目录是不是符号链接。也就是说,如果 `.export/xhs-minitool.json` 被换成 -//! 链接,读写会落到项目外。这与产物(`build::conclude`)、脚本(`script::find`)、图标 -//! (`validation`)三处的拒链口径不同,是有意为之:那三处是「把项目内容交给打包链」的入口, -//! 这里读写的是用户自己项目里的草稿文件,按「项目文件由用户掌控」的前提接受这个重定向。 +//! 链接,读写会落到项目外。这与产物(`build::conclude`)、脚本(`script::find`)不同,也与图标在 +//! 预览 / 下载时走的路径门禁不同,是有意为之:那几处是「把项目内容交给打包链」的入口,这里读写 +//! 的是用户自己项目里的草稿文件,按「项目文件由用户掌控」的前提接受这个重定向。 use std::path::Path; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs deleted file mode 100644 index 997d96c13..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! 小红书上传表单的校验:**首个错误即返回**。 -//! -//! 字数上限来自小红书平台的上传表单,所以是宿主硬校验而不是软提示——写错了用户会被平台打回 -//! 去,代价比早一点拦下来大。icon 的扩展名与大小是本地文件事实,同样硬校验;非 png/jpg/jpeg -//! 目前直接拒绝,格式转换工具是后续事项(见下方 TODO)。 - -use std::path::Path; - -use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; -use super::error::XHSMiniToolExportSaveError; - -/// 小红书平台对名称的字数上限(Unicode 字符数,不是字节数)。 -pub(crate) const NAME_MAX_CHARS: usize = 14; -/// 简介与名称同档。 -pub(crate) const INTRODUCTION_MAX_CHARS: usize = 14; -pub(crate) const ICON_MAX_BYTES: u64 = 5 * 1024 * 1024; -const ICON_EXTENSIONS: [&str; 3] = ["png", "jpg", "jpeg"]; - -pub(crate) fn validate_form( - root: &Path, - form: &XHSMiniToolExportForm, -) -> Result<(), XHSMiniToolExportSaveError> { - validate_text( - XHSMiniToolExportField::Name, - "名称", - &form.name, - NAME_MAX_CHARS, - )?; - validate_text( - XHSMiniToolExportField::Introduction, - "简介", - &form.introduction, - INTRODUCTION_MAX_CHARS, - )?; - validate_icon(root, form) -} - -fn validate_text( - field: XHSMiniToolExportField, - label: &str, - value: &str, - max_chars: usize, -) -> Result<(), XHSMiniToolExportSaveError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!("{label}不能为空"), - )); - } - let chars = trimmed.chars().count(); - if chars > max_chars { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!("{label}最多 {max_chars} 个字,当前 {chars} 个字"), - )); - } - Ok(()) -} - -fn validate_icon( - root: &Path, - form: &XHSMiniToolExportForm, -) -> Result<(), XHSMiniToolExportSaveError> { - let field = XHSMiniToolExportField::IconPath; - let relative = form.icon_path.trim(); - if relative.is_empty() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - "icon 不能为空", - )); - } - // 与通用项目读取同一套拒绝口径:越界、符号链接、Windows reparse point 都在这里挡掉。 - let path = crate::project::resolve_local_project_path(root, relative) - .map_err(|cause| XHSMiniToolExportSaveError::form_invalid(field, cause))?; - let metadata = std::fs::symlink_metadata(&path).map_err(|error| { - XHSMiniToolExportSaveError::form_invalid(field, format!("icon 不存在或不可读:{error}")) - })?; - if metadata.file_type().is_symlink() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - "icon 不能是符号链接", - )); - } - if !metadata.is_file() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - "icon 必须是普通文件", - )); - } - let extension = path - .extension() - .and_then(|value| value.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - if !ICON_EXTENSIONS.contains(&extension.as_str()) { - // TODO: 提供格式转换工具,把平台不认的格式(webp、heic 等)转成 png/jpg/jpeg。 - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!("icon 只支持 png、jpg、jpeg,当前是 .{extension}"), - )); - } - if metadata.len() > ICON_MAX_BYTES { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!( - "icon 不能超过 5 MiB,当前 {:.1} MiB", - metadata.len() as f64 / (1024.0 * 1024.0) - ), - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::path::PathBuf; - - static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - - fn fixture_project(prefix: &str) -> PathBuf { - let root = std::env::temp_dir().join(format!( - "genarrative-xhs-validation-{prefix}-{}-{}", - std::process::id(), - NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - fs::create_dir_all(root.join("assets")).expect("create fixture project"); - root - } - - fn form_with_icon(icon_path: &str) -> XHSMiniToolExportForm { - XHSMiniToolExportForm { - name: "小工具".to_string(), - introduction: "一句话简介".to_string(), - icon_path: icon_path.to_string(), - } - } - - #[test] - fn accepts_a_well_formed_form() { - let root = fixture_project("ok"); - fs::write(root.join("assets/icon.png"), b"png").unwrap(); - - validate_form(&root, &form_with_icon("assets/icon.png")).expect("valid form"); - } - - #[test] - fn counts_unicode_characters_not_bytes() { - let root = fixture_project("unicode"); - fs::write(root.join("assets/icon.png"), b"png").unwrap(); - let mut form = form_with_icon("assets/icon.png"); - form.name = "小".repeat(NAME_MAX_CHARS); - - validate_form(&root, &form).expect("exactly at the limit"); - form.name.push('多'); - let error = validate_form(&root, &form).expect_err("over the limit"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::Name - )); - } - - #[test] - fn reports_the_first_invalid_field_only() { - let root = fixture_project("first"); - let form = XHSMiniToolExportForm { - name: String::new(), - introduction: String::new(), - icon_path: String::new(), - }; - - let error = validate_form(&root, &form).expect_err("empty form"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::Name - )); - } - - #[test] - fn rejects_paths_escaping_the_project_and_symlinks() { - let root = fixture_project("escape"); - let outside = root.parent().unwrap().join("outside.png"); - fs::write(&outside, b"png").unwrap(); - - let error = validate_form(&root, &form_with_icon("../outside.png")) - .expect_err("escaping path must be rejected"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - - #[cfg(unix)] - { - std::os::unix::fs::symlink(&outside, root.join("assets/link.png")).unwrap(); - let error = validate_form(&root, &form_with_icon("assets/link.png")) - .expect_err("symlink must be rejected"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - } - } - - #[test] - fn rejects_unsupported_extension_and_oversized_icon() { - let root = fixture_project("icon"); - fs::write(root.join("assets/icon.webp"), b"webp").unwrap(); - let error = validate_form(&root, &form_with_icon("assets/icon.webp")) - .expect_err("webp is not accepted yet"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - - let big = root.join("assets/big.png"); - fs::write(&big, vec![0_u8; (ICON_MAX_BYTES + 1) as usize]).unwrap(); - let error = validate_form(&root, &form_with_icon("assets/big.png")).expect_err("too large"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - } -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts deleted file mode 100644 index 3898f1c1d..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { XHSMiniToolExportField } from "./XHSMiniToolExportField"; - -export type FormInvalid = { field: XHSMiniToolExportField, reason: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts deleted file mode 100644 index 8e2c17544..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。 - */ -export type XHSMiniToolExportField = "name" | "introduction" | "iconPath"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts index e2e09da22..565ad0901 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts @@ -1,10 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExportUnavailable } from "./ExportUnavailable"; -import type { FormInvalid } from "./FormInvalid"; import type { RegistryMalformed } from "./RegistryMalformed"; import type { SaveConflict } from "./SaveConflict"; /** * 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 */ -export type XHSMiniToolExportSaveError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "formInvalid" } & FormInvalid | { "type": "saveConflict" } & SaveConflict | { "type": "exportUnavailable" } & ExportUnavailable; +export type XHSMiniToolExportSaveError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "saveConflict" } & SaveConflict | { "type": "exportUnavailable" } & ExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 0d071d68c..322eda813 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -20,7 +20,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../../../app/tauri'; import type { UserContentPart } from '../../chat/generated/UserContentPart'; -import type { FormInvalid } from '../generated/FormInvalid'; import type { SaveConflict } from '../generated/SaveConflict'; import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState'; @@ -39,6 +38,10 @@ import { classifyXhsMinitoolFailure, XhsMinitoolFailureRepairability, } from './xhsMinitoolFailure'; +import { + validateXhsMinitoolForm, + type XhsMinitoolFieldError, +} from './xhsMinitoolFields'; import { buildXhsMinitoolAdaptationInstruction, buildXhsMinitoolErrorFixInstruction, @@ -83,7 +86,9 @@ export function useXhsMinitoolExport({ }) { const [form, setForm] = useState(null); const [hasScript, setHasScript] = useState(false); - const [fieldError, setFieldError] = useState(null); + const [fieldError, setFieldError] = useState( + null, + ); const [conflict, setConflict] = useState(null); const [failure, setFailure] = useState(null); /** @@ -318,9 +323,10 @@ export function useXhsMinitoolExport({ /** * 落盘一份表单,返回是否真的写成功。 * - * 这是**唯一**做表单硬校验的地方(`validate_form` 在保存命令里):自动保存到点就调它,「打包」 - * 要落盘手上没保存的输入也调它。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等 - * 用户处理,不拿一份没通过校验的表单去构建。 + * 这是**唯一**做表单硬校验的地方:自动保存到点就调它,「打包」要落盘手上没保存的输入也调它。 + * 校验只挡必填与字数([`validateXhsMinitoolForm`]),宿主那侧不再挡——表单只是草稿,用户最终 + * 面对的是界面。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等用户处理,不拿一份 + * 没通过校验的表单去构建。 * * 写盘经由 [FormSaveLock]:同一时刻只写一版。写盘期间用户又改了表单时,这次的结果虽然成功,也 * **不能**清 `dirty`——否则自动保存效果会把后一版的定时器当成「已经存好了」取消,最后一版输入 @@ -331,6 +337,14 @@ export function useXhsMinitoolExport({ if (!projectPath) return false; const invoke = resolveTauriInvoke(); if (!invoke) return false; + // 首个字段错误即返回:红字挂到对应输入框,等用户改完下一次输入再存。`dirty` 归零,同一份 + // 不合法的输入不会反复排队。 + const invalid = validateXhsMinitoolForm(next); + if (invalid) { + setFieldError(invalid); + setDirty(false); + return false; + } const { saved, superseded } = await saveLockRef.current.run(async () => { try { const nextBaseline = await saveXhsMinitoolExportForm(invoke, { @@ -350,16 +364,15 @@ export function useXhsMinitoolExport({ } catch (error) { // 旧项目的失败不该记到新项目头上。 if (projectPathRef.current !== projectPath) return false; - // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 - // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 + // 剩下的结果都要停下重试,等用户或下一次输入推着走:冲突等用户选、其余按 typed 失败给 + // 建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 setDirty(false); const structured = asXhsMinitoolSaveFailure(error); if (!structured) { throwToErrorPool(String(error), error); return false; } - if (structured.type === 'formInvalid') setFieldError(structured); - else if (structured.type === 'saveConflict') setConflict(structured); + if (structured.type === 'saveConflict') setConflict(structured); else if (structured.type === 'exportUnavailable') { // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 throwToErrorPool(JSON.stringify(structured), error); @@ -452,9 +465,9 @@ export function useXhsMinitoolExport({ * 没有脚本(或这次读不到)时不报错了事,而是把阻断弹窗重新武装起来——用户点「打包」的本意就是 * 「我要一份代码包」,这条路走不通时该当场把「让陶泥儿帮你调通」这个出口递给他。 * - * 这里不做任何表单校验——校验只发生在保存命令里(`persist`)。打包只关心「盘上的注册表是不是 - * 用户现在这份」:有未保存输入就先 flush,flush 回 `false`(字段不合法、撞上冲突、宿主故障) - * 就停在表单上让用户处理;冲突或字段红字还没解决也一样停住。构建本身只认盘上的文件。 + * 这里不做任何表单校验——校验只在 `persist` 落盘前做一次(界面这一层)。打包只关心「盘上的 + * 注册表是不是用户现在这份」:有未保存输入就先 flush,flush 回 `false`(字段不合法、撞上冲突、 + * 宿主故障)就停在表单上让用户处理;冲突或字段红字还没解决也一样停住。构建本身只认盘上的文件。 */ const packageAll = useCallback(async (): Promise => { if (!projectPath) return; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts index 1f215e9a7..8f5196327 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts @@ -2,8 +2,7 @@ * 结构化失败 → 两件互不派生的事:给用户看的一句话,和这次该由谁修。 * * 输入是**会出现在失败卡片上的失败**([`XhsMinitoolCardFailure`])——它按命令把三条失败表拼起来, - * 但不含 `formInvalid`(字段红字)与 `saveConflict`(冲突面板):那两种有专属 UI,到不了卡片, - * 也就不该在这张表里再分一次类。 + * 但不含 `saveConflict`(冲突面板):它有专属 UI,到不了卡片,也就不该在这张表里再分一次类。 * * 拆成两个纯函数,各答一件事: * - `describeXhsMinitoolFailure` 只产用户侧的说法(一个字符串),句子按变体写死,**不从载荷里 @@ -22,18 +21,15 @@ import type { XHSMiniToolExportReadError } from '../generated/XHSMiniToolExportR import type { XHSMiniToolExportSaveError } from '../generated/XHSMiniToolExportSaveError'; /** - * 会出现在失败卡片上的失败:读注册表、保存表单、跑构建三条命令里,除去有专属 UI 的两种。 + * 会出现在失败卡片上的失败:读注册表、保存表单、跑构建三条命令里,除去有专属 UI 的冲突。 * - * `formInvalid` 由保存命令在写盘前挡下,走 `FormCard` 的字段红字;`saveConflict` 走冲突面板。 - * 两者都不是「失败卡片」的现场,所以不在这张表里。 + * 字段红字(必填 / 字数)在界面这一层拦下,根本不会走到保存命令,也就不在这张表里;`saveConflict` + * 走冲突面板。两者都不是「失败卡片」的现场。 */ export type XhsMinitoolCardFailure = | XHSMiniToolExportReadError | XHSMiniToolExportBuildError - | Exclude< - XHSMiniToolExportSaveError, - { type: 'formInvalid' } | { type: 'saveConflict' } - >; + | Exclude; /** * 谁有能力修这次失败。判据只有一条:**要改的东西在不在用户项目的代码里**。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts index 345f12d80..dfd06ee4e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts @@ -1,25 +1,36 @@ /** - * 三个表单字段的展示元数据:标签、占位文案、字数上限。 + * 三个表单字段的展示元数据与本地校验:标签、占位文案、字数上限,以及保存前那道必填 / 字数关。 * - * 字段身份直接用 ts-rs 生成的 `XHSMiniToolExportField`(`"name" | "introduction" | "iconPath"`): - * 它与表单对象的键一一对应,所以 `form[field]` 天然成立,不需要再维护一份字段映射。 + * 字段身份用本文件定义的 `XhsMinitoolField`:它与表单对象的键一一对应,所以 `form[field]` 天然 + * 成立,不需要再维护一份字段映射。宿主那侧不再做表单业务校验,必填与上限都在这里挡。 */ -import type { XHSMiniToolExportField } from '../generated/XHSMiniToolExportField'; +import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; + +/** 表单里的三个字段,与 [`XHSMiniToolExportForm`] 的键一一对应。 */ +export type XhsMinitoolField = 'name' | 'introduction' | 'iconPath'; + +/** 字段红字:一次只挂一个出错字段,用户改完再报下一个。 */ +export type XhsMinitoolFieldError = { + field: XhsMinitoolField; + reason: string; +}; export type XhsMinitoolFieldMeta = { - field: XHSMiniToolExportField; + field: XhsMinitoolField; label: string; /** 输入框的占位提示。图标不给输入框,所以这一项只有文本字段有。 */ placeholder?: string; - /** 宿主硬校验的字数上限;icon 不是文本字段,没有这一项。 */ + /** 字数上限;icon 不是文本字段,没有这一项。 */ maxChars?: number; multiline: boolean; /** 是否给一颗「复制」按钮。图标是文件、不是要粘贴到平台的文字,复制路径没有意义。 */ copyable: boolean; }; -/** 文本字段的字数上限:与 Rust 权威 `validation.rs` 的 NAME_MAX_CHARS / INTRODUCTION_MAX_CHARS 一致。 */ -const TEXT_MAX_CHARS = 14; +/** 文本字段的字数上限,与小红书上传页一致。 */ +export const XHS_MINITOOL_TEXT_MAX_CHARS = 14; + +const TEXT_MAX_CHARS = XHS_MINITOOL_TEXT_MAX_CHARS; /** 表单展示顺序。加字段时只改这里,保存与复制都按字段身份走。 */ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ @@ -46,3 +57,40 @@ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ copyable: false, }, ]; + +/** + * 保存前的本地校验:只挡「平台一定不认」的三件事——三个字段必填、两个文本字段不超字数。 + * + * 校验放在界面这一层是刻意的:用户最终面对的就是界面,这份表单也只是一份草稿,不值得让宿主再挡 + * 一次。文件是不是真的存在、是不是符号链接、扩展名与大小都不在这里判——那些是「真的去读它」时 + * 才成立的事实,由预览 / 下载那条路各自的路径门禁负责。首个错误即返回,与表单的提交顺序一致。 + */ +export function validateXhsMinitoolForm( + form: XHSMiniToolExportForm, +): XhsMinitoolFieldError | null { + return ( + validateText('name', '名称', form.name) ?? + validateText('introduction', '简介', form.introduction) ?? + (form.iconPath.trim() + ? null + : { field: 'iconPath', reason: 'icon 不能为空' }) + ); +} + +function validateText( + field: 'name' | 'introduction', + label: string, + value: string, +): XhsMinitoolFieldError | null { + const trimmed = value.trim(); + if (!trimmed) return { field, reason: `${label}不能为空` }; + // 按码点计数:`String.length` 数的是 UTF-16 码元,一个 emoji 会被算成 2、平台算成 1。 + const chars = Array.from(trimmed).length; + if (chars > XHS_MINITOOL_TEXT_MAX_CHARS) { + return { + field, + reason: `${label}最多 ${XHS_MINITOOL_TEXT_MAX_CHARS} 个字,当前 ${chars} 个字`, + }; + } + return null; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index e53cf0cb8..f413a9205 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -3,9 +3,11 @@ 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 { + XHS_MINITOOL_FORM_FIELDS, + type XhsMinitoolFieldError, +} from '../../state/xhsMinitoolFields'; import { PaneButton } from '../common/PaneButton'; import { CopyButton } from './CopyButton'; import { IconPreview } from './IconPreview'; @@ -14,7 +16,8 @@ import { IconPreview } from './IconPreview'; * 小红书上传表单。名称与介绍可复制(它们是要粘贴到上传页的文字),图标只给「下载」——复制一条 * 项目内路径没有意义。 * - * 表单只收集值:校验在宿主(保存时硬校验),这里的红字是宿主回话的投影,不是前端自己判的。 + * 表单只收集值:校验在界面这一层(保存前,见 `validateXhsMinitoolForm`),红字是本地校验的 + * 结果;宿主那侧只按原样读写草稿。 * 文案一律说「上传到小红书要填什么」,不暴露脚本名、注册表与 zip 这些实现词。 * * 版式对齐发布面板(`GameDistributionPublishPanel`):标签在上、输入独占整行、字段之间留够呼吸, @@ -30,7 +33,7 @@ export function FormCard({ onAskAgentToFill, }: { form: XHSMiniToolExportForm; - fieldError: FormInvalid | null; + fieldError: XhsMinitoolFieldError | null; projectPath: string | null; onChange: (field: keyof XHSMiniToolExportForm, value: string) => void; onDownloadIcon: () => void; @@ -179,9 +182,9 @@ function FieldRow({ /** * 图标一格:左边缩略图、右边文件名与两颗按钮。**没有输入框**——图标位置不接受手填。 * - * 位置必须是一条已登记、可预览的项目内路径:手打出来的路径宿主预览不到、校验也会拒,给一个能打字 - * 的框等于请用户去犯一个必然犯的错。唯一入口是「选择图标」——它开公共 `AssetImporter`(左边是 - * 本地项目、右边是云端素材库),选中后把图登记到 `assets/uploads/` 再回一条项目内相对路径。 + * 位置必须是一条已登记、可预览的项目内路径:手打出来的路径宿主预览不到,给一个能打字的框等于请用户 + * 去犯一个必然犯的错。唯一入口是「选择图标」——它开公共 `AssetImporter`(左边是本地项目、右边是云端 + * 素材库),选中后把图登记到 `assets/uploads/` 再回一条项目内相对路径。 * * 连那条路径也不显示:用户要认的是那张图,不是它在磁盘上的位置。文件名只用来在几张图之间认人。 */ diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 363bc9446..055cc1290 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -206,7 +206,8 @@ describe('交给 code agent 的指令', () => { cause: '项目根不可用:找不到目录', }), ).toBe(XhsMinitoolFailureRepairability.OnlyHumanRepairable); - // `formInvalid` / `saveConflict` 不在这张表里:它们只出现在保存命令的失败表上,各有专属 UI。 + // `saveConflict` 不在这张表里:它只出现在保存命令的失败表上,有专属的冲突面板;字段红字则 + // 在界面这一层就被拦下,根本走不到保存命令。 }); }); @@ -671,7 +672,11 @@ describe('命令失败各有一条表', () => { throw { type: 'registryMalformed', cause: 'JSON 解析失败' }; } if (command === 'save_xhs_minitool_export_form') { - throw { type: 'formInvalid', field: 'name', reason: '名称不能为空' }; + throw { + type: 'saveConflict', + current: FORM, + contentHash: CONTENT_HASH, + }; } throw { type: 'commandFailed', @@ -700,7 +705,7 @@ describe('命令失败各有一条表', () => { baseHash: CONTENT_HASH, }).catch((thrown: unknown) => thrown); - expect(asXhsMinitoolSaveFailure(error)?.type).toBe('formInvalid'); + expect(asXhsMinitoolSaveFailure(error)?.type).toBe('saveConflict'); expect(asXhsMinitoolReadFailure(error)).toBeNull(); expect(asXhsMinitoolBuildFailure(error)).toBeNull(); }); @@ -1086,15 +1091,13 @@ describe('状态 hook', () => { expect(result.current.artifactReady).toBe(true); }); - it('打包时表单校验不过就停在表单上,不跑构建', async () => { + it('打包时表单校验不过就停在表单上,不碰宿主也不跑构建', async () => { + // 校验已经挪到界面这一层:宿主不再有「字段不合法」这张拒绝,`save` 根本不该被调到。 const invoke = vi.fn(async (command: string) => { if (command === 'read_xhs_minitool_export') { return { form: FORM, hasScript: true }; } if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; - if (command === 'save_xhs_minitool_export_form') { - throw { type: 'formInvalid', field: 'name', reason: '名称太长' }; - } return { outputTail: 'zip 已生成', omittedCharacters: 0 }; }); installTauriRuntime({ core: { invoke } }); @@ -1108,13 +1111,17 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await act(async () => { - result.current.updateField('name', '新名字'); + result.current.updateField('name', '这个名字已经超过十四个字了平台会拒'); }); await act(async () => { await result.current.packageAll(); }); expect(result.current.fieldError?.field).toBe('name'); + expect(invoke).not.toHaveBeenCalledWith( + 'save_xhs_minitool_export_form', + expect.anything(), + ); expect(invoke).not.toHaveBeenCalledWith('run_xhs_minitool_export_build', { projectPath: PROJECT_PATH, }); @@ -1236,7 +1243,7 @@ describe('状态 hook', () => { return { form: FORM, hasScript: true }; } if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; - throw { type: 'formInvalid', field: 'name', reason: '名称太长' }; + return { outputTail: '', omittedCharacters: 0 }; }); installTauriRuntime({ core: { invoke } }); const submitTurn = vi.fn(async () => true); @@ -1250,13 +1257,13 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await act(async () => { - result.current.updateField('name', '新名字'); + result.current.updateField('name', '这个名字已经超过十四个字了平台会拒'); }); await act(async () => { await result.current.fillForm(); }); - // 保存被宿主拒了,红字留在表单上,但这条消息已经发出去了——拦住它只剩用户自己改。 + // 保存被界面校验拦下,红字留在表单上,但这条消息已经发出去了——拦住它只剩用户自己改。 expect(result.current.fieldError?.field).toBe('name'); expect(submitTurn).toHaveBeenCalledTimes(1); }); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts new file mode 100644 index 000000000..8ccb0dce8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts @@ -0,0 +1,58 @@ +/** + * 表单本地校验:必填、字数边界(按去空白后的码点数),以及「首个错误即返回」。 + * + * 宿主那侧不再校验表单,这份纯函数就是唯一的表单关;`xhsMinitoolExport.test.tsx` 只钉它接到 + * `persist` 上的那一步,边界都在这里覆盖。 + */ +import { describe, expect, it } from 'vitest'; + +import type { XHSMiniToolExportForm } from '../src/view/project-development/export/generated/XHSMiniToolExportForm'; +import { + validateXhsMinitoolForm, + XHS_MINITOOL_TEXT_MAX_CHARS, +} from '../src/view/project-development/export/state/xhsMinitoolFields'; + +const VALID: XHSMiniToolExportForm = { + name: '小工具', + introduction: '一句话介绍', + iconPath: 'assets/icon.png', +}; + +describe('validateXhsMinitoolForm', () => { + it('三个字段都合法时通过', () => { + expect(validateXhsMinitoolForm(VALID)).toBeNull(); + }); + + it('按去空白后的码点数判字数:正好到上限通过,多一个码点报错', () => { + const atLimit = '字'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); + expect(validateXhsMinitoolForm({ ...VALID, name: atLimit })).toBeNull(); + + const over = `${atLimit}字`; + const error = validateXhsMinitoolForm({ ...VALID, name: over }); + expect(error?.field).toBe('name'); + expect(error?.reason).toContain(`${XHS_MINITOOL_TEXT_MAX_CHARS}`); + }); + + it('一个 emoji 也算一个字:`String.length` 数的是码元,这里按码点算', () => { + const emojis = '🙂'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); + expect(validateXhsMinitoolForm({ ...VALID, name: emojis })).toBeNull(); + expect( + validateXhsMinitoolForm({ ...VALID, name: `${emojis}🙂` })?.field, + ).toBe('name'); + }); + + it('只挡首个错误:先报名称,名称修好后才轮到简介', () => { + const empty = { name: ' ', introduction: ' ', iconPath: '' }; + expect(validateXhsMinitoolForm(empty)?.field).toBe('name'); + expect(validateXhsMinitoolForm({ ...empty, name: '有名字了' })?.field).toBe( + 'introduction', + ); + expect( + validateXhsMinitoolForm({ + ...empty, + name: '有名字了', + introduction: '有简介了', + })?.field, + ).toBe('iconPath'); + }); +});