add(checkin): 新增签到功能
- 一键签到写回结果并跳过今日已签站点 - 管理台展示今日签到状态并区分未签到/失败 - 系统设置增加定时签到开关与时间
This commit is contained in:
1 parent
bdde876c1f
commit
83a06ee59e
21 files changed
+1672
-4
No files matched your search
+187
-1
@@ -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">
|
||||
未知模块
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
|
||||
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
|
||||
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
|
||||
- 签到站点采用列表视图(默认展示全部),支持新增/编辑弹窗、一键签到与手动签到
|
||||
- 签到站点列表展示今日签到结果(基于后台写回的 `last_checkin_*`)
|
||||
- 今日签到状态区分“未签到”与“签到失败”
|
||||
- 系统设置增加定时签到开关与时间(中国时间)
|
||||
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
|
||||
- Token 仅本地保存,不回显
|
||||
- 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定
|
||||
|
||||
@@ -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` 负责前端交互与结果展示
|
||||
@@ -3,6 +3,7 @@
|
||||
## 职责
|
||||
- 维护系统级配置(如日志保留天数)
|
||||
- 为使用日志与面板提供统一配置读取
|
||||
- 维护定时签到的开关与执行时间
|
||||
|
||||
## 接口定义
|
||||
- `GET /api/settings` 读取设置
|
||||
@@ -11,6 +12,7 @@
|
||||
## 行为规范
|
||||
- 配置写入后即时生效
|
||||
- 未配置时使用环境变量默认值
|
||||
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
|
||||
|
||||
## 依赖关系
|
||||
- `settings` 表
|
||||
Reference in new issue
Block a user