feat(admin): 模型排序 - 支持搜索并移动到指定位置

This commit is contained in:
Codex
2026-09-21 15:38:21 +08:00
parent 4e0b0a9256
commit 17a2618ba5
6 changed files with 51 additions and 8 deletions

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>}