- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
197 lines
7.0 KiB
TypeScript
197 lines
7.0 KiB
TypeScript
"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>
|
||
);
|
||
}
|