feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
503
apps/web/app/admin/ai-config/page.tsx
Normal file
503
apps/web/app/admin/ai-config/page.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sparkles, Plus, CheckCircle2, AlertTriangle, Loader2, Trash2, Pencil, X } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
AiConfigPublic,
|
||||
AiProviderPublic,
|
||||
AiProviderUpsertInput,
|
||||
AiProviderFormat,
|
||||
} from '@ftb/shared';
|
||||
|
||||
const FORMAT_LABEL: Record<AiProviderFormat, string> = {
|
||||
anthropic: 'Anthropic 兼容',
|
||||
openai: 'OpenAI 兼容',
|
||||
};
|
||||
|
||||
const FORMAT_HINT: Record<AiProviderFormat, string> = {
|
||||
anthropic: 'Anthropic 官方 + 兼容 Anthropic 格式的中转站',
|
||||
openai: 'OpenAI 官方 + 兼容 OpenAI Chat Completions 格式的中转站',
|
||||
};
|
||||
|
||||
const PRESETS: Array<{ key: string; label: string; data: Partial<AiProviderUpsertInput> & { format: AiProviderFormat; model: string } }> = [
|
||||
{
|
||||
key: 'anthropic-official',
|
||||
label: 'Anthropic 官方',
|
||||
data: { id: 'anthropic-official', name: 'Anthropic 官方', format: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
|
||||
},
|
||||
{
|
||||
key: 'openai-official',
|
||||
label: 'OpenAI 官方',
|
||||
data: { id: 'openai-official', name: 'OpenAI 官方', format: 'openai', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o' },
|
||||
},
|
||||
{
|
||||
key: 'custom-anthropic',
|
||||
label: '+ 自定义(Anthropic 兼容)',
|
||||
data: { id: '', name: '', format: 'anthropic', baseURL: '', model: 'claude-sonnet-4-6' },
|
||||
},
|
||||
{
|
||||
key: 'custom-openai',
|
||||
label: '+ 自定义(OpenAI 兼容)',
|
||||
data: { id: '', name: '', format: 'openai', baseURL: '', model: 'gpt-4o' },
|
||||
},
|
||||
];
|
||||
|
||||
export default function AiConfigPage() {
|
||||
return (
|
||||
<SuperAdminGuard>
|
||||
<AiConfigContent />
|
||||
</SuperAdminGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function SuperAdminGuard({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId));
|
||||
const isSuperAdmin = !!role && role.permissions.includes('*');
|
||||
if (!isSuperAdmin) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-[14px] text-[var(--ink-soft)]">仅超级管理员可访问 AI 配置</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AiConfigContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [config, setConfig] = useState<AiConfigPublic | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<AiProviderPublic | null>(null);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>({});
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const c = await api.get<AiConfigPublic>('/config/ai');
|
||||
setConfig(c);
|
||||
} catch (e: any) {
|
||||
setActionMessage(`读取配置失败:${e.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
const handleActivate = async (id: string) => {
|
||||
try {
|
||||
const next = await api.post<AiConfigPublic>('/config/ai/activate', { id, operator: user?.name });
|
||||
setConfig(next);
|
||||
setActionMessage(`已激活:${next.providers.find((p) => p.id === id)?.name}`);
|
||||
} catch (e: any) {
|
||||
setActionMessage(`激活失败:${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确认删除提供商 "${name}"?`)) return;
|
||||
try {
|
||||
const next = await api.delete<AiConfigPublic>(`/config/ai/providers/${id}?operator=${encodeURIComponent(user?.name || '')}`);
|
||||
setConfig(next);
|
||||
setActionMessage(`已删除:${name}`);
|
||||
} catch (e: any) {
|
||||
setActionMessage(`删除失败:${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting(id);
|
||||
setTestResults((prev) => ({ ...prev, [id]: undefined as any }));
|
||||
try {
|
||||
const r = await api.post<{ ok: boolean; message?: string; error?: string }>('/config/ai/test', { id });
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[id]: { ok: r.ok, message: r.ok ? r.message || '连接成功' : r.error || '连接失败' },
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setTestResults((prev) => ({ ...prev, [id]: { ok: false, message: e.message } }));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activeProvider = config?.providers.find((p) => p.isActive);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<Sparkles className="h-4 w-4 text-purple-600" />
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">AI 配置</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-[13px] text-[var(--ink-muted)]">加载中…</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 当前激活 */}
|
||||
<section className="rounded-2xl border border-purple-200 bg-purple-50/50 p-5">
|
||||
<h2 className="text-[12px] font-semibold text-purple-700 uppercase tracking-wide mb-2">当前激活</h2>
|
||||
{activeProvider ? (
|
||||
<div>
|
||||
<div className="text-[15px] font-semibold text-[var(--ink)]">{activeProvider.name}</div>
|
||||
<div className="mt-1 text-[12px] text-[var(--ink-soft)]">
|
||||
{FORMAT_LABEL[activeProvider.format]} · {activeProvider.baseURL}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[var(--ink-soft)] font-mono">
|
||||
{activeProvider.keyMask} · {activeProvider.model}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[13px] text-amber-700">尚未配置任何提供商,AI 拆解功能不可用</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 操作消息 */}
|
||||
{actionMessage && (
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-[12px] text-blue-700 flex items-center justify-between">
|
||||
<span>{actionMessage}</span>
|
||||
<button onClick={() => setActionMessage(null)} className="text-blue-500 hover:text-blue-700">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提供商列表 */}
|
||||
<section className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">已配置的提供商</h2>
|
||||
<button
|
||||
onClick={() => { setEditingProvider(null); setShowModal(true); }}
|
||||
className="flex items-center gap-1 h-8 px-3 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config?.providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] py-12 text-center">
|
||||
<p className="text-[13px] text-[var(--ink-muted)]">还没有任何提供商,点上方"新增"添加</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config?.providers.map((p) => (
|
||||
<ProviderRow
|
||||
key={p.id}
|
||||
provider={p}
|
||||
testing={testing === p.id}
|
||||
testResult={testResults[p.id]}
|
||||
onActivate={() => handleActivate(p.id)}
|
||||
onEdit={() => { setEditingProvider(p); setShowModal(true); }}
|
||||
onDelete={() => handleDelete(p.id, p.name)}
|
||||
onTest={() => handleTest(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<ProviderModal
|
||||
provider={editingProvider}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSaved={(next) => {
|
||||
setConfig(next);
|
||||
setShowModal(false);
|
||||
setActionMessage('保存成功');
|
||||
}}
|
||||
operator={user?.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
provider,
|
||||
testing,
|
||||
testResult,
|
||||
onActivate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
}: {
|
||||
provider: AiProviderPublic;
|
||||
testing: boolean;
|
||||
testResult?: { ok: boolean; message: string };
|
||||
onActivate: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${provider.isActive ? 'border-purple-300 bg-purple-50/30' : 'border-[var(--line)]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[14px] font-semibold text-[var(--ink)]">{provider.name}</span>
|
||||
{provider.isActive && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-600 text-white font-medium">已激活</span>
|
||||
)}
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
|
||||
{FORMAT_LABEL[provider.format]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--ink-soft)] truncate">{provider.baseURL}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)] font-mono">
|
||||
{provider.keyMask} · {provider.model}
|
||||
</div>
|
||||
{provider.remark && <div className="mt-1 text-[11px] text-[var(--ink-muted)]">{provider.remark}</div>}
|
||||
{testResult && (
|
||||
<div className={`mt-2 inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border ${
|
||||
testResult.ok
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||||
: 'bg-rose-50 border-rose-200 text-rose-700'
|
||||
}`}>
|
||||
{testResult.ok ? <CheckCircle2 className="h-3 w-3" /> : <AlertTriangle className="h-3 w-3" />}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 shrink-0">
|
||||
{!provider.isActive && (
|
||||
<button
|
||||
onClick={onActivate}
|
||||
className="h-7 px-3 rounded text-[11px] font-medium bg-purple-600 text-white hover:bg-purple-700"
|
||||
>
|
||||
激活
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="h-7 px-3 rounded text-[11px] font-medium border border-rose-200 text-rose-600 hover:bg-rose-50 inline-flex items-center justify-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModal({
|
||||
provider,
|
||||
onClose,
|
||||
onSaved,
|
||||
operator,
|
||||
}: {
|
||||
provider: AiProviderPublic | null;
|
||||
onClose: () => void;
|
||||
onSaved: (next: AiConfigPublic) => void;
|
||||
operator?: string;
|
||||
}) {
|
||||
const isEdit = !!provider;
|
||||
const [id, setId] = useState(provider?.id || '');
|
||||
const [name, setName] = useState(provider?.name || '');
|
||||
const [format, setFormat] = useState<AiProviderFormat>(provider?.format || 'anthropic');
|
||||
const [baseURL, setBaseURL] = useState(provider?.baseURL || '');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState(provider?.model || 'claude-sonnet-4-6');
|
||||
const [remark, setRemark] = useState(provider?.remark || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applyPreset = (presetKey: string) => {
|
||||
const p = PRESETS.find((x) => x.key === presetKey);
|
||||
if (!p) return;
|
||||
setId(p.data.id || '');
|
||||
setName(p.data.name || '');
|
||||
setFormat(p.data.format);
|
||||
setBaseURL(p.data.baseURL || '');
|
||||
setModel(p.data.model);
|
||||
};
|
||||
|
||||
const canSubmit = id.trim() && name.trim() && baseURL.trim() && model.trim() && (isEdit || apiKey.trim());
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload: AiProviderUpsertInput & { operator?: string } = {
|
||||
id: id.trim(),
|
||||
name: name.trim(),
|
||||
format,
|
||||
baseURL: baseURL.trim(),
|
||||
model: model.trim(),
|
||||
remark: remark.trim() || undefined,
|
||||
operator,
|
||||
};
|
||||
if (apiKey.trim()) payload.apiKey = apiKey.trim();
|
||||
const next = await api.patch<AiConfigPublic>('/config/ai/providers', payload);
|
||||
onSaved(next);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[14px] font-semibold text-[var(--ink)]">{isEdit ? '编辑提供商' : '新增提供商'}</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1.5">快速选择预设</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => applyPreset(p.key)}
|
||||
className="h-7 px-2.5 rounded-md border border-[var(--line)] text-[11px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">ID *</label>
|
||||
<input
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder="ikuncode"
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">显示名 *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="ikuncode"
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">API 格式 *</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['anthropic', 'openai'] as AiProviderFormat[]).map((f) => (
|
||||
<label
|
||||
key={f}
|
||||
className={`p-3 rounded-lg border cursor-pointer ${
|
||||
format === f ? 'border-purple-400 bg-purple-50' : 'border-[var(--line)] hover:bg-[var(--bg-subtle)]'
|
||||
}`}
|
||||
>
|
||||
<input type="radio" name="format" checked={format === f} onChange={() => setFormat(f)} className="mr-2" />
|
||||
<span className="text-[13px] font-medium text-[var(--ink)]">{FORMAT_LABEL[f]}</span>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--ink-muted)]">{FORMAT_HINT[f]}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">Base URL *</label>
|
||||
<input
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
placeholder="https://api.ikuncode.com"
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">
|
||||
API Key {isEdit ? '(留空则保留原 Key)' : '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={isEdit ? provider!.keyMask : 'sk-...'}
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">出于安全考虑,Key 输入后无法再被读出,仅可重新填写覆盖</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">模型 *</label>
|
||||
<input
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={format === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o'}
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">
|
||||
中转站不会替你选模型 — 必须填写一个该 baseURL 支持的模型 ID(如 claude-sonnet-4-6、claude-opus-4-7、gpt-4o、deepseek-v3 等)。请查阅中转站文档。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">备注</label>
|
||||
<input
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
placeholder="公司账号 / 个人测试 / ..."
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-[12px] text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
|
||||
<button onClick={onClose} className="h-9 px-3 rounded-lg text-[13px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!canSubmit || saving}
|
||||
className="h-9 px-4 rounded-lg text-[13px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user