haixunMaster/app/(dashboard)/automation/page.tsx

508 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useState } from "react";
import {
AlertTriangle,
Bot,
Clock,
Loader2,
Play,
Power,
RefreshCw,
Square,
Zap,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { EmptyState } from "@/components/layout/empty-state";
import { InlineAlert } from "@/components/ui/inline-alert";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/layout/page-header";
import { Switch } from "@/components/ui/switch";
import { useCapabilities } from "@/lib/capabilities/context";
import { useActionFeedback } from "@/lib/use-action-feedback";
import {
AUTOMATION_TASK_TYPES,
AUTOMATION_TASK_META,
OUTBOUND_TASKS,
type AutomationTaskType,
} from "@/lib/automation/types";
import { cn } from "@/lib/utils";
interface AutomationRule {
id: string;
taskType: string;
mode: string;
dailyCap: number;
schedule: string;
enabled: boolean;
lastRunAt?: string | null;
}
interface RulesData {
accountId: string;
accountName: string;
automationEnabled: boolean;
rules: AutomationRule[];
}
interface ActionLog {
id: string;
taskType: string;
action: string;
mode: string;
status: string;
detail?: string | null;
error?: string | null;
createdAt: string;
}
const flowLabels: Record<string, string> = {
A: "流程 A",
B: "流程 B",
both: "流程 A + B",
};
export default function AutomationPage() {
const [rulesData, setRulesData] = useState<RulesData | null>(null);
const [logs, setLogs] = useState<ActionLog[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [killOpen, setKillOpen] = useState(false);
const { feedback, clearFeedback, showError, showSuccess, showWarning } = useActionFeedback();
const { isReady } = useCapabilities();
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const [rulesRes, logsRes] = await Promise.all([
fetch("/api/automation/rules"),
fetch("/api/automation/logs"),
]);
const rulesData = await rulesRes.json();
const logsData = await logsRes.json();
if (rulesRes.ok) setRulesData(rulesData);
if (logsRes.ok) setLogs(logsData.logs ?? []);
} catch {
// ignore
} finally {
if (!silent) setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function toggleMaster(enabled: boolean) {
setBusy("master");
try {
const res = await fetch("/api/automation/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ automationEnabled: enabled }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "無法切換總開關", "操作失敗");
return;
}
setRulesData((prev) => (prev ? { ...prev, automationEnabled: enabled } : prev));
showSuccess(enabled ? "自動化已開啟" : "自動化已關閉");
} finally {
setBusy(null);
}
}
async function updateRule(taskType: AutomationTaskType, patch: Partial<AutomationRule>) {
setBusy(`rule-${taskType}`);
try {
const res = await fetch("/api/automation/rules", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskType, ...patch }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "無法更新規則", "儲存失敗");
return;
}
setRulesData((prev) => {
if (!prev) return prev;
const existing = prev.rules.find((r) => r.taskType === taskType);
const updated = data.rule ?? { ...existing, ...patch };
const others = prev.rules.filter((r) => r.taskType !== taskType);
return { ...prev, rules: [...others, updated] };
});
} finally {
setBusy(null);
}
}
async function runNow(taskType: AutomationTaskType) {
setBusy(`run-${taskType}`);
try {
const res = await fetch("/api/automation/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskType }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "執行失敗", "立即執行失敗");
return;
}
const result = data.result;
if (result?.ok) {
showSuccess(result.summary ?? "執行完成");
} else {
showWarning(result?.summary ?? "執行未完成", "執行結果");
}
load(true);
} finally {
setBusy(null);
}
}
async function killAll() {
setBusy("kill");
try {
const res = await fetch("/api/automation/kill", { method: "POST" });
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "殺停失敗", "操作失敗");
return;
}
setRulesData((prev) => (prev ? { ...prev, automationEnabled: false } : prev));
showSuccess(`已緊急殺停 ${data.disabledAccounts ?? 0} 個帳號的自動化`);
} finally {
setBusy(null);
}
}
if (loading) {
return (
<div>
<PageHeader title="自動化排程" description="設定定時海巡、生成、發文與互動任務。" />
<div className="space-y-4">
<div className="skeleton h-32 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
</div>
</div>
);
}
if (!rulesData) {
return (
<div>
<PageHeader title="自動化排程" description="設定定時海巡、生成、發文與互動任務。" />
<EmptyState
icon={Bot}
title="無法載入自動化設定"
description="請確認已建立經營帳號後重試。"
action={<Button variant="outline" onClick={() => load()}></Button>}
/>
</div>
);
}
return (
<div>
<PageHeader
eyebrow="SYSTEM"
title="自動化排程"
description="設定定時海巡、生成、發文與互動任務。需另啟 workernpm run worker才會按排程執行。"
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
<div className="space-y-6">
{/* 總開關 + 緊急殺停 */}
<Card className={cn(rulesData.automationEnabled && "border-primary/30")}>
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div
className={cn(
"flex h-11 w-11 items-center justify-center rounded-xl",
rulesData.automationEnabled
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}
>
<Power className="h-5 w-5" />
</div>
<div>
<p className="font-semibold">
<span className="ml-2 text-sm font-normal text-muted-foreground">
{rulesData.accountName}
</span>
</p>
<p className="mt-1 text-xs text-muted-foreground">
{rulesData.automationEnabled
? "已開啟 — 符合排程的規則會自動執行"
: "已關閉 — 所有自動化任務暫停"}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Switch
checked={rulesData.automationEnabled}
onCheckedChange={(checked) => toggleMaster(checked)}
disabled={busy === "master"}
/>
<Button
variant="destructive"
size="sm"
onClick={() => setKillOpen(true)}
disabled={!rulesData.automationEnabled}
>
<Square className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
{/* 規則清單 */}
<div className="space-y-4">
{AUTOMATION_TASK_TYPES.map((taskType) => {
const meta = AUTOMATION_TASK_META[taskType];
const rule = rulesData.rules.find((r) => r.taskType === taskType);
const isOutbound = OUTBOUND_TASKS.includes(taskType);
const isAutoMode = rule?.mode === "auto";
const capReady =
taskType === "scan"
? isReady("scan")
: taskType === "generate"
? isReady("generate")
: taskType === "publish"
? isReady("publish")
: taskType === "outreach"
? isReady("outreach")
: isReady("ai");
return (
<Card key={taskType}>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
{meta.label}
<Badge variant="outline">{flowLabels[meta.flow]}</Badge>
{isOutbound && isAutoMode && rule?.enabled && (
<Badge variant="warning">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
)}
</CardTitle>
<CardDescription className="mt-1">{meta.description}</CardDescription>
</div>
<Switch
checked={rule?.enabled ?? false}
onCheckedChange={(checked) =>
updateRule(taskType, { enabled: checked })
}
disabled={busy === `rule-${taskType}`}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label></Label>
<div className="flex rounded-lg border border-border p-0.5">
<button
type="button"
onClick={() => updateRule(taskType, { mode: "manual" })}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
rule?.mode !== "auto"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
</button>
<button
type="button"
onClick={() => updateRule(taskType, { mode: "auto" })}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
rule?.mode === "auto"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
</button>
</div>
{isOutbound && isAutoMode && (
<p className="text-[11px] text-warning">
/
</p>
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
min={0}
max={500}
value={rule?.dailyCap ?? 0}
onChange={(e) =>
updateRule(taskType, {
dailyCap: Math.max(0, Math.min(500, parseInt(e.target.value, 10) || 0)),
})
}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label>cron</Label>
<Input
value={rule?.schedule ?? ""}
onChange={(e) =>
updateRule(taskType, { schedule: e.target.value })
}
placeholder="0 9 * * *"
className="font-mono"
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{rule?.lastRunAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(rule.lastRunAt).toLocaleDateString("zh-TW", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
{!capReady && rule?.enabled && (
<span className="text-warning"></span>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => runNow(taskType)}
disabled={busy === `run-${taskType}` || !capReady}
>
{busy === `run-${taskType}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* 執行日誌 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4" />
</CardTitle>
<CardDescription> 60 </CardDescription>
</div>
<Button size="sm" variant="outline" onClick={() => load(true)}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
{logs.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground"></p>
) : (
<div className="space-y-2">
{logs.map((log) => (
<div
key={log.id}
className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2 text-[13px]"
>
<Badge
variant={
log.status === "success"
? "success"
: log.status === "failed"
? "destructive"
: "secondary"
}
>
{log.status}
</Badge>
<div className="min-w-0 flex-1">
<p className="truncate">
<span className="font-medium">
{AUTOMATION_TASK_META[log.taskType as AutomationTaskType]?.label ?? log.taskType}
</span>
<span className="ml-2 text-muted-foreground">{log.action}</span>
<span className="ml-2 text-muted-foreground">· {log.mode}</span>
</p>
{log.detail && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{log.detail}</p>
)}
{log.error && (
<p className="mt-0.5 text-xs text-destructive">{log.error}</p>
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{new Date(log.createdAt).toLocaleDateString("zh-TW", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={killOpen}
onOpenChange={setKillOpen}
title="緊急殺停所有自動化"
description="這會立即關閉所有帳號的自動化總開關,所有排程任務都會暫停。"
confirmText="確認殺停"
danger
onConfirm={killAll}
/>
</div>
);
}