Refactor config API to use filename-based identifiers

This commit is contained in:
CPTProgrammer 2026-07-16 12:29:55 +08:00
parent c21d67b130
commit f90923736f
No known key found for this signature in database
7 changed files with 99 additions and 42 deletions

View File

@ -1 +1 @@
export const PROJECT_ROOT = __dirname; export const PROJECT_ROOT = process.cwd();

View File

@ -15,7 +15,7 @@ import {
theme, theme,
} from "antd"; } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { createConfig, updateConfig } from "@/services/config"; import { createConfig, updateConfig, previewFilename } from "@/services/config";
import { import {
getModrinthLoaders, getModrinthLoaders,
getModrinthEnvironments, getModrinthEnvironments,
@ -104,6 +104,18 @@ const RELATION_TYPES = Object.entries(RELATION_TYPE_LABEL_MAP).map(v => ({ value
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const STATIC_DEFS: StaticValidationDef<Config>[] = [ const STATIC_DEFS: StaticValidationDef<Config>[] = [
// 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 属性目录 // 1. MC 属性目录
{ {
target: "minecraft_properties_dir", target: "minecraft_properties_dir",
@ -393,7 +405,7 @@ type ConfigValues = Config;
interface ConfigModalProps { interface ConfigModalProps {
open: boolean; open: boolean;
mode: "add" | "edit"; mode: "add" | "edit";
initialName: string | null; initialFile: string | null;
initialConfig: Config | null; initialConfig: Config | null;
onCancel: () => void; onCancel: () => void;
onSaved: (file: string, name: string) => void; onSaved: (file: string, name: string) => void;
@ -402,7 +414,7 @@ interface ConfigModalProps {
export function ConfigModal({ export function ConfigModal({
open, open,
mode, mode,
initialName, initialFile,
initialConfig, initialConfig,
onCancel, onCancel,
onSaved, onSaved,
@ -429,13 +441,13 @@ export function ConfigModal({
setSaving(true); setSaving(true);
try { try {
if (mode === "add") { if (mode === "add") {
await createConfig(data); const { file } = await createConfig(data);
onSaved(`${data.name}.json`, data.name); onSaved(file, data.name);
message.success("配置已创建"); message.success("配置已创建");
} else { } else {
const oldName = (initialName ?? "").replace(/\.json$/, ""); const oldFile = initialFile ?? "";
await updateConfig(oldName, data); const { file } = await updateConfig(oldFile, data);
onSaved(`${data.name}.json`, data.name); onSaved(file, data.name);
message.success("配置已更新"); message.success("配置已更新");
} }
} catch (err) { } catch (err) {
@ -446,7 +458,7 @@ export function ConfigModal({
setSaving(false); setSaving(false);
} }
}, },
[mode, initialName, onSaved, message], [mode, initialFile, onSaved, message],
); );
// --------------- Render --------------- // --------------- Render ---------------
@ -599,13 +611,13 @@ function ConfigModalForm({
label: "通用", label: "通用",
children: ( children: (
<> <>
<Form.Item <ValidatedFormItem
name="name" name="name"
label="配置名称" label="配置名称"
rules={[{ required: true, message: "请输入配置名称" }]} rules={[{ required: true, message: "请输入配置名称" }]}
> >
<Input placeholder="模组名称" /> <Input placeholder="模组名称" />
</Form.Item> </ValidatedFormItem>
<Form.Item <Form.Item
name="project_dir" name="project_dir"

View File

@ -22,11 +22,6 @@ interface ConfigSelectorProps {
onConfigUpdated: (entry: ConfigEntry) => void; onConfigUpdated: (entry: ConfigEntry) => void;
} }
/** Strip .json suffix to get the config name */
function toConfigName(file: string): string {
return file.replace(/\.json$/, "");
}
export function ConfigSelector({ export function ConfigSelector({
configs, configs,
selectedFile, selectedFile,
@ -41,7 +36,7 @@ export function ConfigSelector({
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"add" | "edit">("add"); const [modalMode, setModalMode] = useState<"add" | "edit">("add");
const [modalInitial, setModalInitial] = useState<{ const [modalInitial, setModalInitial] = useState<{
name: string; file: string;
config: Config; config: Config;
} | null>(null); } | null>(null);
@ -50,7 +45,7 @@ export function ConfigSelector({
async (file: string) => { async (file: string) => {
setSelecting(true); setSelecting(true);
try { try {
const config = await getConfig(toConfigName(file)); const config = await getConfig(file);
const project = await parseProject(config); const project = await parseProject(config);
onSelect(file, config, project); onSelect(file, config, project);
} catch (err) { } catch (err) {
@ -78,9 +73,9 @@ export function ConfigSelector({
return; return;
} }
try { try {
const config = await getConfig(toConfigName(selectedFile)); const config = await getConfig(selectedFile);
setModalMode("edit"); setModalMode("edit");
setModalInitial({ name: selectedFile, config }); setModalInitial({ file: selectedFile, config });
setModalOpen(true); setModalOpen(true);
} catch (err) { } catch (err) {
message.error("读取配置失败"); message.error("读取配置失败");
@ -125,7 +120,7 @@ export function ConfigSelector({
<ConfigModal <ConfigModal
open={modalOpen} open={modalOpen}
mode={modalMode} mode={modalMode}
initialName={modalInitial?.name ?? null} initialFile={modalInitial?.file ?? null}
initialConfig={modalInitial?.config ?? null} initialConfig={modalInitial?.config ?? null}
onCancel={() => setModalOpen(false)} onCancel={() => setModalOpen(false)}
onSaved={handleModalSaved} onSaved={handleModalSaved}

View File

@ -283,10 +283,14 @@ export function useFieldValidations<T>(
function renderSlot( function renderSlot(
slot: ValidationSlot, slot: ValidationSlot,
token: { colorSuccess: string; colorError: string; marginXXS: number }, token: { colorSuccess: string; colorError: string; colorTextDescription: string; marginXXS: number },
): React.ReactNode { ): React.ReactNode {
const color = slot.status === "success" ? token.colorSuccess : token.colorError; const neutral = slot.status === "";
const icon = slot.status === "success" const color = neutral ? token.colorTextDescription
: slot.status === "success" ? token.colorSuccess
: token.colorError;
const icon = neutral ? null
: slot.status === "success"
? <CheckOutlined style={{ marginInlineEnd: token.marginXXS }} /> ? <CheckOutlined style={{ marginInlineEnd: token.marginXXS }} />
: <CloseOutlined style={{ marginInlineEnd: token.marginXXS }} />; : <CloseOutlined style={{ marginInlineEnd: token.marginXXS }} />;
return <span style={{ color }}>{icon}{slot.help}</span>; return <span style={{ color }}>{icon}{slot.help}</span>;

View File

@ -1,6 +1,8 @@
"use server"; "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 path from "path";
import { ConfigSchema, type Config } from "@/types"; import { ConfigSchema, type Config } from "@/types";
import { PROJECT_ROOT } from "root"; import { PROJECT_ROOT } from "root";
@ -9,10 +11,51 @@ function configDir(): string {
return path.join(PROJECT_ROOT, "configs"); 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<string> {
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<string> {
return `${slugify(name)}-HASH.json`;
}
// ---------------------------------------------------------------------------
// 公开 API — 以 file文件名作为唯一标识
// ---------------------------------------------------------------------------
export async function listConfigs(): Promise<{ name: string; file: string }[]> { export async function listConfigs(): Promise<{ name: string; file: string }[]> {
const dir = configDir(); const dir = configDir();
@ -42,45 +85,48 @@ export async function listConfigs(): Promise<{ name: string; file: string }[]> {
return results; return results;
} }
export async function getConfig(name: string): Promise<Config> { export async function getConfig(file: string): Promise<Config> {
const fp = configPath(name); const fp = path.join(configDir(), file);
let raw: string; let raw: string;
try { try {
raw = await readFile(fp, "utf-8"); raw = await readFile(fp, "utf-8");
} catch { } catch {
throw new Error(`Config "${name}" not found`); throw new Error(`Config file "${file}" not found`);
} }
const data = JSON.parse(raw); const data = JSON.parse(raw);
return ConfigSchema.parse(data); return ConfigSchema.parse(data);
} }
export async function createConfig(data: Config): Promise<void> { export async function createConfig(data: Config): Promise<{ file: string }> {
const fp = configPath(data.name); const filename = await generateFilename(data.name);
const json = JSON.stringify(data, null, 2); 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<void> { export async function updateConfig(file: string, data: Config): Promise<{ file: string }> {
const fp = configPath(name); const fp = path.join(configDir(), file);
try { try {
await readFile(fp, "utf-8"); await readFile(fp, "utf-8");
} catch { } 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); const json = JSON.stringify(data, null, 2);
await writeFile(fp, json, "utf-8"); await writeFile(fp, json, "utf-8");
return { file };
} }
export async function deleteConfig(name: string): Promise<void> { export async function deleteConfig(file: string): Promise<void> {
const fp = configPath(name); const fp = path.join(configDir(), file);
try { try {
await unlink(fp); await unlink(fp);
} catch { } catch {
throw new Error(`Config "${name}" not found`); throw new Error(`Config file "${file}" not found`);
} }
} }

View File

@ -27,7 +27,7 @@ export type PlatformAdaptor = {
// ── Schemas ──────────────────────────────────────────── // ── Schemas ────────────────────────────────────────────
export const PublishInputSchema = z.object({ export const PublishInputSchema = z.object({
configName: z.string(), configFile: z.string(),
version: z.string(), version: z.string(),
mcVersions: z.object(createRecord( mcVersions: z.object(createRecord(
platforms, z.array(z.object({ mc_version: z.string() })) platforms, z.array(z.object({ mc_version: z.string() }))

View File

@ -133,7 +133,7 @@ export const appRouter = t.router({
publish: t.procedure publish: t.procedure
.input(PublishInputSchema) .input(PublishInputSchema)
.mutation(async ({ input }): Promise<PublishResult> => { .mutation(async ({ input }): Promise<PublishResult> => {
const config = await getConfig(input.configName); const config = await getConfig(input.configFile);
const ctx: PlatformContext = { input, config }; const ctx: PlatformContext = { input, config };