fix(test-round): 测试清单一批 bug 修复 + 团队月限额持久化

后端:
- accounts: 团队级月限额持久化(Team.monthly_credit_limit 三态 + PATCH/GET
  /api/auth/team/settings/,刷新不丢,PMC#12);头像上传 500→502 可读错误;
  设备下线真失效(删并重建 token,旧 token 401)
- assets: 审核类目加 model_portrait,消除三视图"无需审核"误报
- ai/projects/products: 模特已有三视图复用、产品三视图同步回商品库
  (metadata.view=three_view)、真人模特三视图回写

前端:
- 商品图删除判断改用真实图片数;团队成员弹窗禁点外部关闭
- 图片预览骨架铺满占位;平台套图左侧栏折叠改导航同款;收件箱长文换行对齐
- 团队月限额改真落库(乐观更新+失败回滚)

测试:新增 TeamSettingsTests(5 条),accounts 全套 36 tests 通过;前端 build 0 error

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-29 18:50:22 +08:00
co-authored by Claude Opus 4.8
parent ca0c3e2300
commit b0b3ac2348
20 changed files with 476 additions and 57 deletions
+15 -6
View File
@@ -7,14 +7,14 @@ import {
Bookmark,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Download,
Grid2X2,
ImagePlus,
LayoutGrid,
List,
MoreHorizontal,
PanelLeftClose,
PanelLeftOpen,
Pencil,
Plus,
Quote,
@@ -1683,15 +1683,24 @@ export function ImageWorkbenchPage({
<div className="iw-layout">
{/* 最左 · 商品空间(YYX#17:可折叠 → 收起后只剩商品图) */}
<aside className={`iw-prod-space${sideCollapsed ? " collapsed" : ""}`}>
{/* YYX#17:折叠/展开左侧商品栏 —— 复用导航栏(app-shell .sidebar-toggle)同款交互:
贴右边缘的竖条按钮,hover 才显 chevron,点击切换;收起后只剩商品图。 */}
<button
className="iw-side-toggle"
type="button"
aria-pressed={sideCollapsed}
title={sideCollapsed ? "展开商品栏" : "收起商品栏"}
aria-label={sideCollapsed ? "展开商品栏" : "收起商品栏"}
onClick={() => setSideCollapsed((v) => !v)}
>
<span className="iw-side-toggle-icon iw-side-toggle-icon--collapse"><ChevronLeft size={18} strokeWidth={1.8} /></span>
<span className="iw-side-toggle-icon iw-side-toggle-icon--expand"><ChevronRight size={18} strokeWidth={1.8} /></span>
</button>
<div className="iw-side-top">
<button className="back-pill" type="button" onClick={onBack}>
<ArrowLeft size={14} />
</button>
{/* YYX#17:折叠/展开左侧商品栏 */}
<button className="iw-side-collapse" type="button" title={sideCollapsed ? "展开商品栏" : "收起商品栏"} aria-label={sideCollapsed ? "展开商品栏" : "收起商品栏"} onClick={() => setSideCollapsed((v) => !v)}>
{sideCollapsed ? <PanelLeftOpen size={15} /> : <PanelLeftClose size={15} />}
</button>
</div>
<div className="iw-ps-search">
<Search size={13} />
+4 -1
View File
@@ -787,7 +787,10 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const visibleProductImages = productImages.filter((im) => !deletedImageKeys.has(im.key));
// 可删的图(有 ProductImage 关联)数量:只剩一张时锁住最后一张,不允许删到没图
const deletableImageCount = visibleProductImages.filter((im) => im.imageId).length;
// 用 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;这里只按可显示的图片类型过滤。
+37 -4
View File
@@ -136,8 +136,34 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
// 团队充值
const [rechargeAmt, setRechargeAmt] = useState("500");
// 设置月限额
// 设置月限额:savedMonthlyLimit 持久化已保存值(-1=不限,0=未设置/跟成员累加),limitVal 为弹窗编辑中间态
// 初值取后端团队设置(team.monthly_credit_limit:null=未设置→0),刷新后不再丢(PMC#12)
const [savedMonthlyLimit, setSavedMonthlyLimit] = useState(
team.monthly_credit_limit == null ? 0 : Number(team.monthly_credit_limit),
);
const [limitVal, setLimitVal] = useState("3000");
const [limitBusy, setLimitBusy] = useState(false);
// team prop 异步刷新(/me/ 回来)后同步已保存月限额
useEffect(() => {
setSavedMonthlyLimit(team.monthly_credit_limit == null ? 0 : Number(team.monthly_credit_limit));
}, [team.monthly_credit_limit]);
// 保存团队月限额:乐观更新 + 落库;失败回滚到原值(PMC#12)
async function saveLimit() {
const v = limitVal.trim() === "" ? -1 : Number(limitVal);
if (Number.isNaN(v)) return;
const prev = savedMonthlyLimit;
setSavedMonthlyLimit(v);
setLimitBusy(true);
try {
await api.updateTeamSettings({ monthly_credit_limit: v });
setModal("");
} catch {
setSavedMonthlyLimit(prev); // 落库失败回滚,避免显示假成功
} finally {
setLimitBusy(false);
}
}
// 分享凭据弹窗(创建成功 / 重置成功后弹):captured creds(仅展示一次)
const [share, setShare] = useState<{ mode: "create" | "reset"; name: string; username: string; password: string } | null>(null);
@@ -157,7 +183,9 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
const balance = Number(billing?.account.balance || 0);
const used = Number(billing?.charged_total || 0);
const teamLimit = Number(limitVal) >= 0 ? Number(limitVal) : -1;
const limit = rows.reduce((sum, member) => sum + Math.max(0, Number(member.monthly_credit_limit || 0)), 0) || balance;
// limit:优先用已保存的团队月限额(savedMonthlyLimit !== 0);-1 表示不限(显示余额);未设置则用成员额度累加
const memberLimitSum = rows.reduce((sum, member) => sum + Math.max(0, Number(member.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;
// 月限额弹窗实时剩余(按弹窗内输入值预演)
@@ -199,7 +227,9 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
}
function openLimit() {
setLimitVal(limit > 0 ? String(Math.round(limit)) : "3000");
// 打开时回填已保存的团队月限额;若未设置则 fallback 成员累加值或默认 3000
const current = savedMonthlyLimit !== 0 ? savedMonthlyLimit : (limit > 0 ? Math.round(limit) : 3000);
setLimitVal(String(current));
setModal("limit");
}
@@ -518,7 +548,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={() => setModal("")}></button>}
footer={<button className="btn btn-primary" type="button" disabled={limitBusy} onClick={saveLimit}>{limitBusy ? "保存中…" : "保存"}</button>}
>
<div className="limit-modal">
<div className="field">
@@ -662,6 +692,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
icon={<UserPlus size={16} />}
close={() => setEditTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}></button>}
dismissable={false}
>
<div className="edit-member-modal">
<div className="field">
@@ -702,6 +733,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
icon={<KeyRound size={16} />}
close={() => setResetTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitReset}></button>}
dismissable={false}
>
<div className="reset-pwd-modal">
<div className="reset-pwd-warn">
@@ -726,6 +758,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
confirmText="移除"
onCancel={() => setRemoveTarget(null)}
onConfirm={submitRemove}
dismissable={false}
/>
</section>
);