Merge remote-tracking branch 'origin/pr-574' into codex/pr-557-589-security-integration

# Conflicts:
#	docs/content/docs/progress/pending-test.mdx
This commit is contained in:
ddCat
2026-09-22 11:05:07 +08:00
6 changed files with 51 additions and 8 deletions

View File

@@ -138,7 +138,7 @@ description: 当前项目已实现的核心工作台能力与使用入口
系统渠道模型列表按规格显示成本价 / 销售价及利润率;文本输入、输出、缓存 Token 分开呈现。勾选多个模型可统一调价:弹窗一行一个模型,展示模型名、渠道展示名(上游 ID)、规格、成本与可编辑售价,多规格在模型行内纵向对齐。输入目标利润率并选择保留 0–6 位小数(默认 2 位),按 `成本 / (1 − 利润率)` 四舍五入实时计算;也可点击「重新计算」覆盖手动值,再逐项修改。保存最终输入售价,服务端校验价格与版本并原子保存,不改变成本或启停状态。
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
后台系统渠道通过“设置排序”、模型管理通过“自定义排序”打开完整列表。支持搜索选择条目后输入目标位置,也可拖动、上移和下移;列表显示从 1 开始的位置,点击“保存排序”统一生效,取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。

View File

@@ -44,6 +44,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- [ ] 在真实素材页分别验证单个彻底删除、选择 100 条后批量删除、清空回收站及过期自动清理;网络面板应显示单个批量 POST,body 为数组。确认后台清理完成后空间释放、原画布或任务中的旧媒体引用按预期失效、其他素材复用的文件仍可访问;失败时不出现部分删除。
- [ ] 在真实画布快速连续选择两个不同工具,并覆盖相同数字 ID、中文标签、同类型替换和多个运镜工具,确认两个标签均正常显示、保存刷新后不乱码。
## 系统模型自定义排序
- 模型管理入口明确为“自定义排序”,支持搜索选择模型并输入目标位置,保留拖动和上下移动;位置从 1 开始,保存整份顺序,取消放弃草稿。复用已有管理员排序接口、事务与并发冲突检查,前台系统模型选择器沿用保存后的顺序。
- [x] 前端 8 项排序 / 管理页面专项、类型检查、生产构建与 UI lint 通过;后端现有排序事务、权限、过期快照和跨渠道检查专项通过。前端已部署,入口与排序脚本同构建一致,API 就绪;构建仍提示部分 chunk 大于 500 kB。静态资源核验不替代浏览器交互验收。
- [ ] 真实浏览器验证跨分页移动、停用模型排序、取消不保存、刷新恢复、前台模型顺序,以及其他管理员同时修改时的冲突提示。
## 画布 Agent 配置与 Live2D
- [ ] 使用约 50 MiB 的真实模型 ZIP 验收上传和预览;ZIP 上限 128 MiB,解压累计 384 MiB、单文件 128 MiB,超限须拒绝。

View File

@@ -267,7 +267,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
<h3 className="admin-channel-model-heading">
模型管理 <span className="admin-channel-count">{items.length}</span>
</h3>
<p className="admin-channel-model-hint">规格展示成本价 / 销售价及利润率,勾选模型可统一调价</p>
<p className="admin-channel-model-hint">点击“自定义排序”调整模型展示顺序;规格展示成本价 / 销售价及利润率,勾选模型可统一调价</p>
</div>
<Space wrap>
<ChannelOrderDialog

View File

@@ -1,4 +1,4 @@
import { App, Button, Modal, Spin } from "antd";
import { App, Button, InputNumber, Modal, Select, Spin } from "antd";
import { ArrowDown, ArrowUp, GripVertical, ListOrdered } from "lucide-react";
import { useRef, useState } from "react";
import { AdminEmpty } from "@/pages/admin/components/admin-ui";
@@ -6,7 +6,7 @@ import { getChannelOrder, saveChannelOrder, type ChannelOrderItem } from "@/serv
export function moveOrderItem<T extends { id: string }>(items: T[], id: string, target: number): T[] {
const from = items.findIndex((item) => item.id === id);
if (from < 0 || target < 0 || target >= items.length || from === target) return items;
if (from < 0 || !Number.isInteger(target) || target < 0 || target >= items.length || from === target) return items;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(target, 0, item!);
@@ -20,6 +20,8 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string;
[saving, setSaving] = useState(false);
const [items, setItems] = useState<ChannelOrderItem[]>([]),
[original, setOriginal] = useState<string[]>([]);
const [targetId, setTargetId] = useState<string>();
const [targetPosition, setTargetPosition] = useState<number | null>(null);
const dragged = useRef<string | null>(null),
pending = useRef(false);
const changed = items.some((item, index) => item.id !== original[index]);
@@ -28,6 +30,8 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string;
setLoading(true);
setItems([]);
setOriginal([]);
setTargetId(undefined);
setTargetPosition(null);
try {
const result = await getChannelOrder(channelId);
setItems(result.items);
@@ -50,8 +54,12 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string;
original,
);
setOpen(false);
await onSaved();
message.success("排序已保存,用户端按此顺序展示");
try {
await onSaved();
} catch (error) {
message.warning(error instanceof Error ? `排序已保存,但列表刷新失败:${error.message}` : "排序已保存,请刷新页面查看");
}
} catch (error) {
message.error(error instanceof Error ? error.message : "保存排序失败");
} finally {
@@ -62,10 +70,10 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string;
return (
<>
<Button icon={<ListOrdered className="size-4" />} onClick={() => void start()}>
设置排序
{channelId ? "自定义排序" : "设置排序"}
</Button>
<Modal
title={channelId ? "调整模型顺序" : "调整渠道顺序"}
title={channelId ? "自定义模型排序" : "调整渠道顺序"}
open={open}
onCancel={() => setOpen(false)}
closable={!saving && !loading}
@@ -83,7 +91,31 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string;
</div>
}
>
<p className="mb-4 text-sm text-foreground/60">拖动条目或点击上下箭头调整顺序,保存后用户端按此顺序展示。这里包含全部条目,不受列表筛选和分页影响。</p>
<p className="mb-4 text-sm text-foreground/60">选择条目并输入目标位置,或拖动、点击上下箭头调整顺序。点击“保存排序”后生效,取消会放弃本次调整。这里包含全部条目,不受列表筛选和分页影响。</p>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Select
className="min-w-40 flex-1"
aria-label={channelId ? "选择要排序的模型" : "选择要排序的渠道"}
placeholder={channelId ? "搜索并选择模型" : "搜索并选择渠道"}
showSearch={{ optionFilterProp: "label" }}
value={targetId}
disabled={loading || saving || !items.length}
options={items.map((item, index) => ({ value: item.id, label: `${index + 1}. ${item.name}${item.enabled ? "" : "(已停用)"}` }))}
onChange={(id: string) => {
setTargetId(id);
setTargetPosition(items.findIndex((item) => item.id === id) + 1);
}}
/>
<InputNumber aria-label="目标位置" placeholder="位置" min={1} max={items.length || 1} precision={0} value={targetPosition} disabled={loading || saving || !targetId} onChange={setTargetPosition} />
<Button
disabled={loading || saving || !targetId || targetPosition === null || !Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > items.length}
onClick={() => {
if (targetId && targetPosition !== null) setItems((current) => moveOrderItem(current, targetId, targetPosition - 1));
}}
>
移动到此位置
</Button>
</div>
<Spin spinning={loading}>
<div className="max-h-[55vh] overflow-y-auto space-y-2" role="list" aria-label="展示顺序">
{!loading && !items.length ? (
@@ -113,6 +145,7 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string;
className="flex items-center gap-3 rounded-lg border border-border bg-background p-3"
>
<GripVertical aria-hidden className="size-4 shrink-0 cursor-grab text-foreground/40" />
<span className="shrink-0 text-xs tabular-nums text-foreground/50">{index + 1}</span>
<span className="min-w-0 flex-1 break-words">
{item.name}
{!item.enabled && <span className="ml-2 text-xs text-foreground/45">已停用</span>}

View File

@@ -48,6 +48,7 @@ test("real model manager renders inline without a second page or back navigation
expect(html).toContain("模型管理");
expect(html).toContain("拉取模型");
expect(html).toContain("新增模型");
expect(html).toContain("自定义排序");
expect(html).toContain("正在加载表格");
expect(html).not.toContain("admin-page-root");
expect(html).not.toContain("返回系统渠道");

View File

@@ -51,5 +51,8 @@ test("sorting exposes a simple settings entry and moves items without changing t
expect(moveOrderItem(rows, "c", 0).map((item) => item.id)).toEqual(["c", "a", "b"]);
expect(moveOrderItem(rows, "a", 2).map((item) => item.id)).toEqual(["b", "c", "a"]);
expect(moveOrderItem(rows, "a", -1)).toBe(rows);
expect(moveOrderItem(rows, "a", 1.5)).toBe(rows);
expect(moveOrderItem(rows, "a", Number.NaN)).toBe(rows);
expect(moveOrderItem(rows, "a", rows.length)).toBe(rows);
expect(rows.map((item) => item.id)).toEqual(["a", "b", "c"]);
});