feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View File

@@ -0,0 +1,196 @@
"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>
);
}