Files
olons_server/web/src/mobile/views/HomeView.vue
T
richjohnrambo 78165b7a15
Deploy olons-server production / deploy (push) Successful in 4m57s
feat: use login popup announcement on home
2026-10-20 22:13:51 +08:00

328 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>