完成项目

This commit is contained in:
CNLuminous 2025-11-04 19:09:55 +08:00
parent a6af28e4c5
commit ba9d40844b
21 changed files with 11519 additions and 70 deletions

7
webapp/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
release
.DS_Store
*.log
.vite

9
webapp/.npmrc Normal file
View File

@ -0,0 +1,9 @@
# 使用淘宝镜像源
registry=https://registry.npmmirror.com
# Electron 镜像
electron_mirror=https://npmmirror.com/mirrors/electron/
# Electron Builder 缓存镜像
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
# Node.js 二进制文件镜像
disturl=https://npmmirror.com/mirrors/node/

44
webapp/README-ELECTRON.md Normal file
View File

@ -0,0 +1,44 @@
# Electron 打包说明
## 安装依赖
```bash
cd webapp
npm install
```
## 开发模式
### 1. 启动前端开发服务器
```bash
npm run dev
```
### 2. 启动 Electron新终端窗口
```bash
npm run electron:dev
```
## 打包为 Windows EXE
```bash
npm run electron:build:win
```
打包后的文件会在 `release` 目录下。
## 配置后端 API 地址
1. 在应用右上角点击 ⚙️ 设置按钮
2. 输入后端服务器的完整地址(例如:`http://192.168.1.100:7001`
3. 点击"保存设置"
4. 刷新页面以应用新设置
## 注意事项
- Electron 应用使用 `electron-store` 存储配置,配置文件保存在用户数据目录
- Web 版本使用 `localStorage` 存储配置
- 设置页面支持测试连接功能,可以验证后端服务器是否可访问

82
webapp/electron/main.cjs Normal file
View File

@ -0,0 +1,82 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
// 动态导入 electron-storeES 模块)
let store;
let storePromise = import('electron-store').then((StoreModule) => {
const Store = StoreModule.default;
store = new Store();
return store;
});
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.cjs'),
webSecurity: false, // 允许跨域请求(用于访问后端 API
},
// icon: path.join(__dirname, '../assets/icon.png'), // 可选:应用图标(需要提供图标文件)
});
// 加载应用
if (isDev) {
// 开发环境:加载 Vite 开发服务器
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
// 生产环境:加载打包后的文件
const indexPath = path.join(__dirname, '../dist/index.html');
mainWindow.loadFile(indexPath);
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(async () => {
// 确保 store 已初始化
await storePromise;
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// IPC 处理:获取/设置 API 地址
ipcMain.handle('get-api-url', async () => {
await storePromise;
return store.get('apiUrl', 'http://192.168.1.148:7001');
});
ipcMain.handle('set-api-url', async (event, url) => {
await storePromise;
store.set('apiUrl', url);
return true;
});
ipcMain.handle('get-ws-url', async () => {
await storePromise;
const apiUrl = store.get('apiUrl', 'http://192.168.1.148:7001');
// 将 http:// 转换为 ws://, https:// 转换为 wss://
return apiUrl.replace(/^http/, 'ws');
});

View File

@ -0,0 +1,9 @@
const { contextBridge, ipcRenderer } = require('electron');
// 暴露受保护的方法给渲染进程
contextBridge.exposeInMainWorld('electronAPI', {
getApiUrl: () => ipcRenderer.invoke('get-api-url'),
setApiUrl: (url) => ipcRenderer.invoke('set-api-url', url),
getWsUrl: () => ipcRenderer.invoke('get-ws-url'),
});

4165
webapp/node_modules/.package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

4348
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,18 +2,60 @@
"name": "web-karaoke-station", "name": "web-karaoke-station",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"description": "Web点歌台 - 在线音乐点播系统",
"author": "Web点歌台",
"type": "module", "type": "module",
"main": "electron/main.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"electron:dev": "cross-env NODE_ENV=development ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ electron electron/main.cjs",
"electron:build": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run build && electron-builder",
"electron:build:win": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run build && electron-builder --win"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.0" "vue": "^3.4.0",
"electron-store": "^10.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0" "vite": "^5.0.0",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"cross-env": "^7.0.3"
},
"build": {
"appId": "com.webkaraoke.station",
"productName": "Web点歌台",
"electronDownload": {
"mirror": "https://npmmirror.com/mirrors/electron/"
},
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*.cjs",
"node_modules/**/*"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "assets/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
} }
} }

View File

@ -15,6 +15,9 @@
<button class="refresh-btn" @click="handleRefresh" title="刷新页面"> <button class="refresh-btn" @click="handleRefresh" title="刷新页面">
🔄 🔄
</button> </button>
<button class="settings-btn" @click="handleSettings" title="设置">
</button>
</div> </div>
</header> </header>
<main class="app-main"> <main class="app-main">
@ -125,8 +128,10 @@ onMounted(async () => {
} catch (error) { } catch (error) {
console.error('加载播放状态失败:', error) console.error('加载播放状态失败:', error)
} }
// WebSocket // WebSocket使 async/await
initWebSocket(onQueueUpdate, onPlaybackStateUpdate, onOnlineCountUpdate, onAnnouncement, onVolumeControl, onUserId) initWebSocket(onQueueUpdate, onPlaybackStateUpdate, onOnlineCountUpdate, onAnnouncement, onVolumeControl, onUserId).catch(err => {
console.error('WebSocket初始化失败:', err)
})
// //
document.addEventListener('fullscreenchange', handleFullscreenChange) document.addEventListener('fullscreenchange', handleFullscreenChange)
@ -205,6 +210,18 @@ function handleRefresh() {
window.location.reload() window.location.reload()
} }
//
function handleSettings() {
// Electron 使 hash 使
if (window.electronAPI) {
// Electron 使 hash
window.location.hash = '#/settings'
} else {
//
window.location.href = '/settings'
}
}
// /退 // /退
function toggleFullscreen() { function toggleFullscreen() {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
@ -287,15 +304,30 @@ function handleFullscreenChange() {
} }
.fullscreen-btn:hover, .fullscreen-btn:hover,
.refresh-btn:hover { .refresh-btn:hover,
.settings-btn:hover {
background: #f1f5f9; background: #f1f5f9;
} }
.fullscreen-btn:active, .fullscreen-btn:active,
.refresh-btn:active { .refresh-btn:active,
.settings-btn:active {
background: #e2e8f0; background: #e2e8f0;
} }
.settings-btn {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.app-main { .app-main {
display: grid; display: grid;
grid-template-columns: 420px 1fr; grid-template-columns: 420px 1fr;

View File

@ -1,5 +1,15 @@
// 管理员API // 管理员API
const ADMIN_BASE_URL = '' import { getBaseUrl } from '../utils/config'
let adminBaseUrlCache = ''
// 获取基础URL带缓存
async function getCachedBaseUrl() {
if (!adminBaseUrlCache) {
adminBaseUrlCache = await getBaseUrl()
}
return adminBaseUrlCache
}
function getAdminToken() { function getAdminToken() {
return localStorage.getItem('admin_token') || '' return localStorage.getItem('admin_token') || ''
@ -7,8 +17,10 @@ function getAdminToken() {
async function request(url, options = {}) { async function request(url, options = {}) {
const token = getAdminToken() const token = getAdminToken()
const baseUrl = await getCachedBaseUrl()
const fullUrl = baseUrl ? `${baseUrl}${url}` : url
const response = await fetch(`${ADMIN_BASE_URL}${url}`, { const response = await fetch(fullUrl, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -123,3 +135,10 @@ export async function adminSeek(time) {
}) })
} }
/**
* 清除 API 地址缓存当API地址改变时调用
*/
export function clearAdminBaseUrlCache() {
adminBaseUrlCache = ''
}

View File

@ -1,14 +1,27 @@
// Midway后端API // Midway后端API
// 使用相对路径通过Vite代理转发到后端 import { getBaseUrl } from '../utils/config'
const BASE_URL = ''
let baseUrlCache = ''
// 获取基础URL带缓存
async function getCachedBaseUrl() {
if (!baseUrlCache) {
baseUrlCache = await getBaseUrl()
}
return baseUrlCache
}
async function request(path, options = {}) { async function request(path, options = {}) {
// 使用相对路径Vite会通过代理转发到后端 // 获取基础URL
const baseUrl = await getCachedBaseUrl()
// 构建完整URL
const url = path.startsWith('/') ? path : `/${path}` const url = path.startsWith('/') ? path : `/${path}`
const fullUrl = baseUrl ? `${baseUrl}${url}` : url
const { method = 'GET', body, params = {} } = options const { method = 'GET', body, params = {} } = options
// 构建URL添加查询参数 // 构建URL添加查询参数
let requestUrl = url let requestUrl = fullUrl
if (method === 'GET' && params && Object.keys(params).length > 0) { if (method === 'GET' && params && Object.keys(params).length > 0) {
const searchParams = new URLSearchParams() const searchParams = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => { Object.entries(params).forEach(([k, v]) => {
@ -180,8 +193,8 @@ export async function getPlaybackState() {
}) })
} }
export function getBaseUrl() { // 清除缓存当API地址改变时调用
// 返回空字符串,使用相对路径通过代理 export function clearBaseUrlCache() {
return '' baseUrlCache = ''
} }

View File

@ -1,9 +1,15 @@
// WebSocket连接管理 // WebSocket连接管理
// 使用相对路径通过Vite代理转发到后端 import { getWsUrl } from '../utils/config'
// 在开发环境下Vite会代理WebSocket连接
const WS_BASE_URL = import.meta.env.DEV let wsBaseUrlCache = ''
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`
: 'ws://localhost:7001' // 获取 WebSocket 基础URL带缓存
async function getCachedWsBaseUrl() {
if (!wsBaseUrlCache) {
wsBaseUrlCache = await getWsUrl()
}
return wsBaseUrlCache
}
let ws = null let ws = null
let reconnectTimer = null let reconnectTimer = null
@ -27,15 +33,18 @@ function getClientId() {
/** /**
* 初始化WebSocket连接 * 初始化WebSocket连接
*/ */
export function initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId) { export async function initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId) {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
console.log('WebSocket已经连接') console.log('WebSocket已经连接')
return return
} }
// 获取 WebSocket 基础URL
const wsBaseUrl = await getCachedWsBaseUrl()
// 获取客户端唯一标识 // 获取客户端唯一标识
const clientId = getClientId() const clientId = getClientId()
const wsUrl = `${WS_BASE_URL}/ws?clientId=${encodeURIComponent(clientId)}` const wsUrl = `${wsBaseUrl}/ws?clientId=${encodeURIComponent(clientId)}`
console.log('正在连接WebSocket:', wsUrl) console.log('正在连接WebSocket:', wsUrl)
try { try {
@ -89,8 +98,8 @@ export function initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onA
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++ reconnectAttempts++
console.log(`尝试重连 (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`) console.log(`尝试重连 (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`)
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(async () => {
initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId) await initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId)
}, RECONNECT_DELAY) }, RECONNECT_DELAY)
} else { } else {
console.error('WebSocket重连失败已达到最大重试次数') console.error('WebSocket重连失败已达到最大重试次数')
@ -137,3 +146,10 @@ export function getWebSocketStatus() {
} }
} }
/**
* 清除 WebSocket URL 缓存当API地址改变时调用
*/
export function clearWsUrlCache() {
wsBaseUrlCache = ''
}

View File

@ -70,6 +70,7 @@
import { defineProps, ref, watch, nextTick, toRefs, onMounted, onBeforeUnmount, computed } from 'vue' import { defineProps, ref, watch, nextTick, toRefs, onMounted, onBeforeUnmount, computed } from 'vue'
import { getLyric } from '../api/backend' import { getLyric } from '../api/backend'
import { parseLrc } from '../utils/lrc' import { parseLrc } from '../utils/lrc'
import { getBaseUrl } from '../utils/config'
const props = defineProps({ const props = defineProps({
selectedSong: { type: Object, default: null }, selectedSong: { type: Object, default: null },
@ -88,9 +89,43 @@ const currentSong = computed(() => {
}) })
// URL // URL
const streamUrl = computed(() => { const streamUrl = ref(null)
if (!currentSong.value?.id) return null
return `/api/music/stream/${currentSong.value.id}?level=standard` // URL
async function updateStreamUrl() {
if (!currentSong.value?.id) {
streamUrl.value = null
return
}
// Electron 使 API URL
if (window.electronAPI || window.location.protocol === 'file:') {
try {
const baseUrl = await getBaseUrl()
streamUrl.value = `${baseUrl}/api/music/stream/${currentSong.value.id}?level=standard`
} catch (error) {
console.error('获取流URL失败:', error)
streamUrl.value = null
}
} else {
// 使 Vite
streamUrl.value = `/api/music/stream/${currentSong.value.id}?level=standard`
}
}
// URL
watch(() => currentSong.value?.id, () => {
updateStreamUrl()
}, { immediate: true })
// streamUrl src
watch(streamUrl, (newUrl) => {
if (audioRef.value && newUrl) {
console.log('更新音频源URL:', newUrl)
//
audioRef.value.src = newUrl
audioRef.value.load()
}
}) })
function formatDuration(sec) { function formatDuration(sec) {

View File

@ -2,10 +2,32 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import AdminLogin from './pages/AdminLogin.vue' import AdminLogin from './pages/AdminLogin.vue'
import AdminPanel from './pages/AdminPanel.vue' import AdminPanel from './pages/AdminPanel.vue'
import Settings from './pages/Settings.vue'
import './style.css' import './style.css'
// 获取当前路径(支持 Electron 的 file:// 协议)
function getCurrentPath() {
// 在 Electron 的 file:// 协议下pathname 可能总是返回 '/'
// 优先使用 hash 路由Electron 环境)
if (window.electronAPI) {
const hash = window.location.hash
if (hash && hash.startsWith('#/')) {
return hash.substring(1) // 去掉 # 号,返回如 '/settings'
}
return '/'
}
// 浏览器环境:使用 pathname
return window.location.pathname
}
// 根据路径决定加载哪个组件 // 根据路径决定加载哪个组件
const path = window.location.pathname function loadApp() {
const path = getCurrentPath()
// 如果已经有 app 实例,先卸载
if (window.currentApp) {
window.currentApp.unmount()
}
let app let app
@ -18,21 +40,43 @@ if (path.startsWith('/admin')) {
// 检查token // 检查token
const token = localStorage.getItem('admin_token') const token = localStorage.getItem('admin_token')
if (!token) { if (!token) {
if (window.electronAPI) {
window.history.pushState({}, '', '/admin/login')
window.dispatchEvent(new PopStateEvent('popstate'))
} else {
window.location.href = '/admin/login' window.location.href = '/admin/login'
}
app = createApp(AdminLogin) app = createApp(AdminLogin)
} else { } else {
app = createApp(AdminPanel) app = createApp(AdminPanel)
} }
} else { } else {
// 其他admin路径默认跳转到登录页 // 其他admin路径默认跳转到登录页
if (window.electronAPI) {
window.history.pushState({}, '', '/admin/login')
window.dispatchEvent(new PopStateEvent('popstate'))
} else {
window.location.href = '/admin/login' window.location.href = '/admin/login'
}
app = createApp(AdminLogin) app = createApp(AdminLogin)
} }
} else if (path === '/settings') {
// 设置页面
app = createApp(Settings)
} else { } else {
// 主应用 // 主应用
app = createApp(App) app = createApp(App)
} }
app.mount('#app') app.mount('#app')
window.currentApp = app
}
// 监听路由变化(支持 Electron
window.addEventListener('popstate', loadApp)
window.addEventListener('hashchange', loadApp)
// 初始加载
loadApp()

View File

@ -371,7 +371,9 @@ onMounted(() => {
loadUsers() loadUsers()
// WebSocket // WebSocket
initWebSocket(() => {}, onPlaybackStateUpdate, () => {}, () => {}, () => {}, () => {}) initWebSocket(() => {}, onPlaybackStateUpdate, () => {}, () => {}, () => {}, () => {}).catch(err => {
console.error('WebSocket初始化失败:', err)
})
// //
const interval = setInterval(() => { const interval = setInterval(() => {

View File

@ -0,0 +1,387 @@
<template>
<div class="settings-page">
<header class="settings-header">
<h1>设置</h1>
<button class="btn-back" @click="handleBack">返回</button>
</header>
<main class="settings-main">
<section class="settings-section">
<h2>后端服务器配置</h2>
<div class="form-group">
<label>API 地址</label>
<input
type="text"
v-model="apiUrl"
placeholder="http://localhost:7001"
@input="validateUrl"
/>
<p class="help-text">请输入后端服务器的完整地址包含 http:// https://</p>
<p v-if="urlError" class="error-text">{{ urlError }}</p>
</div>
<div class="form-group">
<label>WebSocket 地址</label>
<input
type="text"
v-model="wsUrl"
placeholder="ws://localhost:7001"
readonly
/>
<p class="help-text">WebSocket 地址会根据 API 地址自动生成</p>
</div>
<div class="form-actions">
<button class="btn-save" @click="handleSave" :disabled="loading || !!urlError">
{{ loading ? '保存中...' : '保存设置' }}
</button>
<button class="btn-test" @click="handleTest" :disabled="loading || !!urlError">
测试连接
</button>
<button class="btn-reset" @click="handleReset">
重置为默认值
</button>
</div>
<div v-if="testResult" class="test-result" :class="{ success: testResult.success, error: !testResult.success }">
<p>{{ testResult.message }}</p>
</div>
</section>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { setApiUrl, getApiUrl, getWsUrl } from '../utils/config'
import { clearBaseUrlCache } from '../api/backend'
import { closeWebSocket, clearWsUrlCache } from '../api/websocket'
import { clearAdminBaseUrlCache } from '../api/admin'
const apiUrl = ref('http://localhost:7001')
const wsUrl = ref('ws://localhost:7001')
const urlError = ref('')
const loading = ref(false)
const testResult = ref(null)
//
async function loadSettings() {
try {
const savedApiUrl = await getApiUrl()
apiUrl.value = savedApiUrl
updateWsUrl()
} catch (error) {
console.error('加载设置失败:', error)
}
}
// WebSocket URL
function updateWsUrl() {
wsUrl.value = apiUrl.value.replace(/^http/, 'ws')
}
// URL
function validateUrl() {
urlError.value = ''
testResult.value = null
if (!apiUrl.value) {
urlError.value = 'API 地址不能为空'
return false
}
try {
new URL(apiUrl.value)
//
if (!apiUrl.value.startsWith('http://') && !apiUrl.value.startsWith('https://')) {
urlError.value = 'URL 必须以 http:// 或 https:// 开头'
return false
}
} catch (error) {
urlError.value = '无效的 URL 格式'
return false
}
updateWsUrl()
return true
}
// API URL
watch(apiUrl, () => {
validateUrl()
})
//
async function handleSave() {
if (!validateUrl()) {
return
}
loading.value = true
try {
await setApiUrl(apiUrl.value)
//
clearBaseUrlCache()
clearWsUrlCache()
clearAdminBaseUrlCache()
//
closeWebSocket()
testResult.value = {
success: true,
message: '设置已保存!请刷新页面以应用新设置。'
}
//
setTimeout(() => {
if (confirm('设置已保存,是否立即刷新页面?')) {
window.location.reload()
}
}, 500)
} catch (error) {
testResult.value = {
success: false,
message: '保存失败: ' + error.message
}
} finally {
loading.value = false
}
}
//
async function handleTest() {
if (!validateUrl()) {
return
}
loading.value = true
testResult.value = null
try {
// API
const testUrl = `${apiUrl.value}/api/music/queue`
const response = await fetch(testUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
testResult.value = {
success: true,
message: '连接成功!服务器可以正常访问。'
}
} else {
testResult.value = {
success: false,
message: `连接失败: HTTP ${response.status}`
}
}
} catch (error) {
testResult.value = {
success: false,
message: '连接失败: ' + (error.message || '无法连接到服务器')
}
} finally {
loading.value = false
}
}
//
function handleReset() {
apiUrl.value = 'http://192.168.1.148:7001'
updateWsUrl()
urlError.value = ''
testResult.value = null
}
//
function handleBack() {
// Electron 使 hash
if (window.electronAPI) {
window.location.hash = ''
} else {
window.history.back()
}
}
onMounted(() => {
loadSettings()
})
</script>
<style scoped>
.settings-page {
min-height: 100vh;
background: #f5f5f5;
}
.settings-header {
background: white;
padding: 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.settings-header h1 {
margin: 0;
font-size: 24px;
color: #333;
}
.btn-back {
padding: 8px 16px;
background: #64748b;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.btn-back:hover {
background: #475569;
}
.settings-main {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.settings-section {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.settings-section h2 {
margin: 0 0 20px 0;
font-size: 20px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group input[readonly] {
background: #f8fafc;
color: #64748b;
cursor: not-allowed;
}
.help-text {
margin: 6px 0 0 0;
font-size: 12px;
color: #64748b;
}
.error-text {
margin: 6px 0 0 0;
font-size: 12px;
color: #ef4444;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
flex-wrap: wrap;
}
.btn-save,
.btn-test,
.btn-reset {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-save {
background: #3b82f6;
color: white;
}
.btn-save:hover:not(:disabled) {
background: #2563eb;
}
.btn-save:disabled {
background: #93c5fd;
cursor: not-allowed;
}
.btn-test {
background: #10b981;
color: white;
}
.btn-test:hover:not(:disabled) {
background: #059669;
}
.btn-test:disabled {
background: #6ee7b7;
cursor: not-allowed;
}
.btn-reset {
background: #e5e7eb;
color: #333;
}
.btn-reset:hover {
background: #d1d5db;
}
.test-result {
margin-top: 16px;
padding: 12px;
border-radius: 6px;
font-size: 14px;
}
.test-result.success {
background: #d1fae5;
color: #065f46;
border: 1px solid #10b981;
}
.test-result.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #ef4444;
}
</style>

View File

@ -0,0 +1,99 @@
// 配置管理工具
// 支持从 localStorage 或 Electron 获取 API 地址
const DEFAULT_API_URL = 'http://localhost:7001';
const DEFAULT_WS_URL = 'ws://localhost:7001';
/**
* 获取 API 地址
*/
export async function getApiUrl() {
// 检查是否在 Electron 环境中
if (window.electronAPI) {
try {
return await window.electronAPI.getApiUrl();
} catch (error) {
console.error('获取 Electron API 地址失败:', error);
}
}
// 从 localStorage 获取
const saved = localStorage.getItem('api_url');
if (saved) {
return saved;
}
// 开发环境使用默认值
if (import.meta.env.DEV) {
return DEFAULT_API_URL;
}
// 生产环境Electron 或 Web尝试从当前域名推断否则使用默认值
// 在 Electron 中,如果使用 file:// 协议,则使用默认值
if (window.location.protocol === 'file:') {
return DEFAULT_API_URL;
}
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
const host = window.location.host;
return `${protocol}//${host}`;
}
/**
* 设置 API 地址
*/
export async function setApiUrl(url) {
// 验证 URL 格式
try {
new URL(url);
} catch (error) {
throw new Error('无效的 URL 格式');
}
// 检查是否在 Electron 环境中
if (window.electronAPI) {
try {
await window.electronAPI.setApiUrl(url);
return;
} catch (error) {
console.error('设置 Electron API 地址失败:', error);
}
}
// 保存到 localStorage
localStorage.setItem('api_url', url);
}
/**
* 获取 WebSocket 地址
*/
export async function getWsUrl() {
// 检查是否在 Electron 环境中
if (window.electronAPI) {
try {
return await window.electronAPI.getWsUrl();
} catch (error) {
console.error('获取 Electron WebSocket 地址失败:', error);
}
}
// 从 localStorage 获取 API 地址并转换为 WebSocket 地址
const apiUrl = await getApiUrl();
return apiUrl.replace(/^http/, 'ws');
}
/**
* 获取基础 URL用于 API 请求
*/
export async function getBaseUrl() {
const apiUrl = await getApiUrl();
// 如果在 Electron 或生产环境中,直接使用 API 地址
if (window.electronAPI || !import.meta.env.DEV) {
return apiUrl;
}
// 开发环境:使用相对路径,通过 Vite 代理
return '';
}

View File

@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
base: './', // 使用相对路径,便于 Electron 打包
server: { server: {
port: 5173, port: 5173,
host: true, host: true,
@ -21,8 +22,15 @@ export default defineConfig({
}, },
}, },
}, },
build: {
outDir: 'dist',
// 确保所有路由都返回 index.htmlSPA 路由支持) // 确保所有路由都返回 index.htmlSPA 路由支持)
// Vite 开发服务器默认支持 history API fallback rollupOptions: {
input: {
main: './index.html',
},
},
},
}) })

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@ export class AdminController {
return false; return false;
} }
const adminToken = process.env.ADMIN_TOKEN || 'admin123'; const adminToken = process.env.ADMIN_TOKEN || 'admin1a3sd21as5d3asd21w3a524sd1w3a52s1da23';
if (token !== adminToken) { if (token !== adminToken) {
this.ctx.status = 403; this.ctx.status = 403;
@ -48,7 +48,7 @@ export class AdminController {
*/ */
@Post('/login') @Post('/login')
async login(@Body() body: { password: string }) { async login(@Body() body: { password: string }) {
const adminPassword = process.env.ADMIN_PASSWORD || 'admin'; const adminPassword = process.env.ADMIN_PASSWORD || 'adminYeeywwaea1sd23w1a32sd1was53dw';
if (body.password !== adminPassword) { if (body.password !== adminPassword) {
return { return {
@ -59,7 +59,7 @@ export class AdminController {
} }
// 返回token实际生产环境应使用JWT等更安全的方式 // 返回token实际生产环境应使用JWT等更安全的方式
const token = process.env.ADMIN_TOKEN || 'admin123'; const token = process.env.ADMIN_TOKEN || 'admin1a3sd21as5d3asd21w3a524sd1w3a52s1da23';
return { return {
success: true, success: true,

View File

@ -19,7 +19,7 @@ export class AdminMiddleware implements IMiddleware<Context, NextFunction> {
// 简单的token验证实际生产环境应使用更安全的方式 // 简单的token验证实际生产环境应使用更安全的方式
// 这里使用配置中的admin token // 这里使用配置中的admin token
const adminToken = process.env.ADMIN_TOKEN || 'admin123'; const adminToken = process.env.ADMIN_TOKEN || 'admin1a3sd21as5d3asd21w3a524sd1w3a52s1da23';
if (token !== adminToken) { if (token !== adminToken) {
ctx.status = 403; ctx.status = 403;