小红书小工具的表单 DTO、typed 失败、路径常量与首错校验

- 新增 src-tauri/src/export/draft/xhs_minitool/dto.rs:XHSMiniToolExportForm / Field / State / RunResult 的线上形状;表单 deny_unknown_fields,TS 由 ts-rs 生成。
- 新增 error.rs:命令边界 typed 失败七变体(registryMalformed、formInvalid、buildScriptMissing、saveConflict、commandFailed、artifactMissing、exportUnavailable),带载荷变体持具名结构体,保证前端拿到可判别联合类型。
- 新增 layout.rs:.export/ 下 xhs-minitool.json 与 xhs-minitool.zip 的契约常量、npm 脚本名 build:xhs-minitool 与路径推导。
- 新增 validation.rs:名称与简介按 Unicode 字符数不超过 14 且非空,icon 必须是项目内普通文件、非符号链接、png/jpg/jpeg 且不超过 5 MiB,首个错误即返回。
- 改写 xhs_minitool/mod.rs 为模块声明与重导出,删除上一轮 xhx-* 草案常量与 xhx-minitool 拼写。
- 生成 src/view/project-development/export/generated/ 下 12 份 ts-rs 绑定。
- scripts/check-generated-bindings.mjs 把 export/generated 纳入 AGC 目标的覆盖目录。
This commit is contained in:
2026-10-05 15:27:35 +08:00
parent 91741f9de0
commit 2a70926a3e
18 changed files with 606 additions and 17 deletions
@@ -0,0 +1,54 @@
//! 小红书小工具导出的线上形状(Rust → TS 由 ts-rs 生成,生成物不手改)。
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这三个值的唯一用途是让用户
/// 复制粘贴到小红书平台的上传页。
///
/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能
/// 被静默忽略后带着半份表单继续走。
#[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 XHSMiniToolExportForm {
pub(crate) name: String,
pub(crate) introduction: String,
/// 项目内相对路径。icon 文件与目标无关,多个导出目标可以共用同一个。
pub(crate) icon_path: String,
}
/// 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。
#[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 / 已适配标记——「有没有脚本」
/// 永远从 npm 包的 `package.json` 现算,不落盘。
#[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 XHSMiniToolExportState {
pub(crate) form: XHSMiniToolExportForm,
/// `scripts["build:xhs-minitool"]` 是否存在。
pub(crate) has_script: bool,
/// 注册表文件的当前内容指纹。保存时原样带回做冲突判据;它不写进文件。
pub(crate) content_hash: String,
}
/// 一次成功导出的结果。产物路径是常量(`.export/xhs-minitool.zip`),不进 DTO。
#[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 XHSMiniToolExportRunResult {
pub(crate) exit_code: Option<i32>,
/// 成功时为空;失败时是被截断的输出尾部(stdout 与 stderr 合并),够定位就行。
pub(crate) output_tail: String,
}
@@ -0,0 +1,124 @@
//! 命令边界的 typed 失败。
//!
//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。可达集合是
//! 固定的一张表:读注册表 → 校验表单 → 找 npm 脚本 → 跑脚本 → 查产物 → 存表单。首个错误即
//! 返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。
//!
//! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的
//! TS 联合类型。
use serde::Serialize;
use ts_rs::TS;
use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) enum XHSMiniToolExportError {
/// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。
RegistryMalformed(RegistryMalformed),
/// 表单字段不合法。只回**首个**出错字段。
FormInvalid(FormInvalid),
/// npm 包里没有 `build:xhs-minitool`;这就是「还没适配」的唯一现算判据。
BuildScriptMissing(BuildScriptMissing),
/// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。
SaveConflict(SaveConflict),
/// 脚本跑完没有正常收场(非零退出或超时)。
CommandFailed(CommandFailed),
/// 脚本正常收场但产物不在。
ArtifactMissing(ArtifactMissing),
/// 项目根不可用、权限策略拒绝、脚本无法启动或其它宿主侧事实故障。
ExportUnavailable(ExportUnavailable),
}
#[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 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/"))]
pub(crate) struct BuildScriptMissing {
/// 期望的脚本名(`build:xhs-minitool`)。
pub(crate) script: String,
/// 宿主找过哪些 `package.json`,让用户知道去哪看。
pub(crate) searched: Vec<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 SaveConflict {
/// 文件里现在是什么(字段级二选一的「文件里的」那一侧)。
pub(crate) current: XHSMiniToolExportForm,
/// 冲突时文件的指纹;用户选完之后带着它重试保存。
pub(crate) content_hash: 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 CommandFailed {
pub(crate) exit_code: Option<i32>,
pub(crate) output_tail: 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 ArtifactMissing {
/// 产物相对项目根的路径。
pub(crate) path: 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 ExportUnavailable {
pub(crate) cause: String,
}
impl XHSMiniToolExportError {
pub(crate) fn form_invalid(field: XHSMiniToolExportField, reason: impl Into<String>) -> Self {
Self::FormInvalid(FormInvalid {
field,
reason: reason.into(),
})
}
pub(crate) fn unavailable(cause: impl Into<String>) -> Self {
Self::ExportUnavailable(ExportUnavailable {
cause: cause.into(),
})
}
}
impl From<crate::export::RegistryError> for XHSMiniToolExportError {
fn from(error: crate::export::RegistryError) -> Self {
match error {
crate::export::RegistryError::Malformed { cause, .. } => {
Self::RegistryMalformed(RegistryMalformed { cause })
}
other => Self::ExportUnavailable(ExportUnavailable {
cause: other.to_string(),
}),
}
}
}
@@ -0,0 +1,56 @@
//! 小红书小工具在 `.export/` 下的固定名字与路径。
//!
//! 这些名字同时出现在三处:宿主、agent 写的 npm 脚本、用户手动上传的文件名。所以它们是
//! **契约常量**,只在这里定义一次,改名字要三处一起改。
use std::path::{Path, PathBuf};
use crate::export::EXPORT_WORK_RELATIVE_DIR;
/// 目标标识,用于文件名前缀与 prompt 上下文。
pub(crate) const TARGET: &str = "xhs-minitool";
/// npm 脚本名。冒号形式不是审美选择:既有 `command.exec` 白名单只放行 `build` 与 `build:<后缀>`。
pub(crate) const SCRIPT_NAME: &str = "build:xhs-minitool";
/// 注册表文件名(`.export/` 下)。
pub(crate) const REGISTRY_FILE_NAME: &str = "xhs-minitool.json";
/// 交付产物文件名(`.export/` 下),人工上传用。
pub(crate) const ARTIFACT_FILE_NAME: &str = "xhs-minitool.zip";
pub(crate) fn export_dir(root: &Path) -> PathBuf {
root.join(EXPORT_WORK_RELATIVE_DIR)
}
pub(crate) fn registry_path(root: &Path) -> PathBuf {
export_dir(root).join(REGISTRY_FILE_NAME)
}
pub(crate) fn artifact_path(root: &Path) -> PathBuf {
export_dir(root).join(ARTIFACT_FILE_NAME)
}
/// 产物相对项目根的路径。前端下载与 prompt 用的都是它。
pub(crate) fn artifact_relative_path() -> String {
format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artifact_relative_path_stays_inside_the_export_directory() {
assert_eq!(
artifact_relative_path(),
format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}")
);
assert_eq!(
registry_path(Path::new("/tmp/project")),
Path::new("/tmp/project")
.join(".export")
.join(REGISTRY_FILE_NAME)
);
}
}
@@ -1,17 +1,22 @@
pub mod commands;
//! 小红书小工具导出目标。
//!
//! 文件都堆在项目根的 `.export/`(见 [`crate::export::EXPORT_WORK_RELATIVE_DIR`]):
//! `xhs-minitool.json` 是注册表,`xhs-minitool.zip` 是交付给人工上传的产物,`xhs-minitool.*.mjs`
//! 是 agent 首次适配时从 `vite-export-xhs-minitool` skill 显式拷进来的脚本(允许它改)。共享
//! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。
struct XHSMiniToolExportForm {
// max 14 unicode
name: String,
// max 14 unicode
introduction: String,
// png jpg jpeg, max 5M
icon_path: String,
}
const XHS_MINI_TOOL_CODE_ZIP_FILENAME: &str = "xhx-minitool.zip";
mod dto;
mod error;
mod layout;
mod validation;
const XHS_MINI_TOOL_REGISTRY_FILENAME: &str = "xhx-minitool.json";
const XHS_MINI_TOOL_EXPORT_VITE_SCRIPT: &str = "build-xhx-minitool";
struct XHSMiniToolExportRegistry {
form: XHSMiniToolExportForm,
}
pub(crate) use dto::{
XHSMiniToolExportField, XHSMiniToolExportForm, XHSMiniToolExportRunResult,
XHSMiniToolExportState,
};
pub(crate) use error::XHSMiniToolExportError;
pub(crate) use layout::{
artifact_path, artifact_relative_path, export_dir, registry_path, ARTIFACT_FILE_NAME,
SCRIPT_NAME, TARGET,
};
pub(crate) use validation::validate_form;
@@ -0,0 +1,225 @@
//! 小红书上传表单的校验:**首个错误即返回**。
//!
//! 字数上限来自小红书平台的上传表单,所以是宿主硬校验而不是软提示——写错了用户会被平台打回
//! 去,代价比早一点拦下来大。icon 的扩展名与大小是本地文件事实,同样硬校验;非 png/jpg/jpeg
//! 目前直接拒绝,格式转换工具是后续事项(见下方 TODO)。
use std::path::Path;
use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm};
use super::error::XHSMiniToolExportError;
/// 小红书平台对名称的字数上限(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<(), XHSMiniToolExportError> {
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<(), XHSMiniToolExportError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(XHSMiniToolExportError::form_invalid(
field,
format!("{label}不能为空"),
));
}
let chars = trimmed.chars().count();
if chars > max_chars {
return Err(XHSMiniToolExportError::form_invalid(
field,
format!("{label}最多 {max_chars} 个字,当前 {chars} 个字"),
));
}
Ok(())
}
fn validate_icon(root: &Path, form: &XHSMiniToolExportForm) -> Result<(), XHSMiniToolExportError> {
let field = XHSMiniToolExportField::IconPath;
let relative = form.icon_path.trim();
if relative.is_empty() {
return Err(XHSMiniToolExportError::form_invalid(field, "icon 不能为空"));
}
// 与通用项目读取同一套拒绝口径:越界、符号链接、Windows reparse point 都在这里挡掉。
let path = crate::project::resolve_local_project_path(root, relative)
.map_err(|cause| XHSMiniToolExportError::form_invalid(field, cause))?;
let metadata = std::fs::symlink_metadata(&path).map_err(|error| {
XHSMiniToolExportError::form_invalid(field, format!("icon 不存在或不可读:{error}"))
})?;
if metadata.file_type().is_symlink() {
return Err(XHSMiniToolExportError::form_invalid(
field,
"icon 不能是符号链接",
));
}
if !metadata.is_file() {
return Err(XHSMiniToolExportError::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(XHSMiniToolExportError::form_invalid(
field,
format!("icon 只支持 png、jpg、jpeg,当前是 .{extension}"),
));
}
if metadata.len() > ICON_MAX_BYTES {
return Err(XHSMiniToolExportError::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,
XHSMiniToolExportError::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,
XHSMiniToolExportError::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,
XHSMiniToolExportError::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,
XHSMiniToolExportError::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,
XHSMiniToolExportError::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,
XHSMiniToolExportError::FormInvalid(payload)
if payload.field == XHSMiniToolExportField::IconPath
));
}
}
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ArtifactMissing = {
/**
* 产物相对项目根的路径。
*/
path: string;
};
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type BuildScriptMissing = {
/**
* 期望的脚本名(`build:xhs-minitool`)。
*/
script: string;
/**
* 宿主找过哪些 `package.json`,让用户知道去哪看。
*/
searched: Array<string>;
};
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CommandFailed = { exitCode: number | null; outputTail: string };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExportUnavailable = { cause: string };
@@ -0,0 +1,4 @@
// 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 };
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RegistryMalformed = {
/**
* 解析器原文。它是引导语,不是给前端做判断的依据。
*/
cause: string;
};
@@ -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 type SaveConflict = {
/**
* 文件里现在是什么(字段级二选一的「文件里的」那一侧)。
*/
current: XHSMiniToolExportForm;
/**
* 冲突时文件的指纹;用户选完之后带着它重试保存。
*/
contentHash: string;
};
@@ -0,0 +1,17 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ArtifactMissing } from './ArtifactMissing';
import type { BuildScriptMissing } from './BuildScriptMissing';
import type { CommandFailed } from './CommandFailed';
import type { ExportUnavailable } from './ExportUnavailable';
import type { FormInvalid } from './FormInvalid';
import type { RegistryMalformed } from './RegistryMalformed';
import type { SaveConflict } from './SaveConflict';
export type XHSMiniToolExportError =
| ({ type: 'registryMalformed' } & RegistryMalformed)
| ({ type: 'formInvalid' } & FormInvalid)
| ({ type: 'buildScriptMissing' } & BuildScriptMissing)
| ({ type: 'saveConflict' } & SaveConflict)
| ({ type: 'commandFailed' } & CommandFailed)
| ({ type: 'artifactMissing' } & ArtifactMissing)
| ({ type: 'exportUnavailable' } & ExportUnavailable);
@@ -0,0 +1,6 @@
// 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';
@@ -0,0 +1,17 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这三个值的唯一用途是让用户
* 复制粘贴到小红书平台的上传页。
*
* `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能
* 被静默忽略后带着半份表单继续走。
*/
export type XHSMiniToolExportForm = {
name: string;
introduction: string;
/**
* 项目内相对路径。icon 文件与目标无关,多个导出目标可以共用同一个。
*/
iconPath: string;
};
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 一次成功导出的结果。产物路径是常量(`.export/xhs-minitool.zip`),不进 DTO。
*/
export type XHSMiniToolExportRunResult = {
exitCode: number | null;
/**
* 成功时为空;失败时是被截断的输出尾部(stdout 与 stderr 合并),够定位就行。
*/
outputTail: string;
};
@@ -0,0 +1,20 @@
// 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';
/**
* 面板读到的现场状态。
*
* 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」
* 永远从 npm 包的 `package.json` 现算,不落盘。
*/
export type XHSMiniToolExportState = {
form: XHSMiniToolExportForm;
/**
* `scripts["build:xhs-minitool"]` 是否存在。
*/
hasScript: boolean;
/**
* 注册表文件的当前内容指纹。保存时原样带回做冲突判据;它不写进文件。
*/
contentHash: string;
};
+4 -2
View File
@@ -30,6 +30,8 @@ const AGC_CHAT_DIR =
const AGC_SERVICES_DIR = 'apps/ai-game-creator-shell/src/services/generated';
const AGC_UI_EDITOR_DIR =
'apps/ai-game-creator-shell/src/features/ui-editor/types';
const AGC_EXPORT_DIR =
'apps/ai-game-creator-shell/src/view/project-development/export/generated';
/** 一套绑定的生成入口、覆盖目录与"怎么重新生成"的提示。 */
const targets = [
@@ -51,7 +53,7 @@ const targets = [
},
{
label: 'ai-game-creator-shell',
dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR],
dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR, AGC_EXPORT_DIR],
cargoArgs: [
'test',
'--locked',
@@ -70,7 +72,7 @@ function snapshot(dir) {
// "0 个文件"静默通过——`export_to` 路径被改名或生成文件被整体删除时,校验形同虚设。
throw new Error(`生成绑定目录不存在,无法校验:${dir}`);
}
// 四个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录);
// 五个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录);
// 不用 `recursive`,避免依赖 Node 18.17 / 20.1 才有的选项(旧版本会静默退化成只读顶层)。
const entries = fs.readdirSync(absolute);
return new Map(