feat: add site headers editor and scheduled log cleanup
This commit is contained in:
@@ -25,6 +25,10 @@ type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
type RuntimeSettings = {
|
||||
checkinCron: string;
|
||||
balanceRefreshCron: string;
|
||||
logCleanupCron: string;
|
||||
logCleanupUsageLogsEnabled: boolean;
|
||||
logCleanupProgramLogsEnabled: boolean;
|
||||
logCleanupRetentionDays: number;
|
||||
routingFallbackUnitCost: number;
|
||||
routingWeights: RoutingWeights;
|
||||
systemProxyUrl: string;
|
||||
@@ -179,6 +183,10 @@ export default function Settings() {
|
||||
const [runtime, setRuntime] = useState<RuntimeSettings>({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '0 6 * * *',
|
||||
logCleanupUsageLogsEnabled: false,
|
||||
logCleanupProgramLogsEnabled: false,
|
||||
logCleanupRetentionDays: 30,
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: defaultWeights,
|
||||
systemProxyUrl: '',
|
||||
@@ -381,6 +389,12 @@ export default function Settings() {
|
||||
setRuntime({
|
||||
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
||||
balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
|
||||
logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *',
|
||||
logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled,
|
||||
logCleanupProgramLogsEnabled: !!runtimeInfo.logCleanupProgramLogsEnabled,
|
||||
logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1
|
||||
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
||||
: 30,
|
||||
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
|
||||
? Number(runtimeInfo.routingFallbackUnitCost)
|
||||
: 1,
|
||||
@@ -452,8 +466,12 @@ export default function Settings() {
|
||||
await api.updateRuntimeSettings({
|
||||
checkinCron: runtime.checkinCron,
|
||||
balanceRefreshCron: runtime.balanceRefreshCron,
|
||||
logCleanupCron: runtime.logCleanupCron,
|
||||
logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled,
|
||||
logCleanupProgramLogsEnabled: runtime.logCleanupProgramLogsEnabled,
|
||||
logCleanupRetentionDays: runtime.logCleanupRetentionDays,
|
||||
});
|
||||
toast.success('Schedule settings saved');
|
||||
toast.success('定时任务设置已保存');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存失败');
|
||||
} finally {
|
||||
@@ -903,6 +921,66 @@ export default function Settings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid var(--color-border-light)',
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>自动清理日志</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>清理 Cron</div>
|
||||
<input
|
||||
value={runtime.logCleanupCron}
|
||||
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupCron: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>保留天数</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={runtime.logCleanupRetentionDays}
|
||||
onChange={(e) => setRuntime((prev) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
return {
|
||||
...prev,
|
||||
logCleanupRetentionDays: Number.isFinite(nextValue) && nextValue >= 1
|
||||
? Math.trunc(nextValue)
|
||||
: prev.logCleanupRetentionDays,
|
||||
};
|
||||
})}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={runtime.logCleanupUsageLogsEnabled}
|
||||
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupUsageLogsEnabled: e.target.checked }))}
|
||||
/>
|
||||
清理使用日志
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={runtime.logCleanupProgramLogsEnabled}
|
||||
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupProgramLogsEnabled: e.target.checked }))}
|
||||
/>
|
||||
清理程序日志
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
默认每天早上 6 点执行。按每次定时任务执行时间,清理早于“保留天数”的日志;两个选项都不勾选时不会实际删除日志。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button onClick={saveSchedule} disabled={savingSchedule} className="btn btn-primary">
|
||||
{savingSchedule ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存定时任务'}
|
||||
@@ -1758,4 +1836,3 @@ export default function Settings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+230
-133
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||
@@ -14,7 +14,9 @@ import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteCustomHeader,
|
||||
emptySiteForm,
|
||||
serializeSiteCustomHeaders,
|
||||
siteFormFromSite,
|
||||
type SiteEditorState,
|
||||
type SiteForm,
|
||||
@@ -28,6 +30,7 @@ type SiteRow = {
|
||||
platform?: string;
|
||||
status?: string;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
globalWeight?: number;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
@@ -35,6 +38,10 @@ type SiteRow = {
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
|
||||
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
|
||||
}
|
||||
|
||||
const platformColors: Record<string, string> = {
|
||||
'new-api': 'badge-info',
|
||||
'one-api': 'badge-success',
|
||||
@@ -83,7 +90,6 @@ export default function Sites() {
|
||||
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
|
||||
const isMobile = useIsMobile(768);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
|
||||
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<number | null>(null);
|
||||
@@ -93,6 +99,16 @@ export default function Sites() {
|
||||
const activeEditor = editor || lastEditorRef.current;
|
||||
const isEditing = activeEditor?.mode === 'edit';
|
||||
const isAdding = activeEditor?.mode === 'add';
|
||||
const formInputStyle = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
} as const;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -190,6 +206,11 @@ export default function Sites() {
|
||||
toast.error('全局权重必须是大于 0 的数字');
|
||||
return;
|
||||
}
|
||||
const serializedCustomHeaders = serializeSiteCustomHeaders(form.customHeaders);
|
||||
if (!serializedCustomHeaders.valid) {
|
||||
toast.error(serializedCustomHeaders.error || '自定义请求头格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
@@ -197,6 +218,7 @@ export default function Sites() {
|
||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||
platform: form.platform.trim(),
|
||||
useSystemProxy: !!form.useSystemProxy,
|
||||
customHeaders: serializedCustomHeaders.customHeaders,
|
||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||
};
|
||||
if (!payload.name || !payload.url) {
|
||||
@@ -238,6 +260,34 @@ export default function Sites() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateCustomHeaderRow = (index: number, field: 'key' | 'value', value: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customHeaders: prev.customHeaders.map((item, itemIndex) => (
|
||||
itemIndex === index
|
||||
? { ...item, [field]: value }
|
||||
: item
|
||||
)),
|
||||
}));
|
||||
};
|
||||
|
||||
const addCustomHeaderRow = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customHeaders: [...prev.customHeaders, emptySiteCustomHeader()],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeCustomHeaderRow = (index: number) => {
|
||||
setForm((prev) => {
|
||||
const nextHeaders = prev.customHeaders.filter((_, itemIndex) => itemIndex !== index);
|
||||
return {
|
||||
...prev,
|
||||
customHeaders: nextHeaders.length > 0 ? nextHeaders : [emptySiteCustomHeader()],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleDetect = async () => {
|
||||
if (!form.url.trim()) {
|
||||
toast.error('请先输入 URL');
|
||||
@@ -465,138 +515,174 @@ export default function Sites() {
|
||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||
</div>
|
||||
|
||||
{editorPresence.shouldRender && activeEditor && (
|
||||
<div className={`card panel-presence ${editorPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
{activeEditor && (
|
||||
<CenteredModal
|
||||
open={Boolean(editor)}
|
||||
onClose={closeEditor}
|
||||
title={(
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{isEditing ? '编辑站点' : '添加站点'}
|
||||
</div>
|
||||
<button onClick={closeEditor} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }}>
|
||||
取消
|
||||
)}
|
||||
maxWidth={920}
|
||||
bodyStyle={{
|
||||
maxHeight: isMobile ? '78vh' : '72vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={closeEditor} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : (isEditing ? '保存修改' : '保存站点')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
placeholder="站点名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<input
|
||||
placeholder="站点 URL (例如 https://api.example.com)"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||
onBlur={() => {
|
||||
if (form.url.trim() && !form.platform.trim()) {
|
||||
handleDetect();
|
||||
}
|
||||
}}
|
||||
style={{ ...formInputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={detecting || !form.url.trim()}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<input
|
||||
placeholder="站点名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<ModernSelect
|
||||
value={form.platform}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
|
||||
options={platformOptions}
|
||||
placeholder="平台类型(可自动检测)"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
placeholder="站点 URL (例如 https://api.example.com)"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||
onBlur={() => {
|
||||
if (form.url.trim() && !form.platform.trim()) {
|
||||
handleDetect();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={detecting || !form.url.trim()}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<ModernSelect
|
||||
value={form.platform}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
|
||||
options={platformOptions}
|
||||
placeholder="平台类型(可自动检测)"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="外部签到/福利站点 URL(可选)"
|
||||
value={form.externalCheckinUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<label style={{
|
||||
</div>
|
||||
<input
|
||||
placeholder="外部签到/福利站点 URL(可选)"
|
||||
value={form.externalCheckinUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
padding: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.useSystemProxy}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
|
||||
/>
|
||||
使用系统代理
|
||||
</label>
|
||||
<input
|
||||
placeholder="站点全局权重(默认 1)"
|
||||
value={form.globalWeight}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
越大越容易被路由选中。建议 0.5-3,默认 1。
|
||||
background: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
站点自定义请求头
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomHeaderRow}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
+ 添加请求头
|
||||
</button>
|
||||
</div>
|
||||
{form.customHeaders.map((header, index) => (
|
||||
<div
|
||||
key={`custom-header-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="Header 名称"
|
||||
value={header.key}
|
||||
onChange={(e) => updateCustomHeaderRow(index, 'key', e.target.value)}
|
||||
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="Header 值"
|
||||
value={header.value}
|
||||
onChange={(e) => updateCustomHeaderRow(index, 'value', e.target.value)}
|
||||
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomHeaderRow(index)}
|
||||
className="btn btn-link btn-link-danger"
|
||||
style={isMobile ? { alignSelf: 'flex-end' } : undefined}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="btn btn-primary"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : (isEditing ? '保存修改' : '保存站点')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.useSystemProxy}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
|
||||
/>
|
||||
使用系统代理
|
||||
</label>
|
||||
<input
|
||||
placeholder="站点全局权重(默认 1)"
|
||||
value={form.globalWeight}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
越大越容易被路由选中。建议 0.5-3,默认 1。
|
||||
</div>
|
||||
</CenteredModal>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
@@ -666,6 +752,10 @@ export default function Sites() {
|
||||
</a>
|
||||
) : '-'}
|
||||
/>
|
||||
<MobileField
|
||||
label="自定义头"
|
||||
value={hasConfiguredCustomHeaders(site.customHeaders) ? '已配置' : '-'}
|
||||
/>
|
||||
<MobileField
|
||||
label="创建时间"
|
||||
value={formatDateTimeLocal(site.createdAt)}
|
||||
@@ -774,17 +864,24 @@ export default function Sites() {
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--color-text-primary)',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{site.name}
|
||||
</a>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--color-text-primary)',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{site.name}
|
||||
</a>
|
||||
{hasConfiguredCustomHeaders(site.customHeaders) ? (
|
||||
<span className="badge badge-info" style={{ fontSize: 11 }}>
|
||||
自定义头
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
{site.externalCheckinUrl ? (
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSiteSaveAction, emptySiteForm, siteFormFromSite } from './sitesEditor.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteCustomHeader,
|
||||
emptySiteForm,
|
||||
serializeSiteCustomHeaders,
|
||||
siteFormFromSite,
|
||||
} from './sitesEditor.js';
|
||||
|
||||
describe('buildSiteSaveAction', () => {
|
||||
it('returns add action in add mode', () => {
|
||||
@@ -10,8 +16,9 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -22,8 +29,9 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -36,8 +44,9 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
useSystemProxy: true,
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -49,8 +58,9 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
useSystemProxy: true,
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -64,8 +74,9 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://c.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '1',
|
||||
useSystemProxy: false,
|
||||
customHeaders: '',
|
||||
globalWeight: 1,
|
||||
},
|
||||
),
|
||||
).toThrow('editingSiteId is required in edit mode');
|
||||
@@ -73,14 +84,48 @@ describe('buildSiteSaveAction', () => {
|
||||
|
||||
it('does not expose deprecated apiKey in site editor state', () => {
|
||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
|
||||
expect(siteFormFromSite({
|
||||
name: 'site-d',
|
||||
url: 'https://d.example.com',
|
||||
externalCheckinUrl: null,
|
||||
platform: 'new-api',
|
||||
proxyUrl: null,
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
globalWeight: 1,
|
||||
apiKey: 'sk-legacy-site-key',
|
||||
})).not.toHaveProperty('apiKey');
|
||||
});
|
||||
|
||||
it('parses custom headers json into key value rows', () => {
|
||||
expect(siteFormFromSite({
|
||||
name: 'site-e',
|
||||
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
|
||||
}).customHeaders).toEqual([
|
||||
{ key: 'x-site-token', value: 'alpha' },
|
||||
{ key: 'cf-access-client-id', value: 'beta' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes key value rows into json', () => {
|
||||
expect(serializeSiteCustomHeaders([
|
||||
{ key: 'x-site-token', value: 'alpha' },
|
||||
{ key: 'cf-access-client-id', value: 'beta' },
|
||||
emptySiteCustomHeader(),
|
||||
])).toEqual({
|
||||
valid: true,
|
||||
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate custom header names case-insensitively', () => {
|
||||
expect(serializeSiteCustomHeaders([
|
||||
{ key: 'Authorization', value: 'Bearer a' },
|
||||
{ key: 'authorization', value: 'Bearer b' },
|
||||
])).toEqual({
|
||||
valid: false,
|
||||
customHeaders: '',
|
||||
error: '请求头 "authorization" 重复了',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export type SiteCustomHeaderField = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SiteForm = {
|
||||
name: string;
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
useSystemProxy: boolean;
|
||||
customHeaders: SiteCustomHeaderField[];
|
||||
globalWeight: string;
|
||||
};
|
||||
|
||||
@@ -11,17 +17,69 @@ export type SiteEditorState =
|
||||
| { mode: 'add' }
|
||||
| { mode: 'edit'; editingSiteId: number };
|
||||
|
||||
type SiteSaveAction =
|
||||
| { kind: 'add'; payload: SiteForm }
|
||||
| { kind: 'update'; id: number; payload: SiteForm };
|
||||
export type SiteSavePayload = {
|
||||
name: string;
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
useSystemProxy: boolean;
|
||||
customHeaders: string;
|
||||
globalWeight: number;
|
||||
};
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', useSystemProxy: false, globalWeight: '1' };
|
||||
type SiteSaveAction =
|
||||
| { kind: 'add'; payload: SiteSavePayload }
|
||||
| { kind: 'update'; id: number; payload: SiteSavePayload };
|
||||
|
||||
export function emptySiteCustomHeader(): SiteCustomHeaderField {
|
||||
return { key: '', value: '' };
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
|
||||
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
|
||||
}
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return {
|
||||
name: '',
|
||||
url: '',
|
||||
externalCheckinUrl: '',
|
||||
platform: '',
|
||||
useSystemProxy: false,
|
||||
customHeaders: [emptySiteCustomHeader()],
|
||||
globalWeight: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
||||
if (typeof raw !== 'string') {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
return ensureSiteCustomHeaderRows(
|
||||
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value ?? ''),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'useSystemProxy'>> & {
|
||||
externalCheckinUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
customHeaders?: string | null;
|
||||
globalWeight?: number | string | null;
|
||||
}): SiteForm {
|
||||
const globalWeightRaw = Number(site.globalWeight);
|
||||
@@ -32,11 +90,42 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||
platform: site.platform ?? '',
|
||||
useSystemProxy: !!site.useSystemProxy,
|
||||
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
|
||||
globalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteForm): SiteSaveAction {
|
||||
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
|
||||
valid: boolean;
|
||||
customHeaders: string;
|
||||
error?: string;
|
||||
} {
|
||||
const headers: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const key = field.key.trim();
|
||||
const value = field.value;
|
||||
const hasAnyInput = key.length > 0 || value.trim().length > 0;
|
||||
if (!hasAnyInput) continue;
|
||||
if (!key) {
|
||||
return { valid: false, customHeaders: '', error: '请求头名称不能为空' };
|
||||
}
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (seen.has(normalizedKey)) {
|
||||
return { valid: false, customHeaders: '', error: `请求头 "${key}" 重复了` };
|
||||
}
|
||||
seen.add(normalizedKey);
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
customHeaders: Object.keys(headers).length > 0 ? JSON.stringify(headers) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
|
||||
if (editor.mode === 'edit') {
|
||||
if (!Number.isFinite(editor.editingSiteId)) {
|
||||
throw new Error('editingSiteId is required in edit mode');
|
||||
|
||||
@@ -9,7 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getAuthInfo: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutes: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
factoryReset: vi.fn(),
|
||||
},
|
||||
@@ -66,13 +66,17 @@ describe('Settings factory reset', () => {
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '0 6 * * *',
|
||||
logCleanupUsageLogsEnabled: false,
|
||||
logCleanupProgramLogsEnabled: false,
|
||||
logCleanupRetentionDays: 30,
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: {},
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutes.mockResolvedValue([]);
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Settings from './Settings.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAuthInfo: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: () => null,
|
||||
InlineBrandIcon: () => null,
|
||||
getBrand: () => null,
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Settings log cleanup schedule', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '15 4 * * *',
|
||||
logCleanupUsageLogsEnabled: true,
|
||||
logCleanupProgramLogsEnabled: true,
|
||||
logCleanupRetentionDays: 14,
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: {},
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
restartRequired: false,
|
||||
});
|
||||
apiMock.updateRuntimeSettings.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('saves log cleanup fields together with other schedule settings', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存定时任务'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '15 4 * * *',
|
||||
logCleanupUsageLogsEnabled: true,
|
||||
logCleanupProgramLogsEnabled: true,
|
||||
logCleanupRetentionDays: 14,
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getAuthInfo: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutes: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
},
|
||||
@@ -47,13 +47,17 @@ describe('Settings system proxy', () => {
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '0 6 * * *',
|
||||
logCleanupUsageLogsEnabled: false,
|
||||
logCleanupProgramLogsEnabled: false,
|
||||
logCleanupRetentionDays: 30,
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: {},
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutes.mockResolvedValue([]);
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Sites centered modal adoption', () => {
|
||||
it('uses CenteredModal for add/edit site flows instead of inline form panels', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Sites.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
|
||||
expect(source).toContain('<CenteredModal');
|
||||
expect(source).not.toContain('editorPresence.shouldRender && activeEditor && (');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user