feat(sites): 新增站点管理与调用令牌支持

- 前端替换为 SitesView,支持站点类型/令牌/签到管理
- 后端新增 sites 接口、call token 表与元数据解析
This commit is contained in:
lsy committed 2026-02-22 23:10:55 +08:00
1 parent 83a06ee59e
commit 0dd3b72edd
36 files changed
+2013 -1644

No files matched your search

-2
View File
@@ -7,7 +7,6 @@
- `apps/worker` Cloudflare Worker (Hono)
- `apps/ui` 管理台 (Vite)
- `tests` 基础单元测试
## Action 自动部署流程(GitHub Actions)
@@ -181,4 +180,3 @@ bun run --filter api-worker db:migrate
- `ALL /v1/*` 转发到渠道 `base_url + 请求路径`
- 当请求路径为 `/v1/responses` 且上游 400/404 时自动回退到 `/responses`
- 流式请求会自动补齐 `stream_options.include_usage = true` 以收集用量
+194 -265
View File
@@ -8,34 +8,31 @@ import {
} from "hono/jsx/dom";
import { createApiFetch } from "./core/api";
import {
initialChannelForm,
initialCheckinSiteForm,
initialData,
initialSettingsForm,
initialSiteForm,
tabs,
} from "./core/constants";
import type {
AdminData,
Channel,
ChannelForm,
CheckinSite,
CheckinSiteForm,
CheckinSummary,
DashboardData,
Settings,
SettingsForm,
Site,
SiteForm,
SiteType,
TabId,
Token,
UsageLog,
} from "./core/types";
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";
import { SettingsView } from "./features/SettingsView";
import { SitesView } from "./features/SitesView";
import { TokensView } from "./features/TokensView";
import { UsageView } from "./features/UsageView";
@@ -58,7 +55,6 @@ const tabToPath: Record<TabId, string> = {
models: "/models",
tokens: "/tokens",
usage: "/usage",
"checkin-sites": "/checkin-sites",
settings: "/settings",
};
@@ -68,10 +64,15 @@ const pathToTab: Record<string, TabId> = {
"/models": "models",
"/tokens": "tokens",
"/usage": "usage",
"/checkin-sites": "checkin-sites",
"/settings": "settings",
};
const DEFAULT_BASE_URL_BY_TYPE: Partial<Record<SiteType, string>> = {
chatgpt: "https://api.openai.com",
claude: "https://api.anthropic.com",
gemini: "https://generativelanguage.googleapis.com",
};
/**
* Renders the admin console application.
*
@@ -94,22 +95,16 @@ const App = () => {
const [data, setData] = useState<AdminData>(initialData);
const [settingsForm, setSettingsForm] =
useState<SettingsForm>(initialSettingsForm);
const [channelPage, setChannelPage] = useState(1);
const [channelPageSize, setChannelPageSize] = useState(10);
const [sitePage, setSitePage] = useState(1);
const [sitePageSize, setSitePageSize] = useState(10);
const [tokenPage, setTokenPage] = useState(1);
const [tokenPageSize, setTokenPageSize] = useState(10);
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
const [channelForm, setChannelForm] = useState<ChannelForm>(() => ({
...initialChannelForm,
const [editingSite, setEditingSite] = useState<Site | null>(null);
const [siteForm, setSiteForm] = useState<SiteForm>(() => ({
...initialSiteForm,
}));
const [editingCheckinSite, setEditingCheckinSite] =
useState<CheckinSite | null>(null);
const [checkinSiteForm, setCheckinSiteForm] = useState<CheckinSiteForm>(() => ({
...initialCheckinSiteForm,
}));
const [isChannelModalOpen, setChannelModalOpen] = useState(false);
const [isSiteModalOpen, setSiteModalOpen] = useState(false);
const [isTokenModalOpen, setTokenModalOpen] = useState(false);
const [isCheckinModalOpen, setCheckinModalOpen] = useState(false);
const [checkinSummary, setCheckinSummary] = useState<CheckinSummary | null>(
null,
);
@@ -134,9 +129,14 @@ const App = () => {
setData((prev) => ({ ...prev, dashboard }));
}, [apiFetch]);
const loadChannels = useCallback(async () => {
const result = await apiFetch<{ channels: Channel[] }>("/api/channels");
setData((prev) => ({ ...prev, channels: result.channels }));
const loadSites = useCallback(async () => {
const result = await apiFetch<{
sites: Site[];
}>("/api/sites");
setData((prev) => ({
...prev,
sites: result.sites,
}));
}, [apiFetch]);
const loadModels = useCallback(async () => {
@@ -164,13 +164,6 @@ 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);
@@ -180,7 +173,7 @@ const App = () => {
await loadDashboard();
}
if (tabId === "channels") {
await loadChannels();
await loadSites();
}
if (tabId === "models") {
await loadModels();
@@ -194,24 +187,13 @@ const App = () => {
if (tabId === "settings") {
await loadSettings();
}
if (tabId === "checkin-sites") {
await loadCheckinSites();
}
} catch (error) {
setNotice((error as Error).message);
} finally {
setLoading(false);
}
},
[
loadChannels,
loadDashboard,
loadModels,
loadSettings,
loadCheckinSites,
loadTokens,
loadUsage,
],
[loadDashboard, loadSites, loadModels, loadSettings, loadTokens, loadUsage],
);
useEffect(() => {
@@ -271,8 +253,21 @@ const App = () => {
updateToken(null);
}, [apiFetch, updateToken]);
const handleChannelFormChange = useCallback((patch: Partial<ChannelForm>) => {
setChannelForm((prev) => ({ ...prev, ...patch }));
const handleSiteFormChange = useCallback((patch: Partial<SiteForm>) => {
setSiteForm((prev) => {
const next = { ...prev, ...patch };
if (
patch.site_type &&
(!patch.base_url || patch.base_url.trim().length === 0) &&
!prev.base_url.trim()
) {
const fallback = DEFAULT_BASE_URL_BY_TYPE[patch.site_type];
if (fallback) {
next.base_url = fallback;
}
}
return next;
});
}, []);
const handleSettingsFormChange = useCallback(
@@ -282,20 +277,13 @@ const App = () => {
[],
);
const handleCheckinFormChange = useCallback(
(patch: Partial<CheckinSiteForm>) => {
setCheckinSiteForm((prev) => ({ ...prev, ...patch }));
},
[],
);
const handleChannelPageChange = useCallback((next: number) => {
setChannelPage(next);
const handleSitePageChange = useCallback((next: number) => {
setSitePage(next);
}, []);
const handleChannelPageSizeChange = useCallback((next: number) => {
setChannelPageSize(next);
setChannelPage(1);
const handleSitePageSizeChange = useCallback((next: number) => {
setSitePageSize(next);
setSitePage(1);
}, []);
const handleTokenPageChange = useCallback((next: number) => {
@@ -316,16 +304,16 @@ const App = () => {
setActiveTab(tabId);
}, []);
const closeChannelModal = useCallback(() => {
setEditingChannel(null);
setChannelForm({ ...initialChannelForm });
setChannelModalOpen(false);
const closeSiteModal = useCallback(() => {
setEditingSite(null);
setSiteForm({ ...initialSiteForm });
setSiteModalOpen(false);
}, []);
const openChannelCreate = useCallback(() => {
setEditingChannel(null);
setChannelForm({ ...initialChannelForm });
setChannelModalOpen(true);
const openSiteCreate = useCallback(() => {
setEditingSite(null);
setSiteForm({ ...initialSiteForm });
setSiteModalOpen(true);
setNotice("");
}, []);
@@ -334,22 +322,46 @@ const App = () => {
setNotice("");
}, []);
const openCheckinCreate = useCallback(() => {
setEditingCheckinSite(null);
setCheckinSiteForm({ ...initialCheckinSiteForm });
setCheckinModalOpen(true);
setNotice("");
}, []);
const startChannelEdit = useCallback((channel: Channel) => {
setEditingChannel(channel);
setChannelForm({
name: channel.name ?? "",
base_url: channel.base_url ?? "",
api_key: channel.api_key ?? "",
weight: channel.weight ?? 1,
const startSiteEdit = useCallback((site: Site) => {
setEditingSite(site);
const callTokens =
site.call_tokens && site.call_tokens.length > 0
? site.call_tokens
: site.api_key
? [
{
id: "",
name: "主调用令牌",
api_key: site.api_key,
},
]
: [];
const tokenForms =
callTokens.length > 0
? callTokens.map((token) => ({
id: token.id,
name: token.name,
api_key: token.api_key,
}))
: [
{
name: "主调用令牌",
api_key: "",
},
];
setSiteForm({
name: site.name ?? "",
base_url: site.base_url ?? "",
weight: site.weight ?? 1,
status: site.status ?? "active",
site_type: site.site_type ?? "new-api",
checkin_url: site.checkin_url ?? "",
system_token: site.system_token ?? "",
system_userid: site.system_userid ?? "",
checkin_enabled: Boolean(site.checkin_enabled ?? false),
call_tokens: tokenForms,
});
setChannelModalOpen(true);
setSiteModalOpen(true);
setNotice("");
}, []);
@@ -357,72 +369,77 @@ 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(
const handleSiteSubmit = useCallback(
async (event: Event) => {
event.preventDefault();
const channelName = channelForm.name.trim();
const normalizedName = channelName.toLowerCase();
const nameExists = data.channels.some(
(channel) =>
channel.name.trim().toLowerCase() === normalizedName &&
channel.id !== editingChannel?.id,
const siteName = siteForm.name.trim();
const normalizedName = siteName.toLowerCase();
const nameExists = data.sites.some(
(site) =>
site.name.trim().toLowerCase() === normalizedName &&
site.id !== editingSite?.id,
);
if (nameExists) {
setNotice("渠道名称已存在,请使用其他名称");
setNotice("站点名称已存在,请使用其他名称");
return;
}
const baseUrlValue = siteForm.base_url.trim();
if (!baseUrlValue && !DEFAULT_BASE_URL_BY_TYPE[siteForm.site_type]) {
setNotice("基础 URL 不能为空");
return;
}
const callTokens = siteForm.call_tokens
.map((token, index) => ({
id: token.id,
name: token.name.trim() || `调用令牌${index + 1}`,
api_key: token.api_key.trim(),
}))
.filter((token) => token.api_key.length > 0);
if (callTokens.length === 0) {
setNotice("至少填写一个调用令牌");
return;
}
if (
siteForm.site_type === "new-api" &&
siteForm.checkin_enabled &&
(!siteForm.system_token.trim() || !siteForm.system_userid.trim())
) {
setNotice("启用签到需要填写系统令牌与 User ID");
return;
}
try {
const body = {
name: channelName,
base_url: channelForm.base_url.trim(),
api_key: channelForm.api_key.trim(),
weight: Number(channelForm.weight),
name: siteName,
base_url: baseUrlValue,
weight: Number(siteForm.weight),
status: siteForm.status,
site_type: siteForm.site_type,
system_token: siteForm.system_token.trim(),
system_userid: siteForm.system_userid.trim(),
checkin_url: siteForm.checkin_url.trim() || null,
checkin_enabled: siteForm.checkin_enabled,
call_tokens: callTokens,
};
if (editingChannel) {
await apiFetch(`/api/channels/${editingChannel.id}`, {
if (editingSite) {
await apiFetch(`/api/sites/${editingSite.id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
setNotice("渠道已更新");
setNotice("站点已更新");
} else {
await apiFetch("/api/channels", {
await apiFetch("/api/sites", {
method: "POST",
body: JSON.stringify(body),
});
setNotice("渠道已创建");
setNotice("站点已创建");
}
closeChannelModal();
await loadChannels();
closeSiteModal();
await loadSites();
} catch (error) {
setNotice((error as Error).message);
}
},
[
apiFetch,
channelForm,
closeChannelModal,
data.channels,
editingChannel,
loadChannels,
],
[apiFetch, siteForm, closeSiteModal, data.sites, editingSite, loadSites],
);
const handleTokenSubmit = useCallback(
@@ -456,47 +473,6 @@ 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();
@@ -528,7 +504,7 @@ const App = () => {
[apiFetch, loadSettings, settingsForm],
);
const handleChannelTest = useCallback(
const handleSiteTest = useCallback(
async (id: string) => {
try {
const result = await apiFetch<{ models: Array<{ id: string }> }>(
@@ -537,46 +513,46 @@ const App = () => {
method: "POST",
},
);
await loadChannels();
await loadSites();
setNotice(`连通测试完成,模型数 ${result.models?.length ?? 0}`);
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadChannels],
[apiFetch, loadSites],
);
const handleChannelDelete = useCallback(
const handleSiteDelete = useCallback(
async (id: string) => {
try {
await apiFetch(`/api/channels/${id}`, { method: "DELETE" });
await loadChannels();
setNotice("渠道已删除");
if (editingChannel?.id === id) {
closeChannelModal();
await apiFetch(`/api/sites/${id}`, { method: "DELETE" });
await loadSites();
setNotice("站点已删除");
if (editingSite?.id === id) {
closeSiteModal();
}
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, closeChannelModal, editingChannel, loadChannels],
[apiFetch, closeSiteModal, editingSite, loadSites],
);
const handleChannelToggle = useCallback(
const handleSiteToggle = useCallback(
async (id: string, status: string) => {
try {
const next = toggleStatus(status);
await apiFetch(`/api/channels/${id}`, {
await apiFetch(`/api/sites/${id}`, {
method: "PATCH",
body: JSON.stringify({ status: next }),
});
await loadChannels();
setNotice(`渠道已${next === "active" ? "启用" : "停用"}`);
await loadSites();
setNotice(`站点已${next === "active" ? "启用" : "停用"}`);
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadChannels],
[apiFetch, loadSites],
);
const handleTokenDelete = useCallback(
@@ -632,36 +608,6 @@ 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<{
@@ -674,10 +620,10 @@ const App = () => {
}>;
summary: CheckinSummary;
runs_at: string;
}>("/api/checkin-sites/checkin-all", {
}>("/api/sites/checkin-all", {
method: "POST",
});
await loadCheckinSites();
await loadSites();
setCheckinSummary(result.summary);
setCheckinLastRun(result.runs_at);
setNotice(
@@ -688,7 +634,7 @@ const App = () => {
} catch (error) {
setNotice((error as Error).message);
}
}, [apiFetch, loadCheckinSites]);
}, [apiFetch, loadSites]);
const handleUsageRefresh = useCallback(async () => {
try {
@@ -699,15 +645,15 @@ const App = () => {
}
}, [loadUsage]);
const channelTotal = data.channels.length;
const channelTotalPages = useMemo(
() => Math.max(1, Math.ceil(channelTotal / channelPageSize)),
[channelTotal, channelPageSize],
const siteTotal = data.sites.length;
const siteTotalPages = useMemo(
() => Math.max(1, Math.ceil(siteTotal / sitePageSize)),
[siteTotal, sitePageSize],
);
const pagedChannels = useMemo(() => {
const start = (channelPage - 1) * channelPageSize;
return data.channels.slice(start, start + channelPageSize);
}, [channelPage, channelPageSize, data.channels]);
const pagedSites = useMemo(() => {
const start = (sitePage - 1) * sitePageSize;
return data.sites.slice(start, start + sitePageSize);
}, [sitePage, sitePageSize, data.sites]);
const tokenTotal = data.tokens.length;
const tokenTotalPages = useMemo(
() => Math.max(1, Math.ceil(tokenTotal / tokenPageSize)),
@@ -719,8 +665,8 @@ const App = () => {
}, [data.tokens, tokenPage, tokenPageSize]);
useEffect(() => {
setChannelPage((prev) => Math.min(prev, channelTotalPages));
}, [channelTotalPages]);
setSitePage((prev) => Math.min(prev, siteTotalPages));
}, [siteTotalPages]);
useEffect(() => {
setTokenPage((prev) => Math.min(prev, tokenTotalPages));
@@ -744,25 +690,28 @@ const App = () => {
}
if (activeTab === "channels") {
return (
<ChannelsView
channelForm={channelForm}
channelPage={channelPage}
channelPageSize={channelPageSize}
channelTotal={channelTotal}
channelTotalPages={channelTotalPages}
pagedChannels={pagedChannels}
editingChannel={editingChannel}
isChannelModalOpen={isChannelModalOpen}
onCreate={openChannelCreate}
onCloseModal={closeChannelModal}
onEdit={startChannelEdit}
onSubmit={handleChannelSubmit}
onTest={handleChannelTest}
onToggle={handleChannelToggle}
onDelete={handleChannelDelete}
onPageChange={handleChannelPageChange}
onPageSizeChange={handleChannelPageSizeChange}
onFormChange={handleChannelFormChange}
<SitesView
siteForm={siteForm}
sitePage={sitePage}
sitePageSize={sitePageSize}
siteTotal={siteTotal}
siteTotalPages={siteTotalPages}
pagedSites={pagedSites}
editingSite={editingSite}
isSiteModalOpen={isSiteModalOpen}
summary={checkinSummary}
lastRun={checkinLastRun}
onCreate={openSiteCreate}
onCloseModal={closeSiteModal}
onEdit={startSiteEdit}
onSubmit={handleSiteSubmit}
onTest={handleSiteTest}
onToggle={handleSiteToggle}
onDelete={handleSiteDelete}
onPageChange={handleSitePageChange}
onPageSizeChange={handleSitePageSizeChange}
onFormChange={handleSiteFormChange}
onRunAll={handleCheckinRunAll}
/>
);
}
@@ -802,26 +751,6 @@ 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">
未知模块
+16 -22
View File
@@ -1,38 +1,41 @@
import type {
AdminData,
ChannelForm,
CheckinSiteForm,
SettingsForm,
TabItem,
} from "./types";
import type { AdminData, SettingsForm, SiteForm, TabItem } from "./types";
export const apiBase = import.meta.env.VITE_API_BASE ?? "";
export const tabs: TabItem[] = [
{ id: "dashboard", label: "数据面板" },
{ id: "channels", label: "渠道管理" },
{ id: "channels", label: "站点管理" },
{ id: "models", label: "模型广场" },
{ id: "tokens", label: "令牌管理" },
{ id: "usage", label: "使用日志" },
{ id: "checkin-sites", label: "签到站点" },
{ id: "settings", label: "系统设置" },
];
export const initialData: AdminData = {
channels: [],
sites: [],
tokens: [],
models: [],
usage: [],
dashboard: null,
settings: null,
checkinSites: [],
};
export const initialChannelForm: ChannelForm = {
export const initialSiteForm: SiteForm = {
name: "",
base_url: "",
api_key: "",
weight: 1,
status: "active",
site_type: "new-api",
checkin_url: "",
system_token: "",
system_userid: "",
checkin_enabled: true,
call_tokens: [
{
name: "主调用令牌",
api_key: "",
},
],
};
export const initialSettingsForm: SettingsForm = {
@@ -42,12 +45,3 @@ export const initialSettingsForm: SettingsForm = {
checkin_schedule_enabled: false,
checkin_schedule_time: "00:10",
};
export const initialCheckinSiteForm: CheckinSiteForm = {
name: "",
base_url: "",
checkin_url: "",
token: "",
userid: "",
status: "active",
};
+45 -34
View File
@@ -1,11 +1,37 @@
export type Channel = {
export type SiteType =
| "new-api"
| "done-hub"
| "subapi"
| "chatgpt"
| "claude"
| "gemini";
export type SiteCallToken = {
id: string;
name: string;
api_key: string;
};
export type Site = {
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
models_json?: string;
site_type: SiteType;
api_key?: string;
system_token?: string | null;
system_userid?: string | null;
checkin_enabled?: boolean;
checkin_id?: string | null;
checkin_url?: string | null;
call_tokens: SiteCallToken[];
last_checkin_date?: string | null;
last_checkin_status?: string | null;
last_checkin_message?: string | null;
last_checkin_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
export type Token = {
@@ -69,13 +95,12 @@ export type ModelItem = {
};
export type AdminData = {
channels: Channel[];
sites: Site[];
tokens: Token[];
models: ModelItem[];
usage: UsageLog[];
dashboard: DashboardData | null;
settings: Settings | null;
checkinSites: CheckinSite[];
};
export type TabId =
@@ -84,19 +109,30 @@ export type TabId =
| "models"
| "tokens"
| "usage"
| "settings"
| "checkin-sites";
| "settings";
export type TabItem = {
id: TabId;
label: string;
};
export type ChannelForm = {
export type SiteForm = {
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
site_type: SiteType;
checkin_url: string;
system_token: string;
system_userid: string;
checkin_enabled: boolean;
call_tokens: SiteCallTokenForm[];
};
export type SiteCallTokenForm = {
id?: string;
name: string;
api_key: string;
};
export type SettingsForm = {
@@ -107,31 +143,6 @@ export type SettingsForm = {
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;
+30 -10
View File
@@ -36,17 +36,24 @@ export const AppLayout = ({
toggle.checked = false;
}
};
const toggleMobileNav = () => {
const toggle = document.querySelector<HTMLInputElement>("#app-nav-toggle");
if (toggle) {
toggle.checked = !toggle.checked;
}
};
return (
<div class="relative flex min-h-screen flex-col lg:grid lg:grid-cols-[260px_1fr]">
<input class="peer hidden" id="app-nav-toggle" type="checkbox" />
<header class="flex items-center justify-between border-b border-stone-200 bg-white px-4 py-4 lg:hidden">
<div class="flex items-center gap-3">
<label
<button
aria-controls="app-nav-toggle"
aria-label="打开导航"
class="inline-flex h-10 items-center gap-2 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-700 shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
for="app-nav-toggle"
role="button"
type="button"
onClick={toggleMobileNav}
>
<svg
aria-hidden="true"
@@ -56,12 +63,24 @@ export const AppLayout = ({
stroke="currentColor"
stroke-width="1.8"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 12h16" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 18h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 12h16"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 18h16"
/>
</svg>
菜单
</label>
</button>
<div class="flex flex-col">
<span class="text-sm font-semibold text-stone-900">
api-workers
@@ -140,10 +159,11 @@ export const AppLayout = ({
)}
{children}
</main>
<label
aria-hidden="true"
<button
aria-label="关闭导航"
class="fixed inset-0 z-30 bg-stone-900/40 opacity-0 transition-opacity duration-300 ease-in-out peer-checked:pointer-events-auto peer-checked:opacity-100 lg:hidden pointer-events-none"
for="app-nav-toggle"
type="button"
onClick={closeMobileNav}
/>
</div>
);
+1 -437
View File
@@ -1,437 +1 @@
import type { Channel, ChannelForm } from "../core/types";
import { buildPageItems } from "../core/utils";
type ChannelsViewProps = {
channelForm: ChannelForm;
channelPage: number;
channelPageSize: number;
channelTotal: number;
channelTotalPages: number;
pagedChannels: Channel[];
editingChannel: Channel | null;
isChannelModalOpen: boolean;
onCreate: () => void;
onCloseModal: () => void;
onEdit: (channel: Channel) => void;
onSubmit: (event: Event) => void;
onTest: (id: string) => void;
onToggle: (id: string, status: string) => void;
onDelete: (id: string) => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
onFormChange: (patch: Partial<ChannelForm>) => void;
};
const pageSizeOptions = [10, 20, 50];
/**
* Renders the channels management view.
*
* Args:
* props: Channels view props.
*
* Returns:
* Channels JSX element.
*/
export const ChannelsView = ({
channelForm,
channelPage,
channelPageSize,
channelTotal,
channelTotalPages,
pagedChannels,
editingChannel,
isChannelModalOpen,
onCreate,
onCloseModal,
onEdit,
onSubmit,
onTest,
onToggle,
onDelete,
onPageChange,
onPageSizeChange,
onFormChange,
}: ChannelsViewProps) => {
const isEditing = Boolean(editingChannel);
const pageItems = buildPageItems(channelPage, channelTotalPages);
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">
<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">
{pagedChannels.length === 0 ? (
<div class="rounded-xl border border-stone-200 bg-white px-4 py-10 text-center text-sm text-stone-500">
暂无渠道,请先创建。
</div>
) : (
pagedChannels.map((channel) => {
const isActive = channel.status === "active";
return (
<div
class={`rounded-xl border p-4 shadow-sm ${
editingChannel?.id === channel.id
? "border-amber-200 bg-amber-50/60"
: "border-stone-200 bg-white"
}`}
key={channel.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">
{channel.name}
</p>
<p
class="truncate text-xs text-stone-500"
title={channel.base_url}
>
{channel.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 flex items-center justify-between text-xs text-stone-500">
<span>权重</span>
<span class="font-semibold text-stone-700">
{channel.weight}
</span>
</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={() => onTest(channel.id)}
>
连通测试
</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(channel.id, channel.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(channel)}
>
编辑
</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(channel.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.6fr)_minmax(0,0.7fr)_minmax(0,0.6fr)_minmax(0,1.6fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
<div>渠道</div>
<div>状态</div>
<div>权重</div>
<div>操作</div>
</div>
{pagedChannels.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-stone-500">
暂无渠道,请先创建。
</div>
) : (
<div class="divide-y divide-stone-100">
{pagedChannels.map((channel) => {
const isActive = channel.status === "active";
return (
<div
class={`grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.7fr)_minmax(0,0.6fr)_minmax(0,1.6fr)] items-center gap-3 px-4 py-4 text-sm ${
editingChannel?.id === channel.id
? "bg-amber-50/60"
: "bg-white"
}`}
key={channel.id}
>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-stone-900">
{channel.name}
</span>
<span
class="truncate text-xs text-stone-500"
title={channel.base_url}
>
{channel.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-sm font-semibold text-stone-700">
{channel.weight}
</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={() => onTest(channel.id)}
>
连通测试
</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(channel.id, channel.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(channel)}
>
编辑
</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(channel.id)}
>
删除
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
<div class="mt-4 flex flex-col gap-3 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-stone-500">
共 {channelTotal} 条 · {channelTotalPages} 页
</span>
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white 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-md 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"
disabled={channelPage <= 1}
onClick={() => onPageChange(Math.max(1, channelPage - 1))}
>
&lt;
</button>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span class="px-2 text-xs text-stone-400" key={`e-${index}`}>
...
</span>
) : (
<button
class={`h-8 min-w-8 rounded-full border px-3 text-xs font-semibold transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
item === channelPage
? "border-stone-900 bg-stone-900 text-white shadow-md"
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
}`}
type="button"
key={item}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white 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-md 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"
disabled={channelPage >= channelTotalPages}
onClick={() =>
onPageChange(Math.min(channelTotalPages, channelPage + 1))
}
>
&gt;
</button>
</div>
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-500">
每页条数
<select
class="rounded-full border border-stone-200 bg-white px-2 py-0.5 text-xs text-stone-700 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
value={channelPageSize}
onChange={(event) => {
onPageSizeChange(
Number((event.currentTarget as HTMLSelectElement).value),
);
}}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
</div>
</div>
{isChannelModalOpen && (
<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
? `正在编辑:${editingChannel?.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="channel-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="channel-name"
name="name"
value={channelForm.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="channel-base"
>
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="channel-base"
name="base_url"
placeholder="https://api.openai.com"
value={channelForm.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="channel-key"
>
API Key
</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="channel-key"
name="api_key"
value={channelForm.api_key}
required
onInput={(event) =>
onFormChange({
api_key: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="channel-weight"
>
权重
</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="channel-weight"
name="weight"
type="number"
min="1"
value={channelForm.weight}
onInput={(event) =>
onFormChange({
weight: Number(
(event.currentTarget as HTMLInputElement).value || 0,
),
})
}
/>
</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>
);
};
export { SitesView as ChannelsView } from "./SitesView";
-483
View File
@@ -1,483 +0,0 @@
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>
);
};
+740
View File
@@ -0,0 +1,740 @@
import type { CheckinSummary, Site, SiteForm } from "../core/types";
import {
buildPageItems,
formatDateTime,
getBeijingDateString,
} from "../core/utils";
type SitesViewProps = {
siteForm: SiteForm;
sitePage: number;
sitePageSize: number;
siteTotal: number;
siteTotalPages: number;
pagedSites: Site[];
editingSite: Site | null;
isSiteModalOpen: boolean;
summary: CheckinSummary | null;
lastRun: string | null;
onCreate: () => void;
onCloseModal: () => void;
onEdit: (site: Site) => void;
onSubmit: (event: Event) => void;
onTest: (id: string) => void;
onToggle: (id: string, status: string) => void;
onDelete: (id: string) => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
onFormChange: (patch: Partial<SiteForm>) => void;
onRunAll: () => void;
};
const pageSizeOptions = [10, 20, 50];
const typeLabelMap: Record<Site["site_type"], string> = {
"new-api": "New API",
"done-hub": "Done Hub",
subapi: "Sub API",
chatgpt: "ChatGPT",
claude: "Claude",
gemini: "Gemini",
};
export const SitesView = ({
siteForm,
sitePage,
sitePageSize,
siteTotal,
siteTotalPages,
pagedSites,
editingSite,
isSiteModalOpen,
summary,
lastRun,
onCreate,
onCloseModal,
onEdit,
onSubmit,
onTest,
onToggle,
onDelete,
onPageChange,
onPageSizeChange,
onFormChange,
onRunAll,
}: SitesViewProps) => {
const isEditing = Boolean(editingSite);
const pageItems = buildPageItems(sitePage, siteTotalPages);
const today = getBeijingDateString();
const isOfficialType =
siteForm.site_type === "chatgpt" ||
siteForm.site_type === "claude" ||
siteForm.site_type === "gemini";
const needsSystemToken = !isOfficialType;
const isNewApi = siteForm.site_type === "new-api";
const updateCallToken = (
index: number,
patch: Partial<SiteForm["call_tokens"][number]>,
) => {
const next = siteForm.call_tokens.map((token, idx) =>
idx === index ? { ...token, ...patch } : token,
);
onFormChange({ call_tokens: next });
};
const addCallToken = () => {
const next = [
...siteForm.call_tokens,
{
name: `调用令牌${siteForm.call_tokens.length + 1}`,
api_key: "",
},
];
onFormChange({ call_tokens: next });
};
const removeCallToken = (index: number) => {
if (siteForm.call_tokens.length <= 1) {
return;
}
const next = siteForm.call_tokens.filter((_, idx) => idx !== index);
onFormChange({ call_tokens: next });
};
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">
{pagedSites.length === 0 ? (
<div class="rounded-xl border border-stone-200 bg-white px-4 py-10 text-center text-sm text-stone-500">
暂无站点,请先创建。
</div>
) : (
pagedSites.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;
const systemReady = Boolean(
site.system_token && site.system_userid,
);
const checkinEnabled =
site.site_type === "new-api" && Boolean(site.checkin_enabled);
const showCheckin =
site.site_type === "new-api" && checkinEnabled;
const callTokenCount = site.call_tokens?.length ?? 0;
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 flex items-center justify-between text-xs text-stone-500">
<span>类型</span>
<span class="font-semibold text-stone-700">
{typeLabelMap[site.site_type]}
</span>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-stone-500">
<span>权重</span>
<span class="font-semibold text-stone-700">
{site.weight}
</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>系统令牌</p>
<p class="mt-1 truncate font-semibold text-stone-700">
{systemReady ? "已配置" : "未配置"}
</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">
{callTokenCount > 0 ? `${callTokenCount} 个` : "-"}
</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">
{!showCheckin
? "-"
: status
? status === "success"
? "成功"
: status === "skipped"
? "已签"
: "签到失败"
: "未签到"}
</p>
{message && showCheckin && (
<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={() => onTest(site.id)}
>
连通测试
</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.6fr)_minmax(0,0.6fr)_minmax(0,0.5fr)_minmax(0,0.6fr)_minmax(0,0.8fr)_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>状态</div>
<div>权重</div>
<div>令牌</div>
<div>签到</div>
<div>操作</div>
</div>
{pagedSites.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-stone-500">
暂无站点,请先创建。
</div>
) : (
<div class="divide-y divide-stone-100">
{pagedSites.map((site) => {
const isActive = site.status === "active";
const isToday = site.last_checkin_date === today;
const status = isToday ? site.last_checkin_status : null;
const checkinEnabled =
site.site_type === "new-api" &&
Boolean(site.checkin_enabled);
const showCheckin =
site.site_type === "new-api" && checkinEnabled;
const callTokenCount = site.call_tokens?.length ?? 0;
return (
<div
class={`grid grid-cols-[minmax(0,1.4fr)_minmax(0,0.6fr)_minmax(0,0.6fr)_minmax(0,0.5fr)_minmax(0,0.6fr)_minmax(0,0.8fr)_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"
title={site.base_url}
>
{site.base_url}
</span>
</div>
<div class="text-xs font-semibold text-stone-700">
{typeLabelMap[site.site_type]}
</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 font-semibold text-stone-700">
{site.weight}
</div>
<div class="text-xs text-stone-600">
{callTokenCount > 0 ? `${callTokenCount} 个` : "-"}
</div>
<div class="text-xs text-stone-600">
{!showCheckin
? "-"
: status
? status === "success"
? "成功"
: status === "skipped"
? "已签"
: "签到失败"
: "未签到"}
</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={() => onTest(site.id)}
>
连通测试
</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>
<div class="mt-4 flex flex-col gap-3 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-stone-500">
共 {siteTotal} 条 · {siteTotalPages} 页
</span>
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white 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-md 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"
disabled={sitePage <= 1}
onClick={() => onPageChange(Math.max(1, sitePage - 1))}
>
&lt;
</button>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span class="px-2 text-xs text-stone-400" key={`e-${index}`}>
...
</span>
) : (
<button
class={`h-8 min-w-8 rounded-full border px-3 text-xs font-semibold transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
item === sitePage
? "border-stone-900 bg-stone-900 text-white shadow-md"
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
}`}
type="button"
key={item}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white 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-md 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"
disabled={sitePage >= siteTotalPages}
onClick={() =>
onPageChange(Math.min(siteTotalPages, sitePage + 1))
}
>
&gt;
</button>
</div>
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-500">
每页条数
<select
class="rounded-full border border-stone-200 bg-white px-2 py-0.5 text-xs text-stone-700 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
value={sitePageSize}
onChange={(event) => {
onPageSizeChange(
Number((event.currentTarget as HTMLSelectElement).value),
);
}}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
</div>
{lastRun && (
<p class="mt-3 text-xs text-stone-500">
最近执行时间:{formatDateTime(lastRun)}
</p>
)}
</div>
{isSiteModalOpen && (
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
<div class="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden 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 min-h-0 gap-4 overflow-y-auto pr-1"
onSubmit={onSubmit}
>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="site-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="site-name"
name="name"
value={siteForm.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="site-type"
>
站点类型
</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="site-type"
name="site_type"
value={siteForm.site_type}
onChange={(event) =>
onFormChange({
site_type: (event.currentTarget as HTMLSelectElement)
.value as Site["site_type"],
})
}
>
<option value="new-api">new-api</option>
<option value="done-hub">done-hub</option>
<option value="subapi">subapi</option>
<option value="chatgpt">chatgpt</option>
<option value="claude">claude</option>
<option value="gemini">gemini</option>
</select>
</div>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="site-base"
>
基础 URL{isOfficialType ? "(可留空)" : ""}
</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="site-base"
name="base_url"
placeholder="https://api.example.com"
value={siteForm.base_url}
required={!isOfficialType}
onInput={(event) =>
onFormChange({
base_url: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="site-weight"
>
权重
</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="site-weight"
name="weight"
type="number"
min="1"
value={siteForm.weight}
onInput={(event) =>
onFormChange({
weight: Number(
(event.currentTarget as HTMLInputElement).value || 0,
),
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="site-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="site-status"
name="status"
value={siteForm.status}
onChange={(event) =>
onFormChange({
status: (event.currentTarget as HTMLSelectElement)
.value,
})
}
>
<option value="active">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="rounded-xl border border-stone-200 bg-stone-50 px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-widest text-stone-500">
调用令牌
</p>
<button
class="h-8 rounded-full border border-stone-200 bg-white px-3 text-[11px] font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
type="button"
onClick={addCallToken}
>
新增令牌
</button>
</div>
<p class="mt-2 text-xs text-stone-500">
用于实际调用,系统会按顺序选择可用令牌。
</p>
<div class="mt-3 max-h-64 space-y-3 overflow-y-auto pr-1">
{siteForm.call_tokens.map((token, index) => (
<div
class="rounded-lg border border-stone-200 bg-white px-3 py-3"
key={`${token.id ?? "new"}-${index}`}
>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<input
class="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-xs text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
placeholder="备注名"
value={token.name}
onInput={(event) =>
updateCallToken(index, {
name: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
<input
class="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-xs text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
placeholder="调用令牌"
value={token.api_key}
onInput={(event) =>
updateCallToken(index, {
api_key: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
</div>
<div class="mt-2 flex items-center justify-end">
<button
class="text-[11px] font-semibold text-stone-500 transition-colors hover:text-rose-500 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
disabled={siteForm.call_tokens.length <= 1}
onClick={() => removeCallToken(index)}
>
删除此令牌
</button>
</div>
</div>
))}
</div>
</div>
{needsSystemToken && (
<div class="rounded-xl border border-stone-200 bg-stone-50 px-4 py-4">
<p class="text-xs font-semibold uppercase tracking-widest text-stone-500">
系统令牌与签到
</p>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<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"
placeholder="系统令牌"
value={siteForm.system_token}
onInput={(event) =>
onFormChange({
system_token: (
event.currentTarget as HTMLInputElement
).value,
})
}
/>
<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"
placeholder="User ID"
value={siteForm.system_userid}
onInput={(event) =>
onFormChange({
system_userid: (
event.currentTarget as HTMLInputElement
).value,
})
}
/>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
{isNewApi && (
<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"
value={
siteForm.checkin_enabled ? "enabled" : "disabled"
}
onChange={(event) =>
onFormChange({
checkin_enabled:
(event.currentTarget as HTMLSelectElement)
.value === "enabled",
})
}
>
<option value="disabled">自动签到关闭</option>
<option value="enabled">自动签到开启</option>
</select>
)}
<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"
placeholder={
isNewApi ? "签到地址(可选)" : "外部签到地址(可选)"
}
value={siteForm.checkin_url}
onInput={(event) =>
onFormChange({
checkin_url: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
</div>
</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>
);
};
+3 -1
View File
@@ -202,7 +202,9 @@ export const TokensView = ({
<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(tokenItem.id, tokenItem.status)}
onClick={() =>
onToggle(tokenItem.id, tokenItem.status)
}
>
切换
</button>
@@ -1,15 +1,19 @@
CREATE TABLE IF NOT EXISTS checkin_sites (
ALTER TABLE channels ADD COLUMN system_token TEXT;
ALTER TABLE channels ADD COLUMN system_userid TEXT;
ALTER TABLE channels ADD COLUMN checkin_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE channels ADD COLUMN checkin_url TEXT;
ALTER TABLE channels ADD COLUMN last_checkin_date TEXT;
ALTER TABLE channels ADD COLUMN last_checkin_status TEXT;
ALTER TABLE channels ADD COLUMN last_checkin_message TEXT;
ALTER TABLE channels ADD COLUMN last_checkin_at TEXT;
CREATE TABLE IF NOT EXISTS channel_call_tokens (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
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',
api_key TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS channel_call_tokens_channel_id ON channel_call_tokens(channel_id);
+14 -11
View File
@@ -13,26 +13,29 @@ CREATE TABLE IF NOT EXISTS channels (
metadata_json TEXT,
test_time INTEGER,
response_time_ms INTEGER,
created_at TEXT NOT NULL,
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,
system_token TEXT,
system_userid TEXT,
checkin_enabled INTEGER NOT NULL DEFAULT 0,
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 channel_call_tokens (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
name TEXT NOT NULL,
api_key TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS channel_call_tokens_channel_id ON channel_call_tokens(channel_id);
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
+2 -2
View File
@@ -5,7 +5,6 @@ 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";
@@ -13,6 +12,7 @@ import newapiGroupRoutes from "./routes/newapiGroups";
import newapiUserRoutes from "./routes/newapiUsers";
import proxyRoutes from "./routes/proxy";
import settingsRoutes from "./routes/settings";
import siteRoutes from "./routes/sites";
import tokenRoutes from "./routes/tokens";
import usageRoutes from "./routes/usage";
@@ -101,7 +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/sites", siteRoutes);
app.route("/api/models", modelRoutes);
app.route("/api/tokens", tokenRoutes);
app.route("/api/usage", usageRoutes);
+8
View File
@@ -131,6 +131,14 @@ channels.patch("/:id", async (c) => {
group_name: current.group_name ?? null,
priority: current.priority ?? 0,
metadata_json: current.metadata_json ?? null,
system_token: current.system_token ?? null,
system_userid: current.system_userid ?? null,
checkin_enabled: current.checkin_enabled ?? 0,
checkin_url: current.checkin_url ?? null,
last_checkin_date: current.last_checkin_date ?? null,
last_checkin_status: current.last_checkin_status ?? null,
last_checkin_message: current.last_checkin_message ?? null,
last_checkin_at: current.last_checkin_at ?? null,
updated_at: nowIso(),
});
-156
View File
@@ -1,156 +0,0 @@
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;
+8
View File
@@ -337,6 +337,14 @@ newapi.put("/", async (c) => {
group_name: parsed.group_name ?? current.group_name ?? null,
priority: parsed.priority ?? current.priority ?? 0,
metadata_json: mergedMetadata,
system_token: current.system_token ?? null,
system_userid: current.system_userid ?? null,
checkin_enabled: current.checkin_enabled ?? 0,
checkin_url: current.checkin_url ?? null,
last_checkin_date: current.last_checkin_date ?? null,
last_checkin_status: current.last_checkin_status ?? null,
last_checkin_message: current.last_checkin_message ?? null,
last_checkin_at: current.last_checkin_at ?? null,
updated_at: nowIso(),
});
+30 -3
View File
@@ -1,6 +1,11 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { type TokenRecord, tokenAuth } from "../middleware/tokenAuth";
import {
type CallTokenItem,
selectCallToken,
} from "../services/call-token-selector";
import { listCallTokens } from "../services/channel-call-token-repo";
import {
type ChannelRecord,
createWeightedOrder,
@@ -79,6 +84,10 @@ async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function resolveChannelBaseUrl(channel: ChannelRecord): string {
return normalizeBaseUrl(channel.base_url);
}
/**
* OpenAI-compatible proxy handler.
*/
@@ -119,6 +128,21 @@ proxy.all("/*", tokenAuth, async (c) => {
.bind("active")
.all();
const activeChannels = (channelResult.results ?? []) as ChannelRecord[];
const callTokenRows = await listCallTokens(c.env.DB, {
channelIds: activeChannels.map((channel) => channel.id),
});
const callTokenMap = new Map<string, CallTokenItem[]>();
for (const row of callTokenRows) {
const entry: CallTokenItem = {
id: row.id,
channel_id: row.channel_id,
name: row.name,
api_key: row.api_key,
};
const list = callTokenMap.get(row.channel_id) ?? [];
list.push(entry);
callTokenMap.set(row.channel_id, list);
}
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
const modelChannels = allowedChannels.filter((channel) =>
channelSupportsModel(channel, model),
@@ -149,11 +173,14 @@ proxy.all("/*", tokenAuth, async (c) => {
let shouldRetry = false;
for (const channel of ordered) {
lastChannel = channel;
const baseUrl = normalizeBaseUrl(channel.base_url);
const baseUrl = resolveChannelBaseUrl(channel);
const tokens = callTokenMap.get(channel.id) ?? [];
const selectedToken = tokens.length > 0 ? selectCallToken(tokens) : null;
const apiKey = selectedToken?.api_key ?? channel.api_key;
const target = `${baseUrl}${targetPath}${querySuffix}`;
const headers = new Headers(c.req.header());
headers.set("Authorization", `Bearer ${channel.api_key}`);
headers.set("x-api-key", String(channel.api_key));
headers.set("Authorization", `Bearer ${apiKey}`);
headers.set("x-api-key", String(apiKey));
headers.delete("host");
headers.delete("content-length");
+6 -2
View File
@@ -5,8 +5,8 @@ import {
getRetentionDays,
getSessionTtlHours,
isAdminPasswordSet,
setCheckinSchedule,
setAdminPasswordHash,
setCheckinSchedule,
setRetentionDays,
setSessionTtlHours,
} from "../services/settings";
@@ -87,7 +87,11 @@ settings.put("/", async (c) => {
enabledRaw === undefined
? current.enabled
: enabledRaw === true || enabledRaw === "true";
if (enabledRaw !== undefined && enabledRaw !== true && enabledRaw !== false) {
if (
enabledRaw !== undefined &&
enabledRaw !== true &&
enabledRaw !== false
) {
if (enabledRaw !== "true" && enabledRaw !== "false") {
return jsonError(
c,
+488
View File
@@ -0,0 +1,488 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import {
listCallTokens,
replaceCallTokensForChannel,
} from "../services/channel-call-token-repo";
import {
deleteChannel,
getChannelById,
insertChannel,
listChannels,
updateChannel,
updateChannelCheckinResult,
} from "../services/channel-repo";
import {
type CheckinResultItem,
runCheckin,
summarizeCheckin,
} from "../services/checkin";
import {
buildSiteMetadata,
parseSiteMetadata,
type SiteType,
} from "../services/site-metadata";
import { generateToken } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { beijingDateString, nowIso } from "../utils/time";
import { normalizeBaseUrl } from "../utils/url";
const sites = new Hono<AppEnv>();
type SitePayload = {
name?: string;
base_url?: string;
weight?: number;
status?: string;
site_type?: SiteType;
checkin_url?: string | null;
system_token?: string;
system_userid?: string;
checkin_enabled?: boolean;
call_tokens?: CallTokenPayload[];
api_key?: string;
checkin_token?: string;
checkin_userid?: string;
checkin_status?: string;
};
type CallTokenPayload = {
id?: string;
name?: string;
api_key?: string;
};
const parseSiteType = (value: unknown): SiteType => {
if (
value === "done-hub" ||
value === "new-api" ||
value === "subapi" ||
value === "chatgpt" ||
value === "claude" ||
value === "gemini"
) {
return value;
}
if (value === "custom") {
return "subapi";
}
return "new-api";
};
const trimValue = (value: unknown): string => {
if (typeof value !== "string") {
return "";
}
return value.trim();
};
const DEFAULT_BASE_URL_BY_TYPE: Partial<Record<SiteType, string>> = {
chatgpt: "https://api.openai.com",
claude: "https://api.anthropic.com",
gemini: "https://generativelanguage.googleapis.com",
};
const resolveBaseUrl = (siteType: SiteType, raw: unknown): string => {
const trimmed = trimValue(raw);
if (trimmed) {
return normalizeBaseUrl(trimmed);
}
const fallback = DEFAULT_BASE_URL_BY_TYPE[siteType];
if (fallback) {
return normalizeBaseUrl(fallback);
}
return "";
};
const parseBoolean = (value: unknown, fallback = false): boolean => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
return value.toLowerCase() === "true";
}
return fallback;
};
type NormalizedCallToken = {
name: string;
api_key: string;
};
const normalizeCallTokens = (
rawTokens: CallTokenPayload[] | undefined,
fallbackApiKey: string | undefined,
): NormalizedCallToken[] => {
const tokens =
rawTokens?.map((token, index) => ({
name: trimValue(token.name) || `调用令牌${index + 1}`,
api_key: trimValue(token.api_key),
})) ?? [];
const filtered = tokens.filter((token) => token.api_key.length > 0);
if (filtered.length > 0) {
return filtered;
}
const fallback = trimValue(fallbackApiKey);
if (fallback) {
return [
{
name: "主调用令牌",
api_key: fallback,
},
];
}
return [];
};
const toCallTokenRows = (
channelId: string,
tokens: NormalizedCallToken[],
now: string,
) =>
tokens.map((token) => ({
id: generateToken("ct_"),
channel_id: channelId,
name: token.name,
api_key: token.api_key,
created_at: now,
updated_at: now,
}));
const buildSiteRecord = (
channel: {
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
system_token?: string | null;
system_userid?: string | null;
checkin_enabled?: number | boolean | null;
checkin_url?: string | null;
last_checkin_date?: string | null;
last_checkin_status?: string | null;
last_checkin_message?: string | null;
last_checkin_at?: string | null;
metadata_json?: string | null;
created_at?: string | null;
updated_at?: string | null;
},
callTokens: Array<{
id: string;
name: string;
api_key: string;
}>,
) => {
const metadata = parseSiteMetadata(channel.metadata_json);
const rawEnabled = channel.checkin_enabled ?? 0;
const checkinEnabled =
typeof rawEnabled === "boolean" ? rawEnabled : Number(rawEnabled) === 1;
return {
id: channel.id,
name: channel.name,
base_url: channel.base_url,
weight: Number(channel.weight ?? 1),
status: channel.status,
site_type: metadata.site_type,
api_key: channel.api_key,
system_token: channel.system_token ?? null,
system_userid: channel.system_userid ?? null,
checkin_enabled: checkinEnabled,
checkin_id: null,
checkin_url: channel.checkin_url ?? null,
call_tokens: callTokens,
last_checkin_date: channel.last_checkin_date ?? null,
last_checkin_status: channel.last_checkin_status ?? null,
last_checkin_message: channel.last_checkin_message ?? null,
last_checkin_at: channel.last_checkin_at ?? null,
created_at: channel.created_at ?? null,
updated_at: channel.updated_at ?? null,
};
};
sites.get("/", async (c) => {
const channels = await listChannels(c.env.DB, {
orderBy: "created_at",
order: "DESC",
});
const channelIds = channels.map((channel) => channel.id);
const callTokenRows = await listCallTokens(c.env.DB, {
channelIds,
});
const callTokenMap = new Map<
string,
Array<{
id: string;
name: string;
api_key: string;
}>
>();
for (const row of callTokenRows) {
const entry = {
id: row.id,
name: row.name,
api_key: row.api_key,
};
const list = callTokenMap.get(row.channel_id) ?? [];
list.push(entry);
callTokenMap.set(row.channel_id, list);
}
const sitesList = channels.map((channel) => {
const tokens = callTokenMap.get(channel.id) ?? [];
const callTokens =
tokens.length > 0
? tokens
: channel.api_key
? [
{
id: "",
name: "主调用令牌",
api_key: channel.api_key,
},
]
: [];
return buildSiteRecord(channel, callTokens);
});
return c.json({ sites: sitesList });
});
sites.post("/", async (c) => {
const body = (await c.req.json().catch(() => null)) as SitePayload | null;
if (!body) {
return jsonError(c, 400, "missing_body", "missing_body");
}
const name = trimValue(body.name);
if (!name) {
return jsonError(c, 400, "missing_name", "missing_name");
}
const id = generateToken("ch_");
const now = nowIso();
const siteType = parseSiteType(body.site_type);
const baseUrl = resolveBaseUrl(siteType, body.base_url);
if (!baseUrl) {
return jsonError(c, 400, "missing_base_url", "missing_base_url");
}
const callTokens = normalizeCallTokens(body.call_tokens, body.api_key);
if (callTokens.length === 0) {
return jsonError(c, 400, "missing_call_tokens", "missing_call_tokens");
}
const systemToken = trimValue(body.system_token ?? body.checkin_token);
const systemUser = trimValue(body.system_userid ?? body.checkin_userid);
const checkinUrl =
body.checkin_url !== undefined && body.checkin_url !== null
? trimValue(body.checkin_url)
: "";
const checkinEnabled =
siteType === "new-api"
? parseBoolean(body.checkin_enabled, body.checkin_status === "active")
: false;
if (checkinEnabled && (!systemToken || !systemUser)) {
return jsonError(
c,
400,
"missing_checkin_credentials",
"missing_checkin_credentials",
);
}
const metadataJson = buildSiteMetadata(null, {
site_type: siteType,
});
await insertChannel(c.env.DB, {
id,
name,
base_url: baseUrl,
api_key: callTokens[0].api_key,
weight: Number(body.weight ?? 1),
status: body.status ?? "active",
rate_limit: 0,
models_json: "[]",
type: 1,
group_name: null,
priority: 0,
metadata_json: metadataJson,
system_token: systemToken || null,
system_userid: systemUser || null,
checkin_enabled: checkinEnabled ? 1 : 0,
checkin_url: checkinUrl || null,
last_checkin_date: null,
last_checkin_status: null,
last_checkin_message: null,
last_checkin_at: null,
created_at: now,
updated_at: now,
});
await replaceCallTokensForChannel(
c.env.DB,
id,
toCallTokenRows(id, callTokens, now),
);
return c.json({ id });
});
sites.patch("/:id", async (c) => {
const body = (await c.req.json().catch(() => null)) as SitePayload | null;
const id = c.req.param("id");
if (!body) {
return jsonError(c, 400, "missing_body", "missing_body");
}
const current = await getChannelById(c.env.DB, id);
if (!current) {
return jsonError(c, 404, "site_not_found", "site_not_found");
}
const currentMetadata = parseSiteMetadata(current.metadata_json);
const nextSiteType = body.site_type
? parseSiteType(body.site_type)
: currentMetadata.site_type;
const baseUrl =
body.base_url !== undefined
? resolveBaseUrl(nextSiteType, body.base_url)
: normalizeBaseUrl(String(current.base_url));
if (!baseUrl) {
return jsonError(c, 400, "missing_base_url", "missing_base_url");
}
const shouldUpdateTokens =
body.call_tokens !== undefined || body.api_key !== undefined;
const callTokens = shouldUpdateTokens
? normalizeCallTokens(body.call_tokens, body.api_key ?? current.api_key)
: [];
if (shouldUpdateTokens && callTokens.length === 0) {
return jsonError(c, 400, "missing_call_tokens", "missing_call_tokens");
}
const metadataJson =
body.site_type !== undefined
? buildSiteMetadata(current.metadata_json, {
site_type: nextSiteType,
})
: (current.metadata_json ?? null);
const nextSystemToken =
body.system_token !== undefined || body.checkin_token !== undefined
? trimValue(body.system_token ?? body.checkin_token)
: trimValue(current.system_token ?? "");
const nextSystemUser =
body.system_userid !== undefined || body.checkin_userid !== undefined
? trimValue(body.system_userid ?? body.checkin_userid)
: trimValue(current.system_userid ?? "");
const nextCheckinUrl =
body.checkin_url !== undefined
? body.checkin_url !== null
? trimValue(body.checkin_url)
: ""
: trimValue(current.checkin_url ?? "");
const currentCheckinEnabled =
typeof current.checkin_enabled === "boolean"
? current.checkin_enabled
: Number(current.checkin_enabled ?? 0) === 1;
const nextCheckinEnabled =
nextSiteType === "new-api"
? body.checkin_enabled !== undefined || body.checkin_status !== undefined
? parseBoolean(body.checkin_enabled, body.checkin_status === "active")
: currentCheckinEnabled
: false;
if (nextCheckinEnabled && (!nextSystemToken || !nextSystemUser)) {
return jsonError(
c,
400,
"missing_checkin_credentials",
"missing_checkin_credentials",
);
}
await updateChannel(c.env.DB, id, {
name: body.name ?? current.name,
base_url: baseUrl,
api_key: shouldUpdateTokens ? callTokens[0].api_key : current.api_key,
weight: Number(body.weight ?? current.weight ?? 1),
status: body.status ?? current.status,
rate_limit: current.rate_limit ?? 0,
models_json: current.models_json ?? "[]",
type: current.type ?? 1,
group_name: current.group_name ?? null,
priority: current.priority ?? 0,
metadata_json: metadataJson,
system_token: nextSystemToken || null,
system_userid: nextSystemUser || null,
checkin_enabled: nextCheckinEnabled ? 1 : 0,
checkin_url: nextCheckinUrl || null,
last_checkin_date: current.last_checkin_date ?? null,
last_checkin_status: current.last_checkin_status ?? null,
last_checkin_message: current.last_checkin_message ?? null,
last_checkin_at: current.last_checkin_at ?? null,
updated_at: nowIso(),
});
if (shouldUpdateTokens) {
await replaceCallTokensForChannel(
c.env.DB,
id,
toCallTokenRows(id, callTokens, nowIso()),
);
}
return c.json({ ok: true });
});
sites.delete("/:id", async (c) => {
const id = c.req.param("id");
await deleteChannel(c.env.DB, id);
return c.json({ ok: true });
});
sites.post("/checkin-all", async (c) => {
const channelRows = await listChannels(c.env.DB, { orderBy: "created_at" });
const results: CheckinResultItem[] = [];
const today = beijingDateString();
for (const channel of channelRows) {
if (channel.status !== "active") {
continue;
}
const metadata = parseSiteMetadata(channel.metadata_json);
if (metadata.site_type !== "new-api") {
continue;
}
const rawEnabled = channel.checkin_enabled ?? 0;
const checkinEnabled =
typeof rawEnabled === "boolean" ? rawEnabled : Number(rawEnabled) === 1;
if (!checkinEnabled) {
continue;
}
const alreadyChecked =
channel.last_checkin_date === today &&
(channel.last_checkin_status === "success" ||
channel.last_checkin_status === "skipped");
if (alreadyChecked) {
results.push({
id: channel.id,
name: channel.name,
status: "skipped",
message: channel.last_checkin_message ?? "今日已签到",
checkin_date: channel.last_checkin_date ?? today,
});
continue;
}
const result = await runCheckin({
id: channel.id,
name: channel.name,
base_url: String(channel.base_url),
checkin_url: channel.checkin_url ?? null,
system_token: channel.system_token ?? null,
system_userid: channel.system_userid ?? null,
});
const checkinDate = result.checkin_date ?? today;
await updateChannelCheckinResult(c.env.DB, channel.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 sites;
@@ -0,0 +1,15 @@
export type CallTokenItem = {
id: string;
channel_id: string;
name: string;
api_key: string;
};
export const selectCallToken = (
tokens: CallTokenItem[],
): CallTokenItem | null => {
if (tokens.length === 0) {
return null;
}
return tokens[0];
};
@@ -0,0 +1,81 @@
import type { D1Database } from "@cloudflare/workers-types";
import type { ChannelCallTokenRow } from "./channel-call-token-types";
type ChannelCallTokenFilters = {
channelIds?: string[] | null;
};
const buildWhere = (filters?: ChannelCallTokenFilters) => {
if (!filters?.channelIds || filters.channelIds.length === 0) {
return { whereSql: "", bindings: [] as string[] };
}
const placeholders = filters.channelIds.map(() => "?").join(", ");
return {
whereSql: `WHERE channel_id IN (${placeholders})`,
bindings: filters.channelIds,
};
};
export async function listCallTokens(
db: D1Database,
filters?: ChannelCallTokenFilters,
): Promise<ChannelCallTokenRow[]> {
if (filters?.channelIds && filters.channelIds.length === 0) {
return [];
}
const { whereSql, bindings } = buildWhere(filters);
const statement = db.prepare(
`SELECT * FROM channel_call_tokens ${whereSql} ORDER BY created_at ASC`,
);
const rows = await statement.bind(...bindings).all<ChannelCallTokenRow>();
return rows.results ?? [];
}
export async function deleteCallTokensByChannelId(
db: D1Database,
channelId: string,
): Promise<void> {
await db
.prepare("DELETE FROM channel_call_tokens WHERE channel_id = ?")
.bind(channelId)
.run();
}
export type ChannelCallTokenInsertInput = {
id: string;
channel_id: string;
name: string;
api_key: string;
created_at: string;
updated_at: string;
};
export async function insertCallToken(
db: D1Database,
input: ChannelCallTokenInsertInput,
): Promise<void> {
await db
.prepare(
"INSERT INTO channel_call_tokens (id, channel_id, name, api_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(
input.id,
input.channel_id,
input.name,
input.api_key,
input.created_at,
input.updated_at,
)
.run();
}
export async function replaceCallTokensForChannel(
db: D1Database,
channelId: string,
tokens: ChannelCallTokenInsertInput[],
): Promise<void> {
await deleteCallTokensByChannelId(db, channelId);
for (const token of tokens) {
await insertCallToken(db, token);
}
}
@@ -0,0 +1,8 @@
export type ChannelCallTokenRow = {
id: string;
channel_id: string;
name: string;
api_key: string;
created_at?: string | null;
updated_at?: string | null;
};
+67 -2
View File
@@ -151,6 +151,14 @@ export type ChannelInsertInput = {
group_name: string | null;
priority: number;
metadata_json: string | null;
system_token?: string | null;
system_userid?: string | null;
checkin_enabled?: number | boolean | null;
checkin_url?: string | null;
last_checkin_date?: string | null;
last_checkin_status?: string | null;
last_checkin_message?: string | null;
last_checkin_at?: string | null;
created_at: string;
updated_at: string;
};
@@ -161,7 +169,7 @@ export async function insertChannel(
): Promise<void> {
await db
.prepare(
"INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, type, group_name, priority, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, type, group_name, priority, metadata_json, system_token, system_userid, checkin_enabled, checkin_url, last_checkin_date, last_checkin_status, last_checkin_message, last_checkin_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
input.id,
@@ -176,6 +184,18 @@ export async function insertChannel(
input.group_name,
input.priority,
input.metadata_json,
input.system_token ?? null,
input.system_userid ?? null,
typeof input.checkin_enabled === "boolean"
? input.checkin_enabled
? 1
: 0
: (input.checkin_enabled ?? 0),
input.checkin_url ?? null,
input.last_checkin_date ?? null,
input.last_checkin_status ?? null,
input.last_checkin_message ?? null,
input.last_checkin_at ?? null,
input.created_at,
input.updated_at,
)
@@ -194,6 +214,14 @@ export type ChannelUpdateInput = {
group_name: string | null;
priority: number;
metadata_json: string | null;
system_token: string | null;
system_userid: string | null;
checkin_enabled: number | boolean | null;
checkin_url: string | null;
last_checkin_date: string | null;
last_checkin_status: string | null;
last_checkin_message: string | null;
last_checkin_at: string | null;
updated_at: string;
};
@@ -204,7 +232,7 @@ export async function updateChannel(
): Promise<void> {
await db
.prepare(
"UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, type = ?, group_name = ?, priority = ?, metadata_json = ?, updated_at = ? WHERE id = ?",
"UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, type = ?, group_name = ?, priority = ?, metadata_json = ?, system_token = ?, system_userid = ?, checkin_enabled = ?, checkin_url = ?, last_checkin_date = ?, last_checkin_status = ?, last_checkin_message = ?, last_checkin_at = ?, updated_at = ? WHERE id = ?",
)
.bind(
input.name,
@@ -218,12 +246,49 @@ export async function updateChannel(
input.group_name,
input.priority,
input.metadata_json,
input.system_token,
input.system_userid,
typeof input.checkin_enabled === "boolean"
? input.checkin_enabled
? 1
: 0
: (input.checkin_enabled ?? 0),
input.checkin_url,
input.last_checkin_date,
input.last_checkin_status,
input.last_checkin_message,
input.last_checkin_at,
input.updated_at,
id,
)
.run();
}
export async function updateChannelCheckinResult(
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 channels 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 deleteChannel(db: D1Database, id: string): Promise<void> {
await db.prepare("DELETE FROM channels WHERE id = ?").bind(id).run();
}
@@ -13,6 +13,14 @@ export type ChannelRow = {
metadata_json?: string | null;
test_time?: number | string | null;
response_time_ms?: number | null;
system_token?: string | null;
system_userid?: string | null;
checkin_enabled?: number | boolean | null;
checkin_url?: string | null;
last_checkin_date?: string | null;
last_checkin_status?: string | null;
last_checkin_message?: string | null;
last_checkin_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
@@ -1,156 +0,0 @@
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();
}
@@ -1,17 +0,0 @@
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;
+38 -13
View File
@@ -1,6 +1,14 @@
import { beijingDateString } from "../utils/time";
import { normalizeBaseUrl } from "../utils/url";
import type { CheckinSiteRow } from "./checkin-site-types";
export type CheckinTarget = {
id: string;
name: string;
base_url: string;
checkin_url?: string | null;
system_token?: string | null;
system_userid?: string | null;
};
export type CheckinResultStatus = "success" | "failed" | "skipped";
@@ -41,6 +49,13 @@ const buildCheckinUrl = (baseUrl: string) => {
return `${normalized}/api/user/checkin`;
};
const resolveCheckinBaseUrl = (site: CheckinTarget) => {
if (site.checkin_url?.trim()) {
return site.checkin_url;
}
return site.base_url;
};
const summarizePayload = (payload: unknown): PayloadSummary => {
if (!payload) {
return { type: "null" };
@@ -62,7 +77,7 @@ const summarizePayload = (payload: unknown): PayloadSummary => {
};
const logCheckin = (
site: CheckinSiteRow,
site: CheckinTarget,
stage: string,
data: Record<string, unknown>,
) => {
@@ -148,8 +163,10 @@ const readError = async (response: Response) => {
return parseMessage(payload, `HTTP ${response.status}`);
};
export async function runCheckin(site: CheckinSiteRow): Promise<CheckinResultItem> {
const endpoint = buildCheckinUrl(site.base_url);
export async function runCheckin(
site: CheckinTarget,
): Promise<CheckinResultItem> {
const endpoint = buildCheckinUrl(resolveCheckinBaseUrl(site));
if (!endpoint) {
return {
id: site.id,
@@ -158,10 +175,18 @@ export async function runCheckin(site: CheckinSiteRow): Promise<CheckinResultIte
message: "站点 URL 为空",
};
}
if (!site.system_token || !site.system_token.trim()) {
return {
id: site.id,
name: site.name,
status: "failed",
message: "缺少系统令牌",
};
}
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());
headers.set("Authorization", `Bearer ${site.system_token}`);
if (site.system_userid?.trim()) {
headers.set("New-Api-User", site.system_userid.trim());
} else {
return {
id: site.id,
@@ -251,10 +276,12 @@ export async function runCheckin(site: CheckinSiteRow): Promise<CheckinResultIte
? Number(codeValue)
: null;
const explicitFailure = Boolean(
(record.success === false) ||
(record.status === "error") ||
record.success === false ||
record.status === "error" ||
(record.error && String(record.error).length > 0) ||
(numericCode !== null && !Number.isNaN(numericCode) && numericCode !== 0),
(numericCode !== null &&
!Number.isNaN(numericCode) &&
numericCode !== 0),
);
if (explicitFailure) {
logCheckin(site, "checkin:explicit-failure", {
@@ -322,9 +349,7 @@ export async function runCheckin(site: CheckinSiteRow): Promise<CheckinResultIte
}
}
export function summarizeCheckin(
results: CheckinResultItem[],
): CheckinSummary {
export function summarizeCheckin(results: CheckinResultItem[]): CheckinSummary {
return results.reduce<CheckinSummary>(
(acc, item) => {
acc.total += 1;
+2 -1
View File
@@ -120,7 +120,8 @@ export async function getCheckinSchedule(db: D1Database): Promise<{
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;
const time =
timeRaw && timeRaw.length > 0 ? timeRaw : DEFAULT_CHECKIN_SCHEDULE_TIME;
return { enabled, time };
}
+87
View File
@@ -0,0 +1,87 @@
import { safeJsonParse } from "../utils/json";
import { normalizeBaseUrl } from "../utils/url";
export type SiteType =
| "new-api"
| "done-hub"
| "subapi"
| "chatgpt"
| "claude"
| "gemini";
export type EndpointOverrides = {
chat_url?: string | null;
image_url?: string | null;
embedding_url?: string | null;
};
export type SiteMetadata = {
site_type: SiteType;
endpoint_overrides: EndpointOverrides;
};
const DEFAULT_SITE_TYPE: SiteType = "new-api";
const normalizeOverride = (value: unknown): string | null => {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return normalizeBaseUrl(trimmed);
};
export function parseSiteMetadata(
raw: string | null | undefined,
): SiteMetadata {
const parsed = safeJsonParse<Record<string, unknown>>(raw, {});
const rawType = parsed.site_type;
const site_type =
rawType === "done-hub" ||
rawType === "new-api" ||
rawType === "subapi" ||
rawType === "chatgpt" ||
rawType === "claude" ||
rawType === "gemini"
? (rawType as SiteType)
: rawType === "custom"
? "subapi"
: DEFAULT_SITE_TYPE;
const overrides =
parsed.endpoint_overrides && typeof parsed.endpoint_overrides === "object"
? (parsed.endpoint_overrides as Record<string, unknown>)
: {};
return {
site_type,
endpoint_overrides: {
chat_url: normalizeOverride(overrides.chat_url),
image_url: normalizeOverride(overrides.image_url),
embedding_url: normalizeOverride(overrides.embedding_url),
},
};
}
export function buildSiteMetadata(
existing: string | null | undefined,
updates: {
site_type?: SiteType;
endpoint_overrides?: EndpointOverrides | null;
},
): string | null {
const base = safeJsonParse<Record<string, unknown>>(existing, {});
if (updates.site_type) {
base.site_type = updates.site_type;
}
if (updates.endpoint_overrides) {
base.endpoint_overrides = {
chat_url: normalizeOverride(updates.endpoint_overrides.chat_url),
image_url: normalizeOverride(updates.endpoint_overrides.image_url),
embedding_url: normalizeOverride(
updates.endpoint_overrides.embedding_url,
),
};
}
return Object.keys(base).length > 0 ? JSON.stringify(base) : null;
}
+54
View File
@@ -100,6 +100,23 @@
### 变更
- **[sites/admin-ui]**: 移除历史签到记录关联入口与 orphans 返回,简化站点签到提示
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/sites.ts, apps/ui/src/features/SitesView.tsx, apps/ui/src/App.tsx, apps/ui/src/core/types.ts, apps/ui/src/core/constants.ts, helloagents/modules/sites.md, helloagents/modules/checkin.md, helloagents/modules/admin-ui.md
- **[checkin/db]**: 移除 `checkin_sites` 表与 `/api/checkin-sites` 路由,签到字段迁移至 `channels`
- 类型: 微调(无方案包)
- ⚠️ EHRB: 数据删除 - 用户已确认风险
- 检测依据: DROP TABLE
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/sites.ts, apps/worker/src/services/checkin.ts, apps/worker/src/services/channel-repo.ts, apps/worker/src/services/channel-types.ts, apps/worker/src/db/schema.sql, apps/worker/migrations/0005_move_checkin_to_channels.sql, helloagents/modules/checkin.md, helloagents/modules/sites.md
- **[sites/proxy/admin-ui]**: 移除调用令牌的模型权限字段,改为按顺序选择令牌
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/sites.ts, apps/worker/src/routes/proxy.ts, apps/worker/src/services/call-token-selector.ts, apps/worker/src/services/channel-call-token-repo.ts, apps/worker/src/services/channel-call-token-types.ts, apps/ui/src/features/SitesView.tsx, apps/ui/src/App.tsx, apps/ui/src/core/types.ts, apps/ui/src/core/constants.ts, tests/worker/call-token-selector.test.ts, helloagents/modules/admin-ui.md, helloagents/modules/proxy.md, helloagents/plan/202602221951_site-redesign/proposal.md
- **[admin-ui]**: 新增站点默认开启自动签到,并限制调用令牌列表高度
- 类型: 微调(无方案包)
- 文件: apps/ui/src/core/constants.ts, apps/ui/src/features/SitesView.tsx, helloagents/modules/admin-ui.md
- **[admin-ui]**: 站点弹窗设置最大高度并允许表单滚动,避免调用令牌过多导致超出屏幕
- 类型: 微调(无方案包)
- 文件: apps/ui/src/features/SitesView.tsx
- **[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
@@ -122,6 +139,43 @@
- 类型: 微调(无方案包)
- 文件: apps/worker/migrations/0002_add_checkin_sites.sql
## [0.7.1] - 2026-02-22
### 变更
- **[db]**: 合并 0002-0005 迁移为单一 0002 并同步 schema
- 方案: [202602222203_merge-migrations](archive/2026-02/202602222203_merge-migrations/)
## [0.7.0] - 2026-02-22
### 变更
- **[sites]**: 站点重设计,支持系统令牌与多调用令牌、上游类型扩展与官方地址默认值
- 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/)
- 决策: site-redesign#D001(调用令牌独立表), site-redesign#D002(系统令牌复用 checkin_sites), site-redesign#D003(done-hub 仅默认地址)
- **[proxy]**: 按调用令牌模型权限选择上游并移除 done-hub 多地址切换
- 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/)
- **[admin-ui]**: 站点管理重构为系统令牌 + 多调用令牌,并支持新上游类型
- 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/)
- **[checkin]**: 签到支持自定义地址优先并保留 new-api 自动签到
- 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/)
- **[db]**: 新增 `channel_call_tokens` 以存储多调用令牌
- 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/)
## [0.6.0] - 2026-02-22
### 变更
- **[sites]**: 新增站点聚合接口与类型分类,支持自动关联签到记录
- 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/)
- 决策: unify-sites#D001(渠道为主 + 签到绑定)
- **[admin-ui]**: 站点管理合并渠道与签到配置,支持类型字段与一键签到
- 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/)
- **[checkin]**: 签到记录关联渠道,仅对 new-api 站点执行
- 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/)
- **[proxy]**: done-hub 站点按多地址配置选择上游
- 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/)
- **[db]**: checkin_sites 增加 channel_id 关联字段与索引
- 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/)
## [0.5.0] - 2026-02-22
### 新增
+3 -3
View File
@@ -16,9 +16,9 @@
## 知识库状态
```yaml
最后更新: 2026-02-21 23:47
模块数量: 10
待执行方案: 9
最后更新: 2026-02-22 19:00
模块数量: 11
待执行方案: 6
```
## 读取指引
+3 -1
View File
@@ -9,6 +9,7 @@
| auth | 管理员登录与会话 | 🚧 | [auth.md](./auth.md) |
| proxy | OpenAI 兼容代理与转发 | 🚧 | [proxy.md](./proxy.md) |
| channels | 渠道管理与健康检查 | 🚧 | [channels.md](./channels.md) |
| sites | 站点聚合管理 | 🚧 | [sites.md](./sites.md) |
| models | 模型广场与聚合 | 🚧 | [models.md](./models.md) |
| tokens | 令牌管理与额度 | 🚧 | [tokens.md](./tokens.md) |
| usage | 使用日志与留存 | 🚧 | [usage.md](./usage.md) |
@@ -21,7 +22,8 @@
## 模块依赖关系
```
admin-ui → auth, channels, models, tokens, usage, dashboard, settings, checkin
admin-ui → auth, sites, models, tokens, usage, dashboard, settings
sites → channels, checkin
proxy → channels, tokens, usage
dashboard → usage, tokens
models → channels
+8 -5
View File
@@ -2,7 +2,7 @@
## 职责
- 管理台前端展示与操作
- 覆盖渠道、模型、令牌、日志、面板与设置
- 覆盖站点、模型、令牌、日志、面板与设置
## 接口定义
- 调用 `/api/*` 管理接口
@@ -16,12 +16,15 @@
- 按 `core/`(类型/常量/API)、`features/`(业务视图 + 布局/登录)组织前端模块
- 样式基于 Tailwind v4(结合全局主题变量)
- 前端状态集中在单页
- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
- 站点管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
- 站点类型支持 new-api / done-hub / subapi / chatgpt / claude / gemini,类型驱动表单字段
- 站点表单提供系统令牌、userid、自动签到开关与外部签到地址(new-api 默认开启)
- 站点支持多调用令牌(备注 + 令牌值)
- chatgpt/claude/gemini 站点支持 base_url 为空时自动填充官方地址
- done-hub 站点仅使用 base_url,不再维护多地址输入
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 签到站点采用列表视图(默认展示全部),支持新增/编辑弹窗、一键签到与手动签到
- 签到站点列表展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 今日签到状态区分“未签到”与“签到失败”
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 系统设置增加定时签到开关与时间(中国时间)
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
- Token 仅本地保存,不回显
+3
View File
@@ -11,6 +11,7 @@
- `PATCH /api/channels/:id` 更新渠道
- `DELETE /api/channels/:id` 删除渠道
- `POST /api/channels/:id/test` 连通性测试
- `GET /api/sites` 站点聚合列表(聚合 channels + channel_call_tokens)
- `GET /api/channel` New API 兼容列表
- `GET /api/channel/search` New API 兼容搜索
- `POST /api/channel` New API 兼容新增
@@ -31,6 +32,8 @@
- New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json`
- New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新
- New API 分组列表从 `group_name` 字段解析,空时返回 `default`
- 站点类型写入 `metadata_json.site_type`(new-api / done-hub / subapi / chatgpt / claude / gemini)
- done-hub 仅使用 `base_url`,不再维护多地址配置
## 依赖关系
- `channels` 表
+6 -8
View File
@@ -7,20 +7,18 @@
- 写回今日签到结果到站点表
## 接口定义
- `GET /api/checkin-sites`: 获取签到站点列表
- `POST /api/checkin-sites`: 新增签到站点
- `PATCH /api/checkin-sites/:id`: 更新签到站点
- `DELETE /api/checkin-sites/:id`: 删除签到站点
- `POST /api/checkin-sites/checkin-all`: 一键签到(仅启用站点)
- `POST /api/sites/checkin-all`: 站点一键签到(仅 new-api + 自动签到开启)
## 行为规范
- 一键签到仅执行 `status=active` 的站点
- 仅对 `site_type=new-api` 且 `checkin_enabled=1` 的站点执行签到
- `checkin_sites` CRUD 接口已移除,签到字段统一由 `channels` 承载
- 一键签到会跳过 `last_checkin_date` 已是北京时间当日且状态为成功/已签的站点
- 手动签到优先打开 `checkin_url`,为空则打开 `base_url`
- 签到执行使用后端代理调用 `{base_url}/api/user/checkin`(GET 检查,POST 签到)
- 签到执行使用 `checkin_url`(若为空则回退 `base_url`)拼接 `/api/user/checkin`
- 请求头必须携带 `New-Api-User`(值来自站点的 `userid` 配置)
- 签到结果写回 `checkin_sites.last_checkin_*` 字段(date/status/message/at)
- 签到结果写回 `channels.last_checkin_*` 字段(date/status/message/at)
## 依赖关系
- `worker` 路由与 D1 数据表 `checkin_sites`
- `worker` 路由与 D1 数据表 `channels`
- `admin-ui` 负责前端交互与结果展示
+2
View File
@@ -11,6 +11,8 @@
## 行为规范
- 基于令牌鉴权
- 按渠道权重随机排序
- 调用令牌按顺序选择,未配置时回退 `channels.api_key`
- done-hub 仅使用 `base_url`,不再按多地址切换
- 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
- 对 `/v1/responses` 且上游返回 400/404 时回退为 `/responses` 重试一次
+29
View File
@@ -0,0 +1,29 @@
# sites 模块
## 职责
- 聚合渠道与系统令牌为统一“站点”视图
- 提供站点级 CRUD、上游类型与多调用令牌管理
- 提供站点一键签到入口(仅 new-api)
## 接口定义
- `GET /api/sites`: 获取站点列表
- `POST /api/sites`: 新增站点(系统令牌 + 多调用令牌)
- `PATCH /api/sites/:id`: 更新站点信息、系统令牌与调用令牌
- `DELETE /api/sites/:id`: 删除站点
- `POST /api/sites/checkin-all`: 站点一键签到(仅 new-api)
## 行为规范
- 站点主记录来源于 `channels` 表
- 系统令牌与签到配置存储在 `channels` 的 `system_token/system_userid/checkin_*` 字段
- 调用令牌来源于 `channel_call_tokens` 表,按 `channel_id` 聚合
- 站点类型写入 `channels.metadata_json.site_type`
- done-hub 仅使用 `base_url`,不再支持多地址
- chatgpt/claude/gemini 在未填写 base_url 时自动使用官方地址
- 一键签到仅执行满足以下条件的记录:
- `channels.status=active`
- `site_type=new-api`
- `checkin_enabled=1`
## 依赖关系
- `channels` / `channel_call_tokens` 表
- `channels` / `checkin` / `proxy` 模块