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

508 lines
18 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"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>
);
}