add(checkin): 新增签到功能

- 一键签到写回结果并跳过今日已签站点
- 管理台展示今日签到状态并区分未签到/失败
- 系统设置增加定时签到开关与时间
This commit is contained in:
lsy committed 2026-02-22 17:45:04 +08:00
1 parent bdde876c1f
commit 83a06ee59e
21 files changed
+1672 -4

No files matched your search

+187 -1
View File
@@ -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<TabId, string> = {
models: "/models",
tokens: "/tokens",
usage: "/usage",
"checkin-sites": "/checkin-sites",
settings: "/settings",
};
@@ -62,6 +68,7 @@ const pathToTab: Record<string, TabId> = {
"/models": "models",
"/tokens": "tokens",
"/usage": "usage",
"/checkin-sites": "checkin-sites",
"/settings": "settings",
};
@@ -95,8 +102,18 @@ const App = () => {
const [channelForm, setChannelForm] = useState<ChannelForm>(() => ({
...initialChannelForm,
}));
const [editingCheckinSite, setEditingCheckinSite] =
useState<CheckinSite | null>(null);
const [checkinSiteForm, setCheckinSiteForm] = useState<CheckinSiteForm>(() => ({
...initialCheckinSiteForm,
}));
const [isChannelModalOpen, setChannelModalOpen] = useState(false);
const [isTokenModalOpen, setTokenModalOpen] = useState(false);
const [isCheckinModalOpen, setCheckinModalOpen] = useState(false);
const [checkinSummary, setCheckinSummary] = useState<CheckinSummary | null>(
null,
);
const [checkinLastRun, setCheckinLastRun] = useState<string | null>(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<CheckinSiteForm>) => {
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<string, number | string> = {
const payload: Record<string, number | string | boolean> = {
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 (
<CheckinSitesView
sites={data.checkinSites}
form={checkinSiteForm}
isModalOpen={isCheckinModalOpen}
editingSite={editingCheckinSite}
summary={checkinSummary}
lastRun={checkinLastRun}
onCreate={openCheckinCreate}
onEdit={startCheckinEdit}
onCloseModal={closeCheckinModal}
onSubmit={handleCheckinSubmit}
onFormChange={handleCheckinFormChange}
onToggle={handleCheckinToggle}
onDelete={handleCheckinDelete}
onRunAll={handleCheckinRunAll}
/>
);
}
return (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
未知模块
+20 -1
View File
@@ -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",
};
+47 -1
View File
@@ -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;
};
+19
View File
@@ -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[] => {
+483
View File
@@ -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<CheckinSiteForm>) => 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 (
<div class="space-y-5">
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
签到站点
</h3>
<p class="text-xs text-stone-500">
维护站点信息,一键签到仅对启用站点执行。
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
{summary && (
<div class="flex flex-wrap items-center gap-2 text-xs text-stone-500">
<span>总计 {summary.total}</span>
<span class="text-emerald-600">成功 {summary.success}</span>
<span class="text-stone-500">已签 {summary.skipped}</span>
<span class="text-rose-600">失败 {summary.failed}</span>
</div>
)}
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onRunAll}
>
一键签到
</button>
<button
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onCreate}
>
新增站点
</button>
</div>
</div>
<div class="mt-4">
<div class="space-y-3 md:hidden">
{sites.length === 0 ? (
<div class="rounded-xl border border-stone-200 bg-white px-4 py-10 text-center text-sm text-stone-500">
暂无站点,请先创建。
</div>
) : (
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 (
<div
class={`rounded-xl border p-4 shadow-sm ${
editingSite?.id === site.id
? "border-amber-200 bg-amber-50/60"
: "border-stone-200 bg-white"
}`}
key={site.id}
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-stone-900">
{site.name}
</p>
<p class="truncate text-xs text-stone-500">
{site.base_url}
</p>
</div>
<span
class={`inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-widest ${
isActive
? "border-emerald-100 bg-emerald-50 text-emerald-600"
: "border-stone-200 bg-stone-100 text-stone-500"
}`}
>
{isActive ? "启用" : "禁用"}
</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-stone-500">
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
<p>签到 URL</p>
<p class="mt-1 truncate font-semibold text-stone-700">
{site.checkin_url || "-"}
</p>
</div>
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
<p>User ID</p>
<p class="mt-1 truncate font-semibold text-stone-700">
{site.userid || "-"}
</p>
</div>
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
<p>创建时间</p>
<p class="mt-1 font-semibold text-stone-700">
{formatDateTime(site.created_at)}
</p>
</div>
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
<p>今日签到</p>
<p class="mt-1 font-semibold text-stone-700">
{status
? status === "success"
? "成功"
: status === "skipped"
? "已签"
: "签到失败"
: "未签到"}
</p>
{message && (
<p class="mt-1 truncate text-[11px] text-stone-500">
{message}
</p>
)}
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2">
<button
class="h-9 w-full rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => openManual(site)}
>
手动签到
</button>
<button
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onToggle(site.id, site.status)}
>
{isActive ? "禁用" : "启用"}
</button>
<button
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onEdit(site)}
>
编辑
</button>
<button
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onDelete(site.id)}
>
删除
</button>
</div>
</div>
);
})
)}
</div>
<div class="hidden overflow-hidden rounded-xl border border-stone-200 md:block">
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,0.7fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1.4fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
<div>站点</div>
<div>状态</div>
<div>签到 URL</div>
<div>User ID</div>
<div>今日签到</div>
<div>创建时间</div>
<div>操作</div>
</div>
{sites.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-stone-500">
暂无站点,请先创建。
</div>
) : (
<div class="divide-y divide-stone-100">
{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 (
<div
class={`grid grid-cols-[minmax(0,1.4fr)_minmax(0,0.7fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1.4fr)] items-center gap-3 px-4 py-4 text-sm ${
editingSite?.id === site.id
? "bg-amber-50/60"
: "bg-white"
}`}
key={site.id}
>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-stone-900">
{site.name}
</span>
<span class="truncate text-xs text-stone-500">
{site.base_url}
</span>
</div>
<div>
<span
class={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${
isActive
? "border-emerald-100 bg-emerald-50 text-emerald-600"
: "border-stone-200 bg-stone-100 text-stone-500"
}`}
>
{isActive ? "启用" : "禁用"}
</span>
</div>
<div class="text-xs text-stone-600">
{site.checkin_url || "-"}
</div>
<div class="text-xs text-stone-600">
{site.userid || "-"}
</div>
<div
class="text-xs text-stone-600"
title={message ?? ""}
>
{(() => {
if (!status) {
return "未签到";
}
if (status === "success") {
return "成功";
}
if (status === "skipped") {
return "已签";
}
return "签到失败";
})()}
</div>
<div class="text-xs text-stone-600">
{formatDateTime(site.created_at)}
</div>
<div class="flex flex-wrap gap-2">
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => openManual(site)}
>
手动签到
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onToggle(site.id, site.status)}
>
{isActive ? "禁用" : "启用"}
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onEdit(site)}
>
编辑
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onDelete(site.id)}
>
删除
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{lastRun && (
<p class="mt-3 text-xs text-stone-500">最近执行时间:{formatDateTime(lastRun)}</p>
)}
</div>
{isModalOpen && (
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
<div class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
{isEditing ? "编辑站点" : "新增站点"}
</h3>
<p class="text-xs text-stone-500">
{isEditing
? `正在编辑:${editingSite?.name ?? ""}`
: "填写站点信息并保存。"}
</p>
</div>
<button
class="h-9 rounded-full border border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onCloseModal}
>
关闭
</button>
</div>
<form class="mt-4 grid gap-3.5" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-name"
>
名称
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="checkin-name"
name="name"
value={form.name}
required
onInput={(event) =>
onFormChange({
name: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-base"
>
基础 URL
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="checkin-base"
name="base_url"
placeholder="https://example.com"
value={form.base_url}
required
onInput={(event) =>
onFormChange({
base_url: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-url"
>
签到 URL(可选)
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="checkin-url"
name="checkin_url"
placeholder="https://example.com/checkin"
value={form.checkin_url}
onInput={(event) =>
onFormChange({
checkin_url: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-token"
>
Token
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="checkin-token"
name="token"
value={form.token}
required
onInput={(event) =>
onFormChange({
token: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-user"
>
User ID
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="checkin-user"
name="userid"
value={form.userid}
required
onInput={(event) =>
onFormChange({
userid: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-status"
>
状态
</label>
<select
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="checkin-status"
name="status"
value={form.status}
onChange={(event) =>
onFormChange({
status: (event.currentTarget as HTMLSelectElement).value,
})
}
>
<option value="active">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<button
class="h-10 rounded-full border border-stone-200 bg-stone-50 px-4 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="button"
onClick={onCloseModal}
>
取消
</button>
<button
class="h-10 rounded-full bg-stone-900 px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
type="submit"
>
{isEditing ? "保存修改" : "创建站点"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+45
View File
@@ -73,6 +73,51 @@ export const SettingsView = ({
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-schedule-enabled"
>
定时签到
</label>
<div class="flex items-center gap-3 rounded-lg border border-stone-200 bg-white px-3 py-2.5">
<input
class="h-4 w-4 accent-stone-900"
id="checkin-schedule-enabled"
name="checkin_schedule_enabled"
type="checkbox"
checked={settingsForm.checkin_schedule_enabled}
onChange={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
checkin_schedule_enabled: Boolean(target?.checked),
});
}}
/>
<span class="text-sm text-stone-700">启用(中国时间)</span>
</div>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="checkin-schedule-time"
>
签到时间(中国时间)
</label>
<input
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
id="checkin-schedule-time"
name="checkin_schedule_time"
type="time"
value={settingsForm.checkin_schedule_time}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
checkin_schedule_time: target?.value ?? "",
});
}}
/>
</div>
<div class="lg:col-span-2">
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS checkin_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
checkin_url TEXT,
token TEXT NOT NULL,
new_api_user TEXT,
last_checkin_date TEXT,
last_checkin_status TEXT,
last_checkin_message TEXT,
last_checkin_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+16
View File
@@ -17,6 +17,22 @@ CREATE TABLE IF NOT EXISTS channels (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS checkin_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
checkin_url TEXT,
token TEXT NOT NULL,
new_api_user TEXT,
last_checkin_date TEXT,
last_checkin_status TEXT,
last_checkin_message TEXT,
last_checkin_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
+2
View File
@@ -5,6 +5,7 @@ import type { AppEnv } from "./env";
import { adminAuth } from "./middleware/adminAuth";
import authRoutes from "./routes/auth";
import channelRoutes from "./routes/channels";
import checkinSiteRoutes from "./routes/checkin-sites";
import dashboardRoutes from "./routes/dashboard";
import modelRoutes from "./routes/models";
import newapiChannelRoutes from "./routes/newapiChannels";
@@ -100,6 +101,7 @@ app.get("/health", (c) => c.json({ ok: true }));
app.route("/api/auth", authRoutes);
app.route("/api/channels", channelRoutes);
app.route("/api/checkin-sites", checkinSiteRoutes);
app.route("/api/models", modelRoutes);
app.route("/api/tokens", tokenRoutes);
app.route("/api/usage", usageRoutes);
+156
View File
@@ -0,0 +1,156 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import {
deleteCheckinSite,
getCheckinSiteById,
insertCheckinSite,
listCheckinSites,
listActiveCheckinSites,
updateCheckinSite,
updateCheckinResult,
} from "../services/checkin-site-repo";
import { runCheckin, summarizeCheckin } from "../services/checkin";
import { generateToken } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { beijingDateString, nowIso } from "../utils/time";
import { normalizeBaseUrl } from "../utils/url";
const checkinSites = new Hono<AppEnv>();
type CheckinSitePayload = {
name?: string;
base_url?: string;
checkin_url?: string | null;
token?: string;
userid?: string;
new_api_user?: string;
status?: string;
};
/**
* Lists all checkin sites.
*/
checkinSites.get("/", async (c) => {
const rows = await listCheckinSites(c.env.DB, {
order: "DESC",
});
const sites = rows.map((row) => {
const { new_api_user, ...rest } = row;
return {
...rest,
userid: new_api_user ?? null,
};
});
return c.json({ sites });
});
/**
* Creates a new checkin site.
*/
checkinSites.post("/", async (c) => {
const body = (await c.req.json().catch(() => null)) as CheckinSitePayload | null;
const userid = body?.userid ?? body?.new_api_user;
if (!body?.name || !body.base_url || !body.token || !userid) {
return jsonError(c, 400, "missing_fields", "missing_fields");
}
const id = generateToken("cs_");
const now = nowIso();
await insertCheckinSite(c.env.DB, {
id,
name: body.name,
base_url: normalizeBaseUrl(String(body.base_url)),
checkin_url: body.checkin_url?.trim() || null,
token: body.token,
new_api_user: userid.trim(),
last_checkin_date: null,
last_checkin_status: null,
last_checkin_message: null,
last_checkin_at: null,
status: body.status ?? "active",
created_at: now,
updated_at: now,
});
return c.json({ id });
});
/**
* Updates a checkin site.
*/
checkinSites.patch("/:id", async (c) => {
const body = (await c.req.json().catch(() => null)) as CheckinSitePayload | null;
const id = c.req.param("id");
if (!body) {
return jsonError(c, 400, "missing_body", "missing_body");
}
const current = await getCheckinSiteById(c.env.DB, id);
if (!current) {
return jsonError(c, 404, "site_not_found", "site_not_found");
}
await updateCheckinSite(c.env.DB, id, {
name: body.name ?? current.name,
base_url: normalizeBaseUrl(String(body.base_url ?? current.base_url)),
checkin_url:
body.checkin_url !== undefined
? body.checkin_url?.trim() || null
: current.checkin_url ?? null,
token: body.token ?? current.token,
new_api_user:
body.userid !== undefined || body.new_api_user !== undefined
? String(body.userid ?? body.new_api_user ?? "").trim()
: current.new_api_user ?? null,
status: body.status ?? current.status,
updated_at: nowIso(),
});
return c.json({ ok: true });
});
/**
* Deletes a checkin site.
*/
checkinSites.delete("/:id", async (c) => {
const id = c.req.param("id");
await deleteCheckinSite(c.env.DB, id);
return c.json({ ok: true });
});
/**
* Runs checkin for all active sites.
*/
checkinSites.post("/checkin-all", async (c) => {
const sites = await listActiveCheckinSites(c.env.DB);
const results = [];
const today = beijingDateString();
for (const site of sites) {
const alreadyChecked =
site.last_checkin_date === today &&
(site.last_checkin_status === "success" ||
site.last_checkin_status === "skipped");
if (alreadyChecked) {
results.push({
id: site.id,
name: site.name,
status: "skipped",
message: site.last_checkin_message ?? "今日已签到",
checkin_date: site.last_checkin_date ?? today,
});
continue;
}
const result = await runCheckin(site);
const checkinDate = result.checkin_date ?? today;
await updateCheckinResult(c.env.DB, site.id, {
last_checkin_date: checkinDate,
last_checkin_status: result.status,
last_checkin_message: result.message,
last_checkin_at: nowIso(),
});
results.push({ ...result, checkin_date: checkinDate });
}
const summary = summarizeCheckin(results);
return c.json({
results,
summary,
runs_at: nowIso(),
});
});
export default checkinSites;
+57
View File
@@ -1,9 +1,11 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import {
getCheckinSchedule,
getRetentionDays,
getSessionTtlHours,
isAdminPasswordSet,
setCheckinSchedule,
setAdminPasswordHash,
setRetentionDays,
setSessionTtlHours,
@@ -20,10 +22,13 @@ settings.get("/", async (c) => {
const retention = await getRetentionDays(c.env.DB);
const sessionTtlHours = await getSessionTtlHours(c.env.DB);
const adminPasswordSet = await isAdminPasswordSet(c.env.DB);
const checkinSchedule = await getCheckinSchedule(c.env.DB);
return c.json({
log_retention_days: retention,
session_ttl_hours: sessionTtlHours,
admin_password_set: adminPasswordSet,
checkin_schedule_enabled: checkinSchedule.enabled,
checkin_schedule_time: checkinSchedule.time,
});
});
@@ -72,6 +77,58 @@ settings.put("/", async (c) => {
touched = true;
}
if (
body.checkin_schedule_enabled !== undefined ||
body.checkin_schedule_time !== undefined
) {
const current = await getCheckinSchedule(c.env.DB);
const enabledRaw = body.checkin_schedule_enabled;
const enabled =
enabledRaw === undefined
? current.enabled
: enabledRaw === true || enabledRaw === "true";
if (enabledRaw !== undefined && enabledRaw !== true && enabledRaw !== false) {
if (enabledRaw !== "true" && enabledRaw !== "false") {
return jsonError(
c,
400,
"invalid_checkin_schedule_enabled",
"invalid_checkin_schedule_enabled",
);
}
}
const timeValue =
body.checkin_schedule_time !== undefined
? String(body.checkin_schedule_time).trim()
: current.time;
if (!/^\d{2}:\d{2}$/.test(timeValue)) {
return jsonError(
c,
400,
"invalid_checkin_schedule_time",
"invalid_checkin_schedule_time",
);
}
const [hour, minute] = timeValue.split(":").map((value) => Number(value));
if (
Number.isNaN(hour) ||
Number.isNaN(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return jsonError(
c,
400,
"invalid_checkin_schedule_time",
"invalid_checkin_schedule_time",
);
}
await setCheckinSchedule(c.env.DB, { enabled, time: timeValue });
touched = true;
}
if (!touched) {
return jsonError(c, 400, "settings_empty", "settings_empty");
}
@@ -0,0 +1,156 @@
import type { D1Database } from "@cloudflare/workers-types";
import type { CheckinSiteRow } from "./checkin-site-types";
type CheckinSiteFilters = {
status?: string | null;
};
function buildWhere(filters: CheckinSiteFilters | undefined) {
const where: string[] = [];
const bindings: Array<string> = [];
if (filters?.status) {
where.push("status = ?");
bindings.push(filters.status);
}
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
return { whereSql, bindings };
}
export async function listCheckinSites(
db: D1Database,
options: {
filters?: CheckinSiteFilters;
order?: "ASC" | "DESC";
} = {},
): Promise<CheckinSiteRow[]> {
const { whereSql, bindings } = buildWhere(options.filters);
const order = options.order ?? "DESC";
const statement = db.prepare(
`SELECT * FROM checkin_sites ${whereSql} ORDER BY created_at ${order}`,
);
const rows = await statement.bind(...bindings).all<CheckinSiteRow>();
return rows.results ?? [];
}
export async function listActiveCheckinSites(
db: D1Database,
): Promise<CheckinSiteRow[]> {
return listCheckinSites(db, { filters: { status: "active" } });
}
export async function getCheckinSiteById(
db: D1Database,
id: string,
): Promise<CheckinSiteRow | null> {
const row = await db
.prepare("SELECT * FROM checkin_sites WHERE id = ?")
.bind(id)
.first<CheckinSiteRow>();
return row ?? null;
}
export type CheckinSiteInsertInput = {
id: string;
name: string;
base_url: string;
checkin_url: string | null;
token: string;
new_api_user: 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;
updated_at: string;
};
export async function insertCheckinSite(
db: D1Database,
input: CheckinSiteInsertInput,
): Promise<void> {
await db
.prepare(
"INSERT INTO checkin_sites (id, name, base_url, checkin_url, token, new_api_user, last_checkin_date, last_checkin_status, last_checkin_message, last_checkin_at, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
input.id,
input.name,
input.base_url,
input.checkin_url,
input.token,
input.new_api_user,
input.last_checkin_date,
input.last_checkin_status,
input.last_checkin_message,
input.last_checkin_at,
input.status,
input.created_at,
input.updated_at,
)
.run();
}
export type CheckinSiteUpdateInput = {
name: string;
base_url: string;
checkin_url: string | null;
token: string;
new_api_user: string | null;
status: string;
updated_at: string;
};
export async function updateCheckinSite(
db: D1Database,
id: string,
input: CheckinSiteUpdateInput,
): Promise<void> {
await db
.prepare(
"UPDATE checkin_sites SET name = ?, base_url = ?, checkin_url = ?, token = ?, new_api_user = ?, status = ?, updated_at = ? WHERE id = ?",
)
.bind(
input.name,
input.base_url,
input.checkin_url,
input.token,
input.new_api_user,
input.status,
input.updated_at,
id,
)
.run();
}
export async function updateCheckinResult(
db: D1Database,
id: string,
input: {
last_checkin_date: string | null;
last_checkin_status: string | null;
last_checkin_message: string | null;
last_checkin_at: string | null;
},
): Promise<void> {
await db
.prepare(
"UPDATE checkin_sites SET last_checkin_date = ?, last_checkin_status = ?, last_checkin_message = ?, last_checkin_at = ?, updated_at = ? WHERE id = ?",
)
.bind(
input.last_checkin_date,
input.last_checkin_status,
input.last_checkin_message,
input.last_checkin_at,
input.last_checkin_at ?? new Date().toISOString(),
id,
)
.run();
}
export async function deleteCheckinSite(
db: D1Database,
id: string,
): Promise<void> {
await db.prepare("DELETE FROM checkin_sites WHERE id = ?").bind(id).run();
}
@@ -0,0 +1,17 @@
export type CheckinSiteRow = {
id: string;
name: string;
base_url: string;
checkin_url?: string | null;
token: string;
new_api_user?: 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 CheckinSiteRecord = CheckinSiteRow;
+344
View File
@@ -0,0 +1,344 @@
import { beijingDateString } from "../utils/time";
import { normalizeBaseUrl } from "../utils/url";
import type { CheckinSiteRow } from "./checkin-site-types";
export type CheckinResultStatus = "success" | "failed" | "skipped";
export type CheckinResultItem = {
id: string;
name: string;
status: CheckinResultStatus;
message: string;
checkin_date?: string | null;
};
export type CheckinSummary = {
total: number;
success: number;
failed: number;
skipped: number;
};
type PayloadSummary = {
type: string;
keys?: string[];
signed?: unknown;
success?: unknown;
status?: unknown;
code?: unknown;
message?: unknown;
error?: unknown;
};
const buildCheckinUrl = (baseUrl: string) => {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) {
return "";
}
if (normalized.endsWith("/api")) {
return `${normalized}/user/checkin`;
}
return `${normalized}/api/user/checkin`;
};
const summarizePayload = (payload: unknown): PayloadSummary => {
if (!payload) {
return { type: "null" };
}
if (typeof payload !== "object") {
return { type: typeof payload };
}
const record = payload as Record<string, unknown>;
return {
type: "object",
keys: Object.keys(record).slice(0, 12),
signed: record.signed ?? record.is_signed ?? record.already_signed,
success: record.success,
status: record.status,
code: record.code,
message: record.message ?? record.msg,
error: record.error,
};
};
const logCheckin = (
site: CheckinSiteRow,
stage: string,
data: Record<string, unknown>,
) => {
console.log("[checkin]", {
id: site.id,
name: site.name,
stage,
...data,
});
};
const parseSigned = (payload: unknown): boolean => {
if (!payload || typeof payload !== "object") {
return false;
}
const record = payload as Record<string, unknown>;
const message = String(
record.message ?? record.msg ?? record.error ?? "",
).trim();
if (message.includes("已签到")) {
return true;
}
const data = record.data;
if (data && typeof data === "object") {
const dataRecord = data as Record<string, unknown>;
if (dataRecord.checkin_date || dataRecord.checked_in || dataRecord.signed) {
return true;
}
}
return Boolean(
record.signed ??
record.is_signed ??
record.checked ??
record.checkin ??
record.already_signed ??
record.checked_in,
);
};
const extractCheckinDate = (payload: unknown): string | null => {
if (!payload || typeof payload !== "object") {
return null;
}
const record = payload as Record<string, unknown>;
const data = record.data;
if (data && typeof data === "object") {
const date = (data as Record<string, unknown>).checkin_date;
if (typeof date === "string" && date.trim()) {
return date.trim();
}
}
const message = String(
record.message ?? record.msg ?? record.error ?? "",
).trim();
if (message.includes("已签到")) {
return beijingDateString();
}
return null;
};
const parseMessage = (payload: unknown, fallback: string) => {
if (!payload || typeof payload !== "object") {
return fallback;
}
const record = payload as Record<string, unknown>;
const msg = record.message ?? record.msg ?? record.error;
if (typeof msg === "string" && msg.trim()) {
return msg;
}
return fallback;
};
const readJson = async (response: Response) => {
try {
return await response.json();
} catch (_error) {
return null;
}
};
const readError = async (response: Response) => {
const payload = await readJson(response);
return parseMessage(payload, `HTTP ${response.status}`);
};
export async function runCheckin(site: CheckinSiteRow): Promise<CheckinResultItem> {
const endpoint = buildCheckinUrl(site.base_url);
if (!endpoint) {
return {
id: site.id,
name: site.name,
status: "failed",
message: "站点 URL 为空",
};
}
const headers = new Headers();
headers.set("Authorization", `Bearer ${site.token}`);
if (site.new_api_user && site.new_api_user.trim()) {
headers.set("New-Api-User", site.new_api_user.trim());
} else {
return {
id: site.id,
name: site.name,
status: "failed",
message: "缺少 userid",
};
}
try {
logCheckin(site, "status:request", { endpoint });
const statusResp = await fetch(endpoint, { method: "GET", headers });
logCheckin(site, "status:response", {
status: statusResp.status,
ok: statusResp.ok,
});
if (!statusResp.ok) {
return {
id: site.id,
name: site.name,
status: "failed",
message: await readError(statusResp),
};
}
const statusPayload = await readJson(statusResp);
if (!statusPayload) {
logCheckin(site, "status:invalid-json", {});
return {
id: site.id,
name: site.name,
status: "failed",
message: "签到状态返回非 JSON",
};
}
logCheckin(site, "status:payload", {
payload: summarizePayload(statusPayload),
});
if (parseSigned(statusPayload)) {
return {
id: site.id,
name: site.name,
status: "skipped",
message: parseMessage(statusPayload, "今日已签到"),
checkin_date: extractCheckinDate(statusPayload),
};
}
const checkinResp = await fetch(endpoint, { method: "POST", headers });
logCheckin(site, "checkin:response", {
status: checkinResp.status,
ok: checkinResp.ok,
});
if (!checkinResp.ok) {
return {
id: site.id,
name: site.name,
status: "failed",
message: await readError(checkinResp),
};
}
const checkinPayload = await readJson(checkinResp);
if (!checkinPayload) {
logCheckin(site, "checkin:invalid-json", {});
return {
id: site.id,
name: site.name,
status: "failed",
message: "签到响应非 JSON",
};
}
logCheckin(site, "checkin:payload", {
payload: summarizePayload(checkinPayload),
});
if (parseSigned(checkinPayload)) {
return {
id: site.id,
name: site.name,
status: "skipped",
message: parseMessage(checkinPayload, "今日已签到"),
checkin_date: extractCheckinDate(checkinPayload),
};
}
const record = checkinPayload as Record<string, unknown>;
const codeValue = record.code;
const numericCode =
typeof codeValue === "number"
? codeValue
: typeof codeValue === "string"
? Number(codeValue)
: null;
const explicitFailure = Boolean(
(record.success === false) ||
(record.status === "error") ||
(record.error && String(record.error).length > 0) ||
(numericCode !== null && !Number.isNaN(numericCode) && numericCode !== 0),
);
if (explicitFailure) {
logCheckin(site, "checkin:explicit-failure", {
payload: summarizePayload(checkinPayload),
});
return {
id: site.id,
name: site.name,
status: "failed",
message: parseMessage(checkinPayload, "签到失败"),
checkin_date: extractCheckinDate(checkinPayload),
};
}
logCheckin(site, "verify:request", { endpoint });
const verifyResp = await fetch(endpoint, { method: "GET", headers });
logCheckin(site, "verify:response", {
status: verifyResp.status,
ok: verifyResp.ok,
});
if (!verifyResp.ok) {
return {
id: site.id,
name: site.name,
status: "failed",
message: await readError(verifyResp),
};
}
const verifyPayload = await readJson(verifyResp);
if (!verifyPayload) {
logCheckin(site, "verify:invalid-json", {});
return {
id: site.id,
name: site.name,
status: "failed",
message: "签到验证响应非 JSON",
};
}
logCheckin(site, "verify:payload", {
payload: summarizePayload(verifyPayload),
});
if (!parseSigned(verifyPayload)) {
return {
id: site.id,
name: site.name,
status: "failed",
message: parseMessage(verifyPayload, "签到未生效"),
checkin_date: extractCheckinDate(verifyPayload),
};
}
return {
id: site.id,
name: site.name,
status: "success",
message: parseMessage(checkinPayload, "签到成功"),
checkin_date:
extractCheckinDate(checkinPayload) ?? extractCheckinDate(verifyPayload),
};
} catch (error) {
return {
id: site.id,
name: site.name,
status: "failed",
message: (error as Error).message || "请求失败",
};
}
}
export function summarizeCheckin(
results: CheckinResultItem[],
): CheckinSummary {
return results.reduce<CheckinSummary>(
(acc, item) => {
acc.total += 1;
if (item.status === "success") {
acc.success += 1;
} else if (item.status === "failed") {
acc.failed += 1;
} else {
acc.skipped += 1;
}
return acc;
},
{ total: 0, success: 0, failed: 0, skipped: 0 },
);
}
export { buildCheckinUrl, parseSigned, parseMessage };
+22
View File
@@ -3,9 +3,12 @@ import { nowIso } from "../utils/time";
const DEFAULT_LOG_RETENTION_DAYS = 30;
const DEFAULT_SESSION_TTL_HOURS = 12;
const DEFAULT_CHECKIN_SCHEDULE_TIME = "00:10";
const RETENTION_KEY = "log_retention_days";
const SESSION_TTL_KEY = "session_ttl_hours";
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
const CHECKIN_SCHEDULE_ENABLED_KEY = "checkin_schedule_enabled";
const CHECKIN_SCHEDULE_TIME_KEY = "checkin_schedule_time";
async function readSetting(
db: D1Database,
@@ -110,6 +113,25 @@ export async function isAdminPasswordSet(db: D1Database): Promise<boolean> {
return Boolean(hash);
}
export async function getCheckinSchedule(db: D1Database): Promise<{
enabled: boolean;
time: string;
}> {
const enabledRaw = await readSetting(db, CHECKIN_SCHEDULE_ENABLED_KEY);
const timeRaw = await readSetting(db, CHECKIN_SCHEDULE_TIME_KEY);
const enabled = enabledRaw ? enabledRaw === "true" : false;
const time = timeRaw && timeRaw.length > 0 ? timeRaw : DEFAULT_CHECKIN_SCHEDULE_TIME;
return { enabled, time };
}
export async function setCheckinSchedule(
db: D1Database,
input: { enabled: boolean; time: string },
): Promise<void> {
await upsertSetting(db, CHECKIN_SCHEDULE_ENABLED_KEY, String(input.enabled));
await upsertSetting(db, CHECKIN_SCHEDULE_TIME_KEY, input.time);
}
/**
* Loads generic settings as a key/value map.
*/
+12
View File
@@ -13,3 +13,15 @@ export function addHours(date: Date, hours: number): Date {
copy.setHours(copy.getHours() + hours);
return copy;
}
/**
* Returns Beijing date string (YYYY-MM-DD).
*/
export function beijingDateString(date: Date = new Date()): string {
const offsetMs = 8 * 60 * 60 * 1000;
const beijing = new Date(date.getTime() + offsetMs);
const year = beijing.getUTCFullYear();
const month = String(beijing.getUTCMonth() + 1).padStart(2, "0");
const day = String(beijing.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
+40
View File
@@ -89,6 +89,46 @@
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/proxy.ts
### 修复
- **[checkin]**: 签到结果增加二次校验与非 JSON 响应判定
- 类型: 微调(无方案包)
- 文件: apps/worker/src/services/checkin.ts
- **[checkin]**: 已签到判定兼容 `checkin_date` 与“今日已签到”消息
- 类型: 微调(无方案包)
- 文件: apps/worker/src/services/checkin.ts
### 变更
- **[checkin]**: 对齐 `/api/user/checkin` 接口并增加 `New-Api-User` 头支持
- 类型: 微调(无方案包)
- 文件: apps/worker/src/services/checkin.ts, apps/worker/src/routes/checkin-sites.ts, apps/ui/src/features/CheckinSitesView.tsx, apps/worker/migrations/0002_add_checkin_sites.sql
- **[checkin]**: 站点字段命名为 `userid`,结果展示移动到列表列
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/checkin-sites.ts, apps/ui/src/features/CheckinSitesView.tsx, apps/ui/src/core/types.ts
- **[checkin]**: 一键签到写回结果并跳过今日已签到站点
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/checkin-sites.ts, apps/ui/src/features/CheckinSitesView.tsx, apps/ui/src/core/types.ts, apps/ui/src/core/utils.ts, apps/ui/src/App.tsx
- **[settings]**: 增加定时签到开关与时间配置(中国时间)
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/settings.ts, apps/ui/src/features/SettingsView.tsx, apps/ui/src/core/types.ts, apps/ui/src/core/constants.ts, apps/ui/src/App.tsx, tests/worker/settings.test.ts
- **[admin-ui]**: 今日签到状态区分未签到与签到失败
- 类型: 微调(无方案包)
- 文件: apps/ui/src/features/CheckinSitesView.tsx
- **[checkin]**: 签到失败也标记当日状态,避免显示为未签到
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/checkin-sites.ts
- **[db]**: 合并签到相关迁移到单一脚本
- 类型: 微调(无方案包)
- 文件: apps/worker/migrations/0002_add_checkin_sites.sql
## [0.5.0] - 2026-02-22
### 新增
- **[checkin]**: 新增签到站点管理与一键签到
- 方案: [202602221450_checkin-sites](archive/2026-02/202602221450_checkin-sites/)
## [0.4.11] - 2026-02-22
### 变更
+2 -1
View File
@@ -14,13 +14,14 @@
| usage | 使用日志与留存 | 🚧 | [usage.md](./usage.md) |
| dashboard | 数据面板统计 | 🚧 | [dashboard.md](./dashboard.md) |
| settings | 系统配置 | 🚧 | [settings.md](./settings.md) |
| checkin | 签到站点管理 | 🚧 | [checkin.md](./checkin.md) |
| admin-ui | 管理台前端 | 🚧 | [admin-ui.md](./admin-ui.md) |
| deploy-workflow | GitHub Actions 部署流程 | 🚧 | [deploy-workflow.md](./deploy-workflow.md) |
## 模块依赖关系
```
admin-ui → auth, channels, models, tokens, usage, dashboard, settings
admin-ui → auth, channels, models, tokens, usage, dashboard, settings, checkin
proxy → channels, tokens, usage
dashboard → usage, tokens
models → channels
+4
View File
@@ -19,6 +19,10 @@
- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 签到站点采用列表视图(默认展示全部),支持新增/编辑弹窗、一键签到与手动签到
- 签到站点列表展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 今日签到状态区分“未签到”与“签到失败”
- 系统设置增加定时签到开关与时间(中国时间)
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
- Token 仅本地保存,不回显
- 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定
+26
View File
@@ -0,0 +1,26 @@
# checkin 模块
## 职责
- 维护签到站点列表与状态
- 执行一键签到并汇总结果
- 提供手动签到跳转入口
- 写回今日签到结果到站点表
## 接口定义
- `GET /api/checkin-sites`: 获取签到站点列表
- `POST /api/checkin-sites`: 新增签到站点
- `PATCH /api/checkin-sites/:id`: 更新签到站点
- `DELETE /api/checkin-sites/:id`: 删除签到站点
- `POST /api/checkin-sites/checkin-all`: 一键签到(仅启用站点)
## 行为规范
- 一键签到仅执行 `status=active` 的站点
- 一键签到会跳过 `last_checkin_date` 已是北京时间当日且状态为成功/已签的站点
- 手动签到优先打开 `checkin_url`,为空则打开 `base_url`
- 签到执行使用后端代理调用 `{base_url}/api/user/checkin`(GET 检查,POST 签到)
- 请求头必须携带 `New-Api-User`(值来自站点的 `userid` 配置)
- 签到结果写回 `checkin_sites.last_checkin_*` 字段(date/status/message/at)
## 依赖关系
- `worker` 路由与 D1 数据表 `checkin_sites`
- `admin-ui` 负责前端交互与结果展示
+2
View File
@@ -3,6 +3,7 @@
## 职责
- 维护系统级配置(如日志保留天数)
- 为使用日志与面板提供统一配置读取
- 维护定时签到的开关与执行时间
## 接口定义
- `GET /api/settings` 读取设置
@@ -11,6 +12,7 @@
## 行为规范
- 配置写入后即时生效
- 未配置时使用环境变量默认值
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
## 依赖关系
- `settings` 表