项目改造
This commit is contained in:
@@ -6,7 +6,6 @@ from nonebot.plugin import PluginMetadata
|
||||
require("nonebot_plugin_orm")
|
||||
|
||||
from .config import NICKNAME, config_manager, register_config as _register_learning_config # noqa: E402
|
||||
from . import web_api # noqa: E402
|
||||
from . import web_hub # noqa: E402
|
||||
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
|
||||
|
||||
|
||||
@@ -49,13 +49,6 @@ class ChatGroupConfig(BaseModel):
|
||||
|
||||
class ChatConfig(BaseModel):
|
||||
total_enable: bool = Field(default=True, alias="群聊学习总开关")
|
||||
enable_web: bool = Field(default=True, alias="启用后台管理")
|
||||
web_username: str = Field(default="chat", alias="后台管理用户名")
|
||||
web_password: str = Field(default="admin", alias="后台管理密码")
|
||||
web_secret_key: str = Field(
|
||||
default="49c294d32f69b732ef6447c18379451ce1738922a75cd1d4812ef150318a2ed0",
|
||||
alias="后台管理token密钥",
|
||||
)
|
||||
ban_words: List[str] = Field(default_factory=list, alias="全局屏蔽词")
|
||||
ban_users: List[int] = Field(default_factory=list, alias="全局屏蔽用户")
|
||||
KEYWORDS_SIZE: int = Field(default=3, alias="单句关键词分词数量")
|
||||
@@ -118,7 +111,7 @@ def register_config() -> None:
|
||||
"""把顶层 ChatConfig 顶层字段接入统一 Web 配置标准(/hub 可读可改)。
|
||||
|
||||
getter/setter 直接读写 config_manager.config,并保存到 learning_chat.yml。
|
||||
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json(避免与自有后台互相覆盖)。
|
||||
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json。
|
||||
"""
|
||||
from hexi.config_standard import register_config_items
|
||||
|
||||
@@ -131,10 +124,6 @@ def register_config() -> None:
|
||||
|
||||
fields = [
|
||||
{"key": "total_enable", "label": "群聊学习总开关", "type": "bool"},
|
||||
{"key": "enable_web", "label": "启用后台管理", "type": "bool"},
|
||||
{"key": "web_username", "label": "后台管理用户名", "type": "string"},
|
||||
{"key": "web_password", "label": "后台管理密码", "type": "password", "secret": True},
|
||||
{"key": "web_secret_key", "label": "后台签名密钥", "type": "password", "secret": True},
|
||||
{"key": "ban_words", "label": "全局屏蔽词", "type": "text", "item_type": "str"},
|
||||
{"key": "ban_users", "label": "全局屏蔽用户", "type": "text", "item_type": "int"},
|
||||
{"key": "KEYWORDS_SIZE", "label": "单句关键词分词数量", "type": "int"},
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,14 +0,0 @@
|
||||
<!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>
|
||||
-1559
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,146 +0,0 @@
|
||||
<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>
|
||||
@@ -1,204 +0,0 @@
|
||||
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();
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
<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>
|
||||
@@ -1,22 +0,0 @@
|
||||
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');
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
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;
|
||||
@@ -1,81 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/* 全局样式 — 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;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 与后端 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[];
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/** 后端 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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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));
|
||||
}
|
||||
-264
@@ -1,264 +0,0 @@
|
||||
<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>
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
<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>
|
||||
-586
@@ -1,586 +0,0 @@
|
||||
<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>
|
||||
-280
@@ -1,280 +0,0 @@
|
||||
<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>
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
<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>
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
<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>
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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" }]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,369 +0,0 @@
|
||||
import datetime
|
||||
from typing import Optional, Union
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Header, HTTPException, Depends
|
||||
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
|
||||
from jose import jwt
|
||||
from nonebot import get_app, get_adapter, logger
|
||||
from nonebot.adapters.onebot.v11 import Adapter
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, delete, update, func
|
||||
|
||||
try:
|
||||
import jieba_fast as jieba
|
||||
except ImportError:
|
||||
import jieba
|
||||
|
||||
from nonebot_plugin_orm import get_session
|
||||
from .services.learn import LearningChat
|
||||
from .models import ChatMessage, ChatContext, ChatAnswer, ChatBlackList
|
||||
from .config import config_manager, driver
|
||||
from .web_frontend import ensure_frontend_ready, mount_frontend
|
||||
|
||||
|
||||
def authentication():
|
||||
def inner(token: Optional[str] = Header(...)):
|
||||
try:
|
||||
payload = jwt.decode(token, config_manager.config.web_secret_key, algorithms="HS256")
|
||||
if (
|
||||
not (username := payload.get("username"))
|
||||
or username != config_manager.config.web_username
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
|
||||
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
|
||||
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
|
||||
|
||||
return Depends(inner)
|
||||
|
||||
|
||||
class UserModel(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
async def init_web():
|
||||
if not config_manager.config.enable_web:
|
||||
return
|
||||
if not await ensure_frontend_ready():
|
||||
logger.warning("群聊学习 | 前端构建失败, 管理页面将不可用")
|
||||
app: FastAPI = get_app()
|
||||
|
||||
@app.post("/learning_chat/api/login", response_class=JSONResponse)
|
||||
async def login(user: UserModel):
|
||||
if (
|
||||
user.username != config_manager.config.web_username
|
||||
or user.password != config_manager.config.web_password
|
||||
):
|
||||
return {"status": -100, "msg": "登录失败,请确认用户ID和密码无误"}
|
||||
token = jwt.encode(
|
||||
{
|
||||
"username": user.username,
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30),
|
||||
},
|
||||
config_manager.config.web_secret_key,
|
||||
algorithm="HS256",
|
||||
)
|
||||
return {"status": 0, "msg": "登录成功", "data": {"token": token}}
|
||||
|
||||
@app.options("/learning_chat/api/login")
|
||||
async def options_login():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.get("/learning_chat/api/get_group_list", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def get_group_list_api():
|
||||
try:
|
||||
bots = get_adapter(Adapter).bots
|
||||
if len(bots) == 0:
|
||||
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
|
||||
bot = list(bots.values())[0]
|
||||
group_list = await bot.get_group_list()
|
||||
group_list = [
|
||||
{"label": f'{group["group_name"]}({group["group_id"]})', "value": group["group_id"]}
|
||||
for group in group_list
|
||||
]
|
||||
return {"status": 0, "msg": "ok", "data": {"group_list": group_list}}
|
||||
except ValueError:
|
||||
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
|
||||
|
||||
@app.options("/learning_chat/api/get_group_list")
|
||||
async def options_get_group_list():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.get("/learning_chat/api/chat_global_config", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def get_chat_global_config():
|
||||
# 注意: 不再注入 member_list —— 前端全局配置页不使用(仅分群配置需要,
|
||||
# 由 chat_group_config 单独拉取单个群成员), 避免串行拉取全部群成员导致接口卡顿
|
||||
return config_manager.config.dict(exclude={"group_config"})
|
||||
|
||||
@app.post("/learning_chat/api/chat_global_config", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def post_chat_global_config(data: dict):
|
||||
config_manager.config.update(**data)
|
||||
config_manager.save()
|
||||
async with get_session() as session:
|
||||
await session.execute(
|
||||
update(ChatContext)
|
||||
.where(ChatContext.count > config_manager.config.learn_max_count)
|
||||
.values(count=config_manager.config.learn_max_count)
|
||||
)
|
||||
await session.execute(
|
||||
update(ChatAnswer)
|
||||
.where(ChatAnswer.count > config_manager.config.learn_max_count)
|
||||
.values(count=config_manager.config.learn_max_count)
|
||||
)
|
||||
await session.commit()
|
||||
jieba.load_userdict(config_manager.config.dictionary)
|
||||
return {"status": 0, "msg": "保存成功"}
|
||||
|
||||
@app.options("/learning_chat/api/chat_global_config")
|
||||
async def options_chat_global_config():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.get("/learning_chat/api/chat_group_config", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def get_chat_group_config(group_id: int):
|
||||
try:
|
||||
bots = get_adapter(Adapter).bots
|
||||
if len(bots) == 0:
|
||||
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
|
||||
bot = list(bots.values())[0]
|
||||
members = await bot.get_group_member_list(group_id=group_id)
|
||||
member_list = [
|
||||
{"label": f'{member["nickname"] or member["card"]}({member["user_id"]})', "value": member["user_id"]}
|
||||
for member in members
|
||||
]
|
||||
config = config_manager.get_group_config(group_id).dict()
|
||||
config["break_probability"] = config["break_probability"] * 100
|
||||
config["speak_continuously_probability"] = config["speak_continuously_probability"] * 100
|
||||
config["speak_poke_probability"] = config["speak_poke_probability"] * 100
|
||||
config["member_list"] = member_list
|
||||
return config
|
||||
except ValueError:
|
||||
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
|
||||
|
||||
@app.post("/learning_chat/api/chat_group_config", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def post_chat_group_config(group_id: Union[int, str], data: dict):
|
||||
if not data.get("answer_threshold_weights"):
|
||||
return {"status": 400, "msg": "回复阈值权重不能为空,必须至少有一个数值"}
|
||||
data["break_probability"] = data["break_probability"] / 100
|
||||
data["speak_continuously_probability"] = data["speak_continuously_probability"] / 100
|
||||
data["speak_poke_probability"] = data["speak_poke_probability"] / 100
|
||||
bots = get_adapter(Adapter).bots
|
||||
if len(bots) == 0:
|
||||
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
|
||||
bot = list(bots.values())[0]
|
||||
groups = (
|
||||
[{"group_id": group_id}] if group_id != "all" else await bot.get_group_list()
|
||||
)
|
||||
for group in groups:
|
||||
config = config_manager.get_group_config(int(group["group_id"]))
|
||||
config.update(**data)
|
||||
config_manager.config.group_config[int(group["group_id"])] = config
|
||||
config_manager.save()
|
||||
return {"status": 0, "msg": "保存成功"}
|
||||
|
||||
@app.options("/learning_chat/api/chat_group_config")
|
||||
async def options_chat_group_config():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.get("/learning_chat/api/get_chat_messages", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def get_chat_messages(
|
||||
page: int = 1,
|
||||
perPage: int = 10,
|
||||
orderBy: str = "time",
|
||||
orderDir: str = "desc",
|
||||
group_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
async with get_session() as session:
|
||||
stmt = select(ChatMessage)
|
||||
if group_id:
|
||||
stmt = stmt.where(ChatMessage.group_id == int(group_id))
|
||||
if user_id:
|
||||
stmt = stmt.where(ChatMessage.user_id == int(user_id))
|
||||
if message:
|
||||
stmt = stmt.where(ChatMessage.raw_message.contains(message))
|
||||
|
||||
order_col = getattr(ChatMessage, orderBy or "time", ChatMessage.time)
|
||||
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
|
||||
|
||||
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
|
||||
items = [
|
||||
{c.name: getattr(row, c.name) for c in ChatMessage.__table__.columns}
|
||||
for row in items_result.scalars().all()
|
||||
]
|
||||
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
|
||||
|
||||
@app.options("/learning_chat/api/get_chat_messages")
|
||||
async def options_get_chat_messages():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.get("/learning_chat/api/get_chat_contexts", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def get_chat_context(
|
||||
page: int = 1,
|
||||
perPage: int = 10,
|
||||
orderBy: str = "time",
|
||||
orderDir: str = "desc",
|
||||
keywords: Optional[str] = None,
|
||||
):
|
||||
async with get_session() as session:
|
||||
stmt = select(ChatContext)
|
||||
if keywords:
|
||||
stmt = stmt.where(ChatContext.keywords.contains(keywords))
|
||||
order_col = getattr(ChatContext, orderBy or "time", ChatContext.time)
|
||||
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
|
||||
|
||||
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
|
||||
items = [
|
||||
{c.name: getattr(row, c.name) for c in ChatContext.__table__.columns}
|
||||
for row in items_result.scalars().all()
|
||||
]
|
||||
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
|
||||
|
||||
@app.options("/learning_chat/api/get_chat_contexts")
|
||||
async def options_get_chat_contexts():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.get("/learning_chat/api/get_chat_answers", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def get_chat_answers(
|
||||
context_id: Optional[int] = None,
|
||||
page: int = 1,
|
||||
perPage: int = 10,
|
||||
orderBy: str = "count",
|
||||
orderDir: str = "desc",
|
||||
keywords: Optional[str] = None,
|
||||
):
|
||||
async with get_session() as session:
|
||||
stmt = select(ChatAnswer)
|
||||
if context_id:
|
||||
stmt = stmt.where(ChatAnswer.context_id == context_id)
|
||||
if keywords:
|
||||
stmt = stmt.where(ChatAnswer.keywords.contains(keywords))
|
||||
order_col = getattr(ChatAnswer, orderBy or "count", ChatAnswer.count)
|
||||
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
|
||||
|
||||
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
|
||||
items = []
|
||||
for row in items_result.scalars().all():
|
||||
item = {c.name: getattr(row, c.name) for c in ChatAnswer.__table__.columns}
|
||||
item["messages"] = [{"msg": m} for m in item["messages"]]
|
||||
items.append(item)
|
||||
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
|
||||
|
||||
@app.options("/learning_chat/api/get_chat_answers")
|
||||
async def options_get_chat_answers():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.get("/learning_chat/api/get_chat_blacklist", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def get_chat_blacklist(
|
||||
page: int = 1,
|
||||
perPage: int = 10,
|
||||
keywords: Optional[str] = None,
|
||||
bans: Optional[str] = None,
|
||||
):
|
||||
async with get_session() as session:
|
||||
stmt = select(ChatBlackList).order_by(ChatBlackList.id.desc())
|
||||
if keywords:
|
||||
stmt = stmt.where(ChatBlackList.keywords.contains(keywords))
|
||||
|
||||
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
# bans 过滤需要基于全部数据(而非当前页), 故先全量取回再过滤、再分页
|
||||
items_result = await session.execute(stmt)
|
||||
items = []
|
||||
for row in items_result.scalars().all():
|
||||
item = {c.name: getattr(row, c.name) for c in ChatBlackList.__table__.columns}
|
||||
ban_ids = item["ban_group_id"] or []
|
||||
item["bans"] = "全局禁用" if item["global_ban"] else (str(ban_ids[0]) if ban_ids else "")
|
||||
items.append(item)
|
||||
if bans:
|
||||
items = [x for x in items if bans in x["bans"]]
|
||||
total = len(items)
|
||||
items = items[(page - 1) * perPage : page * perPage]
|
||||
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
|
||||
|
||||
@app.options("/learning_chat/api/get_chat_blacklist")
|
||||
async def options_get_chat_blacklist():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.delete("/learning_chat/api/delete_chat", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def delete_chat(id: int, type: str):
|
||||
try:
|
||||
async with get_session() as session:
|
||||
if type == "message":
|
||||
await session.execute(delete(ChatMessage).where(ChatMessage.id == id))
|
||||
elif type == "context":
|
||||
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
|
||||
await session.execute(delete(ChatContext).where(ChatContext.id == id))
|
||||
elif type == "answer":
|
||||
await session.execute(delete(ChatAnswer).where(ChatAnswer.id == id))
|
||||
elif type == "blacklist":
|
||||
await session.execute(delete(ChatBlackList).where(ChatBlackList.id == id))
|
||||
await session.commit()
|
||||
return {"status": 0, "msg": "删除成功"}
|
||||
except Exception as e:
|
||||
return {"status": 500, "msg": f"删除失败,{e}"}
|
||||
|
||||
@app.options("/learning_chat/api/delete_chat")
|
||||
async def options_delete_chat():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.put("/learning_chat/api/ban_chat", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def ban_chat(id: int, type: str):
|
||||
try:
|
||||
async with get_session() as session:
|
||||
if type == "message":
|
||||
result = await session.execute(select(ChatMessage).where(ChatMessage.id == id))
|
||||
data = result.scalar_one()
|
||||
elif type == "context":
|
||||
result = await session.execute(select(ChatContext).where(ChatContext.id == id))
|
||||
data = result.scalar_one()
|
||||
else:
|
||||
result = await session.execute(select(ChatAnswer).where(ChatAnswer.id == id))
|
||||
data = result.scalar_one()
|
||||
await LearningChat.add_ban(data)
|
||||
return {"status": 0, "msg": "禁用成功"}
|
||||
except Exception as e:
|
||||
return {"status": 500, "msg": f"禁用失败: {e}"}
|
||||
|
||||
@app.options("/learning_chat/api/ban_chat")
|
||||
async def options_ban_chat():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
@app.put("/learning_chat/api/delete_all", response_class=JSONResponse, dependencies=[authentication()])
|
||||
async def delete_all(type: str, id: Optional[int] = None):
|
||||
try:
|
||||
async with get_session() as session:
|
||||
if type == "answer":
|
||||
if id:
|
||||
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
|
||||
else:
|
||||
await session.execute(delete(ChatAnswer))
|
||||
elif type == "blacklist":
|
||||
await session.execute(delete(ChatBlackList))
|
||||
elif type == "context":
|
||||
await session.execute(delete(ChatContext))
|
||||
elif type == "message":
|
||||
await session.execute(delete(ChatMessage))
|
||||
await session.commit()
|
||||
return {"status": 0, "msg": "操作成功"}
|
||||
except Exception as e:
|
||||
return {"status": 500, "msg": f"操作失败,{e}"}
|
||||
|
||||
@app.options("/learning_chat/api/delete_all")
|
||||
async def options_delete_all():
|
||||
return JSONResponse(content={}, status_code=200)
|
||||
|
||||
# 静态资源挂载放在所有 API 路由之后, 保证 /learning_chat/api/* 优先匹配
|
||||
mount_frontend(app)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""前端管理页面的构建与静态挂载。
|
||||
|
||||
方案: 不再由 bot 拉起 vite dev server, 而是把构建产物用 FastAPI StaticFiles
|
||||
挂载到 bot 端口 /learning_chat 路径, 与 API 同源, 无孤儿进程、无 CORS 问题。
|
||||
前端开发时手动 `npm run dev` 即可(API 已配置 dev proxy)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from nonebot import logger
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ADMIN_DIR = BASE_DIR / "web" / "learning-chat-admin"
|
||||
DIST_DIR = ADMIN_DIR / "dist"
|
||||
|
||||
# 参与构建新鲜度检查的源文件(不在 src/ 下的)
|
||||
SRC_FILES = ("vite.config.ts", "index.html", "package.json")
|
||||
|
||||
|
||||
def _dist_is_stale() -> bool:
|
||||
"""dist/index.html 不存在, 或比任何源码旧时视为需要重建"""
|
||||
dist_index = DIST_DIR / "index.html"
|
||||
if not dist_index.exists():
|
||||
return True
|
||||
dist_mtime = dist_index.stat().st_mtime
|
||||
for src_file in (ADMIN_DIR / "src").rglob("*"):
|
||||
if src_file.is_file() and src_file.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
return any(
|
||||
(ADMIN_DIR / name).stat().st_mtime > dist_mtime
|
||||
for name in SRC_FILES
|
||||
if (ADMIN_DIR / name).exists()
|
||||
)
|
||||
|
||||
|
||||
async def build_frontend() -> bool:
|
||||
"""执行 vite 构建, 返回是否成功"""
|
||||
logger.info("群聊学习 | 前端源码已更新, 开始重新构建(约10-40秒)...")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"npx.cmd",
|
||||
"vite",
|
||||
"build",
|
||||
cwd=str(ADMIN_DIR),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
output, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.error(
|
||||
"群聊学习 | 前端构建失败(%s):\n%s",
|
||||
proc.returncode,
|
||||
output.decode(errors="ignore")[-3000:],
|
||||
)
|
||||
return False
|
||||
logger.info("群聊学习 | 前端构建完成")
|
||||
return True
|
||||
|
||||
|
||||
async def ensure_frontend_ready() -> bool:
|
||||
"""确保构建产物是最新的, 返回是否可用(最新或已成功重建)"""
|
||||
if not _dist_is_stale():
|
||||
return True
|
||||
return await build_frontend()
|
||||
|
||||
|
||||
def mount_frontend(app) -> None:
|
||||
"""将构建产物挂载到 bot 端口的 /learning_chat 路径(需在 API 路由注册后调用)。
|
||||
|
||||
history 模式 SPA 需要回退: assets/ 静态挂载 + 真实文件直接返回,
|
||||
其余路径(如 /login /config 等前端路由)一律回退到 index.html。
|
||||
"""
|
||||
if not DIST_DIR.exists():
|
||||
logger.error("群聊学习 | 前端构建产物不存在, 管理页面不可用")
|
||||
return
|
||||
assets_dir = DIST_DIR / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount(
|
||||
"/learning_chat/assets",
|
||||
StaticFiles(directory=str(assets_dir)),
|
||||
name="learning_chat_assets",
|
||||
)
|
||||
dist_resolved = DIST_DIR.resolve()
|
||||
|
||||
@app.get("/learning_chat/{full_path:path}")
|
||||
async def learning_chat_spa(full_path: str):
|
||||
target = (DIST_DIR / full_path).resolve()
|
||||
if full_path and target.is_file() and target.is_relative_to(dist_resolved):
|
||||
return FileResponse(target)
|
||||
# SPA 回退: 前端路由(如 /login /config)全部交给 index.html
|
||||
return FileResponse(DIST_DIR / "index.html")
|
||||
|
||||
@app.get("/learning_chat")
|
||||
async def learning_chat_root():
|
||||
return FileResponse(DIST_DIR / "index.html")
|
||||
|
||||
logger.info("群聊学习 | 管理页面已挂载: http://<host>:<port>/learning_chat/")
|
||||
@@ -2,7 +2,7 @@
|
||||
"""群聊学习 Web API 子应用(挂载到 /api/learning_chat)。
|
||||
|
||||
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
|
||||
旧版独立后台 /learning_chat(JWT 自鉴权)保留可用,前端由 hub 渲染。
|
||||
前端由统一管理台 hexi/web 渲染。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -45,9 +45,6 @@ async def _first_bot():
|
||||
|
||||
def build_admin_app() -> FastAPI | None:
|
||||
"""构建群聊学习管理 API 子应用(挂载到 /api/learning_chat)。"""
|
||||
if not config_manager.config.enable_web:
|
||||
return None
|
||||
|
||||
app = FastAPI(title="Learning Chat API")
|
||||
auth = API
|
||||
|
||||
|
||||
Reference in New Issue
Block a user