fix: polish layout and app branding

This commit is contained in:
2026-04-27 11:15:53 +08:00
parent 8254994df8
commit 20bd25e136
7 changed files with 311 additions and 205 deletions

1
src/assets/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { convertFileSrc, invoke } from '@tauri-apps/api/core';
import appLogo from './assets/logo.svg';
import './styles.css';
type ProviderConfig = {
@@ -20,8 +21,8 @@ type ProviderForm = {
kind: string;
base_url: string;
api_key: string;
text_model: string;
image_model: string;
text_model?: string | null;
image_model?: string | null;
enabled: boolean;
};
@@ -60,8 +61,8 @@ const defaultProviderForm: ProviderForm = {
kind: 'openai-compatible',
base_url: 'https://api.openai.com/v1',
api_key: '',
text_model: 'gpt-5',
image_model: 'gpt-image-2',
text_model: null,
image_model: null,
enabled: true,
};
@@ -73,10 +74,15 @@ const initialGenerationSteps: GenerationStep[] = [
{ label: '更新结果列表', status: 'pending' },
];
const imageModelOptions = ['gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'];
const imageSizeOptions = ['1024x1024', '1024x1536', '1536x1024'];
const imageQualityOptions = ['auto', 'high', 'medium', 'low'];
function App() {
const [providers, setProviders] = useState<ProviderConfig[]>([]);
const [providerForm, setProviderForm] = useState<ProviderForm>(defaultProviderForm);
const [prompt, setPrompt] = useState('一只赛博朋克风格的橘猫坐在霓虹灯下');
const [selectedImageModel, setSelectedImageModel] = useState('gpt-image-2');
const [imageSize, setImageSize] = useState('1024x1024');
const [imageQuality, setImageQuality] = useState('auto');
const [status, setStatus] = useState('准备就绪');
@@ -119,8 +125,8 @@ function App() {
kind: current.kind,
base_url: current.base_url,
api_key: current.api_key ?? form.api_key,
text_model: current.text_model ?? defaultProviderForm.text_model,
image_model: current.image_model ?? defaultProviderForm.image_model,
text_model: current.text_model ?? null,
image_model: null,
enabled: current.enabled,
}));
}
@@ -130,7 +136,7 @@ function App() {
setIsBusy(true);
setStatus('正在保存配置...');
try {
await invoke('upsert_provider', { input: providerForm });
await invoke('upsert_provider', { input: { ...providerForm, image_model: null } });
await refreshProviders();
setStatus('配置已保存');
} catch (error) {
@@ -165,8 +171,8 @@ function App() {
kind: provider.kind,
base_url: provider.base_url,
api_key: provider.api_key ?? form.api_key,
text_model: provider.text_model ?? defaultProviderForm.text_model,
image_model: provider.image_model ?? defaultProviderForm.image_model,
text_model: provider.text_model ?? null,
image_model: null,
enabled: provider.enabled,
}));
setStatus('已切换模型配置');
@@ -178,7 +184,7 @@ function App() {
setStatus('正在生成图片...');
try {
startStep(0);
await invoke('upsert_provider', { input: providerForm });
await invoke('upsert_provider', { input: { ...providerForm, image_model: null } });
startStep(1);
await new Promise((resolve) => window.setTimeout(resolve, 120));
startStep(2);
@@ -186,7 +192,7 @@ function App() {
input: {
provider_id: providerForm.id,
prompt,
model: providerForm.image_model,
model: selectedImageModel,
size: imageSize,
quality: imageQuality,
image_paths: materialPaths,
@@ -260,7 +266,7 @@ function App() {
<main className="app-shell">
<header className="topbar">
<div className="brand">
<div className="brand-mark"></div>
<img className="brand-mark" src={appLogo} alt="Image Draw AI" />
<div>
<h1>Image Draw AI</h1>
<p></p>
@@ -269,7 +275,7 @@ function App() {
<div className="topbar-actions">
<div className="current-provider">
<span></span>
<strong>{providerForm.image_model}</strong>
<strong>{selectedImageModel}</strong>
</div>
<button className="ghost" onClick={() => setIsSettingsOpen(true)}></button>
</div>
@@ -288,25 +294,30 @@ function App() {
</label>
<div className="material-panel">
<div className="material-toolbar">
<button onClick={pickMaterialImages} disabled={isBusy}></button>
{materialPaths.length > 0 && (
<button className="ghost" onClick={() => setMaterialPaths([])} disabled={isBusy}></button>
)}
<span>{materialPaths.length > 0 ? `${materialPaths.length} 张素材` : '未导入素材'}</span>
</div>
<p className="drop-hint"> PNG / JPG / WEBP使</p>
{materialPaths.length > 0 && (
<div className="material-grid">
{materialPaths.map((path) => (
<article className="material-card" key={path}>
<img src={convertFileSrc(path)} alt="素材图片" />
<button onClick={() => removeMaterialImage(path)} disabled={isBusy}></button>
</article>
))}
<div className="material-header">
<div>
<strong></strong>
<span>{materialPaths.length > 0 ? `${materialPaths.length} 张,图像编辑模式` : '可选,支持多张'}</span>
</div>
)}
{materialPaths.length > 0 && (
<button className="ghost mini" onClick={() => setMaterialPaths([])} disabled={isBusy}></button>
)}
</div>
<div className="reference-strip">
<button className="add-reference-card" onClick={pickMaterialImages} disabled={isBusy}>
<span>+</span>
<strong></strong>
<small>PNG/JPG/WEBP</small>
</button>
{materialPaths.map((path, index) => (
<article className="reference-card" key={path}>
<img src={convertFileSrc(path)} alt="素材图片" />
<span>{index + 1}</span>
<button onClick={() => removeMaterialImage(path)} disabled={isBusy}>×</button>
</article>
))}
</div>
</div>
<div className="params-card">
@@ -314,29 +325,31 @@ function App() {
<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>
))}
</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>
))}
<label className="field compact-field">
<span></span>
<select value={selectedImageModel} onChange={(event) => setSelectedImageModel(event.target.value)} disabled={isBusy}>
{imageModelOptions.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<div className="grid two">
<label className="field compact-field">
<span></span>
<select value={imageSize} onChange={(event) => setImageSize(event.target.value)} disabled={isBusy}>
{imageSizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
<label className="field compact-field">
<span></span>
<select value={imageQuality} onChange={(event) => setImageQuality(event.target.value)} disabled={isBusy}>
{imageQualityOptions.map((quality) => (
<option key={quality} value={quality}>{quality}</option>
))}
</select>
</label>
</div>
</div>
@@ -344,22 +357,7 @@ function App() {
{isBusy ? '正在生成...' : '开始生成'}
</button>
<div className={`progress-card ${isBusy ? 'is-loading' : ''}`}>
<div className="spinner" aria-hidden="true" />
<div className="progress-content">
<strong>{isBusy ? '生成中' : '生成流程'}</strong>
<ol className="step-list">
{generationSteps.map((step) => (
<li className={`step ${step.status}`} key={step.label}>
<span />
{step.label}
</li>
))}
</ol>
</div>
</div>
<p className="status">{status}</p>
{status !== '准备就绪' && <p className="status">{status}</p>}
</aside>
<section className="result-card">
@@ -393,6 +391,21 @@ function App() {
))}
</div>
)}
<div className={`progress-card result-progress ${isBusy ? 'is-loading' : ''}`}>
<div className="spinner" aria-hidden="true" />
<div className="progress-content">
<strong>{isBusy ? '生成中' : '生成流程'}</strong>
<ol className="step-list">
{generationSteps.map((step) => (
<li className={`step ${step.status}`} key={step.label}>
<span />
{step.label}
</li>
))}
</ol>
</div>
</div>
</section>
</section>
@@ -452,23 +465,6 @@ function App() {
</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>
@@ -489,7 +485,6 @@ function App() {
<button className="link-button" onClick={() => loadProvider(provider)} disabled={isBusy}>
<strong>{provider.name}</strong>
<span>{provider.base_url}</span>
<span>{provider.image_model}</span>
</button>
<button className="danger" onClick={() => deleteProvider(provider.id)} disabled={isBusy}></button>
</div>

View File

@@ -1,6 +1,9 @@
:root {
color: #172033;
background: #eef3fb;
background:
radial-gradient(circle at 8% 10%, rgba(255, 221, 77, 0.22), transparent 28%),
radial-gradient(circle at 92% 8%, rgba(96, 239, 154, 0.18), transparent 24%),
linear-gradient(135deg, #f7f9ff 0%, #eef4fb 46%, #f8fbf6 100%);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
}
@@ -10,9 +13,10 @@
body {
margin: 0;
overflow: hidden;
}
button, textarea, input {
button, textarea, input, select {
font: inherit;
}
@@ -54,21 +58,28 @@ button.mini {
}
.app-shell {
min-height: 100vh;
padding: 22px;
display: grid;
gap: 18px;
grid-template-rows: auto minmax(0, 1fr);
height: 100vh;
min-width: 1120px;
overflow: hidden;
padding: 20px;
}
.topbar {
align-items: center;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(203, 213, 225, 0.8);
border-radius: 24px;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(18px);
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(255, 255, 255, 0.86);
border-radius: 28px;
box-shadow: 0 20px 60px rgba(30, 41, 59, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.72);
display: flex;
justify-content: space-between;
margin: 0 auto 18px;
margin: 0 auto;
max-width: 1440px;
padding: 16px 18px;
width: 100%;
}
.brand {
@@ -78,15 +89,12 @@ button.mini {
}
.brand-mark {
align-items: center;
background: linear-gradient(135deg, #2563eb, #7c3aed);
background: linear-gradient(180deg, #ffffff, #f8fafc);
border-radius: 18px;
color: #ffffff;
display: grid;
font-size: 24px;
font-weight: 900;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12), inset 0 0 0 1px rgba(15, 23, 42, 0.08);
height: 52px;
place-items: center;
object-fit: contain;
padding: 6px;
width: 52px;
}
@@ -108,8 +116,8 @@ button.mini {
}
.current-provider {
background: #f8fafc;
border: 1px solid #e2e8f0;
background: rgba(248, 250, 252, 0.88);
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 16px;
display: grid;
gap: 4px;
@@ -129,29 +137,56 @@ button.mini {
}
.workspace {
align-items: stretch;
display: grid;
gap: 18px;
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
margin: 0 auto;
max-width: 1440px;
height: 100%;
min-height: 0;
width: 100%;
}
.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);
backdrop-filter: blur(18px);
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 30px;
box-shadow: 0 28px 80px rgba(30, 41, 59, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.74);
}
.compose-card {
align-self: start;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
padding: 16px;
}
.compose-card > .section-heading,
.compose-card > .material-panel,
.compose-card > .params-card,
.compose-card > .generate-button,
.compose-card > .status {
flex: 0 0 auto;
}
.prompt-field {
display: grid;
gap: 18px;
padding: 20px;
grid-template-rows: auto minmax(0, 1fr);
flex: 1 1 auto;
gap: 8px;
min-height: 260px;
overflow: hidden;
}
.result-card {
min-height: calc(100vh - 132px);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 20px;
}
@@ -160,6 +195,7 @@ button.mini {
display: flex;
justify-content: space-between;
gap: 12px;
min-height: 34px;
}
.heading-actions {
@@ -180,25 +216,36 @@ button.mini {
font-weight: 800;
}
input, textarea {
input, textarea, select {
max-width: 100%;
width: 100%;
border: 1px solid #dbe4f0;
border-radius: 18px;
color: #172033;
background: #f8fafc;
background: rgba(248, 250, 252, 0.88);
outline: none;
padding: 13px 14px;
}
input:focus, textarea:focus {
input:focus, textarea:focus, select:focus {
background: #ffffff;
border-color: #60a5fa;
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.18);
}
select {
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, #64748b 50%), linear-gradient(135deg, #64748b 50%, transparent 50%);
background-position: calc(100% - 18px) 52%, calc(100% - 12px) 52%;
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.prompt-field textarea {
min-height: 190px;
resize: vertical;
height: 100%;
min-height: 0;
max-width: 100%;
resize: none;
}
small {
@@ -216,76 +263,154 @@ small {
}
.material-panel {
background: #f8fafc;
border: 1px dashed #cbd5e1;
background: rgba(248, 250, 252, 0.72);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 22px;
padding: 14px;
display: grid;
gap: 12px;
grid-template-rows: auto 120px;
height: 200px;
overflow: hidden;
padding: 12px;
}
.material-toolbar {
.material-header {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
gap: 12px;
}
.material-toolbar span {
.material-header div {
display: grid;
gap: 3px;
}
.material-header strong {
color: #0f172a;
}
.material-header span {
color: #64748b;
font-size: 12px;
}
.reference-strip {
display: flex;
gap: 10px;
min-height: 120px;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 2px;
}
.add-reference-card, .reference-card {
border-radius: 18px;
flex: 0 0 104px;
height: 104px;
}
.add-reference-card {
align-items: center;
background: linear-gradient(180deg, #ffffff, #f8fafc);
border: 1px dashed rgba(37, 99, 235, 0.42);
color: #2563eb;
display: grid;
gap: 2px;
justify-items: center;
padding: 12px;
}
.add-reference-card:hover:not(:disabled) {
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.14);
}
.add-reference-card > span {
align-items: center;
background: #eff6ff;
border-radius: 999px;
display: grid;
font-size: 22px;
height: 34px;
place-items: center;
width: 34px;
}
.add-reference-card strong {
font-size: 13px;
}
.drop-hint {
color: #94a3b8;
font-size: 12px;
margin: 10px 0 0;
.add-reference-card small {
font-size: 11px;
}
.material-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
margin-top: 12px;
}
.material-card {
.reference-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 18px;
display: grid;
gap: 8px;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
overflow: hidden;
padding-bottom: 8px;
position: relative;
}
.material-card img {
aspect-ratio: 1;
.reference-card img {
background: #e2e8f0;
height: 100%;
object-fit: cover;
width: 100%;
}
.material-card button {
justify-self: center;
padding: 6px 10px;
.reference-card button {
align-items: center;
background: rgba(15, 23, 42, 0.72);
border-radius: 999px;
display: grid;
height: 24px;
padding: 0;
place-items: center;
position: absolute;
right: 7px;
top: 7px;
width: 24px;
}
.reference-card > span {
align-items: center;
background: rgba(255, 255, 255, 0.88);
border-radius: 999px;
bottom: 7px;
color: #334155;
display: grid;
font-size: 12px;
font-weight: 900;
height: 24px;
left: 7px;
place-items: center;
position: absolute;
width: 24px;
}
.generate-button {
background: linear-gradient(135deg, #2563eb, #7c3aed);
background: linear-gradient(135deg, #2563eb, #7c3aed 58%, #f97316);
border-radius: 18px;
box-shadow: 0 18px 36px rgba(37, 99, 235, 0.28);
font-size: 17px;
padding: 15px 18px;
}
.progress-card {
align-items: flex-start;
background: #f8fafc;
border: 1px solid #e2e8f0;
background: rgba(248, 250, 252, 0.82);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 22px;
display: flex;
gap: 14px;
padding: 15px;
}
.result-progress {
margin-top: auto;
}
.spinner {
border: 3px solid rgba(148, 163, 184, 0.32);
border-top-color: #2563eb;
@@ -374,13 +499,21 @@ small {
.empty-state {
align-items: center;
border: 1px dashed #cbd5e1;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.5), rgba(255, 255, 255, 0.76));
border: 1px dashed rgba(148, 163, 184, 0.68);
border-radius: 26px;
color: #64748b;
display: grid;
flex: 1;
min-height: 420px;
place-items: center;
text-align: center;
margin-bottom: 16px;
}
.empty-state > div {
display: grid;
place-items: center;
}
.empty-state div {
@@ -390,17 +523,22 @@ small {
margin-bottom: 8px;
}
.empty-state p {
margin: 0;
}
.image-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
margin-bottom: 18px;
}
.image-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 22px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 24px;
box-shadow: 0 18px 46px rgba(15, 23, 42, 0.1);
overflow: hidden;
}
@@ -487,6 +625,11 @@ small {
gap: 10px;
}
.drawer-actions button {
border-radius: 12px;
padding: 8px 14px;
}
.saved-providers {
border-top: 1px solid #e2e8f0;
display: grid;
@@ -536,28 +679,11 @@ small {
}
@media (max-width: 980px) {
.workspace {
grid-template-columns: 1fr;
}
.result-card {
min-height: 420px;
}
.workspace { grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); }
}
@media (max-width: 720px) {
.app-shell {
padding: 12px;
}
.topbar, .topbar-actions {
align-items: stretch;
flex-direction: column;
}
.grid.two, .provider-item {
grid-template-columns: 1fr;
}
.grid.two, .provider-item { grid-template-columns: 1fr; }
}
.params-card {
@@ -565,37 +691,18 @@ small {
border: 1px solid #e2e8f0;
border-radius: 22px;
display: grid;
gap: 12px;
padding: 14px;
gap: 8px;
padding: 10px;
}
.segmented {
background: #eef2f7;
border-radius: 16px;
display: grid;
.compact-field {
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;
.compact-field input,
.compact-field select {
border-radius: 14px;
padding: 10px 12px;
}
.image-preview-button {

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />