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:
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, Grid2X2, List, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { stageMeta, stageOrder, statusPill } from "./stage-config";
|
||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||||
import { Progress } from "../components/pipeline-stage";
|
||||
|
||||
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
products: Product[];
|
||||
@@ -37,6 +35,34 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
);
|
||||
}
|
||||
|
||||
// 对齐 api-bridge:阶段编号 / 状态分桶 / 友好标签 / pill 类
|
||||
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
|
||||
function projStageNo(project: Project) { return project.status === "completed" ? 5 : (PROJ_STAGE_NO[project.current_stage] || 1); }
|
||||
function projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
|
||||
function projStatusLabel(project: Project) {
|
||||
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
||||
}
|
||||
function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "fail" : "info"; }
|
||||
function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); }
|
||||
// 复刻 mock-media coverFor:按项目名关键词映射封面图(无匹配 → 占位)
|
||||
function projCover(name: string): string {
|
||||
const t = name.replace(/\s+/g, "");
|
||||
if (/蓝牙|耳机|南卡/.test(t)) return "cover-earbuds.png";
|
||||
if (/速食|牛肉面|泡面|面条/.test(t)) return "cover-noodle.png";
|
||||
if (/防晒/.test(t)) return "cover-sunscreen.png";
|
||||
if (/咖啡|冻干/.test(t)) return "cover-coffee.png";
|
||||
if (/空气炸锅|小熊/.test(t)) return "cover-air-fryer.png";
|
||||
if (/瑜伽裤|露露/.test(t)) return "cover-yoga.png";
|
||||
if (/v1|final|已完成|成片|敷面膜|化妆台/.test(t)) return "cover-mask-final.png";
|
||||
if (/面膜|补水|玻尿酸|透真/.test(t)) return "cover-mask-v3.png";
|
||||
return "";
|
||||
}
|
||||
const projMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
|
||||
|
||||
const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string }> = [
|
||||
{ filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" }
|
||||
];
|
||||
|
||||
export function ProjectsPage({ products, projects, navigate, openPipeline, onDelete }: {
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
@@ -46,10 +72,21 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
onDelete: (projectId: string) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [view, setView] = useState<"list" | "grid">("list");
|
||||
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
||||
const filtered = projects.filter((project) => `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const counts = {
|
||||
all: projects.length,
|
||||
wip: projects.filter((p) => projBucket(p) === "wip").length,
|
||||
done: projects.filter((p) => projBucket(p) === "done").length,
|
||||
fail: projects.filter((p) => projBucket(p) === "fail").length
|
||||
};
|
||||
const filtered = projects.filter((project) => {
|
||||
if (tab !== "all" && projBucket(project) !== tab) return false;
|
||||
return `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
@@ -58,12 +95,122 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>视频项目</h1><div className="sub"><span className="mono">// {projects.length} 个 · {projects.filter((p) => p.status !== "completed").length} 进行中</span></div></div><div className="actions"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建视频项目</button></div></div>
|
||||
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} /></div><span className="spacer" /><div className="view-toggle"><button className={view === "list" ? "active" : ""} type="button" onClick={() => setView("list")}><List size={13} />列表</button><button className={view === "grid" ? "active" : ""} type="button" onClick={() => setView("grid")}><Grid2X2 size={13} />网格</button></div></div>
|
||||
{view === "list" ? <table className="t"><thead><tr><th>项目</th><th>商品</th><th>当前阶段</th><th>5 段进度</th><th>状态</th><th /></tr></thead><tbody>{filtered.map((project) => <tr key={project.id} onClick={() => openPipeline(project.id)}><td><div className="proj-name-cell"><div className="placeholder proj-thumb"><span className="ph-frame">9:16</span></div><div><div className="proj-name">{project.name}</div><div className="proj-sub">4 段 · 60s · AI 全生</div></div></div></td><td>{productTitle(project.product)}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><div className="hstack"><Progress status={project.current_stage} /><span className="muted-2 mono">{Math.max(stageOrder.indexOf(project.current_stage as never) + 1, 1)}/5</span></div></td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td><button className="icon-btn-sm" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><Trash2 size={13} /></button></td></tr>)}</tbody></table> : <div className="project-card-grid">{filtered.map((project) => <article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}><div className="placeholder proj-card-thumb"><span className="ph-frame">9:16</span></div><div className="proj-card-body"><strong>{project.name}</strong><div className="proj-sub">{productTitle(project.product)}</div><Progress status={project.current_stage} /></div></article>)}</div>}
|
||||
<section className="projects-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>视频项目</h1>
|
||||
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" id="proj-manage-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="proj-manage-label">管理项目</span>
|
||||
</button>
|
||||
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m12.3 3.5 3 4" /><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" /><path d="m6.2 5.3 3.1 3.9" /><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs" id="status-tabs">
|
||||
{PROJ_TABS.map((t) => (
|
||||
<div className={`tab${tab === t.filter ? " active" : ""}`} key={t.filter} data-filter={t.filter} onClick={() => setTab(t.filter)}>{t.label} <span className="count">{counts[t.filter]}</span></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" id="search-input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<div className="chip-wrap" data-key="product"><button className="chip" type="button"><span className="chip-label">商品品类</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
<div className="chip-wrap" data-key="source"><button className="chip" type="button"><span className="chip-label">脚本来源</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
<div className="chip-wrap" data-key="time"><button className="chip" type="button"><span className="chip-label">创建时间</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
|
||||
<span className="spacer"></span>
|
||||
<div className="view-toggle">
|
||||
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="2" width="5" height="5" /><rect x="9" y="2" width="5" height="5" /><rect x="2" y="9" width="5" height="5" /><rect x="9" y="9" width="5" height="5" /></svg>
|
||||
网格
|
||||
</button>
|
||||
<button className={view === "list" ? "active" : ""} type="button" data-view="list" onClick={() => setView("list")}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 4h12M2 8h12M2 12h12" /></svg>
|
||||
列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {projects.length} 个项目</div>
|
||||
|
||||
{view === "list" ? (
|
||||
<div id="list-view">
|
||||
<table className="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "32%" }}>项目</th>
|
||||
<th>商品</th>
|
||||
<th>脚本来源</th>
|
||||
<th style={{ width: "200px" }}>进度</th>
|
||||
<th>状态</th>
|
||||
<th style={{ width: "120px" }}>更新于</th>
|
||||
<th style={{ width: "60px" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="list-tbody">
|
||||
{filtered.map((project) => {
|
||||
const no = projStageNo(project);
|
||||
const shots = project.video_segments.length || 4;
|
||||
const cover = projCover(project.name);
|
||||
return (
|
||||
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} onClick={() => openPipeline(project.id)}>
|
||||
<td>
|
||||
<div className="proj-name-cell">
|
||||
<div className={`placeholder proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
|
||||
<div><div className="proj-name">{project.name}</div><div className="proj-sub">{shots} 镜 · 0-60s</div></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{productTitle(project.product)}</td>
|
||||
<td><span className="muted">AI 全生</span></td>
|
||||
<td>
|
||||
<div className="hstack">
|
||||
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div>
|
||||
<span className="muted-2 mono" style={{ fontSize: "11px" }}>{no}/5</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
|
||||
<td className="muted-2">{projDate(project)}</td>
|
||||
<td>
|
||||
<div className="row-action">
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
|
||||
<span className="row-more" onClick={(event) => event.stopPropagation()}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
|
||||
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>删除项目</button></div>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="proj-grid">{filtered.map((project) => {
|
||||
const cover = projCover(project.name);
|
||||
return (
|
||||
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}>
|
||||
<div className={`placeholder card-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
|
||||
<div className="card-body">
|
||||
<div className="card-name">{project.name}</div>
|
||||
<div className="card-sub">{productTitle(project.product)}</div>
|
||||
<div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}</div>
|
||||
)}
|
||||
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
|
||||
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user