feat(mobile): complete mobile frontend - all pages + code splitting
This commit is contained in:
@@ -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,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 } 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 (await import('@/api/memories')).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>
|
||||||
@@ -22,7 +22,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Login',
|
name: 'Login',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Login.vue'),
|
() => import('@/views/Login.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 LoginMobile
|
() => import('@/views-mobile/LoginMobile.vue')
|
||||||
),
|
),
|
||||||
meta: { requiresGuest: true }
|
meta: { requiresGuest: true }
|
||||||
},
|
},
|
||||||
@@ -31,7 +31,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Register',
|
name: 'Register',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Register.vue'),
|
() => import('@/views/Register.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 RegisterMobile
|
() => import('@/views-mobile/RegisterMobile.vue')
|
||||||
),
|
),
|
||||||
meta: { requiresGuest: true }
|
meta: { requiresGuest: true }
|
||||||
},
|
},
|
||||||
@@ -45,7 +45,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Home',
|
name: 'Home',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Home.vue'),
|
() => import('@/views/Home.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 HomeMobile
|
() => import('@/views-mobile/HomeMobile.vue')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,7 +53,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'MobileGroups',
|
name: 'MobileGroups',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
|
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 GroupsMobile
|
() => import('@/views-mobile/GroupsMobile.vue')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,7 +61,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'MobileNotifications',
|
name: 'MobileNotifications',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Home.vue'),
|
() => import('@/views/Home.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 NotificationsMobile
|
() => import('@/views-mobile/NotificationsMobile.vue')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,7 +69,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'GroupView',
|
name: 'GroupView',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/GroupView.vue'),
|
() => import('@/views/GroupView.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 4 替换为 GroupViewMobile
|
() => import('@/views-mobile/GroupViewMobile.vue')
|
||||||
),
|
),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
@@ -78,7 +78,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'LedgerView',
|
name: 'LedgerView',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/LedgerView.vue'),
|
() => import('@/views/LedgerView.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 LedgerMobile
|
() => import('@/views-mobile/LedgerMobile.vue')
|
||||||
),
|
),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
@@ -87,7 +87,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'AssetView',
|
name: 'AssetView',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/AssetView.vue'),
|
() => import('@/views/AssetView.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 AssetMobile
|
() => import('@/views-mobile/AssetMobile.vue')
|
||||||
),
|
),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
@@ -96,7 +96,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'BlacklistView',
|
name: 'BlacklistView',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/BlacklistView.vue'),
|
() => import('@/views/BlacklistView.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 BlacklistMobile
|
() => import('@/views-mobile/BlacklistMobile.vue')
|
||||||
),
|
),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
@@ -105,7 +105,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'VoiceRoom',
|
name: 'VoiceRoom',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/VoiceRoom.vue'),
|
() => import('@/views/VoiceRoom.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 5 替换为 VoiceRoomMobile
|
() => import('@/views-mobile/VoiceRoomMobile.vue')
|
||||||
),
|
),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'GamesLibrary',
|
name: 'GamesLibrary',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/GamesLibrary.vue'),
|
() => import('@/views/GamesLibrary.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 7 替换为 GamesLibraryMobile
|
() => import('@/views-mobile/GamesLibraryMobile.vue')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -122,7 +122,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Profile.vue'),
|
() => import('@/views/Profile.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ProfileMobile
|
() => import('@/views-mobile/ProfileMobile.vue')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -130,7 +130,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Settings.vue'),
|
() => import('@/views/Settings.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 SettingsMobile
|
() => import('@/views-mobile/SettingsMobile.vue')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -138,7 +138,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Changelog',
|
name: 'Changelog',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/Changelog.vue'),
|
() => import('@/views/Changelog.vue'),
|
||||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ChangelogMobile
|
() => import('@/views-mobile/ChangelogMobile.vue')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,101 @@
|
|||||||
|
<!-- src/views-mobile/ChangelogMobile.vue -->
|
||||||
|
<!-- 手机端更新日志:时间线列表 -->
|
||||||
|
<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-15',
|
||||||
|
title: '手机端前端',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '完整手机端界面:底部 Tab 导航、顶部栏、所有功能页面移动端适配' },
|
||||||
|
{ type: 'feat', text: '设备自动识别:手机访问自动进入手机版,桌面访问保持桌面版' },
|
||||||
|
{ type: 'feat', text: 'Vant 组件库集成:下拉刷新、ActionSheet、弹层等移动端交互' },
|
||||||
|
{ type: 'feat', text: '语音房手机端预览:成员网格 + 控制条 UI(实际语音待 App)' },
|
||||||
|
{ type: 'style', text: '代码分割优化:vendor 拆分,减少首屏加载体积' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.2',
|
||||||
|
date: '2026-04-19',
|
||||||
|
title: '实时语音房间',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '语音房间:组队中可进入独立语音房间,实时语音通话(基于 LiveKit WebRTC)' },
|
||||||
|
{ type: 'feat', text: '成员头像网格:显示在线成员,说话时绿圈呼吸动画提示' },
|
||||||
|
{ type: 'feat', text: '麦克风/扬声器开关:独立控制' },
|
||||||
|
{ type: 'fix', text: '修复 WebRTC ICE 连接失败' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.3.1',
|
||||||
|
date: '2026-04-19',
|
||||||
|
title: '玩家黑名单',
|
||||||
|
items: [
|
||||||
|
{ type: 'feat', text: '玩家黑名单:标记外部平台坑玩家(挂机、送人头、喷人等)' },
|
||||||
|
{ 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: '修复竞猜双重结算和竞态安全漏洞' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
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,170 @@
|
|||||||
|
<!-- src/views-mobile/GamesLibraryMobile.vue -->
|
||||||
|
<!-- 手机端游戏库:搜索 + 瀑布流 + 详情 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { getPopularGames, searchGames, getAllPlatforms } from '@/api/games'
|
||||||
|
import type { Game } from '@/types'
|
||||||
|
|
||||||
|
const popularGames = ref<Game[]>([])
|
||||||
|
const searchResults = ref<Game[]>([])
|
||||||
|
const keyword = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedPlatform = ref<string>('')
|
||||||
|
|
||||||
|
const showDetail = ref(false)
|
||||||
|
const currentGame = ref<Game | null>(null)
|
||||||
|
|
||||||
|
const displayGames = computed(() => {
|
||||||
|
return keyword.value.trim() ? searchResults.value : popularGames.value
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadPopular()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPopular() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
popularGames.value = await getPopularGames(50)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function onSearch(val: string) {
|
||||||
|
keyword.value = val
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
if (!val.trim()) {
|
||||||
|
searchResults.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimer = setTimeout(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
searchResults.value = await searchGames(val.trim(), undefined, 50)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(game: Game) {
|
||||||
|
currentGame.value = game
|
||||||
|
showDetail.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="games-mobile">
|
||||||
|
<van-search
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索游戏"
|
||||||
|
shape="round"
|
||||||
|
show-action
|
||||||
|
@search="onSearch(keyword)"
|
||||||
|
@update:model-value="onSearch"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<span v-if="keyword" @click="keyword = ''; searchResults = []">取消</span>
|
||||||
|
</template>
|
||||||
|
</van-search>
|
||||||
|
|
||||||
|
<div class="platform-bar">
|
||||||
|
<van-tag
|
||||||
|
v-for="p in [''].concat(platforms)"
|
||||||
|
:key="p"
|
||||||
|
:type="selectedPlatform === p ? 'primary' : 'default'"
|
||||||
|
size="medium"
|
||||||
|
round
|
||||||
|
class="platform-tag"
|
||||||
|
@click="selectedPlatform = p"
|
||||||
|
>
|
||||||
|
{{ p || '全部' }}
|
||||||
|
</van-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-box">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="displayGames.length === 0" class="empty">
|
||||||
|
<van-empty :description="keyword ? '未找到游戏' : '暂无游戏'" image-size="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="games-grid">
|
||||||
|
<div
|
||||||
|
v-for="game in displayGames.filter(g => !selectedPlatform || g.platform === selectedPlatform)"
|
||||||
|
:key="game.id"
|
||||||
|
class="game-card"
|
||||||
|
@click="openDetail(game)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="game.cover || '/game-placeholder.svg'"
|
||||||
|
:alt="game.name"
|
||||||
|
class="game-cover"
|
||||||
|
/>
|
||||||
|
<div class="game-name">{{ game.name }}</div>
|
||||||
|
<div class="game-tags">
|
||||||
|
<van-tag v-if="game.platform" plain size="medium">{{ game.platform }}</van-tag>
|
||||||
|
<van-tag v-if="game.popularCount > 0" type="danger" plain size="medium">热门{{ game.popularCount }}</van-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-popup
|
||||||
|
v-model:show="showDetail"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '70%' }"
|
||||||
|
>
|
||||||
|
<div v-if="currentGame" class="detail-content">
|
||||||
|
<img
|
||||||
|
v-if="currentGame.cover"
|
||||||
|
:src="currentGame.cover"
|
||||||
|
class="detail-cover"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="detail-info">
|
||||||
|
<h2 class="detail-name">{{ currentGame.name }}</h2>
|
||||||
|
<div class="detail-tags">
|
||||||
|
<van-tag v-if="currentGame.platform" type="primary">{{ currentGame.platform }}</van-tag>
|
||||||
|
<van-tag v-for="t in currentGame.tags" :key="t" plain>{{ t }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentGame.tags?.length === 0 && !currentGame.platform" class="detail-empty">
|
||||||
|
暂无详细信息
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.games-mobile { padding-bottom: 16px; }
|
||||||
|
.platform-bar { display: flex; gap: 8px; padding: 0 12px 12px; overflow-x: auto; }
|
||||||
|
.platform-bar::-webkit-scrollbar { display: none; }
|
||||||
|
.platform-tag { flex-shrink: 0; }
|
||||||
|
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||||
|
.empty { padding: 30px 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); }
|
||||||
|
.game-card:active { opacity: 0.85; }
|
||||||
|
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: var(--gg-bg-elevated); display: block; }
|
||||||
|
.game-name { font-size: 14px; font-weight: 500; color: var(--gg-text); padding: 8px 10px 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.game-tags { display: flex; gap: 4px; padding: 0 10px 10px; }
|
||||||
|
.detail-content { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||||
|
.detail-cover { width: 100%; height: 200px; object-fit: cover; }
|
||||||
|
.detail-info { padding: 20px 16px; }
|
||||||
|
.detail-name { font-size: 22px; font-weight: 700; color: var(--gg-text); margin: 0 0 12px; }
|
||||||
|
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.detail-empty { color: var(--gg-text-muted); font-size: 14px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<!-- src/views-mobile/GroupViewMobile.vue -->
|
||||||
|
<!-- 手机端群组详情:群信息 + 可横滑标签栏(动态/投票/竞猜/回忆/成员/账本/资产/黑名单/统计) -->
|
||||||
|
<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 { 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 */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标签配置
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'activity', 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" />
|
||||||
|
<PollListMobile v-else-if="activeTab === 'polls'" :group-id="groupId" />
|
||||||
|
<BetListMobile v-else-if="activeTab === 'bets'" :group-id="groupId" />
|
||||||
|
<MemberListMobile v-else-if="activeTab === 'members'" :group-id="groupId" />
|
||||||
|
<MemoryGridMobile v-else-if="activeTab === 'memories'" :group-id="groupId" />
|
||||||
|
<StatsPanelMobile v-else-if="activeTab === 'stats'" :group-id="groupId" />
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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,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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -9,6 +9,25 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, 'src')
|
'@': 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: {
|
server: {
|
||||||
port: Number(process.env.VITE_PORT) || 5173,
|
port: Number(process.env.VITE_PORT) || 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user