wzclm 286ff15a08 重构权限管理路由和组件结构
- 将系统管理中的用户、角色和权限管理移动到新的 auth 目录
- 更新路由配置,将 system 和 auth 路由分离
- 调整权限管理相关组件的导入和路由配置
- 优化路由和组件的命名和结构
- 移除系统管理中的用户相关管理功能
2025-03-09 14:32:42 +08:00

663 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, onMounted, computed, nextTick } from 'vue'
import { Search, Plus, Edit, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getProjectList, deleteProject, updateProjectSort, createProject, updateProject } from '@/api/projects'
import { formatDateTime } from '@/utils/format'
import { sortArrayByField } from '@/utils/sort'
import request from '@/utils/request'
// 定义表格数据结构
const tableData = ref([])
const originalData = ref([]) // 保存原始数据
const loading = ref(false)
// 定义分页数据
const pagination = ref({
page: 1,
limit: 10,
total: 0
})
// 定义搜索表单数据
const searchForm = ref({
title: '',
type: ''
})
// 类型选项
const typeOptions = [
{ label: '关于我们', value: 'about' },
{ label: '团队介绍', value: 'team' },
{ label: '环境介绍', value: 'environment' }
]
// 表格列配置
const columns = [
{ prop: 'title', label: '标题', minWidth: 180 },
{ prop: 'content', label: '内容', minWidth: 300 },
{ prop: 'type', label: '类型', width: 120 },
{ prop: 'sort_order', label: '排序', width: 80 },
{ prop: 'status', label: '状态', width: 80 },
{ prop: 'created_at', label: '创建时间', width: 180 },
{ prop: 'creator_name', label: '创建人', width: 120 },
{ prop: 'updated_at', label: '更新时间', width: 180 },
{ prop: 'updater_name', label: '更新人', width: 120 },
{ prop: 'operation', label: '操作', width: 150, fixed: 'right' }
]
// 弹窗相关
const dialogVisible = ref(false)
const dialogTitle = ref('新增简介')
const formRef = ref(null)
const formData = ref({
title: '',
content: '',
cover_image: '',
type: '',
sort_order: 0,
status: 1,
imageInputType: 'upload' // 默认选择上传图片
})
// 表单校验规则
const rules = {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 2, max: 200, message: '长度在 2 到 200 个字符', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择类型', trigger: 'change' }
],
content: [
{ required: true, message: '请输入内容', trigger: 'blur' }
],
sort_order: [
{ required: true, message: '请输入排序', trigger: 'blur' },
{ type: 'number', message: '排序必须为数字', trigger: 'blur' }
]
}
// 添加计算属性用于处理图片URL
const imageUrl = computed(() => {
const url = formData.value.cover_image
if (!url) return ''
if (url.startsWith('data:')) return url
if (url.startsWith('http')) return url
// 使用后端服务器URL
return `http://localhost:3000${url}`
})
// 获取上传请求头
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return {
Authorization: token ? `Bearer ${token}` : ''
}
})
// 获取列表数据
const getList = async () => {
try {
loading.value = true
const params = {
page: pagination.value.page,
page_size: pagination.value.limit
}
const res = await getProjectList(params)
if (res.success) {
const sortedData = sortArrayByField(res.data.list, 'created_at', 'asc')
originalData.value = sortedData // 保存原始数据
// 应用搜索过滤
tableData.value = filterData(sortedData)
pagination.value.total = res.data.pagination.total
pagination.value.page = res.data.pagination.page
pagination.value.limit = res.data.pagination.page_size
} else {
ElMessage.error(res.message || '获取列表失败')
}
} catch (error) {
console.error('获取列表失败:', error)
ElMessage.error('获取列表失败')
} finally {
loading.value = false
}
}
// 过滤数据
const filterData = (data) => {
return data.filter(item => {
const titleMatch = !searchForm.value.title ||
item.title.toLowerCase().includes(searchForm.value.title.toLowerCase())
const typeMatch = !searchForm.value.type ||
item.type === searchForm.value.type
return titleMatch && typeMatch
})
}
// 处理搜索
const handleSearch = () => {
tableData.value = filterData(originalData.value)
}
// 处理重置
const handleReset = () => {
searchForm.value = {
title: '',
type: ''
}
tableData.value = originalData.value
}
// 处理分页变化
const handleSizeChange = (val) => {
pagination.value.limit = val
getList()
}
const handleCurrentChange = (val) => {
pagination.value.page = val
getList()
}
// 处理新增
const handleAdd = () => {
dialogTitle.value = '新增简介'
formData.value = {
title: '',
content: '',
cover_image: '',
type: '',
sort_order: 0,
status: 1,
imageInputType: 'upload' // 默认选择上传图片
}
dialogVisible.value = true
}
// 处理编辑
const handleEdit = (row) => {
dialogTitle.value = '编辑简介'
formData.value = {
...row,
imageInputType: row.cover_image?.startsWith('http') ? 'url' : 'upload' // 根据图片地址类型设置默认值
}
// 手动触发表单验证
nextTick(() => {
formRef.value?.validateField('cover_image')
})
dialogVisible.value = true
}
// 处理删除
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该简介吗?', '提示', {
type: 'warning'
})
await deleteProject(row.id)
ElMessage.success('删除成功')
getList()
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
}
// 处理图片输入类型切换
const handleImageTypeChange = () => {
formData.value.cover_image = ''
}
// 处理上传成功
const handleUploadSuccess = (response) => {
console.log('上传响应:', response)
try {
if (response.success) {
const imageUrl = response.data?.url
if (!imageUrl) {
ElMessage.error('上传成功但未获取到图片地址')
return
}
// 直接使用完整的URL
formData.value.cover_image = imageUrl
ElMessage.success('图片上传成功')
} else {
ElMessage.error(response.message || '图片上传失败')
}
} catch (error) {
console.error('处理上传响应错误:', error)
ElMessage.error('处理上传响应失败')
}
}
// 处理上传错误
const handleUploadError = (error) => {
console.error('上传失败:', error)
formData.value.cover_image = ''
}
// 处理上传前检查
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB')
return false
}
return true
}
// 处理表单提交
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
// 准备提交的数据
const submitData = { ...formData.value }
delete submitData.imageInputType // 删除不需要提交的字段
// 确保cover_image字段存在且有值
if (!submitData.cover_image) {
ElMessage.error('请先上传封面图片或输入图片地址')
return
}
if (submitData.id) {
const res = await updateProject(submitData.id, submitData)
if (res.success) {
ElMessage.success('更新成功')
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || '更新失败')
}
} else {
const res = await createProject(submitData)
if (res.success) {
ElMessage.success('创建成功')
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || '创建失败')
}
}
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
console.error('提交失败:', error)
ElMessage.error('提交失败,请检查表单数据')
}
}
}
// 处理弹窗关闭
const handleDialogClose = () => {
formRef.value?.resetFields()
dialogVisible.value = false
}
// 处理图片加载错误
const handleImageError = (e) => {
console.error('图片加载失败:', {
src: e.target.src,
error: e
})
ElMessage.error('图片加载失败,请检查图片地址是否正确')
formData.value.cover_image = ''
}
// 页面加载时获取数据
onMounted(() => {
getList()
})
</script>
<template>
<div class="project-introduction">
<!-- 搜索区域 -->
<div class="search-container">
<el-form :model="searchForm" inline>
<el-form-item label="标题">
<el-input
v-model="searchForm.title"
placeholder="请输入标题"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="类型">
<el-select
v-model="searchForm.type"
placeholder="请选择类型"
clearable
style="width: 240px"
>
<el-option
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 操作按钮区域 -->
<div class="operation-container">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增</el-button>
</div>
<!-- 表格区域 -->
<div class="table-container">
<el-table
:data="tableData"
border
style="width: 100%"
v-loading="loading"
>
<el-table-column
type="index"
label="序号"
width="60"
align="center"
/>
<el-table-column
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth"
:width="col.width"
:fixed="col.fixed"
align="center"
>
<template #default="scope">
<template v-if="col.prop === 'type'">
<el-tag
:type="scope.row.type === 'about' ? 'info' : scope.row.type === 'team' ? 'success' : 'warning'"
>
{{ typeOptions.find(item => item.value === scope.row.type)?.label }}
</el-tag>
</template>
<template v-else-if="col.prop === 'status'">
<el-tag
:type="scope.row.status === 1 ? 'success' : 'danger'"
effect="plain"
>
{{ scope.row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
<template v-else-if="col.prop === 'content'">
<el-tooltip
:content="scope.row.content"
placement="top"
:hide-after="0"
>
<div class="content-cell">{{ scope.row.content }}</div>
</el-tooltip>
</template>
<template v-else-if="col.prop === 'sort_order'">
{{ scope.row.sort_order }}
</template>
<template v-else-if="col.prop === 'created_at' || col.prop === 'updated_at'">
{{ formatDateTime(scope.row[col.prop]) }}
</template>
<template v-else-if="col.prop === 'operation'">
<el-button
type="primary"
:icon="Edit"
link
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
type="danger"
:icon="Delete"
link
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
<template v-else>
{{ scope.row[col.prop] }}
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-if="pagination.total > pagination.limit"
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 表单弹窗 -->
<el-dialog
:title="dialogTitle"
v-model="dialogVisible"
width="700px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入标题"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select
v-model="formData.type"
placeholder="请选择类型"
style="width: 100%"
>
<el-option
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="封面图片" prop="cover_image">
<div class="image-input-container">
<el-radio-group v-model="formData.imageInputType" class="mb-4" @change="handleImageTypeChange">
<el-radio value="url">输入图片地址</el-radio>
<el-radio value="upload">上传图片</el-radio>
</el-radio-group>
<template v-if="formData.imageInputType === 'url'">
<el-input
v-model="formData.cover_image"
placeholder="请输入图片URL地址"
/>
</template>
<template v-else>
<el-upload
class="avatar-uploader"
action="http://localhost:3000/api/admin/projects/upload"
:headers="uploadHeaders"
:show-file-list="false"
accept="image/*"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
name="file"
>
<img
v-if="formData.cover_image"
:src="imageUrl"
class="avatar"
@error="handleImageError"
crossorigin="anonymous"
/>
<el-icon v-else class="avatar-uploader-icon">
<Plus />
</el-icon>
</el-upload>
<div class="el-upload__tip">
只能上传 jpg/png/gif 格式图片且不超过 2MB
</div>
</template>
</div>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="formData.content"
type="textarea"
:rows="6"
placeholder="请输入内容"
/>
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number
v-model="formData.sort_order"
:min="0"
:max="999"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="formData.status"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleDialogClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.project-introduction {
padding: 20px;
background-color: #fff;
border-radius: 4px;
.search-container {
margin-bottom: 20px;
padding: 20px;
background-color: #f5f7fa;
border-radius: 4px;
}
.operation-container {
margin-bottom: 20px;
display: flex;
justify-content: flex-end;
}
.table-container {
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
}
.content-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.image-input-container {
.mb-4 {
margin-bottom: 16px;
display: block;
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
}
.avatar-uploader {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
:deep(.el-upload:hover) {
border-color: var(--el-color-primary);
}
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
text-align: center;
line-height: 178px;
}
.avatar {
width: 178px;
height: 178px;
display: block;
object-fit: cover;
}
</style>