Skip to content

Commit 21898a9

Browse files
Bowl42claude
andauthored
fix: 修复 cooldown clientType 和 routes 页面滚动问题 (#156)
* feat: 重构路由页面,合并到 client-routes 并改用 ID 路由 - 将全局路由页面合并到 client-routes,添加 Global/Projects 标签页 - 项目详情页改用 ID 路由替代 slug 路由 - 添加项目自定义路由开关功能 - 后端添加项目更新时 name 和 slug 必填验证 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: 修复手动设置 cooldown 时 clientType 为空的问题 - 修复 provider-details-dialog 中 setCooldown 调用缺少 clientType 参数 - 重构 routes 页面顶部 Tab 样式,分为 Global 和 Projects 两个分组 - 无 Project 时隐藏 Tab 栏 - Tab 栏设置最大宽度并居中对齐 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: 修复 routes 页面无法滚动的问题 - 给 Tabs 和 ClientTypeRoutesContent 添加 min-h-0 允许 flex 子元素收缩 - 修正 px-lg 为 px-6 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: 添加手动冻结功能和修复 cooldown clientType - 后端:添加 SetCooldownUntil 方法支持手动设置冻结时间 - 后端:添加 ReasonManual 冻结原因 - 后端:添加 PUT /admin/cooldowns/{id} API - 前端:在 CooldownsContext 添加 setCooldown 方法 - 前端:在 provider-details-dialog 添加手动冻结 UI - 前端:修复 setCooldown 调用时缺少 clientType 参数 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3594cff commit 21898a9

File tree

18 files changed

+345
-60
lines changed

18 files changed

+345
-60
lines changed

internal/cooldown/manager.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,16 @@ func (m *Manager) SetCooldownDuration(providerID uint64, clientType string, dura
199199
m.setCooldownLocked(providerID, clientType, until, ReasonUnknown)
200200
}
201201

202+
// SetCooldownUntil sets a cooldown for a provider until a specific time
203+
// This is used for manual freezing by admin
204+
func (m *Manager) SetCooldownUntil(providerID uint64, clientType string, until time.Time) {
205+
log.Printf("[Cooldown] SetCooldownUntil: providerID=%d, clientType=%q, until=%v", providerID, clientType, until)
206+
m.mu.Lock()
207+
defer m.mu.Unlock()
208+
m.setCooldownLocked(providerID, clientType, until, ReasonManual)
209+
log.Printf("[Cooldown] SetCooldownUntil: done, current cooldowns count=%d", len(m.cooldowns))
210+
}
211+
202212
// ClearCooldown removes the cooldown for a provider
203213
// If clientType is empty, clears ALL cooldowns for the provider (both global and specific)
204214
// If clientType is specified, only clears that specific cooldown

internal/cooldown/policy.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const (
6868
ReasonRateLimit CooldownReason = "rate_limit_exceeded" // Rate limit (fallback when no explicit time)
6969
ReasonConcurrentLimit CooldownReason = "concurrent_limit" // Concurrent request limit (fallback when no explicit time)
7070
ReasonUnknown CooldownReason = "unknown" // Unknown error
71+
ReasonManual CooldownReason = "manual" // Manually frozen by admin
7172
)
7273

7374
// DefaultPolicies returns the default policy configuration

internal/handler/admin.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handler
22

33
import (
44
"encoding/json"
5+
"log"
56
"net/http"
67
"strconv"
78
"strings"
@@ -936,6 +937,7 @@ func (h *AdminHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
936937

937938
// Cooldowns handler
938939
// GET /admin/cooldowns - list all active cooldowns
940+
// PUT /admin/cooldowns/{id} - set cooldown for a provider until a specific time
939941
// DELETE /admin/cooldowns/{id} - clear cooldown for a provider
940942
func (h *AdminHandler) handleCooldowns(w http.ResponseWriter, r *http.Request, providerID uint64) {
941943
cm := cooldown.Default()
@@ -963,6 +965,32 @@ func (h *AdminHandler) handleCooldowns(w http.ResponseWriter, r *http.Request, p
963965

964966
writeJSON(w, http.StatusOK, result)
965967

968+
case http.MethodPut:
969+
if providerID == 0 {
970+
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id required"})
971+
return
972+
}
973+
var body struct {
974+
UntilTime string `json:"untilTime"` // RFC3339 format
975+
ClientType string `json:"clientType"` // Optional, defaults to empty (global)
976+
}
977+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
978+
log.Printf("[Cooldown] PUT /cooldowns/%d: failed to decode body: %v", providerID, err)
979+
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
980+
return
981+
}
982+
log.Printf("[Cooldown] PUT /cooldowns/%d: received untilTime=%s, clientType=%s", providerID, body.UntilTime, body.ClientType)
983+
until, err := time.Parse(time.RFC3339, body.UntilTime)
984+
if err != nil {
985+
log.Printf("[Cooldown] PUT /cooldowns/%d: failed to parse untilTime: %v", providerID, err)
986+
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid untilTime format"})
987+
return
988+
}
989+
log.Printf("[Cooldown] PUT /cooldowns/%d: setting cooldown until %v", providerID, until)
990+
cm.SetCooldownUntil(providerID, body.ClientType, until)
991+
log.Printf("[Cooldown] PUT /cooldowns/%d: cooldown set successfully", providerID)
992+
writeJSON(w, http.StatusOK, map[string]string{"message": "cooldown set"})
993+
966994
case http.MethodDelete:
967995
if providerID == 0 {
968996
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id required"})

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"class-variance-authority": "^0.7.1",
2727
"clsx": "^2.1.1",
2828
"date-fns": "^4.1.0",
29+
"dayjs": "^1.11.19",
2930
"diff": "^8.0.3",
3031
"i18next": "^25.7.4",
3132
"lucide-react": "^0.562.0",

web/pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/src/components/cooldown-details-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export function CooldownDetailsDialog({
132132
});
133133
};
134134

135-
const untilDateStr = formatUntilTime(cooldown.untilTime);
135+
const untilDateStr = formatUntilTime(cooldown.until);
136136
const [datePart, timePart] = untilDateStr.split(' ');
137137

138138
return (

web/src/components/cooldown-timer.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,9 @@ export function CooldownTimer({ cooldown, className }: CooldownTimerProps) {
4040
}
4141

4242
function calculateRemaining(cooldown: Cooldown): number {
43-
const untilTime =
44-
cooldown.untilTime || ((cooldown as unknown as Record<string, unknown>).until as string);
45-
if (!untilTime) return 0;
43+
if (!cooldown.until) return 0;
4644

47-
const until = new Date(untilTime).getTime();
45+
const until = new Date(cooldown.until).getTime();
4846
const now = Date.now();
4947
return Math.max(0, Math.floor((until - now) / 1000));
5048
}

web/src/components/provider-details-dialog.tsx

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState, useCallback } from 'react';
1+
import { useEffect, useState, useCallback, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import type { TFunction } from 'i18next';
44
import {
@@ -20,7 +20,12 @@ import {
2020
CheckCircle2,
2121
XCircle,
2222
Trash2,
23+
Hand,
2324
} from 'lucide-react';
25+
import dayjs from 'dayjs';
26+
import customParseFormat from 'dayjs/plugin/customParseFormat';
27+
28+
dayjs.extend(customParseFormat);
2429
import type { Cooldown, ProviderStats, ClientType } from '@/lib/transport/types';
2530
import type { ProviderConfigItem } from '@/pages/client-routes/types';
2631
import { useCooldownsContext } from '@/contexts/cooldowns-context';
@@ -96,6 +101,13 @@ const getReasonInfo = (t: TFunction) => ({
96101
color: 'text-muted-foreground',
97102
bgColor: 'bg-muted/50 border-border',
98103
},
104+
manual: {
105+
label: t('provider.reasons.manual'),
106+
description: t('provider.reasons.manualDesc', 'Provider 已被管理员手动冷冻'),
107+
icon: Hand,
108+
color: 'text-indigo-500 dark:text-indigo-400',
109+
bgColor: 'bg-indigo-500/10 dark:bg-indigo-500/15 border-indigo-500/30 dark:border-indigo-500/25',
110+
},
99111
});
100112

101113
// 格式化 Token 数量
@@ -130,6 +142,73 @@ function calcCacheRate(stats: ProviderStats): number {
130142
return (cacheTotal / total) * 100;
131143
}
132144

145+
// 解析用户输入的时间字符串
146+
function parseTimeInput(input: string): dayjs.Dayjs | null {
147+
const trimmed = input.trim().toLowerCase();
148+
if (!trimmed) return null;
149+
150+
const now = dayjs();
151+
152+
// 1. 相对时间格式: "5m", "30min", "2h", "1hour", "3d", "1day"
153+
const relativeMatch = trimmed.match(/^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/);
154+
if (relativeMatch) {
155+
const value = parseInt(relativeMatch[1], 10);
156+
const unit = relativeMatch[2];
157+
if (unit.startsWith('m')) {
158+
return now.add(value, 'minute');
159+
} else if (unit.startsWith('h')) {
160+
return now.add(value, 'hour');
161+
} else if (unit.startsWith('d')) {
162+
return now.add(value, 'day');
163+
}
164+
}
165+
166+
// 2. 纯时间格式: "14:30", "2:30pm", "14:30:00"
167+
const timeOnlyMatch = trimmed.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(am|pm))?$/);
168+
if (timeOnlyMatch) {
169+
let hours = parseInt(timeOnlyMatch[1], 10);
170+
const minutes = parseInt(timeOnlyMatch[2], 10);
171+
const seconds = timeOnlyMatch[3] ? parseInt(timeOnlyMatch[3], 10) : 0;
172+
const ampm = timeOnlyMatch[4];
173+
174+
if (ampm === 'pm' && hours < 12) hours += 12;
175+
if (ampm === 'am' && hours === 12) hours = 0;
176+
177+
let result = now.hour(hours).minute(minutes).second(seconds).millisecond(0);
178+
// 如果时间已过,设为明天
179+
if (result.isBefore(now) || result.isSame(now)) {
180+
result = result.add(1, 'day');
181+
}
182+
return result;
183+
}
184+
185+
// 3. 常见日期时间格式
186+
const formats = [
187+
'YYYY-MM-DD HH:mm:ss',
188+
'YYYY-MM-DD HH:mm',
189+
'YYYY/MM/DD HH:mm:ss',
190+
'YYYY/MM/DD HH:mm',
191+
'MM-DD HH:mm',
192+
'MM/DD HH:mm',
193+
'DD HH:mm',
194+
];
195+
196+
for (const fmt of formats) {
197+
const parsed = dayjs(trimmed, fmt, true);
198+
if (parsed.isValid() && parsed.isAfter(now)) {
199+
return parsed;
200+
}
201+
}
202+
203+
// 4. 尝试 dayjs 自动解析(ISO 格式等)
204+
const autoParsed = dayjs(trimmed);
205+
if (autoParsed.isValid() && autoParsed.isAfter(now)) {
206+
return autoParsed;
207+
}
208+
209+
return null;
210+
}
211+
133212
export function ProviderDetailsDialog({
134213
item,
135214
clientType,
@@ -146,7 +225,12 @@ export function ProviderDetailsDialog({
146225
}: ProviderDetailsDialogProps) {
147226
const { t, i18n } = useTranslation();
148227
const REASON_INFO = getReasonInfo(t);
149-
const { formatRemaining } = useCooldownsContext();
228+
const { formatRemaining, setCooldown, isSettingCooldown } = useCooldownsContext();
229+
const [showCustomTime, setShowCustomTime] = useState(false);
230+
const [customTimeInput, setCustomTimeInput] = useState('');
231+
232+
// 实时解析输入的时间
233+
const parsedTime = useMemo(() => parseTimeInput(customTimeInput), [customTimeInput]);
150234

151235
// 计算初始倒计时值
152236
const getInitialCountdown = useCallback(() => {
@@ -349,6 +433,101 @@ export function ProviderDetailsDialog({
349433
</Button>
350434
)}
351435

436+
{/* Manual Freeze Button (if not in cooldown) */}
437+
{!isInCooldown && !showCustomTime && (
438+
<div className="space-y-2">
439+
<div className="flex items-center gap-2 text-xs font-medium text-indigo-600 dark:text-indigo-400">
440+
<Snowflake size={12} />
441+
{t('provider.manualFreeze')}
442+
</div>
443+
<div className="flex flex-wrap gap-1.5">
444+
{[
445+
{ label: '5m', minutes: 5 },
446+
{ label: '15m', minutes: 15 },
447+
{ label: '30m', minutes: 30 },
448+
{ label: '1h', minutes: 60 },
449+
{ label: '2h', minutes: 120 },
450+
{ label: '6h', minutes: 360 },
451+
].map(({ label, minutes }) => (
452+
<Button
453+
key={label}
454+
disabled={isSettingCooldown || isToggling}
455+
onClick={() => {
456+
const until = new Date(Date.now() + minutes * 60 * 1000);
457+
console.log('Setting cooldown:', provider.id, until.toISOString(), clientType);
458+
setCooldown(provider.id, until.toISOString(), clientType);
459+
}}
460+
className="px-3 py-1.5 text-xs rounded-lg border border-indigo-500/30 dark:border-indigo-500/25 bg-indigo-500/5 dark:bg-indigo-500/10 hover:bg-indigo-500/15 dark:hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400 disabled:opacity-50"
461+
>
462+
{label}
463+
</Button>
464+
))}
465+
<Button
466+
disabled={isSettingCooldown || isToggling}
467+
onClick={() => setShowCustomTime(true)}
468+
className="px-3 py-1.5 text-xs rounded-lg border border-dashed border-indigo-500/30 dark:border-indigo-500/25 bg-transparent hover:bg-indigo-500/5 dark:hover:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 disabled:opacity-50"
469+
>
470+
{t('provider.customTime')}
471+
</Button>
472+
</div>
473+
</div>
474+
)}
475+
476+
{/* Custom Time Input Dialog */}
477+
{showCustomTime && (
478+
<div className="rounded-xl border border-indigo-500/30 dark:border-indigo-500/25 bg-indigo-500/5 dark:bg-indigo-500/10 p-3 space-y-2">
479+
<div className="text-xs font-medium text-indigo-600 dark:text-indigo-400">
480+
{t('provider.freezeUntil')}
481+
</div>
482+
<input
483+
type="text"
484+
value={customTimeInput}
485+
onChange={(e) => setCustomTimeInput(e.target.value)}
486+
placeholder="e.g. 30m, 2h, 14:30, 12:00:30, 2025-01-25 18:00"
487+
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono"
488+
autoFocus
489+
/>
490+
{/* 实时解析预览 */}
491+
<div className="text-xs text-muted-foreground">
492+
{customTimeInput ? (
493+
parsedTime ? (
494+
<span className="text-emerald-600 dark:text-emerald-400">
495+
{parsedTime.format('YYYY-MM-DD HH:mm:ss')}
496+
</span>
497+
) : (
498+
<span className="text-rose-500">{t('provider.invalidTimeFormat')}</span>
499+
)
500+
) : (
501+
<span>{t('provider.timeFormatHint')}</span>
502+
)}
503+
</div>
504+
<div className="flex gap-2">
505+
<Button
506+
onClick={() => {
507+
setShowCustomTime(false);
508+
setCustomTimeInput('');
509+
}}
510+
className="flex-1 rounded-lg border border-border bg-muted/50 px-3 py-1.5 text-xs"
511+
>
512+
{t('common.cancel')}
513+
</Button>
514+
<Button
515+
onClick={() => {
516+
if (parsedTime) {
517+
setCooldown(provider.id, parsedTime.toISOString(), clientType);
518+
setShowCustomTime(false);
519+
setCustomTimeInput('');
520+
}
521+
}}
522+
disabled={!parsedTime}
523+
className="flex-1 rounded-lg bg-indigo-500 text-white px-3 py-1.5 text-xs hover:bg-indigo-600 disabled:opacity-50"
524+
>
525+
{t('provider.freezeConfirm')}
526+
</Button>
527+
</div>
528+
</div>
529+
)}
530+
352531
{/* Delete Button */}
353532
{onDelete && (
354533
<Button
@@ -422,7 +601,7 @@ export function ProviderDetailsDialog({
422601
{liveCountdown}
423602
</div>
424603
{(() => {
425-
const untilDateStr = formatUntilTime(cooldown.untilTime);
604+
const untilDateStr = formatUntilTime(cooldown.until);
426605
return (
427606
<div className="relative mt-2 text-[10px] text-teal-600/70 dark:text-teal-400/70 font-mono flex items-center gap-2">
428607
<Clock size={10} />

web/src/components/routes/ClientTypeRoutesContent.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,8 +373,9 @@ function ClientTypeRoutesContentInner({
373373
}
374374

375375
return (
376-
<div className="px-6 py-6">
377-
<div className="mx-auto max-w-[1400px] space-y-6">
376+
<div className="flex flex-col h-full min-h-0">
377+
<div className="flex-1 overflow-y-auto px-6 py-6">
378+
<div className="mx-auto max-w-[1400px] space-y-6">
378379
{/* Sort Antigravity Button */}
379380
{hasAntigravityRoutes && (
380381
<div className="flex justify-end">
@@ -532,5 +533,6 @@ function ClientTypeRoutesContentInner({
532533
)}
533534
</div>
534535
</div>
536+
</div>
535537
);
536538
}

0 commit comments

Comments
 (0)