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>
This commit is contained in:
zyc
2026-06-05 10:21:41 +08:00
co-authored by Claude Opus 4.8
parent cfdcd84a30
commit d41e487f08
37 changed files with 4108 additions and 456 deletions
+110 -70
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Bell, CircleDollarSign } from "lucide-react";
import { api, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
Asset,
@@ -13,7 +13,7 @@ import type {
TeamMember,
User
} from "./types";
import { Decorations, Sidebar, ToastLike } from "./components/app-shell";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
import {
AccountPage,
AssetFactoryPage,
@@ -36,6 +36,28 @@ import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./r
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;
@@ -275,10 +297,6 @@ export function App() {
projects={projects.filter((project) => project.product === activeProduct.id)}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
onDelete={async () => {
const ok = await action(() => api.deleteProduct(activeProduct.id), "商品已删除");
if (ok !== null) navigate("products");
}}
/>
);
case "projects":
@@ -303,68 +321,29 @@ export function App() {
}}
/>
);
case "pipeline": {
const project = projectDetail || activeProject;
if (!project) {
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>
case "pipeline":
// 有项目时由下方 full-screen 特例渲染;这里只兜底「暂无项目」
return (
<div className="page-head">
<div>
<h1>暂无项目</h1>
<div className="sub">
<span className="mono">// 先创建一个视频项目</span>
</div>
</div>
);
}
return (
<PipelinePage
project={project}
loading={loading}
onRefresh={refreshProjectDetail}
onGenerateScript={(prompt) => action(() => api.generateScript(project.id, { prompt }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(project.id, scriptId), "脚本已采用")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(project.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(project.id, { prompt }), "故事板已生成")}
onSkipStoryboard={() => action(() => api.skipStoryboard(project.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(project.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")}
onPollVideo={(segmentId) => action(() => api.pollVideo(project.id, segmentId), "片段状态已刷新")}
onSubmitAllVideos={(prompt) =>
action(async () => {
const targets = project.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
for (const segment of targets) {
await api.submitVideo(project.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 = project.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
for (const segment of targets) {
await api.pollVideo(project.id, segment.id).catch(() => undefined);
}
return targets.length;
}, "视频片段状态已刷新")
}
onSubmitExport={() => action(() => api.submitExport(project.id), "导出任务已提交")}
/>
<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} navigate={navigate} />;
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} billing={billing} navigate={navigate} />;
case "messages":
return <MessagesPage navigate={navigate} />;
case "assetFactory":
@@ -388,31 +367,92 @@ export function App() {
}
}
const avatarChar = (user.username || "U").slice(0, 1).toUpperCase();
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={user} products={products} />
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} />
<main>
<Decorations />
<header className="topbar">
<div className="crumbs">
<span className="here">{routeLabels[page]}</span>
{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">
<button className="balance-chip" type="button" onClick={() => navigate("account")}>
<CircleDollarSign size={13} />
<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>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} aria-label="消息">
<Bell size={15} />
</button>
<button className="topbar-avatar" type="button" onDoubleClick={logout} title="双击退出登录">
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
<span>{avatarChar}</span>
</button>
</div>
</div>
</header>
<div className="content" id="page-content">
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{renderPage()}
</div>