feat: 添加 HTTP API 功能,支持状态获取、设置应用和重启操作

This commit is contained in:
ILoveBingLu
2026-03-03 04:59:39 +08:00
parent 610215c990
commit b5c5f31e5a
8 changed files with 1115 additions and 3 deletions
+243 -1
View File
@@ -2694,4 +2694,246 @@
}
}
}
}
}
.api-settings {
.api-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.api-status-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
}
.api-status-label {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.api-status-value {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
&.ok {
color: #16a34a;
}
&.error {
color: #dc2626;
}
&.mono {
font-family: var(--font-mono);
font-size: 13px;
}
}
.api-endpoint-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.api-endpoint-item {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 10px;
padding: 10px 12px;
}
.api-endpoint-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
code {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
}
}
.api-method {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 42px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
color: #047857;
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.35);
}
.api-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.input-inline-actions {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border-color);
border-radius: 9999px;
background: var(--bg-primary);
padding: 0 6px 0 14px;
min-height: 40px;
&:focus-within {
border-color: var(--primary);
}
input {
flex: 1;
min-width: 0;
margin-bottom: 0;
border: none;
background: transparent;
padding: 8px 0;
&:focus {
border: none;
}
}
.inline-actions {
display: flex;
align-items: center;
gap: 6px;
.inline-icon-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
&:hover {
color: var(--text-primary);
}
&:focus-visible {
outline: none;
color: var(--primary);
}
}
}
}
.switch-setting {
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
.switch-setting-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.switch-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.switch-desc {
margin-top: 4px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.api-title-row {
display: flex;
align-items: center;
gap: 8px;
.section-title {
margin: 0;
}
}
.api-doc-link-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
&:hover {
color: var(--text-primary);
}
&:focus-visible {
outline: none;
color: var(--primary);
}
}
.api-switch {
width: 44px;
height: 26px;
border: none;
border-radius: 999px;
padding: 2px;
cursor: pointer;
position: relative;
transition: background-color 0.2s ease;
flex-shrink: 0;
&.off {
background: var(--border-color);
}
&.on {
background: var(--primary);
}
.api-switch-thumb {
display: block;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
transform: translateX(0);
}
&.on .api-switch-thumb {
transform: translateX(18px);
}
}
+312 -2
View File
@@ -10,16 +10,17 @@ 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
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile, Network, Copy
} from 'lucide-react'
import { useAuthStore } from '../stores/authStore'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'stt' | 'ai' | 'data' | 'security' | 'activation' | 'about'
type SettingsTab = 'appearance' | 'database' | 'api' | '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 },
@@ -128,6 +129,57 @@ 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('')
@@ -190,9 +242,15 @@ 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)
@@ -223,6 +281,11 @@ function SettingsPage() {
setAiCustomSystemPromptState(savedAiCustomSystemPrompt)
setAiEnableThinkingState(savedAiEnableThinking)
setAiMessageLimitState(savedAiMessageLimit)
const statusResult = await window.electronAPI.httpApi.getStatus()
if (statusResult.success && statusResult.status) {
setHttpApiStatus(statusResult.status)
}
} catch (e) {
console.error('加载配置失败:', e)
}
@@ -393,6 +456,67 @@ 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',
@@ -1195,6 +1319,191 @@ 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('')
@@ -2621,6 +2930,7 @@ function SettingsPage() {
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'stt' && renderSttTab()}
{activeTab === 'ai' && (
+39
View File
@@ -25,6 +25,9 @@ export const CONFIG_KEYS = {
AUTO_UPDATE_CHECK_INTERVAL: 'autoUpdateCheckInterval', // 检查间隔(秒)
AUTO_UPDATE_MIN_INTERVAL: 'autoUpdateMinInterval', // 最小更新间隔(毫秒)
AUTO_UPDATE_DEBOUNCE_TIME: 'autoUpdateDebounceTime', // 防抖时间(毫秒)
HTTP_API_ENABLED: 'httpApiEnabled',
HTTP_API_PORT: 'httpApiPort',
HTTP_API_TOKEN: 'httpApiToken',
AUTH_ENABLED: 'authEnabled',
AUTH_CREDENTIAL_ID: 'authCredentialId',
AUTH_PASSWORD_HASH: 'authPasswordHash',
@@ -82,6 +85,42 @@ export async function setAutoUpdateDebounceTime(ms: number): Promise<void> {
await config.set(CONFIG_KEYS.AUTO_UPDATE_DEBOUNCE_TIME, Math.max(100, Math.min(5000, ms)))
}
// --- HTTP API 配置 ---
// 获取是否启用 HTTP API
export async function getHttpApiEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED)
return value !== undefined ? (value as boolean) : false
}
// 设置是否启用 HTTP API
export async function setHttpApiEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled)
}
// 获取 HTTP API 端口
export async function getHttpApiPort(): Promise<number> {
const value = await config.get(CONFIG_KEYS.HTTP_API_PORT)
return (value as number) || 5031
}
// 设置 HTTP API 端口
export async function setHttpApiPort(port: number): Promise<void> {
const safePort = Number.isFinite(port) ? Math.max(1, Math.min(65535, Math.floor(port))) : 5031
await config.set(CONFIG_KEYS.HTTP_API_PORT, safePort)
}
// 获取 HTTP API 访问令牌
export async function getHttpApiToken(): Promise<string> {
const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN)
return (value as string) || ''
}
// 设置 HTTP API 访问令牌
export async function setHttpApiToken(token: string): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token.trim())
}
// --- AI 摘要配置 ---
+53
View File
@@ -71,6 +71,59 @@ export interface ElectronAPI {
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
}
httpApi: {
getStatus: () => Promise<{
success: boolean
status?: {
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
}
error?: string
}>
applySettings: (payload: { enabled: boolean; port: number; token: string }) => Promise<{
success: boolean
status?: {
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
}
error?: string
}>
restart: () => Promise<{
success: boolean
status?: {
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
}
error?: string
}>
}
// Windows Hello 原生验证 (比 WebAuthn 更快)
windowsHello: {
/** 检查 Windows Hello 是否可用 */