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 {
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;
align-items: center;
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 {
@@ -226,13 +228,13 @@ const themeOverrides: GlobalThemeOverrides = {
.main-content {
flex: 1;
padding: 0;
background-color: transparent;
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
overflow-y: auto;
}
.footer {
height: 48px;
background: rgba(255, 255, 255, 0.05);
background: rgba(30, 27, 75, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1);

View File

@@ -2,15 +2,15 @@
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
NCard, NButton, NSpace, NTag, NTable, NEmpty,
NButton, NSpace, NTag, NEmpty,
NForm, NFormItem, NInput, NInputNumber, NSelect, NModal, NSwitch,
NIcon, useMessage, useDialog, NSpin, NGrid, NGridItem,
NStatistic, NDivider, NTooltip, NDropdown, type FormInst, type FormRules
NTooltip, NDropdown, type FormInst, type FormRules
} from 'naive-ui'
import {
ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, AddOutline, StorefrontOutline, DocumentTextOutline,
ExtensionPuzzleOutline, SettingsOutline, OpenOutline, CloudDownloadOutline, RefreshOutline
ExtensionPuzzleOutline, SettingsOutline, CloudDownloadOutline, RefreshOutline
} from '@vicons/ionicons5'
import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
@@ -73,7 +73,7 @@ const defaultRule = {
name: '',
local_ip: '127.0.0.1',
local_port: 80,
remote_port: 0, // 0 means unset/placeholder
remote_port: 0,
type: 'tcp',
enabled: true,
plugin_config: {} as Record<string, string>
@@ -219,14 +219,13 @@ const saveRename = async () => {
// Rule Management
const openCreateRule = () => {
ruleModalType.value = 'create'
ruleForm.value = { ...defaultRule, remote_port: 8080 } // Reset
ruleForm.value = { ...defaultRule, remote_port: 8080 }
showRuleModal.value = true
}
const openEditRule = (rule: ProxyRule) => {
if (rule.plugin_managed) return
ruleModalType.value = 'edit'
// Deep copy to avoid modifying original until saved
ruleForm.value = JSON.parse(JSON.stringify(rule))
showRuleModal.value = true
}
@@ -259,7 +258,7 @@ const saveRules = async (newRules: ProxyRule[]) => {
}
} catch (e: any) {
message.error('保存失败: ' + (e.response?.data || e.message))
await loadClient() // Revert on failure
await loadClient()
}
}
@@ -267,10 +266,8 @@ const handleRuleSubmit = (e: MouseEvent) => {
e.preventDefault()
ruleFormRef.value?.validate(async (errors) => {
if (!errors) {
// Logic to merge rule
let newRules = [...rules.value]
if (ruleModalType.value === 'create') {
// Check duplicate name
if (newRules.some(r => r.name === ruleForm.value.name)) {
message.error('规则名称已存在')
return
@@ -387,7 +384,6 @@ const openConfigModal = async (plugin: ClientPlugin) => {
const { data } = await getClientPluginConfig(clientId, plugin.name)
configSchema.value = data.schema || []
configValues.value = { ...data.config }
// Fill defaults
configSchema.value.forEach(f => {
if (f.default && !configValues.value[f.key]) {
configValues.value[f.key] = f.default
@@ -477,224 +473,223 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
</script>
<template>
<div class="client-view">
<!-- Header Area -->
<div class="page-header">
<n-space align="center">
<n-button quaternary circle @click="router.push('/')">
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
</n-button>
<h1 class="page-title">{{ nickname || clientId }}</h1>
<n-button text size="small" @click="openRenameModal">
<template #icon><n-icon><CreateOutline /></n-icon></template>
</n-button>
<n-tag :type="online ? 'success' : 'error'" round size="small" style="margin-left: 8px;">
{{ online ? '在线' : '离线' }}
</n-tag>
</n-space>
<n-space>
<n-button v-if="online" type="primary" secondary @click="pushConfigToClient(clientId).then(() => message.success('已推送'))" size="small">
<template #icon><n-icon><PushOutline /></n-icon></template>
推送配置
</n-button>
<n-button size="small" @click="showLogViewer=true">
<template #icon><n-icon><DocumentTextOutline/></n-icon></template>
日志
</n-button>
<n-button type="error" ghost size="small" @click="confirmDelete">
<template #icon><n-icon><TrashOutline/></n-icon></template>
删除客户端
</n-button>
</n-space>
<div class="client-page">
<!-- Particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div>
<n-divider style="margin: 12px 0 24px 0;" />
<div class="client-content">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<button class="back-btn" @click="router.push('/')">
<n-icon size="20"><ArrowBackOutline /></n-icon>
</button>
<h1 class="page-title">{{ nickname || clientId }}</h1>
<button class="edit-btn" @click="openRenameModal">
<n-icon size="16"><CreateOutline /></n-icon>
</button>
<span class="status-tag" :class="{ online }">
{{ online ? '在线' : '离线' }}
</span>
</div>
<div class="header-actions">
<button v-if="online" class="glass-btn primary" @click="pushConfigToClient(clientId).then(() => message.success('已推送'))">
<n-icon size="16"><PushOutline /></n-icon>
<span>推送配置</span>
</button>
<button class="glass-btn" @click="showLogViewer=true">
<n-icon size="16"><DocumentTextOutline /></n-icon>
<span>日志</span>
</button>
<button class="glass-btn danger" @click="confirmDelete">
<n-icon size="16"><TrashOutline /></n-icon>
<span>删除</span>
</button>
</div>
</div>
<n-grid :x-gap="24" :y-gap="24" cols="1 800:3" item-responsive>
<!-- Left Column: Status & Info -->
<n-grid-item span="1">
<n-space vertical size="large">
<n-card title="客户端状态" bordered size="small">
<n-space vertical size="large" justify="space-between">
<n-statistic label="连接 ID">
{{ clientId }}
</n-statistic>
<n-statistic label="远程 IP">
{{ remoteAddr || '-' }}
</n-statistic>
<n-statistic label="最后心跳">
{{ lastPing ? new Date(lastPing).toLocaleTimeString() : '-' }}
</n-statistic>
</n-space>
<template #action>
<n-space vertical>
<n-button block type="warning" dashed @click="disconnect" :disabled="!online">断开连接</n-button>
<n-button block type="error" dashed @click="handleRestartClient" :disabled="!online">重启客户端</n-button>
</n-space>
</template>
</n-card>
<!-- Main Grid -->
<div class="main-grid">
<!-- Left Column -->
<div class="left-column">
<!-- Status Card -->
<div class="glass-card">
<div class="card-header">
<h3>客户端状态</h3>
</div>
<div class="card-body">
<div class="stat-item">
<span class="stat-label">连接 ID</span>
<span class="stat-value mono">{{ clientId }}</span>
</div>
<div class="stat-item">
<span class="stat-label">远程 IP</span>
<span class="stat-value">{{ remoteAddr || '-' }}</span>
</div>
<div class="stat-item">
<span class="stat-label">最后心跳</span>
<span class="stat-value">{{ lastPing ? new Date(lastPing).toLocaleTimeString() : '-' }}</span>
</div>
</div>
<div class="card-actions">
<button class="glass-btn warning small" @click="disconnect" :disabled="!online">断开连接</button>
<button class="glass-btn danger small" @click="handleRestartClient" :disabled="!online">重启客户端</button>
</div>
</div>
<n-card title="统计" bordered size="small">
<n-space justify="space-around">
<n-statistic label="规则数" :value="rules.length" />
<n-statistic label="插件数" :value="clientPlugins.length" />
</n-space>
</n-card>
<!-- Stats Card -->
<div class="glass-card">
<div class="card-header">
<h3>统计</h3>
</div>
<div class="card-body stats-row">
<div class="mini-stat">
<span class="mini-stat-value">{{ rules.length }}</span>
<span class="mini-stat-label">规则数</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value">{{ clientPlugins.length }}</span>
<span class="mini-stat-label">插件数</span>
</div>
</div>
</div>
<!-- 客户端更新 -->
<n-card title="客户端更新" bordered size="small">
<template #header-extra>
<n-button size="tiny" :loading="checkingUpdate" @click="handleCheckClientUpdate" :disabled="!online">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
<!-- Update Card -->
<div class="glass-card">
<div class="card-header">
<h3>客户端更新</h3>
<button class="glass-btn tiny" :disabled="!online || checkingUpdate" @click="handleCheckClientUpdate">
<n-icon size="14"><RefreshOutline /></n-icon>
检查
</n-button>
</template>
<div v-if="clientOs && clientArch" style="margin-bottom: 8px; font-size: 12px; color: #666;">
</button>
</div>
<div class="card-body">
<div v-if="clientOs && clientArch" class="platform-info">
平台: {{ clientOs }}/{{ clientArch }}
</div>
<n-empty v-if="!clientUpdate" description="点击检查更新" size="small" />
<div v-if="!clientUpdate" class="empty-hint">点击检查更新</div>
<template v-else>
<div v-if="clientUpdate.download_url" style="font-size: 13px;">
<p style="margin: 0 0 8px 0; color: #10b981;">发现新版本 {{ clientUpdate.latest }}</p>
<n-button size="small" type="primary" :loading="updatingClient" @click="handleApplyClientUpdate">
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
<div v-if="clientUpdate.download_url" class="update-available">
<p>发现新版本 {{ clientUpdate.latest }}</p>
<button class="glass-btn primary small" :disabled="updatingClient" @click="handleApplyClientUpdate">
<n-icon size="14"><CloudDownloadOutline /></n-icon>
更新
</n-button>
</div>
<div v-else style="font-size: 13px; color: #666;">
已是最新版本
</button>
</div>
<div v-else class="empty-hint">已是最新版本</div>
</template>
</n-card>
</n-space>
</n-grid-item>
<!-- Right Column: Rules & Plugins -->
<n-grid-item span="2">
<n-space vertical size="large">
</div>
</div>
</div>
<!-- Right Column -->
<div class="right-column">
<!-- Rules Card -->
<n-card title="代理规则" bordered>
<template #header-extra>
<n-button type="primary" size="small" @click="openCreateRule">
<template #icon><n-icon><AddOutline /></n-icon></template>
<div class="glass-card">
<div class="card-header">
<h3>代理规则</h3>
<button class="glass-btn primary small" @click="openCreateRule">
<n-icon size="14"><AddOutline /></n-icon>
添加规则
</n-button>
</template>
<n-empty v-if="rules.length === 0" description="暂无代理规则" style="padding: 24px;" />
<n-table v-else :bordered="false" size="small">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>映射</th>
<th>状态</th>
<th style="text-align: right;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in rules" :key="rule.name">
<td><span style="font-weight: 500;">{{ rule.name }}</span></td>
<td><n-tag size="small" :type="rule.type==='websocket'?'info':'default'">{{ (rule.type || 'tcp').toUpperCase() }}</n-tag></td>
<td style="font-family: monospace; font-size: 12px; color: #666;">
</button>
</div>
<div class="card-body">
<div v-if="rules.length === 0" class="empty-state">
<p>暂无代理规则</p>
</div>
<div v-else class="rules-table">
<div class="table-header">
<span>名称</span>
<span>类型</span>
<span>映射</span>
<span>状态</span>
<span>操作</span>
</div>
<div v-for="rule in rules" :key="rule.name" class="table-row">
<span class="rule-name">{{ rule.name }}</span>
<span><n-tag size="small" :type="rule.type==='websocket'?'info':'default'">{{ (rule.type || 'tcp').toUpperCase() }}</n-tag></span>
<span class="rule-mapping">
{{ needsLocalAddr(rule.type||'tcp') ? `${rule.local_ip}:${rule.local_port}` : '-' }}
<n-icon><ArrowBackOutline style="transform: rotate(180deg); margin: 0 4px;" /></n-icon>
:{{ rule.remote_port }}
</td>
<td>
</span>
<span>
<n-switch :value="rule.enabled !== false" @update:value="(v: boolean) => { rule.enabled = v; saveRules(rules) }" size="small" />
</td>
<td style="text-align: right;">
<n-space justify="end" :size="8">
</span>
<span class="rule-actions">
<n-tooltip v-if="rule.plugin_managed">
<template #trigger>
<n-tag type="info" size="small">插件托管</n-tag>
</template>
此规则由插件管理无法手动编辑
此规则由插件管理
</n-tooltip>
<template v-else>
<n-button size="tiny" secondary type="info" @click="openEditRule(rule)">编辑</n-button>
<n-button size="tiny" secondary type="error" @click="handleDeleteRule(rule)">删除</n-button>
<button class="icon-btn" @click="openEditRule(rule)">编辑</button>
<button class="icon-btn danger" @click="handleDeleteRule(rule)">删除</button>
</template>
</n-space>
</td>
</tr>
</tbody>
</n-table>
</n-card>
</span>
</div>
</div>
</div>
</div>
<!-- Plugins Card -->
<n-card title="已安装扩展" bordered>
<template #header-extra>
<n-button secondary size="small" @click="openStoreModal">
<template #icon><n-icon><StorefrontOutline /></n-icon></template>
<div class="glass-card">
<div class="card-header">
<h3>已安装扩展</h3>
<button class="glass-btn small" @click="openStoreModal">
<n-icon size="14"><StorefrontOutline /></n-icon>
插件商店
</n-button>
</template>
<n-empty v-if="clientPlugins.length === 0" description="暂无安装的扩展" style="padding: 24px;" />
<n-table v-else :bordered="false" size="small">
<thead>
<tr>
<th>名称</th>
<th>版本</th>
<th>端口</th>
<th>状态</th>
<th>启用</th>
<th style="text-align: right;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="plugin in clientPlugins" :key="plugin.id">
<td>
<div style="display: flex; align-items: center; gap: 8px;">
<n-icon size="18" color="#18a058"><ExtensionPuzzleOutline /></n-icon>
{{ plugin.name }}
</button>
</div>
</td>
<td>v{{ plugin.version }}</td>
<td>{{ plugin.remote_port || '-' }}</td>
<td>
<div class="card-body">
<div v-if="clientPlugins.length === 0" class="empty-state">
<p>暂无安装的扩展</p>
</div>
<div v-else class="plugins-list">
<div v-for="plugin in clientPlugins" :key="plugin.id" class="plugin-item">
<div class="plugin-info">
<n-icon size="18" color="#a78bfa"><ExtensionPuzzleOutline /></n-icon>
<span class="plugin-name">{{ plugin.name }}</span>
<span class="plugin-version">v{{ plugin.version }}</span>
</div>
<div class="plugin-meta">
<span>端口: {{ plugin.remote_port || '-' }}</span>
<n-tag :type="plugin.running ? 'success' : 'default'" size="small" round>
{{ plugin.running ? '运行中' : '已停止' }}
</n-tag>
</td>
<td>
<n-switch :value="plugin.enabled" size="small" @update:value="toggleClientPlugin(plugin)" />
</td>
<td style="text-align: right;">
<n-space justify="end" :size="4">
<n-button v-if="plugin.running && plugin.remote_port" size="tiny" type="success" secondary @click="handleOpenPlugin(plugin)">
<template #icon><n-icon><OpenOutline /></n-icon></template>
打开
</n-button>
<n-button v-if="!plugin.running" size="tiny" @click="handleStartPlugin(plugin)" :disabled="!online || !plugin.enabled">启动</n-button>
</div>
<div class="plugin-actions">
<button v-if="plugin.running && plugin.remote_port" class="icon-btn success" @click="handleOpenPlugin(plugin)">打开</button>
<button v-if="!plugin.running" class="icon-btn" @click="handleStartPlugin(plugin)" :disabled="!online || !plugin.enabled">启动</button>
<n-dropdown :options="[
{ label: '重启', key: 'restart', disabled: !plugin.running },
{ label: '配置', key: 'config' },
{ label: '删除', key: 'delete', props: { style: 'color: red' } },
{ label: '停止', key: 'stop', disabled: !plugin.running }
{ label: '停止', key: 'stop', disabled: !plugin.running },
{ label: '删除', key: 'delete' }
]" @select="(k: string) => {
if(k==='restart') handleRestartPlugin(plugin);
if(k==='config') openConfigModal(plugin);
if(k==='delete') handleDeletePlugin(plugin);
if(k==='stop') handleStopPlugin(plugin);
}">
<n-button size="tiny" quaternary><template #icon><n-icon><SettingsOutline /></n-icon></template></n-button>
<button class="icon-btn"><n-icon size="16"><SettingsOutline /></n-icon></button>
</n-dropdown>
</n-space>
</td>
</tr>
</tbody>
</n-table>
</n-card>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</n-space>
</n-grid-item>
</n-grid>
<!-- Rule Edit Modal -->
<!-- Rule Modal -->
<n-modal v-model:show="showRuleModal" preset="card" :title="ruleModalType==='create'?'添加规则':'编辑规则'" style="width: 500px">
<n-form ref="ruleFormRef" :model="ruleForm" :rules="ruleValidationRules" label-placement="left" label-width="80">
<n-form-item label="名称" path="name">
@@ -703,7 +698,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<n-form-item label="类型" path="type">
<n-select v-model:value="ruleForm.type" :options="builtinTypes" />
</n-form-item>
<template v-if="needsLocalAddr(ruleForm.type || 'tcp')">
<n-form-item label="本地IP" path="local_ip">
<n-input v-model:value="ruleForm.local_ip" placeholder="127.0.0.1" />
@@ -712,12 +706,9 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<n-input-number v-model:value="ruleForm.local_port" :min="1" :max="65535" style="width: 100%" />
</n-form-item>
</template>
<n-form-item label="远程端口" path="remote_port">
<n-input-number v-model:value="ruleForm.remote_port" :min="1" :max="65535" style="width: 100%" placeholder="将在服务器上监听的端口" />
<n-input-number v-model:value="ruleForm.remote_port" :min="1" :max="65535" style="width: 100%" />
</n-form-item>
<!-- Extra Fields -->
<template v-for="field in getExtraFields(ruleForm.type || '')" :key="field.key">
<n-form-item :label="field.label">
<n-input v-if="field.type==='string'" v-model:value="ruleForm.plugin_config![field.key]" />
@@ -734,13 +725,13 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
</template>
</n-modal>
<!-- Plugin Config Modal -->
<!-- Config Modal -->
<n-modal v-model:show="showConfigModal" preset="card" :title="`${configPluginName} 配置`" style="width: 500px;">
<n-empty v-if="configLoading" description="加载中..." />
<n-form v-else label-placement="left" label-width="100">
<n-form-item v-for="field in configSchema" :key="field.key" :label="field.label">
<n-input v-if="field.type==='string'" v-model:value="configValues[field.key]" />
<n-input v-if="field.type==='password'" type="password" v-model:value="configValues[field.key]" show-password-on="click"/>
<n-input v-if="field.type==='password'" type="password" v-model:value="configValues[field.key]" />
<n-input-number v-if="field.type==='number'" :value="Number(configValues[field.key])" @update:value="(v) => configValues[field.key] = String(v)" />
<n-switch v-if="field.type==='bool'" :value="configValues[field.key]==='true'" @update:value="(v) => configValues[field.key] = String(v)" />
</n-form-item>
@@ -769,18 +760,16 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<n-spin :show="storeLoading">
<n-grid :x-gap="12" :y-gap="12" cols="1 600:2">
<n-grid-item v-for="plugin in storePlugins" :key="plugin.name">
<n-card size="small" hoverable>
<n-space align="center" justify="space-between">
<div style="font-weight: 600;">{{ plugin.name }}</div>
<div class="store-plugin-card">
<div class="store-plugin-header">
<span class="store-plugin-name">{{ plugin.name }}</span>
<n-tag size="small">v{{ plugin.version }}</n-tag>
</n-space>
<div style="color: #666; font-size: 12px; margin: 8px 0; height: 32px; overflow: hidden;">
{{ plugin.description }}
</div>
<n-button block type="primary" size="small" secondary @click="handleInstallStorePlugin(plugin)" :loading="storeInstalling === plugin.name">
<p class="store-plugin-desc">{{ plugin.description }}</p>
<button class="glass-btn primary small full" @click="handleInstallStorePlugin(plugin)">
安装
</n-button>
</n-card>
</button>
</div>
</n-grid-item>
</n-grid>
</n-spin>
@@ -790,7 +779,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<n-modal v-model:show="showInstallConfigModal" preset="card" title="安装配置" style="width: 400px;">
<n-form label-placement="left">
<n-form-item label="远程端口">
<n-input-number v-model:value="installRemotePort" :min="1" :max="65535" style="width: 100%" placeholder="1-65535" />
<n-input-number v-model:value="installRemotePort" :min="1" :max="65535" style="width: 100%" />
</n-form-item>
</n-form>
<template #footer>
@@ -806,18 +795,410 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
</template>
<style scoped>
.client-view {
min-height: 100%;
.client-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; }
}
.client-content {
position: relative;
z-index: 10;
max-width: 1400px;
margin: 0 auto;
}
/* Page Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn, .edit-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 8px;
padding: 8px;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
}
.back-btn:hover, .edit-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.page-title {
font-size: 20px;
font-weight: 600;
font-size: 24px;
font-weight: 700;
color: white;
margin: 0;
color: #1f2937;
}
.status-tag {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.status-tag.online {
background: rgba(52, 211, 153, 0.2);
color: #34d399;
}
.header-actions {
display: flex;
gap: 8px;
}
/* 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.primary {
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
border: none;
}
.glass-btn.danger {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.glass-btn.warning {
background: rgba(251, 191, 36, 0.2);
border-color: rgba(251, 191, 36, 0.3);
color: #fcd34d;
}
.glass-btn.small { padding: 6px 12px; font-size: 12px; }
.glass-btn.tiny { padding: 4px 8px; font-size: 11px; }
.glass-btn.full { width: 100%; justify-content: center; }
/* Main Grid */
.main-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; }
}
.left-column, .right-column {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 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;
}
.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; }
.card-actions {
padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
gap: 8px;
}
/* Stat Items */
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.stat-item:last-child { border-bottom: none; }
.stat-label {
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.stat-value {
color: white;
font-size: 13px;
}
.stat-value.mono {
font-family: monospace;
font-size: 12px;
}
/* Mini Stats */
.stats-row {
display: flex;
justify-content: space-around;
}
.mini-stat {
text-align: center;
}
.mini-stat-value {
display: block;
font-size: 28px;
font-weight: 700;
color: white;
}
.mini-stat-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/* Update Card */
.platform-info {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 8px;
}
.empty-hint {
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
text-align: center;
padding: 16px 0;
}
.update-available p {
margin: 0 0 8px 0;
color: #34d399;
font-size: 13px;
}
/* Rules Table */
.empty-state {
text-align: center;
padding: 32px;
color: rgba(255, 255, 255, 0.4);
}
.rules-table {
overflow-x: auto;
}
.table-header, .table-row {
display: grid;
grid-template-columns: 1fr 80px 1.5fr 60px 100px;
gap: 12px;
padding: 10px 0;
align-items: center;
}
.table-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
font-weight: 500;
}
.table-row {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
}
.rule-name { font-weight: 500; color: white; }
.rule-mapping { font-family: monospace; font-size: 12px; }
.rule-actions { display: flex; gap: 6px; justify-content: flex-end; }
/* Icon Button */
.icon-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 6px;
padding: 4px 10px;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn.danger {
color: #fca5a5;
}
.icon-btn.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
}
.icon-btn.success {
color: #34d399;
}
.icon-btn.success:hover:not(:disabled) {
background: rgba(52, 211, 153, 0.2);
}
/* Plugins List */
.plugins-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-info {
display: flex;
align-items: center;
gap: 10px;
}
.plugin-name {
font-weight: 600;
color: white;
font-size: 14px;
}
.plugin-version {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.plugin-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
.plugin-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* Store Plugin Card */
.store-plugin-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.store-plugin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.store-plugin-name {
font-weight: 600;
color: white;
font-size: 14px;
}
.store-plugin-desc {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
margin: 0 0 12px 0;
line-height: 1.5;
}
</style>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui'
import { login, setToken } from '../api'
const router = useRouter()
@@ -33,51 +32,66 @@ const handleLogin = async () => {
<template>
<div class="login-page">
<n-card class="login-card" :bordered="false">
<template #header>
<!-- Animated particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
<div class="particle particle-4"></div>
</div>
<!-- Login card -->
<div class="login-card">
<div class="login-header">
<h1 class="logo">GoTunnel</h1>
<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>
</template>
<n-form @submit.prevent="handleLogin">
<n-form-item label="用户名">
<n-input
v-model:value="username"
<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="请输入用户名"
:disabled="loading"
/>
</n-form-item>
</div>
<n-form-item label="密码">
<n-input
v-model:value="password"
<div class="form-group">
<label class="form-label">密码</label>
<input
v-model="password"
type="password"
class="glass-input"
placeholder="请输入密码"
:disabled="loading"
show-password-on="click"
/>
</n-form-item>
</div>
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 16px;">
{{ error }}
</n-alert>
<div v-if="error" class="error-alert">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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
type="primary"
block
:loading="loading"
attr-type="submit"
>
<button type="submit" class="glass-button" :disabled="loading">
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '登录中...' : '登录' }}
</n-button>
</n-form>
</button>
</form>
<template #footer>
<div class="login-footer">欢迎使用 GoTunnel</div>
</template>
</n-card>
<div class="login-footer">
<span>欢迎使用 GoTunnel</span>
</div>
</div>
</div>
</template>
@@ -87,36 +101,236 @@ const handleLogin = async () => {
display: flex;
align-items: 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;
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 {
width: 100%;
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 {
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-weight: 700;
color: #18a058;
color: white;
margin: 0 0 8px 0;
}
.subtitle {
color: #666;
color: rgba(255, 255, 255, 0.6);
margin: 0;
font-size: 14px;
}
.login-footer {
text-align: center;
color: #999;
/* Form */
.login-form {
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;
}
.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>

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import {
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage,
NSelect, NModal, NInput, NInputNumber
NButton, NSpace, NTag, NIcon, NSwitch, NModal, NInput, NInputNumber, NSelect,
useMessage
} from 'naive-ui'
import { ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline, SettingsOutline } from '@vicons/ionicons5'
import {
@@ -45,13 +44,8 @@ const loadStorePlugins = async () => {
}
}
const proxyPlugins = computed(() =>
plugins.value.filter(p => p.type === 'proxy')
)
const appPlugins = computed(() =>
plugins.value.filter(p => p.type === 'app')
)
const proxyPlugins = computed(() => plugins.value.filter(p => p.type === 'proxy'))
const appPlugins = computed(() => plugins.value.filter(p => p.type === 'app'))
const togglePlugin = async (plugin: PluginInfo) => {
try {
@@ -69,45 +63,15 @@ const togglePlugin = async (plugin: PluginInfo) => {
}
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
proxy: '协议',
app: '应用',
service: '服务',
tool: '工具'
}
const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
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) => {
if (tab === 'store' && storePlugins.value.length === 0) {
loadStorePlugins()
}
if (tab === 'js' && jsPlugins.value.length === 0) {
loadJSPlugins()
}
if (tab === 'store' && storePlugins.value.length === 0) loadStorePlugins()
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 () => {
jsLoading.value = true
try {
@@ -129,7 +93,7 @@ const loadClients = async () => {
}
}
// JS 插件推送相关
// JS Plugin Push
const showPushModal = ref(false)
const selectedJSPlugin = ref<JSPlugin | null>(null)
const pushClientId = ref('')
@@ -151,7 +115,7 @@ const handlePushJSPlugin = async () => {
pushing.value = true
try {
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
} catch (e: any) {
message.error(e.response?.data || '推送失败')
@@ -162,7 +126,7 @@ const handlePushJSPlugin = async () => {
const onlineClients = computed(() => clients.value.filter(c => c.online))
// JS 插件配置相关
// JS Plugin Config
const showJSConfigModal = ref(false)
const currentJSPlugin = ref<JSPlugin | null>(null)
const jsConfigItems = ref<Array<{ key: string; value: string }>>([])
@@ -170,40 +134,25 @@ const jsConfigSaving = ref(false)
const openJSConfigModal = (plugin: JSPlugin) => {
currentJSPlugin.value = plugin
// 将 config 转换为数组形式便于编辑
jsConfigItems.value = Object.entries(plugin.config || {}).map(([key, value]) => ({ key, value }))
if (jsConfigItems.value.length === 0) {
jsConfigItems.value.push({ key: '', value: '' })
}
if (jsConfigItems.value.length === 0) jsConfigItems.value.push({ key: '', value: '' })
showJSConfigModal.value = true
}
const addJSConfigItem = () => {
jsConfigItems.value.push({ key: '', value: '' })
}
const removeJSConfigItem = (index: number) => {
jsConfigItems.value.splice(index, 1)
}
const addJSConfigItem = () => jsConfigItems.value.push({ key: '', value: '' })
const removeJSConfigItem = (index: number) => jsConfigItems.value.splice(index, 1)
const saveJSPluginConfig = async () => {
if (!currentJSPlugin.value) return
jsConfigSaving.value = true
try {
// 将数组转换回对象
const config: Record<string, string> = {}
for (const item of jsConfigItems.value) {
if (item.key.trim()) {
config[item.key.trim()] = item.value
}
if (item.key.trim()) config[item.key.trim()] = item.value
}
await updateJSPluginConfig(currentJSPlugin.value.name, config)
// 更新本地数据
const plugin = jsPlugins.value.find(p => p.name === currentJSPlugin.value!.name)
if (plugin) {
plugin.config = config
}
if (plugin) plugin.config = config
message.success('配置已保存')
showJSConfigModal.value = false
} catch (e: any) {
@@ -213,7 +162,6 @@ const saveJSPluginConfig = async () => {
}
}
// 切换 JS 插件启用状态
const toggleJSPlugin = async (plugin: JSPlugin) => {
try {
await setJSPluginEnabled(plugin.name, !plugin.enabled)
@@ -224,7 +172,7 @@ const toggleJSPlugin = async (plugin: JSPlugin) => {
}
}
// 商店插件安装相关
// Store Plugin Install
const showInstallModal = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
const selectedClientId = ref('')
@@ -249,12 +197,8 @@ const handleInstallStorePlugin = async () => {
message.warning('请选择要安装到的客户端')
return
}
if (!selectedStorePlugin.value.download_url) {
message.error('该插件没有下载地址')
return
}
if (!selectedStorePlugin.value.signature_url) {
message.error('该插件没有签名文件')
if (!selectedStorePlugin.value.download_url || !selectedStorePlugin.value.signature_url) {
message.error('该插件缺少下载地址或签名')
return
}
installing.value = true
@@ -287,202 +231,164 @@ onMounted(() => {
</script>
<template>
<div class="plugins-view">
<div class="page-header">
<h2>插件管理</h2>
<p>管理已安装插件和浏览插件商店</p>
<div class="plugins-page">
<!-- Particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div>
<n-tabs v-model:value="activeTab" type="line" @update:value="handleTabChange">
<!-- 已安装插件 -->
<n-tab-pane name="installed" tab="已安装插件">
<n-spin :show="loading">
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
<n-gi>
<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>
<div class="plugins-content">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">插件管理</h1>
<p class="page-subtitle">管理已安装插件和浏览插件商店</p>
</div>
<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">
<n-gi v-for="plugin in plugins" :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"><ExtensionPuzzleOutline /></n-icon>
<span>{{ plugin.name }}</span>
</n-space>
</template>
<template #header-extra>
<n-switch :value="plugin.enabled" @update:value="togglePlugin(plugin)" />
</template>
<n-space vertical :size="8">
<n-space>
<!-- Tabs -->
<div class="glass-card">
<div class="tabs-header">
<button class="tab-btn" :class="{ active: activeTab === 'installed' }" @click="activeTab = 'installed'">
已安装插件
</button>
<button class="tab-btn" :class="{ active: activeTab === 'store' }" @click="activeTab = 'store'; handleTabChange('store')">
插件商店
</button>
<button class="tab-btn" :class="{ active: activeTab === 'js' }" @click="activeTab = 'js'; handleTabChange('js')">
JS 插件
</button>
</div>
<!-- Installed Plugins Tab -->
<div v-if="activeTab === 'installed'" class="tab-content">
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="plugins.length === 0" class="empty-state">暂无已安装插件</div>
<div v-else class="plugins-grid">
<div v-for="plugin in plugins" :key="plugin.name" class="plugin-card">
<div class="plugin-header">
<div class="plugin-icon">
<n-icon size="20" color="#a78bfa"><ExtensionPuzzleOutline /></n-icon>
</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="getTypeColor(plugin.type)">
{{ getTypeLabel(plugin.type) }}
</n-tag>
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'info'">
<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>
</n-space>
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
</n-space>
</n-card>
</n-gi>
</n-grid>
</n-spin>
</n-tab-pane>
</div>
<p class="plugin-desc">{{ plugin.description }}</p>
</div>
</div>
</div>
<!-- 插件商店 -->
<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
<!-- 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"
size="small"
type="primary"
class="glass-btn primary tiny"
@click="openInstallModal(plugin)"
>
安装
</n-button>
</template>
<n-space vertical :size="8">
<n-space>
>安装</button>
</div>
<div class="plugin-tags">
<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>
<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 插件 -->
<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>
<!-- 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-space>
</template>
<template #header-extra>
<n-switch :value="plugin.enabled" @update:value="toggleJSPlugin(plugin)" />
</template>
<n-space vertical :size="8">
<n-space>
<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>
</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>
</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" type="default">
{{ key }}: {{ value.length > 10 ? value.slice(0, 10) + '...' : value }}
<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>
</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>
<div class="plugin-actions">
<button class="glass-btn tiny" @click="openJSConfigModal(plugin)">
<n-icon size="14"><SettingsOutline /></n-icon>
配置
</n-button>
<n-button
v-if="onlineClients.length > 0"
size="small"
type="primary"
@click="openPushModal(plugin)"
>
</button>
<button v-if="onlineClients.length > 0" class="glass-btn primary tiny" @click="openPushModal(plugin)">
推送到客户端
</n-button>
</n-space>
</template>
</n-card>
</n-gi>
</n-grid>
</n-spin>
</n-tab-pane>
</n-tabs>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 安全加固暂时禁用创建 JS 插件 Modal
<n-modal v-model:show="showJSModal" preset="card" title="新建 JS 插件" style="width: 600px;">
... 已屏蔽 ...
</n-modal>
-->
<!-- 安装商店插件模态框 -->
<!-- Install Modal -->
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件" style="width: 450px;">
<n-space vertical :size="16">
<div v-if="selectedStorePlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedStorePlugin.name }}</p>
<p style="margin: 0; color: #666;">{{ selectedStorePlugin.description }}</p>
</div>
<n-select
v-model:value="selectedClientId"
placeholder="选择要安装到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<n-select v-model:value="selectedClientId" placeholder="选择客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))" />
<div>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口:</p>
<n-input-number
v-model:value="installRemotePort"
:min="1"
:max="65535"
placeholder="输入端口号"
style="width: 100%;"
/>
<n-input-number v-model:value="installRemotePort" :min="1" :max="65535" 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>
<template v-if="installAuthEnabled">
<n-input v-model:value="installAuthUsername" placeholder="用户名" />
<n-input v-model:value="installAuthPassword" type="password" placeholder="密码" show-password-on="click" />
@@ -491,29 +397,20 @@ onMounted(() => {
<template #footer>
<n-space justify="end">
<n-button @click="showInstallModal = false">取消</n-button>
<n-button
type="primary"
:loading="installing"
:disabled="!selectedClientId"
@click="handleInstallStorePlugin"
>
安装
</n-button>
<n-button type="primary" :loading="installing" :disabled="!selectedClientId" @click="handleInstallStorePlugin">安装</n-button>
</n-space>
</template>
</n-modal>
<!-- JS 插件配置模态框 -->
<!-- JS Config Modal -->
<n-modal v-model:show="showJSConfigModal" preset="card" :title="`${currentJSPlugin?.name || ''} 配置`" style="width: 500px;">
<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">
<n-space :size="8" align="center">
<n-input v-model:value="item.key" placeholder="参数名" style="width: 150px;" />
<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>
<n-button v-if="jsConfigItems.length > 1" quaternary type="error" size="small" @click="removeJSConfigItem(index)">删除</n-button>
</n-space>
</div>
<n-button dashed size="small" @click="addJSConfigItem">添加配置项</n-button>
@@ -526,41 +423,24 @@ onMounted(() => {
</template>
</n-modal>
<!-- JS 插件推送模态框 -->
<!-- Push Modal -->
<n-modal v-model:show="showPushModal" preset="card" title="推送插件到客户端" style="width: 400px;">
<n-space vertical :size="16">
<div v-if="selectedJSPlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedJSPlugin.name }}</p>
<p style="margin: 0; color: #666;">{{ selectedJSPlugin.description || '无描述' }}</p>
</div>
<n-select
v-model:value="pushClientId"
placeholder="选择要推送到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<n-select v-model:value="pushClientId" placeholder="选择客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))" />
<div>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口服务端监听端口:</p>
<n-input-number
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>
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口:</p>
<n-input-number v-model:value="pushRemotePort" :min="1" :max="65535" style="width: 100%;" />
</div>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showPushModal = false">取消</n-button>
<n-button
type="primary"
:loading="pushing"
:disabled="!pushClientId"
@click="handlePushJSPlugin"
>
推送
</n-button>
<n-button type="primary" :loading="pushing" :disabled="!pushClientId" @click="handlePushJSPlugin">推送</n-button>
</n-space>
</template>
</n-modal>
@@ -568,7 +448,39 @@ onMounted(() => {
</template>
<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;
margin: 0 auto;
}
@@ -577,15 +489,226 @@ onMounted(() => {
margin-bottom: 24px;
}
.page-header h2 {
.page-title {
font-size: 28px;
font-weight: 700;
color: white;
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;
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>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
NCard, NButton, NSpace, NTag, NGrid, NGi, NEmpty, NSpin, NIcon,
NAlert, useMessage, useDialog
NTag, NIcon, useMessage, useDialog
} from 'naive-ui'
import { CloudDownloadOutline, RefreshOutline, ServerOutline } from '@vicons/ionicons5'
import {
@@ -88,109 +87,140 @@ onMounted(() => {
</script>
<template>
<div class="settings-view">
<div class="settings-page">
<!-- Particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div>
<div class="settings-content">
<!-- Header -->
<div class="page-header">
<h2>系统设置</h2>
<p>管理服务端配置和系统更新</p>
<h1 class="page-title">系统设置</h1>
<p class="page-subtitle">管理服务端配置和系统更新</p>
</div>
<n-spin :show="loading">
<!-- 当前版本信息 -->
<n-card title="版本信息" class="settings-card">
<template #header-extra>
<n-icon size="20" color="#6366f1"><ServerOutline /></n-icon>
</template>
<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>
<!-- Version Info Card -->
<div class="glass-card">
<div class="card-header">
<h3>版本信息</h3>
<n-icon size="20" color="#a78bfa"><ServerOutline /></n-icon>
</div>
</n-gi>
<n-gi>
<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="label">Git 提交</span>
<span class="value">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
<span class="info-label">版本号</span>
<span class="info-value">{{ versionInfo.version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">构建时间</span>
<span class="value">{{ versionInfo.build_time || 'N/A' }}</span>
<span class="info-label">Git 提交</span>
<span class="info-value mono">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">Go 版本</span>
<span class="value">{{ versionInfo.go_version }}</span>
<span class="info-label">构建时间</span>
<span class="info-value">{{ versionInfo.build_time || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">操作系统</span>
<span class="value">{{ versionInfo.os }}</span>
<span class="info-label">Go 版本</span>
<span class="info-value">{{ versionInfo.go_version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">架构</span>
<span class="value">{{ versionInfo.arch }}</span>
<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>
</n-gi>
</n-grid>
<n-empty v-else description="加载中..." />
</n-card>
<!-- 服务端更新 -->
<n-card title="服务端更新" class="settings-card">
<template #header-extra>
<n-button size="small" :loading="checkingServer" @click="handleCheckServerUpdate">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
<!-- 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>
</template>
<n-empty v-if="!serverUpdate" description="点击检查更新按钮查看是否有新版本" />
</button>
</div>
<div class="card-body">
<div v-if="!serverUpdate" class="empty-state">
点击检查更新按钮查看是否有新版本
</div>
<template v-else>
<n-alert v-if="serverUpdate.available" type="success" style="margin-bottom: 16px;">
<div v-if="serverUpdate.available" class="update-alert success">
发现新版本 {{ serverUpdate.latest }}当前版本 {{ serverUpdate.current }}
</n-alert>
<n-alert v-else type="info" style="margin-bottom: 16px;">
</div>
<div v-else class="update-alert info">
当前已是最新版本 {{ serverUpdate.current }}
</n-alert>
</div>
<n-space vertical :size="12">
<div v-if="serverUpdate.download_url">
<p style="margin: 0 0 8px 0; color: #666;">
<div v-if="serverUpdate.download_url" class="download-info">
下载文件: {{ serverUpdate.asset_name }}
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</n-tag>
</p>
</div>
<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>
</div>
<n-button
<button
v-if="serverUpdate.available && serverUpdate.download_url"
type="primary"
:loading="updatingServer"
class="glass-btn primary"
:disabled="updatingServer"
@click="handleApplyServerUpdate"
>
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
<n-icon size="16"><CloudDownloadOutline /></n-icon>
下载并更新服务端
</n-button>
</n-space>
</button>
</template>
</n-card>
</n-spin>
</div>
</div>
</div>
</div>
</template>
<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;
margin: 0 auto;
}
@@ -199,52 +229,169 @@ onMounted(() => {
margin-bottom: 24px;
}
.page-header h2 {
.page-title {
font-size: 28px;
font-weight: 700;
color: white;
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;
color: #6b7280;
font-size: 14px;
}
.settings-card {
margin-bottom: 16px;
/* 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);
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 {
display: flex;
flex-direction: column;
padding: 8px 0;
gap: 4px;
}
.info-item .label {
.info-label {
font-size: 12px;
color: #9ca3af;
margin-bottom: 4px;
color: rgba(255, 255, 255, 0.5);
}
.info-item .value {
.info-value {
font-size: 14px;
color: #1f2937;
color: white;
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 {
max-height: 150px;
overflow-y: auto;
margin-bottom: 16px;
}
.note-label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 6px;
}
.release-note pre {
margin: 0;
white-space: pre-wrap;
font-size: 12px;
color: #374151;
background: #f9fafb;
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.2);
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>