feat: add GameGroup2 project with frontend and backend

- Add .gitignore for Node.js and PocketBase projects
- Add frontend (Vue 3 + Vite + TypeScript)
- Add backend (PocketBase)
- Add deployment scripts and Docker compose configs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-17 15:45:54 +08:00
parent 2db391901c
commit 2ce8985747
56 changed files with 3981 additions and 783 deletions
@@ -0,0 +1,92 @@
<!-- src/components/common/PasswordInput.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
modelValue: string
placeholder?: string
id?: string
required?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const visible = ref(false)
const inputType = computed(() => visible.value ? 'text' : 'password')
const inputValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
function toggleVisibility() {
visible.value = !visible.value
}
</script>
<template>
<div class="password-input">
<input
:id="id"
:type="inputType"
:value="inputValue"
:placeholder="placeholder"
:required="required"
@input="(e: any) => inputValue = e.target.value"
/>
<button type="button" class="toggle-btn" @click="toggleVisibility">
<svg v-if="!visible" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
</template>
<style scoped>
.password-input {
position: relative;
display: flex;
align-items: center;
}
.password-input input {
width: 100%;
padding: 12px 40px 12px 16px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
}
.password-input input:focus {
outline: none;
border-color: #667eea;
}
.toggle-btn {
position: absolute;
right: 12px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #999;
cursor: pointer;
transition: color 0.2s;
}
.toggle-btn:hover {
color: #667eea;
}
</style>
@@ -0,0 +1,161 @@
<!-- src/components/team/IdleMembersList.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import type { UserStatus } from '@/types'
import { sendInvitation } from '@/api/invitations'
import { ElMessage } from 'element-plus'
interface Props {
status?: UserStatus
}
const props = withDefaults(defineProps<Props>(), {
status: 'idle'
})
const groupStore = useGroupStore()
const idleMembers = computed(() =>
groupStore.currentMembers.filter(m => m.status === props.status)
)
async function inviteMember(userId: string, username: string) {
try {
// 这里需要先创建临时小组,或者检查是否有活跃的临时小组
// 简化版本:发送邀请
await sendInvitation({
to: userId,
teamSession: '' // 实际使用时需要先创建临时小组
})
ElMessage.success(`已邀请 ${username}`)
} catch (error: any) {
ElMessage.error(error.message || '邀请失败')
}
}
</script>
<template>
<div class="idle-members-list">
<h3 class="list-title">
{{ status === 'idle' ? '空闲成员' : '成员列表' }}
<span class="count">({{ idleMembers.length }})</span>
</h3>
<div v-if="idleMembers.length === 0" class="empty">
{{ status === 'idle' ? '暂无空闲成员' : '暂无成员' }}
</div>
<div v-else class="member-list">
<div
v-for="member in idleMembers"
:key="member.id"
class="member-item"
>
<img
:src="member.avatar || '/default-avatar.png'"
:alt="member.username"
class="avatar"
/>
<div class="member-info">
<span class="username">{{ member.username }}</span>
<span v-if="member.statusNote" class="status-note">{{ member.statusNote }}</span>
</div>
<button
v-if="status === 'idle'"
class="invite-btn"
@click="inviteMember(member.id, member.username)"
>
邀请
</button>
</div>
</div>
</div>
</template>
<style scoped>
.idle-members-list {
background: var(--el-bg-color);
border-radius: 12px;
padding: 16px;
}
.list-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
display: flex;
align-items: center;
gap: 8px;
}
.count {
color: var(--el-text-color-secondary);
font-weight: 400;
}
.empty {
text-align: center;
color: var(--el-text-color-secondary);
padding: 32px;
}
.member-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
background: var(--el-bg-color-page);
transition: background-color 0.2s;
}
.member-item:hover {
background: var(--el-fill-color-light);
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.username {
font-size: 14px;
font-weight: 500;
}
.status-note {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.invite-btn {
padding: 6px 16px;
border: none;
border-radius: 6px;
background: var(--el-color-primary);
color: white;
font-size: 13px;
cursor: pointer;
transition: background-color 0.2s;
}
.invite-btn:hover {
background: var(--el-color-primary-light-3);
}
</style>
@@ -0,0 +1,195 @@
<!-- src/components/team/InvitationCard.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import type { Invitation } from '@/types'
import { respondInvitation } from '@/api/invitations'
import { ElMessage } from 'element-plus'
interface Props {
invitation: Invitation
}
const props = defineProps<Props>()
const emit = defineEmits<{
responded: [id: string, accepted: boolean]
}>()
const rejectReason = ref('')
const showRejectInput = ref(false)
async function acceptInvitation() {
try {
await respondInvitation(props.invitation.id, 'accepted')
ElMessage.success('已接受邀请')
emit('responded', props.invitation.id, true)
} catch (error: any) {
ElMessage.error(error.message || '接受邀请失败')
}
}
async function rejectInvitation() {
if (!showRejectInput.value) {
showRejectInput.value = true
return
}
try {
await respondInvitation(props.invitation.id, 'rejected', rejectReason.value)
ElMessage.success('已拒绝邀请')
emit('responded', props.invitation.id, false)
showRejectInput.value = false
rejectReason.value = ''
} catch (error: any) {
ElMessage.error(error.message || '拒绝邀请失败')
}
}
</script>
<template>
<div class="invitation-card">
<div class="invitation-header">
<img
:src="invitation.expand?.from?.avatar || '/default-avatar.png'"
:alt="invitation.expand?.from?.username"
class="avatar"
/>
<div class="invitation-info">
<span class="inviter-name">{{ invitation.expand?.from?.username }}</span>
<span class="invitation-text">邀请你加入</span>
</div>
</div>
<div v-if="invitation.expand?.teamSession" class="session-info">
<span class="game-name">{{ invitation.expand.teamSession.gameName }}</span>
<span class="session-name">{{ invitation.expand.teamSession.name }}</span>
</div>
<div class="invitation-actions">
<button class="action-btn accept" @click="acceptInvitation">
接受
</button>
<button class="action-btn reject" @click="rejectInvitation">
{{ showRejectInput ? '确认拒绝' : '拒绝' }}
</button>
</div>
<div v-if="showRejectInput" class="reject-input">
<textarea
v-model="rejectReason"
placeholder="填写拒绝原因(可选)"
rows="2"
/>
</div>
</div>
</template>
<style scoped>
.invitation-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--el-border-color);
}
.invitation-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.invitation-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.inviter-name {
font-size: 15px;
font-weight: 600;
}
.invitation-text {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.session-info {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--el-bg-color-page);
border-radius: 8px;
margin-bottom: 12px;
}
.game-name {
font-size: 14px;
font-weight: 500;
}
.session-name {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.invitation-actions {
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.accept {
background: var(--el-color-primary);
color: white;
}
.accept:hover {
background: var(--el-color-primary-light-3);
}
.reject {
background: var(--el-fill-color);
color: var(--el-text-color-primary);
}
.reject:hover {
background: var(--el-fill-color-dark);
}
.reject-input {
margin-top: 12px;
}
.reject-input textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
font-size: 13px;
resize: none;
}
.reject-input textarea:focus {
outline: none;
border-color: var(--el-color-primary);
}
</style>
@@ -0,0 +1,72 @@
<!-- src/components/team/StatusToggle.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { UserStatusMap, UserStatusIcon, type UserStatus } from '@/types'
const userStore = useUserStore()
const currentStatus = computed(() => userStore.userStatus)
const statusIcon = computed(() => UserStatusIcon[currentStatus.value])
const statusText = computed(() => UserStatusMap[currentStatus.value])
async function setStatus(status: UserStatus) {
await userStore.setStatus(status)
}
</script>
<template>
<el-dropdown trigger="click" @command="setStatus">
<span class="status-toggle">
<span class="status-icon">{{ statusIcon }}</span>
<span class="status-text">{{ statusText }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="idle">
<span class="menu-item">🟢 空闲</span>
</el-dropdown-item>
<el-dropdown-item command="working">
<span class="menu-item">🔴 工作中</span>
</el-dropdown-item>
<el-dropdown-item command="in_team" :disabled="currentStatus !== 'in_team'">
<span class="menu-item">🔵 组队中</span>
</el-dropdown-item>
<el-dropdown-item command="away">
<span class="menu-item"> 离开</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<style scoped>
.status-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.status-toggle:hover {
background-color: var(--el-bg-color-page);
}
.status-icon {
font-size: 16px;
}
.status-text {
font-size: 14px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,188 @@
<!-- src/components/team/TeamSessionPanel.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useTeamStore } from '@/stores/team'
import { useGroupStore } from '@/stores/group'
import { TeamStatusMap } from '@/types'
import { ElMessageBox } from 'element-plus'
const teamStore = useTeamStore()
const groupStore = useGroupStore()
const session = computed(() => teamStore.currentSession)
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
const memberDetails = computed(() => {
if (!session.value) return []
return session.value.members
.map(id => groupStore.currentMembers.find(m => m.id === id))
.filter((m): m is NonNullable<typeof m> => Boolean(m))
})
async function endGame() {
try {
await ElMessageBox.confirm(
'确定要结束当前游戏吗?临时小组将被解散。',
'确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await teamStore.finishGame()
} catch {
// 用户取消
}
}
</script>
<template>
<div v-if="session" class="team-session-panel">
<div class="panel-header">
<h3 class="session-name">{{ session.name }}</h3>
<span class="session-status">{{ statusText }}</span>
</div>
<div class="session-game">
<span class="game-label">游戏:</span>
<span class="game-name">{{ session.gameName }}</span>
</div>
<div class="members-section">
<h4 class="members-title">成员 ({{ memberDetails.length }})</h4>
<div class="members-list">
<div
v-for="member in memberDetails"
:key="member.id"
class="member-item"
>
<img
:src="member.avatar || '/default-avatar.png'"
:alt="member.username"
class="avatar"
/>
<span class="username">{{ member.username }}</span>
</div>
</div>
</div>
<button class="end-game-btn" @click="endGame">
游戏结束
</button>
</div>
<div v-else class="no-session">
<p>暂未加入任何临时小组</p>
</div>
</template>
<style scoped>
.team-session-panel {
background: var(--el-bg-color);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--el-color-primary);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.session-name {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.session-status {
padding: 4px 12px;
border-radius: 12px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 12px;
}
.session-game {
display: flex;
gap: 8px;
padding: 12px;
background: var(--el-bg-color-page);
border-radius: 8px;
margin-bottom: 16px;
}
.game-label {
color: var(--el-text-color-secondary);
font-size: 14px;
}
.game-name {
font-weight: 500;
font-size: 14px;
}
.members-section {
margin-bottom: 16px;
}
.members-title {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px;
}
.members-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: var(--el-bg-color-page);
border-radius: 6px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.username {
font-size: 14px;
}
.end-game-btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: var(--el-color-danger);
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.end-game-btn:hover {
background: var(--el-color-danger-light-3);
}
.no-session {
background: var(--el-bg-color);
border-radius: 12px;
padding: 32px;
text-align: center;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,127 @@
<!-- src/components/team/WorkScheduleModal.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElDialog, ElCheckbox, ElTimeSelect, ElButton } from 'element-plus'
import { useUserStore } from '@/stores/user'
interface Props {
modelValue: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const userStore = useUserStore()
const workdays = ref<number[]>(userStore.user?.workdays || [])
const workStartTime = ref<string>(userStore.user?.workStartTime || '09:00')
const dayOptions = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 7 }
]
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
async function saveSchedule() {
try {
await userStore.setWorkSchedule({
workdays: workdays.value,
workStartTime: workStartTime.value
})
visible.value = false
} catch (error: any) {
console.error('保存工作时间失败:', error)
}
}
function handleOpen() {
workdays.value = userStore.user?.workdays || []
workStartTime.value = userStore.user?.workStartTime || '09:00'
}
</script>
<template>
<el-dialog
v-model="visible"
title="设置工作时间"
width="400px"
@open="handleOpen"
>
<div class="schedule-form">
<div class="form-group">
<label class="form-label">工作日</label>
<el-checkbox-group v-model="workdays">
<el-checkbox
v-for="day in dayOptions"
:key="day.value"
:label="day.value"
>
{{ day.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<div class="form-group">
<label class="form-label">工作开始时间</label>
<el-time-select
v-model="workStartTime"
start="00:00"
end="23:30"
step="00:30"
/>
</div>
<div class="form-hint">
<p>在工作日的设定时间你的状态会自动变为"工作中"</p>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="saveSchedule">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.schedule-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.form-hint {
padding: 12px;
background: var(--el-color-info-light-9);
border-radius: 6px;
}
.form-hint p {
margin: 0;
font-size: 12px;
color: var(--el-color-info);
}
</style>