yeah chay duoc roi
This commit is contained in:
parent
b50dd91b7a
commit
890b27b96d
|
|
@ -3,23 +3,72 @@
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export interface ShellCommandData {
|
||||||
|
command: string;
|
||||||
|
qos: 0 | 1 | 2;
|
||||||
|
isRetained: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ShellCommandFormProps {
|
interface ShellCommandFormProps {
|
||||||
command: string;
|
command: string;
|
||||||
onCommandChange: (value: string) => void;
|
onCommandChange: (value: string) => void;
|
||||||
|
qos?: 0 | 1 | 2;
|
||||||
|
onQoSChange?: (value: 0 | 1 | 2) => void;
|
||||||
|
isRetained?: boolean;
|
||||||
|
onIsRetainedChange?: (value: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QoSDescriptions = {
|
||||||
|
0: {
|
||||||
|
name: "At Most Once (Fire and Forget)",
|
||||||
|
description:
|
||||||
|
"Gửi lệnh một lần mà không đảm bảo. Nhanh nhất, tiêu tốn ít tài nguyên.",
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
name: "At Least Once",
|
||||||
|
description:
|
||||||
|
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Cân bằng giữa tốc độ và độ tin cậy.",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
name: "Exactly Once",
|
||||||
|
description:
|
||||||
|
"Đảm bảo lệnh được nhận chính xác một lần. Chậm nhất nhưng đáng tin cậy nhất.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function ShellCommandForm({
|
export function ShellCommandForm({
|
||||||
command,
|
command,
|
||||||
onCommandChange,
|
onCommandChange,
|
||||||
|
qos = 0,
|
||||||
|
onQoSChange,
|
||||||
|
isRetained = false,
|
||||||
|
onIsRetainedChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: ShellCommandFormProps) {
|
}: ShellCommandFormProps) {
|
||||||
|
const [selectedQoS, setSelectedQoS] = useState<0 | 1 | 2>(qos);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: { command },
|
defaultValues: { command },
|
||||||
onSubmit: () => {},
|
onSubmit: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleQoSChange = (value: string) => {
|
||||||
|
const newQoS = Number(value) as 0 | 1 | 2;
|
||||||
|
setSelectedQoS(newQoS);
|
||||||
|
onQoSChange?.(newQoS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetainedChange = (checked: boolean) => {
|
||||||
|
onIsRetainedChange?.(checked);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|
@ -28,6 +77,7 @@ export function ShellCommandForm({
|
||||||
}}
|
}}
|
||||||
className="space-y-5"
|
className="space-y-5"
|
||||||
>
|
>
|
||||||
|
{/* Command Input */}
|
||||||
<form.Field
|
<form.Field
|
||||||
name="command"
|
name="command"
|
||||||
validators={{
|
validators={{
|
||||||
|
|
@ -44,10 +94,11 @@ export function ShellCommandForm({
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
children={(field) => (
|
children={(field) => (
|
||||||
<div className="w-full px-0">
|
<div className="w-full space-y-2">
|
||||||
|
<Label>Nội Dung Lệnh *</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="w-full h-[25vh]"
|
className="w-full h-[20vh] font-mono"
|
||||||
placeholder="Nhập lệnh..."
|
placeholder="VD: shutdown /s /t 60 /c 'Máy sẽ tắt trong 60 giây'"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.handleChange(e.target.value);
|
field.handleChange(e.target.value);
|
||||||
|
|
@ -63,6 +114,51 @@ export function ShellCommandForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* QoS Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>QoS (Quality of Service) *</Label>
|
||||||
|
<select
|
||||||
|
value={selectedQoS}
|
||||||
|
onChange={(e) => handleQoSChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="0">QoS 0 - At Most Once (Tốc độ cao)</option>
|
||||||
|
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
|
||||||
|
<option value="2">QoS 2 - Exactly Once (Độ tin cậy cao)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* QoS Description */}
|
||||||
|
<Alert className="border-l-4 border-l-blue-500 bg-blue-50 mt-2">
|
||||||
|
<Info className="h-4 w-4 text-blue-600" />
|
||||||
|
<AlertDescription className="text-sm text-blue-800 mt-1">
|
||||||
|
<div className="font-semibold">
|
||||||
|
{QoSDescriptions[selectedQoS].name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">{QoSDescriptions[selectedQoS].description}</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Retained Checkbox */}
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border p-4">
|
||||||
|
<Checkbox
|
||||||
|
id="retained"
|
||||||
|
checked={isRetained}
|
||||||
|
onCheckedChange={handleRetainedChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="retained" className="text-base cursor-pointer">
|
||||||
|
Lưu giữ lệnh (Retained)
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Broker MQTT sẽ lưu lệnh này và gửi cho client mới khi kết nối. Hữu ích
|
||||||
|
cho các lệnh cấu hình cần duy trì trạng thái.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
379
src/components/forms/command-registry-form.tsx
Normal file
379
src/components/forms/command-registry-form.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface CommandRegistryFormProps {
|
||||||
|
onSubmit: (data: CommandRegistryFormData) => Promise<void>;
|
||||||
|
closeDialog?: () => void;
|
||||||
|
initialData?: Partial<CommandRegistryFormData>;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandRegistryFormData {
|
||||||
|
commandName: string;
|
||||||
|
description?: string;
|
||||||
|
commandContent: string;
|
||||||
|
qos: 0 | 1 | 2;
|
||||||
|
isRetained: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zod validation schema
|
||||||
|
const commandRegistrySchema = z.object({
|
||||||
|
commandName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Tên lệnh không được để trống")
|
||||||
|
.min(3, "Tên lệnh phải có ít nhất 3 ký tự")
|
||||||
|
.max(100, "Tên lệnh tối đa 100 ký tự")
|
||||||
|
.trim(),
|
||||||
|
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional(),
|
||||||
|
commandContent: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Nội dung lệnh không được để trống")
|
||||||
|
.min(5, "Nội dung lệnh phải có ít nhất 5 ký tự")
|
||||||
|
.trim(),
|
||||||
|
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
|
||||||
|
isRetained: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QoSLevels = [
|
||||||
|
{
|
||||||
|
level: 0,
|
||||||
|
name: "At Most Once (Fire and Forget)",
|
||||||
|
description:
|
||||||
|
"Gửi lệnh một lần mà không đảm bảo. Nếu broker hoặc client bị ngắt kết nối, lệnh có thể bị mất. Tốc độ nhanh nhất, tiêu tốn ít tài nguyên.",
|
||||||
|
useCase: "Các lệnh không quan trọng, có thể mất mà không ảnh hưởng",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 1,
|
||||||
|
name: "At Least Once",
|
||||||
|
description:
|
||||||
|
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Có thể gửi lại nếu chưa nhận được ACK. Lệnh có thể được nhận nhiều lần.",
|
||||||
|
useCase: "Hầu hết các lệnh bình thường cần đảm bảo gửi thành công",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 2,
|
||||||
|
name: "Exactly Once",
|
||||||
|
description:
|
||||||
|
"Đảm bảo lệnh được nhận chính xác một lần. Sử dụng bắt tay 4 chiều để đảm bảo độ tin cậy cao nhất. Tốc độ chậm hơn, tiêu tốn nhiều tài nguyên.",
|
||||||
|
useCase: "Các lệnh quan trọng như xóa dữ liệu, thay đổi cấu hình",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CommandRegistryForm({
|
||||||
|
onSubmit,
|
||||||
|
closeDialog,
|
||||||
|
initialData,
|
||||||
|
title = "Đăng ký Lệnh Mới",
|
||||||
|
}: CommandRegistryFormProps) {
|
||||||
|
const [selectedQoS, setSelectedQoS] = useState<number>(
|
||||||
|
initialData?.qos ?? 0
|
||||||
|
);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
commandName: initialData?.commandName || "",
|
||||||
|
description: initialData?.description || "",
|
||||||
|
commandContent: initialData?.commandContent || "",
|
||||||
|
qos: (initialData?.qos || 0) as 0 | 1 | 2,
|
||||||
|
isRetained: initialData?.isRetained || false,
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
try {
|
||||||
|
// Validate using Zod
|
||||||
|
const validatedData = commandRegistrySchema.parse(value);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
await onSubmit(validatedData as CommandRegistryFormData);
|
||||||
|
toast.success("Lưu lệnh thành công!");
|
||||||
|
if (closeDialog) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errors?.length > 0) {
|
||||||
|
toast.error(error.errors[0].message);
|
||||||
|
} else {
|
||||||
|
console.error("Submit error:", error);
|
||||||
|
toast.error("Có lỗi xảy ra khi lưu lệnh!");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tạo và cấu hình lệnh MQTT mới để điều khiển thiết bị
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Tên lệnh */}
|
||||||
|
<form.Field name="commandName">
|
||||||
|
{(field: any) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Tên Lệnh <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="VD: RestartDevice, ShutdownPC, UpdateSoftware..."
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{field.state.meta.errors?.length > 0 && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{String(field.state.meta.errors[0])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tên định danh duy nhất cho lệnh này
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{/* Mô tả */}
|
||||||
|
<form.Field name="description">
|
||||||
|
{(field: any) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mô Tả (Tùy chọn)</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Nhập mô tả chi tiết về lệnh này..."
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
{field.state.meta.errors?.length > 0 && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{String(field.state.meta.errors[0])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Mô tả chi tiết về chức năng và cách sử dụng lệnh
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{/* Nội dung lệnh */}
|
||||||
|
<form.Field name="commandContent">
|
||||||
|
{(field: any) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Nội Dung Lệnh <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="VD: shutdown /s /t 30 /c 'Máy sẽ tắt trong 30 giây'"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
rows={5}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{field.state.meta.errors?.length > 0 && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{String(field.state.meta.errors[0])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nội dung lệnh sẽ được gửi tới thiết bị (PowerShell, CMD, bash...)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{/* QoS Level */}
|
||||||
|
<form.Field name="qos">
|
||||||
|
{(field: any) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
QoS (Quality of Service) <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value) as 0 | 1 | 2;
|
||||||
|
field.handleChange(value);
|
||||||
|
setSelectedQoS(value);
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="0">QoS 0 - At Most Once (Tốc độ cao)</option>
|
||||||
|
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
|
||||||
|
<option value="2">QoS 2 - Exactly Once (Độ tin cậy cao)</option>
|
||||||
|
</select>
|
||||||
|
{field.state.meta.errors?.length > 0 && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{String(field.state.meta.errors[0])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{/* Chú thích QoS */}
|
||||||
|
{selectedQoS !== null && (
|
||||||
|
<Alert className="border-l-4 border-l-blue-500 bg-blue-50">
|
||||||
|
<Info className="h-4 w-4 text-blue-600" />
|
||||||
|
<AlertDescription className="text-sm space-y-3 mt-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-blue-900">
|
||||||
|
{QoSLevels[selectedQoS].name}
|
||||||
|
</div>
|
||||||
|
<div className="text-blue-800 mt-1">
|
||||||
|
{QoSLevels[selectedQoS].description}
|
||||||
|
</div>
|
||||||
|
<div className="text-blue-700 mt-2">
|
||||||
|
<span className="font-medium">Trường hợp sử dụng:</span>{" "}
|
||||||
|
{QoSLevels[selectedQoS].useCase}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bảng so sánh QoS */}
|
||||||
|
<Card className="bg-muted/50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Bảng So Sánh Các Mức QoS
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 px-2 font-semibold">
|
||||||
|
Tiêu Chí
|
||||||
|
</th>
|
||||||
|
<th className="text-center py-2 px-2 font-semibold">
|
||||||
|
QoS 0
|
||||||
|
</th>
|
||||||
|
<th className="text-center py-2 px-2 font-semibold">
|
||||||
|
QoS 1
|
||||||
|
</th>
|
||||||
|
<th className="text-center py-2 px-2 font-semibold">
|
||||||
|
QoS 2
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b bg-white">
|
||||||
|
<td className="py-2 px-2">Đảm bảo gửi</td>
|
||||||
|
<td className="text-center">Không</td>
|
||||||
|
<td className="text-center">Có</td>
|
||||||
|
<td className="text-center">Chính xác</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b bg-white">
|
||||||
|
<td className="py-2 px-2">Tốc độ</td>
|
||||||
|
<td className="text-center">Nhanh nhất</td>
|
||||||
|
<td className="text-center">Trung bình</td>
|
||||||
|
<td className="text-center">Chậm nhất</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b bg-white">
|
||||||
|
<td className="py-2 px-2">Tài nguyên</td>
|
||||||
|
<td className="text-center">Ít nhất</td>
|
||||||
|
<td className="text-center">Trung bình</td>
|
||||||
|
<td className="text-center">Nhiều nhất</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b bg-white">
|
||||||
|
<td className="py-2 px-2">Độ tin cậy</td>
|
||||||
|
<td className="text-center">Thấp</td>
|
||||||
|
<td className="text-center">Cao</td>
|
||||||
|
<td className="text-center">Cao nhất</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-white">
|
||||||
|
<td className="py-2 px-2">Số lần nhận tối đa</td>
|
||||||
|
<td className="text-center">1 (hoặc 0)</td>
|
||||||
|
<td className="text-center">≥ 1</td>
|
||||||
|
<td className="text-center">1</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* IsRetained Checkbox */}
|
||||||
|
<form.Field name="isRetained">
|
||||||
|
{(field: any) => (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border p-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={field.state.value}
|
||||||
|
onCheckedChange={field.handleChange}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-base cursor-pointer">
|
||||||
|
Lưu giữ lệnh (Retained)
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Broker MQTT sẽ lưu lệnh này và gửi cho client mới khi
|
||||||
|
kết nối. Hữu ích cho các lệnh cấu hình cần duy trì trạng
|
||||||
|
thái.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Đang lưu..." : "Lưu Lệnh"}
|
||||||
|
</Button>
|
||||||
|
{closeDialog && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={closeDialog}
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,13 @@ export const API_ENDPOINTS = {
|
||||||
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
||||||
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
|
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
|
||||||
},
|
},
|
||||||
|
COMMAND:
|
||||||
|
{
|
||||||
|
ADD_COMMAND: `${BASE_URL}/Command/add`,
|
||||||
|
GET_COMMANDS: `${BASE_URL}/Command/all`,
|
||||||
|
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
|
||||||
|
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
|
||||||
|
},
|
||||||
SSE_EVENTS: {
|
SSE_EVENTS: {
|
||||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||||
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,344 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { FormSubmitTemplate } from "@/template/form-submit-template";
|
import { useState } from "react";
|
||||||
import { ShellCommandForm } from "@/components/forms/command-form";
|
import { CommandSubmitTemplate } from "@/template/command-submit-template";
|
||||||
import { useMutationData } from "@/hooks/useMutationData";
|
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
||||||
import { useQueryData } from "@/hooks/useQueryData";
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { useMutationData } from "@/hooks/useMutationData";
|
||||||
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Check, X, Edit2, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ShellCommandData } from "@/components/forms/command-form";
|
||||||
|
|
||||||
type SendCommandRequest = { Command: string };
|
interface CommandRegistry {
|
||||||
type SendCommandResponse = { status: string; message: string };
|
id: number;
|
||||||
|
commandName: string;
|
||||||
|
description?: string;
|
||||||
|
commandContent: string;
|
||||||
|
qoS: 0 | 1 | 2;
|
||||||
|
isRetained: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/command/")({
|
export const Route = createFileRoute("/_authenticated/command/")({
|
||||||
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
|
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
|
||||||
component: CommandPage,
|
component: CommandPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function CommandPage() {
|
function CommandPage() {
|
||||||
// Lấy danh sách phòng từ API
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
|
||||||
|
const [table, setTable] = useState<any>();
|
||||||
|
|
||||||
|
// Fetch commands
|
||||||
|
const { data: commands = [], isLoading } = useQueryData({
|
||||||
|
queryKey: ["commands"],
|
||||||
|
url: API_ENDPOINTS.COMMAND.GET_COMMANDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch rooms
|
||||||
const { data: roomData } = useQueryData({
|
const { data: roomData } = useQueryData({
|
||||||
queryKey: ["rooms"],
|
queryKey: ["rooms"],
|
||||||
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
|
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutation gửi lệnh
|
const commandList: CommandRegistry[] = Array.isArray(commands)
|
||||||
const sendCommandMutation = useMutationData<
|
? commands.map((cmd: any) => ({
|
||||||
SendCommandRequest,
|
...cmd,
|
||||||
SendCommandResponse
|
qoS: cmd.qoS ?? 0,
|
||||||
>({
|
isRetained: cmd.isRetained ?? false,
|
||||||
url: "", // sẽ set động theo roomName khi gọi
|
}))
|
||||||
|
: commands
|
||||||
|
? [{
|
||||||
|
...commands,
|
||||||
|
qoS: commands.qoS ?? 0,
|
||||||
|
isRetained: commands.isRetained ?? false,
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Add command mutation
|
||||||
|
const addCommandMutation = useMutationData<CommandRegistryFormData>({
|
||||||
|
url: API_ENDPOINTS.COMMAND.ADD_COMMAND,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
onSuccess: (data) => {
|
invalidate: [["commands"]],
|
||||||
if (data.status === "OK") {
|
onSuccess: () => toast.success("Thêm lệnh thành công!"),
|
||||||
toast.success("Gửi lệnh thành công!");
|
|
||||||
} else {
|
|
||||||
toast.error("Gửi lệnh thất bại!");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Send command error:", error);
|
console.error("Add command error:", error);
|
||||||
toast.error("Gửi lệnh thất bại!");
|
toast.error("Thêm lệnh thất bại!");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
// Update command mutation
|
||||||
<FormSubmitTemplate
|
const updateCommandMutation = useMutationData<CommandRegistryFormData>({
|
||||||
title="CMD Command"
|
url: "",
|
||||||
description="Gửi lệnh shell xuống thiết bị để thực thi"
|
method: "POST",
|
||||||
isLoading={sendCommandMutation.isPending}
|
invalidate: [["commands"]],
|
||||||
rooms={roomData}
|
onSuccess: () => toast.success("Cập nhật lệnh thành công!"),
|
||||||
onSubmit={(roomName, command) => {
|
onError: (error) => {
|
||||||
sendCommandMutation.mutateAsync({
|
console.error("Update command error:", error);
|
||||||
url: API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
|
toast.error("Cập nhật lệnh thất bại!");
|
||||||
data: { Command: command },
|
},
|
||||||
});
|
});
|
||||||
}}
|
|
||||||
submitLoading={sendCommandMutation.isPending}
|
// Delete command mutation
|
||||||
>
|
const deleteCommandMutation = useMutationData<any>({
|
||||||
{({ command, setCommand }) => (
|
url: "",
|
||||||
<ShellCommandForm
|
method: "DELETE",
|
||||||
command={command}
|
invalidate: [["commands"]],
|
||||||
onCommandChange={setCommand}
|
onSuccess: () => toast.success("Xóa lệnh thành công!"),
|
||||||
disabled={sendCommandMutation.isPending}
|
onError: (error) => {
|
||||||
|
console.error("Delete command error:", error);
|
||||||
|
toast.error("Xóa lệnh thất bại!");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute command mutation
|
||||||
|
const executeCommandMutation = useMutationData<{
|
||||||
|
commandIds?: number[];
|
||||||
|
command?: ShellCommandData;
|
||||||
|
}>({
|
||||||
|
url: "",
|
||||||
|
method: "POST",
|
||||||
|
onSuccess: () => toast.success("Gửi yêu cầu thực thi lệnh thành công!"),
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Execute command error:", error);
|
||||||
|
toast.error("Gửi yêu cầu thực thi thất bại!");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Columns for command table
|
||||||
|
const columns: ColumnDef<CommandRegistry>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "commandName",
|
||||||
|
header: "Tên lệnh",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-semibold break-words">{getValue() as string}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: "Mô tả",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="text-sm text-muted-foreground break-words whitespace-normal">
|
||||||
|
{(getValue() as string) || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "commandContent",
|
||||||
|
header: "Nội dung lệnh",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<code className="text-xs bg-muted/50 p-1 rounded break-words whitespace-normal block">
|
||||||
|
{(getValue() as string).substring(0, 100)}...
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "qoS",
|
||||||
|
header: "QoS",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const qos = getValue() as number | undefined;
|
||||||
|
const qosValue = qos !== undefined ? qos : 0;
|
||||||
|
const colors = {
|
||||||
|
0: "text-blue-600",
|
||||||
|
1: "text-amber-600",
|
||||||
|
2: "text-red-600",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={colors[qosValue as 0 | 1 | 2]}>{qosValue}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "isRetained",
|
||||||
|
header: "Lưu trữ",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const retained = getValue() as boolean;
|
||||||
|
return retained ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm text-green-600">Có</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<X className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-400">Không</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: () => <div className="text-center text-xs">Chọn để thực thi</div>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.getIsSelected?.() ?? false}
|
||||||
|
onChange={row.getToggleSelectedHandler?.()}
|
||||||
/>
|
/>
|
||||||
)}
|
),
|
||||||
</FormSubmitTemplate>
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-center text-xs">Hành động</div>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCommand(row.original);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDeleteCommand(row.original.id)}
|
||||||
|
disabled={deleteCommandMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Handle form submit
|
||||||
|
const handleFormSubmit = async (data: CommandRegistryFormData) => {
|
||||||
|
if (selectedCommand) {
|
||||||
|
// Update
|
||||||
|
await updateCommandMutation.mutateAsync({
|
||||||
|
url: BASE_URL + API_ENDPOINTS.COMMAND.UPDATE_COMMAND(selectedCommand.id),
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add
|
||||||
|
await addCommandMutation.mutateAsync({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSelectedCommand(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
const handleDeleteCommand = async (commandId: number) => {
|
||||||
|
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteCommandMutation.mutateAsync({
|
||||||
|
url: API_ENDPOINTS.COMMAND.DELETE_COMMAND(commandId),
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle execute commands from list
|
||||||
|
const handleExecuteSelected = async (targets: string[]) => {
|
||||||
|
if (!table) {
|
||||||
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
toast.error("Vui lòng chọn ít nhất một lệnh để thực thi!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const target of targets) {
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
// API expects PascalCase directly
|
||||||
|
const apiData = {
|
||||||
|
Command: row.original.commandContent,
|
||||||
|
QoS: row.original.qoS,
|
||||||
|
IsRetained: row.original.isRetained,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[DEBUG] Sending to:", target, "Data:", apiData);
|
||||||
|
|
||||||
|
await executeCommandMutation.mutateAsync({
|
||||||
|
url: API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(target),
|
||||||
|
data: apiData as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success("Đã gửi yêu cầu thực thi lệnh cho các mục đã chọn!");
|
||||||
|
if (table) {
|
||||||
|
table.setRowSelection({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DEBUG] Execute error:", error);
|
||||||
|
console.error("[DEBUG] Response:", (error as any)?.response?.data);
|
||||||
|
toast.error("Có lỗi xảy ra khi thực thi!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle execute custom command
|
||||||
|
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
|
||||||
|
try {
|
||||||
|
for (const target of targets) {
|
||||||
|
// API expects PascalCase directly
|
||||||
|
const apiData = {
|
||||||
|
Command: commandData.command,
|
||||||
|
QoS: commandData.qos,
|
||||||
|
IsRetained: commandData.isRetained,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[DEBUG] Sending custom to:", target, "Data:", apiData);
|
||||||
|
|
||||||
|
await executeCommandMutation.mutateAsync({
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(target),
|
||||||
|
data: apiData as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DEBUG] Execute custom error:", error);
|
||||||
|
console.error("[DEBUG] Response:", (error as any)?.response?.data);
|
||||||
|
toast.error("Gửi lệnh tùy chỉnh thất bại!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandSubmitTemplate
|
||||||
|
title="Gửi lệnh từ xa"
|
||||||
|
description="Quản lý và thực thi các lệnh trên thiết bị"
|
||||||
|
data={commandList}
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={columns}
|
||||||
|
dialogOpen={isDialogOpen}
|
||||||
|
onDialogOpen={setIsDialogOpen}
|
||||||
|
dialogTitle={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
|
||||||
|
onAddNew={() => {
|
||||||
|
setSelectedCommand(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
onTableInit={setTable}
|
||||||
|
formContent={
|
||||||
|
<CommandRegistryForm
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
closeDialog={() => setIsDialogOpen(false)}
|
||||||
|
initialData={selectedCommand || undefined}
|
||||||
|
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onExecuteSelected={handleExecuteSelected}
|
||||||
|
onExecuteCustom={handleExecuteCustom}
|
||||||
|
isExecuting={executeCommandMutation.isPending}
|
||||||
|
rooms={roomData}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
424
src/template/command-submit-template.tsx
Normal file
424
src/template/command-submit-template.tsx
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { VersionTable } from "@/components/tables/version-table";
|
||||||
|
import {
|
||||||
|
ShellCommandForm,
|
||||||
|
type ShellCommandData,
|
||||||
|
} from "@/components/forms/command-form";
|
||||||
|
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
||||||
|
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||||
|
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
|
||||||
|
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
|
||||||
|
import { fetchDevicesFromRoom } from "@/services/device.service";
|
||||||
|
import type { Room } from "@/types/room";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface CommandSubmitTemplateProps<T extends { id: number }> {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
// Data & Loading
|
||||||
|
data: T[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
|
||||||
|
// Table config
|
||||||
|
columns: ColumnDef<T>[];
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
dialogOpen: boolean;
|
||||||
|
onDialogOpen: (open: boolean) => void;
|
||||||
|
dialogTitle?: string;
|
||||||
|
formContent?: React.ReactNode;
|
||||||
|
dialogContentClassName?: string;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
onAddNew?: () => void;
|
||||||
|
onTableInit?: (table: any) => void;
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
onExecuteSelected?: (targets: string[]) => void;
|
||||||
|
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
|
||||||
|
isExecuting?: boolean;
|
||||||
|
|
||||||
|
// Execution scope
|
||||||
|
rooms?: Room[];
|
||||||
|
devices?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
data,
|
||||||
|
isLoading = false,
|
||||||
|
columns,
|
||||||
|
dialogOpen,
|
||||||
|
onDialogOpen,
|
||||||
|
dialogTitle = "Thêm Mục Mới",
|
||||||
|
formContent,
|
||||||
|
dialogContentClassName,
|
||||||
|
onAddNew,
|
||||||
|
onTableInit,
|
||||||
|
onExecuteSelected,
|
||||||
|
onExecuteCustom,
|
||||||
|
isExecuting = false,
|
||||||
|
rooms = [],
|
||||||
|
devices = [],
|
||||||
|
}: CommandSubmitTemplateProps<T>) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"list" | "execute">("list");
|
||||||
|
const [customCommand, setCustomCommand] = useState("");
|
||||||
|
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
||||||
|
const [customRetained, setCustomRetained] = useState(false);
|
||||||
|
const [table, setTable] = useState<any>();
|
||||||
|
const [dialogOpen2, setDialogOpen2] = useState(false);
|
||||||
|
const [dialogType, setDialogType] = useState<"room" | "device" | "room-custom" | "device-custom" | null>(null);
|
||||||
|
|
||||||
|
const handleTableInit = (t: any) => {
|
||||||
|
setTable(t);
|
||||||
|
onTableInit?.(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRoomDialog = () => {
|
||||||
|
if (rooms.length > 0 && onExecuteSelected) {
|
||||||
|
setDialogType("room");
|
||||||
|
setDialogOpen2(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeviceDialog = () => {
|
||||||
|
if (onExecuteSelected) {
|
||||||
|
setDialogType("device");
|
||||||
|
setDialogOpen2(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteSelected = () => {
|
||||||
|
if (!table) {
|
||||||
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
toast.error("Vui lòng chọn ít nhất một mục để thực thi!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onExecuteSelected?.([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteAll = () => {
|
||||||
|
if (!onExecuteSelected) return;
|
||||||
|
try {
|
||||||
|
const roomNames = rooms.map((room) =>
|
||||||
|
typeof room === "string" ? room : room.name
|
||||||
|
);
|
||||||
|
const allTargets = [...roomNames, ...devices];
|
||||||
|
onExecuteSelected(allTargets);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute error:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteCustom = async (targets: string[]) => {
|
||||||
|
if (!customCommand.trim()) {
|
||||||
|
toast.error("Vui lòng nhập lệnh!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellCommandData: ShellCommandData = {
|
||||||
|
command: customCommand,
|
||||||
|
qos: customQoS,
|
||||||
|
isRetained: customRetained,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onExecuteCustom?.(targets, shellCommandData);
|
||||||
|
setCustomCommand("");
|
||||||
|
setCustomQoS(0);
|
||||||
|
setCustomRetained(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute custom command error:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteCustomAll = () => {
|
||||||
|
if (!onExecuteCustom) return;
|
||||||
|
try {
|
||||||
|
const roomNames = rooms.map((room) =>
|
||||||
|
typeof room === "string" ? room : room.name
|
||||||
|
);
|
||||||
|
const allTargets = [...roomNames, ...devices];
|
||||||
|
handleExecuteCustom(allTargets);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute error:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRoomDialogCustom = () => {
|
||||||
|
if (rooms.length > 0 && onExecuteCustom) {
|
||||||
|
setDialogType("room-custom");
|
||||||
|
setDialogOpen2(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeviceDialogCustom = () => {
|
||||||
|
if (onExecuteCustom) {
|
||||||
|
setDialogType("device-custom");
|
||||||
|
setDialogOpen2(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader className="bg-muted/50 flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CommandIcon className="h-5 w-5" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
{onAddNew && (
|
||||||
|
<Button onClick={onAddNew} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Thêm Mới
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Tabs Navigation */}
|
||||||
|
<div className="flex gap-4 mb-6 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("list")}
|
||||||
|
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||||
|
activeTab === "list"
|
||||||
|
? "text-primary border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CommandIcon className="h-4 w-4" />
|
||||||
|
Danh sách lệnh có sẵn
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("execute")}
|
||||||
|
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||||
|
activeTab === "execute"
|
||||||
|
? "text-primary border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
Lệnh thủ công
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Danh sách */}
|
||||||
|
{activeTab === "list" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<VersionTable<T>
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onTableInit={handleTableInit}
|
||||||
|
/>
|
||||||
|
<RequestUpdateMenu
|
||||||
|
onUpdateDevice={openDeviceDialog}
|
||||||
|
onUpdateRoom={openRoomDialog}
|
||||||
|
onUpdateAll={handleExecuteAll}
|
||||||
|
loading={isExecuting}
|
||||||
|
label="Thực Thi"
|
||||||
|
deviceLabel="Thực thi cho thiết bị cụ thể"
|
||||||
|
roomLabel="Thực thi cho phòng"
|
||||||
|
allLabel="Thực thi cho tất cả thiết bị"
|
||||||
|
icon={<Zap className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Thực thi */}
|
||||||
|
{activeTab === "execute" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Lệnh tùy chỉnh */}
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Thực Thi Lệnh Tùy Chỉnh
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Nhập lệnh tuỳ chỉnh với QoS và Retained settings
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ShellCommandForm
|
||||||
|
command={customCommand}
|
||||||
|
onCommandChange={setCustomCommand}
|
||||||
|
qos={customQoS}
|
||||||
|
onQoSChange={setCustomQoS}
|
||||||
|
isRetained={customRetained}
|
||||||
|
onIsRetainedChange={setCustomRetained}
|
||||||
|
disabled={isExecuting}
|
||||||
|
/>
|
||||||
|
<RequestUpdateMenu
|
||||||
|
onUpdateDevice={openDeviceDialogCustom}
|
||||||
|
onUpdateRoom={openRoomDialogCustom}
|
||||||
|
onUpdateAll={handleExecuteCustomAll}
|
||||||
|
loading={isExecuting}
|
||||||
|
label="Thực Thi"
|
||||||
|
deviceLabel="Thực thi cho thiết bị cụ thể"
|
||||||
|
roomLabel="Thực thi cho phòng"
|
||||||
|
allLabel="Thực thi cho tất cả thiết bị"
|
||||||
|
icon={<Zap className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/* Dialog chọn phòng - Thực thi */}
|
||||||
|
{dialogType === "room" && (
|
||||||
|
<SelectDialog
|
||||||
|
open={dialogOpen2}
|
||||||
|
onClose={() => {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}}
|
||||||
|
title="Chọn phòng"
|
||||||
|
description="Chọn các phòng để thực thi lệnh"
|
||||||
|
icon={<Building2 className="w-6 h-6 text-primary" />}
|
||||||
|
items={mapRoomsToSelectItems(rooms)}
|
||||||
|
onConfirm={async (selectedItems) => {
|
||||||
|
if (!onExecuteSelected) return;
|
||||||
|
try {
|
||||||
|
await onExecuteSelected(selectedItems);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute error:", e);
|
||||||
|
} finally {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog tìm thiết bị - Thực thi */}
|
||||||
|
{dialogType === "device" && (
|
||||||
|
<DeviceSearchDialog
|
||||||
|
open={dialogOpen2 && dialogType === "device"}
|
||||||
|
onClose={() => {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}}
|
||||||
|
rooms={rooms}
|
||||||
|
fetchDevices={fetchDevicesFromRoom}
|
||||||
|
onSelect={async (deviceIds) => {
|
||||||
|
if (!onExecuteSelected) {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onExecuteSelected(deviceIds);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute error:", e);
|
||||||
|
} finally {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog chọn phòng - Thực thi lệnh tùy chỉnh */}
|
||||||
|
{dialogType === "room-custom" && (
|
||||||
|
<SelectDialog
|
||||||
|
open={dialogOpen2}
|
||||||
|
onClose={() => {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}}
|
||||||
|
title="Chọn phòng"
|
||||||
|
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
|
||||||
|
icon={<Building2 className="w-6 h-6 text-primary" />}
|
||||||
|
items={mapRoomsToSelectItems(rooms)}
|
||||||
|
onConfirm={async (selectedItems) => {
|
||||||
|
try {
|
||||||
|
await handleExecuteCustom(selectedItems);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute error:", e);
|
||||||
|
} finally {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog tìm thiết bị - Thực thi lệnh tùy chỉnh */}
|
||||||
|
{dialogType === "device-custom" && (
|
||||||
|
<DeviceSearchDialog
|
||||||
|
open={dialogOpen2 && dialogType === "device-custom"}
|
||||||
|
onClose={() => {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}}
|
||||||
|
rooms={rooms}
|
||||||
|
fetchDevices={fetchDevicesFromRoom}
|
||||||
|
onSelect={async (deviceIds) => {
|
||||||
|
try {
|
||||||
|
await handleExecuteCustom(deviceIds);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute error:", e);
|
||||||
|
} finally {
|
||||||
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog for add/edit */}
|
||||||
|
{formContent && (
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
||||||
|
<DialogContent className={dialogContentClassName || "max-w-2xl max-h-[90vh] overflow-y-auto"}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{formContent}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user