fix: 测试清单一轮bug修复(商品库/视频项目/设置/消费/平台套图)
- 商品库编辑删图假成功+悬停无删除图标;商品三视图错显角色三视图 - 视频项目演员删除入口;角色三视图防呆;故事板审核失败原因透出 - 设置:管理团队死按钮/通知未接入项屏蔽/邮箱验证链路 - 消费:账单流水切换类型重置分页+独立总页数 - 资产库上传按钮隐藏;月限额两处对齐 - 平台套图:提示词框放大/选模型胶囊内嵌/未读任务角标/工作台记录持久化 - 新建商品独立添加卖点按钮;商品库超1920自适应 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 }); } }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user