项目管理接口部分完成

This commit is contained in:
2025-05-06 16:59:12 +08:00
parent 7b23754ba5
commit f005953af9
5 changed files with 509 additions and 338 deletions

View File

@@ -0,0 +1,19 @@
import {get, post} from '../request.js'
const prefix = 'category'
export function categoryListApi(credentials) {
return get(`${prefix}/list`, credentials)
}
export function categoryOptionApi(credentials) {
return get(`${prefix}/option`, credentials)
}
export function createCategoryApi(credentials) {
return post(`${prefix}/create`, credentials)
}
export function updateCategoryApi(credentials) {
return post(`${prefix}/update`, credentials)
}
export function deleteCategoryApi(credentials) {
return post(`${prefix}/delete`, credentials)
}

19
src/api/request/label.js Normal file
View File

@@ -0,0 +1,19 @@
import {get, post} from '../request.js'
const prefix = 'label'
export function labelListApi(credentials) {
return get(`${prefix}/list`, credentials)
}
export function labelOptionApi(credentials) {
return get(`${prefix}/option`, credentials)
}
export function createLabelApi(credentials) {
return post(`${prefix}/create`, credentials)
}
export function updateLabelApi(credentials) {
return post(`${prefix}/update`, credentials)
}
export function deleteLabelApi(credentials) {
return post(`${prefix}/delete`, credentials)
}

View File

@@ -0,0 +1,19 @@
import {get, post} from '../request.js'
const prefix = 'project'
export function labelListApi(credentials) {
return get(`${prefix}/list`, credentials)
}
export function labelOptionApi(credentials) {
return get(`${prefix}/option`, credentials)
}
export function createProjectApi(credentials) {
return post(`${prefix}/create`, credentials)
}
export function updateProjectApi(credentials) {
return post(`${prefix}/update`, credentials)
}
export function deleteProjectApi(credentials) {
return post(`${prefix}/delete`, credentials)
}

10
src/api/request/upload.js Normal file
View File

@@ -0,0 +1,10 @@
import {upload} from '../request.js'
const prefix = 'upload'
export function uploadImageApi(credentials) {
return upload(`${prefix}/image`, credentials)
}
export function uploadVideoApi(credentials) {
return upload(`${prefix}/video`, credentials)
}

View File

@@ -2,7 +2,11 @@
import { ref, onMounted, computed } from "vue"
import AOS from "aos"
import "aos/dist/aos.css"
import { message, Upload } from "ant-design-vue"
import { message, Upload, Select } from "ant-design-vue"
import {labelOptionApi} from "@/api/request/label.js";
import {categoryOptionApi} from "@/api/request/category.js";
import {createProjectApi, labelListApi, updateProjectApi} from "@/api/request/project.js";
import {uploadImageApi, uploadVideoApi} from "@/api/request/upload.js";
onMounted(() => {
AOS.init({
@@ -14,32 +18,35 @@ onMounted(() => {
})
// 分类数据
const categoryOptions = [
{ id: 1, name: "Web应用", value: "Web应用" },
{ id: 2, name: "移动端", value: "移动端" },
{ id: 3, name: "小程序", value: "小程序" },
{ id: 4, name: "企业系统", value: "企业系统" },
]
const categoryOptions = ref([])
// 标签数据
const allTags = [
{ id: 1, name: "Vue3" },
{ id: 2, name: "Node.js" },
{ id: 3, name: "TypeScript" },
{ id: 4, name: "React" },
{ id: 5, name: "Express" },
{ id: 6, name: "PHP" },
{ id: 7, name: "Python" },
{ id: 8, name: "MongoDB" },
{ id: 9, name: "Docker" },
]
// 表单相关
const tagsOption = ref([]);
const getLabelOption = () => {
labelOptionApi().then((res) => {
tagsOption.value = res;
})
}
const getCategoryOption = () => {
categoryOptionApi().then((res) => {
categoryOptions.value = res;
})
}
const dataSource = ref([
{
id: 1,
title: "电商平台",
category: categoryOptions[0],
tags: [allTags[0], allTags[1], allTags[5]],
category: { id: 1, name: "Web应用", value: "Web应用" },
tags: [
{
id: 1,
name: "Vue",
value: "Vue",
}
],
author: "Admin",
price: 29999,
status: "已发布",
@@ -52,13 +59,19 @@ const dataSource = ref([
"https://pic.rmb.bdstatic.com/bjh/80852bfe7c321988191838517ba64e309354.jpeg@h_1280",
"https://img2.baidu.com/it/u=1629222614,1358629025&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500"
],
video: "http://d-jy.nailaoyun.cn//storage/video//20250327/2cc1abf9bd69410ce7c69660daf54260.mp4"
video_url: "http://d-jy.nailaoyun.cn//storage/video//20250327/2cc1abf9bd69410ce7c69660daf54260.mp4"
},
{
id: 2,
title: "CRM系统",
category: categoryOptions[3],
tags: [allTags[3], allTags[4]],
category: { id: 4, name: "企业系统", value: "企业系统" },
tags: [
{
id: 1,
name: "Vue",
value: "Vue",
}
],
author: "Admin",
price: 19999,
status: "已发布",
@@ -70,13 +83,19 @@ const dataSource = ref([
screenshots: [
"https://pic.rmb.bdstatic.com/bjh/80852bfe7c321988191838517ba64e309354.jpeg@h_1280"
],
video: ""
video_url: ""
},
{
id: 3,
title: "数据分析平台",
category: categoryOptions[0],
tags: [allTags[0], allTags[6]],
category: { id: 1, name: "Web应用", value: "Web应用" },
tags: [
{
id: 1,
name: "Vue",
value: "Vue",
}
],
author: "Admin",
price: 24999,
status: "草稿",
@@ -86,20 +105,34 @@ const dataSource = ref([
features: ["实时数据仪表盘", "自定义报表生成", "数据导入导出"],
technicalStack: ["前端Vue3 + ECharts", "后端Python + Django", "部署Docker + Kubernetes"],
screenshots: [],
video: ""
video_url: ""
},
])
const getList = () => {
labelListApi({
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: "草稿", label: "草稿" },
{ value: "已发布", label: "已发布" },
{ value: "已下架", label: "已下架" },
{ value: 0, label: "草稿" },
{ value: 1, label: "已发布" },
{ value: 2, label: "已下架" },
]
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const totalItems = ref(dataSource.value.length)
const pageSize = ref(20)
const totalItems = ref(0)
const pageCount = ref(0)
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
@@ -138,7 +171,7 @@ const handlePreview = (file) => {
}
const handleVideoPreview = () => {
videoPreviewUrl.value = currentItem.value.video
videoPreviewUrl.value = currentItem.value.video_url
videoPreviewVisible.value = true
}
@@ -173,12 +206,6 @@ const handleScreenshotsUploadChange = (info) => {
currentItem.value.screenshots = []
}
// 确保不重复添加
const newUrl = "https://img2.baidu.com/it/u=1629222614,1358629025&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500"
if (!currentItem.value.screenshots.includes(newUrl)) {
currentItem.value.screenshots.push(newUrl)
}
screenshotsUploading.value = false
message.success("截图上传成功")
}
@@ -191,13 +218,14 @@ const handleScreenshotsUploadChange = (info) => {
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 = "http://d-jy.nailaoyun.cn//storage/video//20250327/2cc1abf9bd69410ce7c69660daf54260.mp4"
// currentItem.value.video_url = info.file.response.url
videoUploading.value = false
message.success("视频上传成功")
}
@@ -239,29 +267,46 @@ const beforeVideoUpload = (file) => {
return isVideo && isLt50M
}
const customRequest = ({ onSuccess }) => {
const customRequest = ({ file, onSuccess }) => {
// 模拟上传
setTimeout(() => {
uploadImageApi(file).then((res) => {
currentItem.value.screenshots.push(res.url);
onSuccess("ok", { status: "done" })
}, 800)
})
}
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: categoryOptions[0],
category_id: 1,
tags: [],
price: "",
status: "草稿",
status: 0,
preferential_price: '',
description: "",
cover: "",
author: "Admin",
features: [],
technicalStack: [],
front: "",
server: "",
deploy: "",
screenshots: [],
video: ""
video_url: ""
}
// 重置文件列表
@@ -292,13 +337,13 @@ const openModal = (item = null) => {
// 重置视频文件列表
videoFileList.value = []
if (currentItem.value.video) {
if (currentItem.value.video_url) {
videoFileList.value = [
{
uid: "-1",
name: "video.mp4",
status: "done",
url: currentItem.value.video,
url: currentItem.value.video_url,
},
]
}
@@ -352,27 +397,27 @@ const handleSubmit = () => {
}
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 }
}
message.success("项目更新成功")
} else {
// 添加新项目
const newId = Math.max(...dataSource.value.map((item) => item.id), 0) + 1
dataSource.value.push({
...currentItem.value,
id: newId,
author: "Admin",
createdAt: new Date().toISOString(),
createProjectApi(currentItem.value).then((res) => {
console.log(res)
message.success("项目创建成功")
closeModal()
getList();
})
totalItems.value = dataSource.value.length
message.success("项目创建成功")
}
formLoading.value = false
closeModal()
}, 800)
}
@@ -381,7 +426,7 @@ const tagInputValue = ref("")
const handleAddTag = () => {
if (tagInputValue.value) {
// 检查是否已存在该标签
const existingTag = allTags.find((tag) => tag.name.toLowerCase() === tagInputValue.value.toLowerCase())
const existingTag = tagsOption.find((tag) => tag.name.toLowerCase() === tagInputValue.value.toLowerCase())
// 如果存在且未添加到当前项目,则添加
if (existingTag && !currentItem.value.tags.some((tag) => tag.id === existingTag.id)) {
@@ -389,9 +434,9 @@ const handleAddTag = () => {
}
// 如果不存在,创建新标签并添加
else if (!existingTag) {
const newTagId = Math.max(...allTags.map((tag) => tag.id), 0) + 1
const newTagId = Math.max(...tagsOption.map((tag) => tag.id), 0) + 1
const newTag = { id: newTagId, name: tagInputValue.value }
allTags.push(newTag)
tagsOption.push(newTag)
currentItem.value.tags.push(newTag)
}
@@ -405,7 +450,9 @@ const removeTag = (index) => {
// 功能和技术栈管理
const featureInputValue = ref("")
const techStackInputValue = ref("")
const front = ref("")
const service = ref("")
const deploy = ref("")
const handleAddFeature = () => {
if (featureInputValue.value && !currentItem.value.features.includes(featureInputValue.value)) {
@@ -421,19 +468,7 @@ const removeFeature = (index) => {
currentItem.value.features.splice(index, 1)
}
const handleAddTechStack = () => {
if (techStackInputValue.value && !currentItem.value.technicalStack.includes(techStackInputValue.value)) {
if (!currentItem.value.technicalStack) {
currentItem.value.technicalStack = []
}
currentItem.value.technicalStack.push(techStackInputValue.value)
techStackInputValue.value = ""
}
}
const removeTechStack = (index) => {
currentItem.value.technicalStack.splice(index, 1)
}
getList();
</script>
<template>
@@ -502,7 +537,7 @@ const removeTechStack = (index) => {
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1">
<span
v-for="(tag, index) in item.tags"
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"
>
@@ -516,14 +551,14 @@ const removeTechStack = (index) => {
<td class="px-6 py-4 whitespace-nowrap">
<span
:class="`px-2 py-1 text-xs font-medium rounded-full ${
item.status === '已发布'
item.status === 0
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: item.status === '已下架'
: item.status === 1
? '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 }}
{{ item.status_text }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
@@ -580,7 +615,7 @@ const removeTechStack = (index) => {
上一页
</button>
<button
v-for="page in Math.ceil(totalItems / pageSize)"
v-for="page in pageCount"
:key="page"
class="px-3 py-1 border rounded-md text-sm font-medium cursor-pointer"
:class="page === currentPage
@@ -592,9 +627,9 @@ const removeTechStack = (index) => {
</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 === Math.ceil(totalItems / pageSize)"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === Math.ceil(totalItems / pageSize) }"
@click="currentPage < Math.ceil(totalItems / pageSize) && changePage(currentPage + 1)"
:disabled="currentPage === pageCount"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === pageCount }"
@click="currentPage < pageCount && changePage(currentPage + 1)"
>
下一页
</button>
@@ -633,116 +668,142 @@ const removeTechStack = (index) => {
</button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- 基本信息 -->
<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>
<form @submit.prevent="handleSubmit" @keydown.enter.prevent="event.preventDefault()" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2">
<!-- 价格 -->
<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 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>
<select
v-model="currentItem.category"
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">
{{ option.name }}
</option>
</select>
</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>
<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>
<!-- 价格 -->
<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>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="(tag, index) in currentItem.tags"
:key="index"
class="px-3 py-1.5 bg-rose-50 dark:bg-rose-900/20 text-rose-600 dark:text-rose-300 text-sm font-medium rounded-lg flex items-center"
>
{{ tag.name }}
<button
type="button"
class="ml-1.5 text-rose-500 hover:text-rose-700 dark:text-rose-400 dark:hover:text-rose-200 cursor-pointer"
@click="removeTag(index)"
<!-- 分类 -->
<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"
>
<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="tagInputValue"
type="text"
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-l-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
placeholder="输入标签"
@keyup.enter="handleAddTag"
/>
<button
type="button"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-r-xl transition-colors cursor-pointer"
@click="handleAddTag"
>
添加
</button>
</div>
</div>
<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>
<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>
<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 flex-wrap gap-2 mb-2">
<!-- 技术栈 -->
<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>
<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>
<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"
@@ -759,164 +820,148 @@ const removeTechStack = (index) => {
</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>
<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="(tech, index) in currentItem.technicalStack"
:key="index"
class="px-3 py-1.5 bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-300 text-sm font-medium rounded-lg flex items-center"
>
{{ tech }}
<button
type="button"
class="ml-1.5 text-purple-500 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-200 cursor-pointer"
@click="removeTechStack(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="techStackInputValue"
type="text"
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-l-xl focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white"
placeholder="输入技术栈"
@keyup.enter="handleAddTechStack"
/>
<button
type="button"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-r-xl transition-colors cursor-pointer"
@click="handleAddTechStack"
>
添加
</button>
</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="fileList"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="customRequest"
@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 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>
</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="screenshotsFileList"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="customRequest"
@preview="handlePreview"
@change="handleScreenshotsUploadChange"
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: true }"
multiple
>
<!-- 项目描述 -->
<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>
<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="customRequest"
@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
>
<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>
</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="customRequest"
@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" 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 class="flex justify-end space-x-3 pt-4">
@@ -1046,6 +1091,7 @@ const removeTechStack = (index) => {
<!-- 模态框内容 -->
<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"
@@ -1175,4 +1221,62 @@ const removeTechStack = (index) => {
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 {
box-sizing: border-box;
margin: 0;
padding: 4px;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
list-style: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
position: absolute;
top: -9999px;
z-index: 1050;
overflow: hidden;
font-variant: initial;
background-color: var(--color-gray-600) !important;
border-radius: 8px;
outline: none;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
: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);
}
</style>