feat(channels): 渠道停用、启用功能

This commit is contained in:
Jeff Gu committed 2026-04-11 23:23:41 +08:00
1 parent 0183be8fa0
commit 1243bf629d
11 files changed
+233 -10

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface SwitchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
onCheckedChange?: (checked: boolean) => void
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, onCheckedChange, disabled, onChange, ...props }, ref) => {
return (
<label
className={cn(
'inline-flex items-center',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
)}
>
<input
type="checkbox"
className="peer sr-only"
ref={ref}
{...props}
disabled={disabled}
onChange={(event) => {
onChange?.(event)
onCheckedChange?.(event.target.checked)
}}
/>
<span
className={cn(
'relative inline-flex h-6 w-11 shrink-0 rounded-full border border-transparent bg-muted transition-colors duration-200',
'after:absolute after:left-0.5 after:top-0.5 after:h-4.5 after:w-4.5 after:rounded-full after:bg-background after:shadow-md after:transition-transform after:duration-200',
'peer-checked:bg-emerald-500 peer-checked:after:translate-x-5',
'peer-focus-visible:ring-2 peer-focus-visible:ring-primary/30 peer-focus-visible:ring-offset-2',
className,
)}
/>
</label>
)
}
)
Switch.displayName = 'Switch'
export { Switch }
+7
View File
@@ -29,6 +29,10 @@ export const parseChannelConfig = (channel: Channel): ChannelConfig => {
}
}
export const isChannelEnabled = (config: ChannelConfig): boolean => {
return config.enabled !== false
}
export const parseTokenConfig = (token: Token): TokenConfig => {
if (typeof token.value !== 'string') {
return token.value
@@ -92,6 +96,9 @@ export const getUniqueModelNamesFromChannels = (channels: Channel[]): string[] =
channels.forEach((channel) => {
const config = parseChannelConfig(channel)
if (!isChannelEnabled(config)) {
return
}
getChannelModels(config).forEach((model) => modelNames.add(model.name))
})
+135 -5
View File
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import {
@@ -54,6 +55,7 @@ const createDefaultChannelFormData = (): ChannelConfig => ({
name: '',
type: 'openai',
endpoint: '',
enabled: true,
api_keys: [],
auto_retry: true,
auto_rotate: true,
@@ -150,6 +152,7 @@ const normalizeChannelFormConfig = (config: ChannelConfig): ChannelConfig => {
return {
...config,
api_key: undefined,
enabled: config.enabled ?? true,
api_keys: mergedKeys,
auto_retry: config.auto_retry ?? true,
auto_rotate: config.auto_rotate ?? true,
@@ -159,6 +162,22 @@ const normalizeChannelFormConfig = (config: ChannelConfig): ChannelConfig => {
}
}
const parseChannelValue = (channel: Channel): ChannelConfig => {
if (typeof channel.value !== 'string') {
return channel.value
}
try {
return JSON.parse(channel.value) as ChannelConfig
} catch {
return createDefaultChannelFormData()
}
}
const buildChannelValue = (source: Channel['value'], config: ChannelConfig): Channel['value'] => {
return typeof source === 'string' ? JSON.stringify(config) : config
}
const isEmptyModelRow = (row: ModelRow): boolean => {
return !row.id.trim() && !row.name.trim()
}
@@ -458,7 +477,7 @@ export function Channels({
const openChannelForEdit = useCallback((channel: Channel) => {
setEditingKey(channel.key)
setChannelKey(channel.key)
const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
const rawConfig = parseChannelValue(channel)
const normalizedConfig = loadFormConfig(rawConfig)
setJsonValue(JSON.stringify(normalizedConfig, null, 2))
setView('form')
@@ -505,6 +524,49 @@ export function Channels({
},
})
const toggleEnabledMutation = useMutation({
mutationFn: async ({ channel, enabled }: { channel: Channel; enabled: boolean }) => {
const config = normalizeChannelFormConfig(parseChannelValue(channel))
return apiClient.saveChannel(channel.key, { ...config, enabled })
},
onMutate: async ({ channel, enabled }) => {
await queryClient.cancelQueries({ queryKey: ['channels'] })
const previousChannels = queryClient.getQueryData<Channel[]>(['channels'])
queryClient.setQueryData<Channel[]>(['channels'], (current) => {
if (!current) {
return current
}
return current.map((item) => {
if (item.key !== channel.key) {
return item
}
const config = normalizeChannelFormConfig(parseChannelValue(item))
return {
...item,
value: buildChannelValue(item.value, { ...config, enabled }),
}
})
})
return { previousChannels }
},
onSuccess: (_, { enabled }) => {
addToast(enabled ? '渠道已启用' : '渠道已停用', 'success')
},
onError: (error: Error, _variables, context) => {
if (context?.previousChannels) {
queryClient.setQueryData(['channels'], context.previousChannels)
}
addToast('更新渠道状态失败:' + error.message, 'error')
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
},
})
const fetchModelsMutation = useMutation({
mutationFn: async (config: ChannelConfig) => {
return apiClient.fetchChannelModels(config)
@@ -588,6 +650,10 @@ export function Channels({
}
}
const handleToggleEnabled = (channel: Channel, enabled: boolean) => {
toggleEnabledMutation.mutate({ channel, enabled })
}
const handleFetchModels = () => {
const apiKeys = parseApiKeys(apiKeysInput)
@@ -757,7 +823,7 @@ export function Channels({
const filteredData = data?.filter((channel) => {
if (!searchQuery) return true
const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
const rawConfig = parseChannelValue(channel)
const config = normalizeChannelFormConfig(rawConfig)
return (
config.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -825,10 +891,12 @@ export function Channels({
<Card>
<div className="divide-y">
{filteredData?.map((channel) => {
const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
const rawConfig = parseChannelValue(channel)
const config = normalizeChannelFormConfig(rawConfig)
const modelCount = (config.models || []).length
const isMenuOpen = openMenu === channel.key
const isEnabled = config.enabled !== false
const isToggling = toggleEnabledMutation.isPending && toggleEnabledMutation.variables?.channel.key === channel.key
return (
<div key={channel.key} className="p-4 hover:bg-muted/30 transition-colors">
@@ -876,6 +944,25 @@ export function Channels({
</span>
<span className="text-muted-foreground">{modelCount} 个模型</span>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2">
<div>
<div className="text-sm font-medium">运行状态</div>
<div
className={cn(
'mt-1 text-xs font-medium',
isEnabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400',
)}
>
{isEnabled ? '已启用' : '已停用'}
</div>
</div>
<Switch
checked={isEnabled}
disabled={isToggling}
onCheckedChange={(checked) => handleToggleEnabled(channel, checked)}
aria-label={`${config.name || channel.key} 渠道启用开关`}
/>
</div>
</div>
<div className="hidden md:flex md:items-center md:gap-4">
@@ -883,7 +970,7 @@ export function Channels({
<div className="font-medium">{config.name}</div>
<div className="text-xs text-muted-foreground font-mono">{channel.key}</div>
</div>
<div className="flex-shrink-0">
<div className="flex items-center">
<span className="px-2 py-1 rounded-md bg-muted text-muted-foreground text-xs font-medium whitespace-nowrap">
{getTypeLabel(config.type)}
</span>
@@ -894,6 +981,22 @@ export function Channels({
<div className="w-20 text-sm text-center flex-shrink-0">
<span className="text-muted-foreground">{modelCount} 模型</span>
</div>
<div className="flex items-center justify-between gap-2 flex-shrink-0">
<span
className={cn(
'text-xs font-medium whitespace-nowrap',
isEnabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400',
)}
>
{isEnabled ? '已启用' : '已停用'}
</span>
<Switch
checked={isEnabled}
disabled={isToggling}
onCheckedChange={(checked) => handleToggleEnabled(channel, checked)}
aria-label={`${config.name || channel.key} 渠道启用开关`}
/>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="sm" onClick={() => handleEdit(channel)}>
<Pencil className="h-4 w-4" />
@@ -988,6 +1091,33 @@ export function Channels({
))}
</Select>
</div>
<div className="rounded-lg border bg-muted/20 px-4 py-3 md:col-span-2">
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-sm">渠道状态</Label>
<p className="mt-1 text-xs text-muted-foreground">
停用后不会参与请求选路,也不会出现在可用模型列表中。
</p>
</div>
<div className="flex items-center gap-3">
<span
className={cn(
'text-sm font-medium',
formData.enabled !== false
? 'text-emerald-600 dark:text-emerald-400'
: 'text-amber-600 dark:text-amber-400',
)}
>
{formData.enabled !== false ? '已启用' : '已停用'}
</span>
<Switch
checked={formData.enabled !== false}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
aria-label="渠道状态开关"
/>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
@@ -1150,7 +1280,7 @@ export function Channels({
onChange={(e) => setJsonValue(e.target.value)}
rows={18}
className="font-mono text-sm"
placeholder='{"name":"Azure OpenAI","type":"azure-openai","endpoint":"https://example.openai.azure.com/","api_keys":["sk-1","sk-2"],"auto_retry":true,"auto_rotate":true,"models":[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini"}]}'
placeholder='{"name":"Azure OpenAI","type":"azure-openai","endpoint":"https://example.openai.azure.com/","enabled":true,"api_keys":["sk-1","sk-2"],"auto_retry":true,"auto_rotate":true,"models":[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini"}]}'
/>
</CardContent>
</Card>
+24 -3
View File
@@ -75,6 +75,19 @@ const createFilterPreset = (hoursBack: number): UsageLogFilterState => {
};
};
const hydrateUsageLogFilters = (
filters: UsageLogFilterState | undefined,
latestWindow: UsageLogFilterState,
): UsageLogFilterState => {
return {
start: latestWindow.start,
end: latestWindow.end,
dimension: filters?.dimension ?? latestWindow.dimension,
keyword: filters?.keyword ?? latestWindow.keyword,
result: filters?.result ?? latestWindow.result,
};
};
const formatDateTime = (value: string): string => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -157,13 +170,21 @@ const DetailField = ({ label, value, mono = false }: { label: string; value: str
export function UsageLogs() {
const defaultFilters = useMemo(() => createFilterPreset(24), []);
const cachedLogsSnapshot = useMemo(() => readScopedCache<UsageLogPageCacheSnapshot>(USAGE_LOGS_PAGE_CACHE_KEY), []);
const initialDraftFilters = useMemo(
() => hydrateUsageLogFilters(cachedLogsSnapshot?.data.draftFilters, defaultFilters),
[cachedLogsSnapshot, defaultFilters],
);
const initialAppliedFilters = useMemo(
() => hydrateUsageLogFilters(cachedLogsSnapshot?.data.appliedFilters, defaultFilters),
[cachedLogsSnapshot, defaultFilters],
);
const [draftFilters, setDraftFilters] = useState<UsageLogFilterState>(
() => cachedLogsSnapshot?.data.draftFilters ?? defaultFilters,
() => initialDraftFilters,
);
const [appliedFilters, setAppliedFilters] = useState<UsageLogFilterState>(
() => cachedLogsSnapshot?.data.appliedFilters ?? defaultFilters,
() => initialAppliedFilters,
);
const [currentPage, setCurrentPage] = useState(() => Math.max(cachedLogsSnapshot?.data.currentPage ?? 1, 1));
const [currentPage, setCurrentPage] = useState(1);
const [selectedItem, setSelectedItem] = useState<AnalyticsEventItem | null>(null);
const initialLogsData =
cachedLogsSnapshot?.data &&
+1
View File
@@ -13,6 +13,7 @@ export interface ChannelConfig {
name: string
type: 'openai' | 'gemini' | 'azure-openai' | 'openai-audio' | 'azure-openai-audio' | 'claude' | 'claude-to-openai' | 'openai-responses' | 'azure-openai-responses'
endpoint: string
enabled?: boolean
api_key?: string
api_keys?: string[]
auto_retry?: boolean
+2
View File
@@ -181,6 +181,7 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute {
name: z.string().describe('Channel name'),
type: z.string().describe('Channel type'),
endpoint: z.string().describe('API endpoint'),
enabled: z.boolean().optional().describe('Whether this channel participates in request routing'),
api_key: z.string().optional().describe('Deprecated single API key'),
api_keys: z.array(z.string()).optional().describe('API keys, one request will pick one randomly'),
auto_retry: z.boolean().optional().describe('Automatically retry the same API key'),
@@ -252,6 +253,7 @@ export class ChannelFetchModelsEndpoint extends OpenAPIRoute {
name: z.string().optional(),
type: z.string().optional(),
endpoint: z.string().optional(),
enabled: z.boolean().optional(),
api_key: z.string().optional(),
api_keys: z.array(z.string()).optional(),
auto_retry: z.boolean().optional(),
+5 -1
View File
@@ -1,10 +1,12 @@
export const DEFAULT_CHANNEL_AUTO_RETRY = true;
export const DEFAULT_CHANNEL_AUTO_ROTATE = true;
export const DEFAULT_CHANNEL_ENABLED = true;
export const DEFAULT_CLAUDE_API_VERSION = "2023-06-01";
export const MAX_RETRIES_PER_KEY = 3;
export const MAX_ROTATION_ATTEMPTS = 3;
export type NormalizedChannelConfig = Omit<ChannelConfig, "api_keys" | "auto_retry" | "auto_rotate" | "models" | "supported_models" | "deployment_mapper"> & {
export type NormalizedChannelConfig = Omit<ChannelConfig, "enabled" | "api_keys" | "auto_retry" | "auto_rotate" | "models" | "supported_models" | "deployment_mapper"> & {
enabled: boolean;
api_keys: string[];
auto_retry: boolean;
auto_rotate: boolean;
@@ -105,6 +107,7 @@ export const normalizeChannelConfig = (
name: config.name || "",
type: config.type,
endpoint: config.endpoint || "",
enabled: config.enabled ?? DEFAULT_CHANNEL_ENABLED,
api_key: typeof config.api_key === "string" ? config.api_key.trim() : undefined,
api_keys: normalizeApiKeys(config),
auto_retry: config.auto_retry ?? DEFAULT_CHANNEL_AUTO_RETRY,
@@ -125,6 +128,7 @@ export const sanitizeChannelConfig = (
name: normalized.name,
type: normalized.type,
endpoint: normalized.endpoint,
enabled: normalized.enabled,
api_keys: normalized.api_keys,
auto_retry: normalized.auto_retry,
auto_rotate: normalized.auto_rotate,
+1 -1
View File
@@ -1,6 +1,6 @@
export const CONSTANTS = {
VERSION: "v0.0.1",
DB_VERSION_KEY: "db_version",
DB_VERSION: "v0.0.3",
DB_VERSION: "v0.0.4",
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
}
+3
View File
@@ -59,6 +59,9 @@ export class ModelsEndpoint extends OpenAPIRoute {
for (const row of channelsResult.results) {
const config = normalizeChannelConfig(JSON.parse(row.value) as ChannelConfig);
if (!config.enabled) {
continue;
}
for (const model of getChannelModels(config)) {
modelsSet.add(model.name);
}
+9
View File
@@ -60,6 +60,7 @@ export const resolveChannel = async (
const allowedTypes = policy.allowedTypes;
const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: ChannelModelMapping }> = [];
let hasDisabledMatchingChannel = false;
for (const row of channelsResult.results) {
const config = (() => {
@@ -83,6 +84,11 @@ export const resolveChannel = async (
continue;
}
if (!config.enabled) {
hasDisabledMatchingChannel = true;
continue;
}
availableChannels.push({
key: row.key,
config: config,
@@ -91,6 +97,9 @@ export const resolveChannel = async (
}
if (availableChannels.length === 0) {
if (hasDisabledMatchingChannel) {
return c.text(`No enabled channels available for model: ${requestedModel}. Please enable a channel first.`, 503);
}
return c.text(`Model not supported: ${requestedModel}. Please configure models.`, 400);
}
Vendored
+1
View File
@@ -70,6 +70,7 @@ type ChannelConfig = {
name: string;
type: ChannelType;
endpoint: string;
enabled?: boolean;
api_key?: string;
api_keys?: string[];
auto_retry?: boolean;