refactor(ClientView): 重构客户端视图界面样式和组件结构
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m31s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m25s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m15s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m39s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m49s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m56s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m16s

- 替换 naive-ui 组件为自定义玻璃态设计组件
- 添加粒子动画背景效果
- 优化页面布局结构和响应式设计
- 移除未使用的 NCard、NTable、NStatistic 等组件导入
- 重构规则表格和插件列表的展示方式
- 更新模态框标题和简化配置表单
- 调整头部导航和底部栏样式
- 优化卡片组件的视觉效果和交互反馈
This commit is contained in:
Flik
2026-01-22 16:16:17 +08:00
parent 381c6911af
commit 4500f48d4c
5 changed files with 1595 additions and 728 deletions

View File

@@ -175,11 +175,13 @@ const themeOverrides: GlobalThemeOverrides = {
.header { .header {
height: 60px; height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: rgba(30, 27, 75, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 24px; padding: 0 24px;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
} }
.header-content { .header-content {
@@ -226,13 +228,13 @@ const themeOverrides: GlobalThemeOverrides = {
.main-content { .main-content {
flex: 1; flex: 1;
padding: 0; padding: 0;
background-color: transparent; background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
overflow-y: auto; overflow-y: auto;
} }
.footer { .footer {
height: 48px; height: 48px;
background: rgba(255, 255, 255, 0.05); background: rgba(30, 27, 75, 0.9);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui'
import { login, setToken } from '../api' import { login, setToken } from '../api'
const router = useRouter() const router = useRouter()
@@ -33,51 +32,66 @@ const handleLogin = async () => {
<template> <template>
<div class="login-page"> <div class="login-page">
<n-card class="login-card" :bordered="false"> <!-- Animated particles -->
<template #header> <div class="particles">
<div class="login-header"> <div class="particle particle-1"></div>
<h1 class="logo">GoTunnel</h1> <div class="particle particle-2"></div>
<p class="subtitle">安全的内网穿透工具</p> <div class="particle particle-3"></div>
</div> <div class="particle particle-4"></div>
</template> </div>
<n-form @submit.prevent="handleLogin"> <!-- Login card -->
<n-form-item label="用户名"> <div class="login-card">
<n-input <div class="login-header">
v-model:value="username" <div class="logo-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h1 class="logo-text">GoTunnel</h1>
<p class="subtitle">安全的内网穿透工具</p>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label class="form-label">用户名</label>
<input
v-model="username"
type="text"
class="glass-input"
placeholder="请输入用户名" placeholder="请输入用户名"
:disabled="loading" :disabled="loading"
/> />
</n-form-item> </div>
<n-form-item label="密码"> <div class="form-group">
<n-input <label class="form-label">密码</label>
v-model:value="password" <input
v-model="password"
type="password" type="password"
class="glass-input"
placeholder="请输入密码" placeholder="请输入密码"
:disabled="loading" :disabled="loading"
show-password-on="click"
/> />
</n-form-item> </div>
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 16px;"> <div v-if="error" class="error-alert">
{{ error }} <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</n-alert> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
</div>
<n-button <button type="submit" class="glass-button" :disabled="loading">
type="primary" <span v-if="loading" class="loading-spinner"></span>
block
:loading="loading"
attr-type="submit"
>
{{ loading ? '登录中...' : '登录' }} {{ loading ? '登录中...' : '登录' }}
</n-button> </button>
</n-form> </form>
<template #footer> <div class="login-footer">
<div class="login-footer">欢迎使用 GoTunnel</div> <span>欢迎使用 GoTunnel</span>
</template> </div>
</n-card> </div>
</div> </div>
</template> </template>
@@ -87,36 +101,236 @@ const handleLogin = async () => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
padding: 16px; padding: 16px;
position: relative;
overflow: hidden;
} }
/* Particles */
.particles {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.particle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
animation: float-particle 20s ease-in-out infinite;
}
.particle-1 {
width: 300px;
height: 300px;
top: -100px;
left: -100px;
animation-delay: 0s;
}
.particle-2 {
width: 200px;
height: 200px;
bottom: -50px;
right: -50px;
animation-delay: -5s;
}
.particle-3 {
width: 150px;
height: 150px;
top: 50%;
right: 10%;
animation-delay: -10s;
}
.particle-4 {
width: 100px;
height: 100px;
bottom: 20%;
left: 10%;
animation-delay: -15s;
}
@keyframes float-particle {
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
25% { transform: translate(30px, -40px) scale(1.1); opacity: 0.5; }
50% { transform: translate(-20px, -80px) scale(0.9); opacity: 0.4; }
75% { transform: translate(-40px, -40px) scale(1.05); opacity: 0.35; }
}
/* Login card */
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
padding: 40px;
position: relative;
z-index: 10;
} }
/* Header */
.login-header { .login-header {
text-align: center; text-align: center;
margin-bottom: 32px;
} }
.logo { .logo-icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(96, 165, 250, 0.4);
}
.logo-icon svg {
color: white;
width: 32px;
height: 32px;
}
.logo-text {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: #18a058; color: white;
margin: 0 0 8px 0; margin: 0 0 8px 0;
} }
.subtitle { .subtitle {
color: #666; color: rgba(255, 255, 255, 0.6);
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
} }
.login-footer { /* Form */
text-align: center; .login-form {
color: #999; display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
}
.glass-input {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 14px 16px;
color: white;
font-size: 15px;
width: 100%;
transition: all 0.2s ease;
outline: none;
}
.glass-input:focus {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
}
.glass-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.glass-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Error alert */
.error-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
color: #fca5a5;
font-size: 14px; font-size: 14px;
} }
.error-alert svg {
flex-shrink: 0;
}
/* Button */
.glass-button {
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
border: none;
border-radius: 12px;
padding: 14px 24px;
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 16px rgba(96, 165, 250, 0.4);
}
.glass-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(96, 165, 250, 0.5);
}
.glass-button:active:not(:disabled) {
transform: translateY(0);
}
.glass-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* Loading spinner */
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Footer */
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
</style> </style>

View File

@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { import {
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NButton, NSpace, NTag, NIcon, NSwitch, NModal, NInput, NInputNumber, NSelect,
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage, useMessage
NSelect, NModal, NInput, NInputNumber
} from 'naive-ui' } from 'naive-ui'
import { ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline, SettingsOutline } from '@vicons/ionicons5' import { ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline, SettingsOutline } from '@vicons/ionicons5'
import { import {
@@ -45,13 +44,8 @@ const loadStorePlugins = async () => {
} }
} }
const proxyPlugins = computed(() => const proxyPlugins = computed(() => plugins.value.filter(p => p.type === 'proxy'))
plugins.value.filter(p => p.type === 'proxy') const appPlugins = computed(() => plugins.value.filter(p => p.type === 'app'))
)
const appPlugins = computed(() =>
plugins.value.filter(p => p.type === 'app')
)
const togglePlugin = async (plugin: PluginInfo) => { const togglePlugin = async (plugin: PluginInfo) => {
try { try {
@@ -69,45 +63,15 @@ const togglePlugin = async (plugin: PluginInfo) => {
} }
const getTypeLabel = (type: string) => { const getTypeLabel = (type: string) => {
const labels: Record<string, string> = { const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
proxy: '协议',
app: '应用',
service: '服务',
tool: '工具'
}
return labels[type] || type return labels[type] || type
} }
const getTypeColor = (type: string) => {
const colors: Record<string, 'info' | 'success' | 'warning' | 'error' | 'default'> = {
proxy: 'info',
app: 'success',
service: 'warning',
tool: 'default'
}
return colors[type] || 'default'
}
const handleTabChange = (tab: string) => { const handleTabChange = (tab: string) => {
if (tab === 'store' && storePlugins.value.length === 0) { if (tab === 'store' && storePlugins.value.length === 0) loadStorePlugins()
loadStorePlugins() if (tab === 'js' && jsPlugins.value.length === 0) loadJSPlugins()
}
if (tab === 'js' && jsPlugins.value.length === 0) {
loadJSPlugins()
}
} }
// JS 插件相关
/* 安全加固:暂时禁用创建/删除功能
const showJSModal = ref(false)
const jsForm = ref<JSPlugin>({...})
const configItems = ref<Array<{ key: string; value: string }>>([])
const configToObject = () => {...}
const handleCreateJSPlugin = async () => {...}
const handleDeleteJSPlugin = async (name: string) => {...}
const resetJSForm = () => {...}
*/
const loadJSPlugins = async () => { const loadJSPlugins = async () => {
jsLoading.value = true jsLoading.value = true
try { try {
@@ -129,7 +93,7 @@ const loadClients = async () => {
} }
} }
// JS 插件推送相关 // JS Plugin Push
const showPushModal = ref(false) const showPushModal = ref(false)
const selectedJSPlugin = ref<JSPlugin | null>(null) const selectedJSPlugin = ref<JSPlugin | null>(null)
const pushClientId = ref('') const pushClientId = ref('')
@@ -151,7 +115,7 @@ const handlePushJSPlugin = async () => {
pushing.value = true pushing.value = true
try { try {
await pushJSPluginToClient(selectedJSPlugin.value.name, pushClientId.value, pushRemotePort.value || 0) await pushJSPluginToClient(selectedJSPlugin.value.name, pushClientId.value, pushRemotePort.value || 0)
message.success(`已推送 ${selectedJSPlugin.value.name}${pushClientId.value},监听端口: ${pushRemotePort.value || '未指定'}`) message.success(`已推送 ${selectedJSPlugin.value.name}`)
showPushModal.value = false showPushModal.value = false
} catch (e: any) { } catch (e: any) {
message.error(e.response?.data || '推送失败') message.error(e.response?.data || '推送失败')
@@ -162,7 +126,7 @@ const handlePushJSPlugin = async () => {
const onlineClients = computed(() => clients.value.filter(c => c.online)) const onlineClients = computed(() => clients.value.filter(c => c.online))
// JS 插件配置相关 // JS Plugin Config
const showJSConfigModal = ref(false) const showJSConfigModal = ref(false)
const currentJSPlugin = ref<JSPlugin | null>(null) const currentJSPlugin = ref<JSPlugin | null>(null)
const jsConfigItems = ref<Array<{ key: string; value: string }>>([]) const jsConfigItems = ref<Array<{ key: string; value: string }>>([])
@@ -170,40 +134,25 @@ const jsConfigSaving = ref(false)
const openJSConfigModal = (plugin: JSPlugin) => { const openJSConfigModal = (plugin: JSPlugin) => {
currentJSPlugin.value = plugin currentJSPlugin.value = plugin
// 将 config 转换为数组形式便于编辑
jsConfigItems.value = Object.entries(plugin.config || {}).map(([key, value]) => ({ key, value })) jsConfigItems.value = Object.entries(plugin.config || {}).map(([key, value]) => ({ key, value }))
if (jsConfigItems.value.length === 0) { if (jsConfigItems.value.length === 0) jsConfigItems.value.push({ key: '', value: '' })
jsConfigItems.value.push({ key: '', value: '' })
}
showJSConfigModal.value = true showJSConfigModal.value = true
} }
const addJSConfigItem = () => { const addJSConfigItem = () => jsConfigItems.value.push({ key: '', value: '' })
jsConfigItems.value.push({ key: '', value: '' }) const removeJSConfigItem = (index: number) => jsConfigItems.value.splice(index, 1)
}
const removeJSConfigItem = (index: number) => {
jsConfigItems.value.splice(index, 1)
}
const saveJSPluginConfig = async () => { const saveJSPluginConfig = async () => {
if (!currentJSPlugin.value) return if (!currentJSPlugin.value) return
jsConfigSaving.value = true jsConfigSaving.value = true
try { try {
// 将数组转换回对象
const config: Record<string, string> = {} const config: Record<string, string> = {}
for (const item of jsConfigItems.value) { for (const item of jsConfigItems.value) {
if (item.key.trim()) { if (item.key.trim()) config[item.key.trim()] = item.value
config[item.key.trim()] = item.value
}
} }
await updateJSPluginConfig(currentJSPlugin.value.name, config) await updateJSPluginConfig(currentJSPlugin.value.name, config)
// 更新本地数据
const plugin = jsPlugins.value.find(p => p.name === currentJSPlugin.value!.name) const plugin = jsPlugins.value.find(p => p.name === currentJSPlugin.value!.name)
if (plugin) { if (plugin) plugin.config = config
plugin.config = config
}
message.success('配置已保存') message.success('配置已保存')
showJSConfigModal.value = false showJSConfigModal.value = false
} catch (e: any) { } catch (e: any) {
@@ -213,7 +162,6 @@ const saveJSPluginConfig = async () => {
} }
} }
// 切换 JS 插件启用状态
const toggleJSPlugin = async (plugin: JSPlugin) => { const toggleJSPlugin = async (plugin: JSPlugin) => {
try { try {
await setJSPluginEnabled(plugin.name, !plugin.enabled) await setJSPluginEnabled(plugin.name, !plugin.enabled)
@@ -224,7 +172,7 @@ const toggleJSPlugin = async (plugin: JSPlugin) => {
} }
} }
// 商店插件安装相关 // Store Plugin Install
const showInstallModal = ref(false) const showInstallModal = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null) const selectedStorePlugin = ref<StorePluginInfo | null>(null)
const selectedClientId = ref('') const selectedClientId = ref('')
@@ -249,12 +197,8 @@ const handleInstallStorePlugin = async () => {
message.warning('请选择要安装到的客户端') message.warning('请选择要安装到的客户端')
return return
} }
if (!selectedStorePlugin.value.download_url) { if (!selectedStorePlugin.value.download_url || !selectedStorePlugin.value.signature_url) {
message.error('该插件没有下载地址') message.error('该插件缺少下载地址或签名')
return
}
if (!selectedStorePlugin.value.signature_url) {
message.error('该插件没有签名文件')
return return
} }
installing.value = true installing.value = true
@@ -287,202 +231,164 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="plugins-view"> <div class="plugins-page">
<div class="page-header"> <!-- Particles -->
<h2>插件管理</h2> <div class="particles">
<p>管理已安装插件和浏览插件商店</p> <div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div> </div>
<n-tabs v-model:value="activeTab" type="line" @update:value="handleTabChange"> <div class="plugins-content">
<!-- 已安装插件 --> <!-- Header -->
<n-tab-pane name="installed" tab="已安装插件"> <div class="page-header">
<n-spin :show="loading"> <h1 class="page-title">插件管理</h1>
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;"> <p class="page-subtitle">管理已安装插件和浏览插件商店</p>
<n-gi> </div>
<n-card>
<n-statistic label="总插件数" :value="plugins.length" />
</n-card>
</n-gi>
<n-gi>
<n-card>
<n-statistic label="协议插件" :value="proxyPlugins.length" />
</n-card>
</n-gi>
<n-gi>
<n-card>
<n-statistic label="应用插件" :value="appPlugins.length" />
</n-card>
</n-gi>
</n-grid>
<n-empty v-if="!loading && plugins.length === 0" description="暂无已安装插件" /> <!-- Stats Row -->
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ plugins.length }}</span>
<span class="stat-label">总插件数</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ proxyPlugins.length }}</span>
<span class="stat-label">协议插件</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ appPlugins.length }}</span>
<span class="stat-label">应用插件</span>
</div>
</div>
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2"> <!-- Tabs -->
<n-gi v-for="plugin in plugins" :key="plugin.name"> <div class="glass-card">
<n-card hoverable> <div class="tabs-header">
<template #header> <button class="tab-btn" :class="{ active: activeTab === 'installed' }" @click="activeTab = 'installed'">
<n-space align="center"> 已安装插件
<img v-if="plugin.icon" :src="plugin.icon" style="width: 24px; height: 24px;" /> </button>
<n-icon v-else size="24" color="#18a058"><ExtensionPuzzleOutline /></n-icon> <button class="tab-btn" :class="{ active: activeTab === 'store' }" @click="activeTab = 'store'; handleTabChange('store')">
<span>{{ plugin.name }}</span> 插件商店
</n-space> </button>
</template> <button class="tab-btn" :class="{ active: activeTab === 'js' }" @click="activeTab = 'js'; handleTabChange('js')">
<template #header-extra> JS 插件
<n-switch :value="plugin.enabled" @update:value="togglePlugin(plugin)" /> </button>
</template> </div>
<n-space vertical :size="8">
<n-space> <!-- Installed Plugins Tab -->
<n-tag size="small">v{{ plugin.version }}</n-tag> <div v-if="activeTab === 'installed'" class="tab-content">
<n-tag size="small" :type="getTypeColor(plugin.type)"> <div v-if="loading" class="loading-state">加载中...</div>
{{ getTypeLabel(plugin.type) }} <div v-else-if="plugins.length === 0" class="empty-state">暂无已安装插件</div>
</n-tag> <div v-else class="plugins-grid">
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'info'"> <div v-for="plugin in plugins" :key="plugin.name" class="plugin-card">
{{ plugin.source === 'builtin' ? '内置' : 'JS' }} <div class="plugin-header">
</n-tag> <div class="plugin-icon">
</n-space> <n-icon size="20" color="#a78bfa"><ExtensionPuzzleOutline /></n-icon>
<p style="margin: 0; color: #666;">{{ plugin.description }}</p> </div>
<span class="plugin-name">{{ plugin.name }}</span>
<n-switch :value="plugin.enabled" size="small" @update:value="togglePlugin(plugin)" />
</div>
<div class="plugin-tags">
<n-tag size="small">v{{ plugin.version }}</n-tag>
<n-tag size="small" :type="plugin.type === 'proxy' ? 'info' : 'success'">{{ getTypeLabel(plugin.type) }}</n-tag>
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'warning'">
{{ plugin.source === 'builtin' ? '内置' : 'JS' }}
</n-tag>
</div>
<p class="plugin-desc">{{ plugin.description }}</p>
</div>
</div>
</div>
<!-- Store Tab -->
<div v-if="activeTab === 'store'" class="tab-content">
<div v-if="storeLoading" class="loading-state">加载中...</div>
<div v-else-if="storePlugins.length === 0" class="empty-state">插件商店暂无可用插件</div>
<div v-else class="plugins-grid">
<div v-for="plugin in storePlugins" :key="plugin.name" class="plugin-card">
<div class="plugin-header">
<div class="plugin-icon store">
<n-icon size="20" color="#60a5fa"><StorefrontOutline /></n-icon>
</div>
<span class="plugin-name">{{ plugin.name }}</span>
<button
v-if="plugin.download_url && plugin.signature_url && onlineClients.length > 0"
class="glass-btn primary tiny"
@click="openInstallModal(plugin)"
>安装</button>
</div>
<div class="plugin-tags">
<n-tag size="small">v{{ plugin.version }}</n-tag>
<n-tag size="small" :type="plugin.type === 'proxy' ? 'info' : 'success'">{{ getTypeLabel(plugin.type) }}</n-tag>
</div>
<p class="plugin-desc">{{ plugin.description }}</p>
<p class="plugin-author">作者: {{ plugin.author }}</p>
</div>
</div>
</div>
<!-- JS Plugins Tab -->
<div v-if="activeTab === 'js'" class="tab-content">
<div v-if="jsLoading" class="loading-state">加载中...</div>
<div v-else-if="jsPlugins.length === 0" class="empty-state">暂无 JS 插件</div>
<div v-else class="plugins-grid wide">
<div v-for="plugin in jsPlugins" :key="plugin.name" class="plugin-card js">
<div class="plugin-header">
<div class="plugin-icon js">
<n-icon size="20" color="#fbbf24"><CodeSlashOutline /></n-icon>
</div>
<span class="plugin-name">{{ plugin.name }}</span>
<n-tag v-if="plugin.version" size="small">v{{ plugin.version }}</n-tag>
<n-switch :value="plugin.enabled" size="small" @update:value="toggleJSPlugin(plugin)" />
</div>
<div class="plugin-tags">
<n-tag size="small" type="warning">JS</n-tag>
<n-tag v-if="plugin.auto_start" size="small" type="success">自动启动</n-tag>
<n-tag v-if="plugin.signature" size="small" type="info">已签名</n-tag>
</div>
<p class="plugin-desc">{{ plugin.description || '无描述' }}</p>
<p v-if="plugin.author" class="plugin-author">作者: {{ plugin.author }}</p>
<div v-if="Object.keys(plugin.config || {}).length > 0" class="plugin-config-preview">
<span class="config-label">配置:</span>
<n-space :size="4" wrap>
<n-tag v-for="(value, key) in plugin.config" :key="key" size="small">
{{ key }}: {{ String(value).length > 10 ? String(value).slice(0, 10) + '...' : value }}
</n-tag>
</n-space> </n-space>
</n-card> </div>
</n-gi> <div class="plugin-actions">
</n-grid> <button class="glass-btn tiny" @click="openJSConfigModal(plugin)">
</n-spin> <n-icon size="14"><SettingsOutline /></n-icon>
</n-tab-pane> 配置
</button>
<button v-if="onlineClients.length > 0" class="glass-btn primary tiny" @click="openPushModal(plugin)">
推送到客户端
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 插件商店 --> <!-- Install Modal -->
<n-tab-pane name="store" tab="插件商店">
<n-spin :show="storeLoading">
<n-empty v-if="!storeLoading && storePlugins.length === 0" description="插件商店暂无可用插件" />
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
<n-gi v-for="plugin in storePlugins" :key="plugin.name">
<n-card hoverable>
<template #header>
<n-space align="center">
<img v-if="plugin.icon" :src="plugin.icon" style="width: 24px; height: 24px;" />
<n-icon v-else size="24" color="#18a058"><StorefrontOutline /></n-icon>
<span>{{ plugin.name }}</span>
</n-space>
</template>
<template #header-extra>
<n-button
v-if="plugin.download_url && plugin.signature_url && onlineClients.length > 0"
size="small"
type="primary"
@click="openInstallModal(plugin)"
>
安装
</n-button>
</template>
<n-space vertical :size="8">
<n-space>
<n-tag size="small">v{{ plugin.version }}</n-tag>
<n-tag size="small" :type="getTypeColor(plugin.type)">
{{ getTypeLabel(plugin.type) }}
</n-tag>
</n-space>
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
<p style="margin: 0; color: #999; font-size: 12px;">作者: {{ plugin.author }}</p>
</n-space>
</n-card>
</n-gi>
</n-grid>
</n-spin>
</n-tab-pane>
<!-- JS 插件 -->
<n-tab-pane name="js" tab="JS 插件">
<n-spin :show="jsLoading">
<n-empty v-if="!jsLoading && jsPlugins.length === 0" description="暂无 JS 插件" />
<n-grid v-else :cols="2" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1">
<n-gi v-for="plugin in jsPlugins" :key="plugin.name">
<n-card hoverable>
<template #header>
<n-space align="center">
<n-icon size="24" color="#f0a020"><CodeSlashOutline /></n-icon>
<span>{{ plugin.name }}</span>
<n-tag v-if="plugin.version" size="small">v{{ plugin.version }}</n-tag>
</n-space>
</template>
<template #header-extra>
<n-switch :value="plugin.enabled" @update:value="toggleJSPlugin(plugin)" />
</template>
<n-space vertical :size="8">
<n-space>
<n-tag size="small" type="warning">JS</n-tag>
<n-tag v-if="plugin.auto_start" size="small" type="success">自动启动</n-tag>
<n-tag v-if="plugin.signature" size="small" type="info">已签名</n-tag>
</n-space>
<p style="margin: 0; color: #666;">{{ plugin.description || '无描述' }}</p>
<p v-if="plugin.author" style="margin: 0; color: #999; font-size: 12px;">作者: {{ plugin.author }}</p>
<!-- 配置预览 -->
<div v-if="Object.keys(plugin.config || {}).length > 0" style="margin-top: 8px;">
<p style="margin: 0 0 4px 0; color: #999; font-size: 12px;">配置:</p>
<n-space :size="4" wrap>
<n-tag v-for="(value, key) in plugin.config" :key="key" size="small" type="default">
{{ key }}: {{ value.length > 10 ? value.slice(0, 10) + '...' : value }}
</n-tag>
</n-space>
</div>
</n-space>
<template #action>
<n-space justify="space-between">
<n-button size="small" quaternary @click="openJSConfigModal(plugin)">
<template #icon><n-icon><SettingsOutline /></n-icon></template>
配置
</n-button>
<n-button
v-if="onlineClients.length > 0"
size="small"
type="primary"
@click="openPushModal(plugin)"
>
推送到客户端
</n-button>
</n-space>
</template>
</n-card>
</n-gi>
</n-grid>
</n-spin>
</n-tab-pane>
</n-tabs>
<!-- 安全加固暂时禁用创建 JS 插件 Modal
<n-modal v-model:show="showJSModal" preset="card" title="新建 JS 插件" style="width: 600px;">
... 已屏蔽 ...
</n-modal>
-->
<!-- 安装商店插件模态框 -->
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件" style="width: 450px;"> <n-modal v-model:show="showInstallModal" preset="card" title="安装插件" style="width: 450px;">
<n-space vertical :size="16"> <n-space vertical :size="16">
<div v-if="selectedStorePlugin"> <div v-if="selectedStorePlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedStorePlugin.name }}</p> <p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedStorePlugin.name }}</p>
<p style="margin: 0; color: #666;">{{ selectedStorePlugin.description }}</p> <p style="margin: 0; color: #666;">{{ selectedStorePlugin.description }}</p>
</div> </div>
<n-select <n-select v-model:value="selectedClientId" placeholder="选择客户端"
v-model:value="selectedClientId" :options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))" />
placeholder="选择要安装到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<div> <div>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口:</p> <p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口:</p>
<n-input-number <n-input-number v-model:value="installRemotePort" :min="1" :max="65535" style="width: 100%;" />
v-model:value="installRemotePort"
:min="1"
:max="65535"
placeholder="输入端口号"
style="width: 100%;"
/>
</div>
<div>
<n-space align="center" :size="8">
<n-switch v-model:value="installAuthEnabled" />
<span style="color: #666;">启用 HTTP Basic Auth</span>
</n-space>
</div> </div>
<n-space align="center" :size="8">
<n-switch v-model:value="installAuthEnabled" />
<span style="color: #666;">启用 HTTP Basic Auth</span>
</n-space>
<template v-if="installAuthEnabled"> <template v-if="installAuthEnabled">
<n-input v-model:value="installAuthUsername" placeholder="用户名" /> <n-input v-model:value="installAuthUsername" placeholder="用户名" />
<n-input v-model:value="installAuthPassword" type="password" placeholder="密码" show-password-on="click" /> <n-input v-model:value="installAuthPassword" type="password" placeholder="密码" show-password-on="click" />
@@ -491,29 +397,20 @@ onMounted(() => {
<template #footer> <template #footer>
<n-space justify="end"> <n-space justify="end">
<n-button @click="showInstallModal = false">取消</n-button> <n-button @click="showInstallModal = false">取消</n-button>
<n-button <n-button type="primary" :loading="installing" :disabled="!selectedClientId" @click="handleInstallStorePlugin">安装</n-button>
type="primary"
:loading="installing"
:disabled="!selectedClientId"
@click="handleInstallStorePlugin"
>
安装
</n-button>
</n-space> </n-space>
</template> </template>
</n-modal> </n-modal>
<!-- JS 插件配置模态框 --> <!-- JS Config Modal -->
<n-modal v-model:show="showJSConfigModal" preset="card" :title="`${currentJSPlugin?.name || ''} 配置`" style="width: 500px;"> <n-modal v-model:show="showJSConfigModal" preset="card" :title="`${currentJSPlugin?.name || ''} 配置`" style="width: 500px;">
<n-space vertical :size="12"> <n-space vertical :size="12">
<p style="margin: 0; color: #666; font-size: 13px;">编辑插件配置参数键值对形式</p> <p style="margin: 0; color: #666; font-size: 13px;">编辑插件配置参数</p>
<div v-for="(item, index) in jsConfigItems" :key="index"> <div v-for="(item, index) in jsConfigItems" :key="index">
<n-space :size="8" align="center"> <n-space :size="8" align="center">
<n-input v-model:value="item.key" placeholder="参数名" style="width: 150px;" /> <n-input v-model:value="item.key" placeholder="参数名" style="width: 150px;" />
<n-input v-model:value="item.value" placeholder="参数值" style="width: 200px;" /> <n-input v-model:value="item.value" placeholder="参数值" style="width: 200px;" />
<n-button v-if="jsConfigItems.length > 1" quaternary type="error" size="small" @click="removeJSConfigItem(index)"> <n-button v-if="jsConfigItems.length > 1" quaternary type="error" size="small" @click="removeJSConfigItem(index)">删除</n-button>
删除
</n-button>
</n-space> </n-space>
</div> </div>
<n-button dashed size="small" @click="addJSConfigItem">添加配置项</n-button> <n-button dashed size="small" @click="addJSConfigItem">添加配置项</n-button>
@@ -526,41 +423,24 @@ onMounted(() => {
</template> </template>
</n-modal> </n-modal>
<!-- JS 插件推送模态框 --> <!-- Push Modal -->
<n-modal v-model:show="showPushModal" preset="card" title="推送插件到客户端" style="width: 400px;"> <n-modal v-model:show="showPushModal" preset="card" title="推送插件到客户端" style="width: 400px;">
<n-space vertical :size="16"> <n-space vertical :size="16">
<div v-if="selectedJSPlugin"> <div v-if="selectedJSPlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedJSPlugin.name }}</p> <p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedJSPlugin.name }}</p>
<p style="margin: 0; color: #666;">{{ selectedJSPlugin.description || '无描述' }}</p> <p style="margin: 0; color: #666;">{{ selectedJSPlugin.description || '无描述' }}</p>
</div> </div>
<n-select <n-select v-model:value="pushClientId" placeholder="选择客户端"
v-model:value="pushClientId" :options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))" />
placeholder="选择要推送到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<div> <div>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口服务端监听端口:</p> <p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口:</p>
<n-input-number <n-input-number v-model:value="pushRemotePort" :min="1" :max="65535" style="width: 100%;" />
v-model:value="pushRemotePort"
:min="1"
:max="65535"
placeholder="输入端口号"
style="width: 100%;"
/>
<p style="margin: 8px 0 0 0; color: #999; font-size: 12px;">用户可以通过 服务端IP:端口 访问此插件提供的服务</p>
</div> </div>
</n-space> </n-space>
<template #footer> <template #footer>
<n-space justify="end"> <n-space justify="end">
<n-button @click="showPushModal = false">取消</n-button> <n-button @click="showPushModal = false">取消</n-button>
<n-button <n-button type="primary" :loading="pushing" :disabled="!pushClientId" @click="handlePushJSPlugin">推送</n-button>
type="primary"
:loading="pushing"
:disabled="!pushClientId"
@click="handlePushJSPlugin"
>
推送
</n-button>
</n-space> </n-space>
</template> </template>
</n-modal> </n-modal>
@@ -568,7 +448,39 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.plugins-view { .plugins-page {
min-height: calc(100vh - 108px);
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
position: relative;
overflow: hidden;
padding: 32px;
}
.particles {
position: absolute;
inset: 0;
pointer-events: none;
}
.particle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
animation: float-particle 20s ease-in-out infinite;
}
.particle-1 { width: 250px; height: 250px; top: -80px; right: -50px; }
.particle-2 { width: 180px; height: 180px; bottom: 10%; left: 5%; animation-delay: -7s; }
.particle-3 { width: 120px; height: 120px; top: 50%; right: 15%; animation-delay: -12s; }
@keyframes float-particle {
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
50% { transform: translate(-20px, -60px) scale(0.95); opacity: 0.4; }
}
.plugins-content {
position: relative;
z-index: 10;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@@ -577,15 +489,226 @@ onMounted(() => {
margin-bottom: 24px; margin-bottom: 24px;
} }
.page-header h2 { .page-title {
font-size: 28px;
font-weight: 700;
color: white;
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
} }
.page-header p { .page-subtitle {
color: rgba(255, 255, 255, 0.6);
margin: 0; margin: 0;
color: #6b7280; font-size: 14px;
}
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 20px;
text-align: center;
}
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: white;
}
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
/* Glass Card */
.glass-card {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
overflow: hidden;
}
/* Tabs */
.tabs-header {
display: flex;
gap: 4px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tab-btn {
background: transparent;
border: none;
padding: 8px 16px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
}
.tab-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.tab-btn.active {
color: white;
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
}
.tab-content {
padding: 20px;
}
.loading-state, .empty-state {
text-align: center;
padding: 48px;
color: rgba(255, 255, 255, 0.5);
}
/* Plugins Grid */
.plugins-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.plugins-grid.wide {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 900px) {
.plugins-grid { grid-template-columns: repeat(2, 1fr); }
.plugins-grid.wide { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.plugins-grid { grid-template-columns: 1fr; }
}
/* Plugin Card */
.plugin-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.plugin-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.plugin-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(167, 139, 250, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.plugin-icon.store {
background: rgba(96, 165, 250, 0.2);
}
.plugin-icon.js {
background: rgba(251, 191, 36, 0.2);
}
.plugin-name {
flex: 1;
font-weight: 600;
color: white;
font-size: 14px;
}
.plugin-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.plugin-desc {
margin: 0;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
line-height: 1.5;
}
.plugin-author {
margin: 8px 0 0 0;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
}
.plugin-config-preview {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.config-label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 6px;
}
.plugin-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
/* Glass Button */
.glass-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 6px 12px;
color: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
.glass-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.glass-btn.primary {
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
border: none;
}
.glass-btn.tiny {
padding: 4px 10px;
font-size: 11px;
} }
</style> </style>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { import {
NCard, NButton, NSpace, NTag, NGrid, NGi, NEmpty, NSpin, NIcon, NTag, NIcon, useMessage, useDialog
NAlert, useMessage, useDialog
} from 'naive-ui' } from 'naive-ui'
import { CloudDownloadOutline, RefreshOutline, ServerOutline } from '@vicons/ionicons5' import { CloudDownloadOutline, RefreshOutline, ServerOutline } from '@vicons/ionicons5'
import { import {
@@ -88,109 +87,140 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="settings-view"> <div class="settings-page">
<div class="page-header"> <!-- Particles -->
<h2>系统设置</h2> <div class="particles">
<p>管理服务端配置和系统更新</p> <div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div> </div>
<n-spin :show="loading"> <div class="settings-content">
<!-- 当前版本信息 --> <!-- Header -->
<n-card title="版本信息" class="settings-card"> <div class="page-header">
<template #header-extra> <h1 class="page-title">系统设置</h1>
<n-icon size="20" color="#6366f1"><ServerOutline /></n-icon> <p class="page-subtitle">管理服务端配置和系统更新</p>
</template> </div>
<n-grid v-if="versionInfo" :cols="6" :x-gap="16" responsive="screen" cols-s="2" cols-m="3">
<n-gi>
<div class="info-item">
<span class="label">版本号</span>
<span class="value">{{ versionInfo.version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">Git 提交</span>
<span class="value">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">构建时间</span>
<span class="value">{{ versionInfo.build_time || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">Go 版本</span>
<span class="value">{{ versionInfo.go_version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">操作系统</span>
<span class="value">{{ versionInfo.os }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">架构</span>
<span class="value">{{ versionInfo.arch }}</span>
</div>
</n-gi>
</n-grid>
<n-empty v-else description="加载中..." />
</n-card>
<!-- 服务端更新 --> <!-- Version Info Card -->
<n-card title="服务端更新" class="settings-card"> <div class="glass-card">
<template #header-extra> <div class="card-header">
<n-button size="small" :loading="checkingServer" @click="handleCheckServerUpdate"> <h3>版本信息</h3>
<template #icon><n-icon><RefreshOutline /></n-icon></template> <n-icon size="20" color="#a78bfa"><ServerOutline /></n-icon>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="versionInfo" class="info-grid">
<div class="info-item">
<span class="info-label">版本号</span>
<span class="info-value">{{ versionInfo.version }}</span>
</div>
<div class="info-item">
<span class="info-label">Git 提交</span>
<span class="info-value mono">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">构建时间</span>
<span class="info-value">{{ versionInfo.build_time || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">Go 版本</span>
<span class="info-value">{{ versionInfo.go_version }}</span>
</div>
<div class="info-item">
<span class="info-label">操作系统</span>
<span class="info-value">{{ versionInfo.os }}</span>
</div>
<div class="info-item">
<span class="info-label">架构</span>
<span class="info-value">{{ versionInfo.arch }}</span>
</div>
</div>
<div v-else class="empty-state">无法加载版本信息</div>
</div>
</div>
<!-- Server Update Card -->
<div class="glass-card">
<div class="card-header">
<h3>服务端更新</h3>
<button class="glass-btn small" :disabled="checkingServer" @click="handleCheckServerUpdate">
<n-icon size="14"><RefreshOutline /></n-icon>
检查更新 检查更新
</n-button> </button>
</template> </div>
<div class="card-body">
<div v-if="!serverUpdate" class="empty-state">
点击检查更新按钮查看是否有新版本
</div>
<template v-else>
<div v-if="serverUpdate.available" class="update-alert success">
发现新版本 {{ serverUpdate.latest }}当前版本 {{ serverUpdate.current }}
</div>
<div v-else class="update-alert info">
当前已是最新版本 {{ serverUpdate.current }}
</div>
<n-empty v-if="!serverUpdate" description="点击检查更新按钮查看是否有新版本" /> <div v-if="serverUpdate.download_url" class="download-info">
下载文件: {{ serverUpdate.asset_name }}
<template v-else> <n-tag size="small" style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</n-tag>
<n-alert v-if="serverUpdate.available" type="success" style="margin-bottom: 16px;">
发现新版本 {{ serverUpdate.latest }}当前版本 {{ serverUpdate.current }}
</n-alert>
<n-alert v-else type="info" style="margin-bottom: 16px;">
当前已是最新版本 {{ serverUpdate.current }}
</n-alert>
<n-space vertical :size="12">
<div v-if="serverUpdate.download_url">
<p style="margin: 0 0 8px 0; color: #666;">
下载文件: {{ serverUpdate.asset_name }}
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</n-tag>
</p>
</div> </div>
<div v-if="serverUpdate.release_note" class="release-note"> <div v-if="serverUpdate.release_note" class="release-note">
<p style="margin: 0 0 4px 0; color: #666; font-size: 12px;">更新日志:</p> <span class="note-label">更新日志:</span>
<pre>{{ serverUpdate.release_note }}</pre> <pre>{{ serverUpdate.release_note }}</pre>
</div> </div>
<n-button <button
v-if="serverUpdate.available && serverUpdate.download_url" v-if="serverUpdate.available && serverUpdate.download_url"
type="primary" class="glass-btn primary"
:loading="updatingServer" :disabled="updatingServer"
@click="handleApplyServerUpdate" @click="handleApplyServerUpdate"
> >
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template> <n-icon size="16"><CloudDownloadOutline /></n-icon>
下载并更新服务端 下载并更新服务端
</n-button> </button>
</n-space> </template>
</template> </div>
</n-card> </div>
</n-spin> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.settings-view { .settings-page {
min-height: calc(100vh - 108px);
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
position: relative;
overflow: hidden;
padding: 32px;
}
.particles {
position: absolute;
inset: 0;
pointer-events: none;
}
.particle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
animation: float-particle 20s ease-in-out infinite;
}
.particle-1 { width: 250px; height: 250px; top: -80px; right: -50px; }
.particle-2 { width: 180px; height: 180px; bottom: 10%; left: 5%; animation-delay: -7s; }
.particle-3 { width: 120px; height: 120px; top: 50%; right: 15%; animation-delay: -12s; }
@keyframes float-particle {
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
50% { transform: translate(-20px, -60px) scale(0.95); opacity: 0.4; }
}
.settings-content {
position: relative;
z-index: 10;
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
} }
@@ -199,52 +229,169 @@ onMounted(() => {
margin-bottom: 24px; margin-bottom: 24px;
} }
.page-header h2 { .page-title {
font-size: 28px;
font-weight: 700;
color: white;
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
} }
.page-header p { .page-subtitle {
color: rgba(255, 255, 255, 0.6);
margin: 0; margin: 0;
color: #6b7280; font-size: 14px;
} }
.settings-card { /* Glass Card */
margin-bottom: 16px; .glass-card {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
margin-bottom: 20px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: white;
}
.card-body {
padding: 20px;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 600px) {
.info-grid { grid-template-columns: repeat(2, 1fr); }
} }
.info-item { .info-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 8px 0; gap: 4px;
} }
.info-item .label { .info-label {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: rgba(255, 255, 255, 0.5);
margin-bottom: 4px;
} }
.info-item .value { .info-value {
font-size: 14px; font-size: 14px;
color: #1f2937; color: white;
font-weight: 500; font-weight: 500;
} }
.info-value.mono {
font-family: monospace;
}
/* States */
.loading-state, .empty-state {
text-align: center;
padding: 32px;
color: rgba(255, 255, 255, 0.5);
}
/* Update Alert */
.update-alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
}
.update-alert.success {
background: rgba(52, 211, 153, 0.15);
border: 1px solid rgba(52, 211, 153, 0.3);
color: #34d399;
}
.update-alert.info {
background: rgba(96, 165, 250, 0.15);
border: 1px solid rgba(96, 165, 250, 0.3);
color: #60a5fa;
}
/* Download Info */
.download-info {
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
margin-bottom: 12px;
}
/* Release Note */
.release-note { .release-note {
max-height: 150px; margin-bottom: 16px;
overflow-y: auto; }
.note-label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 6px;
} }
.release-note pre { .release-note pre {
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
font-size: 12px; font-size: 12px;
color: #374151; color: rgba(255, 255, 255, 0.7);
background: #f9fafb; background: rgba(0, 0, 0, 0.2);
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 8px;
max-height: 150px;
overflow-y: auto;
}
/* Glass Button */
.glass-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 8px 16px;
color: white;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.glass-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
.glass-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.glass-btn.small {
padding: 6px 12px;
font-size: 12px;
}
.glass-btn.primary {
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
border: none;
} }
</style> </style>