feat: add AirShelf core implementation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user