7
src/editor/shared/EditorEmptyState.tsx
Normal file
7
src/editor/shared/EditorEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/editor/shared/EditorNotice.tsx
Normal file
20
src/editor/shared/EditorNotice.tsx
Normal 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>;
|
||||
}
|
||||
48
src/editor/shared/EditorSelectionCard.tsx
Normal file
48
src/editor/shared/EditorSelectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/editor/shared/FormFields.tsx
Normal file
161
src/editor/shared/FormFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/editor/shared/SectionCard.tsx
Normal file
29
src/editor/shared/SectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/editor/shared/cloneValue.ts
Normal file
7
src/editor/shared/cloneValue.ts
Normal 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;
|
||||
}
|
||||
26
src/editor/shared/jsonClient.test.ts
Normal file
26
src/editor/shared/jsonClient.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
58
src/editor/shared/jsonClient.ts
Normal file
58
src/editor/shared/jsonClient.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
45
src/editor/shared/useJsonSave.ts
Normal file
45
src/editor/shared/useJsonSave.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user