212 lines
7.1 KiB
Vue
212 lines
7.1 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 作品表单:封面/图集/技术栈/外链/演示视频。
|
||
*/
|
||
import { computed, reactive, ref } from 'vue'
|
||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||
import { useToast } from '@wot-ui/ui'
|
||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||
import { adminCreate, adminGet, adminUpdate, type Work, type WorkTechGroup } from '@/api'
|
||
import { resolveMediaUrl } from '@/utils/request'
|
||
import { uploadAttachment } from '@/api'
|
||
|
||
const toast = useToast()
|
||
const { guardAdmin } = useAdminGuard()
|
||
|
||
const mode = ref<'create' | 'edit'>('create')
|
||
const id = ref('')
|
||
const loading = ref(false)
|
||
const submitting = ref(false)
|
||
|
||
const form = reactive({
|
||
title: '',
|
||
category: '',
|
||
year: '',
|
||
heroImg: '',
|
||
heroVideo: '',
|
||
desc: '',
|
||
live: '',
|
||
github: '',
|
||
demo: '',
|
||
})
|
||
const techStack = ref<WorkTechGroup[]>([{ category: 'Frontend', items: [] }])
|
||
const gallery = ref<string[]>([])
|
||
const techInput = ref('')
|
||
|
||
const pageTitle = computed(() => (mode.value === 'create' ? '新建作品' : '编辑作品'))
|
||
|
||
async function load() {
|
||
if (!guardAdmin()) return
|
||
if (mode.value !== 'edit' || !id.value) return
|
||
loading.value = true
|
||
try {
|
||
const data = await adminGet('works', id.value) as Work
|
||
form.title = String(data.title || '')
|
||
form.category = String(data.category || '')
|
||
form.year = String(data.year || '')
|
||
form.heroImg = String(data.heroImg || '')
|
||
form.heroVideo = String(data.heroVideo || data.videoUrl || '')
|
||
form.desc = String(data.desc || data.description || '')
|
||
const links = typeof data.links === 'string'
|
||
? (() => { try { return JSON.parse(data.links) } catch { return {} } })()
|
||
: (data.links || {})
|
||
form.live = String((links as Record<string, string>).live || '')
|
||
form.github = String((links as Record<string, string>).github || '')
|
||
form.demo = String((links as Record<string, string>).demo || '')
|
||
techStack.value = Array.isArray(data.techStack) && data.techStack.length
|
||
? data.techStack
|
||
: [{ category: 'Frontend', items: [] }]
|
||
gallery.value = Array.isArray(data.gallery) ? [...data.gallery] : []
|
||
}
|
||
catch (e: unknown) {
|
||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||
}
|
||
finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onLoad((q) => {
|
||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||
id.value = String(q?.id || '')
|
||
})
|
||
onShow(load)
|
||
|
||
function addTechTag() {
|
||
const t = techInput.value.trim()
|
||
if (!t) return
|
||
if (!techStack.value[0]) techStack.value = [{ category: 'Frontend', items: [] }]
|
||
techStack.value[0].items = [...(techStack.value[0].items || []), t]
|
||
techInput.value = ''
|
||
}
|
||
|
||
function removeTech(i: number) {
|
||
techStack.value[0]?.items?.splice(i, 1)
|
||
}
|
||
|
||
async function addGallery() {
|
||
if (gallery.value.length >= 20) {
|
||
toast.show('最多 20 张')
|
||
return
|
||
}
|
||
try {
|
||
const res = await uni.chooseImage({ count: Math.min(9, 20 - gallery.value.length), sizeType: ['compressed'] })
|
||
for (const path of res.tempFilePaths || []) {
|
||
const up = await uploadAttachment(path)
|
||
const url = up.fileUrl || up.url
|
||
if (url) gallery.value.push(url)
|
||
}
|
||
}
|
||
catch (e: unknown) {
|
||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||
}
|
||
}
|
||
|
||
async function onSubmit() {
|
||
if (!form.title.trim()) {
|
||
toast.show('请填写标题')
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
const payload = {
|
||
title: form.title,
|
||
category: form.category,
|
||
year: form.year,
|
||
heroImg: form.heroImg,
|
||
heroVideo: form.heroVideo,
|
||
desc: form.desc,
|
||
techStack: techStack.value,
|
||
gallery: gallery.value,
|
||
links: { live: form.live, github: form.github, demo: form.demo },
|
||
}
|
||
if (mode.value === 'edit' && id.value) await adminUpdate('works', id.value, payload)
|
||
else await adminCreate('works', payload)
|
||
toast.success('已保存')
|
||
setTimeout(() => uni.navigateBack(), 400)
|
||
}
|
||
catch (e: unknown) {
|
||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||
}
|
||
finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<AppPageShell admin>
|
||
<AppNavbar :title="pageTitle" show-back />
|
||
<view class="page-pad">
|
||
<view class="admin-card form-card">
|
||
<wd-input v-model="form.title" label="标题" clearable />
|
||
<wd-input v-model="form.category" label="分类" clearable />
|
||
<wd-input v-model="form.year" label="年份" clearable />
|
||
<AdminMediaPicker v-model="form.heroImg" label="封面图" />
|
||
<AdminMediaPicker v-model="form.heroVideo" label="演示视频" media-type="video" />
|
||
<wd-textarea v-model="form.desc" label="描述" />
|
||
<view class="section">
|
||
<text class="label text-art-muted">技术栈</text>
|
||
<view class="tag-wrap">
|
||
<view v-for="(t, i) in techStack[0]?.items || []" :key="i" class="tag" @click="removeTech(i)">{{ t }} ×</view>
|
||
</view>
|
||
<view class="row-input">
|
||
<wd-input v-model="techInput" placeholder="添加技术标签" clearable />
|
||
<wd-button size="small" @click="addTechTag">添加</wd-button>
|
||
</view>
|
||
</view>
|
||
<view class="section">
|
||
<text class="label text-art-muted">图集 ({{ gallery.length }}/20)</text>
|
||
<view class="gallery">
|
||
<image v-for="(g, i) in gallery" :key="i" class="g-img" :src="resolveMediaUrl(g)" mode="aspectFill" @click="gallery.splice(i, 1)" />
|
||
<view class="g-add" @click="addGallery">+</view>
|
||
</view>
|
||
</view>
|
||
<wd-input v-model="form.live" label="线上地址" clearable />
|
||
<wd-input v-model="form.github" label="GitHub" clearable />
|
||
<wd-input v-model="form.demo" label="Demo" clearable />
|
||
<wd-button
|
||
block
|
||
type="primary"
|
||
:loading="submitting"
|
||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||
@click="onSubmit"
|
||
>
|
||
保存
|
||
</wd-button>
|
||
</view>
|
||
<wd-loading v-if="loading" />
|
||
</view>
|
||
</AppPageShell>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||
.form-card { padding: 24rpx; }
|
||
.section { margin: 20rpx 0; }
|
||
.label { font-size: 24rpx; }
|
||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 10rpx; margin: 12rpx 0; }
|
||
.tag {
|
||
padding: 6rpx 14rpx;
|
||
border-radius: 8rpx;
|
||
background: rgba(212, 179, 131, 0.2);
|
||
color: rgb(var(--art-accent));
|
||
font-size: 22rpx;
|
||
}
|
||
.row-input { display: flex; gap: 12rpx; align-items: center; }
|
||
.gallery { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||
.g-img, .g-add {
|
||
width: 140rpx; height: 140rpx; border-radius: 12rpx;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
.g-add {
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 48rpx; color: rgba(255, 255, 255, 0.4);
|
||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||
}
|
||
</style>
|