fix: 测试清单一轮bug修复(商品库/视频项目/设置/消费/平台套图)

- 商品库编辑删图假成功+悬停无删除图标;商品三视图错显角色三视图
- 视频项目演员删除入口;角色三视图防呆;故事板审核失败原因透出
- 设置:管理团队死按钮/通知未接入项屏蔽/邮箱验证链路
- 消费:账单流水切换类型重置分页+独立总页数
- 资产库上传按钮隐藏;月限额两处对齐
- 平台套图:提示词框放大/选模型胶囊内嵌/未读任务角标/工作台记录持久化
- 新建商品独立添加卖点按钮;商品库超1920自适应

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-30 19:46:35 +08:00
co-authored by Claude Opus 4.8
parent b17a716c06
commit 22268d608b
14 changed files with 261 additions and 107 deletions
+24 -15
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { CreditCard, X } from "lucide-react";
import { createPortal } from "react-dom";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
import { money, stageMeta } from "./stage-config";
import { pageWindow } from "../components/pager";
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
@@ -110,9 +110,11 @@ function TopupModal({ open, channel, amount, bonus, close, onDone }: {
);
}
export function AccountPage({ billing, projects, onRecharge }: {
export function AccountPage({ billing, projects, team, onRecharge }: {
billing: BillingSummary | null;
projects: Project[];
// team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14)
team?: Team | null;
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
}) {
const [tab, setTab] = useState<Tab>("overview");
@@ -150,15 +152,8 @@ export function AccountPage({ billing, projects, onRecharge }: {
}).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); });
return () => { alive = false; };
}, [billPage, reloadFlag]);
const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
const safeBillPage = Math.min(billPage, billTotalPages);
// row40:跳页输入框 —— 输入页码回车/点「跳转」即钳到 [1,总页数] 并翻页
const [billJump, setBillJump] = useState("");
function gotoBillPage() {
const n = parseInt(billJump, 10);
if (!Number.isNaN(n)) setBillPage(Math.min(billTotalPages, Math.max(1, n)));
setBillJump("");
}
const selectedCard = RECHARGE.find((item) => item.amt === recharge);
const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
@@ -175,8 +170,11 @@ export function AccountPage({ billing, projects, onRecharge }: {
const balance = Number(billing?.account.balance || 0);
const used = Number(billing?.charged_total || 0);
const memberLimit = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
const limit = memberLimit || balance;
// 月限额:与团队管理页保持同一来源(team.monthly_credit_limit) · #14
// 优先用团队级月限额;-1 = 不限(显示余额);0/null = 未设置 → 用成员额度累加,再 fallback 余额
const savedMonthlyLimit = team?.monthly_credit_limit == null ? 0 : Number(team.monthly_credit_limit);
const memberLimitSum = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
const limit = savedMonthlyLimit === -1 ? balance : (savedMonthlyLimit > 0 ? savedMonthlyLimit : (memberLimitSum || balance));
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
@@ -219,7 +217,8 @@ export function AccountPage({ billing, projects, onRecharge }: {
const memFiltered = memRole !== "all";
// ── 账单流水筛选(类型 + 成员)── 后端为服务端分页且无过滤参数(api.ledgers 仅收 page/pageSize),
// 故只能对「当前页」已载入的 10 条做客户端过滤;计数标注为本页范围,避免误导成全量过滤。
// 故只能对「当前页」已载入的 10 条做客户端过滤。
// 切换筛选条件时跳回第 1 页(#9a);过滤后按实际可见行数重算总页数(#9b)。
const [billType, setBillType] = useState<string>("all");
const [billMember, setBillMember] = useState<string>("all");
const billMemberOptions = useMemo(() => {
@@ -235,6 +234,16 @@ export function AccountPage({ billing, projects, onRecharge }: {
[ledgerRows, billType, billMember]
);
const billFiltered = billType !== "all" || billMember !== "all";
// 有筛选时按当前页已过滤的可见行数重算总页数,避免总页数用全量致翻到空页(#9b)
const billTotalPages = billFiltered
? Math.max(1, Math.ceil(visibleLedgers.length / BILLS_PER_PAGE))
: Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
const safeBillPage = Math.min(billPage, billTotalPages);
function gotoBillPage() {
const n = parseInt(billJump, 10);
if (!Number.isNaN(n)) setBillPage(Math.min(billTotalPages, Math.max(1, n)));
setBillJump("");
}
return (
<section className="account-page">
@@ -398,7 +407,7 @@ export function AccountPage({ billing, projects, onRecharge }: {
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="filter-bar">
<select value={billType} onChange={(e) => setBillType(e.target.value)} aria-label="按类型筛选">
<select value={billType} onChange={(e) => { setBillType(e.target.value); setBillPage(1); }} aria-label="按类型筛选">
<option value="all">全部类型</option>
<option value="charge">扣费</option>
<option value="recharge">充值</option>
@@ -407,12 +416,12 @@ export function AccountPage({ billing, projects, onRecharge }: {
<option value="adjustment">调整</option>
<option value="refund">退款</option>
</select>
<select value={billMember} onChange={(e) => setBillMember(e.target.value)} aria-label="按成员筛选">
<select value={billMember} onChange={(e) => { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选">
<option value="all">全部成员</option>
{billMemberOptions.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
{billFiltered && (
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); }}>清除筛选</button>
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); setBillPage(1); }}>清除筛选</button>
)}
<span className="spacer"></span>
<span className="ct">本页 <b>{visibleLedgers.length}</b> 条 · 共 {ledgerCount} 条</span>
+10 -6
View File
@@ -568,10 +568,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
</button>
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setUploadOpen(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
上传资产
</button>
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
</div>
</div>
@@ -588,8 +585,15 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
) : (
packs.map((pack) => {
const first = pack.clips[0];
// 视频成品的所有片段 id:批量删除整个素材包
const packClipIds = pack.clips.map((c) => c.id).filter(Boolean);
return (
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenPack(pack); } }}>
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => !editMode && setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenPack(pack); } }}>
{editMode && onDelete && packClipIds.length > 0 && (
<button className="card-del-btn" type="button" title="删除素材包" onClick={(event) => { event.stopPropagation(); setConfirmIds(packClipIds); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb pack-thumb">
{first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
@@ -700,7 +704,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
batches.length ? (
<div className="packs-grid" id="batch-grid">
{batches.map((batch) => (
<article className="pack-card" key={batch.batch_id} onClick={() => setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<article className="pack-card" key={batch.batch_id} onClick={() => !editMode && setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenBatch(batch); } }}>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.id)); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
+65 -22
View File
@@ -852,24 +852,43 @@ export function PipelinePage(props: {
}
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
const [refGate, setRefGate] = useState<{ missing: Array<{ name: string; type: string }>; proceed: () => void } | null>(null);
// reason:"noref" = 还没参考图(立绘/场景图);"notri" = 角色有立绘但缺三视图(故事板合成需多角度参考)。
type RefMiss = { name: string; type: string; reason: "noref" | "notri" };
const [refGate, setRefGate] = useState<{ missing: RefMiss[]; proceed: () => void } | null>(null);
useBodyScrollLock(Boolean(refGate));
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): Array<{ name: string; type: string }> {
// 某角色(按名字)已采用的立绘有没有配套三视图:取该角色代表组的 adopted_asset → 查它的三视图组。
// 用与详情弹窗同款判定(triview 组有候选 / 资产 metadata 标记 / 模特库正面图)。
function personHasTriview(name: string): boolean {
const ent = buildEntities("person").find((e) => (e.name || "").trim() === (name || "").trim());
const portrait = ent?.group.adopted_asset;
if (!portrait) return false;
const tri = triGroupForAsset(portrait);
if (tri && (tri.adopted_asset || (tri.candidate_assets?.length ?? 0) > 0)) return true;
const m: Record<string, unknown> = byId.get(portrait)?.metadata || {};
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
if (m.view === "frontal") return true; // 模特库正面图与三视图同批生成
return false;
}
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): RefMiss[] {
const ents = project.metadata?.script_entities;
if (!Array.isArray(ents) || !ents.length) return []; // 没提取过实体就不拦(交给提取闸门)
const byId = new Map(ents.map((e) => [e.id, e]));
const entById = new Map(ents.map((e) => [e.id, e]));
const adopted = {
person: new Set(buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
scene: new Set(buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
};
const miss = new Map<string, { name: string; type: string }>();
const miss = new Map<string, RefMiss>();
for (const seg of segs) {
for (const rid of seg.entity_refs || []) {
const ent = byId.get(rid);
const ent = entById.get(rid);
if (!ent || ent.type === "product") continue; // 商品永远有主图,不算缺
const kind = ent.type === "character" ? "person" : "scene";
const name = (ent.name || "").trim();
if (name && !adopted[kind].has(name)) miss.set(name, { name, type: ent.type });
if (!name) continue;
if (!adopted[kind].has(name)) { miss.set(name, { name, type: ent.type, reason: "noref" }); continue; }
// 已有立绘/场景图 → 角色再查三视图:故事板 @图 合成需正/侧/背多角度参考,缺则拦(ZWQ#3 防呆)
if (kind === "person" && !personHasTriview(name)) miss.set(name, { name, type: ent.type, reason: "notri" });
}
}
return [...miss.values()];
@@ -3870,28 +3889,52 @@ export function PipelinePage(props: {
);
})()}
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
{refGate && (
{refGate && (() => {
// 拆两类:noref=连参考图都没有;notri=有立绘但缺三视图。文案分别告诉用户「去哪里点什么」。
const noRef = refGate.missing.filter((m) => m.reason === "noref");
const noTri = refGate.missing.filter((m) => m.reason === "notri");
return (
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">参考图还没齐,先补一下?</div>
<div className="rg-body">
下面这些角色 / 场景<strong>还没生成参考图</strong>。现在直接生成,出来的画面会跟你设定的对不上(像纯文生图、白花钱):
</div>
<div className="rg-list">
{refGate.missing.map((m) => (
<span className="rg-chip" key={`${m.type}:${m.name}`}>
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
{m.name}
</span>
))}
</div>
<div className="rg-title">资产还没齐,先补一下?</div>
{noRef.length > 0 && (
<>
<div className="rg-body">
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则故事板会变成纯文生图、跟你的设定对不上(白花钱):
</div>
<div className="rg-list">
{noRef.map((m) => (
<span className="rg-chip" key={`noref:${m.type}:${m.name}`}>
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
{m.name}
</span>
))}
</div>
</>
)}
{noTri.length > 0 && (
<>
<div className="rg-body" style={{ marginTop: noRef.length > 0 ? 14 : 0 }}>
下面这些角色已有立绘,但<strong>还没生成三视图</strong>。故事板 @图 合成要靠正 / 侧 / 背多角度锁人物,缺三视图角色容易跑形。请回「基础资产」页,点角色卡进详情后点<strong>「AI 生成三视图」</strong>:
</div>
<div className="rg-list">
{noTri.map((m) => (
<span className="rg-chip" key={`notri:${m.type}:${m.name}`}>
<span className="rg-kind rg-kind-person">角色</span>
{m.name} <span className="rg-warn">缺三视图</span>
</span>
))}
</div>
</>
)}
<div className="rg-actions">
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去补齐参考图</button>
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>仍要生成 <span className="rg-warn">可能跟角色对不上</span></button>
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去基础资产页补齐</button>
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>仍要继续 <span className="rg-warn">可能跟角色对不上</span></button>
</div>
</div>
</div>
)}
);
})()}
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
{reviewGate && (
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
+12 -13
View File
@@ -798,11 +798,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
}, [productImageKeys]);
const visibleProductImages = productImages.filter((im) => !deletedImageKeys.has(im.key));
// 可删的图(有 ProductImage 关联)数量:只剩一张时锁住最后一张,不允许删到没图
// 用 productImages(真实数量)而非 visibleProductImages(乐观隐藏后数量):
// 乐观隐藏一张不应影响其他图的 canDelete 判断,否则乐观隐藏 → deletableImageCount 减少 →
// 其余图的 canDelete 变 false → hover 删除图标全部消失(Bug#1b 根因)。
const deletableImageCount = productImages.filter((im) => im.imageId).length;
// AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
@@ -1040,27 +1035,31 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div>
<div className="grid" id="ov-images-grid">
{visibleProductImages.map((image) => {
// 商品至少保留一张图:只剩一张可删图时,那张锁住不可删(要移除请删整个商品)
const canDelete = editing && Boolean(image.imageId) && deletableImageCount > 1;
const lastLocked = editing && Boolean(image.imageId) && deletableImageCount <= 1;
// 编辑态下每张有 ProductImage 关联的图都给删除按钮(hover 出垃圾桶),不再因
// 「只剩一张可删」而整张隐藏删除入口 —— 否则单图商品 / 删到最后一张时悬停看不到垃圾桶,
// 体感像「删除坏了,只能放大」。最后一张的「至少保留一张」约束由后端兜底(删时退 400),
// 这里不再前置静默禁用,改成点了真调接口、失败回滚 + 弹后端原文(如「至少保留一张图」)。
const canDelete = editing && Boolean(image.imageId) && Boolean(onDeleteImage);
const handleDelete = async (event: { stopPropagation: () => void }) => {
event.stopPropagation();
if (!canDelete || !image.imageId || !onDeleteImage) return;
// 乐观隐藏:立刻从本地列表移除,toast/loadData 并行回填
// 乐观隐藏:立刻从本地列表移除;真正持久化由后端 DELETE 完成,
// 成功后父组件刷新会把该图从 product.images 抹掉(reconcile effect 同步清理乐观标记)。
setDeletedImageKeys((prev) => new Set([...prev, image.key]));
const result = await onDeleteImage(image.imageId);
if (result === null) {
// action 返回 null 表示调用失败(会弹错误 toast),回滚乐观隐藏
if (result === null || result === undefined) {
// action 返回 null/undefined 表示接口调用失败(已弹错误 toast,如「至少保留一张图」),
// 回滚乐观隐藏 —— 图重新出现,不会出现「弹了删除成功但其实没删」的错觉。
setDeletedImageKeys((prev) => { const next = new Set(prev); next.delete(image.key); return next; });
}
};
return (
<div
className={`thumb placeholder${editing ? " is-edit" : ""}${canDelete ? " is-del" : ""}${lastLocked ? " is-locked" : ""}`}
className={`thumb placeholder${editing ? " is-edit" : ""}${canDelete ? " is-del" : ""}`}
key={image.key}
role={image.url ? "button" : undefined}
tabIndex={image.url ? 0 : undefined}
title={lastLocked ? "商品至少保留一张图;要移除请删除整个商品" : image.url ? "点击放大" : undefined}
title={image.url ? "点击放大" : undefined}
style={{ cursor: image.url ? "zoom-in" : "default" }}
onClick={() => { if (image.url) setPreview({ src: image.url, name: realName }); }}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (image.url) setPreview({ src: image.url, name: realName }); } }}
+22 -10
View File
@@ -54,11 +54,12 @@ function deviceName(ua: string): string {
return `${os} · ${browser}`;
}
// 仅保留已真实接入「站内通知」的行;邮箱/短信/异地登录告警渠道未接入,隐藏对应行。
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" },
{ key: "n-fail", title: "任务失败告警", channels: "站内 · 邮件" },
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内 · 短信" },
{ key: "n-login", title: "异地登录告警", channels: "短信" },
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内" },
{ key: "n-fail", title: "任务失败告警", channels: "站内" },
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内" },
// n-login(异地登录告警·短信渠道)未接入,已移除。
];
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
@@ -69,7 +70,7 @@ const DEFAULT_PREFS = {
bgm: "kapian",
transition: "fade",
twoFactor: false,
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true } as Record<string, boolean>,
notify: { "n-export": true, "n-fail": true, "n-quota": true } as Record<string, boolean>,
appearance: "system",
language: "zh",
density: "standard",
@@ -428,7 +429,7 @@ export function SettingsPage({
{section === "profile" && (
<section className="pane" aria-label="个人信息">
<h3>个人信息</h3>
<div className="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
<div className="pane-desc">// 头像、姓名、联系方式</div>
<div className="form-row">
<div className="lbl">头像</div>
@@ -447,10 +448,11 @@ export function SettingsPage({
<div className="val"><input className="input" value={name} onChange={(event) => patchDraft("name", event.target.value)} /></div>
</div>
<div className="form-row">
<div className="lbl">登录邮箱</div>
<div className="lbl">登录邮箱<div className="lbl-sub">// 仅做记录用</div></div>
<div className="val">
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", event.target.value)} />
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onNotify?.(email ? `已向 ${email} 发送验证邮件` : "请先填写邮箱")}>验证</button>
{/* 邮件服务未接入,验证功能暂不可用,入口已隐藏 */}
<span className="switch-note">// 邮件验证未启用</span>
</div>
</div>
<div className="form-row">
@@ -465,7 +467,17 @@ export function SettingsPage({
<div className="val">
<span className="static">{team.name}</span>
<span className="role-tag"><span className="dot" />超管 · 创建者</span>
<a href="#team" className="row-link">管理团队 →</a>
<a
href="/team"
className="row-link"
onClick={(event) => {
event.preventDefault();
window.history.pushState(null, "", "/team");
window.dispatchEvent(new PopStateEvent("popstate"));
}}
>
管理团队 →
</a>
</div>
</div>
<div className="form-row">
@@ -535,7 +547,7 @@ export function SettingsPage({
{section === "notify" && (
<section className="pane" aria-label="通知">
<h3>通知</h3>
<div className="pane-desc">// 邮件、短信、站内提示开关</div>
<div className="pane-desc">// 站内通知开关 · 邮件/短信渠道未接入</div>
{NOTIFY_ROWS.map((row) => (
<div className="form-row" key={row.key}>
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>