mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
feat(auth-files): add Gemini CLI quota card and API call
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ usage.json
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
antigravity_usage.json
|
antigravity_usage.json
|
||||||
|
codex_usage.json
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -392,6 +392,20 @@
|
|||||||
"plan_team": "Team",
|
"plan_team": "Team",
|
||||||
"plan_free": "Free"
|
"plan_free": "Free"
|
||||||
},
|
},
|
||||||
|
"gemini_cli_quota": {
|
||||||
|
"title": "Gemini CLI Quota",
|
||||||
|
"empty_title": "No Gemini CLI Auth Files",
|
||||||
|
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
||||||
|
"idle": "Not loaded. Click Refresh Button.",
|
||||||
|
"loading": "Loading quota...",
|
||||||
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
|
"missing_project_id": "Gemini CLI credential missing project ID",
|
||||||
|
"empty_buckets": "No quota data available",
|
||||||
|
"refresh_button": "Refresh Quota",
|
||||||
|
"fetch_all": "Fetch All",
|
||||||
|
"remaining_amount": "Remaining {{count}}"
|
||||||
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex JSON Login",
|
"title": "Vertex JSON Login",
|
||||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||||
|
|||||||
@@ -392,6 +392,20 @@
|
|||||||
"plan_team": "Team",
|
"plan_team": "Team",
|
||||||
"plan_free": "Free"
|
"plan_free": "Free"
|
||||||
},
|
},
|
||||||
|
"gemini_cli_quota": {
|
||||||
|
"title": "Gemini CLI 额度",
|
||||||
|
"empty_title": "暂无 Gemini CLI 认证",
|
||||||
|
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||||
|
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||||
|
"loading": "正在加载额度...",
|
||||||
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
|
"missing_project_id": "Gemini CLI 凭证缺少 Project ID",
|
||||||
|
"empty_buckets": "暂无额度数据",
|
||||||
|
"refresh_button": "刷新额度",
|
||||||
|
"fetch_all": "获取全部",
|
||||||
|
"remaining_amount": "剩余 {{count}}"
|
||||||
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex JSON 登录",
|
"title": "Vertex JSON 登录",
|
||||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||||
|
|||||||
@@ -190,6 +190,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.geminiCliGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-md;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.antigravityControls {
|
.antigravityControls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
@@ -232,6 +246,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.geminiCliControls {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiCliControl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.antigravityCard {
|
.antigravityCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
@@ -248,6 +283,14 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.geminiCliCard {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(231, 239, 255, 0.2),
|
||||||
|
rgba(231, 239, 255, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.quotaSection {
|
.quotaSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -338,6 +381,10 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quotaAmount {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.quotaMessage {
|
.quotaMessage {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
|||||||
@@ -99,6 +99,39 @@ interface AntigravityQuotaState {
|
|||||||
errorStatus?: number;
|
errorStatus?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeminiCliQuotaBucket {
|
||||||
|
modelId?: string;
|
||||||
|
model_id?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
token_type?: string;
|
||||||
|
remainingFraction?: number | string;
|
||||||
|
remaining_fraction?: number | string;
|
||||||
|
remainingAmount?: number | string;
|
||||||
|
remaining_amount?: number | string;
|
||||||
|
resetTime?: string;
|
||||||
|
reset_time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeminiCliQuotaPayload {
|
||||||
|
buckets?: GeminiCliQuotaBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeminiCliQuotaBucketState {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
remainingFraction: number | null;
|
||||||
|
remainingAmount: number | null;
|
||||||
|
resetTime: string | undefined;
|
||||||
|
tokenType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeminiCliQuotaState {
|
||||||
|
status: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
buckets: GeminiCliQuotaBucketState[];
|
||||||
|
error?: string;
|
||||||
|
errorStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AntigravityQuotaInfo {
|
interface AntigravityQuotaInfo {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
quotaInfo?: {
|
quotaInfo?: {
|
||||||
@@ -173,6 +206,13 @@ const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const GEMINI_CLI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||||
|
|
||||||
|
const GEMINI_CLI_REQUEST_HEADERS = {
|
||||||
|
Authorization: 'Bearer $TOKEN$',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
interface CodexUsageWindow {
|
interface CodexUsageWindow {
|
||||||
used_percent?: number | string;
|
used_percent?: number | string;
|
||||||
usedPercent?: number | string;
|
usedPercent?: number | string;
|
||||||
@@ -401,6 +441,39 @@ function resolveCodexPlanType(file: AuthFileItem): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractGeminiCliProjectId(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const matches = Array.from(value.matchAll(/\(([^()]+)\)/g));
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
const candidate = matches[matches.length - 1]?.[1]?.trim();
|
||||||
|
return candidate ? candidate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGeminiCliProjectId(file: AuthFileItem): string | null {
|
||||||
|
const metadata =
|
||||||
|
file && typeof file.metadata === 'object' && file.metadata !== null
|
||||||
|
? (file.metadata as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const attributes =
|
||||||
|
file && typeof file.attributes === 'object' && file.attributes !== null
|
||||||
|
? (file.attributes as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
file.account,
|
||||||
|
file['account'],
|
||||||
|
metadata?.account,
|
||||||
|
attributes?.account
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const projectId = extractGeminiCliProjectId(candidate);
|
||||||
|
if (projectId) return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAntigravityPayload(payload: unknown): Record<string, unknown> | null {
|
function parseAntigravityPayload(payload: unknown): Record<string, unknown> | null {
|
||||||
if (payload === undefined || payload === null) return null;
|
if (payload === undefined || payload === null) return null;
|
||||||
if (typeof payload === 'string') {
|
if (typeof payload === 'string') {
|
||||||
@@ -435,6 +508,23 @@ function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayload | null {
|
||||||
|
if (payload === undefined || payload === null) return null;
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
const trimmed = payload.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as GeminiCliQuotaPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof payload === 'object') {
|
||||||
|
return payload as GeminiCliQuotaPayload;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
||||||
remainingFraction: number | null;
|
remainingFraction: number | null;
|
||||||
resetTime?: string;
|
resetTime?: string;
|
||||||
@@ -598,6 +688,10 @@ function isCodexFile(file: AuthFileItem): boolean {
|
|||||||
return resolveAuthProvider(file) === 'codex';
|
return resolveAuthProvider(file) === 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGeminiCliFile(file: AuthFileItem): boolean {
|
||||||
|
return resolveAuthProvider(file) === 'gemini-cli';
|
||||||
|
}
|
||||||
|
|
||||||
function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||||
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||||
if (typeof raw === 'boolean') return raw;
|
if (typeof raw === 'boolean') return raw;
|
||||||
@@ -661,6 +755,8 @@ export function AuthFilesPage() {
|
|||||||
const [antigravityPageSize, setAntigravityPageSize] = useState(6);
|
const [antigravityPageSize, setAntigravityPageSize] = useState(6);
|
||||||
const [codexPage, setCodexPage] = useState(1);
|
const [codexPage, setCodexPage] = useState(1);
|
||||||
const [codexPageSize, setCodexPageSize] = useState(6);
|
const [codexPageSize, setCodexPageSize] = useState(6);
|
||||||
|
const [geminiCliPage, setGeminiCliPage] = useState(1);
|
||||||
|
const [geminiCliPageSize, setGeminiCliPageSize] = useState(6);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
@@ -676,6 +772,11 @@ export function AuthFilesPage() {
|
|||||||
const [codexQuota, setCodexQuota] = useState<Record<string, CodexQuotaState>>({});
|
const [codexQuota, setCodexQuota] = useState<Record<string, CodexQuotaState>>({});
|
||||||
const [codexLoading, setCodexLoading] = useState(false);
|
const [codexLoading, setCodexLoading] = useState(false);
|
||||||
const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null);
|
const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||||
|
const [geminiCliQuota, setGeminiCliQuota] = useState<Record<string, GeminiCliQuotaState>>({});
|
||||||
|
const [geminiCliLoading, setGeminiCliLoading] = useState(false);
|
||||||
|
const [geminiCliLoadingScope, setGeminiCliLoadingScope] = useState<
|
||||||
|
'page' | 'all' | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
// 详情弹窗相关
|
// 详情弹窗相关
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
@@ -702,6 +803,8 @@ export function AuthFilesPage() {
|
|||||||
const antigravityRequestIdRef = useRef(0);
|
const antigravityRequestIdRef = useRef(0);
|
||||||
const codexLoadingRef = useRef(false);
|
const codexLoadingRef = useRef(false);
|
||||||
const codexRequestIdRef = useRef(0);
|
const codexRequestIdRef = useRef(0);
|
||||||
|
const geminiCliLoadingRef = useRef(false);
|
||||||
|
const geminiCliRequestIdRef = useRef(0);
|
||||||
const excludedUnsupportedRef = useRef(false);
|
const excludedUnsupportedRef = useRef(false);
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
@@ -801,6 +904,18 @@ export function AuthFilesPage() {
|
|||||||
const codexStart = (codexCurrentPage - 1) * codexPageSize;
|
const codexStart = (codexCurrentPage - 1) * codexPageSize;
|
||||||
const codexPageItems = codexFiles.slice(codexStart, codexStart + codexPageSize);
|
const codexPageItems = codexFiles.slice(codexStart, codexStart + codexPageSize);
|
||||||
|
|
||||||
|
const geminiCliFiles = useMemo(
|
||||||
|
() => files.filter((file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file)),
|
||||||
|
[files]
|
||||||
|
);
|
||||||
|
const geminiCliTotalPages = Math.max(1, Math.ceil(geminiCliFiles.length / geminiCliPageSize));
|
||||||
|
const geminiCliCurrentPage = Math.min(geminiCliPage, geminiCliTotalPages);
|
||||||
|
const geminiCliStart = (geminiCliCurrentPage - 1) * geminiCliPageSize;
|
||||||
|
const geminiCliPageItems = geminiCliFiles.slice(
|
||||||
|
geminiCliStart,
|
||||||
|
geminiCliStart + geminiCliPageSize
|
||||||
|
);
|
||||||
|
|
||||||
const fetchAntigravityQuota = useCallback(
|
const fetchAntigravityQuota = useCallback(
|
||||||
async (authIndex: string): Promise<AntigravityQuotaGroup[]> => {
|
async (authIndex: string): Promise<AntigravityQuotaGroup[]> => {
|
||||||
let lastError = '';
|
let lastError = '';
|
||||||
@@ -1079,6 +1194,125 @@ export function AuthFilesPage() {
|
|||||||
[fetchCodexQuota, t]
|
[fetchCodexQuota, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchGeminiCliQuota = useCallback(
|
||||||
|
async (file: AuthFileItem): Promise<GeminiCliQuotaBucketState[]> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = resolveGeminiCliProjectId(file);
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'POST',
|
||||||
|
url: GEMINI_CLI_QUOTA_URL,
|
||||||
|
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||||
|
data: JSON.stringify({ project: projectId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||||
|
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||||
|
if (buckets.length === 0) return [];
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
.map((bucket, index) => {
|
||||||
|
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||||
|
if (!modelId) return null;
|
||||||
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||||
|
const remainingFraction = normalizeNumberValue(
|
||||||
|
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||||
|
);
|
||||||
|
const remainingAmount = normalizeNumberValue(
|
||||||
|
bucket.remainingAmount ?? bucket.remaining_amount
|
||||||
|
);
|
||||||
|
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||||
|
return {
|
||||||
|
id: `${modelId}-${tokenType ?? index}`,
|
||||||
|
label: modelId,
|
||||||
|
remainingFraction,
|
||||||
|
remainingAmount,
|
||||||
|
resetTime,
|
||||||
|
tokenType
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((bucket): bucket is GeminiCliQuotaBucketState => bucket !== null);
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadGeminiCliQuota = useCallback(
|
||||||
|
async (targets: AuthFileItem[], scope: 'page' | 'all') => {
|
||||||
|
if (geminiCliLoadingRef.current) return;
|
||||||
|
geminiCliLoadingRef.current = true;
|
||||||
|
const requestId = ++geminiCliRequestIdRef.current;
|
||||||
|
setGeminiCliLoading(true);
|
||||||
|
setGeminiCliLoadingScope(scope);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
setGeminiCliQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
targets.forEach((file) => {
|
||||||
|
nextState[file.name] = { status: 'loading', buckets: [] };
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (file) => {
|
||||||
|
try {
|
||||||
|
const buckets = await fetchGeminiCliQuota(file);
|
||||||
|
return { name: file.name, status: 'success' as const, buckets };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const errorStatus = getStatusFromError(err);
|
||||||
|
return { name: file.name, status: 'error' as const, error: message, errorStatus };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestId !== geminiCliRequestIdRef.current) return;
|
||||||
|
|
||||||
|
setGeminiCliQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'success') {
|
||||||
|
nextState[result.name] = {
|
||||||
|
status: 'success',
|
||||||
|
buckets: result.buckets
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
nextState[result.name] = {
|
||||||
|
status: 'error',
|
||||||
|
buckets: [],
|
||||||
|
error: result.error,
|
||||||
|
errorStatus: result.errorStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (requestId === geminiCliRequestIdRef.current) {
|
||||||
|
setGeminiCliLoading(false);
|
||||||
|
setGeminiCliLoadingScope(null);
|
||||||
|
geminiCliLoadingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchGeminiCliQuota, t]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFiles();
|
loadFiles();
|
||||||
loadKeyStats();
|
loadKeyStats();
|
||||||
@@ -1119,6 +1353,23 @@ export function AuthFilesPage() {
|
|||||||
});
|
});
|
||||||
}, [codexFiles]);
|
}, [codexFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (geminiCliFiles.length === 0) {
|
||||||
|
setGeminiCliQuota({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGeminiCliQuota((prev) => {
|
||||||
|
const nextState: Record<string, GeminiCliQuotaState> = {};
|
||||||
|
geminiCliFiles.forEach((file) => {
|
||||||
|
const cached = prev[file.name];
|
||||||
|
if (cached) {
|
||||||
|
nextState[file.name] = cached;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}, [geminiCliFiles]);
|
||||||
|
|
||||||
// 定时刷新状态数据(每240秒)
|
// 定时刷新状态数据(每240秒)
|
||||||
useInterval(loadKeyStats, 240_000);
|
useInterval(loadKeyStats, 240_000);
|
||||||
|
|
||||||
@@ -1843,6 +2094,102 @@ export function AuthFilesPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderGeminiCliCard = (item: AuthFileItem) => {
|
||||||
|
const displayType = item.type || item.provider || 'gemini-cli';
|
||||||
|
const typeColor = getTypeColor(displayType);
|
||||||
|
const quotaState = geminiCliQuota[item.name];
|
||||||
|
const quotaStatus = quotaState?.status ?? 'idle';
|
||||||
|
const buckets = quotaState?.buckets ?? [];
|
||||||
|
const quotaErrorMessage = getQuotaErrorMessage(
|
||||||
|
quotaState?.errorStatus,
|
||||||
|
quotaState?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.name} className={`${styles.fileCard} ${styles.geminiCliCard}`}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.typeBadge}
|
||||||
|
style={{
|
||||||
|
backgroundColor: typeColor.bg,
|
||||||
|
color: typeColor.text,
|
||||||
|
...(typeColor.border ? { border: typeColor.border } : {})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getTypeLabel(displayType)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fileName}>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t('gemini_cli_quota.loading')}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t('gemini_cli_quota.idle')}</div>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t('gemini_cli_quota.load_failed', {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : buckets.length === 0 ? (
|
||||||
|
<div className={styles.quotaMessage}>{t('gemini_cli_quota.empty_buckets')}</div>
|
||||||
|
) : (
|
||||||
|
buckets.map((bucket) => {
|
||||||
|
const fraction = bucket.remainingFraction;
|
||||||
|
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||||
|
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||||
|
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||||
|
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||||
|
const remainingAmountLabel =
|
||||||
|
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||||
|
? null
|
||||||
|
: t('gemini_cli_quota.remaining_amount', {
|
||||||
|
count: bucket.remainingAmount
|
||||||
|
});
|
||||||
|
const quotaBarClass =
|
||||||
|
percent === null
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: percent >= 60
|
||||||
|
? styles.quotaBarFillHigh
|
||||||
|
: percent >= 20
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: styles.quotaBarFillLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={bucket.id} className={styles.quotaRow}>
|
||||||
|
<div className={styles.quotaRowHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.quotaModel}
|
||||||
|
title={
|
||||||
|
bucket.tokenType ? `${bucket.label} (${bucket.tokenType})` : bucket.label
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bucket.label}
|
||||||
|
</span>
|
||||||
|
<div className={styles.quotaMeta}>
|
||||||
|
<span className={styles.quotaPercent}>{percentLabel}</span>
|
||||||
|
{remainingAmountLabel && (
|
||||||
|
<span className={styles.quotaAmount}>{remainingAmountLabel}</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.quotaReset}>{resetLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.quotaBar}>
|
||||||
|
<div
|
||||||
|
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
||||||
|
style={{ width: `${percent ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
@@ -2144,6 +2491,97 @@ export function AuthFilesPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={t('gemini_cli_quota.title')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadGeminiCliQuota(geminiCliPageItems, 'page')}
|
||||||
|
disabled={disableControls || geminiCliLoading || geminiCliPageItems.length === 0}
|
||||||
|
loading={geminiCliLoading && geminiCliLoadingScope === 'page'}
|
||||||
|
>
|
||||||
|
{t('gemini_cli_quota.refresh_button')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadGeminiCliQuota(geminiCliFiles, 'all')}
|
||||||
|
disabled={disableControls || geminiCliLoading || geminiCliFiles.length === 0}
|
||||||
|
loading={geminiCliLoading && geminiCliLoadingScope === 'all'}
|
||||||
|
>
|
||||||
|
{t('gemini_cli_quota.fetch_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{geminiCliFiles.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('gemini_cli_quota.empty_title')}
|
||||||
|
description={t('gemini_cli_quota.empty_desc')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.geminiCliControls}>
|
||||||
|
<div className={styles.geminiCliControl}>
|
||||||
|
<label>{t('auth_files.page_size_label')}</label>
|
||||||
|
<select
|
||||||
|
className={styles.pageSizeSelect}
|
||||||
|
value={geminiCliPageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGeminiCliPageSize(Number(e.target.value) || 6);
|
||||||
|
setGeminiCliPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={6}>6</option>
|
||||||
|
<option value={9}>9</option>
|
||||||
|
<option value={12}>12</option>
|
||||||
|
<option value={18}>18</option>
|
||||||
|
<option value={24}>24</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.geminiCliControl}>
|
||||||
|
<label>{t('common.info')}</label>
|
||||||
|
<div className={styles.statsInfo}>
|
||||||
|
{geminiCliFiles.length} {t('auth_files.files_count')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.geminiCliGrid}>{geminiCliPageItems.map(renderGeminiCliCard)}</div>
|
||||||
|
{geminiCliFiles.length > geminiCliPageSize && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setGeminiCliPage(Math.max(1, geminiCliCurrentPage - 1))}
|
||||||
|
disabled={geminiCliCurrentPage <= 1}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_prev')}
|
||||||
|
</Button>
|
||||||
|
<div className={styles.pageInfo}>
|
||||||
|
{t('auth_files.pagination_info', {
|
||||||
|
current: geminiCliCurrentPage,
|
||||||
|
total: geminiCliTotalPages,
|
||||||
|
count: geminiCliFiles.length
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setGeminiCliPage(Math.min(geminiCliTotalPages, geminiCliCurrentPage + 1))
|
||||||
|
}
|
||||||
|
disabled={geminiCliCurrentPage >= geminiCliTotalPages}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* OAuth 排除列表卡片 */}
|
{/* OAuth 排除列表卡片 */}
|
||||||
<Card
|
<Card
|
||||||
title={t('oauth_excluded.title')}
|
title={t('oauth_excluded.title')}
|
||||||
|
|||||||
Reference in New Issue
Block a user