feat(theme-4): fix dark backgrounds, quick bet, inbox spacing, customer service theme

This commit is contained in:
mars
2026-07-09 18:03:57 +08:00
parent dcf9afacd6
commit ecfea51545
124 changed files with 26710 additions and 8578 deletions

View File

@@ -0,0 +1,564 @@
<script setup lang="ts">
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 GoldSpinner from '../components/GoldSpinner.vue';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useAuthStore } from '../stores/auth';
import { usePullToRefresh } from '../composables/usePullToRefresh';
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('');
const message = ref('');
const error = ref('');
const saving = ref(false);
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;
}
async function loadEditData() {
await loadProfile(true);
syncFromProfile();
}
onMounted(loadEditData);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: loadEditData,
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
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);
}
async function saveAll() {
error.value = '';
message.value = '';
if (wantsPasswordChange()) {
if (!oldPassword.value || !newPassword.value || !confirmPassword.value) {
error.value = t('profile.password_incomplete');
return;
}
if (newPassword.value !== confirmPassword.value) {
error.value = t('profile.password_mismatch');
return;
}
}
saving.value = true;
const parts: string[] = [];
try {
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 ||
t('profile.save_failed');
saving.value = false;
return;
}
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 =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.password_failed');
saving.value = false;
return;
}
}
message.value = parts.join(' · ');
saving.value = false;
}
function back() {
router.push('/profile');
}
</script>
<template>
<div class="edit-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="page-head sub-toolbar">
<button type="button" class="back-btn" @click="back"> {{ t('profile.back') }}</button>
<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
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 field-last">
<label>{{ t('profile.email') }}</label>
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_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>
</template>
<style scoped>
.edit-page {
padding: 0 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.page-head {
margin-bottom: 0;
}
.back-btn {
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
padding: 0;
}
.page-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
letter-spacing: 0.04em;
}
.avatar-card,
.form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.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: var(--gradient-card);
}
.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: var(--surface-accent);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
}
.form-card {
padding: var(--space-card);
}
.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: 14px;
}
.field-last {
margin-bottom: 18px;
}
.field-hint {
font-size: 11px;
color: var(--text-muted);
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: var(--surface-accent);
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 {
display: block;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 6px;
}
.field-input,
.readonly {
display: block;
width: 100%;
padding: 10px 12px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
transition: border-color 0.2s;
}
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.field-input:-webkit-autofill,
.field-input:-webkit-autofill:hover,
.field-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text);
box-shadow: 0 0 0 1000px var(--bg-input) inset;
border: 1px solid var(--border);
}
.field-input::placeholder {
color: var(--neutral);
}
.readonly {
opacity: 0.55;
cursor: not-allowed;
}
.btn-action {
width: 100%;
margin-top: 4px;
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
}
.btn-action:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.msg {
margin-top: 0;
font-size: 13px;
font-weight: 600;
}
.msg.ok {
color: var(--primary-light);
}
.msg.err {
color: var(--danger);
}
</style>