Files
yunzerwebsiteallinone/backend/src/views/moduleshop/center/index.vue
T
2026-06-03 10:09:03 +08:00

681 lines
18 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.
<template>
<div class="modules-container">
<div class="header-section">
<div class="header-top">
<div class="category-tabs">
<div v-for="category in categories" :key="category.id" class="category-item"
:class="{ active: activeCategory === category.id }" @click="handleCategoryChange(category.id)">
{{ category.title }}
</div>
</div>
</div>
</div>
<div v-loading="loading" class="modules-grid">
<div v-for="module in moduleList" :key="module.id" class="module-card" :class="{ disabled: module.status === 0 }">
<div class="module-preview">
<img v-if="module.thumb" :src="getEnvUrl(module.thumb)" :alt="module.title" />
<div v-else class="placeholder"></div>
</div>
<div class="module-content">
<h3 class="module-name">{{ module.title }}</h3>
<p class="module-desc">{{ module.desc || '暂无描述' }}</p>
<div class="module-footer">
<div class="module-category">
<el-icon>
<OfficeBuilding />
</el-icon>
<span>{{ module.cid || '-' }}</span>
</div>
<div class="module-downloads">
<el-icon>
<Download />
</el-icon>
<span>{{ module.downloads }} 次</span>
</div>
</div>
</div>
<div class="module-actions">
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, module)">
<el-button type="primary" link :icon="MoreFilled" circle class="action-item" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit" :icon="Edit">编辑</el-dropdown-item>
<el-dropdown-item command="delete" :icon="Delete" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-tag :type="module.status === 1 ? 'success' : 'info'" size="small">
{{ module.status === 1 ? '已启用' : '已禁用' }}
</el-tag>
</div>
</div>
</div>
<div v-if="moduleList.length === 0 && !loading" class="empty-state">
<el-empty description="暂无模块数据" :image-size="120" />
</div>
<create-modules v-model="createDialogVisible" @success="handleRefresh" />
<el-dialog v-model="editDialogVisible" title="编辑模块" width="600px" :close-on-click-modal="false"
@close="handleEditClose">
<el-form ref="editFormRef" :model="editFormData" :rules="formRules" label-width="100px">
<el-form-item label="模块名称" prop="title">
<el-input v-model="editFormData.title" placeholder="请输入模块名称" clearable />
</el-form-item>
<el-form-item label="模块描述" prop="desc">
<el-input v-model="editFormData.desc" type="textarea" :rows="3" placeholder="请输入模块描述" />
</el-form-item>
<el-form-item label="缩略图" prop="thumb">
<div class="uploads">
<el-upload v-model:file-list="fileList" :auto-upload="false" list-type="picture-card"
:limit="1" :before-upload="beforeImgUpload" :on-change="handleUploadChange">
<el-icon>
<Plus />
</el-icon>
<template #file="{ file }">
<div>
<img class="el-upload-list__item-thumbnail" :src="getEnvUrl(file.url)" alt="" />
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
<el-icon>
<ZoomIn />
</el-icon>
</span>
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<div class="upload-tip">
<span>建议尺寸:250px × 140px</span>
</div>
</div>
</el-form-item>
<el-form-item label="所属分类" prop="cid">
<el-select v-model="editFormData.cid" placeholder="请选择所属分类">
<el-option v-for="cat in categories.filter(c => c.id > 0)" :key="cat.id" :label="cat.title"
:value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="版本号" prop="version">
<el-input v-model="editFormData.version" placeholder="请输入版本号,如: 1.0.0" clearable />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="editFormData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="handleEditSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox, ElUpload } from "element-plus";
import { Plus, Edit, Delete, MoreFilled, OfficeBuilding, Download, ZoomIn } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import { getModules, getModuleCategory,editModules } from "@/api/moduleCenter";
import CreateModules from "../components/createModules.vue";
import { uploadFile } from '@/api/file.js';
interface Module {
id: number;
title: string;
desc: string;
thumb: string;
cid: number;
downloads: number;
status: number;
version: string;
create_time: string;
}
const loading = ref(false);
const moduleList = ref<Module[]>([]);
const createDialogVisible = ref(false);
const editDialogVisible = ref(false);
const editLoading = ref(false);
const activeCategory = ref(0);
const isEdit = ref(false);
const editId = ref<number>(0);
// 分类数据
const categories = ref([
{ id: 0, title: "全部" }
]);
const handleCategoryChange = (categoryId: number) => {
activeCategory.value = categoryId;
fetchModuleList(categoryId);
};
const editFormRef = ref<FormInstance>();
const editFormData = reactive({
id: 0,
title: "",
desc: "",
thumb: "",
cid: 0,
downloads: 0,
version: "",
status: 1
});
const formRules: FormRules = {
title: [
{ required: true, message: "请输入模块名称", trigger: "blur" }
],
cid: [
{ required: true, message: "请选择分类", trigger: "change" }
],
status: [
{ required: true, message: "请选择状态", trigger: "change" }
]
};
//拼接接口路径
const getEnvUrl = (path: string) => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
return `${API_BASE_URL}${path}`;
};
//获取模块分类
async function getModuleCategoryList() {
try {
const res = await getModuleCategory();
if (res.code === 200 && res.data) {
categories.value = [
{ id: 0, title: "全部" },
...res.data.list || []
];
// console.log("分类数据:", categories.value);
}
} catch (error) {
console.error("获取分类失败:", error);
}
}
// 上传相关
const fileList = ref<any[]>([]);
const dialogVisible = ref(false);
const dialogImageUrl = ref('');
function beforeImgUpload(file: File) {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) ElMessage.error('仅支持图片格式');
if (!isLt10M) ElMessage.error('图片大小不能超过10MB');
return isImage && isLt10M;
}
function handleRemove(file: any) {
fileList.value = [];
editFormData.thumb = '';
}
async function handleUploadChange(file: any) {
if (file.raw) {
const isImage = file.raw.type.startsWith('image/');
const isLt10M = file.raw.size / 1024 / 1024 < 10;
if (!isImage || !isLt10M) {
fileList.value = [];
ElMessage.error(isImage ? '图片大小不能超过10MB' : '仅支持图片格式');
return;
}
}
}
function handlePictureCardPreview(file: any) {
dialogImageUrl.value = file.url;
dialogVisible.value = true;
}
const formatDate = (date: string) => {
if (!date) return "-";
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
const handleCreate = () => {
createDialogVisible.value = true;
};
const handleCommand = (command: string, row: Module) => {
if (command === "edit") {
handleEdit(row);
} else if (command === "delete") {
handleDelete(row);
}
};
const handleEdit = (row: Module) => {
isEdit.value = true;
editId.value = row.id;
Object.assign(editFormData, row);
// 如果有缩略图,显示在文件列表中
if (row.thumb) {
fileList.value = [{
name: 'thumb',
url: row.thumb
}];
} else {
fileList.value = [];
}
editDialogVisible.value = true;
};
const handleEditClose = () => {
isEdit.value = false;
editId.value = 0;
fileList.value = [];
editFormRef.value?.resetFields();
editDialogVisible.value = false;
};
const handleEditSubmit = async () => {
if (!editFormRef.value) return;
await editFormRef.value.validate(async (valid) => {
if (valid) {
editLoading.value = true;
try {
let imageUrl = editFormData.thumb;
// 如果有新上传的图片,先上传图片
if (fileList.value.length > 0 && fileList.value[0].raw) {
const uploadFormData = new FormData();
uploadFormData.append('file', fileList.value[0].raw);
uploadFormData.append('cate', 'module');
const uploadRes = await uploadFile(uploadFormData);
if (uploadRes?.data?.url) {
imageUrl = uploadRes.data.url;
}
}
const data = {
title: editFormData.title,
desc: editFormData.desc,
thumb: imageUrl,
cid: editFormData.cid,
downloads: editFormData.downloads,
version: editFormData.version,
status: editFormData.status
};
if (isEdit.value) {
data.id = editId.value;
await editModules(data);
ElMessage.success("编辑成功");
} else {
await editModules(data);
ElMessage.success("添加成功");
}
handleEditClose();
handleRefresh();
} catch (error) {
console.error(isEdit.value ? "编辑失败:" : "添加失败:", error);
ElMessage.error(isEdit.value ? "编辑失败" : "添加失败");
} finally {
editLoading.value = false;
}
}
});
};
const handleStatusChange = async (row: Module) => {
try {
// TODO: 调用状态更新接口
// await updateModuleStatus(row.id, row.status);
ElMessage.success(row.status === 1 ? "已启用" : "已禁用");
} catch (error) {
console.error("状态更新失败:", error);
ElMessage.error("状态更新失败");
row.status = row.status === 1 ? 0 : 1;
}
};
const handleDelete = (row: Module) => {
ElMessageBox.confirm(`确定要删除模块"${row.title}"吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
try {
// TODO: 调用删除接口
// await deleteModule(row.id);
ElMessage.success("删除成功");
handleRefresh();
} catch (error) {
console.error("删除失败:", error);
ElMessage.error("删除失败");
}
}).catch(() => { });
};
const handleRefresh = () => {
fetchModuleList();
};
const fetchModuleList = async (categoryId?: number) => {
loading.value = true;
try {
// 调用获取模块列表接口
const res = await getModules(categoryId || 0);
if (res.code === 200 && res.data) {
// 转换后端数据格式为前端格式
moduleList.value = res.data.list.map((item: any) => ({
id: item.id,
title: item.title,
desc: item.desc,
thumb: item.thumb,
cid: item.cid,
downloads: item.downloads,
status: item.status,
version: item.version,
create_time: item.create_time
}));
}
} catch (error) {
console.error("获取模块列表失败:", error);
ElMessage.error("获取模块列表失败");
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchModuleList();
getModuleCategoryList();
});
</script>
<style scoped lang="less">
.modules-container {
padding: 0;
margin-top: 50px;
min-height: 100vh;
.header-section {
margin-bottom: 24px;
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.category-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
.category-item {
padding: 8px 20px;
border-radius: 20px;
font-size: 14px;
color: var(--el-text-color-regular);
background: var(--el-bg-color);
cursor: pointer;
transition: all 0.3s;
border: 1px solid transparent;
&:hover {
background: var(--el-fill-color);
color: var(--el-text-color-primary);
}
&.active {
background: var(--el-color-primary);
color: #fff;
border-color: var(--el-color-primary);
}
}
}
}
.modules-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 20px;
.module-card {
background: var(--el-bg-color);
border-radius: 12px;
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.3s;
overflow: hidden;
position: relative;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transform: translateY(-4px);
border-color: var(--el-color-primary-light-7);
}
&.disabled {
opacity: 0.6;
filter: grayscale(100%);
}
.module-preview {
width: 100%;
height: 180px;
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
img{
width: 100%;
height: 100%;
object-fit: cover;
}
&::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0%, transparent 100%);
transform: rotate(45deg);
}
}
.module-content {
padding: 20px;
.module-name {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.module-desc {
margin: 0 0 16px 0;
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 44px;
}
.module-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
.module-category,
.module-downloads {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--el-text-color-placeholder);
.el-icon {
font-size: 16px;
}
}
}
}
.module-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
align-items: center;
gap: 8px;
.action-item {
width: 16px;
height: 16px;
}
.el-button {
color: var(--el-text-color-regular);
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
&:hover {
color: var(--el-color-primary);
}
}
.el-tag {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
}
}
}
}
.empty-state {
padding: 80px 20px;
text-align: center;
}
.uploads {
display: flex;
flex-direction: column;
}
.upload-tip {
font-size: 12px;
color: #999;
margin-top: 8px;
}
}
@media (max-width: 1400px) {
.modules-container {
.modules-grid {
grid-template-columns: repeat(4, 1fr);
}
}
}
@media (max-width: 1200px) {
.modules-container {
.modules-grid {
grid-template-columns: repeat(3, 1fr);
}
}
}
@media (max-width: 992px) {
.modules-container {
.modules-grid {
grid-template-columns: repeat(2, 1fr);
}
}
}
@media (max-width: 576px) {
.modules-container {
.modules-grid {
grid-template-columns: 1fr;
}
}
}
.uploads{
display: flex;
flex-direction: column;
}
.upload-tip {
font-size: 12px;
color: #999;
}
:deep(.el-upload-list__item-thumbnail) {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>