Compare commits

..
5 Commits
Author SHA1 Message Date
Your Name 2530ddada6 更新 2026-07-22 09:49:30 +08:00
Your Name 7803c46b96 更新功能 2026-07-17 17:28:25 +08:00
Your Name 80b0c89a72 更新 2026-07-16 10:15:42 +08:00
Your Name 99a6ca0a77 更新 2026-07-14 10:00:34 +08:00
Your Name 01152efce6 first commit 2026-07-14 10:00:04 +08:00
69 changed files with 14741 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
logs/stability/
logs/conversation/
+188 -1
View File
@@ -1,2 +1,189 @@
# chat
# AI Chat - 类 ChatGPT 智能对话系统
基于 **Vue 3** + **ThinkPHP 8** + **MySQL** 构建的全栈 AI 聊天应用。
## 架构说明
| 层级 | 技术 | 目录 | 端口 |
|------|------|------|------|
| 后端 API | **ThinkPHP 8** | `backend/` | 8080 |
| 会员端 | Vue 3 | `frontend/` | 5173 |
| 管理后台 | Vue 3 | `frontend-admin/` | 5174 |
```
chat/
├── backend/ # ThinkPHP 8 后端
│ ├── app/ # 控制器、模型、服务、中间件
│ ├── config/ # 配置文件
│ ├── database/ # 数据库脚本
│ ├── public/ # Web 入口
│ ├── route/ # 路由定义
│ └── uploads/ # 上传文件
├── frontend/ # Vue 会员端
├── frontend-admin/ # Vue 管理后台
└── README.md
```
## 功能特性
### 会员端
- 响应式聊天界面(PC / 手机自适应)
- Markdown、图片、视频、语音、文档、表情
- 文件上传、粘贴图片、会话留存
- 流式 AI 回复(SSE
- 注册 / 登录
### 管理后台
- 数据概览、用户管理、会话管理
- AI 模型配置(OpenAI 格式 API
- 会员等级与权限、功能开关
### 后端 (ThinkPHP 8)
- RESTful API + JWT 认证
- 会员权限、会话消息持久化
- OpenAI 格式 API 代理(流式输出)
- 文件上传管理
## 快速开始
### 1. 环境要求
- PHP >= 8.0(需启用 curl、pdo_mysql、mbstring
- MySQL 5.7+
- Node.js 18+
### 2. 数据库
默认配置(`backend/.env`):
```
DB_HOST = 127.0.0.1
DB_NAME = ai_chat
DB_USER = root
DB_PASS = root
```
初始化数据库:
```bash
cd backend
php database/install.php
```
### 3. 启动后端
```bash
cd backend
php think run -p 8080
```
生产环境将 Web 服务器指向 `backend/public` 目录。
### 4. 启动会员端
```bash
cd frontend
npm install
npm run dev
```
访问 http://localhost:5173
### 5. 启动管理后台
```bash
cd frontend-admin
npm install
npm run dev
```
访问 http://localhost:5174
### 6. 配置 AI 模型
登录管理后台,进入「AI 模型」,填入 API Key 和接口地址(支持 OpenAI 兼容 API)。
## 默认账户
| 用途 | 用户名 | 密码 |
|------|--------|------|
| 管理后台 / 会员端 | admin | admin123 |
## API 路由
所有接口前缀 `/api`,主要路由:
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/auth/register | 注册 |
| POST | /api/auth/login | 登录 |
| GET | /api/conversations | 会话列表 |
| POST | /api/chat/completions | 发送消息(SSE |
| POST | /api/upload | 上传文件 |
| GET | /api/admin/stats | 管理统计 |
完整路由见 `backend/route/app.php`
## 生产部署
### 1. 一键编译并部署静态资源
在项目根目录执行:
```bash
npm install
npm --prefix frontend install
npm --prefix frontend-admin install
npm run build
```
编译完成后:
- 会员端静态文件 → `backend/public/`
- 管理后台静态文件 → `backend/public/admin/`
访问路径(同域部署):
- 会员端:`https://你的域名/`
- 管理后台:`https://你的域名/admin/`
### 2. 后端配置
编辑 `backend/.env`
```
APP_DEBUG = false
JWT_SECRET = 请改成随机长字符串
```
Web 服务器网站根目录指向 `backend/public`Nginx 示例见 `deploy/nginx.conf.example`phpstudy 详见 `deploy/phpstudy.md`
**若登录接口返回 nginx 404**:说明 `/api` 未转发到 PHP,需配置伪静态(见 `backend/public/.htaccess` 或 nginx 的 `location ^~ /api`)。
### 3. 生产环境要求
- PHP >= 8.0curl、pdo_mysql、mbstring
- MySQL 5.7+
- `backend/uploads/` 目录可写
- Nginx/Apache 配置 SPA 路由回退(`try_files`
- `/api` 请求转发到 `backend/public/index.php`
### 4. 开发 vs 生产
| 环境 | 会员端 | 管理后台 | API |
|------|--------|----------|-----|
| 开发 | :5173 | :5174 | :8080 |
| 生产 | `/` | `/admin/` | `/api` |
开发环境仍用 `npm run dev`;生产用 `npm run build` 后由 Nginx 托管静态文件。
## 生产部署建议(安全)
1. 修改 `.env``JWT_SECRET`
2. 设置 `APP_DEBUG = false`
3. `uploads/` 目录需写权限
4. 启用 HTTPS,修改默认管理员密码
## 技术栈
- **后端**: ThinkPHP 8, ThinkORM, JWT
- **前端**: Vue 3, Vite, Pinia, Vue Router
- **数据库**: MySQL
Submodule
+1
Submodule backend added at 49917ae7c0
Submodule
+1
Submodule backend-tp added at 49917ae7c0
Submodule
+1
Submodule backend-tp8 added at 49917ae7c0
BIN
View File
Binary file not shown.
+46
View File
@@ -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;
}
}
+107
View File
@@ -0,0 +1,107 @@
# phpstudy 部署说明
## 1. 网站根目录
必须指向:
```
D:\web\chat\backend\public
```
**不能**指到 `backend/` 或项目根目录。
## 2. 编译前端
```powershell
cd D:\web\chat
npm run build
```
## 3. 伪静态 / 重写规则
### 若使用 Apachephpstudy 默认)
项目已自带 `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`
+2
View File
@@ -0,0 +1,2 @@
VITE_API_PROXY_TARGET=http://127.0.0.1:8080
VITE_MEMBER_URL=http://localhost:5173
+2
View File
@@ -0,0 +1,2 @@
# 生产环境:会员端入口(同域根路径)
VITE_MEMBER_URL=/
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.local
+13
View File
@@ -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>
+1727
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+4
View File
@@ -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

+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+31
View File
@@ -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
+236
View File
@@ -0,0 +1,236 @@
: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);
}
.badge-warn {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.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);
}
+169
View File
@@ -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
}
+284
View File
@@ -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>
+10
View File
@@ -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')
+78
View File
@@ -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
+142
View File
@@ -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>
+100
View File
@@ -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>
+751
View File
@@ -0,0 +1,751 @@
<template>
<div>
<div class="page-header">
<h2>AI 模型配置</h2>
<p>对接 OpenAI / Dify / ComfyUI 接口</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="providerBadgeClass(m.provider)">
{{ providerLabel(m.provider) }}
</span>
</td>
<td><code>{{ m.model_id || '-' }}</code></td>
<td class="url-cell">{{ m.api_base_url }}</td>
<td>{{ m.provider === 'dify' || m.provider === 'comfy' ? '-' : 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" @change="onProviderChange">
<option value="openai">OpenAI 兼容GPT / DeepSeek / vLLM / SGLang </option>
<option value="dify">Dify 应用chat-messages 接口</option>
<option value="comfy">ComfyUI 文生图</option>
</select>
<p class="field-hint" v-if="form.provider === 'dify'">
Dify 使用自己的一套接口协议/chat-messages OpenAI /chat/completions 不同不要混用否则会报 404
</p>
<p class="field-hint" v-if="form.provider === 'comfy'">
用户发送的文字会写入工作流的提示词节点并调用本地 ComfyUI 生图可在下方粘贴自定义工作流 JSON留空则使用服务器默认文件
<code>backend/config/comfyui_workflow.json</code>当前为 ZImageTurbo
</p>
</div>
<div class="form-group" v-if="form.provider === 'openai'">
<label>Model ID</label>
<input v-model="form.model_id" class="form-input" placeholder="gpt-4o-mini" />
</div>
<div class="form-group" v-if="form.provider === 'comfy'">
<label>宽高比可选</label>
<select v-model="form.model_id" class="form-input">
<option value="">使用工作流默认</option>
<option v-for="opt in aspectRatioOptions" :key="opt" :value="opt">{{ opt }}</option>
<option v-if="customSizeOption" :value="customSizeOption">{{ customSizeOption }}自定义分辨率</option>
</select>
<p class="field-hint">
对应 ResolutionSelector aspect_ratio不要填无关文字需要像素尺寸时可在下方自定义例如
<code>1024x1024</code>
</p>
<input
v-model="customSizeInput"
class="form-input"
style="margin-top: 8px"
placeholder="可选:自定义分辨率 1024x1024"
@change="applyCustomSize"
/>
</div>
<template v-if="form.provider === 'comfy'">
<div class="form-group">
<label>自定义工作流 JSON可选</label>
<textarea
v-model="form.workflow_json"
class="form-input workflow-json"
rows="10"
placeholder="从 ComfyUI 导出 API Format 的 workflow JSON,粘贴到这里;留空使用默认工作流"
/>
<div class="workflow-actions">
<label class="btn btn-secondary btn-sm file-btn">
从文件导入
<input type="file" accept=".json,application/json" @change="onWorkflowFile" hidden />
</label>
<button type="button" class="btn btn-secondary btn-sm" @click="form.workflow_json = ''">清空用默认</button>
</div>
</div>
<div class="form-group">
<label><input type="checkbox" v-model="form.refine_prompt" /> AI 提示词扩写推荐</label>
<p class="field-hint">
开启后走工作流 TextGenerate把用户描述扩成英文视觉提示词再画图画风以用户描述为准也可在下方自定义扩写规则
</p>
</div>
<div class="form-group" v-if="form.refine_prompt">
<label>扩写 System Prompt可选自定义风格规则</label>
<textarea
v-model="form.system_prompt"
class="form-input workflow-json"
rows="8"
:placeholder="defaultSystemPromptPlaceholder"
/>
<p class="field-hint">
留空用内置通用规则不写死清明上河图等具体画风若要固定品牌/画风偏好在此自行编写例如默认国潮插画禁止照片风
</p>
<button type="button" class="btn btn-secondary btn-sm" style="margin-top:6px" @click="form.system_prompt = ''">恢复默认</button>
</div>
<div class="form-row-2">
<div class="form-group">
<label>提示词节点 ID可选</label>
<input v-model="form.prompt_node" class="form-input" placeholder="自动识别,如 30:19" />
</div>
<div class="form-group">
<label>种子节点 ID可选</label>
<input v-model="form.seed_node" class="form-input" placeholder="自动识别,如 30:3" />
</div>
</div>
<div class="form-row-2">
<div class="form-group">
<label>img2img 重绘强度</label>
<input v-model.number="form.img2img_denoise" type="number" min="0.01" max="1" step="0.01" class="form-input" />
<p class="field-hint">默认 0.45越低越保留原图越高变化越明显</p>
</div>
<div class="form-group">
<label>inpaint 重绘强度</label>
<input v-model.number="form.inpaint_denoise" type="number" min="0.01" max="1" step="0.01" class="form-input" />
<p class="field-hint">默认 0.72仅作用于白色遮罩区域</p>
</div>
</div>
<p class="field-hint">
必须使用 <code>Save (API Format)</code> 导出的 JSON节点含 class_type
UI Format nodes/links导入后会秒结束且不出图
ZImageTurbo 默认提示词节点 <code>30:19</code>采样种子 <code>30:3</code>
</p>
</template>
<div class="form-group">
<label>API Base URL</label>
<input
v-model="form.api_base_url"
class="form-input"
:placeholder="apiBasePlaceholder"
/>
</div>
<div class="form-group" v-if="form.provider !== 'comfy'">
<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-else>
<label>API Key可选</label>
<input
v-model="form.api_key"
type="password"
class="form-input"
:placeholder="editingId ? '留空则使用已保存的 Key' : '本地 ComfyUI 一般无需填写'"
/>
</div>
<div class="form-group" v-if="form.provider === 'openai'">
<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 === 'openai'">
<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" v-if="form.provider !== 'comfy'">
<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" v-if="form.provider !== 'comfy'">
<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 === 'openai'">
<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, computed, 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 customSizeInput = ref('')
const aspectRatioOptions = [
'1:1 (Square)',
'2:3 (Portrait Photo)',
'3:2 (Photo)',
'3:4 (Portrait Standard)',
'4:3 (Standard)',
'9:16 (Portrait Widescreen)',
'16:9 (Widescreen)',
'21:9 (Ultrawide)'
]
const customSizeOption = computed(() => {
const v = (customSizeInput.value || '').trim()
if (/^\d+\s*[xX×]\s*\d+$/.test(v)) {
return v.replace(/\s*[xX×]\s*/, 'x')
}
const mid = (form.model_id || '').trim()
if (/^\d+\s*[xX×]\s*\d+$/.test(mid) && !aspectRatioOptions.includes(mid)) {
return mid.replace(/\s*[xX×]\s*/, 'x')
}
return ''
})
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,
workflow_json: '',
prompt_node: '',
seed_node: '',
img2img_denoise: 0.45,
inpaint_denoise: 0.72,
refine_prompt: true,
system_prompt: ''
})
const defaultSystemPromptPlaceholder = `留空则使用内置通用扩写规则。
可自定义,例如:
You are a prompt engineer...
Always prefer Chinese mythic illustration style when relevant.
Never put readable text in the image.`
function applyCustomSize() {
const v = (customSizeInput.value || '').trim()
if (/^\d+\s*[xX×]\s*\d+$/.test(v)) {
form.model_id = v.replace(/\s*[xX×]\s*/, 'x')
}
}
function normalizeComfyModelId(value) {
const v = (value || '').trim()
if (!v) return ''
if (aspectRatioOptions.includes(v)) return v
if (/^\d+\s*[xX×]\s*\d+$/.test(v)) return v.replace(/\s*[xX×]\s*/, 'x')
// 简写 1:1 / 16:9
const m = v.match(/^(\d+)\s*:\s*(\d+)$/)
if (m) {
const short = `${m[1]}:${m[2]}`
const hit = aspectRatioOptions.find(opt => opt.startsWith(short + ' '))
if (hit) return hit
}
// 历史脏数据(如误填 admin)直接清空,避免写入非法 aspect_ratio
return ''
}
const apiBasePlaceholder = computed(() => {
if (form.provider === 'dify') return 'https://api.dify.ai/v1(或自部署地址)'
if (form.provider === 'comfy') return 'http://127.0.0.1:8188'
return 'https://api.openai.com/v1'
})
function providerLabel(provider) {
if (provider === 'dify') return 'Dify'
if (provider === 'comfy') return 'ComfyUI'
return 'OpenAI 兼容'
}
function providerBadgeClass(provider) {
if (provider === 'dify') return 'badge-info'
if (provider === 'comfy') return 'badge-warn'
return 'badge-success'
}
function normalizeProvider(provider) {
if (provider === 'dify' || provider === 'comfy') return provider
return 'openai'
}
function onProviderChange() {
if (form.provider === 'comfy') {
if (!form.api_base_url || form.api_base_url.includes('openai') || form.api_base_url.includes('dify')) {
form.api_base_url = 'http://127.0.0.1:8188'
}
form.support_context = false
form.support_image = false
form.model_id = normalizeComfyModelId(form.model_id) || '1:1 (Square)'
customSizeInput.value = /^\d+x\d+$/.test(form.model_id) ? form.model_id : ''
} else if (form.provider === 'dify') {
if (!form.api_base_url || form.api_base_url.includes('8188') || form.api_base_url.includes('openai')) {
form.api_base_url = 'https://api.dify.ai/v1'
}
} else if (!form.api_base_url || form.api_base_url.includes('8188') || form.api_base_url.includes('dify')) {
form.api_base_url = 'https://api.openai.com/v1'
}
}
function parseExtraConfig(extra) {
if (!extra) return { workflow_json: '', prompt_node: '', seed_node: '', img2img_denoise: 0.45, inpaint_denoise: 0.72, refine_prompt: true, system_prompt: '' }
if (typeof extra === 'string') {
try { extra = JSON.parse(extra) } catch { return { workflow_json: '', prompt_node: '', seed_node: '', img2img_denoise: 0.45, inpaint_denoise: 0.72, refine_prompt: true, system_prompt: '' } }
}
return {
workflow_json: extra.workflow ? JSON.stringify(extra.workflow, null, 2) : '',
prompt_node: extra.prompt_node || '',
seed_node: extra.seed_node || '',
img2img_denoise: Number(extra.img2img_denoise ?? 0.45),
inpaint_denoise: Number(extra.inpaint_denoise ?? 0.72),
refine_prompt: extra.refine_prompt === undefined ? true : !!extra.refine_prompt,
system_prompt: extra.system_prompt || ''
}
}
function isUiWorkflow(obj) {
return !!(obj && typeof obj === 'object' && Array.isArray(obj.nodes) && (obj.links || obj.version || obj.last_node_id))
}
function isApiWorkflow(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false
return Object.values(obj).some(
(n) => n && typeof n === 'object' && n.class_type && n.inputs && typeof n.inputs === 'object'
)
}
function unwrapWorkflow(obj) {
if (obj?.prompt && isApiWorkflow(obj.prompt)) return obj.prompt
if (obj?.workflow && isApiWorkflow(obj.workflow)) return obj.workflow
return obj
}
function buildExtraConfig() {
if (form.provider !== 'comfy') return null
const config = {}
const raw = (form.workflow_json || '').trim()
if (raw) {
let workflow
try {
workflow = JSON.parse(raw)
} catch {
throw new Error('工作流 JSON 格式无效,请检查是否为合法 JSON')
}
workflow = unwrapWorkflow(workflow)
if (isUiWorkflow(workflow)) {
throw new Error('请导入 API Format 工作流(ComfyUI 开发者模式 → Save (API Format)),不要导入带 nodes/links 的 UI Format')
}
if (!isApiWorkflow(workflow)) {
throw new Error('工作流不是有效的 API Format(节点需包含 class_type / inputs')
}
config.workflow = workflow
}
if ((form.prompt_node || '').trim()) config.prompt_node = form.prompt_node.trim()
if ((form.seed_node || '').trim()) config.seed_node = form.seed_node.trim()
config.img2img_denoise = Math.max(0.01, Math.min(1, Number(form.img2img_denoise) || 0.45))
config.inpaint_denoise = Math.max(0.01, Math.min(1, Number(form.inpaint_denoise) || 0.72))
if ((form.system_prompt || '').trim()) config.system_prompt = form.system_prompt.trim()
config.refine_prompt = !!form.refine_prompt
return config
}
function onWorkflowFile(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result || '')
let parsed = JSON.parse(text)
parsed = unwrapWorkflow(parsed)
if (isUiWorkflow(parsed)) {
alert('这是 UI Format 工作流,不能用于后端生图。请在 ComfyUI 开启开发者模式后使用 Save (API Format) 重新导出。')
return
}
if (!isApiWorkflow(parsed)) {
alert('无法识别为 API Format 工作流')
return
}
form.workflow_json = JSON.stringify(parsed, null, 2)
} catch {
alert('无法解析该 JSON 文件')
}
}
reader.readAsText(file)
}
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 = ''
customSizeInput.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,
workflow_json: '', prompt_node: '', seed_node: '', img2img_denoise: 0.45, inpaint_denoise: 0.72, refine_prompt: true, system_prompt: ''
})
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 || ''
const extra = parseExtraConfig(m.extra_config)
const provider = normalizeProvider(m.provider)
const modelId = provider === 'comfy' ? normalizeComfyModelId(m.model_id) : (m.model_id || '')
customSizeInput.value = /^\d+x\d+$/.test(modelId) ? modelId : ''
Object.assign(form, {
name: m.name, provider, model_id: modelId, 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,
workflow_json: extra.workflow_json,
prompt_node: extra.prompt_node,
seed_node: extra.seed_node,
img2img_denoise: extra.img2img_denoise,
inpaint_denoise: extra.inpaint_denoise,
refine_prompt: extra.refine_prompt,
system_prompt: extra.system_prompt
})
showModal.value = true
}
async function saveModel() {
let extra_config = null
try {
extra_config = buildExtraConfig()
} catch (e) {
alert(e.message || '配置无效')
return
}
if (form.provider === 'comfy') {
form.model_id = normalizeComfyModelId(form.model_id)
}
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,
extra_config
}
delete payload.workflow_json
delete payload.prompt_node
delete payload.seed_node
delete payload.img2img_denoise
delete payload.inpaint_denoise
delete payload.refine_prompt
delete payload.system_prompt
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 === 'comfy') {
if (!form.api_base_url) {
testResult.value = { ok: false, message: '请填写 ComfyUI 地址' }
return
}
} else 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);
}
.workflow-json {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
min-height: 180px;
resize: vertical;
}
.workflow-actions {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.file-btn {
cursor: pointer;
display: inline-flex;
align-items: center;
}
@media (max-width: 640px) {
.form-row-2 {
grid-template-columns: 1fr;
}
}
</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>
+466
View File
@@ -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>
+152
View File
@@ -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>
+438
View File
@@ -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>
+33
View File
@@ -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
}
}
}
}
})
+2
View File
@@ -0,0 +1,2 @@
VITE_API_PROXY_TARGET=http://127.0.0.1:8080
VITE_ADMIN_URL=http://localhost:5174
+2
View File
@@ -0,0 +1,2 @@
# 生产环境:管理后台入口(同域子路径)
VITE_ADMIN_URL=/admin
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.local
+13
View File
@@ -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>
+1794
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"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": {
"@tabler/icons-vue": "^3.45.0",
"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"
}
}
+4
View File
@@ -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

+8
View File
@@ -0,0 +1,8 @@
<template>
<router-view />
<NotificationCenter />
</template>
<script setup>
import NotificationCenter from '@/components/NotificationCenter.vue'
</script>
+89
View File
@@ -0,0 +1,89 @@
import axios from 'axios'
import { getGuestKey } from '@/utils/guestSession'
const api = axios.create({
baseURL: '/api',
timeout: 60000
})
let guestRenewal = null
async function renewGuestSession() {
const guestKey = getGuestKey()
if (!guestKey) throw new Error('游客身份已失效')
if (!guestRenewal) {
guestRenewal = axios.post('/api/auth/guest', { guest_key: guestKey })
.then(res => {
const data = res.data?.data
if (!data?.token) throw new Error('游客身份续期失败')
localStorage.setItem('token', data.token)
localStorage.setItem('auth_type', 'guest')
return data.token
})
.finally(() => {
guestRenewal = null
})
}
return guestRenewal
}
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,
async err => {
if (axios.isCancel(err)) {
return Promise.reject(err)
}
const message = err.response?.data?.message || err.message || '请求失败'
if (err.response?.status === 401) {
const original = err.config || {}
const authType = localStorage.getItem('auth_type')
const requestUrl = String(original.url || '')
const isPublicAuthRequest = ['/auth/login', '/auth/register', '/auth/guest']
.some(path => requestUrl.includes(path))
if (isPublicAuthRequest) {
const wrapped = new Error(message)
wrapped.code = err.code
wrapped.status = 401
wrapped.detail = err.response?.data?.detail || ''
return Promise.reject(wrapped)
}
if (authType === 'guest' && !original._guestRetry) {
original._guestRetry = true
try {
const token = await renewGuestSession()
original.headers = original.headers || {}
original.headers.Authorization = `Bearer ${token}`
return api(original)
} catch {
// Fall through so the page can present a useful retry state.
}
}
localStorage.removeItem('token')
localStorage.removeItem('auth_type')
if (authType === 'account' && !window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
const wrapped = new Error(message)
wrapped.code = err.code
wrapped.status = err.response?.status || null
wrapped.detail = err.response?.data?.detail || ''
return Promise.reject(wrapped)
}
)
export default api
+273
View File
@@ -0,0 +1,273 @@
@import 'highlight.js/styles/github.css';
:root {
--bg-primary: #f5f7fb;
--bg-secondary: #ffffff;
--bg-tertiary: #f8fafc;
--bg-hover: #eef3f9;
--bg-soft: #f2f5f8;
--text-primary: #152033;
--text-secondary: #526077;
--text-muted: #7d899b;
--accent: #2d66da;
--accent-hover: #2458c4;
--accent-soft: #edf4ff;
--border: #e1e7ef;
--border-strong: #d4dce7;
--danger: #dc4c4c;
--sidebar-width: 268px;
--header-height: 66px;
--input-max-width: 920px;
--content-max-width: 1040px;
--radius: 18px;
--radius-sm: 12px;
--shadow: 0 18px 50px rgba(38, 59, 92, 0.08);
--shadow-soft: 0 8px 24px rgba(38, 59, 92, 0.06);
--shadow-composer: 0 14px 38px rgba(38, 59, 92, 0.09);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
min-height: 100dvh;
font-family: "Microsoft YaHei UI", "PingFang SC", "Segoe UI", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
overflow: hidden;
}
a {
color: var(--accent);
text-decoration: none;
}
button {
cursor: pointer;
border: none;
background: none;
color: inherit;
font: inherit;
}
button:focus-visible,
a:focus-visible,
select:focus-visible,
textarea:focus-visible,
input:focus-visible {
outline: 2px solid rgba(45, 102, 218, 0.72);
outline-offset: 2px;
}
input, textarea, select {
font: inherit;
color: inherit;
background: transparent;
border: none;
outline: none;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #ced7e3;
border-radius: 999px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
border-radius: 999px;
font-size: 14px;
font-weight: 500;
transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.btn:active {
transform: scale(0.97);
}
.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: #fff;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.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: 14px;
color: var(--text-secondary);
}
.form-input {
width: 100%;
padding: 12px 16px;
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
font-size: 15px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(45, 102, 218, 0.11);
}
.form-error {
color: var(--danger);
font-size: 13px;
margin-top: 12px;
}
.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;
}
.markdown-body br {
content: '';
display: block;
margin-top: 10px;
}
.markdown-body pre {
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 14px;
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: #eef2ff;
padding: 2px 6px;
border-radius: 6px;
}
.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-soft);
}
.markdown-body a {
color: var(--accent);
}
.markdown-body img {
max-width: 100%;
border-radius: 8px;
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(21, 32, 51, 0.34);
backdrop-filter: blur(2px);
z-index: 90;
}
@media (max-width: 768px) {
.sidebar-overlay.active {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
<template>
<aside class="sidebar" :class="{ open: chat.sidebarOpen }" aria-label="会话列表">
<div class="sidebar-top">
<div class="sidebar-brand">
<span class="brand-badge">AI</span>
<div class="brand-copy">
<strong>{{ settings.siteName || 'AI Chat' }}</strong>
<p>统一文本与图片创作</p>
</div>
<button class="sidebar-close" type="button" aria-label="关闭会话列表" @click="chat.closeSidebar">
<IconX :size="19" :stroke-width="1.8" />
</button>
</div>
<button class="new-chat-btn" type="button" @click="handleNewChat">
<IconPlus :size="18" :stroke-width="1.9" />
开始新对话
</button>
</div>
<div class="conversation-list">
<div class="conversation-heading">
<span>最近对话</span>
<span>{{ chat.conversations.length }}</span>
</div>
<div
v-for="conv in chat.conversations"
:key="conv.id"
class="conversation-item"
:class="{ active: conv.id === chat.currentId }"
:aria-current="conv.id === chat.currentId ? 'page' : undefined"
@click="selectConv(conv.id)"
>
<IconMessageCircle class="conversation-icon" :size="17" :stroke-width="1.7" />
<span class="conv-title">{{ conv.title }}</span>
<button
class="delete-btn"
type="button"
:aria-label="`删除对话:${conv.title}`"
@click.stop="handleDelete(conv.id)"
>
<IconTrash :size="15" :stroke-width="1.8" />
</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.isGuest ? '游客模式 · 自动保存' : (auth.user?.membership_name || '普通用户') }}</span>
</div>
</div>
</div>
</aside>
</template>
<script setup>
import { computed, watch } from 'vue'
import IconMessageCircle from '@tabler/icons-vue/dist/esm/icons/IconMessageCircle.mjs'
import IconPlus from '@tabler/icons-vue/dist/esm/icons/IconPlus.mjs'
import IconTrash from '@tabler/icons-vue/dist/esm/icons/IconTrash.mjs'
import IconX from '@tabler/icons-vue/dist/esm/icons/IconX.mjs'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useNotificationStore } from '@/stores/notification'
import { useSettingsStore } from '@/stores/settings'
const auth = useAuthStore()
const chat = useChatStore()
const notification = useNotificationStore()
const settings = useSettingsStore()
const avatarLetter = computed(() => {
const name = auth.user?.nickname || auth.user?.username || '?'
return name.charAt(0).toUpperCase()
})
watch(
() => settings.models,
(list) => {
if (!list?.length) return
if (chat.selectedModelId != null && list.some(m => Number(m.id) === Number(chat.selectedModelId))) return
const def = list.find(m => m.is_default) || list[0]
chat.selectedModelId = def?.id != null ? Number(def.id) : null
},
{ immediate: true }
)
async function handleNewChat() {
try {
await chat.createConversation(chat.selectedModelId)
chat.closeSidebar()
} catch (err) {
notification.error(err, { title: '创建对话失败' })
}
}
async function selectConv(id) {
try {
await chat.selectConversation(id)
chat.closeSidebar()
} catch (err) {
notification.error(err, { title: '加载对话失败' })
}
}
async function handleDelete(id) {
if (confirm('确定删除此对话吗?')) {
try {
await chat.deleteConversation(id)
} catch (err) {
notification.error(err, { title: '删除对话失败' })
}
}
}
</script>
<style scoped>
.sidebar {
width: var(--sidebar-width);
background: rgba(255, 255, 255, 0.94);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: transform 0.24s cubic-bezier(0.16, 1, 0.3, 1);
backdrop-filter: blur(14px);
}
.sidebar-top {
padding: 16px 14px 12px;
display: flex;
flex-direction: column;
gap: 14px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 2px;
}
.brand-badge {
width: 38px;
height: 38px;
border-radius: 12px;
background: var(--accent-soft);
border: 1px solid #d9e6fb;
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
letter-spacing: -0.02em;
flex-shrink: 0;
}
.brand-copy {
min-width: 0;
flex: 1;
}
.sidebar-brand strong {
display: block;
overflow: hidden;
color: var(--text-primary);
font-size: 14px;
font-weight: 650;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-brand p {
margin-top: 2px;
color: var(--text-muted);
font-size: 11px;
line-height: 1.3;
}
.sidebar-close {
display: none;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
width: 100%;
min-height: 42px;
padding: 0 14px;
background: var(--accent);
border-radius: 12px;
color: #fff;
font-size: 13px;
font-weight: 600;
box-shadow: 0 7px 18px rgba(45, 102, 218, 0.2);
transition: background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.new-chat-btn:hover {
background: var(--accent-hover);
box-shadow: 0 9px 22px rgba(45, 102, 218, 0.24);
}
.new-chat-btn:active {
transform: scale(0.98);
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 6px 9px 12px;
}
.conversation-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 9px 7px;
color: var(--text-muted);
font-size: 11px;
}
.conversation-item {
position: relative;
display: flex;
align-items: center;
gap: 9px;
min-height: 42px;
margin-bottom: 2px;
padding: 8px 8px 8px 10px;
border-radius: 10px;
cursor: pointer;
color: var(--text-secondary);
font-size: 13px;
transition: background 0.15s ease, color 0.15s ease;
}
.conversation-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.conversation-item.active {
background: var(--accent-soft);
box-shadow: inset 3px 0 0 var(--accent);
color: var(--text-primary);
font-weight: 500;
}
.conversation-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.conversation-item.active .conversation-icon {
color: var(--accent);
}
.conv-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
opacity: 0;
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
}
.conversation-item:hover .delete-btn,
.delete-btn:focus-visible {
opacity: 1;
}
.delete-btn:hover {
background: #fff0f0;
color: var(--danger);
}
.empty-tip {
padding: 26px 18px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
text-align: center;
}
.sidebar-footer {
padding: 10px 12px 12px;
border-top: 1px solid var(--border);
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
padding: 6px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 11px;
background: #eef1f6;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 650;
flex-shrink: 0;
}
.user-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-name {
overflow: hidden;
color: var(--text-primary);
font-size: 13px;
font-weight: 550;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-level {
margin-top: 2px;
color: var(--text-muted);
font-size: 11px;
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 100;
width: min(84vw, 304px);
transform: translateX(-100%);
box-shadow: 20px 0 50px rgba(21, 32, 51, 0.14);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-close {
display: flex;
}
.delete-btn {
opacity: 0.68;
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar,
.new-chat-btn,
.conversation-item,
.delete-btn {
transition: none;
}
}
</style>
File diff suppressed because one or more lines are too long
+798
View File
@@ -0,0 +1,798 @@
<template>
<div class="message" :class="[message.role, { streaming: isStreaming }]">
<div class="message-avatar" aria-hidden="true">
<IconUser v-if="message.role === 'user'" :size="16" :stroke-width="1.8" />
<IconSparkles v-else :size="16" :stroke-width="1.8" />
</div>
<div ref="messageBodyRef" class="message-body" :class="{ 'wide-image-message': isAssistantImageGrid || showImageSkeleton }">
<div v-if="showImageSkeleton" class="image-loading-grid" aria-label="正在生成四张图片">
<span class="generation-progress"><IconSparkles :size="15" :stroke-width="1.8" /> {{ generationProgress }}%</span>
<i v-for="index in 4" :key="index" />
</div>
<div v-if="attachments.length" class="attachments" :class="{ 'image-grid': isAssistantImageGrid }">
<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"
role="button"
tabindex="0"
:aria-label="`放大查看${att.name || '图片'}`"
@error="markBroken(i)"
@click="previewImage(att)"
@keydown.enter="previewImage(att)"
@keydown.space.prevent="previewImage(att)"
/>
<a
v-else-if="att?.type === 'image'"
:href="att.url"
target="_blank"
rel="noopener"
class="att-document"
>
<IconPhoto :size="17" :stroke-width="1.8" />
{{ 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"
rel="noopener"
class="att-document"
>
<IconFileText :size="17" :stroke-width="1.8" />
<span class="document-name">{{ att.name }}</span>
<span class="document-size">{{ formatFileSize(att.size) }}</span>
</a>
</template>
</div>
<div
v-if="message.content"
class="message-content"
:class="{ 'markdown-body': useMarkdown }"
v-html="renderedContent"
/>
<div
v-if="(message.content || firstImage) && !isStreaming"
class="message-actions"
:class="{ 'has-image': firstImage }"
>
<button
v-if="firstImage"
class="message-action-btn image-edit-btn"
type="button"
aria-label=" AI 图片工作台中编辑"
@click="previewImage(firstImage)"
>
<IconEdit :size="15" :stroke-width="1.8" />
<span>AI 编辑</span>
</button>
<button
v-if="message.content || firstImage"
class="message-action-btn"
type="button"
:aria-label="copied ? '消息图文内容已复制' : '复制消息图文内容'"
@click="copyContent"
>
<IconCheck v-if="copied" :size="15" :stroke-width="2" />
<IconCopy v-else :size="15" :stroke-width="1.8" />
<span>{{ copied ? '已复制' : '复制' }}</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, onBeforeUnmount, watch } from 'vue'
import IconCheck from '@tabler/icons-vue/dist/esm/icons/IconCheck.mjs'
import IconCopy from '@tabler/icons-vue/dist/esm/icons/IconCopy.mjs'
import IconFileText from '@tabler/icons-vue/dist/esm/icons/IconFileText.mjs'
import IconEdit from '@tabler/icons-vue/dist/esm/icons/IconEdit.mjs'
import IconPhoto from '@tabler/icons-vue/dist/esm/icons/IconPhoto.mjs'
import IconSparkles from '@tabler/icons-vue/dist/esm/icons/IconSparkles.mjs'
import IconUser from '@tabler/icons-vue/dist/esm/icons/IconUser.mjs'
import { useNotificationStore } from '@/stores/notification'
import { useSettingsStore } from '@/stores/settings'
import { renderMarkdown, formatFileSize } from '@/utils/markdown'
const props = defineProps({
message: { type: Object, required: true },
isStreaming: Boolean
})
const emit = defineEmits(['preview-image'])
const settings = useSettingsStore()
const notification = useNotificationStore()
const features = computed(() => settings.features)
const copied = ref(false)
const messageBodyRef = ref(null)
const generationProgress = ref(4)
const fourImageLoading = ref(false)
let copiedTimer = null
let progressTimer = null
onBeforeUnmount(() => {
if (copiedTimer) clearTimeout(copiedTimer)
if (progressTimer) clearInterval(progressTimer)
})
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' && !att.hidden)
})
const firstImage = computed(() => attachments.value.find(att => att?.type === 'image' && att?.url) || null)
const imageAttachments = computed(() => attachments.value.filter(att => att?.type === 'image' && att?.url))
const isAssistantImageGrid = computed(() => props.message.role === 'assistant' && imageAttachments.value.length >= 2)
const pendingImageCount = computed(() => Number(props.message.pending_image_count || 0))
const showImageSkeleton = computed(() => fourImageLoading.value && !imageAttachments.value.length)
watch(
() => [props.isStreaming, props.message.pending_job, pendingImageCount.value, props.message.content, imageAttachments.value.length],
([streaming, pending, count, content, images]) => {
if (images) {
fourImageLoading.value = false
return
}
if ((pending && Number(count) >= 4) || (streaming && /正在生成\s*4\s*张图片|正在生成图片|图片生成中/u.test(String(content || '')))) {
fourImageLoading.value = true
return
}
if (!pending && !streaming) fourImageLoading.value = false
},
{ immediate: true }
)
watch(showImageSkeleton, active => {
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
if (!active) return
generationProgress.value = 4
progressTimer = setInterval(() => {
generationProgress.value = Math.min(92, generationProgress.value + Math.max(1, Math.round((94 - generationProgress.value) * 0.08)))
}, 800)
}, { immediate: true })
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(attachment) {
if (!attachment?.url) return
emit('preview-image', {
url: attachment.url,
name: attachment.name || '图片预览'
})
}
const COPY_STYLE_PROPERTIES = [
'background-color', 'border', 'border-collapse', 'border-radius', 'box-shadow',
'color', 'display', 'font-family', 'font-size', 'font-style', 'font-weight',
'height', 'letter-spacing', 'line-height', 'list-style-position', 'list-style-type',
'margin', 'margin-bottom', 'margin-left', 'margin-right', 'margin-top', 'max-height',
'max-width', 'min-width', 'padding', 'padding-bottom', 'padding-left', 'padding-right',
'padding-top', 'text-align', 'text-decoration', 'vertical-align', 'white-space', 'width'
]
function inlineCopyStyles(source, clone) {
if (!(source instanceof Element) || !(clone instanceof Element)) return
const computedStyle = window.getComputedStyle(source)
COPY_STYLE_PROPERTIES.forEach(property => {
const value = computedStyle.getPropertyValue(property)
if (value) clone.style.setProperty(property, value)
})
clone.removeAttribute('class')
clone.removeAttribute('role')
clone.removeAttribute('tabindex')
clone.removeAttribute('aria-label')
if (clone instanceof HTMLImageElement) {
const rect = source.getBoundingClientRect()
if (rect.width > 0) clone.style.width = `${Math.round(rect.width)}px`
clone.style.height = 'auto'
clone.style.cursor = 'default'
}
const sourceChildren = Array.from(source.children)
const cloneChildren = Array.from(clone.children)
sourceChildren.forEach((child, index) => inlineCopyStyles(child, cloneChildren[index]))
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('图片读取失败'))
reader.readAsDataURL(blob)
})
}
async function fetchImageBlob(url) {
const response = await fetch(url, { credentials: 'include' })
if (!response.ok) throw new Error(`图片读取失败(${response.status}`)
const blob = await response.blob()
if (!blob.type.startsWith('image/')) throw new Error('附件不是图片')
return blob
}
function imageBlobToPng(blob) {
if (blob.type === 'image/png') return Promise.resolve(blob)
return new Promise((resolve, reject) => {
const image = new Image()
const objectUrl = URL.createObjectURL(blob)
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext('2d')
if (!context) {
URL.revokeObjectURL(objectUrl)
reject(new Error('浏览器无法转换图片'))
return
}
context.drawImage(image, 0, 0)
URL.revokeObjectURL(objectUrl)
canvas.toBlob(result => {
if (result) resolve(result)
else reject(new Error('浏览器无法转换图片'))
}, 'image/png')
}
image.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error('图片解码失败'))
}
image.src = objectUrl
})
}
function prepareRichCopyPayload() {
const sourceBody = messageBodyRef.value
const container = document.createElement('div')
container.setAttribute('data-ai-chat-message', '')
container.style.cssText = 'max-width:720px;color:#1f2937;font-family:Arial,"Microsoft YaHei",sans-serif;font-size:15px;line-height:1.75;'
if (sourceBody) {
Array.from(sourceBody.children).forEach(source => {
if (!source.classList.contains('attachments') && !source.classList.contains('message-content')) return
const clone = source.cloneNode(true)
inlineCopyStyles(source, clone)
if (source.classList.contains('attachments')) {
clone.style.display = 'block'
clone.style.width = '100%'
}
container.appendChild(clone)
})
}
if (!container.children.length && props.message.content) {
const content = document.createElement('div')
content.innerHTML = renderedContent.value
container.appendChild(content)
}
const imageJobs = Array.from(container.querySelectorAll('img')).map((image, index) => {
const attachment = imageAttachments.value[index]
const url = attachment?.url || image.getAttribute('src') || ''
const absoluteUrl = url ? new URL(url, window.location.href).href : ''
if (absoluteUrl) image.src = absoluteUrl
return { image, url: absoluteUrl }
}).filter(job => job.url)
const textParts = []
if (props.message.content) textParts.push(String(props.message.content))
if (!props.message.content && imageAttachments.value.length) {
textParts.push(...imageAttachments.value.map(att => att.name || '图片'))
}
return {
container,
text: textParts.join('\n\n'),
imageJobs
}
}
async function resolveRichCopyPayload(prepared) {
const blobs = []
await Promise.all(prepared.imageJobs.map(async ({ image, url }, index) => {
try {
const blob = await fetchImageBlob(url)
blobs[index] = blob
image.src = await blobToDataUrl(blob)
} catch {
// Keep the absolute URL so rich-text targets can still retrieve the image.
image.src = url
}
}))
return {
html: prepared.container.outerHTML,
text: prepared.text,
primaryImage: blobs.find(Boolean) || null
}
}
function legacyCopy(payload) {
const holder = document.createElement('div')
holder.contentEditable = 'true'
holder.innerHTML = payload.html
holder.style.position = 'fixed'
holder.style.left = '-10000px'
holder.style.top = '0'
holder.style.opacity = '0'
document.body.appendChild(holder)
const selection = window.getSelection()
const previousRanges = []
if (selection) {
for (let index = 0; index < selection.rangeCount; index += 1) {
previousRanges.push(selection.getRangeAt(index).cloneRange())
}
selection.removeAllRanges()
const range = document.createRange()
range.selectNodeContents(holder)
selection.addRange(range)
}
const success = document.execCommand('copy')
selection?.removeAllRanges()
previousRanges.forEach(range => selection?.addRange(range))
holder.remove()
if (!success) throw new Error('浏览器未允许复制')
}
function writeRichClipboard(prepared) {
const immediatePayload = {
html: prepared.container.outerHTML,
text: prepared.text
}
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
legacyCopy(immediatePayload)
return Promise.resolve()
}
// Start the Clipboard API call inside the click event. The payload promises may
// finish later, but the browser's transient user activation is retained.
const payloadPromise = resolveRichCopyPayload(prepared)
const representations = {
'text/html': payloadPromise.then(payload => new Blob([payload.html], { type: 'text/html' })),
'text/plain': new Blob([prepared.text], { type: 'text/plain' })
}
if (prepared.imageJobs.length) {
representations['image/png'] = payloadPromise.then(payload => {
if (!payload.primaryImage) throw new Error('图片数据读取失败')
return imageBlobToPng(payload.primaryImage)
})
}
return navigator.clipboard.write([new ClipboardItem(representations)])
}
async function copyContent() {
const text = String(props.message.content || '')
if (!text && !imageAttachments.value.length) return
let success = false
const prepared = prepareRichCopyPayload()
try {
await writeRichClipboard(prepared)
success = true
} catch {
try {
legacyCopy({ html: prepared.container.outerHTML, text: prepared.text })
success = true
} catch {
success = false
}
}
if (!success) {
notification.show({
type: 'error',
title: '复制失败',
message: '浏览器未允许访问剪贴板,请直接选中消息文字后复制。',
duration: 4500
})
return
}
copied.value = true
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copied.value = false
}, 1800)
}
</script>
<style scoped>
.message {
display: flex;
gap: 10px;
width: 100%;
max-width: var(--input-max-width);
margin: 0 auto 30px;
padding: 0 10px;
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 30px;
height: 30px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid var(--border);
}
.message.user .message-avatar {
background: #edf1f6;
color: var(--text-secondary);
}
.message.assistant .message-avatar {
background: var(--accent-soft);
border-color: #dbe7fa;
color: var(--accent);
}
.message-body {
display: flex;
flex-direction: column;
min-width: 0;
max-width: 84%;
padding-top: 1px;
}
.message.user .message-body {
max-width: 76%;
align-items: flex-end;
}
.message.assistant .message-body {
align-items: flex-start;
}
.message.assistant .message-body.wide-image-message {
width: 100%;
max-width: 100%;
}
.message-content {
display: inline-block;
max-width: 100%;
color: var(--text-primary);
font-size: 15px;
line-height: 1.75;
cursor: text;
user-select: text;
}
.message-actions {
display: flex;
width: 100%;
height: 0;
justify-content: flex-start;
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition: opacity 0.16s ease;
}
.image-edit-btn {
color: var(--accent);
background: var(--accent-soft);
}
.message-actions.has-image {
height: auto;
margin-top: 7px;
opacity: 1;
pointer-events: auto;
transform: none;
}
.message.user .message-actions {
justify-content: flex-end;
}
.message:hover .message-actions,
.message:focus-within .message-actions {
opacity: 1;
pointer-events: auto;
}
.message-action-btn {
min-height: 28px;
padding: 0 8px;
border-radius: 8px;
color: var(--text-muted);
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 550;
white-space: nowrap;
transition: color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.message-action-btn:hover,
.message-action-btn:focus-visible {
background: #fff;
color: var(--accent);
}
.message-action-btn:active {
transform: scale(0.96);
}
.message.user .message-content {
padding: 11px 14px;
background: var(--accent-soft);
border: 1px solid #dbe7fa;
border-radius: 16px 16px 5px 16px;
}
.message.assistant .message-content {
padding: 3px 1px;
}
.message.streaming .message-content::after {
content: '';
display: inline-block;
width: 2px;
height: 15px;
margin-left: 3px;
border-radius: 1px;
background: var(--accent);
vertical-align: -2px;
animation: cursor-blink 0.9s step-end infinite;
}
.message-content :deep(pre) {
background: #f6f8fb;
border-color: var(--border);
}
.message.user .message-content :deep(pre),
.message.user .message-content :deep(:not(pre) > code) {
background: rgba(255, 255, 255, 0.72);
}
.message.user .message-content :deep(a) {
color: var(--accent);
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.attachments.image-grid,
.image-loading-grid {
display: grid;
position: relative;
width: 100%;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
overflow: hidden;
border-radius: 16px;
}
.attachments.image-grid .att-image {
width: 100%;
max-width: none;
height: auto;
max-height: none;
aspect-ratio: 1 / 1;
border: 0;
border-radius: 0;
box-shadow: none;
}
.image-loading-grid i {
display: block;
aspect-ratio: 1 / 1;
background: linear-gradient(115deg, #edf2ff 8%, #f5f1ff 38%, #eaf3ff 64%, #edf2ff 92%);
background-size: 240% 100%;
animation: image-skeleton 1.8s ease-in-out infinite;
}
.generation-progress {
display: inline-flex;
position: absolute;
z-index: 1;
top: 10px;
left: 10px;
align-items: center;
gap: 4px;
padding: 5px 8px;
border: 1px solid rgba(214, 223, 241, .9);
border-radius: 8px;
background: rgba(255, 255, 255, .92);
color: #4b5565;
font-size: 12px;
font-style: normal;
font-weight: 650;
box-shadow: 0 5px 16px rgba(38, 59, 92, .08);
}
.message.user .attachments {
justify-content: flex-end;
}
.att-image {
display: block;
width: auto;
max-width: min(100%, 460px);
max-height: 520px;
border-radius: 16px;
border: 1px solid var(--border-strong);
box-shadow: 0 12px 32px rgba(38, 59, 92, 0.1);
cursor: zoom-in;
object-fit: cover;
}
.message.user .att-image {
max-width: min(100%, 300px);
max-height: 360px;
}
.att-video {
width: min(100%, 460px);
max-height: 320px;
border-radius: 16px;
}
.att-audio {
width: 100%;
max-width: 380px;
}
.att-document {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 360px;
padding: 9px 11px;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-primary);
font-size: 13px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.att-document:hover {
background: var(--bg-tertiary);
border-color: var(--border-strong);
}
.document-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-size {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
}
@keyframes cursor-blink {
0%, 45% { opacity: 1; }
46%, 100% { opacity: 0; }
}
@keyframes image-skeleton {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
@media (max-width: 768px) {
.message {
gap: 8px;
margin-bottom: 26px;
padding: 0 2px;
}
.message-avatar {
width: 28px;
height: 28px;
border-radius: 9px;
}
.message-body,
.message.user .message-body {
max-width: calc(100% - 38px);
}
.message-content {
font-size: 14px;
line-height: 1.7;
}
.message.user .message-content {
padding: 10px 12px;
}
.message-actions {
opacity: 1;
pointer-events: auto;
}
.att-image,
.message.user .att-image {
max-width: 100%;
max-height: 420px;
border-radius: 14px;
}
.attachments.image-grid,
.image-loading-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
border-radius: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.message-actions,
.message-action-btn,
.message.streaming .message-content::after {
animation: none;
}
.att-document {
transition: none;
}
.image-loading-grid i {
animation: none;
}
}
</style>
+550
View File
@@ -0,0 +1,550 @@
<template>
<div class="message-list-shell">
<div
ref="listRef"
class="message-list"
@scroll.passive="handleScroll"
@wheel.passive="handleWheel"
@touchstart.passive="handleTouchStart"
@touchmove.passive="handleTouchMove"
>
<div v-if="!visibleMessages.length && !loading && !sending && !hasStreaming" class="welcome">
<div class="welcome-panel">
<div class="welcome-icon" aria-hidden="true">
<IconSparkles :size="25" :stroke-width="1.7" />
</div>
<h2>一个输入框完成对话与图片创作</h2>
<p>描述需求即可开始使用 Agent 时会自动选择语言或图片生成模型</p>
</div>
</div>
<div v-if="loading" class="loading-state" aria-label="正在加载会话">
<span></span>
<span></span>
<span></span>
</div>
<MessageItem
v-for="msg in visibleMessages"
:key="msg.id"
:message="msg"
@preview-image="openImagePreview"
/>
<MessageItem
v-if="hasStreaming"
:message="{ role: 'assistant', content: streaming, content_type: 'mixed', attachments: streamingAttachments }"
:is-streaming="true"
@preview-image="openImagePreview"
/>
<div v-if="sending && !hasStreaming" class="typing-indicator" aria-label="AI 正在回复">
<span></span><span></span><span></span>
</div>
</div>
<Transition name="scroll-button">
<button
v-if="showScrollButton"
class="scroll-bottom-btn"
type="button"
aria-label="回到最新消息"
title="回到最新消息"
@click="resumeAutoScroll"
>
<IconArrowDown :size="18" :stroke-width="2" />
<span>回到最新</span>
</button>
</Transition>
<Teleport to="body">
<ImageWorkbench v-if="previewedImage" :image="previewedImage" @close="closeImagePreview" />
</Teleport>
</div>
</template>
<script setup>
import { ref, watch, nextTick, computed, onMounted, onBeforeUnmount } from 'vue'
import IconArrowDown from '@tabler/icons-vue/dist/esm/icons/IconArrowDown.mjs'
import IconSparkles from '@tabler/icons-vue/dist/esm/icons/IconSparkles.mjs'
import MessageItem from './MessageItem.vue'
import ImageWorkbench from './ImageWorkbench.vue'
const props = defineProps({
messages: { type: Array, default: () => [] },
loading: Boolean,
streaming: { type: String, default: '' },
streamingAttachments: { type: Array, default: () => [] },
sending: Boolean
})
const listRef = ref(null)
const autoScrollEnabled = ref(true)
const isAtBottom = ref(true)
const previewedImage = ref(null)
let previousBodyOverflow = ''
let previewLocksBody = false
let touchY = null
const BOTTOM_EPSILON = 2
const hasStreaming = computed(() => {
const hasText = !!(props.streaming && String(props.streaming).trim())
const hasImages = Array.isArray(props.streamingAttachments) && props.streamingAttachments.length > 0
return hasText || hasImages
})
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 : false
return hasContent || hasAttachments
})
})
const showScrollButton = computed(() => {
return !isAtBottom.value && (visibleMessages.value.length > 0 || hasStreaming.value)
})
function distanceFromBottom() {
const el = listRef.value
if (!el) return 0
return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop)
}
function handleScroll() {
const atBottom = distanceFromBottom() <= BOTTOM_EPSILON
isAtBottom.value = atBottom
autoScrollEnabled.value = atBottom
}
function pauseAutoScroll() {
autoScrollEnabled.value = false
isAtBottom.value = false
}
function handleWheel(event) {
if (event.deltaY < 0) pauseAutoScroll()
}
function handleTouchStart(event) {
touchY = event.touches?.[0]?.clientY ?? null
}
function handleTouchMove(event) {
const currentY = event.touches?.[0]?.clientY
if (currentY == null || touchY == null) return
if (currentY > touchY + 2) pauseAutoScroll()
touchY = currentY
}
function scrollToBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
isAtBottom.value = true
}
function resumeAutoScroll() {
autoScrollEnabled.value = true
scrollToBottom()
}
function openImagePreview(image) {
if (!image?.url) return
previewedImage.value = image
}
function closeImagePreview() {
previewedImage.value = null
}
function handlePreviewKeydown(event) {
if (event.key === 'Escape' && previewedImage.value) {
closeImagePreview()
}
}
watch(previewedImage, (image, previousImage) => {
if (image && !previousImage) {
previousBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
previewLocksBody = true
} else if (!image && previousImage && previewLocksBody) {
document.body.style.overflow = previousBodyOverflow
previewLocksBody = false
}
})
onMounted(() => {
window.addEventListener('keydown', handlePreviewKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handlePreviewKeydown)
if (previewLocksBody) {
document.body.style.overflow = previousBodyOverflow
previewLocksBody = false
}
})
watch(
() => {
const last = visibleMessages.value[visibleMessages.value.length - 1]
return [visibleMessages.value.length, last?.id, last?.role]
},
async () => {
const last = visibleMessages.value[visibleMessages.value.length - 1]
if (last?.role === 'user') {
autoScrollEnabled.value = true
}
await nextTick()
if (autoScrollEnabled.value) scrollToBottom()
}
)
watch(
() => [props.streaming, props.streamingAttachments?.length, props.sending],
async () => {
await nextTick()
if (autoScrollEnabled.value) scrollToBottom()
}
)
watch(
() => props.loading,
async (loading, previousLoading) => {
if (loading || !previousLoading) return
autoScrollEnabled.value = true
await nextTick()
scrollToBottom()
}
)
</script>
<style scoped>
.message-list-shell {
position: relative;
flex: 1;
min-height: 0;
}
.message-list {
width: 100%;
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: 24px 24px 18px;
}
.scroll-bottom-btn {
position: absolute;
left: 50%;
bottom: 12px;
z-index: 12;
min-height: 38px;
padding: 0 14px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 30px rgba(38, 59, 92, 0.14);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 600;
transform: translateX(-50%);
backdrop-filter: blur(10px);
transition: color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.scroll-bottom-btn:hover {
color: var(--accent);
border-color: rgba(45, 102, 218, 0.32);
box-shadow: 0 12px 34px rgba(38, 59, 92, 0.18);
transform: translateX(-50%) translateY(-1px);
}
.scroll-bottom-btn:active {
transform: translateX(-50%) scale(0.97);
}
.scroll-button-enter-active,
.scroll-button-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.scroll-button-enter-from,
.scroll-button-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(7px);
}
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
padding: 58px 28px 22px;
background: rgba(12, 19, 31, 0.88);
backdrop-filter: blur(14px);
}
.image-preview-stage {
display: flex;
min-width: 0;
min-height: 0;
align-items: center;
justify-content: center;
}
.image-preview-full {
display: block;
width: auto;
height: auto;
max-width: 94vw;
max-height: calc(100vh - 112px);
border-radius: 14px;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.38);
object-fit: contain;
user-select: none;
}
.image-preview-close {
position: fixed;
top: 18px;
right: 20px;
z-index: 1;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
transition: background 0.16s ease, transform 0.16s ease;
}
.image-preview-close:hover,
.image-preview-close:focus-visible {
background: rgba(255, 255, 255, 0.22);
}
.image-preview-close:active {
transform: scale(0.95);
}
.image-preview-caption {
max-width: min(80vw, 720px);
margin: 14px auto 0;
overflow: hidden;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
line-height: 1.4;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-preview-enter-active,
.image-preview-leave-active {
transition: opacity 0.2s ease;
}
.image-preview-enter-active .image-preview-full,
.image-preview-leave-active .image-preview-full {
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.image-preview-enter-from,
.image-preview-leave-to {
opacity: 0;
}
.image-preview-enter-from .image-preview-full,
.image-preview-leave-to .image-preview-full {
opacity: 0;
transform: scale(0.96);
}
.welcome {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
}
.welcome-panel {
width: min(100%, 620px);
padding: 24px 18px 30px;
text-align: center;
animation: welcome-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.welcome-icon {
width: 52px;
height: 52px;
margin: 0 auto 18px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--border-strong);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 28px rgba(38, 59, 92, 0.08);
}
.welcome h2 {
margin-bottom: 10px;
color: var(--text-primary);
font-size: clamp(23px, 2.4vw, 28px);
font-weight: 680;
letter-spacing: -0.035em;
line-height: 1.28;
}
.welcome p {
max-width: 460px;
margin: 0 auto;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.65;
}
.loading-state {
width: min(100%, 680px);
margin: 28px auto;
padding: 18px 0;
}
.loading-state span {
display: block;
height: 12px;
margin-bottom: 10px;
border-radius: 6px;
background: #e8edf3;
animation: skeleton-pulse 1.2s ease-in-out infinite;
}
.loading-state span:nth-child(1) {
width: 68%;
}
.loading-state span:nth-child(2) {
width: 88%;
}
.loading-state span:nth-child(3) {
width: 52%;
}
.typing-indicator {
display: flex;
gap: 4px;
max-width: var(--input-max-width);
margin: 0 auto;
padding: 12px 40px;
}
.typing-indicator span {
width: 7px;
height: 7px;
background: #aab5c4;
border-radius: 50%;
animation: bounce 1.2s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) {
animation-delay: -0.24s;
}
.typing-indicator span:nth-child(2) {
animation-delay: -0.12s;
}
@keyframes welcome-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.65); opacity: 0.45; }
40% { transform: scale(1); opacity: 1; }
}
@media (max-width: 768px) {
.message-list {
padding: 14px 12px 10px;
}
.scroll-bottom-btn {
bottom: 8px;
min-height: 36px;
padding: 0 12px;
}
.welcome-panel {
padding: 18px 10px 24px;
}
.welcome-icon {
width: 48px;
height: 48px;
margin-bottom: 16px;
border-radius: 15px;
}
.welcome h2 {
max-width: 330px;
margin-right: auto;
margin-left: auto;
font-size: 23px;
line-height: 1.3;
}
.welcome p {
max-width: 310px;
font-size: 13px;
}
.image-preview-overlay {
padding: 54px 12px 16px;
}
.image-preview-full {
max-width: calc(100vw - 24px);
max-height: calc(100vh - 100px);
border-radius: 10px;
}
.image-preview-close {
top: 12px;
right: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.scroll-bottom-btn,
.image-preview-overlay,
.image-preview-full,
.image-preview-close,
.welcome-panel,
.loading-state span,
.typing-indicator span {
animation: none;
}
}
</style>
@@ -0,0 +1,292 @@
<template>
<Teleport to="body">
<section class="notification-region" aria-label="系统通知" aria-live="assertive">
<TransitionGroup name="notification">
<article
v-for="item in notification.notifications"
:key="item.id"
class="notification-card"
:class="`notification-${item.type}`"
role="alert"
>
<div class="notification-icon" aria-hidden="true">
<IconAlertTriangle :size="20" :stroke-width="1.8" />
</div>
<div class="notification-content">
<div class="notification-heading">
<strong>{{ item.title }}</strong>
<button
class="notification-close"
type="button"
aria-label="关闭提示"
@click="notification.dismiss(item.id)"
>
<IconX :size="17" :stroke-width="1.8" />
</button>
</div>
<p>{{ item.message }}</p>
<details v-if="item.detail" class="notification-detail">
<summary>
<span>查看技术详情</span>
<IconChevronDown class="detail-chevron" :size="16" :stroke-width="1.8" />
</summary>
<div class="detail-body">
<pre>{{ item.detail }}</pre>
<button class="copy-detail" type="button" @click="copyDetail(item)">
<IconCheck v-if="copiedId === item.id" :size="15" :stroke-width="1.8" />
<IconCopy v-else :size="15" :stroke-width="1.8" />
{{ copiedId === item.id ? '已复制' : '复制详情' }}
</button>
</div>
</details>
</div>
</article>
</TransitionGroup>
</section>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
import IconAlertTriangle from '@tabler/icons-vue/dist/esm/icons/IconAlertTriangle.mjs'
import IconCheck from '@tabler/icons-vue/dist/esm/icons/IconCheck.mjs'
import IconChevronDown from '@tabler/icons-vue/dist/esm/icons/IconChevronDown.mjs'
import IconCopy from '@tabler/icons-vue/dist/esm/icons/IconCopy.mjs'
import IconX from '@tabler/icons-vue/dist/esm/icons/IconX.mjs'
import { useNotificationStore } from '@/stores/notification'
const notification = useNotificationStore()
const copiedId = ref(null)
let copiedTimer = null
async function copyDetail(item) {
try {
await navigator.clipboard.writeText(item.detail)
copiedId.value = item.id
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copiedId.value = null
}, 1800)
} catch {
copiedId.value = null
}
}
</script>
<style scoped>
.notification-region {
position: fixed;
top: 16px;
right: 16px;
z-index: 120;
display: flex;
width: min(420px, calc(100vw - 32px));
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.notification-card {
position: relative;
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 12px;
overflow: hidden;
padding: 14px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid var(--border-strong);
border-radius: 16px;
box-shadow: 0 18px 48px rgba(38, 59, 92, 0.16);
pointer-events: auto;
}
.notification-card::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--danger);
}
.notification-icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: #fff0f0;
color: var(--danger);
}
.notification-content {
min-width: 0;
}
.notification-heading {
display: flex;
min-height: 28px;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.notification-heading strong {
padding-top: 2px;
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.notification-close {
width: 28px;
height: 28px;
margin: -3px -4px 0 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
flex-shrink: 0;
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.notification-close:hover {
background: var(--bg-soft);
color: var(--text-primary);
}
.notification-close:active,
.copy-detail:active {
transform: scale(0.96);
}
.notification-content > p {
max-width: 36em;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
text-wrap: pretty;
}
.notification-detail {
margin-top: 10px;
border-top: 1px solid var(--border);
}
.notification-detail summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 0 1px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 550;
cursor: pointer;
list-style: none;
user-select: none;
}
.notification-detail summary::-webkit-details-marker {
display: none;
}
.detail-chevron {
transition: transform 0.18s ease;
}
.notification-detail[open] .detail-chevron {
transform: rotate(180deg);
}
.detail-body {
margin-top: 8px;
padding: 10px;
background: #f6f8fb;
border: 1px solid var(--border);
border-radius: 10px;
}
.detail-body pre {
max-height: 190px;
overflow: auto;
color: #354258;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.copy-detail {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 30px;
margin-top: 9px;
padding: 0 9px;
background: #fff;
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text-secondary);
font-size: 11px;
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.copy-detail:hover {
background: var(--bg-soft);
color: var(--text-primary);
}
.notification-enter-active,
.notification-leave-active {
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.notification-enter-from,
.notification-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
@media (max-width: 768px) {
.notification-region {
top: calc(10px + env(safe-area-inset-top));
right: 10px;
left: 10px;
width: auto;
}
.notification-card {
grid-template-columns: 34px minmax(0, 1fr);
gap: 10px;
padding: 12px;
border-radius: 14px;
}
.notification-icon {
width: 34px;
height: 34px;
border-radius: 10px;
}
.detail-body pre {
max-height: min(220px, 36dvh);
}
}
@media (prefers-reduced-motion: reduce) {
.notification-enter-active,
.notification-leave-active,
.notification-close,
.copy-detail,
.detail-chevron {
transition: none;
}
}
</style>
+10
View File
@@ -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')
+50
View File
@@ -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 && !auth.isGuest) {
next({ name: 'Chat' })
return
}
next()
})
export default router
+66
View File
@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
import { getOrCreateGuestKey } from '@/utils/guestSession'
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)
const isGuest = computed(() => !!user.value?.is_guest)
function setSession(data, type) {
token.value = data.token
user.value = data.user
localStorage.setItem('token', token.value)
localStorage.setItem('auth_type', type)
}
async function enterGuestMode() {
const res = await api.post('/auth/guest', { guest_key: getOrCreateGuestKey() })
setSession(res.data.data, 'guest')
return res.data
}
async function init() {
try {
if (token.value) {
const res = await api.get('/auth/me')
user.value = res.data.data
localStorage.setItem('auth_type', user.value?.is_guest ? 'guest' : 'account')
}
if (!user.value) {
await enterGuestMode()
}
} catch {
logout()
await enterGuestMode()
} finally {
initialized.value = true
}
}
async function login(account, password) {
const res = await api.post('/auth/login', { account, password })
setSession(res.data.data, 'account')
return res.data
}
async function register(username, email, password) {
const res = await api.post('/auth/register', { username, email, password })
setSession(res.data.data, 'account')
return res.data
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('auth_type')
}
return { token, user, initialized, isLoggedIn, isGuest, init, enterGuestMode, login, register, logout }
})
+430
View File
@@ -0,0 +1,430 @@
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
}
function hasPendingJobs(list) {
return (list || []).some(m => m && m.pending_job)
}
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 streamingAttachments = ref([])
const sidebarOpen = ref(false)
/** 侧边栏当前选中的模型,发送/新建会话都会使用它 */
const selectedModelId = ref(null)
const selectedAgentId = ref(localStorage.getItem('selected_agent_id') || null)
let pendingPollTimer = null
let pendingPollInFlight = false
function stopPendingJobPolling() {
if (pendingPollTimer) {
clearInterval(pendingPollTimer)
pendingPollTimer = null
}
pendingPollInFlight = false
}
function startPendingJobPolling() {
stopPendingJobPolling()
if (!currentId.value || !hasPendingJobs(messages.value)) return
pendingPollTimer = setInterval(async () => {
if (!currentId.value || pendingPollInFlight) return
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
return
}
pendingPollInFlight = true
try {
await loadMessages(currentId.value, { quiet: true })
} catch {
// 轮询失败时下次再试
} finally {
pendingPollInFlight = false
}
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
await fetchConversations().catch(() => {})
}
}, 4000)
}
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, options = {}) {
if (!id) return
const quiet = !!options.quiet
const res = await api.get(`/conversations/${id}/messages`)
// 切换会话后丢弃过期响应
if (id !== currentId.value) return
const list = normalizeList(res.data?.data)
messages.value = list.filter(isValidMessage)
if (!quiet) {
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
} else {
stopPendingJobPolling()
}
} else if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
} else if (!pendingPollTimer) {
startPendingJobPolling()
}
}
async function createConversation(modelId = null) {
const mid = modelId ?? selectedModelId.value
const res = await api.post('/conversations', { model_id: mid })
const conv = res.data.data
conversations.value.unshift(conv)
currentId.value = conv.id
if (conv.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
messages.value = []
stopPendingJobPolling()
return conv
}
async function selectConversation(id) {
currentId.value = id
stopPendingJobPolling()
const conv = conversations.value.find(c => c.id === id)
if (conv?.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
loading.value = true
try {
await loadMessages(id)
} finally {
loading.value = false
}
}
async function setSelectedModel(modelId) {
const mid = modelId == null || modelId === '' ? null : Number(modelId)
selectedModelId.value = mid
// 已有会话时立即切换绑定模型,否则下拉只改了 UI、实际仍走旧模型
if (!currentId.value || mid == null) return
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) === mid) return
const res = await api.put(`/conversations/${currentId.value}`, { model_id: mid })
const updated = res.data?.data
if (updated) {
conversations.value = conversations.value.map(c =>
c.id === currentId.value ? { ...c, ...updated } : c
)
} else if (conv) {
conv.model_id = mid
}
}
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 = []
stopPendingJobPolling()
}
}
function setSelectedAgent(agentId) {
selectedAgentId.value = agentId || null
if (selectedAgentId.value) {
localStorage.setItem('selected_agent_id', selectedAgentId.value)
} else {
localStorage.removeItem('selected_agent_id')
}
}
async function sendMessage(content, attachments = [], agentId = selectedAgentId.value, options = {}) {
if (sending.value) return
sending.value = true
streamingContent.value = ''
streamingAttachments.value = []
let userMsg = null
try {
if (!currentId.value) {
await createConversation(selectedModelId.value)
} else if (selectedModelId.value != null) {
// 发送前确保当前会话已绑定侧边栏所选模型
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) !== Number(selectedModelId.value)) {
await setSelectedModel(selectedModelId.value)
}
}
userMsg = {
id: Date.now(),
role: 'user',
content,
attachments,
created_at: new Date().toISOString()
}
messages.value.push(userMsg)
await streamChat(content, attachments, agentId, options)
await loadMessages()
} catch (e) {
// 连接断开/代理超时:任务可能已落库为 pending,先同步再决定是否抛错
const cancelled = e?.name === 'AbortError' || options.signal?.aborted
if (userMsg) {
messages.value = messages.value.filter(m => m.id !== userMsg.id)
}
await loadMessages().catch(() => {})
if (cancelled) throw e
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
return
}
throw e
} finally {
sending.value = false
streamingContent.value = ''
streamingAttachments.value = []
}
}
async function streamChat(content, attachments, agentId = null, options = {}) {
const token = localStorage.getItem('token')
const response = await fetch('/api/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
signal: options.signal,
body: JSON.stringify({
conversation_id: currentId.value,
content,
attachments,
agent_id: agentId || null,
image_tool: options.imageTool || null,
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 = ''
let receivedContent = ''
let gotAttachments = false
let pendingDone = false
let agentRenderVersion = 0
let agentRenderQueue = Promise.resolve()
const isAgentStream = !!agentId
const appendVisibleContent = (content) => {
if (!content) return
receivedContent += content
if (!isAgentStream) {
fullContent += content
streamingContent.value = fullContent
return
}
const version = agentRenderVersion
const characters = Array.from(content)
const frameCount = Math.min(10, Math.max(2, Math.ceil(characters.length / 8)))
const frameSize = Math.ceil(characters.length / frameCount)
agentRenderQueue = agentRenderQueue.then(async () => {
for (let index = 0; index < characters.length; index += frameSize) {
if (version !== agentRenderVersion) return
fullContent += characters.slice(index, index + frameSize).join('')
streamingContent.value = fullContent
if (index + frameSize < characters.length) {
await new Promise(resolve => setTimeout(resolve, 14))
}
}
})
}
const replaceVisibleContent = (content) => {
agentRenderVersion++
agentRenderQueue = Promise.resolve()
receivedContent = content
fullContent = content
streamingContent.value = content
}
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') {
agentRenderVersion++
throw new Error(data?.message || 'AI 请求失败')
}
if (event === 'progress' && data?.content) {
streamingContent.value = data.content
}
if (event === 'image' && Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
if (event === 'message' && data?.content) {
appendVisibleContent(data.content)
}
if (event === 'replace' && typeof data?.content === 'string') {
replaceVisibleContent(data.content)
}
if (event === 'done') {
if (data?.pending) {
pendingDone = true
if (data?.content) {
streamingContent.value = data.content
}
}
if (data?.content && !receivedContent) {
appendVisibleContent(data.content)
}
if (Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
}
}
}
await agentRenderQueue
if (pendingDone) {
await loadMessages().catch(() => {})
startPendingJobPolling()
await fetchConversations().catch(() => {})
return
}
if (!fullContent.trim() && !gotAttachments) {
// 生图可能已落库为 pending:刷新后由轮询恢复,不在此当成失败
await loadMessages().catch(() => {})
if (hasPendingJobs(messages.value) || messages.value.some(m =>
Array.isArray(m.attachments) && m.attachments.some(a => a?.type === 'image')
)) {
startPendingJobPolling()
return
}
throw new Error('AI 未返回内容,请在管理后台测试模型配置是否正确')
}
await fetchConversations()
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
function closeSidebar() {
sidebarOpen.value = false
}
return {
conversations,
currentId,
messages,
loading,
sending,
streamingContent,
streamingAttachments,
sidebarOpen,
selectedModelId,
selectedAgentId,
fetchConversations,
createConversation,
selectConversation,
setSelectedModel,
setSelectedAgent,
deleteConversation,
sendMessage,
loadMessages,
toggleSidebar,
closeSidebar
}
})
+45
View File
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { presentError } from '@/utils/errorPresentation'
let nextId = 1
export const useNotificationStore = defineStore('notification', () => {
const notifications = ref([])
const timers = new Map()
function dismiss(id) {
const timer = timers.get(id)
if (timer) clearTimeout(timer)
timers.delete(id)
notifications.value = notifications.value.filter(item => item.id !== id)
}
function show(payload) {
const id = nextId++
const item = {
id,
type: payload.type || 'error',
title: payload.title || '请求失败',
message: payload.message || '请求未能完成,请稍后重试。',
detail: payload.detail || ''
}
notifications.value = [...notifications.value.slice(-2), item]
if (payload.duration > 0) {
timers.set(id, setTimeout(() => dismiss(id), payload.duration))
}
return id
}
function error(errorValue, options = {}) {
return show({
type: 'error',
...presentError(errorValue, options),
duration: options.duration ?? 0
})
}
return { notifications, show, error, dismiss }
})
+44
View File
@@ -0,0 +1,44 @@
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 agents = 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
}
async function loadAgents() {
const res = await api.get('/agents')
agents.value = res.data.data || []
}
return { features, siteName, allowRegister, models, agents, loaded, loadPublic, loadModels, loadAgents }
})
+126
View File
@@ -0,0 +1,126 @@
function toText(value) {
if (typeof value === 'string') return value.trim()
if (value == null) return ''
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function extractRawError(error) {
if (typeof error === 'string') return error.trim()
return toText(
error?.response?.data?.message
|| error?.response?.data?.error
|| error?.detail
|| error?.message
|| error
) || '请求未能完成'
}
function parseEmbeddedJson(raw) {
const start = raw.search(/[\[{]/)
if (start < 0) return { data: null, prefix: '', formatted: '' }
const candidate = raw.slice(start).trim()
try {
const data = JSON.parse(candidate)
const prefix = raw.slice(0, start).trim().replace(/[:;]+$/, '')
const formattedJson = JSON.stringify(data, null, 2)
return {
data,
prefix,
formatted: prefix ? `${prefix}\n\n${formattedJson}` : formattedJson
}
} catch {
return { data: null, prefix: '', formatted: '' }
}
}
function findDeviceValidation(value) {
if (Array.isArray(value)) {
for (const item of value) {
const match = findDeviceValidation(item)
if (match) return match
}
return null
}
if (!value || typeof value !== 'object') return null
const info = value.extra_info
if (info?.input_name === 'device') {
const options = Array.isArray(info.input_config?.[0]) ? info.input_config[0] : []
return {
received: toText(info.received_value),
options: options.map(toText).filter(Boolean)
}
}
for (const child of Object.values(value)) {
const match = findDeviceValidation(child)
if (match) return match
}
return null
}
function humanizeComfyError(raw, parsed) {
const device = findDeviceValidation(parsed)
if (device?.received) {
const allowed = device.options.length ? `,当前可用选项为 ${device.options.join('、')}` : ''
return `工作流请求使用设备 ${device.received}${allowed}。请修改 ComfyUI 的 device 节点配置后重试。`
}
if (/value_not_in_list|Prompt outputs failed validation/i.test(raw)) {
return '工作流参数没有通过 ComfyUI 校验,请检查对应节点、模型和设备配置后重试。'
}
if (/拒绝任务|execution error|执行失败/i.test(raw)) {
return 'ComfyUI 拒绝了本次生成任务,请检查工作流和节点配置后重试。'
}
return '图片生成服务暂时无法完成任务,请检查 ComfyUI 配置后重试。'
}
function defaultTitle(raw, status) {
if (/ComfyUI|Prompt outputs failed|value_not_in_list/i.test(raw)) return '图片生成失败'
if (status === 401) return '登录状态已失效'
if (status === 403) return '没有操作权限'
if (/network|Failed to fetch|ERR_NETWORK|连接失败/i.test(raw)) return '连接失败'
if (/timeout|超时/i.test(raw)) return '请求超时'
return '请求失败'
}
function defaultMessage(raw, status, parsed) {
if (/ComfyUI|Prompt outputs failed|value_not_in_list/i.test(raw)) {
return humanizeComfyError(raw, parsed)
}
if (status === 401) return '登录信息已过期,请重新登录。'
if (status === 403) return '当前账户没有执行此操作的权限。'
if (/network|Failed to fetch|ERR_NETWORK|连接失败/i.test(raw)) {
return '暂时无法连接服务,请检查网络或服务状态后重试。'
}
if (/timeout|超时/i.test(raw)) return '服务响应时间过长,请稍后重试。'
const beforeJson = raw.split(/[\[{]/, 1)[0].trim().replace(/[:;]+$/, '')
if (beforeJson && beforeJson.length <= 120) return beforeJson
if (raw.length <= 120) return raw
return '服务返回了异常信息,请稍后重试或查看技术详情。'
}
export function presentError(error, options = {}) {
const raw = extractRawError(error)
const status = error?.status || error?.response?.status || null
const embedded = parseEmbeddedJson(raw)
const isTechnical = raw.length > 140
|| !!embedded.formatted
|| /value_not_in_list|Prompt outputs failed|stack|traceback/i.test(raw)
return {
title: options.title || defaultTitle(raw, status),
message: options.message || defaultMessage(raw, status, embedded.data),
detail: options.detail ?? (isTechnical ? (embedded.formatted || raw) : ''),
status
}
}
+17
View File
@@ -0,0 +1,17 @@
const GUEST_KEY_STORAGE = 'guest_key'
export function getGuestKey() {
const key = localStorage.getItem(GUEST_KEY_STORAGE) || ''
return /^[a-f0-9]{64}$/.test(key) ? key : ''
}
export function getOrCreateGuestKey() {
const existing = getGuestKey()
if (existing) return existing
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const key = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
localStorage.setItem(GUEST_KEY_STORAGE, key)
return key
}
+63
View File
@@ -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 }
+231
View File
@@ -0,0 +1,231 @@
<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" type="button" aria-label="打开会话列表" @click="chat.toggleSidebar">
<IconMenu2 :size="21" :stroke-width="1.8" />
</button>
<div class="chat-heading">
<span class="chat-kicker">AI 创作空间</span>
<h2 class="chat-title">{{ currentTitle }}</h2>
</div>
<div class="header-actions">
<a
v-if="auth.user?.role === 'admin'"
:href="adminUrl"
target="_blank"
rel="noopener"
class="btn btn-ghost btn-sm admin-link"
aria-label="打开管理后台"
>
<IconSettings :size="17" :stroke-width="1.8" />
<span class="action-label">后台</span>
</a>
<button
class="btn btn-ghost btn-sm"
type="button"
:aria-label="auth.isGuest ? '登录账户' : '退出登录'"
@click="handleAccountAction"
>
<IconLogin v-if="auth.isGuest" :size="17" :stroke-width="1.8" />
<IconLogout v-else :size="17" :stroke-width="1.8" />
<span class="action-label">{{ auth.isGuest ? '登录' : '退出' }}</span>
</button>
</div>
</header>
<MessageList
:messages="chat.messages"
:loading="chat.loading"
:streaming="chat.streamingContent"
:streaming-attachments="chat.streamingAttachments"
: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 { useNotificationStore } from '@/stores/notification'
import { useSettingsStore } from '@/stores/settings'
import ChatSidebar from '@/components/ChatSidebar.vue'
import MessageList from '@/components/MessageList.vue'
import ChatInput from '@/components/ChatInput.vue'
import IconLogout from '@tabler/icons-vue/dist/esm/icons/IconLogout.mjs'
import IconLogin from '@tabler/icons-vue/dist/esm/icons/IconLogin.mjs'
import IconMenu2 from '@tabler/icons-vue/dist/esm/icons/IconMenu2.mjs'
import IconSettings from '@tabler/icons-vue/dist/esm/icons/IconSettings.mjs'
const router = useRouter()
const auth = useAuthStore()
const chat = useChatStore()
const notification = useNotificationStore()
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 () => {
try {
await settings.loadPublic()
await Promise.all([settings.loadModels(), settings.loadAgents()])
await chat.fetchConversations()
} catch (err) {
notification.error(err, { title: '页面加载失败' })
}
})
async function handleSend({ content, attachments, agentId }) {
try {
await chat.sendMessage(content, attachments, agentId)
} catch (e) {
notification.error(e)
}
}
function handleAccountAction() {
if (auth.isGuest) {
router.push('/login')
return
}
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.chat-layout {
display: flex;
height: 100%;
min-height: 100dvh;
overflow: hidden;
background: linear-gradient(180deg, #f8faff 0%, var(--bg-primary) 100%);
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: transparent;
}
.chat-header {
display: flex;
align-items: center;
gap: 10px;
height: var(--header-height);
padding: 0 22px;
border-bottom: 1px solid rgba(225, 231, 239, 0.92);
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px);
flex-shrink: 0;
}
.menu-btn {
display: none;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.menu-btn:hover {
background: var(--bg-soft);
color: var(--text-primary);
}
.menu-btn:active {
transform: scale(0.96);
}
.chat-heading {
flex: 1;
min-width: 0;
}
.chat-kicker {
display: inline-block;
margin-bottom: 1px;
font-size: 10px;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.chat-title {
font-size: 16px;
font-weight: 650;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
gap: 6px;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
font-size: 13px;
}
@media (max-width: 768px) {
.chat-header {
padding: 0 10px 0 8px;
}
.menu-btn {
display: flex;
}
.chat-kicker {
display: none;
}
.chat-title {
font-size: 17px;
}
.btn-sm {
width: 36px;
min-height: 36px;
padding: 0;
border-radius: 50%;
}
.action-label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.menu-btn {
transition: none;
}
}
</style>
+117
View File
@@ -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>
+125
View File
@@ -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>
+40
View File
@@ -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'
}
})
}
}
}
}
}
})
+20
View File
@@ -0,0 +1,20 @@
{
"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",
"test:agent:100": "php backend/tests/agent_100_round_regression.php",
"test:llm:100": "php backend/tests/llm_100_round_regression.php",
"test:image:regression": "php backend/tests/comfy_image_edit_regression.php",
"test:image:integration": "php backend/tests/comfy_image_edit_integration.php",
"test:stability:1000": "powershell -ExecutionPolicy Bypass -File scripts/run-stability-suite.ps1",
"test:conversation:3000": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/run-conversation-adversarial.ps1 -Rounds 3000",
"preview:member": "npm --prefix frontend run preview",
"preview:admin": "npm --prefix frontend-admin run preview"
}
}
+20
View File
@@ -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
+87
View File
@@ -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()
}
+56
View File
@@ -0,0 +1,56 @@
param(
[ValidateScript({ $_ -ge 100 -and $_ % 100 -eq 0 })]
[int]$Rounds = 3000
)
$ErrorActionPreference = "Stop"
$utf8Encoding = New-Object System.Text.UTF8Encoding($false)
[Console]::OutputEncoding = $utf8Encoding
$OutputEncoding = $utf8Encoding
$mutex = New-Object System.Threading.Mutex($false, "Local\AiChat-Conversation-Adversarial")
$ownsMutex = $false
try {
try {
$ownsMutex = $mutex.WaitOne(0)
} catch [System.Threading.AbandonedMutexException] {
$ownsMutex = $true
}
if (!$ownsMutex) {
Write-Host ">> Another conversation adversarial run is active; skipping overlap." -ForegroundColor Yellow
exit 0
}
$root = Split-Path -Parent $PSScriptRoot
$logRoot = Join-Path $root "logs\conversation"
if (!(Test-Path $logRoot)) {
New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$logFile = Join-Path $logRoot "conversation-adversarial-$timestamp.log"
$summaryFile = Join-Path $logRoot "conversation-adversarial-$timestamp.summary.json"
$testScript = Join-Path $root "backend\tests\conversation_adversarial_regression.php"
Write-Host ">> Running $Rounds adversarial conversation rounds" -ForegroundColor Cyan
& php $testScript $Rounds $summaryFile 2>&1 | ForEach-Object {
$line = [string]$_
Write-Host $line
Add-Content -Path $logFile -Value $line -Encoding UTF8
}
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
Write-Error "Conversation adversarial test failed with exit code $exitCode. Log: $logFile"
exit $exitCode
}
Write-Host ">> Conversation adversarial test passed. Log: $logFile" -ForegroundColor Green
} finally {
if ($ownsMutex) {
$mutex.ReleaseMutex()
}
$mutex.Dispose()
}
+87
View File
@@ -0,0 +1,87 @@
param(
[ValidateRange(1, 1000000)]
[int]$Rounds = 1000
)
$ErrorActionPreference = "Stop"
$utf8Encoding = New-Object System.Text.UTF8Encoding($false)
[Console]::OutputEncoding = $utf8Encoding
$OutputEncoding = $utf8Encoding
$mutex = New-Object System.Threading.Mutex($false, "Local\AiChat-Stability-Suite")
$ownsMutex = $false
try {
try {
$ownsMutex = $mutex.WaitOne(0)
} catch [System.Threading.AbandonedMutexException] {
$ownsMutex = $true
}
if (!$ownsMutex) {
Write-Host ">> Another stability run is already active; skipping this overlapping run." -ForegroundColor Yellow
exit 0
}
$root = Split-Path -Parent $PSScriptRoot
$logRoot = Join-Path $root "logs\stability"
if (!(Test-Path $logRoot)) {
New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$logFile = Join-Path $logRoot "stability-$timestamp.log"
$summaryFile = Join-Path $logRoot "stability-$timestamp.summary.json"
function Invoke-StabilitySuite {
param(
[string]$SuiteName
)
$testScript = Join-Path $root "backend\tests\stability_1000_round.php"
Write-Host ">> Running $SuiteName suite ($Rounds rounds)" -ForegroundColor Cyan
Add-Content -Path $logFile -Value "===== $SuiteName =====" -Encoding UTF8
# Windows PowerShell wraps native stderr as ErrorRecord objects. With the
# script-wide Stop preference, a failing suite used to abort this runner
# before the exit code, remaining suites, and JSON summary were recorded.
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
& php $testScript $Rounds $SuiteName 2>&1 | ForEach-Object {
$line = [string]$_
Write-Host $line
Add-Content -Path $logFile -Value $line -Encoding UTF8
}
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
Add-Content -Path $logFile -Value "" -Encoding UTF8
return [PSCustomObject]@{
suite = $SuiteName
requestedRounds = $Rounds
exitCode = $exitCode
completedAt = (Get-Date).ToString("s")
}
}
$summary = @()
foreach ($suite in @("agent", "image", "llm")) {
$summary += Invoke-StabilitySuite -SuiteName $suite
}
$summary | ConvertTo-Json -Depth 4 | Set-Content -Path $summaryFile -Encoding UTF8
$failedSuites = @($summary | Where-Object { $_.exitCode -ne 0 })
if ($failedSuites.Count -gt 0) {
Write-Error "Stability run failed in $($failedSuites.Count) suite(s). Log: $logFile"
exit 1
}
Write-Host ">> Stability suites completed. Log: $logFile" -ForegroundColor Green
} finally {
if ($ownsMutex) {
$mutex.ReleaseMutex()
}
$mutex.Dispose()
}
View File