初始提交:企业级日报系统完整代码

功能特性:
-  JWT用户认证系统
-  日报CRUD管理
-  三级权限控制
-  多维度搜索过滤
-  统计分析功能
-  评论互动系统
-  响应式Cool Admin界面
-  暗色主题支持

 技术栈:
- 后端:Django 4.2.7 + DRF + SimpleJWT
- 前端:Vue 3 + Element Plus + Pinia
- 数据库:SQLite/PostgreSQL
- 部署:Docker + Nginx

 包含内容:
- 完整的后端API代码
- 现代化前端界面
- 数据库迁移文件
- 部署脚本和文档
- 演示页面和测试工具
This commit is contained in:
jiangmingzhao
2025-09-13 14:35:15 +08:00
commit 9b9ee273fc
78 changed files with 24709 additions and 0 deletions

51
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,51 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
<style lang="scss">
#app {
height: 100vh;
width: 100vw;
overflow: hidden;
}
// 全局滚动条样式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 147, 153, 0.5);
}
::-webkit-scrollbar-track {
background: transparent;
}
// Element Plus 样式调整
.el-message {
min-width: 300px;
}
.el-loading-mask {
background-color: rgba(255, 255, 255, 0.8);
}
.dark .el-loading-mask {
background-color: rgba(0, 0, 0, 0.8);
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<router-view :key="key" />
</keep-alive>
</transition>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
const route = useRoute()
const appStore = useAppStore()
// 缓存的视图
const cachedViews = computed(() => appStore.cachedViews)
// 路由key用于强制刷新组件
const key = computed(() => route.path)
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.app-main {
min-height: calc(100vh - #{$header-height} - #{$tags-height});
width: 100%;
position: relative;
overflow: hidden;
background-color: $bg-color;
}
// 暗色模式
.dark {
.app-main {
background-color: $bg-color-dark;
}
}
// 页面切换动画
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.5s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
<span
v-if="item.redirect === 'noRedirect' || index === levelList.length - 1"
class="no-redirect"
>
{{ item.meta.title }}
</span>
<a v-else @click.prevent="handleLink(item)">
{{ item.meta.title }}
</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const levelList = ref([])
// 获取面包屑列表
const getBreadcrumb = () => {
// 过滤掉没有meta.title的路由
let matched = route.matched.filter(item => item.meta && item.meta.title)
// 获取第一个元素
const first = matched[0]
// 如果第一个元素不是dashboard则添加dashboard
if (!isDashboard(first)) {
matched = [{ path: '/dashboard', meta: { title: '工作台' } }].concat(matched)
}
levelList.value = matched.filter(item => {
return item.meta && item.meta.title && item.meta.breadcrumb !== false
})
}
// 判断是否为首页
const isDashboard = (route) => {
const name = route && route.name
if (!name) {
return false
}
return name.trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
}
// 处理链接点击
const handleLink = (item) => {
const { redirect, path } = item
if (redirect) {
router.push(redirect)
return
}
router.push(path)
}
// 监听路由变化
watch(route, getBreadcrumb, { immediate: true })
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: $header-height;
margin-left: 8px;
.no-redirect {
color: $text-color-secondary;
cursor: text;
}
a {
color: $text-color-regular;
cursor: pointer;
text-decoration: none;
&:hover {
color: $primary-color;
}
}
}
// 暗色模式
.dark {
.app-breadcrumb.el-breadcrumb {
.no-redirect {
color: $text-color-secondary-dark;
}
a {
color: $text-color-regular-dark;
&:hover {
color: $primary-color;
}
}
}
}
// 面包屑动画
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-move {
transition: all 0.5s;
}
.breadcrumb-leave-active {
position: absolute;
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<div class="navbar">
<!-- 左侧 -->
<div class="navbar-left">
<!-- 折叠按钮 -->
<div class="hamburger-container" @click="toggleSideBar">
<el-icon class="hamburger" :class="{ 'is-active': appStore.sidebarOpened }">
<Fold v-if="appStore.sidebarOpened" />
<Expand v-else />
</el-icon>
</div>
<!-- 面包屑 -->
<breadcrumb class="breadcrumb-container" />
</div>
<!-- 右侧 -->
<div class="navbar-right">
<!-- 主题切换 -->
<div class="right-menu-item" @click="toggleTheme">
<el-icon>
<Sunny v-if="appStore.isDark" />
<Moon v-else />
</el-icon>
</div>
<!-- 全屏 -->
<div class="right-menu-item" @click="toggleFullscreen">
<el-icon>
<FullScreen />
</el-icon>
</div>
<!-- 用户菜单 -->
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<img
v-if="authStore.user?.avatar"
:src="authStore.user.avatar"
class="user-avatar"
alt="avatar"
>
<el-avatar v-else class="user-avatar" :size="32">
{{ authStore.userName.charAt(0) }}
</el-avatar>
<span class="user-name">{{ authStore.userName }}</span>
<el-icon class="el-icon-caret-bottom">
<CaretBottom />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<router-link to="/profile">
<el-dropdown-item>
<el-icon><User /></el-icon>
个人中心
</el-dropdown-item>
</router-link>
<el-dropdown-item divided @click="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Fold,
Expand,
Sunny,
Moon,
FullScreen,
CaretBottom,
User,
SwitchButton
} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import Breadcrumb from './Breadcrumb.vue'
const router = useRouter()
const appStore = useAppStore()
const authStore = useAuthStore()
// 切换侧边栏
const toggleSideBar = () => {
appStore.toggleSidebar()
}
// 切换主题
const toggleTheme = () => {
appStore.toggleTheme()
ElMessage.success(`已切换到${appStore.isDark ? '暗色' : '亮色'}主题`)
}
// 切换全屏
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
}
// 退出登录
const logout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await authStore.logout()
ElMessage.success('退出登录成功')
await router.push('/login')
} catch (error) {
if (error !== 'cancel') {
console.error('退出登录失败:', error)
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.navbar {
height: $header-height;
background: $header-bg;
border-bottom: 1px solid $header-border-color;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
position: relative;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.navbar-left {
display: flex;
align-items: center;
}
.hamburger-container {
line-height: $header-height;
height: $header-height;
float: left;
cursor: pointer;
transition: background 0.3s;
padding: 0 15px;
margin-left: -15px;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
color: #5a5e66;
}
}
.breadcrumb-container {
margin-left: 16px;
}
.navbar-right {
display: flex;
align-items: center;
.right-menu-item {
display: inline-block;
padding: 0 12px;
height: $header-height;
line-height: $header-height;
color: #5a5e66;
vertical-align: text-bottom;
cursor: pointer;
transition: background 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
.el-icon {
font-size: 18px;
}
}
.avatar-container {
margin-left: 10px;
.avatar-wrapper {
display: flex;
align-items: center;
cursor: pointer;
padding: 5px 0;
.user-avatar {
cursor: pointer;
border-radius: 50%;
}
.user-name {
margin-left: 10px;
font-size: 14px;
color: $text-color-primary;
}
.el-icon-caret-bottom {
cursor: pointer;
margin-left: 5px;
font-size: 12px;
color: #5a5e66;
}
}
}
}
// 暗色模式
.dark {
.navbar {
background: $header-bg-dark;
border-color: $border-color-dark;
.hamburger-container {
&:hover {
background: rgba(255, 255, 255, 0.1);
}
.hamburger {
color: #cfd3dc;
}
}
.right-menu-item {
color: #cfd3dc;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.avatar-container {
.avatar-wrapper {
.user-name {
color: $text-color-primary-dark;
}
.el-icon-caret-bottom {
color: #cfd3dc;
}
}
}
}
}
// 移动端适配
@media screen and (max-width: $sm) {
.navbar {
padding: 0 15px;
}
.breadcrumb-container {
display: none;
}
.avatar-container {
.avatar-wrapper {
.user-name {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div
ref="scrollContainer"
class="scroll-container"
@wheel.prevent="handleScroll"
>
<div ref="scrollWrapper" class="scroll-wrapper">
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const emit = defineEmits(['scroll'])
const scrollContainer = ref()
const scrollWrapper = ref()
let scrollLeft = 0
let scrollTop = 0
const handleScroll = (e) => {
const eventDelta = e.wheelDelta || -e.deltaY * 40
const $container = scrollContainer.value
const $containerWidth = $container.offsetWidth
const $wrapper = scrollWrapper.value
const $wrapperWidth = $wrapper.offsetWidth
if (eventDelta > 0) {
scrollLeft = Math.max(0, scrollLeft - 50)
} else {
if ($containerWidth - 50 < $wrapperWidth) {
if (scrollLeft < $wrapperWidth - $containerWidth + 50) {
scrollLeft += 50
}
}
}
$wrapper.style.left = scrollLeft * -1 + 'px'
emit('scroll')
}
const moveToTarget = (currentTag) => {
const $container = scrollContainer.value
const $containerWidth = $container.offsetWidth
const $wrapper = scrollWrapper.value
if (!currentTag) {
return
}
const tagList = $wrapper.querySelectorAll('.tags-view-item')
let firstTag = null
let lastTag = null
if (tagList.length > 0) {
firstTag = tagList[0]
lastTag = tagList[tagList.length - 1]
}
if (firstTag === currentTag) {
scrollLeft = 0
} else if (lastTag === currentTag) {
scrollLeft = $wrapper.offsetWidth - $containerWidth
} else {
const tagListArray = [...tagList]
const currentIndex = tagListArray.findIndex(item => item === currentTag)
const prevTag = tagListArray[currentIndex - 1]
const nextTag = tagListArray[currentIndex + 1]
const afterNextTagOffsetLeft = nextTag ? nextTag.offsetLeft + nextTag.offsetWidth + 4 : 0
const beforePrevTagOffsetLeft = prevTag ? prevTag.offsetLeft - 4 : 0
if (afterNextTagOffsetLeft > scrollLeft + $containerWidth) {
scrollLeft = afterNextTagOffsetLeft - $containerWidth
} else if (beforePrevTagOffsetLeft < scrollLeft) {
scrollLeft = beforePrevTagOffsetLeft
}
}
$wrapper.style.left = scrollLeft * -1 + 'px'
}
// 暴露方法
defineExpose({
moveToTarget
})
</script>
<style lang="scss" scoped>
.scroll-container {
white-space: nowrap;
position: relative;
overflow: hidden;
width: 100%;
.scroll-wrapper {
position: absolute;
top: 0px;
height: 100%;
transition: left 0.3s ease-in-out;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<component :is="linkProps.is" v-bind="linkProps.props">
<slot />
</component>
</template>
<script setup>
import { computed } from 'vue'
import { isExternal } from '@/utils/validate'
const props = defineProps({
to: {
type: String,
required: true
}
})
const linkProps = computed(() => {
if (isExternal(props.to)) {
return {
is: 'a',
props: {
href: props.to,
target: '_blank',
rel: 'noopener'
}
}
}
return {
is: 'router-link',
props: {
to: props.to
}
}
})
</script>

View File

@@ -0,0 +1,141 @@
<template>
<!-- 单个菜单项 -->
<template v-if="!item.children || (item.children && item.children.length === 1 && !item.children[0].children)">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
<el-icon v-if="onlyOneChild.meta.icon">
<component :is="onlyOneChild.meta.icon" />
</el-icon>
<template #title>
<span>{{ onlyOneChild.meta.title }}</span>
</template>
</el-menu-item>
</app-link>
</template>
<!-- 有子菜单的菜单项 -->
<el-submenu v-else :index="resolvePath(item.path)" popper-append-to-body>
<template #title>
<el-icon v-if="item.meta && item.meta.icon">
<component :is="item.meta.icon" />
</el-icon>
<span>{{ item.meta?.title }}</span>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
/>
</el-submenu>
</template>
<script setup>
import { computed } from 'vue'
import { isExternal } from '@/utils/validate'
import AppLink from './Link.vue'
const props = defineProps({
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
})
// 只有一个子项时的处理
const onlyOneChild = computed(() => {
const { children, ...item } = props.item
if (children && children.length === 1 && !children[0].children) {
return children[0]
}
// 如果没有子项,返回自身
if (!children || children.length === 0) {
return { ...item, path: '' }
}
return false
})
// 解析路径
function resolvePath(routePath) {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
return path.resolve(props.basePath, routePath)
}
// 简单的路径解析函数
const path = {
resolve(...paths) {
let resolvedPath = ''
let resolvedAbsolute = false
for (let i = paths.length - 1; i >= 0 && !resolvedAbsolute; i--) {
const path = paths[i]
if (typeof path !== 'string') {
throw new TypeError('Arguments to path.resolve must be strings')
}
if (!path) {
continue
}
resolvedPath = path + '/' + resolvedPath
resolvedAbsolute = path.charAt(0) === '/'
}
resolvedPath = normalizeArray(resolvedPath.split('/').filter(p => !!p), !resolvedAbsolute).join('/')
return (resolvedAbsolute ? '/' : '') + resolvedPath || '.'
}
}
function normalizeArray(parts, allowAboveRoot) {
const res = []
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
if (!p || p === '.') {
continue
}
if (p === '..') {
if (res.length && res[res.length - 1] !== '..') {
res.pop()
} else if (allowAboveRoot) {
res.push('..')
}
} else {
res.push(p)
}
}
return res
}
</script>
<style lang="scss" scoped>
.el-menu-item.submenu-title-noDropdown {
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="sidebar-wrapper">
<!-- Logo -->
<div class="sidebar-logo">
<router-link to="/" class="logo-link">
<img v-if="!appStore.sidebarOpened" src="/favicon.ico" alt="Logo" class="logo-mini">
<div v-else class="logo-full">
<img src="/favicon.ico" alt="Logo" class="logo-icon">
<span class="logo-text">日报系统</span>
</div>
</router-link>
</div>
<!-- 菜单 -->
<el-scrollbar class="sidebar-menu-container">
<el-menu
:default-active="activeMenu"
:collapse="!appStore.sidebarOpened"
:unique-opened="true"
:collapse-transition="false"
mode="vertical"
:background-color="variables.sidebarBg"
:text-color="variables.sidebarTextColor"
:active-text-color="variables.sidebarActiveColor"
>
<sidebar-item
v-for="route in permissionRoutes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { authRoutes } from '@/router'
import SidebarItem from './SidebarItem.vue'
import variables from '@/styles/variables.scss'
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
// 当前激活的菜单
const activeMenu = computed(() => {
const { meta, path } = route
if (meta.activeMenu) {
return meta.activeMenu
}
return path
})
// 根据权限过滤路由
const permissionRoutes = computed(() => {
return filterRoutes(authRoutes)
})
// 过滤路由
function filterRoutes(routes) {
const res = []
routes.forEach(route => {
const tmp = { ...route }
// 检查路由权限
if (hasPermission(tmp)) {
// 如果有子路由,递归过滤
if (tmp.children) {
tmp.children = filterRoutes(tmp.children)
}
// 只有当路由本身有权限或者有可访问的子路由时才添加
if (!tmp.meta?.hideInMenu && (tmp.children?.length > 0 || !tmp.children)) {
res.push(tmp)
}
}
})
return res
}
// 检查权限
function hasPermission(route) {
if (route.meta?.permission) {
return authStore.hasPermission(route.meta.permission)
}
return true
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.sidebar-wrapper {
height: 100%;
background-color: $sidebar-bg;
display: flex;
flex-direction: column;
}
.sidebar-logo {
height: $header-height;
display: flex;
align-items: center;
justify-content: center;
background-color: $sidebar-bg;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.logo-link {
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: $sidebar-active-color;
.logo-mini {
width: 32px;
height: 32px;
}
.logo-full {
display: flex;
align-items: center;
.logo-icon {
width: 32px;
height: 32px;
margin-right: 12px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
color: $sidebar-active-color;
}
}
}
}
.sidebar-menu-container {
flex: 1;
:deep(.el-scrollbar__view) {
height: 100%;
}
}
// 暗色模式
.dark {
.sidebar-wrapper {
background-color: $sidebar-bg-dark;
}
.sidebar-logo {
background-color: $sidebar-bg-dark;
}
}
// 菜单样式调整
:deep(.el-menu) {
border-right: none;
.el-menu-item {
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
&.is-active {
background-color: rgba(64, 158, 255, 0.2) !important;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: #409eff;
}
}
}
.el-submenu {
.el-submenu__title {
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
}
}
// 收起状态下的样式
&.el-menu--collapse {
.el-submenu {
.el-submenu__title {
span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<div class="tags-view-container">
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
<router-link
v-for="tag in visitedViews"
:key="tag.path"
:class="isActive(tag) ? 'active' : ''"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
class="tags-view-item"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent="openMenu(tag, $event)"
>
{{ tag.title }}
<el-icon
v-if="!isAffix(tag)"
class="el-icon-close"
@click.prevent.stop="closeSelectedTag(tag)"
>
<Close />
</el-icon>
</router-link>
</scroll-pane>
<!-- 右键菜单 -->
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)">刷新</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">关闭</li>
<li @click="closeOthersTags">关闭其他</li>
<li @click="closeAllTags(selectedTag)">关闭所有</li>
</ul>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Close } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
import ScrollPane from './ScrollPane.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const scrollPaneRef = ref()
const visible = ref(false)
const top = ref(0)
const left = ref(0)
const selectedTag = ref({})
const affixTags = ref([])
// 计算属性
const visitedViews = computed(() => appStore.visitedViews)
// 判断标签是否激活
const isActive = (tag) => {
return tag.path === route.path
}
// 判断是否为固定标签
const isAffix = (tag) => {
return tag.meta && tag.meta.affix
}
// 添加标签
const addTags = () => {
const { name } = route
if (name) {
appStore.addVisitedView(route)
appStore.addCachedView(route)
}
return false
}
// 关闭选中的标签
const closeSelectedTag = (view) => {
appStore.delVisitedView(view)
appStore.delCachedView(view)
if (isActive(view)) {
toLastView(appStore.visitedViews, view)
}
}
// 关闭其他标签
const closeOthersTags = () => {
router.push(selectedTag.value)
appStore.delOthersVisitedViews(selectedTag.value)
appStore.delOthersCachedViews(selectedTag.value)
}
// 关闭所有标签
const closeAllTags = (view) => {
appStore.delAllVisitedViews()
appStore.delAllCachedViews()
if (affixTags.value.some(tag => tag.path === view.path)) {
return
}
toLastView(appStore.visitedViews, view)
}
// 刷新选中的标签
const refreshSelectedTag = (view) => {
appStore.delCachedView(view)
const { fullPath } = view
nextTick(() => {
router.replace({
path: '/redirect' + fullPath
})
})
}
// 跳转到最后一个标签
const toLastView = (visitedViews, view) => {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
router.push(latestView.fullPath)
} else {
// 如果TagsView全部被关闭了则重定向到主页
if (view.name === 'Dashboard') {
// 重新加载页面避免出现bug
router.replace({ path: '/redirect' + view.fullPath })
} else {
router.push('/')
}
}
}
// 打开右键菜单
const openMenu = (tag, e) => {
const menuMinWidth = 105
const offsetLeft = scrollPaneRef.value.$el.getBoundingClientRect().left
const offsetWidth = scrollPaneRef.value.$el.offsetWidth
const maxLeft = offsetWidth - menuMinWidth
const left_ = e.clientX - offsetLeft + 15
if (left_ > maxLeft) {
left.value = maxLeft
} else {
left.value = left_
}
top.value = e.clientY
visible.value = true
selectedTag.value = tag
}
// 关闭右键菜单
const closeMenu = () => {
visible.value = false
}
// 处理滚动
const handleScroll = () => {
closeMenu()
}
// 监听路由变化
watch(route, () => {
addTags()
})
// 监听点击事件,关闭右键菜单
watch(visible, (value) => {
if (value) {
document.body.addEventListener('click', closeMenu)
} else {
document.body.removeEventListener('click', closeMenu)
}
})
// 初始化
addTags()
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.tags-view-container {
height: $tags-height;
width: 100%;
background: $tags-bg;
border-bottom: 1px solid $border-color;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 2px 0 rgba(0, 0, 0, 0.24);
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
text-decoration: none;
&:first-of-type {
margin-left: 15px;
}
&:last-of-type {
margin-right: 15px;
}
&.active {
background-color: $primary-color;
color: #fff;
border-color: $primary-color;
&::before {
content: '';
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 2px;
}
}
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
// 暗色模式
.dark {
.tags-view-container {
background: $tags-bg-dark;
border-color: $border-color-dark;
.tags-view-wrapper {
.tags-view-item {
color: #cfd3dc;
background: #1d1e1f;
border-color: $border-color-dark;
&.active {
background-color: $primary-color;
color: #fff;
border-color: $primary-color;
}
.el-icon-close {
&:hover {
background-color: #4c4d4f;
color: #fff;
}
}
}
}
.contextmenu {
background: #1d1e1f;
color: #cfd3dc;
li {
&:hover {
background: #4c4d4f;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<div class="app-wrapper" :class="classObj">
<!-- 侧边栏 -->
<sidebar class="sidebar-container" />
<!-- 主要内容区域 -->
<div class="main-container">
<!-- 顶部导航栏 -->
<navbar />
<!-- 标签页 -->
<tags-view v-if="needTagsView" />
<!-- 页面内容 -->
<app-main />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import Sidebar from './components/Sidebar/index.vue'
import Navbar from './components/Navbar.vue'
import TagsView from './components/TagsView.vue'
import AppMain from './components/AppMain.vue'
const appStore = useAppStore()
// 是否需要标签页
const needTagsView = computed(() => true)
// 动态类名
const classObj = computed(() => {
return {
hideSidebar: !appStore.sidebarOpened,
openSidebar: appStore.sidebarOpened,
withoutAnimation: appStore.sidebar.withoutAnimation,
mobile: appStore.device === 'mobile'
}
})
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.app-wrapper {
position: relative;
height: 100vh;
width: 100%;
display: flex;
&.mobile {
.main-container {
margin-left: 0;
}
&.openSidebar {
position: fixed;
top: 0;
}
}
&.hideSidebar {
.sidebar-container {
width: $sidebar-collapsed-width !important;
}
.main-container {
margin-left: $sidebar-collapsed-width;
}
}
&.openSidebar {
.sidebar-container {
width: $sidebar-width !important;
}
.main-container {
margin-left: $sidebar-width;
}
}
&.withoutAnimation {
.sidebar-container,
.main-container {
transition: none;
}
}
}
.sidebar-container {
width: $sidebar-width;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
overflow: hidden;
transition: width 0.28s;
// 重置element-plus的menu样式
:deep(.el-menu) {
border: none;
}
}
.main-container {
min-height: 100vh;
margin-left: $sidebar-width;
position: relative;
display: flex;
flex-direction: column;
transition: margin-left 0.28s;
flex: 1;
}
// 移动端适配
@media screen and (max-width: $sm) {
.app-wrapper {
&.mobile {
.sidebar-container {
width: $sidebar-width !important;
transform: translateX(-100%);
&.openSidebar {
transform: translateX(0);
}
}
.main-container {
margin-left: 0 !important;
}
}
}
// 移动端遮罩
.app-wrapper.mobile.openSidebar::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
}
}
</style>

41
frontend/src/main.js Normal file
View File

@@ -0,0 +1,41 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
// Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 自定义样式
import '@/styles/index.scss'
// NProgress
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 创建应用
const app = createApp(App)
// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 使用插件
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
// 配置NProgress
NProgress.configure({
showSpinner: false,
minimum: 0.2,
easing: 'ease',
speed: 500
})
// 挂载应用
app.mount('#app')

View File

@@ -0,0 +1,217 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import NProgress from 'nprogress'
// 导入页面组件
const Layout = () => import('@/layout/index.vue')
const Login = () => import('@/views/Login.vue')
const Dashboard = () => import('@/views/Dashboard.vue')
const ReportList = () => import('@/views/reports/ReportList.vue')
const ReportForm = () => import('@/views/reports/ReportForm.vue')
const ReportDetail = () => import('@/views/reports/ReportDetail.vue')
const Profile = () => import('@/views/Profile.vue')
const NotFound = () => import('@/views/404.vue')
// 基础路由
const constantRoutes = [
{
path: '/login',
name: 'Login',
component: Login,
meta: {
title: '登录',
requiresAuth: false,
hideInMenu: true
}
},
{
path: '/404',
name: 'NotFound',
component: NotFound,
meta: {
title: '页面未找到',
requiresAuth: false,
hideInMenu: true
}
}
]
// 需要认证的路由
const authRoutes = [
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: Dashboard,
meta: {
title: '工作台',
icon: 'House',
requiresAuth: true
}
}
]
},
{
path: '/reports',
component: Layout,
meta: {
title: '日报管理',
icon: 'Document',
requiresAuth: true
},
children: [
{
path: '',
name: 'ReportList',
component: ReportList,
meta: {
title: '日报列表',
requiresAuth: true
}
},
{
path: 'create',
name: 'ReportCreate',
component: ReportForm,
meta: {
title: '创建日报',
requiresAuth: true,
hideInMenu: true
}
},
{
path: ':id/edit',
name: 'ReportEdit',
component: ReportForm,
meta: {
title: '编辑日报',
requiresAuth: true,
hideInMenu: true
}
},
{
path: ':id',
name: 'ReportDetail',
component: ReportDetail,
meta: {
title: '日报详情',
requiresAuth: true,
hideInMenu: true
}
}
]
},
{
path: '/profile',
component: Layout,
children: [
{
path: '',
name: 'Profile',
component: Profile,
meta: {
title: '个人中心',
icon: 'User',
requiresAuth: true
}
}
]
}
]
// 所有路由
const routes = [...constantRoutes, ...authRoutes]
// 404 路由(必须放在最后)
routes.push({
path: '/:pathMatch(.*)*',
redirect: '/404'
})
// 创建路由器
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
NProgress.start()
const authStore = useAuthStore()
const appStore = useAppStore()
// 设置页面标题
const title = to.meta.title ? `${to.meta.title} - ${appStore.title}` : appStore.title
document.title = title
// 检查是否需要认证
if (to.meta.requiresAuth !== false && !authStore.isLoggedIn) {
// 需要认证但未登录,跳转到登录页
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
// 已登录用户访问登录页,跳转到首页
if (to.path === '/login' && authStore.isLoggedIn) {
next({ path: '/' })
return
}
// 如果已登录但没有用户信息,尝试获取用户信息
if (authStore.isLoggedIn && !authStore.user) {
try {
await authStore.getUserInfo()
} catch (error) {
console.error('获取用户信息失败:', error)
// 如果获取用户信息失败,清除认证信息并跳转到登录页
authStore.clearAuthData()
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
}
// 检查路由权限
if (to.meta.requiresAuth && to.meta.permission) {
if (!authStore.hasPermission(to.meta.permission)) {
// 没有权限跳转到404页面
next('/404')
return
}
}
// 添加到访问记录
if (to.name && to.meta.title && !to.meta.hideInMenu) {
appStore.addVisitedView(to)
appStore.addCachedView(to)
}
next()
})
router.afterEach(() => {
NProgress.done()
})
export default router
// 导出路由配置,供菜单使用
export { authRoutes }

152
frontend/src/stores/app.js Normal file
View File

@@ -0,0 +1,152 @@
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
// 侧边栏
sidebar: {
opened: true,
withoutAnimation: false
},
// 设备类型
device: 'desktop',
// 主题
theme: 'light',
// 加载状态
loading: false,
// 页面标题
title: '企业级日报系统',
// 标签页
visitedViews: [],
cachedViews: []
}),
getters: {
sidebarOpened: (state) => state.sidebar.opened,
isMobile: (state) => state.device === 'mobile',
isDark: (state) => state.theme === 'dark'
},
actions: {
// 切换侧边栏
toggleSidebar() {
this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = false
},
// 关闭侧边栏
closeSidebar(withoutAnimation = false) {
this.sidebar.opened = false
this.sidebar.withoutAnimation = withoutAnimation
},
// 设置设备类型
setDevice(device) {
this.device = device
},
// 切换主题
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light'
this.updateTheme()
},
// 设置主题
setTheme(theme) {
this.theme = theme
this.updateTheme()
},
// 更新主题
updateTheme() {
const html = document.documentElement
if (this.theme === 'dark') {
html.classList.add('dark')
} else {
html.classList.remove('dark')
}
localStorage.setItem('theme', this.theme)
},
// 初始化主题
initTheme() {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
this.setTheme(savedTheme)
} else {
// 检查系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
this.setTheme(prefersDark ? 'dark' : 'light')
}
},
// 设置加载状态
setLoading(loading) {
this.loading = loading
},
// 设置页面标题
setTitle(title) {
this.title = title
document.title = title
},
// 添加访问过的视图
addVisitedView(view) {
if (this.visitedViews.some(v => v.path === view.path)) return
this.visitedViews.push({
name: view.name,
path: view.path,
title: view.meta?.title || view.name
})
},
// 删除访问过的视图
delVisitedView(view) {
const index = this.visitedViews.findIndex(v => v.path === view.path)
if (index > -1) {
this.visitedViews.splice(index, 1)
}
},
// 删除其他访问过的视图
delOthersVisitedViews(view) {
this.visitedViews = this.visitedViews.filter(v => v.path === view.path)
},
// 删除所有访问过的视图
delAllVisitedViews() {
this.visitedViews = []
},
// 添加缓存视图
addCachedView(view) {
if (this.cachedViews.includes(view.name)) return
if (!view.meta?.noCache) {
this.cachedViews.push(view.name)
}
},
// 删除缓存视图
delCachedView(view) {
const index = this.cachedViews.indexOf(view.name)
if (index > -1) {
this.cachedViews.splice(index, 1)
}
},
// 删除其他缓存视图
delOthersCachedViews(view) {
this.cachedViews = this.cachedViews.filter(name => name === view.name)
},
// 删除所有缓存视图
delAllCachedViews() {
this.cachedViews = []
}
}
})

204
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,204 @@
import { defineStore } from 'pinia'
import { request } from '@/utils/request'
import {
getToken,
setToken,
removeToken,
getRefreshToken,
setRefreshToken,
removeRefreshToken,
getUser,
setUser,
removeUser,
clearAuth
} from '@/utils/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: getToken(),
refreshToken: getRefreshToken(),
user: getUser(),
permissions: []
}),
getters: {
isLoggedIn: (state) => !!state.token,
isAdmin: (state) => state.user && (state.user.is_staff || state.user.is_superuser),
isSuperuser: (state) => state.user && state.user.is_superuser,
userRole: (state) => {
if (!state.user) return 'guest'
if (state.user.is_superuser) return 'superuser'
if (state.user.is_staff) return 'admin'
return 'user'
},
userName: (state) => {
if (!state.user) return ''
const fullName = `${state.user.first_name || ''} ${state.user.last_name || ''}`.trim()
return fullName || state.user.username
}
},
actions: {
// 登录
async login(credentials) {
try {
const response = await request.post('/auth/login/', credentials)
if (response.tokens && response.user) {
this.setAuthData(response.tokens, response.user)
return { success: true, message: response.message || '登录成功' }
} else {
throw new Error('登录响应数据格式错误')
}
} catch (error) {
console.error('登录失败:', error)
return {
success: false,
message: error.response?.data?.message || error.message || '登录失败'
}
}
},
// 注册
async register(userData) {
try {
const response = await request.post('/auth/register/', userData)
if (response.tokens && response.user) {
this.setAuthData(response.tokens, response.user)
return { success: true, message: response.message || '注册成功' }
} else {
throw new Error('注册响应数据格式错误')
}
} catch (error) {
console.error('注册失败:', error)
return {
success: false,
message: error.response?.data?.message || error.message || '注册失败'
}
}
},
// 登出
async logout() {
try {
if (this.refreshToken) {
await request.post('/auth/logout/', {
refresh_token: this.refreshToken
})
}
} catch (error) {
console.error('登出请求失败:', error)
} finally {
this.clearAuthData()
}
},
// 刷新token
async refreshAccessToken() {
try {
if (!this.refreshToken) {
throw new Error('没有refresh token')
}
const response = await request.post('/auth/token/refresh/', {
refresh: this.refreshToken
})
if (response.access) {
this.token = response.access
setToken(response.access)
// 如果返回了新的refresh token也要更新
if (response.refresh) {
this.refreshToken = response.refresh
setRefreshToken(response.refresh)
}
return true
} else {
throw new Error('刷新token失败')
}
} catch (error) {
console.error('刷新token失败:', error)
this.clearAuthData()
return false
}
},
// 获取用户信息
async getUserInfo() {
try {
const response = await request.get('/auth/profile/')
if (response) {
this.user = response
setUser(response)
return response
} else {
throw new Error('获取用户信息失败')
}
} catch (error) {
console.error('获取用户信息失败:', error)
this.clearAuthData()
throw error
}
},
// 更新用户信息
async updateUserInfo(userData) {
try {
const response = await request.put('/auth/profile/', userData)
if (response) {
this.user = response
setUser(response)
return { success: true, message: '更新成功' }
} else {
throw new Error('更新用户信息失败')
}
} catch (error) {
console.error('更新用户信息失败:', error)
return {
success: false,
message: error.response?.data?.message || error.message || '更新失败'
}
}
},
// 设置认证数据
setAuthData(tokens, user) {
this.token = tokens.access
this.refreshToken = tokens.refresh
this.user = user
setToken(tokens.access)
setRefreshToken(tokens.refresh)
setUser(user)
},
// 清除认证数据
clearAuthData() {
this.token = null
this.refreshToken = null
this.user = null
this.permissions = []
clearAuth()
},
// 检查权限
hasPermission(permission) {
switch (permission) {
case 'view_all_reports':
return this.isAdmin
case 'manage_users':
return this.isSuperuser
case 'view_stats':
return this.isAdmin
default:
return true
}
}
}
})

View File

@@ -0,0 +1,289 @@
import { defineStore } from 'pinia'
import { request } from '@/utils/request'
export const useReportsStore = defineStore('reports', {
state: () => ({
// 日报列表
reports: [],
// 分页信息
pagination: {
current: 1,
pageSize: 20,
total: 0
},
// 当前查看的日报
currentReport: null,
// 搜索条件
searchParams: {
report_date_start: '',
report_date_end: '',
user: '',
work_summary: '',
next_day_plan: '',
is_draft: null
},
// 统计数据
stats: {
total_reports: 0,
this_month_reports: 0,
this_week_reports: 0,
draft_reports: 0,
completion_rate: 0
},
// 用户统计数据(管理员可见)
userStats: [],
// 加载状态
loading: false,
submitting: false
}),
getters: {
// 获取当前页的日报
currentPageReports: (state) => state.reports,
// 是否有更多数据
hasMore: (state) => {
const { current, pageSize, total } = state.pagination
return current * pageSize < total
},
// 获取搜索参数(过滤空值)
activeSearchParams: (state) => {
const params = {}
Object.keys(state.searchParams).forEach(key => {
const value = state.searchParams[key]
if (value !== '' && value !== null && value !== undefined) {
params[key] = value
}
})
return params
}
},
actions: {
// 获取日报列表
async fetchReports(params = {}) {
try {
this.loading = true
const queryParams = {
page: this.pagination.current,
page_size: this.pagination.pageSize,
...this.activeSearchParams,
...params
}
const response = await request.get('/reports/', queryParams)
if (response.results) {
this.reports = response.results
this.pagination.total = response.count
}
return { success: true, data: response }
} catch (error) {
console.error('获取日报列表失败:', error)
return { success: false, error }
} finally {
this.loading = false
}
},
// 获取日报详情
async fetchReportDetail(id) {
try {
this.loading = true
const response = await request.get(`/reports/${id}/`)
this.currentReport = response
return { success: true, data: response }
} catch (error) {
console.error('获取日报详情失败:', error)
return { success: false, error }
} finally {
this.loading = false
}
},
// 创建日报
async createReport(reportData) {
try {
this.submitting = true
const response = await request.post('/reports/', reportData)
// 刷新列表
await this.fetchReports()
return { success: true, data: response, message: '日报创建成功' }
} catch (error) {
console.error('创建日报失败:', error)
return {
success: false,
error,
message: error.response?.data?.message || '创建日报失败'
}
} finally {
this.submitting = false
}
},
// 更新日报
async updateReport(id, reportData) {
try {
this.submitting = true
const response = await request.put(`/reports/${id}/`, reportData)
// 更新当前日报
if (this.currentReport && this.currentReport.id === id) {
this.currentReport = response
}
// 更新列表中的日报
const index = this.reports.findIndex(report => report.id === id)
if (index !== -1) {
this.reports[index] = response
}
return { success: true, data: response, message: '日报更新成功' }
} catch (error) {
console.error('更新日报失败:', error)
return {
success: false,
error,
message: error.response?.data?.message || '更新日报失败'
}
} finally {
this.submitting = false
}
},
// 删除日报
async deleteReport(id) {
try {
await request.delete(`/reports/${id}/`)
// 从列表中移除
this.reports = this.reports.filter(report => report.id !== id)
this.pagination.total -= 1
// 如果是当前查看的日报,清空
if (this.currentReport && this.currentReport.id === id) {
this.currentReport = null
}
return { success: true, message: '日报删除成功' }
} catch (error) {
console.error('删除日报失败:', error)
return {
success: false,
error,
message: error.response?.data?.message || '删除日报失败'
}
}
},
// 切换草稿状态
async toggleDraftStatus(id) {
try {
const response = await request.post(`/reports/${id}/toggle-draft/`)
// 更新列表中的日报状态
const index = this.reports.findIndex(report => report.id === id)
if (index !== -1) {
this.reports[index].is_draft = response.is_draft
}
// 更新当前日报状态
if (this.currentReport && this.currentReport.id === id) {
this.currentReport.is_draft = response.is_draft
}
return { success: true, data: response }
} catch (error) {
console.error('切换草稿状态失败:', error)
return { success: false, error }
}
},
// 获取统计数据
async fetchStats() {
try {
const response = await request.get('/stats/')
this.stats = response
return { success: true, data: response }
} catch (error) {
console.error('获取统计数据失败:', error)
return { success: false, error }
}
},
// 获取用户统计数据
async fetchUserStats() {
try {
const response = await request.get('/stats/users/')
this.userStats = response
return { success: true, data: response }
} catch (error) {
console.error('获取用户统计数据失败:', error)
return { success: false, error }
}
},
// 设置搜索参数
setSearchParams(params) {
this.searchParams = { ...this.searchParams, ...params }
},
// 重置搜索参数
resetSearchParams() {
this.searchParams = {
report_date_start: '',
report_date_end: '',
user: '',
work_summary: '',
next_day_plan: '',
is_draft: null
}
},
// 设置分页
setPagination(pagination) {
this.pagination = { ...this.pagination, ...pagination }
},
// 重置分页
resetPagination() {
this.pagination = {
current: 1,
pageSize: 20,
total: 0
}
},
// 清空当前日报
clearCurrentReport() {
this.currentReport = null
},
// 重置状态
resetState() {
this.reports = []
this.currentReport = null
this.resetPagination()
this.resetSearchParams()
this.stats = {
total_reports: 0,
this_month_reports: 0,
this_week_reports: 0,
draft_reports: 0,
completion_rate: 0
}
this.userStats = []
}
}
})

View File

@@ -0,0 +1,318 @@
@import './variables.scss';
// 重置样式
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: $bg-color;
}
// 清除默认样式
ul,
ol {
margin: 0;
padding: 0;
list-style: none;
}
a {
text-decoration: none;
color: inherit;
&:hover {
text-decoration: none;
}
}
// 通用工具类
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-left {
text-align: left;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
.ml-auto {
margin-left: auto;
}
.mr-auto {
margin-right: auto;
}
// 间距工具类
@for $i from 0 through 5 {
.m-#{$i * 5} {
margin: #{$i * 5}px;
}
.mt-#{$i * 5} {
margin-top: #{$i * 5}px;
}
.mr-#{$i * 5} {
margin-right: #{$i * 5}px;
}
.mb-#{$i * 5} {
margin-bottom: #{$i * 5}px;
}
.ml-#{$i * 5} {
margin-left: #{$i * 5}px;
}
.p-#{$i * 5} {
padding: #{$i * 5}px;
}
.pt-#{$i * 5} {
padding-top: #{$i * 5}px;
}
.pr-#{$i * 5} {
padding-right: #{$i * 5}px;
}
.pb-#{$i * 5} {
padding-bottom: #{$i * 5}px;
}
.pl-#{$i * 5} {
padding-left: #{$i * 5}px;
}
}
// 卡片样式
.cool-card {
background: #ffffff;
border-radius: 6px;
box-shadow: $box-shadow-light;
overflow: hidden;
.card-header {
padding: 16px 20px;
border-bottom: 1px solid $border-color;
font-weight: 500;
font-size: 16px;
color: $text-color-primary;
}
.card-body {
padding: 20px;
}
}
// 页面容器
.page-container {
padding: $main-padding;
height: 100%;
overflow: auto;
}
.page-header {
margin-bottom: 20px;
.page-title {
font-size: 24px;
font-weight: 500;
color: $text-color-primary;
margin: 0 0 8px 0;
}
.page-description {
color: $text-color-secondary;
margin: 0;
}
}
// 表格样式增强
.cool-table {
.el-table {
border-radius: 6px;
overflow: hidden;
}
.el-table__header {
th {
background-color: #fafafa;
color: $text-color-primary;
font-weight: 500;
}
}
}
// 表单样式增强
.cool-form {
.el-form-item__label {
font-weight: 500;
color: $text-color-primary;
}
}
// 按钮组样式
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
.el-button {
margin: 0;
}
}
// 搜索表单样式
.search-form {
background: #ffffff;
padding: 20px;
border-radius: 6px;
box-shadow: $box-shadow-light;
margin-bottom: 20px;
.el-form-item {
margin-bottom: 16px;
}
.search-actions {
text-align: right;
margin-top: 10px;
}
}
// 统计卡片
.stat-card {
background: linear-gradient(135deg, $primary-color 0%, lighten($primary-color, 10%) 100%);
color: white;
padding: 24px;
border-radius: 8px;
box-shadow: $box-shadow;
.stat-number {
font-size: 32px;
font-weight: bold;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
&.success {
background: linear-gradient(135deg, $success-color 0%, lighten($success-color, 10%) 100%);
}
&.warning {
background: linear-gradient(135deg, $warning-color 0%, lighten($warning-color, 10%) 100%);
}
&.danger {
background: linear-gradient(135deg, $danger-color 0%, lighten($danger-color, 10%) 100%);
}
&.info {
background: linear-gradient(135deg, $info-color 0%, lighten($info-color, 10%) 100%);
}
}
// 暗色模式
.dark {
body {
background-color: $bg-color-dark;
}
.cool-card {
background: #1d1e1f;
border-color: $border-color-dark;
.card-header {
border-color: $border-color-dark;
color: $text-color-primary-dark;
}
}
.search-form {
background: #1d1e1f;
border-color: $border-color-dark;
}
.page-header {
.page-title {
color: $text-color-primary-dark;
}
.page-description {
color: $text-color-secondary-dark;
}
}
.cool-table {
.el-table__header {
th {
background-color: #262727;
color: $text-color-primary-dark;
}
}
}
.cool-form {
.el-form-item__label {
color: $text-color-primary-dark;
}
}
}
// 响应式
@media (max-width: $sm) {
.page-container {
padding: 10px;
}
.search-form {
padding: 15px;
}
.button-group {
justify-content: center;
}
.stat-card {
text-align: center;
}
}

View File

@@ -0,0 +1,59 @@
// Cool Admin 主题变量
// 主色调
$primary-color: #409eff;
$success-color: #67c23a;
$warning-color: #e6a23c;
$danger-color: #f56c6c;
$info-color: #909399;
// 背景色
$bg-color: #f5f7fa;
$bg-color-dark: #141414;
// 侧边栏
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
$sidebar-bg: #304156;
$sidebar-bg-dark: #1d1e1f;
$sidebar-text-color: #bfcbd9;
$sidebar-active-color: #ffffff;
// 头部
$header-height: 60px;
$header-bg: #ffffff;
$header-bg-dark: #1d1e1f;
$header-border-color: #e4e7ed;
// 标签页
$tags-height: 34px;
$tags-bg: #ffffff;
$tags-bg-dark: #1d1e1f;
// 内容区域
$main-padding: 20px;
// 阴影
$box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
$box-shadow-light: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
// 边框
$border-color: #dcdfe6;
$border-color-dark: #4c4d4f;
// 文字颜色
$text-color-primary: #303133;
$text-color-regular: #606266;
$text-color-secondary: #909399;
$text-color-placeholder: #c0c4cc;
// 暗色模式文字颜色
$text-color-primary-dark: #e5eaf3;
$text-color-regular-dark: #cfd3dc;
$text-color-secondary-dark: #a3a6ad;
// 响应式断点
$sm: 768px;
$md: 992px;
$lg: 1200px;
$xl: 1920px;

View File

@@ -0,0 +1,88 @@
import Cookies from 'js-cookie'
const TokenKey = 'daily_report_token'
const RefreshTokenKey = 'daily_report_refresh_token'
const UserKey = 'daily_report_user'
// Token 相关
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token, { expires: 7 }) // 7天过期
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
// Refresh Token 相关
export function getRefreshToken() {
return Cookies.get(RefreshTokenKey)
}
export function setRefreshToken(token) {
return Cookies.set(RefreshTokenKey, token, { expires: 7 })
}
export function removeRefreshToken() {
return Cookies.remove(RefreshTokenKey)
}
// 用户信息相关
export function getUser() {
const user = localStorage.getItem(UserKey)
return user ? JSON.parse(user) : null
}
export function setUser(user) {
return localStorage.setItem(UserKey, JSON.stringify(user))
}
export function removeUser() {
return localStorage.removeItem(UserKey)
}
// 清除所有认证信息
export function clearAuth() {
removeToken()
removeRefreshToken()
removeUser()
}
// 检查是否已登录
export function isLoggedIn() {
return !!getToken()
}
// 检查是否为管理员
export function isAdmin() {
const user = getUser()
return user && (user.is_staff || user.is_superuser)
}
// 获取用户角色
export function getUserRole() {
const user = getUser()
if (!user) return 'guest'
if (user.is_superuser) return 'superuser'
if (user.is_staff) return 'admin'
return 'user'
}
// 检查权限
export function hasPermission(permission) {
const role = getUserRole()
switch (permission) {
case 'view_all_reports':
return role === 'admin' || role === 'superuser'
case 'manage_users':
return role === 'superuser'
case 'view_stats':
return role === 'admin' || role === 'superuser'
default:
return true
}
}

231
frontend/src/utils/index.js Normal file
View File

@@ -0,0 +1,231 @@
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
// 配置dayjs
dayjs.locale('zh-cn')
dayjs.extend(relativeTime)
/**
* 格式化日期
* @param {string|Date} date 日期
* @param {string} format 格式
* @returns {string}
*/
export function formatDate(date, format = 'YYYY-MM-DD') {
if (!date) return ''
return dayjs(date).format(format)
}
/**
* 格式化日期时间
* @param {string|Date} datetime 日期时间
* @param {string} format 格式
* @returns {string}
*/
export function formatDateTime(datetime, format = 'YYYY-MM-DD HH:mm:ss') {
if (!datetime) return ''
return dayjs(datetime).format(format)
}
/**
* 相对时间
* @param {string|Date} date 日期
* @returns {string}
*/
export function fromNow(date) {
if (!date) return ''
return dayjs(date).fromNow()
}
/**
* 获取今天的日期
* @param {string} format 格式
* @returns {string}
*/
export function getToday(format = 'YYYY-MM-DD') {
return dayjs().format(format)
}
/**
* 获取本周的开始和结束日期
* @returns {object}
*/
export function getThisWeek() {
const today = dayjs()
const startOfWeek = today.startOf('week')
const endOfWeek = today.endOf('week')
return {
start: startOfWeek.format('YYYY-MM-DD'),
end: endOfWeek.format('YYYY-MM-DD')
}
}
/**
* 获取本月的开始和结束日期
* @returns {object}
*/
export function getThisMonth() {
const today = dayjs()
const startOfMonth = today.startOf('month')
const endOfMonth = today.endOf('month')
return {
start: startOfMonth.format('YYYY-MM-DD'),
end: endOfMonth.format('YYYY-MM-DD')
}
}
/**
* 截取文本
* @param {string} text 文本
* @param {number} length 长度
* @param {string} suffix 后缀
* @returns {string}
*/
export function truncateText(text, length = 50, suffix = '...') {
if (!text) return ''
if (text.length <= length) return text
return text.substring(0, length) + suffix
}
/**
* 防抖函数
* @param {Function} func 函数
* @param {number} wait 等待时间
* @returns {Function}
*/
export function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
/**
* 节流函数
* @param {Function} func 函数
* @param {number} limit 限制时间
* @returns {Function}
*/
export function throttle(func, limit) {
let inThrottle
return function executedFunction(...args) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
/**
* 深拷贝
* @param {any} obj 对象
* @returns {any}
*/
export function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj.getTime())
if (obj instanceof Array) return obj.map(item => deepClone(item))
if (typeof obj === 'object') {
const clonedObj = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key])
}
}
return clonedObj
}
}
/**
* 生成随机字符串
* @param {number} length 长度
* @returns {string}
*/
export function generateRandomString(length = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* 下载文件
* @param {Blob} blob 文件blob
* @param {string} filename 文件名
*/
export function downloadFile(blob, filename) {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
/**
* 验证邮箱
* @param {string} email 邮箱
* @returns {boolean}
*/
export function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return re.test(email)
}
/**
* 验证手机号
* @param {string} phone 手机号
* @returns {boolean}
*/
export function validatePhone(phone) {
const re = /^1[3-9]\d{9}$/
return re.test(phone)
}
/**
* 获取文件大小文本
* @param {number} size 文件大小(字节)
* @returns {string}
*/
export function getFileSizeText(size) {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + ' MB'
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
}
/**
* 获取用户角色文本
* @param {object} user 用户对象
* @returns {string}
*/
export function getUserRoleText(user) {
if (!user) return '游客'
if (user.is_superuser) return '超级管理员'
if (user.is_staff) return '管理员'
return '普通用户'
}
/**
* 获取用户全名
* @param {object} user 用户对象
* @returns {string}
*/
export function getUserFullName(user) {
if (!user) return ''
const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim()
return fullName || user.username
}

View File

@@ -0,0 +1,163 @@
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
import NProgress from 'nprogress'
// 创建axios实例
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API || '/api',
timeout: 15000
})
// 请求拦截器
service.interceptors.request.use(
config => {
NProgress.start()
// 添加认证token
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
},
error => {
NProgress.done()
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
response => {
NProgress.done()
const res = response.data
// 如果是文件下载等特殊情况,直接返回
if (response.config.responseType === 'blob') {
return response
}
return res
},
async error => {
NProgress.done()
const { response } = error
let message = '网络错误,请稍后重试'
if (response) {
const { status, data } = response
switch (status) {
case 400:
message = data.message || data.detail || '请求参数错误'
if (data.errors) {
// 处理表单验证错误
const errorMessages = []
for (const field in data.errors) {
if (Array.isArray(data.errors[field])) {
errorMessages.push(...data.errors[field])
} else {
errorMessages.push(data.errors[field])
}
}
message = errorMessages.join(', ')
}
break
case 401:
message = '登录已过期,请重新登录'
// 清除本地存储的认证信息
const authStore = useAuthStore()
authStore.logout()
// 跳转到登录页
router.push('/login')
break
case 403:
message = data.message || '权限不足'
break
case 404:
message = data.message || '请求的资源不存在'
break
case 422:
message = '数据验证失败'
if (data.errors) {
const errorMessages = []
for (const field in data.errors) {
errorMessages.push(...data.errors[field])
}
message = errorMessages.join(', ')
}
break
case 500:
message = '服务器内部错误'
break
case 502:
message = '网关错误'
break
case 503:
message = '服务暂不可用'
break
case 504:
message = '网关超时'
break
default:
message = data.message || `连接错误${status}`
}
} else if (error.code === 'NETWORK_ERROR') {
message = '网络连接异常'
} else if (error.code === 'ECONNABORTED') {
message = '请求超时'
}
// 显示错误消息
ElMessage.error(message)
return Promise.reject(error)
}
)
// 封装常用的请求方法
export const request = {
get(url, params = {}) {
return service.get(url, { params })
},
post(url, data = {}) {
return service.post(url, data)
},
put(url, data = {}) {
return service.put(url, data)
},
patch(url, data = {}) {
return service.patch(url, data)
},
delete(url) {
return service.delete(url)
},
upload(url, data, onUploadProgress) {
return service.post(url, data, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress
})
},
download(url, params = {}) {
return service.get(url, {
params,
responseType: 'blob'
})
}
}
export default service

View File

@@ -0,0 +1,162 @@
/**
* 验证工具函数
*/
/**
* 判断是否为外部链接
* @param {string} path 路径
* @returns {boolean}
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
/**
* 验证邮箱格式
* @param {string} email 邮箱
* @returns {boolean}
*/
export function validEmail(email) {
const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
return reg.test(email)
}
/**
* 验证手机号格式
* @param {string} phone 手机号
* @returns {boolean}
*/
export function validPhone(phone) {
const reg = /^1[3-9]\d{9}$/
return reg.test(phone)
}
/**
* 验证URL格式
* @param {string} url URL
* @returns {boolean}
*/
export function validURL(url) {
const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
return reg.test(url)
}
/**
* 验证小写字母
* @param {string} str 字符串
* @returns {boolean}
*/
export function validLowerCase(str) {
const reg = /^[a-z]+$/
return reg.test(str)
}
/**
* 验证大写字母
* @param {string} str 字符串
* @returns {boolean}
*/
export function validUpperCase(str) {
const reg = /^[A-Z]+$/
return reg.test(str)
}
/**
* 验证字母
* @param {string} str 字符串
* @returns {boolean}
*/
export function validAlphabets(str) {
const reg = /^[A-Za-z]+$/
return reg.test(str)
}
/**
* 验证用户名格式(字母、数字、下划线)
* @param {string} username 用户名
* @returns {boolean}
*/
export function validUsername(username) {
const reg = /^[a-zA-Z0-9_]{3,20}$/
return reg.test(username)
}
/**
* 验证密码强度(至少包含字母和数字)
* @param {string} password 密码
* @returns {boolean}
*/
export function validPassword(password) {
const reg = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{6,}$/
return reg.test(password)
}
/**
* 验证身份证号码
* @param {string} idCard 身份证号
* @returns {boolean}
*/
export function validIdCard(idCard) {
const reg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
return reg.test(idCard)
}
/**
* 验证IP地址
* @param {string} ip IP地址
* @returns {boolean}
*/
export function validIP(ip) {
const reg = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
return reg.test(ip)
}
/**
* 验证中文字符
* @param {string} str 字符串
* @returns {boolean}
*/
export function validChinese(str) {
const reg = /^[\u4e00-\u9fa5]+$/
return reg.test(str)
}
/**
* 验证数字
* @param {string} str 字符串
* @returns {boolean}
*/
export function validNumber(str) {
const reg = /^[0-9]+$/
return reg.test(str)
}
/**
* 验证正整数
* @param {string} str 字符串
* @returns {boolean}
*/
export function validPositiveInteger(str) {
const reg = /^[1-9]\d*$/
return reg.test(str)
}
/**
* 验证非负整数
* @param {string} str 字符串
* @returns {boolean}
*/
export function validNonNegativeInteger(str) {
const reg = /^\d+$/
return reg.test(str)
}
/**
* 验证浮点数
* @param {string} str 字符串
* @returns {boolean}
*/
export function validFloat(str) {
const reg = /^(-?\d+)(\.\d+)?$/
return reg.test(str)
}

252
frontend/src/views/404.vue Normal file
View File

@@ -0,0 +1,252 @@
<template>
<div class="not-found-container">
<div class="not-found-content">
<!-- 404图标 -->
<div class="error-icon">
<el-icon :size="120">
<Warning />
</el-icon>
</div>
<!-- 错误信息 -->
<div class="error-info">
<h1 class="error-code">404</h1>
<h2 class="error-title">页面未找到</h2>
<p class="error-description">
抱歉您访问的页面不存在或已被删除
</p>
</div>
<!-- 操作按钮 -->
<div class="error-actions">
<el-button type="primary" size="large" @click="goHome">
<el-icon><House /></el-icon>
返回首页
</el-button>
<el-button size="large" @click="goBack">
<el-icon><Back /></el-icon>
返回上页
</el-button>
</div>
<!-- 建议链接 -->
<div class="suggested-links">
<h3>您可能想要访问</h3>
<ul>
<li>
<router-link to="/dashboard">
<el-icon><House /></el-icon>
工作台
</router-link>
</li>
<li>
<router-link to="/reports">
<el-icon><Document /></el-icon>
日报管理
</router-link>
</li>
<li>
<router-link to="/profile">
<el-icon><User /></el-icon>
个人中心
</router-link>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import {
Warning,
House,
Back,
Document,
User
} from '@element-plus/icons-vue'
const router = useRouter()
// 返回首页
const goHome = () => {
router.push('/')
}
// 返回上一页
const goBack = () => {
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/')
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.not-found-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.not-found-content {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 60px 40px;
text-align: center;
max-width: 600px;
width: 100%;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.error-icon {
margin-bottom: 32px;
color: #f56c6c;
}
.error-info {
margin-bottom: 40px;
.error-code {
font-size: 72px;
font-weight: bold;
color: #f56c6c;
margin: 0 0 16px 0;
line-height: 1;
}
.error-title {
font-size: 28px;
font-weight: 600;
color: #303133;
margin: 0 0 16px 0;
}
.error-description {
font-size: 16px;
color: #606266;
margin: 0;
line-height: 1.5;
}
}
.error-actions {
display: flex;
gap: 16px;
justify-content: center;
margin-bottom: 40px;
flex-wrap: wrap;
.el-button {
height: 48px;
padding: 0 24px;
font-size: 16px;
.el-icon {
margin-right: 8px;
}
}
}
.suggested-links {
h3 {
font-size: 18px;
color: #303133;
margin: 0 0 20px 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
li {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
a {
display: inline-flex;
align-items: center;
color: #409eff;
text-decoration: none;
font-size: 16px;
transition: color 0.3s;
&:hover {
color: #66b1ff;
}
.el-icon {
margin-right: 8px;
}
}
}
}
}
// 暗色模式
.dark {
.not-found-content {
background: rgba(29, 30, 31, 0.95);
.error-info {
.error-title {
color: #e5eaf3;
}
.error-description {
color: #cfd3dc;
}
}
.suggested-links {
h3 {
color: #e5eaf3;
}
}
}
}
// 响应式
@media (max-width: $sm) {
.not-found-content {
padding: 40px 20px;
}
.error-info {
.error-code {
font-size: 56px;
}
.error-title {
font-size: 24px;
}
.error-description {
font-size: 14px;
}
}
.error-actions {
flex-direction: column;
align-items: center;
.el-button {
width: 100%;
max-width: 200px;
}
}
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<div class="page-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">工作台</h1>
<p class="page-description">欢迎回来{{ authStore.userName }}</p>
</div>
<!-- 统计卡片 -->
<el-row :gutter="20" class="mb-20">
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-card">
<div class="stat-number">{{ stats.total_reports }}</div>
<div class="stat-label">总日报数</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-card success">
<div class="stat-number">{{ stats.this_month_reports }}</div>
<div class="stat-label">本月日报</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-card warning">
<div class="stat-number">{{ stats.this_week_reports }}</div>
<div class="stat-label">本周日报</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-card info">
<div class="stat-number">{{ stats.completion_rate }}%</div>
<div class="stat-label">完成率</div>
</div>
</el-col>
</el-row>
<!-- 快捷操作 -->
<div class="cool-card mb-20">
<div class="card-header">
<h3>快捷操作</h3>
</div>
<div class="card-body">
<div class="quick-actions">
<el-button
type="primary"
size="large"
:icon="Plus"
@click="createReport"
>
创建今日日报
</el-button>
<el-button
size="large"
:icon="Document"
@click="viewReports"
>
查看我的日报
</el-button>
<el-button
v-if="authStore.isAdmin"
size="large"
:icon="DataAnalysis"
@click="viewStats"
>
查看统计
</el-button>
</div>
</div>
</div>
<!-- 最近日报 -->
<div class="cool-card">
<div class="card-header">
<h3>最近日报</h3>
<el-button
type="text"
size="small"
@click="viewReports"
>
查看更多
</el-button>
</div>
<div class="card-body">
<div v-if="loading" class="text-center">
<el-icon class="is-loading"><Loading /></el-icon>
<span class="ml-10">加载中...</span>
</div>
<div v-else-if="recentReports.length === 0" class="empty-state">
<el-empty description="暂无日报数据">
<el-button type="primary" @click="createReport">创建第一份日报</el-button>
</el-empty>
</div>
<div v-else class="recent-reports">
<div
v-for="report in recentReports"
:key="report.id"
class="report-item"
@click="viewReport(report.id)"
>
<div class="report-header">
<span class="report-date">{{ formatDate(report.report_date) }}</span>
<el-tag v-if="report.is_draft" size="small" type="warning">草稿</el-tag>
</div>
<div class="report-content">
<h4 class="report-title">工作总结</h4>
<p class="report-summary">{{ report.work_summary_preview }}</p>
</div>
<div class="report-footer">
<span class="report-time">{{ fromNow(report.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 管理员统计 -->
<div v-if="authStore.isAdmin" class="cool-card mt-20">
<div class="card-header">
<h3>团队统计</h3>
</div>
<div class="card-body">
<div v-if="userStatsLoading" class="text-center">
<el-icon class="is-loading"><Loading /></el-icon>
<span class="ml-10">加载中...</span>
</div>
<div v-else class="user-stats">
<div
v-for="userStat in userStats.slice(0, 5)"
:key="userStat.user.id"
class="user-stat-item"
>
<div class="user-info">
<el-avatar :size="32">{{ userStat.user.full_name.charAt(0) }}</el-avatar>
<div class="user-details">
<div class="user-name">{{ userStat.user.full_name }}</div>
<div class="user-department">{{ userStat.user.department || '未设置部门' }}</div>
</div>
</div>
<div class="user-metrics">
<div class="metric">
<span class="metric-value">{{ userStat.total_reports }}</span>
<span class="metric-label">总日报</span>
</div>
<div class="metric">
<span class="metric-value">{{ userStat.this_month_reports }}</span>
<span class="metric-label">本月</span>
</div>
<div class="metric">
<span class="metric-value">{{ userStat.completion_rate }}%</span>
<span class="metric-label">完成率</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Plus,
Document,
DataAnalysis,
Loading
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useReportsStore } from '@/stores/reports'
import { formatDate, fromNow, getToday } from '@/utils'
const router = useRouter()
const authStore = useAuthStore()
const reportsStore = useReportsStore()
// 状态
const loading = ref(false)
const userStatsLoading = ref(false)
const stats = ref({
total_reports: 0,
this_month_reports: 0,
this_week_reports: 0,
draft_reports: 0,
completion_rate: 0
})
const recentReports = ref([])
const userStats = ref([])
// 获取统计数据
const fetchStats = async () => {
try {
const result = await reportsStore.fetchStats()
if (result.success) {
stats.value = result.data
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 获取最近日报
const fetchRecentReports = async () => {
loading.value = true
try {
reportsStore.setPagination({ current: 1, pageSize: 5 })
const result = await reportsStore.fetchReports()
if (result.success) {
recentReports.value = result.data.results || []
}
} catch (error) {
console.error('获取最近日报失败:', error)
} finally {
loading.value = false
}
}
// 获取用户统计数据
const fetchUserStats = async () => {
if (!authStore.isAdmin) return
userStatsLoading.value = true
try {
const result = await reportsStore.fetchUserStats()
if (result.success) {
userStats.value = result.data || []
}
} catch (error) {
console.error('获取用户统计数据失败:', error)
} finally {
userStatsLoading.value = false
}
}
// 创建日报
const createReport = () => {
router.push('/reports/create')
}
// 查看日报列表
const viewReports = () => {
router.push('/reports')
}
// 查看统计
const viewStats = () => {
// 这里可以跳转到统计页面,暂时显示消息
ElMessage.info('统计页面开发中...')
}
// 查看日报详情
const viewReport = (id) => {
router.push(`/reports/${id}`)
}
// 初始化
onMounted(async () => {
await Promise.all([
fetchStats(),
fetchRecentReports(),
fetchUserStats()
])
})
</script>
<style lang="scss" scoped>
.quick-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
.recent-reports {
.report-item {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #409eff;
box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 0.1);
}
&:last-child {
margin-bottom: 0;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.report-date {
font-weight: 500;
color: #303133;
}
}
.report-content {
.report-title {
font-size: 14px;
font-weight: 500;
color: #606266;
margin: 0 0 4px 0;
}
.report-summary {
font-size: 13px;
color: #909399;
line-height: 1.5;
margin: 0;
}
}
.report-footer {
margin-top: 8px;
text-align: right;
.report-time {
font-size: 12px;
color: #c0c4cc;
}
}
}
}
.user-stats {
.user-stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.user-info {
display: flex;
align-items: center;
.user-details {
margin-left: 12px;
.user-name {
font-weight: 500;
color: #303133;
margin-bottom: 2px;
}
.user-department {
font-size: 12px;
color: #909399;
}
}
}
.user-metrics {
display: flex;
gap: 20px;
.metric {
text-align: center;
.metric-value {
display: block;
font-weight: 500;
color: #303133;
}
.metric-label {
font-size: 12px;
color: #909399;
}
}
}
}
}
.empty-state {
padding: 40px 0;
}
// 暗色模式
.dark {
.recent-reports {
.report-item {
border-color: #4c4d4f;
background: #1d1e1f;
&:hover {
border-color: #409eff;
}
.report-header {
.report-date {
color: #e5eaf3;
}
}
.report-content {
.report-title {
color: #cfd3dc;
}
.report-summary {
color: #a3a6ad;
}
}
}
}
.user-stats {
.user-stat-item {
border-color: #4c4d4f;
.user-info {
.user-details {
.user-name {
color: #e5eaf3;
}
.user-department {
color: #a3a6ad;
}
}
}
.user-metrics {
.metric {
.metric-value {
color: #e5eaf3;
}
.metric-label {
color: #a3a6ad;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,520 @@
<template>
<div class="login-container">
<div class="login-form-wrapper">
<div class="login-header">
<h1 class="login-title">企业级日报系统</h1>
<p class="login-subtitle">基于Cool Admin的现代化管理平台</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
:prefix-icon="User"
clearable
autocomplete="username"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
show-password
clearable
autocomplete="current-password"
/>
</el-form-item>
<el-form-item>
<div class="login-options">
<el-checkbox v-model="rememberMe">记住我</el-checkbox>
<el-link type="primary" :underline="false" @click="showRegister = true">
注册账号
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-button"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<div class="login-demo">
<el-divider>演示账号</el-divider>
<div class="demo-accounts">
<el-button
size="small"
type="info"
plain
@click="setDemoAccount('admin')"
>
管理员账号
</el-button>
<el-button
size="small"
type="success"
plain
@click="setDemoAccount('user')"
>
普通用户账号
</el-button>
</div>
</div>
</div>
<!-- 注册对话框 -->
<el-dialog
v-model="showRegister"
title="用户注册"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-width="80px"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
clearable
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="registerForm.email"
placeholder="请输入邮箱"
clearable
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓" prop="last_name">
<el-input
v-model="registerForm.last_name"
placeholder="请输入姓氏"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名" prop="first_name">
<el-input
v-model="registerForm.first_name"
placeholder="请输入名字"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="部门" prop="department">
<el-input
v-model="registerForm.department"
placeholder="请输入部门"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="职位" prop="position">
<el-input
v-model="registerForm.position"
placeholder="请输入职位"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="registerForm.phone"
placeholder="请输入手机号"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码"
show-password
clearable
/>
</el-form-item>
<el-form-item label="确认密码" prop="password_confirm">
<el-input
v-model="registerForm.password_confirm"
type="password"
placeholder="请再次输入密码"
show-password
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="showRegister = false">取消</el-button>
<el-button
type="primary"
:loading="registerLoading"
@click="handleRegister"
>
注册
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { validateEmail, validatePhone } from '@/utils'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const appStore = useAppStore()
// 表单引用
const loginFormRef = ref()
const registerFormRef = ref()
// 登录表单
const loginForm = reactive({
username: '',
password: ''
})
// 注册表单
const registerForm = reactive({
username: '',
email: '',
first_name: '',
last_name: '',
department: '',
position: '',
phone: '',
password: '',
password_confirm: ''
})
// 状态
const loading = ref(false)
const registerLoading = ref(false)
const rememberMe = ref(false)
const showRegister = ref(false)
// 登录表单验证规则
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
]
}
// 注册表单验证规则
const registerRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在3到20个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ validator: (rule, value, callback) => {
if (!validateEmail(value)) {
callback(new Error('请输入正确的邮箱格式'))
} else {
callback()
}
}, trigger: 'blur' }
],
first_name: [
{ required: true, message: '请输入名字', trigger: 'blur' }
],
last_name: [
{ required: true, message: '请输入姓氏', trigger: 'blur' }
],
phone: [
{ validator: (rule, value, callback) => {
if (value && !validatePhone(value)) {
callback(new Error('请输入正确的手机号格式'))
} else {
callback()
}
}, trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
],
password_confirm: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}, trigger: 'blur' }
]
}
// 处理登录
const handleLogin = async () => {
if (!loginFormRef.value) return
const valid = await loginFormRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const result = await authStore.login(loginForm)
if (result.success) {
ElMessage.success(result.message)
// 跳转到目标页面或首页
const redirect = route.query.redirect || '/'
await router.push(redirect)
} else {
ElMessage.error(result.message)
}
} catch (error) {
console.error('登录失败:', error)
ElMessage.error('登录失败,请稍后重试')
} finally {
loading.value = false
}
}
// 处理注册
const handleRegister = async () => {
if (!registerFormRef.value) return
const valid = await registerFormRef.value.validate().catch(() => false)
if (!valid) return
registerLoading.value = true
try {
const result = await authStore.register(registerForm)
if (result.success) {
ElMessage.success(result.message)
showRegister.value = false
// 跳转到首页
await router.push('/')
} else {
ElMessage.error(result.message)
}
} catch (error) {
console.error('注册失败:', error)
ElMessage.error('注册失败,请稍后重试')
} finally {
registerLoading.value = false
}
}
// 设置演示账号
const setDemoAccount = (type) => {
if (type === 'admin') {
loginForm.username = 'admin'
loginForm.password = 'admin123456'
} else {
loginForm.username = 'zhangsan'
loginForm.password = 'test123456'
}
}
// 重置注册表单
const resetRegisterForm = () => {
Object.assign(registerForm, {
username: '',
email: '',
first_name: '',
last_name: '',
department: '',
position: '',
phone: '',
password: '',
password_confirm: ''
})
registerFormRef.value?.resetFields()
}
// 监听注册对话框关闭
const handleRegisterDialogClose = () => {
resetRegisterForm()
}
onMounted(() => {
// 初始化主题
appStore.initTheme()
// 如果已登录,直接跳转到首页
if (authStore.isLoggedIn) {
router.push('/')
}
})
</script>
<style lang="scss" scoped>
.login-container {
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-form-wrapper {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 32px;
.login-title {
font-size: 28px;
font-weight: bold;
color: #303133;
margin: 0 0 8px 0;
}
.login-subtitle {
font-size: 14px;
color: #909399;
margin: 0;
}
}
.login-form {
.login-button {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 8px;
}
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
}
.login-demo {
margin-top: 24px;
.demo-accounts {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 16px;
}
}
// 暗色模式
.dark {
.login-form-wrapper {
background: rgba(29, 30, 31, 0.95);
.login-title {
color: #e5eaf3;
}
.login-subtitle {
color: #a3a6ad;
}
}
}
// 响应式
@media (max-width: 480px) {
.login-form-wrapper {
padding: 24px;
margin: 0 16px;
}
.login-header {
margin-bottom: 24px;
.login-title {
font-size: 24px;
}
}
.login-demo {
.demo-accounts {
flex-direction: column;
gap: 8px;
}
}
}
:deep(.el-input__inner) {
height: 48px;
border-radius: 8px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-checkbox) {
.el-checkbox__label {
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,461 @@
<template>
<div class="page-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">个人中心</h1>
<p class="page-description">管理您的个人信息和账户设置</p>
</div>
<el-row :gutter="20">
<!-- 个人信息卡片 -->
<el-col :xs="24" :lg="8">
<div class="cool-card">
<div class="card-header">
<h3>个人信息</h3>
</div>
<div class="card-body">
<div class="profile-info">
<!-- 头像 -->
<div class="avatar-section">
<el-avatar :size="80" :src="userInfo.avatar">
{{ userInfo.username?.charAt(0)?.toUpperCase() }}
</el-avatar>
<div class="avatar-actions">
<el-button size="small" type="text">更换头像</el-button>
</div>
</div>
<!-- 基本信息 -->
<div class="info-list">
<div class="info-item">
<label>用户名</label>
<span>{{ userInfo.username }}</span>
</div>
<div class="info-item">
<label>姓名</label>
<span>{{ userInfo.full_name || '未设置' }}</span>
</div>
<div class="info-item">
<label>邮箱</label>
<span>{{ userInfo.email || '未设置' }}</span>
</div>
<div class="info-item">
<label>部门</label>
<span>{{ userInfo.department || '未设置' }}</span>
</div>
<div class="info-item">
<label>职位</label>
<span>{{ userInfo.position || '未设置' }}</span>
</div>
<div class="info-item">
<label>角色</label>
<el-tag :type="getRoleType(userInfo)">
{{ getUserRoleText(userInfo) }}
</el-tag>
</div>
<div class="info-item">
<label>注册时间</label>
<span>{{ formatDate(userInfo.date_joined) }}</span>
</div>
</div>
</div>
</div>
</div>
</el-col>
<!-- 编辑表单 -->
<el-col :xs="24" :lg="16">
<div class="cool-card">
<div class="card-header">
<h3>编辑资料</h3>
</div>
<div class="card-body">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
class="cool-form"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓氏" prop="last_name">
<el-input
v-model="form.last_name"
placeholder="请输入姓氏"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名字" prop="first_name">
<el-input
v-model="form.first_name"
placeholder="请输入名字"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="form.email"
placeholder="请输入邮箱"
clearable
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="部门" prop="department">
<el-input
v-model="form.department"
placeholder="请输入部门"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="职位" prop="position">
<el-input
v-model="form.position"
placeholder="请输入职位"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="form.phone"
placeholder="请输入手机号"
clearable
/>
</el-form-item>
<el-form-item>
<div class="button-group">
<el-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
保存修改
</el-button>
<el-button @click="handleReset">
重置
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</div>
<!-- 修改密码 -->
<div class="cool-card mt-20">
<div class="card-header">
<h3>修改密码</h3>
</div>
<div class="card-body">
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
class="cool-form"
>
<el-form-item label="当前密码" prop="old_password">
<el-input
v-model="passwordForm.old_password"
type="password"
placeholder="请输入当前密码"
show-password
clearable
/>
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input
v-model="passwordForm.new_password"
type="password"
placeholder="请输入新密码"
show-password
clearable
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirm_password">
<el-input
v-model="passwordForm.confirm_password"
type="password"
placeholder="请再次输入新密码"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="passwordSubmitting"
@click="handlePasswordSubmit"
>
修改密码
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { formatDate, getUserRoleText, validateEmail, validatePhone } from '@/utils'
const authStore = useAuthStore()
// 表单引用
const formRef = ref()
const passwordFormRef = ref()
// 状态
const submitting = ref(false)
const passwordSubmitting = ref(false)
// 用户信息
const userInfo = ref({})
// 编辑表单
const form = reactive({
first_name: '',
last_name: '',
email: '',
department: '',
position: '',
phone: ''
})
// 密码表单
const passwordForm = reactive({
old_password: '',
new_password: '',
confirm_password: ''
})
// 表单验证规则
const rules = {
first_name: [
{ required: true, message: '请输入名字', trigger: 'blur' }
],
last_name: [
{ required: true, message: '请输入姓氏', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ validator: (rule, value, callback) => {
if (!validateEmail(value)) {
callback(new Error('请输入正确的邮箱格式'))
} else {
callback()
}
}, trigger: 'blur' }
],
phone: [
{ validator: (rule, value, callback) => {
if (value && !validatePhone(value)) {
callback(new Error('请输入正确的手机号格式'))
} else {
callback()
}
}, trigger: 'blur' }
]
}
// 密码表单验证规则
const passwordRules = {
old_password: [
{ required: true, message: '请输入当前密码', trigger: 'blur' }
],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
],
confirm_password: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: (rule, value, callback) => {
if (value !== passwordForm.new_password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}, trigger: 'blur' }
]
}
// 获取角色类型
const getRoleType = (user) => {
if (user.is_superuser) return 'danger'
if (user.is_staff) return 'warning'
return 'success'
}
// 初始化表单数据
const initFormData = () => {
const user = authStore.user
if (user) {
userInfo.value = { ...user }
Object.assign(form, {
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email || '',
department: user.department || '',
position: user.position || '',
phone: user.phone || ''
})
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const result = await authStore.updateUserInfo(form)
if (result.success) {
ElMessage.success('个人信息更新成功')
// 更新本地用户信息
initFormData()
} else {
ElMessage.error(result.message)
}
} catch (error) {
console.error('更新个人信息失败:', error)
ElMessage.error('更新失败,请稍后重试')
} finally {
submitting.value = false
}
}
// 重置表单
const handleReset = () => {
initFormData()
formRef.value?.clearValidate()
}
// 提交密码表单
const handlePasswordSubmit = async () => {
if (!passwordFormRef.value) return
const valid = await passwordFormRef.value.validate().catch(() => false)
if (!valid) return
passwordSubmitting.value = true
try {
// 这里应该调用修改密码的API
// 由于后端没有实现,暂时模拟
await new Promise(resolve => setTimeout(resolve, 1000))
ElMessage.success('密码修改成功')
// 重置密码表单
Object.assign(passwordForm, {
old_password: '',
new_password: '',
confirm_password: ''
})
passwordFormRef.value?.resetFields()
} catch (error) {
console.error('修改密码失败:', error)
ElMessage.error('修改密码失败,请稍后重试')
} finally {
passwordSubmitting.value = false
}
}
// 初始化
onMounted(() => {
initFormData()
})
</script>
<style lang="scss" scoped>
.profile-info {
.avatar-section {
text-align: center;
margin-bottom: 24px;
.avatar-actions {
margin-top: 12px;
}
}
.info-list {
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
label {
font-weight: 500;
color: #606266;
min-width: 80px;
}
span {
color: #303133;
text-align: right;
flex: 1;
}
}
}
}
// 暗色模式
.dark {
.profile-info {
.info-list {
.info-item {
border-color: #4c4d4f;
label {
color: #cfd3dc;
}
span {
color: #e5eaf3;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,706 @@
<template>
<div class="page-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">日报详情</h1>
<p class="page-description">查看日报的详细内容</p>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
<p>加载中...</p>
</div>
<!-- 日报内容 -->
<template v-else-if="report">
<!-- 日报信息卡片 -->
<div class="cool-card">
<div class="card-header">
<div class="report-header">
<div class="report-info">
<h3>{{ formatDate(report.report_date) }} 的日报</h3>
<div class="report-meta">
<span class="author">{{ report.user.full_name }}</span>
<span class="department">{{ report.user.department || '未设置部门' }}</span>
<el-tag :type="report.is_draft ? 'warning' : 'success'" size="small">
{{ report.is_draft ? '草稿' : '已发布' }}
</el-tag>
</div>
</div>
<div class="report-actions">
<el-button @click="goBack">
<el-icon><Back /></el-icon>
返回
</el-button>
<el-button
v-if="report.can_edit"
type="primary"
:icon="Edit"
@click="editReport"
>
编辑
</el-button>
<el-dropdown v-if="report.can_edit || report.can_delete" @command="handleCommand">
<el-button :icon="More">
更多
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="report.can_edit"
:command="report.is_draft ? 'publish' : 'draft'"
>
{{ report.is_draft ? '发布日报' : '设为草稿' }}
</el-dropdown-item>
<el-dropdown-item
v-if="report.can_delete"
command="delete"
class="danger-item"
>
删除日报
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<div class="card-body">
<!-- 工作总结 -->
<div class="content-section">
<h4 class="section-title">
<el-icon><Document /></el-icon>
工作总结
</h4>
<div class="content-html" v-html="report.work_summary"></div>
</div>
<!-- 明日计划 -->
<div class="content-section">
<h4 class="section-title">
<el-icon><Calendar /></el-icon>
明日计划
</h4>
<div class="content-html" v-html="report.next_day_plan"></div>
</div>
<!-- 遇到的困难 -->
<div v-if="report.difficulties" class="content-section">
<h4 class="section-title">
<el-icon><Warning /></el-icon>
遇到的困难
</h4>
<div class="content-text">{{ report.difficulties }}</div>
</div>
<!-- 建议或意见 -->
<div v-if="report.suggestions" class="content-section">
<h4 class="section-title">
<el-icon><ChatDotRound /></el-icon>
建议或意见
</h4>
<div class="content-text">{{ report.suggestions }}</div>
</div>
<!-- 时间信息 -->
<div class="time-info">
<div class="time-item">
<span class="time-label">提交时间:</span>
<span class="time-value">{{ formatDateTime(report.created_at) }}</span>
</div>
<div v-if="report.updated_at !== report.created_at" class="time-item">
<span class="time-label">更新时间:</span>
<span class="time-value">{{ formatDateTime(report.updated_at) }}</span>
</div>
</div>
</div>
</div>
<!-- 评论区域 -->
<div class="cool-card mt-20">
<div class="card-header">
<h3>评论 ({{ comments.length }})</h3>
</div>
<div class="card-body">
<!-- 评论输入框 -->
<div class="comment-input">
<el-input
v-model="newComment"
type="textarea"
:rows="3"
placeholder="写下你的评论..."
maxlength="500"
show-word-limit
/>
<div class="comment-actions">
<el-button
type="primary"
size="small"
:loading="commentSubmitting"
:disabled="!newComment.trim()"
@click="submitComment"
>
发表评论
</el-button>
</div>
</div>
<!-- 评论列表 -->
<div v-if="comments.length > 0" class="comments-list">
<div
v-for="comment in comments"
:key="comment.id"
class="comment-item"
>
<div class="comment-header">
<div class="comment-author">
<el-avatar :size="32">{{ comment.user.full_name.charAt(0) }}</el-avatar>
<div class="author-info">
<div class="author-name">{{ comment.user.full_name }}</div>
<div class="comment-time">{{ fromNow(comment.created_at) }}</div>
</div>
</div>
<div v-if="canDeleteComment(comment)" class="comment-actions">
<el-button
type="text"
size="small"
class="danger-button"
@click="deleteComment(comment)"
>
删除
</el-button>
</div>
</div>
<div class="comment-content">{{ comment.content }}</div>
</div>
</div>
<div v-else class="empty-comments">
<el-empty description="暂无评论" :image-size="80" />
</div>
</div>
</div>
</template>
<!-- 日报不存在 -->
<div v-else class="not-found">
<el-result
icon="warning"
title="日报不存在"
sub-title="您访问的日报不存在或已被删除"
>
<template #extra>
<el-button type="primary" @click="goBack">返回</el-button>
</template>
</el-result>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Back,
Edit,
More,
ArrowDown,
Document,
Calendar,
Warning,
ChatDotRound,
Loading
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useReportsStore } from '@/stores/reports'
import { request } from '@/utils/request'
import { formatDate, formatDateTime, fromNow } from '@/utils'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const reportsStore = useReportsStore()
// 状态
const loading = ref(true)
const commentSubmitting = ref(false)
const newComment = ref('')
const comments = ref([])
// 计算属性
const report = computed(() => reportsStore.currentReport)
// 检查是否可以删除评论
const canDeleteComment = (comment) => {
return comment.user.id === authStore.user?.id || authStore.isAdmin
}
// 获取日报详情
const fetchReportDetail = async () => {
loading.value = true
try {
const result = await reportsStore.fetchReportDetail(route.params.id)
if (result.success) {
// 获取评论
await fetchComments()
} else {
ElMessage.error('获取日报详情失败')
}
} catch (error) {
console.error('获取日报详情失败:', error)
ElMessage.error('获取日报详情失败')
} finally {
loading.value = false
}
}
// 获取评论列表
const fetchComments = async () => {
try {
const response = await request.get(`/reports/${route.params.id}/comments/`)
comments.value = response.results || response || []
} catch (error) {
console.error('获取评论失败:', error)
}
}
// 提交评论
const submitComment = async () => {
if (!newComment.value.trim()) return
commentSubmitting.value = true
try {
const response = await request.post(`/reports/${route.params.id}/comments/`, {
content: newComment.value.trim()
})
comments.value.unshift(response)
newComment.value = ''
ElMessage.success('评论发表成功')
} catch (error) {
console.error('发表评论失败:', error)
ElMessage.error('发表评论失败')
} finally {
commentSubmitting.value = false
}
}
// 删除评论
const deleteComment = async (comment) => {
try {
await ElMessageBox.confirm('确定要删除这条评论吗?', '确认删除', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/comments/${comment.id}/`)
const index = comments.value.findIndex(c => c.id === comment.id)
if (index !== -1) {
comments.value.splice(index, 1)
}
ElMessage.success('评论删除成功')
} catch (error) {
if (error !== 'cancel') {
console.error('删除评论失败:', error)
ElMessage.error('删除评论失败')
}
}
}
// 编辑日报
const editReport = () => {
router.push(`/reports/${route.params.id}/edit`)
}
// 处理下拉菜单命令
const handleCommand = async (command) => {
switch (command) {
case 'publish':
case 'draft':
await toggleDraftStatus()
break
case 'delete':
await deleteReport()
break
}
}
// 切换草稿状态
const toggleDraftStatus = async () => {
try {
const action = report.value.is_draft ? '发布' : '设为草稿'
await ElMessageBox.confirm(
`确定要${action}这份日报吗?`,
'确认操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const result = await reportsStore.toggleDraftStatus(report.value.id)
if (result.success) {
ElMessage.success(`${action}成功`)
// 重新获取详情
await fetchReportDetail()
} else {
ElMessage.error(`${action}失败`)
}
} catch (error) {
if (error !== 'cancel') {
console.error('切换草稿状态失败:', error)
ElMessage.error('操作失败')
}
}
}
// 删除日报
const deleteReport = async () => {
try {
await ElMessageBox.confirm(
'确定要删除这份日报吗?删除后无法恢复。',
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'error'
}
)
const result = await reportsStore.deleteReport(report.value.id)
if (result.success) {
ElMessage.success('删除成功')
router.push('/reports')
} else {
ElMessage.error(result.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除日报失败:', error)
ElMessage.error('删除失败')
}
}
}
// 返回上一页
const goBack = () => {
router.go(-1)
}
// 初始化
onMounted(() => {
fetchReportDetail()
})
</script>
<style lang="scss" scoped>
.loading-container {
text-align: center;
padding: 60px 0;
p {
margin-top: 16px;
color: #909399;
}
}
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
.report-info {
flex: 1;
h3 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
}
.report-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: #909399;
.author {
font-weight: 500;
color: #303133;
}
}
}
.report-actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
}
.content-section {
margin-bottom: 32px;
&:last-child {
margin-bottom: 0;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid #e4e7ed;
.el-icon {
color: #409eff;
}
}
.content-html {
line-height: 1.8;
color: #606266;
:deep(p) {
margin: 0 0 12px 0;
&:last-child {
margin-bottom: 0;
}
}
:deep(ul), :deep(ol) {
padding-left: 20px;
margin: 0 0 12px 0;
}
:deep(blockquote) {
border-left: 4px solid #409eff;
padding-left: 16px;
margin: 16px 0;
color: #909399;
font-style: italic;
}
}
.content-text {
line-height: 1.6;
color: #606266;
white-space: pre-wrap;
}
}
.time-info {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
.time-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
&:last-child {
margin-bottom: 0;
}
.time-label {
color: #909399;
margin-right: 8px;
min-width: 80px;
}
.time-value {
color: #606266;
}
}
}
.comment-input {
margin-bottom: 24px;
.comment-actions {
margin-top: 12px;
text-align: right;
}
}
.comments-list {
.comment-item {
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.comment-author {
display: flex;
align-items: center;
.author-info {
margin-left: 12px;
.author-name {
font-weight: 500;
color: #303133;
margin-bottom: 2px;
}
.comment-time {
font-size: 12px;
color: #c0c4cc;
}
}
}
.comment-actions {
.danger-button {
color: #f56c6c;
&:hover {
color: #f78989;
}
}
}
}
.comment-content {
color: #606266;
line-height: 1.6;
padding-left: 44px;
}
}
}
.empty-comments {
padding: 40px 0;
}
.not-found {
padding: 60px 0;
}
// 下拉菜单危险项样式
:deep(.danger-item) {
color: #f56c6c;
&:hover {
color: #f56c6c;
background-color: #fef0f0;
}
}
// 暗色模式
.dark {
.report-header {
.report-info {
h3 {
color: #e5eaf3;
}
.report-meta {
.author {
color: #e5eaf3;
}
}
}
}
.content-section {
.section-title {
color: #e5eaf3;
border-color: #4c4d4f;
}
.content-html {
color: #cfd3dc;
}
.content-text {
color: #cfd3dc;
}
}
.time-info {
border-color: #4c4d4f;
.time-item {
.time-label {
color: #a3a6ad;
}
.time-value {
color: #cfd3dc;
}
}
}
.comments-list {
.comment-item {
border-color: #4c4d4f;
.comment-header {
.comment-author {
.author-info {
.author-name {
color: #e5eaf3;
}
.comment-time {
color: #a3a6ad;
}
}
}
}
.comment-content {
color: #cfd3dc;
}
}
}
}
// 响应式
@media (max-width: 768px) {
.report-header {
flex-direction: column;
gap: 16px;
.report-actions {
width: 100%;
justify-content: flex-start;
}
}
.comment-item {
.comment-content {
padding-left: 0;
margin-top: 8px;
}
}
}
</style>

View File

@@ -0,0 +1,451 @@
<template>
<div class="page-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">{{ isEdit ? '编辑日报' : '创建日报' }}</h1>
<p class="page-description">{{ isEdit ? '修改日报内容' : '填写今日工作总结和明日计划' }}</p>
</div>
<!-- 表单卡片 -->
<div class="cool-card">
<div class="card-header">
<h3>{{ isEdit ? '编辑日报' : '新建日报' }}</h3>
<div class="header-actions">
<el-button @click="goBack">
<el-icon><Back /></el-icon>
返回
</el-button>
</div>
</div>
<div class="card-body">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
class="cool-form"
@submit.prevent
>
<!-- 日报日期 -->
<el-form-item label="日报日期" prop="report_date">
<el-date-picker
v-model="form.report_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
style="width: 200px"
/>
</el-form-item>
<!-- 工作总结 -->
<el-form-item label="工作总结" prop="work_summary">
<div class="editor-container">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
style="border-bottom: 1px solid #ccc"
/>
<Editor
v-model="form.work_summary"
:default-config="editorConfig"
mode="default"
style="height: 300px; overflow-y: hidden;"
@on-created="handleCreated"
/>
</div>
<div class="form-tip">
请详细描述今日完成的工作内容取得的成果等
</div>
</el-form-item>
<!-- 明日计划 -->
<el-form-item label="明日计划" prop="next_day_plan">
<div class="editor-container">
<Toolbar
:editor="planEditorRef"
:default-config="toolbarConfig"
mode="default"
style="border-bottom: 1px solid #ccc"
/>
<Editor
v-model="form.next_day_plan"
:default-config="editorConfig"
mode="default"
style="height: 200px; overflow-y: hidden;"
@on-created="handlePlanCreated"
/>
</div>
<div class="form-tip">
请规划明日的工作内容和目标
</div>
</el-form-item>
<!-- 遇到的困难 -->
<el-form-item label="遇到的困难" prop="difficulties">
<el-input
v-model="form.difficulties"
type="textarea"
:rows="4"
placeholder="描述工作中遇到的问题或困难(可选)"
maxlength="1000"
show-word-limit
/>
</el-form-item>
<!-- 建议或意见 -->
<el-form-item label="建议或意见" prop="suggestions">
<el-input
v-model="form.suggestions"
type="textarea"
:rows="4"
placeholder="对工作或团队的建议(可选)"
maxlength="1000"
show-word-limit
/>
</el-form-item>
<!-- 草稿状态 -->
<el-form-item label="发布状态">
<el-radio-group v-model="form.is_draft">
<el-radio :label="false">立即发布</el-radio>
<el-radio :label="true">保存为草稿</el-radio>
</el-radio-group>
<div class="form-tip">
草稿状态的日报不会在列表中显示给其他人
</div>
</el-form-item>
<!-- 提交按钮 -->
<el-form-item>
<div class="button-group">
<el-button
type="primary"
size="large"
:loading="submitting"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : (isEdit ? '更新日报' : '提交日报') }}
</el-button>
<el-button
size="large"
@click="handleReset"
>
重置
</el-button>
<el-button
size="large"
@click="goBack"
>
取消
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Back } from '@element-plus/icons-vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useReportsStore } from '@/stores/reports'
import { getToday } from '@/utils'
// 导入编辑器样式
import '@wangeditor/editor/dist/css/style.css'
const route = useRoute()
const router = useRouter()
const reportsStore = useReportsStore()
// 表单引用
const formRef = ref()
const editorRef = ref()
const planEditorRef = ref()
// 状态
const submitting = ref(false)
const loading = ref(false)
// 是否为编辑模式
const isEdit = computed(() => !!route.params.id)
// 表单数据
const form = reactive({
report_date: getToday(),
work_summary: '',
next_day_plan: '',
difficulties: '',
suggestions: '',
is_draft: false
})
// 编辑器配置
const toolbarConfig = {
excludeKeys: [
'uploadImage',
'uploadVideo',
'insertTable',
'codeBlock',
'group-video'
]
}
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {}
}
// 表单验证规则
const rules = {
report_date: [
{ required: true, message: '请选择日报日期', trigger: 'change' }
],
work_summary: [
{ required: true, message: '请填写工作总结', trigger: 'blur' },
{ min: 10, message: '工作总结至少需要10个字符', trigger: 'blur' }
],
next_day_plan: [
{ required: true, message: '请填写明日计划', trigger: 'blur' },
{ min: 10, message: '明日计划至少需要10个字符', trigger: 'blur' }
]
}
// 禁用日期(只能选择今天及之前的日期)
const disabledDate = (time) => {
return time.getTime() > Date.now()
}
// 编辑器创建回调
const handleCreated = (editor) => {
editorRef.value = editor
}
const handlePlanCreated = (editor) => {
planEditorRef.value = editor
}
// 获取日报详情
const fetchReportDetail = async (id) => {
loading.value = true
try {
const result = await reportsStore.fetchReportDetail(id)
if (result.success) {
const report = result.data
Object.assign(form, {
report_date: report.report_date,
work_summary: report.work_summary,
next_day_plan: report.next_day_plan,
difficulties: report.difficulties || '',
suggestions: report.suggestions || '',
is_draft: report.is_draft
})
} else {
ElMessage.error('获取日报详情失败')
goBack()
}
} catch (error) {
console.error('获取日报详情失败:', error)
ElMessage.error('获取日报详情失败')
goBack()
} finally {
loading.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
// 检查富文本内容
if (!form.work_summary || form.work_summary.trim() === '<p><br></p>') {
ElMessage.error('请填写工作总结')
return
}
if (!form.next_day_plan || form.next_day_plan.trim() === '<p><br></p>') {
ElMessage.error('请填写明日计划')
return
}
submitting.value = true
try {
let result
if (isEdit.value) {
result = await reportsStore.updateReport(route.params.id, form)
} else {
result = await reportsStore.createReport(form)
}
if (result.success) {
ElMessage.success(result.message)
goBack()
} else {
ElMessage.error(result.message)
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
// 重置表单
const handleReset = async () => {
try {
await ElMessageBox.confirm('确定要重置表单吗?所有未保存的内容将丢失。', '确认重置', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
if (isEdit.value) {
// 编辑模式下重新获取数据
await fetchReportDetail(route.params.id)
} else {
// 创建模式下重置为初始值
Object.assign(form, {
report_date: getToday(),
work_summary: '',
next_day_plan: '',
difficulties: '',
suggestions: '',
is_draft: false
})
}
formRef.value?.clearValidate()
} catch (error) {
// 用户取消操作
}
}
// 返回上一页
const goBack = () => {
router.go(-1)
}
// 页面离开确认
const handleBeforeUnload = (e) => {
if (submitting.value) return
const message = '您有未保存的更改,确定要离开吗?'
e.returnValue = message
return message
}
// 初始化
onMounted(async () => {
// 如果是编辑模式,获取日报详情
if (isEdit.value) {
await fetchReportDetail(route.params.id)
}
// 添加页面离开确认
window.addEventListener('beforeunload', handleBeforeUnload)
})
// 清理
onBeforeUnmount(() => {
// 销毁编辑器
if (editorRef.value) {
editorRef.value.destroy()
}
if (planEditorRef.value) {
planEditorRef.value.destroy()
}
// 移除事件监听
window.removeEventListener('beforeunload', handleBeforeUnload)
})
</script>
<style lang="scss" scoped>
.header-actions {
display: flex;
gap: 12px;
}
.editor-container {
border: 1px solid #ccc;
z-index: 100;
&:focus-within {
border-color: #409eff;
}
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
line-height: 1.4;
}
.button-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
// 暗色模式下编辑器样式调整
.dark {
.editor-container {
border-color: #4c4d4f;
&:focus-within {
border-color: #409eff;
}
}
.form-tip {
color: #a3a6ad;
}
}
// 编辑器样式覆盖
:deep(.w-e-text-container) {
background-color: var(--el-bg-color);
color: var(--el-text-color-primary);
}
:deep(.w-e-text-placeholder) {
color: var(--el-text-color-placeholder);
}
:deep(.w-e-toolbar) {
background-color: var(--el-bg-color-page);
border-color: var(--el-border-color);
}
:deep(.w-e-toolbar .w-e-bar-item button) {
color: var(--el-text-color-regular);
}
:deep(.w-e-toolbar .w-e-bar-item button:hover) {
background-color: var(--el-fill-color-light);
}
</style>

View File

@@ -0,0 +1,531 @@
<template>
<div class="page-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">日报管理</h1>
<p class="page-description">查看和管理{{ authStore.isAdmin ? '所有' : '我的' }}日报</p>
</div>
<!-- 搜索表单 -->
<div class="search-form">
<el-form
ref="searchFormRef"
:model="searchForm"
:inline="true"
label-width="80px"
>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleDateRangeChange"
/>
</el-form-item>
<el-form-item v-if="authStore.isAdmin" label="提交人">
<el-input
v-model="searchForm.user_username"
placeholder="请输入用户名"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="searchForm.work_summary"
placeholder="搜索工作总结"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.is_draft"
placeholder="请选择状态"
clearable
style="width: 120px"
>
<el-option label="全部" :value="null" />
<el-option label="已发布" :value="false" />
<el-option label="草稿" :value="true" />
</el-select>
</el-form-item>
<el-form-item>
<div class="search-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">
搜索
</el-button>
<el-button :icon="Refresh" @click="handleReset">
重置
</el-button>
</div>
</el-form-item>
</el-form>
</div>
<!-- 操作栏 -->
<div class="cool-card">
<div class="card-header">
<h3>日报列表</h3>
<div class="header-actions">
<el-button
type="primary"
:icon="Plus"
@click="createReport"
>
新建日报
</el-button>
</div>
</div>
<div class="card-body">
<!-- 表格 -->
<div class="cool-table">
<el-table
v-loading="loading"
:data="reportList"
stripe
border
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="report_date"
label="日报日期"
width="120"
sortable="custom"
>
<template #default="{ row }">
{{ formatDate(row.report_date) }}
</template>
</el-table-column>
<el-table-column
v-if="authStore.isAdmin"
prop="user.full_name"
label="提交人"
width="120"
>
<template #default="{ row }">
<div class="user-info">
<div class="user-name">{{ row.user.full_name }}</div>
<div class="user-department">{{ row.user.department || '未设置' }}</div>
</div>
</template>
</el-table-column>
<el-table-column
prop="work_summary_preview"
label="工作总结"
min-width="200"
show-overflow-tooltip
/>
<el-table-column
prop="next_day_plan_preview"
label="明日计划"
min-width="200"
show-overflow-tooltip
/>
<el-table-column
prop="is_draft"
label="状态"
width="80"
>
<template #default="{ row }">
<el-tag :type="row.is_draft ? 'warning' : 'success'" size="small">
{{ row.is_draft ? '草稿' : '已发布' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="created_at"
label="提交时间"
width="160"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="200"
fixed="right"
>
<template #default="{ row }">
<div class="button-group">
<el-button
type="text"
size="small"
:icon="View"
@click="viewReport(row.id)"
>
查看
</el-button>
<el-button
v-if="canEdit(row)"
type="text"
size="small"
:icon="Edit"
@click="editReport(row.id)"
>
编辑
</el-button>
<el-button
v-if="canEdit(row)"
type="text"
size="small"
:icon="row.is_draft ? 'Promotion' : 'DocumentCopy'"
@click="toggleDraftStatus(row)"
>
{{ row.is_draft ? '发布' : '设为草稿' }}
</el-button>
<el-button
v-if="canDelete(row)"
type="text"
size="small"
:icon="Delete"
class="danger-button"
@click="deleteReport(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
Refresh,
Plus,
View,
Edit,
Delete
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useReportsStore } from '@/stores/reports'
import { formatDate, formatDateTime } from '@/utils'
const router = useRouter()
const authStore = useAuthStore()
const reportsStore = useReportsStore()
// 表单引用
const searchFormRef = ref()
// 状态
const loading = ref(false)
const dateRange = ref([])
// 搜索表单
const searchForm = reactive({
user_username: '',
work_summary: '',
is_draft: null
})
// 计算属性
const reportList = computed(() => reportsStore.reports)
const pagination = computed(() => reportsStore.pagination)
// 检查是否可以编辑
const canEdit = (row) => {
return row.user.id === authStore.user?.id || authStore.isAdmin
}
// 检查是否可以删除
const canDelete = (row) => {
return row.user.id === authStore.user?.id || authStore.isAdmin
}
// 处理日期范围变化
const handleDateRangeChange = (dates) => {
if (dates && dates.length === 2) {
reportsStore.setSearchParams({
report_date_start: dates[0],
report_date_end: dates[1]
})
} else {
reportsStore.setSearchParams({
report_date_start: '',
report_date_end: ''
})
}
}
// 搜索
const handleSearch = () => {
// 设置搜索参数
reportsStore.setSearchParams(searchForm)
// 重置分页
reportsStore.setPagination({ current: 1 })
// 获取数据
fetchReports()
}
// 重置搜索
const handleReset = () => {
// 重置表单
Object.assign(searchForm, {
user_username: '',
work_summary: '',
is_draft: null
})
dateRange.value = []
// 重置搜索参数
reportsStore.resetSearchParams()
// 重置分页
reportsStore.setPagination({ current: 1 })
// 获取数据
fetchReports()
}
// 处理排序变化
const handleSortChange = ({ prop, order }) => {
const orderMap = {
ascending: '',
descending: '-'
}
const ordering = order ? `${orderMap[order]}${prop}` : ''
fetchReports({ ordering })
}
// 处理页码变化
const handleCurrentChange = (page) => {
reportsStore.setPagination({ current: page })
fetchReports()
}
// 处理页大小变化
const handleSizeChange = (size) => {
reportsStore.setPagination({ current: 1, pageSize: size })
fetchReports()
}
// 获取日报列表
const fetchReports = async (params = {}) => {
loading.value = true
try {
await reportsStore.fetchReports(params)
} catch (error) {
console.error('获取日报列表失败:', error)
ElMessage.error('获取日报列表失败')
} finally {
loading.value = false
}
}
// 创建日报
const createReport = () => {
router.push('/reports/create')
}
// 查看日报
const viewReport = (id) => {
router.push(`/reports/${id}`)
}
// 编辑日报
const editReport = (id) => {
router.push(`/reports/${id}/edit`)
}
// 切换草稿状态
const toggleDraftStatus = async (row) => {
try {
const action = row.is_draft ? '发布' : '设为草稿'
await ElMessageBox.confirm(
`确定要${action}这份日报吗?`,
'确认操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const result = await reportsStore.toggleDraftStatus(row.id)
if (result.success) {
ElMessage.success(`${action}成功`)
} else {
ElMessage.error(`${action}失败`)
}
} catch (error) {
if (error !== 'cancel') {
console.error('切换草稿状态失败:', error)
ElMessage.error('操作失败')
}
}
}
// 删除日报
const deleteReport = async (row) => {
try {
await ElMessageBox.confirm(
'确定要删除这份日报吗?删除后无法恢复。',
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'error'
}
)
const result = await reportsStore.deleteReport(row.id)
if (result.success) {
ElMessage.success('删除成功')
// 如果当前页没有数据了,回到上一页
if (reportList.value.length === 1 && pagination.value.current > 1) {
reportsStore.setPagination({ current: pagination.value.current - 1 })
}
// 重新获取数据
fetchReports()
} else {
ElMessage.error(result.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除日报失败:', error)
ElMessage.error('删除失败')
}
}
}
// 初始化
onMounted(() => {
fetchReports()
})
</script>
<style lang="scss" scoped>
.header-actions {
display: flex;
gap: 12px;
}
.search-actions {
display: flex;
gap: 8px;
}
.user-info {
.user-name {
font-weight: 500;
color: #303133;
}
.user-department {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
}
.button-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
.danger-button {
color: #f56c6c;
&:hover {
color: #f78989;
}
}
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
// 暗色模式
.dark {
.user-info {
.user-name {
color: #e5eaf3;
}
.user-department {
color: #a3a6ad;
}
}
}
// 响应式
@media (max-width: 768px) {
.search-form {
:deep(.el-form-item) {
margin-bottom: 16px;
.el-form-item__content {
margin-left: 0 !important;
}
}
:deep(.el-form--inline .el-form-item) {
display: block;
margin-right: 0;
}
}
.header-actions {
margin-top: 12px;
}
.pagination-container {
text-align: center;
:deep(.el-pagination) {
justify-content: center;
}
}
}
</style>