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:
@@ -197,7 +197,10 @@ export function App() {
|
||||
}
|
||||
|
||||
async function revokeSession(id: string) {
|
||||
await action(() => api.revokeSession(id), "设备已下线");
|
||||
// 后端旋转 token 真正吊销目标设备(单 token 体系),回传新 token 给当前设备 —— 必须存下,
|
||||
// 否则当前设备旧 token 已失效会把自己也踢下线
|
||||
const res = await action(() => api.revokeSession(id), "设备已下线");
|
||||
if (res?.token) setToken(res.token);
|
||||
setSessions(await api.loginSessions().catch(() => []));
|
||||
}
|
||||
|
||||
|
||||
@@ -649,16 +649,49 @@
|
||||
/* 胶囊在框底 → 下拉菜单向上弹,避免被框/CTA 遮住 */
|
||||
.image-workbench .iw-model-pill .chip-menu { top: auto; bottom: calc(100% + 4px); }
|
||||
|
||||
/* ── YYX#17:左侧商品栏折叠 ── */
|
||||
.image-workbench .iw-side-top .iw-side-collapse {
|
||||
margin-left: auto;
|
||||
width: 28px; height: 28px;
|
||||
display: grid; place-items: center;
|
||||
border: 1px solid var(--border-faint); border-radius: var(--r-md);
|
||||
background: var(--surface); color: var(--black-alpha-48); cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||
/* ── YYX#17:左侧商品栏折叠 —— 复用导航栏(app-shell .sidebar-toggle)同款视觉/交互 ──
|
||||
贴商品栏右边缘的竖条按钮:平时隐形(chevron opacity 0),hover 才浮现 chevron + 浅底,
|
||||
点击切换收/展。不用原来那个带边框的方块丑按钮。 */
|
||||
.image-workbench .iw-prod-space { position: relative; }
|
||||
.image-workbench .iw-side-toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--black-alpha-48);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.image-workbench .iw-side-top .iw-side-collapse:hover { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
|
||||
.image-workbench .iw-side-toggle:hover,
|
||||
.image-workbench .iw-side-toggle:focus-visible {
|
||||
background: var(--black-alpha-4);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: var(--accent-black);
|
||||
outline: none;
|
||||
}
|
||||
.image-workbench .iw-side-toggle-icon {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
line-height: 1;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.image-workbench .iw-side-toggle:hover .iw-side-toggle-icon,
|
||||
.image-workbench .iw-side-toggle:focus-visible .iw-side-toggle-icon { opacity: 1; }
|
||||
.image-workbench .iw-side-toggle-icon--expand { display: none; }
|
||||
.image-workbench .iw-prod-space.collapsed .iw-side-toggle-icon--collapse { display: none; }
|
||||
.image-workbench .iw-prod-space.collapsed .iw-side-toggle-icon--expand { display: block; }
|
||||
/* 展开态:列表/头部右侧给竖条让位(28px),避免竖条覆盖商品行右侧的点击区 */
|
||||
.image-workbench .iw-prod-space:not(.collapsed) .iw-side-top { padding-right: 30px; }
|
||||
.image-workbench .iw-prod-space:not(.collapsed) .iw-ps-list { padding-right: 28px; }
|
||||
/* 收起后整列变窄,只剩商品图 */
|
||||
.image-workbench .iw-layout:has(.iw-prod-space.collapsed) { grid-template-columns: 76px minmax(0, 1fr); }
|
||||
.image-workbench .iw-prod-space.collapsed .back-pill,
|
||||
@@ -666,7 +699,6 @@
|
||||
.image-workbench .iw-prod-space.collapsed .iw-list-h,
|
||||
.image-workbench .iw-prod-space.collapsed .iw-prod-item .body { display: none; }
|
||||
.image-workbench .iw-prod-space.collapsed .iw-side-top { justify-content: center; padding: 12px 0 10px; }
|
||||
.image-workbench .iw-prod-space.collapsed .iw-side-top .iw-side-collapse { margin-left: 0; }
|
||||
.image-workbench .iw-prod-space.collapsed .iw-ps-list { padding-left: 0; padding-right: 0; align-items: center; }
|
||||
.image-workbench .iw-prod-space.collapsed .iw-prod-item { justify-content: center; padding: 4px; }
|
||||
|
||||
@@ -851,16 +883,20 @@
|
||||
/* 不在此裁切:否则「更多」气泡(.gen-bubble)作为后代会被 overflow:hidden 切掉。
|
||||
改为给图片/占位各自圆角,卡片保持圆角的同时让气泡能溢出展示。 */
|
||||
cursor: pointer;
|
||||
/* YYX#13:容器自带铺满的 skeleton 占位底(网纹 + 灰底),图片加载前/加载中不留空白;
|
||||
真图(.gen-image-img)叠在其上,加载完盖住占位底。复用 .placeholder 同款网纹纹理。 */
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(0, 0, 0, 0.025) 0 1px, transparent 1px 12px),
|
||||
var(--black-alpha-4);
|
||||
}
|
||||
.image-workbench .gen-image .placeholder { position: absolute; inset: 0; border-radius: var(--r-md); overflow: hidden; }
|
||||
/* 生成结果真图 · 填满 .gen-image(比例由容器 aspect-ratio 控制) */
|
||||
/* 生成结果真图 · 填满 .gen-image(比例由容器 aspect-ratio 控制) · 透明背景让容器 skeleton 占位透出,加载完图片自身盖住 */
|
||||
.image-workbench .gen-image-img {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
/* 右上浮层按钮组(§4.18 .gen-image-actions) */
|
||||
.image-workbench .gen-image-actions {
|
||||
|
||||
@@ -164,7 +164,8 @@ export const api = {
|
||||
return request<LoginSession[]>("/api/auth/me/sessions/");
|
||||
},
|
||||
revokeSession(id: string) {
|
||||
return request<{ revoked: number }>(`/api/auth/me/sessions/${id}/revoke/`, { method: "POST" });
|
||||
// 后端旋转 token 真正吊销被下线设备(单 token 体系),回传新 token 给当前设备保活
|
||||
return request<{ revoked: number; token?: string }>(`/api/auth/me/sessions/${id}/revoke/`, { method: "POST" });
|
||||
},
|
||||
revokeOtherSessions() {
|
||||
return request<{ token: string }>("/api/auth/me/sessions/revoke-others/", { method: "POST" });
|
||||
@@ -175,6 +176,10 @@ export const api = {
|
||||
teamMembers() {
|
||||
return request<TeamMember[]>("/api/auth/team/members/");
|
||||
},
|
||||
// 团队级月限额持久化(超管在团队页设置 · PMC#12);monthly_credit_limit: 数值 / null(清空)
|
||||
updateTeamSettings(payload: { monthly_credit_limit?: number | string | null }) {
|
||||
return request<Team>("/api/auth/team/settings/", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
createTeamMember(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
@@ -128,7 +128,7 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "// CONFIRM", icon, onCancel, onConfirm }: {
|
||||
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "// CONFIRM", icon, onCancel, onConfirm, dismissable = true }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
@@ -137,13 +137,15 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "//
|
||||
icon?: ReactNode;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void | Promise<unknown>;
|
||||
/** 是否允许点击遮罩关闭弹窗。默认 true(保持原有行为);传 false 则点遮罩不响应。 */
|
||||
dismissable?: boolean;
|
||||
}) {
|
||||
useBodyScrollLock(open);
|
||||
const { mounted, show } = useOverlayTransition(open, onCancel);
|
||||
if (!mounted) return null;
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
return createPortal(
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={onCancel}>
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}<span>{subtitle}</span></div></div>
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: -.012em;
|
||||
color: var(--accent-black);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.msg-detail-title .meta {
|
||||
display: flex;
|
||||
@@ -317,6 +318,7 @@
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.msg-props {
|
||||
display: grid;
|
||||
@@ -338,6 +340,7 @@
|
||||
min-width: 0;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.msg-props .v a { color: var(--heat); }
|
||||
.msg-timeline {
|
||||
@@ -370,6 +373,7 @@
|
||||
color: var(--black-alpha-72);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.msg-log {
|
||||
margin-top: 14px;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;这里只按可显示的图片类型过滤。
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ export type Team = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
// 团队级月限额(自然月)· null=未设置(按成员累加) · "-1"=不限 · ">=0"=固定上限
|
||||
monthly_credit_limit?: string | null;
|
||||
};
|
||||
|
||||
export type TeamMember = {
|
||||
|
||||
Reference in New Issue
Block a user