Repository: fxaxg/one-api-hub Branch: main Commit: a8a2ac83a3af Files: 56 Total size: 328.8 KB Directory structure: gitextract_sbci5x21/ ├── .github/ │ └── workflows/ │ ├── deploy-docs.yml │ └── submit.yml ├── .gitignore ├── .prettierrc.mjs ├── CLAUDE.md ├── LICENSE ├── README.md ├── background.ts ├── components/ │ ├── AccountList.tsx │ ├── ActionButtons.tsx │ ├── AddAccountDialog.tsx │ ├── AddTokenDialog.tsx │ ├── AutoDetectErrorAlert.tsx │ ├── BalanceSection.tsx │ ├── CopyKeyDialog.tsx │ ├── DelAccountDialog.tsx │ ├── EditAccountDialog.tsx │ ├── HeaderSection.tsx │ ├── ModelItem.tsx │ └── Tooltip.tsx ├── constants/ │ └── ui.ts ├── content.ts ├── debug/ │ └── testStorage.ts ├── docs/ │ ├── docs/ │ │ ├── .vuepress/ │ │ │ └── config.js │ │ ├── README.md │ │ ├── faq.md │ │ └── get-started.md │ └── package.json ├── examples/ │ └── storageExample.ts ├── global.d.ts ├── hooks/ │ ├── useAccountData.ts │ ├── useSort.ts │ ├── useTimeFormatter.ts │ └── useUserPreferences.ts ├── options/ │ ├── index.tsx │ └── pages/ │ ├── About.tsx │ ├── BasicSettings.tsx │ ├── ImportExport.tsx │ ├── KeyManagement.tsx │ └── ModelList.tsx ├── package.json ├── popup/ │ ├── index.tsx │ └── style.css ├── postcss.config.js ├── services/ │ ├── accountOperations.ts │ ├── accountStorage.ts │ ├── apiService.ts │ ├── autoRefreshService.ts │ └── userPreferences.ts ├── tailwind.config.js ├── tsconfig.json ├── types/ │ └── index.ts └── utils/ ├── autoDetectUtils.ts ├── formatters.ts ├── modelPricing.ts └── modelProviders.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/deploy-docs.yml ================================================ name: 部署文档 on: push: branches: # 确保这是你正在使用的分支名称 - dev paths: # <<<<<< 新增:路径过滤 - 'docs/docs/**' # 匹配 /docs/docs/ 目录下所有文档内容和配置 (包括 .vuepress/、config.js 等) - 'docs/package.json' # 匹配 /docs/package.json 文件 (因为依赖定义在这里) - 'docs/pnpm-lock.json' # 匹配 /docs/pnpm-lock.json 文件 (因为依赖版本锁定在这里) permissions: contents: write jobs: deploy-gh-pages: runs-on: ubuntu-latest defaults: run: working-directory: docs # 所有 run 命令将在此目录下执行 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # 如果你的文档需要 Git 子模块,取消注释下一行 # submodules: true - name: 安装 pnpm uses: pnpm/action-setup@v4 with: version: 8 - name: 设置 Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: 安装依赖 run: pnpm install --no-frozen-lockfile - name: 构建文档 env: NODE_OPTIONS: --max_old_space_size=8192 run: |- # 因为 working-directory 是 docs,所以这里执行的是 docs/package.json 中的 docs:build # 且构建输出在 /docs/docs/.vuepress/dist (相对于仓库根目录) # 或者在当前工作目录 /docs 内的 docs/.vuepress/dist pnpm run docs:build # 创建 .nojekyll 文件,防止 GitHub Pages 尝试解析 Jekyll # 该文件将位于 /docs/docs/.vuepress/dist/.nojekyll > docs/.vuepress/dist/.nojekyll - name: 部署文档 uses: JamesIves/github-pages-deploy-action@v4 with: branch: gh-pages # 部署到的目标分支 # 指定要部署的文件夹。相对于 GitHub 仓库根目录。 # 您的 VitePress 文档内容在 /docs/docs/ 下,构建输出通常在 /docs/docs/.vuepress/dist folder: docs/docs/.vuepress/dist ================================================ FILE: .github/workflows/submit.yml ================================================ name: "Submit to Web Store" on: workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Cache pnpm modules uses: actions/cache@v3 with: path: ~/.pnpm-store key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}- - uses: pnpm/action-setup@v2.2.4 with: version: latest run_install: true - name: Use Node.js 16.x uses: actions/setup-node@v3.4.1 with: node-version: 16.x cache: "pnpm" - name: Build the extension run: pnpm build - name: Package the extension into a zip artifact run: pnpm package - name: Browser Platform Publish uses: PlasmoHQ/bpp@v3 with: keys: ${{ secrets.SUBMIT_KEYS }} artifact: build/chrome-mv3-prod.zip ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local out/ build/ dist/ # plasmo .plasmo # typescript .tsbuildinfo .idea # 文档项目的依赖和构建产物 /docs/node_modules/ /docs/docs/.vuepress/dist/ /docs/docs/.vuepress/.cache/ /docs/docs/.vuepress/.temp/ # 其他可能的文档生成器特定忽略项 (例如 Docusaurus 的 build 目录) # /docs/build/ ================================================ FILE: .prettierrc.mjs ================================================ /** * @type {import('prettier').Options} */ export default { printWidth: 80, tabWidth: 2, useTabs: false, semi: false, singleQuote: false, trailingComma: "none", bracketSpacing: true, bracketSameLine: true, plugins: ["@ianvs/prettier-plugin-sort-imports"], importOrder: [ "", // Node.js built-in modules "", // Imports not matched by other special words or groups. "", // Empty line "^@plasmo/(.*)$", "", "^@plasmohq/(.*)$", "", "^~(.*)$", "", "^[./]" ] } ================================================ FILE: CLAUDE.md ================================================ ## 用户定义 - 当前项目使用 Plasmo v0.90.5 开发 - 前端技术栈(Tailwind CSS v3、Headless UI等) - 当技术文档不确定时,应当使用 mcp 工具 context7 进行搜索 ### 项目介绍 ``` ## 介绍 目前市面上有太多 ai-api 中转站点,每次查看余额和支持模型列表等信息都非常麻烦,需要逐个登录查看。 本插件可以便捷的对基于https://github.com/songquanpeng/one-api和[new-api](https://github.com/QuantumNous/new-api)等部署的 Ai 中转站账号进行整合管理。 ### 功能 - 自动识别中转站点,自动创建系统访问 token 并添加到插件的站点列表中 - 每个站点可添加多个账号 - 账号的余额、使用日志进行查看 - 令牌(key)查看与管理 - 站点支持模型信息和渠道查看 - 插件无需联网 ### 未来支持 - 模型降智测试 - webdav 数据备份 ``` ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2025 fxaxg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
One API Hub Logo # 中转站管理器 - One API Hub **一个开源的浏览器插件,聚合管理所有中转站账号的余额、模型和密钥,告别繁琐登录。** [![Version](https://img.shields.io/badge/version-0.0.3-blue.svg)](https://github.com/fxaxg/one-api-hub) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Plasmo](https://img.shields.io/badge/plasmo-v0.90.5-purple.svg)](https://plasmo.com) [![React](https://img.shields.io/badge/react-18.2.0-61dafb.svg)](https://reactjs.org) [![TypeScript](https://img.shields.io/badge/typescript-5.3.3-blue.svg)](https://typescriptlang.org) [![Tailwind CSS](https://img.shields.io/badge/tailwindcss-3.4.17-38bdf8.svg)](https://tailwindcss.com) **[文档教程](https://fxaxg.github.io/one-api-hub/) | [常见问题](https://fxaxg.github.io/one-api-hub/faq.html)**
--- ## 📖 介绍 目前市面上有太多 AI-API 中转站点,每次查看余额和支持模型列表等信息都非常麻烦,需要逐个登录查看。 本插件可以便捷的对基于以下项目的AI 中转站账号进行整合管理: - [one-api](https://github.com/songquanpeng/one-api) - [new-api](https://github.com/QuantumNous/new-api) - [Veloera](https://github.com/Veloera/Veloera) - [one-hub](https://github.com/MartialBE/one-hub) - [done-hub](https://github.com/deanxv/done-hub) ## ✨ 功能特性 - 🔍 **自动识别中转站点** - 自动创建系统访问 token 并添加到插件的站点列表中 - 💰 **自动识别中转站充值比例** - 智能解析站点配置信息 - 👥 **多账号管理** - 每个站点可添加多个账号 - 📊 **余额与日志查看** - 账号的余额、使用日志一目了然 - 🔑 **令牌(key)管理** - 便捷的密钥查看与管理 - 🤖 **模型信息查看** - 站点支持模型信息和渠道查看 - 🔒 **完全离线** - 插件无需联网,保护隐私安全 ## 🖥️ 截图展示 ![软件截图](./docs/docs/static/image/app_show.png) ## 🚀 安装使用 ### Chrome 应用商店(推荐) [⬇️ 前往下载](https://chromewebstore.google.com/detail/%E4%B8%AD%E8%BD%AC%E7%AB%99%E7%AE%A1%E7%90%86%E5%99%A8-one-api-hub/eobdoeafpplhhhjfkinnlkljbkijpobd) ## 🛠️ 开发指南 ### 环境要求 - Node.js 18+ - npm 或 pnpm ### 本地开发 ```bash # 克隆项目 git clone https://github.com/username/one-api-hub.git cd one-api-hub # 安装依赖 pnpm install # 或者 npm install # 启动开发服务器 pnpm dev # 或者 npm run dev ``` 然后在浏览器中加载 `build/chrome-mv3-dev` 目录作为扩展程序。 ### 构建生产版本 ```bash pnpm build # 或者 npm run build ``` 这将在 `build` 目录中创建生产版本的扩展包。 ## 🔮 未来支持 - 🧪 **模型降智测试** - 自动化模型性能测试 - ☁️ **WebDAV 数据备份** - 云端数据同步与备份 ## 👥 贡献者(不分先后) 感谢以下贡献者对项目的支持: - [@qixing-jk](https://github.com/qixing-jk) - [@JianKang-Li](https://github.com/JianKang-Li) ## 🏗️ 技术栈 - **框架**: [Plasmo](https://plasmo.com) v0.90.5 - **UI 库**: [React](https://reactjs.org) 18.2.0 - **样式**: [Tailwind CSS](https://tailwindcss.com) v3.4.17 - **组件**: [Headless UI](https://headlessui.com) - **图标**: [Heroicons](https://heroicons.com) - **状态管理**: [Zustand](https://zustand-demo.pmnd.rs) - **类型检查**: [TypeScript](https://typescriptlang.org) 5.3.3 ## 📄 许可证 本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 ## 🙏 致谢 - [Plasmo](https://plasmo.com) - 现代化的浏览器扩展开发框架 ---
⭐ 如果这个项目对你有帮助,请考虑给它一个星标!
================================================ FILE: background.ts ================================================ import { autoRefreshService, handleAutoRefreshMessage } from "./services/autoRefreshService" // 管理临时窗口的 Map const tempWindows = new Map() // 插件启动时初始化自动刷新服务 chrome.runtime.onStartup.addListener(async () => { console.log("[Background] 插件启动,初始化自动刷新服务") await autoRefreshService.initialize() }) // 插件安装时初始化自动刷新服务 chrome.runtime.onInstalled.addListener(async () => { console.log("[Background] 插件安装/更新,初始化自动刷新服务") await autoRefreshService.initialize() }) // 处理来自 popup 的消息 chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { if (request.action === "openTempWindow") { handleOpenTempWindow(request, sendResponse) return true // 保持异步响应通道 } if (request.action === "closeTempWindow") { handleCloseTempWindow(request, sendResponse) return true } if (request.action === "autoDetectSite") { handleAutoDetectSite(request, sendResponse) return true } // 处理自动刷新相关消息 if ( (request.action && request.action.startsWith("autoRefresh")) || [ "setupAutoRefresh", "refreshNow", "stopAutoRefresh", "updateAutoRefreshSettings", "getAutoRefreshStatus" ].includes(request.action) ) { handleAutoRefreshMessage(request, sendResponse) return true } }) // 打开临时窗口访问指定站点 async function handleOpenTempWindow(request: any, sendResponse: Function) { try { const { url, requestId } = request // 创建新窗口 const window = await chrome.windows.create({ url: url, type: "popup", width: 800, height: 600, focused: false }) if (window.id) { // 记录窗口ID tempWindows.set(requestId, window.id) sendResponse({ success: true, windowId: window.id }) } else { sendResponse({ success: false, error: "无法创建窗口" }) } } catch (error) { sendResponse({ success: false, error: error.message }) } } // 关闭临时窗口 async function handleCloseTempWindow(request: any, sendResponse: Function) { try { const { requestId } = request const windowId = tempWindows.get(requestId) if (windowId) { await chrome.windows.remove(windowId) tempWindows.delete(requestId) } sendResponse({ success: true }) } catch (error) { sendResponse({ success: false, error: error.message }) } } // 自动检测站点信息 async function handleAutoDetectSite(request: any, sendResponse: Function) { const { url, requestId } = request try { // 1. 打开临时窗口 const window = await chrome.windows.create({ url: url, type: "popup", width: 800, height: 600, focused: false }) if (!window.id || !window.tabs?.[0]?.id) { throw new Error("无法创建窗口或获取标签页") } const windowId = window.id const tabId = window.tabs[0].id // 记录窗口 tempWindows.set(requestId, windowId) // 2. 等待页面加载完成 await waitForTabComplete(tabId) // 3. 通过 content script 获取用户信息 const userResponse = await chrome.tabs.sendMessage(tabId, { action: "getUserFromLocalStorage", url: url }) console.log(userResponse.error) if (!userResponse.success) { throw new Error(userResponse.error) } // 4. 关闭临时窗口 await chrome.windows.remove(windowId) tempWindows.delete(requestId) // 5. 返回结果 sendResponse({ success: true, data: { userId: userResponse.data.userId, user: userResponse.data.user } }) } catch (error) { // 清理窗口 const windowId = tempWindows.get(requestId) if (windowId) { try { await chrome.windows.remove(windowId) tempWindows.delete(requestId) } catch (cleanupError) { console.log("清理窗口失败:", cleanupError) } } sendResponse({ success: false, error: error.message }) } } // 等待标签页加载完成 function waitForTabComplete(tabId: number): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("页面加载超时")) }, 10000) // 10秒超时 const checkStatus = () => { chrome.tabs.get(tabId, (tab) => { if (chrome.runtime.lastError) { clearTimeout(timeout) reject(new Error(chrome.runtime.lastError.message)) return } if (tab.status === "complete") { clearTimeout(timeout) // 再等待一秒确保页面完全加载 setTimeout(resolve, 1000) } else { setTimeout(checkStatus, 100) } }) } checkStatus() }) } // 监听窗口关闭事件,清理记录 chrome.windows.onRemoved.addListener((windowId) => { for (const [requestId, storedWindowId] of tempWindows.entries()) { if (storedWindowId === windowId) { tempWindows.delete(requestId) break } } }) ================================================ FILE: components/AccountList.tsx ================================================ import { ChevronUpIcon, ChevronDownIcon, ChartBarIcon, CpuChipIcon, EllipsisHorizontalIcon, DocumentDuplicateIcon, ChartPieIcon, PencilIcon, TrashIcon, ArrowPathIcon, InboxIcon, KeyIcon } from "@heroicons/react/24/outline" import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react' import CountUp from "react-countup" import { UI_CONSTANTS, HEALTH_STATUS_MAP } from "../constants/ui" import { getCurrencySymbol } from "../utils/formatters" import type { DisplaySiteData } from "../types" import { useState, useCallback, useRef, useEffect } from 'react' import Tooltip from './Tooltip' import DelAccountDialog from './DelAccountDialog' import CopyKeyDialog from './CopyKeyDialog' type SortField = 'name' | 'balance' | 'consumption' type SortOrder = 'asc' | 'desc' interface AccountListProps { // 数据 sites: DisplaySiteData[] currencyType: 'USD' | 'CNY' // 排序状态 sortField: SortField sortOrder: SortOrder // 动画相关 isInitialLoad: boolean prevBalances: { [id: string]: { USD: number, CNY: number } } // 刷新状态 refreshingAccountId?: string | null // 事件处理 onSort: (field: SortField) => void onAddAccount: () => void onRefreshAccount?: (site: DisplaySiteData) => Promise onCopyUrl?: (site: DisplaySiteData) => void onViewUsage?: (site: DisplaySiteData) => void onViewModels?: (site: DisplaySiteData) => void onEditAccount?: (site: DisplaySiteData) => void onDeleteAccount?: (site: DisplaySiteData) => void onViewKeys?: (site: DisplaySiteData) => void } export default function AccountList({ sites, currencyType, sortField, sortOrder, isInitialLoad, prevBalances, refreshingAccountId, onSort, onAddAccount, onRefreshAccount, onCopyUrl, onViewUsage, onViewModels, onEditAccount, onDeleteAccount, onViewKeys }: AccountListProps) { const [hoveredSiteId, setHoveredSiteId] = useState(null) const hoverTimeoutRef = useRef(null) const [deleteDialogAccount, setDeleteDialogAccount] = useState(null) const [copyKeyDialogAccount, setCopyKeyDialogAccount] = useState(null) // 防抖的 hover 处理 const handleMouseEnter = useCallback((siteId: string) => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current) } hoverTimeoutRef.current = setTimeout(() => { setHoveredSiteId(siteId) }, 50) // 100ms 防抖延迟 }, []) const handleMouseLeave = useCallback(() => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current) } hoverTimeoutRef.current = setTimeout(() => { setHoveredSiteId(null) }, 0) // 不需要离开时的延迟 }, []) // 清理定时器 useEffect(() => { return () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current) } } }, []) const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text) } catch (err) { console.error('Failed to copy:', err) } } const handleCopyUrl = (site: DisplaySiteData) => { copyToClipboard(site.baseUrl) onCopyUrl?.(site) } const handleCopyKey = (site: DisplaySiteData) => { setCopyKeyDialogAccount(site) } const handleRefreshAccount = async (site: DisplaySiteData) => { if (onRefreshAccount) { try { await onRefreshAccount(site) } catch (error) { console.error('刷新账号失败:', error) } } } if (sites.length === 0) { return (

暂无站点账号

) } const renderSortButton = (field: SortField, label: string) => ( ) return (
{/* 表头 */}
{renderSortButton('name', '账号')}
{renderSortButton('balance', '余额')} / {renderSortButton('consumption', '今日消耗')}
{/* 账号列表 */} {sites.map((site) => (
handleMouseEnter(site.id)} onMouseLeave={handleMouseLeave} >
{/* 站点信息 */}
{/* 站点状态指示器 */}
{site.username}
{/* 按钮组 - 只在 hover 时显示 */} {hoveredSiteId === site.id && (
{/* 刷新按钮 */} {/* 复制下拉菜单 */}
{/* 更多下拉菜单 */}
)} {/* 余额和统计 */}
{getCurrencySymbol(currencyType)}
0 ? 'text-green-500' : 'text-gray-400'}`}> -{getCurrencySymbol(currencyType)}
))} {/* 删除账号确认对话框 */} setDeleteDialogAccount(null)} account={deleteDialogAccount} onDeleted={() => onDeleteAccount?.(deleteDialogAccount!)} /> {/* 复制密钥对话框 */} setCopyKeyDialogAccount(null)} account={copyKeyDialogAccount} />
) } ================================================ FILE: components/ActionButtons.tsx ================================================ import { PlusIcon, KeyIcon, CpuChipIcon } from "@heroicons/react/24/outline" import { UI_CONSTANTS } from "../constants/ui" import Tooltip from "./Tooltip" interface ActionButtonsProps { onAddAccount: () => void onViewKeys: () => void onViewModels: () => void } export default function ActionButtons({ onAddAccount, onViewKeys, onViewModels }: ActionButtonsProps) { return (
) } ================================================ FILE: components/AddAccountDialog.tsx ================================================ import { useState, useEffect, Fragment } from "react" import toast from 'react-hot-toast' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react" import { GlobeAltIcon, XMarkIcon, SparklesIcon, UserIcon, KeyIcon, EyeIcon, EyeSlashIcon, CurrencyDollarIcon } from "@heroicons/react/24/outline" import { autoDetectAccount, validateAndSaveAccount, extractDomainPrefix, isValidExchangeRate } from "../services/accountOperations" import AutoDetectErrorAlert from "./AutoDetectErrorAlert" import type { AutoDetectError } from "../utils/autoDetectUtils" interface AddAccountDialogProps { isOpen: boolean onClose: () => void } export default function AddAccountDialog({ isOpen, onClose }: AddAccountDialogProps) { const [url, setUrl] = useState("") const [isDetecting, setIsDetecting] = useState(false) const [siteName, setSiteName] = useState("") const [username, setUsername] = useState("") const [accessToken, setAccessToken] = useState("") const [userId, setUserId] = useState("") const [isDetected, setIsDetected] = useState(false) const [isSaving, setIsSaving] = useState(false) const [showAccessToken, setShowAccessToken] = useState(false) const [detectionError, setDetectionError] = useState(null) const [showManualForm, setShowManualForm] = useState(false) const [exchangeRate, setExchangeRate] = useState("") const [currentTabUrl, setCurrentTabUrl] = useState(null) useEffect(() => { if (isOpen) { // 重置状态 setIsDetected(false) setSiteName("") setUsername("") setAccessToken("") setUserId("") setShowAccessToken(false) setDetectionError(null) setShowManualForm(false) setExchangeRate("") setCurrentTabUrl(null) setUrl("") // 获取当前标签页的 URL 作为初始参考 chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (tabs[0]?.url) { try { const urlObj = new URL(tabs[0].url) const baseUrl = `${urlObj.protocol}//${urlObj.host}` // 如果站点不是以http开头,则不处理(可能为空白页) if (!baseUrl.startsWith('http')) { return } setCurrentTabUrl(baseUrl) // 设置站点名称为域名前缀,但不自动填入URL const domainPrefix = extractDomainPrefix(urlObj.hostname) setSiteName(domainPrefix) } catch (error) { console.log('无法解析 URL:', error) setCurrentTabUrl(null) setSiteName("") } } }) } }, [isOpen]) // 处理点击当前标签页 URL const handleUseCurrentTabUrl = () => { if (currentTabUrl) { setUrl(currentTabUrl) } } const handleAutoDetect = async () => { if (!url.trim()) { return } setIsDetecting(true) setDetectionError(null) try { const result = await autoDetectAccount(url.trim()) if (!result.success) { setDetectionError(result.detailedError || null) setShowManualForm(true) return } if (result.data) { // 更新表单数据 setUsername(result.data.username) setAccessToken(result.data.accessToken) setUserId(result.data.userId) // 设置充值比例默认值 if (result.data.exchangeRate) { setExchangeRate(result.data.exchangeRate.toString()) console.log('获取到默认充值比例:', result.data.exchangeRate) } else { setExchangeRate("") // 如果没有获取到,设置为空 console.log('未获取到默认充值比例,设置为空') } setIsDetected(true) console.log('自动识别成功:', { username: result.data.username, siteName, exchangeRate: result.data.exchangeRate }) } } catch (error) { console.error('自动识别失败:', error) const errorMessage = error instanceof Error ? error.message : '未知错误' // 使用通用错误处理 setDetectionError({ type: 'unknown' as any, message: `自动识别失败: ${errorMessage}`, helpDocUrl: '#' }) setShowManualForm(true) // 识别失败后显示手动表单 } finally { setIsDetecting(false) } } const handleSaveAccount = async () => { setIsSaving(true) try { await toast.promise( validateAndSaveAccount( url.trim(), siteName.trim(), username.trim(), accessToken.trim(), userId.trim(), exchangeRate ), { loading: '正在添加账号...', success: (result) => { if (result.success) { onClose() return `账号 ${siteName} 添加成功!` } else { throw new Error(result.error || '保存失败') } }, error: (err) => { const errorMsg = err.message || '添加失败' return `添加失败: ${errorMsg}` }, } ) } catch (error) { console.error('保存账号失败:', error) } finally { setIsSaving(false) } } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (isDetected || showManualForm) { handleSaveAccount() } else { handleAutoDetect() } } return ( {/* 背景遮罩动画 */} ) } ================================================ FILE: components/AddTokenDialog.tsx ================================================ import { useState, useEffect, Fragment } from 'react' import { Dialog, Transition, Switch } from '@headlessui/react' import { XMarkIcon, KeyIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { fetchAvailableModels, fetchUserGroups, createApiToken, fetchTokenById, updateApiToken, type GroupInfo, type CreateTokenRequest, type ApiToken } from '../services/apiService' import { UI_CONSTANTS } from '../constants/ui' import toast from 'react-hot-toast' interface AddTokenDialogProps { isOpen: boolean onClose: () => void availableAccounts: Array<{ id: string name: string baseUrl: string userId: number token: string }> preSelectedAccountId?: string | null editingToken?: ApiToken & { accountName: string } | null } interface FormData { accountId: string name: string quota: string expiredTime: string unlimitedQuota: boolean modelLimitsEnabled: boolean modelLimits: string[] allowIps: string group: string } export default function AddTokenDialog({ isOpen, onClose, availableAccounts, preSelectedAccountId, editingToken }: AddTokenDialogProps) { const [formData, setFormData] = useState({ accountId: '', name: '', quota: '', expiredTime: '', unlimitedQuota: true, modelLimitsEnabled: false, modelLimits: [], allowIps: '', group: 'default' }) const [isLoading, setIsLoading] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [availableModels, setAvailableModels] = useState([]) const [groups, setGroups] = useState>({}) const [errors, setErrors] = useState>({}) // 获取当前选中的账号 const currentAccount = availableAccounts.find(acc => acc.id === formData.accountId) // 判断是否为编辑模式 const isEditMode = !!editingToken // 初始化表单数据 useEffect(() => { if (isOpen) { if (isEditMode && editingToken) { // 编辑模式:从 editingToken 填充表单数据 const matchingAccount = availableAccounts.find(acc => acc.name === editingToken.accountName) const accountId = matchingAccount?.id || (availableAccounts.length > 0 ? availableAccounts[0].id : '') setFormData({ accountId, name: editingToken.name, quota: editingToken.unlimited_quota ? '' : (editingToken.remain_quota / UI_CONSTANTS.EXCHANGE_RATE.CONVERSION_FACTOR).toString(), expiredTime: editingToken.expired_time === -1 ? '' : new Date(editingToken.expired_time * 1000).toISOString().slice(0, 16), unlimitedQuota: editingToken.unlimited_quota, modelLimitsEnabled: editingToken.model_limits_enabled || false, modelLimits: editingToken.model_limits ? editingToken.model_limits.split(',') : [], allowIps: editingToken.allow_ips || '', group: editingToken.group || 'default' }) } else { // 创建模式:使用默认值 const defaultAccountId = preSelectedAccountId || (availableAccounts.length > 0 ? availableAccounts[0].id : '') setFormData({ accountId: defaultAccountId, name: '', quota: '', expiredTime: '', unlimitedQuota: true, modelLimitsEnabled: false, modelLimits: [], allowIps: '', group: 'default' }) } } }, [isOpen, preSelectedAccountId, availableAccounts, isEditMode, editingToken]) // 加载数据 useEffect(() => { if (isOpen && currentAccount) { loadInitialData() } }, [isOpen, currentAccount]) const loadInitialData = async () => { if (!currentAccount) return setIsLoading(true) try { const [models, groupsData] = await Promise.all([ fetchAvailableModels(currentAccount.baseUrl, currentAccount.userId, currentAccount.token), fetchUserGroups(currentAccount.baseUrl, currentAccount.userId, currentAccount.token) ]) setAvailableModels(models) setGroups(groupsData) // 设置默认分组 if (groupsData.default) { setFormData(prev => ({ ...prev, group: 'default' })) } else { const firstGroup = Object.keys(groupsData)[0] if (firstGroup) { setFormData(prev => ({ ...prev, group: firstGroup })) } } } catch (error) { console.error('加载初始数据失败:', error) toast.error('加载数据失败,请稍后重试') } finally { setIsLoading(false) } } // 验证表单 const validateForm = (): boolean => { const newErrors: Record = {} if (!formData.accountId) { newErrors.accountId = '请选择账号' } if (!formData.name.trim()) { newErrors.name = '密钥名称不能为空' } if (!formData.unlimitedQuota) { const quota = parseFloat(formData.quota) if (isNaN(quota) || quota <= 0) { newErrors.quota = '请输入有效的额度金额' } } if (formData.expiredTime) { const expiredDate = new Date(formData.expiredTime) if (expiredDate <= new Date()) { newErrors.expiredTime = '过期时间必须大于当前时间' } } if (formData.allowIps && !isValidIpList(formData.allowIps)) { newErrors.allowIps = '请输入有效的IP地址,多个IP用逗号分隔' } setErrors(newErrors) return Object.keys(newErrors).length === 0 } // 验证IP地址列表 const isValidIpList = (ips: string): boolean => { const ipList = ips.split(',').map(ip => ip.trim()) const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/ return ipList.every(ip => { if (!ip) return false if (ip === '*') return true // 允许通配符 return ipRegex.test(ip) && ip.split('.').every(part => { const num = parseInt(part) return num >= 0 && num <= 255 }) }) } // 处理表单提交 const handleSubmit = async () => { if (!currentAccount || !validateForm()) return setIsSubmitting(true) try { // 准备请求数据 const tokenData: CreateTokenRequest = { name: formData.name.trim(), remain_quota: formData.unlimitedQuota ? -1 : Math.floor(parseFloat(formData.quota) * UI_CONSTANTS.EXCHANGE_RATE.CONVERSION_FACTOR), expired_time: formData.expiredTime ? Math.floor(new Date(formData.expiredTime).getTime() / 1000) : -1, unlimited_quota: formData.unlimitedQuota, model_limits_enabled: formData.modelLimitsEnabled, model_limits: formData.modelLimits.join(','), allow_ips: formData.allowIps.trim() || '', group: formData.group } if (isEditMode && editingToken) { // 编辑模式 await updateApiToken(currentAccount.baseUrl, currentAccount.userId, currentAccount.token, editingToken.id, tokenData) toast.success('密钥更新成功') } else { // 创建模式 await createApiToken(currentAccount.baseUrl, currentAccount.userId, currentAccount.token, tokenData) toast.success('密钥创建成功') } handleClose() } catch (error) { console.error(`${isEditMode ? '更新' : '创建'}密钥失败:`, error) toast.error(`${isEditMode ? '更新' : '创建'}密钥失败,请稍后重试`) } finally { setIsSubmitting(false) } } // 关闭对话框 const handleClose = () => { setFormData({ accountId: '', name: '', quota: '', expiredTime: '', unlimitedQuota: true, modelLimitsEnabled: false, modelLimits: [], allowIps: '', group: 'default' }) setErrors({}) setAvailableModels([]) setGroups({}) onClose() } return (
{/* 标题栏 */}
{isEditMode ? '编辑API密钥' : '添加API密钥'}
{isLoading ? (
) : (
{/* 基本信息 */}

基本信息

{/* 账号选择 */}
{errors.accountId && (

{errors.accountId}

)} {isEditMode && (

编辑模式下无法更改账号

)}
{/* 密钥名称 */}
setFormData(prev => ({ ...prev, name: e.target.value }))} className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ errors.name ? 'border-red-300' : 'border-gray-300' }`} placeholder="请输入密钥名称" /> {errors.name && (

{errors.name}

)}
{/* 额度设置 */}
无限额度 setFormData(prev => ({ ...prev, unlimitedQuota: checked }))} className={`${ formData.unlimitedQuota ? 'bg-blue-600' : 'bg-gray-200' } relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`} >
{!formData.unlimitedQuota && (
setFormData(prev => ({ ...prev, quota: e.target.value }))} className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ errors.quota ? 'border-red-300' : 'border-gray-300' }`} placeholder="请输入额度金额(美元)" /> {errors.quota && (

{errors.quota}

)}

1美元 = {UI_CONSTANTS.EXCHANGE_RATE.CONVERSION_FACTOR.toLocaleString()} 配额点数

)}
{/* 过期时间 */}
setFormData(prev => ({ ...prev, expiredTime: e.target.value }))} className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ errors.expiredTime ? 'border-red-300' : 'border-gray-300' }`} /> {errors.expiredTime && (

{errors.expiredTime}

)}

留空表示永不过期

{/* 高级设置 */}

高级设置

{/* 分组选择 */}
{/* 模型限制 */}
setFormData(prev => ({ ...prev, modelLimitsEnabled: enabled, modelLimits: enabled ? prev.modelLimits : [] }))} className={`${ formData.modelLimitsEnabled ? 'bg-blue-600' : 'bg-gray-200' } relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`} >
{formData.modelLimitsEnabled && (

按住 Ctrl/Cmd 键可多选模型,已选择 {formData.modelLimits.length} 个模型

)}
{/* IP 限制 */}
setFormData(prev => ({ ...prev, allowIps: e.target.value }))} className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ errors.allowIps ? 'border-red-300' : 'border-gray-300' }`} placeholder="留空表示不限制,多个IP用逗号分隔" /> {errors.allowIps && (

{errors.allowIps}

)}

例如: 192.168.1.1,10.0.0.1 或使用 * 表示不限制

{/* 警告提示 */}

注意事项

  • • 请妥善保管API密钥,避免泄露
{/* 操作按钮 */}
)}
) } ================================================ FILE: components/AutoDetectErrorAlert.tsx ================================================ import { Fragment } from 'react' import { ExclamationTriangleIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline' import type { AutoDetectError, AutoDetectErrorProps } from '../utils/autoDetectUtils' import { openLoginTab } from '../utils/autoDetectUtils' export default function AutoDetectErrorAlert({ error, siteUrl, onHelpClick, onActionClick }: AutoDetectErrorProps) { const handleActionClick = () => { if (onActionClick) { onActionClick() } else if (error.type === 'unauthorized' && siteUrl) { // 默认行为:打开登录页面 openLoginTab(siteUrl) } } const handleHelpClick = () => { if (onHelpClick) { onHelpClick() } else if (error.helpDocUrl) { // 默认行为:打开帮助文档 chrome.tabs.create({ url: error.helpDocUrl }) } } return (

{error.message}

{/* 操作按钮区域 */} {(error.actionText || error.helpDocUrl) && (
{/* 主要操作按钮 */} {error.actionText && ( )} {/* 帮助文档按钮 */} {error.helpDocUrl && ( )}
)}
) } ================================================ FILE: components/BalanceSection.tsx ================================================ import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react" import { ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/outline" import CountUp from "react-countup" import { UI_CONSTANTS } from "../constants/ui" import { getCurrencySymbol, formatTokenCount } from "../utils/formatters" import { useTimeFormatter } from "../hooks/useTimeFormatter" import Tooltip from "./Tooltip" interface BalanceSectionProps { // 金额数据 totalConsumption: { USD: number; CNY: number } totalBalance: { USD: number; CNY: number } todayTokens: { upload: number; download: number } // 状态 currencyType: 'USD' | 'CNY' activeTab: 'consumption' | 'balance' isInitialLoad: boolean lastUpdateTime: Date // 动画相关 prevTotalConsumption: { USD: number; CNY: number } // 事件处理 onCurrencyToggle: () => void onTabChange: (index: number) => void } export default function BalanceSection({ totalConsumption, totalBalance, todayTokens, currencyType, activeTab, isInitialLoad, lastUpdateTime, prevTotalConsumption, onCurrencyToggle, onTabChange }: BalanceSectionProps) { const { formatRelativeTime, formatFullTime } = useTimeFormatter() return (
{/* 金额标签页 */}
`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${ selected ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700' }` }> 今日消耗 `px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${ selected ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700' }` }> 总余额
{/* 今日消耗面板 */}
{/* 总余额面板 */}
{/* Token 统计信息 */}
提示: {todayTokens.upload.toLocaleString()} tokens
补全: {todayTokens.download.toLocaleString()} tokens
} >
{formatTokenCount(todayTokens.upload)}
{formatTokenCount(todayTokens.download)}
{/* 最后更新时间 */}

更新于 {formatRelativeTime(lastUpdateTime)}

) } ================================================ FILE: components/CopyKeyDialog.tsx ================================================ import { Fragment, useState, useEffect } from "react" import toast from 'react-hot-toast' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react" import { XMarkIcon, KeyIcon, DocumentDuplicateIcon, ExclamationTriangleIcon, CheckIcon, ClockIcon, UserGroupIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline" import { UI_CONSTANTS } from "../constants/ui" import { fetchAccountTokens, type ApiToken } from "../services/apiService" import type { DisplaySiteData } from "../types" interface CopyKeyDialogProps { isOpen: boolean onClose: () => void account: DisplaySiteData | null } export default function CopyKeyDialog({ isOpen, onClose, account }: CopyKeyDialogProps) { const [tokens, setTokens] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [copiedKey, setCopiedKey] = useState(null) const [expandedTokens, setExpandedTokens] = useState>(new Set()) // 获取密钥列表 const fetchTokens = async () => { if (!account) return setIsLoading(true) setError(null) try { // 使用 DisplaySiteData 中的 userId 字段 const tokensResponse = await fetchAccountTokens(account.baseUrl, account.userId, account.token) // 确保返回的是数组 if (Array.isArray(tokensResponse)) { setTokens(tokensResponse) } else { console.warn('Token response is not an array:', tokensResponse) setTokens([]) } } catch (error) { console.error('获取密钥列表失败:', error) const errorMessage = error instanceof Error ? error.message : '未知错误' setError(`获取密钥列表失败: ${errorMessage}`) } finally { setIsLoading(false) } } // 当对话框打开时获取密钥列表 useEffect(() => { if (isOpen && account) { fetchTokens() } else { // 关闭时重置状态 setTokens([]) setError(null) setCopiedKey(null) setExpandedTokens(new Set()) } }, [isOpen, account]) // 复制密钥到剪贴板 const copyKey = async (key: string) => { try { // 检查key是否以"sk-"开头,如果不是则添加前缀 const textToCopy = key.startsWith('sk-') ? key : 'sk-' + key; await navigator.clipboard.writeText(textToCopy); setCopiedKey(key); toast.success('密钥已复制到剪贴板'); // 2秒后清除复制状态 setTimeout(() => { setCopiedKey(null); }, 2000); } catch (error) { console.error('复制失败:', error); toast.error('复制失败,请手动复制'); } }; // 切换密钥展开/折叠状态 const toggleTokenExpansion = (tokenId: number) => { setExpandedTokens(prev => { const newSet = new Set(prev) if (newSet.has(tokenId)) { newSet.delete(tokenId) } else { newSet.add(tokenId) } return newSet }) } // 格式化额度显示 const formatQuota = (token: ApiToken) => { if (token.unlimited_quota || token.remain_quota < 0) { return '无限额度' } // 使用CONVERSION_FACTOR转换真实额度 const realQuota = token.remain_quota / UI_CONSTANTS.EXCHANGE_RATE.CONVERSION_FACTOR return `$${realQuota.toFixed(2)}` } // 格式化已用额度 const formatUsedQuota = (token: ApiToken) => { const realUsedQuota = token.used_quota / UI_CONSTANTS.EXCHANGE_RATE.CONVERSION_FACTOR return `$${realUsedQuota.toFixed(2)}` } // 格式化时间 const formatTime = (timestamp: number) => { if (timestamp <= 0) return '永不过期' return new Date(timestamp * 1000).toLocaleDateString('zh-CN') } // 获取组别徽章样式 const getGroupBadgeStyle = (group: string) => { // 处理可能为空或未定义的 group const groupName = group || 'default' // 根据组别名称生成不同的颜色主题 const hash = groupName.split('').reduce((a, b) => { a = ((a << 5) - a) + b.charCodeAt(0) return a & a }, 0) const colors = [ 'bg-blue-100 text-blue-800 border-blue-200', 'bg-green-100 text-green-800 border-green-200', 'bg-purple-100 text-purple-800 border-purple-200', 'bg-orange-100 text-orange-800 border-orange-200', 'bg-pink-100 text-pink-800 border-pink-200', 'bg-indigo-100 text-indigo-800 border-indigo-200', 'bg-teal-100 text-teal-800 border-teal-200', 'bg-yellow-100 text-yellow-800 border-yellow-200' ] return colors[Math.abs(hash) % colors.length] } // 获取状态徽章样式 const getStatusBadgeStyle = (status: number) => { return status === 1 ? 'bg-green-100 text-green-800 border-green-200' : 'bg-red-100 text-red-800 border-red-200' } return ( {/* 背景遮罩动画 */} ) } ================================================ FILE: components/DelAccountDialog.tsx ================================================ import { Fragment } from "react" import toast from 'react-hot-toast' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react" import { ExclamationTriangleIcon, XMarkIcon, TrashIcon } from "@heroicons/react/24/outline" import { accountStorage } from "../services/accountStorage" import type { DisplaySiteData } from "../types" interface DelAccountDialogProps { isOpen: boolean onClose: () => void account: DisplaySiteData | null onDeleted: () => void } export default function DelAccountDialog({ isOpen, onClose, account, onDeleted }: DelAccountDialogProps) { const handleDelete = async () => { if (!account) { return } try { console.log('准备删除账号:', { id: account.id, name: account.name }) await toast.promise( accountStorage.deleteAccount(account.id), { loading: `正在删除账号 ${account.name}...`, success: (success) => { if (success) { onDeleted() onClose() return `账号 ${account.name} 删除成功!` } else { throw new Error('删除失败') } }, error: (err) => { const errorMsg = err instanceof Error ? err.message : '未知错误' return `删除失败: ${errorMsg}` }, } ) } catch (error) { console.error('删除账号失败:', error) } } return ( {/* 背景遮罩动画 */} ) } ================================================ FILE: components/EditAccountDialog.tsx ================================================ import { useState, useEffect, Fragment } from "react" import toast from 'react-hot-toast' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react" import { GlobeAltIcon, XMarkIcon, PencilIcon, UserIcon, KeyIcon, EyeIcon, EyeSlashIcon, CurrencyDollarIcon, SparklesIcon, CheckIcon, UsersIcon } from "@heroicons/react/24/outline" import { accountStorage } from "../services/accountStorage" import { autoDetectAccount, validateAndUpdateAccount, extractDomainPrefix, isValidExchangeRate } from "../services/accountOperations" import AutoDetectErrorAlert from "./AutoDetectErrorAlert" import type { AutoDetectError } from "../utils/autoDetectUtils" import type { DisplaySiteData } from "../types" interface EditAccountDialogProps { isOpen: boolean onClose: () => void account: DisplaySiteData | null } export default function EditAccountDialog({ isOpen, onClose, account }: EditAccountDialogProps) { const [url, setUrl] = useState("") const [isDetecting, setIsDetecting] = useState(false) const [siteName, setSiteName] = useState("") const [username, setUsername] = useState("") const [accessToken, setAccessToken] = useState("") const [userId, setUserId] = useState("") const [isDetected, setIsDetected] = useState(false) const [isSaving, setIsSaving] = useState(false) const [showAccessToken, setShowAccessToken] = useState(false) const [detectionError, setDetectionError] = useState(null) const [showManualForm, setShowManualForm] = useState(true) // 编辑模式默认显示表单 const [exchangeRate, setExchangeRate] = useState("") // 重置表单数据 const resetForm = () => { setUrl("") setIsDetected(false) setSiteName("") setUsername("") setAccessToken("") setUserId("") setShowAccessToken(false) setDetectionError(null) setShowManualForm(true) setExchangeRate("") } // 加载账号数据到表单 const loadAccountData = async (accountId: string) => { try { const siteAccount = await accountStorage.getAccountById(accountId) if (siteAccount) { setUrl(siteAccount.site_url) setSiteName(siteAccount.site_name) setUsername(siteAccount.account_info.username) setAccessToken(siteAccount.account_info.access_token) setUserId(siteAccount.account_info.id.toString()) setExchangeRate(siteAccount.exchange_rate.toString()) } } catch (error) { console.error('加载账号数据失败:', error) } } useEffect(() => { if (isOpen && account) { resetForm() loadAccountData(account.id) } else if (!isOpen) { resetForm() } }, [isOpen, account]) const handleAutoDetect = async () => { if (!url.trim()) { return } setIsDetecting(true) setDetectionError(null) try { const result = await autoDetectAccount(url.trim()) if (!result.success) { setDetectionError(result.detailedError || null) return } if (result.data) { // 更新表单数据 setUsername(result.data.username) setAccessToken(result.data.accessToken) setUserId(result.data.userId) // 设置充值比例默认值 if (result.data.exchangeRate) { setExchangeRate(result.data.exchangeRate.toString()) console.log('获取到默认充值比例:', result.data.exchangeRate) } else { console.log('未获取到默认充值比例,保持当前值') } setIsDetected(true) console.log('自动识别成功:', { username: result.data.username, siteName, exchangeRate: result.data.exchangeRate }) } } catch (error) { console.error('自动识别失败:', error) const errorMessage = error instanceof Error ? error.message : '未知错误' // 使用通用错误处理 setDetectionError({ type: 'unknown' as any, message: `自动识别失败: ${errorMessage}`, helpDocUrl: '#' }) } finally { setIsDetecting(false) } } const handleSaveAccount = async () => { if (!account) { toast.error('账号信息错误') return } setIsSaving(true) try { await toast.promise( validateAndUpdateAccount( account.id, url.trim(), siteName.trim(), username.trim(), accessToken.trim(), userId.trim(), exchangeRate ), { loading: '正在保存更改...', success: (result) => { if (result.success) { onClose() return `账号 ${siteName} 更新成功!` } else { throw new Error(result.error || '更新失败') } }, error: (err) => { const errorMsg = err.message || '更新失败' return `更新失败: ${errorMsg}` }, } ) } catch (error) { console.error('更新账号失败:', error) } finally { setIsSaving(false) } } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (isDetected || showManualForm) { handleSaveAccount() } else { handleAutoDetect() } } return ( {/* 背景遮罩动画 */} ) } ================================================ FILE: components/HeaderSection.tsx ================================================ import { ArrowsPointingOutIcon, Cog6ToothIcon, ArrowPathIcon } from "@heroicons/react/24/outline" import { UI_CONSTANTS } from "../constants/ui" import Tooltip from "./Tooltip" import iconImage from "../assets/icon.png" interface HeaderSectionProps { isRefreshing: boolean onRefresh: () => void onOpenTab: () => void onOpenSettings: () => void } export default function HeaderSection({ isRefreshing, onRefresh, onOpenTab, onOpenSettings }: HeaderSectionProps) { return (
One API Hub
One API Hub 一键管理所有AI中转站
) } ================================================ FILE: components/ModelItem.tsx ================================================ /** * 模型列表项组件 */ import React, { useState } from 'react' import { DocumentDuplicateIcon, ChevronDownIcon, ChevronUpIcon, TagIcon, CurrencyDollarIcon, ServerIcon } from '@heroicons/react/24/outline' import toast from 'react-hot-toast' import type { ModelPricing } from '../services/apiService' import type { CalculatedPrice } from '../utils/modelPricing' import { getProviderConfig, type ProviderType } from '../utils/modelProviders' import { formatPrice, formatPriceCompact, getBillingModeText, getBillingModeStyle, getEndpointTypesText } from '../utils/modelPricing' interface ModelItemProps { model: ModelPricing calculatedPrice: CalculatedPrice exchangeRate: number showRealPrice: boolean // 是否以真实充值金额展示 showRatioColumn: boolean // 是否显示倍率列 showEndpointTypes: boolean // 是否显示可用端点类型 userGroup: string onGroupClick?: (group: string) => void // 新增:点击分组时的回调函数 availableGroups?: string[] // 新增:用户的所有可用分组列表 isAllGroupsMode?: boolean // 新增:是否为"所有分组"模式 } export default function ModelItem({ model, calculatedPrice, exchangeRate, showRealPrice, showRatioColumn, showEndpointTypes, userGroup, onGroupClick, availableGroups = [], isAllGroupsMode = false }: ModelItemProps) { const [isExpanded, setIsExpanded] = useState(false) // 获取厂商配置 const providerConfig = getProviderConfig(model.model_name) const IconComponent = providerConfig.icon // 获取计费模式样式 const billingStyle = getBillingModeStyle(model.quota_type) // 检查模型是否对当前用户分组可用 const isAvailableForUser = isAllGroupsMode ? availableGroups.some(group => model.enable_groups.includes(group)) // 所有分组模式:任何一个用户分组可用即可 : model.enable_groups.includes(userGroup) // 特定分组模式:必须该分组可用 // 复制模型名称 const handleCopyModelName = async () => { try { await navigator.clipboard.writeText(model.model_name) toast.success('模型名称已复制') } catch (error) { toast.error('复制失败') } } return (
{/* 主要信息行 */}
{/* 左侧:模型名称和基本信息 */}
{/* 厂商图标 */}
{/* 模型名称 */}

{model.model_name}

{/* 复制按钮 */}
{/* 计费模式标签 */} {getBillingModeText(model.quota_type)} {/* 可用状态标签 */} {isAvailableForUser ? '可用' : '不可用'}
{/* 模型描述 */} {model.model_description && (

{model.model_description}

)} {/* 价格信息 */}
{model.quota_type === 0 ? ( // 按量计费 - 横向并排显示价格
{/* 输入价格 */}
输入: {showRealPrice ? `${formatPriceCompact(calculatedPrice.inputCNY, 'CNY')}/M` : `${formatPriceCompact(calculatedPrice.inputUSD, 'USD')}/M` }
{/* 输出价格 */}
输出: {showRealPrice ? `${formatPriceCompact(calculatedPrice.outputCNY, 'CNY')}/M` : `${formatPriceCompact(calculatedPrice.outputUSD, 'USD')}/M` }
{/* 倍率显示 */} {showRatioColumn && (
倍率: {model.model_ratio}x
)}
) : ( // 按次计费
每次调用: {showRealPrice ? formatPriceCompact((calculatedPrice.perCallPrice || 0) * exchangeRate, 'CNY') : formatPriceCompact(calculatedPrice.perCallPrice || 0, 'USD') }
)}
{/* 右侧:展开/收起按钮 */}
{/* 展开的详细信息 */} {isExpanded && (
{/* 可用分组 */}
可用分组
{model.enable_groups.map((group, index) => { const isCurrentGroup = group === userGroup const isClickable = onGroupClick && !isCurrentGroup return ( onGroupClick(group) : undefined} className={`inline-flex items-center px-2 py-1 rounded text-xs cursor-pointer transition-colors ${ isCurrentGroup ? 'bg-blue-100 text-blue-800 font-medium' : isClickable ? 'bg-gray-100 text-gray-600 hover:bg-blue-50 hover:text-blue-700' : 'bg-gray-100 text-gray-600' }`} title={isClickable ? `点击切换到 ${group} 分组` : undefined} > {isCurrentGroup && } {group} ) })}
{/* 可用端点类型 */} {showEndpointTypes && (
端点类型
{getEndpointTypesText(model.supported_endpoint_types)}
)} {/* 详细定价信息(仅按量计费模型) */} {model.quota_type === 0 && (
详细定价
输入(1M tokens)
USD: {formatPrice(calculatedPrice.inputUSD, 'USD')}
CNY: {formatPrice(calculatedPrice.inputCNY, 'CNY')}
输出(1M tokens)
USD: {formatPrice(calculatedPrice.outputUSD, 'USD')}
CNY: {formatPrice(calculatedPrice.outputCNY, 'CNY')}
)}
)}
) } ================================================ FILE: components/Tooltip.tsx ================================================ import { useState, useRef, useEffect } from "react" import type { ReactNode } from "react" interface TooltipProps { content: ReactNode children: ReactNode position?: 'top' | 'bottom' | 'left' | 'right' | 'auto' className?: string delay?: number } export default function Tooltip({ content, children, position = 'auto', className = '', delay = 0 }: TooltipProps) { const [showTooltip, setShowTooltip] = useState(false) const [actualPosition, setActualPosition] = useState<'top' | 'bottom' | 'left' | 'right'>('top') const containerRef = useRef(null) const tooltipRef = useRef(null) const handleMouseEnter = () => { if (delay > 0) { setTimeout(() => setShowTooltip(true), delay) } else { setShowTooltip(true) } } const handleMouseLeave = () => { setShowTooltip(false) } // 检测最佳位置 useEffect(() => { if (showTooltip && position === 'auto' && containerRef.current && tooltipRef.current) { const container = containerRef.current const tooltip = tooltipRef.current const containerRect = container.getBoundingClientRect() const tooltipRect = tooltip.getBoundingClientRect() // 插件窗口的边界(假设宽度为384px,高度为600px) const windowWidth = 384 const windowHeight = 600 let bestPosition: 'top' | 'bottom' | 'left' | 'right' = 'top' // 检查是否有足够空间显示在上方 if (containerRect.top > tooltipRect.height + 10) { bestPosition = 'top' } // 检查是否有足够空间显示在下方 else if (containerRect.bottom + tooltipRect.height + 10 < windowHeight) { bestPosition = 'bottom' } // 检查是否有足够空间显示在左侧 else if (containerRect.left > tooltipRect.width + 10) { bestPosition = 'left' } // 检查是否有足够空间显示在右侧 else if (containerRect.right + tooltipRect.width + 10 < windowWidth) { bestPosition = 'right' } // 默认显示在上方,即使空间不足 else { bestPosition = 'top' } setActualPosition(bestPosition) } else if (position !== 'auto') { setActualPosition(position) } }, [showTooltip, position]) const getPositionClasses = () => { switch (actualPosition) { case 'top': return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2' case 'bottom': return 'top-full left-1/2 transform -translate-x-1/2 mt-2' case 'left': return 'right-full top-1/2 transform -translate-y-1/2 mr-2' case 'right': return 'left-full top-1/2 transform -translate-y-1/2 ml-2' default: return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2' } } const getArrowClasses = () => { switch (actualPosition) { case 'top': return 'absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-2 border-r-2 border-t-4 border-transparent border-t-gray-900' case 'bottom': return 'absolute bottom-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-2 border-r-2 border-b-4 border-transparent border-b-gray-900' case 'left': return 'absolute left-full top-1/2 transform -translate-y-1/2 w-0 h-0 border-t-2 border-b-2 border-l-4 border-transparent border-l-gray-900' case 'right': return 'absolute right-full top-1/2 transform -translate-y-1/2 w-0 h-0 border-t-2 border-b-2 border-r-4 border-transparent border-r-gray-900' default: return 'absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-2 border-r-2 border-t-4 border-transparent border-t-gray-900' } } return (
{children}
{content}
) } ================================================ FILE: constants/ui.ts ================================================ /** * UI 相关常量定义 */ export const UI_CONSTANTS = { // 弹窗尺寸 POPUP: { WIDTH: 'w-96', HEIGHT: 'h-[600px]', MAX_HEIGHT: 'max-h-[90vh]' }, // 动画配置 ANIMATION: { INITIAL_DURATION: 1.5, UPDATE_DURATION: 0.8, FAST_DURATION: 0.6, SLOW_DURATION: 1.0 }, // 更新间隔 UPDATE_INTERVAL: 30000, // 30秒 // 排序相关 SORT: { DEFAULT_FIELD: 'balance' as const, DEFAULT_ORDER: 'desc' as const }, // Token 格式化阈值 TOKEN: { MILLION_THRESHOLD: 1000000, THOUSAND_THRESHOLD: 1000 }, // 汇率相关 EXCHANGE_RATE: { DEFAULT: 7.2, CONVERSION_FACTOR: 500000 // USD to quota conversion }, // 样式类名 STYLES: { // 按钮样式 BUTTON: { PRIMARY: 'flex-1 flex items-center justify-center space-x-2 py-2.5 px-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-sm font-medium shadow-sm', SECONDARY: 'flex items-center justify-center py-2.5 px-3 bg-white text-gray-600 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium border border-gray-200', ICON: 'p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-50 rounded-lg transition-all duration-200' }, // 状态指示器 STATUS_INDICATOR: { HEALTHY: 'bg-green-500', ERROR: 'bg-red-500', WARNING: 'bg-yellow-500', UNKNOWN: 'bg-gray-400' }, // 文本颜色 TEXT: { PRIMARY: 'text-gray-900', SECONDARY: 'text-gray-500', SUCCESS: 'text-green-500', ERROR: 'text-red-500', WARNING: 'text-yellow-500' }, // 输入框 INPUT: { BASE: 'block w-full py-3 border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors', WITH_ICON: 'pl-10' } } } as const export const CURRENCY_SYMBOLS = { USD: '$', CNY: '¥' } as const export const HEALTH_STATUS_MAP = { healthy: { color: UI_CONSTANTS.STYLES.STATUS_INDICATOR.HEALTHY, text: '正常' }, error: { color: UI_CONSTANTS.STYLES.STATUS_INDICATOR.ERROR, text: '错误' }, warning: { color: UI_CONSTANTS.STYLES.STATUS_INDICATOR.WARNING, text: '警告' }, unknown: { color: UI_CONSTANTS.STYLES.STATUS_INDICATOR.UNKNOWN, text: '未知' } } as const ================================================ FILE: content.ts ================================================ import type { PlasmoCSConfig } from "plasmo" import { fetchUserInfo } from "~services/apiService" export const config: PlasmoCSConfig = { matches: [""], all_frames: false } // 监听来自 popup 和 background 的消息 chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === "getLocalStorage") { try { const { key } = request if (key) { // 读取特定键 const value = localStorage.getItem(key) sendResponse({ success: true, data: { [key]: value } }) } else { // 读取所有 localStorage 数据 const localStorage = window.localStorage const data = {} for (let i = 0; i < localStorage.length; i++) { const storageKey = localStorage.key(i) if (storageKey) { data[storageKey] = localStorage.getItem(storageKey) } } sendResponse({ success: true, data }) } } catch (error) { sendResponse({ success: false, error: error.message }) } return true // 保持消息通道开放 } if (request.action === "getUserFromLocalStorage") { ;(async () => { try { // 所有异步逻辑 const userStr = localStorage.getItem("user") let user = userStr ? JSON.parse(userStr) : await fetchUserInfo(request.url) if (!user || !user.id) { sendResponse({ success: false, error: "未找到用户信息,请确保已登录" }) return } sendResponse({ success: true, data: { userId: user.id, user } }) } catch (e) { sendResponse({ success: false, error: e.message }) } })() return true } if (request.action === "waitAndGetUserInfo") { // 新增:等待页面完全加载后获取用户信息 waitForUserInfo() .then((userInfo) => { sendResponse({ success: true, data: userInfo }) }) .catch((error) => { sendResponse({ success: false, error: error.message }) }) return true } }) // 等待用户信息可用 async function waitForUserInfo( maxWaitTime = 5000 ): Promise<{ userId: string; user: any }> { const startTime = Date.now() while (Date.now() - startTime < maxWaitTime) { try { const userStr = localStorage.getItem("user") if (userStr) { const user = JSON.parse(userStr) if (user.id) { return { userId: user.id, user } } } } catch (error) { // 继续等待 } // 等待 100ms 后重试 await new Promise((resolve) => setTimeout(resolve, 100)) } throw new Error("等待用户信息超时,请确保已登录") } ================================================ FILE: debug/testStorage.ts ================================================ import { accountStorage } from "../services/accountStorage"; /** * 存储功能测试脚本 */ export const testStorageFunction = async () => { console.log('=== 开始测试存储功能 ==='); try { // 测试添加账号 const testAccount = { emoji: "", site_name: "测试站点", site_url: "https://test.example.com", health_status: "unknown" as const, exchange_rate: 7.2, account_info: { access_token: "sk-test-1234567890", username: "test@example.com", quota: 100.0, today_prompt_tokens: 0, today_completion_tokens: 0, today_quota_consumption: 0, today_requests_count: 0 }, last_sync_time: Date.now() }; console.log('1. 测试添加账号...'); const accountId = await accountStorage.addAccount(testAccount); console.log('账号添加成功,ID:', accountId); // 测试获取所有账号 console.log('2. 测试获取所有账号...'); const allAccounts = await accountStorage.getAllAccounts(); console.log('获取到账号数量:', allAccounts.length); console.log('账号列表:', allAccounts.map(acc => ({ id: acc.id, name: acc.site_name, username: acc.account_info.username }))); // 测试获取单个账号 console.log('3. 测试获取单个账号...'); const singleAccount = await accountStorage.getAccountById(accountId); console.log('获取单个账号成功:', singleAccount ? singleAccount.site_name : '未找到'); // 测试数据导出 console.log('4. 测试数据导出...'); const exportedData = await accountStorage.exportData(); console.log('导出数据:', { accountCount: exportedData.accounts.length, lastUpdated: new Date(exportedData.last_updated).toLocaleString() }); console.log('=== 所有测试完成 ==='); return true; } catch (error) { console.error('测试失败:', error); return false; } }; // 在浏览器环境中暴露测试函数 if (typeof window !== 'undefined') { (window as any).testStorageFunction = testStorageFunction; console.log('测试函数已挂载到 window.testStorageFunction,在控制台运行 testStorageFunction() 进行测试'); } ================================================ FILE: docs/docs/.vuepress/config.js ================================================ import { defaultTheme } from '@vuepress/theme-default' import { defineUserConfig } from 'vuepress' import { viteBundler } from '@vuepress/bundler-vite' export default defineUserConfig({ lang: 'zh-CN', base: '/one-api-hub/', title: 'One API Hub - 中转站管理器', description: '一个开源的浏览器插件,聚合管理AI中转站账号的余额、模型和密钥,告别繁琐登录。', theme: defaultTheme({ logo: 'https://github.com/fxaxg/one-api-hub/blob/main/assets/icon.png?raw=true', navbar: ['/', '/get-started', '/faq'], }), bundler: viteBundler(), }) ================================================ FILE: docs/docs/README.md ================================================ --- home: true title: 首页 heroImage: https://github.com/fxaxg/one-api-hub/blob/main/assets/icon.png?raw=true heroText: One API Hub - 中转站管理器 tagline: 一个开源的 AI 中转站账号管理插件 actions: - text: 开始使用 link: /get-started.html # 建议修改为您的实际文档路径,例如 /guide/ type: primary - text: 查看源码 link: https://github.com/fxaxg/one-api-hub # 假设这是您的项目仓库地址 type: secondary # ✨ 新增 Google Play 下载按钮 - text: Chrome 应用商店 link: https://chromewebstore.google.com/detail/%E4%B8%AD%E8%BD%AC%E7%AB%99%E7%AE%A1%E7%90%86%E5%99%A8-one-api-hub/eobdoeafpplhhhjfkinnlkljbkijpobd type: secondary features: - title: 站点自动识别 details: 自动识别各类 AI 中转站点(如 One API, New API, Veloera),并自动创建系统访问 Token,添加到插件的站点列表中。 - title: 充值比例识别 details: 自动识别中转站的充值比例,让您清楚了解资金利用率。 - title: 多账号支持 details: 每个中转站点可添加和管理多个账号,满足您多账户需求。 - title: 余额与日志查询 details: 实时查看各账号的余额,并详细审查使用日志,掌握消费情况。 - title: 令牌(Key)管理 details: 便捷地查看与管理各站点的令牌(Key),确保安全与效率。 - title: 模型与渠道信息 details: 详细展示站点支持的模型信息和所关联的渠道,助您做出最佳选择。 - title: 插件无需联网 details: 核心功能无需联网即可使用,保护您的数据隐私和使用稳定性。 footer: MIT Licensed | Copyright © 2023-present Ai API Hub --- ## 介绍 目前市面上有太多 AI-API 中转站点,每次查看余额和支持模型列表等信息都非常麻烦,需要逐个登录查看。 本插件致力于解决这一痛点,可以便捷地对基于 - [One-API] - [New-API] - [Veloera](https://github.com/Veloera/Veloera) 等部署的 Ai 中转站账号进行整合管理,大大提升您的效率。 --- ## 未来支持 - **模型降智测试**: 探索并评估不同模型在特定任务上的表现。 - **WebDAV 数据备份**: 支持将配置和数据备份到 WebDAV 服务,确保数据安全和可移植性。 [One-API]: https://github.com/songquanpeng/one-api [New-API]: https://github.com/QuantumNous/new-api ================================================ FILE: docs/docs/faq.md ================================================ # 常见问题 收集一些插件使用时遇到的常见问题。 文档待完善,可先查看[使用教程](./get-started.md) ================================================ FILE: docs/docs/get-started.md ================================================ # 开始使用 一个开源的浏览器插件,聚合管理AI中转站账号的余额、模型和密钥,告别繁琐登录。 ## 1. 下载 ::: info 推荐 [前往 Chrome 应用商店] ::: ## 2. 支持的站点 支持基于以下项目部署的中转站: - [One-API] - [New-API] - [Veloera](https://github.com/Veloera/Veloera) ::: warning 如果站点进行了二次开发导致一些关键接口(例如`/api/user`)发生了改变,则插件可能无法正常添加此站点。 ::: ## 3. 添加站点 ::: info 提示 必须先使用浏览器,自行在目标中转站登录,这样插件的自动识别功能才能通过cookie获取到您账号的[访问令牌(Access_Token)](#_3-2-手动添加) ::: ### 3.1 自动识别添加 1. 打开插件主页面,点击`新增账号` ![新增账号](./static/image/add-account-btn.png) 2. 输入中转站地址,点击`自动识别` ![自动识别](./static/image/add-account-dialog-btn.png) 3. 确认自动识别无误后点击`确认添加` :::info 提示 插件会自动识别您账号的: - 用户名 - 用户ID - [访问令牌(Access_Token)](#_3-2-手动添加) - 充值金额比例 ::: ![确认添加](./static/image/add-account-dialog-ok-btn.png) ### 3.2 手动添加 :::info 提示 当自动识别未成功后,可进行手动输入添加站点账号,需要先获取以下信息。(每个站点可能UI有所差异,请自行寻找) ::: ![用户信息](./static/image/site-user-info.png) [One-API]: https://github.com/songquanpeng/one-api [New-API]: https://github.com/QuantumNous/new-api [前往 Chrome 应用商店]: https://chromewebstore.google.com/detail/%E4%B8%AD%E8%BD%AC%E7%AB%99%E7%AE%A1%E7%90%86%E5%99%A8-one-api-hub/eobdoeafpplhhhjfkinnlkljbkijpobd ================================================ FILE: docs/package.json ================================================ { "name": "one-api-hub-docs", "description": "A VuePress project", "version": "0.0.1", "license": "MIT", "type": "module", "packageManager": "pnpm@9.0.0", "scripts": { "docs:build": "vuepress build docs", "docs:clean-dev": "vuepress dev docs --clean-cache", "docs:dev": "vuepress dev docs", "docs:update-package": "pnpm dlx vp-update" }, "devDependencies": { "@vuepress/bundler-vite": "2.0.0-rc.20", "@vuepress/theme-default": "2.0.0-rc.88", "sass-embedded": "^1.86.0", "vue": "^3.5.13", "vuepress": "2.0.0-rc.20" } } ================================================ FILE: examples/storageExample.ts ================================================ import { accountStorage, AccountStorageUtils } from "../services/accountStorage"; /** * 账号存储系统使用示例 */ export class AccountStorageExample { /** * 添加新账号示例 */ static async addNewAccount() { try { const newAccountData = { emoji: "", site_name: "测试 API 站点", site_url: "https://api.test.com", health_status: "healthy" as const, exchange_rate: 7.2, account_info: { access_token: "sk-test-xxxxxxxxxxxxxxxxxxxx", username: "test_user@example.com", quota: 100.0, today_prompt_tokens: 0, today_completion_tokens: 0, today_quota_consumption: 0, today_requests_count: 0 }, last_sync_time: Date.now() }; const accountId = await accountStorage.addAccount(newAccountData); console.log(`新账号已添加,ID: ${accountId}`); return accountId; } catch (error) { console.error('添加账号失败:', error); throw error; } } /** * 更新账号统计信息示例 */ static async updateAccountStats(accountId: string) { try { const updates = { account_info: { access_token: "保持原有token", username: "保持原有用户名", quota: 95.50, // 更新余额 today_prompt_tokens: 1500, today_completion_tokens: 2300, today_quota_consumption: 4.50, today_requests_count: 15 }, last_sync_time: Date.now() }; const success = await accountStorage.updateAccount(accountId, updates); console.log(`账号统计更新${success ? '成功' : '失败'}`); return success; } catch (error) { console.error('更新账号统计失败:', error); throw error; } } /** * 获取并显示所有账号信息示例 */ static async displayAllAccounts() { try { const accounts = await accountStorage.getAllAccounts(); console.log(`当前共有 ${accounts.length} 个账号:`); accounts.forEach((account, index) => { console.log(`\n${index + 1}. ${account.site_name}`); console.log(` 用户名: ${account.account_info.username}`); console.log(` 余额: ${AccountStorageUtils.formatBalance(account.account_info.quota, 'USD')}`); console.log(` 今日消耗: ${AccountStorageUtils.formatBalance(account.account_info.today_quota_consumption, 'USD')}`); console.log(` 今日请求: ${account.account_info.today_requests_count} 次`); console.log(` 最后同步: ${new Date(account.last_sync_time).toLocaleString()}`); }); return accounts; } catch (error) { console.error('获取账号信息失败:', error); throw error; } } /** * 获取统计信息示例 */ static async displayStats() { try { const stats = await accountStorage.getAccountStats(); console.log('\n=== 总体统计 ==='); console.log(`总余额: ${AccountStorageUtils.formatBalance(stats.total_quota, 'USD')}`); console.log(`今日总消耗: ${AccountStorageUtils.formatBalance(stats.today_total_consumption, 'USD')}`); console.log(`今日总请求: ${stats.today_total_requests} 次`); console.log(`今日 Prompt Tokens: ${AccountStorageUtils.formatTokenCount(stats.today_total_prompt_tokens)}`); console.log(`今日 Completion Tokens: ${AccountStorageUtils.formatTokenCount(stats.today_total_completion_tokens)}`); return stats; } catch (error) { console.error('获取统计信息失败:', error); throw error; } } /** * 数据转换示例(兼容现有 UI) */ static async convertForUI() { try { const accounts = await accountStorage.getAllAccounts(); const displayData = accountStorage.convertToDisplayData(accounts); console.log('\n=== 转换为 UI 数据格式 ==='); console.log(JSON.stringify(displayData, null, 2)); return displayData; } catch (error) { console.error('数据转换失败:', error); throw error; } } /** * 数据导出/导入示例 */ static async exportImportExample() { try { // 导出数据 const exportedData = await accountStorage.exportData(); console.log('数据已导出'); // 模拟导入相同数据 const importSuccess = await accountStorage.importData(exportedData); console.log(`数据导入${importSuccess ? '成功' : '失败'}`); return { exportedData, importSuccess }; } catch (error) { console.error('导出/导入失败:', error); throw error; } } /** * 数据验证示例 */ static validateAccountData() { const validAccount = { emoji: "", site_name: "有效站点", site_url: "https://api.valid.com", health_status: "healthy" as const, exchange_rate: 7.2, account_info: { access_token: "sk-valid-token", username: "valid_user", quota: 100, today_prompt_tokens: 0, today_completion_tokens: 0, today_quota_consumption: 0, today_requests_count: 0 } }; const invalidAccount = { emoji: "", site_name: "", // 空的站点名称 site_url: "invalid-url", // 不是有效URL health_status: undefined as any, // 缺少健康状态 exchange_rate: -1, // 无效的充值比例 account_info: { access_token: "", // 空的token username: "", quota: 100, today_prompt_tokens: 0, today_completion_tokens: 0, today_quota_consumption: 0, today_requests_count: 0 } }; console.log('\n=== 数据验证示例 ==='); console.log('有效账号验证结果:', AccountStorageUtils.validateAccount(validAccount)); console.log('无效账号验证结果:', AccountStorageUtils.validateAccount(invalidAccount)); } /** * 运行所有示例 */ static async runAllExamples() { console.log('=== 账号存储系统示例 ===\n'); try { // 数据验证示例 this.validateAccountData(); // 添加账号 const accountId = await this.addNewAccount(); // 显示所有账号 await this.displayAllAccounts(); // 更新账号统计 await this.updateAccountStats(accountId); // 显示统计信息 await this.displayStats(); // 数据转换示例 await this.convertForUI(); // 导出/导入示例 await this.exportImportExample(); console.log('\n=== 所有示例运行完成 ==='); } catch (error) { console.error('示例运行失败:', error); } } } // 如果直接运行此文件,执行所有示例 if (typeof window !== 'undefined') { // 浏览器环境 (window as any).AccountStorageExample = AccountStorageExample; } ================================================ FILE: global.d.ts ================================================ declare module "*.png" { const content: string; export default content; } declare module "*.jpg" { const content: string; export default content; } declare module "*.jpeg" { const content: string; export default content; } declare module "*.gif" { const content: string; export default content; } declare module "*.svg" { const content: string; export default content; } declare module "*.webp" { const content: string; export default content; } ================================================ FILE: hooks/useAccountData.ts ================================================ import { useState, useEffect, useCallback } from "react" import { accountStorage } from "../services/accountStorage" import type { SiteAccount, AccountStats, DisplaySiteData } from "../types" interface UseAccountDataResult { // 数据状态 accounts: SiteAccount[] displayData: DisplaySiteData[] stats: AccountStats lastUpdateTime: Date // 加载状态 isInitialLoad: boolean isRefreshing: boolean // 动画相关状态 prevTotalConsumption: { USD: number; CNY: number } prevBalances: { [id: string]: { USD: number; CNY: number } } // 操作函数 loadAccountData: () => Promise handleRefresh: () => Promise<{ success: number; failed: number }> } export const useAccountData = (): UseAccountDataResult => { // 数据状态 const [accounts, setAccounts] = useState([]) const [displayData, setDisplayData] = useState([]) const [stats, setStats] = useState({ total_quota: 0, today_total_consumption: 0, today_total_requests: 0, today_total_prompt_tokens: 0, today_total_completion_tokens: 0 }) const [lastUpdateTime, setLastUpdateTime] = useState(new Date()) // 加载状态 const [isInitialLoad, setIsInitialLoad] = useState(true) const [isRefreshing, setIsRefreshing] = useState(false) // 动画相关状态 const [prevTotalConsumption, setPrevTotalConsumption] = useState({ USD: 0, CNY: 0 }) const [prevBalances, setPrevBalances] = useState<{ [id: string]: { USD: number, CNY: number } }>({}) // 加载账号数据 const loadAccountData = useCallback(async () => { try { const allAccounts = await accountStorage.getAllAccounts() const accountStats = await accountStorage.getAccountStats() const displaySiteData = accountStorage.convertToDisplayData(allAccounts) // 计算新的余额数据 const newBalances: { [id: string]: { USD: number, CNY: number } } = {} displaySiteData.forEach(site => { newBalances[site.id] = { USD: site.balance.USD, CNY: site.balance.CNY } }) // 如果不是初始加载,保存之前的数值供动画使用 if (!isInitialLoad) { setPrevTotalConsumption(prevTotalConsumption) setPrevBalances(prevBalances) } // 更新状态 setAccounts(allAccounts) setStats(accountStats) setDisplayData(displaySiteData) // 更新最后同步时间为最近的一次同步时间 if (allAccounts.length > 0) { const latestSyncTime = Math.max(...allAccounts.map(acc => acc.last_sync_time)) if (latestSyncTime > 0) { setLastUpdateTime(new Date(latestSyncTime)) } } // 标记为非初始加载 if (isInitialLoad) { setIsInitialLoad(false) } console.log('账号数据加载完成:', { accountCount: allAccounts.length, stats: accountStats }) } catch (error) { console.error('加载账号数据失败:', error) } }, [isInitialLoad, prevTotalConsumption, prevBalances]) // 刷新数据 const handleRefresh = useCallback(async () => { setIsRefreshing(true) try { // 刷新所有账号数据 const refreshResult = await accountStorage.refreshAllAccounts() console.log('刷新结果:', refreshResult) // 重新加载显示数据 await loadAccountData() setLastUpdateTime(new Date()) // 返回刷新结果,让组件层处理 UI 反馈 return refreshResult } catch (error) { console.error('刷新数据失败:', error) // 即使刷新失败也尝试加载本地数据 await loadAccountData() throw error } finally { setIsRefreshing(false) } }, [loadAccountData]) // 组件初始化时加载数据 useEffect(() => { loadAccountData() }, [loadAccountData]) return { // 数据状态 accounts, displayData, stats, lastUpdateTime, // 加载状态 isInitialLoad, isRefreshing, // 动画相关状态 prevTotalConsumption, prevBalances, // 操作函数 loadAccountData, handleRefresh } } ================================================ FILE: hooks/useSort.ts ================================================ import { useState, useMemo, useCallback, useEffect } from "react" import { UI_CONSTANTS } from "../constants/ui" import { createSortComparator } from "../utils/formatters" import type { DisplaySiteData } from "../types" type SortField = 'name' | 'balance' | 'consumption' type SortOrder = 'asc' | 'desc' interface UseSortResult { sortField: SortField sortOrder: SortOrder sortedData: DisplaySiteData[] handleSort: (field: SortField) => void } export const useSort = ( data: DisplaySiteData[], currencyType: 'USD' | 'CNY', initialSortField?: SortField, initialSortOrder?: SortOrder, onSortChange?: (field: SortField, order: SortOrder) => void ): UseSortResult => { const [sortField, setSortField] = useState( initialSortField || UI_CONSTANTS.SORT.DEFAULT_FIELD ) const [sortOrder, setSortOrder] = useState( initialSortOrder || UI_CONSTANTS.SORT.DEFAULT_ORDER ) // 当初始值变化时更新状态 useEffect(() => { if (initialSortField !== undefined) { setSortField(initialSortField) } }, [initialSortField]) useEffect(() => { if (initialSortOrder !== undefined) { setSortOrder(initialSortOrder) } }, [initialSortOrder]) // 处理排序 const handleSort = useCallback((field: SortField) => { let newOrder: SortOrder if (sortField === field) { newOrder = sortOrder === 'asc' ? 'desc' : 'asc' setSortOrder(newOrder) } else { newOrder = 'asc' setSortField(field) setSortOrder(newOrder) } // 通知父组件排序变化 onSortChange?.(field === sortField ? sortField : field, newOrder) }, [sortField, sortOrder, onSortChange]) // 排序数据 const sortedData = useMemo(() => { return [...data].sort((a, b) => { let aValue: string | number, bValue: string | number switch (sortField) { case 'name': aValue = a.name bValue = b.name break case 'balance': aValue = a.balance[currencyType] bValue = b.balance[currencyType] break case 'consumption': aValue = a.todayConsumption[currencyType] bValue = b.todayConsumption[currencyType] break default: return 0 } if (sortOrder === 'asc') { return aValue < bValue ? -1 : aValue > bValue ? 1 : 0 } else { return aValue > bValue ? -1 : aValue < bValue ? 1 : 0 } }) }, [data, sortField, sortOrder, currencyType]) return { sortField, sortOrder, sortedData, handleSort } } ================================================ FILE: hooks/useTimeFormatter.ts ================================================ import { useState, useEffect } from "react" import { UI_CONSTANTS } from "../constants/ui" import { formatRelativeTime, formatFullTime } from "../utils/formatters" interface UseTimeFormatterResult { formatRelativeTime: (date: Date) => string formatFullTime: (date: Date) => string forceUpdate: () => void } export const useTimeFormatter = (): UseTimeFormatterResult => { const [, setForceUpdate] = useState({}) // 强制更新函数 const forceUpdate = () => { setForceUpdate({}) } // 定时更新相对时间显示 useEffect(() => { const updateInterval = setInterval(() => { forceUpdate() }, UI_CONSTANTS.UPDATE_INTERVAL) return () => clearInterval(updateInterval) }, []) return { formatRelativeTime, formatFullTime, forceUpdate } } ================================================ FILE: hooks/useUserPreferences.ts ================================================ import { useState, useEffect, useCallback } from 'react'; import { userPreferences, type UserPreferences } from '../services/userPreferences'; /** * 用户偏好设置管理Hook */ export function useUserPreferences() { const [preferences, setPreferences] = useState(null); const [isLoading, setIsLoading] = useState(true); // 加载偏好设置 const loadPreferences = useCallback(async () => { try { setIsLoading(true); const prefs = await userPreferences.getPreferences(); setPreferences(prefs); console.log('[useUserPreferences] 偏好设置加载成功:', prefs); } catch (error) { console.error('[useUserPreferences] 加载偏好设置失败:', error); } finally { setIsLoading(false); } }, []); // 初始化加载 useEffect(() => { loadPreferences(); }, [loadPreferences]); // 更新活动标签页 const updateActiveTab = useCallback(async (activeTab: 'consumption' | 'balance') => { try { const success = await userPreferences.updateActiveTab(activeTab); if (success && preferences) { setPreferences(prev => prev ? { ...prev, activeTab } : null); console.log('[useUserPreferences] 活动标签页更新成功:', activeTab); } return success; } catch (error) { console.error('[useUserPreferences] 更新活动标签页失败:', error); return false; } }, [preferences]); // 更新货币类型 const updateCurrencyType = useCallback(async (currencyType: 'USD' | 'CNY') => { try { const success = await userPreferences.updateCurrencyType(currencyType); if (success && preferences) { setPreferences(prev => prev ? { ...prev, currencyType } : null); console.log('[useUserPreferences] 货币类型更新成功:', currencyType); } return success; } catch (error) { console.error('[useUserPreferences] 更新货币类型失败:', error); return false; } }, [preferences]); // 更新排序配置 const updateSortConfig = useCallback(async (sortField: 'name' | 'balance' | 'consumption', sortOrder: 'asc' | 'desc') => { try { const success = await userPreferences.updateSortConfig(sortField, sortOrder); if (success && preferences) { setPreferences(prev => prev ? { ...prev, sortField, sortOrder } : null); console.log('[useUserPreferences] 排序配置更新成功:', { sortField, sortOrder }); } return success; } catch (error) { console.error('[useUserPreferences] 更新排序配置失败:', error); return false; } }, [preferences]); // 更新自动刷新设置 const updateAutoRefresh = useCallback(async (autoRefresh: boolean) => { try { const success = await userPreferences.updateAutoRefresh(autoRefresh); if (success && preferences) { setPreferences(prev => prev ? { ...prev, autoRefresh } : null); console.log('[useUserPreferences] 自动刷新设置更新成功:', autoRefresh); } return success; } catch (error) { console.error('[useUserPreferences] 更新自动刷新设置失败:', error); return false; } }, [preferences]); // 更新刷新间隔 const updateRefreshInterval = useCallback(async (refreshInterval: number) => { try { const success = await userPreferences.updateRefreshInterval(refreshInterval); if (success && preferences) { setPreferences(prev => prev ? { ...prev, refreshInterval } : null); console.log('[useUserPreferences] 刷新间隔更新成功:', refreshInterval); } return success; } catch (error) { console.error('[useUserPreferences] 更新刷新间隔失败:', error); return false; } }, [preferences]); // 更新打开插件时自动刷新设置 const updateRefreshOnOpen = useCallback(async (refreshOnOpen: boolean) => { try { const success = await userPreferences.updateRefreshOnOpen(refreshOnOpen); if (success && preferences) { setPreferences(prev => prev ? { ...prev, refreshOnOpen } : null); console.log('[useUserPreferences] 打开插件时自动刷新设置更新成功:', refreshOnOpen); } return success; } catch (error) { console.error('[useUserPreferences] 更新打开插件时自动刷新设置失败:', error); return false; } }, [preferences]); // 更新健康状态显示设置 const updateShowHealthStatus = useCallback(async (showHealthStatus: boolean) => { try { const success = await userPreferences.updateShowHealthStatus(showHealthStatus); if (success && preferences) { setPreferences(prev => prev ? { ...prev, showHealthStatus } : null); console.log('[useUserPreferences] 健康状态显示设置更新成功:', showHealthStatus); } return success; } catch (error) { console.error('[useUserPreferences] 更新健康状态显示设置失败:', error); return false; } }, [preferences]); // 批量更新偏好设置 const updatePreferences = useCallback(async (updates: Partial) => { try { const success = await userPreferences.savePreferences(updates); if (success && preferences) { setPreferences(prev => prev ? { ...prev, ...updates } : null); console.log('[useUserPreferences] 偏好设置批量更新成功:', updates); } return success; } catch (error) { console.error('[useUserPreferences] 批量更新偏好设置失败:', error); return false; } }, [preferences]); // 重置为默认设置 const resetToDefaults = useCallback(async () => { try { const success = await userPreferences.resetToDefaults(); if (success) { await loadPreferences(); // 重新加载设置 console.log('[useUserPreferences] 已重置为默认设置'); } return success; } catch (error) { console.error('[useUserPreferences] 重置设置失败:', error); return false; } }, [loadPreferences]); return { // 状态 preferences, isLoading, // 便捷访问属性 activeTab: preferences?.activeTab || 'consumption', currencyType: preferences?.currencyType || 'USD', sortField: preferences?.sortField || 'name', sortOrder: preferences?.sortOrder || 'asc', autoRefresh: preferences?.autoRefresh ?? true, refreshInterval: preferences?.refreshInterval ?? 360, refreshOnOpen: preferences?.refreshOnOpen ?? true, showHealthStatus: preferences?.showHealthStatus ?? true, // 操作方法 updateActiveTab, updateCurrencyType, updateSortConfig, updateAutoRefresh, updateRefreshInterval, updateRefreshOnOpen, updateShowHealthStatus, updatePreferences, resetToDefaults, loadPreferences }; } ================================================ FILE: options/index.tsx ================================================ import "../popup/style.css" import { useState, useEffect } from "react" import { CogIcon, CpuChipIcon, KeyIcon, ArrowPathIcon, InformationCircleIcon } from "@heroicons/react/24/outline" import { Toaster } from 'react-hot-toast' import iconImage from "../assets/icon.png" // 页面组件导入 import BasicSettings from "./pages/BasicSettings" import ModelList from "./pages/ModelList" import KeyManagement from "./pages/KeyManagement" import ImportExport from "./pages/ImportExport" import About from "./pages/About" // 菜单项类型定义 interface MenuItem { id: string name: string icon: React.ComponentType<{ className?: string }> component: React.ComponentType } // 菜单配置 const menuItems: MenuItem[] = [ { id: 'basic', name: '基本设置', icon: CogIcon, component: BasicSettings }, { id: 'models', name: '模型列表', icon: CpuChipIcon, component: ModelList }, { id: 'keys', name: '密钥管理', icon: KeyIcon, component: KeyManagement }, { id: 'import-export', name: '导入/导出', icon: ArrowPathIcon, component: ImportExport }, { id: 'about', name: '关于', icon: InformationCircleIcon, component: About } ] // 解析URL hash和参数 function parseHash() { const hash = window.location.hash.slice(1) // 去掉 # if (!hash) return { page: 'basic', params: {} } const [page, ...paramParts] = hash.split('?') const params: Record = {} if (paramParts.length > 0) { const paramString = paramParts.join('?') const urlParams = new URLSearchParams(paramString) for (const [key, value] of urlParams.entries()) { params[key] = value } } return { page: page || 'basic', params } } // 更新URL hash function updateHash(page: string, params?: Record) { let hash = `#${page}` if (params && Object.keys(params).length > 0) { const searchParams = new URLSearchParams(params) hash += `?${searchParams.toString()}` } window.history.replaceState(null, '', hash) } function OptionsPage() { const [activeMenuItem, setActiveMenuItem] = useState('basic') const [routeParams, setRouteParams] = useState>({}) // 初始化路由 useEffect(() => { const { page, params } = parseHash() const validPage = menuItems.find(item => item.id === page) ? page : 'basic' setActiveMenuItem(validPage) setRouteParams(params) // 监听浏览器前进后退 const handleHashChange = () => { const { page, params } = parseHash() const validPage = menuItems.find(item => item.id === page) ? page : 'basic' setActiveMenuItem(validPage) setRouteParams(params) } window.addEventListener('hashchange', handleHashChange) return () => window.removeEventListener('hashchange', handleHashChange) }, []) // 切换菜单项 const handleMenuItemChange = (itemId: string, params?: Record) => { setActiveMenuItem(itemId) setRouteParams(params || {}) updateHash(itemId, params) } // 获取当前活动的组件 const ActiveComponent = menuItems.find(item => item.id === activeMenuItem)?.component || BasicSettings return (
{/* 顶部导航栏 */}
{/* 插件图标和名称 */}
One API Hub

One API Hub

AI 中转站账号管理插件

{/* 左侧菜单导航栏 */} {/* 右侧内容区域 */}
) } export default OptionsPage ================================================ FILE: options/pages/About.tsx ================================================ import { InformationCircleIcon, HeartIcon, GlobeAltIcon, CodeBracketIcon } from "@heroicons/react/24/outline" import iconImage from "../../assets/icon.png" import packageJson from "../../package.json" export default function About() { const version = packageJson.version const features = [ "自动识别中转站点,自动创建系统访问 token", "每个站点可添加多个账号", "账号的余额、使用日志进行查看", "密钥(key)查看与管理", "站点支持模型信息和渠道查看", "插件无需联网,保护隐私安全" ] const futureFeatures = [ "模型降智测试", "WebDAV 数据备份", "更多API站点支持", "高级统计分析功能" ] const techStack = [ { name: "Plasmo", version: "0.90.5", description: "浏览器扩展开发框架" }, { name: "React", version: "18.2.0", description: "用户界面库" }, { name: "TypeScript", version: "5.3.3", description: "类型安全的JavaScript" }, { name: "Tailwind CSS", version: "3.4.17", description: "原子化CSS框架" }, { name: "Headless UI", version: "2.2.4", description: "无样式UI组件" } ] return (
{/* 页面标题 */}

关于

了解插件信息和开发团队

{/* 插件信息 */}
One API Hub

One API Hub

AI 中转站账号管理插件,帮助用户便捷地管理多个AI API中转站点的账号。

版本号: v{version}
{/* 项目链接 */}

项目链接

GitHub 仓库

查看源代码、提交问题或参与项目开发

去点个Star

项目官网

查看详细文档、使用指南和更多信息

访问官网
{/* 功能特性 */}

功能特性

{/* 主要功能 */}

已实现功能

    {features.map((feature, index) => (
  • {feature}
  • ))}
{/* 未来功能 */}

即将支持

    {futureFeatures.map((feature, index) => (
  • {feature}
  • ))}
{/* 技术栈 */}

技术栈

{techStack.map((tech, index) => (

{tech.name}

v{tech.version}

{tech.description}

))}
{/* 版权和致谢 */}

版权与致谢

开发与维护

感谢所有为开源社区做出贡献的开发者们,本插件的开发得益于这些优秀的开源项目和工具。

Made with ❤️ Open Source Privacy First
{/* 隐私声明 */}

隐私保护

本插件所有数据均存储在本地浏览器中,不会上传到任何服务器。 您的账号信息和使用数据完全由您自己掌控,确保隐私安全。

) } ================================================ FILE: options/pages/BasicSettings.tsx ================================================ import { useState, useEffect } from "react" import { Switch } from "@headlessui/react" import { CogIcon, GlobeAltIcon, EyeIcon, ArrowPathIcon } from "@heroicons/react/24/outline" import { useUserPreferences } from "../../hooks/useUserPreferences" import toast from 'react-hot-toast' export default function BasicSettings() { const { preferences, isLoading, currencyType, activeTab, updateCurrencyType, updateActiveTab, updateAutoRefresh, updateRefreshInterval, updateRefreshOnOpen, resetToDefaults } = useUserPreferences() // 从偏好设置中获取值,或使用默认值 const autoRefresh = preferences?.autoRefresh ?? true const refreshInterval = preferences?.refreshInterval ?? 360 const refreshOnOpen = preferences?.refreshOnOpen ?? true // 本地状态用于输入框编辑 const [intervalInput, setIntervalInput] = useState(refreshInterval.toString()) // 同步刷新间隔值到输入框 useEffect(() => { setIntervalInput(refreshInterval.toString()) }, [refreshInterval]) const handleCurrencyChange = async (currency: 'USD' | 'CNY') => { const success = await updateCurrencyType(currency) if (success) { toast.success(`货币单位已切换到 ${currency === 'USD' ? '美元' : '人民币'}`) } else { toast.error('设置保存失败') } } const handleDefaultTabChange = async (tab: 'consumption' | 'balance') => { const success = await updateActiveTab(tab) if (success) { toast.success(`默认标签页已设置为 ${tab === 'consumption' ? '今日消耗' : '总余额'}`) } else { toast.error('设置保存失败') } } const handleAutoRefreshChange = async (enabled: boolean) => { const success = await updateAutoRefresh(enabled) if (success) { // 通知后台更新设置 chrome.runtime.sendMessage({ action: 'updateAutoRefreshSettings', settings: { autoRefresh: enabled } }); toast.success(`自动刷新已${enabled ? '启用' : '关闭'}`) } else { toast.error('设置保存失败') } } const handleRefreshIntervalChange = async (value: string) => { // 直接更新输入框状态,允许用户清空和编辑 setIntervalInput(value) } const handleRefreshIntervalBlur = async () => { const interval = Number(intervalInput) // 验证输入值 if (!intervalInput || isNaN(interval) || interval < 10) { toast.error('刷新间隔必须大于等于10秒') setIntervalInput(refreshInterval.toString()) // 恢复原值 return } // 保存设置 const success = await updateRefreshInterval(interval) if (success) { // 通知后台更新设置 chrome.runtime.sendMessage({ action: 'updateAutoRefreshSettings', settings: { refreshInterval: interval } }); toast.success(`刷新间隔已设置为 ${interval} 秒`) } else { toast.error('设置保存失败') setIntervalInput(refreshInterval.toString()) // 恢复原值 } } const handleRefreshOnOpenChange = async (enabled: boolean) => { const success = await updateRefreshOnOpen(enabled) if (success) { toast.success(`打开插件时自动刷新已${enabled ? '启用' : '关闭'}`) } else { toast.error('设置保存失败') } } const handleResetToDefaults = async () => { if (window.confirm('确定要重置所有设置到默认值吗?此操作不可撤销。')) { const success = await resetToDefaults() if (success) { toast.success('所有设置已重置为默认值') } else { toast.error('重置失败') } } } if (isLoading) { return (
) } return (
{/* 页面标题 */}

基本设置

管理插件的基本配置选项

{/* 显示设置 */}

显示设置

{/* 默认货币单位 */}

货币单位

设置余额和消费显示的默认货币单位

{/* 默认标签页 */}

默认标签页

设置插件启动时显示的默认标签页

{/* 刷新设置 */}

刷新设置

{/* 自动刷新 */}

自动刷新

定期自动刷新账号数据

{/* 刷新间隔 */} {autoRefresh && (

刷新间隔

设置自动刷新的时间间隔(默认360秒,建议不要设置过小以避免频繁请求)

handleRefreshIntervalChange(e.target.value)} onBlur={handleRefreshIntervalBlur} onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur() // 触发onBlur事件 } }} placeholder="360" className="w-20 px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
)} {/* 打开插件时自动刷新 */}

打开插件时自动刷新

当打开插件弹出层时自动刷新账号数据

{/* 危险操作 */}

危险操作

重置所有设置

将所有配置重置为默认值,此操作不可撤销

) } ================================================ FILE: options/pages/ImportExport.tsx ================================================ import { useState } from "react" import { ArrowPathIcon, ArrowUpTrayIcon, ArrowDownTrayIcon, DocumentIcon, ExclamationTriangleIcon, CheckCircleIcon } from "@heroicons/react/24/outline" import { accountStorage } from "../../services/accountStorage" import { userPreferences } from "../../services/userPreferences" import toast from 'react-hot-toast' export default function ImportExport() { const [isExporting, setIsExporting] = useState(false) const [isImporting, setIsImporting] = useState(false) const [importData, setImportData] = useState("") // 导出所有数据 const handleExportAll = async () => { try { setIsExporting(true) // 获取账号数据和用户偏好设置 const [accountData, preferencesData] = await Promise.all([ accountStorage.exportData(), userPreferences.exportPreferences() ]) const exportData = { version: "1.0", timestamp: Date.now(), accounts: accountData, preferences: preferencesData } // 创建下载链接 const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `one-api-Hub-backup-${new Date().toISOString().split('T')[0]}.json` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) toast.success('数据导出成功') } catch (error) { console.error('导出失败:', error) toast.error('导出失败,请重试') } finally { setIsExporting(false) } } // 导出账号数据 const handleExportAccounts = async () => { try { setIsExporting(true) const accountData = await accountStorage.exportData() const exportData = { version: "1.0", timestamp: Date.now(), type: "accounts", data: accountData } const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `accounts-backup-${new Date().toISOString().split('T')[0]}.json` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) toast.success('账号数据导出成功') } catch (error) { console.error('导出账号数据失败:', error) toast.error('导出失败,请重试') } finally { setIsExporting(false) } } // 导出用户设置 const handleExportPreferences = async () => { try { setIsExporting(true) const preferencesData = await userPreferences.exportPreferences() const exportData = { version: "1.0", timestamp: Date.now(), type: "preferences", data: preferencesData } const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `preferences-backup-${new Date().toISOString().split('T')[0]}.json` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) toast.success('用户设置导出成功') } catch (error) { console.error('导出用户设置失败:', error) toast.error('导出失败,请重试') } finally { setIsExporting(false) } } // 处理文件导入 const handleFileImport = (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return const reader = new FileReader() reader.onload = (e) => { const content = e.target?.result as string setImportData(content) } reader.readAsText(file) } // 导入数据 const handleImport = async () => { if (!importData.trim()) { toast.error('请选择要导入的文件或输入数据') return } try { setIsImporting(true) const data = JSON.parse(importData) // 验证数据格式 if (!data.version || !data.timestamp) { throw new Error('数据格式不正确') } let importSuccess = false // 根据数据类型进行导入 if (data.accounts || !data.type) { // 导入账号数据 const accountsData = data.accounts || data.data if (accountsData) { const success = await accountStorage.importData(accountsData) if (success) { importSuccess = true toast.success('账号数据导入成功') } } } if (data.preferences || data.type === "preferences") { // 导入用户设置 const preferencesData = data.preferences || data.data if (preferencesData) { const success = await userPreferences.importPreferences(preferencesData) if (success) { importSuccess = true toast.success('用户设置导入成功') } } } if (!importSuccess) { throw new Error('没有找到可导入的数据') } // 清空输入框 setImportData("") } catch (error) { console.error('导入失败:', error) if (error instanceof SyntaxError) { toast.error('数据格式错误,请检查JSON格式') } else { toast.error(`导入失败: ${error.message}`) } } finally { setIsImporting(false) } } // 验证导入数据 const validateImportData = () => { if (!importData.trim()) return null try { const data = JSON.parse(importData) return { valid: true, hasAccounts: !!(data.accounts || (data.type !== "preferences" && data.data)), hasPreferences: !!(data.preferences || data.type === "preferences"), timestamp: data.timestamp ? new Date(data.timestamp).toLocaleString('zh-CN') : '未知' } } catch { return { valid: false } } } const validation = validateImportData() return (
{/* 页面标题 */}

导入/导出

备份和恢复插件数据

{/* 导出数据 */}

导出数据

将数据导出为JSON文件进行备份

{/* 导出所有数据 */}

完整备份

导出所有账号数据和用户设置,推荐用于完整备份

{/* 导出账号数据 */}

账号数据

仅导出账号信息和相关数据

{/* 导出用户设置 */}

用户设置

仅导出界面设置和偏好配置

{/* 导入数据 */}

导入数据

从备份文件恢复数据

{/* 文件选择 */}
{/* 数据预览 */}