Files
gamegroup2/frontend/src/components-mobile/bulletin/BulletinPostSheetMobile.vue
T
锦麟 王 062c044295 feat(mobile): stage 10 - bulletin board (group detail '公告' tab)
- new BulletinListMobile.vue: pinned section + normal list + publish button
  + unread dots + realtime subscription + mark-all-read
- new BulletinPostSheetMobile.vue: 3-in-1 bottom sheet (detail / edit / create)
  with priority/pinned/expires + permission-based edit/delete (owner/admin/creator)
- GroupViewMobile: add '公告' tab (after 动态), wire BulletinListMobile
- reuses uat bulletin store (posts/pinnedPosts/normalPosts/isRead/create/update/remove)

build verified: vue-tsc + vite build pass
2026-06-18 11:28:50 +08:00

277 lines
7.4 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.
<!-- src/components-mobile/bulletin/BulletinPostSheetMobile.vue -->
<!-- 公告详情/编辑/创建 三合一底部面板 -->
<!-- - post=null & editing=true 创建 -->
<!-- - post=非null & editing=false 详情含编辑/删除 -->
<!-- - post=非null & editing=true 编辑 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useBulletinStore } from '@/stores/bulletin'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import { BulletinPriorityMap } from '@/types'
import type { BulletinPost, BulletinPriority } from '@/types'
import { displayName } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{
show: boolean
post: BulletinPost | null
editing: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'edit': [post: BulletinPost]
'delete': [post: BulletinPost]
'saved': []
}>()
const bulletinStore = useBulletinStore()
const groupStore = useGroupStore()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
// 表单
const form = ref({
title: '',
content: '',
priority: 'normal' as BulletinPriority,
pinned: false,
expiresAt: ''
})
const saving = ref(false)
// 是否创建模式
const isCreate = computed(() => props.editing && !props.post)
// 权限:群主/管理员可编辑删除
const currentUserId = computed(() => pb.authStore.model?.id || '')
const canManage = computed(() => {
if (!props.post) return false
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (props.post.creator === currentUserId.value) return true
return false
})
// 打开时同步表单
watch(() => props.show, (val) => {
if (!val) return
if (props.editing && props.post) {
// 编辑模式:填充现有数据
form.value = {
title: props.post.title,
content: props.post.content,
priority: props.post.priority,
pinned: props.post.pinned,
expiresAt: props.post.expiresAt || ''
}
} else if (isCreate.value) {
// 创建模式:清空
form.value = { title: '', content: '', priority: 'normal', pinned: false, expiresAt: '' }
}
})
async function handleSubmit() {
if (!form.value.title.trim()) {
showFailToast('请输入标题')
return
}
if (!form.value.content.trim()) {
showFailToast('请输入内容')
return
}
saving.value = true
try {
const payload = {
title: form.value.title.trim(),
content: form.value.content.trim(),
priority: form.value.priority,
pinned: form.value.pinned,
expiresAt: form.value.expiresAt || undefined
}
if (isCreate.value) {
await bulletinStore.create({ group: props.groupId, ...payload })
showSuccessToast('发布成功')
} else if (props.post) {
await bulletinStore.update(props.post.id, payload)
showSuccessToast('修改成功')
}
emit('saved')
} catch (e: any) {
showFailToast(e.message || '操作失败')
} finally {
saving.value = false
}
}
const priorityKeys = Object.keys(BulletinPriorityMap) as BulletinPriority[]
function priorityType(p: string): any {
const map: Record<string, string> = { urgent: 'danger', high: 'warning', normal: 'primary', low: 'default' }
return map[p] || 'default'
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<van-popup
v-model:show="visible"
position="bottom"
round
closeable
:style="{ height: editing ? '80%' : '70%' }"
>
<div class="post-sheet">
<!-- 编辑/创建模式 -->
<template v-if="editing">
<div class="sheet-title">{{ isCreate ? '发布公告' : '编辑公告' }}</div>
<van-cell-group inset>
<van-field v-model="form.title" label="标题" placeholder="公告标题" required maxlength="100" />
<van-field
v-model="form.content"
type="textarea"
label="内容"
placeholder="公告正文(支持换行)"
rows="5"
autosize
required
/>
<van-field name="priority" label="优先级">
<template #input>
<select v-model="form.priority" class="native-select">
<option v-for="p in priorityKeys" :key="p" :value="p">{{ BulletinPriorityMap[p] }}</option>
</select>
</template>
</van-field>
<van-cell title="置顶" center>
<template #right-icon>
<van-switch v-model="form.pinned" size="22px" />
</template>
</van-cell>
<van-field v-model="form.expiresAt" label="截止时间" placeholder="可选,如 2026-12-31" />
</van-cell-group>
<div class="sheet-actions">
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
{{ isCreate ? '发布' : '保存' }}
</van-button>
</div>
</template>
<!-- 详情模式 -->
<template v-else-if="post">
<div class="detail-header">
<van-tag :type="priorityType(post.priority)" size="large">{{ BulletinPriorityMap[post.priority] }}</van-tag>
<van-tag v-if="post.pinned" type="warning" size="large">置顶</van-tag>
<span class="detail-time">{{ formatDate(post.created) }}</span>
</div>
<h2 class="detail-title">{{ post.title }}</h2>
<div class="detail-meta">
<span>发布人{{ displayName(post.expand?.creator) }}</span>
<span v-if="post.expiresAt" class="detail-expire">截止{{ formatDate(post.expiresAt) }}</span>
</div>
<div class="detail-content">{{ post.content }}</div>
<div v-if="canManage" class="detail-actions">
<van-button size="small" round icon="edit" @click="emit('edit', post)">编辑</van-button>
<van-button size="small" round plain type="danger" icon="delete-o" @click="emit('delete', post)">删除</van-button>
</div>
</template>
</div>
</van-popup>
</template>
<style scoped>
.post-sheet {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 16px 0 24px;
}
.sheet-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
}
.native-select {
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
padding: 6px 10px;
font-size: 14px;
background: var(--gg-bg-card);
width: 100%;
}
.sheet-actions {
padding: 20px 16px 0;
margin-top: auto;
}
/* 详情 */
.detail-header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px 12px;
}
.detail-time {
font-size: 12px;
color: var(--gg-text-muted);
margin-left: auto;
}
.detail-title {
font-size: 20px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
padding: 0 16px;
line-height: 1.4;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--gg-text-muted);
padding: 8px 16px 16px;
}
.detail-expire {
color: var(--gg-warning);
}
.detail-content {
font-size: 15px;
color: var(--gg-text);
line-height: 1.7;
padding: 0 16px;
white-space: pre-wrap;
word-break: break-word;
}
.detail-actions {
display: flex;
gap: 10px;
justify-content: center;
padding: 24px 16px 0;
margin-top: auto;
}
</style>