宇恒一号官网

This commit is contained in:
whm
2026-03-17 00:59:32 +08:00
commit eb56519df7
105 changed files with 10783 additions and 0 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# 复制为 .env 或 .env.production 后按需修改
# Go 服务不会自动加载 .env需在启动前导出变量例如
# Linux/Mac: export $(grep -v '^#' .env.production | xargs)
# Windows CMD: for /f "usebackq tokens=*" %a in (".env.production") do set %a
# Windows PowerShell: Get-Content .env.production | ForEach-Object { if ($_ -match '^([^#][^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), 'Process') } }
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=yxd-agent-testing
PORT=8080
GIN_MODE=release

13
.env.production Normal file
View File

@@ -0,0 +1,13 @@
# 生产环境配置(本地 MongoDB无需映射
# 使用方式:启动前 source .env.production 或 set -a && source .env.production && set +a
# Windows: 在 PowerShell 中可逐行 set 这些变量,或用 dotenv 等工具加载
# MongoDB本机
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=yxd-agent-testing
# 服务端口
PORT=8080
# Gin 生产模式(关闭调试信息)
GIN_MODE=release

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# 依赖与构建产物
node_modules/
web/dist/
admin/dist/
server/server
*.exe
# 环境与密钥(服务器上单独配置)
.env
server/.env
!.env.example
!server/.env.example
# 日志
logs/
*.log
# 系统与编辑器
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo
# Docker 本地卷(不提交)
# mongo_data 等由 compose 管理

50
CONFIG.md Normal file
View File

@@ -0,0 +1,50 @@
# 配置文件位置与加载方式
**对外域名:** https://yuheng.yuxindazhineng.com官网 /admin 管理后台 /api 接口均同域,由 Nginx Proxy Manager 反向代理)
---
## 一、后端API 服务)— 启动时自动读取
**配置文件:****server/.env** 一个文件。需要改配置时直接编辑该文件即可。
-`server` 目录下启动时,加载 `server/.env`
- 在项目根目录启动时,加载 `server/.env`
**变量说明:**`server/.env.example`(仅作参考,可不保留)。
---
## 二、前台(官网)— 构建时注入
**配置文件位置:**
| 环境 | 路径 | 说明 |
|------|------|------|
| 生产 | **web/.env.production** | 执行 `npm run build` 时注入 |
| 示例 | web/.env.example | 复制为 .env 或 .env.production 后修改 |
变量需以 `VITE_` 开头才会被打包进前端,如:`VITE_APP_DOMAIN``VITE_API_BASE`
---
## 三、后台(管理端)— 构建时注入
**配置文件位置:**
| 环境 | 路径 | 说明 |
|------|------|------|
| 生产 | **admin/.env.production** | 执行 `npm run build` 时注入 |
| 示例 | admin/.env.example | 复制为 .env 或 .env.production 后修改 |
变量需以 `VITE_` 开头,如:`VITE_APP_DOMAIN``VITE_API_BASE`
---
## 四、汇总表
| 端 | 配置文件 | 自动读取时机 |
|----|----------|--------------|
| 后端 | **server/.env** | **进程启动时**自动加载 |
| 前台 | **web/**.env.production | 执行 `npm run build` 时注入 |
| 后台 | **admin/**.env.production | 执行 `npm run build` 时注入 |

159
MongoDB/README.md Normal file
View File

@@ -0,0 +1,159 @@
# MongoDB 集合说明yh_web
默认数据库名:`yxd-agent-testing`(见 `server/config/constants.go`)。
以下为功能涉及的集合及文档结构,**线上已有的集合也按此结构对照**;缺失的集合可用 `create_collections.js` 创建。
---
## 1. sites站点
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| name | string | 站点名称 |
| domain | string | 域名 |
| description | string | 描述(可选) |
| created_at | string | 创建时间(如 RFC3339 |
---
## 2. pages网页属于某站点
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| site_id | string | 站点 IDObjectID 十六进制字符串) |
| slug | string | 路径标识index, about, ... |
| title | string | 标题 |
| type | string | 类型homepage / page |
| content | string | HTML 或 JSON 字符串(首页为 HomepageData JSON |
| updated_at | string | 更新时间 |
**索引建议**`{ site_id: 1, slug: 1 }` 唯一,便于按站点+slug 查首页/子页。
---
## 3. site_assets站点资源/上传文件)
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| site_id | string | 站点 ID |
| name | string | 文件名/显示名 |
| file_path | string | 相对路径 |
| size | int64 | 字节数 |
| content_type | string | MIME 类型 |
| created_at | string | 创建时间 |
**索引建议**`{ site_id: 1 }`
---
## 4. users用户
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| username | string | 用户名 |
| mobile | string | 手机号(可选) |
| email | string | 邮箱 |
| password | string | 密码哈希 |
| role | string | 角色名 |
| role_id | int | 9527=超级管理员1=普通用户 |
| is_beta | bool | 是否体验用户(可选) |
| trial_start_date | string/date | 试用开始(可选) |
| trial_end_date | string/date | 试用结束(可选) |
| lastLogin | string | 最后登录(可选) |
| llm | string | LLM 配置(可选) |
**索引建议**`{ username: 1 }` 唯一,`{ mobile: 1 }` 可选。
---
## 5. workspaces工作空间
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| name | string | 名称 |
| user_id | string | 用户 ID |
| created_at | string | 创建时间 |
**索引建议**`{ user_id: 1 }`
---
## 6. conversations对话
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| title | string | 标题 |
| user_id | string | 用户 ID |
| workspace_id | string | 工作空间 ID |
| created_at | string | 创建时间 |
| updated_at | string | 更新时间 |
**索引建议**`{ user_id: 1 }``{ workspace_id: 1 }`
---
## 7. messages消息统计用
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| conversation_id | string | 对话 ID |
| role | string | user / assistant / system |
| content | string | 内容 |
| created_at | string | 创建时间 |
**说明**:当前仅统计接口使用,若功能未实现可先建空集合。
---
## 8. files文件统计用
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectID | 主键 |
| user_id | string | 用户 ID |
| name | string | 文件名 |
| file_path | string | 存储路径 |
| size | int64 | 字节数 |
| created_at | string | 创建时间 |
**说明**:当前仅统计接口使用,若功能未实现可先建空集合。
---
## 9. system_config系统配置按 _id 区分类型)
单集合,`_id` 为字符串键,不同键对应不同配置结构。
- ** _id: "payment"** — 支付配置
- wechat: { app_id, mch_id, api_key, api_key_v3, enabled }
- alipay: { app_id, private_key, alipay_public_key, enabled }
- **_id: "sms_platform"** — 短信配置
- provider, access_key, secret_key, sign_name, template_id, enabled
**索引**:默认 _id 唯一即可。
---
## 使用方式
在项目根目录执行(需已安装 mongosh
```bash
mongosh "mongodb://localhost:27017" --file MongoDB/create_collections.js
```
或进入 mongosh 后:
```js
use("yxd-agent-testing")
// 再粘贴 create_collections.js 中的创建与索引语句
```

View File

@@ -0,0 +1,84 @@
// 创建 yh_web 所需 MongoDB 集合及索引mongosh 执行)
// 用法: mongosh "mongodb://localhost:27017" --file MongoDB/create_collections.js
// 或: mongosh "mongodb://localhost:27017" MongoDB/create_collections.js
const dbName = "yxd-agent-testing";
const db = db.getSiblingDB(dbName);
print("使用数据库: " + dbName);
// 1. sites
if (!db.getCollectionNames().includes("sites")) {
db.createCollection("sites");
print("已创建集合: sites");
}
db.sites.createIndex({ created_at: -1 }, { name: "idx_created_at", background: true });
// 2. pages
if (!db.getCollectionNames().includes("pages")) {
db.createCollection("pages");
print("已创建集合: pages");
}
db.pages.createIndex(
{ site_id: 1, slug: 1 },
{ unique: true, name: "idx_site_slug", background: true }
);
// 3. site_assets
if (!db.getCollectionNames().includes("site_assets")) {
db.createCollection("site_assets");
print("已创建集合: site_assets");
}
db.site_assets.createIndex({ site_id: 1 }, { name: "idx_site_id", background: true });
// 4. users
if (!db.getCollectionNames().includes("users")) {
db.createCollection("users");
print("已创建集合: users");
}
db.users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true });
db.users.createIndex({ mobile: 1 }, { name: "idx_mobile", background: true, sparse: true });
// 5. workspaces
if (!db.getCollectionNames().includes("workspaces")) {
db.createCollection("workspaces");
print("已创建集合: workspaces");
}
db.workspaces.createIndex({ user_id: 1 }, { name: "idx_user_id", background: true });
// 6. conversations
if (!db.getCollectionNames().includes("conversations")) {
db.createCollection("conversations");
print("已创建集合: conversations");
}
db.conversations.createIndex({ user_id: 1 }, { name: "idx_user_id", background: true });
db.conversations.createIndex({ workspace_id: 1 }, { name: "idx_workspace_id", background: true });
// 7. messages统计用功能未实现也可先建
if (!db.getCollectionNames().includes("messages")) {
db.createCollection("messages");
print("已创建集合: messages");
}
db.messages.createIndex({ conversation_id: 1 }, { name: "idx_conversation_id", background: true });
// 8. files统计用功能未实现也可先建
if (!db.getCollectionNames().includes("files")) {
db.createCollection("files");
print("已创建集合: files");
}
db.files.createIndex({ user_id: 1 }, { name: "idx_user_id", background: true });
// 9. system_config支付、短信等配置_id 为字符串键)
if (!db.getCollectionNames().includes("system_config")) {
db.createCollection("system_config");
print("已创建集合: system_config");
}
// 10. role_permissions角色权限role_id -> permissions 数组)
if (!db.getCollectionNames().includes("role_permissions")) {
db.createCollection("role_permissions");
print("已创建集合: role_permissions");
}
db.role_permissions.createIndex({ role_id: 1 }, { unique: true, name: "idx_role_id", background: true });
print("集合与索引处理完成。");

45
README.md Normal file
View File

@@ -0,0 +1,45 @@
# 多站点管理后台
前后端分离的多站点管理系统。
## 项目结构
```
yh_web/
├── admin/ # 管理后台前端 (Vue 3 + Element Plus) - 端口 3000
├── web/ # 前台页面 (Vue 3) - 端口 3001
├── server/ # 后端 API (Go + Gin) - 端口 8080
└── README.md
```
## 快速启动
### 1. 启动后端
```bash
cd server
go mod tidy
go run main.go
```
### 2. 启动管理后台
```bash
cd admin
npm install
npm run dev
```
### 3. 启动前台
```bash
cd web
npm install
npm run dev
```
## 访问地址
- 管理后台: http://localhost:3000
- 前台: http://localhost:3001
- API: http://localhost:8080

5
admin/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# 复制为 .env 或 .env.production 后修改
# 后台(管理端)环境变量 - 仅 VITE_ 前缀会在构建时注入
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
VITE_API_BASE=

6
admin/.env.production Normal file
View File

@@ -0,0 +1,6 @@
# 后台(管理端)生产环境 - 对外域名
# 构建时生效npm run build
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
# 与官网同域,接口走 /api留空即可
VITE_API_BASE=

12
admin/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
RUN echo 'server { listen 80; location /admin/ { alias /usr/share/nginx/html/; try_files $uri $uri/ /admin/index.html; } }' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
admin/README.md Normal file
View File

@@ -0,0 +1,12 @@
# 多站点管理后台 - 前端
基于 Vue 3 + Vite + Element Plus 的管理后台。
## 运行
```bash
npm install
npm run dev
```
默认端口 3000API 代理到 8080

12
admin/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>多站点管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1694
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
admin/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "yh-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"element-plus": "^2.4.4",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.10"
}
}

20
admin/src/App.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
}
</style>

73
admin/src/api/admin.js Normal file
View File

@@ -0,0 +1,73 @@
import request from './request'
// 登录
export const login = (data) => request.post('/admin/login', data)
// 当前用户权限(用于菜单与路由)
export const getMyPermissions = () => request.get('/admin/my-permissions')
// 角色权限管理
export const getRolePermissionsList = () => request.get('/admin/role-permissions')
export const updateRolePermissions = (roleId, data) => request.put(`/admin/role-permissions/${roleId}`, data)
// 后台注册(手机号+验证码)
export const sendCode = (mobile) => request.post('/admin/send-code', { mobile })
export const register = (data) => request.post('/admin/register', data)
// 密码找回
export const resetPassword = (data) => request.post('/admin/reset-password', data)
// 统计
export const getStats = () => request.get('/admin/stats')
// 用户
export const getUsers = (params) => request.get('/admin/users', { params })
export const getUserById = (id) => request.get(`/admin/users/${id}`)
export const createUser = (data) => request.post('/admin/users', data)
export const updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
// 工作空间
export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
// 对话
export const getConversations = (params) => request.get('/admin/conversations', { params })
// 短信平台配置
export const getSMSConfig = () => request.get('/admin/sms-config')
export const updateSMSConfig = (data) => request.put('/admin/sms-config', data)
// 支付配置(微信、支付宝)
export const getPaymentConfig = () => request.get('/admin/payment-config')
export const updatePaymentConfig = (data) => request.put('/admin/payment-config', data)
// 官网站点
export const getOfficialSite = () => request.get('/admin/system/official-site')
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
// 站点管理
export const getSites = () => request.get('/admin/sites')
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
export const createSite = (data) => request.post('/admin/sites', data)
export const updateSite = (id, data) => request.put(`/admin/sites/${id}`, data)
export const deleteSite = (id) => request.delete(`/admin/sites/${id}`)
// 网页管理
export const getPages = (params) => request.get('/admin/pages', { params })
export const getPageById = (id) => request.get(`/admin/pages/${id}`)
export const createPage = (data) => request.post('/admin/pages', data)
export const updatePage = (id, data) => request.put(`/admin/pages/${id}`, data)
export const deletePage = (id) => request.delete(`/admin/pages/${id}`)
// 首页编辑与下载
export const getHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage`)
export const updateHomepage = (siteId, data) => request.put(`/admin/sites/${siteId}/homepage`, data)
export const downloadHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage/download`, { responseType: 'blob' })
// 功能模块上传
export const getSiteAssets = (siteId) => request.get(`/admin/sites/${siteId}/assets`)
export const uploadSiteAsset = (siteId, file) => {
const form = new FormData()
form.append('file', file)
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)

34
admin/src/api/request.js Normal file
View File

@@ -0,0 +1,34 @@
import axios from 'axios'
import { useAuthStore } from '../stores/auth'
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
const request = axios.create({
baseURL: apiBase ? `${apiBase}/api` : '/api',
timeout: 15000
})
request.interceptors.request.use((config) => {
const token = useAuthStore().getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
request.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
useAuthStore().logout()
window.location.pathname = '/admin/login'
return Promise.reject(new Error('登录已过期,请重新登录'))
}
let msg = err.response?.data?.error || err.message || '请求失败'
if (err.response?.data?.debug) {
msg += ' (' + err.response.data.debug + ')'
}
return Promise.reject(new Error(msg))
}
)
export default request

View File

@@ -0,0 +1,112 @@
<template>
<el-container class="admin-layout">
<el-aside width="200px" class="sidebar">
<div class="logo">管理后台</div>
<el-menu
:default-active="$route.path"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span>多站点管理后台</span>
<div class="header-right">
<span class="username">{{ authStore.getUser()?.username }}</span>
<el-button link type="danger" @click="handleLogout">退出</el-button>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
import { getMyPermissions } from '../api/admin'
const router = useRouter()
const authStore = useAuthStore()
onMounted(async () => {
if (authStore.isLoggedIn()) {
try {
const res = await getMyPermissions()
authStore.setPermissions(res.permissions || [])
} catch (_) {}
}
})
const menuItems = computed(() => {
const hasPermission = (key) => !key || authStore.hasPermission(key)
const all = [
{ index: '/', title: '控制台', icon: House, permission: null },
{ index: '/users', title: '用户管理', icon: User, permission: 'user:manage' },
{ index: '/workspaces', title: '工作空间', icon: Folder, permission: 'workspace:manage' },
{ index: '/conversations', title: '对话管理', icon: ChatDotRound, permission: 'conversation:manage' },
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
{ index: '/module-upload', title: '功能模块上传', icon: Upload, permission: 'module:upload' },
{ index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' }
]
return all.filter((item) => hasPermission(item.permission))
})
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.admin-layout {
height: 100vh;
}
.sidebar {
background: #304156;
}
.logo {
height: 50px;
line-height: 50px;
text-align: center;
color: #fff;
font-weight: bold;
background: #263445;
}
.header {
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.username {
color: #666;
font-size: 14px;
}
.main {
background: #f0f2f5;
padding: 20px;
}
</style>

11
admin/src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './utils/disable-debug'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.mount('#app')

111
admin/src/router/index.js Normal file
View File

@@ -0,0 +1,111 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { title: '登录', public: true }
},
{
path: '/',
component: () => import('../layouts/AdminLayout.vue'),
children: [
{
path: '',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { title: '控制台' }
},
{
path: 'users',
name: 'Users',
component: () => import('../views/users/UserList.vue'),
meta: { title: '用户管理', permission: 'user:manage' }
},
{
path: 'workspaces',
name: 'Workspaces',
component: () => import('../views/workspaces/WorkspaceList.vue'),
meta: { title: '工作空间', permission: 'workspace:manage' }
},
{
path: 'conversations',
name: 'Conversations',
component: () => import('../views/conversations/ConversationList.vue'),
meta: { title: '对话管理', permission: 'conversation:manage' }
},
{
path: 'sms-config',
name: 'SMSConfig',
component: () => import('../views/settings/SMSConfig.vue'),
meta: { title: '短信平台配置', permission: 'sms_config' }
},
{
path: 'payment-config',
name: 'PaymentConfig',
component: () => import('../views/settings/PaymentConfig.vue'),
meta: { title: '支付配置', permission: 'payment_config' }
},
{
path: 'sites',
name: 'Sites',
component: () => import('../views/sites/SiteList.vue'),
meta: { title: '站点管理', permission: 'site:manage' }
},
{
path: 'pages',
name: 'Pages',
component: () => import('../views/sites/PageList.vue'),
meta: { title: '网页管理', permission: 'page:manage' }
},
{
path: 'homepage-edit',
name: 'HomepageEdit',
component: () => import('../views/sites/HomepageEdit.vue'),
meta: { title: '首页编辑', permission: 'homepage:edit' }
},
{
path: 'module-upload',
name: 'ModuleUpload',
component: () => import('../views/sites/ModuleUpload.vue'),
meta: { title: '功能模块上传', permission: 'module:upload' }
},
{
path: 'role-permissions',
name: 'RolePermissions',
component: () => import('../views/settings/RolePermissions.vue'),
meta: { title: '角色权限管理', permission: 'role:permission' }
}
]
}
]
const router = createRouter({
history: createWebHistory('/admin/'),
routes
})
router.beforeEach((to, from, next) => {
const auth = useAuthStore()
if (to.meta.public) {
if (auth.isLoggedIn() && to.path === '/login') {
next('/')
} else {
next()
}
return
}
if (!auth.isLoggedIn()) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
if (to.meta.permission && !auth.hasPermission(to.meta.permission)) {
next('/')
return
}
next()
})
export default router

60
admin/src/stores/auth.js Normal file
View File

@@ -0,0 +1,60 @@
import { ref } from 'vue'
const TOKEN_KEY = 'admin_token'
const USER_KEY = 'admin_user'
const permissions = ref([])
export function useAuthStore() {
const getToken = () => localStorage.getItem(TOKEN_KEY)
const getUser = () => {
try {
const s = localStorage.getItem(USER_KEY)
return s ? JSON.parse(s) : null
} catch {
return null
}
}
const setToken = (token) => {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
const setUser = (user) => {
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
else localStorage.removeItem(USER_KEY)
}
const getPermissions = () => permissions.value
const setPermissions = (arr) => {
permissions.value = Array.isArray(arr) ? arr : []
}
const logout = () => {
setToken(null)
setUser(null)
setPermissions([])
}
const isLoggedIn = () => !!getToken()
const hasPermission = (key) => {
if (!key) return true
const user = getUser()
if (user && user.role_id === 9527) return true
return permissions.value.includes(key)
}
return {
getToken,
getUser,
setToken,
setUser,
getPermissions,
setPermissions,
hasPermission,
logout,
isLoggedIn
}
}

View File

@@ -0,0 +1,18 @@
/**
* 禁止调试模式及右键 - 全局安全模块
* 在页面加载时立即执行
*/
// 禁止右键
document.addEventListener('contextmenu', (e) => e.preventDefault())
// 禁止 F12、Ctrl+Shift+I/J/C 等开发者工具快捷键
document.addEventListener('keydown', (e) => {
if (
e.key === 'F12' ||
(e.ctrlKey && e.shiftKey && ['I', 'J', 'C'].includes(e.key.toUpperCase())) ||
(e.ctrlKey && e.key === 'U')
) {
e.preventDefault()
}
})

View File

@@ -0,0 +1,88 @@
<template>
<div class="dashboard" v-loading="loading" element-loading-text="加载中...">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.users }}</div>
<div class="stat-label">用户数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.workspaces }}</div>
<div class="stat-label">工作空间</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.conversations }}</div>
<div class="stat-label">对话数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.messages }}</div>
<div class="stat-label">消息数</div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 20px">
<template #header>
<span>快捷入口</span>
</template>
<el-space wrap>
<el-button type="primary" @click="$router.push('/users')">用户管理</el-button>
<el-button @click="$router.push('/workspaces')">工作空间</el-button>
<el-button @click="$router.push('/conversations')">对话管理</el-button>
</el-space>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { getStats } from '../api/admin'
const stats = reactive({
users: 0,
workspaces: 0,
conversations: 0,
messages: 0,
files: 0
})
const loading = ref(true)
const fetchStats = async () => {
loading.value = true
try {
const res = await getStats()
Object.assign(stats, res)
} catch (e) {
console.error('获取统计失败:', e)
// 即使失败也显示 0不阻塞页面
} finally {
loading.value = false
}
}
onMounted(fetchStats)
</script>
<style scoped>
.stat-card {
text-align: center;
padding: 20px 0;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #409eff;
}
.stat-label {
margin-top: 8px;
color: #666;
font-size: 14px;
}
</style>

294
admin/src/views/Login.vue Normal file
View File

@@ -0,0 +1,294 @@
<template>
<div class="login-page">
<div class="login-box">
<h1>管理后台</h1>
<el-tabs v-model="activeTab" class="login-tabs">
<el-tab-pane label="登录" name="login">
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" style="width: 100%">
登录
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="密码找回" name="reset">
<el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" @submit.prevent="handleReset">
<el-form-item prop="mobile">
<el-input v-model="resetForm.mobile" placeholder="手机号" size="large" maxlength="11" />
</el-form-item>
<el-form-item prop="code">
<div class="code-row">
<el-input v-model="resetForm.code" placeholder="验证码" size="large" maxlength="6" style="flex: 1" />
<el-button size="large" :disabled="resetCountdown > 0" @click="handleResetSendCode">
{{ resetCountdown > 0 ? `${resetCountdown}s` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item prop="newPassword">
<el-input v-model="resetForm.newPassword" type="password" placeholder="新密码至少6位" size="large" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="resetLoading" @click="handleReset" style="width: 100%">
重置密码
</el-button>
</el-form-item>
</el-form>
<p class="tip">测试验证码8888</p>
</el-tab-pane>
<el-tab-pane label="注册" name="register">
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" @submit.prevent="handleRegister">
<el-form-item prop="mobile">
<el-input v-model="registerForm.mobile" placeholder="手机号" size="large" maxlength="11" />
</el-form-item>
<el-form-item prop="code">
<div class="code-row">
<el-input v-model="registerForm.code" placeholder="验证码" size="large" maxlength="6" style="flex: 1" />
<el-button size="large" :disabled="countdown > 0" @click="handleSendCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="registerForm.password" type="password" placeholder="密码至少6位" size="large" show-password />
</el-form-item>
<el-form-item>
<el-input v-model="registerForm.username" placeholder="用户名(可选,默认手机号)" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="registerForm.email" placeholder="邮箱(可选)" size="large" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="registerLoading" @click="handleRegister" style="width: 100%">
注册
</el-button>
</el-form-item>
</el-form>
<p class="tip">测试验证码8888</p>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login, sendCode, register, resetPassword, getMyPermissions } from '../api/admin'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref(null)
const registerFormRef = ref(null)
const resetFormRef = ref(null)
const loading = ref(false)
const registerLoading = ref(false)
const resetLoading = ref(false)
const countdown = ref(0)
const resetCountdown = ref(0)
const activeTab = ref('login')
const form = reactive({
username: '',
password: ''
})
const registerForm = reactive({
mobile: '',
code: '',
password: '',
username: '',
email: ''
})
const resetForm = reactive({
mobile: '',
code: '',
newPassword: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const registerRules = {
mobile: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' }
],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' }
]
}
const resetRules = {
mobile: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' }
],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' }
]
}
const handleLogin = async () => {
await formRef.value?.validate()
loading.value = true
try {
const res = await login(form)
authStore.setToken(res.token)
authStore.setUser(res.user)
try {
const permRes = await getMyPermissions()
authStore.setPermissions(permRes.permissions || [])
} catch (_) {}
ElMessage.success('登录成功')
const redirect = router.currentRoute.value.query.redirect || '/'
router.replace(redirect)
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const handleSendCode = async () => {
await registerFormRef.value?.validateField('mobile').catch(() => {})
if (!registerForm.mobile || !/^1\d{10}$/.test(registerForm.mobile)) return
try {
await sendCode(registerForm.mobile)
ElMessage.success('验证码已发送(测试请输入 8888')
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '发送失败')
}
}
const handleResetSendCode = async () => {
await resetFormRef.value?.validateField('mobile').catch(() => {})
if (!resetForm.mobile || !/^1\d{10}$/.test(resetForm.mobile)) return
try {
await sendCode(resetForm.mobile)
ElMessage.success('验证码已发送(测试请输入 8888')
resetCountdown.value = 60
const timer = setInterval(() => {
resetCountdown.value--
if (resetCountdown.value <= 0) clearInterval(timer)
}, 1000)
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '发送失败')
}
}
const handleReset = async () => {
await resetFormRef.value?.validate()
resetLoading.value = true
try {
await resetPassword({
mobile: resetForm.mobile,
code: resetForm.code,
new_password: resetForm.newPassword
})
ElMessage.success('密码已重置,请登录')
activeTab.value = 'login'
form.username = resetForm.mobile
resetForm.mobile = ''
resetForm.code = ''
resetForm.newPassword = ''
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '重置失败')
} finally {
resetLoading.value = false
}
}
const handleRegister = async () => {
await registerFormRef.value?.validate()
registerLoading.value = true
try {
await register({
mobile: registerForm.mobile,
code: registerForm.code,
password: registerForm.password,
username: registerForm.username || undefined,
email: registerForm.email || undefined
})
ElMessage.success('注册成功,请登录')
activeTab.value = 'login'
form.username = registerForm.username || registerForm.mobile
registerForm.mobile = ''
registerForm.code = ''
registerForm.password = ''
registerForm.username = ''
registerForm.email = ''
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '注册失败')
} finally {
registerLoading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.login-box h1 {
margin-bottom: 20px;
text-align: center;
font-size: 24px;
color: #333;
}
.login-tabs {
margin-top: 0;
}
.login-tabs :deep(.el-tabs__header) {
margin-bottom: 20px;
}
.login-tabs :deep(.el-tabs__item) {
font-size: 15px;
}
.login-tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.code-row {
display: flex;
gap: 12px;
width: 100%;
}
.tip {
margin-top: -8px;
font-size: 12px;
color: #999;
text-align: center;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="conversation-list">
<el-card>
<template #header>
<span>对话管理</span>
</template>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column label="ID" width="200">
<template #default="{ row }">{{ row._id || row.id }}</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="user_id" label="用户ID" width="200" />
<el-table-column prop="workspace_id" label="工作空间ID" width="200" />
<el-table-column prop="created_at" label="创建时间" width="180" />
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="fetchList"
@size-change="fetchList"
style="margin-top: 16px"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getConversations } from '../../api/admin'
const list = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const fetchList = async () => {
loading.value = true
try {
const res = await getConversations({ page: page.value, page_size: pageSize.value })
list.value = res.list || []
total.value = res.total || 0
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
onMounted(fetchList)
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="payment-config">
<el-card>
<template #header>
<span>支付配置</span>
</template>
<el-form
v-if="canManage"
ref="formRef"
label-width="140px"
style="max-width: 640px"
>
<el-divider content-position="left">微信支付</el-divider>
<el-form-item label="AppID">
<el-input v-model="form.wechat.app_id" placeholder="微信应用 AppID" />
</el-form-item>
<el-form-item label="商户号">
<el-input v-model="form.wechat.mch_id" placeholder="商户号 MchID" />
</el-form-item>
<el-form-item label="API 密钥">
<el-input v-model="form.wechat.api_key" type="password" placeholder="API 密钥(v2)" show-password />
</el-form-item>
<el-form-item label="APIv3 密钥">
<el-input v-model="form.wechat.api_key_v3" type="password" placeholder="APIv3 密钥(可选)" show-password />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.wechat.enabled" />
</el-form-item>
<el-divider content-position="left">支付宝</el-divider>
<el-form-item label="AppID">
<el-input v-model="form.alipay.app_id" placeholder="支付宝应用 AppID" />
</el-form-item>
<el-form-item label="应用私钥">
<el-input v-model="form.alipay.private_key" type="textarea" :rows="3" placeholder="应用私钥(支持多行)" />
</el-form-item>
<el-form-item label="支付宝公钥">
<el-input v-model="form.alipay.alipay_public_key" type="textarea" :rows="3" placeholder="支付宝公钥(支持多行)" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.alipay.enabled" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { getPaymentConfig, updatePaymentConfig } from '../../api/admin'
import { useAuthStore } from '../../stores/auth'
const authStore = useAuthStore()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
wechat: {
app_id: '',
mch_id: '',
api_key: '',
api_key_v3: '',
enabled: false
},
alipay: {
app_id: '',
private_key: '',
alipay_public_key: '',
enabled: false
}
})
const canManage = computed(() => {
const user = authStore.getUser()
if (!user) return false
return user.role_id === 9527 && user.role === 'admin'
})
const fetchConfig = async () => {
if (!canManage.value) return
try {
const res = await getPaymentConfig()
if (res.wechat) {
form.wechat = { ...form.wechat, ...res.wechat }
}
if (res.alipay) {
form.alipay = { ...form.alipay, ...res.alipay }
}
} catch (e) {
ElMessage.error(e.message || '获取配置失败')
}
}
const handleSave = async () => {
loading.value = true
try {
await updatePaymentConfig({
wechat: form.wechat,
alipay: form.alipay
})
ElMessage.success('保存成功')
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '保存失败')
} finally {
loading.value = false
}
}
onMounted(fetchConfig)
</script>
<style scoped>
.payment-config {
padding: 0;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="role-permissions">
<el-card>
<template #header>
<div class="card-header">
<span>角色权限管理</span>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</div>
</template>
<p class="tip">超级管理员(9527)拥有全部权限且不可修改为其他角色勾选其可用的后台权限</p>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="role_name" label="角色" width="140" />
<el-table-column prop="role_id" label="role_id" width="100" />
<el-table-column label="权限">
<template #default="{ row }">
<span v-if="row.role_id === 9527" class="perm-all">全部权限不可修改</span>
<div v-else class="perm-checkboxes">
<el-checkbox
v-for="p in allPermissions"
:key="p.key"
v-model="row._checked[p.key]"
style="margin-right: 16px; margin-bottom: 8px"
>
{{ p.name }}
</el-checkbox>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getRolePermissionsList, updateRolePermissions } from '../../api/admin'
const list = ref([])
const allPermissions = ref([])
const loading = ref(false)
const saving = ref(false)
function buildChecked(permissions) {
const o = {}
allPermissions.value.forEach((p) => {
o[p.key] = permissions.includes(p.key)
})
return o
}
const fetchList = async () => {
loading.value = true
try {
const res = await getRolePermissionsList()
allPermissions.value = res.all_permissions || []
list.value = (res.list || []).map((r) => ({
...r,
_checked: buildChecked(r.permissions || [])
}))
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const handleSave = async () => {
saving.value = true
try {
for (const row of list.value) {
if (row.role_id === 9527) continue
const permissions = allPermissions.value.filter((p) => row._checked[p.key]).map((p) => p.key)
await updateRolePermissions(row.role_id, { permissions })
}
ElMessage.success('保存成功')
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
} finally {
saving.value = false
}
}
onMounted(fetchList)
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.tip {
color: #666;
font-size: 13px;
margin-bottom: 16px;
}
.perm-checkboxes {
display: flex;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="sms-config">
<el-card>
<template #header>
<span>短信平台配置</span>
</template>
<el-form
v-if="isSuperUser"
ref="formRef"
:model="form"
label-width="120px"
style="max-width: 600px"
>
<el-form-item label="服务商">
<el-select v-model="form.provider" placeholder="请选择">
<el-option label="阿里云" value="aliyun" />
<el-option label="腾讯云" value="tencent" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="AccessKey">
<el-input v-model="form.access_key" placeholder="AccessKey" show-password />
</el-form-item>
<el-form-item label="SecretKey">
<el-input v-model="form.secret_key" placeholder="SecretKey" show-password />
</el-form-item>
<el-form-item label="签名">
<el-input v-model="form.sign_name" placeholder="短信签名" />
</el-form-item>
<el-form-item label="模板ID">
<el-input v-model="form.template_id" placeholder="验证码模板ID" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { getSMSConfig, updateSMSConfig } from '../../api/admin'
import { useAuthStore } from '../../stores/auth'
const authStore = useAuthStore()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
provider: '',
access_key: '',
secret_key: '',
sign_name: '',
template_id: '',
enabled: false
})
const isSuperUser = computed(() => {
const user = authStore.getUser()
if (!user) return false
return user.role_id === 9527 && user.role === 'admin'
})
const fetchConfig = async () => {
if (!isSuperUser.value) return
try {
const res = await getSMSConfig()
Object.assign(form, res)
} catch (e) {
if (e.response?.status === 403) {
ElMessage.warning('仅超级用户可配置')
} else {
ElMessage.error(e.message || '获取配置失败')
}
}
}
const handleSave = async () => {
loading.value = true
try {
await updateSMSConfig(form)
ElMessage.success('保存成功')
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '保存失败')
} finally {
loading.value = false
}
}
onMounted(fetchConfig)
</script>
<style scoped>
.sms-config {
padding: 0;
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div class="homepage-edit">
<el-card>
<template #header>
<div class="card-header">
<span>首页编辑与下载</span>
<div>
<el-select v-model="siteId" placeholder="选择站点" filterable style="width: 220px; margin-right: 12px" @change="fetchData">
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-button type="primary" :loading="saving" :disabled="!siteId" @click="handleSave">保存</el-button>
<el-button type="success" :loading="downloading" :disabled="!siteId" @click="handleDownload">下载首页 HTML</el-button>
</div>
</div>
</template>
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" style="max-width: 720px">
<el-divider content-position="left">导航与标题</el-divider>
<el-form-item label="Logo 文案">
<el-input v-model="form.logo_text" placeholder="YUHENG ONE" />
</el-form-item>
<el-form-item label="主标题">
<el-input v-model="form.title" placeholder="宇恒一号" />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="form.subtitle" placeholder="INTERSTELLAR EXPLORER EDITION" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="支持换行,会显示在首页" />
</el-form-item>
<el-form-item label="导航链接">
<div v-for="(link, i) in form.nav_links" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px">
<el-input v-model="link.label" placeholder="Label" style="width: 120px" />
<el-input v-model="link.url" placeholder="URL" style="flex: 1" />
<el-button link type="danger" @click="form.nav_links.splice(i, 1)">删除</el-button>
</div>
<el-button link type="primary" @click="form.nav_links.push({ label: '', url: '#' })">+ 添加链接</el-button>
</el-form-item>
<el-divider content-position="left">下载按钮</el-divider>
<el-form-item label="按钮文案">
<el-input v-model="form.download_text" placeholder="START EXPLORING" />
</el-form-item>
<el-form-item label="按钮链接">
<el-input v-model="form.download_url" placeholder="#" />
</el-form-item>
<el-divider content-position="left">平台轨道</el-divider>
<el-form-item label="平台列表">
<div v-for="(p, i) in form.platforms" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px">
<el-input v-model="p.name" placeholder="如 WINDOWS" style="width: 140px" />
<el-input v-model="p.url" placeholder="链接" style="flex: 1" />
<el-button link type="danger" @click="form.platforms.splice(i, 1)">删除</el-button>
</div>
<el-button link type="primary" @click="form.platforms.push({ name: '', url: '#' })">+ 添加平台</el-button>
</el-form-item>
<el-divider content-position="left">版本与徽章</el-divider>
<el-form-item label="版本">
<el-input v-model="form.version" placeholder="VERSION 3.2.1" style="width: 200px" />
</el-form-item>
<el-form-item label="发射年份">
<el-input v-model="form.launch_year" placeholder="LAUNCH: 2024" style="width: 200px" />
</el-form-item>
<el-form-item label="徽章文案">
<el-input v-model="form.badge_text" placeholder="FREE ACCESS" style="width: 200px" />
</el-form-item>
<el-divider content-position="left">特性卡片</el-divider>
<el-form-item label="特性">
<div v-for="(f, i) in form.features" :key="i" style="margin-bottom: 12px; padding: 12px; border: 1px solid #eee; border-radius: 8px">
<el-input v-model="f.title" placeholder="标题" style="margin-bottom: 8px" />
<el-input v-model="f.desc" type="textarea" :rows="2" placeholder="描述" />
<el-button link type="danger" size="small" @click="form.features.splice(i, 1)">删除</el-button>
</div>
<el-button link type="primary" @click="form.features.push({ title: '', desc: '' })">+ 添加特性</el-button>
</el-form-item>
<el-form-item label="页脚文案">
<el-input v-model="form.footer_text" placeholder="© 2024 YUHENG ONE" />
</el-form-item>
</el-form>
<el-empty v-else description="请先选择站点" />
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getSites, getOfficialSite, getHomepage, updateHomepage } from '../../api/admin'
import { useAuthStore } from '../../stores/auth'
const siteId = ref('')
const sites = ref([])
const saving = ref(false)
const downloading = ref(false)
const formRef = ref(null)
const defaultForm = () => ({
logo_text: 'YUHENG ONE',
nav_links: [{ label: 'MISSION', url: '#' }, { label: 'DOWNLOAD', url: '#' }, { label: 'CONTACT', url: '#' }],
title: '宇恒一号',
subtitle: 'INTERSTELLAR EXPLORER EDITION',
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙',
download_text: 'START EXPLORING',
download_url: '#',
platforms: [
{ name: 'WINDOWS', url: '#' },
{ name: 'MACOS', url: '#' },
{ name: 'LINUX', url: '#' },
{ name: 'IOS', url: '#' },
{ name: 'ANDROID', url: '#' }
],
version: 'VERSION 3.2.1',
launch_year: 'LAUNCH: 2024',
badge_text: 'FREE ACCESS',
features: [
{ title: '星际导航', desc: '先进的AI导航系统精准定位您的需求引领探索之旅' },
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
],
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE'
})
const form = reactive(defaultForm())
const fetchSites = async () => {
try {
const sitesRes = await getSites()
sites.value = sitesRes.list || []
let officialSiteId = ''
try {
const officialRes = await getOfficialSite()
officialSiteId = officialRes.site_id || ''
} catch (_) {
// 兼容旧后端未提供 official-site 接口时仍可正常选站
}
const q = new URLSearchParams(window.location.search)
if (q.get('site_id') && sites.value.some((s) => s.id === q.get('site_id'))) {
siteId.value = q.get('site_id')
} else if (officialSiteId && sites.value.some((s) => s.id === officialSiteId)) {
siteId.value = officialSiteId
} else if (sites.value.length) {
siteId.value = sites.value[0].id
}
} catch (e) {
ElMessage.error(e.message)
}
}
const fetchData = async () => {
if (!siteId.value) return
try {
const data = await getHomepage(siteId.value)
Object.assign(form, {
...defaultForm(),
...data,
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : defaultForm().nav_links,
platforms: Array.isArray(data.platforms) && data.platforms.length ? data.platforms : defaultForm().platforms,
features: Array.isArray(data.features) && data.features.length ? data.features : defaultForm().features
})
} catch (e) {
ElMessage.error(e.message)
}
}
watch(siteId, fetchData)
const handleSave = async () => {
saving.value = true
try {
await updateHomepage(siteId.value, form)
ElMessage.success('保存成功')
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
} finally {
saving.value = false
}
}
const authStore = useAuthStore()
const handleDownload = async () => {
downloading.value = true
try {
const token = authStore.getToken()
const url = `/api/admin/sites/${siteId.value}/homepage/download`
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('下载失败')
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'index.html'
a.click()
URL.revokeObjectURL(a.href)
ElMessage.success('已下载 index.html')
} catch (e) {
ElMessage.error(e.message || '下载失败')
} finally {
downloading.value = false
}
}
onMounted(() => {
fetchSites().then(() => fetchData())
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="module-upload">
<el-card>
<template #header>
<div class="card-header">
<span>功能模块上传</span>
<div>
<el-select v-model="siteId" placeholder="选择站点" filterable style="width: 220px; margin-right: 12px" @change="fetchList">
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-upload
:show-file-list="false"
:disabled="!siteId"
:before-upload="beforeUpload"
>
<el-button type="primary" :disabled="!siteId" :loading="uploading">上传文件</el-button>
</el-upload>
</div>
</div>
</template>
<el-alert v-if="!siteId" title="请先选择站点" type="info" style="margin-bottom: 16px" />
<el-table v-else :data="list" v-loading="loading" stripe>
<el-table-column label="文件名" prop="name" min-width="180" />
<el-table-column label="存储路径" prop="file_path" min-width="200" show-overflow-tooltip />
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.size) }}</template>
</el-table-column>
<el-table-column label="上传时间" prop="created_at" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="siteId && !loading && list.length === 0" description="暂无上传文件,请点击上传" />
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset } from '../../api/admin'
const siteId = ref('')
const sites = ref([])
const list = ref([])
const loading = ref(false)
const uploading = ref(false)
const fetchSites = async () => {
try {
const res = await getSites()
sites.value = res.list || []
const q = new URLSearchParams(window.location.search)
if (q.get('site_id') && sites.value.some((s) => s.id === q.get('site_id'))) {
siteId.value = q.get('site_id')
} else if (sites.value.length) {
siteId.value = sites.value[0].id
}
} catch (e) {
ElMessage.error(e.message)
}
}
const fetchList = async () => {
if (!siteId.value) {
list.value = []
return
}
loading.value = true
try {
const res = await getSiteAssets(siteId.value)
list.value = res.list || []
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
watch(siteId, fetchList)
const beforeUpload = async (file) => {
uploading.value = true
try {
await uploadSiteAsset(siteId.value, file)
ElMessage.success('上传成功')
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
} finally {
uploading.value = false
}
return false
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('确定删除该文件?', '提示', { type: 'warning' })
try {
await deleteSiteAsset(siteId.value, row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
onMounted(() => {
fetchSites().then(() => fetchList())
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="page-list">
<el-card>
<template #header>
<div class="card-header">
<span>网页管理</span>
<div>
<el-select v-model="siteId" placeholder="选择站点" filterable style="width: 220px; margin-right: 12px" @change="fetchList">
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-button type="primary" :disabled="!siteId" @click="openDialog()">新增网页</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column label="ID" width="240">
<template #default="{ row }">{{ row.id }}</template>
</el-table-column>
<el-table-column prop="slug" label="Slug" width="120" />
<el-table-column prop="title" label="标题" width="160" />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag v-if="row.type === 'homepage'" type="success" size="small">首页</el-tag>
<el-tag v-else size="small">页面</el-tag>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" />
<el-table-column label="操作" fixed="right" width="160">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="560px" @close="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="Slug" prop="slug">
<el-input v-model="form.slug" placeholder="如 about、index" :disabled="!!editId" />
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="页面标题" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="类型">
<el-option label="普通页面" value="page" />
<el-option label="首页" value="homepage" />
</el-select>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="HTML 或 JSON" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites } from '../../api/admin'
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
const siteId = ref('')
const sites = ref([])
const list = ref([])
const loading = ref(false)
const fetchSites = async () => {
try {
const res = await getSites()
sites.value = res.list || []
if (sites.value.length && !siteId.value) {
const q = new URLSearchParams(window.location.search)
siteId.value = q.get('site_id') || sites.value[0].id
}
} catch (e) {
ElMessage.error(e.message)
}
}
const fetchList = async () => {
if (!siteId.value) {
list.value = []
return
}
loading.value = true
try {
const res = await getPages({ site_id: siteId.value })
list.value = res.list || []
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
watch(siteId, fetchList)
const dialogVisible = ref(false)
const editId = ref('')
const submitting = ref(false)
const formRef = ref(null)
const form = reactive({ site_id: '', slug: '', title: '', type: 'page', content: '' })
const rules = {
slug: [{ required: true, message: '请输入 slug', trigger: 'blur' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
}
const openDialog = (row) => {
form.site_id = siteId.value
editId.value = row ? row.id : ''
form.slug = row ? row.slug : ''
form.title = row ? row.title : ''
form.type = row ? row.type || 'page' : 'page'
form.content = row ? row.content || '' : ''
dialogVisible.value = true
}
const resetForm = () => {
form.slug = ''
form.title = ''
form.type = 'page'
form.content = ''
editId.value = ''
}
const submitForm = async () => {
await formRef.value?.validate()
submitting.value = true
try {
if (editId.value) {
await updatePage(editId.value, { slug: form.slug, title: form.title, type: form.type, content: form.content })
ElMessage.success('更新成功')
} else {
await createPage({ ...form, site_id: siteId.value })
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('确定删除该网页?', '提示', { type: 'warning' })
try {
await deletePage(row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
onMounted(() => {
fetchSites().then(() => fetchList())
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="site-list">
<el-card>
<template #header>
<div class="card-header">
<span>站点管理</span>
<el-button type="primary" @click="openDialog()">新增站点</el-button>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column label="ID" width="240">
<template #default="{ row }">{{ row.id }}</template>
</el-table-column>
<el-table-column prop="name" label="站点名称" width="160" />
<el-table-column prop="domain" label="域名" width="180" />
<el-table-column prop="description" label="描述" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" width="380">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="$router.push(`/pages?site_id=${row.id}`)">网页管理</el-button>
<el-button link type="primary" size="small" @click="$router.push(`/homepage-edit?site_id=${row.id}`)">首页编辑</el-button>
<el-button link type="primary" size="small" @click="$router.push(`/module-upload?site_id=${row.id}`)">功能模块</el-button>
<el-tag v-if="officialSiteId === row.id" type="success" size="small">官网</el-tag>
<el-button v-else link type="success" size="small" @click="setAsOfficial(row)">设为官网</el-button>
<el-button link type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editId ? '编辑站点' : '新增站点'" width="500px" @close="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="站点名称" />
</el-form-item>
<el-form-item label="域名" prop="domain">
<el-input v-model="form.domain" placeholder="如 www.example.com" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites, createSite, updateSite, deleteSite, getOfficialSite, setOfficialSite } from '../../api/admin'
const list = ref([])
const loading = ref(false)
const officialSiteId = ref('')
const fetchOfficialSite = async () => {
try {
const res = await getOfficialSite()
officialSiteId.value = res.site_id || ''
} catch (_) {
officialSiteId.value = ''
}
}
const setAsOfficial = async (row) => {
try {
await setOfficialSite(row.id)
ElMessage.success('已设为官网站点')
officialSiteId.value = row.id
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
const fetchList = async () => {
loading.value = true
try {
const sitesRes = await getSites()
list.value = sitesRes.list || []
try {
const officialRes = await getOfficialSite()
officialSiteId.value = officialRes.site_id || ''
} catch (_) {
officialSiteId.value = ''
}
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const dialogVisible = ref(false)
const editId = ref('')
const submitting = ref(false)
const formRef = ref(null)
const form = reactive({ name: '', domain: '', description: '' })
const rules = { name: [{ required: true, message: '请输入站点名称', trigger: 'blur' }] }
const openDialog = (row) => {
editId.value = row ? row.id : ''
form.name = row ? row.name : ''
form.domain = row ? row.domain || '' : ''
form.description = row ? row.description || '' : ''
dialogVisible.value = true
}
const resetForm = () => {
form.name = ''
form.domain = ''
form.description = ''
editId.value = ''
}
const submitForm = async () => {
await formRef.value?.validate()
submitting.value = true
try {
if (editId.value) {
await updateSite(editId.value, { name: form.name, domain: form.domain, description: form.description })
ElMessage.success('更新成功')
} else {
await createSite(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('确定删除该站点?其网页与上传文件将一并删除。', '提示', { type: 'warning' })
try {
await deleteSite(row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
onMounted(fetchList)
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="user-list">
<el-card>
<template #header>
<div class="card-header">
<span>用户管理</span>
<el-button type="primary" @click="openDialog()">新增用户</el-button>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column label="ID" width="200">
<template #default="{ row }">{{ row.id || row._id }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="email" label="邮箱" width="180" />
<el-table-column label="角色" width="120">
<template #default="{ row }">
<el-tag v-if="row.role_id === 9527" type="danger" size="small">超级管理员</el-tag>
<el-tag v-else-if="row.role === 'admin'" type="warning" size="small">管理员</el-tag>
<el-tag v-else type="info" size="small">普通用户</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_beta" label="Beta" width="80">
<template #default="{ row }">
<el-tag v-if="row.is_beta" type="success" size="small"></el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="llm" label="LLM" width="80" />
<el-table-column prop="last_login" label="最后登录" width="120" />
<el-table-column label="操作" fixed="right" width="180">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="fetchList"
@size-change="fetchList"
style="margin-top: 16px"
/>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editId ? '编辑用户' : '新增用户'" width="500px" @close="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="!!editId" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="密码" prop="password" :rules="editId ? [] : [{ required: true, message: '请输入密码' }]">
<el-input v-model="form.password" type="password" :placeholder="editId ? '不修改请留空' : '请输入密码'" show-password />
</el-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="form.role" placeholder="请选择">
<el-option label="用户" value="user" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item label="角色ID" prop="role_id">
<el-select v-model="form.role_id" placeholder="请选择">
<el-option label="普通用户" :value="1" />
<el-option label="超级管理员" :value="9527" />
</el-select>
</el-form-item>
<el-form-item label="Beta用户" prop="is_beta">
<el-switch v-model="form.is_beta" />
</el-form-item>
<el-form-item label="LLM" prop="llm">
<el-input v-model="form.llm" placeholder="如 m1, qwen" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUsers, createUser, updateUser, deleteUser } from '../../api/admin'
const list = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const fetchList = async () => {
loading.value = true
try {
const res = await getUsers({ page: page.value, page_size: pageSize.value })
list.value = res.list || []
total.value = res.total || 0
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const dialogVisible = ref(false)
const editId = ref('')
const submitting = ref(false)
const formRef = ref(null)
const form = reactive({
username: '',
email: '',
password: '',
role: 'user',
role_id: 1,
is_beta: false,
llm: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }]
}
const openDialog = (row) => {
editId.value = row ? (row.id || row._id) : ''
if (row) {
form.username = row.username
form.email = row.email
form.role = row.role || 'user'
form.role_id = row.role_id ?? 1
form.is_beta = row.is_beta || false
form.llm = row.llm || ''
form.password = ''
} else {
resetForm()
}
dialogVisible.value = true
}
const resetForm = () => {
form.username = ''
form.email = ''
form.password = ''
form.role = 'user'
form.role_id = 1
form.is_beta = false
form.llm = ''
formRef.value?.resetFields()
}
const submitForm = async () => {
await formRef.value?.validate()
submitting.value = true
try {
if (editId.value) {
const data = { username: form.username, email: form.email, role: form.role, role_id: form.role_id, is_beta: form.is_beta, llm: form.llm || undefined }
if (form.password) data.password = form.password
await updateUser(editId.value, data)
ElMessage.success('更新成功')
} else {
await createUser({
username: form.username,
email: form.email,
password: form.password,
role: form.role,
role_id: form.role_id,
is_beta: form.is_beta,
llm: form.llm || undefined
})
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
ElMessage.error(e.message)
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
await ElMessageBox.confirm(`确定删除用户 ${row.username}`, '提示', {
type: 'warning'
})
try {
await deleteUser(row.id || row._id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error(e.message)
}
}
onMounted(fetchList)
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="workspace-list">
<el-card>
<template #header>
<span>工作空间管理</span>
</template>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column label="ID" width="200">
<template #default="{ row }">{{ row._id || row.id }}</template>
</el-table-column>
<el-table-column prop="name" label="名称" />
<el-table-column prop="user_id" label="用户ID" width="200" />
<el-table-column prop="created_at" label="创建时间" width="180" />
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="fetchList"
@size-change="fetchList"
style="margin-top: 16px"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getWorkspaces } from '../../api/admin'
const list = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const fetchList = async () => {
loading.value = true
try {
const res = await getWorkspaces({ page: page.value, page_size: pageSize.value })
list.value = res.list || []
total.value = res.total || 0
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
onMounted(fetchList)
</script>

17
admin/vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/admin/',
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})

56
deploy.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# 一键部署 api、web、admin 三个服务Docker
# 用法:
# 本地/当前机部署:./deploy.sh
# 部署到远程机: DEPLOY_HOST=user@192.168.10.241 ./deploy.sh
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
echo "=========================================="
echo " yh_web 部署 (api + web + admin)"
echo " 域名: https://yuheng.yuxindazhineng.com"
echo "=========================================="
run_deploy() {
local dir="$1"
cd "$dir"
if [ -f server/.env ]; then
set -a
source server/.env
set +a
echo "[OK] 已加载 server/.env"
fi
echo ""
echo "[1/3] 构建镜像 (api + web + admin)..."
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
echo ""
echo "[2/3] 停止旧容器..."
docker compose down 2>/dev/null || docker-compose down
echo ""
echo "[3/3] 启动三个服务 + Mongo..."
docker compose up -d 2>/dev/null || docker-compose up -d
cd "$ROOT"
echo ""
echo "=========================================="
echo " 部署完成"
echo " api:9527 web:9528 admin:9529"
echo " 访问: https://yuheng.yuxindazhineng.com"
echo "=========================================="
}
if [ -n "$DEPLOY_HOST" ]; then
echo "远程部署到: $DEPLOY_HOST"
echo "同步项目目录..."
rsync -az --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'web/dist' \
--exclude 'admin/dist' \
--exclude 'logs' \
"$ROOT/" "$DEPLOY_HOST:${DEPLOY_PATH:-yh_web}/"
echo "在远程执行部署..."
ssh "$DEPLOY_HOST" "cd ${DEPLOY_PATH:-yh_web} && chmod +x deploy.sh && ./deploy.sh"
else
run_deploy "$ROOT"
fi

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
# 域名与 HTTPS 由 Nginx Proxy Manager 统一配置: https://npm.yuxindazhineng.com/nginx/proxy
# 本 compose 只暴露 9527(api)、9528(web)、9529(admin),由 NPM 反向代理到对外域名
version: "3.8"
services:
api:
build:
context: .
dockerfile: server/Dockerfile
image: yh_web-api:latest
container_name: yh_api
environment:
- PORT=9527
- MONGODB_URI=${MONGODB_URI:-mongodb://mongo:27017}
- MONGODB_DB=${MONGODB_DB:-yxd-agent-testing}
- GIN_MODE=release
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-https://yuheng.yuxindazhineng.com}
depends_on:
- mongo
networks:
- yh_net
ports:
- "9527:9527"
web:
build:
context: ./web
dockerfile: Dockerfile
image: yh_web-web:latest
container_name: yh_web
networks:
- yh_net
ports:
- "9528:80"
admin:
build:
context: ./admin
dockerfile: Dockerfile
image: yh_web-admin:latest
container_name: yh_admin
networks:
- yh_net
ports:
- "9529:80"
mongo:
image: mongo:7
container_name: yh_mongo
volumes:
- mongo_data:/data/db
networks:
- yh_net
# 仅内网不暴露端口API 容器内用 mongo:27017
# ports:
# - "27017:27017"
networks:
yh_net:
driver: bridge
volumes:
mongo_data:

30
nginx-proxy-manager.md Normal file
View File

@@ -0,0 +1,30 @@
# Nginx Proxy Manager 配置说明
已在 [Nginx Proxy Manager](https://npm.yuxindazhineng.com/nginx/proxy) 配置域名和强制 HTTPS对外统一用域名访问。
## 1. 在 NPM 中编辑该域名的 Proxy Host
- **Details**:域名填 **`yuheng.yuxindazhineng.com`****Forward Hostname / IP** 填本机 IP`192.168.10.241`**Forward Port** 填 **9528**(官网首页)。
- **SSL**:按需开启并强制 HTTPS。
## 2. Custom Locations路径转发
**Custom Locations** 中保证以下三条(端口与截图不一致的请改掉):
| Location | Scheme | Forward Hostname / IP | Forward Port | 说明 |
|----------|--------|------------------------|--------------|----------|
| `/` | `http` | `192.168.10.241` | **9528** | 官网首页 |
| `/admin` | `http` | `192.168.10.241` | **9529** | 管理后台(端口应为 9529不是 9289 |
| `/api` | `http` | `192.168.10.241` | **9527** | 后端接口 |
- 若当前是 `/index` 指向 9528建议改为 **`/`** 指向 **9528**,这样首页是 `https://域名/` 而不是 `https://域名/index`
- `/admin` 的端口请确认为 **9529**(截图里 9289 易误,应为 9529
- 务必增加 **`/api` → 9527**,否则前后台请求接口会 404。
## 3. 访问方式(对外统一域名)
- 官网:**https://yuheng.yuxindazhineng.com/**
- 管理后台:**https://yuheng.yuxindazhineng.com/admin/**
- 接口:**https://yuheng.yuxindazhineng.com/api/...**
前后台与接口同域,无需再设 `VITE_API_BASE`

35
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
server {
listen 80;
server_name yuheng.yuxindazhineng.com;
# 若使用 HTTPS取消下面注释并挂载证书到 /etc/nginx/ssl/
# listen 443 ssl;
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
# ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location /api/ {
proxy_pass http://api:9527;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/ {
proxy_pass http://admin:80/admin/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://web:80/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

24
pull-and-restart.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# 拉取代码并重启项目(线上建议放在 /home/yxd/project执行 ./pull-and-restart.sh
# 也可指定目录PROJECT_ROOT=/home/yxd/project ./pull-and-restart.sh
set -e
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
cd "$ROOT"
echo "=========================================="
echo " yh_web 拉取并重启"
echo " 路径: $ROOT"
echo "=========================================="
[ -f server/.env ] && set -a && source server/.env && set +a
echo "[1/2] 拉取代码..."
git pull
echo ""
echo "[2/2] 重新构建并启动..."
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
docker compose up -d --force-recreate 2>/dev/null || docker-compose up -d --force-recreate
echo ""
echo "完成. api:9527 web:9528 admin:9529"

29
push-to-gitea.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# 推送到 Gitea: https://gitea.yuxindazhineng.com/whm/web
set -e
cd "$(dirname "$0")"
GITEA_URL="https://gitea.yuxindazhineng.com/whm/web.git"
if [ ! -d .git ]; then
echo "初始化 Git 仓库..."
git init
git branch -M main
fi
if ! git remote get-url origin 2>/dev/null; then
echo "添加远程: $GITEA_URL"
git remote add origin "$GITEA_URL"
else
git remote set-url origin "$GITEA_URL"
fi
echo "添加并提交..."
git add .
if [ -n "$(git status --porcelain)" ]; then
git commit -m "chore: push to gitea (yh_web)"
else
echo "无新变更"
fi
echo "推送到 origin..."
git push -u origin main || git push -u origin main --force
echo "完成: https://gitea.yuxindazhineng.com/whm/web"

12
restart.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# 仅重启项目(不拉代码),适用于配置/环境变更后重启
# 用法:./restart.sh 或 PROJECT_ROOT=/home/yxd/project ./restart.sh
set -e
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
cd "$ROOT"
echo "重启 yh_web ($ROOT)..."
[ -f server/.env ] && set -a && source server/.env && set +a
docker compose down 2>/dev/null || docker-compose down
docker compose up -d 2>/dev/null || docker-compose up -d
echo "完成. api:9527 web:9528 admin:9529"

24
run-docker.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# 在 Docker 中运行项目,统一域名: https://yuheng.yuxindazhineng.com
# 端口: 9527=api, 9528=index, 9529=admin直连对外通过 nginx 80/443
set -e
cd "$(dirname "$0")"
echo "=== yh_web Docker 启动 ==="
echo "对外域名与 HTTPS 由 Nginx Proxy Manager 配置,本机端口: 9527=api, 9528=web, 9529=admin"
echo ""
# 可选:从 server/.env 加载到当前 shell供 docker-compose 使用
if [ -f server/.env ]; then
set -a
source server/.env
set +a
echo "已加载 server/.env"
fi
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
docker compose up -d 2>/dev/null || docker-compose up -d
echo ""
echo "已启动。请用 NPM 配置的域名访问(如 https://yuheng.yuxindazhineng.com"
echo "直连: api :9527, web :9528, admin :9529"

11
scripts/kill-ports.ps1 Normal file
View File

@@ -0,0 +1,11 @@
# Kill processes on ports 8080/3000/3001
$ports = @(8080, 3000, 3001)
foreach ($p in $ports) {
$conn = Get-NetTCPConnection -LocalPort $p -ErrorAction SilentlyContinue
if ($conn) {
$conn | ForEach-Object {
Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue
}
Write-Host "Port $p released"
}
}

30
scripts/ssh-tunnel.ps1 Normal file
View File

@@ -0,0 +1,30 @@
# SSH 隧道 - 使用 Posh-SSH 自动输入密码
# 首次运行需安装: Install-Module Posh-SSH -Scope CurrentUser
$hostName = "www.yuxindazhineng.com"
$port = 2223
$user = "yxd"
$password = "yuxindazn001"
$localPort = 27017
$remotePort = 27017
if (-not (Get-Module -ListAvailable -Name Posh-SSH)) {
Write-Host "正在安装 Posh-SSH 模块(仅需一次)..."
Install-Module Posh-SSH -Scope CurrentUser -Force
}
$secPass = ConvertTo-SecureString $password -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential($user, $secPass)
Write-Host "正在建立 SSH 隧道..."
$session = New-SSHSession -ComputerName $hostName -Port $port -Credential $cred -AcceptKey -Force
if (-not $session) {
Write-Host "SSH 连接失败"
pause
exit 1
}
$forward = New-SSHLocalPortForward -Index 0 -LocalAddress 127.0.0.1 -LocalPort $localPort -RemoteAddress localhost -RemotePort $remotePort
Write-Host "SSH 隧道已建立 (localhost:$localPort -> 远程:$remotePort)"
Write-Host "保持此窗口打开,关闭即断开"
pause

45
server/.air.toml Normal file
View File

@@ -0,0 +1,45 @@
# Air 热加载配置(可选,当前使用 CompileDaemon
# 安装: go install github.com/air-verse/air@latest
# 运行: air
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ."
bin = "./tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "node_modules"]
exclude_file = []
exclude_regex = ["_test\\.go$"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = true
keep_scroll = true

9
server/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# 复制为 .env 或 .env.production 后修改
# Go 不会自动加载 .env需在启动前导出变量见项目根目录 .env.example 的说明)
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=yxd-agent-testing
PORT=8080
GIN_MODE=release
SERVER_DOMAIN=https://api.example.com
ALLOWED_ORIGINS=

13
server/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# 需在项目根目录构建: docker build -f server/Dockerfile .
FROM golang:1.21-alpine AS builder
WORKDIR /build
COPY server/ ./
RUN go mod download && CGO_ENABLED=0 go build -o /app/server .
FROM alpine:3.19
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Shanghai
COPY --from=builder /app/server .
EXPOSE 9527
ENTRYPOINT ["./server"]

24
server/README.md Normal file
View File

@@ -0,0 +1,24 @@
# 多站点管理后台 - API 服务
基于 Gin 的后端 API 服务。
## MongoDB 连接SSH 穿透)
MongoDB 在远程服务器,需先建立 SSH 隧道:
```bash
ssh -p 2223 -L 27017:localhost:27017 yxd@www.yuxindazhineng.com
```
或双击运行 `scripts/start-ssh-tunnel.bat`
隧道建立后,`mongodb://localhost:27017` 会转发到远程 MongoDB。
## 运行
```bash
go mod tidy
go run main.go
```
默认端口 8080

View File

@@ -0,0 +1,4 @@
package config
// DBName 数据库名,可由环境变量 MONGODB_DB 覆盖
var DBName = "yxd-agent-testing"

56
server/config/database.go Normal file
View File

@@ -0,0 +1,56 @@
package config
import (
"context"
"log"
"time"
"yh_web/server/pkg/logger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var MongoClient *mongo.Client
// ConnectMongoDB 连接本地 MongoDB
func ConnectMongoDB(uri string) error {
clientOpts := options.Client().ApplyURI(uri)
client, err := mongo.Connect(clientOpts)
if err != nil {
return err
}
// 验证连接
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var result bson.M
if err = client.Database("admin").RunCommand(ctx, bson.D{{Key: "ping", Value: 1}}).Decode(&result); err != nil {
return err
}
MongoClient = client
log.Println("MongoDB 连接成功")
logger.Log("config/database", "MongoDB 连接成功")
return nil
}
// GetDB 获取指定数据库;未连接 MongoDB 时返回 nil
func GetDB(name string) *mongo.Database {
if MongoClient == nil {
return nil
}
return MongoClient.Database(name)
}
// CloseMongoDB 关闭连接
func CloseMongoDB() {
if MongoClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = MongoClient.Disconnect(ctx)
log.Println("MongoDB 连接已关闭")
logger.Log("config/database", "MongoDB 连接已关闭")
}
}

View File

@@ -0,0 +1,78 @@
package config
import (
"context"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// DBStructure 数据库结构信息
type DBStructure struct {
Databases []DatabaseInfo `json:"databases"`
}
// DatabaseInfo 数据库信息
type DatabaseInfo struct {
Name string `json:"name"`
Collections []CollInfo `json:"collections"`
}
// CollInfo 集合信息
type CollInfo struct {
Name string `json:"name"`
Count int64 `json:"count"`
Sample interface{} `json:"sample,omitempty"` // 采样一条文档看结构
}
// GetDBStructure 获取 MongoDB 数据结构
func GetDBStructure() (*DBStructure, error) {
if MongoClient == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 列出所有数据库(排除系统库)
dbs, err := MongoClient.ListDatabaseNames(ctx, bson.M{
"name": bson.M{"$nin": []string{"admin", "config", "local"}},
})
if err != nil {
return nil, err
}
result := &DBStructure{Databases: make([]DatabaseInfo, 0, len(dbs))}
for _, dbName := range dbs {
db := MongoClient.Database(dbName)
colls, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
continue
}
dbInfo := DatabaseInfo{
Name: dbName,
Collections: make([]CollInfo, 0, len(colls)),
}
for _, collName := range colls {
coll := db.Collection(collName)
count, _ := coll.CountDocuments(ctx, bson.M{})
// 采样一条文档
var sample bson.M
_ = coll.FindOne(ctx, bson.M{}).Decode(&sample)
dbInfo.Collections = append(dbInfo.Collections, CollInfo{
Name: collName,
Count: count,
Sample: sample,
})
}
result.Databases = append(result.Databases, dbInfo)
}
return result, nil
}

8
server/dev.bat Normal file
View File

@@ -0,0 +1,8 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo Starting backend with hot reload...
if not exist tmp mkdir tmp
CompileDaemon -command=tmp\main.exe -build="go build -o tmp\main.exe ."

44
server/go.mod Normal file
View File

@@ -0,0 +1,44 @@
module yh_web/server
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
go.mongodb.org/mongo-driver v1.17.9
go.mongodb.org/mongo-driver/v2 v2.5.0
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

130
server/go.sum Normal file
View File

@@ -0,0 +1,130 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

199
server/handlers/auth.go Normal file
View File

@@ -0,0 +1,199 @@
package handlers
import (
"context"
"errors"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"yh_web/server/config"
"yh_web/server/models"
"yh_web/server/pkg/logger"
"yh_web/server/utils"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
const jwtSecret = "yh_web_admin_jwt_secret_change_in_production"
const jwtExpire = 24 * time.Hour
type LoginInput struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type Claims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
RoleID int `json:"role_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// Login 后台登录,仅 role_id=9527 超级管理员可登录
func Login(c *gin.Context) {
var input LoginInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("users")
var user models.User
err := coll.FindOne(ctx, bson.M{"username": input.Username}).Decode(&user)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
// 尝试用手机号登录
err = coll.FindOne(ctx, bson.M{"mobile": input.Username}).Decode(&user)
}
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
logger.Err("handlers/auth", "[Login] FindOne error: %v", err)
resp := gin.H{"error": "登录失败,请稍后重试"}
if gin.Mode() == gin.DebugMode {
resp["debug"] = err.Error()
}
c.JSON(http.StatusInternalServerError, resp)
return
}
}
// 超级管理员(9527)或超级用户(role_id=0, role=admin)可登录后台
if user.RoleID != models.RoleIDSuperAdmin && !(user.RoleID == models.RoleIDSuperUser && user.Role == "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
return
}
roleID := user.RoleID
if roleID == 0 && user.Role == "admin" {
roleID = models.RoleIDSuperAdmin
}
hashed := utils.HashPassword(input.Password)
if hashed != user.Password {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
claims := Claims{
UserID: user.ID.Hex(),
Username: user.Username,
RoleID: roleID,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpire)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(jwtSecret))
if err != nil {
logger.Err("handlers/auth", "JWT SignedString error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": tokenStr,
"user": gin.H{
"id": user.ID.Hex(),
"username": user.Username,
"role_id": roleID,
"role": user.Role,
},
"expires_in": int64(jwtExpire.Seconds()),
})
}
// AuthRequired 鉴权中间件,要求 role_id=9527
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr := c.GetHeader("Authorization")
if tokenStr == "" {
tokenStr = c.Query("token")
}
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
tokenStr = tokenStr[7:]
}
if tokenStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
c.Abort()
return
}
var claims Claims
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
c.Abort()
return
}
// 仅超级管理员或超级用户(role_id=0, role=admin)可访问后台
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role_id", claims.RoleID)
c.Set("role", claims.Role)
c.Next()
}
}
// SuperUserAuthRequired 超级用户鉴权:仅 role_id=0 且 role=admin 可访问(如短信平台配置)
// 集团超级用户 username=admin 只能配置集团信息,不能配置短信
func SuperUserAuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr := c.GetHeader("Authorization")
if tokenStr == "" {
tokenStr = c.Query("token")
}
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
tokenStr = tokenStr[7:]
}
if tokenStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
c.Abort()
return
}
var claims Claims
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
c.Abort()
return
}
// 仅 role_id=9527 且 role=admin 可配置短信平台
if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅超级管理员可配置短信平台"})
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role_id", claims.RoleID)
c.Set("role", claims.Role)
c.Next()
}
}

View File

@@ -0,0 +1,67 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"github.com/gin-gonic/gin"
)
func GetConversations(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("conversations")
page := 1
pageSize := 20
if p := c.Query("page"); p != "" {
if v, err := parseInt(p); err == nil && v > 0 {
page = v
}
}
if ps := c.Query("page_size"); ps != "" {
if v, err := parseInt(ps); err == nil && v > 0 && v <= 100 {
pageSize = v
}
}
skip := (page - 1) * pageSize
opts := options.Find().SetSkip(int64(skip)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "_id", Value: -1}})
filter := bson.M{}
if userID := c.Query("user_id"); userID != "" {
filter["user_id"] = userID
}
if workspaceID := c.Query("workspace_id"); workspaceID != "" {
filter["workspace_id"] = workspaceID
}
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []map[string]interface{}
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, filter)
c.JSON(http.StatusOK, gin.H{
"list": list,
"total": total,
"page": page,
"page_size": pageSize,
})
}

View File

@@ -0,0 +1,7 @@
package handlers
import "strconv"
func parseInt(s string) (int, error) {
return strconv.Atoi(s)
}

345
server/handlers/homepage.go Normal file
View File

@@ -0,0 +1,345 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
const homepageSlug = "index"
const officialSiteConfigID = "official_site_id"
type officialSiteDoc struct {
ID string `bson:"_id"`
SiteID string `bson:"site_id"`
}
// getOfficialSiteID 从 system_config 读取官网站点 ID未设置则返回第一个站点的 ID
func getOfficialSiteID(ctx context.Context) string {
coll := config.GetDB(config.DBName).Collection("system_config")
var doc officialSiteDoc
err := coll.FindOne(ctx, bson.M{"_id": officialSiteConfigID}).Decode(&doc)
if err == nil && doc.SiteID != "" {
return doc.SiteID
}
sitesColl := config.GetDB(config.DBName).Collection("sites")
opts := options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}})
var site models.Site
if err := sitesColl.FindOne(ctx, bson.M{}, opts).Decode(&site); err == nil {
return site.ID.Hex()
}
return ""
}
// GetWebHomepage 前台:获取官网站点首页数据(无需鉴权)
func GetWebHomepage(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
siteID := getOfficialSiteID(ctx)
if siteID == "" {
c.JSON(http.StatusOK, defaultHomepageData())
return
}
coll := config.GetDB(config.DBName).Collection("pages")
var page models.Page
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
if err != nil {
if err == mongo.ErrNoDocuments {
c.JSON(http.StatusOK, defaultHomepageData())
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var data models.HomepageData
if page.Content != "" {
if err := json.Unmarshal([]byte(page.Content), &data); err != nil {
c.JSON(http.StatusOK, defaultHomepageData())
return
}
} else {
data = defaultHomepageData()
}
c.JSON(http.StatusOK, data)
}
// GetHomepage 获取站点首页数据
func GetHomepage(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("pages")
var page models.Page
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
if err != nil {
if err == mongo.ErrNoDocuments {
c.JSON(http.StatusOK, defaultHomepageData())
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var data models.HomepageData
if page.Content != "" {
if err := json.Unmarshal([]byte(page.Content), &data); err != nil {
c.JSON(http.StatusOK, defaultHomepageData())
return
}
} else {
data = defaultHomepageData()
}
c.JSON(http.StatusOK, data)
}
// UpdateHomepage 更新站点首页数据
func UpdateHomepage(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
var data models.HomepageData
if err := c.ShouldBindJSON(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
body, err := json.Marshal(data)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("pages")
now := time.Now().Format(time.RFC3339)
filter := bson.M{"site_id": siteID, "slug": homepageSlug}
update := bson.M{
"$set": bson.M{
"site_id": siteID,
"slug": homepageSlug,
"title": data.Title,
"type": "homepage",
"content": string(body),
"updated_at": now,
},
}
opts := options.UpdateOne().SetUpsert(true)
_, err = coll.UpdateOne(ctx, filter, update, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "保存成功"})
}
// DownloadHomepage 下载首页 HTML
func DownloadHomepage(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("pages")
var page models.Page
var data models.HomepageData
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
if err == nil && page.Content != "" {
_ = json.Unmarshal([]byte(page.Content), &data)
}
if err != nil || page.Content == "" {
data = defaultHomepageData()
}
html := renderHomepageHTML(&data)
c.Header("Content-Disposition", "attachment; filename=index.html")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
func defaultHomepageData() models.HomepageData {
return models.HomepageData{
LogoText: "YUHENG ONE",
NavLinks: []models.NavLink{{Label: "MISSION", URL: "#"}, {Label: "DOWNLOAD", URL: "#"}, {Label: "CONTACT", URL: "#"}},
Title: "宇恒一号",
Subtitle: "INTERSTELLAR EXPLORER EDITION",
Description: "跨越星际的智能伙伴 · 探索无限可能<br>\n 引领您进入前所未有的数字宇宙",
DownloadText: "START EXPLORING",
DownloadURL: "#",
Platforms: []models.PlatformItem{
{Name: "WINDOWS", URL: "#"},
{Name: "MACOS", URL: "#"},
{Name: "LINUX", URL: "#"},
{Name: "IOS", URL: "#"},
{Name: "ANDROID", URL: "#"},
},
Version: "VERSION 3.2.1",
LaunchYear: "LAUNCH: 2024",
BadgeText: "FREE ACCESS",
Features: []models.FeatureItem{
{Title: "星际导航", Desc: "先进的AI导航系统精准定位您的需求引领探索之旅"},
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
},
FooterText: "© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE",
}
}
// renderHomepageHTML 根据数据生成首页 HTML简化版保留原样式与结构
func renderHomepageHTML(d *models.HomepageData) string {
if d == nil {
d = &models.HomepageData{}
}
titleChars := splitTitle(d.Title)
navHTML := ""
for _, l := range d.NavLinks {
navHTML += `<a href="` + escape(l.URL) + `" style="color: rgba(255,255,255,0.5); text-decoration: none; transition: color 0.3s;">` + escape(l.Label) + `</a>`
}
platformsHTML := ""
for _, p := range d.Platforms {
platformsHTML += `<div class="orbit-platform"><svg viewBox="0 0 24 24"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg><span>` + escape(p.Name) + `</span></div>`
}
featuresHTML := ""
for i, f := range d.Features {
iconPath := []string{
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z",
"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM19 18H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h.71C7.37 7.69 9.48 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3s-1.34 3-3 3z",
"M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z",
}
if i >= len(iconPath) {
iconPath = append(iconPath, iconPath[0])
}
path := iconPath[i%3]
featuresHTML += `<div class="feature-space"><div class="feature-icon"><svg viewBox="0 0 24 24"><path d="` + path + `"/></svg></div><h3>` + escape(f.Title) + `</h3><p>` + escape(f.Desc) + `</p></div>`
}
sb := &strings.Builder{}
sb.WriteString("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>")
sb.WriteString(escape(d.Title))
sb.WriteString(" - 星际探索版</title>\n")
sb.WriteString(homepageCSS)
sb.WriteString("</head>\n<body>\n<div class=\"space-bg\"></div>\n<div class=\"stars\" id=\"stars\"></div>\n<div class=\"planet planet-1\"></div>\n<div class=\"planet planet-2\"></div>\n<nav class=\"navbar\"><div class=\"logo-space\">")
sb.WriteString(escape(d.LogoText))
sb.WriteString("</div>\n<div style=\"display: flex; gap: 35px; font-family: 'Exo 2', sans-serif; font-size: 12px; letter-spacing: 2px;\">")
sb.WriteString(navHTML)
sb.WriteString("</div>\n</nav>\n<section class=\"hero\">\n<div class=\"title-container\"><h1 class=\"title-3d\">")
for _, ch := range titleChars {
sb.WriteString("<span>" + escape(ch) + "</span>")
}
sb.WriteString("</h1>\n</div>\n<p class=\"subtitle-space\">")
sb.WriteString(escape(d.Subtitle))
sb.WriteString("</p>\n<p class=\"description-space\">")
sb.WriteString(strings.ReplaceAll(escape(d.Description), "\n", "<br>\n"))
sb.WriteString("</p>\n<div class=\"download-warp\">\n<div class=\"warp-effect\"></div>\n<a href=\"")
sb.WriteString(escape(d.DownloadURL))
sb.WriteString("\" class=\"warp-btn\">\n<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z\"/></svg>\n")
sb.WriteString(escape(d.DownloadText))
sb.WriteString("\n</a>\n</div>\n<div class=\"orbit-platforms\">")
sb.WriteString(platformsHTML)
sb.WriteString("</div>\n<div class=\"mission-info\">\n<span class=\"mission-badge\">")
sb.WriteString(escape(d.Version))
sb.WriteString("</span>\n<span>🚀 ")
sb.WriteString(escape(d.LaunchYear))
sb.WriteString("</span>\n<span>⚡ ")
sb.WriteString(escape(d.BadgeText))
sb.WriteString("</span>\n</div>\n<div class=\"features-space\">")
sb.WriteString(featuresHTML)
sb.WriteString("</div>\n</section>\n<footer>\n<p>")
sb.WriteString(escape(d.FooterText))
sb.WriteString("</p>\n</footer>\n<script>\nconst starsContainer = document.getElementById('stars');\nfor (let i = 0; i < 200; i++) {\nconst star = document.createElement('div');\nstar.className = 'star';\nstar.style.left = Math.random() * 100 + '%';\nstar.style.top = Math.random() * 100 + '%';\nstar.style.width = (Math.random() * 2 + 1) + 'px';\nstar.style.height = star.style.width;\nstar.style.setProperty('--duration', (Math.random() * 3 + 2) + 's');\nstar.style.setProperty('--min-opacity', Math.random() * 0.3 + 0.1);\nstarsContainer.appendChild(star);\n}\n</script>\n</body>\n</html>")
return sb.String()
}
func splitTitle(s string) []string {
var out []rune
for _, r := range s {
out = append(out, r)
}
if len(out) == 0 {
return []string{"宇", "恒", "一", "号"}
}
result := make([]string, len(out))
for i, r := range out {
result[i] = string(r)
}
return result
}
func escape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
const homepageCSS = `<style>
@import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@300;400;700;900&family=Noto+Sans+SC:wght@300;400;700&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
:root{--space-dark:#0a0a12;--space-blue:#1e3a5f;--nebula-purple:#4a1a6b;--star-white:#fff;--plasma-cyan:#00d4ff;--plasma-pink:#ff2d95;}
body{font-family:'Noto Sans SC',sans-serif;background:var(--space-dark);color:var(--star-white);min-height:100vh;overflow-x:hidden;}
.space-bg{position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;background:radial-gradient(ellipse at 20% 80%,rgba(74,26,107,0.3) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,rgba(30,58,95,0.3) 0%,transparent 50%);}
.stars{position:fixed;top:0;left:0;width:100%;height:100%;z-index:1;pointer-events:none;}
.star{position:absolute;background:#fff;border-radius:50%;animation:twinkle 3s ease-in-out infinite;}
@keyframes twinkle{0%,100%{opacity:0.3;transform:scale(1);}50%{opacity:1;transform:scale(1.2);}}
.planet{position:fixed;border-radius:50%;z-index:2;pointer-events:none;}
.planet-1{width:300px;height:300px;top:10%;right:-100px;background:linear-gradient(135deg,var(--nebula-purple),#1a0a2e);opacity:0.6;}
.planet-2{width:150px;height:150px;bottom:20%;left:-50px;background:linear-gradient(135deg,var(--space-blue),#0a1520);opacity:0.5;}
.navbar{position:fixed;top:0;left:0;right:0;padding:25px 50px;display:flex;justify-content:space-between;align-items:center;z-index:100;background:linear-gradient(180deg,rgba(10,10,18,0.9) 0%,transparent 100%);}
.logo-space{font-family:'Exo 2',sans-serif;font-size:26px;font-weight:900;background:linear-gradient(90deg,var(--plasma-cyan),var(--star-white),var(--plasma-pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:4px;}
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:100px 20px;position:relative;z-index:10;}
.title-container{perspective:1000px;margin-bottom:30px;}
.title-3d{font-family:'Exo 2',sans-serif;font-size:clamp(60px,12vw,150px);font-weight:900;color:var(--star-white);text-shadow:0 0 20px rgba(0,212,255,0.3);}
.subtitle-space{font-family:'Exo 2',sans-serif;font-size:clamp(16px,3vw,24px);letter-spacing:12px;color:var(--plasma-cyan);margin-bottom:25px;}
.description-space{max-width:650px;text-align:center;color:rgba(255,255,255,0.6);line-height:2;font-size:16px;margin-bottom:50px;}
.download-warp{position:relative;display:inline-block;padding:0;background:transparent;border:none;cursor:pointer;}
.download-warp .warp-btn{display:flex;align-items:center;gap:15px;padding:22px 45px;font-size:16px;font-weight:700;color:var(--space-dark);background:linear-gradient(135deg,var(--plasma-cyan),var(--plasma-pink));border:none;font-family:'Exo 2',sans-serif;letter-spacing:2px;text-decoration:none;clip-path:polygon(20px 0,100% 0,100% calc(100% - 20px),calc(100% - 20px) 100%,0 100%,0 20px);transition:all 0.4s;}
.download-warp .warp-btn:hover{transform:scale(1.05);box-shadow:0 0 30px rgba(0,212,255,0.6);}
.warp-effect{position:absolute;top:50%;left:50%;width:0;height:0;background:radial-gradient(circle,rgba(255,255,255,0.8) 0%,transparent 70%);border-radius:50%;transform:translate(-50%,-50%);animation:warp-drive 1.5s ease-out infinite;}
@keyframes warp-drive{0%{width:0;height:0;opacity:1;}100%{width:300px;height:300px;opacity:0;}}
.orbit-platforms{display:flex;gap:25px;margin-top:70px;flex-wrap:wrap;justify-content:center;}
.orbit-platform{width:90px;height:90px;background:rgba(30,58,95,0.3);border:1px solid rgba(0,212,255,0.3);border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;transition:all 0.4s;}
.orbit-platform:hover{background:rgba(0,212,255,0.2);border-color:var(--plasma-cyan);transform:translateY(-10px);}
.orbit-platform svg{width:28px;height:28px;fill:var(--star-white);}
.orbit-platform span{font-size:10px;color:rgba(255,255,255,0.6);}
.mission-info{margin-top:60px;display:flex;gap:40px;flex-wrap:wrap;justify-content:center;font-size:13px;color:rgba(255,255,255,0.4);}
.mission-info .mission-badge{padding:4px 12px;background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.3);border-radius:20px;color:var(--plasma-cyan);font-size:11px;}
.features-space{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:30px;max-width:1100px;margin:80px auto 0;padding:0 20px;}
.feature-space{background:linear-gradient(135deg,rgba(30,58,95,0.2),rgba(10,10,18,0.8));border:1px solid rgba(0,212,255,0.1);border-radius:20px;padding:35px;transition:all 0.5s;}
.feature-space:hover{transform:translateY(-15px);border-color:rgba(0,212,255,0.4);}
.feature-icon{width:55px;height:55px;background:linear-gradient(135deg,var(--plasma-cyan),var(--plasma-pink));border-radius:15px;display:flex;align-items:center;justify-content:center;margin-bottom:20px;}
.feature-icon svg{width:28px;height:28px;fill:var(--space-dark);}
.feature-space h3{font-family:'Exo 2',sans-serif;font-size:18px;color:var(--star-white);margin-bottom:12px;}
.feature-space p{color:rgba(255,255,255,0.5);font-size:14px;line-height:1.7;}
footer{padding:40px;text-align:center;border-top:1px solid rgba(255,255,255,0.05);margin-top:80px;}
footer p{color:rgba(255,255,255,0.3);font-size:12px;}
@media(max-width:768px){.planet{display:none;}}
</style>
`

View File

@@ -0,0 +1,145 @@
package handlers
import (
"context"
"net/http"
"os"
"path/filepath"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
const uploadDir = "uploads"
// ListSiteAssets 站点功能模块/上传文件列表
func ListSiteAssets(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []models.SiteAsset
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, bson.M{"site_id": siteID})
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
}
// UploadSiteAsset 上传功能模块/文件
func UploadSiteAsset(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
return
}
baseDir := filepath.Join(uploadDir, "sites", siteID)
if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
// 避免覆盖:加时间戳
name := file.Filename
ext := filepath.Ext(name)
nameNoExt := name[:len(name)-len(ext)]
saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext
relPath := filepath.Join("sites", siteID, saveName)
destPath := filepath.Join(uploadDir, relPath)
if err := c.SaveUploadedFile(file, destPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
doc := models.SiteAsset{
SiteID: siteID,
Name: file.Filename,
FilePath: relPath,
Size: file.Size,
ContentType: file.Header.Get("Content-Type"),
CreatedAt: time.Now().Format(time.RFC3339),
}
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
"site_id": doc.SiteID,
"name": doc.Name,
"file_path": doc.FilePath,
"size": doc.Size,
"content_type": doc.ContentType,
"created_at": doc.CreatedAt,
})
if err != nil {
os.Remove(destPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
}
// DeleteSiteAsset 删除站点资源
func DeleteSiteAsset(c *gin.Context) {
siteID := c.Param("site_id")
idStr := c.Param("asset_id")
if siteID == "" || idStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
var asset models.SiteAsset
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
fullPath := filepath.Join(uploadDir, asset.FilePath)
os.Remove(fullPath)
_, err = coll.DeleteOne(ctx, bson.M{"_id": oid})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}

View File

@@ -0,0 +1,50 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"github.com/gin-gonic/gin"
)
// GetOfficialSite 获取官网站点 ID
func GetOfficialSite(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
siteID := getOfficialSiteID(ctx)
c.JSON(http.StatusOK, gin.H{"site_id": siteID})
}
// SetOfficialSiteInput 设置官网站点
type SetOfficialSiteInput struct {
SiteID string `json:"site_id" binding:"required"`
}
// SetOfficialSite 设置官网站点
func SetOfficialSite(c *gin.Context) {
var input SetOfficialSiteInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("system_config")
doc := officialSiteDoc{ID: officialSiteConfigID, SiteID: input.SiteID}
opts := options.UpdateOne().SetUpsert(true)
_, err := coll.UpdateOne(ctx, bson.M{"_id": officialSiteConfigID}, bson.M{"$set": doc}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已设为官网站点", "site_id": input.SiteID})
}

175
server/handlers/page.go Normal file
View File

@@ -0,0 +1,175 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
// GetPages 网页列表(按站点)
func GetPages(c *gin.Context) {
siteID := c.Query("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("pages")
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []models.Page
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, bson.M{"site_id": siteID})
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
}
// GetPageByID 单页
func GetPageByID(c *gin.Context) {
idStr := c.Param("id")
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var page models.Page
err = config.GetDB(config.DBName).Collection("pages").FindOne(ctx, bson.M{"_id": oid}).Decode(&page)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
c.JSON(http.StatusOK, page)
}
// CreatePageInput 创建网页
type CreatePageInput struct {
SiteID string `json:"site_id" binding:"required"`
Slug string `json:"slug" binding:"required"`
Title string `json:"title" binding:"required"`
Type string `json:"type"` // homepage, page
Content string `json:"content"`
}
// CreatePage 创建网页
func CreatePage(c *gin.Context) {
var input CreatePageInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写 site_id、slug、title"})
return
}
if input.Type == "" {
input.Type = "page"
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("pages")
now := time.Now().Format(time.RFC3339)
doc := bson.M{
"site_id": input.SiteID,
"slug": input.Slug,
"title": input.Title,
"type": input.Type,
"content": input.Content,
"updated_at": now,
}
res, err := coll.InsertOne(ctx, doc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "message": "创建成功"})
}
// UpdatePageInput 更新网页
type UpdatePageInput struct {
Slug *string `json:"slug"`
Title *string `json:"title"`
Type *string `json:"type"`
Content *string `json:"content"`
}
// UpdatePage 更新网页
func UpdatePage(c *gin.Context) {
idStr := c.Param("id")
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面ID"})
return
}
var input UpdatePageInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
set := bson.M{"updated_at": time.Now().Format(time.RFC3339)}
if input.Slug != nil {
set["slug"] = *input.Slug
}
if input.Title != nil {
set["title"] = *input.Title
}
if input.Type != nil {
set["type"] = *input.Type
}
if input.Content != nil {
set["content"] = *input.Content
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = config.GetDB(config.DBName).Collection("pages").UpdateOne(ctx, bson.M{"_id": oid}, bson.M{"$set": set})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
// DeletePage 删除网页
func DeletePage(c *gin.Context) {
idStr := c.Param("id")
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = config.GetDB(config.DBName).Collection("pages").DeleteOne(ctx, bson.M{"_id": oid})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}

View File

@@ -0,0 +1,128 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
const paymentConfigDocID = "payment"
// GetPaymentConfig 获取支付配置(仅 role_id=9527, role=admin
func GetPaymentConfig(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("system_config")
var cfg models.PaymentConfig
err := coll.FindOne(ctx, bson.M{"_id": paymentConfigDocID}).Decode(&cfg)
if err != nil {
if err == mongo.ErrNoDocuments {
c.JSON(http.StatusOK, models.PaymentConfig{})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
// PaymentConfigUpdateInput 支付配置更新
type PaymentConfigUpdateInput struct {
Wechat *WechatPayUpdateInput `json:"wechat"`
Alipay *AlipayUpdateInput `json:"alipay"`
}
type WechatPayUpdateInput struct {
AppID string `json:"app_id"`
MchID string `json:"mch_id"`
APIKey string `json:"api_key"`
APIKeyV3 string `json:"api_key_v3"`
Enabled *bool `json:"enabled"`
}
type AlipayUpdateInput struct {
AppID string `json:"app_id"`
PrivateKey string `json:"private_key"`
AlipayPublicKey string `json:"alipay_public_key"`
Enabled *bool `json:"enabled"`
}
// UpdatePaymentConfig 更新支付配置
func UpdatePaymentConfig(c *gin.Context) {
var input PaymentConfigUpdateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("system_config")
set := bson.M{}
if input.Wechat != nil {
w := bson.M{}
if input.Wechat.AppID != "" {
w["app_id"] = input.Wechat.AppID
}
if input.Wechat.MchID != "" {
w["mch_id"] = input.Wechat.MchID
}
if input.Wechat.APIKey != "" {
w["api_key"] = input.Wechat.APIKey
}
if input.Wechat.APIKeyV3 != "" {
w["api_key_v3"] = input.Wechat.APIKeyV3
}
if input.Wechat.Enabled != nil {
w["enabled"] = *input.Wechat.Enabled
}
for k, v := range w {
set["wechat."+k] = v
}
}
if input.Alipay != nil {
a := bson.M{}
if input.Alipay.AppID != "" {
a["app_id"] = input.Alipay.AppID
}
if input.Alipay.PrivateKey != "" {
a["private_key"] = input.Alipay.PrivateKey
}
if input.Alipay.AlipayPublicKey != "" {
a["alipay_public_key"] = input.Alipay.AlipayPublicKey
}
if input.Alipay.Enabled != nil {
a["enabled"] = *input.Alipay.Enabled
}
for k, v := range a {
set["alipay."+k] = v
}
}
if len(set) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
return
}
opts := options.UpdateOne().SetUpsert(true)
_, err := coll.UpdateOne(ctx, bson.M{"_id": paymentConfigDocID}, bson.M{"$set": set}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "配置已保存"})
}

View File

@@ -0,0 +1,96 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
// roleIDFromContext 从 context 安全取 role_idJWT 等可能解码为 float64
func roleIDFromContext(c *gin.Context) (int, bool) {
roleIDVal, ok := c.Get("role_id")
if !ok {
return 0, false
}
switch v := roleIDVal.(type) {
case int:
return v, true
case float64:
return int(v), true
default:
return 0, false
}
}
// getPermissionsByRoleID 从 role_permissions 读取某角色的权限9527 默认拥有全部
func getPermissionsByRoleID(ctx context.Context, roleID int) []string {
if roleID == models.RoleIDSuperAdmin {
return allPermissionKeys()
}
coll := config.GetDB(config.DBName).Collection("role_permissions")
var doc models.RolePermissionsDoc
err := coll.FindOne(ctx, bson.M{"role_id": roleID}).Decode(&doc)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil
}
return nil
}
return doc.Permissions
}
func allPermissionKeys() []string {
keys := make([]string, 0, len(models.AllPermissions))
for _, p := range models.AllPermissions {
keys = append(keys, p.Key)
}
return keys
}
// RequirePermission 要求当前用户拥有指定权限(在 AuthRequired 之后使用)
func RequirePermission(permission string) gin.HandlerFunc {
return func(c *gin.Context) {
roleID, ok := roleIDFromContext(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权限"})
c.Abort()
return
}
if roleID == models.RoleIDSuperAdmin {
c.Next()
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
perms := getPermissionsByRoleID(ctx, roleID)
for _, p := range perms {
if p == permission {
c.Next()
return
}
}
c.JSON(http.StatusForbidden, gin.H{"error": "无此操作权限"})
c.Abort()
}
}
// GetMyPermissions 返回当前用户权限列表(供前端菜单、按钮显隐)
func GetMyPermissions(c *gin.Context) {
roleID, ok := roleIDFromContext(c)
if !ok {
c.JSON(http.StatusOK, gin.H{"permissions": []string{}})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
perms := getPermissionsByRoleID(ctx, roleID)
c.JSON(http.StatusOK, gin.H{"permissions": perms})
}

294
server/handlers/register.go Normal file
View File

@@ -0,0 +1,294 @@
package handlers
import (
"context"
"errors"
"log"
"net/http"
"regexp"
"sync"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"yh_web/server/config"
"yh_web/server/models"
"yh_web/server/utils"
"github.com/gin-gonic/gin"
)
const (
testVerifyCode = "8888" // 测试验证码(未接入短信时使用)
codeExpire = 5 * time.Minute // 验证码有效期
)
var (
codeStore = make(map[string]codeEntry)
codeStoreMu sync.RWMutex
)
type codeEntry struct {
Code string
ExpireAt time.Time
}
// SendCodeInput 发送验证码请求
type SendCodeInput struct {
Mobile string `json:"mobile" binding:"required"`
}
// RegisterInput 手机注册请求
type RegisterInput struct {
Mobile string `json:"mobile" binding:"required"`
Code string `json:"code" binding:"required"`
Password string `json:"password" binding:"required"`
Username string `json:"username"` // 可选,默认用手机号
Email string `json:"email"` // 可选
}
// SendCode 发送验证码(测试阶段:任意手机号输入 8888 即可)
func SendCode(c *gin.Context) {
var input SendCodeInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入手机号"})
return
}
if !isValidMobile(input.Mobile) {
c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
return
}
// 未接入短信平台,使用测试验证码 8888
codeStoreMu.Lock()
codeStore[input.Mobile] = codeEntry{
Code: testVerifyCode,
ExpireAt: time.Now().Add(codeExpire),
}
codeStoreMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"message": "验证码已发送(测试环境请输入 8888",
"expire": int(codeExpire.Seconds()),
})
}
// Register 手机号注册
func Register(c *gin.Context) {
var input RegisterInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写手机号、验证码和密码"})
return
}
if !isValidMobile(input.Mobile) {
c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
return
}
if len(input.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "密码至少6位"})
return
}
if input.Email != "" && !isValidEmail(input.Email) {
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return
}
// 校验验证码
codeStoreMu.RLock()
entry, ok := codeStore[input.Mobile]
codeStoreMu.RUnlock()
if !ok || entry.Code != input.Code {
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"})
return
}
if time.Now().After(entry.ExpireAt) {
codeStoreMu.Lock()
delete(codeStore, input.Mobile)
codeStoreMu.Unlock()
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码已过期,请重新获取"})
return
}
// 删除已用验证码
codeStoreMu.Lock()
delete(codeStore, input.Mobile)
codeStoreMu.Unlock()
username := input.Username
if username == "" {
username = input.Mobile
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("users")
// 检查手机号是否已注册
var exist models.User
err := coll.FindOne(ctx, bson.M{"mobile": input.Mobile}).Decode(&exist)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "该手机号已注册"})
return
}
if !errors.Is(err, mongo.ErrNoDocuments) {
log.Printf("[Register] FindOne mobile error: %v", err)
resp := gin.H{"error": "注册失败,请稍后重试"}
if gin.Mode() == gin.DebugMode {
resp["debug"] = err.Error()
}
c.JSON(http.StatusInternalServerError, resp)
return
}
err = coll.FindOne(ctx, bson.M{"username": username}).Decode(&exist)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
return
}
if !errors.Is(err, mongo.ErrNoDocuments) {
log.Printf("[Register] FindOne username error: %v", err)
resp := gin.H{"error": "注册失败,请稍后重试"}
if gin.Mode() == gin.DebugMode {
resp["debug"] = err.Error()
}
c.JSON(http.StatusInternalServerError, resp)
return
}
// 若提供了邮箱,检查是否已注册
if input.Email != "" {
err = coll.FindOne(ctx, bson.M{"email": input.Email}).Decode(&exist)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已注册"})
return
}
if !errors.Is(err, mongo.ErrNoDocuments) {
log.Printf("[Register] FindOne email error: %v", err)
resp := gin.H{"error": "注册失败,请稍后重试"}
if gin.Mode() == gin.DebugMode {
resp["debug"] = err.Error()
}
c.JSON(http.StatusInternalServerError, resp)
return
}
}
doc := bson.M{
"username": username,
"mobile": input.Mobile,
"password": utils.HashPassword(input.Password),
"role": "admin",
"role_id": models.RoleIDSuperAdmin,
}
if input.Email != "" {
doc["email"] = input.Email
}
_, err = coll.InsertOne(ctx, doc)
if err != nil {
log.Printf("[Register] InsertOne error: %v", err)
resp := gin.H{"error": "注册失败,请稍后重试"}
if gin.Mode() == gin.DebugMode {
resp["debug"] = err.Error()
}
c.JSON(http.StatusInternalServerError, resp)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "注册成功",
})
}
// ResetPasswordInput 密码找回请求
type ResetPasswordInput struct {
Mobile string `json:"mobile" binding:"required"`
Code string `json:"code" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
// ResetPassword 密码找回(手机号+验证码)
func ResetPassword(c *gin.Context) {
var input ResetPasswordInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写手机号、验证码和新密码"})
return
}
if !isValidMobile(input.Mobile) {
c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
return
}
if len(input.NewPassword) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码至少6位"})
return
}
// 校验验证码
codeStoreMu.RLock()
entry, ok := codeStore[input.Mobile]
codeStoreMu.RUnlock()
if !ok || entry.Code != input.Code {
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"})
return
}
if time.Now().After(entry.ExpireAt) {
codeStoreMu.Lock()
delete(codeStore, input.Mobile)
codeStoreMu.Unlock()
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码已过期,请重新获取"})
return
}
codeStoreMu.Lock()
delete(codeStore, input.Mobile)
codeStoreMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("users")
var user models.User
err := coll.FindOne(ctx, bson.M{"mobile": input.Mobile}).Decode(&user)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
c.JSON(http.StatusNotFound, gin.H{"error": "该手机号未注册"})
return
}
log.Printf("[ResetPassword] FindOne error: %v", err)
resp := gin.H{"error": "操作失败"}
if gin.Mode() == gin.DebugMode {
resp["debug"] = err.Error()
}
c.JSON(http.StatusInternalServerError, resp)
return
}
_, err = coll.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": bson.M{"password": utils.HashPassword(input.NewPassword)}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码已重置,请登录"})
}
func isValidMobile(mobile string) bool {
// 简单校验11位数字1开头
matched, _ := regexp.MatchString(`^1\d{10}$`, mobile)
return matched
}
func isValidEmail(email string) bool {
// 简单邮箱格式校验
matched, _ := regexp.MatchString(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, email)
return matched
}

View File

@@ -0,0 +1,109 @@
package handlers
import (
"context"
"net/http"
"strconv"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
// 预定义角色(与 users.role_id 对应)
var roleMeta = []struct {
RoleID int `json:"role_id"`
RoleName string `json:"role_name"`
}{
{models.RoleIDSuperAdmin, "超级管理员"},
{models.RoleIDSuperUser, "超级用户"},
{models.RoleIDUser, "普通用户"},
}
// GetRolePermissionsList 返回所有角色及其权限(用于角色权限管理页)
func GetRolePermissionsList(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("role_permissions")
cursor, err := coll.Find(ctx, bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var docs []models.RolePermissionsDoc
if err = cursor.All(ctx, &docs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
permMap := make(map[int][]string)
for _, d := range docs {
permMap[d.RoleID] = d.Permissions
}
list := make([]gin.H, 0, len(roleMeta))
for _, r := range roleMeta {
perms := permMap[r.RoleID]
if perms == nil {
perms = []string{}
}
if r.RoleID == models.RoleIDSuperAdmin {
perms = allPermissionKeys()
}
list = append(list, gin.H{
"role_id": r.RoleID,
"role_name": r.RoleName,
"permissions": perms,
})
}
c.JSON(http.StatusOK, gin.H{
"list": list,
"all_permissions": models.AllPermissions,
})
}
// UpdateRolePermissionsInput 更新某角色权限
type UpdateRolePermissionsInput struct {
Permissions []string `json:"permissions"`
}
// UpdateRolePermissions 更新指定角色的权限
func UpdateRolePermissions(c *gin.Context) {
roleIDStr := c.Param("role_id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 role_id"})
return
}
if roleID == models.RoleIDSuperAdmin {
c.JSON(http.StatusBadRequest, gin.H{"error": "超级管理员权限不可修改"})
return
}
var input UpdateRolePermissionsInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("role_permissions")
filter := bson.M{"role_id": roleID}
update := bson.M{"$set": bson.M{"role_id": roleID, "permissions": input.Permissions}}
opts := options.UpdateOne().SetUpsert(true)
_, err = coll.UpdateOne(ctx, filter, update, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "保存成功", "role_id": roleID, "permissions": input.Permissions})
}

165
server/handlers/site.go Normal file
View File

@@ -0,0 +1,165 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
// GetSites 站点列表
func GetSites(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("sites")
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := coll.Find(ctx, bson.M{}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []models.Site
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, bson.M{})
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
}
// GetSiteByID 单个站点
func GetSiteByID(c *gin.Context) {
idStr := c.Param("site_id")
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var site models.Site
err = config.GetDB(config.DBName).Collection("sites").FindOne(ctx, bson.M{"_id": oid}).Decode(&site)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
return
}
c.JSON(http.StatusOK, site)
}
// CreateSiteInput 创建站点
type CreateSiteInput struct {
Name string `json:"name" binding:"required"`
Domain string `json:"domain"`
Description string `json:"description"`
}
// CreateSite 创建站点
func CreateSite(c *gin.Context) {
var input CreateSiteInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写站点名称"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("sites")
doc := bson.M{
"name": input.Name,
"domain": input.Domain,
"description": input.Description,
"created_at": time.Now().Format(time.RFC3339),
}
res, err := coll.InsertOne(ctx, doc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "message": "创建成功"})
}
// UpdateSiteInput 更新站点
type UpdateSiteInput struct {
Name *string `json:"name"`
Domain *string `json:"domain"`
Description *string `json:"description"`
}
// UpdateSite 更新站点
func UpdateSite(c *gin.Context) {
idStr := c.Param("site_id")
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点ID"})
return
}
var input UpdateSiteInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
set := bson.M{}
if input.Name != nil {
set["name"] = *input.Name
}
if input.Domain != nil {
set["domain"] = *input.Domain
}
if input.Description != nil {
set["description"] = *input.Description
}
if len(set) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = config.GetDB(config.DBName).Collection("sites").UpdateOne(ctx, bson.M{"_id": oid}, bson.M{"$set": set})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
// DeleteSite 删除站点
func DeleteSite(c *gin.Context) {
idStr := c.Param("site_id")
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
db := config.GetDB(config.DBName)
_, err = db.Collection("sites").DeleteOne(ctx, bson.M{"_id": oid})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
db.Collection("pages").DeleteMany(ctx, bson.M{"site_id": idStr})
db.Collection("site_assets").DeleteMany(ctx, bson.M{"site_id": idStr})
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}

View File

@@ -0,0 +1,94 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
const smsConfigDocID = "sms_platform"
// GetSMSConfig 获取短信平台配置(仅超级用户 role_id=0, role=admin
func GetSMSConfig(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("system_config")
var cfg models.SMSConfig
err := coll.FindOne(ctx, bson.M{"_id": smsConfigDocID}).Decode(&cfg)
if err != nil {
if err == mongo.ErrNoDocuments {
c.JSON(http.StatusOK, models.SMSConfig{})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
// SMSConfigUpdateInput 短信配置更新
type SMSConfigUpdateInput struct {
Provider string `json:"provider"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
SignName string `json:"sign_name"`
TemplateID string `json:"template_id"`
Enabled *bool `json:"enabled"`
}
// UpdateSMSConfig 更新短信平台配置(仅超级用户)
func UpdateSMSConfig(c *gin.Context) {
var input SMSConfigUpdateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("system_config")
update := bson.M{}
if input.Provider != "" {
update["provider"] = input.Provider
}
if input.AccessKey != "" {
update["access_key"] = input.AccessKey
}
if input.SecretKey != "" {
update["secret_key"] = input.SecretKey
}
if input.SignName != "" {
update["sign_name"] = input.SignName
}
if input.TemplateID != "" {
update["template_id"] = input.TemplateID
}
if input.Enabled != nil {
update["enabled"] = *input.Enabled
}
if len(update) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
return
}
opts := options.UpdateOne().SetUpsert(true)
_, err := coll.UpdateOne(ctx, bson.M{"_id": smsConfigDocID}, bson.M{"$set": update}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "配置已保存"})
}

34
server/handlers/stats.go Normal file
View File

@@ -0,0 +1,34 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"yh_web/server/config"
"github.com/gin-gonic/gin"
)
func GetStats(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
db := config.GetDB(config.DBName)
users, _ := db.Collection("users").CountDocuments(ctx, bson.M{})
workspaces, _ := db.Collection("workspaces").CountDocuments(ctx, bson.M{})
conversations, _ := db.Collection("conversations").CountDocuments(ctx, bson.M{})
messages, _ := db.Collection("messages").CountDocuments(ctx, bson.M{})
files, _ := db.Collection("files").CountDocuments(ctx, bson.M{})
c.JSON(http.StatusOK, gin.H{
"users": users,
"workspaces": workspaces,
"conversations": conversations,
"messages": messages,
"files": files,
})
}

234
server/handlers/user.go Normal file
View File

@@ -0,0 +1,234 @@
package handlers
import (
"context"
"errors"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"yh_web/server/utils"
"github.com/gin-gonic/gin"
)
func GetUsers(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("users")
page := 1
pageSize := 20
if p := c.Query("page"); p != "" {
if v, err := parseInt(p); err == nil && v > 0 {
page = v
}
}
if ps := c.Query("page_size"); ps != "" {
if v, err := parseInt(ps); err == nil && v > 0 && v <= 100 {
pageSize = v
}
}
skip := (page - 1) * pageSize
opts := options.Find().SetSkip(int64(skip)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "_id", Value: -1}})
cursor, err := coll.Find(ctx, bson.M{}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var users []models.User
if err = cursor.All(ctx, &users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, bson.M{})
c.JSON(http.StatusOK, gin.H{
"list": users,
"total": total,
"page": page,
"page_size": pageSize,
})
}
func GetUserByID(c *gin.Context) {
id := c.Param("id")
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var user models.User
err = config.GetDB(config.DBName).Collection("users").FindOne(ctx, bson.M{"_id": oid}).Decode(&user)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
func CreateUser(c *gin.Context) {
var input models.UserCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("users")
// 检查用户名是否已存在
var exist models.User
if err := coll.FindOne(ctx, bson.M{"username": input.Username}).Decode(&exist); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
return
}
if err := coll.FindOne(ctx, bson.M{"email": input.Email}).Decode(&exist); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已存在"})
return
}
if input.Role == "" {
input.Role = "user"
}
roleID := input.RoleID
if roleID == 0 {
roleID = models.RoleIDUser
}
doc := bson.M{
"username": input.Username,
"email": input.Email,
"password": utils.HashPassword(input.Password),
"role": input.Role,
"role_id": roleID,
"is_beta": input.IsBeta,
}
if input.TrialStartDate != "" {
doc["trial_start_date"] = input.TrialStartDate
}
if input.TrialEndDate != "" {
doc["trial_end_date"] = input.TrialEndDate
}
if input.LLM != "" {
doc["llm"] = input.LLM
}
res, err := coll.InsertOne(ctx, doc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": res.InsertedID, "message": "创建成功"})
}
func UpdateUser(c *gin.Context) {
id := c.Param("id")
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
return
}
var input models.UserUpdateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
update := bson.M{}
if input.Username != nil {
update["username"] = *input.Username
}
if input.Email != nil {
update["email"] = *input.Email
}
if input.Password != nil && *input.Password != "" {
update["password"] = utils.HashPassword(*input.Password)
}
if input.Role != nil {
update["role"] = *input.Role
}
if input.IsBeta != nil {
update["is_beta"] = *input.IsBeta
}
if input.TrialStartDate != nil {
update["trial_start_date"] = *input.TrialStartDate
}
if input.TrialEndDate != nil {
update["trial_end_date"] = *input.TrialEndDate
}
if input.LLM != nil {
update["llm"] = *input.LLM
}
if input.RoleID != nil {
update["role_id"] = *input.RoleID
}
if len(update) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
return
}
res, err := config.GetDB(config.DBName).Collection("users").UpdateOne(ctx, bson.M{"_id": oid}, bson.M{"$set": update})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
func DeleteUser(c *gin.Context) {
id := c.Param("id")
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := config.GetDB(config.DBName).Collection("users").DeleteOne(ctx, bson.M{"_id": oid})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.DeletedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}

View File

@@ -0,0 +1,64 @@
package handlers
import (
"context"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"github.com/gin-gonic/gin"
)
func GetWorkspaces(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("workspaces")
page := 1
pageSize := 20
if p := c.Query("page"); p != "" {
if v, err := parseInt(p); err == nil && v > 0 {
page = v
}
}
if ps := c.Query("page_size"); ps != "" {
if v, err := parseInt(ps); err == nil && v > 0 && v <= 100 {
pageSize = v
}
}
skip := (page - 1) * pageSize
opts := options.Find().SetSkip(int64(skip)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "_id", Value: -1}})
filter := bson.M{}
if userID := c.Query("user_id"); userID != "" {
filter["user_id"] = userID
}
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []map[string]interface{}
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, filter)
c.JSON(http.StatusOK, gin.H{
"list": list,
"total": total,
"page": page,
"page_size": pageSize,
})
}

218
server/main.go Normal file
View File

@@ -0,0 +1,218 @@
package main
import (
"context"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"yh_web/server/config"
"yh_web/server/handlers"
"yh_web/server/middleware"
"yh_web/server/models"
"yh_web/server/pkg/logger"
"yh_web/server/pkg/schema"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
// loadEnv 启动时自动加载 .env在 server 目录或项目根/server 下),不覆盖已存在的环境变量
func loadEnv() {
wd, _ := os.Getwd()
serverDir := wd
if !strings.HasSuffix(filepath.Clean(wd), "server") {
serverDir = filepath.Join(wd, "server")
}
envPath := filepath.Clean(filepath.Join(serverDir, ".env"))
if _, err := os.Stat(envPath); err == nil {
if err := godotenv.Load(envPath); err == nil {
log.Printf("已加载配置: %s", envPath)
}
}
}
func main() {
loadEnv()
// 初始化根目录 logs/server支持从项目根或 server 目录启动)
wd, _ := os.Getwd()
baseDir := filepath.Join(wd, "logs", "server")
if strings.HasSuffix(filepath.Clean(wd), "server") {
baseDir = filepath.Join(wd, "..", "logs", "server")
}
logger.Init(filepath.Clean(baseDir))
// 连接 MongoDBURI 从环境变量 MONGODB_URI 读取,默认 mongodb://localhost:27017SKIP_MONGODB=1 时可跳过
if os.Getenv("SKIP_MONGODB") != "1" {
mongoURI := os.Getenv("MONGODB_URI")
if mongoURI == "" {
mongoURI = "mongodb://localhost:27017"
}
if dbName := os.Getenv("MONGODB_DB"); dbName != "" {
config.DBName = dbName
}
if err := config.ConnectMongoDB(mongoURI); err != nil {
logger.Err("main", "MongoDB 连接失败: %v若仅需健康检查可设置环境变量 SKIP_MONGODB=1 后启动)", err)
log.Fatalf("MongoDB 连接失败: %v若仅需健康检查可设置环境变量 SKIP_MONGODB=1 后启动)", err)
}
defer config.CloseMongoDB()
// 启动时获取线上表结构并生成 sql缺失的集合在线上创建并生成 created_*.sql
projectRoot := wd
if strings.HasSuffix(filepath.Clean(wd), "server") {
projectRoot = filepath.Join(wd, "..")
}
projectRoot = filepath.Clean(projectRoot)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if err := schema.Sync(ctx, projectRoot); err != nil {
logger.Err("main", "启动时同步表结构失败: %v", err)
log.Printf("警告: 启动时同步表结构失败: %v", err)
}
cancel()
} else {
logger.Log("main", "已跳过 MongoDB 连接SKIP_MONGODB=1仅 /api/health 等不依赖数据库的接口可用")
log.Println("已跳过 MongoDB 连接SKIP_MONGODB=1仅 /api/health 等不依赖数据库的接口可用")
}
r := gin.Default()
r.Use(middleware.ErrorLogger())
// CORSALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
r.Use(func(c *gin.Context) {
origin := c.GetHeader("Origin")
if allowedOriginsEnv != "" {
for _, o := range strings.Split(allowedOriginsEnv, ",") {
if strings.TrimSpace(o) == origin {
c.Header("Access-Control-Allow-Origin", origin)
break
}
}
} else {
c.Header("Access-Control-Allow-Origin", "*")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
})
// 未连接 MongoDB 时,/api/admin 下所有接口返回 503健康检查不受影响
r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/admin") && config.MongoClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用,数据库未连接"})
c.Abort()
return
}
c.Next()
})
// 健康检查
r.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// 登录、注册(无需鉴权)
r.POST("/api/admin/login", handlers.Login)
r.POST("/api/admin/send-code", handlers.SendCode)
r.POST("/api/admin/register", handlers.Register)
r.POST("/api/admin/reset-password", handlers.ResetPassword)
// 后台 API 路由组(需鉴权)
admin := r.Group("/api/admin")
admin.Use(handlers.AuthRequired())
{
admin.GET("/info", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "admin api"})
})
admin.GET("/my-permissions", handlers.GetMyPermissions)
admin.GET("/db-structure", func(c *gin.Context) {
structure, err := config.GetDBStructure()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, structure)
})
admin.GET("/stats", handlers.GetStats)
// 用户管理
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
admin.GET("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.GetUserByID)
admin.POST("/users", handlers.RequirePermission(models.PermUserManage), handlers.CreateUser)
admin.PUT("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.UpdateUser)
admin.DELETE("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.DeleteUser)
// 工作空间
admin.GET("/workspaces", handlers.RequirePermission(models.PermWorkspaceManage), handlers.GetWorkspaces)
// 对话
admin.GET("/conversations", handlers.RequirePermission(models.PermConversationManage), handlers.GetConversations)
// 站点管理(带子路径的路由放前面;与 :site_id 统一,避免 Gin 路由冲突)
admin.GET("/sites/:site_id/homepage/download", handlers.RequirePermission(models.PermHomepageEdit), handlers.DownloadHomepage)
admin.GET("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.GetHomepage)
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage)
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
admin.GET("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.GetSiteByID)
admin.POST("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.CreateSite)
admin.PUT("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateSite)
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
// 角色权限管理
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
admin.PUT("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.UpdateRolePermissions)
// 网页管理(按站点)
admin.GET("/pages", handlers.RequirePermission(models.PermPageManage), handlers.GetPages)
admin.GET("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.GetPageByID)
admin.POST("/pages", handlers.RequirePermission(models.PermPageManage), handlers.CreatePage)
admin.PUT("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.UpdatePage)
admin.DELETE("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.DeletePage)
// 短信平台配置
smsConfig := admin.Group("/sms-config")
smsConfig.Use(handlers.RequirePermission(models.PermSMSConfig))
{
smsConfig.GET("", handlers.GetSMSConfig)
smsConfig.PUT("", handlers.UpdateSMSConfig)
}
// 支付配置(微信、支付宝)
paymentConfig := admin.Group("/payment-config")
paymentConfig.Use(handlers.RequirePermission(models.PermPaymentConfig))
{
paymentConfig.GET("", handlers.GetPaymentConfig)
paymentConfig.PUT("", handlers.UpdatePaymentConfig)
}
}
// 官网站点首页(前台,无需鉴权)
r.GET("/api/web/homepage", handlers.GetWebHomepage)
// 前台 API 路由组
web := r.Group("/api/web")
{
web.GET("/info", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "web api"})
})
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
r.Run(":" + port)
}

View File

@@ -0,0 +1,39 @@
package middleware
import (
"bytes"
"yh_web/server/pkg/logger"
"github.com/gin-gonic/gin"
)
// responseBodyWriter 包装 ResponseWriter 以捕获响应体
type responseBodyWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w responseBodyWriter) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
// ErrorLogger 在 4xx/5xx 时记录响应体中的错误信息
func ErrorLogger() gin.HandlerFunc {
return func(c *gin.Context) {
w := &responseBodyWriter{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = w
c.Next()
if w.Status() >= 400 {
if body := w.body.String(); body != "" {
logger.Err("middleware/logger", "[%d] %s %s | body: %s", w.Status(), c.Request.Method, c.Request.URL.Path, body)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package models
import "go.mongodb.org/mongo-driver/v2/bson"
type Conversation struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
Title string `bson:"title" json:"title"`
UserID string `bson:"user_id" json:"user_id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
CreatedAt string `bson:"created_at" json:"created_at"`
UpdatedAt string `bson:"updated_at" json:"updated_at"`
}

24
server/models/payment.go Normal file
View File

@@ -0,0 +1,24 @@
package models
// WechatPayConfig 微信支付配置
type WechatPayConfig struct {
AppID string `bson:"app_id" json:"app_id"`
MchID string `bson:"mch_id" json:"mch_id"`
APIKey string `bson:"api_key" json:"api_key"`
APIKeyV3 string `bson:"api_key_v3" json:"api_key_v3"`
Enabled bool `bson:"enabled" json:"enabled"`
}
// AlipayConfig 支付宝配置
type AlipayConfig struct {
AppID string `bson:"app_id" json:"app_id"`
PrivateKey string `bson:"private_key" json:"private_key"`
AlipayPublicKey string `bson:"alipay_public_key" json:"alipay_public_key"`
Enabled bool `bson:"enabled" json:"enabled"`
}
// PaymentConfig 支付配置(微信+支付宝)
type PaymentConfig struct {
Wechat *WechatPayConfig `bson:"wechat,omitempty" json:"wechat,omitempty"`
Alipay *AlipayConfig `bson:"alipay,omitempty" json:"alipay,omitempty"`
}

View File

@@ -0,0 +1,38 @@
package models
// 权限码(与前端、路由 meta.permission 一致)
const (
PermSiteManage = "site:manage"
PermHomepageEdit = "homepage:edit"
PermPageManage = "page:manage"
PermModuleUpload = "module:upload"
PermUserManage = "user:manage"
PermWorkspaceManage = "workspace:manage"
PermConversationManage = "conversation:manage"
PermSMSConfig = "sms_config"
PermPaymentConfig = "payment_config"
PermRolePermission = "role:permission" // 角色权限管理
)
// AllPermissions 所有可配置权限(用于角色权限管理页)
var AllPermissions = []struct {
Key string
Name string
}{
{PermSiteManage, "站点管理"},
{PermHomepageEdit, "首页编辑"},
{PermPageManage, "网页管理"},
{PermModuleUpload, "功能模块上传"},
{PermUserManage, "用户管理"},
{PermWorkspaceManage, "工作空间"},
{PermConversationManage, "对话管理"},
{PermSMSConfig, "短信配置"},
{PermPaymentConfig, "支付配置"},
{PermRolePermission, "角色权限管理"},
}
// RolePermissionsDoc MongoDB 文档:角色 ID -> 权限列表
type RolePermissionsDoc struct {
RoleID int `bson:"role_id" json:"role_id"`
Permissions []string `bson:"permissions" json:"permissions"`
}

66
server/models/site.go Normal file
View File

@@ -0,0 +1,66 @@
package models
import "go.mongodb.org/mongo-driver/v2/bson"
// Site 站点
type Site struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Domain string `bson:"domain" json:"domain"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
CreatedAt string `bson:"created_at" json:"created_at"`
}
// Page 网页(属于某站点)
type Page struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
SiteID string `bson:"site_id" json:"site_id"`
Slug string `bson:"slug" json:"slug"` // index, about, ...
Title string `bson:"title" json:"title"`
Type string `bson:"type" json:"type"` // homepage, page
Content string `bson:"content" json:"content"` // HTML 或 JSON 字符串
UpdatedAt string `bson:"updated_at" json:"updated_at"`
}
// HomepageData 首页可编辑数据(与 yuheng-download-space 对应)
type HomepageData struct {
LogoText string `json:"logo_text"` // YUHENG ONE
NavLinks []NavLink `json:"nav_links"` // MISSION, DOWNLOAD, CONTACT
Title string `json:"title"` // 宇恒一号
Subtitle string `json:"subtitle"` // INTERSTELLAR EXPLORER EDITION
Description string `json:"description"` // 跨越星际的智能伙伴...
DownloadText string `json:"download_text"` // START EXPLORING
DownloadURL string `json:"download_url"` // #
Platforms []PlatformItem `json:"platforms"` // Windows, macOS, ...
Version string `json:"version"` // VERSION 3.2.1
LaunchYear string `json:"launch_year"` // LAUNCH: 2024
BadgeText string `json:"badge_text"` // FREE ACCESS
Features []FeatureItem `json:"features"` // 星际导航等
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE
}
type NavLink struct {
Label string `json:"label"`
URL string `json:"url"`
}
type PlatformItem struct {
Name string `json:"name"` // WINDOWS, MACOS, ...
URL string `json:"url"`
}
type FeatureItem struct {
Title string `json:"title"`
Desc string `json:"desc"`
}
// SiteAsset 站点功能模块/上传文件
type SiteAsset struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
SiteID string `bson:"site_id" json:"site_id"`
Name string `bson:"name" json:"name"`
FilePath string `bson:"file_path" json:"file_path"` // 相对路径
Size int64 `bson:"size" json:"size"`
ContentType string `bson:"content_type" json:"content_type"`
CreatedAt string `bson:"created_at" json:"created_at"`
}

11
server/models/sms.go Normal file
View File

@@ -0,0 +1,11 @@
package models
// SMSConfig 短信平台配置(仅超级用户 role_id=0, role=admin 可配置)
type SMSConfig struct {
Provider string `bson:"provider" json:"provider"` // 服务商aliyun/tencent/...
AccessKey string `bson:"access_key" json:"access_key"` // AccessKey
SecretKey string `bson:"secret_key" json:"secret_key"` // SecretKey
SignName string `bson:"sign_name" json:"sign_name"` // 签名
TemplateID string `bson:"template_id" json:"template_id"` // 模板ID
Enabled bool `bson:"enabled" json:"enabled"` // 是否已启用
}

77
server/models/user.go Normal file
View File

@@ -0,0 +1,77 @@
package models
import (
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
RoleIDSuperAdmin = 9527 // 超级管理员(后台登录)
RoleIDSuperUser = 0 // 超级用户role_id=0 且 role=admin可配置短信平台
RoleIDUser = 1 // 普通用户
)
// FlexDate 可解码 BSON DateTime 或 string统一以 string 输出(兼容库中既有日期类型又有字符串的情况)
type FlexDate string
func (d *FlexDate) UnmarshalBSONValue(typ byte, data []byte) error {
rv := bson.RawValue{Type: bson.Type(typ), Value: data}
switch bson.Type(typ) {
case bson.TypeDateTime:
if dt, ok := rv.DateTimeOK(); ok {
*d = FlexDate(time.UnixMilli(dt).Format("2006-01-02"))
}
return nil
case bson.TypeString:
if s, ok := rv.StringValueOK(); ok {
*d = FlexDate(s)
}
return nil
case bson.TypeNull, bson.TypeUndefined:
*d = ""
return nil
default:
return fmt.Errorf("FlexDate: cannot decode type %v", typ)
}
}
type User struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
Username string `bson:"username" json:"username"`
Mobile string `bson:"mobile,omitempty" json:"mobile,omitempty"`
Email string `bson:"email" json:"email"`
Password string `bson:"password" json:"-"` // 不返回给前端
Role string `bson:"role" json:"role"`
RoleID int `bson:"role_id,omitempty" json:"role_id"` // 9527=超级管理员 1=普通用户
IsBeta bool `bson:"is_beta,omitempty" json:"is_beta"`
TrialStartDate FlexDate `bson:"trial_start_date,omitempty" json:"trial_start_date,omitempty"`
TrialEndDate FlexDate `bson:"trial_end_date,omitempty" json:"trial_end_date,omitempty"`
LastLogin string `bson:"lastLogin,omitempty" json:"last_login,omitempty"`
LLM string `bson:"llm,omitempty" json:"llm,omitempty"`
}
type UserCreateInput struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
Role string `json:"role"`
RoleID int `json:"role_id"` // 9527=超级管理员 1=普通用户
IsBeta bool `json:"is_beta"`
TrialStartDate string `json:"trial_start_date"`
TrialEndDate string `json:"trial_end_date"`
LLM string `json:"llm"`
}
type UserUpdateInput struct {
Username *string `json:"username"`
Email *string `json:"email"`
Password *string `json:"password"` // 若提供则更新
Role *string `json:"role"`
RoleID *int `json:"role_id"`
IsBeta *bool `json:"is_beta"`
TrialStartDate *string `json:"trial_start_date"`
TrialEndDate *string `json:"trial_end_date"`
LLM *string `json:"llm"`
}

View File

@@ -0,0 +1,10 @@
package models
import "go.mongodb.org/mongo-driver/v2/bson"
type Workspace struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
UserID string `bson:"user_id" json:"user_id"`
CreatedAt string `bson:"created_at" json:"created_at"`
}

View File

@@ -0,0 +1,66 @@
package logger
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
var (
baseDir string
mu sync.Mutex
)
// Init 初始化日志根目录(例如 logs/server应在 main 中调用
func Init(dir string) {
baseDir = filepath.Clean(dir)
_ = os.MkdirAll(baseDir, 0755)
}
// pathFile 返回 path 对应的 .log 或 .err 的完整路径path 如 "main" 或 "handlers/auth"
func pathFile(path, ext string) string {
// 安全化:只保留路径分隔符和字母数字
clean := filepath.Clean(path)
if clean == "" || clean == "." {
clean = "main"
}
full := filepath.Join(baseDir, clean+ext)
dir := filepath.Dir(full)
_ = os.MkdirAll(dir, 0755)
return full
}
func appendLine(fpath, line string) {
if baseDir == "" || fpath == "" {
return
}
mu.Lock()
defer mu.Unlock()
f, err := os.OpenFile(fpath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
_, _ = f.WriteString(line)
}
func line(level, format string, a ...interface{}) string {
return fmt.Sprintf("%s [%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), level, fmt.Sprintf(format, a...))
}
// Log 写普通日志:写入 log.log简单总日志和 path 对应的 xxx.log按路径的详细日志
func Log(path, format string, a ...interface{}) {
msg := line("INFO", format, a...)
appendLine(filepath.Join(baseDir, "log.log"), msg)
appendLine(pathFile(path, ".log"), msg)
}
// Err 写报错:写入 path 对应的 xxx.err仅报错、xxx.log 和 log.log
func Err(path, format string, a ...interface{}) {
msg := line("ERROR", format, a...)
appendLine(pathFile(path, ".err"), msg)
appendLine(pathFile(path, ".log"), msg)
appendLine(filepath.Join(baseDir, "log.log"), msg)
}

203
server/pkg/schema/sync.go Normal file
View File

@@ -0,0 +1,203 @@
package schema
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"yh_web/server/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// 应用需要的集合及索引(与 MongoDB/create_collections.js、sql/init.sql 一致)
var requiredCollections = map[string][]indexSpec{
"sites": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
"pages": {{Keys: bson.D{{Key: "site_id", Value: 1}, {Key: "slug", Value: 1}}, Name: "idx_site_slug", Unique: true}},
"site_assets": {{Keys: bson.D{{Key: "site_id", Value: 1}}, Name: "idx_site_id"}},
"users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}, {Keys: bson.D{{Key: "mobile", Value: 1}}, Name: "idx_mobile", Sparse: true}},
"workspaces": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
"conversations": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}, {Keys: bson.D{{Key: "workspace_id", Value: 1}}, Name: "idx_workspace_id"}},
"messages": {{Keys: bson.D{{Key: "conversation_id", Value: 1}}, Name: "idx_conversation_id"}},
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
"system_config": {},
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
}
type indexSpec struct {
Keys bson.D
Name string
Unique bool
Sparse bool
}
// 每个集合对应的单表 SQL DDL仅 CREATE TABLE 段落,用于 created_*.sql反引号用 \x60 表示
var tableDDL = map[string]string{
"sites": "CREATE TABLE IF NOT EXISTS \x60sites\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键,与 MongoDB ObjectID 字符串一致',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '站点名称',\n \x60domain\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '域名',\n \x60description\x60 TEXT COMMENT '描述',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点表';",
"pages": "CREATE TABLE IF NOT EXISTS \x60pages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60site_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',\n \x60slug\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '路径标识 index, about, ...',\n \x60title\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',\n \x60type\x60 VARCHAR(32) NOT NULL DEFAULT 'page' COMMENT '类型 homepage, page',\n \x60content\x60 LONGTEXT COMMENT 'HTML 或 JSON 字符串',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_site_slug\x60 (\x60site_id\x60, \x60slug\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='网页表';",
"site_assets": "CREATE TABLE IF NOT EXISTS \x60site_assets\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60site_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名/显示名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '相对路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60content_type\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_site_id\x60 (\x60site_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点资源表';",
"users": "CREATE TABLE IF NOT EXISTS \x60users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',\n \x60mobile\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',\n \x60email\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',\n \x60password\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60role\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '角色名',\n \x60role_id\x60 INT NOT NULL DEFAULT 1 COMMENT '9527=超级管理员 1=普通用户',\n \x60is_beta\x60 TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否体验用户',\n \x60trial_start_date\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用开始日期',\n \x60trial_end_date\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用结束日期',\n \x60last_login\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最后登录时间',\n \x60llm\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'LLM 配置',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60),\n KEY \x60idx_mobile\x60 (\x60mobile\x60),\n KEY \x60idx_email\x60 (\x60email\x60),\n KEY \x60idx_role_id\x60 (\x60role_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';",
"workspaces": "CREATE TABLE IF NOT EXISTS \x60workspaces\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '名称',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作空间表';",
"conversations": "CREATE TABLE IF NOT EXISTS \x60conversations\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60title\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60workspace_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '工作空间ID',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60),\n KEY \x60idx_workspace_id\x60 (\x60workspace_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='对话表';",
"messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';",
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
}
// CollectionInfo 线上集合信息(名称、文档数、索引)
type CollectionInfo struct {
Name string `json:"name"`
Count int64 `json:"count"`
Indexes []string `json:"indexes"` // 索引名或 key 描述
}
// Sync 连接 MongoDB 后调用:获取线上表结构并生成 sql缺失的集合在线上创建并生成本次创建的 sql
// projectRoot 为项目根目录sql/ 所在目录)
func Sync(ctx context.Context, projectRoot string) error {
db := config.GetDB(config.DBName)
if db == nil {
return nil
}
sqlDir := filepath.Join(projectRoot, "sql")
_ = os.MkdirAll(sqlDir, 0755)
// 1. 获取当前已有集合
existing, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
return fmt.Errorf("列出集合失败: %w", err)
}
existingSet := make(map[string]bool)
for _, n := range existing {
existingSet[n] = true
}
// 2. 缺失的集合在线上创建(集合 + 索引)
var created []string
for collName, idxSpecs := range requiredCollections {
if !existingSet[collName] {
if err := db.CreateCollection(ctx, collName); err != nil {
return fmt.Errorf("创建集合 %s 失败: %w", collName, err)
}
created = append(created, collName)
coll := db.Collection(collName)
for _, spec := range idxSpecs {
opts := options.Index().SetName(spec.Name)
if spec.Unique {
opts.SetUnique(true)
}
if spec.Sparse {
opts.SetSparse(true)
}
_, _ = coll.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: spec.Keys, Options: opts})
}
existingSet[collName] = true
}
}
sort.Strings(created)
// 3. 拉取线上结构:每个集合的名称、文档数、索引列表
allNames := make([]string, 0, len(existingSet))
for n := range existingSet {
allNames = append(allNames, n)
}
sort.Strings(allNames)
infos := make([]CollectionInfo, 0, len(allNames))
for _, name := range allNames {
coll := db.Collection(name)
count, _ := coll.CountDocuments(ctx, bson.M{})
indexLines := listIndexes(ctx, coll)
infos = append(infos, CollectionInfo{Name: name, Count: count, Indexes: indexLines})
}
ts := time.Now().Format("20060102_150405")
// 4. 生成「线上表结构」的 sql 文件
if err := writeOnlineSchemaSQL(sqlDir, config.DBName, ts, infos); err != nil {
return err
}
// 5. 若有本次创建的集合,生成「需要创建的 sql」并在本地写入线上已通过上面 CreateCollection 创建)
if len(created) > 0 {
if err := writeCreatedSQL(sqlDir, ts, created); err != nil {
return err
}
}
return nil
}
func listIndexes(ctx context.Context, coll *mongo.Collection) []string {
cursor, err := coll.Indexes().List(ctx)
if err != nil {
return nil
}
defer cursor.Close(ctx)
var lines []string
for cursor.Next(ctx) {
var spec bson.M
if err := cursor.Decode(&spec); err != nil {
continue
}
name, _ := spec["name"].(string)
key, _ := spec["key"].(bson.M)
if name != "" {
lines = append(lines, fmt.Sprintf("%s: %v", name, key))
}
}
return lines
}
func writeOnlineSchemaSQL(sqlDir, dbName, ts string, infos []CollectionInfo) error {
var b strings.Builder
b.WriteString("-- 线上 MongoDB 表结构快照(对应数据库: " + dbName + "\n")
b.WriteString("-- 生成时间: " + time.Now().Format("2006-01-02 15:04:05") + "\n")
b.WriteString("-- 以下为集合与索引说明;等效 MySQL 建表见 sql/init.sql\n\n")
b.WriteString("SET NAMES utf8mb4;\n\n")
for _, info := range infos {
b.WriteString("-- -------------------------------\n")
b.WriteString("-- 集合: " + info.Name + " (文档数: " + fmt.Sprintf("%d", info.Count) + ")\n")
b.WriteString("-- -------------------------------\n")
for _, idx := range info.Indexes {
b.WriteString("-- 索引: " + idx + "\n")
}
if len(info.Indexes) == 0 {
b.WriteString("-- 索引: _id\n")
}
b.WriteString("\n")
// 附上等效 CREATE TABLE便于对照
if ddl, ok := tableDDL[info.Name]; ok {
b.WriteString(ddl + "\n\n")
}
}
fpath := filepath.Join(sqlDir, "online_schema_"+ts+".sql")
return os.WriteFile(fpath, []byte(b.String()), 0644)
}
func writeCreatedSQL(sqlDir, ts string, created []string) error {
var b strings.Builder
b.WriteString("-- 本次启动时在线上缺失并已创建的集合,对应 SQL 建表MySQL 等效)\n")
b.WriteString("-- 生成时间: " + time.Now().Format("2006-01-02 15:04:05") + "\n")
b.WriteString("-- 线上 MongoDB 已通过 CreateCollection 创建;本文件供留档与 SQL 环境对照。\n\n")
b.WriteString("SET NAMES utf8mb4;\n\n")
for _, name := range created {
if ddl, ok := tableDDL[name]; ok {
b.WriteString("-- " + name + "\n")
b.WriteString(ddl + "\n\n")
}
}
fpath := filepath.Join(sqlDir, "created_"+ts+".sql")
return os.WriteFile(fpath, []byte(b.String()), 0644)
}

View File

@@ -0,0 +1,74 @@
// 独立运行go run scripts/inspect_db.go
// 查看 MongoDB 数据结构(需先建立 SSH 隧道)
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func main() {
uri := "mongodb://localhost:27017"
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
log.Fatalf("连接失败: %v", err)
}
defer client.Disconnect(context.TODO())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 验证连接
var result bson.M
if err = client.Database("admin").RunCommand(ctx, bson.D{{Key: "ping", Value: 1}}).Decode(&result); err != nil {
log.Fatalf("Ping 失败: %v", err)
}
fmt.Println("MongoDB 连接成功\n")
// 列出数据库(排除系统库)
dbs, err := client.ListDatabaseNames(ctx, bson.M{
"name": bson.M{"$nin": []string{"admin", "config", "local"}},
})
if err != nil {
log.Fatalf("列出数据库失败: %v", err)
}
output := map[string]interface{}{"databases": []interface{}{}}
for _, dbName := range dbs {
db := client.Database(dbName)
colls, _ := db.ListCollectionNames(ctx, bson.M{})
dbInfo := map[string]interface{}{
"name": dbName,
"collections": []interface{}{},
}
for _, collName := range colls {
coll := db.Collection(collName)
count, _ := coll.CountDocuments(ctx, bson.M{})
var sample bson.M
_ = coll.FindOne(ctx, bson.M{}).Decode(&sample)
dbInfo["collections"] = append(dbInfo["collections"].([]interface{}), map[string]interface{}{
"name": collName,
"count": count,
"sample": sample,
})
}
output["databases"] = append(output["databases"].([]interface{}), dbInfo)
}
jsonBytes, _ := json.MarshalIndent(output, "", " ")
fmt.Println(string(jsonBytes))
}

View File

@@ -0,0 +1,13 @@
@echo off
REM MongoDB SSH 穿透 - 将远程 27017 映射到本地 27017
REM 运行此脚本后再启动 server即可连接远程 MongoDB
echo 正在建立 SSH 隧道...
echo 远程: www.yuxindazhineng.com:27017 -^> 本地: localhost:27017
echo.
echo 保持此窗口打开,隧道有效。关闭窗口即断开。
echo.
ssh -p 2223 -L 27017:localhost:27017 yxd@www.yuxindazhineng.com
pause

1
server/uploads/.gitkeep Normal file
View File

@@ -0,0 +1 @@

12
server/utils/password.go Normal file
View File

@@ -0,0 +1,12 @@
package utils
import (
"crypto/sha256"
"encoding/hex"
)
// HashPassword 使用 SHA-256 哈希密码(与 Python 实现一致)
func HashPassword(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
}

View File

@@ -0,0 +1,51 @@
-- 本次启动时在线上缺失并已创建的集合,对应 SQL 建表MySQL 等效)
-- 生成时间: 2026-03-14 14:41:15
-- 线上 MongoDB 已通过 CreateCollection 创建;本文件供留档与 SQL 环境对照。
SET NAMES utf8mb4;
-- pages
CREATE TABLE IF NOT EXISTS `pages` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`site_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',
`slug` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '路径标识 index, about, ...',
`title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',
`type` VARCHAR(32) NOT NULL DEFAULT 'page' COMMENT '类型 homepage, page',
`content` LONGTEXT COMMENT 'HTML 或 JSON 字符串',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_site_slug` (`site_id`, `slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='网页表';
-- site_assets
CREATE TABLE IF NOT EXISTS `site_assets` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`site_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名/显示名',
`file_path` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '相对路径',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`content_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_site_id` (`site_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点资源表';
-- sites
CREATE TABLE IF NOT EXISTS `sites` (
`id` VARCHAR(24) NOT NULL COMMENT '主键,与 MongoDB ObjectID 字符串一致',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '站点名称',
`domain` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '域名',
`description` TEXT COMMENT '描述',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点表';
-- system_config
CREATE TABLE IF NOT EXISTS `system_config` (
`id` VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',
`payload` JSON COMMENT '配置内容(支付/短信等)',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';

134
sql/init.sql Normal file
View File

@@ -0,0 +1,134 @@
-- yh_web 建表语句MySQL 5.7+ / MariaDB 10.2+
-- 与当前功能及线上已有结构对应,便于迁移或对照。主键使用 VARCHAR(24) 与 MongoDB ObjectID 字符串形式兼容。
SET NAMES utf8mb4;
-- -------------------------------
-- 站点
-- -------------------------------
CREATE TABLE IF NOT EXISTS `sites` (
`id` VARCHAR(24) NOT NULL COMMENT '主键,与 MongoDB ObjectID 字符串一致',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '站点名称',
`domain` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '域名',
`description` TEXT COMMENT '描述',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点表';
-- -------------------------------
-- 网页(属于某站点)
-- -------------------------------
CREATE TABLE IF NOT EXISTS `pages` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`site_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',
`slug` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '路径标识 index, about, ...',
`title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',
`type` VARCHAR(32) NOT NULL DEFAULT 'page' COMMENT '类型 homepage, page',
`content` LONGTEXT COMMENT 'HTML 或 JSON 字符串',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_site_slug` (`site_id`, `slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='网页表';
-- -------------------------------
-- 站点资源/上传文件
-- -------------------------------
CREATE TABLE IF NOT EXISTS `site_assets` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`site_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名/显示名',
`file_path` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '相对路径',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`content_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_site_id` (`site_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点资源表';
-- -------------------------------
-- 用户
-- -------------------------------
CREATE TABLE IF NOT EXISTS `users` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
`mobile` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',
`email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
`password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码哈希',
`role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '角色名',
`role_id` INT NOT NULL DEFAULT 1 COMMENT '9527=超级管理员 1=普通用户',
`is_beta` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否体验用户',
`trial_start_date` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用开始日期',
`trial_end_date` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用结束日期',
`last_login` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最后登录时间',
`llm` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'LLM 配置',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_mobile` (`mobile`),
KEY `idx_email` (`email`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- -------------------------------
-- 工作空间
-- -------------------------------
CREATE TABLE IF NOT EXISTS `workspaces` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '名称',
`user_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作空间表';
-- -------------------------------
-- 对话
-- -------------------------------
CREATE TABLE IF NOT EXISTS `conversations` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',
`user_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',
`workspace_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '工作空间ID',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_workspace_id` (`workspace_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='对话表';
-- -------------------------------
-- 消息(统计用,功能上若未实现可先建表)
-- -------------------------------
CREATE TABLE IF NOT EXISTS `messages` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`conversation_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',
`role` VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',
`content` LONGTEXT COMMENT '内容',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_conversation_id` (`conversation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
-- -------------------------------
-- 文件(统计用,功能上若未实现可先建表)
-- -------------------------------
CREATE TABLE IF NOT EXISTS `files` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`user_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',
`file_path` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';
-- -------------------------------
-- 系统配置键值_id 为配置键payment / sms_platform
-- -------------------------------
CREATE TABLE IF NOT EXISTS `system_config` (
`id` VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',
`payload` JSON COMMENT '配置内容(支付/短信等)',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';

View File

@@ -0,0 +1,253 @@
-- 线上 MongoDB 表结构快照(对应数据库: yxd-agent-testing
-- 生成时间: 2026-03-14 14:41:15
-- 以下为集合与索引说明;等效 MySQL 建表见 sql/init.sql
SET NAMES utf8mb4;
-- -------------------------------
-- 集合: app_domains (文档数: 1)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1: {}
-- 索引: domain_prefix_1: {}
-- 索引: token_1: {}
-- -------------------------------
-- 集合: conversations (文档数: 61)
-- -------------------------------
-- 索引: _id_: {}
CREATE TABLE IF NOT EXISTS `conversations` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',
`user_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',
`workspace_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '工作空间ID',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_workspace_id` (`workspace_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='对话表';
-- -------------------------------
-- 集合: file_logs (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: file_id_1_user_id_1_created_at_1: {}
-- -------------------------------
-- 集合: file_permissions (文档数: 8)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: file_id_1_user_id_1_team_id_1: {}
-- -------------------------------
-- 集合: files (文档数: 34)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: owner_id_1_full_path_1: {}
CREATE TABLE IF NOT EXISTS `files` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`user_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',
`file_path` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';
-- -------------------------------
-- 集合: knowledge_bases (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1_name_1: {}
-- 索引: read_users_1: {}
-- 索引: write_users_1: {}
-- -------------------------------
-- 集合: messages (文档数: 1469)
-- -------------------------------
-- 索引: _id_: {}
CREATE TABLE IF NOT EXISTS `messages` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`conversation_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',
`role` VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',
`content` LONGTEXT COMMENT '内容',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_conversation_id` (`conversation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
-- -------------------------------
-- 集合: orders (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1_payment_id_1: {}
-- -------------------------------
-- 集合: overload (文档数: 2)
-- -------------------------------
-- 索引: _id_: {}
-- -------------------------------
-- 集合: pages (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: idx_site_slug: {}
CREATE TABLE IF NOT EXISTS `pages` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`site_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',
`slug` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '路径标识 index, about, ...',
`title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',
`type` VARCHAR(32) NOT NULL DEFAULT 'page' COMMENT '类型 homepage, page',
`content` LONGTEXT COMMENT 'HTML 或 JSON 字符串',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_site_slug` (`site_id`, `slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='网页表';
-- -------------------------------
-- 集合: permanent (文档数: 5)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1_database_id_1_team_id_1: {}
-- -------------------------------
-- 集合: permanent_databases (文档数: 6)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1: {}
-- -------------------------------
-- 集合: public_database (文档数: 5)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: database_name_1_user_id_1_team_id_1: {}
-- -------------------------------
-- 集合: railway_accounts (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1_account_name_1: {}
-- -------------------------------
-- 集合: railway_accounts_new (文档数: 25)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: account_1: {}
-- -------------------------------
-- 集合: scheduler_tasks (文档数: 2)
-- -------------------------------
-- 索引: _id_: {}
-- -------------------------------
-- 集合: site_assets (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: idx_site_id: {}
CREATE TABLE IF NOT EXISTS `site_assets` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`site_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名/显示名',
`file_path` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '相对路径',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`content_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_site_id` (`site_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点资源表';
-- -------------------------------
-- 集合: sites (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: idx_created_at: {}
CREATE TABLE IF NOT EXISTS `sites` (
`id` VARCHAR(24) NOT NULL COMMENT '主键,与 MongoDB ObjectID 字符串一致',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '站点名称',
`domain` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '域名',
`description` TEXT COMMENT '描述',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点表';
-- -------------------------------
-- 集合: system_config (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
CREATE TABLE IF NOT EXISTS `system_config` (
`id` VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',
`payload` JSON COMMENT '配置内容(支付/短信等)',
`updated_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
-- -------------------------------
-- 集合: team_membership (文档数: 47)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: team_id_1_user_id_1: {}
-- -------------------------------
-- 集合: teams (文档数: 3)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: name_1: {}
-- -------------------------------
-- 集合: users (文档数: 45)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: username_1: {}
-- 索引: email_1: {}
CREATE TABLE IF NOT EXISTS `users` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
`mobile` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',
`email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
`password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码哈希',
`role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '角色名',
`role_id` INT NOT NULL DEFAULT 1 COMMENT '9527=超级管理员 1=普通用户',
`is_beta` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否体验用户',
`trial_start_date` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用开始日期',
`trial_end_date` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用结束日期',
`last_login` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最后登录时间',
`llm` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'LLM 配置',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_mobile` (`mobile`),
KEY `idx_email` (`email`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- -------------------------------
-- 集合: word_config (文档数: 0)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1: {}
-- -------------------------------
-- 集合: workspaces (文档数: 73)
-- -------------------------------
-- 索引: _id_: {}
-- 索引: user_id_1_name_1: {}
CREATE TABLE IF NOT EXISTS `workspaces` (
`id` VARCHAR(24) NOT NULL COMMENT '主键',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '名称',
`user_id` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',
`created_at` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作空间表';

4
stop-docker.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
docker compose down 2>/dev/null || docker-compose down
echo "已停止所有容器"

5
web/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# 复制为 .env 或 .env.production 后修改
# 前台(官网)环境变量 - 仅 VITE_ 前缀会在构建时注入
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
VITE_API_BASE=

6
web/.env.production Normal file
View File

@@ -0,0 +1,6 @@
# 前台(官网)生产环境 - 对外域名
# 构建时生效npm run build
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
# 与官网同域,接口走 /api留空即可
VITE_API_BASE=

12
web/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
RUN echo 'server { listen 80; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
web/README.md Normal file
View File

@@ -0,0 +1,12 @@
# 多站点管理 - 前台
基于 Vue 3 + Vite 的前台展示页面。
## 运行
```bash
npm install
npm run dev
```
默认端口 3001API 代理到 8080

15
web/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>宇恒一号 - 星际探索版</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@300;400;700;900&family=Noto+Sans+SC:wght@300;400;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1509
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
web/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "yh-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"axios": "^1.6.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.10"
}
}

21
web/src/App.vue Normal file
View File

@@ -0,0 +1,21 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
min-height: 100%;
}
body {
background: #0a0a12;
color: #fff;
overflow-x: hidden;
}
</style>

View File

@@ -0,0 +1,187 @@
/* 首页动效:星星、流星、标题、按钮、平台图标 - 全局生效 */
/* 星星闪烁 */
.landing .stars .star {
position: absolute;
background: #fff;
border-radius: 50%;
animation: landing-twinkle var(--duration, 2.5s) ease-in-out infinite;
will-change: opacity, transform;
}
@keyframes landing-twinkle {
0%, 100% { opacity: var(--min-opacity, 0.3); transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
/* 流星 */
.landing ~ .comet,
body .comet {
position: fixed !important;
width: 120px;
height: 3px;
z-index: 99;
pointer-events: none;
background: linear-gradient(90deg, rgba(255,255,255,0.9) 0%, transparent 100%);
border-radius: 2px;
box-shadow: 0 0 10px rgba(255,255,255,0.6);
}
/* 标题浮动 + 发光 */
.landing .title-3d {
animation: landing-float-title 4s ease-in-out infinite;
}
@keyframes landing-float-title {
0%, 100% { transform: translateY(0) rotateX(0deg); }
50% { transform: translateY(-15px) rotateX(5deg); }
}
.landing .title-3d span {
animation: landing-glow-text 3s ease-in-out infinite;
}
@keyframes landing-glow-text {
0%, 100% { text-shadow: 0 0 10px rgba(0,212,255,0.5); }
50% { text-shadow: 0 0 30px rgba(255,45,149,0.8); }
}
/* 副标题脉冲 */
.landing .subtitle-space {
animation: landing-pulse-glow 2s ease-in-out infinite;
}
@keyframes landing-pulse-glow {
0%, 100% { opacity: 0.7; text-shadow: 0 0 10px #00d4ff; }
50% { opacity: 1; text-shadow: 0 0 30px #00d4ff; }
}
/* 按钮波纹 */
.landing .warp-effect {
animation: landing-warp-drive 1.5s ease-out infinite;
}
@keyframes landing-warp-drive {
0% { width: 0; height: 0; opacity: 1; }
100% { width: 300px; height: 300px; opacity: 0; }
}
/* 平台图标:立体水波纹(双层环+立体光晕)+ 波纹间碰撞效果 */
.landing .orbit-platform {
position: relative;
z-index: 1;
transition: all 0.4s ease;
overflow: visible;
}
/* 立体水波纹 - 内环(先动) */
.landing .orbit-platform::before {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
border-radius: 50%;
border: 2px solid rgba(0,212,255,0.5);
box-shadow:
0 0 0 1px rgba(0,212,255,0.3),
inset 0 0 15px rgba(0,212,255,0.08),
0 0 20px rgba(0,212,255,0.15);
animation: orbit-ripple-inner 2s ease-out infinite;
pointer-events: none;
}
/* 立体水波纹 - 外环(主波,带厚度与景深) */
.landing .orbit-platform::after {
content: '';
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
border: 1px solid rgba(0,212,255,0.9);
border-radius: 50%;
box-shadow:
0 0 0 2px rgba(0,212,255,0.25),
0 0 0 4px rgba(0,212,255,0.1),
0 0 15px rgba(0,212,255,0.35),
0 0 30px rgba(255,45,149,0.15),
inset 0 0 20px rgba(0,212,255,0.06);
animation: orbit-pulse-ripple 2s ease-out infinite;
pointer-events: none;
}
@keyframes orbit-ripple-inner {
0% { transform: scale(1); opacity: 0.7; }
100% { transform: scale(1.45); opacity: 0; }
}
@keyframes orbit-pulse-ripple {
0% { transform: scale(1); opacity: 0.9; }
100% { transform: scale(1.5); opacity: 0; }
}
/* 中心碰撞光点(波纹交汇瞬间) */
.landing .orbit-platforms {
position: relative;
}
.landing .orbit-platforms::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 24px;
height: 24px;
margin-left: -12px;
margin-top: -12px;
border-radius: 50%;
background: radial-gradient(circle, rgba(0,212,255,0.95) 0%, rgba(255,45,149,0.5) 35%, transparent 65%);
box-shadow: 0 0 40px rgba(0,212,255,0.7), 0 0 80px rgba(255,45,149,0.4);
pointer-events: none;
z-index: 0;
animation: orbit-collision 2s ease-out infinite;
}
@keyframes orbit-collision {
0%, 48% { transform: scale(0.2); opacity: 0; }
58% { transform: scale(0.6); opacity: 0.5; }
72% { transform: scale(1); opacity: 1; }
85% { transform: scale(1.3); opacity: 0.4; }
100% { transform: scale(1.6); opacity: 0; }
}
/* 碰撞扩散环:波纹相撞后产生的同心圆扩散(立体环) */
.landing .orbit-collision-ring {
position: absolute;
left: 50%;
top: 50%;
width: 60px;
height: 60px;
margin-left: -30px;
margin-top: -30px;
border-radius: 50%;
border: 2px solid transparent;
box-shadow:
0 0 0 1px rgba(0,212,255,0.6),
0 0 0 3px rgba(0,212,255,0.2),
0 0 25px rgba(0,212,255,0.4),
inset 0 0 15px rgba(0,212,255,0.1);
pointer-events: none;
z-index: 0;
animation: orbit-collision-ring 2s ease-out infinite;
}
@keyframes orbit-collision-ring {
0%, 52% { transform: scale(0.3); opacity: 0; border-color: rgba(0,212,255,0); }
62% { transform: scale(0.6); opacity: 0.8; border-color: rgba(0,212,255,0.8); }
75% { transform: scale(1); opacity: 1; border-color: rgba(255,45,149,0.4); }
90% { transform: scale(1.5); opacity: 0.3; border-color: rgba(0,212,255,0.1); }
100% { transform: scale(1.8); opacity: 0; border-color: rgba(0,212,255,0); }
}
.landing .orbit-platform:hover {
background: rgba(0,212,255,0.25);
border-color: #00d4ff;
transform: translateY(-12px) scale(1.08);
box-shadow: 0 12px 35px rgba(0,212,255,0.4);
}
.landing .orbit-platform:hover::before,
.landing .orbit-platform:hover::after {
animation-duration: 1.2s;
}
/* 底部卡片 hover */
.landing .feature-space {
transition: all 0.5s ease;
}
.landing .feature-space:hover {
transform: translateY(-15px);
border-color: rgba(0,212,255,0.4);
box-shadow: 0 25px 50px rgba(0,0,0,0.3);
}

8
web/src/config.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* 前台(官网)运行时常量,来自构建时注入的环境变量
* 开发时未设置则为空,请求走同源 /api依赖 vite proxy
*/
const appDomain = import.meta.env.VITE_APP_DOMAIN || ''
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
export { appDomain, apiBase }

Some files were not shown because too many files have changed in this diff Show More