328 lines
11 KiB
Vue
328 lines
11 KiB
Vue
<template>
|
||
<section class="m-home">
|
||
<AnnouncementPopup
|
||
:show="popupVisible"
|
||
:notice="loginPopupNotice"
|
||
@read="markPopupRead"
|
||
/>
|
||
<div class="m-home-hero" :style="bannerStyle">
|
||
<button
|
||
v-if="currentBanner && currentBanner.target_type !== 'none'"
|
||
class="m-home-banner-link"
|
||
type="button"
|
||
:aria-label="currentBanner.alt_text || '打开 Banner 链接'"
|
||
@click="openBanner(currentBanner)"
|
||
></button>
|
||
<label class="m-search">
|
||
<n-icon :component="SearchOutline" />
|
||
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
|
||
</label>
|
||
<div class="m-greeting">
|
||
<h1>Hi,{{ home?.profile?.username || '会员' }}</h1>
|
||
<p>一起创造最美好的生活</p>
|
||
</div>
|
||
<div class="m-balance-card">
|
||
<div
|
||
><span>我的积分账户</span
|
||
><strong>{{ money(home?.wallet?.available_amount, home?.wallet?.currency) }}</strong></div
|
||
>
|
||
<div class="m-coin" aria-hidden="true">¥</div>
|
||
</div>
|
||
<div v-if="(home?.banners?.length || 0) > 1" class="m-home-banner-dots" aria-label="Banner 切换">
|
||
<button
|
||
v-for="(_, index) in home?.banners"
|
||
:key="index"
|
||
type="button"
|
||
:class="{ active: index === activeBannerIndex }"
|
||
:aria-label="`显示第 ${index + 1} 张 Banner`"
|
||
@click="selectBanner(index)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="m-home-body">
|
||
<AsyncState :loading="loading" :error="error" @retry="load">
|
||
<div class="m-shortcuts m-card">
|
||
<button
|
||
v-for="item in shortcuts"
|
||
:key="item.label"
|
||
:disabled="!item.enabled"
|
||
@click="openShortcut(item)"
|
||
>
|
||
<span><img :src="item.icon" alt="" /></span><b>{{ item.label }}</b>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
v-if="displayNotice"
|
||
class="m-notice m-card"
|
||
type="button"
|
||
:aria-label="`查看公告:${displayNotice.title || '最新公告'}`"
|
||
@click="openNotice"
|
||
>
|
||
<n-icon :component="MegaphoneOutline" />
|
||
<span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
|
||
{{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
|
||
</span>
|
||
<p :key="displayNotice.id" class="m-notice-copy">
|
||
{{ displayNotice.title || displayNotice.content || '查看最新通知' }}
|
||
</p>
|
||
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
|
||
</button>
|
||
|
||
<section class="m-section m-card m-home-ranking">
|
||
<div class="m-section-title"
|
||
><h2>荣誉榜</h2><router-link to="/app/leaderboard">更多 ›</router-link></div
|
||
>
|
||
<div v-if="leaderboard.length" class="m-home-rank-list">
|
||
<router-link
|
||
v-for="entry in leaderboard.slice(0, 4)"
|
||
:key="entry.rank"
|
||
to="/app/leaderboard"
|
||
>
|
||
<b :class="`rank-${entry.rank}`">{{ entry.rank === 1 ? '♛' : entry.rank }}</b>
|
||
<span class="m-home-rank-avatar">
|
||
<img v-if="safeImage(entry.avatar_url)" :src="safeImage(entry.avatar_url)" alt="" />
|
||
<i v-else>{{ entry.username.slice(0, 1) }}</i>
|
||
</span>
|
||
<strong>{{ entry.username }}</strong>
|
||
<em>{{ formatPoints(entry.claimed_points) }}积分</em>
|
||
<small>{{ entry.status || '领取成功' }}</small>
|
||
</router-link>
|
||
</div>
|
||
<p v-else class="m-home-rank-empty">暂无排行数据</p>
|
||
</section>
|
||
|
||
<section class="m-section m-card">
|
||
<div class="m-section-title"><h2>公司简介</h2></div>
|
||
<div class="m-company-links">
|
||
<router-link
|
||
v-for="item in companyLinks"
|
||
:key="item.label"
|
||
:to="item.path"
|
||
>
|
||
<img :src="item.icon" alt="" /><span>{{ item.label }}</span>
|
||
</router-link>
|
||
</div>
|
||
</section>
|
||
</AsyncState>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
|
||
import AsyncState from '../components/AsyncState.vue';
|
||
import AnnouncementPopup, {
|
||
type AnnouncementNotice,
|
||
} from '../components/AnnouncementPopup.vue';
|
||
import {
|
||
completeLoginNotice,
|
||
errorText,
|
||
money,
|
||
request,
|
||
safeImage,
|
||
shouldShowLoginNotice,
|
||
} from '../api';
|
||
import { mobileAssets } from '../assets';
|
||
|
||
interface HomeData {
|
||
profile: { username: string; avatar_url: string; level_name: string };
|
||
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
||
features: Record<string, boolean>;
|
||
banners: BannerData[];
|
||
notices: NoticeData[];
|
||
task_summary: {
|
||
today_count: number;
|
||
today_commission_amount: number;
|
||
yesterday_commission_amount: number;
|
||
};
|
||
}
|
||
interface BannerData {
|
||
id: number;
|
||
image_url?: string;
|
||
alt_text?: string;
|
||
target_type: 'none' | 'internal_route' | 'external_url';
|
||
target_value?: string;
|
||
}
|
||
interface NoticeData {
|
||
id: number;
|
||
title?: string;
|
||
content?: string;
|
||
source_type?: string;
|
||
}
|
||
interface LeaderboardEntry {
|
||
rank: number;
|
||
username: string;
|
||
avatar_url: string;
|
||
claimed_points: number;
|
||
status: string;
|
||
}
|
||
const router = useRouter();
|
||
const home = ref<HomeData>();
|
||
const loading = ref(true);
|
||
const error = ref('');
|
||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||
const loginPopupNotice = ref<AnnouncementNotice | null>(null);
|
||
const popupVisible = ref(false);
|
||
const homeAnnouncement = ref<AnnouncementNotice | null>(null);
|
||
const activeBannerIndex = ref(0);
|
||
const activeNoticeIndex = ref(0);
|
||
let bannerTimer: number | undefined;
|
||
let noticeTimer: number | undefined;
|
||
const formatPoints = (value: number) =>
|
||
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||
(value || 0) / 100
|
||
);
|
||
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
||
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
|
||
const displayNotice = computed(() => homeAnnouncement.value || currentNotice.value);
|
||
const bannerStyle = computed(() => {
|
||
const url = safeImage(currentBanner.value?.image_url);
|
||
return {
|
||
backgroundImage: url
|
||
? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})`
|
||
: `url(${mobileAssets.homeHero})`,
|
||
};
|
||
});
|
||
const shortcuts = computed(() => [
|
||
{
|
||
label: '领取',
|
||
path: '/app/claim',
|
||
icon: mobileAssets.shortcutClaim,
|
||
enabled: home.value?.features?.task_enabled !== false,
|
||
},
|
||
{
|
||
label: '储入',
|
||
path: '/app/recharge',
|
||
icon: mobileAssets.shortcutRecharge,
|
||
enabled: home.value?.features?.recharge_enabled === true,
|
||
},
|
||
{
|
||
label: '取出',
|
||
path: '/app/profile/withdrawal',
|
||
icon: mobileAssets.shortcutWithdraw,
|
||
enabled: home.value?.features?.withdrawal_enabled === true,
|
||
},
|
||
{
|
||
label: '手工',
|
||
path: '/app/mall',
|
||
icon: mobileAssets.shortcutMall,
|
||
enabled: home.value?.features?.manual_mall_enabled !== false,
|
||
},
|
||
]);
|
||
const companyLinks = [
|
||
{
|
||
label: '平台介绍',
|
||
path: '/app/content/platform_intro',
|
||
icon: mobileAssets.companyPlatform,
|
||
},
|
||
{ label: '规则说明', path: '/app/rules', icon: mobileAssets.companyRules },
|
||
{
|
||
label: '公司资质',
|
||
path: '/app/qualification',
|
||
icon: mobileAssets.companyQualification,
|
||
},
|
||
{ label: '代理合作', path: '/app/agency', icon: mobileAssets.companyAgency },
|
||
];
|
||
|
||
function openShortcut(item: { enabled: boolean; path: string }) {
|
||
if (item.enabled) router.push(item.path);
|
||
else window.$message?.warning('该功能暂未开放');
|
||
}
|
||
|
||
function selectBanner(index: number) {
|
||
activeBannerIndex.value = index;
|
||
restartBannerTimer();
|
||
}
|
||
|
||
function openBanner(banner: BannerData) {
|
||
const target = String(banner.target_value || '').trim();
|
||
if (!target) return;
|
||
if (banner.target_type === 'internal_route' && target.startsWith('/')) {
|
||
void router.push(target);
|
||
} else if (banner.target_type === 'external_url' && /^https:\/\//i.test(target)) {
|
||
window.open(target, '_blank', 'noopener,noreferrer');
|
||
}
|
||
}
|
||
|
||
function restartBannerTimer() {
|
||
if (bannerTimer) window.clearInterval(bannerTimer);
|
||
bannerTimer = undefined;
|
||
const count = home.value?.banners?.length || 0;
|
||
if (count > 1) {
|
||
bannerTimer = window.setInterval(() => {
|
||
activeBannerIndex.value = (activeBannerIndex.value + 1) % count;
|
||
}, 4500);
|
||
}
|
||
}
|
||
|
||
function restartNoticeTimer() {
|
||
if (noticeTimer) window.clearInterval(noticeTimer);
|
||
noticeTimer = undefined;
|
||
const count = home.value?.notices?.length || 0;
|
||
if (count > 1) {
|
||
noticeTimer = window.setInterval(() => {
|
||
activeNoticeIndex.value = (activeNoticeIndex.value + 1) % count;
|
||
}, 4000);
|
||
}
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const noticeRequest = request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null);
|
||
const [homeData, rankData, noticeData] = await Promise.all([
|
||
request<HomeData>({ url: '/v1/home' }),
|
||
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
|
||
noticeRequest,
|
||
]);
|
||
home.value = homeData;
|
||
leaderboard.value = rankData || [];
|
||
homeAnnouncement.value = noticeData;
|
||
loginPopupNotice.value = shouldShowLoginNotice() ? noticeData : null;
|
||
popupVisible.value = Boolean(loginPopupNotice.value);
|
||
} catch (err) {
|
||
error.value = errorText(err);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function markPopupRead() {
|
||
if (!loginPopupNotice.value) return;
|
||
const noticeId = loginPopupNotice.value.id;
|
||
popupVisible.value = false;
|
||
loginPopupNotice.value = null;
|
||
completeLoginNotice();
|
||
if (noticeId > 0) {
|
||
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
|
||
}
|
||
}
|
||
|
||
function openNotice() {
|
||
if (homeAnnouncement.value) {
|
||
loginPopupNotice.value = homeAnnouncement.value;
|
||
popupVisible.value = true;
|
||
return;
|
||
}
|
||
void router.push('/app/content/notices');
|
||
}
|
||
watch(() => home.value?.banners?.length, () => {
|
||
activeBannerIndex.value = 0;
|
||
restartBannerTimer();
|
||
});
|
||
watch(() => home.value?.notices?.length, () => {
|
||
activeNoticeIndex.value = 0;
|
||
restartNoticeTimer();
|
||
});
|
||
onMounted(load);
|
||
onBeforeUnmount(() => {
|
||
if (bannerTimer) window.clearInterval(bannerTimer);
|
||
if (noticeTimer) window.clearInterval(noticeTimer);
|
||
});
|
||
</script>
|