Files
spa-view/src/views/admin/Projects.vue
2025-05-07 23:12:06 +08:00

1291 lines
49 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 } from "vue"
import AOS from "aos"
import "aos/dist/aos.css"
import { message, Upload, Select } from "ant-design-vue"
import {labelOptionApi} from "@/api/request/label.js";
import {categoryOptionApi} from "@/api/request/category.js";
import {
createProjectApi,
deleteProjectApi,
projectDetailApi,
projectListApi,
updateProjectApi
} from "@/api/request/project.js";
import {uploadImageApi, uploadVideoApi} from "@/api/request/upload.js";
import ProjectDetails from './common/ProjectDetails.vue'
onMounted(() => {
AOS.init({
duration: 800,
offset: 120,
easing: "ease-out-cubic",
once: false,
})
})
// 分类数据
const categoryOptions = ref([])
// 表单相关
const tagsOption = ref([]);
const getLabelOption = () => {
labelOptionApi().then((res) => {
tagsOption.value = res;
})
}
const getCategoryOption = () => {
categoryOptionApi().then((res) => {
categoryOptions.value = res;
})
}
const dataSource = ref([])
const getList = () => {
projectListApi({
page: currentPage.value,
pageSize: pageSize.value,
}).then((res) => {
dataSource.value = res.items;
currentPage.value = res.page;
pageSize.value = res.size;
totalItems.value = res.total;
pageCount.value = res.page_count;
})
}
const statusOptions = [
{ value: 0, label: "草稿" },
{ value: 1, label: "已发布" },
{ value: 2, label: "已下架" },
]
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
const totalItems = ref(0)
const pageCount = ref(0)
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return dataSource.value.slice(start, end)
})
const changePage = (page) => {
currentPage.value = page
getList();
}
// 模态框
const showModal = ref(false)
const currentItem = ref(null)
const formLoading = ref(false)
// 图片上传相关
const fileList = ref([])
const uploading = ref(false)
const previewVisible = ref(false)
const previewImage = ref("")
// 多图上传相关
const screenshotsFileList = ref([])
const screenshotsUploading = ref(false)
// 视频上传相关
const videoFileList = ref([])
const videoUploading = ref(false)
const videoPreviewVisible = ref(false)
const videoPreviewUrl = ref("")
// 详情模态框
const showDetailModal = ref(false)
const currentDetailItem = ref(null)
const handlePreview = (file) => {
previewImage.value = file.url || file.thumbUrl
previewVisible.value = true
}
const handleVideoPreview = () => {
videoPreviewUrl.value = currentItem.value.video_url
videoPreviewVisible.value = true
}
const handleUploadChange = (info) => {
if (info.file.status === "uploading") {
uploading.value = true
return
}
if (info.file.status === "done") {
// 模拟上传成功后获取图片URL
uploading.value = false
message.success("封面上传成功")
}
if (info.file.status === "error") {
uploading.value = false
message.error("封面上传失败")
}
}
const handleScreenshotsUploadChange = (info) => {
screenshotsFileList.value = info.fileList
if (info.file.status === "uploading") {
screenshotsUploading.value = true
return
}
if (info.file.status === "done") {
// 模拟上传成功后获取图片URL
if (!currentItem.value.screenshots) {
currentItem.value.screenshots = []
}
screenshotsUploading.value = false
message.success("截图上传成功")
}
if (info.file.status === "error") {
screenshotsUploading.value = false
message.error("截图上传失败")
}
}
const handleVideoUploadChange = (info) => {
videoFileList.value = info.fileList.slice(-1) // 只保留最后一个文件
console.log(info, 'ssssssss')
if (info.file.status === "uploading") {
videoUploading.value = true
return
}
if (info.file.status === "done") {
// 模拟上传成功后获取视频URL
// currentItem.value.video_url = info.file.response.url
videoUploading.value = false
message.success("视频上传成功")
}
if (info.file.status === "error") {
videoUploading.value = false
message.error("视频上传失败")
}
}
const removeScreenshot = (index) => {
currentItem.value.screenshots.splice(index, 1)
// 同步更新文件列表
if (screenshotsFileList.value.length > index) {
screenshotsFileList.value.splice(index, 1)
}
}
const beforeUpload = (file) => {
const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png"
if (!isJpgOrPng) {
message.error("只能上传JPG或PNG格式的图片!")
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error("图片大小不能超过2MB!")
}
return isJpgOrPng && isLt2M
}
const beforeVideoUpload = (file) => {
const isVideo = file.type.startsWith("video/")
if (!isVideo) {
message.error("只能上传视频文件!")
}
const isLt50M = file.size / 1024 / 1024 < 50
if (!isLt50M) {
message.error("视频大小不能超过50MB!")
}
return isVideo && isLt50M
}
const customCoverRequest = ({ file, onSuccess }) => {
// 模拟上传
uploadImageApi(file).then((res) => {
currentItem.value.cover = res.url;
onSuccess("ok", { status: "done" })
})
}
const customRequest = ({ file, onSuccess }) => {
// 模拟上传
uploadImageApi(file).then((res) => {
currentItem.value.screenshots.push(res.url);
onSuccess("ok", { status: "done" })
})
}
const customVideoRequest = ({ file, onSuccess }) => {
// 模拟上传
uploadVideoApi(file).then((res) => {
currentItem.value.video_url = res.url
onSuccess("ok", { status: "done" })
})
}
const openModal = (item = null) => {
getLabelOption();
getCategoryOption();
if (item) {
item = JSON.parse(JSON.stringify(item));
item.labels = item.label_ids;
}
currentItem.value = item
? JSON.parse(JSON.stringify(item)) // 深拷贝避免直接修改原对象
: {
title: "",
category_id: 1,
tags: [],
price: "",
status: 0,
preferential_price: '',
description: "",
cover: "",
features: [],
front: "",
server: "",
deploy: "",
screenshots: [],
video_url: ""
}
// 重置文件列表
fileList.value = []
if (currentItem.value.cover) {
fileList.value = [
{
uid: "-1",
name: "cover.jpg",
status: "done",
url: currentItem.value.cover,
},
]
}
// 重置截图文件列表
screenshotsFileList.value = []
if (currentItem.value.screenshots && currentItem.value.screenshots.length > 0) {
currentItem.value.screenshots.forEach((url, index) => {
screenshotsFileList.value.push({
uid: `-${index + 1}`,
name: `screenshot-${index + 1}.jpg`,
status: "done",
url: url,
})
})
}
// 重置视频文件列表
videoFileList.value = []
if (currentItem.value.video_url) {
videoFileList.value = [
{
uid: "-1",
name: "video.mp4",
status: "done",
url: currentItem.value.video_url,
},
]
}
showModal.value = true
}
const closeModal = () => {
showModal.value = false
}
// 确认删除模态框
const showDeleteConfirm = ref(false)
const itemToDelete = ref(null)
const openDeleteConfirm = (id) => {
itemToDelete.value = id
showDeleteConfirm.value = true
}
const closeDeleteConfirm = () => {
showDeleteConfirm.value = false
itemToDelete.value = null
}
const handleDelete = () => {
if (itemToDelete.value) {
deleteProjectApi({
ids: [itemToDelete.value]
}).then(() => {
message.success("项目删除成功")
closeDeleteConfirm()
getList();
})
}
}
const formatPrice = (price) => {
return "¥ " + parseFloat(price).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" })
}
const handleSubmit = () => {
formLoading.value = true
// 模拟API请求
setTimeout(() => {
// 确保price是数字
if (typeof currentItem.value.price === "string") {
currentItem.value.price = parseFloat(currentItem.value.price.replace(/,/g, ""))
}
if (currentItem.value.id) {
updateProjectApi(currentItem.value).then((res) => {
message.success("项目更新成功")
closeModal()
getList();
})
// 更新现有项目
const index = dataSource.value.findIndex((item) => item.id === currentItem.value.id)
if (index !== -1) {
dataSource.value[index] = {...currentItem.value}
}
} else {
createProjectApi(currentItem.value).then((res) => {
console.log(res)
message.success("项目创建成功")
closeModal()
getList();
})
}
formLoading.value = false
}, 800)
}
// 标签管理
const tagInputValue = ref("")
const handleAddTag = () => {
if (tagInputValue.value) {
// 检查是否已存在该标签
const existingTag = tagsOption.find((tag) => tag.name.toLowerCase() === tagInputValue.value.toLowerCase())
// 如果存在且未添加到当前项目,则添加
if (existingTag && !currentItem.value.tags.some((tag) => tag.id === existingTag.id)) {
currentItem.value.tags.push(existingTag)
}
// 如果不存在,创建新标签并添加
else if (!existingTag) {
const newTagId = Math.max(...tagsOption.map((tag) => tag.id), 0) + 1
const newTag = {id: newTagId, name: tagInputValue.value}
tagsOption.push(newTag)
currentItem.value.tags.push(newTag)
}
tagInputValue.value = ""
}
}
const removeTag = (index) => {
currentItem.value.tags.splice(index, 1)
}
// 功能和技术栈管理
const featureInputValue = ref("")
const front = ref("")
const service = ref("")
const deploy = ref("")
const handleAddFeature = () => {
if (featureInputValue.value && !currentItem.value.features.includes(featureInputValue.value)) {
if (!currentItem.value.features) {
currentItem.value.features = []
}
currentItem.value.features.push(featureInputValue.value)
featureInputValue.value = ""
}
}
const removeFeature = (index) => {
currentItem.value.features.splice(index, 1)
}
const openDetailModal = (item) => {
projectDetailApi({
id: item.id
}).then((res) => {
currentDetailItem.value = res
showDetailModal.value = true
})
}
getList();
</script>
<template>
<div class="bg-white dark:bg-gray-800 rounded-3xl shadow-xl p-6" data-aos="fade-up">
<!-- 页面标题和新建按钮 -->
<div class="mb-6 flex justify-between items-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">项目管理</h2>
<button
class="px-5 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex items-center cursor-pointer"
@click="openModal()"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
新建项目
</button>
</div>
<!-- 表格 -->
<div class="rounded-2xl border border-gray-200 dark:border-gray-700">
<div class="overflow-x-auto" style="height: 60vh">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<!-- 表头 -->
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
项目名称
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
分类
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
标签
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
价格
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
状态
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
创建时间
</th>
<th scope="col"
class="px-6 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<!-- 表格内容 -->
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="item in paginatedData" :key="item.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0 h-10 w-10 rounded-lg overflow-hidden">
<img :src="item.cover" :alt="item.title" class="h-full w-full object-cover"/>
</div>
<div>
{{ item.title }}
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{{ item.category.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1">
<span
v-for="(tag, index) in item.labels"
:key="index"
class="px-2 py-1 bg-rose-50 dark:bg-rose-900/20 text-rose-600 dark:text-rose-300 text-xs font-medium rounded-full"
>
{{ tag.name }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{{ formatPrice(item.price) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
:class="`px-2 py-1 text-xs font-medium rounded-full ${
item.status === 1
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: item.status === 2
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`"
>
{{ item.status_text }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(item.createdAt) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end space-x-2">
<button
class="p-1.5 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors cursor-pointer"
title="查看"
@click="openDetailModal(item)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
<button
class="p-1.5 text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors cursor-pointer"
title="编辑"
@click="openModal(item)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button
class="p-1.5 text-rose-600 hover:text-rose-800 dark:text-rose-400 dark:hover:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-lg transition-colors cursor-pointer"
title="删除"
@click="openDeleteConfirm(item.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
class="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-300">
{{ totalItems }} 条记录
</div>
<div class="flex space-x-1">
<button
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
:disabled="currentPage === 1"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === 1 }"
@click="currentPage > 1 && changePage(currentPage - 1)"
>
上一页
</button>
<button
v-for="page in pageCount"
:key="page"
class="px-3 py-1 border rounded-md text-sm font-medium cursor-pointer"
:class="page === currentPage
? 'bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:border-rose-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'"
@click="changePage(page)"
>
{{ page }}
</button>
<button
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
:disabled="currentPage === pageCount"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === pageCount }"
@click="currentPage < pageCount && changePage(currentPage + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
<!-- 项目表单模态框 -->
<div v-if="showModal" class="fixed inset-0 z-50 overflow-y-scroll" aria-labelledby="modal-title" role="dialog"
aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- 背景遮罩 -->
<div
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
aria-hidden="true"
@click="closeModal"
></div>
<!-- 使模态框居中 -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!-- 模态框内容 -->
<div
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full relative z-50">
<div class="bg-white dark:bg-gray-800 px-6 pt-5 pb-4 sm:p-6">
<div class="flex justify-between items-center mb-5">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white" id="modal-title">
{{ currentItem?.id ? '编辑项目' : '新建项目' }}
</h3>
<button
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400 cursor-pointer"
@click="closeModal"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form @submit.prevent="handleSubmit" @keydown.enter.prevent="event.preventDefault()" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2">
<!-- 基本信息 -->
<div class="mr-10">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 项目名称 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目名称</label>
<input
v-model="currentItem.title"
type="text"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入项目名称"
required
/>
</div>
<!-- 价格 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目价格</label>
<div class="relative">
<span
class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500 dark:text-gray-400">¥</span>
<input
v-model="currentItem.price"
type="text"
class="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入项目价格"
/>
</div>
</div>
<!-- 价格 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">优惠价格</label>
<div class="relative">
<span
class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500 dark:text-gray-400">¥</span>
<input
v-model="currentItem.preferential_price"
type="text"
class="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入项目价格"
/>
</div>
</div>
<!-- 分类 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目分类</label>
<select
v-model="currentItem.category_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white cursor-pointer"
>
<option v-for="option in categoryOptions" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
</div>
<!-- 状态 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目状态</label>
<select
v-model="currentItem.status"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white cursor-pointer"
>
<option v-for="option in statusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- 技术栈 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">前端技术栈</label>
<div class="flex">
<input
v-model="currentItem.front"
type="text"
class="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="输入前端技术栈"
required
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">后端技术栈</label>
<div class="flex">
<input
v-model="currentItem.service"
type="text"
class="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="输入后端技术栈"
required
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">部署架构</label>
<div class="flex">
<input
v-model="currentItem.deploy"
type="text"
class="w-full pl-8 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="输入部署架构"
required
/>
</div>
</div>
</div>
<div>
<!-- 标签 -->
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目标签</label>
<div
class="w-full px-1 py-1 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white cursor-pointer">
<Select
class="w-full"
v-model:value="currentItem.labels"
:options="tagsOption"
mode="multiple"
placeholder="请选择标签"
:fieldNames="{
label: 'name',
value: 'id',
}"
/>
</div>
</div>
<!-- 项目功能 -->
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">核心功能</label>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="(feature, index) in currentItem.features"
:key="index"
class="px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 text-sm font-medium rounded-lg flex items-center"
>
{{ feature }}
<button
type="button"
class="ml-1.5 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-200 cursor-pointer"
@click="removeFeature(index)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>
</div>
<div class="flex">
<input
v-model="featureInputValue"
type="text"
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-l-xl focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="输入功能"
@keyup.enter="handleAddFeature"
/>
<button
type="button"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-r-xl transition-colors cursor-pointer"
@click="handleAddFeature"
>
添加
</button>
</div>
</div>
<!-- 项目描述 -->
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目描述</label>
<textarea
v-model="currentItem.description"
rows="4"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入项目描述"
></textarea>
</div>
</div>
</div>
<!-- 截图视频 -->
<div class="ml-10">
<!-- 项目封面上传 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目封面</label>
<div class="border border-gray-300 dark:border-gray-600 rounded-xl p-4 dark:bg-gray-700">
<Upload
v-model:file-list="fileList"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="customCoverRequest"
@preview="handlePreview"
@change="handleUploadChange"
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: true }"
:maxCount="1"
>
<div v-if="fileList.length < 1">
<div
class="ant-upload-text flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mb-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>点击上传封面</span>
</div>
</div>
</Upload>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持JPGPNG格式大小不超过2MB
</div>
</div>
</div>
<!-- 视频上传 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">演示视频</label>
<div class="border border-gray-300 dark:border-gray-600 rounded-xl p-4 dark:bg-gray-700">
<Upload
v-model:file-list="videoFileList"
list-type="picture-card"
:before-upload="beforeVideoUpload"
:custom-request="customVideoRequest"
@change="handleVideoUploadChange"
:show-upload-list="{ showPreviewIcon: false, showRemoveIcon: true }"
:maxCount="1"
>
<div v-if="videoFileList.length < 1">
<div
class="ant-upload-text flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mb-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span>点击上传视频</span>
</div>
</div>
</Upload>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持MP4WebM等常见视频格式大小不超过50MB
</div>
<!-- 视频预览按钮 -->
<div v-if="currentItem.video_url" class="mt-3">
<button
type="button"
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm font-medium rounded-lg flex items-center hover:bg-gray-200 dark:hover:bg-gray-500 transition-colors cursor-pointer"
@click="handleVideoPreview"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
预览视频
</button>
</div>
</div>
</div>
<!-- 项目截图上传 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">项目截图</label>
<div class="border border-gray-300 dark:border-gray-600 rounded-xl p-4 dark:bg-gray-700">
<Upload
v-model:file-list="screenshotsFileList"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="customRequest"
@preview="handlePreview"
@change="handleScreenshotsUploadChange"
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: true }"
multiple
:maxCount="10"
>
<div>
<div
class="ant-upload-text flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mb-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>点击上传截图</span>
</div>
</div>
</Upload>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持JPGPNG格式大小不超过2MB可上传多张
</div>
</div>
</div>
</div>
</div>
<!-- 表单按钮 -->
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
class="px-5 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
@click="closeModal"
>
取消
</button>
<button
type="submit"
class="px-5 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex items-center cursor-pointer"
:disabled="formLoading"
>
<span v-if="formLoading" class="mr-2">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
{{ currentItem?.id ? '保存修改' : '创建项目' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50" aria-labelledby="modal-title" role="dialog"
aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- 背景遮罩 -->
<div
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
aria-hidden="true"
@click="closeDeleteConfirm"
></div>
<!-- 使模态框居中 -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!-- 模态框内容 -->
<div
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full relative z-50">
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-600 dark:text-red-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
确认删除
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
确定要删除这个项目吗此操作无法撤销
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm cursor-pointer"
@click="handleDelete"
>
确认
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm cursor-pointer"
@click="closeDeleteConfirm"
>
取消
</button>
</div>
</div>
</div>
</div>
<!-- 图片预览模态框 -->
<div v-if="previewVisible" class="fixed inset-0 z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- 背景遮罩 -->
<div
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
aria-hidden="true"
@click="previewVisible = false"
></div>
<!-- 使模态框居中 -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!-- 模态框内容 -->
<div
class="inline-block align-bottom bg-transparent rounded-lg text-left overflow-hidden transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl sm:w-full relative z-50">
<div class="relative">
<button
class="absolute top-2 right-2 p-2 rounded-full bg-gray-800/50 text-white hover:bg-gray-700/50 transition-colors cursor-pointer"
@click="previewVisible = false"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<img :src="previewImage" class="w-full max-h-[80vh] max-w-full rounded-lg shadow-xl"/>
</div>
</div>
</div>
</div>
<!-- 视频预览模态框 -->
<div v-if="videoPreviewVisible" class="fixed inset-0 z-50" aria-labelledby="modal-title" role="dialog"
aria-modal="true">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- 背景遮罩 -->
<div
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
aria-hidden="true"
@click="videoPreviewVisible = false"
></div>
<!-- 使模态框居中 -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!-- 模态框内容 -->
<div
class="inline-block align-bottom bg-transparent rounded-lg text-left overflow-hidden transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full relative z-50">
{{ videoPreviewUrl }}
<div class="relative">
<button
class="absolute top-2 right-2 p-2 rounded-full bg-gray-800/50 text-white hover:bg-gray-700/50 transition-colors cursor-pointer z-10"
@click="videoPreviewVisible = false"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="aspect-w-16 aspect-h-9 rounded-lg overflow-hidden shadow-xl">
<video
:src="videoPreviewUrl"
class="w-full h-full"
controls
autoplay
></video>
</div>
</div>
</div>
</div>
</div>
<!-- 项目详情模态框 -->
<ProjectDetails
v-model:visible="showDetailModal"
:project="currentDetailItem"
@close="showDetailModal = false"
/>
</template>
<style lang="scss" scoped>
/* 自定义样式 */
/* 毛玻璃效果增强 */
.backdrop-blur-xl {
backdrop-filter: blur(16px);
}
.backdrop-blur-md {
backdrop-filter: blur(12px);
}
.backdrop-blur-sm {
backdrop-filter: blur(4px);
}
/* 上传组件暗黑模式适配 */
:deep(.ant-upload-list-picture-card .ant-upload-list-item) {
border-color: #4b5563;
}
:deep(.ant-upload.ant-upload-select-picture-card) {
background-color: transparent;
border-color: #4b5563;
}
:deep(.ant-upload-list-picture-card-container) {
width: 150px;
height: 150px;
}
:deep(.ant-upload.ant-upload-select-picture-card) {
width: 150px;
height: 150px;
}
/* 视频播放器样式 */
.aspect-w-16 {
position: relative;
padding- {
width: 150px;
height: 150px;
}
/* 视频播放器样式 */
.aspect-w-16 {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
}
.aspect-h-9 {
position: relative;
}
.aspect-w-16 > *, .aspect-h-9 > * {
position: absolute;
height: 100%;
width: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
/* 模态框动画 */
.transform {
transition-property: transform, opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* 表单元素暗黑模式适配 */
:deep(.dark input),
:deep(.dark select),
:deep(.dark textarea) {
color-scheme: dark;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 20px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
/* 暗黑模式滚动条 */
.dark ::-webkit-scrollbar-thumb {
background-color: rgba(75, 85, 99, 0.5);
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: rgba(75, 85, 99, 0.7);
}
}
:deep .ant-select-show-search:where(.css-dev-only-do-not-override-1p3hq3p).ant-select:not(.ant-select-customize-input) .ant-select-selector {
cursor: text;
border-color: #364153;
background-color: #364153;
color: white;
}
:deep :where(.css-dev-only-do-not-override-1p3hq3p).ant-select-multiple .ant-select-selection-item {
position: relative;
display: flex;
flex: none;
box-sizing: border-box;
max-width: 100%;
height: 24px;
margin-top: 2px;
margin-bottom: 2px;
line-height: 22px;
background: rgba(255, 32, 86, 0.35);
border: 1px solid;
border-radius: 4px;
cursor: default;
transition: font-size 0.3s, line-height 0.3s, height 0.3s;
user-select: none;
margin-inline-end: 4px;
padding-inline-start: 8px;
padding-inline-end: 4px;
border-color: var(--color-gray-600);
}
:deep :where(.css-dev-only-do-not-override-1p3hq3p).ant-select-dropdown .ant-select-item-option-active:not(.ant-select-item-option-disabled) {
//background-color: #364153;
background-color: var(--color-gray-600);
}
:deep :where(.css-dev-only-do-not-override-1p3hq3p).ant-select-dropdown .ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
color: rgba(0, 0, 0, 0.88);
font-weight: 600;
background-color: var(--color-purple-500);
}
:where(.css-dev-only-do-not-override-1p3hq3p).ant-select-multiple .ant-select-selection-placeholder {
position: absolute;
top: 50%;
inset-inline-start: 11px;
inset-inline-end: 11px;
transform: translateY(-50%);
transition: all 0.3s;
color: var(--color-gray-300);
}
</style>