Files
nl-blogs/client/src/pages/admin/WorkForm.vue
2026-01-20 09:13:51 +08:00

503 lines
14 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="work-form-container">
<h1 class="page-title">{{ isEditing ? '编辑作品' : '新建作品' }}</h1>
<div class="form-container">
<form @submit.prevent="handleSubmit" class="work-form">
<!-- Title Field -->
<div class="form-group">
<label for="title">作品标题</label>
<input
type="text"
id="title"
v-model="form.title"
placeholder="请输入作品标题"
required
class="admin-input"
/>
<div class="error-message" v-if="errors.title">
{{ errors.title }}
</div>
</div>
<!-- Category Field -->
<div class="form-group">
<label for="category">分类</label>
<input
type="text"
id="category"
v-model="form.category"
placeholder="请输入作品分类"
required
class="admin-input"
/>
<div class="error-message" v-if="errors.category">
{{ errors.category }}
</div>
</div>
<!-- Year Field -->
<div class="form-group">
<label for="year">创作年份</label>
<input
type="text"
id="year"
v-model="form.year"
placeholder="请输入创作年份"
required
class="admin-input"
/>
<div class="error-message" v-if="errors.year">
{{ errors.year }}
</div>
</div>
<!-- Hero Image Field -->
<div class="form-group">
<label>作品主图</label>
<ImageUpload
v-model="form.heroImg"
:category-id="1"
/>
<div class="error-message" v-if="errors.heroImg">
{{ errors.heroImg }}
</div>
</div>
<!-- Description Field -->
<div class="form-group">
<label for="desc">作品描述</label>
<textarea
id="desc"
v-model="form.desc"
placeholder="请输入作品描述"
rows="5"
required
class="admin-input resize-none"
></textarea>
<div class="error-message" v-if="errors.desc">
{{ errors.desc }}
</div>
</div>
<!-- Tech Stack Field -->
<div class="form-group">
<div class="flex justify-between items-center mb-2">
<label>技术栈</label>
<button
type="button"
@click="addTechStackCategory"
class="admin-btn-secondary text-xs py-1 px-3"
>
+ 添加分类
</button>
</div>
<div class="space-y-4">
<div
v-for="(item, index) in form.techStack"
:key="index"
class="tech-stack-item p-4 border border-white/10 rounded-lg bg-white/5 relative"
>
<button
type="button"
@click="removeTechStackCategory(index)"
class="absolute top-2 right-2 text-red-400 hover:text-red-300 text-lg"
>
&times;
</button>
<div class="space-y-3">
<div>
<label class="block text-xs text-art-muted mb-1">分类名称</label>
<input
type="text"
v-model="item.category"
placeholder="例如:前端"
class="admin-input text-sm"
/>
</div>
<div>
<div class="flex justify-between items-center mb-1">
<label class="block text-xs text-art-muted">技术标签</label>
<button
type="button"
@click="addTechItem(index)"
class="text-xs text-art-accent hover:text-art-accent/80"
>
+ 添加标签
</button>
</div>
<div class="space-y-2">
<div
v-for="(_, techIndex) in item.items"
:key="techIndex"
class="flex gap-2"
>
<input
type="text"
v-model="item.items[techIndex]"
placeholder="例如Vue 3"
class="admin-input text-sm flex-1"
/>
<button
type="button"
@click="removeTechItem(index, techIndex)"
class="px-3 py-2 text-red-400 hover:text-red-300 text-sm"
>
删除
</button>
</div>
<div v-if="item.items.length === 0" class="text-center text-art-muted text-sm py-2">
暂无技术标签请点击"添加标签"
</div>
</div>
</div>
</div>
</div>
<div v-if="form.techStack.length === 0" class="text-center text-art-muted py-4 border border-dashed border-white/10 rounded-lg">
暂无技术栈分类请点击"添加分类"
</div>
</div>
<div class="error-message" v-if="errors.techStack">
{{ errors.techStack }}
</div>
</div>
<!-- Gallery Field -->
<div class="form-group">
<label>作品图库</label>
<ImageUpload
v-model="form.gallery"
:multiple="true"
:max-count="20"
:category-id="1"
/>
<div class="error-message" v-if="errors.gallery">
{{ errors.gallery }}
</div>
</div>
<!-- Links Field -->
<div class="form-group">
<label>链接</label>
<div class="space-y-3">
<div>
<label class="block text-xs text-art-muted mb-1">在线演示链接</label>
<input
type="url"
v-model="form.links.live"
placeholder="https://example.com"
class="admin-input"
/>
</div>
<div>
<label class="block text-xs text-art-muted mb-1">GitHub 链接</label>
<input
type="url"
v-model="form.links.github"
placeholder="https://github.com/..."
class="admin-input"
/>
</div>
<div>
<label class="block text-xs text-art-muted mb-1">演示链接</label>
<input
type="url"
v-model="form.links.demo"
placeholder="https://demo.example.com"
class="admin-input"
/>
</div>
</div>
<div class="error-message" v-if="errors.links">
{{ errors.links }}
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="admin-btn-secondary" @click="handleCancel">
取消
</button>
<button type="submit" class="admin-btn-primary" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新作品' : '创建作品') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createWork, updateWork, fetchWork } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// Form state
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
// Form data
const form = reactive({
title: '',
category: '',
year: '',
heroImg: '',
desc: '',
techStack: [] as { category: string; items: string[] }[],
gallery: [] as string[],
links: {
live: '',
github: '',
demo: ''
} as { live?: string; github?: string; demo?: string }
})
// Tech Stack management
const addTechStackCategory = () => {
form.techStack.push({
category: '',
items: []
})
}
const removeTechStackCategory = (index: number) => {
form.techStack.splice(index, 1)
}
const addTechItem = (categoryIndex: number) => {
form.techStack[categoryIndex].items.push('')
}
const removeTechItem = (categoryIndex: number, itemIndex: number) => {
form.techStack[categoryIndex].items.splice(itemIndex, 1)
}
// Validation function
const validateForm = (): boolean => {
// Reset errors
Object.keys(errors).forEach(key => delete errors[key])
let isValid = true
// Validate title
if (!form.title.trim()) {
errors.title = '作品标题不能为空'
isValid = false
}
// Validate category
if (!form.category.trim()) {
errors.category = '作品分类不能为空'
isValid = false
}
// Validate year
if (!form.year.trim()) {
errors.year = '创作年份不能为空'
isValid = false
}
// Validate hero image
if (!form.heroImg.trim()) {
errors.heroImg = '作品主图不能为空'
isValid = false
}
// Validate description
if (!form.desc.trim()) {
errors.desc = '作品描述不能为空'
isValid = false
}
// Validate tech stack
if (form.techStack.length === 0) {
errors.techStack = '请至少添加一个技术栈分类'
isValid = false
} else {
for (let i = 0; i < form.techStack.length; i++) {
const item = form.techStack[i]
if (!item.category.trim()) {
errors.techStack = `${i + 1} 个分类的名称不能为空`
isValid = false
break
}
if (item.items.length === 0) {
errors.techStack = `${i + 1} 个分类至少需要一个技术标签`
isValid = false
break
}
for (let j = 0; j < item.items.length; j++) {
if (!item.items[j].trim()) {
errors.techStack = `${i + 1} 个分类的第 ${j + 1} 个技术标签不能为空`
isValid = false
break
}
}
if (!isValid) break
}
}
return isValid
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isSubmitting.value = true
try {
if (isEditing.value) {
// Update existing work
await updateWork(route.params.id as string, form)
toast.showToast('作品更新成功', 'success')
} else {
// Create new work
await createWork(form)
toast.showToast('作品创建成功', 'success')
}
// Redirect to works list
router.push('/admin/works')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.showToast(error.message || (isEditing.value ? '更新作品失败' : '创建作品失败'), 'error')
} finally {
isSubmitting.value = false
}
}
// Cancel handler
const handleCancel = () => {
router.push('/admin/works')
}
// Lifecycle
onMounted(async () => {
if (isEditing.value) {
try {
const workId = route.params.id as string
const work = await fetchWork(workId)
// Populate form with work data
form.title = work.title
form.category = work.category
form.year = work.year
form.heroImg = work.heroImg
form.desc = work.desc
form.techStack = work.techStack || []
form.gallery = work.gallery || []
// Handle links - support both old format (just live) and new format
if (typeof work.links === 'object' && work.links !== null) {
form.links = {
live: work.links.live || '',
github: (work.links as any).github || '',
demo: (work.links as any).demo || ''
}
} else {
form.links = { live: '', github: '', demo: '' }
}
} catch (error: any) {
console.error('Failed to fetch work data:', error)
toast.showToast('加载作品数据失败: ' + (error.message || '未知错误'), 'error')
}
}
})
</script>
<style scoped>
.work-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.work-form {
max-width: 800px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
font-family: 'Inter', sans-serif;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.tech-stack-item {
transition: all 0.2s;
}
.tech-stack-item:hover {
border-color: rgba(255, 255, 255, 0.2);
}
/* Responsive Design */
@media (max-width: 768px) {
.form-container {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.admin-btn-secondary,
.admin-btn-primary {
width: 100%;
}
}
</style>