后台管理系统后端 API:在线配置编辑、手动订阅管理、日志查看(logs 目录避开 gitignore)

This commit is contained in:
2569718930@qq.com
2026-05-18 20:18:42 +08:00
parent 073d0efbb0
commit 67701a4715
9 changed files with 481 additions and 1 deletions
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config fetch failed" }); }
}
export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config update failed" }); }
}
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
method: "POST", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription extend failed" }); }
}
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
method: "POST", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" }); }
}
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/logs`);
const level = req.nextUrl.searchParams.get("level");
const lines = req.nextUrl.searchParams.get("lines");
if (level) url.searchParams.set("level", level);
if (lines) url.searchParams.set("lines", lines);
const res = await fetch(url.toString(), { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Log fetch failed" }); }
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { requireOpsAdmin } from "@/lib/ops-admin";
import { LogsPageClient } from "@/components/ops/view-logs/LogsPageClient";
export const metadata: Metadata = { title: "日志查看 — PolyWeather Ops" };
export default async function LogsPage() {
await requireOpsAdmin("/ops/view-logs");
return <LogsPageClient />;
}
@@ -38,7 +38,7 @@ const navGroups = [
items: [
{ href: "/ops/config", icon: Settings, label: "系统配置" },
{ href: "/ops/subscriptions", icon: ScrollText, label: "订阅操作" },
{ href: "/ops/logs", icon: FileText, label: "日志查看" },
{ href: "/ops/view-logs", icon: FileText, label: "日志查看" },
],
},
{
@@ -0,0 +1,120 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCcw, Pause, Play } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function LogsPageClient() {
const [lines, setLines] = useState<string[]>([]);
const [level, setLevel] = useState("");
const [limit, setLimit] = useState(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const load = useCallback(async () => {
try {
const params = new URLSearchParams({ lines: String(limit) });
if (level) params.set("level", level);
const res = await fetch(`/api/ops/view-logs?${params}`);
if (res.ok) {
const data = (await res.json()) as { lines?: string[] };
setLines(data.lines ?? []);
setError("");
} else {
setError(await res.text().catch(() => "fetch failed"));
}
} catch {
setError("日志 API 尚未就绪(需要后端支持)");
}
}, [level, limit]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (autoRefresh) {
timerRef.current = setInterval(load, 5000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [autoRefresh, load]);
const levelColor = (line: string) => {
if (line.includes("ERROR")) return "text-red-400";
if (line.includes("WARNING")) return "text-amber-400";
if (line.includes("INFO")) return "text-slate-300";
return "text-slate-500";
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-white"></h1>
<div className="flex gap-2 flex-wrap">
<select
value={level}
onChange={(e) => setLevel(e.target.value)}
className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none"
>
<option value=""></option>
<option value="info">INFO</option>
<option value="warning">WARNING</option>
<option value="error">ERROR</option>
</select>
<select
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none"
>
<option value="50">50 </option>
<option value="100">100 </option>
<option value="200">200 </option>
<option value="500">500 </option>
</select>
<Button
variant="outline"
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
{autoRefresh ? "暂停" : "自动刷新"}
</Button>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Card>
<CardContent className="p-0">
{error ? (
<div className="p-6 text-amber-400 text-sm">{error}</div>
) : (
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto font-mono text-xs leading-relaxed">
<div className="min-w-[800px]">
{lines.map((line, i) => (
<div
key={i}
className={`px-4 py-0.5 border-b border-white/[0.02] hover:bg-white/[0.03] ${levelColor(line)}`}
>
{line}
</div>
))}
{lines.length === 0 && (
<div className="p-6 text-center text-slate-500"></div>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}