feat(create): improve mobile creation experience
This commit is contained in:
@@ -129,8 +129,8 @@ All routes in this section require admin unless noted.
|
||||
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. GET is currently not guarded in source; mutations require admin. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. |
|
||||
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key as `待填写` for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
|
||||
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
|
||||
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |
|
||||
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview retained for the system-default-model template path, not for the `智能配置 API` UI. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
|
||||
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer retained for system-default-model template management, not for the generic smart import UI. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/model-recommendations` | `src/app/api/admin/model-recommendations/route.ts` | Managed model recommendations. |
|
||||
| GET/DELETE | `/api/admin/generation-jobs` | `src/app/api/admin/generation-jobs/route.ts` | Admin task listing and deletion. |
|
||||
| GET | `/api/admin/data-export` | `src/app/api/admin/data-export/route.ts` | Export business data plus `_media` entries for storage assets referenced by works and site config. `_meta` reports media count/bytes/missing/skipped. |
|
||||
@@ -164,7 +164,7 @@ Primary SQL tables touched directly in API routes include:
|
||||
|
||||
`user_api_keys.manifest_path` is an optional local-storage key for an imported JSON Manifest. The storage convention is `user-api-manifests/<userId>/<keyId>.json`, so even the same user can have multiple isolated request configs. Generation must load the manifest linked to the selected model/key row instead of looking up a user-level shared config.
|
||||
|
||||
`system_api_configs.polling_mode` and `system_api_configs.polling_order` control admin default-model supplier fallback for image generation. `system_api_configs.video_usage_modes` controls whether a video model appears in 文生视频, 图生视频, or both creation entries. `/api/model-config` deduplicates default system rows by model type/name for clients, while `/api/generate/image` expands the selected row back into allowed same-model supplier candidates.
|
||||
`system_api_configs.polling_mode` and `system_api_configs.polling_order` control admin default-model supplier fallback for image generation. `system_api_configs.video_usage_modes` controls whether a video model appears in 文生视频, 图生视频, or both creation entries. `/api/model-config` deduplicates default system rows by media type plus admin display name (`system_api_configs.name`) for clients, while `/api/generate/image` expands the selected row back into allowed supplier candidates with the same media type and display name. `model_name` stays provider-specific and is used as the upstream request model value.
|
||||
|
||||
`redeem_codes` stores admin-generated single-use credit and membership redemption codes. Runtime code generation and redemption go through `src/lib/redeem-code-service.ts`; redemption must lock both the code row and profile row in one transaction before updating `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes. Credit-code redemptions also insert a `credit_transactions` record.
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ The app is route-driven through `src/app`:
|
||||
- Profile: `src/app/profile/page.tsx` plus `src/components/profile/*`.
|
||||
- Admin console: `src/app/console/*`, `src/modules/console/pages/*`, `src/components/admin/*`.
|
||||
|
||||
Mobile adaptation is handled primarily through page-level structure classes plus `src/app/globals.css`. The create center uses `.create-chat-layout`, `.create-chat-thread`, and `.create-chat-composer` so phones behave like a modern AI chat client: the single mode switch is the sticky icon row, the page title and duplicate text mode strip are hidden, and text-to-image reads as a chronological conversation from oldest to newest so the latest work sits above the fixed composer. Text-to-image suppresses the empty result placeholder until the user submits a prompt, then renders the generating task as the newest prompt-plus-progress message. `src/components/create/mobile-creation-composer.tsx` is the fixed bottom input shell for text-to-image; it holds extra-compact labeled ratio/resolution/count controls and similar parameters, the optional style strip, the prompt input, and the right-side send button, and intentionally does not duplicate mode switching. Gallery masonry keeps at least two columns on phone widths. The admin console keeps the drawer navigation from `console-dashboard-page.tsx` and uses `console-mobile-*` shell rules to constrain cards while allowing dense admin tables to scroll horizontally instead of overflowing the viewport.
|
||||
|
||||
Client stores in `src/lib/*-store.ts` mediate API calls and local UI state. When fixing a UI persistence bug, inspect both the component and the matching store/API route.
|
||||
|
||||
## API Architecture
|
||||
@@ -146,7 +148,7 @@ There are three provider sources:
|
||||
|
||||
- `customApiKeyId` into a user-owned decrypted API config.
|
||||
- `systemApiId` into an active admin-managed decrypted API config after checking platform-default visibility and the requesting user's membership tier.
|
||||
- `systemApiId` polling candidates for admin default models by matching `type + model_name` across active/default system API rows and ordering them by `polling_mode` plus `polling_order`/`sort_order`.
|
||||
- `systemApiId` polling candidates for admin default models by matching media type plus admin display name (`system_api_configs.name`) across active/default system API rows and ordering them by `polling_mode` plus `polling_order`/`sort_order`; each candidate still sends its own provider-specific `model_name` upstream.
|
||||
- direct `apiKey` passthrough for legacy/custom callers.
|
||||
|
||||
Secrets must be encrypted at rest with `src/lib/server-crypto.ts` and never returned in API responses.
|
||||
@@ -155,7 +157,7 @@ User-level intelligent API imports add a fourth data artifact tied to source 2:
|
||||
|
||||
At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for user custom keys and admin system API keys. `src/app/api/generate/image/route.ts` and `src/app/api/generate/video/route.ts` call `src/lib/user-api-manifest-executor.ts` first when that path exists. The executor handles JSON, multipart file fields, `{task_id}` polling, `*` JSON-path extraction, and media persistence handoff. Imported Manifest rows still need the user or admin to edit and save an API Key before they can generate.
|
||||
|
||||
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. Each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. The same admin panel can call `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` to preview or install built-in 元界 AI image templates from `src/lib/yuanjie-image-model-templates.ts` and video templates from `src/lib/yuanjie-video-model-templates.ts`. This 元界 path is deterministic: it does not call `/v1/skills`, does not parse interface names as models, resets only the media type being installed, and creates inactive per-model system API rows plus per-model Manifest files. Admins must edit each installed row to enter the model Key, set pricing/member visibility/polling, choose `video_usage_modes` for video rows, and enable it before the model is available to users. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed model name/type so the create page shows a single default model, and image generation expands the selected row back into all allowed supplier candidates. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If every supplier fails or returns no usable result, the user-facing error is the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
|
||||
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If every supplier fails or returns no usable result, the user-facing error is the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
|
||||
|
||||
Admin console navigation state is intentionally short-lived. `src/modules/console/pages/console-dashboard-page.tsx` stores the active console view in `sessionStorage`: page refresh stays on the current admin page, logout clears the stored view, and a new browser tab/session opens the dashboard first.
|
||||
|
||||
|
||||
@@ -66,13 +66,13 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| --- | --- | --- |
|
||||
| Model list empty in create/profile | `src/app/api/model-config/route.ts`, `src/lib/model-config.ts`, `src/lib/managed-model-store.ts`, `src/lib/custom-api-store.ts` | Public model config response, admin recommendations, local client store mapping. |
|
||||
| Default model group shows raw API model name instead of the admin display name | `src/lib/model-display.ts`, `src/app/api/model-config/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Frontend system model labels should use `system_api_configs.name` first. `model_name` is the upstream request model identifier and should remain available for generation dispatch, but it should not override the admin-facing display name in the create-page default model group. |
|
||||
| System API saved but not used | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | `systemApiId` in request payload, active config, decrypted key, type matches image/video/text, `is_default` is true, and `allowed_membership_tiers` includes the current user's normalized tier. For admin default image models, also verify same `type + model_name` polling candidates, `polling_mode`, and `polling_order`; user custom APIs should not enter this polling path. |
|
||||
| System API saved but not used | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | `systemApiId` in request payload, active config, decrypted key, type matches image/video/text, `is_default` is true, and `allowed_membership_tiers` includes the current user's normalized tier. For admin default image models, also verify same media type plus same admin display name (`system_api_configs.name`) polling candidates, `polling_mode`, and `polling_order`; `model_name` is only the upstream request model. User custom APIs should not enter this polling path. |
|
||||
| User custom API saved but not used | `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`, `src/lib/server-api-config.ts` | `customApiKeyId`, owner auth, encrypted key exists, `is_active`. |
|
||||
| Intelligent API dialog is too narrow, clipped, or shows only JSON | `src/components/profile/api-key-manager.tsx`, `src/components/ui/dialog.tsx` | Smart import dialogs must override the shared dialog's `sm:max-w-lg` with explicit wide sizing such as `w-[min(...)] max-w-none sm:max-w-none`, cap height to the viewport, and keep the JSON editor inside an internal scrollable/flexible area so title, actions, and footer remain visible. |
|
||||
| Intelligent API import creates wrong or mixed requests | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Each imported profile/model must have its own `user_api_keys` row and `user-api-manifests/<userId>/<keyId>.json` file. Verify `manifest_path` on the selected `customApiKeyId`, not a user-level shared file. Imported edit forms should show a human-readable provider name and a non-empty API request URL derived from `profile.baseUrl + submit.path` only when the Manifest provides enough endpoint data; never invent an OpenAI default URL for a third-party relay document. Editing a key should preserve `manifest_path`; generation should execute the selected manifest before legacy custom API fallback. |
|
||||
| Create model dropdown shows many `导入的 API Key` entries | `src/lib/model-display.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts` | These are user custom API key rows, not admin default models. Generic import placeholder notes must be ignored/cleared so labels show provider plus model or a real custom note plus model. Do not delete user custom API rows unless explicitly requested. |
|
||||
| Admin intelligent API import is missing or generated system models ignore Manifest | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts` | Admin imports must create one `system_api_configs` row per Manifest profile, write `system-api-manifests/<systemApiId>.json`, persist `manifest_path`, and resolve that path from the selected `systemApiId`. Imported rows still need API Key and pricing review before use. |
|
||||
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/components/admin/api-management-tab.tsx`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts` | 元界不应再从 `/v1/skills` 或 `/v1/skills/guide` 猜模型。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
|
||||
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills` 或 `/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
|
||||
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
|
||||
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
|
||||
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |
|
||||
|
||||
@@ -49,15 +49,16 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Primary Files | Server/API Files |
|
||||
| --- | --- | --- |
|
||||
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. |
|
||||
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
|
||||
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx` inside the results column. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. |
|
||||
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. |
|
||||
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |
|
||||
| Image to video | `src/components/create/image-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. |
|
||||
| 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. |
|
||||
| Mobile creation composer | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css` | Mobile-only fixed bottom composer used by text-to-image to match chat-style clients: top parameter strip with extra-compact labeled controls, optional style strip, prompt input, and right send button. Mode selection stays only in the sticky header tabs. Desktop creation forms remain the source for full advanced controls. |
|
||||
| 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/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. |
|
||||
| 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. The selector exposes stable `.style-preset-selector` and `.style-preset-list` classes so mobile create CSS can show a compact collapsed strip and an expanded list of at least several rows inside the bottom composer. |
|
||||
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column. |
|
||||
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
|
||||
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge. `BareImagePreview` is the no-container overlay for uploaded reference image previews. |
|
||||
@@ -72,12 +73,12 @@ Use this document to jump directly to code before broad searching.
|
||||
| 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. |
|
||||
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
|
||||
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. For admin default system models, image generation resolves all same-type/same-model default API candidates and silently retries them by the configured polling mode before returning the generic busy message. User custom APIs remain single-config and do not use this polling fallback. |
|
||||
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. For admin default system models, image generation resolves all same-type/same-display-name default API candidates and silently retries them by the configured polling mode before returning the generic busy message. User custom APIs remain single-config and do not use this polling fallback. |
|
||||
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. |
|
||||
| Custom API transport | `src/lib/custom-api-fetch.ts` | Headers, retries, progress JSON parsing, upstream error parsing. |
|
||||
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by `type + model_name`. |
|
||||
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). The upstream `model_name` remains the per-provider request model only. |
|
||||
| User API smart import | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts`, `src/lib/model-display.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a wide viewport-capped Manifest editor, can copy the LLM prompt, shows guidance under the prompt button explaining the copy-to-chat-AI and paste-and-import flow, can paste clipboard JSON without importing, and can paste-and-import in one action. The prompt instructs the LLM to stop and ask the user for the relay API Base URL when the docs do not contain it. Imports create each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file and reject incomplete configs without a resolvable request URL. Imported rows should store a human-readable provider name in the editable provider/supplier fields and resolve the visible API request URL from `profile.baseUrl + submit.path` for synchronous endpoints. Generic placeholder notes such as `导入的 API Key` must not be used as model labels; creation/profile UI should prefer a real note plus model, or provider plus model. Optional `profile.capabilities` filters or hides create-page aspect ratio, resolution, image format, and quality controls for the selected model. Polling Manifest query values can include `{task_id}` so task IDs are sent as real query parameters rather than being embedded into pathname strings. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
|
||||
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts` | The console API management page has a separate `智能配置 API` section for admins. Its built-in 元界 AI connector no longer fetches `/v1/skills` or guesses models. Instead, `src/lib/yuanjie-image-model-templates.ts` owns 17 deterministic image templates copied from the local 元界 image model docs, and `src/lib/yuanjie-video-model-templates.ts` owns deterministic video templates from the local 元界 video docs. `src/lib/yuanjie-template-installer.ts` resets image rows only for image installs and video rows only for video installs, creates inactive per-model system API rows plus per-model Manifest files, and preserves already configured rows of the other media type. Admins then edit each model row to enter the model Key, set pricing/visibility/member scope, choose video usage modes when applicable, and enable it. The same page still supports generic copy-to-chat-AI and paste-and-import Manifest flow. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. |
|
||||
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. |
|
||||
| Admin console active page persistence | `src/modules/console/pages/console-dashboard-page.tsx` | The console active view is stored in `sessionStorage`, so browser refresh keeps the current admin page/tab. Logout clears the value, and closing/reopening the console starts from the dashboard because `sessionStorage` is tab-scoped. |
|
||||
|
||||
## Models And Providers
|
||||
@@ -106,7 +107,7 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies media into gallery folders and inserts public work. |
|
||||
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works and published state. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
@@ -116,7 +117,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Feature | Frontend | API |
|
||||
| --- | --- | --- |
|
||||
| Console login | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx` | `src/app/api/auth/login/route.ts` with `adminOnly` |
|
||||
| Console dashboard | `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx` | `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts` |
|
||||
| Console dashboard | `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx` | `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts`. The dashboard page owns the mobile admin shell classes (`console-mobile-page`, `console-mobile-main`, `console-mobile-content`) used by `src/app/globals.css` to keep cards constrained and admin tables horizontally scrollable on phones. |
|
||||
| Users | `src/components/admin/user-management-tab.tsx` | `src/app/api/admin/users/route.ts`, `src/app/api/admin/clear-users/route.ts`, `src/app/api/admin/invitations/route.ts`. The user-management UI has separate subpages for `用户列表` and `邀请注册记录`; invitation records have independent search, pagination, total count, inviter/invitee details, invite code, reward amounts, and creation time. |
|
||||
| API/model management | `src/components/admin/api-management-tab.tsx` | `src/app/api/admin/providers/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/model-recommendations/route.ts` |
|
||||
| Pricing | `src/components/admin/pricing-tab.tsx` | Admin store/site config related routes |
|
||||
|
||||
@@ -76,44 +76,50 @@ function CreateContent() {
|
||||
replaceCreateTabUrl(value);
|
||||
};
|
||||
|
||||
const renderModeTriggers = (mobile = false) => (
|
||||
<>
|
||||
<TabsTrigger value="text2img" className="gap-2">
|
||||
<Brush className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>文生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2img" className="gap-2">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>图生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="text2video" className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>文生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2video" className="gap-2">
|
||||
<Film className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>图生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="reversePrompt" className="gap-2">
|
||||
<FileSearch className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>图片反推</span>
|
||||
</TabsTrigger>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<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>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2img" className="gap-2">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="text2video" className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">文生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2video" className="gap-2">
|
||||
<Film className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="reversePrompt" className="gap-2">
|
||||
<FileSearch className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图片反推</span>
|
||||
</TabsTrigger>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="create-mobile-tabs-root space-y-6">
|
||||
<TabsList className="create-mode-tabs create-mode-tabs-desktop grid w-full grid-cols-5 max-w-4xl">
|
||||
{renderModeTriggers()}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="text2img">
|
||||
<TabsContent value="text2img" className="create-tab-content">
|
||||
<TextToImagePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="img2img">
|
||||
<TabsContent value="img2img" className="create-tab-content">
|
||||
<ImageToImagePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="text2video">
|
||||
<TabsContent value="text2video" className="create-tab-content">
|
||||
<TextToVideoPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="img2video">
|
||||
<TabsContent value="img2video" className="create-tab-content">
|
||||
<ImageToVideoPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="reversePrompt">
|
||||
<TabsContent value="reversePrompt" className="create-tab-content">
|
||||
<ReversePromptPanel
|
||||
onUseForTextToImage={() => handleTabChange('text2img')}
|
||||
onUseForImageToImage={() => handleTabChange('img2img')}
|
||||
@@ -125,9 +131,9 @@ function CreateContent() {
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="create-mobile-page min-h-screen bg-background">
|
||||
<div className="create-mobile-shell mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="create-mobile-heading mb-8">
|
||||
<h1 className="font-serif text-3xl font-bold">创作中心</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
选择创作模式,释放你的想象力
|
||||
|
||||
@@ -420,8 +420,7 @@ export default function GalleryPage() {
|
||||
const width = window.innerWidth;
|
||||
if (width >= 1280) setMasonryColumnCount(4);
|
||||
else if (width >= 1024) setMasonryColumnCount(3);
|
||||
else if (width >= 640) setMasonryColumnCount(2);
|
||||
else setMasonryColumnCount(1);
|
||||
else setMasonryColumnCount(2);
|
||||
};
|
||||
|
||||
updateColumnCount();
|
||||
@@ -779,7 +778,7 @@ export default function GalleryPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
className="gallery-masonry-grid grid gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${masonryColumnCount}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{masonryColumns.map((columnWorks, columnIndex) => (
|
||||
|
||||
@@ -550,6 +550,14 @@
|
||||
border-color: rgb(116 88 43 / 0.38) !important;
|
||||
}
|
||||
|
||||
.create-mobile-dialog-composer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.create-mobile-history-flow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mobile-page-shell {
|
||||
@apply px-3 py-4;
|
||||
@@ -562,6 +570,519 @@
|
||||
.mobile-card-list {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.create-mobile-page {
|
||||
min-height: calc(100dvh - env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.create-mobile-shell {
|
||||
display: flex;
|
||||
min-height: calc(100dvh - 4.25rem - env(safe-area-inset-bottom));
|
||||
flex-direction: column;
|
||||
padding: 3.55rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.create-mobile-heading {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.create-mode-tabs-desktop {
|
||||
display: grid;
|
||||
position: fixed;
|
||||
top: 4rem;
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 45;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.create-mobile-tabs-root {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.create-mobile-tabs-root > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.create-mode-tabs {
|
||||
max-width: none;
|
||||
height: 3rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.10);
|
||||
border-radius: 999px;
|
||||
background: rgb(10 16 27 / 0.72);
|
||||
padding: 0.25rem;
|
||||
box-shadow: 0 12px 34px rgb(0 0 0 / 0.22);
|
||||
backdrop-filter: blur(18px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(125%);
|
||||
}
|
||||
|
||||
.light .create-mode-tabs {
|
||||
border-color: rgb(116 88 43 / 0.16);
|
||||
background: rgb(255 255 255 / 0.72);
|
||||
box-shadow: 0 14px 34px rgb(92 69 32 / 0.12);
|
||||
}
|
||||
|
||||
.create-mode-tabs [role="tab"] {
|
||||
min-width: max-content;
|
||||
border-radius: 999px;
|
||||
padding-inline: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.create-mode-tabs [role="tab"] svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
}
|
||||
|
||||
.create-tab-content {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.create-chat-layout {
|
||||
display: flex;
|
||||
min-height: calc(100dvh - 10rem - env(safe-area-inset-bottom));
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-chat-thread {
|
||||
order: 1;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding-bottom: calc(13.5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.create-chat-thread .create-empty-result {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.create-desktop-results {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.create-chat-thread > .liquid-glass {
|
||||
min-height: 38dvh;
|
||||
padding-block: 3.5rem;
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.create-chat-thread .grid.grid-cols-2,
|
||||
.create-chat-thread .grid.grid-cols-3 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.create-chat-composer {
|
||||
position: sticky;
|
||||
bottom: calc(0.5rem + env(safe-area-inset-bottom));
|
||||
z-index: 25;
|
||||
order: 2;
|
||||
max-height: min(58dvh, 31rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid rgb(255 255 255 / 0.11);
|
||||
border-radius: 1.25rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.08), rgb(255 255 255 / 0.035)),
|
||||
rgb(10 16 27 / 0.86);
|
||||
padding: 0.85rem;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.12),
|
||||
0 -14px 34px rgb(0 0 0 / 0.24);
|
||||
backdrop-filter: blur(22px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(125%);
|
||||
}
|
||||
|
||||
.light .create-chat-composer {
|
||||
border-color: rgb(116 88 43 / 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.80), rgb(255 255 255 / 0.62)),
|
||||
rgb(250 247 241 / 0.86);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.72),
|
||||
0 -14px 34px rgb(92 69 32 / 0.12);
|
||||
}
|
||||
|
||||
.create-chat-composer textarea {
|
||||
min-height: 5.5rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.create-chat-composer button,
|
||||
.create-chat-composer [role="combobox"] {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.create-chat-composer .grid.grid-cols-2,
|
||||
.create-chat-composer .grid.grid-cols-3 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gallery-masonry-grid {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.gallery-work-shell:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.gallery-card-border-frame::before {
|
||||
inset: -1px;
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
|
||||
.gallery-work-card [data-slot="card-content"] {
|
||||
height: 6.75rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.gallery-work-card [data-slot="card-content"] p {
|
||||
height: 4.2rem;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.gallery-work-card [data-slot="card-content"] span {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.gallery-work-card .gallery-work-action-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.console-mobile-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.console-mobile-main {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.console-mobile-content {
|
||||
min-width: 0;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.console-mobile-content [data-slot="card"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.console-mobile-content [data-slot="card-header"],
|
||||
.console-mobile-content [data-slot="card-content"],
|
||||
.console-mobile-content [data-slot="card-footer"] {
|
||||
padding-inline: 0.85rem;
|
||||
}
|
||||
|
||||
.console-mobile-content [data-slot="card-content"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.console-mobile-content table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
|
||||
.create-chat-layout:has(.create-mobile-dialog-composer) > .create-chat-composer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.create-mobile-dialog-composer {
|
||||
position: fixed;
|
||||
right: 0.75rem;
|
||||
bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
||||
left: 0.75rem;
|
||||
z-index: 35;
|
||||
display: block;
|
||||
max-height: calc(100dvh - 8.25rem - env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid rgb(255 255 255 / 0.11);
|
||||
border-radius: 1.25rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.08), rgb(255 255 255 / 0.035)),
|
||||
rgb(10 16 27 / 0.92);
|
||||
padding: 0.75rem;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.12),
|
||||
0 -16px 38px rgb(0 0 0 / 0.30);
|
||||
backdrop-filter: blur(24px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(130%);
|
||||
}
|
||||
|
||||
.create-mobile-dialog-composer:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light .create-mobile-dialog-composer {
|
||||
border-color: rgb(116 88 43 / 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.86), rgb(255 255 255 / 0.66)),
|
||||
rgb(250 247 241 / 0.92);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.75),
|
||||
0 -16px 38px rgb(92 69 32 / 0.14);
|
||||
}
|
||||
|
||||
.create-mobile-param-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.1rem;
|
||||
overflow: visible;
|
||||
padding: 0 0 0.45rem;
|
||||
}
|
||||
|
||||
.create-mobile-param-field {
|
||||
display: flex;
|
||||
height: 1.75rem;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.12rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.create-mobile-param-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.create-mobile-param-trigger {
|
||||
width: 3.1rem !important;
|
||||
max-width: 100% !important;
|
||||
height: 1.58rem !important;
|
||||
min-height: 1.58rem !important;
|
||||
padding: 0 0.3rem !important;
|
||||
gap: 0.1rem !important;
|
||||
border-radius: 999px !important;
|
||||
font-size: 0.64rem !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.create-mobile-count-combobox {
|
||||
width: 3.1rem !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.create-mobile-count-combobox input[role="combobox"] {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: 1.58rem !important;
|
||||
min-height: 1.58rem !important;
|
||||
padding: 0 0.72rem 0 0.18rem !important;
|
||||
border-radius: 999px !important;
|
||||
font-size: 0.64rem !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.create-mobile-count-combobox button[aria-label="选择生成数量"] {
|
||||
width: 0.78rem !important;
|
||||
height: 1.58rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-count-combobox [role="listbox"] {
|
||||
width: 3.1rem;
|
||||
min-width: 3.1rem;
|
||||
}
|
||||
|
||||
.create-mobile-param-trigger svg {
|
||||
width: 0.5rem !important;
|
||||
height: 0.5rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-count-combobox svg {
|
||||
width: 0.5rem !important;
|
||||
height: 0.5rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-param-trigger [data-slot="select-value"] {
|
||||
font-size: 0.64rem !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.create-mobile-param-select-content {
|
||||
width: max-content !important;
|
||||
min-width: max-content !important;
|
||||
max-width: min(12rem, calc(100vw - 1.5rem)) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-param-select-content [data-slot="select-viewport"] {
|
||||
width: max-content !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-param-select-item {
|
||||
width: max-content !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 1.8rem !important;
|
||||
gap: 0.25rem !important;
|
||||
padding: 0.3rem 1.45rem 0.3rem 0.55rem !important;
|
||||
border-radius: 0.55rem !important;
|
||||
font-size: 0.75rem !important;
|
||||
line-height: 1.1 !important;
|
||||
}
|
||||
|
||||
.create-mobile-param-select-item [data-slot="select-item-indicator"] {
|
||||
right: 0.45rem !important;
|
||||
width: 0.75rem !important;
|
||||
height: 0.75rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-param-select-item [data-slot="select-item-indicator"] svg {
|
||||
width: 0.72rem !important;
|
||||
height: 0.72rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-count-combobox .glass-popover {
|
||||
width: max-content !important;
|
||||
min-width: 2.8rem !important;
|
||||
padding: 0.25rem !important;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
.create-mobile-count-combobox .glass-popover [role="option"] {
|
||||
min-height: 1.65rem !important;
|
||||
width: max-content !important;
|
||||
min-width: 2.25rem !important;
|
||||
padding: 0.25rem 0.45rem !important;
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.create-mobile-style-strip {
|
||||
max-height: min(12.6rem, calc(100dvh - 19.5rem));
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.create-mobile-style-strip .style-preset-selector {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.create-mobile-style-strip .style-preset-list.is-collapsed {
|
||||
max-height: 1.85rem;
|
||||
}
|
||||
|
||||
.create-mobile-style-strip .style-preset-list.is-expanded {
|
||||
max-height: min(10.7rem, calc(100dvh - 22rem));
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.create-mobile-style-strip [role="radiogroup"],
|
||||
.create-mobile-style-strip .flex {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.create-mobile-input-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.light .create-mobile-input-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.create-mobile-prompt-input {
|
||||
min-height: 5.4rem;
|
||||
max-height: 7.5rem;
|
||||
resize: none;
|
||||
border-radius: 0.9rem;
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.create-mobile-send-button {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
border-radius: 999px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.create-mobile-conversation-card {
|
||||
border-radius: 1.2rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.create-mobile-active-task .liquid-glass {
|
||||
min-height: 13rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.create-mobile-active-task .liquid-glass > div {
|
||||
min-height: 13rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.create-mobile-active-task .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.light .create-mobile-conversation-card {
|
||||
background: rgb(255 255 255 / 0.58);
|
||||
}
|
||||
|
||||
.create-mobile-conversation-prompt {
|
||||
display: block;
|
||||
color: var(--foreground);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.create-desktop-history {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.create-mobile-history-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-mobile-history-end {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.create-mobile-history-image,
|
||||
.create-mobile-history-placeholder {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: min(68vw, 16rem);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.create-mobile-history-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed rgb(255 255 255 / 0.16);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inspector } from 'react-dev-inspector';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { AppShell } from '@/modules/web';
|
||||
@@ -26,6 +26,13 @@ export const metadata: Metadata = {
|
||||
],
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
|
||||
@@ -197,9 +197,6 @@ export default function ApiManagementTab() {
|
||||
const [smartImporting, setSmartImporting] = useState(false);
|
||||
const [systemProviderView, setSystemProviderView] = useState<string | null>(null);
|
||||
const [systemTypeView, setSystemTypeView] = useState<'image' | 'video' | 'text' | null>(null);
|
||||
const [yuanjieLoading, setYuanjieLoading] = useState(false);
|
||||
const [yuanjieSyncing, setYuanjieSyncing] = useState(false);
|
||||
const [yuanjieCapabilities, setYuanjieCapabilities] = useState('');
|
||||
|
||||
const [providerEditingId, setProviderEditingId] = useState<string | null>(null);
|
||||
const [providerName, setProviderName] = useState('');
|
||||
@@ -550,87 +547,6 @@ export default function ApiManagementTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchYuanjieCapabilities = async () => {
|
||||
setYuanjieLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/system-apis/yuanjie-capabilities', {
|
||||
method: 'GET',
|
||||
headers: authHeaders(accessToken),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '读取元界 AI 内置模型失败');
|
||||
setYuanjieCapabilities(data.capabilitiesText || '');
|
||||
toast.success('元界 AI 内置模型已读取');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '读取元界 AI 内置模型失败');
|
||||
} finally {
|
||||
setYuanjieLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncYuanjieModels = async () => {
|
||||
setYuanjieSyncing(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/system-apis/yuanjie-capabilities', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(accessToken),
|
||||
body: JSON.stringify({
|
||||
syncModels: true,
|
||||
isDefault: true,
|
||||
allowedMembershipTiers: ['free', 'pro', 'max', 'ultra'],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '同步元界 AI 模型失败');
|
||||
setYuanjieCapabilities(data.capabilitiesText || '');
|
||||
await refreshSystemApis();
|
||||
toast.success(data.message || '元界 AI 模型已同步');
|
||||
setActiveSection('system');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '同步元界 AI 模型失败');
|
||||
} finally {
|
||||
setYuanjieSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncYuanjieVideoModels = async () => {
|
||||
setYuanjieSyncing(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/system-apis/yuanjie-capabilities', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(accessToken),
|
||||
body: JSON.stringify({
|
||||
syncVideoModels: true,
|
||||
isDefault: true,
|
||||
allowedMembershipTiers: ['free', 'pro', 'max', 'ultra'],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '安装元界 AI 视频模型失败');
|
||||
setYuanjieCapabilities(data.capabilitiesText || '');
|
||||
await refreshSystemApis();
|
||||
toast.success(data.message || '元界 AI 视频模型已安装');
|
||||
setActiveSection('system');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '安装元界 AI 视频模型失败');
|
||||
} finally {
|
||||
setYuanjieSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyYuanjieCapabilities = async () => {
|
||||
if (!yuanjieCapabilities.trim()) {
|
||||
toast.error('请先获取元界 AI 能力文档');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(yuanjieCapabilities);
|
||||
toast.success('元界 AI 能力文档已复制');
|
||||
} catch {
|
||||
toast.error('复制失败,请检查浏览器剪贴板权限');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAllowedTier = (tier: 'free' | 'pro' | 'max' | 'ultra', checked: boolean) => {
|
||||
setFormAllowedTiers(prev => {
|
||||
const next = checked ? [...prev, tier] : prev.filter(item => item !== tier);
|
||||
@@ -936,7 +852,7 @@ export default function ApiManagementTab() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg"><Bot className="h-5 w-5" />智能配置 API</CardTitle>
|
||||
<CardDescription>内置元界 AI 同步和通用 Manifest 导入;每个模型会生成独立系统 API 配置。</CardDescription>
|
||||
<CardDescription>通用 Manifest 导入;每个模型会生成独立系统 API 配置。</CardDescription>
|
||||
</div>
|
||||
<Badge variant={isSmartConfigJson(smartConfigText) ? 'default' : 'outline'}>
|
||||
{isSmartConfigJson(smartConfigText) ? '可导入' : '待检查'}
|
||||
@@ -978,39 +894,8 @@ export default function ApiManagementTab() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-background/45 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-medium">元界 AI 内置模型</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-2 xl:grid-cols-3">
|
||||
<Button variant="outline" className="gap-2" onClick={fetchYuanjieCapabilities} disabled={yuanjieLoading}>
|
||||
{yuanjieLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Globe className="h-4 w-4" />}
|
||||
查看模板
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={syncYuanjieModels} disabled={yuanjieSyncing || yuanjieLoading || !membershipEnabled}>
|
||||
{yuanjieSyncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
重置图片模型
|
||||
</Button>
|
||||
<Button className="gap-2" onClick={syncYuanjieVideoModels} disabled={yuanjieSyncing || yuanjieLoading || !membershipEnabled}>
|
||||
{yuanjieSyncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
安装视频模型
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Button variant="outline" className="gap-2" onClick={copyYuanjieCapabilities} disabled={!yuanjieCapabilities}>
|
||||
<Copy className="h-4 w-4" />
|
||||
复制文档
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
“重置图片模型”只重装元界图片行;“安装视频模型”只重装元界视频行,不会清除已配置的图片模型 Key。安装后到“系统默认模型”逐个填写 Key、设置定价、选择视频用途并启用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-background/45 p-4 text-sm text-muted-foreground">
|
||||
<p className="leading-6">元界 AI 不再从能力接口猜模型;后台只安装内置图片/视频模型模板,用户端只展示管理员已启用且匹配当前创作入口的模型。</p>
|
||||
<p className="leading-6">此处只用于导入通用 Manifest。系统默认模型请在“系统默认模型”页面按供应商、模型类型和模型列表逐级管理。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
@@ -1035,17 +920,6 @@ export default function ApiManagementTab() {
|
||||
导入为系统 API
|
||||
</Button>
|
||||
</div>
|
||||
{yuanjieCapabilities && (
|
||||
<div className="space-y-2">
|
||||
<Label>元界 AI 内置模板</Label>
|
||||
<Textarea
|
||||
className="h-[260px] max-h-[320px] min-h-[180px] resize-y overflow-auto font-mono text-xs leading-relaxed"
|
||||
value={yuanjieCapabilities}
|
||||
onChange={event => setYuanjieCapabilities(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -642,10 +642,10 @@ export function ImageToImagePanel() {
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="img2img" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
{/* Reference Images Upload (Multi) */}
|
||||
<div className="create-chat-layout grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="create-chat-composer min-w-0 space-y-5 pb-8 pr-2">
|
||||
{/* Reference Images Upload (Multi) */}
|
||||
<div className="space-y-2">
|
||||
<Label>参考图片 <span className="text-destructive">*</span> <span className="text-muted-foreground text-xs">至少1张,可上传多张</span></Label>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" multiple onChange={handleFileChange} />
|
||||
@@ -837,7 +837,7 @@ export function ImageToImagePanel() {
|
||||
</div>
|
||||
|
||||
{/* Right: Results + History */}
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
{generating ? (
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
|
||||
) : generationError ? (
|
||||
|
||||
@@ -477,10 +477,10 @@ export function ImageToVideoPanel() {
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="img2video" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
{/* Reference Image */}
|
||||
<div className="create-chat-layout grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="create-chat-composer min-w-0 space-y-5 pb-8 pr-2">
|
||||
{/* Reference Image */}
|
||||
<div className="space-y-2">
|
||||
<Label>参考图片 <span className="text-destructive">*</span> <span className="text-muted-foreground text-xs">可上传多张</span></Label>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" multiple onChange={handleFileChange} />
|
||||
@@ -654,7 +654,7 @@ export function ImageToVideoPanel() {
|
||||
</div>
|
||||
|
||||
{/* Right: Results + History */}
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
{generating ? (
|
||||
<GenerationTaskList tasks={activeTasks} />
|
||||
) : generationError ? (
|
||||
|
||||
57
src/components/create/mobile-creation-composer.tsx
Normal file
57
src/components/create/mobile-creation-composer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type MobileCreationComposerProps = {
|
||||
prompt: string;
|
||||
placeholder: string;
|
||||
onPromptChange: (value: string) => void;
|
||||
onGenerate: () => void;
|
||||
disabled?: boolean;
|
||||
generating?: boolean;
|
||||
params?: ReactNode;
|
||||
styles?: ReactNode;
|
||||
prefix?: ReactNode;
|
||||
};
|
||||
|
||||
export function MobileCreationComposer({
|
||||
prompt,
|
||||
placeholder,
|
||||
onPromptChange,
|
||||
onGenerate,
|
||||
disabled,
|
||||
generating,
|
||||
params,
|
||||
styles,
|
||||
prefix,
|
||||
}: MobileCreationComposerProps) {
|
||||
return (
|
||||
<div className="create-mobile-dialog-composer">
|
||||
{prefix}
|
||||
{params && <div className="create-mobile-param-strip">{params}</div>}
|
||||
{styles && <div className="create-mobile-style-strip">{styles}</div>}
|
||||
<div className="create-mobile-input-shell">
|
||||
<Textarea
|
||||
className="create-mobile-prompt-input"
|
||||
rows={1}
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="create-mobile-send-button"
|
||||
size="icon"
|
||||
onClick={onGenerate}
|
||||
disabled={disabled}
|
||||
aria-label={generating ? '继续提交任务' : '发送创作'}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -273,8 +273,8 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
}, [fullPrompt, onUseForImageToImage, onUseForTextToImage, promptMode, result, reverseImage]);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
<div className="min-w-0 space-y-5 pb-8 pl-1 pr-2 pt-1">
|
||||
<div className="create-chat-layout grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
<div className="create-chat-composer min-w-0 space-y-5 pb-8 pl-1 pr-2 pt-1">
|
||||
<div className="space-y-2">
|
||||
<Label>参考图片 <span className="text-destructive">*</span></Label>
|
||||
<div
|
||||
@@ -346,7 +346,7 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
<div className="flex items-center gap-6 border-b border-border">
|
||||
{promptMode === 'structured' && (
|
||||
<button
|
||||
|
||||
@@ -24,7 +24,7 @@ export function StylePresetSelector({ presets, selectedLabel, onSelect }: StyleP
|
||||
}, [presets, query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="style-preset-selector space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">预设风格</span>
|
||||
<Button
|
||||
@@ -51,7 +51,7 @@ export function StylePresetSelector({ presets, selectedLabel, onSelect }: StyleP
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex flex-wrap gap-1.5 ${expanded ? 'max-h-56 overflow-y-auto pr-1' : 'max-h-[64px] overflow-hidden'}`}>
|
||||
<div className={`style-preset-list flex flex-wrap gap-1.5 ${expanded ? 'is-expanded max-h-56 overflow-y-auto pr-1' : 'is-collapsed max-h-[64px] overflow-hidden'}`}>
|
||||
{visiblePresets.map(preset => {
|
||||
const selected = selectedLabel === preset.label;
|
||||
return (
|
||||
|
||||
@@ -49,6 +49,7 @@ import { GenerationTaskList, type ActiveGenerationTask } from '@/components/crea
|
||||
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
|
||||
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
|
||||
import { TEXT_TO_IMAGE_DRAFT_EVENT, TEXT_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { MobileCreationComposer } from '@/components/create/mobile-creation-composer';
|
||||
|
||||
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
|
||||
const TEXT_TO_IMAGE_SELECTED_MODEL_KEY = 'miaojing_create_text_to_image_selected_model';
|
||||
@@ -90,6 +91,8 @@ export function TextToImagePanel() {
|
||||
// Generation state
|
||||
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [resultPrompt, setResultPrompt] = useState('');
|
||||
const [activeGenerationPrompt, setActiveGenerationPrompt] = useState('');
|
||||
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
@@ -101,9 +104,14 @@ export function TextToImagePanel() {
|
||||
const { records, add: addRecord, remove: removeRecord } = useCreationHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const imageHistory = records.filter(r => getCreationMode(r) === 'text2img');
|
||||
const mobileImageHistory = useMemo(
|
||||
() => [...imageHistory].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
||||
[imageHistory],
|
||||
);
|
||||
|
||||
// Lightbox state
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
const mobileHistoryEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// History detail dialog
|
||||
const [selectedHistoryRecord, setSelectedHistoryRecord] = useState<CreationRecord | null>(null);
|
||||
@@ -141,6 +149,14 @@ export function TextToImagePanel() {
|
||||
return () => window.removeEventListener(TEXT_TO_IMAGE_DRAFT_EVENT, handleDraft);
|
||||
}, [applyPromptDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
if (!isMobile) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
mobileHistoryEndRef.current?.scrollIntoView({ block: 'end' });
|
||||
});
|
||||
}, [mobileImageHistory.length, activeTasks.length, generationError]);
|
||||
|
||||
// System APIs
|
||||
const systemImageApis = managedSystemApis.filter(api => api.type === 'image' && api.isActive);
|
||||
const systemTextApis = managedSystemApis.filter(api => api.type === 'text' && api.isActive);
|
||||
@@ -361,7 +377,8 @@ export function TextToImagePanel() {
|
||||
|
||||
// Generate
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim()) { toast.error('请输入创作描述'); return; }
|
||||
const submittedPrompt = prompt.trim();
|
||||
if (!submittedPrompt) { toast.error('请输入创作描述'); return; }
|
||||
if (!user) { toast.error('请先登录'); return; }
|
||||
|
||||
setGenerationError(null);
|
||||
@@ -378,7 +395,7 @@ export function TextToImagePanel() {
|
||||
: resolveImageSize(resolvedParams.aspectRatio, resolvedParams.resolution);
|
||||
|
||||
let requestBodyBase: Record<string, unknown> = {
|
||||
prompt: prompt.trim(),
|
||||
prompt: submittedPrompt,
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
model: selectedModel,
|
||||
aspectRatio: resolvedParams.aspectRatio,
|
||||
@@ -405,7 +422,7 @@ export function TextToImagePanel() {
|
||||
}
|
||||
|
||||
submissionSignature = JSON.stringify({
|
||||
prompt: prompt.trim(),
|
||||
prompt: submittedPrompt,
|
||||
negativePrompt: negativePrompt.trim(),
|
||||
model: selectedModel,
|
||||
aspectRatio: resolvedParams.aspectRatio,
|
||||
@@ -424,6 +441,7 @@ export function TextToImagePanel() {
|
||||
|
||||
const batchId = `text2img-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const taskIds = Array.from({ length: taskCount }, (_, index) => `${batchId}-${index + 1}`);
|
||||
setActiveGenerationPrompt(submittedPrompt);
|
||||
setActiveTasks(prev => [
|
||||
...prev,
|
||||
...taskIds.map(taskId => ({
|
||||
@@ -482,10 +500,11 @@ export function TextToImagePanel() {
|
||||
|
||||
if (generatedImages.length > 0) {
|
||||
setResults(prev => [...generatedImages, ...prev]);
|
||||
setResultPrompt(submittedPrompt);
|
||||
setGenerationError(null);
|
||||
for (const url of generatedImages) {
|
||||
addRecord({
|
||||
type: 'image', url, prompt: prompt.trim(),
|
||||
type: 'image', url, prompt: submittedPrompt,
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
@@ -570,9 +589,9 @@ export function TextToImagePanel() {
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="text2img" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
<div className="create-chat-layout grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings (scrollable) */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
<div className="create-chat-composer min-w-0 space-y-5 pb-8 pr-2">
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>生成模型</Label>
|
||||
@@ -710,43 +729,47 @@ export function TextToImagePanel() {
|
||||
</div>
|
||||
|
||||
{/* Right: Results + History (flex-1, takes remaining space) */}
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
{/* Results area */}
|
||||
{generating ? (
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
|
||||
) : generationError ? (
|
||||
<GenerationErrorPanel error={generationError} />
|
||||
) : results.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><ImageIcon className="h-4 w-4" />生成结果</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{results.map((url, i) => (
|
||||
<div key={i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
|
||||
<CachedPreviewImage
|
||||
src={url}
|
||||
alt={`生成结果 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-zoom-in"
|
||||
onClick={() => setLightboxSrc(url)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" />预览</Button>
|
||||
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleShareToGallery(url)}><Share2 className="h-3.5 w-3.5" />分享</Button>
|
||||
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleDownload(url, i)}><Download className="h-3.5 w-3.5" />下载</Button>
|
||||
<div className="create-desktop-results">
|
||||
{generating ? (
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
|
||||
) : generationError ? (
|
||||
<GenerationErrorPanel error={generationError} />
|
||||
) : results.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><ImageIcon className="h-4 w-4" />生成结果</div>
|
||||
<p className="text-sm font-medium">{resultPrompt || '图片生成'}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{results.map((url, i) => (
|
||||
<div key={i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
|
||||
<CachedPreviewImage
|
||||
src={url}
|
||||
alt={`生成结果 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-zoom-in"
|
||||
onClick={() => setLightboxSrc(url)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" />预览</Button>
|
||||
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleShareToGallery(url)}><Share2 className="h-3.5 w-3.5" />分享</Button>
|
||||
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleDownload(url, i)}><Download className="h-3.5 w-3.5" />下载</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
|
||||
<ImageIcon className="h-14 w-14 mb-3 opacity-20" />
|
||||
<p className="text-sm">生成结果将显示在这里</p>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="create-empty-result liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
|
||||
<ImageIcon className="h-14 w-14 mb-3 opacity-20" />
|
||||
<p className="text-sm">生成结果将显示在这里</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{imageHistory.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<>
|
||||
<div className="create-desktop-history space-y-2">
|
||||
<button className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" onClick={() => setShowHistory(!showHistory)}>
|
||||
<History className="h-4 w-4" />历史创作 ({imageHistory.length})
|
||||
{showHistory ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
@@ -777,9 +800,100 @@ export function TextToImagePanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="create-mobile-history-flow">
|
||||
{mobileImageHistory.slice(-40).map(record => (
|
||||
<div key={record.id} className="create-mobile-conversation-card space-y-3">
|
||||
<p className="create-mobile-conversation-prompt">{record.prompt || '历史创作'}</p>
|
||||
{isPlaceholder(record.url) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="create-mobile-history-placeholder"
|
||||
onClick={() => setSelectedHistoryRecord(record)}
|
||||
>
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
</button>
|
||||
) : (
|
||||
<CachedPreviewImage
|
||||
src={record.url}
|
||||
alt={record.prompt?.slice(0, 20) || '历史记录'}
|
||||
className="create-mobile-history-image cursor-zoom-in"
|
||||
badgeClassName="absolute right-1.5 top-1.5 z-10 scale-75 origin-top-right"
|
||||
onClick={() => setLightboxSrc(record.url)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{generating && (
|
||||
<div className="create-mobile-conversation-card create-mobile-active-task space-y-3">
|
||||
<p className="create-mobile-conversation-prompt">{activeGenerationPrompt || prompt || '正在生成图片'}</p>
|
||||
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
|
||||
</div>
|
||||
)}
|
||||
{!generating && generationError && (
|
||||
<div className="create-mobile-conversation-card">
|
||||
<GenerationErrorPanel error={generationError} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={mobileHistoryEndRef} className="create-mobile-history-end" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileCreationComposer
|
||||
prompt={prompt}
|
||||
placeholder="请描述画面内容"
|
||||
onPromptChange={setPrompt}
|
||||
onGenerate={handleGenerate}
|
||||
disabled={!hasModels}
|
||||
generating={generating}
|
||||
styles={(
|
||||
<StylePresetSelector
|
||||
presets={stylePresets}
|
||||
selectedLabel={selectedStyleLabel}
|
||||
onSelect={setSelectedStyleLabel}
|
||||
/>
|
||||
)}
|
||||
params={(
|
||||
<>
|
||||
{imageParamOptions.supportsAspectRatio && (
|
||||
<div className="create-mobile-param-field">
|
||||
<span className="create-mobile-param-label">画面比例</span>
|
||||
<Select value={aspectRatio} onValueChange={setAspectRatio}>
|
||||
<SelectTrigger className="create-mobile-param-trigger"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="create-mobile-param-select-content">
|
||||
{visibleImageParamOptions.aspectRatios.map(ar => (
|
||||
<SelectItem className="create-mobile-param-select-item" key={ar.value} value={ar.value}>{ar.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{imageParamOptions.supportsResolution && (
|
||||
<div className="create-mobile-param-field">
|
||||
<span className="create-mobile-param-label">分辨率</span>
|
||||
<Select value={resolution} onValueChange={setResolution}>
|
||||
<SelectTrigger className="create-mobile-param-trigger"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="create-mobile-param-select-content">
|
||||
{visibleImageParamOptions.resolutions.map(r => (
|
||||
<SelectItem className="create-mobile-param-select-item" key={r.value} value={r.value}>{r.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="create-mobile-param-field">
|
||||
<span className="create-mobile-param-label">生成数量</span>
|
||||
<ImageCountCombobox
|
||||
value={count}
|
||||
onChange={setCount}
|
||||
className="create-mobile-count-combobox"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Lightbox */}
|
||||
<ImageLightbox src={lightboxSrc || ''} open={!!lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||||
|
||||
|
||||
@@ -363,9 +363,9 @@ export function TextToVideoPanel() {
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="text2video" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
<div className="create-chat-layout grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
<div className="create-chat-composer min-w-0 space-y-5 pb-8 pr-2">
|
||||
<div className="space-y-2">
|
||||
<Label>视频模型</Label>
|
||||
{hasModels ? (
|
||||
@@ -489,7 +489,7 @@ export function TextToVideoPanel() {
|
||||
</div>
|
||||
|
||||
{/* Right: Results + History */}
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="create-chat-thread min-w-0 space-y-4">
|
||||
{generating ? (
|
||||
<GenerationTaskList tasks={activeTasks} />
|
||||
) : generationError ? (
|
||||
|
||||
@@ -77,6 +77,7 @@ function SelectContent({
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-slot="select-viewport"
|
||||
className={cn(
|
||||
"p-1.5",
|
||||
position === "popper" &&
|
||||
|
||||
@@ -173,6 +173,7 @@ export async function ensureSystemApiSchema(client: Awaited<ReturnType<typeof ge
|
||||
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_group_type_sort_idx ON system_api_configs (model_group, type, sort_order)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_default_sort_idx ON system_api_configs (is_default, is_active, sort_order)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_polling_idx ON system_api_configs (type, model_name, is_default, is_active, polling_order, sort_order)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_display_polling_idx ON system_api_configs (type, name, is_default, is_active, polling_order, sort_order)');
|
||||
}
|
||||
|
||||
function normalizeBillingMode(value: unknown): ServerManagedApiConfig['billingMode'] {
|
||||
@@ -233,6 +234,10 @@ export function toSafeSystemApi(row: Record<string, unknown>, includeInactive =
|
||||
};
|
||||
}
|
||||
|
||||
function getSystemApiDisplayKey(api: Pick<ServerManagedApiConfig, 'name' | 'modelName'>): string {
|
||||
return api.name?.trim() || api.modelName?.trim() || '';
|
||||
}
|
||||
|
||||
export async function getUserMembershipTier(userId: string): Promise<MembershipTier> {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
@@ -267,7 +272,8 @@ export async function listSystemApis(
|
||||
if (!options.collapseDefaultModels) return apis;
|
||||
const seen = new Set<string>();
|
||||
return apis.filter(api => {
|
||||
const key = `${api.type}:${api.modelName}`;
|
||||
const displayKey = getSystemApiDisplayKey(api);
|
||||
const key = `${api.type}:${displayKey}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@@ -314,7 +320,7 @@ export async function resolveSystemApiPollingCandidates(
|
||||
try {
|
||||
await ensureSystemApiSchema(client);
|
||||
const selectedResult = await client.query(
|
||||
`SELECT id, type, model_name, is_default, allowed_membership_tiers, polling_mode
|
||||
`SELECT id, type, name, model_name, is_default, allowed_membership_tiers, polling_mode
|
||||
FROM system_api_configs
|
||||
WHERE id = $1 AND is_active = true
|
||||
LIMIT 1`,
|
||||
@@ -336,10 +342,10 @@ export async function resolveSystemApiPollingCandidates(
|
||||
allowed_membership_tiers, polling_mode, polling_order, sort_order, created_at
|
||||
FROM system_api_configs
|
||||
WHERE type = $1
|
||||
AND model_name = $2
|
||||
AND COALESCE(NULLIF(BTRIM(name), ''), model_name) = $2
|
||||
AND is_default = true
|
||||
AND is_active = true`,
|
||||
[selected.type, selected.model_name],
|
||||
[selected.type, String(selected.name || selected.model_name || '').trim()],
|
||||
);
|
||||
const allowedRows = candidatesResult.rows.filter(row => (
|
||||
normalizeAllowedMembershipTiers(row.allowed_membership_tiers).includes(tier)
|
||||
|
||||
@@ -445,7 +445,7 @@ export default function ConsoleDashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex overflow-hidden bg-background text-foreground">
|
||||
<div className="console-mobile-page fixed inset-0 flex overflow-hidden bg-background text-foreground">
|
||||
<aside className="hidden w-[17rem] shrink-0 border-r border-border/70 bg-sidebar/95 lg:flex lg:flex-col">
|
||||
<ConsoleSidebar
|
||||
activeView={activeView}
|
||||
@@ -503,8 +503,8 @@ export default function ConsoleDashboardPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="min-h-full w-full px-4 py-5 sm:px-6 lg:px-8">
|
||||
<main className="console-mobile-main min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="console-mobile-content min-h-full w-full px-4 py-5 sm:px-6 lg:px-8">
|
||||
<ConsoleContent activeView={activeView} setActiveView={setActiveView} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user