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:
+110
-70
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user