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
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:
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
<div class="login-header">
|
||||
<h1 class="logo">GoTunnel</h1>
|
||||
<p class="subtitle">安全的内网穿透工具</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 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>
|
||||
|
||||
<n-form @submit.prevent="handleLogin">
|
||||
<n-form-item label="用户名">
|
||||
<n-input
|
||||
v-model:value="username"
|
||||
<!-- Login card -->
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<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="请输入用户名"
|
||||
: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>
|
||||
|
||||
@@ -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>
|
||||
<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'">
|
||||
{{ plugin.source === 'builtin' ? '内置' : 'JS' }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
|
||||
<!-- 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="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-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
</div>
|
||||
<div class="plugin-actions">
|
||||
<button class="glass-btn tiny" @click="openJSConfigModal(plugin)">
|
||||
<n-icon size="14"><SettingsOutline /></n-icon>
|
||||
配置
|
||||
</button>
|
||||
<button v-if="onlineClients.length > 0" class="glass-btn primary tiny" @click="openPushModal(plugin)">
|
||||
推送到客户端
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
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>
|
||||
-->
|
||||
|
||||
<!-- 安装商店插件模态框 -->
|
||||
<!-- 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%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<n-space align="center" :size="8">
|
||||
<n-switch v-model:value="installAuthEnabled" />
|
||||
<span style="color: #666;">启用 HTTP Basic Auth</span>
|
||||
</n-space>
|
||||
<n-input-number v-model:value="installRemotePort" :min="1" :max="65535" style="width: 100%;" />
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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="page-header">
|
||||
<h2>系统设置</h2>
|
||||
<p>管理服务端配置和系统更新</p>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<div class="settings-content">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-subtitle">管理服务端配置和系统更新</p>
|
||||
</div>
|
||||
|
||||
<!-- 服务端更新 -->
|
||||
<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>
|
||||
<!-- Version Info Card -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h3>版本信息</h3>
|
||||
<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>
|
||||
</template>
|
||||
</button>
|
||||
</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="点击检查更新按钮查看是否有新版本" />
|
||||
|
||||
<template v-else>
|
||||
<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 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>
|
||||
</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>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</button>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user