feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { GamificationReportDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import AdminRules from '@/components/gamification/admin/AdminRules.vue';
|
||||
import AdminApprovals from '@/components/gamification/admin/AdminApprovals.vue';
|
||||
import AdminRewards from '@/components/gamification/admin/AdminRewards.vue';
|
||||
import AdminAchievements from '@/components/gamification/admin/AdminAchievements.vue';
|
||||
import AdminAdjust from '@/components/gamification/admin/AdminAdjust.vue';
|
||||
import AdminSettings from '@/components/gamification/admin/AdminSettings.vue';
|
||||
import AdminReports from '@/components/gamification/admin/AdminReports.vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
type Tab = 'rules' | 'redemptions' | 'rewards' | 'badges' | 'adjust' | 'settings' | 'reports';
|
||||
const TABS: Array<{
|
||||
value: Tab;
|
||||
label: string;
|
||||
icon: 'sliders' | 'check' | 'gift' | 'medal' | 'edit' | 'settings' | 'chart';
|
||||
}> = [
|
||||
{ value: 'rules', label: 'قوانین امتیاز', icon: 'sliders' },
|
||||
{ value: 'redemptions', label: 'تأییدها', icon: 'check' },
|
||||
{ value: 'rewards', label: 'فروشگاه پاداش', icon: 'gift' },
|
||||
{ value: 'badges', label: 'نشانها و بونوسها', icon: 'medal' },
|
||||
{ value: 'adjust', label: 'تنظیم دستی', icon: 'edit' },
|
||||
{ value: 'reports', label: 'گزارش و ROI', icon: 'chart' },
|
||||
{ value: 'settings', label: 'تنظیمات', icon: 'settings' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'owner' || auth.user?.role === 'admin');
|
||||
const pendingCount = ref(0);
|
||||
|
||||
const tab = computed<Tab>(() => {
|
||||
const t = route.query.tab;
|
||||
return TABS.some((x) => x.value === t) ? (t as Tab) : 'rules';
|
||||
});
|
||||
|
||||
function setTab(next: Tab) {
|
||||
void router.replace({ query: { ...route.query, tab: next === 'rules' ? undefined : next } });
|
||||
}
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
const { data } = await api.get<GamificationReportDto>('/admin/gamification/reports', {
|
||||
params: { period: 'today' },
|
||||
});
|
||||
pendingCount.value = data.totals.pendingApprovals + data.totals.pendingRedemptions;
|
||||
} catch {
|
||||
pendingCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onChanged() {
|
||||
void loadPending();
|
||||
window.dispatchEvent(new CustomEvent('gamification:updated'));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) void loadPending();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page gadmin">
|
||||
<header class="head">
|
||||
<RouterLink to="/club" class="back"><UiIcon name="chevron-right" :size="16" /> باشگاه امتیازات</RouterLink>
|
||||
<h1>مدیریت باشگاه امتیازات</h1>
|
||||
<p>قوانین امتیازدهی، پاداشها، نشانها و درخواستها را از اینجا مدیریت کنید.</p>
|
||||
</header>
|
||||
|
||||
<p v-if="!isAdmin" class="denied">این بخش فقط برای مالک و مدیران سازمان در دسترس است.</p>
|
||||
|
||||
<template v-else>
|
||||
<nav class="tabs" aria-label="بخشهای مدیریت باشگاه">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: tab === t.value }"
|
||||
@click="setTab(t.value)"
|
||||
>
|
||||
<UiIcon :name="t.icon" :size="15" />
|
||||
<span>{{ t.label }}</span>
|
||||
<em v-if="t.value === 'redemptions' && pendingCount">{{ toFaDigits(pendingCount) }}</em>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AdminRules v-if="tab === 'rules'" />
|
||||
<AdminApprovals v-else-if="tab === 'redemptions'" @changed="onChanged" />
|
||||
<AdminRewards v-else-if="tab === 'rewards'" />
|
||||
<AdminAchievements v-else-if="tab === 'badges'" />
|
||||
<AdminAdjust v-else-if="tab === 'adjust'" @changed="onChanged" />
|
||||
<AdminReports v-else-if="tab === 'reports'" />
|
||||
<AdminSettings v-else-if="tab === 'settings'" @changed="onChanged" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gadmin {
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-sm);
|
||||
text-decoration: none;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.denied {
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
color: var(--accent-deep);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab em {
|
||||
font-style: normal;
|
||||
font-size: 0.68rem;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,653 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { GamificationLeaderboardDto, GamificationMeDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
import ClubHome from '@/components/gamification/ClubHome.vue';
|
||||
import ClubHistory from '@/components/gamification/ClubHistory.vue';
|
||||
import ClubStore from '@/components/gamification/ClubStore.vue';
|
||||
import ClubBadges from '@/components/gamification/ClubBadges.vue';
|
||||
import ClubTeams from '@/components/gamification/ClubTeams.vue';
|
||||
import ClubRulesModal from '@/components/gamification/ClubRulesModal.vue';
|
||||
import { CLUB_CTX } from '@/components/gamification/club-context';
|
||||
|
||||
type Tab = 'home' | 'history' | 'store' | 'badges' | 'teams';
|
||||
const TABS: Array<{ value: Tab; label: string; icon: 'home' | 'clock' | 'gift' | 'medal' | 'users' }> = [
|
||||
{ value: 'home', label: 'باشگاه', icon: 'home' },
|
||||
{ value: 'history', label: 'تاریخچه امتیازات', icon: 'clock' },
|
||||
{ value: 'store', label: 'فروشگاه پاداش', icon: 'gift' },
|
||||
{ value: 'badges', label: 'نشانها', icon: 'medal' },
|
||||
{ value: 'teams', label: 'رقابت تیمی', icon: 'users' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const me = ref<GamificationMeDto | null>(null);
|
||||
const podium = ref<GamificationLeaderboardDto | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(true);
|
||||
const rulesOpen = ref(false);
|
||||
|
||||
const tab = computed<Tab>(() => {
|
||||
const t = route.query.tab;
|
||||
return TABS.some((x) => x.value === t) ? (t as Tab) : 'home';
|
||||
});
|
||||
|
||||
function setTab(next: Tab) {
|
||||
void router.replace({ query: { ...route.query, tab: next === 'home' ? undefined : next } });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [m, p] = await Promise.all([
|
||||
api.get<GamificationMeDto>('/gamification/me', { params: { period: 'month' } }),
|
||||
api.get<GamificationLeaderboardDto>('/gamification/leaderboard', { params: { period: 'month', limit: 3 } }),
|
||||
]);
|
||||
me.value = m.data;
|
||||
podium.value = p.data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری باشگاه امتیازات ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
provide(CLUB_CTX, { me, reload: load });
|
||||
|
||||
/** سکوی سه نفر اول به ترتیب نمایش: دوم، اول، سوم */
|
||||
const podiumOrder = computed(() => {
|
||||
const items = podium.value?.items ?? [];
|
||||
return [items[1], items[0], items[2]].filter((x): x is NonNullable<typeof x> => !!x && x.score > 0);
|
||||
});
|
||||
|
||||
const gapText = computed(() => {
|
||||
const m = me.value;
|
||||
if (!m || m.rank == null) return '';
|
||||
if (m.rank === 1 && m.periodScore > 0) return 'تو صدرنشین این ماهی! همینطور ادامه بده.';
|
||||
if (m.gapToFirst != null && m.gapToFirst > 0) {
|
||||
return `تا رتبه اول فقط ${faNum(m.gapToFirst)} امتیاز فاصله داری!`;
|
||||
}
|
||||
return 'با اولین امتیاز این ماه وارد رقابت شو!';
|
||||
});
|
||||
|
||||
const gapProgress = computed(() => {
|
||||
const m = me.value;
|
||||
if (!m) return 0;
|
||||
if (m.rank === 1) return 100;
|
||||
const top = m.periodScore + (m.gapToFirst ?? 0);
|
||||
return top > 0 ? Math.round((m.periodScore / top) * 100) : 0;
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
}, 60_000);
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('gamification:updated', load);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('gamification:updated', load);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.rules,
|
||||
(v) => {
|
||||
if (v === '1') rulesOpen.value = true;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page club">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div class="hero-title">
|
||||
<span class="star"><UiIcon name="star" :size="20" /></span>
|
||||
<div>
|
||||
<h1>باشگاه امتیازات</h1>
|
||||
<p>هر کار خوب، امتیاز و سکه دارد؛ رقابت کن، پیشرفت کن و پاداش بگیر.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button type="button" class="hero-btn" @click="rulesOpen = true">
|
||||
<UiIcon name="lightbulb" :size="16" />
|
||||
<span>قوانین و نحوه امتیازدهی</span>
|
||||
</button>
|
||||
<button v-if="me?.isManager" type="button" class="hero-btn solid" @click="router.push('/club/admin')">
|
||||
<UiIcon name="sliders" :size="16" />
|
||||
<span>مدیریت باشگاه</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="hero-error">{{ error }}</p>
|
||||
|
||||
<div v-else class="hero-body" :class="{ loading }">
|
||||
<div class="podium" aria-label="سه نفر برتر این ماه">
|
||||
<div
|
||||
v-for="p in podiumOrder"
|
||||
:key="p.userId"
|
||||
class="pod"
|
||||
:class="[`r${p.rank}`, { me: p.isMe }]"
|
||||
>
|
||||
<span v-if="p.rank === 1" class="crown"><UiIcon name="crown" :size="18" /></span>
|
||||
<UserAvatar :user-id="p.userId" :has-avatar="p.hasAvatar" :name="p.fullName" :size="p.rank === 1 ? 54 : 44" />
|
||||
<strong class="pod-name">{{ p.isMe ? 'شما' : p.fullName }}</strong>
|
||||
<span class="pod-score">{{ faNum(p.score) }}</span>
|
||||
<span class="pod-step">{{ toFaDigits(p.rank) }}</span>
|
||||
</div>
|
||||
<p v-if="!podiumOrder.length && !loading" class="pod-empty">
|
||||
هنوز امتیازی در این ماه ثبت نشده — اولین نفر باش!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<div class="stat-row">
|
||||
<div class="stat">
|
||||
<span class="stat-label">رتبه شما</span>
|
||||
<strong>{{ me?.rank ? `#${toFaDigits(me.rank)}` : '—' }}</strong>
|
||||
<small v-if="me">از {{ toFaDigits(me.totalUsers) }} نفر</small>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">امتیاز کل</span>
|
||||
<strong>{{ faNum(me?.score ?? 0) }}</strong>
|
||||
<small v-if="me">این ماه {{ faNum(me.periodScore) }}</small>
|
||||
</div>
|
||||
<div class="stat coin">
|
||||
<span class="stat-label">موجودی سکه</span>
|
||||
<strong>{{ faNum(me?.coins ?? 0) }}</strong>
|
||||
<small>قابل خرج در فروشگاه</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap">
|
||||
<div class="gap-text">
|
||||
<UiIcon name="target" :size="15" />
|
||||
<span>{{ gapText }}</span>
|
||||
</div>
|
||||
<div class="bar"><i :style="{ width: `${gapProgress}%` }" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-side">
|
||||
<div class="goal">
|
||||
<div class="goal-head">
|
||||
<span>هدف بعدی</span>
|
||||
<strong>{{ toFaDigits(me?.goal.progress ?? 0) }}٪</strong>
|
||||
</div>
|
||||
<p>رسیدن به {{ faNum(me?.goal.target ?? 0) }} امتیاز در این ماه</p>
|
||||
<div class="bar warm"><i :style="{ width: `${me?.goal.progress ?? 0}%` }" /></div>
|
||||
</div>
|
||||
<button
|
||||
v-if="me?.nextReward"
|
||||
type="button"
|
||||
class="next-reward"
|
||||
@click="setTab('store')"
|
||||
>
|
||||
<UiIcon name="gift" :size="18" />
|
||||
<span>
|
||||
<small>پاداش بعدی</small>
|
||||
<b>{{ me.nextReward.name }}</b>
|
||||
</span>
|
||||
<em>{{ faNum(me.nextReward.remaining) }} سکه مانده</em>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="c-tabs" aria-label="بخشهای باشگاه امتیازات">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="c-tab"
|
||||
:class="{ on: tab === t.value }"
|
||||
@click="setTab(t.value)"
|
||||
>
|
||||
<UiIcon :name="t.icon" :size="15" />
|
||||
<span>{{ t.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<ClubHome v-if="tab === 'home'" @go="setTab" />
|
||||
<ClubHistory v-else-if="tab === 'history'" />
|
||||
<ClubStore v-else-if="tab === 'store'" />
|
||||
<ClubBadges v-else-if="tab === 'badges'" />
|
||||
<ClubTeams v-else-if="tab === 'teams'" />
|
||||
|
||||
<ClubRulesModal :open="rulesOpen" @close="rulesOpen = false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.club {
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
padding: var(--sp-5);
|
||||
color: #ecfeff;
|
||||
background:
|
||||
radial-gradient(circle at 12% 20%, rgba(45, 212, 191, 0.28), transparent 42%),
|
||||
radial-gradient(circle at 85% 90%, rgba(250, 204, 21, 0.14), transparent 40%),
|
||||
linear-gradient(135deg, #0b3b3c 0%, #0f5154 55%, #115e59 100%);
|
||||
box-shadow: 0 18px 40px -24px rgba(15, 81, 84, 0.8);
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.star {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #fde68a, #f59e0b);
|
||||
color: #78350f;
|
||||
flex: 0 0 auto;
|
||||
box-shadow: 0 6px 16px -6px rgba(245, 158, 11, 0.8);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-title p {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.95rem;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hero-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.hero-btn.solid {
|
||||
background: #fff;
|
||||
color: #0f5154;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.hero-error {
|
||||
margin: 0;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||
gap: var(--sp-4);
|
||||
align-items: stretch;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-body.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.podium {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.pod {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 92px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pod-name {
|
||||
font-size: var(--fs-xs);
|
||||
color: #fff;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pod-score {
|
||||
font-size: var(--fs-xs);
|
||||
color: #fde68a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pod-step {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px 10px 4px 4px;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
color: #0f5154;
|
||||
background: linear-gradient(180deg, #e2e8f0, #94a3b8);
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.pod.r1 .pod-step {
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, #fde68a, #f59e0b);
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.pod.r3 .pod-step {
|
||||
height: 34px;
|
||||
background: linear-gradient(180deg, #fed7aa, #c2410c);
|
||||
color: #431407;
|
||||
}
|
||||
|
||||
.pod.me .pod-name {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.crown {
|
||||
color: #fbbf24;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35));
|
||||
animation: bob 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bob {
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.pod-empty {
|
||||
margin: auto 0;
|
||||
color: #a5f3fc;
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stat.coin strong {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.stat small {
|
||||
font-size: 0.7rem;
|
||||
color: #99f6e4;
|
||||
}
|
||||
|
||||
.gap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gap-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #2dd4bf, #a7f3d0);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.bar.warm {
|
||||
background: rgba(154, 52, 18, 0.15);
|
||||
}
|
||||
|
||||
.bar.warm i {
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.goal {
|
||||
background: linear-gradient(145deg, #fff7ed, #ffedd5);
|
||||
color: #7c2d12;
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.goal-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.goal-head strong {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.goal p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.next-reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.next-reward span {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.next-reward small {
|
||||
font-size: 0.7rem;
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.next-reward b {
|
||||
font-size: var(--fs-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.next-reward em {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
color: #fde68a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c-tabs {
|
||||
display: flex;
|
||||
gap: var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.c-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.c-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.15rem;
|
||||
margin-bottom: -1px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--muted-light);
|
||||
font: inherit;
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c-tab:hover {
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.c-tab.on {
|
||||
color: var(--accent-hover);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.hero-body {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-column: 1 / -1;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.hero {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.pod {
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.c-tabs {
|
||||
gap: var(--sp-4);
|
||||
margin-inline: calc(-1 * var(--sp-3));
|
||||
padding-inline: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user