feat: add lottery post type options

This commit is contained in:
Tim
2025-08-11 01:45:23 +08:00
parent 0cf1bf187a
commit e0291868bc
4 changed files with 259 additions and 5 deletions

View File

@@ -0,0 +1,46 @@
<template>
<Dropdown v-model="selected" :fetch-options="fetchTypes" placeholder="选择帖子类型" :initial-options="providedOptions" />
</template>
<script>
import { computed, ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
export default {
name: 'PostTypeSelect',
components: { Dropdown },
props: {
modelValue: { type: String, default: 'NORMAL' },
options: { type: Array, default: () => [] }
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options,
val => {
providedOptions.value = Array.isArray(val) ? [...val] : []
}
)
const fetchTypes = async () => {
return [
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' }
]
}
const selected = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
})
return { fetchTypes, selected, providedOptions }
}
}
</script>
<style scoped>
</style>

View File

@@ -8,6 +8,7 @@
"dependencies": {
"cropperjs": "^1.6.2",
"echarts": "^5.6.0",
"flatpickr": "^4.6.13",
"highlight.js": "^11.11.1",
"ldrs": "^1.0.0",
"markdown-it": "^14.1.0",
@@ -16,6 +17,7 @@
"vditor": "^3.11.1",
"vue-easy-lightbox": "^1.19.0",
"vue-echarts": "^7.0.3",
"vue-flatpickr-component": "^12.0.0",
"vue-toastification": "^2.0.0-rc.5"
}
},
@@ -4868,6 +4870,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flatpickr": {
"version": "4.6.13",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
"license": "MIT"
},
"node_modules/fn.name": {
"version": "1.1.0",
"license": "MIT"
@@ -10227,6 +10235,21 @@
}
}
},
"node_modules/vue-flatpickr-component": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/vue-flatpickr-component/-/vue-flatpickr-component-12.0.0.tgz",
"integrity": "sha512-CJ5jrgTaeD66Z4mjEocSTAdB/n6IGSlUICwdBanpyCI8hswq5rwXvEYQ5IKA3K3uVjP5pBlY9Rg6o3xoszTPpA==",
"license": "MIT",
"dependencies": {
"flatpickr": "^4.6.13"
},
"engines": {
"node": ">=14.13.0"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"license": "MIT",

View File

@@ -19,6 +19,8 @@
"vditor": "^3.11.1",
"vue-easy-lightbox": "^1.19.0",
"vue-echarts": "^7.0.3",
"vue-toastification": "^2.0.0-rc.5"
"vue-toastification": "^2.0.0-rc.5",
"flatpickr": "^4.6.13",
"vue-flatpickr-component": "^12.0.0"
}
}

View File

@@ -10,6 +10,7 @@
<div class="post-options-left">
<CategorySelect v-model="selectedCategory" />
<TagSelect v-model="selectedTags" creatable />
<PostTypeSelect v-model="postType" />
</div>
<div class="post-options-right">
<div class="post-clear" @click="clearPost">
@@ -32,31 +33,101 @@
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div>
</div>
</div>
<div v-if="postType === 'LOTTERY'" class="lottery-section">
<AvatarCropper
:src="tempPrizeIcon"
:show="showPrizeCropper"
@close="showPrizeCropper = false"
@crop="onPrizeCropped"
/>
<div class="prize-row">
<label class="prize-container">
<img v-if="prizeIcon" :src="prizeIcon" class="prize-preview" alt="prize" />
<div class="prize-overlay">上传奖品图片</div>
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
</label>
</div>
<div class="prize-count-row">
<span>奖品数量</span>
<div class="prize-count-input">
<button type="button" @click="decPrizeCount">-</button>
<input type="number" v-model.number="prizeCount" min="1" />
<button type="button" @click="incPrizeCount">+</button>
</div>
</div>
<div class="prize-time-row">
<span>抽奖结束时间</span>
<client-only>
<flat-pickr v-model="endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import PostEditor from '../components/PostEditor.vue'
import CategorySelect from '../components/CategorySelect.vue'
import TagSelect from '../components/TagSelect.vue'
import PostTypeSelect from '../components/PostTypeSelect.vue'
import AvatarCropper from '../components/AvatarCropper.vue'
import FlatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import { API_BASE_URL, toast } from '../main'
import { getToken, authState } from '../utils/auth'
import LoginOverlay from '../components/LoginOverlay.vue'
export default {
name: 'NewPostPageView',
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay, PostTypeSelect, AvatarCropper, FlatPickr },
setup() {
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const prizeIcon = ref('')
const prizeIconFile = ref(null)
const tempPrizeIcon = ref('')
const showPrizeCropper = ref(false)
const prizeCount = ref(1)
const endTime = ref(null)
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const onPrizeIconChange = e => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
tempPrizeIcon.value = reader.result
showPrizeCropper.value = true
}
reader.readAsDataURL(file)
}
}
const onPrizeCropped = ({ file, url }) => {
prizeIconFile.value = file
prizeIcon.value = url
}
const incPrizeCount = () => {
prizeCount.value++
}
const decPrizeCount = () => {
if (prizeCount.value > 1) prizeCount.value--
}
watch(prizeCount, val => {
if (!val || val < 1) prizeCount.value = 1
})
const loadDraft = async () => {
const token = getToken()
if (!token) return
@@ -85,6 +156,13 @@ export default {
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
postType.value = 'NORMAL'
prizeIcon.value = ''
prizeIconFile.value = null
tempPrizeIcon.value = ''
showPrizeCropper.value = false
prizeCount.value = 1
endTime.value = null
// 删除草稿
const token = getToken()
@@ -213,10 +291,40 @@ export default {
toast.error('请选择标签')
return
}
if (postType.value === 'LOTTERY') {
if (!prizeIcon.value) {
toast.error('请上传奖品图片')
return
}
if (!prizeCount.value || prizeCount.value < 1) {
toast.error('奖品数量必须大于0')
return
}
if (!endTime.value) {
toast.error('请选择抽奖结束时间')
return
}
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
let prizeIconUrl = prizeIcon.value
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
const form = new FormData()
form.append('file', prizeIconFile.value)
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form
})
const uploadData = await uploadRes.json()
if (!uploadRes.ok || uploadData.code !== 0) {
toast.error('奖品图片上传失败')
return
}
prizeIconUrl = uploadData.data.url
}
const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST',
headers: {
@@ -227,7 +335,11 @@ export default {
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
endTime: postType.value === 'LOTTERY' ? new Date(endTime.value).toISOString() : undefined
})
})
const data = await res.json()
@@ -251,7 +363,7 @@ export default {
isWaitingPosting.value = false
}
}
return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin }
return { title, content, selectedCategory, selectedTags, postType, prizeIcon, prizeCount, endTime, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin, onPrizeIconChange, onPrizeCropped, incPrizeCount, decPrizeCount, showPrizeCropper, tempPrizeIcon, dateConfig }
}
}
</script>
@@ -366,6 +478,77 @@ export default {
padding-bottom: 50px;
}
.lottery-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.prize-row {
display: flex;
}
.prize-container {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
}
.prize-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.prize-input {
display: none;
}
.prize-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.prize-container:hover .prize-overlay {
opacity: 1;
}
.prize-count-row,
.prize-time-row {
display: flex;
align-items: center;
gap: 10px;
}
.prize-count-input {
display: flex;
align-items: center;
gap: 5px;
}
.prize-count-input button {
width: 24px;
height: 24px;
}
.time-picker {
max-width: 200px;
}
@media (max-width: 768px) {
.new-post-page {
width: calc(100vw - 20px);