diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index da61c6e..c3d855c 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -7,6 +7,7 @@ import type { BillingSummary, Ledger, ModelConfig, + Notification, Product, Project, Team, @@ -75,6 +76,8 @@ export function App() { const [aiTasks, setAiTasks] = useState([]); const [billing, setBilling] = useState(null); const [ledgers, setLedgers] = useState([]); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); const [projectDetail, setProjectDetail] = useState(null); const [activeProductId, setActiveProductId] = useState(route.productId || ""); @@ -92,7 +95,7 @@ export function App() { ); const loadData = useCallback(async () => { - const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData] = + const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData, notificationData] = await Promise.all([ api.products(), api.projects(), @@ -101,7 +104,8 @@ export function App() { api.ledgers().catch(() => []), api.teamMembers().catch(() => []), api.modelConfigs().catch(() => null), - api.aiTasks().catch(() => null) + api.aiTasks().catch(() => null), + api.listNotifications().catch(() => null) ]); setProducts(productData.results); setProjects(projectData.results); @@ -111,10 +115,22 @@ export function App() { setAiTasks(taskData?.results || []); if (billingData) setBilling(billingData); setLedgers(ledgerData); + if (notificationData) { + setNotifications(notificationData.results); + setUnreadCount(notificationData.unread_count); + } setActiveProjectId((current) => current || projectData.results[0]?.id || ""); setActiveProductId((current) => current || productData.results[0]?.id || ""); }, []); + const reloadNotifications = useCallback(async () => { + const data = await api.listNotifications().catch(() => null); + if (data) { + setNotifications(data.results); + setUnreadCount(data.unread_count); + } + }, []); + // Boot: validate token, hydrate identity + data. useEffect(() => { if (!getToken()) { @@ -210,6 +226,16 @@ export function App() { } } + async function markNotificationRead(id: string) { + await api.markNotificationRead(id).catch(() => undefined); + await reloadNotifications(); + } + + async function markAllNotificationsRead() { + await api.markAllNotificationsRead().catch(() => undefined); + await reloadNotifications(); + } + function onAuthed(payload: { token: string; user: User; team: Team }) { setToken(payload.token); setUser(payload.user); @@ -341,11 +367,40 @@ export function App() { case "library": return action(() => api.uploadAsset(formData), "资产已上传")} />; case "account": - return ; + return ( + action(() => api.recharge({ amount, bonus }), "充值成功")} + /> + ); case "team": - return ; + return ( + action(() => api.createTeamMember(payload), "成员账户已创建")} + onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")} + onRemoveMember={(id) => action(() => api.removeTeamMember(id), "成员已移除")} + onResetPassword={(id, password) => action(() => api.resetMemberPassword(id, password), "密码已重置")} + onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")} + /> + ); case "messages": - return ; + return ( + + ); case "assetFactory": return ; case "imageOptimize": @@ -444,7 +499,7 @@ export function App() {
{avatarChar} diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 7952368..e707ee1 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -5,9 +5,12 @@ import type { BillingSummary, Ledger, ModelConfig, + Notification, + NotificationList, Paginated, Product, Project, + RechargeResult, ScriptVersion, Team, TeamMember, @@ -69,6 +72,28 @@ export const api = { teamMembers() { return request("/api/auth/team/members/"); }, + createTeamMember(payload: { + username: string; + password: string; + name?: string; + email?: string; + role?: string; + monthly_credit_limit?: number | string; + }) { + return request("/api/auth/team/members/", { method: "POST", body: JSON.stringify(payload) }); + }, + updateTeamMember(id: string, payload: { role?: string; monthly_credit_limit?: number | string; name?: string }) { + return request(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, + removeTeamMember(id: string) { + return request(`/api/auth/team/members/${id}/`, { method: "DELETE" }); + }, + resetMemberPassword(id: string, password: string) { + return request(`/api/auth/team/members/${id}/password/`, { + method: "POST", + body: JSON.stringify({ password }) + }); + }, products() { return request>("/api/products/"); }, @@ -157,5 +182,23 @@ export const api = { }, aiTasks() { return request>("/api/ai/tasks/"); + }, + recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) { + return request("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) }); + }, + listNotifications(params?: { type?: string; unread?: boolean }) { + const query = new URLSearchParams(); + if (params?.type && params.type !== "all") query.set("type", params.type); + if (params?.unread) query.set("unread", "1"); + const qs = query.toString(); + return request(`/api/ops/notifications/${qs ? `?${qs}` : ""}`); + }, + markAllNotificationsRead() { + return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", { + method: "POST" + }); + }, + markNotificationRead(id: string) { + return request(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" }); } }; diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index cbd21de..306d955 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -4,11 +4,11 @@ import { money } from "./stage-config"; type Tab = "overview" | "by-project" | "by-member" | "bills"; -const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; ribbon?: string }> = [ - { amt: 100, gift: "无赠送", bonus: false }, - { amt: 500, gift: "+ ¥30 赠送", bonus: true, ribbon: "推荐" }, - { amt: 1000, gift: "+ ¥80 赠送", bonus: true }, - { amt: 3000, gift: "+ ¥300 赠送", bonus: true } +const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [ + { amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 }, + { amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" }, + { amt: 1000, gift: "+ ¥80 赠送", bonus: true, bonusAmt: 80 }, + { amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 } ]; const STAGES: Array<{ k: string; color: string }> = [ @@ -18,14 +18,26 @@ const STAGES: Array<{ k: string; color: string }> = [ { k: "脚本 LLM", color: "var(--black-alpha-32)" } ]; -export function AccountPage({ billing, ledgers, projects, teamMembers }: { +export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharge }: { billing: BillingSummary | null; ledgers: Ledger[]; projects: Project[]; teamMembers: TeamMember[]; + onRecharge: (amount: number, bonus: number) => void | Promise; }) { const [tab, setTab] = useState("overview"); const [recharge, setRecharge] = useState(500); + const [customAmt, setCustomAmt] = useState(""); + + const selectedCard = RECHARGE.find((item) => item.amt === recharge); + const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge; + const effectiveBonus = Number(customAmt) > 0 ? 0 : selectedCard?.bonusAmt || 0; + + async function submitRecharge() { + if (effectiveAmount <= 0) return; + await onRecharge(effectiveAmount, effectiveBonus); + setCustomAmt(""); + } const balance = Number(billing?.account.balance || 0); const used = Number(billing?.charged_total || 0); @@ -78,7 +90,7 @@ export function AccountPage({ billing, ledgers, projects, teamMembers }: {

快速充值

// 充值后立刻到账,可开发票 · 仅超管可操作
-
已选 ¥{recharge}
+
已选 ¥{effectiveAmount}{effectiveBonus > 0 ? ` + ¥${effectiveBonus} 赠送` : ""}
{RECHARGE.map((item) => ( @@ -98,13 +110,13 @@ export function AccountPage({ billing, ledgers, projects, teamMembers }: {
自定义金额
- + setCustomAmt(event.target.value)} />
- - diff --git a/core/frontend/src/routes/messages.tsx b/core/frontend/src/routes/messages.tsx index 3895e4e..7aa9d97 100644 --- a/core/frontend/src/routes/messages.tsx +++ b/core/frontend/src/routes/messages.tsx @@ -1,21 +1,98 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Bell, Search } from "lucide-react"; +import type { Notification } from "../types"; import type { Page } from "./route-config"; import { routeLabels } from "./route-config"; -export function MessagesPage({ navigate }: { navigate: (page: Page) => void }) { - const messages = [ - { id: "m1", type: "task", priority: "ok", title: "补水面膜 · 痛点种草 v3 成片已完成", brief: "7 镜 · 40 秒 · ¥18.40 已结算。", body: "视频生成全部完成。", target: "pipeline" as Page }, - { id: "m2", type: "billing", priority: "warn", title: "团队余额低于预警线", brief: "当前余额低于 ¥100。", body: "建议先充值或降低任务量。", target: "account" as Page } - ]; +// 通知的 related_url(.html 风格)→ 应用内 Page +function targetPage(n: Notification): Page { + const url = n.related_url || ""; + if (url.includes("pipeline")) return "pipeline"; + if (url.includes("account")) return "account"; + if (url.includes("library")) return "library"; + if (url.includes("settings")) return "settingsNotify"; + if (url.includes("product")) return "products"; + return "dashboard"; +} + +export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: { + notifications: Notification[]; + unreadCount: number; + onMarkRead: (id: string) => void | Promise; + onMarkAllRead: () => void | Promise; + navigate: (page: Page) => void; +}) { const [query, setQuery] = useState(""); - const [selectedId, setSelectedId] = useState(messages[0].id); - const visible = messages.filter((item) => !query || `${item.title} ${item.brief}`.toLowerCase().includes(query.toLowerCase())); - const selected = messages.find((item) => item.id === selectedId) || visible[0] || messages[0]; + const [selectedId, setSelectedId] = useState(""); + + const visible = notifications.filter( + (item) => !query || `${item.title} ${item.brief}`.toLowerCase().includes(query.toLowerCase()) + ); + const selected = notifications.find((item) => item.id === selectedId) || visible[0] || notifications[0] || null; + + // 默认选中第一条(不自动标已读;仅显式点选才标) + useEffect(() => { + if (!selectedId && notifications.length) setSelectedId(notifications[0].id); + }, [selectedId, notifications]); + + function selectItem(item: Notification) { + setSelectedId(item.id); + if (!item.is_read) void onMarkRead(item.id); + } + + const target = selected ? targetPage(selected) : "dashboard"; + return ( <> -

消息中心

// {messages.length} 条总计 任务提醒 · 团队协作 · 计费与系统公告
-
收件箱// 显示 {visible.length} 条
setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
{visible.map((item) => )}

{selected.title}

{selected.type}

{selected.body}

关联资源{routeLabels[selected.target]}
+
+
+

消息中心

+
+ // {notifications.length} 条总计 · {unreadCount} 未读 任务提醒 · 团队协作 · 计费与系统公告 +
+
+
+ + +
+
+
+
+
收件箱// 显示 {visible.length} 条
+
setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
+
+ {visible.length === 0 && ( +
// 暂无消息
+ )} + {visible.map((item) => ( + + ))} +
+
+
+ {selected ? ( + <> +
+
+ +
+

{selected.title}

+
{selected.source || selected.notification_type}{selected.stage ? · {selected.stage} : null}
+
+
+

{selected.body || selected.brief}

+
关联资源{routeLabels[target]}
+
+
+ + ) : ( +

// 选择左侧一条消息查看详情

+ )} +
+
); } diff --git a/core/frontend/src/routes/team.tsx b/core/frontend/src/routes/team.tsx index 3993f82..6d335cc 100644 --- a/core/frontend/src/routes/team.tsx +++ b/core/frontend/src/routes/team.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import { CircleDollarSign, UserPlus } from "lucide-react"; +import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react"; import type { BillingSummary, Team, TeamMember, User } from "../types"; import type { Page } from "./route-config"; import { money } from "./stage-config"; -import { TeamModal } from "../components/overlays"; +import { ConfirmModal, TeamModal } from "../components/overlays"; // 角色 → pill key/label(对齐 api-bridge roleUi) function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } { @@ -24,16 +24,43 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo { cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true } ]; -export function TeamPage({ team, user, members, billing, navigate }: { +export function TeamPage({ team, user, members, billing, navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: { team: Team; user: User; members: TeamMember[]; billing: BillingSummary | null; navigate: (page: Page) => void; + onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise; + onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise; + onRemoveMember: (id: string) => void | Promise; + onResetPassword: (id: string, password: string) => void | Promise; + onRecharge: (amount: number, bonus: number) => void | Promise; }) { - const [modal, setModal] = useState<"" | "invite" | "limit">(""); + const [modal, setModal] = useState<"" | "invite" | "limit" | "recharge">(""); const [search, setSearch] = useState(""); + // 创建账户表单 + const [cuUser, setCuUser] = useState(""); + const [cuPass, setCuPass] = useState(""); + const [cuName, setCuName] = useState(""); + const [cuRole, setCuRole] = useState("member"); + const [cuMonthly, setCuMonthly] = useState(""); + + // 编辑成员 + const [editTarget, setEditTarget] = useState(null); + const [edRole, setEdRole] = useState("member"); + const [edMonthly, setEdMonthly] = useState(""); + + // 重置密码 + const [resetTarget, setResetTarget] = useState(null); + const [resetPwd, setResetPwd] = useState(""); + + // 移除成员 + const [removeTarget, setRemoveTarget] = useState(null); + + // 团队充值 + const [rechargeAmt, setRechargeAmt] = useState("500"); + const rows: TeamMember[] = members.length ? members : [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember]; @@ -52,6 +79,55 @@ export function TeamPage({ team, user, members, billing, navigate }: { return !needle || `${name} ${email}`.toLowerCase().includes(needle); }); + function openEdit(member: TeamMember) { + setEditTarget(member); + setEdRole(member.role === "owner" ? "admin" : member.role || "member"); + setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : ""); + } + + async function submitCreate() { + if (!cuUser.trim() || cuPass.length < 8) return; + await onCreateMember({ + username: cuUser.trim(), + password: cuPass, + name: cuName.trim() || undefined, + role: cuRole, + monthly_credit_limit: Number(cuMonthly) || 0 + }); + setModal(""); + setCuUser(""); + setCuPass(""); + setCuName(""); + setCuRole("member"); + setCuMonthly(""); + } + + async function submitEdit() { + if (!editTarget) return; + await onUpdateMember(editTarget.id, { role: edRole, monthly_credit_limit: Number(edMonthly) || 0 }); + setEditTarget(null); + } + + async function submitReset() { + if (!resetTarget || resetPwd.length < 8) return; + await onResetPassword(resetTarget.id, resetPwd); + setResetTarget(null); + setResetPwd(""); + } + + async function submitRemove() { + if (!removeTarget) return; + await onRemoveMember(removeTarget.id); + setRemoveTarget(null); + } + + async function submitRecharge() { + const amt = Number(rechargeAmt) || 0; + if (amt <= 0) return; + await onRecharge(amt, 0); + setModal(""); + } + return (
@@ -80,7 +156,7 @@ export function TeamPage({ team, user, members, billing, navigate }: {
// 团队 ID: {team.id} · {rows.length} 名成员
- @@ -119,7 +195,7 @@ export function TeamPage({ team, user, members, billing, navigate }: {

团队动态

// 真实动态接口待接入 - 全部 → + navigate("messages")}>全部 →
@@ -171,9 +247,9 @@ export function TeamPage({ team, user, members, billing, navigate }: {
{isOwner ? 不可编辑 : <> - - - + + + }
); @@ -207,8 +283,83 @@ export function TeamPage({ team, user, members, billing, navigate }: {
- } close={() => setModal("")}>
- } close={() => setModal("")}>
+ {/* 设置月限额(团队级)· 注:后端暂无团队级限额端点,真实限额请在「编辑成员」逐人设置 */} + } close={() => setModal("")}>
+ + {/* 创建账户 */} + } + close={() => setModal("")} + footer={} + > +
setCuUser(e.target.value)} placeholder="zhang.yunying" />
+
setCuPass(e.target.value)} placeholder="至少 8 位" />
+
setCuName(e.target.value)} placeholder="张运营" />
+
+ +
+
setCuMonthly(e.target.value)} placeholder="0" />
+
+ + {/* 团队充值 */} + } + close={() => setModal("")} + footer={} + > +
setRechargeAmt(e.target.value)} placeholder="最低 ¥50" />
+
+ + {/* 编辑成员 */} + } + close={() => setEditTarget(null)} + footer={} + > +
+ +
+
setEdMonthly(e.target.value)} placeholder="0" />
+
+ + {/* 重置密码 */} + } + close={() => setResetTarget(null)} + footer={} + > +
setResetPwd(e.target.value)} placeholder="新密码" />
+
+ + {/* 移除成员确认 */} + setRemoveTarget(null)} + onConfirm={submitRemove} + />
); } diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index dd0c33e..bfd6ed8 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -177,3 +177,34 @@ export type AITask = { created_at: string; updated_at: string; }; + +export type Notification = { + id: string; + type: string; + notification_type: string; + priority: string; + title: string; + brief: string; + body: string; + source: string; + project: string | null; + project_name?: string; + stage: string; + owner_label: string; + cost_label: string; + related_url: string; + is_read: boolean; + unread: boolean; + read_at: string | null; + archived_at: string | null; + metadata: Record; + created_at: string; + updated_at: string; +}; + +export type NotificationList = Paginated & { unread_count: number }; + +export type RechargeResult = { + account: BillingSummary["account"]; + ledger: Ledger; +};