feat(mobile): stage 9 - memories, stats, profile, settings, changelog

- migrate MemoryGridMobile.vue (grid + upload + fullscreen preview + delete)
- migrate StatsPanelMobile.vue (overview + points ranking)
- migrate ProfileMobile.vue (user header + stats + status + entries + logout)
- migrate SettingsMobile.vue (notify/sound toggles + desktop switch + about)
- migrate ChangelogMobile.vue (timeline; data synced with uat PC v0.3.6 + mobile v2.1.0 entry)
- GroupViewMobile: wire memories/stats tabs (replace placeholders)
- router: wire Profile/Settings/Changelog mobile views
- verified: memories/points APIs + Memory type + user store logout/setStatus match uat

build verified: vue-tsc + vite build pass
This commit is contained in:
锦麟 王
2026-06-18 11:25:10 +08:00
parent 13e87110ae
commit 4b54f71902
7 changed files with 677 additions and 4 deletions
@@ -0,0 +1,211 @@
<!-- src/components-mobile/memory/MemoryGridMobile.vue -->
<!-- 手机端回忆九宫格 + 上传 + 全屏浏览 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { listMemories, deleteMemory, uploadMemory } from '@/api/memories'
import { pb } from '@/api/pocketbase'
import type { Memory } from '@/types'
import { showSuccessToast, showFailToast, showImagePreview, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const memories = ref<Memory[]>([])
const loading = ref(false)
async function loadMemories() {
loading.value = true
try {
const result = await listMemories(props.groupId, { limit: 100 })
memories.value = result.items
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadMemories()
})
// 文件 URL
function fileUrl(m: Memory): string {
if (!m.file) return ''
return pb.files.getUrl(m as any, m.file) as string
}
function thumbUrl(m: Memory): string {
if (!m.file) return ''
return pb.files.getUrl(m as any, m.file, { thumb: '300x300' }) as string
}
// 图片记忆列表(用于全屏预览)
const imageMemories = computed(() => memories.value.filter(m => m.fileType === 'image'))
function previewImage(index: number) {
const urls = imageMemories.value.map(m => fileUrl(m))
showImagePreview(urls, index)
}
// 上传
const showUpload = ref(false)
const uploadFiles = ref<File[]>([])
const uploadTitle = ref('')
const uploading = ref(false)
function onFileSelect(items: any) {
const arr = Array.isArray(items) ? items : [items]
uploadFiles.value = arr.map((it: any) => it.file).filter(Boolean)
}
async function handleUpload() {
if (uploadFiles.value.length === 0) {
showFailToast('请选择文件')
return
}
uploading.value = true
try {
for (const file of uploadFiles.value) {
await uploadMemory(props.groupId, file, {
title: uploadTitle.value.trim() || file.name
})
}
showSuccessToast(`上传 ${uploadFiles.value.length} 个文件成功`)
showUpload.value = false
uploadFiles.value = []
uploadTitle.value = ''
await loadMemories()
} catch (e: any) {
showFailToast(e.message || '上传失败')
} finally {
uploading.value = false
}
}
// 删除
async function handleDelete(m: Memory) {
showConfirmDialog({ title: '删除', message: '确定删除这个回忆吗?' })
.then(async () => {
try {
await deleteMemory(m.id)
showSuccessToast('已删除')
await loadMemories()
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
function fileTypeIcon(type: string): string {
const map: Record<string, string> = {
image: 'photo-o', video: 'video-o', audio: 'music-o', document: 'description', other: 'description'
}
return map[type] || 'description'
}
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="memory-mobile">
<div class="list-header">
<span class="count-text">{{ memories.length }} 个回忆</span>
<van-button type="primary" size="small" round icon="photo-o" @click="showUpload = true">
上传
</van-button>
</div>
<div v-if="loading && memories.length === 0" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="memories.length === 0" class="empty">
<van-empty description="还没有回忆" image-size="100" />
</div>
<div v-else class="memory-grid">
<div
v-for="m in memories"
:key="m.id"
class="memory-item"
>
<div class="item-thumb" @click="m.fileType === 'image' ? previewImage(imageMemories.indexOf(m)) : null">
<img
v-if="m.fileType === 'image' && m.file"
:src="thumbUrl(m)"
class="thumb-img"
alt=""
/>
<div v-else class="thumb-placeholder">
<van-icon :name="fileTypeIcon(m.fileType)" size="32" />
</div>
<van-tag v-if="m.fileType !== 'image'" class="type-tag" size="medium">
{{ m.fileType }}
</van-tag>
</div>
<div class="item-info">
<div class="item-title">{{ m.title }}</div>
<div class="item-meta">{{ timeAgo(m.created) }}</div>
</div>
<van-icon name="delete-o" class="delete-icon" @click="handleDelete(m)" />
</div>
</div>
<!-- 上传弹层 -->
<van-popup v-model:show="showUpload" position="bottom" round closeable :style="{ height: '60%' }">
<div class="popup-content">
<div class="popup-title">上传回忆</div>
<van-cell-group inset>
<van-field v-model="uploadTitle" label="标题" placeholder="可选" />
</van-cell-group>
<div class="upload-area">
<van-uploader
multiple
:after-read="onFileSelect"
:max-count="9"
accept="image/*,video/*,audio/*"
/>
</div>
<div class="popup-actions">
<van-button type="primary" block round :loading="uploading" @click="handleUpload">
上传 {{ uploadFiles.length > 0 ? `(${uploadFiles.length})` : '' }}
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.memory-mobile { padding: 12px; }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.empty { padding: 30px 0; }
.list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.count-text { font-size: 13px; color: var(--gg-text-muted); }
.memory-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.memory-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); overflow: hidden; box-shadow: var(--gg-shadow); position: relative; }
.item-thumb { width: 100%; aspect-ratio: 1; background: var(--gg-bg-elevated); position: relative; }
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.thumb-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--gg-text-muted); }
.type-tag { position: absolute; top: 6px; right: 6px; }
.item-info { padding: 8px 10px; }
.item-title { font-size: 13px; color: var(--gg-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-meta { font-size: 11px; color: var(--gg-text-muted); margin-top: 2px; }
.delete-icon { position: absolute; top: 6px; left: 6px; color: var(--gg-danger); background: rgba(255,255,255,0.8); border-radius: 50%; padding: 2px; font-size: 16px; }
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
.upload-area { padding: 16px; }
.popup-actions { padding: 16px; }
</style>
@@ -0,0 +1,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>
+3 -3
View File
@@ -137,7 +137,7 @@ const routes: RouteRecordRaw[] = [
name: 'Profile',
component: view(
() => import('@/views/Profile.vue'),
mobilePlaceholder
() => import('@/views-mobile/ProfileMobile.vue')
)
},
{
@@ -145,7 +145,7 @@ const routes: RouteRecordRaw[] = [
name: 'Settings',
component: view(
() => import('@/views/Settings.vue'),
mobilePlaceholder
() => import('@/views-mobile/SettingsMobile.vue')
)
},
{
@@ -153,7 +153,7 @@ const routes: RouteRecordRaw[] = [
name: 'Changelog',
component: view(
() => import('@/views/Changelog.vue'),
mobilePlaceholder
() => import('@/views-mobile/ChangelogMobile.vue')
)
}
]
@@ -0,0 +1,152 @@
<!-- src/views-mobile/ChangelogMobile.vue -->
<!-- 手机端更新日志时间线列表数据对齐 uat PC Changelog.vue v0.3.6 最新版本 -->
<script setup lang="ts">
import { ref } from 'vue'
interface LogEntry {
version: string
date: string
title: string
items: { type: 'feat' | 'fix' | 'refactor' | 'style'; text: string }[]
}
const logs = ref<LogEntry[]>([
{
version: 'v2.1.0',
date: '2026-06-18',
title: '手机端前端(uat 重做)',
items: [
{ type: 'feat', text: '完整手机端界面:底部 Tab 导航、顶部栏、所有功能页面移动端适配' },
{ type: 'feat', text: '设备自动识别:手机访问自动进入手机版,桌面访问保持桌面版' },
{ type: 'feat', text: '游戏库移动端:按群组划分,支持详情/评论/收藏/添加/导入' },
{ type: 'feat', text: 'Vant 组件库集成:下拉刷新、ActionSheet、弹层等移动端交互' },
{ type: 'feat', text: '语音房手机端预览:成员网格 + 控制条 UI(实际语音待 App)' },
{ type: 'style', text: '代码分割优化:vendor 拆分,减少首屏加载体积' },
]
},
{
version: 'v0.3.6',
date: '2026-04-24',
title: '游戏库别名与编辑权限',
items: [
{ type: 'feat', text: '游戏支持多个别名(如 LOL、撸啊撸),搜索时可通过别名匹配到游戏' },
{ type: 'feat', text: '游戏详情弹窗内新增编辑功能,创建人、群主、管理员可修改游戏信息' },
{ type: 'feat', text: '游戏编辑表单支持修改名称、别名、平台、标签、封面图' },
{ type: 'feat', text: '导入/导出游戏支持别名(aliases)字段' },
{ type: 'fix', text: '游戏删除按钮改为权限控制:仅创建人、群主、管理员可见' },
{ type: 'fix', text: '组队选游戏限制为本群组游戏库,不再显示其他群组的游戏' },
]
},
{
version: 'v0.3.5',
date: '2026-04-23',
title: 'Bug 修复与体验优化',
items: [
{ type: 'feat', text: '群组页面顶部新增待审核入群申请徽章(脉冲动画提示)' },
{ type: 'feat', text: '个人中心新增"我的入群申请"记录,展示申请状态、时间和拒绝原因' },
{ type: 'feat', text: '游戏封面支持本地上传图片,无需外部图床' },
{ type: 'fix', text: '修复群组审核队列从首页进入时不显示的问题' },
{ type: 'fix', text: '修复拒绝入群申请后标题区待审核计数不同步更新' },
{ type: 'fix', text: '修复邀请链接复制在 HTTP 环境下报错,添加降级方案' },
{ type: 'fix', text: '修复取消 RSVP 时误删所有用户 RSVP 记录的问题' },
{ type: 'fix', text: '修复活动管理权限判断,拆分编辑权限和删除权限' },
{ type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的创建/加入群组按钮' },
]
},
{
version: 'v0.3.4',
date: '2026-04-21',
title: '公告详情弹窗',
items: [
{ type: 'feat', text: '公告卡片点击打开详情弹窗,展示完整内容(保留换行格式)' },
{ type: 'feat', text: '详情弹窗显示优先级标签、作者、发布时间、截止时间等信息' },
{ type: 'feat', text: '详情弹窗内置编辑和删除操作按钮' },
]
},
{
version: 'v0.3.3',
date: '2026-04-20',
title: 'Electron 桌面客户端',
items: [
{ type: 'feat', text: 'Electron 桌面端封装:将 Web 应用包装为独立桌面应用,支持 Windows/Linux/macOS' },
{ type: 'fix', text: '修复 HTTP 环境下 mediaDevices 不可用导致语音认证失败的问题' },
]
},
{
version: 'v0.3.2',
date: '2026-04-19',
title: '实时语音房间',
items: [
{ type: 'feat', text: '语音房间:组队中可进入独立语音房间,实时语音通话(基于 LiveKit WebRTC' },
{ type: 'feat', text: '成员头像网格:显示在线成员,说话时绿圈呼吸动画提示' },
{ type: 'feat', text: '麦克风/扬声器开关:独立控制' },
{ type: 'feat', text: 'LiveKit 后端服务:Docker 部署 LiveKit SFU + Token 签发微服务' },
]
},
{
version: 'v0.3.1',
date: '2026-04-19',
title: '玩家黑名单',
items: [
{ type: 'feat', text: '玩家黑名单:标记外部平台坑玩家,记录玩家ID和游戏平台' },
{ type: 'feat', text: '玩家卡片聚合:按玩家ID+平台聚合展示' },
{ type: 'feat', text: '自定义标签:除预定义标签外支持填写自定义标签' },
{ type: 'feat', text: '实时订阅:玩家黑名单变更实时更新' },
]
},
{
version: 'v0.3.0',
date: '2026-04-19',
title: '积分竞猜、游戏黑名单',
items: [
{ type: 'feat', text: '积分竞猜:发起竞猜、下注、开奖、奖池分配' },
{ type: 'feat', text: '游戏黑名单:标记体验差的游戏,提醒队友避坑' },
{ type: 'fix', text: '修复竞猜双重结算和 TOCTOU 竞态安全漏洞' },
{ type: 'fix', text: '修复黑名单条目允许非创建者修改的安全问题' },
]
},
])
const typeColor: Record<string, string> = {
feat: 'success',
fix: 'warning',
refactor: 'primary',
style: 'default'
}
const typeLabel: Record<string, string> = {
feat: '新增', fix: '修复', refactor: '重构', style: '样式'
}
</script>
<template>
<div class="changelog-mobile">
<div v-for="log in logs" :key="log.version" class="log-block">
<div class="log-header">
<van-tag type="primary" size="large">{{ log.version }}</van-tag>
<span class="log-date">{{ log.date }}</span>
</div>
<div class="log-title">{{ log.title }}</div>
<div class="log-items">
<div v-for="(item, idx) in log.items" :key="idx" class="log-item">
<van-tag :type="typeColor[item.type] as any" plain size="medium">{{ typeLabel[item.type] }}</van-tag>
<span class="item-text">{{ item.text }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.changelog-mobile { padding: 16px 12px; }
.log-block { margin-bottom: 24px; background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 16px; box-shadow: var(--gg-shadow); }
.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.log-date { font-size: 12px; color: var(--gg-text-muted); }
.log-title { font-size: 16px; font-weight: 600; color: var(--gg-text); margin-bottom: 12px; }
.log-items { display: flex; flex-direction: column; gap: 10px; }
.log-item { display: flex; align-items: flex-start; gap: 8px; }
.item-text { font-size: 13px; color: var(--gg-text-secondary); line-height: 1.5; flex: 1; }
</style>
@@ -10,6 +10,8 @@ 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 Placeholder from '@/views-mobile/Placeholder.vue'
import { Wallet, Box, Warning } from '@element-plus/icons-vue'
@@ -128,7 +130,10 @@ function goBlacklist() {
<!-- 阶段 6 已迁移 -->
<PollListMobile v-else-if="activeTab === 'polls'" :group-id="groupId" />
<BetListMobile v-else-if="activeTab === 'bets'" :group-id="groupId" />
<!-- 以下 tab 子组件在阶段 9 迁移后接入现暂用占位 -->
<!-- 阶段 9 迁移 -->
<MemoryGridMobile v-else-if="activeTab === 'memories'" :group-id="groupId" />
<StatsPanelMobile v-else-if="activeTab === 'stats'" :group-id="groupId" />
<!-- 以下 tab 子组件在阶段 10/11 迁移后接入现暂用占位 -->
<Placeholder v-else />
</div>
</van-tab>
+137
View File
@@ -0,0 +1,137 @@
<!-- src/views-mobile/ProfileMobile.vue -->
<!-- 手机端个人中心 -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { UserStatusMap } from '@/types'
import type { UserStatus } from '@/types'
import { resetDeviceMode, setDeviceMode } from '@/mobile/useDevice'
import { showSuccessToast, showConfirmDialog } from 'vant'
const router = useRouter()
const userStore = useUserStore()
const groupStore = useGroupStore()
const user = computed(() => userStore.user)
const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知')
const showStatusSheet = ref(false)
const statusActions = [
{ status: 'idle' as UserStatus, name: '🟢 空闲' },
{ status: 'away' as UserStatus, name: '⚫ 离开' },
{ status: 'working' as UserStatus, name: '🔴 工作中' },
]
async function onStatusSelect(action: any) {
showStatusSheet.value = false
try {
await userStore.setStatus(action.status)
showSuccessToast('已切换')
} catch { /* ignore */ }
}
function goGroups() {
router.push('/mobile-groups')
}
function goChangelog() {
router.push('/changelog')
}
function goSettings() {
router.push('/settings')
}
// 切换桌面版
function switchToDesktop() {
showConfirmDialog({
title: '切换到桌面版',
message: '切换后页面将以桌面版显示,确定吗?'
}).then(() => {
setDeviceMode('desktop')
location.reload()
}).catch(() => {})
}
function handleLogout() {
showConfirmDialog({ title: '退出登录', message: '确定退出吗?' })
.then(() => {
resetDeviceMode()
userStore.logout()
}).catch(() => {})
}
</script>
<template>
<div class="profile-mobile">
<!-- 用户头部 -->
<div class="user-header">
<img :src="user?.avatar || '/default-avatar.svg'" class="user-avatar" alt="" />
<div class="user-info">
<div class="user-name">{{ user?.name || user?.username }}</div>
<div class="user-id">@{{ user?.username }}</div>
</div>
</div>
<!-- 数据概览 -->
<div class="stats-row">
<div class="stat-item">
<div class="stat-num">{{ user?.points ?? 0 }}</div>
<div class="stat-label">积分</div>
</div>
<div class="stat-item" @click="goGroups">
<div class="stat-num">{{ groupStore.groups.length }}</div>
<div class="stat-label">群组</div>
</div>
</div>
<!-- 状态切换 -->
<van-cell-group inset title="我的状态">
<van-cell title="当前状态" :value="statusText" is-link @click="showStatusSheet = true" />
</van-cell-group>
<!-- 功能入口 -->
<van-cell-group inset title="更多">
<van-cell title="我的群组" icon="friends-o" is-link @click="goGroups" />
<van-cell title="更新日志" icon="notes-o" is-link @click="goChangelog" />
<van-cell title="设置" icon="setting-o" is-link @click="goSettings" />
</van-cell-group>
<!-- 视图切换 -->
<van-cell-group inset title="视图">
<van-cell title="切换到桌面版" icon="desktop-o" is-link @click="switchToDesktop" />
</van-cell-group>
<!-- 退出 -->
<div class="logout-area">
<van-button type="danger" block round plain @click="handleLogout">退出登录</van-button>
</div>
<!-- 状态选择 -->
<van-action-sheet
v-model:show="showStatusSheet"
title="切换状态"
:actions="statusActions"
@select="onStatusSelect"
/>
</div>
</template>
<style scoped>
.profile-mobile { padding: 12px 0 24px; display: flex; flex-direction: column; gap: 16px; }
.user-header { display: flex; align-items: center; gap: 14px; padding: 20px 16px; background: var(--gg-bg-card); margin: 0 12px; border-radius: var(--gg-radius-lg); box-shadow: var(--gg-shadow); }
.user-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; border: 2px solid var(--gg-border); }
.user-info { min-width: 0; }
.user-name { font-size: 18px; font-weight: 700; color: var(--gg-text); }
.user-id { font-size: 13px; color: var(--gg-text-muted); margin-top: 2px; }
.stats-row { display: flex; gap: 12px; padding: 0 12px; }
.stat-item { flex: 1; background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 16px; text-align: center; box-shadow: var(--gg-shadow); }
.stat-num { font-size: 24px; font-weight: 700; color: var(--gg-primary); }
.stat-label { font-size: 12px; color: var(--gg-text-muted); margin-top: 2px; }
.logout-area { padding: 8px 28px; }
</style>
@@ -0,0 +1,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>