chore: sync current WechatHookBot workspace
This commit is contained in:
165
utils/webui_static/app.js
Normal file
165
utils/webui_static/app.js
Normal file
@@ -0,0 +1,165 @@
|
||||
const { createApp, ref, provide, onMounted, onUnmounted } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const currentPage = ref('log');
|
||||
const authReady = ref(false);
|
||||
const authenticated = ref(false);
|
||||
const authUser = ref('');
|
||||
const loginLoading = ref(false);
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
provide('currentPage', currentPage);
|
||||
|
||||
async function refreshAuthState() {
|
||||
const json = await api.getAuthStatus();
|
||||
if (json) {
|
||||
authenticated.value = !!json.authenticated;
|
||||
authUser.value = json.username || '';
|
||||
if (json.username && !loginForm.value.username) {
|
||||
loginForm.value.username = json.username;
|
||||
}
|
||||
} else {
|
||||
authenticated.value = false;
|
||||
authUser.value = '';
|
||||
}
|
||||
authReady.value = true;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const username = (loginForm.value.username || '').trim();
|
||||
const password = loginForm.value.password || '';
|
||||
if (!username) {
|
||||
ElementPlus.ElMessage.warning('请输入账号');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
ElementPlus.ElMessage.warning('请输入密码');
|
||||
return;
|
||||
}
|
||||
|
||||
loginLoading.value = true;
|
||||
const json = await api.login(username, password);
|
||||
loginLoading.value = false;
|
||||
if (!json) return;
|
||||
|
||||
authenticated.value = true;
|
||||
authUser.value = json.username || username;
|
||||
loginForm.value.password = '';
|
||||
currentPage.value = 'log';
|
||||
ElementPlus.ElMessage.success('登录成功');
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.logout();
|
||||
authenticated.value = false;
|
||||
loginForm.value.password = '';
|
||||
currentPage.value = 'log';
|
||||
}
|
||||
|
||||
function handleAuthExpired() {
|
||||
const wasAuthenticated = authenticated.value;
|
||||
authenticated.value = false;
|
||||
loginForm.value.password = '';
|
||||
currentPage.value = 'log';
|
||||
if (wasAuthenticated) {
|
||||
ElementPlus.ElMessage.warning('登录状态已失效,请重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthUpdated(event) {
|
||||
const username = event?.detail?.username;
|
||||
if (username) {
|
||||
authUser.value = username;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLayoutAuthUpdated(username) {
|
||||
if (username) {
|
||||
authUser.value = username;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('webui-auth-required', handleAuthExpired);
|
||||
window.addEventListener('webui-auth-updated', handleAuthUpdated);
|
||||
refreshAuthState();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('webui-auth-required', handleAuthExpired);
|
||||
window.removeEventListener('webui-auth-updated', handleAuthUpdated);
|
||||
});
|
||||
|
||||
return {
|
||||
authReady,
|
||||
authenticated,
|
||||
authUser,
|
||||
loginLoading,
|
||||
loginForm,
|
||||
login,
|
||||
logout,
|
||||
handleLayoutAuthUpdated,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div v-if="authReady" class="app-root">
|
||||
<div v-if="!authenticated"
|
||||
class="login-page">
|
||||
<el-card class="login-card">
|
||||
<template #header>
|
||||
<div class="login-title">WechatHookBot 控制台</div>
|
||||
<div class="login-subtitle">Bright Tech UI · 安全登录</div>
|
||||
</template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="账号">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
autocomplete="username"
|
||||
placeholder="请输入账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="login" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loginLoading" style="width:100%" @click="login">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<AppLayout
|
||||
v-else
|
||||
:auth-user="authUser"
|
||||
@logout="logout"
|
||||
@auth-updated="handleLayoutAuthUpdated" />
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
app.use(ElementPlus, {
|
||||
locale: ElementPlusLocaleZhCn,
|
||||
});
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component);
|
||||
}
|
||||
|
||||
app.component('AppLayout', window.AppLayout);
|
||||
app.component('LogViewer', window.LogViewer);
|
||||
app.component('ConfigEditor', window.ConfigEditor);
|
||||
app.component('ConfigSection', window.ConfigSection);
|
||||
app.component('PluginList', window.PluginList);
|
||||
app.component('PluginConfigDialog', window.PluginConfigDialog);
|
||||
app.component('SecuritySettings', window.SecuritySettings);
|
||||
|
||||
app.mount('#app');
|
||||
60
utils/webui_static/components/AppLayout.js
Normal file
60
utils/webui_static/components/AppLayout.js
Normal file
@@ -0,0 +1,60 @@
|
||||
window.AppLayout = {
|
||||
props: {
|
||||
authUser: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['logout', 'auth-updated'],
|
||||
setup() {
|
||||
const { inject } = Vue;
|
||||
const currentPage = inject('currentPage');
|
||||
|
||||
const menuItems = [
|
||||
{ index: 'log', icon: 'Document', label: '日志' },
|
||||
{ index: 'config', icon: 'Setting', label: '配置' },
|
||||
{ index: 'plugin', icon: 'Box', label: '插件' },
|
||||
{ index: 'security', icon: 'Lock', label: '安全' },
|
||||
];
|
||||
|
||||
return { currentPage, menuItems };
|
||||
},
|
||||
template: `
|
||||
<el-container class="app-shell">
|
||||
<el-aside width="220px" class="app-aside">
|
||||
<div class="brand-panel">
|
||||
<div class="brand-title">WechatHookBot</div>
|
||||
<div class="brand-sub">Control Surface</div>
|
||||
</div>
|
||||
<el-menu :default-active="currentPage"
|
||||
class="app-menu"
|
||||
@select="(idx) => currentPage = idx"
|
||||
background-color="transparent">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main class="app-main">
|
||||
<div class="app-topbar">
|
||||
<div class="topbar-title">Real-time Operations Panel</div>
|
||||
<div class="topbar-right">
|
||||
<span class="auth-pill">
|
||||
当前账号: {{ authUser || '-' }}
|
||||
</span>
|
||||
<el-button size="small" @click="$emit('logout')">退出登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-stage">
|
||||
<LogViewer v-show="currentPage === 'log'" />
|
||||
<ConfigEditor v-show="currentPage === 'config'" />
|
||||
<PluginList v-show="currentPage === 'plugin'" />
|
||||
<SecuritySettings
|
||||
v-show="currentPage === 'security'"
|
||||
@updated="$emit('auth-updated', $event)" />
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
`
|
||||
};
|
||||
51
utils/webui_static/components/ConfigEditor.js
Normal file
51
utils/webui_static/components/ConfigEditor.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.ConfigEditor = {
|
||||
setup() {
|
||||
const { ref, onMounted } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const configData = ref({});
|
||||
const configLabels = ref({});
|
||||
const loaded = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
async function load() {
|
||||
const json = await api.getConfig();
|
||||
if (json) {
|
||||
configData.value = json.data;
|
||||
configLabels.value = json.labels || {};
|
||||
loaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
const json = await api.saveConfig(configData.value);
|
||||
if (json) ElementPlus.ElMessage.success('配置已保存');
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
return { configData, configLabels, loaded, saving, save };
|
||||
},
|
||||
template: `
|
||||
<div class="panel-page">
|
||||
<div class="panel-scroll">
|
||||
<template v-if="loaded">
|
||||
<ConfigSection
|
||||
v-for="(fields, section) in configData" :key="section"
|
||||
:section="section"
|
||||
:label="configLabels[section] || section"
|
||||
:fields="fields"
|
||||
v-model="configData[section]" />
|
||||
</template>
|
||||
<div v-else class="panel-loading">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<el-button type="primary" @click="save" :loading="saving">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
84
utils/webui_static/components/ConfigSection.js
Normal file
84
utils/webui_static/components/ConfigSection.js
Normal file
@@ -0,0 +1,84 @@
|
||||
window.ConfigSection = {
|
||||
props: {
|
||||
section: String,
|
||||
label: String,
|
||||
fields: Object,
|
||||
modelValue: Object,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { computed } = Vue;
|
||||
|
||||
const flatFields = computed(() => {
|
||||
const result = [];
|
||||
for (const [key, val] of Object.entries(props.fields)) {
|
||||
if (typeof val === 'object' && !Array.isArray(val)) continue;
|
||||
result.push({ key, val });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function updateField(key, newVal) {
|
||||
props.modelValue[key] = newVal;
|
||||
}
|
||||
|
||||
function fieldType(val) {
|
||||
if (typeof val === 'boolean') return 'boolean';
|
||||
if (typeof val === 'number') return 'number';
|
||||
if (Array.isArray(val)) return 'array';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function removeTag(key, index) {
|
||||
props.modelValue[key].splice(index, 1);
|
||||
}
|
||||
|
||||
function addTag(key, val) {
|
||||
if (!val || !val.trim()) return;
|
||||
if (!Array.isArray(props.modelValue[key])) props.modelValue[key] = [];
|
||||
props.modelValue[key].push(val.trim());
|
||||
}
|
||||
|
||||
return { flatFields, updateField, fieldType, removeTag, addTag };
|
||||
},
|
||||
template: `
|
||||
<el-card shadow="never" class="config-card">
|
||||
<template #header>
|
||||
<div class="config-card-header">
|
||||
<span class="config-card-title">{{ label }}</span>
|
||||
<span class="config-card-section">[{{ section }}]</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="item in flatFields" :key="item.key"
|
||||
class="config-row">
|
||||
<div class="config-key">
|
||||
{{ item.key }}
|
||||
</div>
|
||||
<div class="config-val">
|
||||
<el-switch v-if="fieldType(item.val) === 'boolean'"
|
||||
:model-value="modelValue[item.key]"
|
||||
@update:model-value="updateField(item.key, $event)"
|
||||
active-text="开" inactive-text="关" />
|
||||
<el-input-number v-else-if="fieldType(item.val) === 'number'"
|
||||
:model-value="modelValue[item.key]"
|
||||
@update:model-value="updateField(item.key, $event)"
|
||||
:step="Number.isInteger(item.val) ? 1 : 0.1"
|
||||
controls-position="right" style="width: 200px" />
|
||||
<div v-else-if="fieldType(item.val) === 'array'" class="config-tags">
|
||||
<el-tag v-for="(tag, ti) in modelValue[item.key]" :key="ti"
|
||||
closable @close="removeTag(item.key, ti)"
|
||||
style="margin-bottom:2px">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<el-input size="small" style="width:140px"
|
||||
placeholder="回车添加"
|
||||
@keyup.enter="addTag(item.key, $event.target.value); $event.target.value=''" />
|
||||
</div>
|
||||
<el-input v-else
|
||||
:model-value="String(modelValue[item.key] ?? '')"
|
||||
@update:model-value="updateField(item.key, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
`
|
||||
};
|
||||
113
utils/webui_static/components/LogViewer.js
Normal file
113
utils/webui_static/components/LogViewer.js
Normal file
@@ -0,0 +1,113 @@
|
||||
window.LogViewer = {
|
||||
setup() {
|
||||
const { ref, computed, onMounted, onUnmounted, nextTick, watch } = Vue;
|
||||
const { connected, logs, paused, connect, clear, togglePause, destroy } = useWebSocket();
|
||||
|
||||
const filterText = ref('');
|
||||
const containerRef = ref(null);
|
||||
|
||||
function onWheel(event) {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const atTop = el.scrollTop <= 0;
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
const scrollingUp = event.deltaY < 0;
|
||||
const scrollingDown = event.deltaY > 0;
|
||||
|
||||
// 防止日志容器触底/触顶时把滚轮事件传递给外层页面
|
||||
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const kw = filterText.value.toLowerCase();
|
||||
if (!kw) return logs.value;
|
||||
return logs.value.filter(line => line.toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
watch(filteredLogs, () => {
|
||||
if (!paused.value) {
|
||||
nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.scrollTop = containerRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const levelColors = {
|
||||
DEBUG: 'var(--el-text-color-placeholder)',
|
||||
INFO: 'var(--el-color-primary)',
|
||||
SUCCESS: 'var(--el-color-success)',
|
||||
WARNING: 'var(--el-color-warning)',
|
||||
ERROR: 'var(--el-color-danger)',
|
||||
CRITICAL: '#b42318',
|
||||
};
|
||||
|
||||
function colorize(raw) {
|
||||
let s = raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(
|
||||
/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/,
|
||||
'<span style="color:var(--el-text-color-placeholder)">$1</span>'
|
||||
);
|
||||
const m = raw.match(/\|\s*(DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\s*\|/);
|
||||
if (m) {
|
||||
s = s.replace(
|
||||
/\|\s*(DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\s*\|/,
|
||||
'| <span style="color:' + levelColors[m[1]] + '">' + m[1] + '</span> |'
|
||||
);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
if (containerRef.value) {
|
||||
containerRef.value.addEventListener('wheel', onWheel, { passive: false });
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('wheel', onWheel);
|
||||
}
|
||||
destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
filterText, paused, connected, filteredLogs,
|
||||
containerRef, togglePause, clear, colorize
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="panel-page">
|
||||
<div class="log-toolbar">
|
||||
<el-input v-model="filterText" placeholder="搜索过滤..." clearable
|
||||
class="log-search" size="small">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button size="small" :type="paused ? 'primary' : ''" @click="togglePause">
|
||||
{{ paused ? '恢复' : '暂停' }}
|
||||
</el-button>
|
||||
<el-button size="small" @click="clear">清空</el-button>
|
||||
<span class="log-status" :class="connected ? 'is-online' : 'is-offline'">
|
||||
<span class="status-dot"></span>
|
||||
{{ connected ? '已连接' : '已断开' }}
|
||||
</span>
|
||||
</div>
|
||||
<div ref="containerRef"
|
||||
class="log-stream">
|
||||
<div v-for="(line, i) in filteredLogs" :key="i"
|
||||
class="log-line"
|
||||
v-html="colorize(line)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
82
utils/webui_static/components/PluginConfigDialog.js
Normal file
82
utils/webui_static/components/PluginConfigDialog.js
Normal file
@@ -0,0 +1,82 @@
|
||||
window.PluginConfigDialog = {
|
||||
props: {
|
||||
visible: Boolean,
|
||||
pluginName: String,
|
||||
},
|
||||
emits: ['update:visible'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, watch, computed } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const configData = ref({});
|
||||
const configLabels = ref({});
|
||||
const saving = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (val && props.pluginName) {
|
||||
loading.value = true;
|
||||
const json = await api.getPluginConfig(props.pluginName);
|
||||
loading.value = false;
|
||||
if (json) {
|
||||
configData.value = json.data;
|
||||
configLabels.value = json.labels || {};
|
||||
} else {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function collectSections(obj, prefix) {
|
||||
const result = [];
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (typeof val !== 'object' || Array.isArray(val)) continue;
|
||||
const fullKey = prefix ? prefix + '.' + key : key;
|
||||
result.push({
|
||||
key: fullKey,
|
||||
fields: val,
|
||||
label: configLabels.value[fullKey] || fullKey,
|
||||
});
|
||||
result.push(...collectSections(val, fullKey));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const sections = computed(() => collectSections(configData.value, ''));
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
const json = await api.savePluginConfig(props.pluginName, configData.value);
|
||||
if (json) {
|
||||
ElementPlus.ElMessage.success('插件配置已保存');
|
||||
emit('update:visible', false);
|
||||
}
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
function close() { emit('update:visible', false); }
|
||||
|
||||
return { configData, sections, saving, loading, save, close };
|
||||
},
|
||||
template: `
|
||||
<el-dialog :model-value="visible"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
:title="pluginName + ' 配置'"
|
||||
width="720px" top="8vh" destroy-on-close>
|
||||
<div v-if="loading" class="panel-loading">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else class="dialog-body-scroll">
|
||||
<ConfigSection v-for="sec in sections" :key="sec.key"
|
||||
:section="sec.key"
|
||||
:label="sec.label"
|
||||
:fields="sec.fields"
|
||||
v-model="sec.fields" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
`
|
||||
};
|
||||
73
utils/webui_static/components/PluginList.js
Normal file
73
utils/webui_static/components/PluginList.js
Normal file
@@ -0,0 +1,73 @@
|
||||
window.PluginList = {
|
||||
setup() {
|
||||
const { ref, onMounted } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const plugins = ref([]);
|
||||
const loaded = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogPluginName = ref('');
|
||||
|
||||
async function load() {
|
||||
const json = await api.getPlugins();
|
||||
if (json) { plugins.value = json.plugins; loaded.value = true; }
|
||||
}
|
||||
|
||||
async function toggle(plugin, enable) {
|
||||
const json = await api.togglePlugin(plugin.name, enable);
|
||||
if (json) {
|
||||
ElementPlus.ElMessage.success((enable ? '已启用: ' : '已禁用: ') + plugin.name);
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function openConfig(name) {
|
||||
dialogPluginName.value = name;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
return { plugins, loaded, toggle, openConfig, dialogVisible, dialogPluginName };
|
||||
},
|
||||
template: `
|
||||
<div class="panel-scroll">
|
||||
<template v-if="loaded">
|
||||
<el-card v-for="p in plugins" :key="p.name" shadow="hover" class="plugin-card">
|
||||
<div class="plugin-main">
|
||||
<div class="plugin-info">
|
||||
<div class="plugin-title">
|
||||
<span class="plugin-name">{{ p.name }}</span>
|
||||
<span class="plugin-version">
|
||||
v{{ p.version }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plugin-desc">
|
||||
{{ p.description }}
|
||||
</div>
|
||||
<div class="plugin-meta">
|
||||
作者: {{ p.author }} · 目录: {{ p.directory }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-actions">
|
||||
<el-button v-if="p.has_config" size="small" @click="openConfig(p.name)">
|
||||
配置
|
||||
</el-button>
|
||||
<el-tag :type="p.enabled ? 'success' : 'danger'" size="small">
|
||||
{{ p.enabled ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
<el-switch :model-value="p.enabled"
|
||||
@update:model-value="toggle(p, $event)"
|
||||
:disabled="p.name === 'ManagePlugin'"
|
||||
active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
<div v-else class="panel-loading">
|
||||
加载中...
|
||||
</div>
|
||||
<PluginConfigDialog v-model:visible="dialogVisible" :plugin-name="dialogPluginName" />
|
||||
</div>
|
||||
`
|
||||
};
|
||||
138
utils/webui_static/components/SecuritySettings.js
Normal file
138
utils/webui_static/components/SecuritySettings.js
Normal file
@@ -0,0 +1,138 @@
|
||||
window.SecuritySettings = {
|
||||
emits: ['updated'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, onMounted } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const currentUsername = ref('');
|
||||
const form = ref({
|
||||
current_password: '',
|
||||
new_username: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
const json = await api.getAuthStatus();
|
||||
if (json) {
|
||||
currentUsername.value = json.username || '';
|
||||
form.value.new_username = json.username || '';
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = {
|
||||
current_password: form.value.current_password || '',
|
||||
new_username: (form.value.new_username || '').trim(),
|
||||
new_password: form.value.new_password || '',
|
||||
};
|
||||
|
||||
if (!payload.current_password) {
|
||||
ElementPlus.ElMessage.warning('请输入当前密码');
|
||||
return;
|
||||
}
|
||||
if (!payload.new_username) {
|
||||
ElementPlus.ElMessage.warning('请输入新账号');
|
||||
return;
|
||||
}
|
||||
if (payload.new_password.length < 8) {
|
||||
ElementPlus.ElMessage.warning('新密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
if (payload.new_password !== form.value.confirm_password) {
|
||||
ElementPlus.ElMessage.warning('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
const json = await api.changeCredentials(payload);
|
||||
saving.value = false;
|
||||
if (!json) return;
|
||||
|
||||
currentUsername.value = json.username || payload.new_username;
|
||||
form.value.current_password = '';
|
||||
form.value.new_password = '';
|
||||
form.value.confirm_password = '';
|
||||
form.value.new_username = currentUsername.value;
|
||||
|
||||
emit('updated', currentUsername.value);
|
||||
window.dispatchEvent(new CustomEvent('webui-auth-updated', {
|
||||
detail: { username: currentUsername.value },
|
||||
}));
|
||||
ElementPlus.ElMessage.success('账号密码已更新');
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
return {
|
||||
loading,
|
||||
saving,
|
||||
currentUsername,
|
||||
form,
|
||||
save,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="panel-scroll">
|
||||
<el-card shadow="never" class="security-wrap">
|
||||
<template #header>
|
||||
<div class="plugin-main">
|
||||
<span class="plugin-name">管理员账号安全</span>
|
||||
<el-tag size="small" type="info">当前账号: {{ currentUsername || '-' }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
<div v-else>
|
||||
<el-alert
|
||||
title="密码仅保存为哈希值,修改后会立即写入 main_config.toml"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom:16px;" />
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="当前密码">
|
||||
<el-input
|
||||
v-model="form.current_password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入当前密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新账号">
|
||||
<el-input
|
||||
v-model="form.new_username"
|
||||
autocomplete="username"
|
||||
placeholder="请输入新账号" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码(至少 8 位)">
|
||||
<el-input
|
||||
v-model="form.new_password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input
|
||||
v-model="form.confirm_password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="请再次输入新密码"
|
||||
@keyup.enter="save" />
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" :loading="saving" @click="save">
|
||||
保存账号密码
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
67
utils/webui_static/composables/useApi.js
Normal file
67
utils/webui_static/composables/useApi.js
Normal file
@@ -0,0 +1,67 @@
|
||||
window.useApi = function useApi() {
|
||||
async function request(url, options = {}, extra = {}) {
|
||||
const { silent = false, skipAuthRedirect = false } = extra;
|
||||
try {
|
||||
const mergedHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: mergedHeaders,
|
||||
});
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (e) {
|
||||
if (!silent) ElementPlus.ElMessage.error('服务端返回格式错误');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
if (!skipAuthRedirect) {
|
||||
window.dispatchEvent(new CustomEvent('webui-auth-required'));
|
||||
}
|
||||
if (!silent) ElementPlus.ElMessage.error(json.error || '未登录或会话已过期');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!json.ok) {
|
||||
if (!silent) ElementPlus.ElMessage.error(json.error || '请求失败');
|
||||
return null;
|
||||
}
|
||||
return json;
|
||||
} catch (e) {
|
||||
if (!silent) ElementPlus.ElMessage.error('网络请求失败');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getAuthStatus: () => request('/api/auth/status', {}, { silent: true, skipAuthRedirect: true }),
|
||||
login: (username, password) => request('/api/auth/login', {
|
||||
method: 'POST', body: JSON.stringify({ username, password })
|
||||
}, { skipAuthRedirect: true }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }, { silent: true }),
|
||||
changeCredentials: (payload) => request('/api/auth/change-credentials', {
|
||||
method: 'POST', body: JSON.stringify(payload)
|
||||
}),
|
||||
|
||||
getConfig: () => request('/api/config'),
|
||||
saveConfig: (data) => request('/api/config', {
|
||||
method: 'POST', body: JSON.stringify({ data })
|
||||
}),
|
||||
getPlugins: () => request('/api/plugins'),
|
||||
togglePlugin: (name, enable) => request('/api/plugins/toggle', {
|
||||
method: 'POST', body: JSON.stringify({ name, enable })
|
||||
}),
|
||||
getPluginConfig: (name) =>
|
||||
request(`/api/plugins/${encodeURIComponent(name)}/config`),
|
||||
savePluginConfig: (name, data) =>
|
||||
request(`/api/plugins/${encodeURIComponent(name)}/config`, {
|
||||
method: 'POST', body: JSON.stringify({ data })
|
||||
}),
|
||||
};
|
||||
};
|
||||
35
utils/webui_static/composables/useWebSocket.js
Normal file
35
utils/webui_static/composables/useWebSocket.js
Normal file
@@ -0,0 +1,35 @@
|
||||
window.useWebSocket = function useWebSocket() {
|
||||
const { ref, onUnmounted } = Vue;
|
||||
|
||||
const connected = ref(false);
|
||||
const logs = ref([]);
|
||||
const paused = ref(false);
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
|
||||
ws.onopen = () => { connected.value = true; };
|
||||
ws.onclose = () => {
|
||||
connected.value = false;
|
||||
reconnectTimer = setTimeout(connect, 3000);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
// 使用新数组引用,确保依赖 logs 的 watch/computed 能稳定触发(用于自动滚动到底部)
|
||||
const nextLogs = [...logs.value, e.data];
|
||||
logs.value = nextLogs.length > 2000 ? nextLogs.slice(-1500) : nextLogs;
|
||||
};
|
||||
}
|
||||
|
||||
function clear() { logs.value = []; }
|
||||
function togglePause() { paused.value = !paused.value; }
|
||||
|
||||
function destroy() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) ws.close();
|
||||
}
|
||||
|
||||
return { connected, logs, paused, connect, clear, togglePause, destroy };
|
||||
};
|
||||
32
utils/webui_static/index.html
Normal file
32
utils/webui_static/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WechatHookBot</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260302_3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/element-plus"></script>
|
||||
<script src="https://unpkg.com/element-plus/dist/locale/zh-cn.js"></script>
|
||||
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
|
||||
|
||||
<script src="/static/composables/useWebSocket.js?v=20260302_3"></script>
|
||||
<script src="/static/composables/useApi.js?v=20260302_3"></script>
|
||||
<script src="/static/components/ConfigSection.js?v=20260302_3"></script>
|
||||
<script src="/static/components/LogViewer.js?v=20260302_3"></script>
|
||||
<script src="/static/components/ConfigEditor.js?v=20260302_3"></script>
|
||||
<script src="/static/components/PluginConfigDialog.js?v=20260302_3"></script>
|
||||
<script src="/static/components/PluginList.js?v=20260302_3"></script>
|
||||
<script src="/static/components/SecuritySettings.js?v=20260302_3"></script>
|
||||
<script src="/static/components/AppLayout.js?v=20260302_3"></script>
|
||||
<script src="/static/app.js?v=20260302_3"></script>
|
||||
</body>
|
||||
</html>
|
||||
591
utils/webui_static/style.css
Normal file
591
utils/webui_static/style.css
Normal file
@@ -0,0 +1,591 @@
|
||||
:root {
|
||||
--wb-primary: #0077ff;
|
||||
--wb-primary-alt: #00b8ff;
|
||||
--wb-ink: #0f2a4b;
|
||||
--wb-subtle: #5f7da3;
|
||||
--wb-page: #eef5ff;
|
||||
--wb-panel: rgba(255, 255, 255, 0.76);
|
||||
--wb-card: rgba(255, 255, 255, 0.92);
|
||||
--wb-border: #d7e4f6;
|
||||
--wb-border-strong: #c7d8ef;
|
||||
--wb-grid: rgba(0, 102, 255, 0.08);
|
||||
--wb-shadow-lg: 0 24px 56px rgba(24, 74, 150, 0.16);
|
||||
--wb-shadow-md: 0 12px 30px rgba(20, 76, 154, 0.12);
|
||||
|
||||
--el-color-primary: var(--wb-primary);
|
||||
--el-color-success: #17b26a;
|
||||
--el-color-warning: #f79009;
|
||||
--el-color-danger: #f04438;
|
||||
--el-bg-color: #f8fbff;
|
||||
--el-bg-color-page: var(--wb-page);
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-border-color: var(--wb-border);
|
||||
--el-border-color-light: #e4edf9;
|
||||
--el-border-color-lighter: #edf3fb;
|
||||
--el-text-color-primary: var(--wb-ink);
|
||||
--el-text-color-regular: #26486f;
|
||||
--el-text-color-secondary: var(--wb-subtle);
|
||||
--el-text-color-placeholder: #8ca4c1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-family: "Space Grotesk", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 15% 12%, rgba(0, 167, 255, 0.2), transparent 42%),
|
||||
radial-gradient(circle at 85% 8%, rgba(0, 98, 255, 0.17), transparent 44%),
|
||||
linear-gradient(180deg, #f9fcff 0%, #edf4ff 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
body::before {
|
||||
background-image:
|
||||
linear-gradient(transparent 31px, var(--wb-grid) 32px),
|
||||
linear-gradient(90deg, transparent 31px, var(--wb-grid) 32px);
|
||||
background-size: 32px 32px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
body::after {
|
||||
background:
|
||||
radial-gradient(circle at 18% 78%, rgba(0, 168, 255, 0.16), transparent 34%),
|
||||
radial-gradient(circle at 80% 74%, rgba(0, 110, 255, 0.14), transparent 38%);
|
||||
}
|
||||
|
||||
.app-root {
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.app-aside {
|
||||
border: 1px solid var(--wb-border);
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: var(--wb-panel);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--wb-shadow-lg);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
padding: 20px 16px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #083a72;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
color: #6284af;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
border-right: none !important;
|
||||
background: transparent !important;
|
||||
padding: 10px 8px 14px;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item {
|
||||
height: 42px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 12px;
|
||||
color: #2f557f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item .el-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item:hover {
|
||||
background: rgba(0, 122, 255, 0.12);
|
||||
color: #005ac0;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item.is-active {
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(135deg, #0084ff 0%, #00b0ff 100%);
|
||||
box-shadow: 0 10px 22px rgba(0, 129, 255, 0.3);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
border: 1px solid var(--wb-border);
|
||||
border-radius: 22px;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--wb-panel);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--wb-shadow-lg);
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
height: 58px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 250, 255, 0.92));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: #6584ac;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-pill {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #0b4d94;
|
||||
border: 1px solid #b8d8ff;
|
||||
background: linear-gradient(120deg, #eaf6ff 0%, #f4fbff 100%);
|
||||
}
|
||||
|
||||
.content-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(251, 253, 255, 0.8);
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(246, 250, 255, 0.8));
|
||||
}
|
||||
|
||||
.log-search {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: #5f7ca1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #9ab4d8;
|
||||
box-shadow: 0 0 0 4px rgba(154, 180, 216, 0.2);
|
||||
}
|
||||
|
||||
.log-status.is-online .status-dot {
|
||||
background: #0fb772;
|
||||
box-shadow: 0 0 0 4px rgba(15, 183, 114, 0.16);
|
||||
}
|
||||
|
||||
.log-status.is-offline .status-dot {
|
||||
background: #f04438;
|
||||
box-shadow: 0 0 0 4px rgba(240, 68, 56, 0.14);
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 16px;
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.config-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-card-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-card-section {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
gap: 12px;
|
||||
border-bottom: 1px dashed var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.config-key {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
color: #4f7097;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-val {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.config-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plugin-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.plugin-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.plugin-desc {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-meta {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-body-scroll {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.security-wrap {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(92vw, 430px);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
color: #0d335f;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #6b87ab;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--wb-border);
|
||||
background: var(--wb-card);
|
||||
box-shadow: var(--wb-shadow-md);
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
margin-right: 0;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(249, 252, 255, 0.86));
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 10px 18px 16px;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase,
|
||||
.el-input-number .el-input__wrapper {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-input-number .el-input__wrapper {
|
||||
box-shadow: 0 0 0 1px var(--wb-border-strong) inset !important;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-textarea__inner:focus,
|
||||
.el-input-number .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px rgba(0, 119, 255, 0.22) inset !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, var(--wb-primary) 0%, var(--wb-primary-alt) 100%);
|
||||
box-shadow: 0 10px 22px rgba(0, 123, 255, 0.26);
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
color: #ffffff;
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-aside {
|
||||
width: 86px !important;
|
||||
min-width: 86px !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
padding: 14px 8px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
padding: 8px 6px 10px;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item {
|
||||
justify-content: center;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item .el-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
height: 52px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-search {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.config-key {
|
||||
width: 132px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.log-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.plugin-main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-key {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user