feat(sites): 新增站点管理与调用令牌支持
- 前端替换为 SitesView,支持站点类型/令牌/签到管理 - 后端新增 sites 接口、call token 表与元数据解析
This commit is contained in:
1 parent
83a06ee59e
commit
0dd3b72edd
36 files changed
+2013
-1644
No files matched your search
@@ -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
@@ -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">
|
||||
未知模块
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 +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))}
|
||||
>
|
||||
<
|
||||
</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))
|
||||
}
|
||||
>
|
||||
>
|
||||
</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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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))}
|
||||
>
|
||||
<
|
||||
</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))
|
||||
}
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
## 知识库状态
|
||||
|
||||
```yaml
|
||||
最后更新: 2026-02-21 23:47
|
||||
模块数量: 10
|
||||
待执行方案: 9
|
||||
最后更新: 2026-02-22 19:00
|
||||
模块数量: 11
|
||||
待执行方案: 6
|
||||
```
|
||||
|
||||
## 读取指引
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 仅本地保存,不回显
|
||||
|
||||
@@ -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` 表
|
||||
|
||||
@@ -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` 负责前端交互与结果展示
|
||||
@@ -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` 重试一次
|
||||
|
||||
@@ -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` 模块
|
||||
Reference in new issue
Block a user