feat(admin,player,api): 玩家账号密码管理与代理上下分

新增玩家头像、可查密码与全局改密/改账号开关;玩家资料页合并账号密码展示;代理直属玩家列表支持自定义上下分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 11:36:53 +08:00
parent f76728dc3e
commit a8e4ead618
81 changed files with 1763 additions and 217 deletions

View File

@@ -4,14 +4,34 @@ import { useI18n } from 'vue-i18n';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import BannerCarousel from '../components/BannerCarousel.vue';
import { usePlayerHome } from '../composables/usePlayerHome';
import { teamFlagUrl } from '../utils/teamFlag';
const { t } = useI18n();
const { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, loading } = usePlayerHome();
function goMatch(id: string) {
router.push(`/match/${id}`);
}
function formatKickoff(startTime: string) {
return new Date(startTime).toLocaleString(locale.value, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function homeFlag(match: (typeof hotMatches.value)[number]) {
return teamFlagUrl(match.homeTeamCode, match.homeTeamName);
}
function awayFlag(match: (typeof hotMatches.value)[number]) {
return teamFlagUrl(match.awayTeamCode, match.awayTeamName);
}
</script>
<template>
@@ -19,9 +39,23 @@ function goMatch(id: string) {
<BannerCarousel :banners="banners" />
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
<div v-for="match in hotMatches" :key="match.id" class="card match-card" @click="goMatch(match.id)">
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
<div
v-for="match in hotMatches"
:key="match.id"
class="card match-card"
@click="goMatch(match.id)"
>
<div class="match-info">
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="match-time">{{ formatKickoff(match.startTime) }}</div>
</div>
<div class="match-flags" aria-hidden="true">
<img v-if="homeFlag(match)" :src="homeFlag(match)" alt="" class="flag" />
<span v-else class="flag-ph"></span>
<span class="vs">VS</span>
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
<span v-else class="flag-ph"></span>
</div>
</div>
<div v-if="!loading && !hotMatches.length" class="empty">
@@ -32,10 +66,83 @@ function goMatch(id: string) {
</template>
<style scoped>
.match-card { cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; }
.match-card:active { border-color: var(--border-gold-soft); }
.match-teams { font-weight: 800; margin-bottom: 8px; font-size: 16px; }
.match-time { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.empty { text-align: center; color: var(--text-muted); padding: 40px 20px; font-weight: 600; }
.empty-icon { width: 96px; height: 96px; margin-bottom: 14px; }
.match-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.match-card:active {
border-color: var(--border-gold-soft);
}
.match-info {
flex: 1;
min-width: 0;
}
.match-teams {
font-weight: 800;
margin-bottom: 8px;
font-size: 16px;
line-height: 1.3;
}
.match-time {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.match-flags {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2a2a2a;
border-radius: 8px;
}
.flag {
width: 32px;
height: 22px;
object-fit: cover;
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.flag-ph {
width: 32px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0.45;
}
.vs {
font-size: 11px;
font-weight: 900;
color: var(--primary-light);
letter-spacing: 0.04em;
}
.empty {
text-align: center;
color: var(--text-muted);
padding: 40px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
</style>

View File

@@ -1,15 +1,25 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import api from '../api';
import PlayerAvatarModal from '../components/PlayerAvatarModal.vue';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useAuthStore } from '../stores/auth';
const { t } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { loadProfile, setAvatarKey, profileRaw, avatarUrl, avatarKey } = usePlayerProfile();
const username = ref('');
const viewablePassword = ref('');
const passwordVisible = ref(false);
const phone = ref('');
const email = ref('');
const avatarModalOpen = ref(false);
const passwordChangeOpen = ref(false);
const oldPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
@@ -17,14 +27,60 @@ const message = ref('');
const error = ref('');
const saving = ref(false);
onMounted(async () => {
const { data } = await api.get('/player/profile');
const user = data.data;
username.value = user?.username ?? '';
const allowPasswordChange = computed(
() => profileRaw.value?.preferences?.allowPasswordChange ?? true,
);
const allowUsernameChange = computed(
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
);
const passwordDisplay = computed(() => viewablePassword.value || '');
const canTogglePassword = computed(() => !!viewablePassword.value);
const passwordInputType = computed(() =>
passwordVisible.value && canTogglePassword.value ? 'text' : 'password',
);
const displayAvatarUrl = computed(() => {
if (avatarUrl.value) return avatarUrl.value;
const seed = profileRaw.value?.username ?? auth.user?.username;
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
});
function syncFromProfile() {
const user = profileRaw.value;
username.value = user?.username ?? auth.user?.username ?? '';
viewablePassword.value = user?.preferences?.viewablePassword ?? '';
phone.value = user?.preferences?.phone ?? '';
email.value = user?.preferences?.email ?? '';
}
function togglePasswordVisible() {
if (!canTogglePassword.value) return;
passwordVisible.value = !passwordVisible.value;
}
onMounted(async () => {
await loadProfile(true);
syncFromProfile();
});
function openAvatarModal() {
avatarModalOpen.value = true;
}
async function confirmAvatar(key: string | null) {
avatarModalOpen.value = false;
try {
await api.patch('/player/profile', { avatarKey: key });
setAvatarKey(key);
} catch (e: unknown) {
setAvatarKey(key);
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.save_failed');
}
}
function wantsPasswordChange() {
return !!(oldPassword.value || newPassword.value || confirmPassword.value);
}
@@ -48,11 +104,21 @@ async function saveAll() {
const parts: string[] = [];
try {
await api.patch('/player/profile', {
const profilePayload: { phone?: string; email?: string; username?: string } = {
phone: phone.value.trim() || undefined,
email: email.value.trim() || undefined,
});
};
if (allowUsernameChange.value) {
profilePayload.username = username.value.trim();
}
await api.patch('/player/profile', profilePayload);
if (allowUsernameChange.value && username.value.trim() && auth.user) {
auth.user.username = username.value.trim();
}
parts.push(t('profile.saved'));
if (allowUsernameChange.value && profilePayload.username) {
parts.push(t('profile.username_updated'));
}
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
@@ -62,14 +128,22 @@ async function saveAll() {
}
if (wantsPasswordChange()) {
if (!allowPasswordChange.value) {
error.value = t('profile.password_disabled');
saving.value = false;
return;
}
try {
await api.post('/player/auth/change-password', {
oldPassword: oldPassword.value,
newPassword: newPassword.value,
});
viewablePassword.value = newPassword.value;
passwordVisible.value = false;
oldPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
passwordChangeOpen.value = false;
parts.push(t('profile.password_changed'));
} catch (e: unknown) {
error.value =
@@ -96,57 +170,117 @@ function back() {
<h2 class="page-title">{{ t('profile.edit') }}</h2>
</header>
<section class="avatar-card">
<div class="avatar-circle">
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
</div>
<button type="button" class="avatar-change-btn" @click="openAvatarModal">
{{ t('profile.avatar_change') }}
</button>
</section>
<form class="form-card" @submit.prevent="saveAll">
<h3 class="section-title">{{ t('profile.section_account') }}</h3>
<div class="field">
<label>{{ t('auth.username') }}</label>
<input :value="username" class="readonly" disabled />
<input
v-model="username"
class="field-input"
:class="{ readonly: !allowUsernameChange }"
:disabled="!allowUsernameChange"
:placeholder="t('profile.username_placeholder')"
/>
<p v-if="!allowUsernameChange" class="field-hint inline-hint">{{ t('profile.username_readonly_hint') }}</p>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<div class="input-eye-wrap">
<input
:type="passwordInputType"
:value="passwordDisplay"
class="field-input input-with-eye"
readonly
:placeholder="canTogglePassword ? '' : t('profile.password_unavailable')"
/>
<button
type="button"
class="eye-btn"
:disabled="!canTogglePassword"
:aria-label="passwordVisible ? t('profile.hide_password') : t('profile.show_password')"
@click="togglePasswordVisible"
>
{{ passwordVisible ? t('profile.hide_password') : t('profile.show_password') }}
</button>
</div>
<p v-if="!canTogglePassword" class="field-hint inline-hint">{{ t('profile.password_unavailable_hint') }}</p>
</div>
<template v-if="allowPasswordChange">
<button type="button" class="section-toggle compact-toggle" @click="passwordChangeOpen = !passwordChangeOpen">
<span>{{ t('profile.change_password') }}</span>
<span class="chevron" :class="{ open: passwordChangeOpen }"></span>
</button>
<div v-show="passwordChangeOpen" class="password-block">
<div class="field">
<label>{{ t('profile.old_password') }}</label>
<input
v-model="oldPassword"
type="password"
class="field-input"
autocomplete="current-password"
:placeholder="t('profile.old_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.new_password') }}</label>
<input
v-model="newPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.new_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.confirm_password') }}</label>
<input
v-model="confirmPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.confirm_password_placeholder')"
/>
</div>
</div>
</template>
<div class="section-divider" />
<h3 class="section-title">{{ t('profile.section_contact') }}</h3>
<div class="field">
<label>{{ t('profile.phone') }}</label>
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" />
</div>
<div class="field">
<div class="field field-last">
<label>{{ t('profile.email') }}</label>
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
</div>
<p class="field-hint">{{ t('profile.password_optional_hint') }}</p>
<div class="field">
<label>{{ t('profile.old_password') }}</label>
<input
v-model="oldPassword"
type="password"
class="field-input"
autocomplete="current-password"
:placeholder="t('profile.old_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.new_password') }}</label>
<input
v-model="newPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.new_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.confirm_password') }}</label>
<input
v-model="confirmPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.confirm_password_placeholder')"
/>
</div>
<button type="submit" class="btn-action btn-gold-outline" :disabled="saving">
{{ t('profile.save') }}
</button>
</form>
<PlayerAvatarModal
:open="avatarModalOpen"
:model-value="avatarKey"
@close="avatarModalOpen = false"
@confirm="confirmAvatar"
/>
<p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error" class="msg err">{{ error }}</p>
</div>
@@ -154,11 +288,14 @@ function back() {
<style scoped>
.edit-page {
padding: 8px 0 12px;
padding: 8px 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.page-head {
margin-bottom: 10px;
margin-bottom: 0;
}
.back-btn {
@@ -177,22 +314,150 @@ function back() {
letter-spacing: 0.04em;
}
.avatar-card,
.form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.avatar-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 16px;
}
.avatar-circle {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid var(--border-gold-soft);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #2a2210, #141008);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.avatar-change-btn {
padding: 7px 18px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
}
.form-card {
padding: 16px;
}
.section-title {
margin: 0 0 14px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.section-divider {
height: 1px;
background: var(--border);
margin: 6px 0 12px;
}
.section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0 12px;
background: none;
color: var(--text);
font-size: 14px;
font-weight: 700;
}
.chevron {
color: var(--text-muted);
font-size: 20px;
line-height: 1;
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.password-block {
padding-bottom: 4px;
}
.field {
margin-bottom: 10px;
margin-bottom: 14px;
}
.field-last {
margin-bottom: 18px;
}
.field-hint {
font-size: 11px;
color: var(--text-muted);
margin: 4px 0 10px;
line-height: 1.4;
margin: 6px 0 0;
line-height: 1.45;
}
.inline-hint {
margin-bottom: 0;
}
.input-eye-wrap {
position: relative;
display: flex;
align-items: stretch;
}
.input-with-eye {
padding-right: 52px;
}
.eye-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
min-width: 48px;
padding: 0 10px;
border: none;
border-left: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: rgba(212, 175, 55, 0.06);
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.eye-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.compact-toggle {
padding: 2px 0 10px;
font-size: 13px;
}
label {
@@ -200,14 +465,14 @@ label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 4px;
margin-bottom: 6px;
}
.field-input,
.readonly {
display: block;
width: 100%;
padding: 9px 11px;
padding: 10px 12px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
@@ -243,7 +508,7 @@ label {
.btn-action {
width: 100%;
margin-top: 4px;
padding: 10px 14px;
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 800;
@@ -256,7 +521,7 @@ label {
}
.msg {
margin-top: 10px;
margin-top: 0;
font-size: 13px;
font-weight: 600;
}