Files
nl-blogs/blog-wot-uniapp/src/subPackages/admin/pages/works/form.vue
李琦 4e245f789b 1. 小程序端
2. 视频优化2
2026-07-30 14:14:28 +08:00

212 lines
7.1 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 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>