diff --git a/root.ts b/root.ts index e98bf33..6e41042 100644 --- a/root.ts +++ b/root.ts @@ -1 +1 @@ -export const PROJECT_ROOT = __dirname; +export const PROJECT_ROOT = process.cwd(); diff --git a/src/components/ConfigModal.tsx b/src/components/ConfigModal.tsx index c4e5db9..c53ed36 100644 --- a/src/components/ConfigModal.tsx +++ b/src/components/ConfigModal.tsx @@ -15,7 +15,7 @@ import { theme, } from "antd"; import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; -import { createConfig, updateConfig } from "@/services/config"; +import { createConfig, updateConfig, previewFilename } from "@/services/config"; import { getModrinthLoaders, getModrinthEnvironments, @@ -104,6 +104,18 @@ const RELATION_TYPES = Object.entries(RELATION_TYPE_LABEL_MAP).map(v => ({ value // --------------------------------------------------------------------------- const STATIC_DEFS: StaticValidationDef[] = [ + // 0. 配置名称 — 预览文件名 + { + target: "name", + deps: ["name"], + debounce: 50, + async validate(getVal) { + const name = getVal("name") as string | undefined; + if (!name) return null; + const preview = await previewFilename(name); + return { status: "", help: `文件名:${preview}` }; + }, + }, // 1. MC 属性目录 { target: "minecraft_properties_dir", @@ -393,7 +405,7 @@ type ConfigValues = Config; interface ConfigModalProps { open: boolean; mode: "add" | "edit"; - initialName: string | null; + initialFile: string | null; initialConfig: Config | null; onCancel: () => void; onSaved: (file: string, name: string) => void; @@ -402,7 +414,7 @@ interface ConfigModalProps { export function ConfigModal({ open, mode, - initialName, + initialFile, initialConfig, onCancel, onSaved, @@ -429,13 +441,13 @@ export function ConfigModal({ setSaving(true); try { if (mode === "add") { - await createConfig(data); - onSaved(`${data.name}.json`, data.name); + const { file } = await createConfig(data); + onSaved(file, data.name); message.success("配置已创建"); } else { - const oldName = (initialName ?? "").replace(/\.json$/, ""); - await updateConfig(oldName, data); - onSaved(`${data.name}.json`, data.name); + const oldFile = initialFile ?? ""; + const { file } = await updateConfig(oldFile, data); + onSaved(file, data.name); message.success("配置已更新"); } } catch (err) { @@ -446,7 +458,7 @@ export function ConfigModal({ setSaving(false); } }, - [mode, initialName, onSaved, message], + [mode, initialFile, onSaved, message], ); // --------------- Render --------------- @@ -599,13 +611,13 @@ function ConfigModalForm({ label: "通用", children: ( <> - - + void; } -/** Strip .json suffix to get the config name */ -function toConfigName(file: string): string { - return file.replace(/\.json$/, ""); -} - export function ConfigSelector({ configs, selectedFile, @@ -41,7 +36,7 @@ export function ConfigSelector({ const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState<"add" | "edit">("add"); const [modalInitial, setModalInitial] = useState<{ - name: string; + file: string; config: Config; } | null>(null); @@ -50,7 +45,7 @@ export function ConfigSelector({ async (file: string) => { setSelecting(true); try { - const config = await getConfig(toConfigName(file)); + const config = await getConfig(file); const project = await parseProject(config); onSelect(file, config, project); } catch (err) { @@ -78,9 +73,9 @@ export function ConfigSelector({ return; } try { - const config = await getConfig(toConfigName(selectedFile)); + const config = await getConfig(selectedFile); setModalMode("edit"); - setModalInitial({ name: selectedFile, config }); + setModalInitial({ file: selectedFile, config }); setModalOpen(true); } catch (err) { message.error("读取配置失败"); @@ -125,7 +120,7 @@ export function ConfigSelector({ setModalOpen(false)} onSaved={handleModalSaved} diff --git a/src/hooks/useFieldValidations.tsx b/src/hooks/useFieldValidations.tsx index 978bb88..ed02530 100644 --- a/src/hooks/useFieldValidations.tsx +++ b/src/hooks/useFieldValidations.tsx @@ -283,10 +283,14 @@ export function useFieldValidations( function renderSlot( slot: ValidationSlot, - token: { colorSuccess: string; colorError: string; marginXXS: number }, + token: { colorSuccess: string; colorError: string; colorTextDescription: string; marginXXS: number }, ): React.ReactNode { - const color = slot.status === "success" ? token.colorSuccess : token.colorError; - const icon = slot.status === "success" + const neutral = slot.status === ""; + const color = neutral ? token.colorTextDescription + : slot.status === "success" ? token.colorSuccess + : token.colorError; + const icon = neutral ? null + : slot.status === "success" ? : ; return {icon}{slot.help}; diff --git a/src/services/config.ts b/src/services/config.ts index 0bab53e..12fa647 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,6 +1,8 @@ "use server"; -import { readdir, readFile, writeFile, unlink } from "fs/promises"; +import { readdir, readFile, writeFile, unlink, access } from "fs/promises"; +import { constants } from "fs"; +import { randomBytes } from "crypto"; import path from "path"; import { ConfigSchema, type Config } from "@/types"; import { PROJECT_ROOT } from "root"; @@ -9,10 +11,51 @@ function configDir(): string { return path.join(PROJECT_ROOT, "configs"); } -function configPath(name: string): string { - return path.join(configDir(), `${name}.json`); +// --------------------------------------------------------------------------- +// 文件名生成 — slug(name) + 4 位 base36 时间哈希,终身不变 +// --------------------------------------------------------------------------- + +function slugify(name: string): string { + return name + .replace(/[<>:"/\\|?*\x00]/g, "") // 去掉 OS 非法字符 + .replace(/\s+/g, "-") // 空格 → 连字符 + .replace(/-+/g, "-") // 合并连续连字符 + .replace(/[^a-zA-Z0-9\u4e00-\u9fff\-]/g, "") // 只保留安全字符 + .replace(/^-+|-+$/g, "") // 去首尾连字符 + .toLowerCase() + || "unnamed"; } +async function generateFilename(name: string): Promise { + const slug = slugify(name); + const dir = configDir(); + + let filename: string; + do { + const hash = randomBytes(2).toString("hex"); + filename = `${slug}-${hash}.json`; + + // 极端情况碰撞:稍等 1ms 重新生成 hash + try { + await access(path.join(dir, filename), constants.F_OK); + await new Promise((r) => setTimeout(r, 1)); + } catch { + break; // 文件不存在,可用 + } + } while (true); + + return filename; +} + +/** 预览文件名,hash 位替换为占位符,供前端实时展示 */ +export async function previewFilename(name: string): Promise { + return `${slugify(name)}-HASH.json`; +} + +// --------------------------------------------------------------------------- +// 公开 API — 以 file(文件名)作为唯一标识 +// --------------------------------------------------------------------------- + export async function listConfigs(): Promise<{ name: string; file: string }[]> { const dir = configDir(); @@ -42,45 +85,48 @@ export async function listConfigs(): Promise<{ name: string; file: string }[]> { return results; } -export async function getConfig(name: string): Promise { - const fp = configPath(name); +export async function getConfig(file: string): Promise { + const fp = path.join(configDir(), file); let raw: string; try { raw = await readFile(fp, "utf-8"); } catch { - throw new Error(`Config "${name}" not found`); + throw new Error(`Config file "${file}" not found`); } const data = JSON.parse(raw); return ConfigSchema.parse(data); } -export async function createConfig(data: Config): Promise { - const fp = configPath(data.name); +export async function createConfig(data: Config): Promise<{ file: string }> { + const filename = await generateFilename(data.name); const json = JSON.stringify(data, null, 2); - await writeFile(fp, json, "utf-8"); + await writeFile(path.join(configDir(), filename), json, "utf-8"); + return { file: filename }; } -export async function updateConfig(name: string, data: Config): Promise { - const fp = configPath(name); +export async function updateConfig(file: string, data: Config): Promise<{ file: string }> { + const fp = path.join(configDir(), file); try { await readFile(fp, "utf-8"); } catch { - throw new Error(`Config "${name}" not found`); + throw new Error(`Config file "${file}" not found`); } + // 文件名由创建时决定,update 不改文件名(即使 data.name 变了) const json = JSON.stringify(data, null, 2); await writeFile(fp, json, "utf-8"); + return { file }; } -export async function deleteConfig(name: string): Promise { - const fp = configPath(name); +export async function deleteConfig(file: string): Promise { + const fp = path.join(configDir(), file); try { await unlink(fp); } catch { - throw new Error(`Config "${name}" not found`); + throw new Error(`Config file "${file}" not found`); } } diff --git a/src/services/publish.schemas.ts b/src/services/publish.schemas.ts index 51f4f66..d8e92d3 100644 --- a/src/services/publish.schemas.ts +++ b/src/services/publish.schemas.ts @@ -27,7 +27,7 @@ export type PlatformAdaptor = { // ── Schemas ──────────────────────────────────────────── export const PublishInputSchema = z.object({ - configName: z.string(), + configFile: z.string(), version: z.string(), mcVersions: z.object(createRecord( platforms, z.array(z.object({ mc_version: z.string() })) diff --git a/src/services/publish.ts b/src/services/publish.ts index e80348f..8248cd3 100644 --- a/src/services/publish.ts +++ b/src/services/publish.ts @@ -133,7 +133,7 @@ export const appRouter = t.router({ publish: t.procedure .input(PublishInputSchema) .mutation(async ({ input }): Promise => { - const config = await getConfig(input.configName); + const config = await getConfig(input.configFile); const ctx: PlatformContext = { input, config };