508 lines
18 KiB
TypeScript
508 lines
18 KiB
TypeScript
|
|
"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="設定定時海巡、生成、發文與互動任務。需另啟 worker(npm 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>
|
|||
|
|
);
|
|||
|
|
}
|