把后台 token、Ant 主题、壳层控件和抽屉弹窗从用户工作台拆开,去掉树状侧栏装饰线,色板仍用原来的中性黑白台阶。 Co-authored-by: Cursor <cursoragent@cursor.com>
137 lines
6.7 KiB
TypeScript
137 lines
6.7 KiB
TypeScript
import { App, Button, Modal, Spin } from "antd";
|
||
import { ArrowDown, ArrowUp, GripVertical, ListOrdered } from "lucide-react";
|
||
import { useRef, useState } from "react";
|
||
import { AdminEmpty } from "@/pages/admin/components/admin-ui";
|
||
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 ? (
|
||
<AdminEmpty size="compact" title="暂无可排序条目" />
|
||
) : (
|
||
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>
|
||
</>
|
||
);
|
||
}
|