feat: refine tool UI and result actions
This commit is contained in:
24
src-tauri/src/commands/file.rs
Normal file
24
src-tauri/src/commands/file.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reveal_path(app: AppHandle, path: String) -> Result<(), String> {
|
||||||
|
app.opener()
|
||||||
|
.reveal_item_in_dir(path)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_generated_dir(app: AppHandle) -> Result<(), String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.join("images")
|
||||||
|
.join("generated");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||||
|
app.opener()
|
||||||
|
.open_path(dir.to_string_lossy().to_string(), None::<&str>)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
|
pub mod file;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ pub fn run() {
|
|||||||
commands::provider::upsert_provider,
|
commands::provider::upsert_provider,
|
||||||
commands::provider::delete_provider,
|
commands::provider::delete_provider,
|
||||||
commands::dialog::pick_material_images,
|
commands::dialog::pick_material_images,
|
||||||
|
commands::file::reveal_path,
|
||||||
|
commands::file::open_generated_dir,
|
||||||
commands::generation::create_generation_task,
|
commands::generation::create_generation_task,
|
||||||
commands::generation::generate_image,
|
commands::generation::generate_image,
|
||||||
])
|
])
|
||||||
|
|||||||
359
src/main.tsx
359
src/main.tsx
@@ -66,19 +66,23 @@ const defaultProviderForm: ProviderForm = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initialGenerationSteps: GenerationStep[] = [
|
const initialGenerationSteps: GenerationStep[] = [
|
||||||
{ label: '保存 Provider 配置', status: 'pending' },
|
{ label: '保存配置', status: 'pending' },
|
||||||
{ label: '提交生成任务', status: 'pending' },
|
{ label: '提交任务', status: 'pending' },
|
||||||
{ label: '请求并等待图像模型返回', status: 'pending' },
|
{ label: '等待模型返回', status: 'pending' },
|
||||||
{ label: '保存图片到应用数据文件夹', status: 'pending' },
|
{ label: '保存到应用文件夹', status: 'pending' },
|
||||||
{ label: '更新本次打开图片列表', status: 'pending' },
|
{ label: '更新结果列表', status: 'pending' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [providers, setProviders] = useState<ProviderConfig[]>([]);
|
const [providers, setProviders] = useState<ProviderConfig[]>([]);
|
||||||
const [providerForm, setProviderForm] = useState<ProviderForm>(defaultProviderForm);
|
const [providerForm, setProviderForm] = useState<ProviderForm>(defaultProviderForm);
|
||||||
const [prompt, setPrompt] = useState('一只赛博朋克风格的橘猫坐在霓虹灯下');
|
const [prompt, setPrompt] = useState('一只赛博朋克风格的橘猫坐在霓虹灯下');
|
||||||
|
const [imageSize, setImageSize] = useState('1024x1024');
|
||||||
|
const [imageQuality, setImageQuality] = useState('auto');
|
||||||
const [status, setStatus] = useState('准备就绪');
|
const [status, setStatus] = useState('准备就绪');
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [previewImage, setPreviewImage] = useState<SessionImage | null>(null);
|
||||||
const [sessionImages, setSessionImages] = useState<SessionImage[]>([]);
|
const [sessionImages, setSessionImages] = useState<SessionImage[]>([]);
|
||||||
const [materialPaths, setMaterialPaths] = useState<string[]>([]);
|
const [materialPaths, setMaterialPaths] = useState<string[]>([]);
|
||||||
const [generationSteps, setGenerationSteps] = useState<GenerationStep[]>(initialGenerationSteps);
|
const [generationSteps, setGenerationSteps] = useState<GenerationStep[]>(initialGenerationSteps);
|
||||||
@@ -124,13 +128,11 @@ function App() {
|
|||||||
|
|
||||||
async function saveProvider() {
|
async function saveProvider() {
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
setStatus('正在保存 Provider 配置...');
|
setStatus('正在保存配置...');
|
||||||
try {
|
try {
|
||||||
await invoke('upsert_provider', {
|
await invoke('upsert_provider', { input: providerForm });
|
||||||
input: providerForm,
|
|
||||||
});
|
|
||||||
await refreshProviders();
|
await refreshProviders();
|
||||||
setStatus('Provider 已保存,可以直接生成图片');
|
setStatus('配置已保存');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`保存失败:${formatError(error)}`);
|
setStatus(`保存失败:${formatError(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,14 +142,14 @@ function App() {
|
|||||||
|
|
||||||
async function deleteProvider(id: string) {
|
async function deleteProvider(id: string) {
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
setStatus('正在删除 Provider...');
|
setStatus('正在删除配置...');
|
||||||
try {
|
try {
|
||||||
await invoke('delete_provider', { id });
|
await invoke('delete_provider', { id });
|
||||||
await refreshProviders();
|
await refreshProviders();
|
||||||
if (providerForm.id === id) {
|
if (providerForm.id === id) {
|
||||||
setProviderForm(defaultProviderForm);
|
setProviderForm(defaultProviderForm);
|
||||||
}
|
}
|
||||||
setStatus('Provider 已删除');
|
setStatus('配置已删除');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`删除失败:${formatError(error)}`);
|
setStatus(`删除失败:${formatError(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -167,7 +169,7 @@ function App() {
|
|||||||
image_model: provider.image_model ?? defaultProviderForm.image_model,
|
image_model: provider.image_model ?? defaultProviderForm.image_model,
|
||||||
enabled: provider.enabled,
|
enabled: provider.enabled,
|
||||||
}));
|
}));
|
||||||
setStatus('已载入 Provider,修改后点击保存即可覆盖当前配置。');
|
setStatus('已切换模型配置');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateImage() {
|
async function generateImage() {
|
||||||
@@ -176,9 +178,7 @@ function App() {
|
|||||||
setStatus('正在生成图片...');
|
setStatus('正在生成图片...');
|
||||||
try {
|
try {
|
||||||
startStep(0);
|
startStep(0);
|
||||||
await invoke('upsert_provider', {
|
await invoke('upsert_provider', { input: providerForm });
|
||||||
input: providerForm,
|
|
||||||
});
|
|
||||||
startStep(1);
|
startStep(1);
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||||
startStep(2);
|
startStep(2);
|
||||||
@@ -187,8 +187,8 @@ function App() {
|
|||||||
provider_id: providerForm.id,
|
provider_id: providerForm.id,
|
||||||
prompt,
|
prompt,
|
||||||
model: providerForm.image_model,
|
model: providerForm.image_model,
|
||||||
size: '1024x1024',
|
size: imageSize,
|
||||||
quality: 'auto',
|
quality: imageQuality,
|
||||||
image_paths: materialPaths,
|
image_paths: materialPaths,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -206,7 +206,7 @@ function App() {
|
|||||||
...images,
|
...images,
|
||||||
]);
|
]);
|
||||||
setStep(4, 'done');
|
setStep(4, 'done');
|
||||||
setStatus(`生成完成,已保存到应用数据文件夹:${result.asset.file_path}`);
|
setStatus(`生成完成:${result.asset.file_path}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setGenerationSteps((steps) =>
|
setGenerationSteps((steps) =>
|
||||||
steps.map((step) => (step.status === 'active' ? { ...step, status: 'error' } : step)),
|
steps.map((step) => (step.status === 'active' ? { ...step, status: 'error' } : step)),
|
||||||
@@ -218,15 +218,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickMaterialImages() {
|
async function pickMaterialImages() {
|
||||||
setStatus('正在打开素材图片选择器...');
|
setStatus('正在打开素材选择器...');
|
||||||
try {
|
try {
|
||||||
const paths = await invoke<string[]>('pick_material_images');
|
const paths = await invoke<string[]>('pick_material_images');
|
||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
setStatus('未选择素材图片');
|
setStatus('未选择素材');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMaterialPaths((current) => Array.from(new Set([...current, ...paths])));
|
setMaterialPaths((current) => Array.from(new Set([...current, ...paths])));
|
||||||
setStatus(`已导入 ${paths.length} 张素材图片`);
|
setStatus(`已导入 ${paths.length} 张素材`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`打开素材选择器失败:${formatError(error)}`);
|
setStatus(`打开素材选择器失败:${formatError(error)}`);
|
||||||
}
|
}
|
||||||
@@ -236,104 +236,66 @@ function App() {
|
|||||||
setMaterialPaths((current) => current.filter((item) => item !== path));
|
setMaterialPaths((current) => current.filter((item) => item !== path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function revealImage(path: string) {
|
||||||
|
try {
|
||||||
|
await invoke('reveal_path', { path });
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`打开文件位置失败:${formatError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGeneratedDir() {
|
||||||
|
try {
|
||||||
|
await invoke('open_generated_dir');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`打开保存目录失败:${formatError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshProviders().catch(() => setStatus('后端未启动或数据库初始化失败'));
|
refreshProviders().catch(() => setStatus('后端未启动或数据库初始化失败'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app-shell">
|
||||||
<section className="hero">
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-mark">绘</div>
|
||||||
|
<div>
|
||||||
<h1>Image Draw AI</h1>
|
<h1>Image Draw AI</h1>
|
||||||
<p>图片默认保存到应用数据文件夹。</p>
|
<p>图片默认保存到应用数据文件夹</p>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-title">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Provider</p>
|
|
||||||
<h2>模型供应商配置</h2>
|
|
||||||
</div>
|
|
||||||
<button onClick={refreshProviders} disabled={isBusy}>刷新</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid two">
|
|
||||||
<label>
|
|
||||||
配置 ID
|
|
||||||
<input
|
|
||||||
value={providerForm.id}
|
|
||||||
onChange={(event) => updateProviderForm('id', event.target.value)}
|
|
||||||
placeholder="default-openai"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
名称
|
|
||||||
<input
|
|
||||||
value={providerForm.name}
|
|
||||||
onChange={(event) => updateProviderForm('name', event.target.value)}
|
|
||||||
placeholder="OpenAI / 中转站"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Base URL
|
|
||||||
<input
|
|
||||||
value={providerForm.base_url}
|
|
||||||
onChange={(event) => updateProviderForm('base_url', event.target.value)}
|
|
||||||
placeholder="https://api.openai.com/v1"
|
|
||||||
/>
|
|
||||||
<small>填写 API 地址,不是中转站网页地址;通常以 /v1 结尾。</small>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
API Key
|
|
||||||
<input
|
|
||||||
value={providerForm.api_key}
|
|
||||||
onChange={(event) => updateProviderForm('api_key', event.target.value)}
|
|
||||||
placeholder="sk-... 或中转站 key"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="grid two">
|
|
||||||
<label>
|
|
||||||
文本模型
|
|
||||||
<input
|
|
||||||
value={providerForm.text_model}
|
|
||||||
onChange={(event) => updateProviderForm('text_model', event.target.value)}
|
|
||||||
placeholder="gpt-5"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
图像模型
|
|
||||||
<input
|
|
||||||
value={providerForm.image_model}
|
|
||||||
onChange={(event) => updateProviderForm('image_model', event.target.value)}
|
|
||||||
placeholder="gpt-image-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row">
|
|
||||||
<button onClick={saveProvider} disabled={isBusy}>保存 Provider</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-title">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Generate</p>
|
|
||||||
<h2>生成图片</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<div className="current-provider">
|
||||||
|
<span>当前模型</span>
|
||||||
|
<strong>{providerForm.image_model}</strong>
|
||||||
|
</div>
|
||||||
|
<button className="ghost" onClick={() => setIsSettingsOpen(true)}>设置</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="workspace">
|
||||||
|
<aside className="compose-card">
|
||||||
|
<div className="section-heading">
|
||||||
|
<span>创作区</span>
|
||||||
|
<strong>{materialPaths.length > 0 ? '素材生成' : '文字生成'}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field prompt-field">
|
||||||
|
<span>提示词</span>
|
||||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} />
|
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="material-panel">
|
||||||
<div className="material-toolbar">
|
<div className="material-toolbar">
|
||||||
<button onClick={pickMaterialImages} disabled={isBusy}>导入素材图片</button>
|
<button onClick={pickMaterialImages} disabled={isBusy}>导入素材</button>
|
||||||
{materialPaths.length > 0 && (
|
{materialPaths.length > 0 && (
|
||||||
<button onClick={() => setMaterialPaths([])} disabled={isBusy}>清空素材</button>
|
<button className="ghost" onClick={() => setMaterialPaths([])} disabled={isBusy}>清空</button>
|
||||||
)}
|
)}
|
||||||
<span>{materialPaths.length > 0 ? `已选择 ${materialPaths.length} 张素材,将使用图像编辑模式` : '未选择素材,将使用文生图模式'}</span>
|
<span>{materialPaths.length > 0 ? `${materialPaths.length} 张素材` : '未导入素材'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="drop-hint">支持 PNG / JPG / WEBP,多张素材会使用图像编辑模式</p>
|
||||||
|
|
||||||
{materialPaths.length > 0 && (
|
{materialPaths.length > 0 && (
|
||||||
<div className="material-grid">
|
<div className="material-grid">
|
||||||
@@ -341,19 +303,51 @@ function App() {
|
|||||||
<article className="material-card" key={path}>
|
<article className="material-card" key={path}>
|
||||||
<img src={convertFileSrc(path)} alt="素材图片" />
|
<img src={convertFileSrc(path)} alt="素材图片" />
|
||||||
<button onClick={() => removeMaterialImage(path)} disabled={isBusy}>移除</button>
|
<button onClick={() => removeMaterialImage(path)} disabled={isBusy}>移除</button>
|
||||||
<span>{path}</span>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="primary" onClick={generateImage} disabled={isBusy}>
|
<div className="params-card">
|
||||||
{isBusy ? '正在生成...' : '生成图片'}
|
<div className="section-heading">
|
||||||
|
<span>生成参数</span>
|
||||||
|
<strong>基础</strong>
|
||||||
|
</div>
|
||||||
|
<div className="segmented">
|
||||||
|
{['1024x1024', '1024x1536', '1536x1024'].map((size) => (
|
||||||
|
<button
|
||||||
|
className={imageSize === size ? 'active' : ''}
|
||||||
|
key={size}
|
||||||
|
onClick={() => setImageSize(size)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="segmented compact">
|
||||||
|
{['auto', 'high', 'medium', 'low'].map((quality) => (
|
||||||
|
<button
|
||||||
|
className={imageQuality === quality ? 'active' : ''}
|
||||||
|
key={quality}
|
||||||
|
onClick={() => setImageQuality(quality)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{quality}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="generate-button" onClick={generateImage} disabled={isBusy}>
|
||||||
|
{isBusy ? '正在生成...' : '开始生成'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className={`progress-card ${isBusy ? 'is-loading' : ''}`}>
|
<div className={`progress-card ${isBusy ? 'is-loading' : ''}`}>
|
||||||
<div className="spinner" aria-hidden="true" />
|
<div className="spinner" aria-hidden="true" />
|
||||||
<div className="progress-content">
|
<div className="progress-content">
|
||||||
<strong>{isBusy ? '生成流程进行中' : '生成流程'}</strong>
|
<strong>{isBusy ? '生成中' : '生成流程'}</strong>
|
||||||
<ol className="step-list">
|
<ol className="step-list">
|
||||||
{generationSteps.map((step) => (
|
{generationSteps.map((step) => (
|
||||||
<li className={`step ${step.status}`} key={step.label}>
|
<li className={`step ${step.status}`} key={step.label}>
|
||||||
@@ -364,27 +358,35 @@ function App() {
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="status">{status}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel">
|
<p className="status">{status}</p>
|
||||||
<div className="panel-title">
|
</aside>
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Session</p>
|
<section className="result-card">
|
||||||
<h2>本次打开生成的图片</h2>
|
<div className="section-heading result-heading">
|
||||||
|
<span>结果区</span>
|
||||||
|
<div className="heading-actions">
|
||||||
|
<strong>本次生成 {sessionImages.length} 张</strong>
|
||||||
|
<button className="ghost mini" onClick={openGeneratedDir}>打开目录</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="count">{sessionImages.length} 张</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionImages.length === 0 ? (
|
{sessionImages.length === 0 ? (
|
||||||
<p>当前打开周期内还没有生成图片。</p>
|
<div className="empty-state">
|
||||||
|
<div>暂无图片</div>
|
||||||
|
<p>输入提示词,点击开始生成后会显示在这里。</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="image-grid">
|
<div className="image-grid">
|
||||||
{sessionImages.map((image) => (
|
{sessionImages.map((image) => (
|
||||||
<article className="image-card" key={image.id}>
|
<article className="image-card" key={image.id}>
|
||||||
|
<button className="image-preview-button" onClick={() => setPreviewImage(image)}>
|
||||||
<img src={convertFileSrc(image.file_path)} alt={image.prompt} />
|
<img src={convertFileSrc(image.file_path)} alt={image.prompt} />
|
||||||
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<strong>{image.created_at}</strong>
|
<strong>{image.created_at}</strong>
|
||||||
<p>{image.prompt}</p>
|
<p>{image.prompt}</p>
|
||||||
|
<button className="ghost mini" onClick={() => revealImage(image.file_path)}>定位文件</button>
|
||||||
<span>{image.file_path}</span>
|
<span>{image.file_path}</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -392,11 +394,93 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
{isSettingsOpen && (
|
||||||
<h2>已保存 Provider</h2>
|
<div className="drawer-layer">
|
||||||
|
<button className="drawer-mask" onClick={() => setIsSettingsOpen(false)} aria-label="关闭设置" />
|
||||||
|
<aside className="settings-drawer">
|
||||||
|
<div className="drawer-header">
|
||||||
|
<div>
|
||||||
|
<span>设置</span>
|
||||||
|
<h2>模型供应商</h2>
|
||||||
|
</div>
|
||||||
|
<button className="ghost" onClick={() => setIsSettingsOpen(false)}>关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-content">
|
||||||
|
<section className="settings-group">
|
||||||
|
<div className="section-heading">
|
||||||
|
<span>基础信息</span>
|
||||||
|
<strong>配置名称</strong>
|
||||||
|
</div>
|
||||||
|
<div className="grid two">
|
||||||
|
<label className="field">
|
||||||
|
<span>配置 ID</span>
|
||||||
|
<input value={providerForm.id} onChange={(event) => updateProviderForm('id', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>名称</span>
|
||||||
|
<input value={providerForm.name} onChange={(event) => updateProviderForm('name', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-group">
|
||||||
|
<div className="section-heading">
|
||||||
|
<span>接口信息</span>
|
||||||
|
<strong>中转站 / OpenAI</strong>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>Base URL</span>
|
||||||
|
<input
|
||||||
|
value={providerForm.base_url}
|
||||||
|
onChange={(event) => updateProviderForm('base_url', event.target.value)}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
<small>填写 API 地址,不是中转站网页地址;通常以 /v1 结尾。</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>API Key</span>
|
||||||
|
<input
|
||||||
|
value={providerForm.api_key}
|
||||||
|
onChange={(event) => updateProviderForm('api_key', event.target.value)}
|
||||||
|
placeholder="sk-... 或中转站 key"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-group">
|
||||||
|
<div className="section-heading">
|
||||||
|
<span>模型</span>
|
||||||
|
<strong>默认模型</strong>
|
||||||
|
</div>
|
||||||
|
<div className="grid two">
|
||||||
|
<label className="field">
|
||||||
|
<span>文本模型</span>
|
||||||
|
<input value={providerForm.text_model} onChange={(event) => updateProviderForm('text_model', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>图像模型</span>
|
||||||
|
<input value={providerForm.image_model} onChange={(event) => updateProviderForm('image_model', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="drawer-actions">
|
||||||
|
<button onClick={saveProvider} disabled={isBusy}>保存配置</button>
|
||||||
|
<button className="ghost" onClick={refreshProviders} disabled={isBusy}>刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="saved-providers">
|
||||||
|
<div className="section-heading">
|
||||||
|
<span>已保存</span>
|
||||||
|
<strong>{providers.length} 个配置</strong>
|
||||||
|
</div>
|
||||||
{providers.length === 0 ? (
|
{providers.length === 0 ? (
|
||||||
<p>暂无 Provider,请先保存配置。</p>
|
<p className="muted">暂无配置,保存后会出现在这里。</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="provider-list">
|
<ul className="provider-list">
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
@@ -407,15 +491,38 @@ function App() {
|
|||||||
<span>{provider.base_url}</span>
|
<span>{provider.base_url}</span>
|
||||||
<span>{provider.image_model}</span>
|
<span>{provider.image_model}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="danger" onClick={() => deleteProvider(provider.id)} disabled={isBusy}>
|
<button className="danger" onClick={() => deleteProvider(provider.id)} disabled={isBusy}>删除</button>
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewImage && (
|
||||||
|
<div className="preview-layer">
|
||||||
|
<button className="drawer-mask" onClick={() => setPreviewImage(null)} aria-label="关闭预览" />
|
||||||
|
<section className="preview-modal">
|
||||||
|
<div className="preview-header">
|
||||||
|
<div>
|
||||||
|
<span>图片预览</span>
|
||||||
|
<h2>{previewImage.created_at}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="ghost" onClick={() => setPreviewImage(null)}>关闭</button>
|
||||||
|
</div>
|
||||||
|
<img src={convertFileSrc(previewImage.file_path)} alt={previewImage.prompt} />
|
||||||
|
<div className="preview-info">
|
||||||
|
<p>{previewImage.prompt}</p>
|
||||||
|
<button className="ghost" onClick={() => revealImage(previewImage.file_path)}>在文件夹中显示</button>
|
||||||
|
<span>{previewImage.file_path}</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
848
src/styles.css
848
src/styles.css
@@ -1,7 +1,11 @@
|
|||||||
:root {
|
:root {
|
||||||
color: #f8fafc;
|
color: #172033;
|
||||||
background: radial-gradient(circle at top left, #1e3a8a 0, #0f172a 34%, #020617 100%);
|
background: #eef3fb;
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -12,129 +16,487 @@ button, textarea, input {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 11px 16px;
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.22);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.62;
|
opacity: 0.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
button.ghost {
|
||||||
max-width: 1040px;
|
background: #eef4ff;
|
||||||
margin: 0 auto;
|
color: #2563eb;
|
||||||
padding: 48px 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
button.danger {
|
||||||
margin-bottom: 24px;
|
background: #fff1f2;
|
||||||
|
color: #e11d48;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
button.mini {
|
||||||
color: #38bdf8;
|
border-radius: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
padding: 7px 10px;
|
||||||
letter-spacing: 0.12em;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.app-shell {
|
||||||
font-size: 48px;
|
min-height: 100vh;
|
||||||
margin: 0 0 12px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.topbar {
|
||||||
margin: 0 0 16px;
|
align-items: center;
|
||||||
}
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(203, 213, 225, 0.8);
|
||||||
.panel {
|
border-radius: 24px;
|
||||||
background: rgba(15, 23, 42, 0.86);
|
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
align-items: flex-start;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
margin: 0 auto 18px;
|
||||||
margin-bottom: 16px;
|
max-width: 1440px;
|
||||||
|
padding: 16px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.brand {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
border-radius: 18px;
|
||||||
|
color: #ffffff;
|
||||||
|
display: grid;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 900;
|
||||||
|
height: 52px;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p, .muted {
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
}
|
||||||
|
|
||||||
|
.current-provider {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-provider span, .section-heading span, .drawer-header span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-provider strong {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-card, .result-card, .settings-drawer {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid rgba(203, 213, 225, 0.9);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-card {
|
||||||
|
align-self: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
min-height: calc(100vh - 132px);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
color: #334155;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 18px;
|
||||||
|
color: #172033;
|
||||||
|
background: #f8fafc;
|
||||||
|
outline: none;
|
||||||
|
padding: 13px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-field textarea {
|
||||||
|
min-height: 190px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid.two {
|
.grid.two {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
.material-panel {
|
||||||
color: #cbd5e1;
|
background: #f8fafc;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-toolbar span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-hint {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 14px;
|
overflow: hidden;
|
||||||
font-weight: 700;
|
padding-bottom: 8px;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
.material-card img {
|
||||||
color: #94a3b8;
|
aspect-ratio: 1;
|
||||||
font-weight: 500;
|
background: #e2e8f0;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.material-card button {
|
||||||
border: 0;
|
justify-self: center;
|
||||||
border-radius: 12px;
|
padding: 6px 10px;
|
||||||
padding: 10px 16px;
|
|
||||||
color: #e2e8f0;
|
|
||||||
background: #334155;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary {
|
.generate-button {
|
||||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 17px;
|
||||||
|
padding: 15px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card {
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 22px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid rgba(148, 163, 184, 0.32);
|
||||||
|
border-top-color: #2563eb;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card.is-loading .spinner {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
align-items: center;
|
||||||
|
background: #eef2f7;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step span {
|
||||||
|
background: #94a3b8;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 7px;
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, textarea {
|
.step.active span {
|
||||||
width: 100%;
|
background: #2563eb;
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid #475569;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
color: #f8fafc;
|
|
||||||
background: #020617;
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus, textarea:focus {
|
.step.done {
|
||||||
border-color: #38bdf8;
|
background: #dcfce7;
|
||||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.16);
|
color: #15803d;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.step.done span {
|
||||||
min-height: 140px;
|
background: #22c55e;
|
||||||
margin-bottom: 16px;
|
}
|
||||||
resize: vertical;
|
|
||||||
|
.step.error {
|
||||||
|
background: #ffe4e6;
|
||||||
|
color: #e11d48;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.error span {
|
||||||
|
background: #e11d48;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
color: #93c5fd;
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-heading {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 26px;
|
||||||
|
color: #64748b;
|
||||||
|
display: grid;
|
||||||
|
min-height: 420px;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state div {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card img {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: #e2e8f0;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card div {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card p {
|
||||||
|
color: #475569;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card span {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-layer {
|
||||||
|
inset: 0;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-mask {
|
||||||
|
background: rgba(15, 23, 42, 0.28);
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-mask:hover {
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-drawer {
|
||||||
|
animation: slideIn 0.22s ease-out;
|
||||||
|
border-radius: 28px 0 0 28px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
max-width: 520px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: min(92vw, 520px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header h2 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-providers {
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.provider-list {
|
.provider-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -149,209 +511,183 @@ textarea {
|
|||||||
|
|
||||||
.link-button {
|
.link-button {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
background: rgba(30, 41, 59, 0.72);
|
background: #f8fafc;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #172033;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
|
||||||
background: rgba(185, 28, 28, 0.9);
|
|
||||||
color: #fee2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-button span {
|
.link-button span {
|
||||||
color: #94a3b8;
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(28px); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.grid.two {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-item {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
color: #93c5fd;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-card {
|
|
||||||
background: rgba(2, 6, 23, 0.72);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-card img {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background: #020617;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-card div {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card p {
|
.topbar, .topbar-actions {
|
||||||
color: #cbd5e1;
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.two, .provider-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 22px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
background: #eef2f7;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented.compact {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 9px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button:hover:not(:disabled) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.active {
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-button {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-button:hover:not(:disabled) {
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-layer {
|
||||||
|
inset: 0;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal {
|
||||||
|
animation: previewIn 0.22s ease-out;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 30px;
|
||||||
|
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.24);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
left: 50%;
|
||||||
|
max-height: 90vh;
|
||||||
|
max-width: min(920px, 92vw);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal > img {
|
||||||
|
background: #eef2f7;
|
||||||
|
border-radius: 22px;
|
||||||
|
max-height: 62vh;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info p {
|
||||||
|
color: #334155;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card span {
|
.preview-info span {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-card {
|
@keyframes previewIn {
|
||||||
align-items: flex-start;
|
from { opacity: 0; transform: translate(-50%, -48%) scale(0.98); }
|
||||||
background: rgba(2, 6, 23, 0.5);
|
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
}
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
.settings-group {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 22px;
|
||||||
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-top: 16px;
|
padding: 14px;
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
border: 3px solid rgba(148, 163, 184, 0.28);
|
|
||||||
border-top-color: #38bdf8;
|
|
||||||
border-radius: 999px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
height: 22px;
|
|
||||||
width: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-card.is-loading .spinner {
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-content {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
align-items: center;
|
|
||||||
color: #94a3b8;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step span {
|
|
||||||
background: #475569;
|
|
||||||
border-radius: 999px;
|
|
||||||
height: 9px;
|
|
||||||
width: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step.active {
|
|
||||||
color: #93c5fd;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step.active span {
|
|
||||||
background: #38bdf8;
|
|
||||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step.done {
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step.done span {
|
|
||||||
background: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step.error {
|
|
||||||
color: #fca5a5;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step.error span {
|
|
||||||
background: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-toolbar {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-toolbar span {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-card {
|
|
||||||
background: rgba(2, 6, 23, 0.72);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
||||||
border-radius: 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-card img {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background: #020617;
|
|
||||||
object-fit: cover;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-card button {
|
|
||||||
justify-self: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-card span {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 11px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user