更新
This commit is contained in:
Submodule
+1
Submodule backend added at 49917ae7c0
Submodule
+1
Submodule backend-tp added at 49917ae7c0
Submodule
+1
Submodule backend-tp8 added at 49917ae7c0
Binary file not shown.
@@ -0,0 +1,46 @@
|
|||||||
|
# AI Chat 生产环境 Nginx 配置示例
|
||||||
|
# 网站根目录必须指向 backend/public
|
||||||
|
# phpstudy:网站 -> 设置 -> 配置文件,粘贴以下 server 块内容
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
# listen 9998; # 若使用自定义端口
|
||||||
|
server_name chat.zhenyangtang.com.cn;
|
||||||
|
|
||||||
|
root D:/web/chat/backend/public;
|
||||||
|
index index.html index.php;
|
||||||
|
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
# ===== 1. API 必须优先走 PHP(不要走 SPA 的 index.html)=====
|
||||||
|
location ^~ /api {
|
||||||
|
rewrite ^ /index.php?s=$uri last;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 2. PHP 处理 =====
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass 127.0.0.1:9000; # phpstudy 可能是 9000 或 9001,按实际修改
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 3. 管理后台 SPA =====
|
||||||
|
location ^~ /admin/ {
|
||||||
|
try_files $uri $uri/ /admin/index.html;
|
||||||
|
}
|
||||||
|
location = /admin {
|
||||||
|
return 301 /admin/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 4. 会员端 SPA =====
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 5. 静态资源缓存 =====
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# phpstudy 部署说明
|
||||||
|
|
||||||
|
## 1. 网站根目录
|
||||||
|
|
||||||
|
必须指向:
|
||||||
|
|
||||||
|
```
|
||||||
|
D:\web\chat\backend\public
|
||||||
|
```
|
||||||
|
|
||||||
|
**不能**指到 `backend/` 或项目根目录。
|
||||||
|
|
||||||
|
## 2. 编译前端
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\web\chat
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 伪静态 / 重写规则
|
||||||
|
|
||||||
|
### 若使用 Apache(phpstudy 默认)
|
||||||
|
|
||||||
|
项目已自带 `backend/public/.htaccess`,确保 phpstudy 中:
|
||||||
|
|
||||||
|
- 网站 → 设置 → **Apache** → 开启 `mod_rewrite`
|
||||||
|
- 允许 `.htaccess` 覆盖(AllowOverride All)
|
||||||
|
|
||||||
|
### 若使用 Nginx
|
||||||
|
|
||||||
|
phpstudy → 网站 → 设置 → **配置文件**,参考 `deploy/nginx.conf.example`。
|
||||||
|
|
||||||
|
**关键配置**(缺少会导致登录 404):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /api {
|
||||||
|
rewrite ^ /index.php?s=$uri last;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`location /` 的 `try_files ... /index.html` 不能拦截 `/api`,所以 `/api` 必须写在前面并使用 `^~` 前缀匹配。
|
||||||
|
|
||||||
|
## 4. 验证 API 是否正常
|
||||||
|
|
||||||
|
浏览器或 curl 测试:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://你的域名/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"account":"admin","password":"admin123"}
|
||||||
|
```
|
||||||
|
|
||||||
|
应返回 JSON(`code: 0` 表示成功),**不应**是 nginx 404 页面。
|
||||||
|
|
||||||
|
## 5. 访问地址
|
||||||
|
|
||||||
|
| 功能 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 会员端 | `http://域名/` |
|
||||||
|
| 管理后台 | `http://域名/admin/` |
|
||||||
|
| API | `http://域名/api/...` |
|
||||||
|
|
||||||
|
## 6. 常见问题
|
||||||
|
|
||||||
|
| 现象 | 原因 | 处理 |
|
||||||
|
|------|------|------|
|
||||||
|
| 打开首页是 ThinkPHP 欢迎页 | 默认走了 index.php | 设置 `index index.html index.php`;勿用 ThinkPHP 全站伪静态;已内置兜底会输出 Vue 页面 |
|
||||||
|
| 登录 404 nginx | `/api` 未转发到 index.php | 按上文添加 Nginx/Apache 重写 |
|
||||||
|
| 页面空白 | 根目录指错或未编译 | 改为 `backend/public` 并执行 `npm run build` |
|
||||||
|
| 管理后台 404 | 未编译或未部署 admin | 执行 `npm run build` |
|
||||||
|
| 上传失败 | PHP 限制 / 目录权限 | php.ini 调大 upload_max_filesize;`chmod -R 775 backend/uploads` |
|
||||||
|
|
||||||
|
### Linux 上传图片 500 错误
|
||||||
|
|
||||||
|
在服务器上执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建并授权 uploads 目录(PHP 运行用户通常是 www-data 或 nginx)
|
||||||
|
mkdir -p /path/to/chat/backend/uploads
|
||||||
|
chmod -R 775 /path/to/chat/backend/uploads
|
||||||
|
chown -R www-data:www-data /path/to/chat/backend/uploads
|
||||||
|
|
||||||
|
# 2. 确认 PHP 扩展已安装
|
||||||
|
php -m | grep fileinfo
|
||||||
|
|
||||||
|
# 3. 确认 php.ini 上传限制
|
||||||
|
php -i | grep -E 'upload_max_filesize|post_max_size'
|
||||||
|
```
|
||||||
|
|
||||||
|
常见原因:
|
||||||
|
- `uploads/` 目录不存在或 PHP 进程无写权限
|
||||||
|
- Linux 上 MIME 识别为 `application/octet-stream`(代码已做扩展名兜底)
|
||||||
|
- 未安装 `fileinfo` 扩展
|
||||||
|
|
||||||
|
### phpstudy 伪静态注意
|
||||||
|
|
||||||
|
**不要**选用「ThinkPHP」默认模板(会把所有请求转发到 index.php)。
|
||||||
|
|
||||||
|
请使用 `backend/public/nginx.htaccess` 中的规则,或完整配置见 `deploy/nginx.conf.example`。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:8080
|
||||||
|
VITE_MEMBER_URL=http://localhost:5173
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 生产环境:会员端入口(同域根路径)
|
||||||
|
VITE_MEMBER_URL=/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI Chat 管理后台</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1727
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-chat-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build && node ../scripts/deploy-static.js admin",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#6366f1"/>
|
||||||
|
<path d="M10 12h12v2H10v-2zm0 4h12v2H10v-2zm0 4h8v2h-8v-2z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 212 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 60000
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
const message = err.response?.data?.message || '请求失败'
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
const loginPath = `${import.meta.env.BASE_URL}login`.replace(/\/{2,}/g, '/')
|
||||||
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
window.location.href = loginPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(message))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0f172a;
|
||||||
|
--bg-secondary: #1e293b;
|
||||||
|
--bg-tertiary: #334155;
|
||||||
|
--bg-hover: #475569;
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #4f46e5;
|
||||||
|
--border: #334155;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--success: #22c55e;
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--header-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus, .form-select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/** 与后端 PermissionCatalog 保持一致:目录 / 菜单 / 按钮 */
|
||||||
|
export const permissionTree = [
|
||||||
|
{
|
||||||
|
code: 'dir:overview',
|
||||||
|
name: '概览',
|
||||||
|
type: 'dir',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
code: 'menu:dashboard',
|
||||||
|
name: '数据概览',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/dashboard',
|
||||||
|
icon: '📊',
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'dir:org',
|
||||||
|
name: '组织架构',
|
||||||
|
type: 'dir',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
code: 'menu:users',
|
||||||
|
name: '用户管理',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/users',
|
||||||
|
icon: '👥',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:user:create', name: '新增用户', type: 'btn' },
|
||||||
|
{ code: 'btn:user:edit', name: '编辑用户', type: 'btn' },
|
||||||
|
{ code: 'btn:user:reset_password', name: '重置密码', type: 'btn' },
|
||||||
|
{ code: 'btn:user:delete', name: '删除用户', type: 'btn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'menu:departments',
|
||||||
|
name: '部门管理',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/departments',
|
||||||
|
icon: '🏢',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:dept:create', name: '新增部门', type: 'btn' },
|
||||||
|
{ code: 'btn:dept:edit', name: '编辑部门', type: 'btn' },
|
||||||
|
{ code: 'btn:dept:delete', name: '删除部门', type: 'btn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'menu:roles',
|
||||||
|
name: '角色管理',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/roles',
|
||||||
|
icon: '🛡️',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:role:create', name: '新增角色', type: 'btn' },
|
||||||
|
{ code: 'btn:role:edit', name: '编辑角色', type: 'btn' },
|
||||||
|
{ code: 'btn:role:delete', name: '删除角色', type: 'btn' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'dir:business',
|
||||||
|
name: '业务数据',
|
||||||
|
type: 'dir',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
code: 'menu:conversations',
|
||||||
|
name: '会话管理',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/conversations',
|
||||||
|
icon: '💬',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:conv:view_all', name: '查看全部会话', type: 'btn' },
|
||||||
|
{ code: 'btn:conv:view_subordinate', name: '查看下级部门会话', type: 'btn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'menu:memberships',
|
||||||
|
name: '会员等级',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/memberships',
|
||||||
|
icon: '⭐',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:membership:create', name: '新增会员等级', type: 'btn' },
|
||||||
|
{ code: 'btn:membership:edit', name: '编辑会员等级', type: 'btn' },
|
||||||
|
{ code: 'btn:membership:delete', name: '删除会员等级', type: 'btn' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'dir:system',
|
||||||
|
name: '系统管理',
|
||||||
|
type: 'dir',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
code: 'menu:models',
|
||||||
|
name: 'AI 模型',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/models',
|
||||||
|
icon: '🤖',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:model:create', name: '新增模型', type: 'btn' },
|
||||||
|
{ code: 'btn:model:edit', name: '编辑模型', type: 'btn' },
|
||||||
|
{ code: 'btn:model:delete', name: '删除模型', type: 'btn' },
|
||||||
|
{ code: 'btn:model:test', name: '测试连接', type: 'btn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'menu:permissions',
|
||||||
|
name: '权限管理',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/permissions',
|
||||||
|
icon: '🔑',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:perm:create', name: '新增权限', type: 'btn' },
|
||||||
|
{ code: 'btn:perm:edit', name: '编辑权限', type: 'btn' },
|
||||||
|
{ code: 'btn:perm:delete', name: '删除权限', type: 'btn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'menu:settings',
|
||||||
|
name: '系统设置',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/settings',
|
||||||
|
icon: '🔧',
|
||||||
|
children: [
|
||||||
|
{ code: 'btn:settings:save', name: '保存设置', type: 'btn' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function emptyPermissions() {
|
||||||
|
return {
|
||||||
|
can_access_admin: false,
|
||||||
|
dirs: [],
|
||||||
|
menus: [],
|
||||||
|
buttons: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenMenus(tree = permissionTree) {
|
||||||
|
const list = []
|
||||||
|
for (const dir of tree) {
|
||||||
|
for (const menu of dir.children || []) {
|
||||||
|
list.push({
|
||||||
|
...menu,
|
||||||
|
dir: dir.code,
|
||||||
|
dirName: dir.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizePermissionLabels(perms = {}) {
|
||||||
|
const labels = []
|
||||||
|
if (perms.can_access_admin) labels.push('后台')
|
||||||
|
for (const dir of permissionTree) {
|
||||||
|
if ((perms.dirs || []).includes(dir.code)) labels.push(dir.name)
|
||||||
|
}
|
||||||
|
for (const menu of flattenMenus()) {
|
||||||
|
if ((perms.menus || []).includes(menu.code)) labels.push(menu.name)
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<span class="brand-icon">⚙️</span>
|
||||||
|
<span>AI Chat 管理</span>
|
||||||
|
</div>
|
||||||
|
<a :href="memberUrl" target="_blank" class="sidebar-member-link">← 会员聊天端</a>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<template v-for="group in navGroups" :key="group.dir">
|
||||||
|
<div v-if="group.dirName" class="nav-group-title">{{ group.dirName }}</div>
|
||||||
|
<router-link
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="nav-item"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">{{ item.icon }}</span>
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="admin-user">
|
||||||
|
<span class="avatar">{{ avatarLetter }}</span>
|
||||||
|
<span>{{ auth.user?.nickname || auth.user?.username }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost logout-btn" @click="handleLogout">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="sidebar-overlay" :class="{ active: sidebarOpen }" @click="sidebarOpen = false" />
|
||||||
|
|
||||||
|
<div class="main-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="menu-btn" @click="sidebarOpen = true">☰</button>
|
||||||
|
<span class="page-title">{{ currentTitle }}</span>
|
||||||
|
<a :href="memberUrl" target="_blank" class="member-link">会员端</a>
|
||||||
|
</header>
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
|
const navGroups = computed(() => {
|
||||||
|
const menus = auth.allowedMenus
|
||||||
|
const groups = []
|
||||||
|
for (const menu of menus) {
|
||||||
|
let group = groups.find(g => g.dir === menu.dir)
|
||||||
|
if (!group) {
|
||||||
|
group = { dir: menu.dir, dirName: menu.dirName, items: [] }
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
group.items.push({
|
||||||
|
path: menu.path,
|
||||||
|
label: menu.name,
|
||||||
|
icon: menu.icon || '📄'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleMap = computed(() =>
|
||||||
|
Object.fromEntries(auth.allowedMenus.map(m => [m.path, m.name]))
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentTitle = computed(() => titleMap.value[route.path] || '管理后台')
|
||||||
|
const avatarLetter = computed(() => (auth.user?.username || 'A').charAt(0).toUpperCase())
|
||||||
|
const memberUrl = import.meta.env.VITE_MEMBER_URL || `${window.location.protocol}//${window.location.hostname}:5173`
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-member-link {
|
||||||
|
display: block;
|
||||||
|
margin: 0 12px 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.sidebar-member-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-title {
|
||||||
|
padding: 12px 12px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.router-link-active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.member-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.sidebar-overlay.active {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/LoginView.vue'),
|
||||||
|
meta: { guest: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AdminLayout,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/dashboard' },
|
||||||
|
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { menu: 'menu:dashboard' } },
|
||||||
|
{ path: 'users', name: 'Users', component: () => import('@/views/UsersView.vue'), meta: { menu: 'menu:users' } },
|
||||||
|
{
|
||||||
|
path: 'conversations',
|
||||||
|
name: 'Conversations',
|
||||||
|
component: () => import('@/views/ConversationsView.vue'),
|
||||||
|
meta: {
|
||||||
|
menu: 'menu:conversations',
|
||||||
|
menuAny: ['btn:conv:view_all', 'btn:conv:view_subordinate', 'can_view_all_conversations', 'can_view_subordinate_conversations']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ path: 'models', name: 'Models', component: () => import('@/views/ModelsView.vue'), meta: { menu: 'menu:models' } },
|
||||||
|
{ path: 'memberships', name: 'Memberships', component: () => import('@/views/MembershipsView.vue'), meta: { menu: 'menu:memberships' } },
|
||||||
|
{ path: 'roles', name: 'Roles', component: () => import('@/views/RolesView.vue'), meta: { menu: 'menu:roles' } },
|
||||||
|
{ path: 'departments', name: 'Departments', component: () => import('@/views/DepartmentsView.vue'), meta: { menu: 'menu:departments' } },
|
||||||
|
{ path: 'permissions', name: 'Permissions', component: () => import('@/views/PermissionsView.vue'), meta: { menu: 'menu:permissions' } },
|
||||||
|
{ path: 'settings', name: 'Settings', component: () => import('@/views/SettingsView.vue'), meta: { menu: 'menu:settings' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (!auth.initialized) {
|
||||||
|
await auth.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
||||||
|
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAdmin && !auth.canAccessAdmin) {
|
||||||
|
next({ name: 'Login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.menu) {
|
||||||
|
const ok = auth.hasMenu(to.meta.menu)
|
||||||
|
|| (to.meta.menuAny && auth.hasAny(to.meta.menuAny))
|
||||||
|
if (!ok && to.path !== '/dashboard') {
|
||||||
|
next({ path: '/dashboard' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.guest && auth.isLoggedIn) {
|
||||||
|
next({ name: 'Dashboard' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { flattenMenus, permissionTree as fallbackTree } from '@/config/permissions'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('admin_token') || '')
|
||||||
|
const user = ref(null)
|
||||||
|
const initialized = ref(false)
|
||||||
|
const menuTree = ref(fallbackTree)
|
||||||
|
|
||||||
|
const permissions = computed(() => user.value?.role_permissions || {})
|
||||||
|
|
||||||
|
const isSuper = computed(() =>
|
||||||
|
user.value?.role_slug === 'super_admin' ||
|
||||||
|
(user.value?.role === 'admin' && !user.value?.role_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const canAccessAdmin = computed(() =>
|
||||||
|
isSuper.value || user.value?.role === 'admin' || !!permissions.value.can_access_admin
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value && !!user.value)
|
||||||
|
const isAdmin = computed(() => canAccessAdmin.value)
|
||||||
|
|
||||||
|
function hasCode(code) {
|
||||||
|
if (!code) return true
|
||||||
|
if (isSuper.value || user.value?.role === 'admin') return true
|
||||||
|
const p = permissions.value
|
||||||
|
if (code === 'can_access_admin') return !!p.can_access_admin
|
||||||
|
if (code.startsWith('dir:')) return (p.dirs || []).includes(code)
|
||||||
|
if (code.startsWith('menu:')) return (p.menus || []).includes(code)
|
||||||
|
if (code.startsWith('btn:')) return (p.buttons || []).includes(code)
|
||||||
|
return !!p[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(key) {
|
||||||
|
return hasCode(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMenu(code) {
|
||||||
|
return hasCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasButton(code) {
|
||||||
|
return hasCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDir(code) {
|
||||||
|
return hasCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAny(codes = []) {
|
||||||
|
return codes.some(c => hasCode(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedMenus = computed(() =>
|
||||||
|
flattenMenus(menuTree.value).filter(m => {
|
||||||
|
if (m.code === 'menu:conversations') {
|
||||||
|
return hasMenu('menu:conversations')
|
||||||
|
|| hasButton('btn:conv:view_all')
|
||||||
|
|| hasButton('btn:conv:view_subordinate')
|
||||||
|
|| hasPermission('can_view_all_conversations')
|
||||||
|
|| hasPermission('can_view_subordinate_conversations')
|
||||||
|
}
|
||||||
|
return hasMenu(m.code)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadMenuTree() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/permissions/tree')
|
||||||
|
if (res.data.data?.tree?.length) {
|
||||||
|
menuTree.value = res.data.data.tree
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (token.value) {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/me')
|
||||||
|
user.value = res.data.data
|
||||||
|
if (!canAccessAdmin.value) {
|
||||||
|
logout()
|
||||||
|
} else {
|
||||||
|
await loadMenuTree()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account, password) {
|
||||||
|
const res = await api.post('/auth/login', { account, password })
|
||||||
|
const data = res.data.data
|
||||||
|
const perms = data.user.role_permissions || {}
|
||||||
|
const ok = data.user.role === 'admin' || data.user.role_slug === 'super_admin' || !!perms.can_access_admin
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error('该账号无管理后台访问权限')
|
||||||
|
}
|
||||||
|
token.value = data.token
|
||||||
|
user.value = data.user
|
||||||
|
localStorage.setItem('admin_token', token.value)
|
||||||
|
await loadMenuTree()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
user.value = null
|
||||||
|
menuTree.value = fallbackTree
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
initialized,
|
||||||
|
permissions,
|
||||||
|
menuTree,
|
||||||
|
isLoggedIn,
|
||||||
|
isAdmin,
|
||||||
|
isSuper,
|
||||||
|
canAccessAdmin,
|
||||||
|
allowedMenus,
|
||||||
|
hasPermission,
|
||||||
|
hasCode,
|
||||||
|
hasMenu,
|
||||||
|
hasButton,
|
||||||
|
hasDir,
|
||||||
|
hasAny,
|
||||||
|
loadMenuTree,
|
||||||
|
init,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
<template>
|
||||||
|
<div class="conv-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>会话管理</h2>
|
||||||
|
<p>选择左侧会话,查看用户与 AI 的完整对话</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conv-layout">
|
||||||
|
<aside class="conv-list-panel panel">
|
||||||
|
<div class="list-toolbar">
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
class="form-input search-input"
|
||||||
|
placeholder="搜索标题 / 用户名 / 邮箱"
|
||||||
|
@input="onSearchInput"
|
||||||
|
/>
|
||||||
|
<select v-model="departmentFilter" class="form-select dept-filter" @change="reloadList">
|
||||||
|
<option :value="null">全部部门</option>
|
||||||
|
<option v-for="d in departments" :key="d.id" :value="d.id">{{ d.label || d.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conv-list" v-if="conversations.length">
|
||||||
|
<button
|
||||||
|
v-for="c in conversations"
|
||||||
|
:key="c.id"
|
||||||
|
type="button"
|
||||||
|
class="conv-item"
|
||||||
|
:class="{ active: selectedId === c.id }"
|
||||||
|
@click="selectConversation(c.id)"
|
||||||
|
>
|
||||||
|
<div class="conv-item-title">{{ c.title || '未命名会话' }}</div>
|
||||||
|
<div class="conv-item-meta">
|
||||||
|
<span>{{ c.username }}</span>
|
||||||
|
<span v-if="c.department_name">{{ c.department_name }}</span>
|
||||||
|
<span>{{ c.message_count }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="conv-item-time">{{ formatDate(c.updated_at) }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!listLoading" class="empty">暂无会话</p>
|
||||||
|
<p v-else class="empty">加载中...</p>
|
||||||
|
|
||||||
|
<div v-if="total > limit" class="list-pagination">
|
||||||
|
<button class="btn btn-ghost" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
|
||||||
|
<span>{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button class="btn btn-ghost" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="conv-detail-panel panel">
|
||||||
|
<template v-if="selectedId">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div>
|
||||||
|
<h3>{{ detail?.title || '未命名会话' }}</h3>
|
||||||
|
<p class="detail-meta">
|
||||||
|
<span>{{ detail?.username }} ({{ detail?.email }})</span>
|
||||||
|
<span v-if="detail?.department_name">· {{ detail.department_name }}</span>
|
||||||
|
<span v-if="detail?.model_name">· {{ detail.model_name }}</span>
|
||||||
|
<span>· {{ detail?.message_count ?? 0 }} 条消息</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost" :disabled="detailLoading" @click="reloadDetail">刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesEl" class="messages-scroll">
|
||||||
|
<div v-if="detailLoading && !messages.length" class="empty">加载对话中...</div>
|
||||||
|
<div v-else-if="!messages.length" class="empty">该会话暂无消息</div>
|
||||||
|
<div v-else class="messages">
|
||||||
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="message"
|
||||||
|
:class="msg.role"
|
||||||
|
>
|
||||||
|
<div class="message-avatar">{{ msg.role === 'user' ? '用户' : 'AI' }}</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<div v-if="getAttachments(msg).length" class="attachments">
|
||||||
|
<template v-for="(att, i) in getAttachments(msg)" :key="i">
|
||||||
|
<img
|
||||||
|
v-if="att.type === 'image'"
|
||||||
|
:src="att.url"
|
||||||
|
:alt="att.name"
|
||||||
|
class="att-image"
|
||||||
|
@click="previewImage(att.url)"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else-if="att.url"
|
||||||
|
:href="att.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="att-link"
|
||||||
|
>
|
||||||
|
{{ documentIcon(att) }} {{ att.name || '附件' }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="msg.content" class="message-content">{{ msg.content }}</div>
|
||||||
|
<time class="message-time">{{ formatDate(msg.created_at) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="messagesEnd" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="detail-empty">
|
||||||
|
<p>← 请从左侧选择一条会话</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const conversations = ref([])
|
||||||
|
const selectedId = ref(null)
|
||||||
|
const detail = ref(null)
|
||||||
|
const messages = ref([])
|
||||||
|
const listLoading = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const departmentFilter = ref(null)
|
||||||
|
const departments = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const messagesEl = ref(null)
|
||||||
|
const messagesEnd = ref(null)
|
||||||
|
|
||||||
|
let searchTimer = null
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadDepartments()
|
||||||
|
await loadConversations()
|
||||||
|
const routeId = Number(route.query.id)
|
||||||
|
if (routeId && conversations.value.some(c => c.id === routeId)) {
|
||||||
|
await selectConversation(routeId)
|
||||||
|
} else if (conversations.value.length) {
|
||||||
|
await selectConversation(conversations.value[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.id, async (id) => {
|
||||||
|
const numId = Number(id)
|
||||||
|
if (numId && numId !== selectedId.value) {
|
||||||
|
await selectConversation(numId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadDepartments() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/department-options')
|
||||||
|
departments.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
departments.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
listLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/conversations', {
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
limit,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
department_id: departmentFilter.value || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = res.data.data || {}
|
||||||
|
conversations.value = data.list || []
|
||||||
|
total.value = data.total ?? conversations.value.length
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(id) {
|
||||||
|
if (selectedId.value === id && messages.value.length && !detailLoading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedId.value = id
|
||||||
|
router.replace({ query: { ...route.query, id: String(id) } })
|
||||||
|
await loadDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(id) {
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/admin/conversations/${id}`)
|
||||||
|
const data = res.data.data || {}
|
||||||
|
detail.value = data.conversation || null
|
||||||
|
messages.value = data.messages || []
|
||||||
|
await scrollToBottom()
|
||||||
|
} catch (err) {
|
||||||
|
detail.value = null
|
||||||
|
messages.value = []
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadDetail() {
|
||||||
|
if (selectedId.value) {
|
||||||
|
loadDetail(selectedId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadList() {
|
||||||
|
page.value = 1
|
||||||
|
await loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(async () => {
|
||||||
|
page.value = 1
|
||||||
|
await loadConversations()
|
||||||
|
if (selectedId.value && !conversations.value.some(c => c.id === selectedId.value)) {
|
||||||
|
selectedId.value = conversations.value[0]?.id ?? null
|
||||||
|
if (selectedId.value) {
|
||||||
|
await loadDetail(selectedId.value)
|
||||||
|
} else {
|
||||||
|
detail.value = null
|
||||||
|
messages.value = []
|
||||||
|
router.replace({ query: {} })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(nextPage) {
|
||||||
|
page.value = nextPage
|
||||||
|
await loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await nextTick()
|
||||||
|
messagesEnd.value?.scrollIntoView({ behavior: 'auto' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachments(msg) {
|
||||||
|
const raw = msg.attachments
|
||||||
|
const list = Array.isArray(raw) ? raw : (raw && typeof raw === 'object' ? Object.values(raw) : [])
|
||||||
|
return list.filter(att => att && typeof att === 'object')
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentIcon(att) {
|
||||||
|
const name = (att?.name || '').toLowerCase()
|
||||||
|
const mime = att?.mime || ''
|
||||||
|
if (name.endsWith('.pdf') || mime.includes('pdf')) return '📕'
|
||||||
|
if (name.endsWith('.doc') || name.endsWith('.docx') || mime.includes('word')) return '📘'
|
||||||
|
return '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewImage(url) {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
if (!d) return '-'
|
||||||
|
return new Date(d).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.conv-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 48px);
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-filter {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item.active {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item-meta,
|
||||||
|
.conv-item-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-avatar {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-time {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-image {
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 180px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-link:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.conv-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 280px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-page {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>数据概览</h2>
|
||||||
|
<p>系统运行统计数据</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-icon">👥</span>
|
||||||
|
<span class="stat-value">{{ stats.users }}</span>
|
||||||
|
<span class="stat-label">用户总数</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-icon">💬</span>
|
||||||
|
<span class="stat-value">{{ stats.conversations }}</span>
|
||||||
|
<span class="stat-label">会话总数</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-icon">📝</span>
|
||||||
|
<span class="stat-value">{{ stats.messages }}</span>
|
||||||
|
<span class="stat-label">消息总数</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-icon">📈</span>
|
||||||
|
<span class="stat-value">{{ stats.today_messages }}</span>
|
||||||
|
<span class="stat-label">今日消息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const stats = ref({ users: 0, conversations: 0, messages: 0, today_messages: 0 })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const res = await api.get('/admin/stats')
|
||||||
|
stats.value = res.data.data
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>部门管理</h2>
|
||||||
|
<p>维护组织部门层级,上级部门可查看下级部门员工聊天记录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button v-if="auth.hasButton('btn:dept:create')" class="btn btn-primary" @click="openCreate()">新增部门</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>部门名称</th>
|
||||||
|
<th>上级部门</th>
|
||||||
|
<th>排序</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="dept in treeRows" :key="dept.id">
|
||||||
|
<td>
|
||||||
|
<span :style="{ paddingLeft: `${dept.depth * 16}px` }">{{ dept.label || dept.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ parentName(dept.parent_id) }}</td>
|
||||||
|
<td>{{ dept.sort_order ?? 0 }}</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="auth.hasButton('btn:dept:create')" class="btn btn-ghost" @click="openCreate(dept.id)">添加下级</button>
|
||||||
|
<button v-if="auth.hasButton('btn:dept:edit')" class="btn btn-ghost" @click="openEdit(dept)">编辑</button>
|
||||||
|
<button v-if="auth.hasButton('btn:dept:delete')" class="btn btn-ghost danger" @click="removeDept(dept)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="!treeRows.length" class="empty">暂无部门</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ editingId ? '编辑部门' : '新增部门' }}</h3>
|
||||||
|
<button @click="closeModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>部门名称</label>
|
||||||
|
<input v-model="form.name" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>上级部门</label>
|
||||||
|
<select v-model="form.parent_id" class="form-select">
|
||||||
|
<option :value="null">无(顶级部门)</option>
|
||||||
|
<option
|
||||||
|
v-for="opt in parentOptions"
|
||||||
|
:key="opt.id"
|
||||||
|
:value="opt.id"
|
||||||
|
:disabled="editingId === opt.id"
|
||||||
|
>
|
||||||
|
{{ opt.label || opt.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>排序</label>
|
||||||
|
<input v-model.number="form.sort_order" type="number" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" @click="closeModal">取消</button>
|
||||||
|
<button class="btn btn-primary" @click="saveDept">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const treeRows = ref([])
|
||||||
|
const flatList = ref([])
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentOptions = computed(() =>
|
||||||
|
treeRows.value.filter(d => d.id !== editingId.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(loadDepartments)
|
||||||
|
|
||||||
|
async function loadDepartments() {
|
||||||
|
const res = await api.get('/admin/departments')
|
||||||
|
const data = res.data.data || {}
|
||||||
|
treeRows.value = data.tree || []
|
||||||
|
flatList.value = data.list || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentName(parentId) {
|
||||||
|
if (!parentId) return '-'
|
||||||
|
return flatList.value.find(d => d.id === parentId)?.name || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(parentId = null) {
|
||||||
|
editingId.value = null
|
||||||
|
form.name = ''
|
||||||
|
form.parent_id = parentId
|
||||||
|
form.sort_order = 0
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(dept) {
|
||||||
|
editingId.value = dept.id
|
||||||
|
form.name = dept.name
|
||||||
|
form.parent_id = dept.parent_id || null
|
||||||
|
form.sort_order = dept.sort_order ?? 0
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDept() {
|
||||||
|
error.value = ''
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
error.value = '请填写部门名称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
parent_id: form.parent_id,
|
||||||
|
sort_order: form.sort_order
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await api.put(`/admin/departments/${editingId.value}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/admin/departments', payload)
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
await loadDepartments()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || '保存失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDept(dept) {
|
||||||
|
if (!confirm(`确定删除部门「${dept.name}」?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/departments/${dept.id}`)
|
||||||
|
await loadDepartments()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo">⚙️</div>
|
||||||
|
<h1>AI Chat 管理后台</h1>
|
||||||
|
<p>请使用管理员账户登录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>账号</label>
|
||||||
|
<input v-model="account" class="form-input" placeholder="管理员用户名或邮箱" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input v-model="password" type="password" class="form-input" placeholder="请输入密码" required />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn btn-primary login-btn" :disabled="loading">
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const account = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(account.value, password.value)
|
||||||
|
router.push(route.query.redirect || '/dashboard')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>会员等级</h2>
|
||||||
|
<p>配置会员权限与使用限制,支持新增、编辑、删除</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button v-if="auth.hasButton('btn:membership:create') || auth.hasButton('btn:membership:edit')" class="btn btn-primary" @click="openCreate">
|
||||||
|
新增等级
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="membership-grid">
|
||||||
|
<div v-for="level in memberships" :key="level.id" class="membership-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>{{ level.name }}</h3>
|
||||||
|
<span class="slug">{{ level.slug }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="info-row">
|
||||||
|
<span>最大会话数</span>
|
||||||
|
<strong>{{ level.max_conversations }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span>每日消息上限</span>
|
||||||
|
<strong>{{ level.max_messages_per_day }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span>上传大小限制</span>
|
||||||
|
<strong>{{ level.max_upload_size_mb }} MB</strong>
|
||||||
|
</div>
|
||||||
|
<div class="permissions">
|
||||||
|
<span v-if="level.permissions?.can_upload_image" class="perm-tag">图片上传</span>
|
||||||
|
<span v-if="level.permissions?.can_upload_video" class="perm-tag">视频上传</span>
|
||||||
|
<span v-if="level.permissions?.can_upload_file" class="perm-tag">文件上传</span>
|
||||||
|
<span v-if="level.permissions?.can_use_voice" class="perm-tag">语音</span>
|
||||||
|
<span v-if="!hasAnyPermission(level)" class="field-hint">无额外权限</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button v-if="auth.hasButton('btn:membership:edit')" class="btn btn-ghost" @click="openEdit(level)">编辑</button>
|
||||||
|
<button
|
||||||
|
v-if="auth.hasButton('btn:membership:delete') && !isProtected(level)"
|
||||||
|
class="btn btn-ghost danger"
|
||||||
|
@click="removeLevel(level)"
|
||||||
|
>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ isCreate ? '新增会员等级' : `编辑会员等级 - ${editLevel?.name}` }}</h3>
|
||||||
|
<button @click="closeModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>名称</label>
|
||||||
|
<input v-model="form.name" class="form-input" placeholder="如:企业版" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>标识 slug</label>
|
||||||
|
<input v-model="form.slug" class="form-input" placeholder="英文标识,留空自动生成" :disabled="!isCreate && isProtected(editLevel)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>最大会话数</label>
|
||||||
|
<input v-model.number="form.max_conversations" type="number" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>每日消息上限</label>
|
||||||
|
<input v-model.number="form.max_messages_per_day" type="number" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>上传大小 (MB)</label>
|
||||||
|
<input v-model.number="form.max_upload_size_mb" type="number" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>权限</label>
|
||||||
|
<label class="check-item"><input type="checkbox" v-model="form.permissions.can_upload_image" /> 图片上传</label>
|
||||||
|
<label class="check-item"><input type="checkbox" v-model="form.permissions.can_upload_video" /> 视频上传</label>
|
||||||
|
<label class="check-item"><input type="checkbox" v-model="form.permissions.can_upload_file" /> 文件上传</label>
|
||||||
|
<label class="check-item"><input type="checkbox" v-model="form.permissions.can_use_voice" /> 语音功能</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" @click="closeModal">取消</button>
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="saveLevel">
|
||||||
|
{{ saving ? '保存中...' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const memberships = ref([])
|
||||||
|
const editLevel = ref(null)
|
||||||
|
const isCreate = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
max_conversations: 20,
|
||||||
|
max_messages_per_day: 50,
|
||||||
|
max_upload_size_mb: 5,
|
||||||
|
permissions: {
|
||||||
|
can_upload_image: false,
|
||||||
|
can_upload_video: false,
|
||||||
|
can_upload_file: false,
|
||||||
|
can_use_voice: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadMemberships)
|
||||||
|
|
||||||
|
async function loadMemberships() {
|
||||||
|
const res = await api.get('/admin/memberships')
|
||||||
|
memberships.value = res.data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtected(level) {
|
||||||
|
if (!level) return false
|
||||||
|
return level.id === 1 || ['free', 'admin'].includes(level.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyPermission(level) {
|
||||||
|
const p = level.permissions || {}
|
||||||
|
return p.can_upload_image || p.can_upload_video || p.can_upload_file || p.can_use_voice
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.name = ''
|
||||||
|
form.slug = ''
|
||||||
|
form.max_conversations = 20
|
||||||
|
form.max_messages_per_day = 50
|
||||||
|
form.max_upload_size_mb = 5
|
||||||
|
form.permissions = {
|
||||||
|
can_upload_image: false,
|
||||||
|
can_upload_video: false,
|
||||||
|
can_upload_file: false,
|
||||||
|
can_use_voice: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
isCreate.value = true
|
||||||
|
editLevel.value = null
|
||||||
|
resetForm()
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(level) {
|
||||||
|
isCreate.value = false
|
||||||
|
editLevel.value = level
|
||||||
|
form.name = level.name
|
||||||
|
form.slug = level.slug
|
||||||
|
form.max_conversations = level.max_conversations
|
||||||
|
form.max_messages_per_day = level.max_messages_per_day
|
||||||
|
form.max_upload_size_mb = level.max_upload_size_mb
|
||||||
|
form.permissions = {
|
||||||
|
can_upload_image: false,
|
||||||
|
can_upload_video: false,
|
||||||
|
can_upload_file: false,
|
||||||
|
can_use_voice: false,
|
||||||
|
...(level.permissions || {})
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
editLevel.value = null
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLevel() {
|
||||||
|
error.value = ''
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
error.value = '请填写名称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
slug: form.slug.trim() || undefined,
|
||||||
|
max_conversations: form.max_conversations,
|
||||||
|
max_messages_per_day: form.max_messages_per_day,
|
||||||
|
max_upload_size_mb: form.max_upload_size_mb,
|
||||||
|
permissions: { ...form.permissions }
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isCreate.value) {
|
||||||
|
await api.post('/admin/memberships', payload)
|
||||||
|
} else {
|
||||||
|
await api.put(`/admin/memberships/${editLevel.value.id}`, payload)
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
await loadMemberships()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || '保存失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLevel(level) {
|
||||||
|
if (!confirm(`确定删除会员等级「${level.name}」?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/memberships/${level.id}`)
|
||||||
|
await loadMemberships()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slug {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>AI 模型配置</h2>
|
||||||
|
<p>对接 OpenAI 格式 API 接口</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button v-if="auth.hasButton('btn:model:create')" class="btn btn-primary" @click="openCreate">添加模型</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>接口类型</th>
|
||||||
|
<th>Model ID</th>
|
||||||
|
<th>API 地址</th>
|
||||||
|
<th>Max Tokens</th>
|
||||||
|
<th>默认</th>
|
||||||
|
<th>上下文</th>
|
||||||
|
<th>图片</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="m in models" :key="m.id">
|
||||||
|
<td>{{ m.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="m.provider === 'dify' ? 'badge-info' : 'badge-success'">
|
||||||
|
{{ m.provider === 'dify' ? 'Dify' : 'OpenAI 兼容' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ m.model_id || '-' }}</code></td>
|
||||||
|
<td class="url-cell">{{ m.api_base_url }}</td>
|
||||||
|
<td>{{ m.provider === 'dify' ? '-' : m.max_tokens }}</td>
|
||||||
|
<td>{{ m.is_default ? '✓' : '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="m.support_context ? 'badge-success' : 'badge-info'">
|
||||||
|
{{ m.support_context ? '支持' : '不支持' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="m.support_image ? 'badge-success' : 'badge-info'">
|
||||||
|
{{ m.support_image ? '支持' : '不支持' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="m.enabled ? 'badge-success' : 'badge-danger'">
|
||||||
|
{{ m.enabled ? '启用' : '禁用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="auth.hasButton('btn:model:edit')" class="btn btn-ghost" @click="openEdit(m)">编辑</button>
|
||||||
|
<button v-if="auth.hasButton('btn:model:test')" class="btn btn-ghost" @click="quickTest(m.id)" :disabled="testingId === m.id">
|
||||||
|
{{ testingId === m.id ? '测试中...' : '测试' }}
|
||||||
|
</button>
|
||||||
|
<button v-if="auth.hasButton('btn:model:delete')" class="btn btn-danger" @click="handleDelete(m.id)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ editingId ? '编辑模型' : '添加模型' }}</h3>
|
||||||
|
<button @click="showModal = false">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>显示名称</label>
|
||||||
|
<input v-model="form.name" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>接口类型</label>
|
||||||
|
<select v-model="form.provider" class="form-input">
|
||||||
|
<option value="openai">OpenAI 兼容(GPT / DeepSeek / vLLM / SGLang 等)</option>
|
||||||
|
<option value="dify">Dify 应用(chat-messages 接口)</option>
|
||||||
|
</select>
|
||||||
|
<p class="field-hint" v-if="form.provider === 'dify'">
|
||||||
|
Dify 使用自己的一套接口协议(/chat-messages),跟 OpenAI 的 /chat/completions 不同,不要混用,否则会报 404
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="form.provider !== 'dify'">
|
||||||
|
<label>Model ID</label>
|
||||||
|
<input v-model="form.model_id" class="form-input" placeholder="gpt-4o-mini" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Base URL</label>
|
||||||
|
<input
|
||||||
|
v-model="form.api_base_url"
|
||||||
|
class="form-input"
|
||||||
|
:placeholder="form.provider === 'dify' ? 'https://api.dify.ai/v1(或自部署地址)' : 'https://api.openai.com/v1'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key</label>
|
||||||
|
<p v-if="editingId && hasSavedKey" class="saved-key-hint">
|
||||||
|
已保存 Key:<code>{{ savedKeyHint }}</code>(输入新值可覆盖,留空则不修改)
|
||||||
|
</p>
|
||||||
|
<p v-else-if="editingId && !hasSavedKey" class="field-hint field-hint-warn">
|
||||||
|
当前未配置 Key,请填写
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
v-model="form.api_key"
|
||||||
|
type="password"
|
||||||
|
class="form-input"
|
||||||
|
:placeholder="editingId ? '留空则使用已保存的 Key' : (form.provider === 'dify' ? 'Dify 应用“访问 API”页面获取的密钥' : '可选,部分本地模型可不填')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="form.provider !== 'dify'">
|
||||||
|
<label>Max Tokens</label>
|
||||||
|
<input v-model.number="form.max_tokens" type="number" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="form.provider !== 'dify'">
|
||||||
|
<label>Temperature</label>
|
||||||
|
<input v-model.number="form.temperature" type="number" step="0.1" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><input type="checkbox" v-model="form.is_default" /> 设为默认模型</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><input type="checkbox" v-model="form.enabled" /> 启用</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><input type="checkbox" v-model="form.support_context" /> 支持上下文(多轮对话)</label>
|
||||||
|
<p class="field-hint" v-if="form.provider === 'dify'">关闭后每次提问都会让 Dify 开启一个新会话,不延续之前的上下文</p>
|
||||||
|
<p class="field-hint" v-else>关闭后每次提问只发送当前这一条消息,不携带历史聊天记录</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label><input type="checkbox" v-model="form.support_image" /> 支持图片/多模态输入</label>
|
||||||
|
<p class="field-hint">关闭后用户仍可上传图片给自己看,但不会把图片发给该模型识别(避免"not a multimodal model"报错),关闭后聊天界面也会自动隐藏图片上传按钮</p>
|
||||||
|
</div>
|
||||||
|
<template v-if="form.provider !== 'dify'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frequency Penalty(频率惩罚)</label>
|
||||||
|
<input v-model.number="form.frequency_penalty" type="number" step="0.1" min="0" max="2" class="form-input" />
|
||||||
|
<p class="field-hint">部分自部署/OCR 类模型容易陷入重复输出循环(同一句话刷屏),调高此值(如 1.0~1.5)可以有效抑制,默认 0 表示不干预</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Presence Penalty(存在惩罚)</label>
|
||||||
|
<input v-model.number="form.presence_penalty" type="number" step="0.1" min="0" max="2" class="form-input" />
|
||||||
|
<p class="field-hint">抑制模型反复围绕同一话题/短语,与频率惩罚搭配使用效果更好,默认 0 表示不干预</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="testResult" class="test-result" :class="testResult.ok ? 'success' : 'error'">
|
||||||
|
<strong>{{ testResult.ok ? '✓ 测试成功' : '✗ 测试失败' }}</strong>
|
||||||
|
<p v-if="testResult.ok">
|
||||||
|
延迟 {{ testResult.latency_ms }}ms
|
||||||
|
<span v-if="testResult.tokens"> · Token {{ testResult.tokens }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="testResult.reply" class="reply-preview">模型回复:{{ testResult.reply }}</p>
|
||||||
|
<p v-if="!testResult.ok">{{ testResult.message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" @click="showModal = false">取消</button>
|
||||||
|
<button v-if="auth.hasButton('btn:model:test')" class="btn btn-ghost" @click="testModel" :disabled="testing">
|
||||||
|
{{ testing ? '测试中...' : '测试连接' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" @click="saveModel">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const models = ref([])
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testingId = ref(null)
|
||||||
|
const testResult = ref(null)
|
||||||
|
const hasSavedKey = ref(false)
|
||||||
|
const savedKeyHint = ref('')
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
provider: 'openai',
|
||||||
|
model_id: '',
|
||||||
|
api_base_url: 'https://api.openai.com/v1',
|
||||||
|
api_key: '',
|
||||||
|
max_tokens: 4096,
|
||||||
|
temperature: 0.7,
|
||||||
|
is_default: false,
|
||||||
|
enabled: true,
|
||||||
|
support_context: true,
|
||||||
|
support_image: true,
|
||||||
|
frequency_penalty: 0,
|
||||||
|
presence_penalty: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadModels)
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
const res = await api.get('/admin/models')
|
||||||
|
models.value = res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null
|
||||||
|
testResult.value = null
|
||||||
|
hasSavedKey.value = false
|
||||||
|
savedKeyHint.value = ''
|
||||||
|
Object.assign(form, {
|
||||||
|
name: '', provider: 'openai', model_id: '', api_base_url: 'https://api.openai.com/v1',
|
||||||
|
api_key: '', max_tokens: 4096, temperature: 0.7, is_default: false, enabled: true, support_context: true, support_image: true,
|
||||||
|
frequency_penalty: 0, presence_penalty: 0
|
||||||
|
})
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(m) {
|
||||||
|
editingId.value = m.id
|
||||||
|
testResult.value = null
|
||||||
|
hasSavedKey.value = !!m.has_api_key
|
||||||
|
savedKeyHint.value = m.api_key_hint || ''
|
||||||
|
Object.assign(form, {
|
||||||
|
name: m.name, provider: m.provider === 'dify' ? 'dify' : 'openai', model_id: m.model_id, api_base_url: m.api_base_url,
|
||||||
|
api_key: '', max_tokens: m.max_tokens, temperature: parseFloat(m.temperature),
|
||||||
|
is_default: !!m.is_default, enabled: !!m.enabled,
|
||||||
|
support_context: m.support_context === undefined ? true : !!m.support_context,
|
||||||
|
support_image: m.support_image === undefined ? true : !!m.support_image,
|
||||||
|
frequency_penalty: m.frequency_penalty !== undefined && m.frequency_penalty !== null ? parseFloat(m.frequency_penalty) : 0,
|
||||||
|
presence_penalty: m.presence_penalty !== undefined && m.presence_penalty !== null ? parseFloat(m.presence_penalty) : 0
|
||||||
|
})
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModel() {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
is_default: form.is_default ? 1 : 0,
|
||||||
|
enabled: form.enabled ? 1 : 0,
|
||||||
|
support_context: form.support_context ? 1 : 0,
|
||||||
|
support_image: form.support_image ? 1 : 0
|
||||||
|
}
|
||||||
|
if (editingId.value) {
|
||||||
|
if (!payload.api_key) delete payload.api_key
|
||||||
|
await api.put(`/admin/models/${editingId.value}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/admin/models', payload)
|
||||||
|
}
|
||||||
|
showModal.value = false
|
||||||
|
await loadModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (confirm('确定删除此模型?')) {
|
||||||
|
await api.delete(`/admin/models/${id}`)
|
||||||
|
await loadModels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testModel() {
|
||||||
|
if (form.provider === 'dify') {
|
||||||
|
if (!form.api_base_url) {
|
||||||
|
testResult.value = { ok: false, message: '请填写 API 地址' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.api_key && !editingId.value) {
|
||||||
|
testResult.value = { ok: false, message: '请填写 API Key' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.api_key && editingId.value && !hasSavedKey.value) {
|
||||||
|
testResult.value = { ok: false, message: '当前模型未配置 Key,请填写' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (!form.model_id || !form.api_base_url) {
|
||||||
|
testResult.value = { ok: false, message: '请填写 Model ID 和 API 地址' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
provider: form.provider,
|
||||||
|
model_id: form.model_id,
|
||||||
|
api_base_url: form.api_base_url,
|
||||||
|
temperature: form.temperature
|
||||||
|
}
|
||||||
|
if (form.api_key) payload.api_key = form.api_key
|
||||||
|
|
||||||
|
const url = editingId.value
|
||||||
|
? `/admin/models/${editingId.value}/test`
|
||||||
|
: '/admin/models/test'
|
||||||
|
const res = await api.post(url, payload)
|
||||||
|
testResult.value = { ok: true, ...res.data.data }
|
||||||
|
} catch (e) {
|
||||||
|
testResult.value = { ok: false, message: e.message }
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickTest(id) {
|
||||||
|
testingId.value = id
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/admin/models/${id}/test`, {})
|
||||||
|
alert(`测试成功!\n延迟: ${res.data.data.latency_ms}ms\n回复: ${res.data.data.reply}`)
|
||||||
|
} catch (e) {
|
||||||
|
alert('测试失败: ' + e.message)
|
||||||
|
} finally {
|
||||||
|
testingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-cell {
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.test-result.success {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.test-result.error {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.test-result p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.test-result.success p {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.reply-preview {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint-warn {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-key-hint {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-key-hint code {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>权限管理</h2>
|
||||||
|
<p>维护目录、菜单、按钮节点,角色勾选后即可生效</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button v-if="auth.hasButton('btn:perm:create')" class="btn btn-primary" @click="openCreate('dir')">新增目录</button>
|
||||||
|
<button v-if="auth.hasButton('btn:perm:create')" class="btn btn-ghost" @click="openCreate('menu')">新增菜单</button>
|
||||||
|
<button v-if="auth.hasButton('btn:perm:create')" class="btn btn-ghost" @click="openCreate('btn')">新增按钮</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>标识</th>
|
||||||
|
<th>路径 / 图标</th>
|
||||||
|
<th>系统</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in flatRows" :key="row.id || row.code">
|
||||||
|
<td>
|
||||||
|
<span :style="{ paddingLeft: `${row.depth * 18}px` }">{{ row.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="type-badge" :class="row.type">{{ typeLabel(row.type) }}</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ row.code }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.path">{{ row.path }}</span>
|
||||||
|
<span v-if="row.icon"> {{ row.icon }}</span>
|
||||||
|
<span v-if="!row.path && !row.icon">-</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.is_system ? '是' : '否' }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
v-if="auth.hasButton('btn:perm:create') && row.type !== 'btn'"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
@click="openCreate(row.type === 'dir' ? 'menu' : 'btn', row.id)"
|
||||||
|
>添加下级</button>
|
||||||
|
<button v-if="auth.hasButton('btn:perm:edit')" class="btn btn-ghost" @click="openEdit(row)">编辑</button>
|
||||||
|
<button
|
||||||
|
v-if="auth.hasButton('btn:perm:delete') && !row.is_system"
|
||||||
|
class="btn btn-ghost danger"
|
||||||
|
@click="removeRow(row)"
|
||||||
|
>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="!flatRows.length" class="empty">暂无权限节点,请先执行数据库迁移</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ editingId ? '编辑权限' : '新增' + typeLabel(form.type) }}</h3>
|
||||||
|
<button @click="closeModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>类型</label>
|
||||||
|
<select v-model="form.type" class="form-select" :disabled="!!editingId">
|
||||||
|
<option value="dir">目录</option>
|
||||||
|
<option value="menu">菜单</option>
|
||||||
|
<option value="btn">按钮</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.type !== 'dir'" class="form-group">
|
||||||
|
<label>上级</label>
|
||||||
|
<select v-model="form.parent_id" class="form-select">
|
||||||
|
<option v-for="opt in parentOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>名称</label>
|
||||||
|
<input v-model="form.name" class="form-input" placeholder="显示名称" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>标识 code</label>
|
||||||
|
<input v-model="form.code" class="form-input" :placeholder="codePlaceholder" :disabled="editingIsSystem" />
|
||||||
|
<div class="field-hint">建议格式:dir:xxx / menu:xxx / btn:xxx:action</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.type === 'menu'" class="form-group">
|
||||||
|
<label>路由路径</label>
|
||||||
|
<input v-model="form.path" class="form-input" placeholder="如 /users" />
|
||||||
|
</div>
|
||||||
|
<div v-if="form.type === 'menu'" class="form-group">
|
||||||
|
<label>图标</label>
|
||||||
|
<input v-model="form.icon" class="form-input" placeholder="可选 emoji" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>排序</label>
|
||||||
|
<input v-model.number="form.sort_order" type="number" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" @click="closeModal">取消</button>
|
||||||
|
<button class="btn btn-primary" @click="saveRow">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const tree = ref([])
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const editingIsSystem = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
type: 'dir',
|
||||||
|
parent_id: null,
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
path: '',
|
||||||
|
icon: '',
|
||||||
|
sort_order: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const flatRows = computed(() => {
|
||||||
|
const rows = []
|
||||||
|
const walk = (nodes, depth = 0) => {
|
||||||
|
for (const n of nodes || []) {
|
||||||
|
rows.push({ ...n, depth })
|
||||||
|
if (n.children?.length) walk(n.children, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree.value)
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentOptions = computed(() => {
|
||||||
|
if (form.type === 'menu') {
|
||||||
|
return flatRows.value
|
||||||
|
.filter(r => r.type === 'dir')
|
||||||
|
.map(r => ({ id: r.id, label: r.name }))
|
||||||
|
}
|
||||||
|
if (form.type === 'btn') {
|
||||||
|
return flatRows.value
|
||||||
|
.filter(r => r.type === 'menu')
|
||||||
|
.map(r => ({ id: r.id, label: `${r.name} (${r.code})` }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const codePlaceholder = computed(() => {
|
||||||
|
if (form.type === 'dir') return 'dir:custom'
|
||||||
|
if (form.type === 'menu') return 'menu:custom'
|
||||||
|
return 'btn:custom:action'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadTree)
|
||||||
|
|
||||||
|
async function loadTree() {
|
||||||
|
const res = await api.get('/admin/permissions/tree')
|
||||||
|
tree.value = res.data.data?.tree || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type) {
|
||||||
|
return { dir: '目录', menu: '菜单', btn: '按钮' }[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(type, parentId = null) {
|
||||||
|
editingId.value = null
|
||||||
|
editingIsSystem.value = false
|
||||||
|
form.type = type
|
||||||
|
form.parent_id = parentId
|
||||||
|
form.name = ''
|
||||||
|
form.code = ''
|
||||||
|
form.path = ''
|
||||||
|
form.icon = ''
|
||||||
|
form.sort_order = 0
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
editingId.value = row.id
|
||||||
|
editingIsSystem.value = !!row.is_system
|
||||||
|
form.type = row.type
|
||||||
|
form.parent_id = row.parent_id
|
||||||
|
form.name = row.name
|
||||||
|
form.code = row.code
|
||||||
|
form.path = row.path || ''
|
||||||
|
form.icon = row.icon || ''
|
||||||
|
form.sort_order = row.sort_order || 0
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRow() {
|
||||||
|
error.value = ''
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
error.value = '请填写名称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.type !== 'dir' && !form.parent_id) {
|
||||||
|
error.value = '请选择上级'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: form.type,
|
||||||
|
name: form.name.trim(),
|
||||||
|
code: form.code.trim() || undefined,
|
||||||
|
parent_id: form.type === 'dir' ? null : form.parent_id,
|
||||||
|
path: form.path.trim(),
|
||||||
|
icon: form.icon.trim(),
|
||||||
|
sort_order: form.sort_order
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await api.put(`/admin/permissions/${editingId.value}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/admin/permissions', payload)
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
await loadTree()
|
||||||
|
await auth.loadMenuTree()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || '保存失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRow(row) {
|
||||||
|
if (!confirm(`确定删除「${row.name}」?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/permissions/${row.id}`)
|
||||||
|
await loadTree()
|
||||||
|
await auth.loadMenuTree()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.type-badge.dir { background: rgba(14, 165, 233, 0.15); color: #0ea5e9; }
|
||||||
|
.type-badge.menu { background: rgba(99, 102, 241, 0.15); color: var(--accent); }
|
||||||
|
.type-badge.btn { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger { color: #ef4444; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>角色管理</h2>
|
||||||
|
<p>配置目录权限、菜单权限与按钮权限</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button v-if="auth.hasButton('btn:role:create')" class="btn btn-primary" @click="openCreate">新增角色</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>标识</th>
|
||||||
|
<th>权限摘要</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="role in roles" :key="role.id">
|
||||||
|
<td>{{ role.name }}</td>
|
||||||
|
<td><code>{{ role.slug }}</code></td>
|
||||||
|
<td>
|
||||||
|
<div class="perm-tags">
|
||||||
|
<span v-for="tag in summarize(role.permissions)" :key="tag" class="perm-tag">{{ tag }}</span>
|
||||||
|
<span v-if="!summarize(role.permissions).length" class="field-hint">无权限</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="auth.hasButton('btn:role:edit')" class="btn btn-ghost" @click="openEdit(role)">编辑</button>
|
||||||
|
<button
|
||||||
|
v-if="auth.hasButton('btn:role:delete') && role.slug !== 'super_admin'"
|
||||||
|
class="btn btn-ghost danger"
|
||||||
|
@click="removeRole(role)"
|
||||||
|
>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ editingId ? '编辑角色' : '新增角色' }}</h3>
|
||||||
|
<button @click="closeModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>角色名称</label>
|
||||||
|
<input v-model="form.name" class="form-input" placeholder="如:部门主管" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>标识 slug</label>
|
||||||
|
<input v-model="form.slug" class="form-input" placeholder="英文标识,留空自动生成" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="check-item top-check">
|
||||||
|
<input type="checkbox" v-model="form.can_access_admin" />
|
||||||
|
允许访问管理后台
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="perm-section">
|
||||||
|
<div class="perm-section-header">
|
||||||
|
<span>权限树(目录 / 菜单 / 按钮)</span>
|
||||||
|
<div class="perm-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="checkAll">全选</button>
|
||||||
|
<button type="button" class="btn btn-ghost" @click="clearAll">清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="dir in tree" :key="dir.code" class="perm-dir">
|
||||||
|
<label class="check-item dir-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChecked(dir.code)"
|
||||||
|
@change="toggleDir(dir, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span class="type-badge dir">目录</span>
|
||||||
|
{{ dir.name }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-for="menu in dir.children" :key="menu.code" class="perm-menu">
|
||||||
|
<label class="check-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChecked(menu.code)"
|
||||||
|
@change="toggleMenu(dir, menu, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span class="type-badge menu">菜单</span>
|
||||||
|
{{ menu.name }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="menu.children?.length" class="perm-btns">
|
||||||
|
<label v-for="btn in menu.children" :key="btn.code" class="check-item btn-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChecked(btn.code)"
|
||||||
|
@change="toggleBtn(dir, menu, btn, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span class="type-badge btn">按钮</span>
|
||||||
|
{{ btn.name }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" @click="closeModal">取消</button>
|
||||||
|
<button class="btn btn-primary" @click="saveRole">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { permissionTree, emptyPermissions, summarizePermissionLabels } from '@/config/permissions'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const roles = ref([])
|
||||||
|
const tree = ref(permissionTree)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
can_access_admin: false,
|
||||||
|
dirs: [],
|
||||||
|
menus: [],
|
||||||
|
buttons: []
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadRoles(), loadTree()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
const res = await api.get('/admin/roles')
|
||||||
|
roles.value = res.data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTree() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/permissions/tree')
|
||||||
|
if (res.data.data?.tree?.length) {
|
||||||
|
tree.value = res.data.data.tree
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
tree.value = permissionTree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(perms) {
|
||||||
|
return summarizePermissionLabels(normalizePerms(perms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChecked(code) {
|
||||||
|
if (code.startsWith('dir:')) return form.dirs.includes(code)
|
||||||
|
if (code.startsWith('menu:')) return form.menus.includes(code)
|
||||||
|
if (code.startsWith('btn:')) return form.buttons.includes(code)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function setList(listName, code, checked) {
|
||||||
|
const list = form[listName]
|
||||||
|
const idx = list.indexOf(code)
|
||||||
|
if (checked && idx < 0) list.push(code)
|
||||||
|
if (!checked && idx >= 0) list.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDir(dir, checked) {
|
||||||
|
setList('dirs', dir.code, checked)
|
||||||
|
for (const menu of dir.children || []) {
|
||||||
|
toggleMenu(dir, menu, checked, false)
|
||||||
|
}
|
||||||
|
if (checked) form.can_access_admin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu(dir, menu, checked, syncDir = true) {
|
||||||
|
setList('menus', menu.code, checked)
|
||||||
|
for (const btn of menu.children || []) {
|
||||||
|
setList('buttons', btn.code, checked)
|
||||||
|
}
|
||||||
|
if (syncDir) {
|
||||||
|
const anyMenu = (dir.children || []).some(m => form.menus.includes(m.code))
|
||||||
|
setList('dirs', dir.code, anyMenu)
|
||||||
|
}
|
||||||
|
if (checked) form.can_access_admin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBtn(dir, menu, btn, checked) {
|
||||||
|
setList('buttons', btn.code, checked)
|
||||||
|
const anyBtn = (menu.children || []).some(b => form.buttons.includes(b.code))
|
||||||
|
if (checked || anyBtn) {
|
||||||
|
setList('menus', menu.code, true)
|
||||||
|
setList('dirs', dir.code, true)
|
||||||
|
form.can_access_admin = true
|
||||||
|
} else if (!(menu.children || []).length) {
|
||||||
|
// no-op
|
||||||
|
} else if (!anyBtn && !form.menus.includes(menu.code)) {
|
||||||
|
// keep menu if explicitly checked alone — already handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAll() {
|
||||||
|
form.can_access_admin = true
|
||||||
|
form.dirs = []
|
||||||
|
form.menus = []
|
||||||
|
form.buttons = []
|
||||||
|
for (const dir of tree.value) {
|
||||||
|
form.dirs.push(dir.code)
|
||||||
|
for (const menu of dir.children || []) {
|
||||||
|
form.menus.push(menu.code)
|
||||||
|
for (const btn of menu.children || []) {
|
||||||
|
form.buttons.push(btn.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
Object.assign(form, {
|
||||||
|
...form,
|
||||||
|
can_access_admin: false,
|
||||||
|
dirs: [],
|
||||||
|
menus: [],
|
||||||
|
buttons: []
|
||||||
|
})
|
||||||
|
form.dirs = []
|
||||||
|
form.menus = []
|
||||||
|
form.buttons = []
|
||||||
|
form.can_access_admin = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null
|
||||||
|
form.name = ''
|
||||||
|
form.slug = ''
|
||||||
|
const empty = emptyPermissions()
|
||||||
|
form.can_access_admin = empty.can_access_admin
|
||||||
|
form.dirs = []
|
||||||
|
form.menus = []
|
||||||
|
form.buttons = []
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(role) {
|
||||||
|
editingId.value = role.id
|
||||||
|
form.name = role.name
|
||||||
|
form.slug = role.slug
|
||||||
|
const p = normalizePerms(role.permissions)
|
||||||
|
form.can_access_admin = !!p.can_access_admin
|
||||||
|
form.dirs = [...p.dirs]
|
||||||
|
form.menus = [...p.menus]
|
||||||
|
form.buttons = [...p.buttons]
|
||||||
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePerms(raw) {
|
||||||
|
let p = raw
|
||||||
|
if (typeof p === 'string') {
|
||||||
|
try {
|
||||||
|
p = JSON.parse(p)
|
||||||
|
} catch {
|
||||||
|
p = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!p || typeof p !== 'object') p = {}
|
||||||
|
return {
|
||||||
|
can_access_admin: !!p.can_access_admin,
|
||||||
|
dirs: Array.isArray(p.dirs) ? p.dirs : [],
|
||||||
|
menus: Array.isArray(p.menus) ? p.menus : [],
|
||||||
|
buttons: Array.isArray(p.buttons) ? p.buttons : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRole() {
|
||||||
|
error.value = ''
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
error.value = '请填写角色名称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
slug: form.slug.trim() || undefined,
|
||||||
|
permissions: {
|
||||||
|
can_access_admin: form.can_access_admin,
|
||||||
|
dirs: [...form.dirs],
|
||||||
|
menus: [...form.menus],
|
||||||
|
buttons: [...form.buttons]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await api.put(`/admin/roles/${editingId.value}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/admin/roles', payload)
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
await loadRoles()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || '保存失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRole(role) {
|
||||||
|
if (!confirm(`确定删除角色「${role.name}」?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/roles/${role.id}`)
|
||||||
|
await loadRoles()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-wide {
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-wide .modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-check {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-section {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-dir {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-dir:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-check {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-menu {
|
||||||
|
margin-left: 22px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-btns {
|
||||||
|
margin-left: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-check {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.dir {
|
||||||
|
background: rgba(14, 165, 233, 0.15);
|
||||||
|
color: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.menu {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.btn {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>系统设置</h2>
|
||||||
|
<p>控制前端功能开关与站点配置</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 class="section-title">站点配置</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>站点名称</label>
|
||||||
|
<input v-model="siteName" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="check-item">
|
||||||
|
<input type="checkbox" v-model="allowRegister" />
|
||||||
|
允许用户注册
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top: 16px">
|
||||||
|
<h3 class="section-title">功能开关(会员端)</h3>
|
||||||
|
<p class="section-desc">关闭后,会员端对应功能将不可用</p>
|
||||||
|
<div class="feature-grid">
|
||||||
|
<label v-for="(val, key) in features" :key="key" class="feature-item">
|
||||||
|
<input type="checkbox" v-model="features[key]" />
|
||||||
|
<span>{{ featureLabels[key] || key }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button v-if="auth.hasButton('btn:settings:save')" class="btn btn-primary" @click="saveAll" :disabled="saving">
|
||||||
|
{{ saving ? '保存中...' : '保存全部设置' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="saved" class="success-msg">保存成功</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const siteName = ref('AI Chat')
|
||||||
|
const allowRegister = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const saved = ref(false)
|
||||||
|
|
||||||
|
const features = reactive({
|
||||||
|
markdown: true,
|
||||||
|
image: true,
|
||||||
|
video: true,
|
||||||
|
voice: true,
|
||||||
|
document: true,
|
||||||
|
emoji: true,
|
||||||
|
upload_image: true,
|
||||||
|
upload_video: true,
|
||||||
|
upload_file: true,
|
||||||
|
paste_image: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const featureLabels = {
|
||||||
|
markdown: 'Markdown 解析',
|
||||||
|
image: '图片解析',
|
||||||
|
video: '视频解析',
|
||||||
|
voice: '语音解析',
|
||||||
|
document: '文档解析',
|
||||||
|
emoji: '表情',
|
||||||
|
upload_image: '上传图片',
|
||||||
|
upload_video: '上传视频',
|
||||||
|
upload_file: '上传文件',
|
||||||
|
paste_image: '粘贴图片'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const res = await api.get('/admin/settings')
|
||||||
|
const data = res.data.data
|
||||||
|
|
||||||
|
if (data.site_name) {
|
||||||
|
siteName.value = data.site_name.value
|
||||||
|
}
|
||||||
|
if (data.allow_register) {
|
||||||
|
allowRegister.value = data.allow_register.value === true || data.allow_register.value === 'true'
|
||||||
|
}
|
||||||
|
if (data.features?.value) {
|
||||||
|
Object.assign(features, data.features.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveAll() {
|
||||||
|
saving.value = true
|
||||||
|
saved.value = false
|
||||||
|
try {
|
||||||
|
await api.put('/admin/settings', {
|
||||||
|
site_name: siteName.value,
|
||||||
|
allow_register: allowRegister.value ? 'true' : 'false',
|
||||||
|
features: { ...features }
|
||||||
|
})
|
||||||
|
saved.value = true
|
||||||
|
setTimeout(() => { saved.value = false }, 3000)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>用户管理</h2>
|
||||||
|
<p>管理注册用户、密码、角色与会员套餐</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button v-if="auth.hasButton('btn:user:create')" class="btn btn-primary" @click="openCreate">新增账户</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>邮箱</th>
|
||||||
|
<th>昵称</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>部门</th>
|
||||||
|
<th>会员等级</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>最后登录</th>
|
||||||
|
<th>注册时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.id">
|
||||||
|
<td>{{ u.id }}</td>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.email }}</td>
|
||||||
|
<td>{{ u.nickname || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="u.role_slug === 'super_admin' || u.role === 'admin' ? 'badge-info' : ''">
|
||||||
|
{{ u.role_name || roleLabel(u.role) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ u.department_name || '-' }}</td>
|
||||||
|
<td>{{ u.membership_name || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="u.status === 'active' ? 'badge-success' : 'badge-danger'">
|
||||||
|
{{ u.status === 'active' ? '正常' : '禁用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(u.last_login_at) }}</td>
|
||||||
|
<td>{{ formatDate(u.created_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="auth.hasButton('btn:user:edit')" class="btn btn-ghost" @click="openEdit(u)">编辑</button>
|
||||||
|
<button
|
||||||
|
v-if="auth.hasButton('btn:user:delete') && u.id !== auth.user?.id"
|
||||||
|
class="btn btn-ghost danger"
|
||||||
|
@click="removeUser(u)"
|
||||||
|
>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="!users.length" class="empty">暂无用户</p>
|
||||||
|
|
||||||
|
<div v-if="total > limit" class="pagination">
|
||||||
|
<button class="btn btn-ghost" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
|
||||||
|
<span>{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button class="btn btn-ghost" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ isCreate ? '新增账户' : `编辑用户 — ${editUser?.username}` }}</h3>
|
||||||
|
<button @click="closeModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div v-if="!isCreate" class="user-meta">
|
||||||
|
<span>ID: {{ editUser.id }}</span>
|
||||||
|
<span>邮箱: {{ editUser.email }}</span>
|
||||||
|
<span>注册: {{ formatDate(editUser.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isCreate">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="editForm.username" class="form-input" placeholder="登录用户名" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>邮箱</label>
|
||||||
|
<input v-model="editForm.email" class="form-input" placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>昵称</label>
|
||||||
|
<input v-model="editForm.nickname" class="form-input" placeholder="显示名称" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>角色</label>
|
||||||
|
<select v-model.number="editForm.role_id" class="form-select">
|
||||||
|
<option v-for="r in roles" :key="r.id" :value="r.id">{{ r.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>所属部门</label>
|
||||||
|
<select v-model="editForm.department_id" class="form-select">
|
||||||
|
<option :value="null">未分配</option>
|
||||||
|
<option v-for="d in departments" :key="d.id" :value="d.id">{{ d.label || d.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>账号状态</label>
|
||||||
|
<select v-model="editForm.status" class="form-select">
|
||||||
|
<option value="active">正常</option>
|
||||||
|
<option value="disabled">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>会员套餐</label>
|
||||||
|
<select v-model.number="editForm.membership_level_id" class="form-select">
|
||||||
|
<option v-for="m in memberships" :key="m.id" :value="m.id">
|
||||||
|
{{ m.name }}(会话 {{ m.max_conversations }} · 日消息 {{ m.max_messages_per_day }} · 上传 {{ m.max_upload_size_mb }}MB)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-if="selectedMembership" class="membership-preview">
|
||||||
|
<span v-if="selectedMembership.permissions?.can_upload_image" class="perm-tag">图片</span>
|
||||||
|
<span v-if="selectedMembership.permissions?.can_upload_video" class="perm-tag">视频</span>
|
||||||
|
<span v-if="selectedMembership.permissions?.can_upload_file" class="perm-tag">文件</span>
|
||||||
|
<span v-if="selectedMembership.permissions?.can_use_voice" class="perm-tag">语音</span>
|
||||||
|
<span v-if="!hasAnyPermission(selectedMembership)" class="field-hint">该套餐暂无额外权限</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isCreate || auth.hasButton('btn:user:reset_password')" class="form-divider">
|
||||||
|
{{ isCreate ? '登录密码' : '重置密码(可选)' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isCreate || auth.hasButton('btn:user:reset_password')" class="form-group">
|
||||||
|
<label>{{ isCreate ? '密码' : '新密码' }}</label>
|
||||||
|
<input
|
||||||
|
v-model="editForm.password"
|
||||||
|
type="password"
|
||||||
|
class="form-input"
|
||||||
|
:placeholder="isCreate ? '至少 6 位' : '留空则不修改密码'"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isCreate || auth.hasButton('btn:user:reset_password')" class="form-group">
|
||||||
|
<label>确认密码</label>
|
||||||
|
<input
|
||||||
|
v-model="editForm.passwordConfirm"
|
||||||
|
type="password"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" @click="closeModal">取消</button>
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="saveUser">
|
||||||
|
{{ saving ? '保存中...' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const users = ref([])
|
||||||
|
const memberships = ref([])
|
||||||
|
const roles = ref([])
|
||||||
|
const departments = ref([])
|
||||||
|
const editUser = ref(null)
|
||||||
|
const isCreate = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = 20
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
nickname: '',
|
||||||
|
role_id: null,
|
||||||
|
department_id: null,
|
||||||
|
status: 'active',
|
||||||
|
membership_level_id: 1,
|
||||||
|
password: '',
|
||||||
|
passwordConfirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
||||||
|
|
||||||
|
const selectedMembership = computed(() =>
|
||||||
|
memberships.value.find(m => m.id === editForm.membership_level_id) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadUsers(), loadMemberships(), loadRoles(), loadDepartments()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const res = await api.get('/admin/users', { params: { page: page.value, limit } })
|
||||||
|
const data = res.data.data || {}
|
||||||
|
users.value = data.list || []
|
||||||
|
total.value = data.total ?? users.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMemberships() {
|
||||||
|
const res = await api.get('/admin/memberships')
|
||||||
|
memberships.value = res.data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
const res = await api.get('/admin/roles')
|
||||||
|
roles.value = res.data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDepartments() {
|
||||||
|
const res = await api.get('/admin/department-options')
|
||||||
|
departments.value = res.data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(nextPage) {
|
||||||
|
page.value = nextPage
|
||||||
|
await loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
isCreate.value = true
|
||||||
|
editUser.value = null
|
||||||
|
showModal.value = true
|
||||||
|
error.value = ''
|
||||||
|
editForm.username = ''
|
||||||
|
editForm.email = ''
|
||||||
|
editForm.nickname = ''
|
||||||
|
editForm.role_id = roles.value.find(r => r.slug === 'user')?.id || roles.value[0]?.id || null
|
||||||
|
editForm.department_id = null
|
||||||
|
editForm.status = 'active'
|
||||||
|
editForm.membership_level_id = memberships.value[0]?.id || 1
|
||||||
|
editForm.password = ''
|
||||||
|
editForm.passwordConfirm = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(user) {
|
||||||
|
isCreate.value = false
|
||||||
|
editUser.value = user
|
||||||
|
showModal.value = true
|
||||||
|
error.value = ''
|
||||||
|
editForm.username = user.username
|
||||||
|
editForm.email = user.email
|
||||||
|
editForm.nickname = user.nickname || user.username
|
||||||
|
editForm.role_id = user.role_id || roles.value.find(r => r.slug === 'user')?.id || null
|
||||||
|
editForm.department_id = user.department_id || null
|
||||||
|
editForm.status = user.status
|
||||||
|
editForm.membership_level_id = user.membership_level_id || memberships.value[0]?.id || 1
|
||||||
|
editForm.password = ''
|
||||||
|
editForm.passwordConfirm = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
editUser.value = null
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyPermission(level) {
|
||||||
|
const p = level.permissions || {}
|
||||||
|
return p.can_upload_image || p.can_upload_video || p.can_upload_file || p.can_use_voice
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (isCreate.value) {
|
||||||
|
if (editForm.username.trim().length < 3) {
|
||||||
|
error.value = '用户名至少 3 位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editForm.email.trim()) {
|
||||||
|
error.value = '请填写邮箱'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editForm.password.length < 6) {
|
||||||
|
error.value = '密码至少 6 位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editForm.password || editForm.passwordConfirm || isCreate.value) {
|
||||||
|
if (editForm.password.length < 6 && (isCreate.value || editForm.password || editForm.passwordConfirm)) {
|
||||||
|
error.value = '密码至少 6 位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editForm.password !== editForm.passwordConfirm) {
|
||||||
|
error.value = '两次输入的密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isCreate.value) {
|
||||||
|
await api.post('/admin/users', {
|
||||||
|
username: editForm.username.trim(),
|
||||||
|
email: editForm.email.trim(),
|
||||||
|
nickname: editForm.nickname.trim(),
|
||||||
|
role_id: editForm.role_id,
|
||||||
|
department_id: editForm.department_id,
|
||||||
|
status: editForm.status,
|
||||||
|
membership_level_id: editForm.membership_level_id,
|
||||||
|
password: editForm.password
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const payload = {
|
||||||
|
nickname: editForm.nickname.trim(),
|
||||||
|
role_id: editForm.role_id,
|
||||||
|
department_id: editForm.department_id,
|
||||||
|
status: editForm.status,
|
||||||
|
membership_level_id: editForm.membership_level_id
|
||||||
|
}
|
||||||
|
if (editForm.password) payload.password = editForm.password
|
||||||
|
await api.put(`/admin/users/${editUser.value.id}`, payload)
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || '保存失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(user) {
|
||||||
|
if (!confirm(`确定删除账户「${user.username}」?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/users/${user.id}`)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role) {
|
||||||
|
return role === 'admin' ? '管理员' : '用户'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
if (!d) return '-'
|
||||||
|
return new Date(d).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-wide {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-divider {
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const apiTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080'
|
||||||
|
const base = env.VITE_BASE_PATH || (mode === 'production' ? '/admin/' : '/')
|
||||||
|
|
||||||
|
return {
|
||||||
|
base,
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:8080
|
||||||
|
VITE_ADMIN_URL=http://localhost:5174
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 生产环境:管理后台入口(同域子路径)
|
||||||
|
VITE_ADMIN_URL=/admin
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>AI Chat</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1767
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-chat-member",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build && node ../scripts/deploy-static.js member",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"marked": "^15.0.6",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#10a37f"/>
|
||||||
|
<path d="M16 8c-4.4 0-8 3.1-8 7 0 2.2 1.1 4.2 2.9 5.5-.3.9-.9 2.4-1.5 3.5 1.3-.2 3-.8 4.2-1.4 0 0 .3.1.4.1 4.4 0 8-3.1 8-7s-3.6-7-8-7z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 60000
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
if (axios.isCancel(err)) {
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err.response?.data?.message || '请求失败'
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wrapped = new Error(message)
|
||||||
|
wrapped.code = err.code
|
||||||
|
return Promise.reject(wrapped)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
@import 'highlight.js/styles/github-dark.css';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #212121;
|
||||||
|
--bg-secondary: #171717;
|
||||||
|
--bg-tertiary: #2f2f2f;
|
||||||
|
--bg-hover: #3a3a3a;
|
||||||
|
--text-primary: #ececec;
|
||||||
|
--text-secondary: #b4b4b4;
|
||||||
|
--text-muted: #8e8e8e;
|
||||||
|
--accent: #10a37f;
|
||||||
|
--accent-hover: #0d8a6a;
|
||||||
|
--border: #3a3a3a;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--sidebar-width: 260px;
|
||||||
|
--header-height: 56px;
|
||||||
|
--input-max-width: 768px;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown content */
|
||||||
|
.markdown-body {
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 15px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.markdown-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单个换行(非空行分段)时也留出呼吸空间,避免像 OCR 结果那样一行行挤在一起 */
|
||||||
|
.markdown-body br {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body code {
|
||||||
|
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :not(pre) > code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body ul, .markdown-body ol {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th, .markdown-body td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay for mobile sidebar */
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar-overlay.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<!-- 附件预览 -->
|
||||||
|
<div v-if="pendingAttachments.length || uploadingFiles.length" class="attachment-preview">
|
||||||
|
<div v-for="(att, i) in pendingAttachments" :key="'done-' + i" class="preview-item">
|
||||||
|
<img v-if="att?.type === 'image'" :src="att.preview" class="preview-thumb" />
|
||||||
|
<span v-else class="preview-file">{{ att?.name || '未知文件' }}</span>
|
||||||
|
<button class="remove-btn" @click="removeAttachment(i)">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="u in uploadingFiles" :key="u.id" class="preview-item preview-uploading">
|
||||||
|
<span class="upload-name" :title="u.name">{{ u.name }}</span>
|
||||||
|
<span class="upload-percent">{{ u.progress }}%</span>
|
||||||
|
<div class="upload-progress-track">
|
||||||
|
<div class="upload-progress-fill" :style="{ width: u.progress + '%' }" />
|
||||||
|
</div>
|
||||||
|
<button class="remove-btn" title="取消上传" @click="cancelUpload(u.id)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<div class="input-actions">
|
||||||
|
<button
|
||||||
|
v-if="canUploadImage || canUploadVideo || canUploadFile"
|
||||||
|
class="action-btn"
|
||||||
|
title="上传文件"
|
||||||
|
@click="triggerUpload"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="features.emoji"
|
||||||
|
class="action-btn"
|
||||||
|
title="表情"
|
||||||
|
@click="showEmoji = !showEmoji"
|
||||||
|
>
|
||||||
|
😊
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
:accept="acceptTypes"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表情面板 -->
|
||||||
|
<div v-if="showEmoji" class="emoji-panel">
|
||||||
|
<button
|
||||||
|
v-for="emoji in EMOJIS"
|
||||||
|
:key="emoji"
|
||||||
|
class="emoji-btn"
|
||||||
|
@click="insertEmoji(emoji)"
|
||||||
|
>{{ emoji }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
|
v-model="content"
|
||||||
|
class="input-textarea"
|
||||||
|
placeholder="输入消息... (Shift+Enter 换行)"
|
||||||
|
rows="1"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@input="autoResize"
|
||||||
|
@paste="handlePaste"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="send-btn"
|
||||||
|
:disabled="!canSend || sending"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="uploadingFiles.length" class="input-hint">正在上传 {{ uploadingFiles.length }} 个文件…</p>
|
||||||
|
<p v-else-if="imageBlockedByModel" class="input-hint input-hint-warn">当前模型不支持图片识别,已隐藏图片上传</p>
|
||||||
|
<p v-else class="input-hint">AI 可能会犯错,请核实重要信息</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { EMOJIS } from '@/utils/markdown'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const emit = defineEmits(['send'])
|
||||||
|
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const chat = useChatStore()
|
||||||
|
|
||||||
|
const content = ref('')
|
||||||
|
const pendingAttachments = ref([])
|
||||||
|
const uploadingFiles = ref([])
|
||||||
|
const showEmoji = ref(false)
|
||||||
|
const sending = ref(false)
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const textareaRef = ref(null)
|
||||||
|
|
||||||
|
const features = computed(() => settings.features)
|
||||||
|
const perms = computed(() => auth.user?.membership_permissions || {})
|
||||||
|
|
||||||
|
const activeModel = computed(() => {
|
||||||
|
const conv = chat.conversations.find(c => c.id === chat.currentId)
|
||||||
|
const modelId = conv?.model_id
|
||||||
|
if (modelId) {
|
||||||
|
return settings.models.find(m => m.id === modelId) || null
|
||||||
|
}
|
||||||
|
return settings.models.find(m => m.is_default) || settings.models[0] || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelSupportsImage = computed(() => {
|
||||||
|
if (!activeModel.value) return true
|
||||||
|
return activeModel.value.support_image !== 0 && activeModel.value.support_image !== false
|
||||||
|
})
|
||||||
|
|
||||||
|
const canUploadImage = computed(() => features.value.upload_image && perms.value.can_upload_image && modelSupportsImage.value)
|
||||||
|
const canUploadVideo = computed(() => features.value.upload_video && perms.value.can_upload_video)
|
||||||
|
const canUploadFile = computed(() => features.value.upload_file && perms.value.can_upload_file)
|
||||||
|
|
||||||
|
const imageBlockedByModel = computed(() => {
|
||||||
|
return features.value.upload_image && perms.value.can_upload_image && !modelSupportsImage.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const acceptTypes = computed(() => {
|
||||||
|
const types = []
|
||||||
|
if (canUploadImage.value) types.push('image/*')
|
||||||
|
if (canUploadVideo.value) types.push('video/*')
|
||||||
|
if (canUploadFile.value) types.push('.pdf,.doc,.docx,.txt,.md')
|
||||||
|
return types.join(',')
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSend = computed(() => {
|
||||||
|
return (content.value.trim() || pendingAttachments.value.length) &&
|
||||||
|
!sending.value &&
|
||||||
|
!uploadingFiles.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 200) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
await uploadFile(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePaste(e) {
|
||||||
|
if (!features.value.paste_image || !canUploadImage.value) return
|
||||||
|
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item?.type?.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) await uploadFile(file)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
const id = `up-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const controller = new AbortController()
|
||||||
|
const entry = { id, name: file.name, progress: 0, controller }
|
||||||
|
uploadingFiles.value.push(entry)
|
||||||
|
|
||||||
|
const localPreview = file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post('/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
signal: controller.signal,
|
||||||
|
onUploadProgress: (evt) => {
|
||||||
|
if (evt.total) {
|
||||||
|
entry.progress = Math.round((evt.loaded / evt.total) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = res.data?.data
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('上传失败:服务器未返回文件信息')
|
||||||
|
}
|
||||||
|
pendingAttachments.value.push({
|
||||||
|
...data,
|
||||||
|
preview: data.type === 'image' ? (localPreview || data.url) : null,
|
||||||
|
_localPreview: localPreview
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (localPreview) URL.revokeObjectURL(localPreview)
|
||||||
|
if (err.code !== 'ERR_CANCELED' && err.name !== 'CanceledError') {
|
||||||
|
alert(err.message || '上传失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploadingFiles.value = uploadingFiles.value.filter(u => u.id !== id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelUpload(id) {
|
||||||
|
const entry = uploadingFiles.value.find(u => u.id === id)
|
||||||
|
entry?.controller.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(index) {
|
||||||
|
const att = pendingAttachments.value[index]
|
||||||
|
if (att?._localPreview) URL.revokeObjectURL(att._localPreview)
|
||||||
|
pendingAttachments.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertEmoji(emoji) {
|
||||||
|
content.value += emoji
|
||||||
|
showEmoji.value = false
|
||||||
|
textareaRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!canSend.value) return
|
||||||
|
|
||||||
|
sending.value = true
|
||||||
|
const text = content.value.trim()
|
||||||
|
const attachments = pendingAttachments.value.map(a => ({
|
||||||
|
type: a.type,
|
||||||
|
url: a.url,
|
||||||
|
name: a.name,
|
||||||
|
size: a.size,
|
||||||
|
mime: a.mime
|
||||||
|
}))
|
||||||
|
|
||||||
|
pendingAttachments.value.forEach(a => {
|
||||||
|
if (a._localPreview) URL.revokeObjectURL(a._localPreview)
|
||||||
|
})
|
||||||
|
|
||||||
|
content.value = ''
|
||||||
|
pendingAttachments.value = []
|
||||||
|
showEmoji.value = false
|
||||||
|
|
||||||
|
if (textareaRef.value) {
|
||||||
|
textareaRef.value.style.height = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('send', { content: text, attachments })
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-input-area {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: var(--input-max-width);
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-file {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-uploading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 140px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-percent {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: var(--input-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-btn {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.emoji-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.send-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint-warn {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="sidebar" :class="{ open: chat.sidebarOpen }">
|
||||||
|
<div class="sidebar-top">
|
||||||
|
<button class="new-chat-btn" @click="handleNewChat">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
新对话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conversation-list">
|
||||||
|
<div
|
||||||
|
v-for="conv in chat.conversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="conversation-item"
|
||||||
|
:class="{ active: conv.id === chat.currentId }"
|
||||||
|
@click="selectConv(conv.id)"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="conv-title">{{ conv.title }}</span>
|
||||||
|
<button class="delete-btn" @click.stop="handleDelete(conv.id)" title="删除">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!chat.conversations.length" class="empty-tip">暂无对话,点击上方开始</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="avatar">{{ avatarLetter }}</div>
|
||||||
|
<div class="user-meta">
|
||||||
|
<span class="user-name">{{ auth.user?.nickname || auth.user?.username }}</span>
|
||||||
|
<span class="user-level">{{ auth.user?.membership_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="auth.user?.role === 'admin'"
|
||||||
|
:href="adminUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="admin-entry"
|
||||||
|
>⚙️ 进入管理后台</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const chat = useChatStore()
|
||||||
|
|
||||||
|
const avatarLetter = computed(() => {
|
||||||
|
const name = auth.user?.nickname || auth.user?.username || '?'
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174`
|
||||||
|
|
||||||
|
async function handleNewChat() {
|
||||||
|
await chat.createConversation()
|
||||||
|
chat.closeSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConv(id) {
|
||||||
|
await chat.selectConversation(id)
|
||||||
|
chat.closeSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (confirm('确定删除此对话?')) {
|
||||||
|
await chat.deleteConversation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-top {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.new-chat-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.conversation-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.conversation-item.active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-title {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
opacity: 0;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.conversation-item:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-level {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-entry {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(16, 163, 127, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.admin-entry:hover {
|
||||||
|
background: rgba(16, 163, 127, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message" :class="message.role">
|
||||||
|
<div class="message-avatar">
|
||||||
|
{{ message.role === 'user' ? 'U' : 'AI' }}
|
||||||
|
</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<!-- 附件 -->
|
||||||
|
<div v-if="attachments.length" class="attachments">
|
||||||
|
<template v-for="(att, i) in attachments" :key="i">
|
||||||
|
<img
|
||||||
|
v-if="att?.type === 'image' && features.image && !brokenImages[i]"
|
||||||
|
:src="att.url"
|
||||||
|
:alt="att.name"
|
||||||
|
class="att-image"
|
||||||
|
@error="markBroken(i)"
|
||||||
|
@click="previewImage(att.url)"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else-if="att?.type === 'image'"
|
||||||
|
:href="att.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="att-document"
|
||||||
|
>
|
||||||
|
🖼️ {{ att.name || '图片' }}(点击打开)
|
||||||
|
</a>
|
||||||
|
<video
|
||||||
|
v-else-if="att?.type === 'video' && features.video"
|
||||||
|
:src="att.url"
|
||||||
|
controls
|
||||||
|
class="att-video"
|
||||||
|
/>
|
||||||
|
<audio
|
||||||
|
v-else-if="att?.type === 'audio' && features.voice"
|
||||||
|
:src="att.url"
|
||||||
|
controls
|
||||||
|
class="att-audio"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else-if="att?.type === 'document' && features.document"
|
||||||
|
:href="att.url"
|
||||||
|
target="_blank"
|
||||||
|
class="att-document"
|
||||||
|
>
|
||||||
|
{{ documentIcon(att) }} {{ att.name }} ({{ formatFileSize(att.size) }})
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本内容 -->
|
||||||
|
<div
|
||||||
|
v-if="message.content"
|
||||||
|
class="message-content"
|
||||||
|
:class="{ 'markdown-body': useMarkdown }"
|
||||||
|
v-html="renderedContent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { renderMarkdown, formatFileSize } from '@/utils/markdown'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: { type: Object, required: true },
|
||||||
|
isStreaming: Boolean
|
||||||
|
})
|
||||||
|
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
const features = computed(() => settings.features)
|
||||||
|
|
||||||
|
const attachments = computed(() => {
|
||||||
|
const raw = props.message.attachments
|
||||||
|
const list = Array.isArray(raw) ? raw : (raw && typeof raw === 'object' ? Object.values(raw) : [])
|
||||||
|
return list.filter(att => att && typeof att === 'object')
|
||||||
|
})
|
||||||
|
|
||||||
|
function documentIcon(att) {
|
||||||
|
const name = (att?.name || '').toLowerCase()
|
||||||
|
const mime = att?.mime || ''
|
||||||
|
if (name.endsWith('.pdf') || mime.includes('pdf')) return '📕'
|
||||||
|
if (name.endsWith('.doc') || name.endsWith('.docx') || mime.includes('word')) return '📘'
|
||||||
|
return '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
const useMarkdown = computed(() => {
|
||||||
|
return features.value.markdown && props.message.role === 'assistant'
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
if (!props.message.content) return ''
|
||||||
|
if (useMarkdown.value) {
|
||||||
|
return renderMarkdown(props.message.content)
|
||||||
|
}
|
||||||
|
return escapeHtml(props.message.content).replace(/\n/g, '<br>')
|
||||||
|
})
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
const brokenImages = reactive({})
|
||||||
|
|
||||||
|
function markBroken(index) {
|
||||||
|
brokenImages[index] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewImage(url) {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: var(--input-max-width);
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-avatar {
|
||||||
|
background: #5436da;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-avatar {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 78%;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-body {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-body {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 16px 16px 4px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-content {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 16px 16px 16px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(pre) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content :deep(pre),
|
||||||
|
.message.user .message-content :deep(:not(pre) > code) {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content :deep(a) {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .attachments {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-image {
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-video {
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-audio {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-document {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.att-document:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.message-body {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
.att-image {
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message-list" ref="listRef">
|
||||||
|
<div v-if="!visibleMessages.length && !loading && !sending && !streaming.trim()" class="welcome">
|
||||||
|
<div class="welcome-icon">💬</div>
|
||||||
|
<h2>有什么可以帮您的?</h2>
|
||||||
|
<p>输入消息开始对话,支持 Markdown、图片、文件等</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">加载中...</div>
|
||||||
|
|
||||||
|
<MessageItem
|
||||||
|
v-for="msg in visibleMessages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="msg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MessageItem
|
||||||
|
v-if="streaming.trim()"
|
||||||
|
:message="{ role: 'assistant', content: streaming, content_type: 'markdown' }"
|
||||||
|
:is-streaming="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="sending && !streaming" class="typing-indicator">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick, computed } from 'vue'
|
||||||
|
import MessageItem from './MessageItem.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
messages: { type: Array, default: () => [] },
|
||||||
|
loading: Boolean,
|
||||||
|
streaming: { type: String, default: '' },
|
||||||
|
sending: Boolean
|
||||||
|
})
|
||||||
|
|
||||||
|
const listRef = ref(null)
|
||||||
|
|
||||||
|
const visibleMessages = computed(() => {
|
||||||
|
return props.messages.filter(msg => {
|
||||||
|
const hasContent = !!(msg.content && String(msg.content).trim())
|
||||||
|
const attachments = msg.attachments || []
|
||||||
|
const hasAttachments = Array.isArray(attachments) && attachments.length > 0
|
||||||
|
return hasContent || hasAttachments
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.messages.length, props.streaming],
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (listRef.value) {
|
||||||
|
listRef.value.scrollTop = listRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.4s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||||
|
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0); }
|
||||||
|
40% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/LoginView.vue'),
|
||||||
|
meta: { guest: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: () => import('@/views/RegisterView.vue'),
|
||||||
|
meta: { guest: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Chat',
|
||||||
|
component: () => import('@/views/ChatView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (!auth.initialized) {
|
||||||
|
await auth.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
||||||
|
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.guest && auth.isLoggedIn) {
|
||||||
|
next({ name: 'Chat' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('token') || '')
|
||||||
|
const user = ref(null)
|
||||||
|
const initialized = ref(false)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value && !!user.value)
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (token.value) {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/me')
|
||||||
|
user.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account, password) {
|
||||||
|
const res = await api.post('/auth/login', { account, password })
|
||||||
|
token.value = res.data.data.token
|
||||||
|
user.value = res.data.data.user
|
||||||
|
localStorage.setItem('token', token.value)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(username, email, password) {
|
||||||
|
const res = await api.post('/auth/register', { username, email, password })
|
||||||
|
token.value = res.data.data.token
|
||||||
|
user.value = res.data.data.user
|
||||||
|
localStorage.setItem('token', token.value)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, user, initialized, isLoggedIn, init, login, register, logout }
|
||||||
|
})
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
function parseSseBlock(block) {
|
||||||
|
const lines = block.split('\n')
|
||||||
|
let event = 'message'
|
||||||
|
let data = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
event = line.slice(6).trim()
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
const raw = line.slice(5).trim()
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
data = { content: raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { event, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(data) {
|
||||||
|
if (Array.isArray(data)) return data
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
if (Array.isArray(data.list)) return data.list
|
||||||
|
return Object.values(data)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidMessage(msg) {
|
||||||
|
if (!msg || typeof msg !== 'object') return false
|
||||||
|
const hasContent = !!(msg.content && String(msg.content).trim())
|
||||||
|
const attachments = msg.attachments || []
|
||||||
|
const hasAttachments = Array.isArray(attachments) ? attachments.length > 0 : false
|
||||||
|
return hasContent || hasAttachments
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = defineStore('chat', () => {
|
||||||
|
const conversations = ref([])
|
||||||
|
const currentId = ref(null)
|
||||||
|
const messages = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const sending = ref(false)
|
||||||
|
const streamingContent = ref('')
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
|
async function fetchConversations() {
|
||||||
|
const res = await api.get('/conversations')
|
||||||
|
const data = res.data?.data
|
||||||
|
conversations.value = normalizeList(data?.list ?? data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(id = currentId.value) {
|
||||||
|
if (!id) return
|
||||||
|
const res = await api.get(`/conversations/${id}/messages`)
|
||||||
|
const list = normalizeList(res.data?.data)
|
||||||
|
messages.value = list.filter(isValidMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createConversation(modelId = null) {
|
||||||
|
const res = await api.post('/conversations', { model_id: modelId })
|
||||||
|
const conv = res.data.data
|
||||||
|
conversations.value.unshift(conv)
|
||||||
|
currentId.value = conv.id
|
||||||
|
messages.value = []
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(id) {
|
||||||
|
currentId.value = id
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await loadMessages(id)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteConversation(id) {
|
||||||
|
await api.delete(`/conversations/${id}`)
|
||||||
|
conversations.value = conversations.value.filter(c => c.id !== id)
|
||||||
|
if (currentId.value === id) {
|
||||||
|
currentId.value = null
|
||||||
|
messages.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(content, attachments = []) {
|
||||||
|
if (!currentId.value) {
|
||||||
|
await createConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg = {
|
||||||
|
id: Date.now(),
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
messages.value.push(userMsg)
|
||||||
|
sending.value = true
|
||||||
|
streamingContent.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamChat(content, attachments)
|
||||||
|
await loadMessages()
|
||||||
|
} catch (e) {
|
||||||
|
// 发送失败时移除本地临时用户消息,随后从服务端重新同步
|
||||||
|
messages.value = messages.value.filter(m => m.id !== userMsg.id)
|
||||||
|
await loadMessages().catch(() => {})
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
streamingContent.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamChat(content, attachments) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch('/api/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversation_id: currentId.value,
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
|
||||||
|
if (!contentType.includes('text/event-stream')) {
|
||||||
|
const json = await response.json().catch(() => null)
|
||||||
|
const msg = json?.message || `请求失败 (${response.status})`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('浏览器不支持流式响应')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
let fullContent = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const blocks = buffer.split('\n\n')
|
||||||
|
buffer = blocks.pop() || ''
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (!block.trim()) continue
|
||||||
|
|
||||||
|
const { event, data } = parseSseBlock(block)
|
||||||
|
|
||||||
|
if (event === 'error') {
|
||||||
|
throw new Error(data?.message || 'AI 请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'message' && data?.content) {
|
||||||
|
fullContent += data.content
|
||||||
|
streamingContent.value = fullContent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'done' && data?.content && !fullContent) {
|
||||||
|
fullContent = data.content
|
||||||
|
streamingContent.value = fullContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fullContent.trim()) {
|
||||||
|
throw new Error('AI 未返回内容,请在管理后台测试模型配置是否正确')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarOpen.value = !sidebarOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
sidebarOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations,
|
||||||
|
currentId,
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
sending,
|
||||||
|
streamingContent,
|
||||||
|
sidebarOpen,
|
||||||
|
fetchConversations,
|
||||||
|
createConversation,
|
||||||
|
selectConversation,
|
||||||
|
deleteConversation,
|
||||||
|
sendMessage,
|
||||||
|
toggleSidebar,
|
||||||
|
closeSidebar
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const features = ref({
|
||||||
|
markdown: true,
|
||||||
|
image: true,
|
||||||
|
video: true,
|
||||||
|
voice: true,
|
||||||
|
document: true,
|
||||||
|
emoji: true,
|
||||||
|
upload_image: true,
|
||||||
|
upload_video: true,
|
||||||
|
upload_file: true,
|
||||||
|
paste_image: true
|
||||||
|
})
|
||||||
|
const siteName = ref('AI Chat')
|
||||||
|
const allowRegister = ref(true)
|
||||||
|
const models = ref([])
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
async function loadPublic() {
|
||||||
|
const res = await api.get('/settings/public')
|
||||||
|
const data = res.data.data
|
||||||
|
features.value = data.features
|
||||||
|
siteName.value = data.site_name
|
||||||
|
allowRegister.value = data.allow_register
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
const res = await api.get('/models')
|
||||||
|
models.value = res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
return { features, siteName, allowRegister, models, loaded, loadPublic, loadModels }
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
highlight(code, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(code, { language: lang }).value
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value
|
||||||
|
},
|
||||||
|
breaks: true,
|
||||||
|
gfm: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 识别形如 "label [1, 2, 3, 4]" 的坐标/检测框数据(常见于 OCR 类模型输出),
|
||||||
|
// 加粗字段名并用等宽字体展示坐标,避免整段文字挤成一堆不好看
|
||||||
|
function formatDetectionLines(content) {
|
||||||
|
return content.replace(
|
||||||
|
/^([A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*)\s+(\[\s*-?\d+(?:\s*,\s*-?\d+)+\s*\])/gm,
|
||||||
|
'**$1** `$2`'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(content) {
|
||||||
|
if (!content) return ''
|
||||||
|
const html = marked.parse(formatDetectionLines(content))
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ADD_TAGS: ['iframe'],
|
||||||
|
ADD_ATTR: ['target', 'rel']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(dateStr) {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||||
|
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJIS = [
|
||||||
|
'😀', '😃', '😄', '😁', '😊', '🙂', '😉', '😍', '🥰', '😘',
|
||||||
|
'🤔', '😮', '😢', '😭', '😡', '👍', '👎', '👏', '🙏', '💪',
|
||||||
|
'❤️', '🔥', '✨', '🎉', '💡', '✅', '❌', '⭐', '🚀', '💯'
|
||||||
|
]
|
||||||
|
|
||||||
|
export { EMOJIS }
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-layout">
|
||||||
|
<div
|
||||||
|
class="sidebar-overlay"
|
||||||
|
:class="{ active: chat.sidebarOpen }"
|
||||||
|
@click="chat.closeSidebar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatSidebar />
|
||||||
|
|
||||||
|
<main class="chat-main">
|
||||||
|
<header class="chat-header">
|
||||||
|
<button class="menu-btn" @click="chat.toggleSidebar">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 class="chat-title">{{ currentTitle }}</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a
|
||||||
|
v-if="auth.user?.role === 'admin'"
|
||||||
|
:href="adminUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-ghost btn-sm admin-link"
|
||||||
|
>管理后台</a>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="handleLogout">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<MessageList
|
||||||
|
:messages="chat.messages"
|
||||||
|
:loading="chat.loading"
|
||||||
|
:streaming="chat.streamingContent"
|
||||||
|
:sending="chat.sending"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatInput @send="handleSend" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import ChatSidebar from '@/components/ChatSidebar.vue'
|
||||||
|
import MessageList from '@/components/MessageList.vue'
|
||||||
|
import ChatInput from '@/components/ChatInput.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const chat = useChatStore()
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
const currentTitle = computed(() => {
|
||||||
|
const conv = chat.conversations.find(c => c.id === chat.currentId)
|
||||||
|
return conv?.title || '新对话'
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174`
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await settings.loadPublic()
|
||||||
|
await settings.loadModels()
|
||||||
|
await chat.fetchConversations()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSend({ content, attachments }) {
|
||||||
|
try {
|
||||||
|
await chat.sendMessage(content, attachments)
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
display: none;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.menu-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="logo">💬</div>
|
||||||
|
<h1>{{ settings.siteName }}</h1>
|
||||||
|
<p>登录您的账户</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>账号</label>
|
||||||
|
<input v-model="account" class="form-input" placeholder="用户名或邮箱" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input v-model="password" type="password" class="form-input" placeholder="请输入密码" required />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn btn-primary auth-btn" :disabled="loading">
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-footer">
|
||||||
|
还没有账户?
|
||||||
|
<router-link to="/register">立即注册</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
const account = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => settings.loadPublic())
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(account.value, password.value)
|
||||||
|
router.push(route.query.redirect || '/')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="logo">💬</div>
|
||||||
|
<h1>{{ settings.siteName }}</h1>
|
||||||
|
<p>创建新账户</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleRegister">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="username" class="form-input" placeholder="3-50 个字符" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>邮箱</label>
|
||||||
|
<input v-model="email" type="email" class="form-input" placeholder="your@email.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input v-model="password" type="password" class="form-input" placeholder="至少 6 位" required />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn btn-primary auth-btn" :disabled="loading">
|
||||||
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-footer">
|
||||||
|
已有账户?
|
||||||
|
<router-link to="/login">立即登录</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => settings.loadPublic())
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
if (!settings.allowRegister) {
|
||||||
|
error.value = '当前不允许注册'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.register(username.value, email.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const apiTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: '/',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyRes', (proxyRes, req) => {
|
||||||
|
if (req.url?.includes('/chat/completions')) {
|
||||||
|
proxyRes.headers['cache-control'] = 'no-cache'
|
||||||
|
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-chat",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:member && npm run build:admin",
|
||||||
|
"build:member": "npm --prefix frontend run build",
|
||||||
|
"build:admin": "npm --prefix frontend-admin run build",
|
||||||
|
"deploy:static": "node scripts/deploy-static.js",
|
||||||
|
"preview:member": "npm --prefix frontend run preview",
|
||||||
|
"preview:admin": "npm --prefix frontend-admin run preview"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 生产环境一键编译部署(Windows PowerShell)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
Write-Host ">> 编译会员端..." -ForegroundColor Cyan
|
||||||
|
Set-Location (Join-Path $root "frontend")
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
Write-Host ">> 编译管理后台..." -ForegroundColor Cyan
|
||||||
|
Set-Location (Join-Path $root "frontend-admin")
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
Write-Host ">> 部署静态资源到 backend/public ..." -ForegroundColor Cyan
|
||||||
|
Set-Location $root
|
||||||
|
node scripts/deploy-static.js
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "完成!请将 Web 服务器根目录指向 backend/public" -ForegroundColor Green
|
||||||
|
Write-Host " 会员端: /" -ForegroundColor Green
|
||||||
|
Write-Host " 管理后台: /admin/" -ForegroundColor Green
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 将 frontend/dist、frontend-admin/dist 复制到 backend/public
|
||||||
|
* 用法:
|
||||||
|
* node scripts/deploy-static.js # 两端都部署
|
||||||
|
* node scripts/deploy-static.js member # 仅会员端
|
||||||
|
* node scripts/deploy-static.js admin # 仅管理端
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const publicDir = path.join(root, 'backend', 'public')
|
||||||
|
const memberDist = path.join(root, 'frontend', 'dist')
|
||||||
|
const adminDist = path.join(root, 'frontend-admin', 'dist')
|
||||||
|
const adminPublic = path.join(publicDir, 'admin')
|
||||||
|
|
||||||
|
const target = (process.argv[2] || 'all').toLowerCase()
|
||||||
|
if (!['all', 'member', 'admin'].includes(target)) {
|
||||||
|
console.error(`未知目标: ${target},可用: all | member | admin`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepInPublic = new Set([
|
||||||
|
'index.php',
|
||||||
|
'router.php',
|
||||||
|
'robots.txt',
|
||||||
|
'nginx.htaccess',
|
||||||
|
'.htaccess',
|
||||||
|
'static'
|
||||||
|
])
|
||||||
|
|
||||||
|
function assertDir(dir, label) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
throw new Error(`${label} 不存在,请先执行对应前端的 npm run build`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rmPath(targetPath) {
|
||||||
|
if (!fs.existsSync(targetPath)) return
|
||||||
|
fs.rmSync(targetPath, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDir(src, dest) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true })
|
||||||
|
for (const name of fs.readdirSync(src)) {
|
||||||
|
const from = path.join(src, name)
|
||||||
|
const to = path.join(dest, name)
|
||||||
|
if (fs.statSync(from).isDirectory()) {
|
||||||
|
copyDir(from, to)
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanMemberStatic() {
|
||||||
|
if (!fs.existsSync(publicDir)) {
|
||||||
|
fs.mkdirSync(publicDir, { recursive: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const name of fs.readdirSync(publicDir)) {
|
||||||
|
if (keepInPublic.has(name) || name === 'admin') continue
|
||||||
|
rmPath(path.join(publicDir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deployMember() {
|
||||||
|
assertDir(memberDist, '会员端 dist')
|
||||||
|
cleanMemberStatic()
|
||||||
|
copyDir(memberDist, publicDir)
|
||||||
|
console.log('✓ 会员端已部署到 backend/public/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function deployAdmin() {
|
||||||
|
assertDir(adminDist, '管理端 dist')
|
||||||
|
rmPath(adminPublic)
|
||||||
|
copyDir(adminDist, adminPublic)
|
||||||
|
console.log('✓ 管理端已部署到 backend/public/admin/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === 'all' || target === 'member') {
|
||||||
|
deployMember()
|
||||||
|
}
|
||||||
|
if (target === 'all' || target === 'admin') {
|
||||||
|
deployAdmin()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user