feat(mobile): SVG icon system + team disband feature

图标/界面优化(问题1):
- new MobileIcon.vue: 统一 SVG 图标组件(40+ 图标,2px 描边风,currentColor)
- 重画占位图 default-avatar.svg / game-placeholder.svg(去 emoji,纯描边)
- 替换全部 emoji 为 SVG:登录品牌🎮、组队🔊、事件🕐📍、胜出🏆、人气🔥、
  邀请🔗🎮、状态切换🟢🔴 等
- 重做底部 Tab 栏:van-icon 默认 → 自定义 SVG(激活态主色高亮)
- 顶部栏 bell、群组详情快捷入口(账本/资产/黑名单)换 SVG
- mobile.css 全局体验增强:按钮按压反馈、空状态、骨架屏工具类、
  页面淡入动画、Toast 刘海屏适配、滚动条隐藏、省略号工具类

组队解散功能(问题2):
- ActivityFeedMobile 补结束/解散按钮(招募中→解散,游戏中→结束游戏)
- 调用 teamStore.finishGame(),带确认弹窗
- 与 PC 端 TeamSessionPanel 行为一致(session 存在且招募/游戏中时群组成员可操作)

build verified: vue-tsc + vite build pass
This commit is contained in:
锦麟 王
2026-06-18 15:14:38 +08:00
parent f79bdac889
commit 7cd53ac420
16 changed files with 446 additions and 62 deletions
+9 -2
View File
@@ -1,4 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect fill="#e8f5e9" width="64" height="64" rx="32"/>
<text x="32" y="40" text-anchor="middle" fill="#059669" font-size="28">👤</text>
<defs>
<linearGradient id="avBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ecfdf5"/>
<stop offset="100%" stop-color="#d1fae5"/>
</linearGradient>
</defs>
<rect fill="url(#avBg)" width="64" height="64" rx="32"/>
<circle cx="32" cy="25" r="10" fill="none" stroke="#059669" stroke-width="2.5"/>
<path d="M14 54c0-9 8-15 18-15s18 6 18 15" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 547 B

+15 -3
View File
@@ -1,5 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="260" viewBox="0 0 200 260">
<rect fill="#e8f5e9" width="200" height="260"/>
<text x="100" y="120" text-anchor="middle" fill="#059669" font-size="48">🎮</text>
<text x="100" y="155" text-anchor="middle" fill="#6b7280" font-size="14">暂无封面</text>
<defs>
<linearGradient id="gBg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f0fdf4"/>
<stop offset="100%" stop-color="#dcfce7"/>
</linearGradient>
</defs>
<rect fill="url(#gBg)" width="200" height="260"/>
<!-- 手柄主体 -->
<rect x="44" y="108" width="112" height="58" rx="20" fill="none" stroke="#059669" stroke-width="3.5"/>
<!-- 左侧十字方向 -->
<path d="M70 137v-10M65 132h10" stroke="#059669" stroke-width="3.5" stroke-linecap="round"/>
<!-- 右侧按钮 -->
<circle cx="125" cy="128" r="3.2" fill="#10b981"/>
<circle cx="138" cy="142" r="3.2" fill="#10b981"/>
<text x="100" y="200" text-anchor="middle" fill="#059669" font-size="13" font-family="system-ui,sans-serif" font-weight="500">暂无封面</text>
</svg>

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 874 B

+102
View File
@@ -51,3 +51,105 @@
.mobile-app * {
-webkit-tap-highlight-color: transparent;
}
/* ============ 细节体验优化(全局) ============ */
/* 1. 按钮点击反馈:统一按压缩放 */
.mobile-app .van-button:active {
opacity: 0.85;
transform: scale(0.98);
}
.mobile-app .van-button {
transition: opacity 0.15s, transform 0.15s;
}
/* 2. 可点击卡片按压反馈(约定:凡 .clickable 或带 @click 的卡片建议加此类) */
.mobile-app .clickable {
transition: transform 0.12s, box-shadow 0.12s;
cursor: pointer;
}
.mobile-app .clickable:active {
transform: scale(0.98);
}
/* 3. van-empty 空状态:图标加品牌色淡背景,提升存在感 */
.mobile-app .van-empty__image {
opacity: 0.85;
}
.mobile-app .van-empty__description {
color: var(--gg-text-muted);
font-size: 13px;
}
/* 4. 列表项分隔线统一为更柔和的颜色 */
.mobile-app .van-cell {
border-color: var(--gg-border);
}
.mobile-app .van-cell::after {
border-color: var(--gg-border) !important;
}
/* 5. 全局卡片阴影统一(覆盖各页面自定义的过深/过浅阴影) */
.mobile-app .van-popup,
.mobile-app .van-action-sheet {
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.08);
}
/* 6. Tab 栏激活态下划线更柔和 */
.mobile-app .van-tabs__line {
background: var(--gg-gradient);
border-radius: 3px;
}
/* 7. 输入框聚焦态:边框过渡更顺滑 */
.mobile-app .van-field {
transition: border-color 0.2s;
}
/* 8. 骨架屏占位(list 加载时,配合 van-skeleton 或 .skeleton 类使用) */
.mobile-app .skeleton-block {
background: linear-gradient(90deg, var(--gg-bg-elevated) 25%, var(--gg-bg) 37%, var(--gg-bg-elevated) 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
border-radius: var(--gg-radius-sm);
}
@keyframes skeleton-loading {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
/* 9. 滚动容器:隐藏滚动条但保留滚动能力(移动端原生体验) */
.mobile-app .scroll-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.mobile-app .scroll-hide::-webkit-scrollbar {
display: none;
}
/* 10. 文字溢出省略工具类(列表标题常用) */
.mobile-app .ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-app .ellipsis-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 11. 页面切换淡入动画(router-view 配合) */
.mobile-app .mobile-content {
animation: page-fade-in 0.25s ease;
}
@keyframes page-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* 12. Toast 在刘海屏顶部不被遮挡 */
.mobile-app .van-toast {
top: calc(env(safe-area-inset-top, 0px) + 10%);
}
@@ -0,0 +1,113 @@
<!--
MobileIcon.vue
手机端统一 SVG 图标组件 线性描边风格2px 线宽24×24 viewBox
颜色继承 currentColor通过 color/style/class 控制
用法<MobileIcon name="game" /> <MobileIcon name="bell" :size="22" color="var(--gg-primary)" />
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
name: string
size?: number | string
color?: string
}>(), {
size: 20,
color: 'currentColor'
})
// 所有图标的 path 数据(线性描边,stroke 风格统一)
// 每个 name 对应一组 SVG 内部元素(path/circle 等)
const ICONS: Record<string, string> = {
// ---- 品牌/导航 ----
logo: '<path d="M6 8h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z"/><path d="M7 13h2"/><path d="M15 13h2"/><path d="M9 16h6"/>',
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V20a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5"/><path d="M9.5 21v-6h5v6"/>',
users: '<circle cx="9" cy="8" r="3"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><path d="M16 6.5a3 3 0 0 1 0 5.8"/><path d="M17 14.5a5.5 5.5 0 0 1 3.5 5.1"/>',
game: '<rect x="2.5" y="7" width="19" height="10" rx="3"/><path d="M7 11v3"/><path d="M5.5 12.5h3"/><circle cx="16" cy="11.5" r="0.8" fill="currentColor" stroke="none"/><circle cx="18.5" cy="14" r="0.8" fill="currentColor" stroke="none"/>',
bell: '<path d="M6 9a6 6 0 0 1 12 0c0 4 1.2 5.5 2 6.5H4c.8-1 2-2.5 2-6.5Z"/><path d="M10 19a2 2 0 0 0 4 0"/>',
user: '<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/>',
// ---- 功能/操作 ----
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-3.6-3.6"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m14 6 4 4"/>',
trash: '<path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M10 11v6"/><path d="M14 11v6"/>',
close: '<path d="M6 6l12 12"/><path d="M18 6 6 18"/>',
check: '<path d="m5 12.5 4.5 4.5L19 7"/>',
arrowDown: '<path d="M12 5v14"/><path d="m6 13 6 6 6-6"/>',
arrowLeft: '<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>',
arrowRight: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
arrowUp: '<path d="M12 19V5"/><path d="m6 11 6-6 6 6"/>',
refresh: '<path d="M4 12a8 8 0 0 1 13.5-5.8L20 8"/><path d="M20 4v4h-4"/><path d="M20 12a8 8 0 0 1-13.5 5.8L4 16"/><path d="M4 20v-4h4"/>',
more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
// ---- 状态/反馈 ----
star: '<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.3L12 17.3 6.5 20.3l1.1-6.3L3 9.6l6.3-.9L12 3Z"/>',
starFilled: '<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.3L12 17.3 6.5 20.3l1.1-6.3L3 9.6l6.3-.9L12 3Z" fill="currentColor" stroke="none"/>',
fire: '<path d="M12 3c1 3-1 4-1 6 0 1 1 2 2 2s2-1 2-3c2 2 3 4 3 7a6 6 0 0 1-12 0c0-3 2-5 3-6 1 1 1 2 2 2 1 0 1-2 1-5 0-1 0-2 0-3Z"/>',
bookmark: '<path d="M6 4h12v16l-6-4-6 4V4Z"/>',
warning: '<path d="M12 4 2.5 20h19L12 4Z"/><path d="M12 10v4"/><circle cx="12" cy="17" r="0.8" fill="currentColor" stroke="none"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><circle cx="12" cy="7.5" r="0.8" fill="currentColor" stroke="none"/>',
// ---- 时间/地点/类型 ----
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3.5 2"/>',
location: '<path d="M12 21s7-6 7-11a7 7 0 0 0-14 0c0 5 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/>',
calendar: '<rect x="4" y="5" width="16" height="16" rx="2"/><path d="M4 9h16"/><path d="M8 3v4"/><path d="M16 3v4"/>',
trophy: '<path d="M8 4h8v4a4 4 0 0 1-8 0V4Z"/><path d="M8 5H5v2a3 3 0 0 0 3 3"/><path d="M16 5h3v2a3 3 0 0 1-3 3"/><path d="M12 12v4"/><path d="M9 20h6"/><path d="M10 16h4l.5 4h-5l.5-4Z"/>',
tag: '<path d="M3 5h7l9 9-7 7-9-9V5Z"/><circle cx="7.5" cy="9.5" r="1.2" fill="currentColor" stroke="none"/>',
chart: '<path d="M4 20V4"/><path d="M4 20h16"/><path d="M8 16v-4"/><path d="M13 16V8"/><path d="M18 16v-7"/>',
// ---- 组队/语音 ----
volume: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="M16 9a3 3 0 0 1 0 6"/><path d="M18.5 7a6 6 0 0 1 0 10"/>',
volumeOff: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="m16 9 5 6"/><path d="m21 9-5 6"/>',
speaker: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="M16 8a5 5 0 0 1 0 8"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
micOff: '<path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 9v2a3 3 0 0 0 5 2"/><path d="M5 11a7 7 0 0 0 11 4"/><path d="M12 18v3"/><path d="m4 4 16 16"/>',
// ---- 群组/资产相关 ----
wallet: '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 9h18"/><circle cx="16.5" cy="13" r="1.2" fill="currentColor" stroke="none"/>',
gift: '<rect x="4" y="8" width="16" height="12" rx="1"/><path d="M4 12h16"/><path d="M12 8v12"/><path d="M12 8C12 5 10 4 8.5 4S6 6 8 8"/><path d="M12 8c0-3 2-4 3.5-4S18 6 16 8"/>',
photo: '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="m3 17 5-4 4 3 3-2 6 4"/>',
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.5 1.5"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.5-1.5"/>',
download: '<path d="M12 4v11"/><path d="m7 11 5 5 5-5"/><path d="M5 20h14"/>',
upload: '<path d="M12 16V5"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/>',
music: '<circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="16" r="2.5"/><path d="M8.5 18V5l12-2v13"/>',
video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
document: '<path d="M6 3h8l4 4v14H6V3Z"/><path d="M14 3v4h4"/><path d="M9 13h6"/><path d="M9 17h6"/>',
setting: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
logout: '<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4"/><path d="M16 16l4-4-4-4"/><path d="M20 12H9"/>',
// ---- 状态点 ----
dotIdle: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
}
const innerSvg = computed(() => ICONS[props.name] || ICONS.info)
const wrapperStyle = computed(() => ({
width: typeof props.size === 'number' ? `${props.size}px` : props.size,
height: typeof props.size === 'number' ? `${props.size}px` : props.size,
color: props.color
}))
</script>
<template>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
:style="wrapperStyle"
class="mobile-icon"
v-html="innerSvg"
/>
</template>
<style scoped>
.mobile-icon {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
}
</style>
@@ -6,6 +6,7 @@ import { listBets, getBet, getBetOptions, getBetEntries, placeBet, createBet, cl
import { useUserStore } from '@/stores/user'
import type { Bet, BetOption, BetEntry } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
const props = defineProps<{ groupId: string }>()
@@ -224,7 +225,7 @@ function timeAgo(dateStr: string): string {
>
<div class="option-row">
<span class="option-text">{{ opt.content }}</span>
<span v-if="currentBet.resultOption === opt.id" class="winner-badge">🏆 胜出</span>
<span v-if="currentBet.resultOption === opt.id" class="winner-badge"><MobileIcon name="trophy" :size="13" color="var(--gg-warning)" /> 胜出</span>
</div>
<div class="option-stats">
<span>{{ optionEntryCount(opt.id) }} </span>
@@ -239,7 +240,7 @@ function timeAgo(dateStr: string): string {
:type="myEntry()?.won ? 'success' : 'warning'"
wrapable
>
{{ myEntry()?.won ? `🎉 你赢了!` : '本次未中奖' }}
{{ myEntry()?.won ? `你赢了!` : '本次未中奖' }}
</van-notice-bar>
</div>
@@ -385,7 +386,7 @@ function timeAgo(dateStr: string): string {
.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; }
.winner-badge { display: inline-flex; align-items: center; gap: 2px; 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; }
@@ -10,6 +10,7 @@ import { EventStatusMap, RSVPTypeMap } from '@/types'
import type { Event, RSVPType } from '@/types'
import { displayName } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
import CreateEventSheetMobile from './CreateEventSheetMobile.vue'
const props = defineProps<{ groupId: string }>()
@@ -180,8 +181,8 @@ async function onCreated() {
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">🕐 {{ formatDateTime(event.startTime) }}</div>
<div v-if="event.location" class="event-loc">📍 {{ event.location }}</div>
<div class="event-time"><MobileIcon name="clock" :size="13" /> {{ formatDateTime(event.startTime) }}</div>
<div v-if="event.location" class="event-loc"><MobileIcon name="location" :size="13" /> {{ event.location }}</div>
</div>
<!-- 展开详情 -->
@@ -245,7 +246,7 @@ async function onCreated() {
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">🕐 {{ formatDateTime(event.startTime) }}</div>
<div class="event-time"><MobileIcon name="clock" :size="13" /> {{ formatDateTime(event.startTime) }}</div>
</div>
<div v-if="expandedId === event.id" class="event-detail">
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
@@ -296,8 +297,8 @@ async function onCreated() {
.expand-icon { margin-left: auto; color: var(--gg-text-muted); font-size: 14px; }
.event-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
.event-time { font-size: 13px; color: var(--gg-text-secondary); margin-top: 6px; }
.event-loc { font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; }
.event-time { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--gg-text-secondary); margin-top: 6px; }
.event-loc { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; }
.event-detail {
background: var(--gg-bg);
@@ -13,6 +13,7 @@ import { createTeamSession } from '@/api/sessions'
import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
const props = defineProps<{
show: boolean
@@ -239,7 +240,7 @@ function formatDate(dateStr: string): string {
<div class="detail-meta">
<van-tag v-if="game.platform" type="primary" size="medium">{{ game.platform }}</van-tag>
<span class="popularity">🔥 {{ game.popularCount }} 人游玩</span>
<span class="popularity"><MobileIcon name="fire" :size="13" color="var(--gg-warning)" /> {{ game.popularCount }} 人游玩</span>
</div>
<div v-if="game.tags?.length" class="detail-tags">
@@ -412,6 +413,9 @@ function formatDate(dateStr: string): string {
}
.popularity {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 13px;
color: var(--gg-text-secondary);
}
@@ -9,7 +9,8 @@ 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'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
const props = defineProps<{ groupId: string }>()
@@ -107,21 +108,65 @@ function goVoiceRoom() {
})
}
}
// 当前会话状态(用于显示招募中/游戏中)
const sessionStatus = computed(() => teamStore.currentSession?.status || 'recruiting')
const isRecruiting = computed(() => sessionStatus.value === 'recruiting')
const isPlaying = computed(() => sessionStatus.value === 'playing')
// 解散/结束组队(与 PC 端一致:session 存在且处于招募/游戏中时,群组成员可操作)
const ending = ref(false)
async function handleEndSession() {
const s = teamStore.currentSession
if (!s) return
const isRecruit = s.status === 'recruiting'
showConfirmDialog({
title: isRecruit ? '解散小队' : '结束游戏',
message: isRecruit
? '确定要解散当前小队吗?'
: '确定要结束当前游戏吗?小队将被解散。'
}).then(async () => {
ending.value = true
try {
await teamStore.finishGame()
showSuccessToast(isRecruit ? '已解散' : '已结束')
} catch (e: any) {
showFailToast(e.message || '操作失败')
} finally {
ending.value = false
}
}).catch(() => {})
}
</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 v-if="hasSession && sessionInThisGroup" class="current-team-card">
<div class="team-card-body" @click="goVoiceRoom">
<div class="team-header">
<MobileIcon name="volume" :size="16" color="#fff" />
<span class="team-label">{{ isPlaying ? '游戏中' : '招募中' }}</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">
<MobileIcon name="arrowRight" :size="14" color="#fff" />
进入语音房
</div>
</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>
<!-- 解散/结束按钮招募中或游戏中时显示 -->
<button
v-if="isRecruiting || isPlaying"
class="team-end-btn"
:loading="ending"
@click.stop="handleEndSession"
>
<MobileIcon name="close" :size="14" color="#fff" />
{{ isRecruiting ? '解散' : '结束' }}
</button>
</div>
<!-- 快速组队按钮 -->
@@ -130,10 +175,10 @@ function goVoiceRoom() {
type="primary"
block
round
icon="plus"
@click="showCreateTeam = true"
>
发起组队
<MobileIcon name="plus" :size="16" color="#fff" />
<span style="margin-left: 4px;">发起组队</span>
</van-button>
<!-- 成员状态分组 -->
@@ -226,6 +271,11 @@ function goVoiceRoom() {
padding: 16px;
color: #fff;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.25);
position: relative;
}
.team-card-body:active {
opacity: 0.92;
}
.team-header {
@@ -249,12 +299,38 @@ function goVoiceRoom() {
}
.team-go {
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
font-size: 13px;
margin-top: 8px;
font-weight: 500;
}
/* 解散/结束按钮 */
.team-end-btn {
position: absolute;
top: 14px;
right: 14px;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 14px;
color: #fff;
font-size: 12px;
font-weight: 500;
backdrop-filter: blur(4px);
}
.team-end-btn:active {
background: rgba(239, 68, 68, 0.5);
border-color: rgba(255, 255, 255, 0.6);
}
.members-section {
display: flex;
flex-direction: column;
+32 -6
View File
@@ -3,6 +3,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
@@ -113,7 +114,7 @@ function goNotifications() {
>
<template #right>
<van-badge :content="unreadCount > 0 ? unreadCount : ''" :show-zero="false">
<van-icon name="bell" size="22" @click="goNotifications" />
<MobileIcon name="bell" :size="22" @click="goNotifications" />
</van-badge>
</template>
</van-nav-bar>
@@ -125,11 +126,36 @@ function goNotifications() {
<!-- 底部 Tab -->
<van-tabbar v-model="activeTab" @change="onTabChange" placeholder fixed>
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item name="groups" icon="friends-o">群组</van-tabbar-item>
<van-tabbar-item name="games" icon="game-o">游戏</van-tabbar-item>
<van-tabbar-item name="notifications" icon="bell">通知</van-tabbar-item>
<van-tabbar-item name="profile" icon="user-o">我的</van-tabbar-item>
<van-tabbar-item name="home">
<template #icon="{ active }">
<MobileIcon name="home" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
</template>
首页
</van-tabbar-item>
<van-tabbar-item name="groups">
<template #icon="{ active }">
<MobileIcon name="users" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
</template>
群组
</van-tabbar-item>
<van-tabbar-item name="games">
<template #icon="{ active }">
<MobileIcon name="game" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
</template>
游戏
</van-tabbar-item>
<van-tabbar-item name="notifications">
<template #icon="{ active }">
<MobileIcon name="bell" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
</template>
通知
</van-tabbar-item>
<van-tabbar-item name="profile">
<template #icon="{ active }">
<MobileIcon name="user" :size="22" :color="active ? 'var(--gg-primary)' : 'var(--gg-text-muted)'" />
</template>
我的
</van-tabbar-item>
</van-tabbar>
</div>
</template>
@@ -14,8 +14,8 @@ import MemoryGridMobile from '@/components-mobile/memory/MemoryGridMobile.vue'
import StatsPanelMobile from '@/components-mobile/stats/StatsPanelMobile.vue'
import BulletinListMobile from '@/components-mobile/bulletin/BulletinListMobile.vue'
import EventListMobile from '@/components-mobile/event/EventListMobile.vue'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
import Placeholder from '@/views-mobile/Placeholder.vue'
import { Wallet, Box, Warning } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
@@ -103,15 +103,15 @@ function goBlacklist() {
<!-- 快捷入口 -->
<div class="quick-entry">
<div class="entry-item" @click="goLedger">
<el-icon><Wallet /></el-icon>
<MobileIcon name="wallet" :size="20" color="var(--gg-primary)" />
<span>账本</span>
</div>
<div class="entry-item" @click="goAssets">
<el-icon><Box /></el-icon>
<MobileIcon name="gift" :size="20" color="var(--gg-primary)" />
<span>资产</span>
</div>
<div class="entry-item" @click="goBlacklist">
<el-icon><Warning /></el-icon>
<MobileIcon name="warning" :size="20" color="var(--gg-primary)" />
<span>黑名单</span>
</div>
</div>
@@ -228,8 +228,7 @@ function goBlacklist() {
opacity: 0.7;
}
.entry-item .el-icon {
font-size: 20px;
.entry-item .mobile-icon {
color: var(--gg-primary);
}
+33 -10
View File
@@ -44,11 +44,11 @@ function selectGroup(groupId: string) {
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: '🔴' },
{ status: 'idle' as const, label: '空闲', color: 'var(--gg-success)' },
{ status: 'away' as const, label: '离开', color: 'var(--gg-text-muted)' },
{ status: 'working' as const, label: '工作中', color: 'var(--gg-danger)' },
]
const showStatusSheet = ref(false)
@@ -191,12 +191,20 @@ function goSession() {
</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"
/>
<van-action-sheet v-model:show="showStatusSheet" title="切换状态">
<div class="status-sheet">
<div
v-for="a in statusActions"
:key="a.status"
class="status-sheet-item"
@click="onStatusSelect(a)"
>
<span class="status-sheet-dot" :style="{ background: a.color }" />
<span class="status-sheet-label">{{ a.label }}</span>
<van-icon v-if="userStore.userStatus === a.status" name="success" class="status-sheet-check" />
</div>
</div>
</van-action-sheet>
</div>
</template>
@@ -261,6 +269,21 @@ function goSession() {
.dot-working { background: var(--gg-danger); }
.dot-away { background: var(--gg-text-muted); }
/* 状态切换面板 */
.status-sheet { padding: 8px 0 calc(16px + env(safe-area-inset-bottom, 0px)); }
.status-sheet-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 24px;
font-size: 15px;
color: var(--gg-text);
}
.status-sheet-item:active { background: var(--gg-bg-elevated); }
.status-sheet-dot { width: 10px; height: 10px; border-radius: 50%; }
.status-sheet-label { flex: 1; }
.status-sheet-check { color: var(--gg-primary); font-size: 18px; }
.status-note {
color: var(--gg-text-muted);
overflow: hidden;
@@ -8,6 +8,7 @@ import { useGroupStore } from '@/stores/group'
import { pb, isAuthenticated } from '@/api/pocketbase'
import type { Group } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
const route = useRoute()
const router = useRouter()
@@ -75,7 +76,7 @@ async function handleJoin() {
<div class="join-page">
<div class="join-card">
<div class="join-header">
<div class="header-icon">🔗</div>
<div class="header-icon"><MobileIcon name="link" :size="40" color="var(--gg-primary)" /></div>
<h1>群组邀请</h1>
</div>
@@ -8,6 +8,7 @@ import { useGroupStore } from '@/stores/group'
import { joinTeamSession, getTeamSession } from '@/api/sessions'
import type { TeamSession } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
const route = useRoute()
const router = useRouter()
@@ -78,7 +79,7 @@ async function handleJoin() {
<div class="join-page">
<div class="join-card">
<div class="join-header">
<div class="header-icon">🎮</div>
<div class="header-icon"><MobileIcon name="game" :size="40" color="var(--gg-primary)" /></div>
<h1>组队邀请</h1>
</div>
+2 -1
View File
@@ -6,6 +6,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { pb } from '@/api/pocketbase'
import { showFailToast } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
const router = useRouter()
const route = useRoute()
@@ -52,7 +53,7 @@ async function handleLogin() {
<template>
<div class="login-mobile">
<div class="brand-area">
<div class="brand-icon">🎮</div>
<div class="brand-icon"><MobileIcon name="logo" :size="56" color="var(--gg-primary)" /></div>
<h1 class="brand-title">Game Group</h1>
<p class="brand-subtitle">组队开黑一起嗨</p>
</div>
+25 -9
View File
@@ -19,9 +19,9 @@ 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: '🔴 工作中' },
{ status: 'idle' as UserStatus, label: '空闲', color: 'var(--gg-success)' },
{ status: 'away' as UserStatus, label: '离开', color: 'var(--gg-text-muted)' },
{ status: 'working' as UserStatus, label: '工作中', color: 'var(--gg-danger)' },
]
async function onStatusSelect(action: any) {
@@ -110,12 +110,20 @@ function handleLogout() {
</div>
<!-- 状态选择 -->
<van-action-sheet
v-model:show="showStatusSheet"
title="切换状态"
:actions="statusActions"
@select="onStatusSelect"
/>
<van-action-sheet v-model:show="showStatusSheet" title="切换状态">
<div class="status-sheet">
<div
v-for="a in statusActions"
:key="a.status"
class="status-sheet-item"
@click="onStatusSelect(a)"
>
<span class="status-sheet-dot" :style="{ background: a.color }" />
<span class="status-sheet-label">{{ a.label }}</span>
<van-icon v-if="userStore.userStatus === a.status" name="success" class="status-sheet-check" />
</div>
</div>
</van-action-sheet>
</div>
</template>
@@ -134,4 +142,12 @@ function handleLogout() {
.stat-label { font-size: 12px; color: var(--gg-text-muted); margin-top: 2px; }
.logout-area { padding: 8px 28px; }
/* 状态切换面板 */
.status-sheet { padding: 8px 0 calc(16px + env(safe-area-inset-bottom, 0px)); }
.status-sheet-item { display: flex; align-items: center; gap: 12px; padding: 14px 24px; font-size: 15px; color: var(--gg-text); }
.status-sheet-item:active { background: var(--gg-bg-elevated); }
.status-sheet-dot { width: 10px; height: 10px; border-radius: 50%; }
.status-sheet-label { flex: 1; }
.status-sheet-check { color: var(--gg-primary); font-size: 18px; }
</style>
+2 -1
View File
@@ -5,6 +5,7 @@ import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { showFailToast, showSuccessToast } from 'vant'
import MobileIcon from '@/components-mobile/MobileIcon.vue'
const router = useRouter()
const route = useRoute()
@@ -62,7 +63,7 @@ async function handleRegister() {
<div class="form-area">
<div class="intro">
<div class="intro-icon">🎮</div>
<div class="intro-icon"><MobileIcon name="logo" :size="48" color="var(--gg-primary)" /></div>
<h2 class="intro-title">创建账号</h2>
<p class="intro-desc">输入昵称开始你的组队之旅</p>
</div>