This commit is contained in:
congsh
2026-06-18 13:54:21 +08:00
39 changed files with 8527 additions and 16 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Game Group V2</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>">
</head>
+1
View File
@@ -15,6 +15,7 @@
"livekit-client": "^2.18.3",
"pinia": "^2.1.7",
"pocketbase": "^0.21.1",
"vant": "^4.9.0",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
+53
View File
@@ -0,0 +1,53 @@
/* src/assets/mobile.css */
/* Vant 4 主题变量覆盖 —— 映射到项目 design.css 的 --gg-* 设计系统 */
:root {
/* 主色(Vant primary → gg-primary 绿色) */
--van-primary-color: var(--gg-primary); /* #059669 */
--van-success-color: var(--gg-success); /* #10b981 */
--van-danger-color: var(--gg-danger); /* #ef4444 */
--van-warning-color: var(--gg-warning); /* #f59e0b */
/* 文字色 */
--van-text-color: var(--gg-text); /* #1e293b */
--van-text-color-2: var(--gg-text-secondary); /* #475569 */
--van-text-color-3: var(--gg-text-muted); /* #94a3b8 */
/* 背景 */
--van-background: var(--gg-bg); /* #f0fdf4 */
--van-background-2: var(--gg-bg-card); /* #ffffff */
/* 边框 */
--van-border-color: var(--gg-border); /* #e2e8f0 */
/* 圆角 */
--van-radius-sm: var(--gg-radius-sm);
--van-radius-md: var(--gg-radius-md);
--van-radius-lg: var(--gg-radius-lg);
/* Tabbar(底部导航) */
--van-tabbar-background: var(--gg-bg-card);
--van-tabbar-item-active-color: var(--gg-primary);
--van-tabbar-item-text-color: var(--gg-text-muted);
--van-tabbar-height: 56px;
/* NavBar(顶部栏) */
--van-nav-bar-background: var(--gg-bg-card);
--van-nav-bar-title-font-size: 17px;
--van-nav-bar-height: 52px;
--van-nav-bar-icon-color: var(--gg-text);
/* 按钮主色渐变 */
--van-button-primary-background: var(--gg-primary);
--van-button-primary-border-color: var(--gg-primary);
}
/* 手机端全局:安全区域适配(刘海屏底部 Tab 不被遮挡) */
.mobile-app {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* 禁止移动端点击高亮 */
.mobile-app * {
-webkit-tap-highlight-color: transparent;
}
@@ -0,0 +1,410 @@
<!-- src/components-mobile/bet/BetListMobile.vue -->
<!-- 手机端竞猜列表 + 详情 + 下注 + 创建 + 结算 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listBets, getBet, getBetOptions, getBetEntries, placeBet, createBet, closeBet, settleBet } from '@/api/bets'
import { useUserStore } from '@/stores/user'
import type { Bet, BetOption, BetEntry } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const userStore = useUserStore()
const bets = ref<Bet[]>([])
const loading = ref(false)
const viewingBetId = ref<string | null>(null)
const currentBet = ref<Bet | null>(null)
const options = ref<BetOption[]>([])
const entries = ref<BetEntry[]>([])
const detailLoading = ref(false)
async function loadBets() {
loading.value = true
try {
bets.value = await listBets(props.groupId)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadBets()
})
async function viewBet(betId: string) {
viewingBetId.value = betId
detailLoading.value = true
try {
const [bet, opts, ents] = await Promise.all([
getBet(betId),
getBetOptions(betId),
getBetEntries(betId)
])
currentBet.value = bet
options.value = opts
entries.value = ents
} catch (e) {
showFailToast('加载失败')
} finally {
detailLoading.value = false
}
}
function backToList() {
viewingBetId.value = null
currentBet.value = null
loadBets()
}
function optionEntryCount(optionId: string): number {
return entries.value.filter(e => e.option === optionId).length
}
function optionTotalStake(optionId: string): number {
return entries.value.filter(e => e.option === optionId).reduce((sum, e) => sum + e.stake, 0)
}
const myEntry = () => entries.value.find(e => e.user === userStore.userId)
// 下注
const showBet = ref(false)
const selectedOption = ref<string>('')
const stakeAmount = ref(0)
const placeLoading = ref(false)
function openBetSheet(optionId: string) {
if (!currentBet.value || currentBet.value.status !== 'open') return
if (myEntry()) {
showFailToast('你已经下注了')
return
}
selectedOption.value = optionId
stakeAmount.value = currentBet.value.minStake
showBet.value = true
}
async function handlePlaceBet() {
if (!currentBet.value) return
if (stakeAmount.value < currentBet.value.minStake || stakeAmount.value > currentBet.value.maxStake) {
showFailToast(`下注范围 ${currentBet.value.minStake}-${currentBet.value.maxStake}`)
return
}
placeLoading.value = true
try {
await placeBet(currentBet.value.id, selectedOption.value, stakeAmount.value)
showSuccessToast('下注成功')
showBet.value = false
await viewBet(currentBet.value.id)
} catch (e: any) {
showFailToast(e.message || '下注失败')
} finally {
placeLoading.value = false
}
}
// 创建竞猜
const showCreate = ref(false)
const createForm = ref({
title: '',
description: '',
optionsText: '',
minStake: 1,
maxStake: 100,
deadline: ''
})
const createLoading = ref(false)
async function handleCreate() {
if (!createForm.value.title.trim()) {
showFailToast('请输入标题')
return
}
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
if (opts.length < 2) {
showFailToast('至少 2 个选项')
return
}
if (!createForm.value.deadline) {
showFailToast('请设置截止时间')
return
}
createLoading.value = true
try {
await createBet({
group: props.groupId,
title: createForm.value.title.trim(),
description: createForm.value.description.trim() || undefined,
options: opts,
minStake: createForm.value.minStake,
maxStake: createForm.value.maxStake,
deadline: createForm.value.deadline
})
showSuccessToast('创建成功')
showCreate.value = false
createForm.value = { title: '', description: '', optionsText: '', minStake: 1, maxStake: 100, deadline: '' }
await loadBets()
} catch (e: any) {
showFailToast(e.message || '创建失败')
} finally {
createLoading.value = false
}
}
// 关闭/结算(创建者)
async function handleClose() {
if (!currentBet.value) return
showConfirmDialog({ title: '关闭竞猜', message: '关闭后将停止下注,确定?' })
.then(async () => {
try {
await closeBet(currentBet.value!.id)
showSuccessToast('已关闭')
await viewBet(currentBet.value!.id)
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}).catch(() => {})
}
const showSettle = ref(false)
const settleOption = ref('')
async function handleSettle() {
if (!currentBet.value || !settleOption.value) return
try {
await settleBet(currentBet.value.id, settleOption.value)
showSuccessToast('已结算')
showSettle.value = false
await viewBet(currentBet.value.id)
} catch (e: any) {
showFailToast(e.message || '结算失败')
}
}
const isCreator = () => currentBet.value?.creator === userStore.userId
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="bet-mobile">
<!-- 详情 -->
<div v-if="viewingBetId && currentBet">
<van-nav-bar :title="currentBet.title" left-arrow @click-left="backToList" />
<div v-if="detailLoading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else class="detail-content">
<div class="detail-header">
<van-tag :type="currentBet.status === 'open' ? 'success' : currentBet.status === 'closed' ? 'warning' : 'default'">
{{ currentBet.status === 'open' ? '下注中' : currentBet.status === 'closed' ? '待结算' : '已结算' }}
</van-tag>
<span class="detail-meta">范围 {{ currentBet.minStake }}-{{ currentBet.maxStake }} 积分</span>
</div>
<p v-if="currentBet.description" class="detail-desc">{{ currentBet.description }}</p>
<div class="options-list">
<div
v-for="opt in options"
:key="opt.id"
class="option-item"
@click="currentBet.status === 'open' && openBetSheet(opt.id)"
>
<div class="option-row">
<span class="option-text">{{ opt.content }}</span>
<span v-if="currentBet.resultOption === opt.id" class="winner-badge">🏆 胜出</span>
</div>
<div class="option-stats">
<span>{{ optionEntryCount(opt.id) }} </span>
<span>{{ optionTotalStake(opt.id) }} 积分</span>
</div>
</div>
</div>
<!-- 我的结果 -->
<div v-if="currentBet.status === 'settled' && myEntry()" class="my-result">
<van-notice-bar
:type="myEntry()?.won ? 'success' : 'warning'"
wrapable
>
{{ myEntry()?.won ? `🎉 你赢了!` : '本次未中奖' }}
</van-notice-bar>
</div>
<!-- 创建者操作 -->
<div v-if="isCreator()" class="detail-actions">
<van-button v-if="currentBet.status === 'open'" type="warning" block round plain @click="handleClose">
关闭下注
</van-button>
<van-button v-if="currentBet.status === 'closed'" type="primary" block round @click="showSettle = true">
结算
</van-button>
</div>
</div>
</div>
<!-- 列表 -->
<template v-else>
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
发起竞猜
</van-button>
</div>
<van-pull-refresh v-model="loading" @refresh="loadBets">
<div v-if="bets.length === 0 && !loading" class="empty">
<van-empty description="暂无竞猜" image-size="100" />
</div>
<div class="bet-list">
<div
v-for="bet in bets"
:key="bet.id"
class="bet-card"
@click="viewBet(bet.id)"
>
<div class="bet-card-header">
<van-tag :type="bet.status === 'open' ? 'success' : bet.status === 'closed' ? 'warning' : 'default'" size="medium">
{{ bet.status === 'open' ? '下注中' : bet.status === 'closed' ? '待结算' : '已结算' }}
</van-tag>
<span class="bet-stake">{{ bet.minStake }}-{{ bet.maxStake }} 积分</span>
</div>
<div class="bet-title">{{ bet.title }}</div>
<div class="bet-card-footer">
<span class="bet-time">{{ timeAgo(bet.created) }}</span>
<van-icon name="arrow" />
</div>
</div>
</div>
</van-pull-refresh>
</template>
<!-- 下注弹层 -->
<van-action-sheet v-model:show="showBet" title="下注">
<div class="bet-sheet-content">
<div class="stake-display">
<span class="stake-num">{{ stakeAmount }}</span>
<span class="stake-unit">积分</span>
</div>
<van-stepper
v-model="stakeAmount"
:min="currentBet?.minStake"
:max="currentBet?.maxStake"
integer
/>
<div class="bet-sheet-actions">
<van-button type="primary" block round :loading="placeLoading" @click="handlePlaceBet">
确认下注
</van-button>
</div>
</div>
</van-action-sheet>
<!-- 创建弹层 -->
<van-popup v-model:show="showCreate" position="bottom" round closeable :style="{ height: '85%' }">
<div class="popup-content">
<div class="popup-title">发起竞猜</div>
<van-cell-group inset>
<van-field v-model="createForm.title" label="标题" placeholder="竞猜主题" required />
<van-field v-model="createForm.description" label="说明" placeholder="可选" />
<van-field
v-model="createForm.optionsText"
type="textarea"
label="选项"
placeholder="每行一个选项"
rows="3"
/>
<van-field name="stepper" label="最低下注">
<template #input><van-stepper v-model="createForm.minStake" min="1" /></template>
</van-field>
<van-field name="stepper" label="最高下注">
<template #input><van-stepper v-model="createForm.maxStake" min="1" /></template>
</van-field>
<van-field v-model="createForm.deadline" label="截止时间" placeholder="如 2026-12-31 23:59" required />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
创建
</van-button>
</div>
</div>
</van-popup>
<!-- 结算弹层 -->
<van-action-sheet v-model:show="showSettle" title="选择胜出选项">
<div class="settle-content">
<div
v-for="opt in options"
:key="opt.id"
class="settle-option"
:class="{ 'settle-selected': settleOption === opt.id }"
@click="settleOption = opt.id"
>
{{ opt.content }}
</div>
<div class="settle-actions">
<van-button type="primary" block round @click="handleSettle">确认结算</van-button>
</div>
</div>
</van-action-sheet>
</div>
</template>
<style scoped>
.bet-mobile { padding: 12px; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.empty { padding: 30px 0; }
.list-header { display: flex; justify-content: flex-end; margin-bottom: 10px; }
.bet-list { display: flex; flex-direction: column; gap: 10px; }
.bet-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
.bet-card:active { opacity: 0.85; }
.bet-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.bet-stake { font-size: 12px; color: var(--gg-text-muted); }
.bet-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
.bet-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--gg-text-muted); font-size: 13px; }
.detail-content { padding: 12px; }
.detail-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.detail-meta { font-size: 12px; color: var(--gg-text-muted); }
.detail-desc { font-size: 14px; color: var(--gg-text-secondary); margin: 0 0 16px; line-height: 1.5; }
.options-list { display: flex; flex-direction: column; gap: 10px; }
.option-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 12px 14px; border: 1px solid var(--gg-border); }
.option-row { display: flex; align-items: center; justify-content: space-between; }
.option-text { font-size: 14px; font-weight: 500; color: var(--gg-text); }
.winner-badge { font-size: 13px; color: var(--gg-warning); font-weight: 600; }
.option-stats { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--gg-text-muted); }
.my-result { margin-top: 16px; }
.detail-actions { margin-top: 20px; }
/* 下注弹层 */
.bet-sheet-content { padding: 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.stake-display { display: flex; align-items: baseline; gap: 4px; }
.stake-num { font-size: 40px; font-weight: 700; color: var(--gg-primary); }
.stake-unit { font-size: 14px; color: var(--gg-text-muted); }
.bet-sheet-actions { width: 100%; }
/* 弹层通用 */
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.popup-actions { padding: 20px 16px 0; }
.settle-content { padding: 16px; }
.settle-option { padding: 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm); margin-bottom: 8px; text-align: center; border: 1px solid var(--gg-border); font-size: 14px; }
.settle-selected { background: rgba(5, 150, 105, 0.1); border-color: var(--gg-primary); color: var(--gg-primary); font-weight: 600; }
.settle-actions { margin-top: 16px; }
</style>
@@ -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"
/>
<!-- 发布面板复用 PostSheetpost 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>
@@ -0,0 +1,168 @@
<!-- src/components-mobile/event/CreateEventSheetMobile.vue -->
<!-- 创建活动底部面板标题/描述/地点/开始/结束/人数上限 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useEventStore } from '@/stores/event'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{
show: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'created': []
}>()
const eventStore = useEventStore()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const form = ref({
title: '',
description: '',
location: '',
startTime: '',
endTime: '',
maxParticipants: 0
})
const saving = ref(false)
watch(() => props.show, (val) => {
if (val) {
// 默认开始时间为当前+1小时(取整点)
const now = new Date()
now.setHours(now.getHours() + 1, 0, 0, 0)
form.value = {
title: '',
description: '',
location: '',
startTime: toLocalInput(now),
endTime: '',
maxParticipants: 0
}
}
})
function toLocalInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function handleSubmit() {
if (!form.value.title.trim()) {
showFailToast('请输入活动标题')
return
}
if (!form.value.startTime) {
showFailToast('请设置开始时间')
return
}
const startTime = new Date(form.value.startTime).toISOString()
const endTime = form.value.endTime ? new Date(form.value.endTime).toISOString() : undefined
if (endTime && new Date(endTime) <= new Date(startTime)) {
showFailToast('结束时间必须晚于开始时间')
return
}
saving.value = true
try {
await eventStore.addEvent({
group: props.groupId,
title: form.value.title.trim(),
description: form.value.description.trim() || undefined,
location: form.value.location.trim() || undefined,
startTime,
endTime,
maxParticipants: form.value.maxParticipants > 0 ? form.value.maxParticipants : undefined
})
showSuccessToast('创建成功')
emit('created')
} catch (e: any) {
showFailToast(e.message || '创建失败')
} finally {
saving.value = false
}
}
</script>
<template>
<van-popup
v-model:show="visible"
position="bottom"
round
closeable
:style="{ height: '80%' }"
>
<div class="create-sheet">
<div class="sheet-title">发起活动</div>
<van-cell-group inset>
<van-field v-model="form.title" label="标题" placeholder="活动名称" required maxlength="100" />
<van-field
v-model="form.description"
type="textarea"
label="描述"
placeholder="活动详情(可选)"
rows="3"
autosize
/>
<van-field v-model="form.location" label="地点" placeholder="如:语音房 / 线下某处" />
<van-field
v-model="form.startTime"
type="datetime-local"
label="开始时间"
placeholder="选择时间"
required
/>
<van-field
v-model="form.endTime"
type="datetime-local"
label="结束时间"
placeholder="可选"
/>
<van-field name="stepper" label="人数上限">
<template #input>
<van-stepper v-model="form.maxParticipants" min="0" max="200" />
</template>
</van-field>
</van-cell-group>
<div class="hint">人数上限为 0 表示不限制</div>
<div class="sheet-actions">
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
创建活动
</van-button>
</div>
</div>
</van-popup>
</template>
<style scoped>
.create-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;
}
.hint {
padding: 8px 16px;
font-size: 12px;
color: var(--gg-text-muted);
}
.sheet-actions {
padding: 16px;
margin-top: auto;
}
</style>
@@ -0,0 +1,328 @@
<!-- src/components-mobile/event/EventListMobile.vue -->
<!-- 手机端事件列表 + 展开详情RSVP + 评论+ 创建入口 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useEventStore } from '@/stores/event'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
import { EventStatusMap, RSVPTypeMap } from '@/types'
import type { Event, RSVPType } from '@/types'
import { displayName } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import CreateEventSheetMobile from './CreateEventSheetMobile.vue'
const props = defineProps<{ groupId: string }>()
const eventStore = useEventStore()
const groupStore = useGroupStore()
const upcomingEvents = computed(() => eventStore.upcomingEvents)
const pastEvents = computed(() => eventStore.pastEvents)
const expandedId = ref<string | null>(null)
const showCreate = ref(false)
let unsubEvents: (() => void) | null = null
let unsubComments: (() => void) | null = null
let unsubRSVPs: (() => void) | null = null
const currentUserId = computed(() => pb.authStore.model?.id || '')
onMounted(async () => {
await eventStore.loadEvents(props.groupId)
try {
unsubEvents = await subscribeEvents(props.groupId, async () => {
await eventStore.loadEvents(props.groupId)
if (expandedId.value) await loadDetail(expandedId.value)
})
} catch (e) { console.error(e) }
})
onUnmounted(() => {
if (unsubEvents) unsubEvents()
if (unsubComments) unsubComments()
if (unsubRSVPs) unsubRSVPs()
eventStore.clear()
})
async function loadDetail(eventId: string) {
await eventStore.loadCommentsAndRSVPs(eventId)
}
async function toggleExpand(event: Event) {
if (expandedId.value === event.id) {
expandedId.value = null
if (unsubComments) { unsubComments(); unsubComments = null }
if (unsubRSVPs) { unsubRSVPs(); unsubRSVPs = null }
} else {
expandedId.value = event.id
await loadDetail(event.id)
if (unsubComments) unsubComments()
if (unsubRSVPs) unsubRSVPs()
try {
unsubComments = await subscribeEventComments(event.id, () => loadDetail(event.id))
unsubRSVPs = await subscribeEventRSVPs(event.id, () => loadDetail(event.id))
} catch (e) { console.error(e) }
}
}
const rsvps = computed(() => eventStore.rsvps)
const comments = computed(() => eventStore.comments)
function myRsvp(eventId: string) {
if (expandedId.value !== eventId) return null
return rsvps.value.find(r => r.user === currentUserId.value) || null
}
function goingCount(eventId: string): number {
if (expandedId.value !== eventId) return 0
return rsvps.value.filter(r => r.type === 'going').length
}
async function doRsvp(event: Event, type: RSVPType) {
try {
await eventStore.rsvp(event.id, type)
showSuccessToast(`${RSVPTypeMap[type]}`)
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}
async function cancelRsvp(event: Event) {
try {
await eventStore.cancelRSVP(event.id)
showSuccessToast('已取消')
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}
// 评论
const newComment = ref('')
const commentSubmitting = ref(false)
async function submitComment(eventId: string) {
if (!newComment.value.trim()) {
showFailToast('请输入评论')
return
}
commentSubmitting.value = true
try {
await eventStore.addComment(eventId, newComment.value.trim())
newComment.value = ''
} catch (e: any) {
showFailToast(e.message || '评论失败')
} finally {
commentSubmitting.value = false
}
}
async function handleDelete(event: Event) {
showConfirmDialog({
title: '删除事件',
message: `确定要删除「${event.title}」吗?`
}).then(async () => {
try {
await eventStore.removeEvent(event.id)
showSuccessToast('已删除')
expandedId.value = null
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
function canManage(event: Event): boolean {
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (event.creator === currentUserId.value) return true
return false
}
function statusType(s: string): any {
return s === 'upcoming' ? 'primary' : s === 'ongoing' ? 'success' : 'default'
}
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
async function onCreated() {
showCreate.value = false
await eventStore.loadEvents(props.groupId)
}
</script>
<template>
<div class="event-mobile">
<div class="toolbar">
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
发起活动
</van-button>
</div>
<div v-if="upcomingEvents.length === 0 && pastEvents.length === 0" class="empty">
<van-empty description="暂无活动" image-size="100">
<van-button type="primary" size="small" round @click="showCreate = true">发起第一个活动</van-button>
</van-empty>
</div>
<!-- 即将开始 -->
<div v-if="upcomingEvents.length > 0" class="section-block">
<div class="block-title">即将开始</div>
<div v-for="event in upcomingEvents" :key="event.id" class="event-card-wrap">
<div class="event-card" @click="toggleExpand(event)">
<div class="event-card-header">
<van-tag :type="statusType(event.status)" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
<span v-if="expandedId !== event.id && goingCount(event.id) > 0" class="event-going">
{{ goingCount(event.id) }} 人参加
</span>
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">🕐 {{ formatDateTime(event.startTime) }}</div>
<div v-if="event.location" class="event-loc">📍 {{ event.location }}</div>
</div>
<!-- 展开详情 -->
<div v-if="expandedId === event.id" class="event-detail">
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
<div v-if="event.endTime" class="detail-meta">结束{{ formatDateTime(event.endTime) }}</div>
<div v-if="event.maxParticipants" class="detail-meta">人数上限{{ event.maxParticipants }}</div>
<!-- RSVP -->
<div class="detail-section">
<div class="detail-section-title">参加报名{{ goingCount(event.id) }} </div>
<div class="rsvp-row">
<template v-if="myRsvp(event.id)">
<van-tag :type="myRsvp(event.id)!.type === 'going' ? 'success' : 'primary'" size="large">
{{ RSVPTypeMap[myRsvp(event.id)!.type] }}
</van-tag>
<van-button size="mini" plain round @click="cancelRsvp(event)">取消报名</van-button>
</template>
<template v-else>
<van-button size="small" type="primary" round @click="doRsvp(event, 'going')">参加</van-button>
<van-button size="small" round @click="doRsvp(event, 'interested')">感兴趣</van-button>
<van-button size="small" round @click="doRsvp(event, 'maybe')">可能参加</van-button>
</template>
</div>
</div>
<!-- 评论 -->
<div class="detail-section">
<div class="detail-section-title">评论 ({{ comments.length }})</div>
<div class="comment-input-row">
<van-field v-model="newComment" placeholder="写评论..." class="comment-input" />
<van-button size="small" type="primary" :loading="commentSubmitting" @click="submitComment(event.id)">发送</van-button>
</div>
<div v-if="comments.length === 0" class="comment-empty">暂无评论</div>
<div v-else class="comment-list">
<div v-for="c in comments" :key="c.id" class="comment-item">
<img :src="c.expand?.user?.avatar || '/default-avatar.svg'" class="comment-avatar" alt="" />
<div class="comment-body">
<div class="comment-author">{{ displayName(c.expand?.user) }}</div>
<div class="comment-content">{{ c.content }}</div>
</div>
</div>
</div>
</div>
<!-- 管理操作 -->
<div v-if="canManage(event)" class="detail-actions">
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
</div>
</div>
</div>
</div>
<!-- 已结束 -->
<div v-if="pastEvents.length > 0" class="section-block">
<div class="block-title">已结束</div>
<div v-for="event in pastEvents" :key="event.id" class="event-card-wrap">
<div class="event-card ended" @click="toggleExpand(event)">
<div class="event-card-header">
<van-tag type="default" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">🕐 {{ formatDateTime(event.startTime) }}</div>
</div>
<div v-if="expandedId === event.id" class="event-detail">
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
<div v-if="canManage(event)" class="detail-actions">
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
</div>
</div>
</div>
</div>
<CreateEventSheetMobile v-model:show="showCreate" :group-id="props.groupId" @created="onCreated" />
</div>
</template>
<style scoped>
.event-mobile { padding: 12px; }
.toolbar { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.empty { padding: 20px 0; }
.section-block { margin-bottom: 16px; }
.block-title {
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
margin-bottom: 8px;
padding: 0 2px;
}
.event-card-wrap { margin-bottom: 10px; }
.event-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 14px;
box-shadow: var(--gg-shadow);
}
.event-card:active { opacity: 0.85; }
.event-card.ended { opacity: 0.7; }
.event-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.event-going { font-size: 12px; color: var(--gg-text-muted); margin-left: auto; }
.expand-icon { margin-left: auto; color: var(--gg-text-muted); font-size: 14px; }
.event-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
.event-time { font-size: 13px; color: var(--gg-text-secondary); margin-top: 6px; }
.event-loc { font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; }
.event-detail {
background: var(--gg-bg);
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
padding: 14px;
margin-top: -4px;
}
.detail-desc { font-size: 14px; color: var(--gg-text); line-height: 1.6; margin: 0 0 12px; white-space: pre-wrap; }
.detail-meta { font-size: 12px; color: var(--gg-text-muted); margin-bottom: 4px; }
.detail-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
.detail-section-title { font-size: 13px; font-weight: 600; color: var(--gg-text-secondary); margin-bottom: 10px; }
.rsvp-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.comment-input-row { display: flex; gap: 8px; align-items: center; }
.comment-input { flex: 1; background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 4px 10px; }
.comment-empty { font-size: 12px; color: var(--gg-text-muted); padding: 12px 0; text-align: center; }
.comment-list { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.comment-item { display: flex; gap: 8px; }
.comment-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.comment-body { flex: 1; min-width: 0; }
.comment-author { font-size: 12px; font-weight: 600; color: var(--gg-text); }
.comment-content { font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; word-break: break-word; }
.detail-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
</style>
@@ -0,0 +1,228 @@
<!-- src/components-mobile/game/AddGameSheetMobile.vue -->
<!-- 添加游戏表单绑定到当前群组名称/别名/平台/标签/封面 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { addGame, getAllPlatforms } from '@/api/games'
import type { GamePlatform } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{
show: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'saved': []
}>()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const platforms = getAllPlatforms()
const form = ref({
name: '',
aliases: '',
platform: '' as GamePlatform | '',
tags: ''
})
const coverFile = ref<File | null>(null)
const coverPreview = ref('')
const saving = ref(false)
watch(() => props.show, (val) => {
if (val) {
form.value = { name: '', aliases: '', platform: '', tags: '' }
coverFile.value = null
coverPreview.value = ''
}
})
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files?.[0]) {
coverFile.value = input.files[0]
coverPreview.value = URL.createObjectURL(input.files[0])
}
}
function clearCover() {
coverFile.value = null
coverPreview.value = ''
}
async function handleSubmit() {
if (!form.value.name.trim()) {
showFailToast('请输入游戏名称')
return
}
try {
saving.value = true
const aliases = form.value.aliases.split(',').map(t => t.trim()).filter(Boolean)
const tags = form.value.tags.split(',').map(t => t.trim()).filter(Boolean)
await addGame(props.groupId, {
name: form.value.name.trim(),
aliases: aliases.length > 0 ? aliases : undefined,
platform: form.value.platform || undefined,
tags: tags.length > 0 ? tags : undefined,
coverFile: coverFile.value || undefined
})
showSuccessToast('添加成功')
emit('saved')
} catch (e: any) {
showFailToast(e.message || '添加失败')
} finally {
saving.value = false
}
}
</script>
<template>
<van-popup
v-model:show="visible"
position="bottom"
round
closeable
:style="{ height: '80%' }"
>
<div class="add-sheet">
<div class="sheet-title">添加游戏</div>
<van-cell-group inset>
<van-field v-model="form.name" label="名称" placeholder="游戏名称" required />
<van-field v-model="form.aliases" label="别名" placeholder="逗号分隔,如 LOL,英雄联盟" />
<van-field name="platform" label="平台">
<template #input>
<select v-model="form.platform" class="native-select">
<option value="">不指定</option>
<option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
</select>
</template>
</van-field>
<van-field v-model="form.tags" label="标签" placeholder="逗号分隔,如 MOBA,竞技" />
</van-cell-group>
<!-- 封面上传 -->
<div class="cover-field">
<div class="field-label">封面图</div>
<div class="cover-upload">
<div v-if="coverPreview" class="cover-preview">
<img :src="coverPreview" alt="" />
<button class="cover-clear" @click="clearCover">
<van-icon name="cross" />
</button>
</div>
<label v-else class="cover-picker">
<van-icon name="photograph" size="28" />
<span>上传封面</span>
<input type="file" accept="image/*" class="file-input" @change="onFileChange" />
</label>
</div>
</div>
<div class="sheet-actions">
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
添加游戏
</van-button>
</div>
</div>
</van-popup>
</template>
<style scoped>
.add-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%;
}
.cover-field {
padding: 16px;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--gg-text-secondary);
margin-bottom: 8px;
}
.cover-upload {
display: flex;
}
.cover-preview {
position: relative;
width: 90px;
height: 120px;
border-radius: var(--gg-radius-sm);
overflow: hidden;
border: 1px solid var(--gg-border);
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-clear {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.cover-picker {
width: 90px;
height: 120px;
border: 1px dashed var(--gg-border);
border-radius: var(--gg-radius-sm);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--gg-text-muted);
font-size: 12px;
cursor: pointer;
}
.file-input {
display: none;
}
.sheet-actions {
padding: 20px 16px 0;
margin-top: auto;
}
</style>
@@ -0,0 +1,553 @@
<!-- src/components-mobile/game/GameDetailSheetMobile.vue -->
<!-- 游戏详情底部面板封面/名称/别名/标签/收藏/编辑/删除/快速组队 + 评论评分 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Game, GamePlatform, GameComment } from '@/types'
import {
toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms,
getGameComments, addComment
} from '@/api/games'
import { useGroupStore } from '@/stores/group'
import { createTeamSession } from '@/api/sessions'
import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{
show: boolean
game: Game | null
groupId?: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'deleted': []
}>()
const router = useRouter()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const platforms = getAllPlatforms()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const favorited = ref(false)
const editing = ref(false)
const editName = ref('')
const editAliases = ref('')
const editPlatform = ref<GamePlatform | ''>('')
const editTags = ref('')
const saving = ref(false)
// 评论
const comments = ref<GameComment[]>([])
const newContent = ref('')
const newRating = ref(0)
const commentLoading = ref(false)
const commentSubmitting = ref(false)
const canModify = computed(() => {
if (!props.game) return false
const userId = pb.authStore.model?.id
if (!userId) return false
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (props.game.addedBy === userId) return true
return false
})
// 打开时初始化
watch(() => props.show, async (val) => {
if (val && props.game) {
editing.value = false
try {
favorited.value = await isFavorite(props.game.id)
} catch { /* ignore */ }
await loadComments()
}
})
async function loadComments() {
if (!props.game) return
try {
commentLoading.value = true
comments.value = await getGameComments(props.game.id)
} catch { /* ignore */ } finally {
commentLoading.value = false
}
}
function startEdit() {
if (!props.game) return
editName.value = props.game.name
editAliases.value = (props.game.aliases || []).join(', ')
editPlatform.value = props.game.platform || ''
editTags.value = (props.game.tags || []).join(', ')
editing.value = true
}
async function handleSave() {
if (!props.game || !editName.value.trim()) {
showFailToast('请输入游戏名称')
return
}
try {
saving.value = true
const aliases = editAliases.value.split(',').map(t => t.trim()).filter(Boolean)
const tags = editTags.value.split(',').map(t => t.trim()).filter(Boolean)
await updateGame(props.game.id, {
name: editName.value.trim(),
aliases: aliases.length > 0 ? aliases : undefined,
platform: editPlatform.value || undefined,
tags: tags.length > 0 ? tags : undefined
})
showSuccessToast('修改成功')
editing.value = false
emit('deleted') // 触发父组件重新加载
} catch (e: any) {
showFailToast(e.message || '修改失败')
} finally {
saving.value = false
}
}
async function handleFavorite() {
if (!props.game) return
try {
favorited.value = await toggleFavorite(props.game.id)
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}
async function handleDelete() {
if (!props.game) return
const game = props.game
showConfirmDialog({
title: '删除游戏',
message: `确定要删除「${game.name}」吗?`
}).then(async () => {
try {
await deleteGame(game.id)
showSuccessToast('删除成功')
visible.value = false
emit('deleted')
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
async function handleCreateTeam() {
if (!props.game || !props.groupId) return
try {
const me = pb.authStore.model?.id
if (!me) return
const session = await createTeamSession({
sourceGroup: props.groupId,
name: `${props.game.name}小队`,
gameName: props.game.name,
members: [me]
})
await teamStore.loadActiveSession()
showSuccessToast('已发起组队')
visible.value = false
router.push({
name: 'VoiceRoom',
params: { groupId: props.groupId, sessionId: session.id }
})
} catch (e: any) {
showFailToast(e.message || '组队失败')
}
}
async function handleSubmitComment() {
if (!props.game) return
if (!newContent.value.trim()) {
showFailToast('请输入评论内容')
return
}
try {
commentSubmitting.value = true
await addComment(props.game.id, newContent.value.trim(), newRating.value || undefined)
newContent.value = ''
newRating.value = 0
await loadComments()
showSuccessToast('评论成功')
} catch (e: any) {
showFailToast(e.message || '评论失败')
} finally {
commentSubmitting.value = false
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
</script>
<template>
<van-popup
v-model:show="visible"
position="bottom"
round
closeable
:style="{ height: '85%' }"
>
<div v-if="game" class="detail-sheet">
<!-- 编辑模式 -->
<template v-if="editing">
<div class="sheet-title">编辑游戏</div>
<van-cell-group inset>
<van-field v-model="editName" label="名称" placeholder="游戏名称" required />
<van-field v-model="editAliases" label="别名" placeholder="逗号分隔" />
<van-field name="platform" label="平台">
<template #input>
<select v-model="editPlatform" class="native-select">
<option value="">不指定</option>
<option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
</select>
</template>
</van-field>
<van-field v-model="editTags" label="标签" placeholder="逗号分隔" />
</van-cell-group>
<div class="edit-actions">
<van-button block round @click="editing = false">取消</van-button>
<van-button type="primary" block round :loading="saving" @click="handleSave">保存</van-button>
</div>
</template>
<!-- 详情模式 -->
<template v-else>
<div class="detail-cover-wrap">
<img
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
:alt="game.name"
class="detail-cover"
/>
</div>
<h2 class="detail-name">{{ game.name }}</h2>
<div v-if="game.aliases?.length" class="detail-aliases">
<span v-for="alias in game.aliases" :key="alias" class="alias-tag">{{ alias }}</span>
</div>
<div class="detail-meta">
<van-tag v-if="game.platform" type="primary" size="medium">{{ game.platform }}</van-tag>
<span class="popularity">🔥 {{ game.popularCount }} 人游玩</span>
</div>
<div v-if="game.tags?.length" class="detail-tags">
<van-tag v-for="tag in game.tags" :key="tag" plain size="medium">{{ tag }}</van-tag>
</div>
<!-- 操作按钮 -->
<div class="detail-actions">
<van-button
:type="favorited ? 'warning' : 'default'"
size="small"
round
:icon="favorited ? 'star' : 'star-o'"
@click="handleFavorite"
>{{ favorited ? '已收藏' : '收藏' }}</van-button>
<van-button v-if="canModify" size="small" round icon="edit" @click="startEdit">编辑</van-button>
<van-button v-if="canModify" size="small" round plain type="danger" icon="delete-o" @click="handleDelete">删除</van-button>
</div>
<van-button type="primary" block round class="team-btn" @click="handleCreateTeam">
快速组队
</van-button>
<!-- 评论区 -->
<div class="comments-section">
<div class="comments-title">评论 ({{ comments.length }})</div>
<!-- 评论输入 -->
<div class="comment-form">
<div class="rating-row">
<span class="rating-label">评分</span>
<div class="stars">
<van-icon
v-for="star in 5"
:key="star"
name="star"
:class="{ active: star <= newRating }"
size="18"
@click="newRating = star"
/>
</div>
</div>
<van-field
v-model="newContent"
type="textarea"
placeholder="写下你的评价..."
rows="2"
autosize
class="comment-input"
/>
<van-button
type="primary"
size="small"
round
:loading="commentSubmitting"
@click="handleSubmitComment"
>发表</van-button>
</div>
<!-- 评论列表 -->
<div v-if="commentLoading" class="comment-loading">
<van-loading size="20px">加载中...</van-loading>
</div>
<div v-else-if="comments.length === 0" class="comment-empty">
暂无评论快来抢沙发
</div>
<div v-else class="comment-list">
<div v-for="c in comments" :key="c.id" class="comment-item">
<img
:src="c.expand?.author?.avatar || '/default-avatar.svg'"
class="comment-avatar"
alt=""
/>
<div class="comment-body">
<div class="comment-head">
<span class="comment-author">{{ c.expand?.author?.name || c.expand?.author?.username || '匿名' }}</span>
<span v-if="c.rating" class="comment-rating">
<van-icon v-for="s in c.rating" :key="s" name="star" class="star-filled" size="12" />
</span>
</div>
<div class="comment-content">{{ c.content }}</div>
<div class="comment-time">{{ formatDate(c.created) }}</div>
</div>
</div>
</div>
</div>
</template>
</div>
</van-popup>
</template>
<style scoped>
.detail-sheet {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 16px 0 24px;
height: 100%;
overflow-y: auto;
}
.sheet-title {
font-size: 16px;
font-weight: 600;
padding: 4px 0 8px;
}
.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%;
}
.edit-actions {
display: flex;
gap: 10px;
width: 100%;
padding: 0 16px;
}
.detail-cover-wrap {
width: 160px;
height: 210px;
border-radius: var(--gg-radius-md);
overflow: hidden;
background: var(--gg-bg-elevated);
border: 1px solid var(--gg-border);
}
.detail-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-name {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--gg-text);
text-align: center;
padding: 0 16px;
}
.detail-aliases {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
padding: 0 16px;
}
.alias-tag {
padding: 3px 10px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.detail-meta {
display: flex;
align-items: center;
gap: 12px;
}
.popularity {
font-size: 13px;
color: var(--gg-text-secondary);
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
padding: 0 16px;
}
.detail-actions {
display: flex;
gap: 10px;
}
.team-btn {
width: calc(100% - 32px);
}
/* 评论区 */
.comments-section {
width: 100%;
padding: 8px 16px 0;
border-top: 8px solid var(--gg-bg);
}
.comments-title {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
margin-bottom: 12px;
}
.comment-form {
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.rating-row {
display: flex;
align-items: center;
gap: 8px;
}
.rating-label {
font-size: 13px;
color: var(--gg-text-secondary);
}
.stars {
display: flex;
gap: 2px;
}
.stars .van-icon {
color: var(--gg-text-muted);
}
.stars .van-icon.active {
color: var(--gg-warning);
}
.comment-input {
background: transparent;
padding: 0;
}
.comment-form .van-button {
align-self: flex-end;
}
.comment-loading, .comment-empty {
text-align: center;
padding: 20px;
font-size: 13px;
color: var(--gg-text-muted);
}
.comment-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.comment-item {
display: flex;
gap: 10px;
}
.comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.comment-body {
flex: 1;
min-width: 0;
}
.comment-head {
display: flex;
align-items: center;
gap: 8px;
}
.comment-author {
font-size: 13px;
font-weight: 600;
color: var(--gg-text);
}
.comment-rating .star-filled {
color: var(--gg-warning);
}
.comment-content {
font-size: 13px;
color: var(--gg-text);
line-height: 1.5;
margin-top: 4px;
word-break: break-word;
}
.comment-time {
font-size: 11px;
color: var(--gg-text-muted);
margin-top: 4px;
}
</style>
@@ -0,0 +1,152 @@
<!-- src/components-mobile/game/ImportGamesSheetMobile.vue -->
<!-- 批量导入游戏每行一个游戏名可含平台/标签绑定到当前群组 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { importGames } from '@/api/games'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{
show: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'imported': []
}>()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const gamesText = ref('')
const importing = ref(false)
const result = ref<{ success: number; failed: number } | null>(null)
watch(() => props.show, (val) => {
if (val) {
gamesText.value = ''
result.value = null
}
})
async function handleImport() {
const lines = gamesText.value.split('\n').map(s => s.trim()).filter(Boolean)
if (lines.length === 0) {
showFailToast('请输入游戏名称(每行一个)')
return
}
try {
importing.value = true
result.value = null
// 每行解析:支持 "游戏名" 或 "游戏名 | 平台" 或 "游戏名 | 平台 | 标签1,标签2"
const games = lines.map(line => {
const parts = line.split('|').map(s => s.trim())
const name = parts[0]
const platform = parts[1] || undefined
const tags = parts[2] ? parts[2].split(',').map(t => t.trim()).filter(Boolean) : undefined
return { name, platform: platform as any, tags }
})
const results = await importGames(props.groupId, games)
const success = results.filter(r => r.success).length
const failed = results.length - success
result.value = { success, failed }
if (failed === 0) {
showSuccessToast(`成功导入 ${success} 个游戏`)
emit('imported')
} else {
showFailToast(`成功 ${success},失败 ${failed}`)
}
} catch (e: any) {
showFailToast(e.message || '导入失败')
} finally {
importing.value = false
}
}
</script>
<template>
<van-popup
v-model:show="visible"
position="bottom"
round
closeable
:style="{ height: '70%' }"
>
<div class="import-sheet">
<div class="sheet-title">批量导入游戏</div>
<div class="hint">
每行输入一个游戏支持以下格式
<code>游戏名</code>
<code>游戏名 | 平台</code>
<code>游戏名 | 平台 | 标签1,标签2</code>
</div>
<van-cell-group inset>
<van-field
v-model="gamesText"
type="textarea"
placeholder="每行一个游戏,可用 | 分隔平台和标签"
rows="8"
autosize
/>
</van-cell-group>
<div v-if="result" class="result-bar">
<van-tag type="success">成功 {{ result.success }}</van-tag>
<van-tag v-if="result.failed > 0" type="danger">失败 {{ result.failed }}</van-tag>
</div>
<div class="sheet-actions">
<van-button type="primary" block round :loading="importing" @click="handleImport">
开始导入
</van-button>
</div>
</div>
</van-popup>
</template>
<style scoped>
.import-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 12px;
}
.hint {
padding: 0 16px 12px;
font-size: 12px;
color: var(--gg-text-muted);
line-height: 1.6;
}
.hint code {
background: var(--gg-bg-elevated);
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
color: var(--gg-primary);
}
.result-bar {
display: flex;
gap: 8px;
padding: 12px 16px 0;
}
.sheet-actions {
padding: 20px 16px 0;
margin-top: auto;
}
</style>
@@ -0,0 +1,400 @@
<!-- src/components-mobile/group/ActivityFeedMobile.vue -->
<!-- 群组动态当前组队状态 + 空闲成员 + 所有成员按状态分组 -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { createTeamSession } from '@/api/sessions'
import { sendBulkInvitations } from '@/api/invitations'
import type { User } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const userStore = useUserStore()
const router = useRouter()
const members = computed(() => groupStore.currentMembers)
// 按状态分组
const membersByStatus = computed(() => {
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
for (const m of members.value) {
const s = (m.status || 'idle') as keyof typeof groups
if (groups[s]) groups[s].push(m)
else groups.away.push(m)
}
return groups
})
const statusLabels: Record<string, string> = {
idle: '空闲',
in_team: '组队中',
working: '工作中',
away: '离开'
}
const statusColors: Record<string, string> = {
idle: 'var(--gg-success)',
in_team: 'var(--gg-info)',
working: 'var(--gg-danger)',
away: 'var(--gg-text-muted)'
}
// 发起组队
const showCreateTeam = ref(false)
const teamName = ref('')
const teamGame = ref('')
const selectedMembers = ref<string[]>([])
const teamLoading = ref(false)
const idleMembers = computed(() => membersByStatus.value.idle)
async function handleCreateTeam() {
if (!teamGame.value.trim()) {
showFailToast('请输入游戏名称')
return
}
teamLoading.value = true
try {
const me = userStore.userId
const memberIds = [...new Set([me, ...selectedMembers.value])]
const session = await createTeamSession({
sourceGroup: props.groupId,
name: teamName.value.trim() || `${teamGame.value}小队`,
gameName: teamGame.value.trim(),
members: memberIds
})
// 邀请选中的成员
const toInvite = selectedMembers.value.filter(id => id !== me)
if (toInvite.length > 0 && session?.id) {
await sendBulkInvitations(toInvite, session.id)
}
await teamStore.loadActiveSession()
showCreateTeam.value = false
teamName.value = ''
teamGame.value = ''
selectedMembers.value = []
showSuccessToast('组队成功')
} catch (e: any) {
showFailToast(e.message || '组队失败')
} finally {
teamLoading.value = false
}
}
function toggleMember(id: string) {
const idx = selectedMembers.value.indexOf(id)
if (idx === -1) selectedMembers.value.push(id)
else selectedMembers.value.splice(idx, 1)
}
// 当前组队状态
const hasSession = computed(() => !!teamStore.currentSession)
const sessionInThisGroup = computed(() =>
teamStore.currentSession?.sourceGroup === props.groupId
)
function goVoiceRoom() {
const s = teamStore.currentSession
if (s) {
router.push({
name: 'VoiceRoom',
params: { groupId: s.sourceGroup, sessionId: s.id }
})
}
}
</script>
<template>
<div class="activity-feed">
<!-- 当前组队卡片 -->
<div v-if="hasSession && sessionInThisGroup" class="current-team-card" @click="goVoiceRoom">
<div class="team-header">
<van-icon name="volume-o" />
<span class="team-label">进行中</span>
</div>
<div class="team-game">{{ teamStore.currentSession?.gameName }}</div>
<div class="team-meta">
{{ teamStore.currentSession?.name }} · {{ teamStore.currentSession?.members?.length || 0 }}
</div>
<div class="team-go">进入语音房 </div>
</div>
<!-- 快速组队按钮 -->
<van-button
v-else
type="primary"
block
round
icon="plus"
@click="showCreateTeam = true"
>
发起组队
</van-button>
<!-- 成员状态分组 -->
<div class="members-section">
<div
v-for="(list, status) in membersByStatus"
:key="status"
v-show="list.length > 0"
class="status-group"
>
<div class="status-group-header">
<span class="status-dot" :style="{ background: statusColors[status] }" />
<span class="status-group-label">{{ statusLabels[status] }}</span>
<span class="status-group-count">{{ list.length }}</span>
</div>
<div class="member-list">
<div v-for="m in list" :key="m.id" class="member-item">
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<div class="member-name">{{ m.name || m.username }}</div>
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 发起组队弹层 -->
<van-popup
v-model:show="showCreateTeam"
position="bottom"
round
closeable
:style="{ height: '75%' }"
>
<div class="popup-content">
<div class="popup-title">发起组队</div>
<van-cell-group inset>
<van-field v-model="teamGame" label="游戏" placeholder="玩什么游戏" required />
<van-field v-model="teamName" label="队名" placeholder="可选,默认游戏+小队" />
</van-cell-group>
<div class="invite-title">邀请成员空闲{{ idleMembers.length }}</div>
<div class="invite-list">
<div
v-for="m in idleMembers.filter(x => x.id !== userStore.userId)"
:key="m.id"
class="invite-member"
:class="{ 'invite-selected': selectedMembers.includes(m.id) }"
@click="toggleMember(m.id)"
>
<img :src="m.avatar || '/default-avatar.svg'" class="invite-avatar" alt="" />
<span class="invite-name">{{ m.name || m.username }}</span>
<van-icon
v-if="selectedMembers.includes(m.id)"
name="success"
class="invite-check"
/>
</div>
<div v-if="idleMembers.length <= 1" class="no-idle">暂无其他空闲成员</div>
</div>
<div class="popup-actions">
<van-button
type="primary"
block
round
:loading="teamLoading"
@click="handleCreateTeam"
>
开始组队
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.activity-feed {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.current-team-card {
background: linear-gradient(135deg, var(--gg-primary), var(--gg-accent));
border-radius: var(--gg-radius-lg);
padding: 16px;
color: #fff;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.25);
}
.team-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
opacity: 0.9;
}
.team-game {
font-size: 18px;
font-weight: 700;
margin-top: 6px;
}
.team-meta {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.team-go {
text-align: right;
font-size: 13px;
margin-top: 8px;
font-weight: 500;
}
.members-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.status-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 2px 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-group-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.status-group-count {
font-size: 12px;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
padding: 1px 8px;
border-radius: 10px;
}
.member-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 6px;
background: var(--gg-bg-card);
padding: 6px 10px 6px 6px;
border-radius: var(--gg-radius-sm);
box-shadow: var(--gg-shadow);
}
.member-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
min-width: 0;
}
.member-name {
font-size: 13px;
color: var(--gg-text);
}
.member-note {
font-size: 11px;
color: var(--gg-text-muted);
}
/* 弹层 */
.popup-content {
padding: 16px 0 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.popup-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
color: var(--gg-text);
}
.invite-title {
padding: 16px 16px 8px;
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.invite-list {
padding: 0 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.invite-member {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
border: 1px solid var(--gg-border);
}
.invite-member.invite-selected {
background: rgba(5, 150, 105, 0.1);
border-color: var(--gg-primary);
}
.invite-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.invite-name {
font-size: 13px;
color: var(--gg-text);
}
.invite-check {
color: var(--gg-primary);
font-size: 14px;
}
.no-idle {
font-size: 13px;
color: var(--gg-text-muted);
padding: 8px 0;
}
.popup-actions {
padding: 20px 16px 0;
}
</style>
@@ -0,0 +1,315 @@
<!-- src/components-mobile/group/MemberListMobile.vue -->
<!-- 群组成员列表按状态展示 + 群主管理移除成员审核开关入群申请 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { getGroupJoinRequests, updateGroupApproval } from '@/api/groups'
import { respondJoinRequest } from '@/api/groups'
import { pb } from '@/api/pocketbase'
import type { JoinRequest, User } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const userStore = useUserStore()
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
const isOwner = computed(() => group.value?.owner === userStore.userId)
const joinRequests = ref<JoinRequest[]>([])
onMounted(async () => {
if (isOwner.value && group.value) {
try {
joinRequests.value = await getGroupJoinRequests(group.value.id)
} catch { /* ignore */ }
}
})
// 按状态分组
const membersByStatus = computed(() => {
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
for (const m of members.value) {
const s = (m.status || 'idle') as keyof typeof groups
if (groups[s]) groups[s].push(m)
else groups.away.push(m)
}
return groups
})
const statusLabels: Record<string, string> = { idle: '空闲', in_team: '组队中', working: '工作中', away: '离开' }
const statusColors: Record<string, string> = {
idle: 'var(--gg-success)', in_team: 'var(--gg-info)',
working: 'var(--gg-danger)', away: 'var(--gg-text-muted)'
}
// 移除成员(群主)
async function removeMember(userId: string, name: string) {
if (!isOwner.value || !group.value) return
showConfirmDialog({
title: '移除成员',
message: `确定要将 ${name} 移出群组吗?`
}).then(async () => {
try {
const newMembers = group.value!.members.filter(id => id !== userId)
await pb.collection('groups').update(group.value!.id, { members: newMembers })
await groupStore.setCurrentGroup(group.value!.id)
showSuccessToast('已移除')
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}).catch(() => {})
}
// 审核开关
async function toggleApproval(val: boolean) {
if (!group.value) return
try {
await updateGroupApproval(group.value.id, val)
await groupStore.setCurrentGroup(group.value.id)
showSuccessToast(val ? '已开启审核' : '已关闭审核')
} catch (e: any) {
showFailToast('更新失败')
}
}
// 处理入群申请
const requestLoading = ref<string | null>(null)
async function handleRequest(requestId: string, status: 'approved' | 'rejected') {
requestLoading.value = requestId
try {
await respondJoinRequest(requestId, status)
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
await groupStore.setCurrentGroup(props.groupId)
showSuccessToast(status === 'approved' ? '已通过' : '已拒绝')
} catch (e: any) {
showFailToast(e.message || '操作失败')
} finally {
requestLoading.value = null
}
}
</script>
<template>
<div class="member-list-mobile">
<!-- 群主管理区 -->
<div v-if="isOwner" class="owner-panel">
<!-- 入群申请 -->
<div v-if="joinRequests.length > 0" class="requests-block">
<div class="block-title">入群申请{{ joinRequests.length }}</div>
<div
v-for="req in joinRequests"
:key="req.id"
class="request-item"
>
<img
:src="req.expand?.user?.avatar || '/default-avatar.svg'"
class="req-avatar"
alt=""
/>
<div class="req-info">
<div class="req-name">{{ req.expand?.user?.name || req.expand?.user?.username }}</div>
</div>
<div class="req-actions">
<van-button
type="primary"
size="mini"
round
:loading="requestLoading === req.id"
@click="handleRequest(req.id, 'approved')"
>通过</van-button>
<van-button
size="mini"
round
:loading="requestLoading === req.id"
@click="handleRequest(req.id, 'rejected')"
>拒绝</van-button>
</div>
</div>
</div>
<!-- 审核开关 -->
<van-cell-group inset>
<van-cell title="加入审核" center>
<template #right-icon>
<van-switch
:model-value="group?.requireApproval"
size="22px"
@update:model-value="toggleApproval"
/>
</template>
</van-cell>
</van-cell-group>
</div>
<!-- 成员分组列表 -->
<div
v-for="(list, status) in membersByStatus"
:key="status"
v-show="list.length > 0"
class="status-section"
>
<div class="status-header">
<span class="status-dot" :style="{ background: statusColors[status] }" />
<span class="status-label">{{ statusLabels[status] }}</span>
<span class="status-count">{{ list.length }}</span>
</div>
<div class="members-grid">
<div
v-for="m in list"
:key="m.id"
class="member-card"
>
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<div class="member-name">
{{ m.name || m.username }}
<van-tag v-if="m.id === group?.owner" type="warning" size="medium">群主</van-tag>
</div>
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
</div>
<van-button
v-if="isOwner && m.id !== group?.owner && m.id !== userStore.userId"
size="mini"
plain
type="danger"
@click="removeMember(m.id, m.name || m.username)"
>移除</van-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.member-list-mobile {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.owner-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.block-title {
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
margin-bottom: 8px;
padding: 0 4px;
}
.request-item {
display: flex;
align-items: center;
gap: 10px;
background: var(--gg-bg-card);
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
margin-bottom: 8px;
box-shadow: var(--gg-shadow);
}
.req-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.req-info {
flex: 1;
min-width: 0;
}
.req-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.req-actions {
display: flex;
gap: 6px;
}
.status-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.status-count {
font-size: 12px;
color: var(--gg-text-muted);
}
.members-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-card {
display: flex;
align-items: center;
gap: 10px;
background: var(--gg-bg-card);
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
box-shadow: var(--gg-shadow);
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
display: flex;
align-items: center;
gap: 4px;
}
.member-note {
font-size: 12px;
color: var(--gg-text-muted);
margin-top: 2px;
}
</style>
@@ -0,0 +1,211 @@
<!-- src/components-mobile/memory/MemoryGridMobile.vue -->
<!-- 手机端回忆九宫格 + 上传 + 全屏浏览 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { listMemories, deleteMemory, uploadMemory } from '@/api/memories'
import { pb } from '@/api/pocketbase'
import type { Memory } from '@/types'
import { showSuccessToast, showFailToast, showImagePreview, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const memories = ref<Memory[]>([])
const loading = ref(false)
async function loadMemories() {
loading.value = true
try {
const result = await listMemories(props.groupId, { limit: 100 })
memories.value = result.items
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadMemories()
})
// 文件 URL
function fileUrl(m: Memory): string {
if (!m.file) return ''
return pb.files.getUrl(m as any, m.file) as string
}
function thumbUrl(m: Memory): string {
if (!m.file) return ''
return pb.files.getUrl(m as any, m.file, { thumb: '300x300' }) as string
}
// 图片记忆列表(用于全屏预览)
const imageMemories = computed(() => memories.value.filter(m => m.fileType === 'image'))
function previewImage(index: number) {
const urls = imageMemories.value.map(m => fileUrl(m))
showImagePreview(urls, index)
}
// 上传
const showUpload = ref(false)
const uploadFiles = ref<File[]>([])
const uploadTitle = ref('')
const uploading = ref(false)
function onFileSelect(items: any) {
const arr = Array.isArray(items) ? items : [items]
uploadFiles.value = arr.map((it: any) => it.file).filter(Boolean)
}
async function handleUpload() {
if (uploadFiles.value.length === 0) {
showFailToast('请选择文件')
return
}
uploading.value = true
try {
for (const file of uploadFiles.value) {
await uploadMemory(props.groupId, file, {
title: uploadTitle.value.trim() || file.name
})
}
showSuccessToast(`上传 ${uploadFiles.value.length} 个文件成功`)
showUpload.value = false
uploadFiles.value = []
uploadTitle.value = ''
await loadMemories()
} catch (e: any) {
showFailToast(e.message || '上传失败')
} finally {
uploading.value = false
}
}
// 删除
async function handleDelete(m: Memory) {
showConfirmDialog({ title: '删除', message: '确定删除这个回忆吗?' })
.then(async () => {
try {
await deleteMemory(m.id)
showSuccessToast('已删除')
await loadMemories()
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
function fileTypeIcon(type: string): string {
const map: Record<string, string> = {
image: 'photo-o', video: 'video-o', audio: 'music-o', document: 'description', other: 'description'
}
return map[type] || 'description'
}
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="memory-mobile">
<div class="list-header">
<span class="count-text">{{ memories.length }} 个回忆</span>
<van-button type="primary" size="small" round icon="photo-o" @click="showUpload = true">
上传
</van-button>
</div>
<div v-if="loading && memories.length === 0" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="memories.length === 0" class="empty">
<van-empty description="还没有回忆" image-size="100" />
</div>
<div v-else class="memory-grid">
<div
v-for="m in memories"
:key="m.id"
class="memory-item"
>
<div class="item-thumb" @click="m.fileType === 'image' ? previewImage(imageMemories.indexOf(m)) : null">
<img
v-if="m.fileType === 'image' && m.file"
:src="thumbUrl(m)"
class="thumb-img"
alt=""
/>
<div v-else class="thumb-placeholder">
<van-icon :name="fileTypeIcon(m.fileType)" size="32" />
</div>
<van-tag v-if="m.fileType !== 'image'" class="type-tag" size="medium">
{{ m.fileType }}
</van-tag>
</div>
<div class="item-info">
<div class="item-title">{{ m.title }}</div>
<div class="item-meta">{{ timeAgo(m.created) }}</div>
</div>
<van-icon name="delete-o" class="delete-icon" @click="handleDelete(m)" />
</div>
</div>
<!-- 上传弹层 -->
<van-popup v-model:show="showUpload" position="bottom" round closeable :style="{ height: '60%' }">
<div class="popup-content">
<div class="popup-title">上传回忆</div>
<van-cell-group inset>
<van-field v-model="uploadTitle" label="标题" placeholder="可选" />
</van-cell-group>
<div class="upload-area">
<van-uploader
multiple
:after-read="onFileSelect"
:max-count="9"
accept="image/*,video/*,audio/*"
/>
</div>
<div class="popup-actions">
<van-button type="primary" block round :loading="uploading" @click="handleUpload">
上传 {{ uploadFiles.length > 0 ? `(${uploadFiles.length})` : '' }}
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.memory-mobile { padding: 12px; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.empty { padding: 30px 0; }
.list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.count-text { font-size: 13px; color: var(--gg-text-muted); }
.memory-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.memory-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); overflow: hidden; box-shadow: var(--gg-shadow); position: relative; }
.item-thumb { width: 100%; aspect-ratio: 1; background: var(--gg-bg-elevated); position: relative; }
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.thumb-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--gg-text-muted); }
.type-tag { position: absolute; top: 6px; right: 6px; }
.item-info { padding: 8px 10px; }
.item-title { font-size: 13px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
.delete-icon { position: absolute; top: 6px; left: 6px; color: var(--gg-danger); background: rgba(255,255,255,0.8); border-radius: 50%; padding: 2px; font-size: 16px; }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.upload-area { padding: 16px; }
.popup-actions { padding: 16px; }
</style>
@@ -0,0 +1,463 @@
<!-- src/components-mobile/poll/PollListMobile.vue -->
<!-- 手机端投票列表 + 详情 + 创建 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { listPolls, getPoll, getPollOptions, getPollVotes, getUserVote, votePoll, createPoll, settlePoll } from '@/api/polls'
import type { Poll, PollOption, PollVote } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const polls = ref<Poll[]>([])
const loading = ref(false)
const viewingPollId = ref<string | null>(null)
// 详情数据
const currentPoll = ref<Poll | null>(null)
const options = ref<PollOption[]>([])
const votes = ref<PollVote[]>([])
const userVote = ref<PollVote | null>(null)
const detailLoading = ref(false)
let unsub: (() => void) | null = null
async function loadPolls() {
loading.value = true
try {
polls.value = await listPolls(props.groupId)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadPolls()
})
onUnmounted(() => {
if (unsub) { (unsub as () => void)() }
})
async function viewPoll(pollId: string) {
viewingPollId.value = pollId
detailLoading.value = true
try {
const [poll, opts, allVotes, myVote] = await Promise.all([
getPoll(pollId),
getPollOptions(pollId),
getPollVotes(pollId),
getUserVote(pollId)
])
currentPoll.value = poll
options.value = opts
votes.value = allVotes
userVote.value = myVote
} catch (e) {
showFailToast('加载失败')
} finally {
detailLoading.value = false
}
}
function backToList() {
viewingPollId.value = null
currentPoll.value = null
loadPolls()
}
// 每个选项的票数
function optionVoteCount(optionId: string): number {
return votes.value.filter(v => v.option === optionId).length
}
function totalVotes(): number {
return votes.value.length || 1
}
async function doVote(optionId: string) {
if (!currentPoll.value || currentPoll.value.status === 'settled') return
if (userVote.value) {
showFailToast('你已经投过票了')
return
}
try {
await votePoll(currentPoll.value.id, optionId)
showSuccessToast('投票成功')
await viewPoll(currentPoll.value.id)
} catch (e: any) {
showFailToast(e.message || '投票失败')
}
}
async function doSettle() {
if (!currentPoll.value) return
showConfirmDialog({
title: '结算投票',
message: '结算后将结束投票,确定吗?'
}).then(async () => {
try {
await settlePoll(currentPoll.value!.id)
showSuccessToast('已结算')
await viewPoll(currentPoll.value!.id)
} catch (e: any) {
showFailToast(e.message || '结算失败')
}
}).catch(() => {})
}
// 创建投票
const showCreate = ref(false)
const createForm = ref({
title: '',
type: 'option' as 'option' | 'rollcall',
anonymous: false,
deadline: '',
optionsText: ''
})
const createLoading = ref(false)
async function handleCreate() {
if (!createForm.value.title.trim()) {
showFailToast('请输入标题')
return
}
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
if (opts.length < 2) {
showFailToast('至少输入 2 个选项(每行一个)')
return
}
createLoading.value = true
try {
await createPoll({
group: props.groupId,
title: createForm.value.title.trim(),
type: createForm.value.type,
anonymous: createForm.value.anonymous,
deadline: createForm.value.deadline || undefined,
options: opts
})
showSuccessToast('创建成功')
showCreate.value = false
createForm.value = { title: '', type: 'option', anonymous: false, deadline: '', optionsText: '' }
await loadPolls()
} catch (e: any) {
showFailToast(e.message || '创建失败')
} finally {
createLoading.value = false
}
}
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="poll-mobile">
<!-- 详情视图 -->
<div v-if="viewingPollId && currentPoll" class="poll-detail">
<van-nav-bar
:title="currentPoll.title"
left-arrow
@click-left="backToList"
/>
<div v-if="detailLoading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else class="detail-content">
<div class="detail-header">
<van-tag :type="currentPoll.status === 'settled' ? 'default' : 'success'">
{{ currentPoll.status === 'settled' ? '已结算' : '进行中' }}
</van-tag>
<span class="detail-meta">{{ votes.length }} 人参与 · {{ timeAgo(currentPoll.created) }}</span>
</div>
<div class="options-list">
<div
v-for="opt in options"
:key="opt.id"
class="option-item"
:class="{
'option-voted': userVote?.option === opt.id,
'option-disabled': currentPoll.status === 'settled' || !!userVote
}"
@click="doVote(opt.id)"
>
<div class="option-bar">
<div
class="option-bar-fill"
:style="{ width: `${(optionVoteCount(opt.id) / totalVotes()) * 100}%` }"
/>
</div>
<div class="option-content">
<span class="option-text">{{ opt.content }}</span>
<span class="option-count">{{ optionVoteCount(opt.id) }} </span>
</div>
</div>
</div>
<div v-if="currentPoll.status === 'active'" class="detail-actions">
<van-button
type="warning"
block
round
plain
@click="doSettle"
>
结算投票
</van-button>
</div>
</div>
</div>
<!-- 列表视图 -->
<template v-else>
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
发起投票
</van-button>
</div>
<van-pull-refresh v-model="loading" @refresh="loadPolls">
<div v-if="polls.length === 0 && !loading" class="empty">
<van-empty description="暂无投票" image-size="100" />
</div>
<div class="poll-list">
<div
v-for="poll in polls"
:key="poll.id"
class="poll-card"
@click="viewPoll(poll.id)"
>
<div class="poll-card-header">
<van-tag :type="poll.status === 'settled' ? 'default' : 'success'" size="medium">
{{ poll.status === 'settled' ? '已结算' : '进行中' }}
</van-tag>
<span class="poll-time">{{ timeAgo(poll.created) }}</span>
</div>
<div class="poll-title">{{ poll.title }}</div>
<div class="poll-card-footer">
<span class="poll-type">{{ poll.type === 'rollcall' ? '点名' : '选项投票' }}</span>
<van-icon name="arrow" />
</div>
</div>
</div>
</van-pull-refresh>
</template>
<!-- 创建弹层 -->
<van-popup
v-model:show="showCreate"
position="bottom"
round
closeable
:style="{ height: '80%' }"
>
<div class="popup-content">
<div class="popup-title">发起投票</div>
<van-cell-group inset>
<van-field v-model="createForm.title" label="标题" placeholder="投票主题" required />
<van-field name="radio" label="类型">
<template #input>
<van-radio-group v-model="createForm.type" direction="horizontal">
<van-radio name="option">选项投票</van-radio>
<van-radio name="rollcall">点名</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-model="createForm.optionsText"
type="textarea"
label="选项"
placeholder="每行输入一个选项"
rows="4"
/>
<van-field
v-model="createForm.deadline"
label="截止时间"
placeholder="留空则不截止"
/>
<van-cell title="匿名投票" center>
<template #right-icon>
<van-switch v-model="createForm.anonymous" size="22px" />
</template>
</van-cell>
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
创建
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.poll-mobile {
padding: 12px;
}
.loading-box {
display: flex;
justify-content: center;
padding: 40px;
}
.empty {
padding: 30px 0;
}
.list-header {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.poll-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.poll-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 14px;
box-shadow: var(--gg-shadow);
}
.poll-card:active {
opacity: 0.85;
}
.poll-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.poll-time {
font-size: 12px;
color: var(--gg-text-muted);
}
.poll-title {
font-size: 16px;
font-weight: 600;
color: var(--gg-text);
}
.poll-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
color: var(--gg-text-muted);
font-size: 13px;
}
/* 详情 */
.detail-content {
padding: 12px;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.detail-meta {
font-size: 12px;
color: var(--gg-text-muted);
}
.options-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-item {
position: relative;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-sm);
overflow: hidden;
border: 1px solid var(--gg-border);
}
.option-voted {
border-color: var(--gg-primary);
}
.option-bar {
position: absolute;
inset: 0;
}
.option-bar-fill {
height: 100%;
background: rgba(5, 150, 105, 0.08);
transition: width 0.3s;
}
.option-content {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
}
.option-text {
font-size: 14px;
color: var(--gg-text);
font-weight: 500;
}
.option-count {
font-size: 13px;
color: var(--gg-primary);
font-weight: 600;
}
.option-disabled {
opacity: 0.9;
}
.detail-actions {
margin-top: 20px;
}
/* 弹层 */
.popup-content {
padding: 16px 0 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.popup-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
}
.popup-actions {
padding: 20px 16px 0;
}
</style>
@@ -0,0 +1,109 @@
<!-- src/components-mobile/stats/StatsPanelMobile.vue -->
<!-- 手机端统计简化版积分排行 + 群组数据 -->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getGroupMemberRanking } from '@/api/points'
import { getGroupGames } from '@/api/games'
import { useGroupStore } from '@/stores/group'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const ranking = ref<{ userId: string; points: number; name?: string }[]>([])
const gameCount = ref(0)
const loading = ref(false)
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
onMounted(async () => {
loading.value = true
try {
const [rank, games] = await Promise.all([
getGroupMemberRanking(props.groupId, 20),
getGroupGames(props.groupId, { limit: 1 })
])
ranking.value = rank
gameCount.value = games.total
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
})
// 排名样式
function rankColor(index: number): string {
if (index === 0) return '#f59e0b'
if (index === 1) return '#94a3b8'
if (index === 2) return '#d97706'
return 'var(--gg-text-muted)'
}
</script>
<template>
<div class="stats-mobile">
<div v-if="loading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<template v-else>
<!-- 概览卡片 -->
<div class="overview-card">
<div class="overview-item">
<div class="overview-num">{{ members.length }}</div>
<div class="overview-label">成员</div>
</div>
<div class="overview-item">
<div class="overview-num">{{ gameCount }}</div>
<div class="overview-label">游戏</div>
</div>
<div class="overview-item">
<div class="overview-num">{{ group?.maxMembers || '-' }}</div>
<div class="overview-label">上限</div>
</div>
</div>
<!-- 积分排行 -->
<div class="section">
<div class="section-title">积分排行</div>
<div v-if="ranking.length === 0" class="empty-row">暂无数据</div>
<div class="rank-list">
<div
v-for="(item, idx) in ranking"
:key="item.userId"
class="rank-item"
>
<div class="rank-num" :style="{ color: rankColor(idx) }">{{ idx + 1 }}</div>
<div class="rank-info">
<div class="rank-name">{{ item.name || '玩家' }}</div>
</div>
<div class="rank-points">{{ item.points }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.stats-mobile { padding: 12px; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.overview-card { display: flex; background: var(--gg-bg-card); border-radius: var(--gg-radius-lg); padding: 20px; box-shadow: var(--gg-shadow); margin-bottom: 16px; }
.overview-item { flex: 1; display: flex; flex-direction: column; align-items: center; }
.overview-num { font-size: 24px; font-weight: 700; color: var(--gg-primary); }
.overview-label { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
.section { margin-bottom: 16px; }
.section-title { font-size: 15px; font-weight: 600; color: var(--gg-text); margin-bottom: 10px; }
.empty-row { text-align: center; padding: 20px; color: var(--gg-text-muted); font-size: 13px; }
.rank-list { display: flex; flex-direction: column; gap: 8px; }
.rank-item { display: flex; align-items: center; gap: 12px; background: var(--gg-bg-card); padding: 12px 14px; border-radius: var(--gg-radius-sm); box-shadow: var(--gg-shadow); }
.rank-num { font-size: 18px; font-weight: 700; width: 28px; text-align: center; }
.rank-info { flex: 1; min-width: 0; }
.rank-name { font-size: 14px; color: var(--gg-text); }
.rank-points { font-size: 15px; font-weight: 600; color: var(--gg-primary); }
</style>
+4
View File
@@ -3,7 +3,10 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Vant from 'vant'
import 'vant/lib/index.css'
import './assets/design.css'
import './assets/mobile.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
@@ -19,5 +22,6 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.use(Vant)
app.mount('#app')
+149
View File
@@ -0,0 +1,149 @@
<!-- src/mobile/MobileLayout.vue -->
<!-- 手机端主布局顶部栏 + 底部 Tab + 内容区 -->
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useNotificationStore } from '@/stores/notification'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const notificationStore = useNotificationStore()
// 与桌面 Layout 相同的初始化
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await userStore.initUser()
await groupStore.loadGroups()
await teamStore.loadActiveSession()
await notificationStore.loadPendingInvitations()
await notificationStore.startListening()
refreshTimer = setInterval(async () => {
await groupStore.loadGroups()
await teamStore.loadActiveSession()
}, 30000)
})
onUnmounted(() => {
notificationStore.stopListening()
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
// 当前激活的底部 Tab(根据路由匹配)
const activeTab = computed(() => {
if (route.path.startsWith('/mobile-groups')) return 'groups'
if (route.path.startsWith('/games')) return 'games'
if (route.path.startsWith('/mobile-notifications')) return 'notifications'
if (route.path.startsWith('/profile')) return 'profile'
return 'home'
})
// 顶部栏标题
const pageTitle = computed(() => {
const titles: Record<string, string> = {
home: 'Game Group',
groups: '我的群组',
games: '游戏库',
notifications: '通知',
profile: '我的'
}
// 群组详情页显示群组名
if (route.name === 'GroupView' && groupStore.currentGroup) {
return groupStore.currentGroup.name
}
return titles[activeTab.value] || 'Game Group'
})
// 顶部栏是否显示返回按钮(非 Tab 根页面显示)
const showBack = computed(() => {
return !['home', 'groups', 'games', 'notifications', 'profile'].includes(activeTab.value)
})
// 底部 Tab 切换
function onTabChange(name: string | number) {
const routes: Record<string, string> = {
home: '/',
groups: '/mobile-groups',
games: '/games',
notifications: '/mobile-notifications',
profile: '/profile'
}
const target = routes[String(name)]
if (target && route.path !== target) {
router.push(target)
}
}
// 返回上一页
function onBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
// 通知未读数
const unreadCount = computed(() => notificationStore.unreadCount)
function goNotifications() {
router.push('/mobile-notifications')
}
</script>
<template>
<div class="mobile-app">
<!-- 顶部栏 -->
<van-nav-bar
:title="pageTitle"
:left-arrow="showBack"
fixed
placeholder
@click-left="onBack"
>
<template #right>
<van-badge :content="unreadCount > 0 ? unreadCount : ''" :show-zero="false">
<van-icon name="bell" size="22" @click="goNotifications" />
</van-badge>
</template>
</van-nav-bar>
<!-- 内容区 -->
<main class="mobile-content">
<router-view />
</main>
<!-- 底部 Tab -->
<van-tabbar v-model="activeTab" @change="onTabChange" placeholder fixed>
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item name="groups" icon="friends-o">群组</van-tabbar-item>
<van-tabbar-item name="games" icon="game-o">游戏</van-tabbar-item>
<van-tabbar-item name="notifications" icon="bell">通知</van-tabbar-item>
<van-tabbar-item name="profile" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<style scoped>
.mobile-app {
min-height: 100vh;
background: var(--gg-bg);
display: flex;
flex-direction: column;
}
.mobile-content {
flex: 1;
padding-bottom: 8px;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
// src/mobile/useDevice.ts
// 设备检测:判断当前是否移动端,结果存 localStorage 避免重复检测
const STORAGE_KEY = 'device_mode'
/** UA 关键字匹配移动设备 */
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i
/** 屏幕宽度阈值(含平板竖屏) */
const MOBILE_WIDTH = 768
/**
* 原始检测:综合 UA 和屏幕宽度判断
* - UA 命中移动设备关键字 → 手机
* - 屏宽 <= 768 → 手机
* - 否则 → 桌面
*/
function detectRaw(): 'mobile' | 'desktop' {
if (typeof navigator === 'undefined') return 'desktop'
if (MOBILE_UA.test(navigator.userAgent)) return 'mobile'
if (typeof window !== 'undefined' && window.innerWidth <= MOBILE_WIDTH) return 'mobile'
return 'desktop'
}
/** 当前设备模式(读取 localStorage,无则检测并存入) */
export function getDeviceMode(): 'mobile' | 'desktop' {
if (typeof localStorage === 'undefined') return detectRaw()
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'mobile' || stored === 'desktop') return stored
const detected = detectRaw()
localStorage.setItem(STORAGE_KEY, detected)
return detected
}
/** 是否移动端(路由分流用,同步函数) */
export function isMobile(): boolean {
return getDeviceMode() === 'mobile'
}
/**
* 手动切换设备模式(设置页"切换到桌面版/手机版"用)
* 切换后需整页刷新以重新走路由解析
*/
export function setDeviceMode(mode: 'mobile' | 'desktop') {
localStorage.setItem(STORAGE_KEY, mode)
}
/** 重置为自动检测(登出时调用,避免下个用户沿用上个用户的偏好) */
export function resetDeviceMode() {
localStorage.removeItem(STORAGE_KEY)
}
+85 -15
View File
@@ -2,97 +2,167 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { isAuthenticated } from '@/api/pocketbase'
import { isMobile } from '@/mobile/useDevice'
// 动态选择布局:手机端用 MobileLayout,桌面端用 Layout
const LayoutComponent = () =>
isMobile()
? import('@/mobile/MobileLayout.vue')
: import('@/views/Layout.vue')
// 动态选择视图:同一路由名,根据设备加载桌面/手机视图
function view(desktop: () => Promise<any>, mobile: () => Promise<any>) {
return isMobile() ? mobile : desktop
}
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
component: view(
() => import('@/views/Login.vue'),
() => import('@/views-mobile/LoginMobile.vue')
),
meta: { requiresGuest: true }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/Register.vue'),
component: view(
() => import('@/views/Register.vue'),
() => import('@/views-mobile/RegisterMobile.vue')
),
meta: { requiresGuest: true }
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
component: LayoutComponent,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/Home.vue')
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/HomeMobile.vue')
)
},
{
path: 'mobile-groups',
name: 'MobileGroups',
component: view(
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
() => import('@/views-mobile/GroupsMobile.vue')
)
},
{
path: 'mobile-notifications',
name: 'MobileNotifications',
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/NotificationsMobile.vue')
)
},
{
path: 'group/:id',
name: 'GroupView',
component: () => import('@/views/GroupView.vue'),
component: view(
() => import('@/views/GroupView.vue'),
() => import('@/views-mobile/GroupViewMobile.vue')
),
props: true
},
{
path: 'group/:groupId/ledger',
name: 'LedgerView',
component: () => import('@/views/LedgerView.vue'),
component: view(
() => import('@/views/LedgerView.vue'),
() => import('@/views-mobile/LedgerMobile.vue')
),
props: true,
meta: { requiresAuth: true }
},
{
path: 'group/:groupId/assets',
name: 'AssetView',
component: () => import('@/views/AssetView.vue'),
component: view(
() => import('@/views/AssetView.vue'),
() => import('@/views-mobile/AssetMobile.vue')
),
props: true,
meta: { requiresAuth: true }
},
{
path: 'group/:groupId/blacklist',
name: 'BlacklistView',
component: () => import('@/views/BlacklistView.vue'),
component: view(
() => import('@/views/BlacklistView.vue'),
() => import('@/views-mobile/BlacklistMobile.vue')
),
props: true,
meta: { requiresAuth: true }
},
{
path: 'group/:groupId/voice/:sessionId',
name: 'VoiceRoom',
component: () => import('@/views/VoiceRoom.vue'),
component: view(
() => import('@/views/VoiceRoom.vue'),
() => import('@/views-mobile/VoiceRoomMobile.vue')
),
props: true,
meta: { requiresAuth: true }
},
{
path: 'games',
name: 'GamesLibrary',
component: () => import('@/views/GamesLibrary.vue')
component: view(
() => import('@/views/GamesLibrary.vue'),
() => import('@/views-mobile/GamesLibraryMobile.vue')
)
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/Profile.vue')
component: view(
() => import('@/views/Profile.vue'),
() => import('@/views-mobile/ProfileMobile.vue')
)
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue')
component: view(
() => import('@/views/Settings.vue'),
() => import('@/views-mobile/SettingsMobile.vue')
)
},
{
path: 'changelog',
name: 'Changelog',
component: () => import('@/views/Changelog.vue')
component: view(
() => import('@/views/Changelog.vue'),
() => import('@/views-mobile/ChangelogMobile.vue')
)
}
]
},
{
path: '/join/group/:groupId',
name: 'JoinGroup',
component: () => import('@/views/JoinGroupPage.vue'),
component: view(
() => import('@/views/JoinGroupPage.vue'),
() => import('@/views-mobile/JoinGroupPageMobile.vue')
),
props: true
},
{
path: '/join/team/:sessionId',
name: 'JoinTeam',
component: () => import('@/views/JoinTeamPage.vue'),
component: view(
() => import('@/views/JoinTeamPage.vue'),
() => import('@/views-mobile/JoinTeamPageMobile.vue')
),
props: true
},
{
+230
View File
@@ -0,0 +1,230 @@
<!-- src/views-mobile/AssetMobile.vue -->
<!-- 手机端资产列表 + 添加 + 转移 + 删除 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAssetStore } from '@/stores/asset'
import { useGroupStore } from '@/stores/group'
import { getAssetImageUrl } from '@/api/assets'
import { AssetTypeMap } from '@/types'
import type { AssetType } from '@/types'
import { displayName } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const route = useRoute()
const groupId = route.params.groupId as string
const assetStore = useAssetStore()
const groupStore = useGroupStore()
const assets = computed(() => assetStore.assets)
const members = computed(() => groupStore.currentMembers)
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
await assetStore.loadAssets(groupId)
await assetStore.startSubscription(groupId)
})
onUnmounted(() => {
assetStore.stopSubscription()
})
function imageUrl(assetId: string, filename: string): string {
if (!filename) return ''
return getAssetImageUrl(assetId, filename, '200x200')
}
// 添加
const showAdd = ref(false)
const addForm = ref({ name: '', type: 'other' as AssetType, description: '' })
const addImage = ref<File | null>(null)
const addLoading = ref(false)
function onImageSelect(items: any) {
const arr = Array.isArray(items) ? items : [items]
addImage.value = arr[0]?.file || null
}
async function handleAdd() {
if (!addForm.value.name.trim()) {
showFailToast('请输入名称')
return
}
addLoading.value = true
try {
await assetStore.addAsset({
group: groupId,
name: addForm.value.name.trim(),
type: addForm.value.type,
description: addForm.value.description.trim(),
image: addImage.value || undefined
})
showSuccessToast('添加成功')
showAdd.value = false
addForm.value = { name: '', type: 'other', description: '' }
addImage.value = null
} catch (e: any) {
showFailToast(e.message || '添加失败')
} finally {
addLoading.value = false
}
}
// 转移
const showTransfer = ref(false)
const transferAssetId = ref('')
const transferUserId = ref('')
function openTransfer(assetId: string) {
transferAssetId.value = assetId
transferUserId.value = ''
showTransfer.value = true
}
async function handleTransfer() {
if (!transferUserId.value) {
showFailToast('请选择成员')
return
}
try {
await assetStore.transfer(transferAssetId.value, transferUserId.value)
showSuccessToast('已转移')
showTransfer.value = false
} catch (e: any) {
showFailToast(e.message || '转移失败')
}
}
// 删除
async function handleDelete(assetId: string) {
showConfirmDialog({ title: '删除', message: '确定删除这个资产吗?' })
.then(async () => {
try {
await assetStore.removeAsset(assetId)
showSuccessToast('已删除')
} catch (e: any) {
showFailToast('删除失败')
}
}).catch(() => {})
}
const assetTypes = Object.keys(AssetTypeMap) as AssetType[]
</script>
<template>
<div class="asset-mobile">
<div v-if="assets.length === 0" class="empty">
<van-empty description="暂无资产" image-size="100" />
</div>
<div v-else class="asset-list">
<div v-for="a in assets" :key="a.id" class="asset-card">
<div class="asset-main">
<img
v-if="a.image"
:src="imageUrl(a.id, a.image)"
class="asset-img"
alt=""
/>
<div v-else class="asset-img-placeholder">
<van-icon name="gift-o" size="28" />
</div>
<div class="asset-info">
<div class="asset-name">{{ a.name }}</div>
<div class="asset-meta">
<van-tag plain size="medium">{{ AssetTypeMap[a.type] }}</van-tag>
</div>
<div v-if="a.expand?.currentHolder" class="asset-holder">
持有: {{ displayName(a.expand.currentHolder) }}
</div>
</div>
</div>
<div class="asset-actions">
<van-button size="mini" plain round @click="openTransfer(a.id)">转移</van-button>
<van-button size="mini" plain type="danger" round @click="handleDelete(a.id)">删除</van-button>
</div>
</div>
</div>
<!-- 浮动添加 -->
<div class="fab" @click="showAdd = true">
<van-icon name="plus" size="24" />
</div>
<!-- 添加弹层 -->
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
<div class="popup-content">
<div class="popup-title">添加资产</div>
<van-cell-group inset>
<van-field v-model="addForm.name" label="名称" placeholder="资产名称" required />
<van-field name="select" label="类型">
<template #input>
<select v-model="addForm.type" class="type-select">
<option v-for="t in assetTypes" :key="t" :value="t">{{ AssetTypeMap[t] }}</option>
</select>
</template>
</van-field>
<van-field v-model="addForm.description" label="描述" placeholder="可选" />
</van-cell-group>
<div class="upload-area">
<van-uploader :after-read="onImageSelect" :max-count="1" accept="image/*" />
</div>
<div class="popup-actions">
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
</div>
</div>
</van-popup>
<!-- 转移弹层 -->
<van-action-sheet v-model:show="showTransfer" title="转移给">
<div class="transfer-list">
<div
v-for="m in members"
:key="m.id"
class="transfer-item"
:class="{ active: transferUserId === m.id }"
@click="transferUserId = m.id"
>
<img :src="m.avatar || '/default-avatar.svg'" class="transfer-avatar" alt="" />
<span>{{ displayName(m) }}</span>
<van-icon v-if="transferUserId === m.id" name="success" class="transfer-check" />
</div>
<div class="transfer-actions">
<van-button type="primary" block round @click="handleTransfer">确认转移</van-button>
</div>
</div>
</van-action-sheet>
</div>
</template>
<style scoped>
.asset-mobile { padding: 12px; min-height: 60vh; }
.empty { padding: 30px 0; }
.asset-list { display: flex; flex-direction: column; gap: 10px; }
.asset-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
.asset-main { display: flex; gap: 12px; }
.asset-img { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); object-fit: cover; flex-shrink: 0; }
.asset-img-placeholder { width: 60px; height: 60px; border-radius: var(--gg-radius-sm); background: var(--gg-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--gg-text-muted); flex-shrink: 0; }
.asset-info { flex: 1; min-width: 0; }
.asset-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
.asset-meta { margin-top: 6px; }
.asset-holder { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
.asset-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.type-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
.upload-area { padding: 16px; }
.popup-actions { padding: 16px; }
.transfer-list { padding: 8px 0; }
.transfer-item { display: flex; align-items: center; gap: 10px; padding: 12px 24px; }
.transfer-item.active { background: rgba(5,150,105,0.06); color: var(--gg-primary); }
.transfer-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
.transfer-check { margin-left: auto; color: var(--gg-primary); }
.transfer-actions { padding: 16px 24px; }
</style>
@@ -0,0 +1,278 @@
<!-- src/views-mobile/BlacklistMobile.vue -->
<!-- 手机端黑名单游戏/玩家双 Tab + 添加 + 删除 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { listBlacklist, createBlacklistEntry, deleteBlacklistEntry } from '@/api/gameBlacklist'
import { listPlayerBlacklist, createPlayerBlacklistEntry, deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
import { BlacklistReasonMap, BlacklistSeverityMap, PlayerTagMap } from '@/types'
import type { BlacklistReason, BlacklistSeverity, PlayerTag, BlacklistEntry, PlayerBlacklistEntry } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const route = useRoute()
const groupId = route.params.groupId as string
const activeTab = ref<'game' | 'player'>('game')
const gameList = ref<BlacklistEntry[]>([])
const playerList = ref<PlayerBlacklistEntry[]>([])
const loading = ref(false)
async function loadAll() {
loading.value = true
try {
const [games, players] = await Promise.all([
listBlacklist(groupId),
listPlayerBlacklist(groupId)
])
gameList.value = games
playerList.value = players
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(loadAll)
const showAddGame = ref(false)
const gameForm = ref({
gameName: '',
reason: 'behavior' as BlacklistReason,
severity: 'medium' as BlacklistSeverity,
description: ''
})
async function handleAddGame() {
if (!gameForm.value.gameName.trim()) {
showFailToast('请输入游戏名称')
return
}
try {
await createBlacklistEntry({
group: groupId,
gameName: gameForm.value.gameName.trim(),
reason: gameForm.value.reason,
severity: gameForm.value.severity,
description: gameForm.value.description.trim()
})
showSuccessToast('已添加')
showAddGame.value = false
gameForm.value = { gameName: '', reason: 'behavior', severity: 'medium', description: '' }
await loadAll()
} catch (e: any) {
showFailToast(e.message || '添加失败')
}
}
const showAddPlayer = ref(false)
const playerForm = ref({
playerId: '',
platform: '',
tags: [] as PlayerTag[],
severity: 'medium' as BlacklistSeverity,
description: ''
})
function toggleTag(tag: PlayerTag) {
const idx = playerForm.value.tags.indexOf(tag)
if (idx === -1) playerForm.value.tags.push(tag)
else playerForm.value.tags.splice(idx, 1)
}
async function handleAddPlayer() {
if (!playerForm.value.playerId.trim()) {
showFailToast('请输入玩家 ID')
return
}
if (playerForm.value.tags.length === 0) {
showFailToast('请至少选一个标签')
return
}
try {
await createPlayerBlacklistEntry({
group: groupId,
playerId: playerForm.value.playerId.trim(),
platform: playerForm.value.platform.trim(),
tags: playerForm.value.tags,
severity: playerForm.value.severity,
description: playerForm.value.description.trim()
})
showSuccessToast('已添加')
showAddPlayer.value = false
playerForm.value = { playerId: '', platform: '', tags: [], severity: 'medium', description: '' }
await loadAll()
} catch (e: any) {
showFailToast(e.message || '添加失败')
}
}
async function deleteGame(id: string) {
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
.then(async () => {
try {
await deleteBlacklistEntry(id)
showSuccessToast('已删除')
await loadAll()
} catch { showFailToast('删除失败') }
}).catch(() => {})
}
async function deletePlayer(id: string) {
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
.then(async () => {
try {
await deletePlayerBlacklistEntry(id)
showSuccessToast('已删除')
await loadAll()
} catch { showFailToast('删除失败') }
}).catch(() => {})
}
const reasonKeys = Object.keys(BlacklistReasonMap) as BlacklistReason[]
const severityKeys = Object.keys(BlacklistSeverityMap) as BlacklistSeverity[]
const tagKeys = Object.keys(PlayerTagMap) as PlayerTag[]
const severityType = (s: string): any => s === 'severe' ? 'danger' : s === 'medium' ? 'warning' : 'default'
</script>
<template>
<div class="blacklist-mobile">
<van-tabs v-model:active="activeTab" sticky>
<van-tab title="游戏黑名单" name="game">
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showAddGame = true">添加</van-button>
</div>
<div v-if="gameList.length === 0" class="empty">
<van-empty description="暂无游戏黑名单" image-size="100" />
</div>
<div v-else class="entry-list">
<van-swipe-cell v-for="g in gameList" :key="g.id">
<div class="entry-card">
<div class="entry-top">
<span class="entry-name">{{ g.gameName }}</span>
<van-tag :type="severityType(g.severity)" size="medium">{{ BlacklistSeverityMap[g.severity] }}</van-tag>
</div>
<van-tag plain size="medium">{{ BlacklistReasonMap[g.reason] }}</van-tag>
<p v-if="g.description" class="entry-desc">{{ g.description }}</p>
</div>
<template #right>
<van-button square type="danger" text="删除" class="delete-btn" @click="deleteGame(g.id)" />
</template>
</van-swipe-cell>
</div>
</van-tab>
<van-tab title="玩家黑名单" name="player">
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showAddPlayer = true">添加</van-button>
</div>
<div v-if="playerList.length === 0" class="empty">
<van-empty description="暂无玩家黑名单" image-size="100" />
</div>
<div v-else class="entry-list">
<van-swipe-cell v-for="p in playerList" :key="p.id">
<div class="entry-card">
<div class="entry-top">
<span class="entry-name">{{ p.playerId }}</span>
<van-tag :type="severityType(p.severity)" size="medium">{{ BlacklistSeverityMap[p.severity] }}</van-tag>
</div>
<div class="tag-row">
<van-tag v-for="t in p.tags" :key="t" type="danger" plain size="medium">{{ PlayerTagMap[t] }}</van-tag>
<van-tag v-if="p.customTag" type="danger" plain size="medium">{{ p.customTag }}</van-tag>
</div>
<p v-if="p.description" class="entry-desc">{{ p.description }}</p>
<div v-if="p.platform" class="entry-platform">平台: {{ p.platform }}</div>
</div>
<template #right>
<van-button square type="danger" text="删除" class="delete-btn" @click="deletePlayer(p.id)" />
</template>
</van-swipe-cell>
</div>
</van-tab>
</van-tabs>
<van-popup v-model:show="showAddGame" position="bottom" round closeable :style="{ height: '70%' }">
<div class="popup-content">
<div class="popup-title">添加游戏黑名单</div>
<van-cell-group inset>
<van-field v-model="gameForm.gameName" label="游戏名" placeholder="游戏名称" required />
<van-field name="select" label="原因">
<template #input>
<select v-model="gameForm.reason" class="sel">
<option v-for="r in reasonKeys" :key="r" :value="r">{{ BlacklistReasonMap[r] }}</option>
</select>
</template>
</van-field>
<van-field name="select" label="严重度">
<template #input>
<select v-model="gameForm.severity" class="sel">
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
</select>
</template>
</van-field>
<van-field v-model="gameForm.description" type="textarea" label="说明" placeholder="详细描述" rows="2" />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round @click="handleAddGame">添加</van-button>
</div>
</div>
</van-popup>
<van-popup v-model:show="showAddPlayer" position="bottom" round closeable :style="{ height: '75%' }">
<div class="popup-content">
<div class="popup-title">添加玩家黑名单</div>
<van-cell-group inset>
<van-field v-model="playerForm.playerId" label="玩家 ID" placeholder="游戏内 ID" required />
<van-field v-model="playerForm.platform" label="平台" placeholder="如 Steam/PSN" />
<van-field name="select" label="严重度">
<template #input>
<select v-model="playerForm.severity" class="sel">
<option v-for="s in severityKeys" :key="s" :value="s">{{ BlacklistSeverityMap[s] }}</option>
</select>
</template>
</van-field>
</van-cell-group>
<div class="tag-select-title">标签多选</div>
<div class="tag-select-area">
<van-tag
v-for="t in tagKeys"
:key="t"
:type="playerForm.tags.includes(t) ? 'primary' : 'default'"
size="large"
round
class="tag-opt"
@click="toggleTag(t)"
>{{ PlayerTagMap[t] }}</van-tag>
</div>
<van-cell-group inset>
<van-field v-model="playerForm.description" type="textarea" label="说明" placeholder="可选" rows="2" />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round @click="handleAddPlayer">添加</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.blacklist-mobile { min-height: 60vh; }
.list-header { display: flex; justify-content: flex-end; padding: 12px; }
.empty { padding: 30px 0; }
.entry-list { padding: 0 12px; display: flex; flex-direction: column; gap: 1px; }
.entry-card { background: var(--gg-bg-card); padding: 12px 14px; }
.entry-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.entry-name { font-size: 15px; font-weight: 600; color: var(--gg-text); }
.tag-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.entry-desc { font-size: 13px; color: var(--gg-text-secondary); margin: 6px 0 0; line-height: 1.5; }
.entry-platform { font-size: 12px; color: var(--gg-text-muted); margin-top: 4px; }
.delete-btn { height: 100%; }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.sel { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
.tag-select-title { padding: 16px 16px 8px; font-size: 14px; font-weight: 600; }
.tag-select-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 16px 16px; }
.tag-opt { user-select: none; }
.popup-actions { padding: 20px 16px 0; }
</style>
@@ -0,0 +1,152 @@
<!-- src/views-mobile/ChangelogMobile.vue -->
<!-- 手机端更新日志时间线列表数据对齐 uat PC Changelog.vue v0.3.6 最新版本 -->
<script setup lang="ts">
import { ref } from 'vue'
interface LogEntry {
version: string
date: string
title: string
items: { type: 'feat' | 'fix' | 'refactor' | 'style'; text: string }[]
}
const logs = ref<LogEntry[]>([
{
version: 'v2.1.0',
date: '2026-06-18',
title: '手机端前端(uat 重做)',
items: [
{ type: 'feat', text: '完整手机端界面:底部 Tab 导航、顶部栏、所有功能页面移动端适配' },
{ type: 'feat', text: '设备自动识别:手机访问自动进入手机版,桌面访问保持桌面版' },
{ type: 'feat', text: '游戏库移动端:按群组划分,支持详情/评论/收藏/添加/导入' },
{ type: 'feat', text: 'Vant 组件库集成:下拉刷新、ActionSheet、弹层等移动端交互' },
{ type: 'feat', text: '语音房手机端预览:成员网格 + 控制条 UI(实际语音待 App)' },
{ type: 'style', text: '代码分割优化:vendor 拆分,减少首屏加载体积' },
]
},
{
version: 'v0.3.6',
date: '2026-04-24',
title: '游戏库别名与编辑权限',
items: [
{ type: 'feat', text: '游戏支持多个别名(如 LOL、撸啊撸),搜索时可通过别名匹配到游戏' },
{ type: 'feat', text: '游戏详情弹窗内新增编辑功能,创建人、群主、管理员可修改游戏信息' },
{ type: 'feat', text: '游戏编辑表单支持修改名称、别名、平台、标签、封面图' },
{ type: 'feat', text: '导入/导出游戏支持别名(aliases)字段' },
{ type: 'fix', text: '游戏删除按钮改为权限控制:仅创建人、群主、管理员可见' },
{ type: 'fix', text: '组队选游戏限制为本群组游戏库,不再显示其他群组的游戏' },
]
},
{
version: 'v0.3.5',
date: '2026-04-23',
title: 'Bug 修复与体验优化',
items: [
{ type: 'feat', text: '群组页面顶部新增待审核入群申请徽章(脉冲动画提示)' },
{ type: 'feat', text: '个人中心新增"我的入群申请"记录,展示申请状态、时间和拒绝原因' },
{ type: 'feat', text: '游戏封面支持本地上传图片,无需外部图床' },
{ type: 'fix', text: '修复群组审核队列从首页进入时不显示的问题' },
{ type: 'fix', text: '修复拒绝入群申请后标题区待审核计数不同步更新' },
{ type: 'fix', text: '修复邀请链接复制在 HTTP 环境下报错,添加降级方案' },
{ type: 'fix', text: '修复取消 RSVP 时误删所有用户 RSVP 记录的问题' },
{ type: 'fix', text: '修复活动管理权限判断,拆分编辑权限和删除权限' },
{ type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的创建/加入群组按钮' },
]
},
{
version: 'v0.3.4',
date: '2026-04-21',
title: '公告详情弹窗',
items: [
{ type: 'feat', text: '公告卡片点击打开详情弹窗,展示完整内容(保留换行格式)' },
{ type: 'feat', text: '详情弹窗显示优先级标签、作者、发布时间、截止时间等信息' },
{ type: 'feat', text: '详情弹窗内置编辑和删除操作按钮' },
]
},
{
version: 'v0.3.3',
date: '2026-04-20',
title: 'Electron 桌面客户端',
items: [
{ type: 'feat', text: 'Electron 桌面端封装:将 Web 应用包装为独立桌面应用,支持 Windows/Linux/macOS' },
{ type: 'fix', text: '修复 HTTP 环境下 mediaDevices 不可用导致语音认证失败的问题' },
]
},
{
version: 'v0.3.2',
date: '2026-04-19',
title: '实时语音房间',
items: [
{ type: 'feat', text: '语音房间:组队中可进入独立语音房间,实时语音通话(基于 LiveKit WebRTC' },
{ type: 'feat', text: '成员头像网格:显示在线成员,说话时绿圈呼吸动画提示' },
{ type: 'feat', text: '麦克风/扬声器开关:独立控制' },
{ type: 'feat', text: 'LiveKit 后端服务:Docker 部署 LiveKit SFU + Token 签发微服务' },
]
},
{
version: 'v0.3.1',
date: '2026-04-19',
title: '玩家黑名单',
items: [
{ type: 'feat', text: '玩家黑名单:标记外部平台坑玩家,记录玩家ID和游戏平台' },
{ type: 'feat', text: '玩家卡片聚合:按玩家ID+平台聚合展示' },
{ type: 'feat', text: '自定义标签:除预定义标签外支持填写自定义标签' },
{ type: 'feat', text: '实时订阅:玩家黑名单变更实时更新' },
]
},
{
version: 'v0.3.0',
date: '2026-04-19',
title: '积分竞猜、游戏黑名单',
items: [
{ type: 'feat', text: '积分竞猜:发起竞猜、下注、开奖、奖池分配' },
{ type: 'feat', text: '游戏黑名单:标记体验差的游戏,提醒队友避坑' },
{ type: 'fix', text: '修复竞猜双重结算和 TOCTOU 竞态安全漏洞' },
{ type: 'fix', text: '修复黑名单条目允许非创建者修改的安全问题' },
]
},
])
const typeColor: Record<string, string> = {
feat: 'success',
fix: 'warning',
refactor: 'primary',
style: 'default'
}
const typeLabel: Record<string, string> = {
feat: '新增', fix: '修复', refactor: '重构', style: '样式'
}
</script>
<template>
<div class="changelog-mobile">
<div v-for="log in logs" :key="log.version" class="log-block">
<div class="log-header">
<van-tag type="primary" size="large">{{ log.version }}</van-tag>
<span class="log-date">{{ log.date }}</span>
</div>
<div class="log-title">{{ log.title }}</div>
<div class="log-items">
<div v-for="(item, idx) in log.items" :key="idx" class="log-item">
<van-tag :type="typeColor[item.type] as any" plain size="medium">{{ typeLabel[item.type] }}</van-tag>
<span class="item-text">{{ item.text }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.changelog-mobile { padding: 16px 12px; }
.log-block { margin-bottom: 24px; background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 16px; box-shadow: var(--gg-shadow); }
.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.log-date { font-size: 12px; color: var(--gg-text-muted); }
.log-title { font-size: 16px; font-weight: 600; color: var(--gg-text); margin-bottom: 12px; }
.log-items { display: flex; flex-direction: column; gap: 10px; }
.log-item { display: flex; align-items: flex-start; gap: 8px; }
.item-text { font-size: 13px; color: var(--gg-text-secondary); line-height: 1.5; flex: 1; }
</style>
@@ -0,0 +1,430 @@
<!-- src/views-mobile/GamesLibraryMobile.vue -->
<!-- 手机端游戏库按群组划分 + 搜索 + 平台筛选 + 瀑布流 + 添加/导入/详情 -->
<!-- 对齐 uat PC GamesLibrary游戏绑定群组非全局 -->
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { getGroupGames, deleteGame, getAllPlatforms, getGameCoverUrl } from '@/api/games'
import { pb } from '@/api/pocketbase'
import type { Game, GamePlatform } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import GameDetailSheetMobile from '@/components-mobile/game/GameDetailSheetMobile.vue'
import AddGameSheetMobile from '@/components-mobile/game/AddGameSheetMobile.vue'
import ImportGamesSheetMobile from '@/components-mobile/game/ImportGamesSheetMobile.vue'
const groupStore = useGroupStore()
const selectedGroupId = ref('')
const games = ref<Game[]>([])
const loading = ref(false)
const searchQuery = ref('')
const selectedPlatform = ref<GamePlatform | ''>('')
const showGroupPicker = ref(false)
const platforms = getAllPlatforms()
const showDetail = ref(false)
const selectedGame = ref<Game | null>(null)
const showAddGame = ref(false)
const showImport = ref(false)
const groupOptions = computed(() => groupStore.groups)
const hasGroups = computed(() => groupOptions.value.length > 0)
const currentGroupName = computed(() => {
const g = groupOptions.value.find(x => x.id === selectedGroupId.value)
return g?.name || '选择群组'
})
const currentUserId = computed(() => pb.authStore.model?.id || '')
function canModifyGame(game: Game): boolean {
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (game.addedBy === currentUserId.value) return true
return false
}
onMounted(async () => {
if (groupStore.groups.length === 0) {
await groupStore.loadGroups()
}
if (groupStore.currentGroupId) {
selectedGroupId.value = groupStore.currentGroupId
} else if (groupOptions.value.length > 0) {
selectedGroupId.value = groupOptions.value[0].id
}
if (selectedGroupId.value) {
await loadGames()
}
})
watch(selectedGroupId, () => {
searchQuery.value = ''
selectedPlatform.value = ''
loadGames()
})
async function loadGames() {
if (!selectedGroupId.value) return
try {
loading.value = true
const result = await getGroupGames(selectedGroupId.value, {
limit: 100,
search: searchQuery.value || undefined,
platform: selectedPlatform.value || undefined
})
games.value = result.items
} catch (error) {
console.error('加载游戏失败:', error)
} finally {
loading.value = false
}
}
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => loadGames(), 400)
}
function openGameDetail(game: Game) {
selectedGame.value = game
showDetail.value = true
}
async function handleDeleteGame(game: Game) {
showConfirmDialog({
title: '删除游戏',
message: `确定要删除「${game.name}」吗?`
}).then(async () => {
try {
await deleteGame(game.id)
showSuccessToast('删除成功')
await loadGames()
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
function onGroupConfirm({ selectedValues }: { selectedValues: string[] }) {
if (selectedValues[0]) {
selectedGroupId.value = selectedValues[0]
// 同步到 store,供子组件(详情/添加)判断权限
groupStore.setCurrentGroup(selectedGroupId.value)
}
showGroupPicker.value = false
}
const groupPickerColumns = computed(() =>
groupOptions.value.map(g => ({ text: g.name, value: g.id }))
)
async function handleDetailDeleted() {
showDetail.value = false
await loadGames()
}
async function handleGameSaved() {
showAddGame.value = false
await loadGames()
}
async function handleImportComplete() {
showImport.value = false
await loadGames()
}
</script>
<template>
<div class="games-mobile">
<!-- 无群组引导 -->
<div v-if="!hasGroups" class="no-group">
<van-empty description="还没有群组" image-size="100">
<p class="no-group-hint">先创建或加入群组再管理游戏库</p>
</van-empty>
</div>
<template v-else>
<!-- 顶部群组选择 + 工具栏 -->
<div class="page-header">
<div class="group-selector" @click="showGroupPicker = true">
<van-icon name="friends-o" />
<span class="group-name">{{ currentGroupName }}</span>
<van-icon name="arrow-down" />
</div>
<van-search
v-model="searchQuery"
placeholder="搜索游戏"
shape="round"
@update:model-value="onSearchInput"
@search="loadGames"
/>
<!-- 平台筛选横滑 -->
<div class="platform-bar">
<van-tag
:type="selectedPlatform === '' ? 'primary' : 'default'"
size="medium"
round
class="platform-tag"
@click="selectedPlatform = ''; loadGames()"
>全部</van-tag>
<van-tag
v-for="p in platforms"
:key="p"
:type="selectedPlatform === p ? 'primary' : 'default'"
size="medium"
round
class="platform-tag"
@click="selectedPlatform = p; loadGames()"
>{{ p }}</van-tag>
</div>
<!-- 操作按钮 -->
<div class="action-row">
<van-button type="primary" size="small" round icon="plus" @click="showAddGame = true">
添加游戏
</van-button>
<van-button size="small" round icon="down" @click="showImport = true">
导入
</van-button>
</div>
</div>
<!-- 游戏列表 -->
<div v-if="loading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="games.length === 0" class="empty">
<van-empty :description="searchQuery ? '未找到游戏' : '暂无游戏'" image-size="100">
<van-button v-if="!searchQuery" type="primary" size="small" round @click="showAddGame = true">
添加第一个游戏
</van-button>
</van-empty>
</div>
<div v-else class="games-grid">
<div
v-for="game in games"
:key="game.id"
class="game-card"
@click="openGameDetail(game)"
>
<div class="cover-wrap">
<img
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
<button
v-if="canModifyGame(game)"
class="delete-btn"
@click.stop="handleDeleteGame(game)"
>
<van-icon name="cross" />
</button>
</div>
<div class="game-info">
<div class="game-name">{{ game.name }}</div>
<div v-if="game.platform" class="game-platform">{{ game.platform }}</div>
<div v-if="game.tags?.length" class="game-tags">
<span v-for="tag in game.tags.slice(0, 2)" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</div>
</div>
<!-- 群组选择器 -->
<van-popup v-model:show="showGroupPicker" position="bottom" round>
<van-picker
:columns="groupPickerColumns"
:default-index="groupOptions.findIndex(g => g.id === selectedGroupId)"
@confirm="onGroupConfirm"
@cancel="showGroupPicker = false"
/>
</van-popup>
<!-- 详情面板 -->
<GameDetailSheetMobile
v-model:show="showDetail"
:game="selectedGame"
:group-id="selectedGroupId"
@deleted="handleDetailDeleted"
/>
<!-- 添加游戏 -->
<AddGameSheetMobile
v-model:show="showAddGame"
:group-id="selectedGroupId"
@saved="handleGameSaved"
/>
<!-- 导入游戏 -->
<ImportGamesSheetMobile
v-model:show="showImport"
:group-id="selectedGroupId"
@imported="handleImportComplete"
/>
</template>
</div>
</template>
<style scoped>
.games-mobile {
padding-bottom: 16px;
min-height: 60vh;
}
.no-group {
padding: 40px 0;
}
.no-group-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 8px 0 0;
}
.page-header {
padding: 8px 12px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-selector {
display: flex;
align-items: center;
gap: 6px;
background: var(--gg-bg-card);
padding: 10px 14px;
border-radius: var(--gg-radius-sm);
box-shadow: var(--gg-shadow);
}
.group-name {
flex: 1;
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
}
.platform-bar {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.platform-bar::-webkit-scrollbar {
display: none;
}
.platform-tag {
flex-shrink: 0;
}
.action-row {
display: flex;
gap: 8px;
}
.action-row .van-button {
flex: 1;
}
.loading-box {
display: flex;
justify-content: center;
padding: 40px;
}
.empty {
padding: 20px 0;
}
.games-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 0 12px;
}
.game-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
overflow: hidden;
box-shadow: var(--gg-shadow);
position: relative;
}
.game-card:active {
opacity: 0.85;
}
.cover-wrap {
position: relative;
}
.game-cover {
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
background: var(--gg-bg-elevated);
display: block;
}
.delete-btn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.game-info {
padding: 8px 10px 10px;
}
.game-name {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-platform {
font-size: 11px;
color: var(--gg-text-muted);
margin-top: 2px;
}
.game-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.tag {
padding: 1px 6px;
background: rgba(5, 150, 105, 0.1);
border-radius: 4px;
font-size: 10px;
color: var(--gg-primary);
font-weight: 500;
}
</style>
@@ -0,0 +1,240 @@
<!-- src/views-mobile/GroupViewMobile.vue -->
<!-- 手机端群组详情群信息 + 可横滑标签栏 -->
<!-- 标签动态/投票/竞猜/回忆/成员/账本/资产/黑名单/统计 + [阶段10]公示板 + [阶段11]活动 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import ActivityFeedMobile from '@/components-mobile/group/ActivityFeedMobile.vue'
import MemberListMobile from '@/components-mobile/group/MemberListMobile.vue'
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 EventListMobile from '@/components-mobile/event/EventListMobile.vue'
import Placeholder from '@/views-mobile/Placeholder.vue'
import { Wallet, Box, Warning } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const groupStore = useGroupStore()
const groupId = route.params.id as string
const activeTab = ref(route.query.tab as string || 'activity')
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
const ownerName = computed(() => {
const owner = members.value.find(m => m.id === group.value?.owner)
return owner?.name || owner?.username || '未知'
})
const unsubFns: (() => Promise<void>)[] = []
async function loadGroup() {
await groupStore.setCurrentGroup(groupId)
}
onMounted(async () => {
await loadGroup()
// 订阅群组、成员、会话变更
try {
unsubFns.push(await pb.collection('users').subscribe('*', () => loadGroup()))
unsubFns.push(await pb.collection('groups').subscribe('*', (payload) => {
if (payload.record.id === groupId) loadGroup()
}))
} catch (e) {
console.error('订阅失败:', e)
}
})
onUnmounted(async () => {
for (const fn of unsubFns) {
try { await fn() } catch { /* ignore */ }
}
})
// 标签配置(全部 tab 已接入)
const tabs = [
{ name: 'activity', label: '动态' },
{ name: 'bulletin', label: '公告' },
{ name: 'events', label: '活动' },
{ name: 'polls', label: '投票' },
{ name: 'bets', label: '竞猜' },
{ name: 'members', label: '成员' },
{ name: 'memories', label: '回忆' },
{ name: 'stats', label: '统计' },
]
function onTabChange(name: string) {
activeTab.value = name
}
// 跳转群组级独立页面
function goLedger() {
router.push({ name: 'LedgerView', params: { groupId } })
}
function goAssets() {
router.push({ name: 'AssetView', params: { groupId } })
}
function goBlacklist() {
router.push({ name: 'BlacklistView', params: { groupId } })
}
</script>
<template>
<div class="group-view-mobile">
<!-- 群信息条 -->
<div class="group-header">
<div class="header-row">
<h1 class="group-name">{{ group?.name || '加载中...' }}</h1>
<van-tag type="success" size="large">{{ members.length }} </van-tag>
</div>
<p class="group-desc">{{ group?.description || '暂无群组简介' }}</p>
<div class="header-meta">
<span class="meta-text">群主: {{ ownerName }}</span>
<span class="meta-divider">·</span>
<span class="meta-text">{{ members.length }}/{{ group?.maxMembers || '-' }}</span>
</div>
<!-- 快捷入口 -->
<div class="quick-entry">
<div class="entry-item" @click="goLedger">
<el-icon><Wallet /></el-icon>
<span>账本</span>
</div>
<div class="entry-item" @click="goAssets">
<el-icon><Box /></el-icon>
<span>资产</span>
</div>
<div class="entry-item" @click="goBlacklist">
<el-icon><Warning /></el-icon>
<span>黑名单</span>
</div>
</div>
</div>
<!-- 可横滑标签栏 -->
<van-tabs
v-model:active="activeTab"
class="group-tabs"
line-width="20"
@change="onTabChange"
>
<van-tab v-for="tab in tabs" :key="tab.name" :name="tab.name" :title="tab.label">
<div class="tab-content">
<ActivityFeedMobile v-if="activeTab === 'activity'" :group-id="groupId" />
<MemberListMobile v-else-if="activeTab === 'members'" :group-id="groupId" />
<PollListMobile v-else-if="activeTab === 'polls'" :group-id="groupId" />
<BetListMobile v-else-if="activeTab === 'bets'" :group-id="groupId" />
<MemoryGridMobile v-else-if="activeTab === 'memories'" :group-id="groupId" />
<StatsPanelMobile v-else-if="activeTab === 'stats'" :group-id="groupId" />
<BulletinListMobile v-else-if="activeTab === 'bulletin'" :group-id="groupId" />
<EventListMobile v-else-if="activeTab === 'events'" :group-id="groupId" />
<Placeholder v-else />
</div>
</van-tab>
</van-tabs>
</div>
</template>
<style scoped>
.group-view-mobile {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 群信息条 */
.group-header {
background: var(--gg-bg-card);
padding: 16px;
box-shadow: var(--gg-shadow);
position: relative;
overflow: hidden;
}
.group-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gg-gradient);
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.group-name {
font-size: 20px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-desc {
font-size: 13px;
color: var(--gg-text-muted);
margin: 8px 0;
line-height: 1.5;
}
.header-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--gg-text-secondary);
}
.meta-divider {
color: var(--gg-text-muted);
}
.quick-entry {
display: flex;
gap: 8px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--gg-border);
}
.entry-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
font-size: 12px;
color: var(--gg-text-secondary);
}
.entry-item:active {
opacity: 0.7;
}
.entry-item .el-icon {
font-size: 20px;
color: var(--gg-primary);
}
/* 标签内容 */
.tab-content {
min-height: 50vh;
}
</style>
+381
View File
@@ -0,0 +1,381 @@
<!-- src/views-mobile/GroupsMobile.vue -->
<!-- 手机端群组列表 + 创建/加入群组 -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { createGroup, searchGroups, joinGroup } from '@/api/groups'
import pb from '@/api/pocketbase'
import type { Group } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const router = useRouter()
const groupStore = useGroupStore()
// 创建群组
const showCreate = ref(false)
const createForm = ref({ name: '', description: '', maxMembers: 20 })
const createLoading = ref(false)
async function handleCreate() {
if (!createForm.value.name.trim()) {
showFailToast('请输入群组名称')
return
}
createLoading.value = true
try {
// 名称查重(与桌面端一致)
const existing = await pb.collection('groups').getList(1, 1, {
filter: `name="${createForm.value.name.trim()}"`
})
if (existing.items.length > 0) {
showFailToast('该群组名称已存在')
createLoading.value = false
return
}
const group = await createGroup({
name: createForm.value.name.trim(),
description: createForm.value.description.trim(),
maxMembers: createForm.value.maxMembers
})
await groupStore.loadGroups()
showCreate.value = false
createForm.value = { name: '', description: '', maxMembers: 20 }
showSuccessToast('创建成功')
if (group?.id) {
router.push({ name: 'GroupView', params: { id: group.id } })
}
} catch (error: any) {
showFailToast(error.message || '创建失败')
} finally {
createLoading.value = false
}
}
// 加入群组(搜索)
const showJoin = ref(false)
const searchKeyword = ref('')
const searchResults = ref<Group[]>([])
const searching = ref(false)
async function doSearch() {
if (!searchKeyword.value.trim()) {
searchResults.value = []
return
}
searching.value = true
try {
searchResults.value = await searchGroups(searchKeyword.value.trim())
} catch (e) {
console.error(e)
} finally {
searching.value = false
}
}
async function handleJoin(groupId: string) {
try {
await joinGroup(groupId)
await groupStore.loadGroups()
showSuccessToast('加入成功')
showJoin.value = false
searchKeyword.value = ''
searchResults.value = []
router.push({ name: 'GroupView', params: { id: groupId } })
} catch (error: any) {
showFailToast(error.message || '加入失败')
}
}
function selectGroup(groupId: string) {
groupStore.setCurrentGroup(groupId)
router.push({ name: 'GroupView', params: { id: groupId } })
}
// 下拉刷新
async function onRefresh() {
await groupStore.loadGroups()
showSuccessToast('刷新成功')
}
const refreshing = ref(false)
</script>
<template>
<div class="groups-mobile">
<!-- 浮动操作按钮 -->
<div class="fab-row">
<van-button type="primary" round icon="plus" size="small" @click="showCreate = true">
创建
</van-button>
<van-button type="default" round icon="search" size="small" @click="showJoin = true">
加入
</van-button>
</div>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<div v-if="groupStore.groups.length === 0" class="empty-state">
<van-empty description="还没有群组">
<van-button type="primary" round size="small" @click="showCreate = true">
创建第一个群组
</van-button>
</van-empty>
</div>
<div v-else class="group-list">
<div
v-for="group in groupStore.groups"
:key="group.id"
class="group-card"
@click="selectGroup(group.id)"
>
<div class="group-main">
<div class="group-name">{{ group.name }}</div>
<div class="group-desc">{{ group.description || '暂无简介' }}</div>
<div class="group-footer">
<van-tag type="success" size="medium">{{ group.members?.length || 0 }} </van-tag>
<span class="group-capacity">/{{ group.maxMembers || '-' }}</span>
</div>
</div>
<van-icon name="arrow" class="group-arrow" />
</div>
</div>
</van-pull-refresh>
<!-- 创建群组弹层 -->
<van-popup
v-model:show="showCreate"
position="bottom"
round
closeable
:style="{ height: '70%' }"
>
<div class="popup-content">
<div class="popup-title">创建群组</div>
<van-cell-group inset>
<van-field
v-model="createForm.name"
label="名称"
placeholder="给群组起个名字"
maxlength="50"
required
/>
<van-field
v-model="createForm.description"
type="textarea"
label="描述"
placeholder="简单介绍这个群组"
rows="2"
maxlength="500"
autosize
/>
<van-field name="stepper" label="最大成员数">
<template #input>
<van-stepper v-model="createForm.maxMembers" min="2" max="100" />
</template>
</van-field>
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
创建
</van-button>
</div>
</div>
</van-popup>
<!-- 加入群组弹层 -->
<van-popup
v-model:show="showJoin"
position="bottom"
round
closeable
:style="{ height: '70%' }"
>
<div class="popup-content">
<div class="popup-title">加入群组</div>
<van-search
v-model="searchKeyword"
placeholder="搜索群组名称"
shape="round"
@search="doSearch"
@clear="searchResults = []"
/>
<div v-if="searching" class="search-loading">
<van-loading size="24px">搜索中...</van-loading>
</div>
<div v-else-if="searchResults.length === 0 && searchKeyword" class="search-empty">
<van-empty description="未找到群组" image-size="80" />
</div>
<div v-else class="search-list">
<div
v-for="group in searchResults"
:key="group.id"
class="search-item"
>
<div class="search-info">
<div class="search-name">{{ group.name }}</div>
<div class="search-desc">{{ group.description || '暂无简介' }}</div>
<div class="search-meta">{{ group.members?.length || 0 }}/{{ group.maxMembers }} </div>
</div>
<van-button
type="primary"
size="small"
round
@click="handleJoin(group.id)"
>
加入
</van-button>
</div>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.groups-mobile {
padding-bottom: 16px;
position: relative;
}
.fab-row {
display: flex;
gap: 8px;
padding: 12px;
position: sticky;
top: 0;
background: var(--gg-bg);
z-index: 10;
}
.empty-state {
padding: 40px 0;
}
.group-list {
padding: 0 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-card {
display: flex;
align-items: center;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 14px 16px;
box-shadow: var(--gg-shadow);
}
.group-card:active {
opacity: 0.85;
}
.group-main {
flex: 1;
min-width: 0;
}
.group-name {
font-size: 16px;
font-weight: 600;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-desc {
font-size: 13px;
color: var(--gg-text-muted);
margin: 4px 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-footer {
display: flex;
align-items: center;
gap: 4px;
}
.group-capacity {
font-size: 12px;
color: var(--gg-text-muted);
}
.group-arrow {
color: var(--gg-text-muted);
font-size: 16px;
}
/* 弹层 */
.popup-content {
padding: 16px 0 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.popup-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
color: var(--gg-text);
}
.popup-actions {
padding: 20px 16px 0;
}
.search-loading, .search-empty {
display: flex;
justify-content: center;
padding: 32px 0;
}
.search-list {
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.search-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
}
.search-info {
flex: 1;
min-width: 0;
}
.search-name {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.search-desc {
font-size: 12px;
color: var(--gg-text-muted);
margin: 2px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-meta {
font-size: 11px;
color: var(--gg-text-muted);
}
</style>
+463
View File
@@ -0,0 +1,463 @@
<!-- src/views-mobile/HomeMobile.vue -->
<!-- 手机端首页状态 + 当前组队 + 我的群组 + 热门游戏 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { useTeamStore } from '@/stores/team'
import { getPopularGames } from '@/api/games'
import type { Game } from '@/types'
import { UserStatusMap } from '@/types'
import { showSuccessToast } from 'vant'
const router = useRouter()
const groupStore = useGroupStore()
const userStore = useUserStore()
const teamStore = useTeamStore()
const popularGames = ref<Game[]>([])
const loading = ref(false)
const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知')
const hasNoGroup = computed(() => groupStore.groups.length === 0)
const hasSession = computed(() => !!teamStore.currentSession)
onMounted(async () => {
await loadPopularGames()
})
async function loadPopularGames() {
try {
loading.value = true
popularGames.value = await getPopularGames(10)
} catch (error) {
console.error('加载热门游戏失败:', error)
} finally {
loading.value = false
}
}
function selectGroup(groupId: string) {
groupStore.setCurrentGroup(groupId)
router.push({ name: 'GroupView', params: { id: groupId } })
}
// 一键切换状态(空闲/离开/工作中循环)
const statusActions = [
{ status: 'idle' as const, label: '空闲', icon: '🟢' },
{ status: 'away' as const, label: '离开', icon: '⚫' },
{ status: 'working' as const, label: '工作中', icon: '🔴' },
]
const showStatusSheet = ref(false)
async function onStatusSelect(action: { status: any; label: string }) {
showStatusSheet.value = false
try {
await userStore.setStatus(action.status)
showSuccessToast(`已切换为${action.label}`)
} catch (e: any) {
console.error(e)
}
}
function goCreateGroup() {
router.push('/mobile-groups')
}
function goSession() {
const session = teamStore.currentSession
if (session?.voiceRoom) {
router.push({
name: 'VoiceRoom',
params: { groupId: session.sourceGroup, sessionId: session.id }
})
}
}
</script>
<template>
<div class="home-mobile">
<!-- 用户状态卡片 -->
<div class="status-card">
<div class="status-info" @click="showStatusSheet = true">
<img
:src="userStore.user?.avatar || '/default-avatar.svg'"
class="avatar"
alt=""
/>
<div class="status-text">
<div class="user-name">{{ userStore.user?.name || userStore.user?.username }}</div>
<div class="user-status">
<span class="status-dot" :class="`dot-${userStore.userStatus}`" />
<span>{{ statusText }}</span>
<span v-if="userStore.user?.statusNote" class="status-note">
· {{ userStore.user.statusNote }}
</span>
</div>
</div>
<van-icon name="arrow-down" class="status-arrow" />
</div>
<div class="quick-stats">
<div class="stat-item">
<span class="stat-num">{{ userStore.user?.points ?? 0 }}</span>
<span class="stat-label">积分</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ groupStore.groups.length }}</span>
<span class="stat-label">群组</span>
</div>
</div>
</div>
<!-- 当前组队卡片 -->
<div v-if="hasSession" class="session-card" @click="goSession">
<div class="session-header">
<van-icon name="volume-o" class="session-icon" />
<span class="session-title">当前组队</span>
<van-tag type="success" size="medium">{{ teamStore.teamStatus }}</van-tag>
</div>
<div class="session-body">
<div class="session-game">{{ teamStore.currentSession?.gameName }}</div>
<div class="session-members">
{{ teamStore.currentSession?.name }}
· {{ teamStore.currentSession?.members?.length || 0 }}
</div>
</div>
<div class="session-action">
进入语音房 <van-icon name="arrow" />
</div>
</div>
<!-- 我的群组 -->
<div class="section">
<div class="section-header">
<span class="section-title">我的群组</span>
<span v-if="!hasNoGroup" class="section-more" @click="goCreateGroup">全部</span>
</div>
<div v-if="hasNoGroup" class="empty-card">
<van-empty description="还没有群组" image-size="80">
<van-button type="primary" size="small" round @click="goCreateGroup">
创建或加入群组
</van-button>
</van-empty>
</div>
<div v-else class="group-scroll">
<div
v-for="group in groupStore.groups"
:key="group.id"
class="group-card"
@click="selectGroup(group.id)"
>
<div class="group-name">{{ group.name }}</div>
<div class="group-meta">{{ group.members?.length || 0 }} </div>
</div>
</div>
</div>
<!-- 热门游戏 -->
<div class="section">
<div class="section-header">
<span class="section-title">热门游戏</span>
</div>
<div v-if="loading" class="loading-row">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="popularGames.length === 0" class="empty-row">
<span class="empty-text">暂无热门游戏</span>
</div>
<div v-else class="games-scroll">
<div
v-for="game in popularGames"
:key="game.id"
class="game-card"
>
<img
:src="game.cover || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
<div class="game-name">{{ game.name }}</div>
<van-tag v-if="game.platform" plain size="medium">{{ game.platform }}</van-tag>
</div>
</div>
</div>
<!-- 状态切换 ActionSheet -->
<van-action-sheet
v-model:show="showStatusSheet"
title="切换状态"
:actions="statusActions.map(a => ({ name: `${a.icon} ${a.label}`, status: a.status, label: a.label }))"
@select="onStatusSelect"
/>
</div>
</template>
<style scoped>
.home-mobile {
padding: 12px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* 状态卡片 */
.status-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-lg);
padding: 16px;
box-shadow: var(--gg-shadow);
}
.status-info {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--gg-border);
}
.status-text {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 17px;
font-weight: 600;
color: var(--gg-text);
}
.user-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--gg-text-secondary);
margin-top: 4px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot-idle { background: var(--gg-success); }
.dot-in_team { background: var(--gg-info); }
.dot-working { background: var(--gg-danger); }
.dot-away { background: var(--gg-text-muted); }
.status-note {
color: var(--gg-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-arrow {
color: var(--gg-text-muted);
font-size: 14px;
}
.quick-stats {
display: flex;
gap: 24px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--gg-border);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-num {
font-size: 20px;
font-weight: 700;
color: var(--gg-primary);
}
.stat-label {
font-size: 12px;
color: var(--gg-text-muted);
margin-top: 2px;
}
/* 组队卡片 */
.session-card {
background: linear-gradient(135deg, var(--gg-primary), var(--gg-accent));
border-radius: var(--gg-radius-lg);
padding: 16px;
color: #fff;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.25);
}
.session-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
opacity: 0.9;
}
.session-icon {
font-size: 16px;
}
.session-body {
margin: 10px 0;
}
.session-game {
font-size: 18px;
font-weight: 700;
}
.session-members {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.session-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
font-size: 14px;
font-weight: 500;
}
/* 通用 section */
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--gg-text);
}
.section-more {
font-size: 13px;
color: var(--gg-primary);
}
.empty-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 8px;
}
.loading-row, .empty-row {
display: flex;
justify-content: center;
padding: 24px;
}
.empty-text {
font-size: 13px;
color: var(--gg-text-muted);
}
/* 群组横滑 */
.group-scroll {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
scroll-behavior: smooth;
}
.group-scroll::-webkit-scrollbar {
display: none;
}
.group-card {
flex-shrink: 0;
width: 130px;
padding: 14px;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
box-shadow: var(--gg-shadow);
}
.group-card:active {
opacity: 0.85;
}
.group-name {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-meta {
font-size: 12px;
color: var(--gg-text-muted);
margin-top: 6px;
}
/* 游戏横滑 */
.games-scroll {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
}
.games-scroll::-webkit-scrollbar {
display: none;
}
.game-card {
flex-shrink: 0;
width: 100px;
}
.game-cover {
width: 100px;
height: 130px;
border-radius: var(--gg-radius-md);
object-fit: cover;
background: var(--gg-bg-elevated);
}
.game-name {
font-size: 12px;
color: var(--gg-text);
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,222 @@
<!-- src/views-mobile/JoinGroupPageMobile.vue -->
<!-- 手机端群组邀请落地页 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getGroup, joinGroup, createJoinRequest } from '@/api/groups'
import { useGroupStore } from '@/stores/group'
import { pb, isAuthenticated } from '@/api/pocketbase'
import type { Group } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const route = useRoute()
const router = useRouter()
const groupStore = useGroupStore()
const group = ref<Group | null>(null)
const loading = ref(true)
const joining = ref(false)
const error = ref('')
const groupId = route.params.groupId as string
onMounted(async () => {
try {
group.value = await getGroup(groupId)
} catch {
error.value = '群组不存在或已解散'
} finally {
loading.value = false
}
})
const isLoggedIn = computed(() => isAuthenticated())
const isMember = computed(() => {
if (!group.value) return false
const userId = pb.authStore.model?.id
if (!userId) return false
return group.value.members?.includes(userId) || false
})
function goToLogin() {
router.push({ name: 'Login', query: { redirect: route.fullPath } })
}
function goToGroup() {
router.push({ name: 'GroupView', params: { id: groupId } })
}
async function handleJoin() {
if (!isLoggedIn.value) {
goToLogin()
return
}
joining.value = true
try {
if (group.value?.requireApproval) {
await createJoinRequest(groupId)
showSuccessToast('已提交加入申请,等待审核')
} else {
await joinGroup(groupId)
await groupStore.loadGroups()
showSuccessToast('已成功加入群组')
goToGroup()
}
} catch (e: any) {
showFailToast(e.message || '加入失败')
} finally {
joining.value = false
}
}
</script>
<template>
<div class="join-page">
<div class="join-card">
<div class="join-header">
<div class="header-icon">🔗</div>
<h1>群组邀请</h1>
</div>
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="error" class="error-state">
<van-icon name="warning-o" size="48" />
<p>{{ error }}</p>
<van-button block round @click="router.push('/')">返回首页</van-button>
</div>
<div v-else-if="group" class="group-info">
<h2 class="group-name">{{ group.name }}</h2>
<p v-if="group.description" class="group-desc">{{ group.description }}</p>
<div class="meta-row">
<span class="meta-item">
<van-icon name="friends-o" /> {{ group.members?.length || 0 }} / {{ group.maxMembers }}
</span>
</div>
<van-tag v-if="group.requireApproval" type="warning" size="large" round class="approval-tag">需审核</van-tag>
<van-tag v-else type="success" size="large" round class="approval-tag">可直接加入</van-tag>
<div v-if="isMember" class="action-block">
<p class="tip">你已经是该群组的成员</p>
<van-button type="primary" block round @click="goToGroup">进入群组</van-button>
</div>
<div v-else-if="!isLoggedIn" class="action-block">
<p class="tip">登录后即可加入群组</p>
<van-button type="primary" block round @click="goToLogin">登录 / 注册</van-button>
</div>
<div v-else class="action-block">
<p class="tip">{{ group.requireApproval ? '加入该群组需要群主审核' : '点击即可加入群组' }}</p>
<van-button type="primary" block round :loading="joining" @click="handleJoin">
{{ group.requireApproval ? '申请加入' : '立即加入' }}
</van-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: var(--gg-bg);
}
.join-card {
width: 100%;
max-width: 420px;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-lg);
padding: 32px 24px;
box-shadow: var(--gg-shadow);
}
.join-header {
text-align: center;
margin-bottom: 24px;
}
.header-icon {
font-size: 40px;
margin-bottom: 8px;
}
.join-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.loading-state {
display: flex;
justify-content: center;
padding: 40px;
}
.error-state {
text-align: center;
padding: 16px 0;
color: var(--gg-danger);
}
.error-state p { margin: 12px 0 16px; font-size: 14px; }
.error-state .van-button { max-width: 200px; margin: 0 auto; }
.group-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.group-name {
font-size: 22px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.group-desc {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
line-height: 1.5;
}
.meta-row {
font-size: 13px;
color: var(--gg-text-secondary);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.approval-tag { margin: 4px 0 12px; }
.action-block {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.tip {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
</style>
@@ -0,0 +1,229 @@
<!-- src/views-mobile/JoinTeamPageMobile.vue -->
<!-- 手机端组队邀请落地页 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pb, isAuthenticated } from '@/api/pocketbase'
import { useGroupStore } from '@/stores/group'
import { joinTeamSession, getTeamSession } from '@/api/sessions'
import type { TeamSession } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const route = useRoute()
const router = useRouter()
const groupStore = useGroupStore()
const session = ref<TeamSession | null>(null)
const loading = ref(true)
const joining = ref(false)
const error = ref('')
const sessionId = route.params.sessionId as string
onMounted(async () => {
try {
if (isAuthenticated()) {
await groupStore.loadGroups()
}
session.value = await getTeamSession(sessionId)
} catch {
error.value = '小队不存在或已解散'
} finally {
loading.value = false
}
})
const isLoggedIn = computed(() => isAuthenticated())
const isInTeam = computed(() => {
const userId = pb.authStore.model?.id
if (!userId || !session.value) return false
return session.value.members?.includes(userId) || false
})
const isInSourceGroup = computed(() => {
const userId = pb.authStore.model?.id
if (!userId || !session.value) return false
return groupStore.groups.some(g => g.id === session.value?.sourceGroup)
})
function goToLogin() {
router.push({ name: 'Login', query: { redirect: route.fullPath } })
}
function goToGroup() {
const gid = session.value?.sourceGroup
if (gid) router.push({ name: 'GroupView', params: { id: gid } })
}
async function handleJoin() {
if (!isLoggedIn.value) {
goToLogin()
return
}
joining.value = true
try {
await joinTeamSession(sessionId)
showSuccessToast('已成功加入小队')
goToGroup()
} catch (e: any) {
showFailToast(e.message || '加入失败')
} finally {
joining.value = false
}
}
</script>
<template>
<div class="join-page">
<div class="join-card">
<div class="join-header">
<div class="header-icon">🎮</div>
<h1>组队邀请</h1>
</div>
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="error" class="error-state">
<van-icon name="warning-o" size="48" />
<p>{{ error }}</p>
<van-button block round @click="router.push('/')">返回首页</van-button>
</div>
<div v-else-if="session" class="team-info">
<h2 class="team-game">{{ session.gameName }}</h2>
<p class="team-name">{{ session.name }}</p>
<div class="meta-row">
<van-icon name="friends-o" />
<span>{{ session.members?.length || 0 }} </span>
</div>
<!-- 已在队中 -->
<div v-if="isInTeam" class="action-block">
<p class="tip">你已在该小队中</p>
<van-button type="primary" block round @click="goToGroup">进入群组</van-button>
</div>
<!-- 未登录 -->
<div v-else-if="!isLoggedIn" class="action-block">
<p class="tip">登录后即可加入小队</p>
<van-button type="primary" block round @click="goToLogin">登录 / 注册</van-button>
</div>
<!-- 未在源群组 -->
<div v-else-if="!isInSourceGroup" class="action-block">
<p class="tip warn">需要先加入该小队所在的群组</p>
<van-button type="primary" block round @click="goToGroup">前往群组</van-button>
</div>
<!-- 可加入 -->
<div v-else class="action-block">
<p class="tip">点击加入小队一起开黑</p>
<van-button type="primary" block round :loading="joining" @click="handleJoin">
立即加入
</van-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: var(--gg-bg);
}
.join-card {
width: 100%;
max-width: 420px;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-lg);
padding: 32px 24px;
box-shadow: var(--gg-shadow);
}
.join-header {
text-align: center;
margin-bottom: 24px;
}
.header-icon {
font-size: 40px;
margin-bottom: 8px;
}
.join-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.loading-state {
display: flex;
justify-content: center;
padding: 40px;
}
.error-state {
text-align: center;
padding: 16px 0;
color: var(--gg-danger);
}
.error-state p { margin: 12px 0 16px; font-size: 14px; }
.error-state .van-button { max-width: 200px; margin: 0 auto; }
.team-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.team-game {
font-size: 22px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.team-name {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
.meta-row {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--gg-text-secondary);
margin: 4px 0 16px;
}
.action-block {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.tip {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
.tip.warn {
color: var(--gg-warning);
}
</style>
+260
View File
@@ -0,0 +1,260 @@
<!-- src/views-mobile/LedgerMobile.vue -->
<!-- 手机端账本月度汇总 + 列表 + 添加 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useLedgerStore } from '@/stores/ledger'
import { useGroupStore } from '@/stores/group'
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
import type { LedgerType, LedgerCategory } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const route = useRoute()
const groupId = route.params.groupId as string
const ledgerStore = useLedgerStore()
const groupStore = useGroupStore()
const ledgers = computed(() => ledgerStore.ledgers)
const summary = computed(() => ledgerStore.summary)
const showMonthPicker = ref(false)
const currentMonth = ref(ledgerStore.currentMonth || new Date().toISOString().slice(0, 7))
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
await ledgerStore.loadLedgers(groupId, currentMonth.value)
await ledgerStore.startSubscription(groupId)
})
onUnmounted(() => {
ledgerStore.stopSubscription()
})
async function changeMonth(month: string) {
currentMonth.value = month
showMonthPicker.value = false
await ledgerStore.loadLedgers(groupId, month)
}
// 月份选择器
const months = computed(() => {
const now = new Date()
const list = []
for (let i = 0; i < 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
list.push({
value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`,
label: `${d.getFullYear()}${d.getMonth() + 1}`
})
}
return list
})
// 添加
const showAdd = ref(false)
const addForm = ref({
type: 'expense' as LedgerType,
amount: 0,
category: 'gaming' as LedgerCategory,
description: '',
occurredAt: new Date().toISOString().slice(0, 10)
})
const addLoading = ref(false)
async function handleAdd() {
if (addForm.value.amount <= 0) {
showFailToast('请输入金额')
return
}
addLoading.value = true
try {
await ledgerStore.addLedger({
group: groupId,
type: addForm.value.type,
amount: addForm.value.amount,
category: addForm.value.category,
description: addForm.value.description.trim(),
occurredAt: addForm.value.occurredAt
})
await ledgerStore.loadLedgers(groupId, currentMonth.value)
showSuccessToast('添加成功')
showAdd.value = false
addForm.value = { type: 'expense', amount: 0, category: 'gaming', description: '', occurredAt: new Date().toISOString().slice(0, 10) }
} catch (e: any) {
showFailToast(e.message || '添加失败')
} finally {
addLoading.value = false
}
}
// 删除
async function handleDelete(id: string) {
showConfirmDialog({ title: '删除', message: '确定删除这条记录吗?' })
.then(async () => {
try {
await ledgerStore.removeLedger(id)
await ledgerStore.loadLedgers(groupId, currentMonth.value)
showSuccessToast('已删除')
} catch (e: any) {
showFailToast('删除失败')
}
}).catch(() => {})
}
const typeColors: Record<string, 'success' | 'danger' | 'warning' | 'primary' | 'default'> = { income: 'success', expense: 'danger' }
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
}
</script>
<template>
<div class="ledger-mobile">
<!-- 月度汇总 -->
<div class="summary-card">
<div class="month-bar" @click="showMonthPicker = true">
<span class="month-label">{{ months.find(m => m.value === currentMonth)?.label || currentMonth }}</span>
<van-icon name="arrow-down" />
</div>
<div class="balance-row">
<div class="balance-item">
<div class="balance-label">收入</div>
<div class="balance-num income">{{ summary.totalIncome.toFixed(2) }}</div>
</div>
<div class="balance-divider" />
<div class="balance-item">
<div class="balance-label">支出</div>
<div class="balance-num expense">{{ summary.totalExpense.toFixed(2) }}</div>
</div>
<div class="balance-divider" />
<div class="balance-item">
<div class="balance-label">结余</div>
<div class="balance-num" :class="summary.balance >= 0 ? 'income' : 'expense'">
{{ summary.balance.toFixed(2) }}
</div>
</div>
</div>
</div>
<!-- 列表 -->
<div v-if="ledgers.length === 0" class="empty">
<van-empty description="本月暂无记录" image-size="100" />
</div>
<div v-else class="ledger-list">
<van-swipe-cell v-for="l in ledgers" :key="l.id">
<div class="ledger-item">
<div class="ledger-left">
<van-tag :type="typeColors[l.type]" size="medium">{{ LedgerTypeMap[l.type] }}</van-tag>
<div class="ledger-info">
<div class="ledger-desc">{{ l.description || LedgerCategoryMap[l.category] }}</div>
<div class="ledger-meta">
{{ LedgerCategoryMap[l.category] }} · {{ formatDate(l.occurredAt) }}
</div>
</div>
</div>
<div class="ledger-amount" :class="l.type">
{{ l.type === 'income' ? '+' : '-' }}{{ l.amount.toFixed(2) }}
</div>
</div>
<template #right>
<van-button square type="danger" text="删除" class="delete-btn" @click="handleDelete(l.id)" />
</template>
</van-swipe-cell>
</div>
<!-- 浮动添加按钮 -->
<div class="fab" @click="showAdd = true">
<van-icon name="plus" size="24" />
</div>
<!-- 月份选择 -->
<van-action-sheet v-model:show="showMonthPicker" title="选择月份">
<div class="month-list">
<div
v-for="m in months"
:key="m.value"
class="month-option"
:class="{ active: m.value === currentMonth }"
@click="changeMonth(m.value)"
>
{{ m.label }}
</div>
</div>
</van-action-sheet>
<!-- 添加弹层 -->
<van-popup v-model:show="showAdd" position="bottom" round closeable :style="{ height: '70%' }">
<div class="popup-content">
<div class="popup-title">添加账目</div>
<van-cell-group inset>
<van-field name="radio" label="类型">
<template #input>
<van-radio-group v-model="addForm.type" direction="horizontal">
<van-radio name="expense">支出</van-radio>
<van-radio name="income">收入</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field name="stepper" label="金额">
<template #input>
<van-stepper v-model="addForm.amount" min="0" step="0.01" allow-empty />
</template>
</van-field>
<van-field name="radio" label="分类">
<template #input>
<select v-model="addForm.category" class="category-select">
<option v-for="(label, key) in LedgerCategoryMap" :key="key" :value="key">{{ label }}</option>
</select>
</template>
</van-field>
<van-field v-model="addForm.description" label="备注" placeholder="可选" />
<van-field v-model="addForm.occurredAt" label="日期" placeholder="YYYY-MM-DD" />
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="addLoading" @click="handleAdd">添加</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.ledger-mobile { padding: 12px; min-height: 60vh; }
.summary-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-lg); padding: 16px; box-shadow: var(--gg-shadow); margin-bottom: 16px; }
.month-bar { display: flex; align-items: center; gap: 4px; font-size: 15px; font-weight: 600; color: var(--gg-text); margin-bottom: 14px; }
.balance-row { display: flex; align-items: center; }
.balance-item { flex: 1; text-align: center; }
.balance-label { font-size: 12px; color: var(--gg-text-muted); }
.balance-num { font-size: 18px; font-weight: 700; margin-top: 4px; }
.balance-num.income { color: var(--gg-success); }
.balance-num.expense { color: var(--gg-danger); }
.balance-divider { width: 1px; height: 32px; background: var(--gg-border); }
.empty { padding: 30px 0; }
.ledger-list { display: flex; flex-direction: column; gap: 1px; }
.ledger-item { display: flex; align-items: center; justify-content: space-between; background: var(--gg-bg-card); padding: 12px 14px; }
.ledger-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.ledger-info { min-width: 0; }
.ledger-desc { font-size: 14px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
.ledger-amount { font-size: 15px; font-weight: 600; flex-shrink: 0; }
.ledger-amount.income { color: var(--gg-success); }
.ledger-amount.expense { color: var(--gg-danger); }
.delete-btn { height: 100%; }
.fab { position: fixed; right: 20px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); width: 52px; height: 52px; border-radius: 50%; background: var(--gg-gradient-green); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(5,150,105,0.4); z-index: 20; }
.fab:active { transform: scale(0.92); }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.category-select { border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 4px 8px; font-size: 14px; }
.popup-actions { padding: 20px 16px 0; }
.month-list { padding: 8px 0; }
.month-option { padding: 14px 24px; font-size: 15px; color: var(--gg-text); }
.month-option.active { color: var(--gg-primary); font-weight: 600; background: rgba(5,150,105,0.06); }
</style>
+158
View File
@@ -0,0 +1,158 @@
<!-- src/views-mobile/LoginMobile.vue -->
<!-- 手机端登录页 -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { pb } from '@/api/pocketbase'
import { showFailToast } from 'vant'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const identity = ref('')
const password = ref('')
const loading = ref(false)
async function handleLogin() {
if (!identity.value || !password.value) {
showFailToast('请输入昵称/邮箱和密码')
return
}
try {
loading.value = true
let loginIdentity = identity.value.trim()
// 与桌面端一致:不含 @ 时按昵称或用户名查找对应 username
if (!loginIdentity.includes('@')) {
const result = await pb.collection('users').getList(1, 1, {
filter: `name="${loginIdentity}" || username="${loginIdentity}"`,
$autoCancel: false
})
if (result.items.length === 0) {
showFailToast('用户不存在')
return
}
loginIdentity = (result.items[0] as any).username
}
await userStore.login(loginIdentity, password.value)
const redirect = (route.query.redirect as string) || '/'
router.replace(redirect)
} catch (error: any) {
showFailToast(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-mobile">
<div class="brand-area">
<div class="brand-icon">🎮</div>
<h1 class="brand-title">Game Group</h1>
<p class="brand-subtitle">组队开黑一起嗨</p>
</div>
<div class="form-area">
<van-cell-group inset>
<van-field
v-model="identity"
label="账号"
placeholder="昵称 / 邮箱"
left-icon="contact"
clearable
/>
<van-field
v-model="password"
type="password"
label="密码"
placeholder="请输入密码"
left-icon="lock"
/>
</van-cell-group>
<div class="submit-area">
<van-button
type="primary"
block
round
:loading="loading"
loading-text="登录中..."
@click="handleLogin"
>
登录
</van-button>
</div>
<div class="footer-links">
还没有账号
<router-link :to="{ name: 'Register', query: route.query }" class="link">
立即注册
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.login-mobile {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
padding: 24px 16px;
background: linear-gradient(160deg, #ecfdf5 0%, #f0fdf4 50%, #ffffff 100%);
}
.brand-area {
text-align: center;
margin-bottom: 40px;
}
.brand-icon {
font-size: 56px;
margin-bottom: 12px;
}
.brand-title {
font-size: 28px;
font-weight: 800;
margin: 0 0 8px;
background: var(--gg-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-subtitle {
font-size: 14px;
color: var(--gg-text-secondary);
margin: 0;
}
.form-area {
max-width: 420px;
width: 100%;
margin: 0 auto;
}
.submit-area {
padding: 20px 16px 0;
}
.footer-links {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: var(--gg-text-muted);
}
.link {
color: var(--gg-primary);
font-weight: 500;
}
</style>
@@ -0,0 +1,393 @@
<!-- src/views-mobile/NotificationsMobile.vue -->
<!-- 手机端通知页站内通知 + 邀请/入群申请 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useNotificationStore } from '@/stores/notification'
import { respondInvitation } from '@/api/invitations'
import { respondJoinRequest } from '@/api/groups'
import { useGroupStore } from '@/stores/group'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import type { AppNotification } from '@/types'
const notificationStore = useNotificationStore()
const groupStore = useGroupStore()
const activeTab = ref<'notifications' | 'invitations'>('invitations')
const notifications = computed(() => notificationStore.appNotifications)
const invitations = computed(() => notificationStore.pendingInvitations)
const joinRequests = computed(() => notificationStore.pendingJoinRequests)
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
await notificationStore.loadAppNotifications()
await notificationStore.loadPendingInvitations()
} finally {
loading.value = false
}
})
// 通知操作
async function onRead(n: AppNotification) {
try {
await notificationStore.markRead(n.id)
} catch (e: any) {
showFailToast('操作失败')
}
}
async function onReadAll() {
try {
await notificationStore.markAllRead()
showSuccessToast('全部已读')
} catch (e: any) {
showFailToast('操作失败')
}
}
async function onDelete(n: AppNotification) {
showConfirmDialog({
title: '删除通知',
message: '确定删除这条通知吗?'
}).then(async () => {
try {
await notificationStore.removeNotification(n.id)
showSuccessToast('已删除')
} catch (e: any) {
showFailToast('删除失败')
}
}).catch(() => {})
}
// 通知类型图标/颜色
function notifyIcon(type: string): string {
const map: Record<string, string> = {
poll_new: 'chart-trending-o',
poll_deadline: 'clock-o',
poll_result: 'certificate',
team_invite: 'volume-o',
team_starting: 'fire-o',
join_request: 'add-o',
member_joined: 'user-o'
}
return map[type] || 'bell'
}
// 邀请操作
const invitationLoading = ref<string | null>(null)
async function handleInvitation(invitationId: string, response: 'accepted' | 'rejected') {
invitationLoading.value = invitationId
try {
await respondInvitation(invitationId, response)
notificationStore.removeInvitation(invitationId)
showSuccessToast(response === 'accepted' ? '已接受' : '已拒绝')
} catch (e: any) {
showFailToast(e.message || '操作失败')
} finally {
invitationLoading.value = null
}
}
// 入群申请操作
const requestLoading = ref<string | null>(null)
async function handleJoinRequest(requestId: string, status: 'approved' | 'rejected') {
requestLoading.value = requestId
try {
await respondJoinRequest(requestId, status)
notificationStore.removeJoinRequest(requestId)
await groupStore.loadGroups()
showSuccessToast(status === 'approved' ? '已通过' : '已拒绝')
} catch (e: any) {
showFailToast(e.message || '操作失败')
} finally {
requestLoading.value = null
}
}
// 时间格式化
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const min = Math.floor(diff / 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')
}
</script>
<template>
<div class="notifications-mobile">
<van-tabs v-model:active="activeTab" sticky>
<van-tab title="邀请/申请" name="invitations">
<div v-if="invitations.length === 0 && joinRequests.length === 0" class="empty-state">
<van-empty description="暂无待处理邀请" image-size="100" />
</div>
<!-- 组队邀请 -->
<div v-if="invitations.length > 0" class="section-block">
<div class="block-title">组队邀请</div>
<div
v-for="inv in invitations"
:key="inv.id"
class="invite-card"
>
<div class="invite-info">
<img
:src="inv.expand?.from?.avatar || '/default-avatar.svg'"
class="invite-avatar"
alt=""
/>
<div class="invite-text">
<div class="invite-from">{{ inv.expand?.from?.name || inv.expand?.from?.username || '未知' }}</div>
<div class="invite-detail">
邀请你组队{{ inv.expand?.teamSession?.gameName || '游戏' }}
</div>
</div>
</div>
<div class="invite-actions">
<van-button
type="primary"
size="small"
round
:loading="invitationLoading === inv.id"
@click="handleInvitation(inv.id, 'accepted')"
>
接受
</van-button>
<van-button
size="small"
round
:loading="invitationLoading === inv.id"
@click="handleInvitation(inv.id, 'rejected')"
>
拒绝
</van-button>
</div>
</div>
</div>
<!-- 入群申请 -->
<div v-if="joinRequests.length > 0" class="section-block">
<div class="block-title">入群申请</div>
<div
v-for="req in joinRequests"
:key="req.id"
class="invite-card"
>
<div class="invite-info">
<img
:src="req.expand?.user?.avatar || '/default-avatar.svg'"
class="invite-avatar"
alt=""
/>
<div class="invite-text">
<div class="invite-from">{{ req.expand?.user?.name || req.expand?.user?.username || '未知' }}</div>
<div class="invite-detail">
申请加入{{ req.expand?.group?.name || '群组' }}
</div>
</div>
</div>
<div class="invite-actions">
<van-button
type="primary"
size="small"
round
:loading="requestLoading === req.id"
@click="handleJoinRequest(req.id, 'approved')"
>
通过
</van-button>
<van-button
size="small"
round
:loading="requestLoading === req.id"
@click="handleJoinRequest(req.id, 'rejected')"
>
拒绝
</van-button>
</div>
</div>
</div>
</van-tab>
<van-tab title="通知" name="notifications">
<div v-if="notifications.length > 0" class="read-all-bar">
<van-button plain hairline size="small" round icon="success" @click="onReadAll">
全部标为已读
</van-button>
</div>
<div v-if="notifications.length === 0" class="empty-state">
<van-empty description="暂无通知" image-size="100" />
</div>
<div v-else class="notify-list">
<van-swipe-cell
v-for="n in notifications"
:key="n.id"
>
<div
class="notify-item"
:class="{ 'notify-unread': !n.read }"
@click="!n.read && onRead(n)"
>
<van-icon :name="notifyIcon(n.type)" class="notify-icon" size="22" />
<div class="notify-body">
<div class="notify-title">{{ n.title }}</div>
<div v-if="n.content" class="notify-content">{{ n.content }}</div>
<div class="notify-time">{{ timeAgo(n.created) }}</div>
</div>
<span v-if="!n.read" class="unread-dot" />
</div>
<template #right>
<van-button square type="danger" text="删除" class="delete-btn" @click="onDelete(n)" />
</template>
</van-swipe-cell>
</div>
</van-tab>
</van-tabs>
</div>
</template>
<style scoped>
.notifications-mobile {
min-height: 60vh;
}
.empty-state {
padding: 40px 0;
}
.read-all-bar {
display: flex;
justify-content: flex-end;
padding: 8px 16px;
}
.section-block {
padding: 12px;
}
.block-title {
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
margin-bottom: 8px;
padding: 0 4px;
}
.invite-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 14px;
margin-bottom: 10px;
box-shadow: var(--gg-shadow);
}
.invite-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.invite-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.invite-text {
flex: 1;
min-width: 0;
}
.invite-from {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.invite-detail {
font-size: 13px;
color: var(--gg-text-secondary);
margin-top: 2px;
}
.invite-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 通知列表 */
.notify-list {
padding: 0 12px;
}
.notify-item {
display: flex;
align-items: flex-start;
gap: 12px;
background: var(--gg-bg-card);
padding: 14px;
border-radius: var(--gg-radius-sm);
margin-bottom: 1px;
}
.notify-unread {
background: rgba(5, 150, 105, 0.04);
}
.notify-icon {
color: var(--gg-primary);
flex-shrink: 0;
margin-top: 2px;
}
.notify-body {
flex: 1;
min-width: 0;
}
.notify-title {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.notify-content {
font-size: 13px;
color: var(--gg-text-secondary);
margin-top: 4px;
line-height: 1.5;
}
.notify-time {
font-size: 11px;
color: var(--gg-text-muted);
margin-top: 6px;
}
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--gg-danger);
flex-shrink: 0;
margin-top: 6px;
}
.delete-btn {
height: 100%;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<!-- src/views-mobile/Placeholder.vue -->
<!-- 占位页手机端尚未实现的页面暂时显示此组件 -->
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<div class="placeholder-page">
<van-empty description="该页面手机版开发中">
<p class="placeholder-hint">当前页面{{ route.name }}</p>
</van-empty>
</div>
</template>
<style scoped>
.placeholder-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 24px;
}
.placeholder-hint {
margin-top: 8px;
font-size: 12px;
color: var(--gg-text-muted);
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<!-- src/views-mobile/ProfileMobile.vue -->
<!-- 手机端个人中心 -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { UserStatusMap } from '@/types'
import type { UserStatus } from '@/types'
import { resetDeviceMode, setDeviceMode } from '@/mobile/useDevice'
import { showSuccessToast, showConfirmDialog } from 'vant'
const router = useRouter()
const userStore = useUserStore()
const groupStore = useGroupStore()
const user = computed(() => userStore.user)
const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知')
const showStatusSheet = ref(false)
const statusActions = [
{ status: 'idle' as UserStatus, name: '🟢 空闲' },
{ status: 'away' as UserStatus, name: '⚫ 离开' },
{ status: 'working' as UserStatus, name: '🔴 工作中' },
]
async function onStatusSelect(action: any) {
showStatusSheet.value = false
try {
await userStore.setStatus(action.status)
showSuccessToast('已切换')
} catch { /* ignore */ }
}
function goGroups() {
router.push('/mobile-groups')
}
function goChangelog() {
router.push('/changelog')
}
function goSettings() {
router.push('/settings')
}
// 切换桌面版
function switchToDesktop() {
showConfirmDialog({
title: '切换到桌面版',
message: '切换后页面将以桌面版显示,确定吗?'
}).then(() => {
setDeviceMode('desktop')
location.reload()
}).catch(() => {})
}
function handleLogout() {
showConfirmDialog({ title: '退出登录', message: '确定退出吗?' })
.then(() => {
resetDeviceMode()
userStore.logout()
}).catch(() => {})
}
</script>
<template>
<div class="profile-mobile">
<!-- 用户头部 -->
<div class="user-header">
<img :src="user?.avatar || '/default-avatar.svg'" class="user-avatar" alt="" />
<div class="user-info">
<div class="user-name">{{ user?.name || user?.username }}</div>
<div class="user-id">@{{ user?.username }}</div>
</div>
</div>
<!-- 数据概览 -->
<div class="stats-row">
<div class="stat-item">
<div class="stat-num">{{ user?.points ?? 0 }}</div>
<div class="stat-label">积分</div>
</div>
<div class="stat-item" @click="goGroups">
<div class="stat-num">{{ groupStore.groups.length }}</div>
<div class="stat-label">群组</div>
</div>
</div>
<!-- 状态切换 -->
<van-cell-group inset title="我的状态">
<van-cell title="当前状态" :value="statusText" is-link @click="showStatusSheet = true" />
</van-cell-group>
<!-- 功能入口 -->
<van-cell-group inset title="更多">
<van-cell title="我的群组" icon="friends-o" is-link @click="goGroups" />
<van-cell title="更新日志" icon="notes-o" is-link @click="goChangelog" />
<van-cell title="设置" icon="setting-o" is-link @click="goSettings" />
</van-cell-group>
<!-- 视图切换 -->
<van-cell-group inset title="视图">
<van-cell title="切换到桌面版" icon="desktop-o" is-link @click="switchToDesktop" />
</van-cell-group>
<!-- 退出 -->
<div class="logout-area">
<van-button type="danger" block round plain @click="handleLogout">退出登录</van-button>
</div>
<!-- 状态选择 -->
<van-action-sheet
v-model:show="showStatusSheet"
title="切换状态"
:actions="statusActions"
@select="onStatusSelect"
/>
</div>
</template>
<style scoped>
.profile-mobile { padding: 12px 0 24px; display: flex; flex-direction: column; gap: 16px; }
.user-header { display: flex; align-items: center; gap: 14px; padding: 20px 16px; background: var(--gg-bg-card); margin: 0 12px; border-radius: var(--gg-radius-lg); box-shadow: var(--gg-shadow); }
.user-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; border: 2px solid var(--gg-border); }
.user-info { min-width: 0; }
.user-name { font-size: 18px; font-weight: 700; color: var(--gg-text); }
.user-id { font-size: 13px; color: var(--gg-text-muted); margin-top: 2px; }
.stats-row { display: flex; gap: 12px; padding: 0 12px; }
.stat-item { flex: 1; background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 16px; text-align: center; box-shadow: var(--gg-shadow); }
.stat-num { font-size: 24px; font-weight: 700; color: var(--gg-primary); }
.stat-label { font-size: 12px; color: var(--gg-text-muted); margin-top: 2px; }
.logout-area { padding: 8px 28px; }
</style>
@@ -0,0 +1,166 @@
<!-- src/views-mobile/RegisterMobile.vue -->
<!-- 手机端注册页 -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { showFailToast, showSuccessToast } from 'vant'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const name = ref('')
const password = ref('')
const passwordConfirm = ref('')
const loading = ref(false)
// 自动生成 username(与桌面端 Register 一致:'u' + 时间戳base36 + 随机)
const generatedUsername = computed(() => {
return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
})
async function handleRegister() {
if (!name.value.trim()) {
showFailToast('请输入昵称')
return
}
if (!password.value || password.value.length < 6) {
showFailToast('密码至少 6 位')
return
}
if (password.value !== passwordConfirm.value) {
showFailToast('两次密码不一致')
return
}
try {
loading.value = true
const username = generatedUsername.value
// 注册用 email 占位(与桌面端一致,username 为系统标识,email 用 username@local 模拟)
await userStore.register({
email: `${username}@gamegroup.local`,
password: password.value,
passwordConfirm: passwordConfirm.value,
username,
name: name.value.trim()
})
showSuccessToast('注册成功')
const redirect = (route.query.redirect as string) || '/'
router.replace(redirect)
} catch (error: any) {
showFailToast(error.message || '注册失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="register-mobile">
<van-nav-bar title="注册" left-arrow @click-left="router.back()" />
<div class="form-area">
<div class="intro">
<div class="intro-icon">🎮</div>
<h2 class="intro-title">创建账号</h2>
<p class="intro-desc">输入昵称开始你的组队之旅</p>
</div>
<van-cell-group inset>
<van-field
v-model="name"
label="昵称"
placeholder="请输入中文昵称"
left-icon="contact"
clearable
/>
<van-field
v-model="password"
type="password"
label="密码"
placeholder="至少 6 位"
left-icon="lock"
/>
<van-field
v-model="passwordConfirm"
type="password"
label="确认密码"
placeholder="再次输入密码"
left-icon="lock"
/>
</van-cell-group>
<div class="submit-area">
<van-button
type="primary"
block
round
:loading="loading"
loading-text="注册中..."
@click="handleRegister"
>
注册
</van-button>
</div>
<div class="footer-links">
已有账号
<router-link :to="{ name: 'Login', query: route.query }" class="link">
去登录
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.register-mobile {
min-height: 100vh;
background: var(--gg-bg);
}
.form-area {
padding: 24px 0;
}
.intro {
text-align: center;
margin-bottom: 28px;
padding: 0 16px;
}
.intro-icon {
font-size: 48px;
margin-bottom: 8px;
}
.intro-title {
font-size: 22px;
font-weight: 700;
color: var(--gg-text);
margin: 0 0 6px;
}
.intro-desc {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
.submit-area {
padding: 20px 16px 0;
}
.footer-links {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: var(--gg-text-muted);
}
.link {
color: var(--gg-primary);
font-weight: 500;
}
</style>
@@ -0,0 +1,59 @@
<!-- src/views-mobile/SettingsMobile.vue -->
<!-- 手机端设置 -->
<script setup lang="ts">
import { ref } from 'vue'
import { setDeviceMode } from '@/mobile/useDevice'
import { showConfirmDialog, showSuccessToast } from 'vant'
const notifyEnabled = ref(true)
const soundEnabled = ref(true)
function onNotifyToggle(val: boolean) {
notifyEnabled.value = val
showSuccessToast(val ? '已开启通知' : '已关闭通知')
}
function switchToDesktop() {
showConfirmDialog({
title: '切换到桌面版',
message: '切换后页面将以桌面版显示,确定吗?'
}).then(() => {
setDeviceMode('desktop')
location.reload()
}).catch(() => {})
}
function about() {
showSuccessToast('Game Group V2')
}
</script>
<template>
<div class="settings-mobile">
<van-cell-group inset title="通知">
<van-cell title="站内通知" center>
<template #right-icon>
<van-switch :model-value="notifyEnabled" size="22px" @update:model-value="onNotifyToggle" />
</template>
</van-cell>
<van-cell title="提示音" center>
<template #right-icon>
<van-switch v-model="soundEnabled" size="22px" />
</template>
</van-cell>
</van-cell-group>
<van-cell-group inset title="视图">
<van-cell title="切换到桌面版" icon="desktop-o" is-link @click="switchToDesktop" />
</van-cell-group>
<van-cell-group inset title="关于">
<van-cell title="版本" value="v2.0.0" />
<van-cell title="关于应用" is-link @click="about" />
</van-cell-group>
</div>
</template>
<style scoped>
.settings-mobile { padding: 12px 0; }
</style>
@@ -0,0 +1,255 @@
<!-- src/views-mobile/VoiceRoomMobile.vue -->
<!-- 手机端语音房成员网格 + 控制条 + 占位提示实际语音待 App -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTeamStore } from '@/stores/team'
import { getUser } from '@/api/users'
import { pb } from '@/api/pocketbase'
import type { User } from '@/types'
import { displayName } from '@/types'
const route = useRoute()
const router = useRouter()
const teamStore = useTeamStore()
const sessionId = route.params.sessionId as string
const groupId = route.params.groupId as string
const session = computed(() => teamStore.currentSession)
const gameName = computed(() => session.value?.gameName || '语音房间')
const members = ref<User[]>([])
const loading = ref(true)
// UI 状态(实际未连接 WebRTC
const micEnabled = ref(true)
const speakerEnabled = ref(true)
onMounted(async () => {
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
await teamStore.loadActiveSession()
}
// 加载成员详情
const s = teamStore.currentSession
if (s?.members?.length) {
try {
const users = await Promise.all(s.members.map(id => getUser(id).catch(() => null)))
members.value = users.filter(Boolean) as User[]
} catch (e) {
console.error(e)
}
}
loading.value = false
})
function toggleMic() {
micEnabled.value = !micEnabled.value
}
function toggleSpeaker() {
speakerEnabled.value = !speakerEnabled.value
}
async function handleLeave() {
router.replace(`/group/${groupId}`)
}
const currentUserId = computed(() => pb.authStore.model?.id as string | undefined)
</script>
<template>
<div class="voice-room-mobile">
<!-- 顶部 -->
<div class="room-header">
<van-icon name="arrow-left" size="20" @click="handleLeave" />
<div class="room-title-area">
<div class="room-game">{{ gameName }}</div>
<div class="room-status">
<span class="status-dot online" />
<span>{{ members.length }} 人在线</span>
</div>
</div>
</div>
<!-- 占位提示 -->
<van-notice-bar
left-icon="info-o"
text="语音功能请在 App 中使用,当前为预览界面"
class="voice-notice"
/>
<!-- 成员网格 -->
<div v-if="loading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else class="members-grid">
<div
v-for="m in members"
:key="m.id"
class="member-tile"
:class="{ 'is-me': m.id === currentUserId }"
>
<div class="avatar-wrap">
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="mic-indicator" :class="{ off: !micEnabled && m.id === currentUserId }">
<van-icon :name="micEnabled && m.id === currentUserId ? 'volume-o' : 'volume'" />
</div>
</div>
<div class="member-name">
{{ displayName(m) }}
<span v-if="m.id === currentUserId" class="me-tag"></span>
</div>
</div>
<div v-if="members.length === 0" class="empty-members">
<van-empty description="房间无人" image-size="80" />
</div>
</div>
<!-- 底部控制条 -->
<div class="control-bar">
<div class="control-btn" :class="{ active: micEnabled }" @click="toggleMic">
<van-icon :name="micEnabled ? 'volume-o' : 'volume'" size="26" />
<span>{{ micEnabled ? '麦克风' : '已静音' }}</span>
</div>
<div class="control-btn" :class="{ active: speakerEnabled }" @click="toggleSpeaker">
<van-icon :name="speakerEnabled ? 'ear' : 'closed-eye'" size="26" />
<span>{{ speakerEnabled ? '扬声器' : '已关闭' }}</span>
</div>
<div class="control-btn leave-btn" @click="handleLeave">
<van-icon name="cross" size="26" />
<span>退出</span>
</div>
</div>
</div>
</template>
<style scoped>
.voice-room-mobile {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
color: #fff;
}
.room-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
.room-title-area { flex: 1; }
.room-game { font-size: 18px; font-weight: 700; }
.room-status { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: 0.8; margin-top: 4px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
.status-dot.online { background: #10b981; }
.voice-notice { margin: 0 12px; border-radius: var(--gg-radius-sm); }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.members-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 24px 16px;
align-content: start;
}
.member-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar-wrap {
position: relative;
width: 72px;
height: 72px;
}
.member-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(255,255,255,0.2);
}
.member-tile.is-me .member-avatar {
border-color: var(--gg-primary);
}
.mic-indicator {
position: absolute;
bottom: 0;
right: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gg-success);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #1e293b;
font-size: 12px;
}
.mic-indicator.off {
background: var(--gg-text-muted);
}
.member-name {
font-size: 13px;
text-align: center;
display: flex;
align-items: center;
gap: 4px;
}
.me-tag {
font-size: 10px;
background: var(--gg-primary);
padding: 0 4px;
border-radius: 4px;
}
.empty-members { grid-column: 1 / -1; }
.control-bar {
display: flex;
justify-content: space-around;
padding: 16px 24px calc(16px + env(safe-area-inset-bottom, 0px));
background: rgba(0,0,0,0.3);
backdrop-filter: blur(10px);
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 11px;
opacity: 0.6;
transition: opacity 0.2s;
}
.control-btn.active {
opacity: 1;
}
.control-btn:active {
transform: scale(0.95);
}
.leave-btn {
color: var(--gg-danger);
opacity: 1;
}
</style>
+19
View File
@@ -9,6 +9,25 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src')
}
},
build: {
rollupOptions: {
output: {
// 代码分割:vendor 按依赖分组,避免单个超大 chunk
manualChunks: {
// Vue 核心运行时(vue + vue-router + pinia
'vue-vendor': ['vue', 'vue-router', 'pinia'],
// 桌面端 UI 库
'element-plus': ['element-plus', '@element-plus/icons-vue'],
// 手机端 UI 库
'vant': ['vant'],
// 后端 SDK
'pocketbase': ['pocketbase'],
// 语音房依赖(仅 VoiceRoom 用到,体积大,单独拆分)
'livekit': ['livekit-client'],
}
}
}
},
server: {
port: Number(process.env.VITE_PORT) || 5173,
proxy: {