初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
export function EditorEmptyState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
{message}
</div>
);
}

View File

@@ -0,0 +1,20 @@
type EditorNoticeTone = 'muted' | 'warning';
const TONE_CLASS_NAMES: Record<EditorNoticeTone, string> = {
muted: 'text-xs text-zinc-400',
warning: 'text-xs text-amber-200/90',
};
export function EditorNotice({
message,
tone = 'muted',
}: {
message: string | null;
tone?: EditorNoticeTone;
}) {
if (!message) {
return null;
}
return <div className={TONE_CLASS_NAMES[tone]}>{message}</div>;
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
import { SaveBar, SelectField, type SelectFieldOption } from './FormFields';
import { SectionCard } from './SectionCard';
export function EditorSelectionCard({
title,
description,
selectLabel,
selectValue,
onSelectChange,
selectOptions,
saveLabel,
onSave,
isSaving,
saveMessage,
children,
}: {
title: string;
description?: string;
selectLabel: string;
selectValue: string | number;
onSelectChange: (value: string) => void;
selectOptions: SelectFieldOption[];
saveLabel: string;
onSave: () => void;
isSaving: boolean;
saveMessage: string | null;
children?: ReactNode;
}) {
return (
<SectionCard title={title} description={description}>
<SelectField
label={selectLabel}
value={selectValue}
onChange={onSelectChange}
options={selectOptions}
/>
{children}
<SaveBar
saveLabel={saveLabel}
onSave={onSave}
isSaving={isSaving}
saveMessage={saveMessage}
/>
</SectionCard>
);
}

View File

@@ -0,0 +1,161 @@
import { Save } from 'lucide-react';
import { EditorNotice } from './EditorNotice';
function safeNumber(value: number) {
return Number.isFinite(value) ? value : 0;
}
function toNumber(value: string, fallback = 0) {
const next = Number(value);
return Number.isFinite(next) ? next : fallback;
}
export type SelectFieldOption = {
label: string;
value: string | number;
};
export function TextField({
label,
value,
onChange,
placeholder,
disabled = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
return (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
);
}
export function NumberField({
label,
value,
onChange,
min,
step = 1,
}: {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
step?: number;
}) {
return (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<input
type="number"
value={safeNumber(value)}
min={min}
step={step}
onChange={(event) => onChange(toNumber(event.target.value, value))}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40"
/>
</label>
);
}
export function TextAreaField({
label,
value,
onChange,
rows = 4,
placeholder,
disabled = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
disabled?: boolean;
}) {
return (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<textarea
rows={rows}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-relaxed text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
);
}
export function SelectField({
label,
value,
onChange,
options,
disabled = false,
}: {
label: string;
value: string | number;
onChange: (value: string) => void;
options: SelectFieldOption[];
disabled?: boolean;
}) {
return (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<select
value={String(value)}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{options.map((option) => (
<option key={`${label}-${option.value}`} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</label>
);
}
export function SaveBar({
saveLabel,
onSave,
isSaving,
saveMessage,
}: {
saveLabel: string;
onSave: () => void;
isSaving: boolean;
saveMessage: string | null;
}) {
return (
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onSave}
disabled={isSaving}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<Save className="h-4 w-4" />
<span>{isSaving ? '保存中...' : saveLabel}</span>
</button>
<EditorNotice message={saveMessage} />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
export function SectionCard({
title,
description,
children,
className = '',
}: {
title: string;
description?: string;
children: ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-2xl border border-white/10 bg-black/20 p-5 ${className}`}
>
<div className="mb-4">
<div className="text-sm font-semibold text-white">{title}</div>
{description && (
<div className="mt-1 text-xs leading-relaxed text-zinc-400">
{description}
</div>
)}
</div>
{children}
</section>
);
}

View File

@@ -0,0 +1,7 @@
export function cloneValue<T>(value: T): T {
if (typeof structuredClone === 'function') {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}

View File

@@ -0,0 +1,26 @@
import {describe, expect, it} from 'vitest';
import {parseApiErrorMessage} from './jsonClient';
describe('parseApiErrorMessage', () => {
it('prefers nested api error messages', () => {
expect(
parseApiErrorMessage(
JSON.stringify({
error: {
message: 'Detailed failure',
},
}),
'Fallback failure',
),
).toBe('Detailed failure');
});
it('falls back to the raw response text when the payload is not json', () => {
expect(parseApiErrorMessage('Plain text failure', 'Fallback failure')).toBe('Plain text failure');
});
it('uses the fallback when the response body is empty', () => {
expect(parseApiErrorMessage('', 'Fallback failure')).toBe('Fallback failure');
});
});

View File

@@ -0,0 +1,58 @@
type ApiErrorPayload = {
error?: {
message?: string;
};
message?: string;
};
export function parseApiErrorMessage(responseText: string, fallbackMessage: string) {
if (!responseText) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(responseText) as ApiErrorPayload;
if (parsed.error?.message) {
return parsed.error.message;
}
if (typeof parsed.message === 'string' && parsed.message.trim()) {
return parsed.message;
}
} catch {
// Fall through to the raw response text below.
}
return responseText;
}
export async function fetchJson<T>(
url: string,
fallbackMessage = '请求失败',
): Promise<T> {
const response = await fetch(url);
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, `${fallbackMessage}: ${response.status}`));
}
return responseText ? (JSON.parse(responseText) as T) : ({} as T);
}
export async function saveJsonObject(
url: string,
payload: Record<string, unknown>,
fallbackMessage = '保存失败',
) {
const response = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload, null, 2),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
}
}

View File

@@ -0,0 +1,45 @@
import { useState } from 'react';
import { saveJsonObject } from './jsonClient';
type UseJsonSaveOptions = {
endpoint: string;
payload: Record<string, unknown>;
validate?: () => string[];
successMessage: string;
errorMessage: string;
};
export function useJsonSave({
endpoint,
payload,
validate,
successMessage,
errorMessage,
}: UseJsonSaveOptions) {
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const save = async () => {
setIsSaving(true);
setSaveMessage(null);
const validationErrors = validate?.() ?? [];
if (validationErrors.length > 0) {
setSaveMessage(validationErrors.slice(0, 3).join(' | '));
setIsSaving(false);
return;
}
try {
await saveJsonObject(endpoint, payload);
setSaveMessage(successMessage);
} catch (error) {
setSaveMessage(error instanceof Error ? error.message : errorMessage);
} finally {
setIsSaving(false);
}
};
return { isSaving, saveMessage, save };
}