feat(channels): 渠道管理 - 可视化排序与别名并修复注册及外观清理 (#444)

This commit is contained in:
Ray
2026-09-08 20:56:42 +08:00
committed by GitHub
parent 381edf37e3
commit 8360b2c933
41 changed files with 1485 additions and 148 deletions

View File

@@ -12,6 +12,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
import { ChannelModelEditor } from "./channel-model-editor";
import { AdminPageFrame } from "./admin-shell";
import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
import { ChannelOrderDialog } from "./channel-order-dialog";
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
const { message, modal } = App.useApp();
@@ -252,9 +253,17 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
return (
<AdminPageFrame
title={`${channel.name} / 模型管理`}
description="模型按用户端展示顺序排列,点击“设置排序”即可调整。"
back={{ label: "返回系统渠道", onClick: onClose }}
actions={
<Space wrap>
<ChannelOrderDialog
channelId={channel.id}
onSaved={async () => {
await reload();
await onChanged();
}}
/>
<Button loading={fetching} icon={<RefreshCw className="size-4" />} onClick={() => void fetchModels()}>
拉取模型
</Button>
@@ -372,7 +381,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
columns,
dataSource: pagedItems,
pagination: false,
scroll: { x: 990 },
scroll: { x: 1150 },
}}
footer={
<PaginationBar

View File

@@ -0,0 +1,135 @@
import { App, Button, Empty, Modal, Spin } from "antd";
import { ArrowDown, ArrowUp, GripVertical, ListOrdered } from "lucide-react";
import { useRef, useState } from "react";
import { getChannelOrder, saveChannelOrder, type ChannelOrderItem } from "@/services/api/channel-order";
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;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(target, 0, item!);
return next;
}
export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; onSaved: () => Promise<void> }) {
const { message } = App.useApp();
const [open, setOpen] = useState(false),
[loading, setLoading] = useState(false),
[saving, setSaving] = useState(false);
const [items, setItems] = useState<ChannelOrderItem[]>([]),
[original, setOriginal] = useState<string[]>([]);
const dragged = useRef<string | null>(null),
pending = useRef(false);
const changed = items.some((item, index) => item.id !== original[index]);
const start = async () => {
setOpen(true);
setLoading(true);
setItems([]);
setOriginal([]);
try {
const result = await getChannelOrder(channelId);
setItems(result.items);
setOriginal(result.items.map((item) => item.id));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取排序失败");
setOpen(false);
} finally {
setLoading(false);
}
};
const save = async () => {
if (pending.current || !changed) return;
pending.current = true;
setSaving(true);
try {
await saveChannelOrder(
channelId,
items.map((item) => item.id),
original,
);
setOpen(false);
await onSaved();
message.success("排序已保存,用户端按此顺序展示");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存排序失败");
} finally {
pending.current = false;
setSaving(false);
}
};
return (
<>
<Button icon={<ListOrdered className="size-4" />} onClick={() => void start()}>
设置排序
</Button>
<Modal
title={channelId ? "调整模型顺序" : "调整渠道顺序"}
open={open}
onCancel={() => setOpen(false)}
closable={!saving && !loading}
mask={{ closable: !saving && !loading }}
keyboard={!saving && !loading}
width={600}
footer={
<div className="flex justify-end gap-2">
<Button disabled={saving || loading} onClick={() => setOpen(false)}>
取消
</Button>
<Button type="primary" loading={saving} disabled={!changed || loading} onClick={() => void save()}>
保存排序
</Button>
</div>
}
>
<p className="mb-4 text-sm text-foreground/60">拖动条目或点击上下箭头调整顺序,保存后用户端按此顺序展示。这里包含全部条目,不受列表筛选和分页影响。</p>
<Spin spinning={loading}>
<div className="max-h-[55vh] overflow-y-auto space-y-2" role="list" aria-label="展示顺序">
{!loading && !items.length ? (
<Empty description="暂无可排序条目" />
) : (
items.map((item, index) => (
<div
role="listitem"
key={item.id}
draggable={!saving}
onDragStart={(event) => {
event.dataTransfer.setData("text/plain", item.id);
dragged.current = item.id;
}}
onDragEnd={() => {
dragged.current = null;
}}
onDragOver={(event) => {
if (!saving) event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
const source = dragged.current;
if (!saving && source) setItems((current) => moveOrderItem(current, source, index));
dragged.current = null;
}}
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="min-w-0 flex-1 break-words">
{item.name}
{!item.enabled && <span className="ml-2 text-xs text-foreground/45">已停用</span>}
</span>
<Button aria-label={`上移${item.name}`} title="上移" icon={<ArrowUp className="size-4" />} disabled={saving || index === 0} onClick={() => setItems((current) => moveOrderItem(current, item.id, index - 1))} />
<Button
aria-label={`下移${item.name}`}
title="下移"
icon={<ArrowDown className="size-4" />}
disabled={saving || index === items.length - 1}
onClick={() => setItems((current) => moveOrderItem(current, item.id, index + 1))}
/>
</div>
))
)}
</div>
</Spin>
</Modal>
</>
);
}