Add HeXi bot codebase: custom plugins, web frontends, tests

- hexi core: message handling, rate limiting, cooldown, plugin manager
- Custom plugins: BF stats, daily check-in, quotes, persona cards, etc.
- Community plugins vendored under hexi/plugins with local fixes
- Web admin frontends (learning-chat, persona-admin), unified hexi/web
- Tests for rate_limit/cooldown/memes/persona; poetry.lock

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Nonebot-Plugin-Learning-Chat 控制台" />
<title>Learning-Chat 控制台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
{
"name": "learning-chat-admin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.5.0",
"element-plus": "^2.14.3",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^4.5.0",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vue-tsc": "^1.8.19"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,146 @@
<template>
<el-config-provider>
<!-- 登录页: 无框架布局 -->
<RouterView v-if="!requiresAuth" />
<!-- 主界面: 侧边栏 + 顶栏 + 内容区 -->
<el-container v-else class="app-container">
<el-aside width="220px" class="app-aside">
<div class="aside-title">Learning-Chat 控制台</div>
<el-menu :default-active="route.path" router class="aside-menu">
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<span>配置</span>
</el-menu-item>
<el-menu-item index="/messages">
<el-icon><ChatDotRound /></el-icon>
<span>群聊消息</span>
</el-menu-item>
<el-menu-item index="/contexts">
<el-icon><Collection /></el-icon>
<span>学习内容</span>
</el-menu-item>
<el-menu-item index="/answers">
<el-icon><ChatLineRound /></el-icon>
<span>回复内容</span>
</el-menu-item>
<el-menu-item index="/blacklist">
<el-icon><CircleClose /></el-icon>
<span>禁用列表</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container class="app-body">
<el-header class="app-header" height="60px">
<span class="header-title">Nonebot-Plugin-Learning-Chat</span>
<div class="header-right">
<el-tag size="small" type="primary" effect="plain">{{ authStore.username }}</el-tag>
<el-button size="small" type="danger" plain @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main class="app-main">
<RouterView />
</el-main>
</el-container>
</el-container>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
// 依据路由而非登录态切换布局, 避免登录瞬间闪现后台框架
const requiresAuth = computed(() => route.meta.requiresAuth !== false);
// token 到期自动登出(守卫已拦截"已过期"情况, 这里覆盖"使用中到期")
onMounted(() => {
authStore.scheduleAutoLogout(() => {
ElMessage.warning('登录已过期,请重新登录');
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
});
});
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消',
});
} catch {
return; // 用户取消
}
authStore.logout();
router.push('/login');
};
</script>
<style scoped>
.app-container {
height: 100vh;
}
.app-aside {
background: #fff;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.aside-title {
height: 60px;
line-height: 60px;
padding: 0 20px;
font-size: 15px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f2f5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aside-menu {
flex: 1;
border-right: none;
}
.app-body {
min-width: 0;
}
.app-header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.app-main {
background: #f5f7fa;
overflow-y: auto;
}
</style>
@@ -0,0 +1,204 @@
import axios, { AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
import { useAuthStore } from '@/stores/auth';
import type {
ApiResponse,
BlacklistItem,
ChatAnswer,
ChatContext,
ChatMessage,
GlobalConfig,
GroupConfig,
GroupInfo,
LoginResult,
PaginatedData,
} from '@/types';
/** 带状态码的请求错误 */
export class ApiError extends Error {
status: number;
/** 认证失效类错误: 已由全局拦截器提示并跳转登录, 视图层不再重复弹窗 */
authHandled = false;
constructor(message: string, status = 0) {
super(message);
this.status = status;
}
}
let redirectingToLogin = false;
/** 认证失效: 清除登录态并跳转登录页(避免并发失败时重复跳转) */
function handleUnauthorized() {
const authStore = useAuthStore();
authStore.logout();
if (router.currentRoute.value.path !== '/login' && !redirectingToLogin) {
redirectingToLogin = true;
ElMessage.warning('登录已失效,请重新登录');
router.push('/login').finally(() => {
redirectingToLogin = false;
});
}
}
class ApiClient {
private axiosInstance;
constructor() {
this.axiosInstance = axios.create({
baseURL: '/learning_chat/api',
timeout: 30000,
});
// 请求: 附加 token 头(后端契约: authentication() 读取 token header)
this.axiosInstance.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.token = authStore.token;
}
return config;
});
// 响应: 三态归一化
// 1. {"status": 0, "msg", "data"} → 成功, 返回整个信封(data 由调用方取)
// 2. {"status": !=0, "msg"} → 业务失败(HTTP 200 内)
// 3. {"detail": ...}(HTTP 400/422)→ 认证失效/参数错误
// 4. 裸 dict(全局/分群配置 GET)→ 直接返回
this.axiosInstance.interceptors.response.use(
(response) => {
const data = response.data;
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (typeof data.detail !== 'undefined') {
handleUnauthorized();
const err = new ApiError(String(data.detail), response.status);
err.authHandled = true;
throw err;
}
if (typeof data.status === 'number') {
if (data.status === 0) return data;
throw new ApiError(String(data.msg || '操作失败'), data.status);
}
}
return data;
},
(error: AxiosError) => {
const status = error.response?.status;
const data = error.response?.data as { detail?: unknown } | undefined;
const isAuthError = status === 400 || status === 422;
// 后端认证失效契约为 HTTP 400/422 + {"detail"}
if (isAuthError) {
handleUnauthorized();
}
const detail = data?.detail;
const message =
typeof detail === 'string'
? detail
: status === 0
? '无法连接到服务器'
: error.message || '网络请求失败';
const apiError = new ApiError(message, status ?? 0);
apiError.authHandled = isAuthError;
return Promise.reject(apiError);
}
);
}
/** 登录 */
async login(username: string, password: string): Promise<ApiResponse<LoginResult>> {
return this.axiosInstance.post('/login', { username, password });
}
/** 获取群列表 */
async getGroupList(): Promise<ApiResponse<{ group_list: GroupInfo[] }>> {
return this.axiosInstance.get('/get_group_list');
}
/** 获取全局配置(裸 dict) */
async getGlobalConfig(): Promise<GlobalConfig> {
return this.axiosInstance.get('/chat_global_config');
}
/** 保存全局配置 */
async saveGlobalConfig(data: GlobalConfig): Promise<ApiResponse> {
return this.axiosInstance.post('/chat_global_config', data);
}
/** 获取分群配置(裸 dict, 概率为百分比) */
async getGroupConfig(groupId: number | string): Promise<GroupConfig> {
return this.axiosInstance.get(`/chat_group_config?group_id=${groupId}`);
}
/** 保存分群配置(groupId 传 'all' 表示保存至所有群) */
async saveGroupConfig(groupId: number | string, data: GroupConfig): Promise<ApiResponse> {
return this.axiosInstance.post(`/chat_group_config?group_id=${groupId}`, data);
}
/** 获取聊天消息 */
async getMessages(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
group_id?: string;
user_id?: string;
message?: string;
}): Promise<ApiResponse<PaginatedData<ChatMessage>>> {
return this.axiosInstance.get('/get_chat_messages', { params });
}
/** 获取学习内容 */
async getContexts(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatContext>>> {
return this.axiosInstance.get('/get_chat_contexts', { params });
}
/** 获取回复 */
async getAnswers(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
context_id?: number;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatAnswer>>> {
return this.axiosInstance.get('/get_chat_answers', { params });
}
/** 获取禁用列表 */
async getBlacklist(params: {
page?: number;
perPage?: number;
keywords?: string;
bans?: string;
}): Promise<ApiResponse<PaginatedData<BlacklistItem>>> {
return this.axiosInstance.get('/get_chat_blacklist', { params });
}
/** 删除(type: message | context | answer | blacklist) */
async deleteChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.delete(`/delete_chat?type=${type}&id=${id}`);
}
/** 禁用(type: message | context | answer) */
async banChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.put(`/ban_chat?type=${type}&id=${id}`);
}
/** 批量删除/取消禁用(type: answer | blacklist | context | message, id 可选) */
async deleteAll(type: string, id?: number): Promise<ApiResponse> {
let url = `/delete_all?type=${type}`;
if (id !== undefined) {
url += `&id=${id}`;
}
return this.axiosInstance.put(url);
}
}
export default new ApiClient();
@@ -0,0 +1,54 @@
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="640px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div class="full-content">{{ content }}</div>
<template #footer>
<el-button type="primary" plain @click="copyContent">复制内容</el-button>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
const props = defineProps<{
modelValue: boolean;
title?: string;
content?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const copyContent = async () => {
try {
await navigator.clipboard.writeText(props.content || '');
ElMessage.success('已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动选择复制');
}
};
</script>
<style scoped>
.full-content {
max-height: 60vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.7;
color: #303133;
background: #f5f7fa;
border-radius: 6px;
padding: 12px;
user-select: text;
}
</style>
@@ -0,0 +1,22 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import router from '@/router';
import App from '@/App.vue';
import '@/style.css';
// 移除旧版「API 地址设置」功能残留(同源架构下该功能已废弃, 写死的地址会导致跨域失败)
localStorage.removeItem('API_BASE_URL');
const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount('#app');
@@ -0,0 +1,105 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
// 懒加载视图
const Login = () => import('@/views/Login.vue');
const Config = () => import('@/views/Config.vue');
const Messages = () => import('@/views/Messages.vue');
const Contexts = () => import('@/views/Contexts.vue');
const Answers = () => import('@/views/Answers.vue');
const Blacklist = () => import('@/views/Blacklist.vue');
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: () => {
const authStore = useAuthStore();
return authStore.isAuthenticated ? '/config' : '/login';
},
},
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false },
},
{
path: '/config',
name: 'Config',
component: Config,
meta: { requiresAuth: true },
},
{
path: '/messages',
name: 'Messages',
component: Messages,
meta: { requiresAuth: true },
},
{
path: '/contexts',
name: 'Contexts',
component: Contexts,
meta: { requiresAuth: true },
},
{
path: '/answers',
name: 'Answers',
component: Answers,
meta: { requiresAuth: true },
},
{
path: '/blacklist',
name: 'Blacklist',
component: Blacklist,
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
meta: { requiresAuth: false },
},
];
const router = createRouter({
// base 与 vite.config.ts 的 base 保持一致, 否则 SPA 导航会产生不带前缀的根路径 URL, 刷新即 404
history: createWebHistory('/learning_chat/'),
routes,
});
// 全局路由守卫
router.beforeEach((to, _from) => {
const authStore = useAuthStore();
// token 已过期(按 JWT exp 主动检测) -> 立即登出跳登录页
if (authStore.token) {
const exp = authStore.getTokenExpiry(authStore.token);
if (exp !== null && exp * 1000 <= Date.now()) {
authStore.logout();
if (to.path !== '/login') {
ElMessage.warning('登录已过期,请重新登录');
return { path: '/login', query: { redirect: to.fullPath } };
}
}
}
const requiresAuth = to.meta.requiresAuth;
// 需要登录但未认证 -> 跳登录页
if (requiresAuth && !authStore.isAuthenticated) {
return {
path: '/login',
query: { redirect: to.fullPath },
};
}
// 已登录但访问登录页 -> 跳配置页
if (to.path === '/login' && authStore.isAuthenticated) {
return '/config';
}
// 其他情况放行
return true;
});
export default router;
@@ -0,0 +1,81 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAuthStore = defineStore('auth', () => {
// 初始化时从 localStorage 读取
const token = ref<string>(localStorage.getItem('token') || '');
const username = ref<string>(localStorage.getItem('username') || '');
const isAuthenticated = computed(() => !!token.value);
let expireTimer: ReturnType<typeof setTimeout> | null = null;
/** 解析 JWT payload 中的 exp(unix 秒); 非 JWT 或解析失败返回 null */
const getTokenExpiry = (t: string): number | null => {
try {
const payload = t.split('.')[1];
if (!payload) return null;
const json = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
);
return typeof json.exp === 'number' ? json.exp : null;
} catch {
return null;
}
};
/**
* 注册到期自动登出:
* - token 已过期 → 立即 logout 并返回 true
* - 未过期 → 在 exp 时刻触发 onExpired 回调
*/
const scheduleAutoLogout = (onExpired?: () => void): boolean => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
if (!token.value) return false;
const exp = getTokenExpiry(token.value);
if (exp === null) return false;
const remainingMs = exp * 1000 - Date.now();
if (remainingMs <= 0) {
logout();
return true;
}
expireTimer = setTimeout(() => {
logout();
onExpired?.();
}, remainingMs);
return false;
};
const setAuth = (newToken: string, newUsername: string) => {
token.value = newToken;
username.value = newUsername;
localStorage.setItem('token', newToken);
localStorage.setItem('username', newUsername);
// 登录即武装到期定时器
scheduleAutoLogout();
};
const logout = () => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
token.value = '';
username.value = '';
localStorage.removeItem('token');
localStorage.removeItem('username');
};
return {
token,
username,
isAuthenticated,
getTokenExpiry,
scheduleAutoLogout,
setAuth,
logout,
};
});
@@ -0,0 +1,79 @@
/* 全局样式 — Element Plus 自带 reset, 这里只保留主题与少量通用类 */
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: #f5f7fa;
color: #303133;
}
/* 页面级白色卡片容器(列表页) */
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* 页面顶部说明条 */
.page-tip {
margin-bottom: 16px;
}
/* 搜索栏与操作行 */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.toolbar .el-input {
width: 160px;
}
.toolbar .toolbar-wide {
width: 240px;
}
/* 分页容器 */
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 16px;
}
/* 表格内可点击查看完整内容的长文本单元格 */
.clickable-cell {
cursor: pointer;
color: #606266;
transition: color 0.2s;
}
.clickable-cell:hover {
color: var(--el-color-primary);
text-decoration: underline;
}
/* 单行省略(配合可点击单元格使用) */
.cell-ellipsis {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 表单辅助说明文字 */
.help-text {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-top: 4px;
}
@@ -0,0 +1,97 @@
/**
* 与后端 web_api.py 契约一致的类型定义
*/
/** 后端统一信封(列表/操作类接口); 配置 GET 返回裸 dict, 认证错误返回 {"detail"} */
export interface ApiResponse<T = unknown> {
status: number;
msg: string;
data?: T;
}
export interface PaginatedData<T> {
items: T[];
total: number;
}
export interface LoginResult {
token: string;
}
export interface GroupInfo {
label: string;
value: number;
}
export interface ChatMessage {
id: number;
message_id: string | number;
group_id: number;
user_id: number;
raw_message: string;
message?: string;
/** unix 秒整数 */
time: number;
}
export interface ChatContext {
id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
}
export interface ChatAnswer {
id: number;
group_id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
context_id: number;
/** 旧数据可能为 null */
messages: Array<{ msg: string }> | null;
}
export interface BlacklistItem {
id: number;
keywords: string;
bans: string;
global_ban?: boolean;
ban_group_id?: number[];
}
export interface GlobalConfig {
total_enable: boolean;
enable_web: boolean;
web_username: string;
web_password: string;
web_secret_key: string;
KEYWORDS_SIZE: number;
cross_group_threshold: number;
learn_max_count: number;
ban_words: string[];
ban_users: string[];
dictionary: string[];
}
export interface GroupConfig {
enable: boolean;
answer_threshold: number;
answer_threshold_weights: number[];
repeat_threshold: number;
/** 0-100 百分比(后端 GET ×100 / POST ÷100 契约) */
break_probability: number;
speak_enable: boolean;
speak_threshold: number;
speak_min_interval: number;
/** 0-100 百分比 */
speak_continuously_probability: number;
speak_continuously_max_len: number;
/** 0-100 百分比 */
speak_poke_probability: number;
ban_words: string[];
ban_users: string[];
member_list: GroupInfo[];
}
@@ -0,0 +1,14 @@
/** 后端 time 为 unix 秒整数, 格式化为本地时间(兼容毫秒/字符串) */
export function formatTime(time: number | string | null | undefined): string {
if (time === null || time === undefined || time === '') return '';
let t = Number(time);
if (Number.isNaN(t)) return String(time);
if (t < 1e12) t *= 1000; // 秒 → 毫秒
return new Date(t).toLocaleString('zh-CN');
}
/** 从错误对象提取可读消息(ApiError/普通 Error/未知值) */
export function errorMessage(err: unknown, fallback: string): string {
if (err instanceof Error && err.message) return err.message;
return fallback;
}
@@ -0,0 +1,12 @@
import { ElMessage } from 'element-plus';
import { ApiError } from '@/api/client';
import { errorMessage } from './format';
/**
* 展示错误提示。
* 认证失效类错误已由全局拦截器统一提示并跳转登录, 这里跳过, 避免重复弹窗。
*/
export function showError(err: unknown, fallback: string): void {
if (err instanceof ApiError && err.authHandled) return;
ElMessage.error(errorMessage(err, fallback));
}
@@ -0,0 +1,264 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人已学习到的所有回复。</p>
<p>点击「禁用」可以将该回复进行禁用;点击「删除」可以删除该回复。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="answers" stripe border @sort-change="handleSortChange">
<el-table-column type="expand">
<template #default="{ row }">
<div class="answers-detail">
<div v-for="(m, i) in (row.messages || [])" :key="i" class="answer-msg">
{{ m.msg }}
</div>
<el-empty
v-if="!row.messages || row.messages.length === 0"
description="无消息内容"
:image-size="50"
/>
</div>
</template>
</el-table-column>
<el-table-column label="内容/关键词" min-width="180" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`回复 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="次数" width="90" sortable="custom" />
<el-table-column label="消息预览" min-width="240">
<template #default="{ row }">
<div class="msg-preview clickable-cell" @click="showDetail(`完整消息 - ${row.keywords}`, (row.messages || []).map((m) => m.msg).join('\n'))">
<div v-for="(m, i) in (row.messages || []).slice(0, 2)" :key="i" class="msg-preview-line">
{{ m.msg }}
</div>
<div v-if="(row.messages || []).length > 2" class="msg-preview-more">
+{{ row.messages!.length - 2 }} 更多(点击查看全部)
</div>
<span v-if="!row.messages || row.messages.length === 0" class="msg-preview-empty">无消息内容</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无回复数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchAnswers"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer } from '@/types';
const answers = ref<ChatAnswer[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('count');
const sortOrder = ref<'asc' | 'desc'>('desc');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchAnswers = async () => {
loading.value = true;
try {
const res = await apiClient.getAnswers({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
answers.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchAnswers();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchAnswers();
};
const handleDelete = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
fetchAnswers();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有回复吗?这是不可逆的操作!',
'删除所有回复',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('answer');
ElMessage.success('已删除所有回复');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchAnswers();
</script>
<style scoped>
.answers-detail {
padding: 8px 24px;
}
.answer-msg {
padding: 6px 10px;
background: #f5f7fa;
border-radius: 4px;
border-left: 3px solid #67c23a;
margin-bottom: 6px;
font-size: 13px;
color: #606266;
word-break: break-all;
}
.answer-msg:last-child {
margin-bottom: 0;
}
.msg-preview-line {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-preview-more {
font-size: 12px;
color: #409eff;
}
.msg-preview-empty {
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,170 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人被禁用的内容/关键词。</p>
<p>可以取消禁用,使其能够重新继续学习;不能在此添加禁用,只能在配置中添加屏蔽词来达到禁用效果。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
@keyup.enter="handleSearch"
/>
<el-input
v-model="searchBans"
placeholder="搜索禁用群"
clearable
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">取消所有禁用</el-button>
</div>
<el-table v-loading="loading" :data="blacklist" stripe border>
<el-table-column label="内容/关键词" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`禁用项 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column label="已禁用的群" min-width="160">
<template #default="{ row }">
<el-tag v-if="row.global_ban" type="danger" effect="plain">全局禁用</el-tag>
<el-tag v-else-if="row.bans" type="info" effect="plain">{{ row.bans }}</el-tag>
<span v-else class="bans-empty">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" plain @click="handleUnban(row)">取消禁用</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无禁用项目" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchBlacklist"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { showError } from '@/utils/notify';
import type { BlacklistItem } from '@/types';
const blacklist = ref<BlacklistItem[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const searchBans = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchBlacklist = async () => {
loading.value = true;
try {
const res = await apiClient.getBlacklist({
page: page.value,
perPage,
keywords: searchKeywords.value,
bans: searchBans.value,
});
blacklist.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取禁用列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchBlacklist();
};
const handleUnban = async (row: BlacklistItem) => {
try {
await ElMessageBox.confirm(`确定要取消禁用 "${row.keywords}" 吗?`, '取消禁用', {
type: 'warning',
confirmButtonText: '取消禁用',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteChat('blacklist', row.id);
ElMessage.success('已取消禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm('确定要取消所有禁用吗?', '取消所有禁用', {
type: 'warning',
confirmButtonText: '全部取消',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteAll('blacklist');
ElMessage.success('已取消所有禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
// 初始加载
fetchBlacklist();
</script>
<style scoped>
.bans-empty {
color: #c0c4cc;
}
</style>
@@ -0,0 +1,586 @@
<template>
<div class="config-container">
<el-tabs v-model="activeTab">
<!-- ==================== 全局配置 ==================== -->
<el-tab-pane label="全局配置" name="global">
<div v-loading="loading" class="config-body">
<template v-if="globalConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习总开关">
<el-switch
v-model="globalConfig.total_enable"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">关闭后,全局都将不会再学习和回复</div>
</el-form-item>
<el-form-item label="后台管理总开关">
<el-switch
v-model="globalConfig.enable_web"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">是否开启本后台管理</div>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="config-card">
<template #header>认证设置</template>
<el-form label-position="top">
<el-form-item label="后台管理用户名">
<el-input v-model="globalConfig.web_username" placeholder="e.g. chat" />
<div class="help-text">登录本后台管理所需要的用户名</div>
</el-form-item>
<el-form-item label="后台管理密码">
<el-input
v-model="globalConfig.web_password"
type="password"
show-password
placeholder="输入密码"
/>
<div class="help-text">登录本后台管理所需要的密码</div>
</el-form-item>
<el-form-item label="后台管理Token密钥">
<el-input v-model="globalConfig.web_secret_key" placeholder="输入密钥" />
<div class="help-text">用于加密验证Token的密钥</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>学习参数</template>
<el-form label-position="top">
<el-form-item label="单句关键词数量">
<el-input-number v-model="globalConfig.KEYWORDS_SIZE" :min="2" controls-position="right" />
<div class="help-text">单句语句标签数量,建议保持默认为3</div>
</el-form-item>
<el-form-item label="跨群回复阈值">
<el-input-number v-model="globalConfig.cross_group_threshold" :min="1" controls-position="right" />
<div class="help-text">当学习到的一种回复在N个群都有,就变为全局回复</div>
</el-form-item>
<el-form-item label="最高学习次数">
<el-input-number v-model="globalConfig.learn_max_count" :min="2" controls-position="right" />
<div class="help-text">值越高,这个回复就会越容易进行回复</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="全局屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="banWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_words, banWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="全局屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(user, i) in globalConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_users.splice(i, 1)"
>
{{ user }}
</el-tag>
<el-input
v-model="banUserInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_users, banUserInput)"
/>
</div>
<div class="help-text">和这些用户有关的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="自定义词典">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.dictionary"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.dictionary.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="dictionaryInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.dictionary, dictionaryInput)"
/>
</div>
<div class="help-text">添加自定义词语,提高学习的准确性</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGlobal">保存</el-button>
<el-button @click="resetGlobal">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
<!-- ==================== 分群配置 ==================== -->
<el-tab-pane label="分群配置" name="group">
<div class="page-card group-select-row">
<span class="group-select-label">选择群:</span>
<el-select
v-model="selectedGroupId"
placeholder="-- 请选择群 --"
clearable
style="flex: 1"
@change="handleGroupChange"
>
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
</div>
<div v-if="selectedGroupId" v-loading="groupLoading" class="config-body">
<template v-if="groupConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习开关">
<el-switch v-model="groupConfig.enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">针对该群的学习开关</div>
</el-form-item>
<el-form-item v-if="groupConfig.enable" label="回复阈值">
<el-input-number v-model="groupConfig.answer_threshold" :min="2" controls-position="right" />
<div class="help-text">学习成功所需要的次数,值越低学得越快</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>回复阈值权重</template>
<el-form label-position="top">
<el-form-item label="设置权重(%)">
<div class="weight-list">
<div v-for="(weight, i) in groupConfig.answer_threshold_weights" :key="i" class="weight-row">
<el-input-number v-model="groupConfig.answer_threshold_weights[i]" :min="1" :max="100" controls-position="right" />
<el-button size="small" type="danger" plain @click="removeWeight(i)">删除</el-button>
</div>
<el-button size="small" type="success" plain @click="addWeight">+ 添加权重</el-button>
</div>
<div class="help-text">影响回复阈值的计算方式</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>复读设置</template>
<el-form label-position="top">
<el-form-item label="复读阈值">
<el-input-number v-model="groupConfig.repeat_threshold" :min="2" controls-position="right" />
<div class="help-text">N个人复读后机器人就会跟着复读</div>
</el-form-item>
<el-form-item label="打断复读概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.break_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">达到复读阈值时,打断复读的概率</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>主动发言设置</template>
<el-form label-position="top">
<el-form-item label="主动发言开关">
<el-switch v-model="groupConfig.speak_enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">是否允许机器人在该群主动发言</div>
</el-form-item>
<template v-if="groupConfig.speak_enable">
<el-form-item label="主动发言阈值">
<el-input-number v-model="groupConfig.speak_threshold" :min="0" controls-position="right" />
<div class="help-text">值越低,主动发言的可能性越高</div>
</el-form-item>
<el-form-item label="主动发言最小间隔">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_min_interval" :min="0" controls-position="right" />
<span class="suffix-text">秒</span>
</div>
<div class="help-text">进行主动发言的最小时间间隔</div>
</el-form-item>
<el-form-item label="连续主动发言概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_continuously_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">触发主动发言时,连续发言的概率</div>
</el-form-item>
<el-form-item label="最大连续发言句数">
<el-input-number v-model="groupConfig.speak_continuously_max_len" :min="1" controls-position="right" />
<div class="help-text">连续主动发言的最大句数</div>
</el-form-item>
<el-form-item label="戳一戳概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_poke_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">主动发言时附带戳一戳的概率</div>
</el-form-item>
</template>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in groupConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="groupBanWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(groupConfig.ban_words, groupBanWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习</div>
</el-form-item>
<el-form-item label="屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(userId, i) in groupConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_users.splice(i, 1)"
>
{{ formatUserId(userId) }}
</el-tag>
<el-select
v-model="pendingMember"
size="small"
class="member-select"
placeholder="-- 选择用户添加 --"
clearable
@change="addMember"
>
<el-option v-for="m in groupConfig.member_list" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</div>
<div class="help-text">和这些用户有关的消息不会学习</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGroup">保存</el-button>
<el-button type="success" plain :loading="saving" @click="saveGroupAll">保存至所有群</el-button>
<el-button @click="resetGroup">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, type Ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import { showError } from '@/utils/notify';
import type { GlobalConfig, GroupConfig, GroupInfo } from '@/types';
const activeTab = ref<'global' | 'group'>('global');
// ==================== 全局配置 ====================
const loading = ref(false);
const saving = ref(false);
const globalConfig = ref<GlobalConfig | null>(null);
let globalBackup: GlobalConfig | null = null;
const banWordInput = ref('');
const banUserInput = ref('');
const dictionaryInput = ref('');
const fetchGlobalConfig = async () => {
loading.value = true;
try {
globalConfig.value = await apiClient.getGlobalConfig();
// 深拷贝快照, 重置时恢复(修复原浅拷贝 bug)
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
} catch (err) {
showError(err, '获取全局配置失败');
} finally {
loading.value = false;
}
};
const saveGlobal = async () => {
if (!globalConfig.value) return;
saving.value = true;
try {
await apiClient.saveGlobalConfig(globalConfig.value);
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGlobal = () => {
if (globalBackup) {
globalConfig.value = JSON.parse(JSON.stringify(globalBackup));
}
};
// ==================== 分群配置 ====================
const groups = ref<GroupInfo[]>([]);
const selectedGroupId = ref<number | null>(null);
const groupLoading = ref(false);
const groupConfig = ref<GroupConfig | null>(null);
let groupBackup: GroupConfig | null = null;
const groupBanWordInput = ref('');
const pendingMember = ref<number | null>(null);
const fetchGroupList = async () => {
try {
const res = await apiClient.getGroupList();
groups.value = res.data!.group_list;
} catch (err) {
showError(err, '获取群列表失败');
}
};
const handleGroupChange = (value: number | null) => {
if (value === null) {
groupConfig.value = null;
return;
}
fetchGroupConfig(value);
};
const fetchGroupConfig = async (groupId: number) => {
groupLoading.value = true;
try {
groupConfig.value = await apiClient.getGroupConfig(groupId);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
} catch (err) {
groupConfig.value = null;
showError(err, '获取分群配置失败');
} finally {
groupLoading.value = false;
}
};
const saveGroup = async () => {
if (!groupConfig.value || selectedGroupId.value === null) return;
saving.value = true;
try {
await apiClient.saveGroupConfig(selectedGroupId.value, groupConfig.value);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const saveGroupAll = async () => {
if (!groupConfig.value) return;
try {
await ElMessageBox.confirm('确定要将当前配置保存至所有群吗?', '保存至所有群', {
type: 'warning',
confirmButtonText: '保存',
cancelButtonText: '取消',
});
} catch {
return;
}
saving.value = true;
try {
await apiClient.saveGroupConfig('all', groupConfig.value);
ElMessage.success('已保存至所有群');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGroup = () => {
if (groupBackup) {
groupConfig.value = JSON.parse(JSON.stringify(groupBackup));
}
};
// ==================== 通用 ====================
/** 动态标签输入: 回车添加, 重复值不添加 */
const addTag = (list: string[], input: Ref<string>) => {
const value = input.value.trim();
if (value && !list.includes(value)) {
list.push(value);
}
input.value = '';
};
const addWeight = () => {
if (!groupConfig.value) return;
groupConfig.value.answer_threshold_weights.push(25);
};
const removeWeight = (index: number) => {
if (!groupConfig.value) return;
if (groupConfig.value.answer_threshold_weights.length > 1) {
groupConfig.value.answer_threshold_weights.splice(index, 1);
}
};
const addMember = (value: number | null) => {
if (!groupConfig.value || value === null) return;
const v = String(value);
if (!groupConfig.value.ban_users.includes(v)) {
groupConfig.value.ban_users.push(v);
}
pendingMember.value = null;
};
const formatUserId = (userId: string | number) => {
const member = groupConfig.value?.member_list.find(
(m) => m.value.toString() === userId.toString()
);
return member?.label || String(userId);
};
onMounted(() => {
fetchGlobalConfig();
fetchGroupList();
});
</script>
<style scoped>
.config-container {
max-width: 1000px;
margin: 0 auto;
}
.config-body {
min-height: 100px;
}
.config-card {
margin-bottom: 16px;
border-radius: 8px;
}
.config-card .el-form {
max-width: 520px;
}
.config-card :deep(.el-input-number) {
width: 100%;
}
.suffix-wrap {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.suffix-text {
color: #909399;
font-size: 13px;
flex-shrink: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.tag-input {
width: 200px;
}
.member-select {
width: 220px;
}
.weight-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.weight-row {
display: flex;
align-items: center;
gap: 8px;
}
.weight-row .el-input-number {
width: 180px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.group-select-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 16px 20px;
}
.group-select-label {
font-weight: 500;
color: #303133;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,280 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人所学习的内容。</p>
<p>点击「回复列表」可以查看该条内容已学习到的回复;点击「禁用」可以禁用该学习;点击「删除」可以删除该学习。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="contexts" stripe border @sort-change="handleSortChange">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="内容/关键词" min-width="240" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`学习内容 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="已学次数" width="110" sortable="custom" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" plain @click="showAnswers(row)">回复列表</el-button>
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无学习内容" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchContexts"
/>
</div>
</div>
<!-- 回复列表弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="`回复列表 - ${currentContext?.keywords ?? ''}`"
width="640px"
destroy-on-close
@closed="dialogVisible = false; currentContext = null; contextAnswers = []"
>
<div v-loading="answersLoading">
<el-table v-if="contextAnswers.length" :data="contextAnswers" size="small" stripe border>
<el-table-column prop="keywords" label="回复内容" min-width="180" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBanAnswer(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDeleteAnswer(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!answersLoading" description="暂无回复" :image-size="80" />
</div>
</el-dialog>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer, ChatContext } from '@/types';
const contexts = ref<ChatContext[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('time');
const sortOrder = ref<'asc' | 'desc'>('desc');
const dialogVisible = ref(false);
const currentContext = ref<ChatContext | null>(null);
const contextAnswers = ref<ChatAnswer[]>([]);
const answersLoading = ref(false);
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchContexts = async () => {
loading.value = true;
try {
const res = await apiClient.getContexts({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
contexts.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取学习内容失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchContexts();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchContexts();
};
const handleDelete = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要删除学习内容 "${row.keywords}" 吗?`, '删除学习内容', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('context', row.id);
ElMessage.success('已删除');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要禁用学习内容 "${row.keywords}" 吗?`, '禁用学习内容', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('context', row.id);
ElMessage.success('已禁用');
fetchContexts();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有学习内容吗?这是不可逆的操作!',
'删除所有学习内容',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('context');
ElMessage.success('已删除所有学习内容');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const showAnswers = async (row: ChatContext) => {
currentContext.value = row;
contextAnswers.value = [];
dialogVisible.value = true;
answersLoading.value = true;
try {
const res = await apiClient.getAnswers({ context_id: row.id, perPage: 100 });
contextAnswers.value = res.data!.items;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
answersLoading.value = false;
}
};
const handleDeleteAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '删除失败');
}
};
const handleBanAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '禁用失败');
}
};
// 初始加载
fetchContexts();
</script>
@@ -0,0 +1,159 @@
<template>
<div class="login-wrapper">
<el-card class="login-card" shadow="always">
<div class="logo-container">
<img
:src="LOGO_URL"
alt="Nonebot-Plugin-Learning-Chat"
class="logo"
@error="logoFailed = true"
/>
</div>
<h1 class="app-title">Nonebot-Plugin-Learning-Chat 控制台</h1>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
@submit.prevent
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
:disabled="loading"
autofocus
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
:disabled="loading"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
class="login-error"
/>
<el-button
type="primary"
class="login-button"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
import apiClient from '@/api/client';
const LOGO_URL =
'https://s3.sansenhoshi.top/s-file-trans/%E5%9B%BE%E7%89%87/icon/123.jpg';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const formRef = ref<FormInstance>();
const loading = ref(false);
const error = ref('');
const logoFailed = ref(false);
const form = reactive({ username: '', password: '' });
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
const handleLogin = async () => {
error.value = '';
if (!formRef.value) return;
const valid = await formRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const res = await apiClient.login(form.username.trim(), form.password);
authStore.setAuth(res.data!.token, form.username.trim());
// 登录后跳回被拦截前的页面(如 token 过期时重定向来的路径)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
await router.push(redirect);
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败,请重试';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-wrapper {
width: 100%;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 12px;
padding: 12px 8px;
}
.logo-container {
text-align: center;
margin-bottom: 20px;
}
.logo {
width: 90px;
height: 90px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.app-title {
text-align: center;
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0 0 28px;
line-height: 1.4;
}
.login-error {
margin-bottom: 16px;
}
.login-button {
width: 100%;
margin-top: 4px;
}
</style>
@@ -0,0 +1,188 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人收到的聊天记录。</p>
<p>点击「禁用」可以将某条聊天记录进行禁用;点击「删除」可以删除某条记录。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input v-model="searchGroupId" placeholder="群ID" clearable />
<el-input v-model="searchUserId" placeholder="用户ID" clearable />
<el-input
v-model="searchMessage"
placeholder="消息内容"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="messages" stripe border>
<el-table-column prop="message_id" label="消息ID" width="130" show-overflow-tooltip />
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="user_id" label="用户ID" width="110" />
<el-table-column label="消息" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.raw_message || row.message"
@click="showDetail(`消息 #${row.message_id}`, row.raw_message || row.message)"
>
{{ row.raw_message || row.message }}
</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无消息数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchMessages"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatMessage } from '@/types';
const messages = ref<ChatMessage[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchGroupId = ref('');
const searchUserId = ref('');
const searchMessage = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchMessages = async () => {
loading.value = true;
try {
const res = await apiClient.getMessages({
page: page.value,
perPage,
group_id: searchGroupId.value,
user_id: searchUserId.value,
message: searchMessage.value,
});
messages.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取消息失败');
} finally {
loading.value = false;
}
};
// 搜索必须重置到第 1 页, 否则可能停留在超范围的页码上
const handleSearch = () => {
page.value = 1;
fetchMessages();
};
const handleDelete = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要删除消息 "${row.raw_message || row.message}" 吗?`, '删除消息', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('message', row.id);
ElMessage.success('已删除');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要禁用这条消息吗?`, '禁用消息', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('message', row.id);
ElMessage.success('已禁用');
fetchMessages();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有消息吗?这是不可逆的操作!',
'删除所有消息',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('message');
ElMessage.success('已删除所有消息');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchMessages();
</script>
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* 路径别名 */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Vue支持 */
"types": ["vite/client"]
},
"include": ["src", "src/**/*.ts", "src/**/*.vue", "src/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [vue()],
// 构建产物由 bot 在 /learning_chat 路径下提供
base: '/learning_chat/',
resolve: {
alias: {
'@': path.resolve(process.cwd(), 'src'),
},
},
server: {
port: 5173,
open: false,
host: '0.0.0.0',
// 开发模式下 API 请求转发到 bot 端口
proxy: {
'/learning_chat/api': {
target: 'http://localhost:39697',
changeOrigin: true,
},
},
},
})