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:
ILoveBingLu
2026-03-03 21:42:05 +08:00
parent b5c5f31e5a
commit 8f797fa70f
6 changed files with 599 additions and 319 deletions
+236 -1
View File
@@ -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
View File
@@ -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>
+11 -1
View File
@@ -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">
+328
View File
@@ -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 &lt;token&gt;`</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
+14 -2
View File
@@ -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
View File
@@ -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 &lt;token&gt;`</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>
)
}