- core/frontend: Vite 多阶段镜像 + nginx 同源反代 /api,/admin,/static(零 CORS) - core/backend: Django gunicorn 镜像 + entrypoint(自动 migrate/collectstatic)+ WhiteNoise - k8s/core: api/worker/web Deployment+Service + ingress(airshelf-web.airlabs.art) - workflow: 追加 core 前后端 build/push,从 core/backend/.env 套生产覆盖生成 env Secret 后部署 - .gitignore 放行 core/backend/.env;.env 白名单加入 airshelf-web 域名 - 含前端 WIP 还原改动 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
463 lines
18 KiB
TypeScript
463 lines
18 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { api, getToken, setToken } from "./api";
|
|
import { IconKitSvg } from "./components/IconKitSvg";
|
|
import type {
|
|
AITask,
|
|
Asset,
|
|
BillingSummary,
|
|
Ledger,
|
|
ModelConfig,
|
|
Product,
|
|
Project,
|
|
Team,
|
|
TeamMember,
|
|
User
|
|
} from "./types";
|
|
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
|
|
import {
|
|
AccountPage,
|
|
AssetFactoryPage,
|
|
AuthScreen,
|
|
Dashboard,
|
|
ImageWorkbenchPage,
|
|
LibraryPage,
|
|
MessagesPage,
|
|
ModelPhotoDemoPage,
|
|
PipelinePage,
|
|
ProductCreateUploadPage,
|
|
ProductDetailPage,
|
|
ProductsPage,
|
|
ProjectWizardPage,
|
|
ProjectsPage,
|
|
SettingsPage,
|
|
TeamPage
|
|
} from "./routes";
|
|
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
|
|
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
|
|
import { money } from "./routes/stage-config";
|
|
|
|
const crumbLabels: Partial<Record<Page, string>> = {
|
|
dashboard: "工作台",
|
|
products: "商品库",
|
|
productDetail: "商品详情",
|
|
productCreateUpload: "新建商品",
|
|
projects: "视频项目",
|
|
projectWizard: "新建视频项目",
|
|
pipeline: "生产管线",
|
|
library: "资产库",
|
|
account: "消费",
|
|
team: "团队",
|
|
messages: "消息中心",
|
|
assetFactory: "图片生成",
|
|
imageOptimize: "图片创作",
|
|
modelPhoto: "模特上身图",
|
|
modelPhotoDemoA: "模特图方案 A",
|
|
modelPhotoDemoB: "模特图方案 B",
|
|
platformCover: "平台套图",
|
|
settings: "设置",
|
|
settingsNotify: "设置"
|
|
};
|
|
|
|
export function App() {
|
|
const [route, setRoute] = useState<ResolvedRoute>(() => resolveRoute());
|
|
const page = route.page;
|
|
const [authMode, setAuthMode] = useState<AuthMode>(route.authMode);
|
|
const [authed, setAuthed] = useState<boolean>(() => Boolean(getToken()));
|
|
const [booting, setBooting] = useState<boolean>(() => Boolean(getToken()));
|
|
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [team, setTeam] = useState<Team | null>(null);
|
|
const [products, setProducts] = useState<Product[]>([]);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
|
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
|
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
|
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
|
const [ledgers, setLedgers] = useState<Ledger[]>([]);
|
|
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
|
|
|
|
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
|
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
|
|
const [notice, setNotice] = useState<Notice>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const activeProject = useMemo(
|
|
() => projects.find((project) => project.id === activeProjectId) || projects[0],
|
|
[projects, activeProjectId]
|
|
);
|
|
const activeProduct = useMemo(
|
|
() => products.find((product) => product.id === activeProductId) || products[0],
|
|
[products, activeProductId]
|
|
);
|
|
|
|
const loadData = useCallback(async () => {
|
|
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData] =
|
|
await Promise.all([
|
|
api.products(),
|
|
api.projects(),
|
|
api.assets(),
|
|
api.billingSummary().catch(() => null),
|
|
api.ledgers().catch(() => []),
|
|
api.teamMembers().catch(() => []),
|
|
api.modelConfigs().catch(() => null),
|
|
api.aiTasks().catch(() => null)
|
|
]);
|
|
setProducts(productData.results);
|
|
setProjects(projectData.results);
|
|
setAssets(assetData.results);
|
|
setTeamMembers(memberData);
|
|
setModelConfigs(modelData?.results || []);
|
|
setAiTasks(taskData?.results || []);
|
|
if (billingData) setBilling(billingData);
|
|
setLedgers(ledgerData);
|
|
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
|
|
setActiveProductId((current) => current || productData.results[0]?.id || "");
|
|
}, []);
|
|
|
|
// Boot: validate token, hydrate identity + data.
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
setBooting(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const identity = await api.me();
|
|
if (cancelled) return;
|
|
setUser(identity.user);
|
|
setTeam(identity.team);
|
|
await loadData();
|
|
} catch {
|
|
setToken(null);
|
|
if (!cancelled) setAuthed(false);
|
|
} finally {
|
|
if (!cancelled) setBooting(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [loadData]);
|
|
|
|
// Keep route in sync with browser navigation.
|
|
useEffect(() => {
|
|
function syncRouteFromHistory() {
|
|
const next = resolveRoute();
|
|
setRoute(next);
|
|
setAuthMode(next.authMode);
|
|
if (next.productId !== undefined) setActiveProductId(next.productId);
|
|
if (next.projectId !== undefined) setActiveProjectId(next.projectId);
|
|
}
|
|
window.addEventListener("popstate", syncRouteFromHistory);
|
|
return () => window.removeEventListener("popstate", syncRouteFromHistory);
|
|
}, []);
|
|
|
|
// Load full project detail when entering the pipeline.
|
|
useEffect(() => {
|
|
if (!authed || page !== "pipeline" || !activeProjectId) {
|
|
if (page !== "pipeline") setProjectDetail(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
api
|
|
.project(activeProjectId)
|
|
.then((detail) => {
|
|
if (!cancelled) setProjectDetail(detail);
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [authed, page, activeProjectId]);
|
|
|
|
function navigate(next: Page, options: NavigateOptions = {}) {
|
|
const productId = options.productId ?? activeProductId;
|
|
const projectId = options.projectId ?? activeProjectId;
|
|
if (options.productId !== undefined) setActiveProductId(options.productId);
|
|
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
|
|
const hash = options.hash?.replace(/^#/, "");
|
|
setRoute({ page: next, authMode, productId, projectId, hash });
|
|
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
|
|
if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) {
|
|
const method = options.replace ? "replaceState" : "pushState";
|
|
window.history[method](null, "", path);
|
|
}
|
|
window.scrollTo({ top: 0, behavior: "auto" });
|
|
}
|
|
|
|
async function refreshProjectDetail() {
|
|
if (!activeProjectId) return;
|
|
const detail = await api.project(activeProjectId).catch(() => null);
|
|
if (detail) setProjectDetail(detail);
|
|
}
|
|
|
|
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
|
|
setLoading(true);
|
|
setNotice(null);
|
|
try {
|
|
const result = await work();
|
|
setNotice({ type: "success", text: successText });
|
|
await loadData();
|
|
await refreshProjectDetail();
|
|
return result;
|
|
} catch (error) {
|
|
setNotice({ type: "error", text: error instanceof Error ? error.message : "操作失败" });
|
|
return null;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function onAuthed(payload: { token: string; user: User; team: Team }) {
|
|
setToken(payload.token);
|
|
setUser(payload.user);
|
|
setTeam(payload.team);
|
|
setAuthed(true);
|
|
setBooting(true);
|
|
loadData().finally(() => setBooting(false));
|
|
navigate("dashboard", { replace: true });
|
|
}
|
|
|
|
async function logout() {
|
|
await api.logout().catch(() => undefined);
|
|
setToken(null);
|
|
setAuthed(false);
|
|
setUser(null);
|
|
setTeam(null);
|
|
setAuthMode("login");
|
|
window.history.replaceState(null, "", "/login");
|
|
}
|
|
|
|
// ---- Auth gate ----
|
|
if (!authed) {
|
|
return (
|
|
<AuthScreen
|
|
initialMode={authMode}
|
|
onModeChange={(next) => {
|
|
setAuthMode(next);
|
|
window.history.pushState(null, "", next === "register" ? "/register" : "/login");
|
|
}}
|
|
onAuthed={onAuthed}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (booting || !user || !team) {
|
|
return (
|
|
<div className="app">
|
|
<main>
|
|
<div className="content">
|
|
<div className="page-head">
|
|
<div>
|
|
<h1>加载中…</h1>
|
|
<div className="sub">
|
|
<span className="mono">// 正在拉取团队数据</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentUser: User = user;
|
|
const currentTeam: Team = team;
|
|
|
|
function renderPage() {
|
|
switch (page) {
|
|
case "dashboard":
|
|
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} userName={currentUser.username} navigate={navigate} />;
|
|
case "products":
|
|
return (
|
|
<ProductsPage
|
|
products={products}
|
|
navigate={navigate}
|
|
openProduct={(productId) => navigate("productDetail", { productId })}
|
|
onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")}
|
|
/>
|
|
);
|
|
case "productCreateUpload":
|
|
return (
|
|
<ProductCreateUploadPage
|
|
onCreate={async (payload) => {
|
|
const created = await action(() => api.createProduct(payload), "商品已创建");
|
|
if (created) navigate("productDetail", { productId: created.id });
|
|
}}
|
|
onBack={() => navigate("products")}
|
|
/>
|
|
);
|
|
case "productDetail":
|
|
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />;
|
|
return (
|
|
<ProductDetailPage
|
|
product={activeProduct}
|
|
projects={projects.filter((project) => project.product === activeProduct.id)}
|
|
navigate={navigate}
|
|
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
|
|
/>
|
|
);
|
|
case "projects":
|
|
return (
|
|
<ProjectsPage
|
|
products={products}
|
|
projects={projects}
|
|
navigate={navigate}
|
|
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
|
|
openPipeline={(projectId) => navigate("pipeline", { projectId })}
|
|
onDelete={(projectId) => action(() => api.deleteProject(projectId), "项目已删除")}
|
|
/>
|
|
);
|
|
case "projectWizard":
|
|
return (
|
|
<ProjectWizardPage
|
|
products={products}
|
|
onBack={() => navigate("projects")}
|
|
onCreate={async (payload) => {
|
|
const created = await action(() => api.createProject(payload), "项目已创建");
|
|
if (created) navigate("pipeline", { projectId: created.id });
|
|
}}
|
|
/>
|
|
);
|
|
case "pipeline":
|
|
// 有项目时由下方 full-screen 特例渲染;这里只兜底「暂无项目」
|
|
return (
|
|
<div className="page-head">
|
|
<div>
|
|
<h1>暂无项目</h1>
|
|
<div className="sub">
|
|
<span className="mono">// 先创建一个视频项目</span>
|
|
</div>
|
|
</div>
|
|
<div className="actions">
|
|
<button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}>
|
|
新建视频项目
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
case "library":
|
|
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
|
|
case "account":
|
|
return <AccountPage billing={billing} ledgers={ledgers} projects={projects} teamMembers={teamMembers} />;
|
|
case "team":
|
|
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} billing={billing} navigate={navigate} />;
|
|
case "messages":
|
|
return <MessagesPage navigate={navigate} />;
|
|
case "assetFactory":
|
|
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
|
case "imageOptimize":
|
|
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
|
case "modelPhoto":
|
|
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
|
case "platformCover":
|
|
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
|
|
case "modelPhotoDemoA":
|
|
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
|
|
case "modelPhotoDemoB":
|
|
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
|
|
case "settings":
|
|
return <SettingsPage user={currentUser} team={currentTeam} />;
|
|
case "settingsNotify":
|
|
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" />;
|
|
default:
|
|
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
|
|
}
|
|
}
|
|
|
|
const avatarChar = (currentTeam.name || currentUser.username || "A").slice(0, 1).toUpperCase();
|
|
const here = crumbLabels[page] || routeLabels[page] || "工作台";
|
|
|
|
// 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell
|
|
const pipelineProject = projectDetail || activeProject;
|
|
if (page === "pipeline" && pipelineProject) {
|
|
return (
|
|
<PipelinePage
|
|
project={pipelineProject}
|
|
loading={loading}
|
|
navigate={navigate}
|
|
user={currentUser}
|
|
team={currentTeam}
|
|
products={products}
|
|
projects={projects}
|
|
billing={billing}
|
|
notice={notice}
|
|
avatarChar={avatarChar}
|
|
logout={logout}
|
|
onRefresh={refreshProjectDetail}
|
|
onGenerateScript={(prompt) => action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")}
|
|
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
|
|
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")}
|
|
onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已生成")}
|
|
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
|
|
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")}
|
|
onPollVideo={(segmentId) => action(() => api.pollVideo(pipelineProject.id, segmentId), "片段状态已刷新")}
|
|
onSubmitAllVideos={(prompt) =>
|
|
action(async () => {
|
|
const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
|
|
for (const segment of targets) {
|
|
await api.submitVideo(pipelineProject.id, {
|
|
video_segment_id: segment.id,
|
|
prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒`
|
|
});
|
|
}
|
|
return targets.length;
|
|
}, "60s 多段视频任务已提交")
|
|
}
|
|
onPollAllVideos={() =>
|
|
action(async () => {
|
|
const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
|
|
for (const segment of targets) {
|
|
await api.pollVideo(pipelineProject.id, segment.id).catch(() => undefined);
|
|
}
|
|
return targets.length;
|
|
}, "视频片段状态已刷新")
|
|
}
|
|
onSubmitExport={() => action(() => api.submitExport(pipelineProject.id), "导出任务已提交")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="app">
|
|
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} />
|
|
<main>
|
|
<Decorations />
|
|
<header className="topbar">
|
|
<div className="crumbs">
|
|
{page === "dashboard" ? (
|
|
<span className="here">工作台</span>
|
|
) : (
|
|
<>
|
|
<a href="/dashboard" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>工作台</a>
|
|
<span className="sep">/</span>
|
|
<span className="here">{here}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="right">
|
|
<span className="balance-chip" onClick={() => navigate("account")}>
|
|
<IconKitSvg name="creditCard" />
|
|
余额 <strong>{money(billing?.account.balance)}</strong>
|
|
</span>
|
|
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
|
<IconKitSvg name="bell" />
|
|
<span className="count-noti">12</span>
|
|
</button>
|
|
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
|
|
<span>{avatarChar}</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div className="content" id="page-content">
|
|
<CornerMarks />
|
|
{notice && <ToastLike notice={notice} />}
|
|
{renderPage()}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|