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
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
<!-- src/components-mobile/bulletin/BulletinListMobile.vue -->
|
||||
<!-- 手机端信息公示板:置顶区 + 普通列表 + 发布 + 已读标记 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { subscribeBulletins } from '@/api/bulletins'
|
||||
import { BulletinPriorityMap } from '@/types'
|
||||
import type { BulletinPost } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
import BulletinPostSheetMobile from './BulletinPostSheetMobile.vue'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
|
||||
const posts = computed(() => bulletinStore.posts)
|
||||
const pinnedPosts = computed(() => bulletinStore.pinnedPosts)
|
||||
const normalPosts = computed(() => bulletinStore.normalPosts)
|
||||
|
||||
const loading = ref(false)
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 详情/编辑面板
|
||||
const showSheet = ref(false)
|
||||
const viewingPost = ref<BulletinPost | null>(null)
|
||||
const editing = ref(false)
|
||||
|
||||
// 发布面板
|
||||
const showCreate = ref(false)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
await bulletinStore.loadPosts(props.groupId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
try {
|
||||
unsubscribeFn = await subscribeBulletins(props.groupId, () => loadAll())
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
})
|
||||
|
||||
function openDetail(post: BulletinPost) {
|
||||
viewingPost.value = post
|
||||
editing.value = false
|
||||
showSheet.value = true
|
||||
// 标记已读
|
||||
if (!bulletinStore.isRead(post.id)) {
|
||||
bulletinStore.markAsRead(post.id).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(post: BulletinPost) {
|
||||
viewingPost.value = post
|
||||
editing.value = true
|
||||
showSheet.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(post: BulletinPost) {
|
||||
showConfirmDialog({
|
||||
title: '删除公告',
|
||||
message: `确定要删除「${post.title}」吗?`
|
||||
}).then(async () => {
|
||||
try {
|
||||
await bulletinStore.remove(post.id)
|
||||
showSuccessToast('删除成功')
|
||||
showSheet.value = false
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
try {
|
||||
await bulletinStore.markAllAsRead(props.groupId)
|
||||
showSuccessToast('全部已读')
|
||||
} catch (e: any) {
|
||||
showFailToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function priorityType(p: string): any {
|
||||
const map: Record<string, string> = { urgent: 'danger', high: 'warning', normal: 'primary', low: 'default' }
|
||||
return map[p] || 'default'
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||||
if (min < 1) return '刚刚'
|
||||
if (min < 60) return `${min}分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}小时前`
|
||||
const day = Math.floor(hour / 24)
|
||||
if (day < 30) return `${day}天前`
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 面板关闭后处理(编辑/发布后刷新)
|
||||
async function onSheetSaved() {
|
||||
showSheet.value = false
|
||||
showCreate.value = false
|
||||
await loadAll()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulletin-mobile">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<van-button size="small" round icon="success" plain @click="handleMarkAllRead">全部已读</van-button>
|
||||
<van-button type="primary" size="small" round icon="edit" @click="showCreate = true">发布公告</van-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && posts.length === 0" class="loading-box">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<div v-else-if="posts.length === 0" class="empty">
|
||||
<van-empty description="暂无公告" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div v-else class="post-list">
|
||||
<!-- 置顶区 -->
|
||||
<div v-if="pinnedPosts.length > 0" class="section-block">
|
||||
<div class="block-title">
|
||||
<van-icon name="bookmark-o" /> 置顶
|
||||
</div>
|
||||
<div
|
||||
v-for="post in pinnedPosts"
|
||||
:key="post.id"
|
||||
class="post-card pinned"
|
||||
:class="{ unread: !bulletinStore.isRead(post.id) }"
|
||||
@click="openDetail(post)"
|
||||
>
|
||||
<div class="post-header">
|
||||
<van-tag :type="priorityType(post.priority)" size="medium">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||
<span class="post-time">{{ timeAgo(post.created) }}</span>
|
||||
</div>
|
||||
<div class="post-title">{{ post.title }}</div>
|
||||
<div class="post-content-preview">{{ post.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通列表 -->
|
||||
<div v-if="normalPosts.length > 0" class="section-block">
|
||||
<div v-if="pinnedPosts.length > 0" class="block-title">
|
||||
<van-icon name="notes-o" /> 公告
|
||||
</div>
|
||||
<div
|
||||
v-for="post in normalPosts"
|
||||
:key="post.id"
|
||||
class="post-card"
|
||||
:class="{ unread: !bulletinStore.isRead(post.id) }"
|
||||
@click="openDetail(post)"
|
||||
>
|
||||
<div class="post-header">
|
||||
<van-tag :type="priorityType(post.priority)" size="medium">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||
<span class="post-time">{{ timeAgo(post.created) }}</span>
|
||||
</div>
|
||||
<div class="post-title">{{ post.title }}</div>
|
||||
<div class="post-content-preview">{{ post.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情/编辑面板 -->
|
||||
<BulletinPostSheetMobile
|
||||
v-model:show="showSheet"
|
||||
:post="viewingPost"
|
||||
:editing="editing"
|
||||
:group-id="props.groupId"
|
||||
@edit="openEdit"
|
||||
@delete="handleDelete"
|
||||
@saved="onSheetSaved"
|
||||
/>
|
||||
|
||||
<!-- 发布面板(复用 PostSheet,post 为 null 表示新建) -->
|
||||
<BulletinPostSheetMobile
|
||||
v-model:show="showCreate"
|
||||
:post="null"
|
||||
:editing="true"
|
||||
:group-id="props.groupId"
|
||||
@saved="onSheetSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-mobile { padding: 12px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||
.empty { padding: 20px 0; }
|
||||
|
||||
.section-block { margin-bottom: 16px; }
|
||||
|
||||
.block-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
background: var(--gg-bg-card);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--gg-shadow);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-card:active { opacity: 0.85; }
|
||||
|
||||
.post-card.pinned {
|
||||
border-left: 3px solid var(--gg-warning);
|
||||
}
|
||||
|
||||
.post-card.unread::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-danger);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-time {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.post-content-preview {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<!-- 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>
|
||||
@@ -12,6 +12,7 @@ import PollListMobile from '@/components-mobile/poll/PollListMobile.vue'
|
||||
import BetListMobile from '@/components-mobile/bet/BetListMobile.vue'
|
||||
import MemoryGridMobile from '@/components-mobile/memory/MemoryGridMobile.vue'
|
||||
import StatsPanelMobile from '@/components-mobile/stats/StatsPanelMobile.vue'
|
||||
import BulletinListMobile from '@/components-mobile/bulletin/BulletinListMobile.vue'
|
||||
import Placeholder from '@/views-mobile/Placeholder.vue'
|
||||
import { Wallet, Box, Warning } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -57,10 +58,10 @@ onUnmounted(async () => {
|
||||
})
|
||||
|
||||
// 标签配置
|
||||
// 注:polls/bets/memories/stats 子组件阶段 6/9 迁移后接入;
|
||||
// bulletin/events 阶段 10/11 新增
|
||||
// polls/bets/memories/stats 子组件已迁移;bulletin 阶段 10 新增;events 阶段 11 新增
|
||||
const tabs = [
|
||||
{ name: 'activity', label: '动态' },
|
||||
{ name: 'bulletin', label: '公告' },
|
||||
{ name: 'polls', label: '投票' },
|
||||
{ name: 'bets', label: '竞猜' },
|
||||
{ name: 'members', label: '成员' },
|
||||
@@ -133,7 +134,9 @@ function goBlacklist() {
|
||||
<!-- 阶段 9 已迁移 -->
|
||||
<MemoryGridMobile v-else-if="activeTab === 'memories'" :group-id="groupId" />
|
||||
<StatsPanelMobile v-else-if="activeTab === 'stats'" :group-id="groupId" />
|
||||
<!-- 以下 tab 子组件在阶段 10/11 迁移后接入,现暂用占位 -->
|
||||
<!-- 阶段 10 新增 -->
|
||||
<BulletinListMobile v-else-if="activeTab === 'bulletin'" :group-id="groupId" />
|
||||
<!-- 以下 tab 子组件在阶段 11 迁移后接入,现暂用占位 -->
|
||||
<Placeholder v-else />
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
Reference in New Issue
Block a user