Files
yingqing/core/frontend/src/App.tsx
T
zycandClaude Opus 4.8 d41e487f08 feat(k8s): 新增 core 真应用(前端+Django API+Celery worker)构建与部署
- 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>
2026-06-05 10:21:41 +08:00

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>
);
}