feat: add AirShelf core implementation

This commit is contained in:
zyc
2026-06-05 10:21:40 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Bell, CircleDollarSign } from "lucide-react";
import { api, getToken, setToken } from "./api";
import type {
AITask,
Asset,
BillingSummary,
Ledger,
ModelConfig,
Product,
Project,
Team,
TeamMember,
User
} from "./types";
import { 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";
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), "商品已更新")}
onDelete={async () => {
const ok = await action(() => api.deleteProduct(activeProduct.id), "商品已删除");
if (ok !== null) navigate("products");
}}
/>
);
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": {
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>
</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), "导出任务已提交")}
/>
);
}
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} />;
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 = (user.username || "U").slice(0, 1).toUpperCase();
return (
<div className="app">
<Sidebar page={page} navigate={navigate} user={user} products={products} />
<main>
<Decorations />
<header className="topbar">
<div className="crumbs">
<span className="here">{routeLabels[page]}</span>
</div>
<div className="right">
<button className="balance-chip" type="button" onClick={() => navigate("account")}>
<CircleDollarSign size={13} />
余额 <strong>{money(billing?.account.balance)}</strong>
</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="双击退出登录">
<span>{avatarChar}</span>
</button>
</div>
</header>
<div className="content" id="page-content">
{notice && <ToastLike notice={notice} />}
{renderPage()}
</div>
</main>
</div>
);
}