From 83a06ee59e4c02ee7ec1e50dbfc0606885425533 Mon Sep 17 00:00:00 2001 From: lsy Date: Sun, 22 Feb 2026 17:45:04 +0800 Subject: [PATCH] =?UTF-8?q?add(checkin):=20=E6=96=B0=E5=A2=9E=E7=AD=BE?= =?UTF-8?q?=E5=88=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 一键签到写回结果并跳过今日已签站点 - 管理台展示今日签到状态并区分未签到/失败 - 系统设置增加定时签到开关与时间 --- apps/ui/src/App.tsx | 188 ++++++- apps/ui/src/core/constants.ts | 21 +- apps/ui/src/core/types.ts | 48 +- apps/ui/src/core/utils.ts | 19 + apps/ui/src/features/CheckinSitesView.tsx | 483 ++++++++++++++++++ apps/ui/src/features/SettingsView.tsx | 45 ++ .../migrations/0002_add_checkin_sites.sql | 15 + apps/worker/src/db/schema.sql | 16 + apps/worker/src/index.ts | 2 + apps/worker/src/routes/checkin-sites.ts | 156 ++++++ apps/worker/src/routes/settings.ts | 57 +++ apps/worker/src/services/checkin-site-repo.ts | 156 ++++++ .../worker/src/services/checkin-site-types.ts | 17 + apps/worker/src/services/checkin.ts | 344 +++++++++++++ apps/worker/src/services/settings.ts | 22 + apps/worker/src/utils/time.ts | 12 + helloagents/CHANGELOG.md | 40 ++ helloagents/modules/_index.md | 3 +- helloagents/modules/admin-ui.md | 4 + helloagents/modules/checkin.md | 26 + helloagents/modules/settings.md | 2 + 21 files changed, 1672 insertions(+), 4 deletions(-) create mode 100644 apps/ui/src/features/CheckinSitesView.tsx create mode 100644 apps/worker/migrations/0002_add_checkin_sites.sql create mode 100644 apps/worker/src/routes/checkin-sites.ts create mode 100644 apps/worker/src/services/checkin-site-repo.ts create mode 100644 apps/worker/src/services/checkin-site-types.ts create mode 100644 apps/worker/src/services/checkin.ts create mode 100644 helloagents/modules/checkin.md diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 0aacd4d..1c5571e 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -9,6 +9,7 @@ import { import { createApiFetch } from "./core/api"; import { initialChannelForm, + initialCheckinSiteForm, initialData, initialSettingsForm, tabs, @@ -17,6 +18,9 @@ import type { AdminData, Channel, ChannelForm, + CheckinSite, + CheckinSiteForm, + CheckinSummary, DashboardData, Settings, SettingsForm, @@ -27,6 +31,7 @@ import type { import { toggleStatus } from "./core/utils"; import { AppLayout } from "./features/AppLayout"; import { ChannelsView } from "./features/ChannelsView"; +import { CheckinSitesView } from "./features/CheckinSitesView"; import { DashboardView } from "./features/DashboardView"; import { LoginView } from "./features/LoginView"; import { ModelsView } from "./features/ModelsView"; @@ -53,6 +58,7 @@ const tabToPath: Record = { models: "/models", tokens: "/tokens", usage: "/usage", + "checkin-sites": "/checkin-sites", settings: "/settings", }; @@ -62,6 +68,7 @@ const pathToTab: Record = { "/models": "models", "/tokens": "tokens", "/usage": "usage", + "/checkin-sites": "checkin-sites", "/settings": "settings", }; @@ -95,8 +102,18 @@ const App = () => { const [channelForm, setChannelForm] = useState(() => ({ ...initialChannelForm, })); + const [editingCheckinSite, setEditingCheckinSite] = + useState(null); + const [checkinSiteForm, setCheckinSiteForm] = useState(() => ({ + ...initialCheckinSiteForm, + })); const [isChannelModalOpen, setChannelModalOpen] = useState(false); const [isTokenModalOpen, setTokenModalOpen] = useState(false); + const [isCheckinModalOpen, setCheckinModalOpen] = useState(false); + const [checkinSummary, setCheckinSummary] = useState( + null, + ); + const [checkinLastRun, setCheckinLastRun] = useState(null); const updateToken = useCallback((next: string | null) => { setToken(next); @@ -147,6 +164,13 @@ const App = () => { setData((prev) => ({ ...prev, settings })); }, [apiFetch]); + const loadCheckinSites = useCallback(async () => { + const result = await apiFetch<{ sites: CheckinSite[] }>( + "/api/checkin-sites", + ); + setData((prev) => ({ ...prev, checkinSites: result.sites })); + }, [apiFetch]); + const loadTab = useCallback( async (tabId: TabId) => { setLoading(true); @@ -170,6 +194,9 @@ const App = () => { if (tabId === "settings") { await loadSettings(); } + if (tabId === "checkin-sites") { + await loadCheckinSites(); + } } catch (error) { setNotice((error as Error).message); } finally { @@ -181,6 +208,7 @@ const App = () => { loadDashboard, loadModels, loadSettings, + loadCheckinSites, loadTokens, loadUsage, ], @@ -211,6 +239,10 @@ const App = () => { log_retention_days: String(data.settings.log_retention_days ?? 30), session_ttl_hours: String(data.settings.session_ttl_hours ?? 12), admin_password: "", + checkin_schedule_enabled: Boolean( + data.settings.checkin_schedule_enabled ?? false, + ), + checkin_schedule_time: data.settings.checkin_schedule_time ?? "00:10", }); }, [data.settings]); @@ -250,6 +282,13 @@ const App = () => { [], ); + const handleCheckinFormChange = useCallback( + (patch: Partial) => { + setCheckinSiteForm((prev) => ({ ...prev, ...patch })); + }, + [], + ); + const handleChannelPageChange = useCallback((next: number) => { setChannelPage(next); }, []); @@ -295,6 +334,13 @@ const App = () => { setNotice(""); }, []); + const openCheckinCreate = useCallback(() => { + setEditingCheckinSite(null); + setCheckinSiteForm({ ...initialCheckinSiteForm }); + setCheckinModalOpen(true); + setNotice(""); + }, []); + const startChannelEdit = useCallback((channel: Channel) => { setEditingChannel(channel); setChannelForm({ @@ -311,6 +357,24 @@ const App = () => { setTokenModalOpen(false); }, []); + const closeCheckinModal = useCallback(() => { + setCheckinModalOpen(false); + }, []); + + const startCheckinEdit = useCallback((site: CheckinSite) => { + setEditingCheckinSite(site); + setCheckinSiteForm({ + name: site.name ?? "", + base_url: site.base_url ?? "", + checkin_url: site.checkin_url ?? "", + token: site.token ?? "", + userid: site.userid ?? "", + status: site.status ?? "active", + }); + setCheckinModalOpen(true); + setNotice(""); + }, []); + const handleChannelSubmit = useCallback( async (event: Event) => { event.preventDefault(); @@ -392,14 +456,58 @@ const App = () => { [apiFetch, loadTokens], ); + const handleCheckinSubmit = useCallback( + async (event: Event) => { + event.preventDefault(); + const payload = { + name: checkinSiteForm.name.trim(), + base_url: checkinSiteForm.base_url.trim(), + checkin_url: checkinSiteForm.checkin_url.trim() || null, + token: checkinSiteForm.token.trim(), + userid: checkinSiteForm.userid.trim(), + status: checkinSiteForm.status, + }; + try { + if (editingCheckinSite) { + await apiFetch(`/api/checkin-sites/${editingCheckinSite.id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); + setNotice("签到站点已更新"); + } else { + await apiFetch("/api/checkin-sites", { + method: "POST", + body: JSON.stringify(payload), + }); + setNotice("签到站点已创建"); + } + setCheckinModalOpen(false); + setEditingCheckinSite(null); + setCheckinSiteForm({ ...initialCheckinSiteForm }); + await loadCheckinSites(); + } catch (error) { + setNotice((error as Error).message); + } + }, + [ + apiFetch, + checkinSiteForm, + editingCheckinSite, + loadCheckinSites, + ], + ); + const handleSettingsSubmit = useCallback( async (event: Event) => { event.preventDefault(); const retention = Number(settingsForm.log_retention_days); const sessionTtlHours = Number(settingsForm.session_ttl_hours); - const payload: Record = { + const payload: Record = { log_retention_days: retention, session_ttl_hours: sessionTtlHours, + checkin_schedule_enabled: settingsForm.checkin_schedule_enabled, + checkin_schedule_time: + settingsForm.checkin_schedule_time.trim() || "00:10", }; const password = settingsForm.admin_password.trim(); if (password) { @@ -524,6 +632,64 @@ const App = () => { [apiFetch, loadTokens], ); + const handleCheckinToggle = useCallback( + async (id: string, status: string) => { + try { + const next = toggleStatus(status); + await apiFetch(`/api/checkin-sites/${id}`, { + method: "PATCH", + body: JSON.stringify({ status: next }), + }); + await loadCheckinSites(); + setNotice(`签到站点已${next === "active" ? "启用" : "停用"}`); + } catch (error) { + setNotice((error as Error).message); + } + }, + [apiFetch, loadCheckinSites], + ); + + const handleCheckinDelete = useCallback( + async (id: string) => { + try { + await apiFetch(`/api/checkin-sites/${id}`, { method: "DELETE" }); + await loadCheckinSites(); + setNotice("签到站点已删除"); + } catch (error) { + setNotice((error as Error).message); + } + }, + [apiFetch, loadCheckinSites], + ); + + const handleCheckinRunAll = useCallback(async () => { + try { + const result = await apiFetch<{ + results: Array<{ + id: string; + name: string; + status: "success" | "failed" | "skipped"; + message: string; + checkin_date?: string | null; + }>; + summary: CheckinSummary; + runs_at: string; + }>("/api/checkin-sites/checkin-all", { + method: "POST", + }); + await loadCheckinSites(); + setCheckinSummary(result.summary); + setCheckinLastRun(result.runs_at); + setNotice( + result.summary.failed > 0 + ? "一键签到完成,有部分站点失败。" + : "一键签到完成。", + ); + } catch (error) { + setNotice((error as Error).message); + } + }, [apiFetch, loadCheckinSites]); + const handleUsageRefresh = useCallback(async () => { try { await loadUsage(); @@ -636,6 +802,26 @@ const App = () => { /> ); } + if (activeTab === "checkin-sites") { + return ( + + ); + } return (
未知模块 diff --git a/apps/ui/src/core/constants.ts b/apps/ui/src/core/constants.ts index abade80..accd833 100644 --- a/apps/ui/src/core/constants.ts +++ b/apps/ui/src/core/constants.ts @@ -1,4 +1,10 @@ -import type { AdminData, ChannelForm, SettingsForm, TabItem } from "./types"; +import type { + AdminData, + ChannelForm, + CheckinSiteForm, + SettingsForm, + TabItem, +} from "./types"; export const apiBase = import.meta.env.VITE_API_BASE ?? ""; @@ -8,6 +14,7 @@ export const tabs: TabItem[] = [ { id: "models", label: "模型广场" }, { id: "tokens", label: "令牌管理" }, { id: "usage", label: "使用日志" }, + { id: "checkin-sites", label: "签到站点" }, { id: "settings", label: "系统设置" }, ]; @@ -18,6 +25,7 @@ export const initialData: AdminData = { usage: [], dashboard: null, settings: null, + checkinSites: [], }; export const initialChannelForm: ChannelForm = { @@ -31,4 +39,15 @@ export const initialSettingsForm: SettingsForm = { log_retention_days: "30", session_ttl_hours: "12", admin_password: "", + checkin_schedule_enabled: false, + checkin_schedule_time: "00:10", +}; + +export const initialCheckinSiteForm: CheckinSiteForm = { + name: "", + base_url: "", + checkin_url: "", + token: "", + userid: "", + status: "active", }; diff --git a/apps/ui/src/core/types.ts b/apps/ui/src/core/types.ts index 5352aac..4445370 100644 --- a/apps/ui/src/core/types.ts +++ b/apps/ui/src/core/types.ts @@ -54,6 +54,8 @@ export type Settings = { log_retention_days: number; session_ttl_hours: number; admin_password_set?: boolean; + checkin_schedule_enabled?: boolean; + checkin_schedule_time?: string; }; export type ModelChannel = { @@ -73,6 +75,7 @@ export type AdminData = { usage: UsageLog[]; dashboard: DashboardData | null; settings: Settings | null; + checkinSites: CheckinSite[]; }; export type TabId = @@ -81,7 +84,8 @@ export type TabId = | "models" | "tokens" | "usage" - | "settings"; + | "settings" + | "checkin-sites"; export type TabItem = { id: TabId; @@ -99,4 +103,46 @@ export type SettingsForm = { log_retention_days: string; session_ttl_hours: string; admin_password: string; + checkin_schedule_enabled: boolean; + checkin_schedule_time: string; +}; + +export type CheckinSite = { + id: string; + name: string; + base_url: string; + checkin_url?: string | null; + token: string; + userid?: string | null; + last_checkin_date?: string | null; + last_checkin_status?: string | null; + last_checkin_message?: string | null; + last_checkin_at?: string | null; + status: string; + created_at?: string | null; + updated_at?: string | null; +}; + +export type CheckinSiteForm = { + name: string; + base_url: string; + checkin_url: string; + token: string; + userid: string; + status: string; +}; + +export type CheckinResultItem = { + id: string; + name: string; + status: "success" | "failed" | "skipped"; + message: string; + checkin_date?: string | null; +}; + +export type CheckinSummary = { + total: number; + success: number; + failed: number; + skipped: number; }; diff --git a/apps/ui/src/core/utils.ts b/apps/ui/src/core/utils.ts index cd36e32..96dbece 100644 --- a/apps/ui/src/core/utils.ts +++ b/apps/ui/src/core/utils.ts @@ -36,6 +36,25 @@ export const formatDateTime = (value?: string | null) => { export const toggleStatus = (value: string) => value === "active" ? "disabled" : "active"; +/** + * Returns Beijing date string (YYYY-MM-DD). + * + * Args: + * date: Optional date value. + * + * Returns: + * Beijing date string. + */ +export const getBeijingDateString = (date: Date = new Date()) => { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return formatter.format(date); +}; + export type PageItem = number | "ellipsis"; export const buildPageItems = (current: number, total: number): PageItem[] => { diff --git a/apps/ui/src/features/CheckinSitesView.tsx b/apps/ui/src/features/CheckinSitesView.tsx new file mode 100644 index 0000000..4dd6594 --- /dev/null +++ b/apps/ui/src/features/CheckinSitesView.tsx @@ -0,0 +1,483 @@ +import type { CheckinSite, CheckinSiteForm, CheckinSummary } from "../core/types"; +import { formatDateTime, getBeijingDateString } from "../core/utils"; + +type CheckinSitesViewProps = { + sites: CheckinSite[]; + form: CheckinSiteForm; + isModalOpen: boolean; + editingSite: CheckinSite | null; + summary: CheckinSummary | null; + lastRun: string | null; + onCreate: () => void; + onEdit: (site: CheckinSite) => void; + onCloseModal: () => void; + onSubmit: (event: Event) => void; + onFormChange: (patch: Partial) => void; + onToggle: (id: string, status: string) => void; + onDelete: (id: string) => void; + onRunAll: () => void; +}; + +export const CheckinSitesView = ({ + sites, + form, + isModalOpen, + editingSite, + summary, + lastRun, + onCreate, + onEdit, + onCloseModal, + onSubmit, + onFormChange, + onToggle, + onDelete, + onRunAll, +}: CheckinSitesViewProps) => { + const isEditing = Boolean(editingSite); + const today = getBeijingDateString(); + const openManual = (site: CheckinSite) => { + const url = + site.checkin_url && site.checkin_url.trim() + ? site.checkin_url.trim() + : site.base_url; + if (!url) { + return; + } + window.open(url, "_blank", "noopener"); + }; + return ( +
+
+
+
+

+ 签到站点 +

+

+ 维护站点信息,一键签到仅对启用站点执行。 +

+
+
+ {summary && ( +
+ 总计 {summary.total} + 成功 {summary.success} + 已签 {summary.skipped} + 失败 {summary.failed} +
+ )} + + +
+
+
+
+ {sites.length === 0 ? ( +
+ 暂无站点,请先创建。 +
+ ) : ( + sites.map((site) => { + const isActive = site.status === "active"; + const isToday = site.last_checkin_date === today; + const status = isToday ? site.last_checkin_status : null; + const message = isToday ? site.last_checkin_message : null; + return ( +
+
+
+

+ {site.name} +

+

+ {site.base_url} +

+
+ + {isActive ? "启用" : "禁用"} + +
+
+
+

签到 URL

+

+ {site.checkin_url || "-"} +

+
+
+

User ID

+

+ {site.userid || "-"} +

+
+
+

创建时间

+

+ {formatDateTime(site.created_at)} +

+
+
+

今日签到

+

+ {status + ? status === "success" + ? "成功" + : status === "skipped" + ? "已签" + : "签到失败" + : "未签到"} +

+ {message && ( +

+ {message} +

+ )} +
+
+
+ + + + +
+
+ ); + }) + )} +
+ +
+ {lastRun && ( +

最近执行时间:{formatDateTime(lastRun)}

+ )} +
+ {isModalOpen && ( +
+
+
+
+

+ {isEditing ? "编辑站点" : "新增站点"} +

+

+ {isEditing + ? `正在编辑:${editingSite?.name ?? ""}` + : "填写站点信息并保存。"} +

+
+ +
+
+
+ + + onFormChange({ + name: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+ + + onFormChange({ + base_url: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+ + + onFormChange({ + checkin_url: (event.currentTarget as HTMLInputElement) + .value, + }) + } + /> +
+
+ + + onFormChange({ + token: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+ + + onFormChange({ + userid: (event.currentTarget as HTMLInputElement) + .value, + }) + } + /> +
+
+ + +
+
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/ui/src/features/SettingsView.tsx b/apps/ui/src/features/SettingsView.tsx index fbb6424..adae2b0 100644 --- a/apps/ui/src/features/SettingsView.tsx +++ b/apps/ui/src/features/SettingsView.tsx @@ -73,6 +73,51 @@ export const SettingsView = ({ }} />
+
+ +
+ { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + checkin_schedule_enabled: Boolean(target?.checked), + }); + }} + /> + 启用(中国时间) +
+
+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + checkin_schedule_time: target?.value ?? "", + }); + }} + /> +