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,20 +1,214 @@
|
||||
import { useState } from "react";
|
||||
import { CircleDollarSign, KeyRound, Settings, UserPlus } from "lucide-react";
|
||||
import type { Team, TeamMember, User } from "../types";
|
||||
import { CircleDollarSign, UserPlus } from "lucide-react";
|
||||
import type { BillingSummary, Team, TeamMember, User } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { money, statusPill } from "./stage-config";
|
||||
import { money } from "./stage-config";
|
||||
import { TeamModal } from "../components/overlays";
|
||||
|
||||
export function TeamPage({ team, user, members, navigate }: { team: Team; user: User; members: TeamMember[]; navigate: (page: Page) => void }) {
|
||||
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
||||
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
|
||||
if (role === "owner" || role === "super") return { key: "super", label: "超管" };
|
||||
if (role === "admin") return { key: "admin", label: "团管" };
|
||||
if (role === "viewer") return { key: "member", label: "访客" };
|
||||
return { key: "member", label: "成员" };
|
||||
}
|
||||
|
||||
const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: boolean }> = [
|
||||
{ cap: "邀请 / 移除成员", cells: ["✓", "✓", "—"] },
|
||||
{ cap: "设置成员额度", cells: ["✓", "✓", "—"] },
|
||||
{ cap: "团队充值", cells: ["✓", "—", "—"] },
|
||||
{ cap: "设置月限额", cells: ["✓", "—", "—"] },
|
||||
{ cap: "编辑别人项目", cells: ["✓", "✓", "—"] },
|
||||
{ cap: "团队共享资产库管理", cells: ["✓", "✓", "仅自传"] },
|
||||
{ cap: "查看团队消费明细", cells: ["✓", "✓", "仅自己"] },
|
||||
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
|
||||
];
|
||||
|
||||
export function TeamPage({ team, user, members, billing, navigate }: {
|
||||
team: Team;
|
||||
user: User;
|
||||
members: TeamMember[];
|
||||
billing: BillingSummary | null;
|
||||
navigate: (page: Page) => void;
|
||||
}) {
|
||||
const [modal, setModal] = useState<"" | "invite" | "limit">("");
|
||||
const rows = members.length ? members : [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "3000.00", user } as TeamMember];
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const rows: TeamMember[] = members.length
|
||||
? members
|
||||
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
|
||||
|
||||
// 统计 · 对齐 api-bridge renderLiveTeamPayload
|
||||
const balance = Number(billing?.account.balance || 0);
|
||||
const used = Number(billing?.charged_total || 0);
|
||||
const limit = rows.reduce((sum, member) => sum + Math.max(0, Number(member.monthly_credit_limit || 0)), 0) || balance;
|
||||
const left = Math.max(0, limit - used);
|
||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
const list = rows.filter((member) => {
|
||||
const name = member.user.username || "";
|
||||
const email = member.user.email || "";
|
||||
return !needle || `${name} ${email}`.toLowerCase().includes(needle);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>团队管理</h1><div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div></div><button className="btn btn-primary" type="button" onClick={() => setModal("invite")}><UserPlus size={13} />创建账户</button></div>
|
||||
<div className="team-top"><div className="team-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="banner-head"><div className="banner-id"><div className="lbl">[ TEAM ]</div><div className="nm">{team.name} <span className="tag">企业</span></div><div className="meta">// 团队 ID: {team.id.slice(0, 8)} · {rows.length} 名成员</div></div><div className="banner-actions"><button className="btn btn-sm" type="button" onClick={() => navigate("account")}>充值</button><button className="btn btn-ghost btn-sm" type="button" onClick={() => setModal("limit")}>设置月限额</button></div></div></div></div>
|
||||
<div className="team-grid"><div className="pane" style={{ padding: 0 }}><div className="pane-table-h"><h3>成员列表 <span className="ct">// {rows.length} 人</span></h3><input className="input" placeholder="搜索姓名 / 手机号" /></div><table className="t members-table"><thead><tr><th>成员</th><th>角色</th><th>月度额度</th><th>状态</th><th /></tr></thead><tbody>{rows.map((member) => <tr key={member.id}><td><div className="proj-name-cell"><div className="av">{member.user.username.slice(0, 1).toUpperCase()}</div><div><div className="proj-name">{member.user.username}</div><div className="proj-sub">{member.user.email || "未填写邮箱"}</div></div></div></td><td><span className="pill info"><span className="dot" />{member.role}</span></td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td><td><button className="icon-btn-sm" type="button"><Settings size={13} /></button><button className="icon-btn-sm" type="button"><KeyRound size={13} /></button></td></tr>)}</tbody></table></div></div>
|
||||
<section className="team-page">
|
||||
<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" id="open-invite" onClick={() => setModal("invite")}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6M22 11h-6" /></svg>
|
||||
创建账户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 顶部行:团队 banner(左)+ 团队动态(右) */}
|
||||
<div className="team-top">
|
||||
|
||||
<div className="team-banner">
|
||||
<span className="corner-tr" aria-hidden></span><span className="corner-bl" aria-hidden></span>
|
||||
|
||||
<div className="banner-head">
|
||||
<div className="banner-id">
|
||||
<div className="lbl">[ TEAM ]</div>
|
||||
<div className="nm">{team.name} <span className="tag">企业</span></div>
|
||||
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => navigate("account")}>
|
||||
<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 className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={() => setModal("limit")}>设置月限额</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="banner-divider"></div>
|
||||
|
||||
<div className="banner-stats">
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 充值余额 ]</div>
|
||||
<div className="v">{money(balance)}</div>
|
||||
<div className="sub">// 团队总池</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 月限额 ]</div>
|
||||
<div className="v" id="stat-limit">{money(limit)}</div>
|
||||
<div className="sub">// 自然月重置</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 当月已用 ]</div>
|
||||
<div className="v" id="stat-used">{money(used)}</div>
|
||||
<div className="sub" id="stat-used-sub">// 占月限 {pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">[ 当月剩余 ]</div>
|
||||
<div className="v warn" id="stat-left">{money(left)}</div>
|
||||
<div className="sub" id="stat-left-sub">// 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 团队动态(banner 右栏)· 对齐 api-bridge renderLiveTeamActivity 的真实状态占位 */}
|
||||
<div className="team-feed">
|
||||
<div className="h">
|
||||
<h3>团队动态</h3>
|
||||
<span className="ct">// 真实动态接口待接入</span>
|
||||
<a className="more" id="open-feed-all" role="button" tabIndex={0}>全部 →</a>
|
||||
</div>
|
||||
<div className="feed-list">
|
||||
<div className="feed-item">
|
||||
<div className="av">Q</div>
|
||||
<div>
|
||||
<div className="txt"><span className="who">真实团队表</span><span className="act">已同步</span><span className="obj">{rows.length} 名成员</span></div>
|
||||
<div className="ts">local cache</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{/* /.team-top */}
|
||||
|
||||
<div className="team-grid">
|
||||
{/* 左:成员表 */}
|
||||
<div>
|
||||
<div className="pane" style={{ padding: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid var(--border-faint)" }}>
|
||||
<h3 style={{ margin: 0 }}>成员列表 <span className="ct">// {list.length} / {rows.length} 人 · 真实团队表</span></h3>
|
||||
<span className="spacer"></span>
|
||||
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
</div>
|
||||
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>角色</th>
|
||||
<th>每日额度</th>
|
||||
<th>月度额度</th>
|
||||
<th style={{ width: "140px" }}>当月已用</th>
|
||||
<th style={{ textAlign: "right", width: "88px" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="members-tbody">
|
||||
{list.map((member) => {
|
||||
const name = member.user.username || member.user.email || "成员";
|
||||
const role = roleUi(member.role);
|
||||
const monthly = Number(member.monthly_credit_limit || 0);
|
||||
const memberPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0;
|
||||
const isOwner = member.role === "owner";
|
||||
return (
|
||||
<tr key={member.id} data-id={member.id}>
|
||||
<td><span className="member-cell"><span className="av">{name.slice(0, 1).toUpperCase()}</span><span><span className="nm">{name}</span><span className="em">{member.user.email || ""}</span></span></span></td>
|
||||
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
|
||||
<td><span className="quota-cell"><span className="v">不限</span></span></td>
|
||||
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
|
||||
<td><div className="quota-cell"><span className="v">¥0.00</span> <span className="lbl">/ {memberPct.toFixed(0)}%</span></div><div className="used-bar"><span className="ok" style={{ width: `${memberPct.toFixed(0)}%` }}></span></div></td>
|
||||
<td><div className="acts">{isOwner
|
||||
? <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="重置密码"><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>
|
||||
</>}</div></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右:权限矩阵 */}
|
||||
<div>
|
||||
<div className="pane">
|
||||
<h3>角色权限</h3>
|
||||
<div style={{ fontSize: "12px", color: "var(--black-alpha-48)", marginTop: "-10px", marginBottom: "12px", fontFamily: "var(--font-mono)", letterSpacing: ".02em" }}>// PRD §10.2 权限矩阵节选</div>
|
||||
<table className="perm-table">
|
||||
<thead>
|
||||
<tr><th>能力</th><th>超管</th><th>团管</th><th>成员</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERM_ROWS.map((row) => (
|
||||
<tr key={row.cap} style={row.last ? { borderBottom: 0 } : undefined}>
|
||||
<td>{row.cap}</td>
|
||||
{row.cells.map((cell, index) => (
|
||||
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user