feat(web): 重构前端UI并支持OpenAI协议
- 添加账号管理详情页(基本信息、提示词、CapCut、参考图标签页) - 重构资产页面,按项目组分开展示图片/视频 - 聊天界面支持深度思考内容折叠展示、复制、删除消息 - 设置页面支持Agent配置(Anthropic/OpenAI协议)和工具配置 - 后端支持OpenAI兼容协议流式输出和DeepSeek思考模式 - 添加对话置顶/删除功能、数据库迁移、资产清单API - 添加账号参考图上传/删除、技能配置持久化、连接测试API
This commit is contained in:
@@ -2,68 +2,164 @@ import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { api } from '@/lib/api';
|
||||
import { Save, Check, Loader2, TestTube2 } from 'lucide-react';
|
||||
|
||||
type SkillsConfig = Record<string, unknown>;
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: 'CapCut / 剪映',
|
||||
fields: [
|
||||
{ key: 'jianyingDraftPath', label: '剪映草稿路径', placeholder: 'C:/Users/.../com.lveditor.draft' },
|
||||
{ key: 'capcutMateDir', label: 'CapCut Mate 目录', placeholder: 'C:/Users/.../capcut-mate' },
|
||||
{ key: 'capcutMateApiBase', label: 'CapCut Mate API', placeholder: 'http://localhost:30000' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Gemini 生图',
|
||||
fields: [
|
||||
{ key: 'geminiApiBaseUrl', label: 'API Base URL', placeholder: 'https://generativelanguage.googleapis.com' },
|
||||
{ key: 'geminiApiKey', label: 'API Key', type: 'password' as const },
|
||||
{ key: 'geminiModel', label: '模型', placeholder: 'gemini-3.1-flash-image-preview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Midjourney',
|
||||
fields: [
|
||||
{ key: 'mjApiBaseUrl', label: 'API Base URL' },
|
||||
{ key: 'mjApiKey', label: 'API Key', type: 'password' as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Veo 视频生成',
|
||||
fields: [
|
||||
{ key: 'veoApiBaseUrl', label: 'API Base URL' },
|
||||
{ key: 'veoApiKey', label: 'API Key', type: 'password' as const },
|
||||
{ key: 'veoModel', label: '模型', placeholder: 'veo3-fast-frames' },
|
||||
{ key: 'veoEnhancePrompt', label: '增强提示词', type: 'toggle' as const },
|
||||
{ key: 'veoEnableUpsample', label: '启用 Upsample', type: 'toggle' as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Grok 视频生成',
|
||||
fields: [
|
||||
{ key: 'grokApiBaseUrl', label: 'API Base URL' },
|
||||
{ key: 'grokApiKey', label: 'API Key', type: 'password' as const },
|
||||
{ key: 'grokModel', label: '模型', placeholder: 'grok-video-3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'GPT Image',
|
||||
fields: [
|
||||
{ key: 'gptImageApiBaseUrl', label: 'API Base URL' },
|
||||
{ key: 'gptImageApiKey', label: 'API Key', type: 'password' as const },
|
||||
{ key: 'gptImageModel', label: '模型', placeholder: 'gpt-image-2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Kling 可灵',
|
||||
fields: [
|
||||
{ key: 'kelingApiBaseUrl', label: 'API Base URL', placeholder: 'https://api-beijing.klingai.com' },
|
||||
{ key: 'kelingApiKey', label: 'API Key', type: 'password' as const },
|
||||
{ key: 'kelingSecretAccessKey', label: 'Secret Key', type: 'password' as const },
|
||||
{ key: 'kelingModel', label: '模型', placeholder: 'kling-v2-5-turbo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'OSS 存储',
|
||||
fields: [
|
||||
{ key: 'ossRegion', label: 'Region', placeholder: 'oss-cn-hangzhou' },
|
||||
{ key: 'ossAccessKeyId', label: 'Access Key ID' },
|
||||
{ key: 'ossAccessKeySecret', label: 'Access Key Secret', type: 'password' as const },
|
||||
{ key: 'ossBucket', label: 'Bucket', placeholder: 'my-bucket' },
|
||||
{ key: 'ossFolder', label: '目录前缀', placeholder: 'tmp/' },
|
||||
{ key: 'ossExpires', label: '链接有效期(秒)', placeholder: '31536000' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'TTS 语音合成',
|
||||
fields: [
|
||||
{ key: 'ttsApiBaseUrl', label: 'API Base URL', placeholder: 'https://dashscope.aliyuncs.com/api/v1' },
|
||||
{ key: 'ttsApiKey', label: 'API Key', type: 'password' as const },
|
||||
{ key: 'ttsModel', label: '模型', placeholder: 'cosyvoice-v3.5-plus' },
|
||||
{ key: 'ttsVoice', label: '音色 ID', placeholder: 'cosyvoice-v3.5-plus-bailian-xxx' },
|
||||
{ key: 'ttsLanguage', label: '语言', placeholder: 'Chinese' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigForm() {
|
||||
const [form, setForm] = useState({
|
||||
const [tab, setTab] = useState<'agent' | 'skills'>('agent');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; models?: string[] } | null>(null);
|
||||
|
||||
// Agent config (DB-based)
|
||||
const [agentForm, setAgentForm] = useState({
|
||||
protocol: 'anthropic' as 'anthropic' | 'openai',
|
||||
model: '',
|
||||
baseUrl: '',
|
||||
authToken: '',
|
||||
defaultImageModel: '',
|
||||
defaultVideoModel: '',
|
||||
defaultFormat: '',
|
||||
ossEndpoint: '',
|
||||
ossBucket: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Skills config (file-based)
|
||||
const [skillsForm, setSkillsForm] = useState<SkillsConfig>({});
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfigs().then((list) => {
|
||||
const next = { ...form };
|
||||
const next = { ...agentForm };
|
||||
for (const item of list) {
|
||||
try {
|
||||
const v = item.value as Record<string, string>;
|
||||
const v = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
|
||||
if (item.key === 'api_keys') {
|
||||
if (v.ANTHROPIC_MODEL) next.model = v.ANTHROPIC_MODEL;
|
||||
if (v.ANTHROPIC_BASE_URL) next.baseUrl = v.ANTHROPIC_BASE_URL;
|
||||
if (v.ANTHROPIC_AUTH_TOKEN) next.authToken = v.ANTHROPIC_AUTH_TOKEN;
|
||||
if (v.ANTHROPIC_MODEL) next.model = v.ANTHROPIC_MODEL as string;
|
||||
if (v.ANTHROPIC_BASE_URL) next.baseUrl = v.ANTHROPIC_BASE_URL as string;
|
||||
if (v.ANTHROPIC_AUTH_TOKEN) next.authToken = v.ANTHROPIC_AUTH_TOKEN as string;
|
||||
if (v.PROTOCOL) next.protocol = v.PROTOCOL as 'anthropic' | 'openai';
|
||||
}
|
||||
if (item.key === 'defaults') {
|
||||
if (v.imageModel) next.defaultImageModel = v.imageModel;
|
||||
if (v.videoModel) next.defaultVideoModel = v.videoModel;
|
||||
if (v.format) next.defaultFormat = v.format;
|
||||
}
|
||||
if (item.key === 'endpoints') {
|
||||
if (v.ossEndpoint) next.ossEndpoint = v.ossEndpoint;
|
||||
if (v.ossBucket) next.ossBucket = v.ossBucket;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setForm(next);
|
||||
setAgentForm(next);
|
||||
});
|
||||
|
||||
api.getSkillsConfig().then(setSkillsForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
const handleAgentChange = (key: string, value: string) => {
|
||||
setAgentForm((f) => ({ ...f, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSkillsChange = (key: string, value: unknown) => {
|
||||
setSkillsForm((f) => ({ ...f, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveConfig('api_keys', {
|
||||
ANTHROPIC_MODEL: form.model,
|
||||
ANTHROPIC_BASE_URL: form.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: form.authToken,
|
||||
});
|
||||
await api.saveConfig('defaults', {
|
||||
imageModel: form.defaultImageModel,
|
||||
videoModel: form.defaultVideoModel,
|
||||
format: form.defaultFormat,
|
||||
});
|
||||
await api.saveConfig('endpoints', {
|
||||
ossEndpoint: form.ossEndpoint,
|
||||
ossBucket: form.ossBucket,
|
||||
});
|
||||
if (tab === 'agent') {
|
||||
await api.saveConfig('api_keys', {
|
||||
PROTOCOL: agentForm.protocol,
|
||||
ANTHROPIC_MODEL: agentForm.model,
|
||||
ANTHROPIC_BASE_URL: agentForm.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: agentForm.authToken,
|
||||
});
|
||||
await api.saveConfig('defaults', {
|
||||
imageModel: agentForm.defaultImageModel,
|
||||
videoModel: agentForm.defaultVideoModel,
|
||||
format: agentForm.defaultFormat,
|
||||
});
|
||||
} else {
|
||||
await api.saveSkillsConfig(skillsForm);
|
||||
}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch {}
|
||||
@@ -71,119 +167,192 @@ export function ConfigForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto p-6 space-y-8 overflow-auto">
|
||||
<h2 className="text-lg font-semibold text-zinc-800">设置</h2>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex gap-0 border-b border-zinc-200 px-6 bg-white">
|
||||
<button
|
||||
onClick={() => setTab('agent')}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||
${tab === 'agent' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
|
||||
>
|
||||
Agent 配置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('skills')}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||
${tab === 'skills' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
|
||||
>
|
||||
工具配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">API 配置</h3>
|
||||
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">模型</label>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
placeholder="deepseek-v4-pro[1m]"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Base URL</label>
|
||||
<Input
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => handleChange('baseUrl', e.target.value)}
|
||||
placeholder="https://api.deepseek.com/anthropic"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Auth Token</label>
|
||||
<Input
|
||||
value={form.authToken}
|
||||
onChange={(e) => handleChange('authToken', e.target.value)}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
{tab === 'agent' ? (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">API 配置</h3>
|
||||
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">协议</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{(['anthropic', 'openai'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handleAgentChange('protocol', p)}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors border ${
|
||||
agentForm.protocol === p
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-zinc-50 border-zinc-200 text-zinc-500 hover:bg-zinc-100'
|
||||
}`}
|
||||
>
|
||||
{p === 'anthropic' ? 'Anthropic' : 'OpenAI'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 mt-1">
|
||||
{agentForm.protocol === 'anthropic'
|
||||
? 'Anthropic 兼容协议(支持 Claude、GLM、DeepSeek 代理等)'
|
||||
: 'OpenAI 兼容协议(支持 GPT、DeepSeek、Qwen、Ollama 等)'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">模型</label>
|
||||
<Input value={agentForm.model} onChange={(e) => handleAgentChange('model', e.target.value)}
|
||||
placeholder={agentForm.protocol === 'anthropic' ? 'claude-sonnet-4-6 或 GLM-5.1' : 'gpt-4o 或 deepseek-chat'}
|
||||
className="mt-1 bg-zinc-50 border-zinc-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Base URL</label>
|
||||
<Input value={agentForm.baseUrl} onChange={(e) => handleAgentChange('baseUrl', e.target.value)}
|
||||
placeholder={agentForm.protocol === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1'}
|
||||
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Auth Token</label>
|
||||
<Input value={agentForm.authToken} onChange={(e) => handleAgentChange('authToken', e.target.value)}
|
||||
type="password" placeholder="sk-..." className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={testing}
|
||||
onClick={async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
await handleSave();
|
||||
const res = await fetch('/api/configs/test-connection', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
setTestResult(data);
|
||||
} catch (e) {
|
||||
setTestResult({ ok: false, error: (e as Error).message });
|
||||
}
|
||||
setTesting(false);
|
||||
}}
|
||||
>
|
||||
{testing ? <Loader2 size={12} className="mr-1 animate-spin" /> : <TestTube2 size={12} className="mr-1" />}
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<span className={`text-xs ${testResult.ok ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{testResult.ok
|
||||
? `连接成功 ${testResult.models?.length ? `(${testResult.models.join(', ')})` : ''}`
|
||||
: testResult.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">默认参数</h3>
|
||||
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">生图模型</label>
|
||||
<select value={agentForm.defaultImageModel} onChange={(e) => handleAgentChange('defaultImageModel', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
|
||||
<option value="">未设置</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="mj">Midjourney</option>
|
||||
<option value="gpt">GPT Image</option>
|
||||
<option value="kling">Kling</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">视频模型</label>
|
||||
<select value={agentForm.defaultVideoModel} onChange={(e) => handleAgentChange('defaultVideoModel', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
|
||||
<option value="">未设置</option>
|
||||
<option value="veo3-fast">Veo3 Fast</option>
|
||||
<option value="veo3-fast-frames">Veo3 Fast Frames</option>
|
||||
<option value="kling">Kling</option>
|
||||
<option value="grok">Grok</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">画幅</label>
|
||||
<select value={agentForm.defaultFormat} onChange={(e) => handleAgentChange('defaultFormat', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
|
||||
<option value="">未设置</option>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
<option value="16:9">16:9 横屏</option>
|
||||
<option value="1:1">1:1 方形</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{SECTIONS.map((section) => (
|
||||
<section key={section.title} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">{section.title}</h3>
|
||||
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||
{section.fields.map((field) => {
|
||||
const val = skillsForm[field.key];
|
||||
if (field.type === 'toggle') {
|
||||
return (
|
||||
<div key={field.key} className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-zinc-500">{field.label}</label>
|
||||
<button
|
||||
onClick={() => handleSkillsChange(field.key, !val)}
|
||||
className={`w-10 h-5 rounded-full transition-colors relative ${val ? 'bg-indigo-600' : 'bg-zinc-300'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${val ? 'left-5' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-xs font-medium text-zinc-500">{field.label}</label>
|
||||
<Input
|
||||
value={typeof val === 'string' ? val : val != null ? String(val) : ''}
|
||||
onChange={(e) => handleSkillsChange(field.key, e.target.value)}
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
placeholder={field.placeholder}
|
||||
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} className="w-full">
|
||||
{saved ? <><Check size={14} className="mr-1" />已保存</> :
|
||||
saving ? <><Loader2 size={14} className="mr-1 animate-spin" />保存中...</> :
|
||||
<><Save size={14} className="mr-1" />保存设置</>}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">默认参数</h3>
|
||||
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">默认生图模型</label>
|
||||
<select
|
||||
value={form.defaultImageModel}
|
||||
onChange={(e) => handleChange('defaultImageModel', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="mj">Midjourney</option>
|
||||
<option value="gpt">GPT Image</option>
|
||||
<option value="kling">Kling</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">默认视频模型</label>
|
||||
<select
|
||||
value={form.defaultVideoModel}
|
||||
onChange={(e) => handleChange('defaultVideoModel', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
<option value="veo3-fast">Veo3 Fast</option>
|
||||
<option value="veo3-fast-frames">Veo3 Fast Frames</option>
|
||||
<option value="kling">Kling</option>
|
||||
<option value="grok">Grok</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">默认画幅</label>
|
||||
<select
|
||||
value={form.defaultFormat}
|
||||
onChange={(e) => handleChange('defaultFormat', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
<option value="16:9">16:9 横屏</option>
|
||||
<option value="1:1">1:1 方形</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">OSS 存储</h3>
|
||||
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Endpoint</label>
|
||||
<Input
|
||||
value={form.ossEndpoint}
|
||||
onChange={(e) => handleChange('ossEndpoint', e.target.value)}
|
||||
placeholder="https://oss-cn-hangzhou.aliyuncs.com"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Bucket</label>
|
||||
<Input
|
||||
value={form.ossBucket}
|
||||
onChange={(e) => handleChange('ossBucket', e.target.value)}
|
||||
placeholder="my-bucket"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} className="w-full">
|
||||
{saved ? '已保存' : saving ? '保存中...' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user