fix:优化控制面板ui

This commit is contained in:
lsy committed 2026-03-18 13:43:59 +08:00
1 parent 54baba28a3
commit dc74a5087a
11 files changed
+731 -633

No files matched your search

+12
View File
@@ -9,6 +9,12 @@
### 变更
- **[settings/admin-ui]**: 设置页分区重排并内联队列状态与使用数量 — by lsy
- 方案: [202603181300_settings-panel-sections](archive/2026-03/202603181300_settings-panel-sections/)
- 决策: settings-panel-sections#D001(只读项内联展示)
- **[settings/admin-ui]**: 运行时设置改为仅数据库生效,移除环境变量回退 — by lsy
- 方案: [202603181221_settings-db-only](archive/2026-03/202603181221_settings-db-only/)
- 决策: settings-db-only#D001(停用环境变量回退)
- **[settings/proxy/usage/admin-ui]**: 运行时设置可在后台配置,新增队列日限额与直写比例并接入 UsageLimiter — by lsy
- 方案: [202603180031_usage-queue-simplified](archive/2026-03/202603180031_usage-queue-simplified/)
- **[cache/settings/proxy/usage/admin-ui]**: 引入分组缓存与自动失效,面板/日志/模型短期缓存可配置,清理节流并补齐索引 — by lsy
@@ -221,6 +227,12 @@
### 快速修改
- **[admin-ui/settings]**: 缓存 TTL 与版本成对展示为卡片 — by lsy
- 类型: 快速修改(无方案包)
- 文件: apps/ui/src/features/SettingsView.tsx:417-632
- **[admin-ui/settings]**: 缓存版本调整为小标题展示 — by lsy
- 类型: 快速修改(无方案包)
- 文件: apps/ui/src/features/SettingsView.tsx:566-584
- **[ci]**: 队列创建失败时识别“名称已占用(11009)”并视为已存在 — by lsy
- 类型: 快速修改(无方案包)
- 文件: .github/workflows/deploy.yml:263-320,416-473
+1 -1
View File
@@ -39,7 +39,7 @@
- 系统设置增加定时签到开关与时间(中国时间)
- 系统设置提供模型失败冷却分钟数配置
- 系统设置提供运行时配置可编辑(上游超时、流式解析模式/上限、队列开关/日限额/直写比例)
- 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示设置优先生效
- 系统设置将运行时配置拆分为“解析与请求 / 队列设置”,队列绑定/实际生效/使用数量以内联只读项展示
- 系统设置提供缓存开关与 TTL(面板/日志/模型/鉴权/渠道/调用令牌/设置)配置
- 系统设置展示缓存版本号并提供刷新入口
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数(无数据时显示 0 页)
+9 -6
View File
@@ -12,15 +12,16 @@
## 行为规范
- 配置写入后即时生效
- 未配置时使用环境变量/默认值回退
- 未配置时使用默认值回退
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
- 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型
- 运行时配置可写入 settings(环境变量作为回退)
- 运行时配置可写入 settings(不再使用环境变量回退)
- `GET /api/settings` 返回:
- `runtime_settings`(可写配置值)
- `runtime_config`(生效值 + 队列绑定/生效状态)
- `usage_queue_status`(队列使用数量与状态:count/date/limit/enabled/bound/active)
- 运行时配置字段:
- `proxy_upstream_timeout_ms`
- `proxy_stream_usage_mode`
@@ -30,10 +31,11 @@
- `usage_queue_daily_limit`
- `usage_queue_direct_write_ratio`
- 运行时配置默认值:
- `PROXY_STREAM_USAGE_MODE=full`
- `PROXY_STREAM_USAGE_MAX_BYTES=0`
- `PROXY_STREAM_USAGE_MAX_PARSERS=0`
- `PROXY_UPSTREAM_TIMEOUT_MS=30000`
- `proxy_stream_usage_mode=full`
- `proxy_stream_usage_max_bytes=0`
- `proxy_stream_usage_max_parsers=0`
- `proxy_upstream_timeout_ms=30000`
- `proxy_usage_queue_enabled=true`
- `usage_queue_daily_limit=10000`
- `usage_queue_direct_write_ratio=0.5`
- `PROXY_STREAM_USAGE_MAX_BYTES/PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制
@@ -44,6 +46,7 @@
- `cache_ttl_settings_seconds`
- `cache_config` 中包含分组版本号(dashboard/usage/models/tokens/channels/call_tokens/settings),用于精准失效
- `POST /api/settings/cache/refresh` 会 bump 所有缓存版本(用于紧急刷新)
- 队列使用数量来自 UsageLimiter DO 的 `/status`,未绑定时返回空值
## 依赖关系
- `settings` 表
+1 -1
View File
@@ -86,7 +86,7 @@ bun run dev:worker
- 日志保留天数(默认 30)
- 会话时长(小时,默认 12)
- 管理员密码(首次登录在登录页输入密码将自动设置,可在系统设置中修改)
- 运行时配置(只读,来自环境变量):`PROXY_STREAM_USAGE_MODE`、`PROXY_STREAM_USAGE_MAX_BYTES`、`PROXY_STREAM_USAGE_MAX_PARSERS`、`PROXY_USAGE_QUEUE_ENABLED`(需在部署环境中修改)
- 运行时配置(以数据库为准,可在系统设置中修改)
### 3) 启动管理台 UI
+1
View File
@@ -1902,6 +1902,7 @@ const App = () => {
buildActionKey("settings:cache:refresh"),
)}
runtimeConfig={data.settings?.runtime_config ?? null}
usageQueueStatus={data.settings?.usage_queue_status ?? null}
isSaving={isActionPending(buildActionKey("settings:submit"))}
onSubmit={handleSettingsSubmit}
onFormChange={handleSettingsFormChange}
+10
View File
@@ -125,6 +125,7 @@ export type Settings = {
runtime_settings?: RuntimeProxySettings;
runtime_config?: RuntimeProxyConfig;
cache_config?: CacheConfig;
usage_queue_status?: UsageQueueStatus | null;
};
export type RuntimeProxySettings = {
@@ -160,6 +161,15 @@ export type CacheConfig = {
version_settings: number;
};
export type UsageQueueStatus = {
count: number | null;
date: string | null;
limit: number;
enabled: boolean;
bound: boolean;
active: boolean;
};
export type ModelChannel = {
id: string;
name: string;
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -457,7 +457,7 @@ proxy.all("/*", tokenAuth, async (c) => {
const requestStart = Date.now();
const [cacheConfig, runtimeSettings] = await Promise.all([
getCacheConfig(c.env.DB),
getProxyRuntimeSettings(c.env.DB, c.env),
getProxyRuntimeSettings(c.env.DB),
]);
const scheduleUsageEvent = createUsageEventScheduler(c, runtimeSettings);
let requestText = await c.req.text();
+42 -1
View File
@@ -22,6 +22,10 @@ import {
setModelFailureCooldownMinutes,
setProxyRuntimeSettings,
} from "../services/settings";
import {
getUsageLimiterStub,
getUsageQueueStatus,
} from "../services/usage-limiter";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
@@ -38,9 +42,45 @@ settings.get("/", async (c) => {
const modelFailureCooldownMinutes = await getModelFailureCooldownMinutes(
c.env.DB,
);
const runtimeSettings = await getProxyRuntimeSettings(c.env.DB, c.env);
const runtimeSettings = await getProxyRuntimeSettings(c.env.DB);
const runtimeConfig = getRuntimeProxyConfig(c.env, runtimeSettings);
const cacheConfig = await getCacheConfig(c.env.DB);
let usageQueueStatus: {
count: number | null;
date: string | null;
limit: number;
enabled: boolean;
bound: boolean;
active: boolean;
} | null = null;
if (c.env.USAGE_LIMITER) {
try {
const status = await getUsageQueueStatus(
getUsageLimiterStub(c.env.USAGE_LIMITER),
);
usageQueueStatus = {
count: status.count,
date: status.date,
limit: runtimeSettings.usage_queue_daily_limit,
enabled: runtimeSettings.usage_queue_enabled,
bound: runtimeConfig.usage_queue_bound,
active: runtimeConfig.usage_queue_active,
};
} catch (error) {
console.warn("[settings:usage_queue_status_failed]", {
error: error instanceof Error ? error.message : String(error),
});
}
} else {
usageQueueStatus = {
count: null,
date: null,
limit: runtimeSettings.usage_queue_daily_limit,
enabled: runtimeSettings.usage_queue_enabled,
bound: runtimeConfig.usage_queue_bound,
active: runtimeConfig.usage_queue_active,
};
}
return c.json({
log_retention_days: retention,
session_ttl_hours: sessionTtlHours,
@@ -50,6 +90,7 @@ settings.get("/", async (c) => {
runtime_config: runtimeConfig,
runtime_settings: runtimeSettings,
cache_config: cacheConfig,
usage_queue_status: usageQueueStatus,
});
});
+6 -41
View File
@@ -20,6 +20,7 @@ const DEFAULT_CACHE_CHANNELS_TTL_SECONDS = 15;
const DEFAULT_CACHE_CALL_TOKENS_TTL_SECONDS = 15;
const DEFAULT_CACHE_SETTINGS_TTL_SECONDS = 30;
const DEFAULT_PROXY_UPSTREAM_TIMEOUT_MS = 30000;
const DEFAULT_PROXY_USAGE_QUEUE_ENABLED = true;
const DEFAULT_USAGE_QUEUE_DAILY_LIMIT = 10000;
const DEFAULT_USAGE_QUEUE_DIRECT_WRITE_RATIO = 0.5;
const CACHE_CONFIG_TTL_MS = 1000;
@@ -184,35 +185,6 @@ function parsePositiveNumber(value: string | null, fallback: number): number {
return fallback;
}
function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value === null) {
return fallback;
}
return parseBooleanSetting(value, fallback);
}
function parsePositiveEnv(value: string | undefined, fallback: number): number {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && parsed > 0) {
return Math.floor(parsed);
}
return fallback;
}
function parseNonNegativeInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return fallback;
}
function parseNonNegativeSetting(value: string | null, fallback: number): number {
if (!value) {
return fallback;
@@ -381,33 +353,26 @@ export async function getSettingsSnapshot(
export async function getProxyRuntimeSettings(
db: D1Database,
env: Bindings,
): Promise<ProxyRuntimeSettings> {
const settings = await getSettingsSnapshot(db);
const upstreamTimeout = parsePositiveSetting(
settings[PROXY_UPSTREAM_TIMEOUT_KEY] ?? null,
parsePositiveEnv(env.PROXY_UPSTREAM_TIMEOUT_MS, DEFAULT_PROXY_UPSTREAM_TIMEOUT_MS),
DEFAULT_PROXY_UPSTREAM_TIMEOUT_MS,
);
const streamUsageMode = normalizeStreamUsageMode(
settings[PROXY_STREAM_USAGE_MODE_KEY] ?? env.PROXY_STREAM_USAGE_MODE,
settings[PROXY_STREAM_USAGE_MODE_KEY],
);
const streamUsageMaxBytes = parseNonNegativeSetting(
settings[PROXY_STREAM_USAGE_MAX_BYTES_KEY] ?? null,
parseNonNegativeInt(
env.PROXY_STREAM_USAGE_MAX_BYTES,
DEFAULT_PROXY_STREAM_USAGE_MAX_BYTES,
),
DEFAULT_PROXY_STREAM_USAGE_MAX_BYTES,
);
const streamUsageMaxParsers = parseNonNegativeSetting(
settings[PROXY_STREAM_USAGE_MAX_PARSERS_KEY] ?? null,
parseNonNegativeInt(
env.PROXY_STREAM_USAGE_MAX_PARSERS,
DEFAULT_PROXY_STREAM_USAGE_MAX_PARSERS,
),
DEFAULT_PROXY_STREAM_USAGE_MAX_PARSERS,
);
const usageQueueEnabled = parseBooleanSetting(
settings[PROXY_USAGE_QUEUE_ENABLED_KEY] ?? null,
parseBooleanEnv(env.PROXY_USAGE_QUEUE_ENABLED, true),
DEFAULT_PROXY_USAGE_QUEUE_ENABLED,
);
const usageQueueDailyLimit = parseNonNegativeSetting(
settings[USAGE_QUEUE_DAILY_LIMIT_KEY] ?? null,
+19 -1
View File
@@ -1,6 +1,7 @@
import type {
DurableObjectNamespace,
DurableObjectState,
DurableObjectStub,
} from "@cloudflare/workers-types";
import { beijingDateString } from "../utils/time";
@@ -16,11 +17,17 @@ export type UsageLimiterReserveResult = {
date: string;
};
export type UsageLimiterStatus = {
ok: boolean;
date: string;
count: number;
};
export const getUsageLimiterStub = (namespace: DurableObjectNamespace) =>
namespace.get(namespace.idFromName(LIMITER_NAME));
export async function reserveUsageQueue(
stub: { fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> },
stub: DurableObjectStub,
options: { limit: number; amount?: number },
): Promise<UsageLimiterReserveResult> {
const limit = Math.max(0, Math.floor(options.limit));
@@ -36,6 +43,17 @@ export async function reserveUsageQueue(
return payload;
}
export async function getUsageQueueStatus(
stub: DurableObjectStub,
): Promise<UsageLimiterStatus> {
const response = await stub.fetch("https://usage-limiter/status");
if (!response.ok) {
throw new Error(`usage_limiter_status_failed:${response.status}`);
}
const payload = (await response.json()) as UsageLimiterStatus;
return payload;
}
export class UsageLimiter {
private state: DurableObjectState;