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

385 lines
15 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Loader2, Package, PlugZap, Settings2, ShieldCheck, UserRound } from "lucide-react";
import { ApiKeysForm } from "@/components/settings/api-keys-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/layout/page-header";
import { ThemeToggle } from "@/components/theme-toggle";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InlineAlert } from "@/components/ui/inline-alert";
import { PROVIDER_OPTIONS } from "@/lib/ai/provider";
import type { ProviderApiKeys, ProviderId } from "@/lib/ai/keys";
import { useActionFeedback } from "@/lib/use-action-feedback";
interface SettingsData {
aiProvider: string;
aiModel: string;
researchAiProvider?: string | null;
researchAiModel?: string | null;
draftsPerScan: number;
matrixRows: number;
scanCron: string;
apiKeys?: ProviderApiKeys;
apiKeysConfigured?: Partial<Record<ProviderId, boolean>>;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsData | null>(null);
const [apiKeyInputs, setApiKeyInputs] = useState<ProviderApiKeys>({});
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
async function load() {
try {
const res = await fetch("/api/settings");
const data = (await res.json().catch(() => ({}))) as SettingsData;
if (!res.ok || !data.aiProvider) {
setLoadError("無法載入設定,請重新整理頁面");
return;
}
setSettings(data);
setApiKeyInputs(data.apiKeys ?? {});
} catch {
setLoadError("網路連線異常,請重新整理頁面");
}
}
useEffect(() => {
load();
}, []);
async function handleSave() {
if (!settings) return;
clearFeedback();
setSaving(true);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
researchAiProvider: settings.researchAiProvider,
researchAiModel: settings.researchAiModel,
draftsPerScan: settings.draftsPerScan,
matrixRows: settings.matrixRows,
scanCron: settings.scanCron,
apiKeys: apiKeyInputs,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法儲存設定", "儲存失敗");
return;
}
setSettings(data);
showSuccess("設定已儲存");
} catch {
showError("網路連線異常,請稍後再試", "儲存失敗");
} finally {
setSaving(false);
}
}
if (loadError) {
return (
<div>
<PageHeader eyebrow="SYSTEM" title="設定" description="只留下會直接影響海巡與找 TA 的設定。" />
<InlineAlert type="error" title="載入失敗" message={loadError} />
<Button onClick={load} className="mt-4"></Button>
</div>
);
}
if (!settings) {
return (
<div className="space-y-4">
<div className="skeleton h-12 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
<div className="skeleton h-64 animate-pulse" />
</div>
);
}
const provider = PROVIDER_OPTIONS.find((p) => p.value === settings.aiProvider);
const researchProvider = PROVIDER_OPTIONS.find(
(p) => p.value === (settings.researchAiProvider ?? settings.aiProvider)
);
const currentProviderConfigured = settings.apiKeysConfigured?.[settings.aiProvider as ProviderId];
return (
<div>
<PageHeader
eyebrow="SYSTEM"
title="設定"
description="只留下會直接影響海巡與找 TA 的設定。"
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><ShieldCheck className="h-4 w-4 text-success" /></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl bg-muted/70 p-3"><b className="block"></b><span className="text-xs text-muted-foreground"></span></div>
<div className="rounded-xl bg-muted/70 p-3"><b className="block"> 4 </b><span className="text-xs text-muted-foreground"> 12 </span></div>
<div className="rounded-xl bg-muted/70 p-3"><b className="block"> 4 × 5</b><span className="text-xs text-muted-foreground"></span></div>
<div className="rounded-xl bg-muted/70 p-3"><b className="block"> 40 </b><span className="text-xs text-muted-foreground"></span></div>
</div>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><PlugZap className="h-5 w-5" /></div>
<div className="min-w-0 flex-1"><p className="font-semibold"></p><p className="mt-1 text-xs text-muted-foreground">Chrome Threads API</p></div>
<Button asChild variant="outline" size="sm"><Link href="/connections"></Link></Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><UserRound className="h-5 w-5" /></div>
<div className="min-w-0 flex-1"><p className="font-semibold"></p><p className="mt-1 text-xs text-muted-foreground"></p></div>
<Button asChild variant="outline" size="sm"><Link href="/accounts"></Link></Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><Package className="h-5 w-5" /></div>
<div className="min-w-0 flex-1"><p className="font-semibold"></p><p className="mt-1 text-xs text-muted-foreground"> TA </p></div>
<Button asChild variant="outline" size="sm"><Link href="/products"></Link></Button>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ThemeToggle />
</CardContent>
</Card>
<Card id="ai-keys">
<CardHeader>
<CardTitle>AI API Key</CardTitle>
<CardDescription>
Threads
{currentProviderConfigured ? " 已設定 key。" : " 尚未設定 key。"}
</CardDescription>
</CardHeader>
<CardContent>
<ApiKeysForm
values={apiKeyInputs}
configured={settings.apiKeysConfigured ?? {}}
onChange={(providerId, value) =>
setApiKeyInputs((prev) => ({ ...prev, [providerId]: value }))
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>稿使 AI </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>AI </Label>
<Select
value={settings.aiProvider}
onValueChange={(value) => {
const nextProvider = PROVIDER_OPTIONS.find((option) => option.value === value);
setSettings({
...settings,
aiProvider: value,
aiModel: nextProvider?.models[0] ?? settings.aiModel,
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={settings.aiModel}
onValueChange={(value) => setSettings({ ...settings, aiModel: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(provider?.models ?? []).map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-lg border border-border bg-muted p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label> AI </Label>
<Select
value={settings.researchAiProvider ?? settings.aiProvider}
onValueChange={(value) => {
const nextProvider = PROVIDER_OPTIONS.find((option) => option.value === value);
setSettings({
...settings,
researchAiProvider: value,
researchAiModel:
nextProvider?.models[0] ?? settings.researchAiModel ?? settings.aiModel,
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={settings.researchAiModel ?? settings.aiModel}
onValueChange={(value) => setSettings({ ...settings, researchAiModel: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(researchProvider?.models ?? []).map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>稿</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>稿</Label>
<Input
type="number"
min={1}
max={10}
value={settings.draftsPerScan}
onChange={(event) =>
setSettings({
...settings,
draftsPerScan: parseInt(event.target.value, 10) || 4,
})
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
min={3}
max={20}
value={settings.matrixRows}
onChange={(event) =>
setSettings({
...settings,
matrixRows: parseInt(event.target.value, 10) || 7,
})
}
/>
</div>
<div className="space-y-2">
<Label>cron</Label>
<Input
value={settings.scanCron}
onChange={(event) => setSettings({ ...settings, scanCron: event.target.value })}
placeholder="0 9 * * *"
className="font-mono"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-[13px] text-muted-foreground">
<p>
<strong></strong> Threads API
Brave
</p>
<p>
<code>.env</code> <code>BRAVE_SEARCH_API_KEY</code>
<a
href="https://api-dashboard.search.brave.com/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Brave Search API
</a>
8 Brave <code>SCAN_BRAVE_MAX_QUERIES</code> 調
</p>
</CardContent>
</Card>
<Button onClick={handleSave} disabled={saving} size="lg" className="w-full sm:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Settings2 className="h-4 w-4" />}
{saving ? "儲存中…" : "儲存設定"}
</Button>
</div>
</div>
);
}