feat(core/frontend): wire P0 team mgmt / recharge / notifications
- App.tsx: load notifications, expose team/recharge/notification handlers, real unread bell count (was hardcoded 12) - team.tsx: create/edit/reset-password/remove member + team recharge modals - account.tsx: recharge via wechat/alipay buttons + custom amount - messages.tsx: real notification inbox, mark-read on select, mark-all-read - verified: tsc --noEmit clean; e2e create->update->reset->recharge->mark-read->delete all green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d41e487f08
commit
25bf3293df
@ -7,6 +7,7 @@ import type {
|
|||||||
BillingSummary,
|
BillingSummary,
|
||||||
Ledger,
|
Ledger,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
|
Notification,
|
||||||
Product,
|
Product,
|
||||||
Project,
|
Project,
|
||||||
Team,
|
Team,
|
||||||
@ -75,6 +76,8 @@ export function App() {
|
|||||||
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
||||||
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
||||||
const [ledgers, setLedgers] = useState<Ledger[]>([]);
|
const [ledgers, setLedgers] = useState<Ledger[]>([]);
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
|
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
|
||||||
|
|
||||||
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
||||||
@ -92,7 +95,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
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([
|
await Promise.all([
|
||||||
api.products(),
|
api.products(),
|
||||||
api.projects(),
|
api.projects(),
|
||||||
@ -101,7 +104,8 @@ export function App() {
|
|||||||
api.ledgers().catch(() => []),
|
api.ledgers().catch(() => []),
|
||||||
api.teamMembers().catch(() => []),
|
api.teamMembers().catch(() => []),
|
||||||
api.modelConfigs().catch(() => null),
|
api.modelConfigs().catch(() => null),
|
||||||
api.aiTasks().catch(() => null)
|
api.aiTasks().catch(() => null),
|
||||||
|
api.listNotifications().catch(() => null)
|
||||||
]);
|
]);
|
||||||
setProducts(productData.results);
|
setProducts(productData.results);
|
||||||
setProjects(projectData.results);
|
setProjects(projectData.results);
|
||||||
@ -111,10 +115,22 @@ export function App() {
|
|||||||
setAiTasks(taskData?.results || []);
|
setAiTasks(taskData?.results || []);
|
||||||
if (billingData) setBilling(billingData);
|
if (billingData) setBilling(billingData);
|
||||||
setLedgers(ledgerData);
|
setLedgers(ledgerData);
|
||||||
|
if (notificationData) {
|
||||||
|
setNotifications(notificationData.results);
|
||||||
|
setUnreadCount(notificationData.unread_count);
|
||||||
|
}
|
||||||
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
|
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
|
||||||
setActiveProductId((current) => current || productData.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.
|
// Boot: validate token, hydrate identity + data.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
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 }) {
|
function onAuthed(payload: { token: string; user: User; team: Team }) {
|
||||||
setToken(payload.token);
|
setToken(payload.token);
|
||||||
setUser(payload.user);
|
setUser(payload.user);
|
||||||
@ -341,11 +367,40 @@ export function App() {
|
|||||||
case "library":
|
case "library":
|
||||||
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
|
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
|
||||||
case "account":
|
case "account":
|
||||||
return <AccountPage billing={billing} ledgers={ledgers} projects={projects} teamMembers={teamMembers} />;
|
return (
|
||||||
|
<AccountPage
|
||||||
|
billing={billing}
|
||||||
|
ledgers={ledgers}
|
||||||
|
projects={projects}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "team":
|
case "team":
|
||||||
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} billing={billing} navigate={navigate} />;
|
return (
|
||||||
|
<TeamPage
|
||||||
|
team={currentTeam}
|
||||||
|
user={currentUser}
|
||||||
|
members={teamMembers}
|
||||||
|
billing={billing}
|
||||||
|
navigate={navigate}
|
||||||
|
onCreateMember={(payload) => 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":
|
case "messages":
|
||||||
return <MessagesPage navigate={navigate} />;
|
return (
|
||||||
|
<MessagesPage
|
||||||
|
notifications={notifications}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onMarkRead={markNotificationRead}
|
||||||
|
onMarkAllRead={markAllNotificationsRead}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "assetFactory":
|
case "assetFactory":
|
||||||
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
|
||||||
case "imageOptimize":
|
case "imageOptimize":
|
||||||
@ -444,7 +499,7 @@ export function App() {
|
|||||||
</span>
|
</span>
|
||||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||||
<IconKitSvg name="bell" />
|
<IconKitSvg name="bell" />
|
||||||
<span className="count-noti">12</span>
|
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
|
||||||
</button>
|
</button>
|
||||||
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
|
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
|
||||||
<span>{avatarChar}</span>
|
<span>{avatarChar}</span>
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import type {
|
|||||||
BillingSummary,
|
BillingSummary,
|
||||||
Ledger,
|
Ledger,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
|
Notification,
|
||||||
|
NotificationList,
|
||||||
Paginated,
|
Paginated,
|
||||||
Product,
|
Product,
|
||||||
Project,
|
Project,
|
||||||
|
RechargeResult,
|
||||||
ScriptVersion,
|
ScriptVersion,
|
||||||
Team,
|
Team,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
@ -69,6 +72,28 @@ export const api = {
|
|||||||
teamMembers() {
|
teamMembers() {
|
||||||
return request<TeamMember[]>("/api/auth/team/members/");
|
return request<TeamMember[]>("/api/auth/team/members/");
|
||||||
},
|
},
|
||||||
|
createTeamMember(payload: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
monthly_credit_limit?: number | string;
|
||||||
|
}) {
|
||||||
|
return request<TeamMember>("/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<TeamMember>(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
|
removeTeamMember(id: string) {
|
||||||
|
return request<void>(`/api/auth/team/members/${id}/`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
resetMemberPassword(id: string, password: string) {
|
||||||
|
return request<void>(`/api/auth/team/members/${id}/password/`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
},
|
||||||
products() {
|
products() {
|
||||||
return request<Paginated<Product>>("/api/products/");
|
return request<Paginated<Product>>("/api/products/");
|
||||||
},
|
},
|
||||||
@ -157,5 +182,23 @@ export const api = {
|
|||||||
},
|
},
|
||||||
aiTasks() {
|
aiTasks() {
|
||||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
return request<Paginated<AITask>>("/api/ai/tasks/");
|
||||||
|
},
|
||||||
|
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
|
||||||
|
return request<RechargeResult>("/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<NotificationList>(`/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<Notification>(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import { money } from "./stage-config";
|
|||||||
|
|
||||||
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
||||||
|
|
||||||
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; ribbon?: string }> = [
|
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
|
||||||
{ amt: 100, gift: "无赠送", bonus: false },
|
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
||||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, ribbon: "推荐" },
|
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
|
||||||
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true },
|
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true, bonusAmt: 80 },
|
||||||
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true }
|
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
|
||||||
];
|
];
|
||||||
|
|
||||||
const STAGES: Array<{ k: string; color: string }> = [
|
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)" }
|
{ 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;
|
billing: BillingSummary | null;
|
||||||
ledgers: Ledger[];
|
ledgers: Ledger[];
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
teamMembers: TeamMember[];
|
teamMembers: TeamMember[];
|
||||||
|
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("overview");
|
const [tab, setTab] = useState<Tab>("overview");
|
||||||
const [recharge, setRecharge] = useState(500);
|
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 balance = Number(billing?.account.balance || 0);
|
||||||
const used = Number(billing?.charged_total || 0);
|
const used = Number(billing?.charged_total || 0);
|
||||||
@ -78,7 +90,7 @@ export function AccountPage({ billing, ledgers, projects, teamMembers }: {
|
|||||||
<h3>快速充值</h3>
|
<h3>快速充值</h3>
|
||||||
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="topup-selected">已选 ¥{recharge}</div>
|
<div className="topup-selected">已选 ¥{effectiveAmount}{effectiveBonus > 0 ? ` + ¥${effectiveBonus} 赠送` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="recharge-row">
|
<div className="recharge-row">
|
||||||
{RECHARGE.map((item) => (
|
{RECHARGE.map((item) => (
|
||||||
@ -98,13 +110,13 @@ export function AccountPage({ billing, ledgers, projects, teamMembers }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pay-row">
|
<div className="pay-row">
|
||||||
<div className="pay-title">自定义金额</div>
|
<div className="pay-title">自定义金额</div>
|
||||||
<input className="input" placeholder="最低 ¥50,可输入任意金额" />
|
<input className="input" placeholder="最低 ¥50,可输入任意金额" type="number" value={customAmt} onChange={(event) => setCustomAmt(event.target.value)} />
|
||||||
<div className="pay-btn-row">
|
<div className="pay-btn-row">
|
||||||
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付">
|
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付" onClick={submitRecharge}>
|
||||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
|
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
|
||||||
微信支付
|
微信支付
|
||||||
</button>
|
</button>
|
||||||
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝">
|
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝" onClick={submitRecharge}>
|
||||||
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
|
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
|
||||||
支付宝
|
支付宝
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,21 +1,98 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Bell, Search } from "lucide-react";
|
import { Bell, Search } from "lucide-react";
|
||||||
|
import type { Notification } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { routeLabels } from "./route-config";
|
import { routeLabels } from "./route-config";
|
||||||
|
|
||||||
export function MessagesPage({ navigate }: { navigate: (page: Page) => void }) {
|
// 通知的 related_url(.html 风格)→ 应用内 Page
|
||||||
const messages = [
|
function targetPage(n: Notification): Page {
|
||||||
{ id: "m1", type: "task", priority: "ok", title: "补水面膜 · 痛点种草 v3 成片已完成", brief: "7 镜 · 40 秒 · ¥18.40 已结算。", body: "视频生成全部完成。", target: "pipeline" as Page },
|
const url = n.related_url || "";
|
||||||
{ id: "m2", type: "billing", priority: "warn", title: "团队余额低于预警线", brief: "当前余额低于 ¥100。", body: "建议先充值或降低任务量。", target: "account" as Page }
|
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<unknown>;
|
||||||
|
onMarkAllRead: () => void | Promise<unknown>;
|
||||||
|
navigate: (page: Page) => void;
|
||||||
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState(messages[0].id);
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head"><div><h1>消息中心</h1><div className="sub"><span className="mono">// {messages.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button></div></div>
|
<div className="page-head">
|
||||||
<div className="msg-workbench"><section className="msg-panel msg-inbox"><div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div><div className="msg-search"><Search size={14} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /></div><div className="msg-list">{visible.map((item) => <button className={`msg-item ${selected.id === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setSelectedId(item.id)}><span className={`msg-type-ic ${item.type}`}><Bell size={13} /></span><span className="msg-item-main"><span className="msg-item-title">{item.title}</span><span className="msg-brief">{item.brief}</span></span></button>)}</div></section><section className="msg-panel msg-detail"><div className="msg-detail-body"><div className="msg-detail-top"><span className={`msg-type-ic ${selected.type}`}><Bell size={15} /></span><div className="msg-detail-title"><h2>{selected.title}</h2><div className="meta"><span>{selected.type}</span></div></div></div><p className="msg-body-text">{selected.body}</p><div className="msg-props"><span className="k">关联资源</span><span className="v">{routeLabels[selected.target]}</span></div></div><div className="msg-detail-f"><span className="spacer" /><button className="btn btn-primary" type="button" onClick={() => navigate(selected.target)}>进入{routeLabels[selected.target]}</button></div></section></div>
|
<div>
|
||||||
|
<h1>消息中心</h1>
|
||||||
|
<div className="sub">
|
||||||
|
<span className="mono">// {notifications.length} 条总计 · {unreadCount} 未读</span> 任务提醒 · 团队协作 · 计费与系统公告
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部已读</button>
|
||||||
|
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="msg-workbench">
|
||||||
|
<section className="msg-panel msg-inbox">
|
||||||
|
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div>
|
||||||
|
<div className="msg-search"><Search size={14} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /></div>
|
||||||
|
<div className="msg-list">
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<div className="msg-empty" style={{ padding: "24px 16px", color: "var(--black-alpha-48)", fontSize: "12px", fontFamily: "var(--font-mono)" }}>// 暂无消息</div>
|
||||||
|
)}
|
||||||
|
{visible.map((item) => (
|
||||||
|
<button className={`msg-item ${selected?.id === item.id ? "active" : ""} ${item.is_read ? "" : "unread"}`} type="button" key={item.id} onClick={() => selectItem(item)}>
|
||||||
|
<span className={`msg-type-ic ${item.notification_type}`}><Bell size={13} /></span>
|
||||||
|
<span className="msg-item-main"><span className="msg-item-title">{item.title}</span><span className="msg-brief">{item.brief}</span></span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="msg-panel msg-detail">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className="msg-detail-body">
|
||||||
|
<div className="msg-detail-top">
|
||||||
|
<span className={`msg-type-ic ${selected.notification_type}`}><Bell size={15} /></span>
|
||||||
|
<div className="msg-detail-title">
|
||||||
|
<h2>{selected.title}</h2>
|
||||||
|
<div className="meta"><span>{selected.source || selected.notification_type}</span>{selected.stage ? <span> · {selected.stage}</span> : null}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="msg-body-text">{selected.body || selected.brief}</p>
|
||||||
|
<div className="msg-props"><span className="k">关联资源</span><span className="v">{routeLabels[target]}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="msg-detail-f"><span className="spacer" /><button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="msg-detail-body"><p className="msg-body-text">// 选择左侧一条消息查看详情</p></div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
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 { BillingSummary, Team, TeamMember, User } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { money } from "./stage-config";
|
import { money } from "./stage-config";
|
||||||
import { TeamModal } from "../components/overlays";
|
import { ConfirmModal, TeamModal } from "../components/overlays";
|
||||||
|
|
||||||
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
||||||
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
|
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 }
|
{ 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;
|
team: Team;
|
||||||
user: User;
|
user: User;
|
||||||
members: TeamMember[];
|
members: TeamMember[];
|
||||||
billing: BillingSummary | null;
|
billing: BillingSummary | null;
|
||||||
navigate: (page: Page) => void;
|
navigate: (page: Page) => void;
|
||||||
|
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
|
||||||
|
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
|
||||||
|
onRemoveMember: (id: string) => void | Promise<unknown>;
|
||||||
|
onResetPassword: (id: string, password: string) => void | Promise<unknown>;
|
||||||
|
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
const [modal, setModal] = useState<"" | "invite" | "limit">("");
|
const [modal, setModal] = useState<"" | "invite" | "limit" | "recharge">("");
|
||||||
const [search, setSearch] = useState("");
|
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<TeamMember | null>(null);
|
||||||
|
const [edRole, setEdRole] = useState("member");
|
||||||
|
const [edMonthly, setEdMonthly] = useState("");
|
||||||
|
|
||||||
|
// 重置密码
|
||||||
|
const [resetTarget, setResetTarget] = useState<TeamMember | null>(null);
|
||||||
|
const [resetPwd, setResetPwd] = useState("");
|
||||||
|
|
||||||
|
// 移除成员
|
||||||
|
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null);
|
||||||
|
|
||||||
|
// 团队充值
|
||||||
|
const [rechargeAmt, setRechargeAmt] = useState("500");
|
||||||
|
|
||||||
const rows: TeamMember[] = members.length
|
const rows: TeamMember[] = members.length
|
||||||
? members
|
? members
|
||||||
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
|
: [{ 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);
|
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 (
|
return (
|
||||||
<section className="team-page">
|
<section className="team-page">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@ -80,7 +156,7 @@ export function TeamPage({ team, user, members, billing, navigate }: {
|
|||||||
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
|
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="banner-actions">
|
<div className="banner-actions">
|
||||||
<button className="btn btn-sm" type="button" onClick={() => navigate("account")}>
|
<button className="btn btn-sm" type="button" onClick={() => setModal("recharge")}>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
|
||||||
充值
|
充值
|
||||||
</button>
|
</button>
|
||||||
@ -119,7 +195,7 @@ export function TeamPage({ team, user, members, billing, navigate }: {
|
|||||||
<div className="h">
|
<div className="h">
|
||||||
<h3>团队动态</h3>
|
<h3>团队动态</h3>
|
||||||
<span className="ct">// 真实动态接口待接入</span>
|
<span className="ct">// 真实动态接口待接入</span>
|
||||||
<a className="more" id="open-feed-all" role="button" tabIndex={0}>全部 →</a>
|
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}>全部 →</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="feed-list">
|
<div className="feed-list">
|
||||||
<div className="feed-item">
|
<div className="feed-item">
|
||||||
@ -171,9 +247,9 @@ export function TeamPage({ team, user, members, billing, navigate }: {
|
|||||||
<td><div className="acts">{isOwner
|
<td><div className="acts">{isOwner
|
||||||
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
||||||
: <>
|
: <>
|
||||||
<button className="icon-btn-sm" type="button" title="编辑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
|
<button className="icon-btn-sm" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
|
||||||
<button className="icon-btn-sm" type="button" title="重置密码"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
|
<button className="icon-btn-sm" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(""); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
|
||||||
<button className="icon-btn-sm danger" type="button" title="移出"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
<button className="icon-btn-sm danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
||||||
</>}</div></td>
|
</>}</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@ -207,8 +283,83 @@ export function TeamPage({ team, user, members, billing, navigate }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 自然月重置 · 仅超管可改" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">月限额 ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
|
{/* 设置月限额(团队级)· 注:后端暂无团队级限额端点,真实限额请在「编辑成员」逐人设置 */}
|
||||||
<TeamModal open={modal === "invite"} title="创建账户" subtitle="// 直接生成账号 · 分享给成员登录" icon={<UserPlus size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">用户名</label><input className="input" defaultValue="zhang.yunying" /></div><div className="field"><label className="field-label">登录密码</label><input className="input mono" defaultValue="AirShelf2026" /></div></TeamModal>
|
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 团队级限额暂存本地 · 成员限额请在编辑成员中设置" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">月限额 ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
|
||||||
|
|
||||||
|
{/* 创建账户 */}
|
||||||
|
<TeamModal
|
||||||
|
open={modal === "invite"}
|
||||||
|
title="创建账户"
|
||||||
|
subtitle="// 直接生成账号 · 分享给成员登录"
|
||||||
|
icon={<UserPlus size={16} />}
|
||||||
|
close={() => setModal("")}
|
||||||
|
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}>创建账户</button>}
|
||||||
|
>
|
||||||
|
<div className="field"><label className="field-label">用户名</label><input className="input" value={cuUser} onChange={(e) => setCuUser(e.target.value)} placeholder="zhang.yunying" /></div>
|
||||||
|
<div className="field"><label className="field-label">登录密码</label><input className="input mono" value={cuPass} onChange={(e) => setCuPass(e.target.value)} placeholder="至少 8 位" /></div>
|
||||||
|
<div className="field"><label className="field-label">姓名(可选)</label><input className="input" value={cuName} onChange={(e) => setCuName(e.target.value)} placeholder="张运营" /></div>
|
||||||
|
<div className="field"><label className="field-label">角色</label>
|
||||||
|
<select className="input" value={cuRole} onChange={(e) => setCuRole(e.target.value)}>
|
||||||
|
<option value="admin">团管</option>
|
||||||
|
<option value="member">成员</option>
|
||||||
|
<option value="viewer">访客</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label className="field-label">月度额度 ¥(0 = 不限)</label><input className="input" type="number" value={cuMonthly} onChange={(e) => setCuMonthly(e.target.value)} placeholder="0" /></div>
|
||||||
|
</TeamModal>
|
||||||
|
|
||||||
|
{/* 团队充值 */}
|
||||||
|
<TeamModal
|
||||||
|
open={modal === "recharge"}
|
||||||
|
title="团队充值"
|
||||||
|
subtitle="// 充值后立即到账 · 仅超管可操作"
|
||||||
|
icon={<CircleDollarSign size={16} />}
|
||||||
|
close={() => setModal("")}
|
||||||
|
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
|
||||||
|
>
|
||||||
|
<div className="field"><label className="field-label">充值金额 ¥</label><input className="input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>
|
||||||
|
</TeamModal>
|
||||||
|
|
||||||
|
{/* 编辑成员 */}
|
||||||
|
<TeamModal
|
||||||
|
open={!!editTarget}
|
||||||
|
title="编辑成员"
|
||||||
|
subtitle={editTarget ? `// ${editTarget.user.username || editTarget.user.email}` : ""}
|
||||||
|
icon={<UserPlus size={16} />}
|
||||||
|
close={() => setEditTarget(null)}
|
||||||
|
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}>保存</button>}
|
||||||
|
>
|
||||||
|
<div className="field"><label className="field-label">角色</label>
|
||||||
|
<select className="input" value={edRole} onChange={(e) => setEdRole(e.target.value)}>
|
||||||
|
<option value="admin">团管</option>
|
||||||
|
<option value="member">成员</option>
|
||||||
|
<option value="viewer">访客</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label className="field-label">月度额度 ¥(0 = 不限)</label><input className="input" type="number" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} placeholder="0" /></div>
|
||||||
|
</TeamModal>
|
||||||
|
|
||||||
|
{/* 重置密码 */}
|
||||||
|
<TeamModal
|
||||||
|
open={!!resetTarget}
|
||||||
|
title="重置密码"
|
||||||
|
subtitle={resetTarget ? `// ${resetTarget.user.username || resetTarget.user.email}` : ""}
|
||||||
|
icon={<KeyRound size={16} />}
|
||||||
|
close={() => setResetTarget(null)}
|
||||||
|
footer={<button className="btn btn-primary" type="button" onClick={submitReset}>重置密码</button>}
|
||||||
|
>
|
||||||
|
<div className="field"><label className="field-label">新密码(至少 8 位)</label><input className="input mono" value={resetPwd} onChange={(e) => setResetPwd(e.target.value)} placeholder="新密码" /></div>
|
||||||
|
</TeamModal>
|
||||||
|
|
||||||
|
{/* 移除成员确认 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!removeTarget}
|
||||||
|
title="移除成员"
|
||||||
|
detail={removeTarget ? `确认将「${removeTarget.user.username || removeTarget.user.email}」移出团队?移除后该成员将失去登录与访问权限。` : ""}
|
||||||
|
confirmText="移除"
|
||||||
|
onCancel={() => setRemoveTarget(null)}
|
||||||
|
onConfirm={submitRemove}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -177,3 +177,34 @@ export type AITask = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationList = Paginated<Notification> & { unread_count: number };
|
||||||
|
|
||||||
|
export type RechargeResult = {
|
||||||
|
account: BillingSummary["account"];
|
||||||
|
ledger: Ledger;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user