mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-17 09:48:52 +08:00
feat: 新增 Open API 页面并集成 HTTP API 配置功能
新增 OpenApiPage 组件,用于管理 HTTP API 相关配置项 更新侧边栏(Sidebar),新增 Open API 页面的导航入口 增强 HttpApiService 服务,新增会话(sessions)和联系人(contacts)相关接口端点 实现会话 / 联系人数据的请求处理逻辑,支持筛选、排序功能 优化 SettingsPage 页面:移除过时的 HTTP API 配置代码,集成新的 Open API 功能 优化 Open API 板块样式,提升布局合理性与用户体验
This commit is contained in:
@@ -2,6 +2,7 @@ import * as http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService } from './chatService'
|
||||
|
||||
interface ApiEnvelopeSuccess<T> {
|
||||
success: true
|
||||
@@ -32,6 +33,9 @@ interface HttpApiSettings {
|
||||
token: string
|
||||
}
|
||||
|
||||
type ContactType = 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
type SessionTypeFilter = 'friend' | 'group' | 'official' | 'other'
|
||||
|
||||
class HttpApiService {
|
||||
private server: http.Server | null = null
|
||||
private readonly connections: Set<import('net').Socket> = new Set()
|
||||
@@ -133,7 +137,9 @@ class HttpApiService {
|
||||
endpoints: [
|
||||
{ method: 'GET', path: '/v1', desc: '接口详情' },
|
||||
{ method: 'GET', path: '/v1/health', desc: '健康检查' },
|
||||
{ method: 'GET', path: '/v1/status', desc: '服务状态' }
|
||||
{ method: 'GET', path: '/v1/status', desc: '服务状态' },
|
||||
{ method: 'GET', path: '/v1/sessions', desc: '会话列表' },
|
||||
{ method: 'GET', path: '/v1/contacts', desc: '联系人列表' }
|
||||
],
|
||||
lastError: this.startError
|
||||
}
|
||||
@@ -218,6 +224,58 @@ class HttpApiService {
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseBoolean(value: string | null, defaultValue: boolean): boolean {
|
||||
if (value === null) return defaultValue
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private parseIntInRange(value: string | null, defaultValue: number, min: number, max: number): number {
|
||||
if (!value) return defaultValue
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(n)) return defaultValue
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
private parseTypeFilter(value: string | null): Set<ContactType> | null {
|
||||
if (!value) return null
|
||||
const allowed: ContactType[] = ['friend', 'group', 'official', 'former_friend', 'other']
|
||||
const result = new Set<ContactType>()
|
||||
value
|
||||
.split(',')
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.forEach((x) => {
|
||||
if (allowed.includes(x as ContactType)) {
|
||||
result.add(x as ContactType)
|
||||
}
|
||||
})
|
||||
return result.size > 0 ? result : null
|
||||
}
|
||||
|
||||
private parseSessionTypeFilter(value: string | null): Set<SessionTypeFilter> | null {
|
||||
if (!value) return null
|
||||
const allowed: SessionTypeFilter[] = ['friend', 'group', 'official', 'other']
|
||||
const result = new Set<SessionTypeFilter>()
|
||||
value
|
||||
.split(',')
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.forEach((x) => {
|
||||
if (allowed.includes(x as SessionTypeFilter)) {
|
||||
result.add(x as SessionTypeFilter)
|
||||
}
|
||||
})
|
||||
return result.size > 0 ? result : null
|
||||
}
|
||||
|
||||
private detectSessionType(username: string): SessionTypeFilter {
|
||||
if (username.includes('@chatroom')) return 'group'
|
||||
if (username.startsWith('gh_')) return 'official'
|
||||
if (username) return 'friend'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
this.handleCors(res)
|
||||
|
||||
@@ -259,6 +317,14 @@ class HttpApiService {
|
||||
this.sendRedirect(res, '/v1/status')
|
||||
return
|
||||
}
|
||||
if (pathname === '/api/v1/sessions') {
|
||||
this.sendRedirect(res, '/v1/sessions')
|
||||
return
|
||||
}
|
||||
if (pathname === '/api/v1/contacts') {
|
||||
this.sendRedirect(res, '/v1/contacts')
|
||||
return
|
||||
}
|
||||
if (pathname === '/') {
|
||||
this.sendRedirect(res, '/v1')
|
||||
return
|
||||
@@ -382,6 +448,175 @@ class HttpApiService {
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/v1/sessions') {
|
||||
const q = (url.searchParams.get('q') || '').trim().toLowerCase()
|
||||
const typeFilter = this.parseSessionTypeFilter(url.searchParams.get('type'))
|
||||
const unreadOnly = this.parseBoolean(url.searchParams.get('unreadOnly'), false)
|
||||
const sort = (url.searchParams.get('sort') || 'sortTimestamp_desc').trim()
|
||||
const offset = this.parseIntInRange(url.searchParams.get('offset'), 0, 0, 100000)
|
||||
const limit = this.parseIntInRange(url.searchParams.get('limit'), 100, 1, 500)
|
||||
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success) {
|
||||
this.sendJson(
|
||||
res,
|
||||
503,
|
||||
this.failure(
|
||||
requestId,
|
||||
'DB_NOT_CONNECTED',
|
||||
sessionsResult.error || 'Failed to read sessions',
|
||||
'Please complete DB decrypt/setup in Settings and ensure data is available.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let sessions = (sessionsResult.sessions || []).map((item) => {
|
||||
const sessionType = this.detectSessionType(item.username || '')
|
||||
return {
|
||||
username: item.username,
|
||||
displayName: item.displayName || item.username,
|
||||
avatarUrl: item.avatarUrl,
|
||||
summary: item.summary,
|
||||
unreadCount: item.unreadCount || 0,
|
||||
sortTimestamp: item.sortTimestamp || 0,
|
||||
lastTimestamp: item.lastTimestamp || 0,
|
||||
lastMsgType: item.lastMsgType || 0,
|
||||
sessionType
|
||||
}
|
||||
})
|
||||
|
||||
if (typeFilter) {
|
||||
sessions = sessions.filter((item) => typeFilter.has(item.sessionType))
|
||||
}
|
||||
|
||||
if (unreadOnly) {
|
||||
sessions = sessions.filter((item) => Number(item.unreadCount || 0) > 0)
|
||||
}
|
||||
|
||||
if (q) {
|
||||
sessions = sessions.filter((item) => {
|
||||
const username = String(item.username || '').toLowerCase()
|
||||
const displayName = String(item.displayName || '').toLowerCase()
|
||||
const summary = String(item.summary || '').toLowerCase()
|
||||
return username.includes(q) || displayName.includes(q) || summary.includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
if (sort === 'name_asc') {
|
||||
sessions.sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'name_desc') {
|
||||
sessions.sort((a, b) => String(b.displayName || '').localeCompare(String(a.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'lastTimestamp_asc') {
|
||||
sessions.sort((a, b) => Number(a.lastTimestamp || 0) - Number(b.lastTimestamp || 0))
|
||||
} else if (sort === 'lastTimestamp_desc') {
|
||||
sessions.sort((a, b) => Number(b.lastTimestamp || 0) - Number(a.lastTimestamp || 0))
|
||||
} else if (sort === 'unreadCount_desc') {
|
||||
sessions.sort((a, b) => Number(b.unreadCount || 0) - Number(a.unreadCount || 0))
|
||||
} else {
|
||||
sessions.sort((a, b) => Number(b.sortTimestamp || 0) - Number(a.sortTimestamp || 0))
|
||||
}
|
||||
|
||||
const total = sessions.length
|
||||
const paged = sessions.slice(offset, offset + limit)
|
||||
const hasMore = offset + paged.length < total
|
||||
|
||||
this.sendJson(res, 200, this.success(requestId, {
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore,
|
||||
sort,
|
||||
filters: {
|
||||
q,
|
||||
type: typeFilter ? Array.from(typeFilter) : null,
|
||||
unreadOnly
|
||||
},
|
||||
sessions: paged
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/v1/contacts') {
|
||||
const q = (url.searchParams.get('q') || '').trim().toLowerCase()
|
||||
const typeFilter = this.parseTypeFilter(url.searchParams.get('type'))
|
||||
const includeAvatar = this.parseBoolean(url.searchParams.get('includeAvatar'), true)
|
||||
const sort = (url.searchParams.get('sort') || 'lastContactTime_desc').trim()
|
||||
const offset = this.parseIntInRange(url.searchParams.get('offset'), 0, 0, 100000)
|
||||
const limit = this.parseIntInRange(url.searchParams.get('limit'), 100, 1, 500)
|
||||
|
||||
const contactsResult = await chatService.getContacts()
|
||||
if (!contactsResult.success) {
|
||||
this.sendJson(
|
||||
res,
|
||||
503,
|
||||
this.failure(
|
||||
requestId,
|
||||
'DB_NOT_CONNECTED',
|
||||
contactsResult.error || 'Failed to read contacts',
|
||||
'Please complete DB decrypt/setup in Settings and ensure data is available.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let contacts = (contactsResult.contacts || []) as Array<Record<string, any>>
|
||||
|
||||
if (typeFilter) {
|
||||
contacts = contacts.filter((item) => typeFilter.has((item.type || 'other') as ContactType))
|
||||
}
|
||||
|
||||
if (q) {
|
||||
contacts = contacts.filter((item) => {
|
||||
const username = String(item.username || '').toLowerCase()
|
||||
const displayName = String(item.displayName || '').toLowerCase()
|
||||
const remark = String(item.remark || '').toLowerCase()
|
||||
const nickname = String(item.nickname || '').toLowerCase()
|
||||
return (
|
||||
username.includes(q) ||
|
||||
displayName.includes(q) ||
|
||||
remark.includes(q) ||
|
||||
nickname.includes(q)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (sort === 'name_asc') {
|
||||
contacts.sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'name_desc') {
|
||||
contacts.sort((a, b) => String(b.displayName || '').localeCompare(String(a.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'lastContactTime_asc') {
|
||||
contacts.sort((a, b) => Number((a as any).lastContactTime || 0) - Number((b as any).lastContactTime || 0))
|
||||
} else {
|
||||
contacts.sort((a, b) => Number((b as any).lastContactTime || 0) - Number((a as any).lastContactTime || 0))
|
||||
}
|
||||
|
||||
const total = contacts.length
|
||||
const paged = contacts.slice(offset, offset + limit)
|
||||
const hasMore = offset + paged.length < total
|
||||
|
||||
const finalContacts = paged.map((item) => {
|
||||
if (includeAvatar) return item
|
||||
const { avatarUrl, ...rest } = item
|
||||
return rest
|
||||
})
|
||||
|
||||
this.sendJson(res, 200, this.success(requestId, {
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore,
|
||||
sort,
|
||||
filters: {
|
||||
q,
|
||||
type: typeFilter ? Array.from(typeFilter) : null,
|
||||
includeAvatar
|
||||
},
|
||||
contacts: finalContacts
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
this.sendJson(
|
||||
res,
|
||||
404,
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@ import AgreementPage from './pages/AgreementPage'
|
||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import OpenApiPage from './pages/OpenApiPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import ActivationPage from './pages/ActivationPage'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
@@ -482,7 +483,9 @@ function App() {
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar />
|
||||
<main className={`content ${location.pathname === '/data-management' ? 'no-overflow' : ''}`}>
|
||||
<main
|
||||
className={`content ${['/data-management', '/settings', '/open-api'].includes(location.pathname) ? 'no-overflow' : ''}`}
|
||||
>
|
||||
<RouteGuard>
|
||||
<Routes>
|
||||
<Route path="/" element={<WelcomePage />} />
|
||||
@@ -491,6 +494,7 @@ function App() {
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/open-api" element={<OpenApiPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network } from 'lucide-react'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
@@ -117,6 +117,16 @@ function Sidebar() {
|
||||
<span className="nav-icon"><Database size={20} /></span>
|
||||
<span className="nav-label">数据管理</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 开放接口 */}
|
||||
<NavLink
|
||||
to="/open-api"
|
||||
className={`nav-item ${isActive('/open-api') ? 'active' : ''}`}
|
||||
title={collapsed ? '开放接口' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Network size={20} /></span>
|
||||
<span className="nav-label">开放接口</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as configService from '../services/config'
|
||||
import { Save, RefreshCw, RotateCcw, Eye, EyeOff, Sparkles, Copy, FileText, AlertCircle } from 'lucide-react'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
function OpenApiPage() {
|
||||
const HTTP_API_DOC_URL = 'https://ciphertalk.apifox.cn/'
|
||||
|
||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||
const [httpApiToken, setHttpApiToken] = useState('')
|
||||
const [showHttpApiToken, setShowHttpApiToken] = useState(false)
|
||||
const [httpApiStatus, setHttpApiStatus] = useState<{
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
enabled: boolean
|
||||
startedAt: string
|
||||
uptimeMs: number
|
||||
tokenConfigured: boolean
|
||||
tokenPreview: string
|
||||
baseUrl: string
|
||||
endpoints: Array<{ method: string; path: string; desc: string }>
|
||||
lastError: string
|
||||
} | null>(null)
|
||||
const [isSavingHttpApi, setIsSavingHttpApi] = useState(false)
|
||||
const [isRefreshingHttpApi, setIsRefreshingHttpApi] = useState(false)
|
||||
const [nowTs, setNowTs] = useState(Date.now())
|
||||
|
||||
const showMessage = (text: string, success: boolean) => {
|
||||
setMessage({ text, success })
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
}
|
||||
|
||||
const copyText = async (text: string, successText: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showMessage(successText, true)
|
||||
} catch {
|
||||
showMessage('复制失败,请手动复制', false)
|
||||
}
|
||||
}
|
||||
|
||||
const createRandomToken = () => {
|
||||
const randomPart = Math.random().toString(36).slice(2)
|
||||
const randomPart2 = Math.random().toString(36).slice(2)
|
||||
return `ct_${Date.now().toString(36)}_${randomPart}${randomPart2}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const enabled = await configService.getHttpApiEnabled()
|
||||
const port = await configService.getHttpApiPort()
|
||||
const token = await configService.getHttpApiToken()
|
||||
setHttpApiEnabled(enabled)
|
||||
setHttpApiPort(port)
|
||||
setHttpApiToken(token)
|
||||
|
||||
const statusResult = await window.electronAPI.httpApi.getStatus()
|
||||
if (statusResult.success && statusResult.status) {
|
||||
setHttpApiStatus(statusResult.status)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`加载开放接口配置失败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!httpApiStatus?.running) return
|
||||
const timer = window.setInterval(() => setNowTs(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [httpApiStatus?.running])
|
||||
|
||||
const refreshHttpApiStatus = async () => {
|
||||
setIsRefreshingHttpApi(true)
|
||||
try {
|
||||
const result = await window.electronAPI.httpApi.getStatus()
|
||||
if (result.success && result.status) {
|
||||
setHttpApiStatus(result.status)
|
||||
setHttpApiEnabled(result.status.enabled)
|
||||
setHttpApiPort(result.status.port)
|
||||
} else {
|
||||
showMessage(result.error || '获取接口状态失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`获取接口状态失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsRefreshingHttpApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveHttpApiSettings = async () => {
|
||||
setIsSavingHttpApi(true)
|
||||
try {
|
||||
const result = await window.electronAPI.httpApi.applySettings({
|
||||
enabled: httpApiEnabled,
|
||||
port: httpApiPort,
|
||||
token: httpApiToken
|
||||
})
|
||||
|
||||
if (result.success && result.status) {
|
||||
setHttpApiStatus(result.status)
|
||||
setHttpApiPort(result.status.port)
|
||||
await configService.setHttpApiEnabled(httpApiEnabled)
|
||||
await configService.setHttpApiPort(result.status.port)
|
||||
await configService.setHttpApiToken(httpApiToken)
|
||||
showMessage('开放接口配置已保存并生效', true)
|
||||
} else {
|
||||
showMessage(result.error || '保存开放接口配置失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`保存开放接口配置失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsSavingHttpApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestartHttpApi = async () => {
|
||||
setIsRefreshingHttpApi(true)
|
||||
try {
|
||||
const result = await window.electronAPI.httpApi.restart()
|
||||
if (result.success && result.status) {
|
||||
setHttpApiStatus(result.status)
|
||||
showMessage('接口服务已重启', true)
|
||||
} else {
|
||||
showMessage(result.error || '接口服务重启失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`接口服务重启失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsRefreshingHttpApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const status = httpApiStatus
|
||||
const startedAtMs = status?.startedAt ? new Date(status.startedAt).getTime() : 0
|
||||
const uptime = status?.running && startedAtMs > 0
|
||||
? Math.max(0, nowTs - startedAtMs)
|
||||
: (status?.uptimeMs ?? 0)
|
||||
const uptimeText = uptime > 0 ? `${Math.floor(uptime / 1000)} 秒` : '0 秒'
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
|
||||
|
||||
<div className="settings-header">
|
||||
<div className="open-api-header-title">
|
||||
<h1>开放接口</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => window.electronAPI.shell.openExternal(HTTP_API_DOC_URL)}
|
||||
>
|
||||
<FileText size={16} /> 接口文档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<div className="tab-content">
|
||||
<section className="settings-section api-settings">
|
||||
<h3 className="section-title">开放接口(HTTP API)</h3>
|
||||
<div className="section-desc">用于给外部工具调用,默认仅监听本机地址 `127.0.0.1`。</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="switch-setting">
|
||||
<div className="switch-setting-main">
|
||||
<div>
|
||||
<div className="switch-title">启用 HTTP API</div>
|
||||
<div className="switch-desc">关闭后将停止监听端口,不再对外提供接口。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`api-switch ${httpApiEnabled ? 'on' : 'off'}`}
|
||||
aria-label={httpApiEnabled ? '关闭 HTTP API' : '启用 HTTP API'}
|
||||
title={httpApiEnabled ? '点击关闭' : '点击启用'}
|
||||
onClick={() => setHttpApiEnabled((v) => !v)}
|
||||
>
|
||||
<span className="api-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>监听端口</label>
|
||||
<span className="form-hint">建议保持默认 5031,范围 1-65535</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={httpApiPort}
|
||||
onChange={(e) => setHttpApiPort(Number(e.target.value || 5031))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>访问密钥(可选)</label>
|
||||
<span className="form-hint">令牌预设说明:留空表示不鉴权;设置后,`/v1/status` 等接口必须携带 `Authorization: Bearer <token>`。</span>
|
||||
<div className="input-inline-actions">
|
||||
<input
|
||||
type={showHttpApiToken ? 'text' : 'password'}
|
||||
value={httpApiToken}
|
||||
onChange={(e) => setHttpApiToken(e.target.value)}
|
||||
placeholder="留空表示不启用令牌鉴权"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title={showHttpApiToken ? '隐藏密钥' : '显示密钥'}
|
||||
aria-label={showHttpApiToken ? '隐藏密钥' : '显示密钥'}
|
||||
onClick={() => setShowHttpApiToken((v) => !v)}
|
||||
>
|
||||
{showHttpApiToken ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title="生成随机密钥"
|
||||
aria-label="生成随机密钥"
|
||||
onClick={() => setHttpApiToken(createRandomToken())}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-primary" onClick={handleSaveHttpApiSettings} disabled={isSavingHttpApi}>
|
||||
<Save size={16} /> {isSavingHttpApi ? '保存中...' : '保存并应用'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={refreshHttpApiStatus} disabled={isRefreshingHttpApi}>
|
||||
<RefreshCw size={16} className={isRefreshingHttpApi ? 'spin' : ''} /> 刷新状态
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleRestartHttpApi} disabled={isRefreshingHttpApi}>
|
||||
<RotateCcw size={16} /> 重启服务
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="divider" style={{ margin: '2rem 0', borderBottom: '1px solid var(--border-color)', opacity: 0.1 }} />
|
||||
|
||||
<section className="settings-section api-settings">
|
||||
<h3 className="section-title">接口状态与信息</h3>
|
||||
|
||||
{status ? (
|
||||
<>
|
||||
<div className="api-status-grid">
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">运行状态</div>
|
||||
<div className={`api-status-value ${status.running ? 'ok' : 'error'}`}>{status.running ? '运行中' : '未运行'}</div>
|
||||
</div>
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">监听地址</div>
|
||||
<div className="api-status-value mono">{status.host}:{status.port}</div>
|
||||
</div>
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">运行时长</div>
|
||||
<div className="api-status-value">{uptimeText}</div>
|
||||
</div>
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">鉴权状态</div>
|
||||
<div className="api-status-value">{status.tokenConfigured ? '已启用' : '未启用'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: '16px' }}>
|
||||
<label>基础地址</label>
|
||||
<div className="input-inline-actions">
|
||||
<input type="text" value={status.baseUrl} readOnly />
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title="复制基础地址"
|
||||
aria-label="复制基础地址"
|
||||
onClick={() => copyText(status.baseUrl, '基础地址已复制')}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>令牌预览</label>
|
||||
<div className="input-inline-actions">
|
||||
<input type="text" value={status.tokenConfigured ? status.tokenPreview : '未配置'} readOnly />
|
||||
<div className="inline-actions">
|
||||
{status.tokenConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title="复制访问密钥"
|
||||
aria-label="复制访问密钥"
|
||||
onClick={() => copyText(httpApiToken, '访问密钥已复制')}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.lastError && (
|
||||
<div className="unavailable-notice" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--danger)' }}>
|
||||
<AlertCircle size={16} />
|
||||
<p>最近错误:{status.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>尚未读取到接口状态,请点击“刷新状态”。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenApiPage
|
||||
@@ -3,7 +3,7 @@
|
||||
flex-direction: column;
|
||||
height: calc(100% + 48px);
|
||||
margin: -24px;
|
||||
padding: 24px 24px 0 24px;
|
||||
padding: 32px 24px 0 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -69,9 +69,11 @@
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@@ -2881,6 +2883,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.open-api-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.api-doc-link-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
+5
-314
@@ -9,18 +9,17 @@ import AISummarySettings from '../components/ai/AISummarySettings'
|
||||
import {
|
||||
Eye, EyeOff, Key, FolderSearch, FolderOpen, Search,
|
||||
RotateCcw, Trash2, Save, Plug, X, Check, Sun, Moon, Monitor,
|
||||
Palette, Database, ImageIcon, Download, HardDrive, Info, RefreshCw, Shield, Clock, CheckCircle, AlertCircle, FileText, Mic,
|
||||
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile, Network, Copy
|
||||
Palette, Database, ImageIcon, Download, HardDrive, Info, RefreshCw, Shield, Clock, CheckCircle, AlertCircle, Mic,
|
||||
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile
|
||||
} from 'lucide-react'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'database' | 'api' | 'stt' | 'ai' | 'data' | 'security' | 'activation' | 'about'
|
||||
type SettingsTab = 'appearance' | 'database' | 'stt' | 'ai' | 'data' | 'security' | 'activation' | 'about'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'database', label: '数据解密', icon: Database },
|
||||
{ id: 'api', label: '开放接口', icon: Network },
|
||||
{ id: 'security', label: '安全设置', icon: Lock },
|
||||
{ id: 'stt', label: '语音转文字', icon: Mic },
|
||||
{ id: 'ai', label: 'AI 摘要', icon: Sparkles },
|
||||
@@ -44,6 +43,7 @@ const sttModelTypeOptions = [
|
||||
|
||||
function SettingsPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const location = useLocation()
|
||||
const { setDbConnected, setLoading } = useAppStore()
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode, appIcon, setAppIcon } = useThemeStore()
|
||||
const { status: activationStatus, checkStatus: checkActivationStatus } = useActivationStore()
|
||||
@@ -129,57 +129,6 @@ function SettingsPage() {
|
||||
const [autoUpdateMinInterval, setAutoUpdateMinInterval] = useState(1000) // 最小更新间隔(毫秒)
|
||||
const [autoUpdateDebounceTime, setAutoUpdateDebounceTime] = useState(500) // 防抖时间(毫秒)
|
||||
|
||||
// HTTP API 配置
|
||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||
const [httpApiToken, setHttpApiToken] = useState('')
|
||||
const [showHttpApiToken, setShowHttpApiToken] = useState(false)
|
||||
const [httpApiStatus, setHttpApiStatus] = useState<{
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
enabled: boolean
|
||||
startedAt: string
|
||||
uptimeMs: number
|
||||
tokenConfigured: boolean
|
||||
tokenPreview: string
|
||||
baseUrl: string
|
||||
endpoints: Array<{ method: string; path: string; desc: string }>
|
||||
lastError: string
|
||||
} | null>(null)
|
||||
const [isSavingHttpApi, setIsSavingHttpApi] = useState(false)
|
||||
const [isRefreshingHttpApi, setIsRefreshingHttpApi] = useState(false)
|
||||
const [nowTs, setNowTs] = useState(Date.now())
|
||||
|
||||
const copyText = async (text: string, successText: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showMessage(successText, true)
|
||||
} catch (e) {
|
||||
showMessage('复制失败,请手动复制', false)
|
||||
}
|
||||
}
|
||||
|
||||
const createRandomToken = () => {
|
||||
const randomPart = Math.random().toString(36).slice(2)
|
||||
const randomPart2 = Math.random().toString(36).slice(2)
|
||||
return `ct_${Date.now().toString(36)}_${randomPart}${randomPart2}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'api' || !httpApiStatus?.running) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setNowTs(Date.now())
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [activeTab, httpApiStatus?.running])
|
||||
|
||||
// AI 相关配置状态
|
||||
const [aiProvider, setAiProviderState] = useState('zhipu')
|
||||
const [aiApiKey, setAiApiKeyState] = useState('')
|
||||
@@ -242,15 +191,9 @@ function SettingsPage() {
|
||||
const savedCheckInterval = await configService.getAutoUpdateCheckInterval()
|
||||
const savedMinInterval = await configService.getAutoUpdateMinInterval()
|
||||
const savedDebounceTime = await configService.getAutoUpdateDebounceTime()
|
||||
const savedHttpApiEnabled = await configService.getHttpApiEnabled()
|
||||
const savedHttpApiPort = await configService.getHttpApiPort()
|
||||
const savedHttpApiToken = await configService.getHttpApiToken()
|
||||
setAutoUpdateCheckInterval(savedCheckInterval)
|
||||
setAutoUpdateMinInterval(savedMinInterval)
|
||||
setAutoUpdateDebounceTime(savedDebounceTime)
|
||||
setHttpApiEnabled(savedHttpApiEnabled)
|
||||
setHttpApiPort(savedHttpApiPort)
|
||||
setHttpApiToken(savedHttpApiToken)
|
||||
|
||||
const savedQuoteStyle = await configService.getQuoteStyle()
|
||||
setQuoteStyle(savedQuoteStyle)
|
||||
@@ -282,10 +225,6 @@ function SettingsPage() {
|
||||
setAiEnableThinkingState(savedAiEnableThinking)
|
||||
setAiMessageLimitState(savedAiMessageLimit)
|
||||
|
||||
const statusResult = await window.electronAPI.httpApi.getStatus()
|
||||
if (statusResult.success && statusResult.status) {
|
||||
setHttpApiStatus(statusResult.status)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
}
|
||||
@@ -456,67 +395,6 @@ function SettingsPage() {
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
}
|
||||
|
||||
const refreshHttpApiStatus = async () => {
|
||||
setIsRefreshingHttpApi(true)
|
||||
try {
|
||||
const result = await window.electronAPI.httpApi.getStatus()
|
||||
if (result.success && result.status) {
|
||||
setHttpApiStatus(result.status)
|
||||
setHttpApiEnabled(result.status.enabled)
|
||||
setHttpApiPort(result.status.port)
|
||||
} else {
|
||||
showMessage(result.error || '获取接口状态失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`获取接口状态失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsRefreshingHttpApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveHttpApiSettings = async () => {
|
||||
setIsSavingHttpApi(true)
|
||||
try {
|
||||
const result = await window.electronAPI.httpApi.applySettings({
|
||||
enabled: httpApiEnabled,
|
||||
port: httpApiPort,
|
||||
token: httpApiToken
|
||||
})
|
||||
|
||||
if (result.success && result.status) {
|
||||
setHttpApiStatus(result.status)
|
||||
setHttpApiPort(result.status.port)
|
||||
await configService.setHttpApiEnabled(httpApiEnabled)
|
||||
await configService.setHttpApiPort(result.status.port)
|
||||
await configService.setHttpApiToken(httpApiToken)
|
||||
showMessage('开放接口配置已保存并生效', true)
|
||||
} else {
|
||||
showMessage(result.error || '保存开放接口配置失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`保存开放接口配置失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsSavingHttpApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestartHttpApi = async () => {
|
||||
setIsRefreshingHttpApi(true)
|
||||
try {
|
||||
const result = await window.electronAPI.httpApi.restart()
|
||||
if (result.success && result.status) {
|
||||
setHttpApiStatus(result.status)
|
||||
showMessage('接口服务已重启', true)
|
||||
} else {
|
||||
showMessage(result.error || '接口服务重启失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`接口服务重启失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsRefreshingHttpApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearImages = () => {
|
||||
setShowClearDialog({
|
||||
type: 'images',
|
||||
@@ -1319,191 +1197,6 @@ function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderApiTab = () => {
|
||||
const HTTP_API_DOC_URL = 'https://github.com/ILoveBingLu/miyu/blob/main/Docs/HTTP_API_%E5%BC%80%E5%8F%91%E8%A7%84%E5%88%92%E4%B8%8E%E6%8E%A5%E5%8F%A3%E6%A0%87%E5%87%86.md'
|
||||
const status = httpApiStatus
|
||||
const startedAtMs = status?.startedAt ? new Date(status.startedAt).getTime() : 0
|
||||
const uptime = status?.running && startedAtMs > 0
|
||||
? Math.max(0, nowTs - startedAtMs)
|
||||
: (status?.uptimeMs ?? 0)
|
||||
const uptimeText = uptime > 0
|
||||
? `${Math.floor(uptime / 1000)} 秒`
|
||||
: '0 秒'
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<section className="settings-section api-settings">
|
||||
<div className="api-title-row">
|
||||
<h3 className="section-title">开放接口(HTTP API)</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn api-doc-link-btn"
|
||||
title="查看接口文档"
|
||||
aria-label="查看接口文档"
|
||||
onClick={() => window.electronAPI.shell.openExternal(HTTP_API_DOC_URL)}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="section-desc">用于给外部工具调用,默认仅监听本机地址 `127.0.0.1`。</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="switch-setting">
|
||||
<div className="switch-setting-main">
|
||||
<div>
|
||||
<div className="switch-title">启用 HTTP API</div>
|
||||
<div className="switch-desc">关闭后将停止监听端口,不再对外提供接口。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`api-switch ${httpApiEnabled ? 'on' : 'off'}`}
|
||||
aria-label={httpApiEnabled ? '关闭 HTTP API' : '启用 HTTP API'}
|
||||
title={httpApiEnabled ? '点击关闭' : '点击启用'}
|
||||
onClick={() => setHttpApiEnabled((v) => !v)}
|
||||
>
|
||||
<span className="api-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>监听端口</label>
|
||||
<span className="form-hint">建议保持默认 5031,范围 1-65535</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={httpApiPort}
|
||||
onChange={(e) => setHttpApiPort(Number(e.target.value || 5031))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>访问密钥(可选)</label>
|
||||
<span className="form-hint">令牌预设说明:留空表示不鉴权;设置后,`/v1/status` 等接口必须携带 `Authorization: Bearer <token>`。</span>
|
||||
<div className="input-inline-actions">
|
||||
<input
|
||||
type={showHttpApiToken ? 'text' : 'password'}
|
||||
value={httpApiToken}
|
||||
onChange={(e) => setHttpApiToken(e.target.value)}
|
||||
placeholder="留空表示不启用令牌鉴权"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title={showHttpApiToken ? '隐藏密钥' : '显示密钥'}
|
||||
aria-label={showHttpApiToken ? '隐藏密钥' : '显示密钥'}
|
||||
onClick={() => setShowHttpApiToken((v) => !v)}
|
||||
>
|
||||
{showHttpApiToken ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title="生成随机密钥"
|
||||
aria-label="生成随机密钥"
|
||||
onClick={() => setHttpApiToken(createRandomToken())}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-primary" onClick={handleSaveHttpApiSettings} disabled={isSavingHttpApi}>
|
||||
<Save size={16} /> {isSavingHttpApi ? '保存中...' : '保存并应用'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={refreshHttpApiStatus} disabled={isRefreshingHttpApi}>
|
||||
<RefreshCw size={16} className={isRefreshingHttpApi ? 'spin' : ''} /> 刷新状态
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleRestartHttpApi} disabled={isRefreshingHttpApi}>
|
||||
<RotateCcw size={16} /> 重启服务
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="divider" style={{ margin: '2rem 0', borderBottom: '1px solid var(--border-color)', opacity: 0.1 }} />
|
||||
|
||||
<section className="settings-section api-settings">
|
||||
<h3 className="section-title">接口状态与信息</h3>
|
||||
|
||||
{status ? (
|
||||
<>
|
||||
<div className="api-status-grid">
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">运行状态</div>
|
||||
<div className={`api-status-value ${status.running ? 'ok' : 'error'}`}>{status.running ? '运行中' : '未运行'}</div>
|
||||
</div>
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">监听地址</div>
|
||||
<div className="api-status-value mono">{status.host}:{status.port}</div>
|
||||
</div>
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">运行时长</div>
|
||||
<div className="api-status-value">{uptimeText}</div>
|
||||
</div>
|
||||
<div className="api-status-card">
|
||||
<div className="api-status-label">鉴权状态</div>
|
||||
<div className="api-status-value">{status.tokenConfigured ? '已启用' : '未启用'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: '16px' }}>
|
||||
<label>基础地址</label>
|
||||
<div className="input-inline-actions">
|
||||
<input type="text" value={status.baseUrl} readOnly />
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title="复制基础地址"
|
||||
aria-label="复制基础地址"
|
||||
onClick={() => copyText(status.baseUrl, '基础地址已复制')}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>令牌预览</label>
|
||||
<div className="input-inline-actions">
|
||||
<input type="text" value={status.tokenConfigured ? status.tokenPreview : '未配置'} readOnly />
|
||||
<div className="inline-actions">
|
||||
{status.tokenConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-icon-btn"
|
||||
title="复制访问密钥"
|
||||
aria-label="复制访问密钥"
|
||||
onClick={() => copyText(httpApiToken, '访问密钥已复制')}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.lastError && (
|
||||
<div className="unavailable-notice" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--danger)' }}>
|
||||
<AlertCircle size={16} />
|
||||
<p>最近错误:{status.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>尚未读取到接口状态,请点击“刷新状态”。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [isGettingImageKey, setIsGettingImageKey] = useState(false)
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||
|
||||
@@ -2797,8 +2490,6 @@ function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
// 检查导航传递的更新信息
|
||||
useEffect(() => {
|
||||
if (location.state?.updateInfo) {
|
||||
@@ -2930,7 +2621,6 @@ function SettingsPage() {
|
||||
<div className="settings-body">
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'api' && renderApiTab()}
|
||||
{activeTab === 'security' && renderSecurityTab()}
|
||||
{activeTab === 'stt' && renderSttTab()}
|
||||
{activeTab === 'ai' && (
|
||||
@@ -2960,6 +2650,7 @@ function SettingsPage() {
|
||||
{activeTab === 'activation' && renderActivationTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user