feat(admin): Phase 4 质量词 Admin(平台单层) — QualityWord + 生成侧读配置(无配置回落) + 质量词页
后端:apps/ai QualityWord(stage/slot/text/sort/enabled)+ migration;services.py quality_words/quality_suffix, 4 个 builder 接入(person/model_tryon/storyboard/video)无配置回落原写死值零回归;adminpanel CRUD + 审计。 前端:adminApi quality 系列;Admin 质量词页(按 stage 分组卡 + 编辑模式开关 + 行内增改删 chip)。 测试:adminpanel 23 + accounts 22 = 45 单测过(CRUD/权限/生成读配置+回落/停用词不取); 无头 e2e _admin-p4.mjs 4 断言过 + 0 console error + live 库零残留;tsc+build 绿。 注:apps.ai 3 个 image_edit 路由测试失败为进场前既有(已 stash 比对 HEAD 同样失败),非本 Phase 引入。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6560633959
commit
01d4f235ed
@@ -144,3 +144,54 @@
|
||||
.admin-detail-meta .v { font-size: 14px; color: var(--accent-black); }
|
||||
.admin-detail-subhead { font-size: 13px; font-weight: 500; color: var(--accent-black); margin: 8px 0 10px; }
|
||||
.admin-inline-pill { margin-left: 8px; }
|
||||
|
||||
/* ── 质量词:按 stage 分组 + 编辑模式 chip ── */
|
||||
.qw-stages { display: flex; flex-direction: column; gap: 16px; }
|
||||
.qw-stage { padding: 18px 20px; }
|
||||
.qw-stage-h { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
|
||||
.qw-stage-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }
|
||||
.qw-stage-hint { font-size: 11px; color: var(--black-alpha-48); }
|
||||
.qw-empty { font-size: 12px; color: var(--black-alpha-48); }
|
||||
.qw-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.qw-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
background: var(--heat-12);
|
||||
border: 1px solid var(--heat-20);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12.5px;
|
||||
color: var(--heat);
|
||||
}
|
||||
.qw-chip.editing { background: var(--surface); border-color: var(--border-faint); padding: 0 6px 0 10px; }
|
||||
.qw-chip-input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
color: var(--accent-black);
|
||||
width: 110px;
|
||||
height: 26px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.qw-add .qw-chip-input {
|
||||
width: 150px;
|
||||
border: 1px dashed var(--black-alpha-24);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 0 12px;
|
||||
height: 28px;
|
||||
}
|
||||
.qw-del {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--black-alpha-48);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.qw-del:hover { color: var(--accent-crimson); background: var(--black-alpha-4); }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AdminQualityWord,
|
||||
AdminTeam,
|
||||
AdminTeamDetail,
|
||||
AdminUser,
|
||||
@@ -564,5 +565,17 @@ export const adminApi = {
|
||||
},
|
||||
resetUserPassword(id: string, password: string) {
|
||||
return request<void>(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) });
|
||||
},
|
||||
qualityWords() {
|
||||
return request<AdminQualityWord[]>("/api/admin/quality-words/");
|
||||
},
|
||||
createQualityWord(payload: { stage: string; text: string; sort?: number; slot?: string }) {
|
||||
return request<AdminQualityWord>("/api/admin/quality-words/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
updateQualityWord(id: string, payload: { text?: string; sort?: number; enabled?: boolean }) {
|
||||
return request<AdminQualityWord>(`/api/admin/quality-words/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
deleteQualityWord(id: string) {
|
||||
return request<void>(`/api/admin/quality-words/${id}/`, { method: "DELETE" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell"
|
||||
import type { Team, User } from "../../types";
|
||||
import type { NavigateFn } from "../route-config";
|
||||
import { AdminInvitesPage } from "./admin-invites";
|
||||
import { AdminQualityPage } from "./admin-quality";
|
||||
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
|
||||
|
||||
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -174,6 +175,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
|
||||
if (section.slug === "users") {
|
||||
return <AdminUsersPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "quality") {
|
||||
return <AdminQualityPage notify={notify} />;
|
||||
}
|
||||
// 其余模块在各自阶段替换此占位为真实页面
|
||||
return <AdminPlaceholder section={section} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import type { AdminQualityWord } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
|
||||
// 与后端 QualityWord.Stage 对齐(生成侧已接入这 4 个阶段)
|
||||
const STAGES = [
|
||||
{ key: "person", label: "人物立绘" },
|
||||
{ key: "model_tryon", label: "模特上身图" },
|
||||
{ key: "storyboard", label: "故事板" },
|
||||
{ key: "video", label: "视频" }
|
||||
];
|
||||
|
||||
export function AdminQualityPage({ notify }: { notify: Notify }) {
|
||||
const [words, setWords] = useState<AdminQualityWord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [adding, setAdding] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setWords(await adminApi.qualityWords());
|
||||
} catch {
|
||||
notify("error", "加载质量词失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function add(stage: string) {
|
||||
const text = (adding[stage] || "").trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
await adminApi.createQualityWord({ stage, text });
|
||||
setAdding((a) => ({ ...a, [stage]: "" }));
|
||||
notify("success", "已添加质量词");
|
||||
await load();
|
||||
} catch {
|
||||
notify("error", "添加失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveText(w: AdminQualityWord, text: string) {
|
||||
const t = text.trim();
|
||||
if (!t || t === w.text) return;
|
||||
try {
|
||||
await adminApi.updateQualityWord(w.id, { text: t });
|
||||
notify("success", "已更新");
|
||||
await load();
|
||||
} catch {
|
||||
notify("error", "更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function del(w: AdminQualityWord) {
|
||||
try {
|
||||
await adminApi.deleteQualityWord(w.id);
|
||||
notify("success", "已删除");
|
||||
await load();
|
||||
} catch {
|
||||
notify("error", "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>质量词</h1>
|
||||
<div className="sub"><span className="mono">// 平台单层</span> · 各生成阶段的画质 / 风格词,留空则用默认</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className={`btn${editMode ? " btn-primary" : ""}`} type="button" onClick={() => setEditMode((v) => !v)}>
|
||||
{editMode ? "完成编辑" : "编辑模式"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3>加载中…</h3><p>// fetching quality words</p></div>
|
||||
) : (
|
||||
<div className="qw-stages">
|
||||
{STAGES.map((s) => {
|
||||
const ws = words.filter((w) => w.stage === s.key);
|
||||
return (
|
||||
<div key={s.key} className="card-hard qw-stage">
|
||||
<div className="qw-stage-h">
|
||||
<span className="qw-stage-title">{s.label}</span>
|
||||
<span className="mono qw-stage-hint">// {s.key}</span>
|
||||
</div>
|
||||
{ws.length === 0 && !editMode && <div className="qw-empty mono">未配置 · 使用默认质量词</div>}
|
||||
<div className="qw-chips">
|
||||
{ws.map((w) =>
|
||||
editMode ? (
|
||||
<span key={w.id} className="qw-chip editing">
|
||||
<input
|
||||
className="qw-chip-input"
|
||||
defaultValue={w.text}
|
||||
onBlur={(e) => saveText(w, e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
|
||||
/>
|
||||
<button className="qw-del" type="button" aria-label="删除" onClick={() => del(w)}><X size={12} /></button>
|
||||
</span>
|
||||
) : (
|
||||
<span key={w.id} className="qw-chip">{w.text}</span>
|
||||
)
|
||||
)}
|
||||
{editMode && (
|
||||
<span className="qw-add">
|
||||
<input
|
||||
className="qw-chip-input"
|
||||
placeholder="+ 添加质量词"
|
||||
value={adding[s.key] || ""}
|
||||
onChange={(e) => setAdding((a) => ({ ...a, [s.key]: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void add(s.key); } }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,15 @@ export type AdminUser = {
|
||||
date_joined: string;
|
||||
teams: { team_id: string; team_name: string; role: string }[];
|
||||
};
|
||||
export type AdminQualityWord = {
|
||||
id: string;
|
||||
stage: string;
|
||||
slot: string;
|
||||
text: string;
|
||||
sort: number;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
count: number;
|
||||
|
||||
Reference in New Issue
Block a user