diff --git a/electron/services/httpApiService.ts b/electron/services/httpApiService.ts index 7eddcc7..051ad1e 100644 --- a/electron/services/httpApiService.ts +++ b/electron/services/httpApiService.ts @@ -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 { 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 = 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 | null { + if (!value) return null + const allowed: ContactType[] = ['friend', 'group', 'official', 'former_friend', 'other'] + const result = new Set() + 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 | null { + if (!value) return null + const allowed: SessionTypeFilter[] = ['friend', 'group', 'official', 'other'] + const result = new Set() + 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 { 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> + + 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, diff --git a/src/App.tsx b/src/App.tsx index 55030b4..7a55c5c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() {
-
+
} /> @@ -491,6 +494,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index e651547..f72d8ba 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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() { 数据管理 + + {/* 开放接口 */} + + + 开放接口 +
diff --git a/src/pages/OpenApiPage.tsx b/src/pages/OpenApiPage.tsx new file mode 100644 index 0000000..9aae85f --- /dev/null +++ b/src/pages/OpenApiPage.tsx @@ -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 ( +
+ {message &&
{message.text}
} + +
+
+

开放接口

+ +
+
+ +
+
+
+

开放接口(HTTP API)

+
用于给外部工具调用,默认仅监听本机地址 `127.0.0.1`。
+ +
+
+
+
+
启用 HTTP API
+
关闭后将停止监听端口,不再对外提供接口。
+
+ +
+
+
+ +
+ + 建议保持默认 5031,范围 1-65535 + setHttpApiPort(Number(e.target.value || 5031))} + /> +
+ +
+ + 令牌预设说明:留空表示不鉴权;设置后,`/v1/status` 等接口必须携带 `Authorization: Bearer <token>`。 +
+ setHttpApiToken(e.target.value)} + placeholder="留空表示不启用令牌鉴权" + /> +
+ + +
+
+
+ +
+ + + +
+
+ +
+ +
+

接口状态与信息

+ + {status ? ( + <> +
+
+
运行状态
+
{status.running ? '运行中' : '未运行'}
+
+
+
监听地址
+
{status.host}:{status.port}
+
+
+
运行时长
+
{uptimeText}
+
+
+
鉴权状态
+
{status.tokenConfigured ? '已启用' : '未启用'}
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ {status.tokenConfigured && ( + + )} +
+
+
+ + {status.lastError && ( +
+ +

最近错误:{status.lastError}

+
+ )} + + ) : ( +

尚未读取到接口状态,请点击“刷新状态”。

+ )} +
+
+
+
+ ) +} + +export default OpenApiPage diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 4f4e70c..d4642d0 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -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; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index ed46374..b26130b 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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() {
) - 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 ( -
-
-
-

开放接口(HTTP API)

- -
-
用于给外部工具调用,默认仅监听本机地址 `127.0.0.1`。
- -
-
-
-
-
启用 HTTP API
-
关闭后将停止监听端口,不再对外提供接口。
-
- -
-
-
- -
- - 建议保持默认 5031,范围 1-65535 - setHttpApiPort(Number(e.target.value || 5031))} - /> -
- -
- - 令牌预设说明:留空表示不鉴权;设置后,`/v1/status` 等接口必须携带 `Authorization: Bearer <token>`。 -
- setHttpApiToken(e.target.value)} - placeholder="留空表示不启用令牌鉴权" - /> -
- - -
-
-
- -
- - - -
-
- -
- -
-

接口状态与信息

- - {status ? ( - <> -
-
-
运行状态
-
{status.running ? '运行中' : '未运行'}
-
-
-
监听地址
-
{status.host}:{status.port}
-
-
-
运行时长
-
{uptimeText}
-
-
-
鉴权状态
-
{status.tokenConfigured ? '已启用' : '未启用'}
-
-
- -
- -
- -
- -
-
-
- -
- -
- -
- {status.tokenConfigured && ( - - )} -
-
-
- - {status.lastError && ( -
- -

最近错误:{status.lastError}

-
- )} - - ) : ( -

尚未读取到接口状态,请点击“刷新状态”。

- )} -
-
- ) - } - const [isGettingImageKey, setIsGettingImageKey] = useState(false) const [imageKeyStatus, setImageKeyStatus] = useState('') @@ -2797,8 +2490,6 @@ function SettingsPage() {
) - const location = useLocation() - // 检查导航传递的更新信息 useEffect(() => { if (location.state?.updateInfo) { @@ -2930,7 +2621,6 @@ function SettingsPage() {
{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()}
+
) }