Store image style presets with usage ordering
This commit is contained in:
@@ -44,7 +44,7 @@ Use this table before searching.
|
||||
| --- | --- | --- |
|
||||
| Home page, shell, navigation, footer, announcement popup | `src/app/page.tsx`, `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, `src/components/announcement-popup.tsx` | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/app/api/announcements/route.ts` |
|
||||
| Create center tabs | `src/app/create/page.tsx` | `src/components/create/*` |
|
||||
| Text/image generation | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/generation-job-*` |
|
||||
| Text/image generation | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/generation-job-*`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts` |
|
||||
| Video generation | `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | `src/app/api/generate/video/route.ts` |
|
||||
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
|
||||
| Model/provider visibility | `src/lib/model-config.ts`, `src/lib/model-config-types.ts`, `src/lib/server-api-config.ts` | `src/app/api/model-config/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/providers/route.ts`, `src/app/api/user-api-keys/route.ts` |
|
||||
|
||||
@@ -35,6 +35,7 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
|
||||
| PUT | `/api/announcements` | Admin | `src/app/api/announcements/route.ts` | Update announcement. Body includes `id` and changed fields. |
|
||||
| DELETE | `/api/announcements?id=...` | Admin | `src/app/api/announcements/route.ts` | Delete announcement. |
|
||||
| GET | `/api/model-config` | Public | `src/app/api/model-config/route.ts` | Read managed provider/model configuration for clients. |
|
||||
| GET | `/api/style-presets` | Public | `src/app/api/style-presets/route.ts` | Returns active image style presets from `image_style_presets`, sorted by usage count. |
|
||||
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve local storage object by key. |
|
||||
| GET | `/api/download?url=...&filename=...` | Public by URL | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and local-storage URLs. |
|
||||
|
||||
@@ -70,7 +71,7 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video", payload: {...} }` | Inserts `generation_jobs`, starts worker, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. |
|
||||
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists result to local storage, updates job progress when headers include job ID. |
|
||||
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference image, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or custom endpoint, persists media to local storage. |
|
||||
|
||||
@@ -86,6 +86,7 @@ Major groups:
|
||||
- `profile/*`: user profile and theme.
|
||||
- `user-api-keys`: user-owned custom API credentials.
|
||||
- `model-config`: public model/provider config.
|
||||
- `style-presets`: public DB-backed image style presets ordered by usage count.
|
||||
- `generate/*`: direct generation, reverse prompt, prompt suggestion.
|
||||
- `generation-jobs/*`: queued generation job creation/status.
|
||||
- `creation-history`: user works/history.
|
||||
@@ -126,6 +127,8 @@ Key source files:
|
||||
- `src/components/create/image-to-video.tsx`
|
||||
- `src/lib/generation-job-client.ts`
|
||||
- `src/app/api/generation-jobs/route.ts`
|
||||
- `src/app/api/style-presets/route.ts`
|
||||
- `src/lib/style-preset-store.ts`
|
||||
- `src/lib/generation-job-worker.ts`
|
||||
- `src/lib/generation-job-runner.ts`
|
||||
- `src/app/api/generate/image/route.ts`
|
||||
|
||||
@@ -29,7 +29,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Footer content missing or not Markdown-rendered | `src/components/site-footer.tsx`, `src/components/site-policy-page.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Config response fields, Markdown renderer, fallback defaults, PUT persistence. |
|
||||
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
|
||||
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
|
||||
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background should be `w-full`, but content should use wide adaptive containers such as `max-w-[1680px]` plus responsive padding. Do not fix this by removing all max widths; keep text, tabs, policy articles, and compact controls constrained for readability. |
|
||||
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
|
||||
| Disabled canvas/`画布` appears again in public UI | `src/components/navbar.tsx`, `src/app/canvas/page.tsx`, `docs/codex-miaojing/feature-code-index.md` | Navbar should not include `/canvas`, and `/canvas` should continue to call `notFound()` unless the product explicitly re-enables the legacy canvas feature. |
|
||||
| Announcement not popping up | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/components/app-shell.tsx` | App shell includes popup, active date range, local/session dismissal behavior, GET payload shape. |
|
||||
| Announcement admin edit fails | `src/components/admin/announcement-tab.tsx`, `src/app/api/announcements/route.ts` | Admin token, required fields, `starts_at`/`expires_at` compatibility. |
|
||||
@@ -48,6 +48,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Generated result previews but does not persist | `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/creation-history/route.ts` | Media copied to local storage, presigned URL returned, history POST called. |
|
||||
| Fullscreen/preview/download broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. |
|
||||
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
|
||||
| Style presets are hardcoded, missing, or not ordered by usage | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/app/api/generation-jobs/route.ts` | Presets should come from `image_style_presets`; `generation-jobs` increments `usage_count`; GET `/api/style-presets` should return active presets sorted by usage count. |
|
||||
| Reverse prompt option missing | `src/components/create/reverse-prompt-panel.tsx`, `src/app/api/generate/reverse-prompt/route.ts` | UI option list and server `outputMode` handling both updated, app rebuilt/restarted if deployed. |
|
||||
| Prompt optimization fails | `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/server-api-config.ts`, `src/lib/custom-api-fetch.ts` | Text-capable system/custom API, chat response shape, JSON parsing fallback. |
|
||||
|
||||
@@ -67,6 +68,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| --- | --- | --- |
|
||||
| History missing after generation | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder. |
|
||||
| Published work not in gallery | `src/lib/creation-history-store.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, media copied to gallery folder, filters. |
|
||||
| Imported gallery images do not render after production data import | `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | Check whether production media files were copied into `LOCAL_STORAGE_DIR`; DB rows alone are not enough for relative `/api/local-storage/*` URLs. |
|
||||
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
|
||||
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `limit`, `offset`, `sort`, `q/search`; SQL where/order. |
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, and full-width page mounting. Use wide adaptive containers inside pages instead of stretching all content to viewport edges. |
|
||||
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, and full-width page mounting. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. |
|
||||
| Home page | `src/app/page.tsx` | Landing/dashboard-like public entry. Check site config dependencies when changing brand text. |
|
||||
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav intentionally excludes the disabled legacy canvas route. |
|
||||
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content uses the shared wide adaptive width. |
|
||||
| Announcement popup | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts` | Frontend popup behavior plus backend announcement CRUD. |
|
||||
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content keeps the original `max-w-7xl` scale. |
|
||||
| Announcement popup | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts` | Frontend popup behavior plus backend announcement CRUD. Desktop dialog is intentionally wide (`max-w-5xl`) for long Markdown notices. |
|
||||
| Site config sync | `src/components/site-config-sync.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Site name, tab title, logo, favicon, policy Markdown, filing, membership switch. |
|
||||
| Visit tracking | `src/components/visit-tracker.tsx`, `src/app/api/site-stats/route.ts` | Public visit counter. |
|
||||
|
||||
@@ -56,7 +56,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
|
||||
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
|
||||
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
|
||||
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/model-config.ts` | Style preset selection and image params. |
|
||||
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. |
|
||||
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. |
|
||||
| Lightbox/fullscreen | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx` | Preview, copy, download, share. |
|
||||
|
||||
@@ -65,7 +65,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Responsibility | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs` and starts worker. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, and increments selected image style preset usage. |
|
||||
| Job status API | `src/app/api/generation-jobs/[id]/route.ts` | Owner/admin visibility, stale running job handling. |
|
||||
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. |
|
||||
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
|
||||
@@ -79,8 +79,8 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in model options | `src/lib/model-config.ts`, `src/lib/model-config-types.ts` | Image/video model lists, ratios, sizes, inference helpers. |
|
||||
| Public model config API | `src/app/api/model-config/route.ts` | Returns model recommendations and provider config to clients. |
|
||||
| Built-in model options | `src/lib/model-config.ts`, `src/lib/model-config-types.ts` | Image/video model lists, ratios, sizes, inference helpers, and fallback style preset seed labels. Runtime style ordering comes from DB. |
|
||||
| Public model config API | `src/app/api/model-config/route.ts`, `src/app/api/style-presets/route.ts` | Returns model/provider config plus DB-backed image style presets for clients. |
|
||||
| User custom API keys | `src/lib/custom-api-store.ts`, `src/app/api/user-api-keys/route.ts`, `src/components/profile/api-key-manager.tsx` | User-owned encrypted API credentials. |
|
||||
| Admin provider presets | `src/app/api/admin/providers/route.ts`, `src/components/admin/api-management-tab.tsx` | Provider registry, default API URL/model/type. |
|
||||
| Admin system API configs | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts` | Encrypted shared system API credentials, pricing metadata. |
|
||||
@@ -148,6 +148,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Area | Files |
|
||||
| --- | --- |
|
||||
| Database connection pool | `src/storage/database/local-db.ts` |
|
||||
| Image style preset store | `src/lib/style-preset-store.ts`, `src/app/api/style-presets/route.ts` |
|
||||
| Schema snapshot | `src/storage/database/shared/schema.ts` |
|
||||
| Supabase compatibility client | `src/storage/database/supabase-client.ts` |
|
||||
| Init SQL | `scripts/init-database.sql` |
|
||||
|
||||
@@ -335,6 +335,19 @@ CREATE INDEX IF NOT EXISTS api_providers_active_sort_idx ON api_providers (is_ac
|
||||
CREATE INDEX IF NOT EXISTS model_recommendations_active_type_sort_idx ON model_recommendations (is_active, type, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS model_recommendations_provider_idx ON model_recommendations (provider_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS image_style_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
label VARCHAR(128) NOT NULL UNIQUE,
|
||||
prompt TEXT NOT NULL,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS image_style_presets_active_usage_idx ON image_style_presets (is_active, usage_count DESC, sort_order ASC);
|
||||
|
||||
INSERT INTO api_providers (name, default_api_url, default_model, type, website, is_active, sort_order)
|
||||
VALUES
|
||||
('硅基流动', 'https://api.siliconflow.cn/v1/images/generations', 'black-forest-labs/FLUX.1-schnell', 'image', 'https://cloud.siliconflow.cn', true, 10),
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function GET(request: NextRequest) {
|
||||
'user_api_keys',
|
||||
'system_api_configs',
|
||||
'payment_methods',
|
||||
'image_style_presets',
|
||||
'work_likes',
|
||||
'announcements',
|
||||
];
|
||||
|
||||
@@ -31,6 +31,7 @@ const UUID_ID_TABLES = new Set([
|
||||
'orders',
|
||||
'user_api_keys',
|
||||
'system_api_configs',
|
||||
'image_style_presets',
|
||||
'work_likes',
|
||||
]);
|
||||
|
||||
@@ -44,6 +45,7 @@ const TABLE_COLUMNS: Record<string, string[]> = {
|
||||
orders: ['id', 'user_id', 'order_no', 'product_type', 'product_name', 'amount', 'credits_amount', 'status', 'payment_method', 'paid_at', 'created_at', 'updated_at'],
|
||||
user_api_keys: ['id', 'user_id', 'provider', 'supplier_name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'is_active', 'created_at', 'updated_at'],
|
||||
system_api_configs: ['id', 'provider', 'name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'credits_per_use', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
image_style_presets: ['id', 'label', 'prompt', 'usage_count', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
payment_methods: ['id', 'type', 'name', 'is_active', 'public_config', 'secret_config_encrypted', 'secret_config_preview', 'created_at', 'updated_at'],
|
||||
work_likes: ['id', 'user_id', 'work_id', 'created_at'],
|
||||
};
|
||||
@@ -62,6 +64,7 @@ const CONFLICT_COLUMNS: Record<string, string[]> = {
|
||||
orders: ['id'],
|
||||
user_api_keys: ['id'],
|
||||
system_api_configs: ['id'],
|
||||
image_style_presets: ['id'],
|
||||
payment_methods: ['id'],
|
||||
work_likes: ['id'],
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveGenerationJobIdentity,
|
||||
} from '@/lib/generation-job-estimates';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { incrementImageStylePresetUsage } from '@/lib/style-preset-store';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -64,6 +65,11 @@ export async function POST(request: NextRequest) {
|
||||
],
|
||||
);
|
||||
jobId = result.rows[0].id as string;
|
||||
if (type === 'image' && typeof payload.styleLabel === 'string') {
|
||||
await incrementImageStylePresetUsage(client, payload.styleLabel).catch(error => {
|
||||
console.warn('[generation-jobs] style preset usage update failed:', error);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
13
src/app/api/style-presets/route.ts
Normal file
13
src/app/api/style-presets/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { STYLE_PRESETS } from '@/lib/model-config';
|
||||
import { listImageStylePresets } from '@/lib/style-preset-store';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const presets = await listImageStylePresets();
|
||||
return NextResponse.json({ presets });
|
||||
} catch (err) {
|
||||
console.error('[style-presets] GET error:', err);
|
||||
return NextResponse.json({ presets: STYLE_PRESETS });
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function CreateContent() {
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="mx-auto grid w-full max-w-5xl grid-cols-5">
|
||||
<TabsList className="grid w-full grid-cols-5 max-w-4xl">
|
||||
<TabsTrigger value="text2img" className="gap-2">
|
||||
<Brush className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">文生图</span>
|
||||
@@ -74,7 +74,7 @@ function CreateContent() {
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto w-full max-w-[1680px] px-4 py-8 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="font-serif text-3xl font-bold">创作中心</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
|
||||
@@ -424,7 +424,7 @@ export default function GalleryPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto w-full max-w-[1800px] px-4 py-8 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function HomePage() {
|
||||
<div className="absolute bottom-0 right-0 w-[400px] h-[400px] bg-primary/3 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-[1440px] px-4 pb-24 pt-20 text-center sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 pt-20 pb-24 text-center">
|
||||
<Badge variant="secondary" className="mb-6 px-4 py-1.5 text-sm font-medium gap-2">
|
||||
<SiteLogo className="h-5 w-5 rounded" />
|
||||
一站式AI多模态创作平台
|
||||
@@ -157,7 +157,7 @@ export default function HomePage() {
|
||||
|
||||
{/* Core Features */}
|
||||
<section className="py-24 bg-muted/20">
|
||||
<div className="mx-auto w-full max-w-[1680px] px-4 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">四大核心能力</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">从文字到画面,从静态到动态,全方位AI创作体验</p>
|
||||
@@ -193,7 +193,7 @@ export default function HomePage() {
|
||||
|
||||
{/* Highlights */}
|
||||
<section className="py-24">
|
||||
<div className="mx-auto w-full max-w-[1440px] px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">为什么选择妙境</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">创作无界,效率无限</p>
|
||||
@@ -219,7 +219,7 @@ export default function HomePage() {
|
||||
{/* Pricing */}
|
||||
<BillingPlanGuard>
|
||||
<section className="py-24 bg-muted/20">
|
||||
<div className="mx-auto w-full max-w-[1680px] px-4 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">灵活的计费方案</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">按需选择,从免费体验到企业定制</p>
|
||||
@@ -271,7 +271,7 @@ export default function HomePage() {
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-24">
|
||||
<div className="mx-auto w-full max-w-[1120px] px-4 text-center sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 text-center">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">准备好了吗?</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
加入数千名创作者,用AI开启你的创作之旅
|
||||
|
||||
@@ -411,7 +411,7 @@ export default function ProfilePage() {
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto w-full max-w-[1680px] px-4 py-8 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-muted animate-pulse" />
|
||||
@@ -428,7 +428,7 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto w-full max-w-[1680px] px-4 py-8 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
{/* Profile Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -518,7 +518,7 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className={`grid w-full max-w-5xl grid-cols-3 ${membershipEnabled ? 'sm:grid-cols-6' : 'sm:grid-cols-3'}`}>
|
||||
<TabsList className={`grid w-full grid-cols-3 ${membershipEnabled ? 'sm:grid-cols-6' : 'sm:grid-cols-3'} max-w-3xl`}>
|
||||
<TabsTrigger value="account" className="gap-1.5"><User className="h-4 w-4" /><span className="hidden sm:inline">账户</span></TabsTrigger>
|
||||
{membershipEnabled && <TabsTrigger value="membership" className="gap-1.5"><Crown className="h-4 w-4" /><span className="hidden sm:inline">会员</span></TabsTrigger>}
|
||||
{membershipEnabled && <TabsTrigger value="credits" className="gap-1.5"><Coins className="h-4 w-4" /><span className="hidden sm:inline">积分</span></TabsTrigger>}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AnnouncementPopup() {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className="w-[calc(100vw-2rem)] !max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone className="h-5 w-5 text-primary" />
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
IMAGE_QUALITY_OPTIONS,
|
||||
RESOLUTION_OPTIONS,
|
||||
IMG2IMG_STYLE_PRESETS,
|
||||
getImageStylePreset,
|
||||
isCustomModel,
|
||||
isCustomModel,
|
||||
isSystemModel,
|
||||
getCustomKeyId,
|
||||
getSystemApiId,
|
||||
@@ -47,6 +46,7 @@ import { GenerationLoadingPanel } from '@/components/create/generation-loading-p
|
||||
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
|
||||
import { ImageCountCombobox } from '@/components/create/image-count-combobox';
|
||||
import { StylePresetSelector } from '@/components/create/style-preset-selector';
|
||||
import { useImageStylePresets } from '@/lib/style-presets-client';
|
||||
|
||||
const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
|
||||
|
||||
@@ -94,8 +94,9 @@ export function ImageToImagePanel() {
|
||||
// Lightbox state
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
|
||||
// History detail dialog
|
||||
// History detail dialog
|
||||
const [selectedHistoryRecord, setSelectedHistoryRecord] = useState<CreationRecord | null>(null);
|
||||
const stylePresets = useImageStylePresets(IMG2IMG_STYLE_PRESETS);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -318,7 +319,10 @@ export function ImageToImagePanel() {
|
||||
() => inferImageParamsFromPrompt(prompt, { allowOriginalAspectRatio: true }),
|
||||
[prompt],
|
||||
);
|
||||
const selectedStylePreset = useMemo(() => getImageStylePreset(selectedStyleLabel), [selectedStyleLabel]);
|
||||
const selectedStylePreset = useMemo(
|
||||
() => stylePresets.find(preset => preset.label === selectedStyleLabel),
|
||||
[stylePresets, selectedStyleLabel],
|
||||
);
|
||||
const creditCount = count === 'auto' ? (inferredImageParams.count ?? 1) : (Number(count) || 1);
|
||||
const credits = calcImageCredits(selectedModel, resolution, aspectRatio, creditCount);
|
||||
|
||||
@@ -569,7 +573,7 @@ export function ImageToImagePanel() {
|
||||
onValueChange={setPrompt}
|
||||
/>
|
||||
<StylePresetSelector
|
||||
presets={IMG2IMG_STYLE_PRESETS}
|
||||
presets={stylePresets}
|
||||
selectedLabel={selectedStyleLabel}
|
||||
onSelect={setSelectedStyleLabel}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
IMAGE_QUALITY_OPTIONS,
|
||||
RESOLUTION_OPTIONS,
|
||||
STYLE_PRESETS,
|
||||
getImageStylePreset,
|
||||
isCustomModel,
|
||||
isSystemModel,
|
||||
getCustomKeyId,
|
||||
@@ -45,6 +44,7 @@ import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-
|
||||
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
|
||||
import { ImageCountCombobox } from '@/components/create/image-count-combobox';
|
||||
import { StylePresetSelector } from '@/components/create/style-preset-selector';
|
||||
import { useImageStylePresets } from '@/lib/style-presets-client';
|
||||
|
||||
const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
|
||||
|
||||
@@ -85,6 +85,7 @@ export function TextToImagePanel() {
|
||||
|
||||
// History detail dialog
|
||||
const [selectedHistoryRecord, setSelectedHistoryRecord] = useState<CreationRecord | null>(null);
|
||||
const stylePresets = useImageStylePresets(STYLE_PRESETS);
|
||||
|
||||
const applyPromptDraft = useCallback((draft: unknown) => {
|
||||
if (!draft || typeof draft !== 'object') return;
|
||||
@@ -193,7 +194,10 @@ export function TextToImagePanel() {
|
||||
}, [prompt, user, accessToken, textModelOptions, getCurrentModelLabel]);
|
||||
|
||||
const inferredImageParams = useMemo(() => inferImageParamsFromPrompt(prompt), [prompt]);
|
||||
const selectedStylePreset = useMemo(() => getImageStylePreset(selectedStyleLabel), [selectedStyleLabel]);
|
||||
const selectedStylePreset = useMemo(
|
||||
() => stylePresets.find(preset => preset.label === selectedStyleLabel),
|
||||
[stylePresets, selectedStyleLabel],
|
||||
);
|
||||
const creditCount = count === 'auto' ? (inferredImageParams.count ?? 1) : (Number(count) || 1);
|
||||
const credits = calcImageCredits(selectedModel, resolution, aspectRatio, creditCount);
|
||||
|
||||
@@ -389,7 +393,7 @@ export function TextToImagePanel() {
|
||||
onValueChange={setPrompt}
|
||||
/>
|
||||
<StylePresetSelector
|
||||
presets={STYLE_PRESETS}
|
||||
presets={stylePresets}
|
||||
selectedLabel={selectedStyleLabel}
|
||||
onSelect={setSelectedStyleLabel}
|
||||
/>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function Navbar() {
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 w-full max-w-[1680px] items-center justify-between px-4 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<img
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SiteFooter() {
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border/50 py-12">
|
||||
<div className="mx-auto w-full max-w-[1680px] px-4 sm:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SitePolicyPage({ kind }: { kind: PolicyPageKind }) {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 py-10 sm:px-6">
|
||||
<header className="mb-10 flex items-center justify-between gap-4">
|
||||
<Link href="/" className="inline-flex items-center gap-2">
|
||||
<SiteLogo className="h-8 w-8 rounded" />
|
||||
|
||||
@@ -252,7 +252,7 @@ export type ImageStylePreset = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
const IMAGE_STYLE_PRESET_LABELS = [
|
||||
export const IMAGE_STYLE_PRESET_LABELS = [
|
||||
'写实照片', '动漫插画', '水墨国风', '油画质感', '赛博朋克', '水彩淡雅', '像素复古', '极简线条', '梦幻童话', '暗黑哥特',
|
||||
'电影写实', '胶片摄影', '宝丽来', '复古港风', '日系清新', '韩系写真', '法式浪漫', '美式复古', '北欧极简', '东方禅意',
|
||||
'新中式', '国潮插画', '工笔重彩', '宋画雅致', '敦煌壁画', '浮世绘', '漫画分镜', '少女漫画', '少年热血', '欧美漫画',
|
||||
@@ -268,7 +268,7 @@ const IMAGE_STYLE_PRESET_LABELS = [
|
||||
'运动视觉', '奢华金色', '透明水晶', '液态金属', '全息镭射', '红外摄影', '航拍视角', '鱼眼镜头', '长曝光', '双重曝光',
|
||||
] as const;
|
||||
|
||||
function buildImage2StylePrompt(label: string): string {
|
||||
export function buildImage2StylePrompt(label: string): string {
|
||||
return `Apply a ${label} visual style for an image2 generation model; preserve the user's main subject and composition, refine lighting, color grading, texture, and detail quality, keep clean edges and coherent anatomy, no text or watermark.`;
|
||||
}
|
||||
|
||||
|
||||
83
src/lib/style-preset-store.ts
Normal file
83
src/lib/style-preset-store.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import {
|
||||
IMAGE_STYLE_PRESET_LABELS,
|
||||
buildImage2StylePrompt,
|
||||
type ImageStylePreset,
|
||||
} from '@/lib/model-config';
|
||||
|
||||
export type StoredImageStylePreset = ImageStylePreset & {
|
||||
id: string;
|
||||
usageCount: number;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export async function ensureStylePresetSchema(client: PoolClient): Promise<void> {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS image_style_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
label VARCHAR(128) NOT NULL UNIQUE,
|
||||
prompt TEXT NOT NULL,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
`);
|
||||
await client.query('CREATE INDEX IF NOT EXISTS image_style_presets_active_usage_idx ON image_style_presets (is_active, usage_count DESC, sort_order ASC)');
|
||||
|
||||
for (const [index, label] of IMAGE_STYLE_PRESET_LABELS.entries()) {
|
||||
await client.query(
|
||||
`INSERT INTO image_style_presets (label, prompt, sort_order)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (label) DO UPDATE
|
||||
SET prompt = COALESCE(NULLIF(image_style_presets.prompt, ''), EXCLUDED.prompt),
|
||||
sort_order = image_style_presets.sort_order`,
|
||||
[label, buildImage2StylePrompt(label), (index + 1) * 10],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStylePreset(row: Record<string, unknown>): StoredImageStylePreset {
|
||||
return {
|
||||
id: String(row.id),
|
||||
label: String(row.label || ''),
|
||||
prompt: String(row.prompt || ''),
|
||||
usageCount: Number(row.usage_count || 0),
|
||||
sortOrder: Number(row.sort_order || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listImageStylePresets(): Promise<StoredImageStylePreset[]> {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureStylePresetSchema(client);
|
||||
const result = await client.query(
|
||||
`SELECT id, label, prompt, usage_count, sort_order
|
||||
FROM image_style_presets
|
||||
WHERE is_active = true
|
||||
ORDER BY usage_count DESC, sort_order ASC, label ASC`,
|
||||
);
|
||||
return result.rows.map(mapStylePreset);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function incrementImageStylePresetUsage(
|
||||
client: PoolClient,
|
||||
label: string | undefined,
|
||||
): Promise<void> {
|
||||
const normalized = label?.trim();
|
||||
if (!normalized) return;
|
||||
await ensureStylePresetSchema(client);
|
||||
await client.query(
|
||||
`INSERT INTO image_style_presets (label, prompt, usage_count, sort_order)
|
||||
VALUES ($1, $2, 1, 99990)
|
||||
ON CONFLICT (label) DO UPDATE
|
||||
SET usage_count = image_style_presets.usage_count + 1,
|
||||
updated_at = now()`,
|
||||
[normalized, buildImage2StylePrompt(normalized)],
|
||||
);
|
||||
}
|
||||
29
src/lib/style-presets-client.ts
Normal file
29
src/lib/style-presets-client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ImageStylePreset } from '@/lib/model-config';
|
||||
|
||||
export function useImageStylePresets(fallback: ImageStylePreset[]): ImageStylePreset[] {
|
||||
const [presets, setPresets] = useState<ImageStylePreset[]>(fallback);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/style-presets')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (cancelled || !Array.isArray(data?.presets)) return;
|
||||
const next = data.presets.filter((item: unknown): item is ImageStylePreset => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const preset = item as Partial<ImageStylePreset>;
|
||||
return typeof preset.label === 'string' && typeof preset.prompt === 'string';
|
||||
});
|
||||
if (next.length > 0) setPresets(next);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return presets;
|
||||
}
|
||||
Reference in New Issue
Block a user