mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Merge custom order frontend for issue #575
This commit is contained in:
@@ -619,3 +619,13 @@ export const assignMonitorPermission = (userId: number) =>
|
||||
|
||||
export const removeMonitorPermission = (userId: number) =>
|
||||
Network.post("/monitor/permission/remove", { userId });
|
||||
|
||||
// Complete ordered IDs plus the last observed server order for conflict detection.
|
||||
export interface OrderUpdatePayload {
|
||||
ids: number[];
|
||||
expectedIds: number[];
|
||||
}
|
||||
export const updateUserOrder = (data: OrderUpdatePayload) =>
|
||||
Network.post("/user/update-order", data);
|
||||
export const updateSpeedLimitOrder = (data: OrderUpdatePayload) =>
|
||||
Network.post("/speed-limit/update-order", data);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
import { TableRow } from "@/shadcn-bridge/heroui/table";
|
||||
|
||||
const HandleContext = createContext<ReactNode>(null);
|
||||
|
||||
export function OrderHandle() {
|
||||
return <>{useContext(HandleContext)}</>;
|
||||
}
|
||||
|
||||
export function OrderItem({
|
||||
id,
|
||||
label,
|
||||
disabled,
|
||||
row = false,
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
row?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
const Component = row ? TableRow : "div";
|
||||
|
||||
return (
|
||||
<HandleContext.Provider
|
||||
value={
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`拖动排序:${label}`}
|
||||
className="shrink-0 rounded p-1 text-default-500 hover:bg-default-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-grab active:cursor-grabbing"
|
||||
disabled={disabled}
|
||||
style={{ touchAction: "none" }}
|
||||
type="button"
|
||||
>
|
||||
<GripVertical aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Component
|
||||
ref={setNodeRef}
|
||||
className={row ? undefined : "h-full min-w-0"}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
position: "relative",
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</HandleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderList({
|
||||
ids,
|
||||
children,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: {
|
||||
ids: number[];
|
||||
children: ReactNode;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 250, tolerance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
accessibility={{
|
||||
screenReaderInstructions: {
|
||||
draggable: "按空格开始排序,方向键移动,再按空格保存,Escape 取消。",
|
||||
},
|
||||
}}
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
{children}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderStatus({
|
||||
reason,
|
||||
error,
|
||||
retry,
|
||||
}: {
|
||||
reason: string;
|
||||
error: string;
|
||||
retry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 text-sm text-default-500">
|
||||
<p role="status">
|
||||
{reason || "拖动手柄调整全局顺序;键盘可按空格、方向键、空格完成排序。"}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-danger mt-2" role="alert">
|
||||
{error}{" "}
|
||||
<button className="underline" type="button" onClick={retry}>
|
||||
重新加载
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import type { OrderUpdatePayload } from "@/api";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
interface Result {
|
||||
code: number;
|
||||
msg?: string;
|
||||
transportError?: boolean;
|
||||
}
|
||||
|
||||
// Keep one complete, authoritative snapshot. Filtering is presentation-only.
|
||||
export function useServerOrder<T extends { id: number }>(
|
||||
fetchItems: () => Promise<T[]>,
|
||||
saveOrder: (payload: OrderUpdatePayload) => Promise<Result>,
|
||||
) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const mounted = useRef(false);
|
||||
const request = useRef(0);
|
||||
const savingRef = useRef(false);
|
||||
const snapshot = useRef(items);
|
||||
|
||||
snapshot.current = items;
|
||||
const dragSnapshot = useRef<T[] | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const version = ++request.current;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const rows = await fetchItems();
|
||||
|
||||
if (!mounted.current || version !== request.current) return;
|
||||
setItems(rows);
|
||||
} catch {
|
||||
if (!mounted.current || version !== request.current) return;
|
||||
// Never expose a partial or unverified set as reorderable after failure.
|
||||
setItems([]);
|
||||
setError("加载列表失败,请重试后再排序。");
|
||||
} finally {
|
||||
if (mounted.current && version === request.current) setLoading(false);
|
||||
}
|
||||
}, [fetchItems]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void reload();
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
++request.current;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const onDragStart = () => {
|
||||
dragSnapshot.current = snapshot.current;
|
||||
};
|
||||
const reorder = async ({ active, over }: DragEndEvent, disabled: boolean) => {
|
||||
const before = dragSnapshot.current;
|
||||
|
||||
dragSnapshot.current = null;
|
||||
if (
|
||||
disabled ||
|
||||
loading ||
|
||||
savingRef.current ||
|
||||
!over ||
|
||||
active.id === over.id ||
|
||||
before !== snapshot.current
|
||||
)
|
||||
return;
|
||||
const from = items.findIndex((item) => item.id === active.id);
|
||||
const to = items.findIndex((item) => item.id === over.id);
|
||||
|
||||
if (from < 0 || to < 0) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await saveOrder({
|
||||
ids: arrayMove(items, from, to).map((item) => item.id),
|
||||
expectedIds: items.map((item) => item.id),
|
||||
});
|
||||
|
||||
if (!mounted.current) return;
|
||||
if (result.code !== 0) {
|
||||
setError(
|
||||
result.transportError
|
||||
? "排序保存未确认,请检查重新加载后的服务器顺序再重试。"
|
||||
: result.code === 409
|
||||
? "列表已被其他操作更改,排序未保存,请按重新加载后的顺序重试。"
|
||||
: `排序保存失败:${result.msg || "请重试"}。请检查重新加载后的服务器顺序。`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current)
|
||||
setError("排序保存未确认,请检查重新加载后的服务器顺序再重试。");
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
await reload();
|
||||
if (mounted.current) setSaving(false);
|
||||
}
|
||||
savingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
items,
|
||||
setItems,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
reload,
|
||||
onDragStart,
|
||||
reorder,
|
||||
};
|
||||
}
|
||||
+217
-187
@@ -1,12 +1,16 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
StaggerList,
|
||||
StaggerItem,
|
||||
} from "@/components/animated-page";
|
||||
OrderHandle,
|
||||
OrderItem,
|
||||
OrderList,
|
||||
OrderStatus,
|
||||
} from "@/components/server-order";
|
||||
import { useServerOrder } from "@/hooks/use-server-order";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import { AnimatedPage, StaggerList } from "@/components/animated-page";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
@@ -24,11 +28,11 @@ import {
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import {
|
||||
createSpeedLimit,
|
||||
updateSpeedLimitOrder,
|
||||
getSpeedLimitList,
|
||||
updateSpeedLimit,
|
||||
deleteSpeedLimit,
|
||||
@@ -52,9 +56,17 @@ interface SpeedLimitForm {
|
||||
status: number;
|
||||
}
|
||||
|
||||
const fetchOrderedRules = async (): Promise<SpeedLimitRule[]> => {
|
||||
const response = await getSpeedLimitList();
|
||||
|
||||
if (response.code !== 0) throw new Error(response.msg);
|
||||
|
||||
return (response.data || []) as SpeedLimitRule[];
|
||||
};
|
||||
|
||||
export default function LimitPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||
const order = useServerOrder(fetchOrderedRules, updateSpeedLimitOrder);
|
||||
const { items: rules, loading, reload: loadData } = order;
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"limit-search-keyword",
|
||||
"",
|
||||
@@ -92,28 +104,6 @@ export default function LimitPage() {
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rulesRes = await getSpeedLimitList();
|
||||
|
||||
if (rulesRes.code === 0) {
|
||||
setRules(rulesRes.data || []);
|
||||
} else {
|
||||
toast.error(rulesRes.msg || "获取限速规则失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
@@ -166,7 +156,7 @@ export default function LimitPage() {
|
||||
|
||||
// 确认删除规则
|
||||
const confirmDelete = async () => {
|
||||
if (!ruleToDelete) return;
|
||||
if (!ruleToDelete || order.saving || deleteLoading) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
@@ -175,7 +165,7 @@ export default function LimitPage() {
|
||||
if (res.code === 0) {
|
||||
toast.success("删除成功");
|
||||
setDeleteModalOpen(false);
|
||||
loadData();
|
||||
await loadData();
|
||||
} else {
|
||||
toast.error(res.msg || "删除失败");
|
||||
}
|
||||
@@ -188,7 +178,7 @@ export default function LimitPage() {
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
if (order.saving || !validateForm()) return;
|
||||
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
@@ -215,7 +205,7 @@ export default function LimitPage() {
|
||||
if (res.code === 0) {
|
||||
toast.success(isEdit ? "修改成功" : "创建成功");
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
await loadData();
|
||||
} else {
|
||||
toast.error(res.msg || "操作失败");
|
||||
}
|
||||
@@ -226,9 +216,16 @@ export default function LimitPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoadingState message="正在加载..." />;
|
||||
}
|
||||
const orderReason = !isAdmin()
|
||||
? "仅管理员可调整全局顺序。"
|
||||
: searchKeyword.length > 0
|
||||
? "搜索期间无法排序,请清空搜索后重试。"
|
||||
: order.saving
|
||||
? "正在保存顺序,请稍候。"
|
||||
: loading || submitLoading || deleteLoading
|
||||
? "正在加载或更新列表,请稍候。"
|
||||
: "";
|
||||
const orderDisabled = Boolean(orderReason);
|
||||
|
||||
return (
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
@@ -263,172 +260,203 @@ export default function LimitPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{filteredRules.length > 0 ? (
|
||||
viewMode === "list" ? (
|
||||
<Card>
|
||||
<Table
|
||||
aria-label="限速规则列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
wrapper:
|
||||
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
|
||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>规则名称</TableColumn>
|
||||
<TableColumn>速度限制</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={filteredRules}>
|
||||
{(rule) => (
|
||||
<TableRow key={rule.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||
rule.status === 1 ? "bg-success" : "bg-danger"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{rule.name}
|
||||
<OrderStatus
|
||||
error={order.error}
|
||||
reason={orderReason}
|
||||
retry={() => {
|
||||
if (!order.saving) void loadData();
|
||||
}}
|
||||
/>
|
||||
<OrderList
|
||||
key={`${viewMode}:${searchKeyword}:${orderDisabled}`}
|
||||
ids={filteredRules.map((rule) => rule.id)}
|
||||
onDragEnd={(event) => {
|
||||
void order.reorder(event, orderDisabled);
|
||||
}}
|
||||
onDragStart={order.onDragStart}
|
||||
>
|
||||
{/* 统一卡片网格 */}
|
||||
{loading ? (
|
||||
<PageLoadingState message="正在加载..." />
|
||||
) : filteredRules.length > 0 ? (
|
||||
viewMode === "list" ? (
|
||||
<Card>
|
||||
<Table
|
||||
aria-label="限速规则列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
wrapper:
|
||||
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
|
||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>规则名称</TableColumn>
|
||||
<TableColumn>速度限制</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={filteredRules}>
|
||||
{(rule) => (
|
||||
<OrderItem
|
||||
key={rule.id}
|
||||
row
|
||||
disabled={orderDisabled}
|
||||
id={rule.id}
|
||||
label={rule.name}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrderHandle />
|
||||
<div
|
||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||
rule.status === 1 ? "bg-success" : "bg-danger"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{rule.name}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono text-default-600">
|
||||
{rule.speed} Mbps
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono text-default-600">
|
||||
{rule.speed} Mbps
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</OrderItem>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{filteredRules.map((rule) => (
|
||||
<OrderItem
|
||||
key={rule.id}
|
||||
disabled={orderDisabled}
|
||||
id={rule.id}
|
||||
label={rule.name}
|
||||
>
|
||||
<Card className="shadow-sm overflow-hidden h-full">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<OrderHandle />
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</OrderItem>
|
||||
))}
|
||||
</StaggerList>
|
||||
)
|
||||
) : (
|
||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{filteredRules.map((rule) => (
|
||||
<StaggerItem key={rule.id}>
|
||||
<Card className="shadow-sm overflow-hidden h-full">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerList>
|
||||
)
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无限速规则
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何限速规则,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无限速规则
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何限速规则,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</OrderList>
|
||||
|
||||
{/* 新增/编辑模态框 */}
|
||||
<Modal
|
||||
@@ -497,6 +525,7 @@ export default function LimitPage() {
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={order.saving}
|
||||
isLoading={submitLoading}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
@@ -544,6 +573,7 @@ export default function LimitPage() {
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={order.saving}
|
||||
isLoading={deleteLoading}
|
||||
onPress={confirmDelete}
|
||||
>
|
||||
|
||||
+493
-495
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user