Refactor config API to use filename-based identifiers
This commit is contained in:
parent
c21d67b130
commit
f90923736f
2
root.ts
2
root.ts
@ -1 +1 @@
|
|||||||
export const PROJECT_ROOT = __dirname;
|
export const PROJECT_ROOT = process.cwd();
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() }))
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user