Files
akmon/pages/admins/layout.uvue
2026-01-20 08:04:15 +08:00

908 lines
24 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="admin-layout">
<view class="admin-sidebar" :class="{ collapsed: sidebarCollapsed }">
<view class="sidebar-header">
<image class="logo" src="/static/logo.png" mode="aspectFit"></image>
<text v-if="!sidebarCollapsed" class="logo-text">智跑后台管理</text>
<text class="toggle-btn" @click="toggleSidebar">{{ sidebarCollapsed ? '<27>? : '<27>? }}</text>
</view>
<scroll-view scroll-y class="menu-container">
<view class="menu-section">
<view class="menu-header" v-if="!sidebarCollapsed">
<text>数据中心</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/index') }"
@click="navigateTo('/pages/admin/index')"
>
<text class="menu-icon">📊</text>
<text v-if="!sidebarCollapsed" class="menu-text">总览</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/analytics') }"
@click="navigateTo('/pages/admin/analytics')"
>
<text class="menu-icon">📈</text>
<text v-if="!sidebarCollapsed" class="menu-text">数据分析</text>
</view>
</view>
<view class="menu-section">
<view class="menu-header" v-if="!sidebarCollapsed">
<text>学校管理</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/schools') }"
@click="navigateTo('/pages/admin/schools/index')"
v-if="canViewSchools"
>
<text class="menu-icon">🏫</text>
<text v-if="!sidebarCollapsed" class="menu-text">学校列表</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/classes') }"
@click="navigateTo('/pages/admin/classes/index')"
v-if="canManageClass"
>
<text class="menu-icon">👨‍></text>
<text v-if="!sidebarCollapsed" class="menu-text">班级管理</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/teachers') }"
@click="navigateTo('/pages/admin/teachers/index')"
v-if="canManageTeachers"
>
<text class="menu-icon">👨‍></text>
<text v-if="!sidebarCollapsed" class="menu-text">教师管理</text>
</view>
</view>
<view class="menu-section">
<view class="menu-header" v-if="!sidebarCollapsed">
<text>训练管理</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/training') }"
@click="navigateTo('/pages/admin/training/index')"
>
<text class="menu-icon">🏃</text>
<text v-if="!sidebarCollapsed" class="menu-text">训练计划</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/assignments') }"
@click="navigateTo('/pages/admin/assignments/index')"
>
<text class="menu-icon">📝</text>
<text v-if="!sidebarCollapsed" class="menu-text">作业管理</text>
</view>
</view>
<view class="menu-section">
<view class="menu-header" v-if="!sidebarCollapsed">
<text>系统设置</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/roles') }"
@click="navigateTo('/pages/admin/roles/index')"
v-if="canManageRoles"
>
<text class="menu-icon">🔑</text>
<text v-if="!sidebarCollapsed" class="menu-text">角色权限</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/users') }"
@click="navigateTo('/pages/admin/users/index')"
v-if="canManageUsers"
>
<text class="menu-icon">👤</text>
<text v-if="!sidebarCollapsed" class="menu-text">用户管理</text>
</view>
<view
class="menu-item"
:class="{ active: isActive('/pages/admin/settings') }"
@click="navigateTo('/pages/admin/settings/index')"
>
<text class="menu-icon">⚙️</text>
<text v-if="!sidebarCollapsed" class="menu-text">系统设置</text>
</view>
</view>
</scroll-view>
</view>
<view class="admin-main" :style="{ marginLeft: sidebarCollapsed ? '60px' : '240px' }">
<view class="admin-header">
<view class="header-left">
<view class="header-title">{{ pageTitle }}</view>
</view>
<view class="header-right">
<view class="notification-btn" @click="showNotifications">
<text class="notification-icon">🔔</text>
<view v-if="unreadCount > 0" class="badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</view>
</view>
<view class="user-dropdown" @click="toggleUserMenu">
<image class="avatar" :src="currentUser.avatar_url ?? '/static/avatar-default.png'" mode="aspectFill"></image>
<text v-if="!sidebarCollapsed" class="username">{{ currentUser.username }}</text>
<text class="dropdown-icon">></text>
<view class="dropdown-menu" v-if="showUserMenu">
<view class="dropdown-item" @click="navigateTo('/pages/user/profile')">
<text>个人信息</text>
</view>
<view class="dropdown-item" @click="changePassword">
<text>修改密码</text>
</view>
<view class="dropdown-item logout" @click="logout">
<text>退出登></text>
</view>
</view>
</view>
</view>
</view>
<view class="admin-content">
<slot></slot>
</view>
</view>
<!-- 通知抽屉 -->
<view class="notification-drawer" v-if="showNotificationDrawer" @click.stop>
<view class="drawer-content" @click.stop>
<view class="drawer-header">
<text class="drawer-title">消息通知</text>
<text class="close-btn" @click="showNotificationDrawer = false">×</text>
</view>
<view class="drawer-tabs">
<view
class="drawer-tab"
:class="{ active: notificationTab === 'unread' }"
@click="notificationTab = 'unread'"
>
未读 ({{ unreadCount }})
</view>
<view
class="drawer-tab"
:class="{ active: notificationTab === 'all' }"
@click="notificationTab = 'all'"
>
全部
</view>
</view>
<view class="drawer-body">
<view v-if="loadingNotifications" class="loading-state">
<text>加载<E58AA0>?..</text>
</view>
<view v-else-if="notifications.length === 0" class="empty-state">
<text>暂无通知</text>
</view>
<view v-else class="notification-list">
<view
v-for="(notification, index) in (filteredNotifications as UTSJSONObject[] ?? [])"
:key="notification.get('id')"
class="notification-item"
:class="{ unread: notification.get('status') === 'unread' }"
@click="readNotification(notification)"
>
<text class="notification-title">{{ notification.get('title') }}</text>
<text class="notification-content">{{ notification.get('content') }}</text>
<text class="notification-time">{{ formatNotificationTime(`${notification.get('created_at') ?? ''}`) }}</text>
</view>
</view>
</view>
<view class="drawer-footer">
<button v-if="unreadCount > 0" class="mark-all-read" @click="markAllAsRead">
全部标为已读
</button>
</view>
</view>
</view>
<!-- 修改密码模态框 -->
<view class="modal" v-if="showPasswordModal">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">修改密码</text>
<text class="close-btn" @click="showPasswordModal = false">×</text>
</view>
<view class="modal-body">
<view class="form-group">
<text class="form-label">当前密码:</text>
<input type="password" v-model="passwordForm.oldPassword" placeholder="请输入当前密<E5898D>? />
</view>
<view class="form-group">
<text class="form-label">新密<E696B0>?</text>
<input type="password" v-model="passwordForm.newPassword" placeholder="请输入新密码" />
</view>
<view class="form-group">
<text class="form-label">确认密码:</text>
<input type="password" v-model="passwordForm.confirmPassword" placeholder="请再次输入新密码" />
</view>
</view>
<view class="modal-footer">
<button class="cancel-btn" @click="showPasswordModal = false">取消</button>
<button class="submit-btn" @click="updatePassword">确定</button>
</view>
</view>
</view>
<!-- 遮罩<E981AE>?-->
<view
class="overlay"
v-if="showUserMenu || showNotificationDrawer"
@click="closeAllPopups"
></view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { hasPermission, getUserPermissions } from '@/utils/permissionService.uts'
import { state as userStore, getCurrentUser, logout as doLogout } from '../../utils/store.uts'
import { User, Notification, PasswordForm } from './admintypes.uts'
import db from '@/components/supadb/aksupainstance.uts'
// 侧边栏状<E6A08F>?
const sidebarCollapsed = ref(false)
// 用户相关
const currentUser = reactive<User>({
id: '', username: '', email: '', avatar_url: '', role: ''
})
const showUserMenu = ref(false)
// 权限状<E99990>?
const canManageRoles = ref(false)
const canManageUsers = ref(false)
const canViewSchools = ref(false)
const canManageClass = ref(false)
const canManageTeachers = ref(false)
// 页面标题
const pageTitle = ref('后台管理')
// 通知相关
const notifications = ref<UTSJSONObject[]>([])
const unreadCount = ref(0)
const showNotificationDrawer = ref(false)
const notificationTab = ref('unread')
const loadingNotifications = ref(false)
// 修改密码相关
const showPasswordModal = ref(false)
const passwordForm = reactive<PasswordForm>({
oldPassword: '', newPassword: '', confirmPassword: ''
})
// 计算属性:根据当前选项卡过滤通知
const filteredNotifications = computed<UTSJSONObject[]>(() => {
if (notificationTab.value === 'unread') {
// Replace filter with for loop for UTS compatibility
const unreadNotifications: UTSJSONObject[] = []
for (let i = 0; i < notifications.value.length; i++) {
const n = notifications.value[i]
if (n.get('status') === 'unread') {
unreadNotifications.push(n)
}
}
return unreadNotifications
}
return notifications.value
})
// 获取当前用户信息
// 检查各项权<E9A1B9>?
const checkPermissions = async () => {
const userId = currentUser.id
canManageRoles.value = await hasPermission({ userId, permissionCode: 'admin.roles.manage' })
canManageUsers.value = await hasPermission({ userId, permissionCode: 'admin.users.manage' })
canViewSchools.value = await hasPermission({ userId, permissionCode: 'school_admin.class.manage' }) ||
await hasPermission({ userId, permissionCode: 'admin.system.manage' })
canManageClass.value = await hasPermission({ userId, permissionCode: 'school_admin.class.manage' })
canManageTeachers.value = await hasPermission({ userId, permissionCode: 'school_admin.teacher.manage' })
}
// 设置页面标题根据当前路<E5898D>?
const setPageTitle = () => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
// @ts-ignore
const route = currentPage?.route ?? ''
if (route.includes('/admin/index')) pageTitle.value = '后台首页'
else if (route.includes('/admin/schools')) pageTitle.value = '学校管理'
else if (route.includes('/admin/classes')) pageTitle.value = '班级管理'
else if (route.includes('/admin/teachers')) pageTitle.value = '教师管理'
else if (route.includes('/admin/users')) pageTitle.value = '用户管理'
else if (route.includes('/admin/roles')) pageTitle.value = '角色权限管理'
else if (route.includes('/admin/settings')) pageTitle.value = '系统设置'
else if (route.includes('/admin/training')) pageTitle.value = '训练计划'
else if (route.includes('/admin/assignments')) pageTitle.value = '作业管理'
else if (route.includes('/admin/analytics')) pageTitle.value = '数据分析'
else pageTitle.value = '后台管理'
}
// 获取通知列表
const fetchNotifications = async () => {
loadingNotifications.value = true
try {
const { data, error } = await db.from('ak_notifications')
.select('*', {})
.eq('user_id', currentUser.id)
.order('created_at', { ascending: false })
.limit(50)
.execute()
if (error != null) return if (Array.isArray(data)) {
notifications.value = data as UTSJSONObject[]
// Replace filter with for loop for UTS compatibility
let unreadCountValue = 0
for (let i = 0; i < data.length; i++) {
const n = data[i] as UTSJSONObject
if (n.get('status') === 'unread') {
unreadCountValue++
}
}
unreadCount.value = unreadCountValue
} else {
notifications.value = []
unreadCount.value = 0
}
} catch (error) {
} finally {
loadingNotifications.value = false
}
}
// 阅读通知
const readNotification = async (notification: UTSJSONObject) => {
if (notification.get('status') === 'unread') {
try {
const result = await db.from('ak_notifications')
.update({ status: 'read' })
.eq('id', `${notification.get('id') ?? ''}`)
.execute()
const error = result.error
if (error == null) {
notification.set('status', 'read')
unreadCount.value--
}
} catch (error) {}
}
}
// 标记所有为已读
const markAllAsRead = async () => {
try {
const result = await db.from('ak_notifications')
.update({ status: 'read' })
.eq('user_id', currentUser.id)
.eq('status', 'unread')
.execute()
const error = result.error if (error == null) {
// Replace map with for loop for UTS compatibility
for (let i = 0; i < notifications.value.length; i++) {
const n = notifications.value[i]
n.set('status', 'read')
}
unreadCount.value = 0
}
} catch (error) {}
}
const padZero = (num: number): string => num < 10 ? `0${num}` : `${num}`
const formatNotificationTime = (timeStr: string): string => {
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return '刚刚'
else if (diffMins < 60) return `${diffMins}分钟前`
else if (diffHours < 24) return `${diffHours}小时前`
else if (diffDays < 30) return `${diffDays}天前`
else return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())}`
}
const toggleSidebar = () => { sidebarCollapsed.value = !sidebarCollapsed.value }
const toggleUserMenu = (e: Event) => { e.stopPropagation(); showUserMenu.value = !showUserMenu.value }
const closeAllPopups = () => { showUserMenu.value = false; showNotificationDrawer.value = false }
const showNotifications = () => { showNotificationDrawer.value = true; fetchNotifications() }
const updatePassword = async () => {
if (passwordForm.oldPassword == null || passwordForm.oldPassword == '') { uni.showToast({ title: '请输入当前密<E5898D>?, icon: 'none' }); return }
if (passwordForm.newPassword == null || passwordForm.newPassword == '') { uni.showToast({ title: '请输入新密码', icon: 'none' }); return }
if (passwordForm.newPassword !== passwordForm.confirmPassword) { uni.showToast({ title: '两次输入的密码不一<E4B88D>?, icon: 'none' }); return }
try {
const { error } = await db.rpc('change_user_password', {
p_old_password: passwordForm.oldPassword,
p_new_password: passwordForm.newPassword
})
if (error != null) {
uni.showToast({ title: '更新密码失败: ' + (error?.message ?? ''), icon: 'none' }); return
}
uni.showToast({ title: '密码更新成功', icon: 'success' })
showPasswordModal.value = false
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
} catch (error) {
uni.showToast({ title: '更新密码失败', icon: 'none' })
}
}
const changePassword = () => { showUserMenu.value = false; showPasswordModal.value = true }
const logout = async () => {
try {
await doLogout()
uni.reLaunch({ url: '/pages/user/login' })
} catch (error) {}
}
const isActive = (route: string): boolean => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
let currentRoute = ''
if (currentPage != null && typeof currentPage.route === 'string') {
currentRoute = currentPage.route ?? ''
}
return currentRoute.indexOf(route) !== -1
}
const navigateTo = (url: string) => { uni.navigateTo({ url }) }
const init = async () => {
// 检查是否已登录未登录直接跳转登录<E799BB>?
console.log('layout init')
const userData = await getCurrentUser()
console.log('layout getcurrentuser',userData)
if (userData == null || userData.id==null) {
uni.redirectTo({ url: '/pages/user/login' })
return
}
else
{
currentUser.id = userData.id??""
currentUser.username = userData.username??""
currentUser.email = userData.email??""
currentUser.avatar_url = userData.avatar_url
currentUser.role = userData.role??"anon"
}
await checkPermissions()
fetchNotifications()
setPageTitle()
}
onMounted(() => { init() })
onUnmounted(() => {})
</script>
<style>
.admin-layout {
display: flex;
width: 100%; }
/* 侧边栏样<E6A08F>?*/
.admin-sidebar {
width: 240px; background-color: #001529;
color: rgba(255, 255, 255, 0.65);
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
transition: width 0.2s;
overflow: hidden;
}
.admin-sidebar.collapsed {
width: 60px;
}
.sidebar-header {
height: 60px;
display: flex;
align-items: center;
padding: 0 16px;
box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.1);
position: relative;
}
.logo {
width: 30px;
height: 30px;
}
.logo-text {
margin-left: 10px;
font-size: 16px;
font-weight: bold;
color: #fff;
white-space: nowrap;
}
.toggle-btn {
position: absolute;
right: 10px;
top: 18px;
font-size: 14px;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
}
.menu-container {
height: calc(100% - 60px);
overflow-y: auto;
}
.menu-section {
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.menu-section:last-child {
border-bottom: none;
}
.menu-header {
padding: 8px 16px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
}
.menu-item {
padding: 8px 16px;
display: flex; align-items: center;
cursor: pointer;
transition: background-color 0.3s ease;
}
.menu-item.active {
background-color: #1890ff;
color: #fff;
}
.menu-icon {
font-size: 18px;
margin-right: 12px;
}
.menu-text {
font-size: 14px;
white-space: nowrap;
}
/* 主内容区样式 */
.admin-main {
flex: 1;
margin-left: 240px; transition: margin-left 0.2s;
display: flex;
flex-direction: column; }
.admin-header {
height: 60px;
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 0 20px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.header-left {
display: flex;
align-items: center;
}
.header-title {
font-size: 18px;
font-weight: bold;
}
.header-right {
display: flex;
flex-direction: row;
align-items: center;
}
.notification-btn {
position: relative;
margin-right: 20px;
cursor: pointer;
}
.notification-icon {
font-size: 20px;
}
.badge {
position: absolute;
top: -5px;
right: -8px;
min-width: 16px;
height: 16px;
line-height: 16px;
padding: 0 4px;
border-radius: 8px;
background-color: #f5222d;
color: white;
font-size: 12px;
text-align: center;
}
.user-dropdown {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
cursor: pointer;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 16px;
margin-right: 10px;
}
.username {
font-size: 14px;
margin-right: 5px;
}
.dropdown-icon {
font-size: 12px;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background-color: #fff;
min-width: 120px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 1000;
}
.dropdown-item {
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.dropdown-item.logout {
color: #f5222d;
}
.admin-content {
flex: 1;
padding: 20px;
background-color: #f0f2f5;
overflow-y: auto;
}
/* 通知抽屉样式 */
.notification-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 380px;
z-index: 1001;
background-color: rgba(0, 0, 0, 0.5);
}
.drawer-content {
width: 100%; background-color: #fff;
display: flex;
flex-direction: column;
}
.drawer-header {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.drawer-title {
font-size: 16px;
font-weight: bold;
}
.close-btn {
font-size: 20px;
cursor: pointer;
}
.drawer-tabs {
display: flex;
border-bottom: 1px solid #e8e8e8;
}
.drawer-tab {
flex: 1;
text-align: center;
padding: 10px 0;
font-size: 14px;
cursor: pointer;
position: relative;
}
.drawer-tab.active {
color: #1890ff;
font-weight: bold;
}
.drawer-tab.active::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: #1890ff;
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.loading-state, .empty-state {
padding: 20px;
text-align: center;
color: #999;
}
.notification-list {
display: flex;
flex-direction: column;
}
.notification-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.notification-item.unread {
background-color: #e6f7ff;
}
.notification-title {
font-size: 14px;
font-weight: bold;
margin-bottom: 4px;
display: block;
}
.notification-content {
font-size: 12px;
color: #666;
margin-bottom: 4px;
display: block;
}
.notification-time {
font-size: 12px;
color: #999;
display: block;
}
.drawer-footer {
padding: 16px;
border-top: 1px solid #e8e8e8;
text-align: center;
}
.mark-all-read {
width: 100%;
height: 36px;
line-height: 36px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
}
/* 修改密码模态框 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1002;
}
.modal-content {
background-color: white;
width: 400px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.modal-header {
padding: 15px;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 18px;
font-weight: bold;
}
.modal-body {
padding: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-label {
display: block;
margin-bottom: 5px;
}
.modal-body input {
width: 100%;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 10px;
}
.modal-footer {
padding: 10px 15px;
border-top: 1px solid #ebeef5;
display: flex;
justify-content: flex-end;
}
.cancel-btn, .submit-btn {
padding: 8px 15px;
border-radius: 4px;
margin-left: 10px;
}
.cancel-btn {
border: 1px solid #dcdfe6;
background-color: white;
}
.submit-btn {
background-color: #1890ff;
color: white;
border: none;
}
/* 遮罩<E981AE>?*/
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.1);
z-index: 999;
}
</style>