chore: sync current WechatHookBot workspace

This commit is contained in:
2026-03-09 15:48:45 +08:00
parent 4016c1e6eb
commit 9119e2307d
195 changed files with 24438 additions and 17498 deletions

View 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>
`
};

View 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>
`
};

View 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>
`
};

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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>
`
};

View 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>
`
};

View 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>
`
};

View 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>
`,
};