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

385 lines
15 KiB
TypeScript
Raw 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 { 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>
);
}