宇恒一号官网
This commit is contained in:
5
admin/.env.example
Normal file
5
admin/.env.example
Normal 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
6
admin/.env.production
Normal file
@@ -0,0 +1,6 @@
|
||||
# 后台(管理端)生产环境 - 对外域名
|
||||
# 构建时生效:npm run build
|
||||
|
||||
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
|
||||
# 与官网同域,接口走 /api,留空即可
|
||||
VITE_API_BASE=
|
||||
12
admin/Dockerfile
Normal file
12
admin/Dockerfile
Normal 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
12
admin/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 多站点管理后台 - 前端
|
||||
|
||||
基于 Vue 3 + Vite + Element Plus 的管理后台。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认端口 3000,API 代理到 8080
|
||||
12
admin/index.html
Normal file
12
admin/index.html
Normal 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
1694
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
admin/package.json
Normal file
22
admin/package.json
Normal 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
20
admin/src/App.vue
Normal 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
73
admin/src/api/admin.js
Normal 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
34
admin/src/api/request.js
Normal 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
|
||||
112
admin/src/layouts/AdminLayout.vue
Normal file
112
admin/src/layouts/AdminLayout.vue
Normal 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
11
admin/src/main.js
Normal 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
111
admin/src/router/index.js
Normal 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
60
admin/src/stores/auth.js
Normal 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
|
||||
}
|
||||
}
|
||||
18
admin/src/utils/disable-debug.js
Normal file
18
admin/src/utils/disable-debug.js
Normal 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()
|
||||
}
|
||||
})
|
||||
88
admin/src/views/Dashboard.vue
Normal file
88
admin/src/views/Dashboard.vue
Normal 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
294
admin/src/views/Login.vue
Normal 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>
|
||||
55
admin/src/views/conversations/ConversationList.vue
Normal file
55
admin/src/views/conversations/ConversationList.vue
Normal 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>
|
||||
121
admin/src/views/settings/PaymentConfig.vue
Normal file
121
admin/src/views/settings/PaymentConfig.vue
Normal 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>
|
||||
102
admin/src/views/settings/RolePermissions.vue
Normal file
102
admin/src/views/settings/RolePermissions.vue
Normal 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>
|
||||
102
admin/src/views/settings/SMSConfig.vue
Normal file
102
admin/src/views/settings/SMSConfig.vue
Normal 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>
|
||||
217
admin/src/views/sites/HomepageEdit.vue
Normal file
217
admin/src/views/sites/HomepageEdit.vue
Normal 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>
|
||||
129
admin/src/views/sites/ModuleUpload.vue
Normal file
129
admin/src/views/sites/ModuleUpload.vue
Normal 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>
|
||||
176
admin/src/views/sites/PageList.vue
Normal file
176
admin/src/views/sites/PageList.vue
Normal 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>
|
||||
160
admin/src/views/sites/SiteList.vue
Normal file
160
admin/src/views/sites/SiteList.vue
Normal 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>
|
||||
213
admin/src/views/users/UserList.vue
Normal file
213
admin/src/views/users/UserList.vue
Normal 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>
|
||||
54
admin/src/views/workspaces/WorkspaceList.vue
Normal file
54
admin/src/views/workspaces/WorkspaceList.vue
Normal 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
17
admin/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user