Compare commits
28 Commits
98db427ac5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b72455062 | |||
| 526ad490f7 | |||
| 4993f6eeec | |||
| 79f220dbbf | |||
| c1c7cb3cc7 | |||
| b67f2e7601 | |||
| f056547160 | |||
| de3eb1d06a | |||
| f929be30ed | |||
| a2875738ce | |||
| 85adcdceef | |||
| 66b761314b | |||
| ab99e3bf2f | |||
| e3b48e2614 | |||
| 9a9c7eb86d | |||
| 5b316a2399 | |||
| 3f1954b38d | |||
| 96d335db8a | |||
| 307537a7ce | |||
| 48262d6233 | |||
| 062c8b3445 | |||
| 0b2d6b901f | |||
| e1fdbe5f9b | |||
| f51dfb17e1 | |||
| 76ae9ab0ac | |||
| e88edbe165 | |||
| 863f1f075e | |||
| aa133d0f5c |
@@ -0,0 +1,43 @@
|
||||
# 自动检测文本文件并统一换行符
|
||||
* text=auto eol=lf
|
||||
|
||||
# 源码强制使用 LF(跨平台一致)
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.cjs text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.svg text eol=lf
|
||||
|
||||
# 配置类(统一 LF)
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.conf text eol=lf
|
||||
|
||||
# Windows 专用脚本保持 CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# 二进制文件,不做换行符转换
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="dark" data-ui-theme="dark-green" style="color-scheme: dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
+20
-36
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BugOutlined,
|
||||
CheckCircleFilled,
|
||||
@@ -20,9 +20,7 @@ import {
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import ToastContainer from "./components/toast/ToastContainer";
|
||||
import { toast } from "./components/toast/toastStore";
|
||||
import EcommercePage from "./features/ecommerce/EcommercePage";
|
||||
import { flushPendingGenerationRecords } from "./api/generationRecordClient";
|
||||
import { ossAssets } from "./data/ossAssets";
|
||||
import { keyServerClient } from "./api/keyServerClient";
|
||||
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
||||
import {
|
||||
@@ -38,6 +36,8 @@ import { useAppStore, useSessionStore } from "./stores";
|
||||
import type { WebUserSession } from "./types";
|
||||
import "./styles/ecommerce-standalone.css";
|
||||
|
||||
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
type AuthMethod = "account" | "email" | "phone";
|
||||
|
||||
@@ -51,17 +51,6 @@ interface LocalProfilePageProps {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const profileWorks = [
|
||||
{ title: "主图套图生成", desc: "电商主图与场景图自动生成", image: ossAssets.ecommerce.templateCases[0], type: "图像", time: "6/9 18:13" },
|
||||
{ title: "A+详情页设计", desc: "产品卖点与长图详情版式", image: ossAssets.ecommerce.templateCases[1], type: "图像", time: "6/9 10:11" },
|
||||
{ title: "短视频广告", desc: "产品展示短视频脚本与画面", image: ossAssets.ecommerce.productSet.hosting, type: "视频", time: "6/9 10:05" },
|
||||
{ title: "模特图生成", desc: "服饰商品真人上身展示", image: ossAssets.ecommerce.tryOn.tryA, type: "图像", time: "6/9 10:03" },
|
||||
{ title: "商品场景图", desc: "按平台比例输出营销素材", image: ossAssets.ecommerce.detail.gridA, type: "图像", time: "6/9 10:01" },
|
||||
{ title: "高度复刻", desc: "参考图结构复刻与商品替换", image: ossAssets.ecommerce.detail.gridB, type: "图像", time: "6/9 09:39" },
|
||||
{ title: "详情模块", desc: "功能卖点、参数和包装模块", image: ossAssets.ecommerce.detail.gridC, type: "图像", time: "6/8 21:20" },
|
||||
{ title: "平台素材", desc: "淘宝/天猫投放图批量生成", image: ossAssets.ecommerce.detail.gridD, type: "图像", time: "6/8 18:26" },
|
||||
];
|
||||
|
||||
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
|
||||
const displayName = session.user.displayName || session.user.username || "用户";
|
||||
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
|
||||
@@ -75,9 +64,9 @@ function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?:
|
||||
|
||||
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
|
||||
const displayName = session.user.displayName || session.user.username || "用户";
|
||||
const workCount = Math.max(imageCount + videoCount, profileWorks.length);
|
||||
const projectCount = Math.max(1, Math.round(workCount / 18));
|
||||
const assetCount = Math.max(1, Math.round(workCount / 20));
|
||||
const workCount = Math.max(imageCount + videoCount, 0);
|
||||
const projectCount = 0;
|
||||
const assetCount = 0;
|
||||
|
||||
return (
|
||||
<section className="local-profile-page">
|
||||
@@ -142,22 +131,15 @@ function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, on
|
||||
<header>
|
||||
<div>
|
||||
<strong>代表作</strong>
|
||||
<span>最近完成的高质量生成内容</span>
|
||||
<span>后续将展示接口返回的真实作品</span>
|
||||
</div>
|
||||
<em>{workCount} 项</em>
|
||||
</header>
|
||||
<div className="local-profile-work-grid">
|
||||
{profileWorks.map((work) => (
|
||||
<article key={`${work.title}-${work.time}`} className="local-profile-work-card">
|
||||
<img src={work.image} alt="" />
|
||||
<div>
|
||||
<span>{work.type}</span>
|
||||
<strong>{work.title}</strong>
|
||||
<p>{work.desc}</p>
|
||||
<em>已完成 · {work.time}</em>
|
||||
<div className="local-profile-work-grid local-profile-work-grid--empty">
|
||||
<div className="local-profile-empty">
|
||||
<strong>暂无代表作数据</strong>
|
||||
<span>作品接口接入后,这里会显示你的真实生成内容。</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -184,7 +166,6 @@ function App() {
|
||||
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
|
||||
const [workspaceKey, setWorkspaceKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDarkGreenTheme();
|
||||
@@ -339,7 +320,7 @@ function App() {
|
||||
const balance = Math.max(usage.balanceCents, 0) / 100;
|
||||
const displayName = session?.user.displayName || session?.user.username || "用户";
|
||||
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
|
||||
const shownWorkCount = Math.max(actualWorkCount, profileWorks.length);
|
||||
const shownWorkCount = actualWorkCount;
|
||||
|
||||
const avatarMenuStats = useMemo(
|
||||
() => [
|
||||
@@ -360,7 +341,6 @@ function App() {
|
||||
const handleOpenWorkspace = () => {
|
||||
setProfileMenuOpen(false);
|
||||
setCurrentPage("workspace");
|
||||
setWorkspaceKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const handleBugFeedback = () => {
|
||||
@@ -447,7 +427,8 @@ function App() {
|
||||
</header>
|
||||
|
||||
<main className="ecommerce-standalone__content">
|
||||
{currentPage === "profile" && session ? (
|
||||
{session ? (
|
||||
<div className="ecommerce-standalone__page" hidden={currentPage !== "profile"}>
|
||||
<LocalProfilePage
|
||||
session={session}
|
||||
balance={balance}
|
||||
@@ -457,7 +438,11 @@ function App() {
|
||||
onBugFeedback={handleBugFeedback}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
) : (
|
||||
</div>
|
||||
) : null}
|
||||
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
|
||||
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
|
||||
<div className="ecommerce-standalone__page" hidden={Boolean(session) && currentPage === "profile"}>
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -468,7 +453,6 @@ function App() {
|
||||
}
|
||||
>
|
||||
<EcommercePage
|
||||
key={workspaceKey}
|
||||
projects={[]}
|
||||
isAuthenticated={Boolean(session)}
|
||||
onStartCreate={() => undefined}
|
||||
@@ -482,7 +466,7 @@ function App() {
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{authOpen ? (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./aiGenerationClient.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./apiErrorUtils.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./generationRecordClient.ts";
|
||||
@@ -38,6 +38,45 @@ export interface SaveGenerationRecordResult {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
|
||||
// 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时
|
||||
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
||||
// 避免后端在缺少去重时插入重复记录。
|
||||
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
||||
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
|
||||
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
||||
|
||||
function pruneRecentlySaved(now: number): void {
|
||||
for (const [id, record] of recentlySavedRecords) {
|
||||
if (now - record.savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedRecords.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function stableJsonStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableJsonStringify).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`).join(",")}}`;
|
||||
}
|
||||
|
||||
function buildSaveSignature(input: SaveGenerationRecordInput): string {
|
||||
return stableJsonStringify({
|
||||
tool: input.tool,
|
||||
mode: input.mode,
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
prompt: input.prompt,
|
||||
taskIds: input.taskIds,
|
||||
assets: input.assets,
|
||||
config: input.config,
|
||||
result: input.result,
|
||||
metadata: input.metadata,
|
||||
createdAt: input.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
function readPendingRecords(): SaveGenerationRecordInput[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
||||
@@ -60,6 +99,39 @@ function writePendingRecord(input: SaveGenerationRecordInput): void {
|
||||
}
|
||||
|
||||
export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
||||
const now = Date.now();
|
||||
pruneRecentlySaved(now);
|
||||
|
||||
const recordId = input.clientRecordId;
|
||||
const signature = buildSaveSignature(input);
|
||||
if (recordId) {
|
||||
const saveKey = `${recordId}:${signature}`;
|
||||
const inFlight = inFlightSaves.get(saveKey);
|
||||
if (inFlight) return inFlight;
|
||||
const savedRecord = recentlySavedRecords.get(recordId);
|
||||
if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
||||
// 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。
|
||||
return { source: "server", id: recordId };
|
||||
}
|
||||
}
|
||||
|
||||
const promise = saveGenerationRecordInternal(input);
|
||||
if (recordId) {
|
||||
const saveKey = `${recordId}:${signature}`;
|
||||
inFlightSaves.set(saveKey, promise);
|
||||
void promise
|
||||
.then((result) => {
|
||||
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inFlightSaves.delete(saveKey);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function saveGenerationRecordInternal(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
||||
try {
|
||||
const response = await serverRequest<{ id?: string | number }>("ai/generation-records", {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./serverConnection.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./taskSubscription.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./webGenerationGateway.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./toastStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ossAssets.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./workflows.ts";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./EcommercePage.tsx";
|
||||
export * from "./EcommercePage.tsx";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface EcommerceProgressBarProps {
|
||||
status: "idle" | "generating" | "done" | "failed" | string;
|
||||
label?: string;
|
||||
onCancel?: () => void;
|
||||
/** 0-100 真实进度。传入时进度条按真实值推进;省略时按状态做平滑蠕动。 */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
function mapStatus(status: string): "running" | "completed" | "failed" {
|
||||
@@ -14,9 +15,13 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
|
||||
return "running";
|
||||
}
|
||||
|
||||
export function EcommerceProgressBar({ status, label, onCancel }: EcommerceProgressBarProps) {
|
||||
const progress = mapStatus(status) === "running" ? 50 : 100;
|
||||
const smoothed = useSmoothedProgress(progress, mapStatus(status));
|
||||
export function EcommerceProgressBar({ status, label, onCancel, progress }: EcommerceProgressBarProps) {
|
||||
const mapped = mapStatus(status);
|
||||
// running 时目标取「真实进度」与兜底值 88 的较大者:有真实进度则跟随推进,
|
||||
// 后端不推中间进度时也由平滑器持续蠕动到高位,不再卡死在 75%。
|
||||
const realProgress = typeof progress === "number" ? Math.max(0, Math.min(100, progress)) : 0;
|
||||
const target = mapped === "running" ? Math.max(realProgress, 88) : 100;
|
||||
const smoothed = useSmoothedProgress(target, mapped);
|
||||
|
||||
if (status === "idle") return null;
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ecommerceGenerationPersistence.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ecommerceImageValidation.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ecommerceTemplates.ts";
|
||||
@@ -381,108 +381,6 @@ export default function EcommerceClonePanel({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "hot" ? (
|
||||
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
|
||||
<div className="clone-ai-dynamic-head">
|
||||
<strong>爆款图参考设置</strong>
|
||||
<span>随生成模式切换</span>
|
||||
</div>
|
||||
<div className="clone-ai-replicate-section">
|
||||
<span className="clone-ai-replicate-title">参考内容</span>
|
||||
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
|
||||
<button
|
||||
type="button"
|
||||
className={cloneReferenceMode === "upload" ? "is-active" : ""}
|
||||
aria-selected={cloneReferenceMode === "upload"}
|
||||
onClick={() => setCloneReferenceMode("upload")}
|
||||
>
|
||||
上传参考图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cloneReferenceMode === "link" ? "is-active" : ""}
|
||||
aria-selected={cloneReferenceMode === "link"}
|
||||
onClick={() => setCloneReferenceMode("link")}
|
||||
>
|
||||
导入链接
|
||||
</button>
|
||||
</div>
|
||||
{cloneReferenceMode === "upload" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`clone-ai-replicate-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-files" : ""}`}
|
||||
onClick={() => cloneReferenceInputRef.current?.click()}
|
||||
onDragOver={handleCloneReferenceDragOver}
|
||||
onDragLeave={handleCloneReferenceDragLeave}
|
||||
onDrop={handleCloneReferenceDrop}
|
||||
>
|
||||
{cloneReferenceImages.length ? (
|
||||
<>
|
||||
<div className="clone-ai-replicate-files">
|
||||
{cloneReferenceImages.map((item) => (
|
||||
<figure
|
||||
key={item.id}
|
||||
className="clone-ai-replicate-file"
|
||||
onMouseEnter={(e) => handleFileMouseEnter(item.src, e)}
|
||||
onMouseLeave={handleFileMouseLeave}
|
||||
>
|
||||
<img src={item.src} alt="" />
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
<span className="clone-ai-replicate-add-more">
|
||||
<CloudUploadOutlined />
|
||||
点击继续上传文件
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
<CloudUploadOutlined />
|
||||
<span className="clone-ai-replicate-upload-text">拖拽或点击上传参考图</span>
|
||||
</span>
|
||||
)}
|
||||
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages} 张`}</em>
|
||||
{isCloneReferenceDragging ? (
|
||||
<div className="clone-ai-replicate-upload-overlay">
|
||||
<CloudUploadOutlined />
|
||||
<span>释放文件以上传</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
) : (
|
||||
<label className="clone-ai-replicate-link">
|
||||
<input placeholder="粘贴商品图或详情页链接" />
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={cloneReferenceInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={handleCloneReferenceUpload}
|
||||
aria-label="上传参考图片"
|
||||
/>
|
||||
</div>
|
||||
<div className="clone-ai-replicate-section">
|
||||
<span className="clone-ai-replicate-title">复刻程度</span>
|
||||
<div className="clone-ai-replicate-levels" role="toolbar" aria-label="复刻程度">
|
||||
{cloneReplicateLevelOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cloneReplicateLevel === option.key ? "is-active" : ""}
|
||||
aria-pressed={cloneReplicateLevel === option.key}
|
||||
onClick={() => setCloneReplicateLevel(option.key)}
|
||||
>
|
||||
<strong>{option.title}</strong>
|
||||
<span>{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "set" ? (
|
||||
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
||||
<div className="clone-ai-dynamic-head">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./workbenchDownload.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useGenerationTasks.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useTypewriter.ts";
|
||||
@@ -2,15 +2,6 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./styles/index.css";
|
||||
import App from "./App";
|
||||
import { reportError } from "./utils/errorReporting";
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
reportError(event.reason, "rejection");
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
if (event.error) reportError(event.error, "unhandled");
|
||||
});
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./backgroundTaskRunner.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./index.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useAppStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useGenerationStore.ts";
|
||||
@@ -24,17 +24,33 @@ interface PersistedQueueSnapshot {
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "omniai:generation-queue";
|
||||
const STORAGE_KEY_PREFIX = "omniai:generation-queue";
|
||||
const MAX_ITEMS = 80;
|
||||
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
function hashUserId(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem("omniai-web-session");
|
||||
if (!raw) return "anon";
|
||||
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
||||
return String(parsed?.user?.id || "anon");
|
||||
} catch {
|
||||
return "anon";
|
||||
}
|
||||
}
|
||||
|
||||
// 队列按用户分桶持久化:不同账号读写不同 key,避免登出再登他人账号时读到上一个用户的队列。
|
||||
function getStorageKey(): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${hashUserId()}`;
|
||||
}
|
||||
|
||||
function loadPersistedQueue(): GenerationQueueItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(getStorageKey());
|
||||
if (!raw) return [];
|
||||
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
|
||||
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(getStorageKey());
|
||||
return [];
|
||||
}
|
||||
return snapshot.items.filter(
|
||||
@@ -48,7 +64,7 @@ function loadPersistedQueue(): GenerationQueueItem[] {
|
||||
function persistQueue(items: GenerationQueueItem[]): void {
|
||||
try {
|
||||
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(snapshot));
|
||||
} catch { /* quota exceeded */ }
|
||||
}
|
||||
|
||||
@@ -63,17 +79,6 @@ interface GenerationStoreState {
|
||||
clearTerminal: () => void;
|
||||
}
|
||||
|
||||
function hashUserId(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem("omniai-web-session");
|
||||
if (!raw) return "anon";
|
||||
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
||||
return String(parsed?.user?.id || "anon");
|
||||
} catch {
|
||||
return "anon";
|
||||
}
|
||||
}
|
||||
|
||||
const initialQueue = loadPersistedQueue();
|
||||
|
||||
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useProjectStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useSessionStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useTaskStore.ts";
|
||||
+3970
-10
File diff suppressed because it is too large
Load Diff
@@ -2950,6 +2950,15 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-stack > .clone-ai-node-label {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
z-index: 5;
|
||||
transform: translate(-50%, -100%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-source-corner-action {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
@@ -7852,7 +7861,7 @@
|
||||
.product-set-preview-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
z-index: 4000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(17 24 39 / 58%);
|
||||
@@ -12093,3 +12102,110 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
grid-row: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Composer menu anchors: place option popovers under the clicked control, not under the whole composer. */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover {
|
||||
position: absolute !important;
|
||||
inset: var(--composer-popover-top, 48px) auto auto var(--composer-popover-left, 0px) !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
translate: none !important;
|
||||
z-index: 160 !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--platform {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
/* 平台弹窗宽度仅桌面/平板固定;≤640px 由移动端断点的全宽规则接管。 */
|
||||
@media (min-width: 641px) {
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--platform {
|
||||
width: min(460px, calc(100% - 24px)) !important;
|
||||
max-width: min(460px, calc(100% - 24px)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平台选项:logo + 名称横排,名称过长省略,避免在窄网格里溢出弹窗。 */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 8px !important;
|
||||
min-width: 0 !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button .ecom-platform-name {
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--languages,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--ratio-picker {
|
||||
width: max-content !important;
|
||||
min-width: 200px !important;
|
||||
max-width: min(420px, calc(100% - var(--composer-popover-left, 0px))) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 宽设置面板:固定宽度并靠右对齐 composer,避免从靠右的"设置"按钮左对齐展开时顶出右边缘被裁。
|
||||
仅桌面/平板生效;≤640px 由移动端断点的全宽规则接管。 */
|
||||
@media (min-width: 641px) {
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--settings {
|
||||
width: min(520px, calc(100% - 24px)) !important;
|
||||
max-width: min(520px, calc(100% - 24px)) !important;
|
||||
left: auto !important;
|
||||
inset: var(--composer-popover-top, 48px) 12px auto auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Uploaded assets stay as compact attachments inside the composer hierarchy. */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) {
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-popover,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-popover {
|
||||
position: static !important;
|
||||
grid-column: 1 !important;
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
justify-self: start !important;
|
||||
gap: 10px !important;
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
padding: 2px 2px 0 !important;
|
||||
overflow: visible !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-thumb,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-thumb {
|
||||
flex: 0 0 64px !important;
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
border-radius: 14px !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-add,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-add {
|
||||
flex: 0 0 44px !important;
|
||||
width: 44px !important;
|
||||
height: 64px !important;
|
||||
min-height: 44px !important;
|
||||
margin: 0 !important;
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./types.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./enterpriseVideoPolicy.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./happyHorseRouting.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pixverseRouting.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./resolveVideoModel.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./taskLifecycle.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./translateTaskError.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./viduRouting.ts";
|
||||
+9
-5
@@ -2,8 +2,14 @@ import react from "@vitejs/plugin-react";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig(() => {
|
||||
const devApiTarget = process.env.OMNIAI_DEV_API_TARGET?.trim();
|
||||
export default defineConfig(({ command }) => {
|
||||
// dev 模式下默认把 /api 代理到线上电商后端,本地 `npm run dev` 即可直接登录/生成。
|
||||
// 想连本地或 SSH 隧道的后端时,用环境变量覆盖:
|
||||
// $env:OMNIAI_DEV_API_TARGET="http://127.0.0.1:3601"; npm run dev
|
||||
// 仅 dev 代理用途,不会打进生产构建产物。
|
||||
const devApiTarget =
|
||||
process.env.OMNIAI_DEV_API_TARGET?.trim() ||
|
||||
(command === "serve" ? "https://omniai.com.cn" : "");
|
||||
const apiProxy = devApiTarget
|
||||
? {
|
||||
"/api": {
|
||||
@@ -27,9 +33,7 @@ export default defineConfig(() => {
|
||||
port: 4174,
|
||||
host: "127.0.0.1",
|
||||
},
|
||||
esbuild: {
|
||||
drop: ["console", "debugger"],
|
||||
},
|
||||
...(command === "build" ? { esbuild: { drop: ["console", "debugger"] } } : {}),
|
||||
build: {
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
|
||||
Reference in New Issue
Block a user