小红书表单校验只留前端
- 保存命令不再校验表单:`save_form` 只判 baseHash 冲突并写盘,删掉 `validation` 模块与它的必填 / 字数 / icon 约束 - `FormInvalid` 与 `XHSMiniToolExportField` 从 Rust 失败表和 DTO 退役,ts-rs 同步删掉两个生成类型 - 前端新增 `validateXhsMinitoolForm`(必填 + 14 字上限,按码点算),`persist` 先过它再落盘 - `fieldError` 改用本地 `XhsMinitoolFieldError`,`save` 守卫与失败卡片分流同步移除 `formInvalid` - 测试:新增 `xhsMinitoolFields.test.ts`,改写两处依赖宿主 `formInvalid` 的用例为前端校验
This commit is contained in:
@@ -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<String, XHSMiniToolExportSaveError> {
|
||||
let root = project_root(project_path)?;
|
||||
validate_form(&root, form)?;
|
||||
Ok(registry::save(&root, form, base_hash)?.content_hash)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 / 已适配标记——「有没有脚本」
|
||||
|
||||
@@ -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<String>) -> Self {
|
||||
Self::FormInvalid(FormInvalid {
|
||||
field,
|
||||
reason: reason.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。
|
||||
impl From<ExportUnavailable> for XHSMiniToolExportReadError {
|
||||
fn from(payload: ExportUnavailable) -> Self {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
-4
@@ -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, };
|
||||
-6
@@ -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";
|
||||
+1
-2
@@ -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;
|
||||
|
||||
+25
-12
@@ -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<XHSMiniToolExportForm | null>(null);
|
||||
const [hasScript, setHasScript] = useState(false);
|
||||
const [fieldError, setFieldError] = useState<FormInvalid | null>(null);
|
||||
const [fieldError, setFieldError] = useState<XhsMinitoolFieldError | null>(
|
||||
null,
|
||||
);
|
||||
const [conflict, setConflict] = useState<SaveConflict | null>(null);
|
||||
const [failure, setFailure] = useState<XhsMinitoolCardFailure | null>(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<void> => {
|
||||
if (!projectPath) return;
|
||||
|
||||
+5
-9
@@ -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<XHSMiniToolExportSaveError, { type: 'saveConflict' }>;
|
||||
|
||||
/**
|
||||
* 谁有能力修这次失败。判据只有一条:**要改的东西在不在用户项目的代码里**。
|
||||
|
||||
+56
-8
@@ -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;
|
||||
}
|
||||
|
||||
+10
-7
@@ -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/` 再回一条项目内相对路径。
|
||||
*
|
||||
* 连那条路径也不显示:用户要认的是那张图,不是它在磁盘上的位置。文件名只用来在几张图之间认人。
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user