fix: 1.修复ClaudeConsole账号设置为专属绑定的功能

2. 修复Claude 官方账号会话窗口计算错误的问题
This commit is contained in:
KevinLiao
2025-07-30 20:20:12 +08:00
parent 363d1c3ed3
commit b86adcd6d2
21 changed files with 230 additions and 137 deletions

View File

@@ -340,6 +340,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
tokenLimit,
expiresAt,
claudeAccountId,
claudeConsoleAccountId,
geminiAccountId,
permissions,
concurrencyLimit,
@@ -416,6 +417,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
tokenLimit,
expiresAt,
claudeAccountId,
claudeConsoleAccountId,
geminiAccountId,
permissions,
concurrencyLimit,
@@ -441,7 +443,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit, tags } = req.body;
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, claudeConsoleAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit, tags } = req.body;
// 只允许更新指定字段
const updates = {};
@@ -479,6 +481,11 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.claudeAccountId = claudeAccountId || '';
}
if (claudeConsoleAccountId !== undefined) {
// 空字符串表示解绑null或空字符串都设置为空字符串
updates.claudeConsoleAccountId = claudeConsoleAccountId || '';
}
if (geminiAccountId !== undefined) {
// 空字符串表示解绑null或空字符串都设置为空字符串
updates.geminiAccountId = geminiAccountId || '';

View File

@@ -17,6 +17,7 @@ class ApiKeyService {
tokenLimit = config.limits.defaultTokenLimit,
expiresAt = null,
claudeAccountId = null,
claudeConsoleAccountId = null,
geminiAccountId = null,
permissions = 'all', // 'claude', 'gemini', 'all'
isActive = true,
@@ -47,6 +48,7 @@ class ApiKeyService {
rateLimitRequests: String(rateLimitRequests ?? 0),
isActive: String(isActive),
claudeAccountId: claudeAccountId || '',
claudeConsoleAccountId: claudeConsoleAccountId || '',
geminiAccountId: geminiAccountId || '',
permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
@@ -77,6 +79,7 @@ class ApiKeyService {
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
isActive: keyData.isActive === 'true',
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
@@ -162,6 +165,7 @@ class ApiKeyService {
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
@@ -237,7 +241,7 @@ class ApiKeyService {
}
// 允许更新的字段
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', 'tags'];
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'claudeConsoleAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', 'tags'];
const updatedData = { ...keyData };
for (const [field, value] of Object.entries(updates)) {

View File

@@ -892,7 +892,10 @@ class ClaudeAccountService {
const windowStartHour = Math.floor(hour / 5) * 5; // 向下取整到最近的5小时边界
const windowStart = new Date(requestTime);
windowStart.setHours(windowStartHour, 0, 0, 0);
windowStart.setHours(windowStartHour);
windowStart.setMinutes(0);
windowStart.setSeconds(0);
windowStart.setMilliseconds(0);
return windowStart;
}
@@ -969,79 +972,67 @@ class ClaudeAccountService {
logger.info('🔄 Initializing session windows for all Claude accounts...');
const accounts = await redis.getAllClaudeAccounts();
let initializedCount = 0;
let skippedCount = 0;
let expiredCount = 0;
let validWindowCount = 0;
let expiredWindowCount = 0;
let noWindowCount = 0;
const now = new Date();
for (const account of accounts) {
// 如果已经有会话窗口信息且不强制重算,跳过
if (account.sessionWindowStart && account.sessionWindowEnd && !forceRecalculate) {
skippedCount++;
logger.debug(`⏭️ Skipped account ${account.name} (${account.id}) - already has session window`);
continue;
// 如果强制重算,清除现有窗口信息
if (forceRecalculate && (account.sessionWindowStart || account.sessionWindowEnd)) {
logger.info(`🔄 Force recalculating window for account ${account.name} (${account.id})`);
delete account.sessionWindowStart;
delete account.sessionWindowEnd;
delete account.lastRequestTime;
await redis.setClaudeAccount(account.id, account);
}
// 如果有lastUsedAt基于它恢复会话窗口
if (account.lastUsedAt) {
const lastUsedTime = new Date(account.lastUsedAt);
const now = new Date();
// 检查现有会话窗口
if (account.sessionWindowStart && account.sessionWindowEnd) {
const windowEnd = new Date(account.sessionWindowEnd);
const windowStart = new Date(account.sessionWindowStart);
const timeUntilExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60));
// 计算时间差(分钟)
const timeSinceLastUsed = Math.round((now.getTime() - lastUsedTime.getTime()) / (1000 * 60));
// 计算lastUsedAt对应的会话窗口
const windowStart = this._calculateSessionWindowStart(lastUsedTime);
const windowEnd = this._calculateSessionWindowEnd(windowStart);
// 计算窗口剩余时间(分钟)
const timeUntilWindowExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60));
logger.info(`🔍 Analyzing account ${account.name} (${account.id}):`);
logger.info(` Last used: ${lastUsedTime.toISOString()} (${timeSinceLastUsed} minutes ago)`);
logger.info(` Calculated window: ${windowStart.toISOString()} - ${windowEnd.toISOString()}`);
logger.info(` Window expires in: ${timeUntilWindowExpires > 0 ? timeUntilWindowExpires + ' minutes' : 'EXPIRED'}`);
// 只有窗口未过期才恢复
if (now.getTime() < windowEnd.getTime()) {
account.sessionWindowStart = windowStart.toISOString();
account.sessionWindowEnd = windowEnd.toISOString();
account.lastRequestTime = account.lastUsedAt;
await redis.setClaudeAccount(account.id, account);
initializedCount++;
logger.success(`✅ Initialized session window for account ${account.name} (${account.id})`);
// 窗口仍然有效,保留它
validWindowCount++;
logger.info(`✅ Account ${account.name} (${account.id}) has valid window: ${windowStart.toISOString()} - ${windowEnd.toISOString()} (${timeUntilExpires} minutes remaining)`);
} else {
expiredCount++;
logger.warn(`⏰ Window expired for account ${account.name} (${account.id}) - will create new window on next request`);
// 窗口已过期,清除它
expiredWindowCount++;
logger.warn(`⏰ Account ${account.name} (${account.id}) window expired: ${windowStart.toISOString()} - ${windowEnd.toISOString()}`);
// 清除过期的窗口信息
delete account.sessionWindowStart;
delete account.sessionWindowEnd;
delete account.lastRequestTime;
await redis.setClaudeAccount(account.id, account);
}
} else {
logger.info(`📭 No lastUsedAt data for account ${account.name} (${account.id}) - will create window on first request`);
noWindowCount++;
logger.info(`📭 Account ${account.name} (${account.id}) has no session window - will create on next request`);
}
}
logger.success('✅ Session window initialization completed:');
logger.success(` 📊 Total accounts: ${accounts.length}`);
logger.success(`Initialized: ${initializedCount}`);
logger.success(` ⏭️ Skipped (existing): ${skippedCount}`);
logger.success(` ⏰ Expired: ${expiredCount}`);
logger.success(` 📭 No usage data: ${accounts.length - initializedCount - skippedCount - expiredCount}`);
logger.success(`Valid windows: ${validWindowCount}`);
logger.success(` ⏰ Expired windows: ${expiredWindowCount}`);
logger.success(` 📭 No windows: ${noWindowCount}`);
return {
total: accounts.length,
initialized: initializedCount,
skipped: skippedCount,
expired: expiredCount,
noData: accounts.length - initializedCount - skippedCount - expiredCount
validWindows: validWindowCount,
expiredWindows: expiredWindowCount,
noWindows: noWindowCount
};
} catch (error) {
logger.error('❌ Failed to initialize session windows:', error);
return {
total: 0,
initialized: 0,
skipped: 0,
expired: 0,
noData: 0,
validWindows: 0,
expiredWindows: 0,
noWindows: 0,
error: error.message
};
}

View File

@@ -12,16 +12,31 @@ class UnifiedClaudeScheduler {
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try {
// 如果API Key绑定了专属账户优先使用
// 1. 检查Claude OAuth账户绑定
if (apiKeyData.claudeAccountId) {
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
logger.info(`🎯 Using bound dedicated Claude account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`);
logger.info(`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`);
return {
accountId: apiKeyData.claudeAccountId,
accountType: 'claude-official'
};
} else {
logger.warn(`⚠️ Bound Claude account ${apiKeyData.claudeAccountId} is not available, falling back to pool`);
logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available, falling back to pool`);
}
}
// 2. 检查Claude Console账户绑定
if (apiKeyData.claudeConsoleAccountId) {
const boundConsoleAccount = await claudeConsoleAccountService.getAccount(apiKeyData.claudeConsoleAccountId);
if (boundConsoleAccount && boundConsoleAccount.isActive === true && boundConsoleAccount.status === 'active') {
logger.info(`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`);
return {
accountId: apiKeyData.claudeConsoleAccountId,
accountType: 'claude-console'
};
} else {
logger.warn(`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available, falling back to pool`);
}
}
@@ -81,13 +96,14 @@ class UnifiedClaudeScheduler {
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
const availableAccounts = [];
// 如果API Key绑定了专属Claude账户,优先返回
// 如果API Key绑定了专属账户优先返回
// 1. 检查Claude OAuth账户绑定
if (apiKeyData.claudeAccountId) {
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error' && boundAccount.status !== 'blocked') {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id);
if (!isRateLimited) {
logger.info(`🎯 Using bound dedicated Claude account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`);
logger.info(`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`);
return [{
...boundAccount,
accountId: boundAccount.id,
@@ -97,7 +113,27 @@ class UnifiedClaudeScheduler {
}];
}
} else {
logger.warn(`⚠️ Bound Claude account ${apiKeyData.claudeAccountId} is not available`);
logger.warn(`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available`);
}
}
// 2. 检查Claude Console账户绑定
if (apiKeyData.claudeConsoleAccountId) {
const boundConsoleAccount = await claudeConsoleAccountService.getAccount(apiKeyData.claudeConsoleAccountId);
if (boundConsoleAccount && boundConsoleAccount.isActive === true && boundConsoleAccount.status === 'active') {
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(boundConsoleAccount.id);
if (!isRateLimited) {
logger.info(`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})`);
return [{
...boundConsoleAccount,
accountId: boundConsoleAccount.id,
accountType: 'claude-console',
priority: parseInt(boundConsoleAccount.priority) || 50,
lastUsedAt: boundConsoleAccount.lastUsedAt || '0'
}];
}
} else {
logger.warn(`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available`);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.accounts-container[data-v-d6375019]{min-height:calc(100vh - 300px)}.table-container[data-v-d6375019]{overflow-x:auto;border-radius:12px;border:1px solid rgba(0,0,0,.05)}.table-row[data-v-d6375019]{transition:all .2s ease}.table-row[data-v-d6375019]:hover{background-color:#00000005}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
pre[data-v-3d91f349]{white-space:pre-wrap;word-wrap:break-word}.tab-content[data-v-518dfd83]{min-height:calc(100vh - 300px)}.table-container[data-v-518dfd83]{overflow-x:auto;border-radius:12px;border:1px solid rgba(0,0,0,.05)}.table-row[data-v-518dfd83]{transition:all .2s ease}.table-row[data-v-518dfd83]:hover{background-color:#00000005}.loading-spinner[data-v-518dfd83]{width:24px;height:24px;border:2px solid #e5e7eb;border-top:2px solid #3b82f6;border-radius:50%;animation:spin-518dfd83 1s linear infinite}@keyframes spin-518dfd83{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.api-key-date-picker[data-v-518dfd83] .el-input__inner{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.api-key-date-picker[data-v-518dfd83] .el-input__inner:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1));--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.api-key-date-picker[data-v-518dfd83] .el-range-separator{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{c as b,r as x,q as f,x as a,z as s,L as i,Q as y,u as o,P as m,Y as _,K as u,aq as c,O as g,y as n}from"./vue-vendor-CKToUHZx.js";import{_ as v,u as w}from"./index-HsvoyPKA.js";/* empty css */import"./element-plus-B8Fs_0jW.js";import"./vendor-BDiMbLwQ.js";const h={class:"flex items-center justify-center min-h-screen p-6"},k={class:"glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl"},L={class:"text-center mb-8"},S={class:"w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden"},V=["src"],I={key:1,class:"fas fa-cloud text-3xl text-gray-700"},N={key:1,class:"w-12 h-12 bg-gray-300/50 rounded animate-pulse"},q={key:0,class:"text-3xl font-bold text-white mb-2 header-title"},D={key:1,class:"h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"},E=["disabled"],j={key:0,class:"fas fa-sign-in-alt mr-2"},B={key:1,class:"loading-spinner mr-2"},M={key:0,class:"mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm"},F={__name:"LoginView",setup(O){const e=w(),d=b(()=>e.oemLoading),l=x({username:"",password:""});f(()=>{e.loadOemSettings()});const p=async()=>{await e.login(l.value)};return(T,t)=>(n(),a("div",h,[s("div",k,[s("div",L,[s("div",S,[d.value?(n(),a("div",N)):(n(),a(y,{key:0},[o(e).oemSettings.siteIconData||o(e).oemSettings.siteIcon?(n(),a("img",{key:0,src:o(e).oemSettings.siteIconData||o(e).oemSettings.siteIcon,alt:"Logo",class:"w-12 h-12 object-contain",onError:t[0]||(t[0]=r=>r.target.style.display="none")},null,40,V)):(n(),a("i",I))],64))]),!d.value&&o(e).oemSettings.siteName?(n(),a("h1",q,m(o(e).oemSettings.siteName),1)):d.value?(n(),a("div",D)):i("",!0),t[3]||(t[3]=s("p",{class:"text-gray-600 text-lg"},"管理后台",-1))]),s("form",{onSubmit:_(p,["prevent"]),class:"space-y-6"},[s("div",null,[t[4]||(t[4]=s("label",{class:"block text-sm font-semibold text-gray-900 mb-3"},"用户名",-1)),u(s("input",{"onUpdate:modelValue":t[1]||(t[1]=r=>l.value.username=r),type:"text",required:"",class:"form-input w-full",placeholder:"请输入用户名"},null,512),[[c,l.value.username]])]),s("div",null,[t[5]||(t[5]=s("label",{class:"block text-sm font-semibold text-gray-900 mb-3"},"密码",-1)),u(s("input",{"onUpdate:modelValue":t[2]||(t[2]=r=>l.value.password=r),type:"password",required:"",class:"form-input w-full",placeholder:"请输入密码"},null,512),[[c,l.value.password]])]),s("button",{type:"submit",disabled:o(e).loginLoading,class:"btn btn-primary w-full py-4 px-6 text-lg font-semibold"},[o(e).loginLoading?i("",!0):(n(),a("i",j)),o(e).loginLoading?(n(),a("div",B)):i("",!0),g(" "+m(o(e).loginLoading?"登录中...":"登录"),1)],8,E)],32),o(e).loginError?(n(),a("div",M,[t[6]||(t[6]=s("i",{class:"fas fa-exclamation-triangle mr-2"},null,-1)),g(m(o(e).loginError),1)])):i("",!0)])]))}},P=v(F,[["__scopeId","data-v-4a19afbe"]]);export{P as default};

View File

@@ -1 +0,0 @@
/* empty css */import{_ as r}from"./index-HsvoyPKA.js";import{x as t,y as s,z as l,Q as d,L as o,A as c,C as g,P as i}from"./vue-vendor-CKToUHZx.js";const u={class:"flex items-center gap-4"},f={class:"w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden"},y=["src"],m={key:1,class:"fas fa-cloud text-xl text-gray-700"},h={key:1,class:"w-8 h-8 bg-gray-300/50 rounded animate-pulse"},x={class:"flex flex-col justify-center min-h-[48px]"},b={class:"flex items-center gap-3"},k={key:1,class:"h-8 w-64 bg-gray-300/50 rounded animate-pulse"},_={key:0,class:"text-gray-600 text-sm leading-tight mt-0.5"},S={__name:"LogoTitle",props:{loading:{type:Boolean,default:!1},title:{type:String,default:""},subtitle:{type:String,default:""},logoSrc:{type:String,default:""},titleClass:{type:String,default:"text-gray-900"}},setup(e){const n=a=>{a.target.style.display="none"};return(a,p)=>(s(),t("div",u,[l("div",f,[e.loading?(s(),t("div",h)):(s(),t(d,{key:0},[e.logoSrc?(s(),t("img",{key:0,src:e.logoSrc,alt:"Logo",class:"w-8 h-8 object-contain",onError:n},null,40,y)):(s(),t("i",m))],64))]),l("div",x,[l("div",b,[!e.loading&&e.title?(s(),t("h1",{key:0,class:g(["text-2xl font-bold header-title leading-tight",e.titleClass])},i(e.title),3)):e.loading?(s(),t("div",k)):o("",!0),c(a.$slots,"after-title",{},void 0,!0)]),e.subtitle?(s(),t("p",_,i(e.subtitle),1)):o("",!0)])]))}},C=r(S,[["__scopeId","data-v-a75bf797"]]);export{C as L};

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
import{aR as k,r as x,aW as C,q as O,x as m,z as e,u as i,K as T,aq as N,L as _,O as v,C as j,P as S,y as g}from"./vue-vendor-CKToUHZx.js";import{s as c}from"./toast-BvwA7Mwb.js";import{a as D,_ as F}from"./index-HsvoyPKA.js";import"./element-plus-B8Fs_0jW.js";import"./vendor-BDiMbLwQ.js";const E=k("settings",()=>{const l=x({siteName:"Claude Relay Service",siteIcon:"",siteIconData:"",updatedAt:null}),r=x(!1),p=x(!1),d=async()=>{r.value=!0;try{const s=await D.get("/admin/oem-settings");return s&&s.success&&(l.value={...l.value,...s.data},f()),s}catch(s){throw console.error("Failed to load OEM settings:",s),s}finally{r.value=!1}},a=async s=>{p.value=!0;try{const o=await D.put("/admin/oem-settings",s);return o&&o.success&&(l.value={...l.value,...o.data},f()),o}catch(o){throw console.error("Failed to save OEM settings:",o),o}finally{p.value=!1}},w=async()=>{const s={siteName:"Claude Relay Service",siteIcon:"",siteIconData:"",updatedAt:null};return l.value={...s},await a(s)},f=()=>{if(l.value.siteName&&(document.title=`${l.value.siteName} - 管理后台`),l.value.siteIconData||l.value.siteIcon){const s=document.querySelector('link[rel="icon"]')||document.createElement("link");s.rel="icon",s.href=l.value.siteIconData||l.value.siteIcon,document.querySelector('link[rel="icon"]')||document.head.appendChild(s)}};return{oemSettings:l,loading:r,saving:p,loadOemSettings:d,saveOemSettings:a,resetOemSettings:w,applyOemSettings:f,formatDateTime:s=>s?new Date(s).toLocaleString("zh-CN",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"",validateIconFile:s=>{const o=[];return s.size>350*1024&&o.push("图标文件大小不能超过 350KB"),["image/x-icon","image/png","image/jpeg","image/jpg","image/svg+xml"].includes(s.type)||o.push("不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件"),{isValid:o.length===0,errors:o}},fileToBase64:s=>new Promise((o,n)=>{const t=new FileReader;t.onload=u=>o(u.target.result),t.onerror=n,t.readAsDataURL(s)})}}),B={class:"settings-container"},V={class:"card p-6"},R={key:0,class:"text-center py-12"},M={key:1,class:"table-container"},A={class:"min-w-full"},q={class:"divide-y divide-gray-200/50"},z={class:"table-row"},K={class:"px-6 py-4"},L={class:"table-row"},U={class:"px-6 py-4"},$={class:"space-y-3"},P={key:0,class:"inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg"},W=["src"],G={class:"px-6 py-6",colspan:"2"},H={class:"flex items-center justify-between"},J={class:"flex gap-3"},Q=["disabled"],X={key:0,class:"loading-spinner mr-2"},Y={key:1,class:"fas fa-save mr-2"},Z=["disabled"],ee={key:0,class:"text-sm text-gray-500"},te={__name:"SettingsView",setup(l){const r=E(),{loading:p,saving:d,oemSettings:a}=C(r),w=x();O(async()=>{try{await r.loadOemSettings()}catch{c("加载设置失败","error")}});const f=async()=>{try{const n={siteName:a.value.siteName,siteIcon:a.value.siteIcon,siteIconData:a.value.siteIconData},t=await r.saveOemSettings(n);t&&t.success?c("OEM设置保存成功","success"):c((t==null?void 0:t.message)||"保存失败","error")}catch{c("保存OEM设置失败","error")}},b=async()=>{if(confirm(`确定要重置为默认设置吗?
这将清除所有自定义的网站名称和图标设置。`))try{const n=await r.resetOemSettings();n&&n.success?c("已重置为默认设置","success"):c("重置失败","error")}catch{c("重置失败","error")}},h=async n=>{const t=n.target.files[0];if(!t)return;const u=r.validateIconFile(t);if(!u.isValid){u.errors.forEach(y=>c(y,"error"));return}try{const y=await r.fileToBase64(t);a.value.siteIconData=y}catch{c("文件读取失败","error")}n.target.value=""},I=()=>{a.value.siteIcon="",a.value.siteIconData=""},s=()=>{console.warn("Icon failed to load")},o=r.formatDateTime;return(n,t)=>(g(),m("div",B,[e("div",V,[t[12]||(t[12]=e("div",{class:"flex flex-col md:flex-row justify-between items-center gap-4 mb-6"},[e("div",null,[e("h3",{class:"text-xl font-bold text-gray-900 mb-2"},"其他设置"),e("p",{class:"text-gray-600"},"自定义网站名称和图标")])],-1)),i(p)?(g(),m("div",R,t[2]||(t[2]=[e("div",{class:"loading-spinner mx-auto mb-4"},null,-1),e("p",{class:"text-gray-500"},"正在加载设置...",-1)]))):(g(),m("div",M,[e("table",A,[e("tbody",q,[e("tr",z,[t[4]||(t[4]=e("td",{class:"px-6 py-4 whitespace-nowrap w-48"},[e("div",{class:"flex items-center"},[e("div",{class:"w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3"},[e("i",{class:"fas fa-font text-white text-xs"})]),e("div",null,[e("div",{class:"text-sm font-semibold text-gray-900"},"网站名称"),e("div",{class:"text-xs text-gray-500"},"品牌标识")])])],-1)),e("td",K,[T(e("input",{"onUpdate:modelValue":t[0]||(t[0]=u=>i(a).siteName=u),type:"text",class:"form-input w-full max-w-md",placeholder:"Claude Relay Service",maxlength:"100"},null,512),[[N,i(a).siteName]]),t[3]||(t[3]=e("p",{class:"text-xs text-gray-500 mt-1"},"将显示在浏览器标题和页面头部",-1))])]),e("tr",L,[t[9]||(t[9]=e("td",{class:"px-6 py-4 whitespace-nowrap w-48"},[e("div",{class:"flex items-center"},[e("div",{class:"w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3"},[e("i",{class:"fas fa-image text-white text-xs"})]),e("div",null,[e("div",{class:"text-sm font-semibold text-gray-900"},"网站图标"),e("div",{class:"text-xs text-gray-500"},"Favicon")])])],-1)),e("td",U,[e("div",$,[i(a).siteIconData||i(a).siteIcon?(g(),m("div",P,[e("img",{src:i(a).siteIconData||i(a).siteIcon,alt:"图标预览",class:"w-8 h-8",onError:s},null,40,W),t[6]||(t[6]=e("span",{class:"text-sm text-gray-600"},"当前图标",-1)),e("button",{onClick:I,class:"text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"},t[5]||(t[5]=[e("i",{class:"fas fa-trash mr-1"},null,-1),v("删除 ",-1)]))])):_("",!0),e("div",null,[e("input",{type:"file",ref_key:"iconFileInput",ref:w,onChange:h,accept:".ico,.png,.jpg,.jpeg,.svg",class:"hidden"},null,544),e("button",{onClick:t[1]||(t[1]=u=>n.$refs.iconFileInput.click()),class:"btn btn-success px-4 py-2"},t[7]||(t[7]=[e("i",{class:"fas fa-upload mr-2"},null,-1),v(" 上传图标 ",-1)])),t[8]||(t[8]=e("span",{class:"text-xs text-gray-500 ml-3"},"支持 .ico, .png, .jpg, .svg 格式,最大 350KB",-1))])])])]),e("tr",null,[e("td",G,[e("div",H,[e("div",J,[e("button",{onClick:f,disabled:i(d),class:j(["btn btn-primary px-6 py-3",{"opacity-50 cursor-not-allowed":i(d)}])},[i(d)?(g(),m("div",X)):(g(),m("i",Y)),v(" "+S(i(d)?"保存中...":"保存设置"),1)],10,Q),e("button",{onClick:b,class:"btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3",disabled:i(d)},t[10]||(t[10]=[e("i",{class:"fas fa-undo mr-2"},null,-1),v(" 重置为默认 ",-1)]),8,Z)]),i(a).updatedAt?(g(),m("div",ee,[t[11]||(t[11]=e("i",{class:"fas fa-clock mr-1"},null,-1)),v(" 最后更新:"+S(i(o)(i(a).updatedAt)),1)])):_("",!0)])])])])])]))])]))}},le=F(te,[["__scopeId","data-v-3508e0de"]]);export{le as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,7 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<script type="module" crossorigin src="/admin-next/assets/index-HsvoyPKA.js"></script>
<script type="module" crossorigin src="/admin-next/assets/index-CxLQf0HX.js"></script>
<link rel="modulepreload" crossorigin href="/admin-next/assets/vue-vendor-CKToUHZx.js">
<link rel="modulepreload" crossorigin href="/admin-next/assets/vendor-BDiMbLwQ.js">
<link rel="modulepreload" crossorigin href="/admin-next/assets/element-plus-B8Fs_0jW.js">

View File

@@ -267,13 +267,24 @@
:disabled="form.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in accounts.claude.filter(a => a.isDedicated)"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0" label="Claude OAuth 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console').length > 0" label="Claude Console 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console')"
:key="account.id"
:value="`console:${account.id}`"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
</select>
</div>
<div>
@@ -603,11 +614,25 @@ const createApiKey = async () => {
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
expiresAt: form.expiresAt || undefined,
permissions: form.permissions,
claudeAccountId: form.claudeAccountId || undefined,
geminiAccountId: form.geminiAccountId || undefined,
tags: form.tags.length > 0 ? form.tags : undefined
}
// 处理Claude账户绑定区分OAuth和Console
if (form.claudeAccountId) {
if (form.claudeAccountId.startsWith('console:')) {
// Claude Console账户
data.claudeConsoleAccountId = form.claudeAccountId.substring(8);
} else {
// Claude OAuth账户
data.claudeAccountId = form.claudeAccountId;
}
}
// Gemini账户绑定
if (form.geminiAccountId) {
data.geminiAccountId = form.geminiAccountId;
}
// 模型限制 - 始终提交这些字段
data.enableModelRestriction = form.enableModelRestriction
data.restrictedModels = form.restrictedModels

View File

@@ -224,13 +224,24 @@
:disabled="form.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in accounts.claude"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0" label="Claude OAuth 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console').length > 0" label="Claude Console 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console')"
:key="account.id"
:value="`console:${account.id}`"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
</select>
</div>
<div>
@@ -242,7 +253,7 @@
>
<option value="">使用共享账号池</option>
<option
v-for="account in accounts.gemini"
v-for="account in accounts.gemini.filter(a => a.isDedicated)"
:key="account.id"
:value="account.id"
>
@@ -476,8 +487,6 @@ const updateApiKey = async () => {
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
permissions: form.permissions,
claudeAccountId: form.claudeAccountId || null,
geminiAccountId: form.geminiAccountId || null,
tags: form.tags
}
@@ -489,6 +498,25 @@ const updateApiKey = async () => {
data.enableClientRestriction = form.enableClientRestriction
data.allowedClients = form.allowedClients
// 处理Claude账户绑定区分OAuth和Console
if (form.claudeAccountId) {
if (form.claudeAccountId.startsWith('console:')) {
// Claude Console账户
data.claudeConsoleAccountId = form.claudeAccountId.substring(8);
data.claudeAccountId = null; // 清空OAuth绑定
} else {
// Claude OAuth账户
data.claudeAccountId = form.claudeAccountId;
data.claudeConsoleAccountId = null; // 清空Console绑定
}
} else {
data.claudeAccountId = null;
data.claudeConsoleAccountId = null;
}
// Gemini账户绑定
data.geminiAccountId = form.geminiAccountId || null;
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
if (result.success) {
@@ -517,7 +545,15 @@ onMounted(async () => {
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
form.claudeAccountId = props.apiKey.claudeAccountId || ''
// 处理Claude账户绑定初始化
if (props.apiKey.claudeAccountId) {
form.claudeAccountId = props.apiKey.claudeAccountId;
} else if (props.apiKey.claudeConsoleAccountId) {
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`;
} else {
form.claudeAccountId = '';
}
form.geminiAccountId = props.apiKey.geminiAccountId || ''
form.restrictedModels = props.apiKey.restrictedModels || []
form.allowedClients = props.apiKey.allowedClients || []

View File

@@ -397,8 +397,9 @@ const loadAccounts = async () => {
if (claudeConsoleData.success) {
const claudeConsoleAccounts = (claudeConsoleData.data || []).map(acc => {
// Claude Console账户暂时不支持直接绑定
return { ...acc, platform: 'claude-console', boundApiKeysCount: 0 }
// 计算每个Claude Console账户绑定的API Key数量
const boundApiKeysCount = apiKeys.value.filter(key => key.claudeConsoleAccountId === acc.id).length
return { ...acc, platform: 'claude-console', boundApiKeysCount }
})
allAccounts.push(...claudeConsoleAccounts)
}

View File

@@ -98,9 +98,9 @@
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</div>
<div class="text-xs text-gray-500">{{ key.id }}</div>
<div class="text-xs text-gray-500 mt-1">
<span v-if="key.claudeAccountId">
<span v-if="key.claudeAccountId || key.claudeConsoleAccountId">
<i class="fas fa-link mr-1"></i>
绑定: {{ getBoundAccountName(key.claudeAccountId) }}
绑定: {{ getBoundAccountName(key.claudeAccountId, key.claudeConsoleAccountId) }}
</span>
<span v-else>
<i class="fas fa-share-alt mr-1"></i>
@@ -546,17 +546,42 @@ const sortedApiKeys = computed(() => {
// 加载账户列表
const loadAccounts = async () => {
try {
const [claudeData, geminiData] = await Promise.all([
const [claudeData, claudeConsoleData, geminiData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts')
])
// 合并Claude OAuth账户和Claude Console账户
const claudeAccounts = []
if (claudeData.success) {
accounts.value.claude = claudeData.data || []
claudeData.data?.forEach(account => {
claudeAccounts.push({
...account,
platform: 'claude-oauth',
isDedicated: account.accountType === 'dedicated'
})
})
}
if (claudeConsoleData.success) {
claudeConsoleData.data?.forEach(account => {
claudeAccounts.push({
...account,
platform: 'claude-console',
isDedicated: account.accountType === 'dedicated'
})
})
}
accounts.value.claude = claudeAccounts
if (geminiData.success) {
accounts.value.gemini = geminiData.data || []
accounts.value.gemini = (geminiData.data || []).map(account => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
} catch (error) {
console.error('加载账户列表失败:', error)
@@ -611,23 +636,28 @@ const calculateApiKeyCost = (usage) => {
}
// 获取绑定账户名称
const getBoundAccountName = (accountId) => {
if (!accountId) return '未知账户'
// 从Claude账户列表中查找
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
if (claudeAccount) {
return claudeAccount.name
const getBoundAccountName = (claudeAccountId, claudeConsoleAccountId) => {
// 优先显示Claude OAuth账户
if (claudeAccountId) {
const claudeAccount = accounts.value.claude.find(acc => acc.id === claudeAccountId)
if (claudeAccount) {
return claudeAccount.name
}
// 如果找不到返回账户ID的前8位
return `账户-${claudeAccountId.substring(0, 8)}`
}
// 从Gemini账户列表中查找
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
if (geminiAccount) {
return geminiAccount.name
// 其次显示Claude Console账户
if (claudeConsoleAccountId) {
const consoleAccount = accounts.value.claude.find(acc => acc.id === claudeConsoleAccountId)
if (consoleAccount) {
return `${consoleAccount.name} (Console)`
}
// 如果找不到返回账户ID的前8位
return `Console-${claudeConsoleAccountId.substring(0, 8)}`
}
// 如果找不到返回账户ID的前8位
return `账户-${accountId.substring(0, 8)}`
return '未知账户'
}
// 检查API Key是否过期