Files
jiang13-bbs/frontend/app/admin/analytics/settings/page.tsx
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

197 lines
7.0 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 { useCallback, useEffect, useState } from "react";
import { Database, HardDrive, Trash2, Activity } from "lucide-react";
import { AdminSettingRow, AdminStatCard, AdminSwitch } from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import { apiStatsPurge, apiStatsSaveSettings, apiStatsSettings, type VisitSettingsPayload } from "@/lib/api";
import { StatsState, formatBytes, formatInt } from "../ui";
/** 保留天数输入边界(与后端 service 一致) */
const RETENTION_MIN = 7;
const RETENTION_MAX = 365;
export default function SettingsPage() {
const [data, setData] = useState<VisitSettingsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [enabled, setEnabled] = useState(true);
const [retention, setRetention] = useState(90);
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState("");
// 立即清理:天数输入 + 确认弹窗 + 受理提示
const [purgeDays, setPurgeDays] = useState(90);
const [purgeOpen, setPurgeOpen] = useState(false);
const [purging, setPurging] = useState(false);
const [purgeMsg, setPurgeMsg] = useState("");
const load = useCallback(() => {
apiStatsSettings()
.then((d) => {
setData(d);
setEnabled(d.enabled);
setRetention(d.retention_days);
setPurgeDays(d.retention_days);
setError("");
})
.catch(() => setError("加载失败,请稍后重试"))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
const save = (nextEnabled: boolean, nextRetention: number) => {
setSaving(true);
setSavedMsg("");
apiStatsSaveSettings(nextEnabled, nextRetention)
.then((d) => {
setData(d);
setEnabled(d.enabled);
setRetention(d.retention_days);
setSavedMsg("已保存");
setTimeout(() => setSavedMsg(""), 2000);
})
.catch(() => setError("保存失败,请稍后重试"))
.finally(() => setSaving(false));
};
const toggleEnabled = () => {
if (saving) return;
save(!enabled, retention);
};
const submitRetention = () => {
if (saving) return;
const v = Math.min(RETENTION_MAX, Math.max(RETENTION_MIN, Math.round(Number(retention) || 90)));
setRetention(v);
save(enabled, v);
};
const doPurge = () => {
setPurging(true);
apiStatsPurge(purgeDays)
.then(() => {
setPurgeMsg(`清理任务已受理(删除 ${purgeDays} 天前的明细),稍后刷新本页查看容量变化。`);
setTimeout(load, 3000);
})
.catch(() => setPurgeMsg("清理请求失败,请稍后重试。"))
.finally(() => {
setPurging(false);
setPurgeOpen(false);
});
};
if (!data) return <StatsState loading={loading} error={error || undefined} />;
const clampPurge = (v: number) =>
Math.min(RETENTION_MAX, Math.max(1, Math.round(Number(v) || 1)));
return (
<div className="flex flex-col gap-4">
{error ? <StatsState error={error} /> : null}
{purgeMsg ? (
<div className="panel p-4 text-[13px]" style={{ color: "var(--ink-2)" }}>
{purgeMsg}
</div>
) : null}
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
<AdminStatCard label="明细行数" value={formatInt(data.usage.rows)} icon={<Database size={17} />} />
<AdminStatCard
label="磁盘占用"
value={formatBytes(data.usage.bytes)}
hint="含索引(pg_total_relation_size)"
icon={<HardDrive size={17} />}
color="var(--ok)"
soft="var(--ok-soft, var(--panel-2))"
/>
<AdminStatCard label="保留天数" value={`${data.retention_days} 天`} hint="超期明细每日 04:30 自动清理" icon={<Activity size={17} />} color="var(--gold)" soft="var(--gold-soft)" />
<AdminStatCard
label="采集丢弃"
value={formatInt(data.dropped)}
hint="写入队列满时丢弃的事件数(进程启动以来)"
icon={<Trash2 size={17} />}
color="var(--ink-2)"
soft="var(--panel-2)"
/>
</div>
<AdminSettingRow
title="访问统计采集"
description="关闭后前端埋点与后端捕获中间件立即停止写入(返回 204),已有数据保留。"
control={<AdminSwitch checked={enabled} disabled={saving} onChange={toggleEnabled} label="访问统计采集开关" />}
/>
<AdminSettingRow
title="明细保留期"
description={`超过保留期的访问明细每日 04:30 自动分批清理;日聚合趋势不受影响。范围 ${RETENTION_MIN}–${RETENTION_MAX} 天。`}
>
<div className="flex items-center gap-2 flex-wrap">
<input
type="number"
min={RETENTION_MIN}
max={RETENTION_MAX}
value={retention}
onChange={(e) => setRetention(Number(e.target.value))}
onBlur={submitRetention}
onKeyDown={(e) => e.key === "Enter" && submitRetention()}
className="admin-field-input"
style={{ width: 120 }}
aria-label="明细保留天数"
/>
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
天
</span>
{saving ? <span className="meta text-[12px]">保存中…</span> : null}
{savedMsg ? (
<span className="text-[12px]" style={{ color: "var(--ok)" }}>
{savedMsg}
</span>
) : null}
</div>
</AdminSettingRow>
<AdminSettingRow
title="立即清理"
description="手动删除指定天数之前的明细记录,异步执行,不影响正常访问。"
>
<div className="flex items-center gap-2 flex-wrap">
<input
type="number"
min={1}
max={RETENTION_MAX}
value={purgeDays}
onChange={(e) => setPurgeDays(clampPurge(Number(e.target.value)))}
className="admin-field-input"
style={{ width: 120 }}
aria-label="清理多少天前的明细"
/>
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
天前的明细
</span>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setPurgeOpen(true)}
disabled={purging}
>
<Trash2 size={14} /> 立即清理
</button>
</div>
</AdminSettingRow>
<ConfirmDialog
open={purgeOpen}
title="确认立即清理"
message={`将删除 ${purgeDays} 天前的全部访问明细(当前共 ${formatInt(data.usage.rows)} 行)。该操作不可恢复,日聚合趋势不受影响。确认执行?`}
confirmLabel="确认清理"
danger
busy={purging}
onConfirm={doPurge}
onCancel={() => setPurgeOpen(false)}
/>
</div>
);
}