feat: connect portal content and banner carousel
This commit is contained in:
@@ -13,6 +13,9 @@ import (
|
||||
var publicContentKeys = map[string]struct{}{
|
||||
"privacy_policy": {},
|
||||
"user_agreement": {},
|
||||
"platform_intro": {},
|
||||
"platform_introduction": {},
|
||||
"frequently_asked_questions": {},
|
||||
}
|
||||
|
||||
func (a *API) home(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -53,6 +53,7 @@ type Notice struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
BodyHTML string `json:"body_html,omitempty"`
|
||||
SourceType string `json:"source_type"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
@@ -207,13 +208,16 @@ func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, p
|
||||
}
|
||||
|
||||
func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
|
||||
row, err := s.repository.LatestPopupNotice(ctx, tenantID, memberID)
|
||||
if err != nil || row == nil {
|
||||
row, err := s.repository.PublishedContent(ctx, tenantID, "home_popup_content")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt,
|
||||
CreatedAt: row.CreatedAt}, nil
|
||||
content := strings.TrimSpace(row.BodyText)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(row.Summary)
|
||||
}
|
||||
return &Notice{ID: 0, Title: row.Title, Content: content, BodyHTML: row.BodyHTML,
|
||||
SourceType: "managed_content", CreatedAt: row.PublishedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||
@@ -225,6 +229,20 @@ func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Con
|
||||
if !contentKeyPattern.MatchString(key) {
|
||||
return nil, ErrContentKeyInvalid
|
||||
}
|
||||
requestedKey := key
|
||||
titleOverride := ""
|
||||
switch key {
|
||||
case "platform_intro", "user_agreement":
|
||||
key = "platform_introduction"
|
||||
if requestedKey == "user_agreement" {
|
||||
titleOverride = "用户协议"
|
||||
}
|
||||
case "privacy_policy":
|
||||
// 当前后台只维护原型的 12 条正式文本,隐私条款沿用平台协议正文,
|
||||
// 避免为了前端路由额外制造一条无法在原型中对应的初始化数据。
|
||||
key = "platform_introduction"
|
||||
titleOverride = "隐私政策"
|
||||
}
|
||||
row, err := s.repository.PublishedContent(ctx, tenantID, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -233,7 +251,11 @@ func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Con
|
||||
if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" {
|
||||
_ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs)
|
||||
}
|
||||
return &Content{ContentKey: row.ContentKey, ContentType: row.ContentType, Title: row.Title,
|
||||
title := row.Title
|
||||
if titleOverride != "" {
|
||||
title = titleOverride
|
||||
}
|
||||
return &Content{ContentKey: requestedKey, ContentType: row.ContentType, Title: title,
|
||||
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
|
||||
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
|
||||
}
|
||||
|
||||
@@ -203,15 +203,29 @@ func TestNoticesRejectsOversizedPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupNoticeMapsLatestUnreadNotice(t *testing.T) {
|
||||
service := New(fakeRepository{notices: []store.MemberNotice{{
|
||||
ID: 9, Title: "公告通知", Content: "新活动已上线", SourceType: 1,
|
||||
}}})
|
||||
func TestPopupNoticeUsesManagedContent(t *testing.T) {
|
||||
service := New(fakeRepository{content: &store.Content{
|
||||
ContentKey: "home_popup_content", Title: "公告通知", BodyText: "新活动已上线",
|
||||
PublishedAt: "2026-10-16T08:00:00Z",
|
||||
}})
|
||||
notice, err := service.PopupNotice(context.Background(), 1, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notice == nil || notice.ID != 9 || notice.Content != "新活动已上线" || notice.SourceType != "real" {
|
||||
if notice == nil || notice.ID != 0 || notice.Content != "新活动已上线" || notice.SourceType != "managed_content" {
|
||||
t.Fatalf("unexpected popup notice: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentAliasesPublicAgreementRoutesToManagedText(t *testing.T) {
|
||||
service := New(fakeRepository{content: &store.Content{
|
||||
ContentKey: "platform_introduction", Title: "平台简介", BodyText: "服务条款正文",
|
||||
}})
|
||||
content, err := service.Content(context.Background(), 1, "user_agreement")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content.ContentKey != "user_agreement" || content.Title != "用户协议" || content.BodyText != "服务条款正文" {
|
||||
t.Fatalf("unexpected aliased content: %+v", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||
<div class="m-announcement-body">
|
||||
<div id="announcement-content" class="m-announcement-content">
|
||||
{{ notice?.content }}
|
||||
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
|
||||
<template v-else>{{ notice?.content }}</template>
|
||||
</div>
|
||||
<button type="button" @click="$emit('read')">我已阅读</button>
|
||||
</div>
|
||||
@@ -33,6 +34,7 @@
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
body_html?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -375,12 +375,45 @@ body {
|
||||
background: var(--m-bg);
|
||||
}
|
||||
.m-home-hero {
|
||||
position: relative;
|
||||
min-height: 305px;
|
||||
padding: calc(34px + env(safe-area-inset-top)) 30px 26px;
|
||||
color: #fff;
|
||||
background-color: #1b1711;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: background-image 0.35s ease;
|
||||
}
|
||||
.m-home-banner-link {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-home-hero > :not(.m-home-banner-link) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.m-home-banner-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 13px;
|
||||
}
|
||||
.m-home-banner-dots button {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
transition: width 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.m-home-banner-dots button.active {
|
||||
width: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
.m-search {
|
||||
height: 48px;
|
||||
@@ -2371,6 +2404,25 @@ body {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.m-managed-rich-text {
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.m-managed-rich-text :where(h1, h2, h3, h4) {
|
||||
margin: 1em 0 0.55em;
|
||||
color: inherit;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.m-managed-rich-text :where(p, ul, ol) {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
.m-managed-rich-text :where(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.m-agency-page {
|
||||
padding: 0 18px 32px;
|
||||
@@ -2610,6 +2662,7 @@ body {
|
||||
gap: 22px;
|
||||
margin-top: 20px;
|
||||
font-size: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-rules-banner strong {
|
||||
font-size: 31px;
|
||||
@@ -2621,10 +2674,12 @@ body {
|
||||
.m-rules-row > span:first-child,
|
||||
.m-rules-row > span:last-child {
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.m-rule-level {
|
||||
gap: 1px;
|
||||
flex-direction: column;
|
||||
white-space: normal;
|
||||
}
|
||||
.m-rule-level img {
|
||||
width: 22px;
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
|
||||
|
||||
<section class="m-agency-block">
|
||||
<h2><i>1</i>什么是平台代理?</h2>
|
||||
<h2><i>1</i>{{ content?.title || '什么是平台代理?' }}</h2>
|
||||
<div v-if="content" class="m-managed-rich-text" v-html="content.body_html"></div>
|
||||
<template v-else>
|
||||
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
|
||||
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="m-agency-block">
|
||||
@@ -48,12 +51,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const content = ref<{ title: string; body_html: string }>();
|
||||
|
||||
const rewards = [
|
||||
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
|
||||
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
|
||||
{ level: '三级用户', rate: '4%', className: 'is-tertiary', icon: mobileAssets.agencyPersonTertiary },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
content.value = await request<{ title: string; body_html: string }>({
|
||||
url: '/v1/contents/agent_cooperation',
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -213,7 +213,8 @@
|
||||
<article v-if="content" class="m-content"
|
||||
><h1>{{ content.title }}</h1
|
||||
><p class="m-content-summary">{{ content.summary }}</p
|
||||
><div class="m-content-body">{{ content.body_text || '暂无正文' }}</div
|
||||
><div v-if="content.body_html" class="m-content-body m-managed-rich-text" v-html="content.body_html"></div
|
||||
><div v-else class="m-content-body">{{ content.body_text || '暂无正文' }}</div
|
||||
><time v-if="content.published_at"
|
||||
>发布时间:{{ dateTime(content.published_at) }}</time
|
||||
></article
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
@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')" />
|
||||
@@ -21,6 +28,16 @@
|
||||
>
|
||||
<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">
|
||||
@@ -82,7 +99,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
@@ -103,7 +120,7 @@
|
||||
profile: { username: string; avatar_url: string; level_name: string };
|
||||
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
||||
features: Record<string, boolean>;
|
||||
banners: Array<{ image_url?: string; title?: string }>;
|
||||
banners: BannerData[];
|
||||
notices: Array<{ title?: string; content?: string }>;
|
||||
task_summary: {
|
||||
today_count: number;
|
||||
@@ -111,6 +128,13 @@
|
||||
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 LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
@@ -124,9 +148,12 @@
|
||||
const error = ref('');
|
||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||
const popupNotice = ref<AnnouncementNotice | null>(null);
|
||||
const activeBannerIndex = ref(0);
|
||||
let bannerTimer: number | undefined;
|
||||
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
|
||||
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
||||
const bannerStyle = computed(() => {
|
||||
const url = safeImage(home.value?.banners?.[0]?.image_url);
|
||||
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})`
|
||||
@@ -178,6 +205,32 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -205,7 +258,16 @@
|
||||
const noticeId = popupNotice.value.id;
|
||||
popupNotice.value = null;
|
||||
completeLoginNotice();
|
||||
if (noticeId > 0) {
|
||||
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
watch(() => home.value?.banners?.length, () => {
|
||||
activeBannerIndex.value = 0;
|
||||
restartBannerTimer();
|
||||
});
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => {
|
||||
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
<section class="m-platform-intro">
|
||||
<PageHeader title="平台介绍" />
|
||||
|
||||
<AsyncState :loading="loading" :error="error" :empty="!intro" empty-text="暂无平台介绍" @retry="load">
|
||||
<template v-if="intro">
|
||||
<header class="m-platform-intro-head">
|
||||
<span>OLONS PLATFORM</span>
|
||||
<h2>平台简介</h2>
|
||||
<p>{{ platformIntroLead }}</p>
|
||||
<div><b>{{ platformIntroSections.length }}</b><small>服务条款</small><i></i><b>{{ platformFAQs.length }}</b><small>常见问题</small></div>
|
||||
<span>YUEXUANLI</span>
|
||||
<h2>{{ intro.title }}</h2>
|
||||
<p>{{ intro.summary }}</p>
|
||||
<div><b>01</b><small>服务条款</small><i></i><b>02</b><small>常见问题</small></div>
|
||||
</header>
|
||||
|
||||
<nav class="m-platform-intro-nav" aria-label="页面目录">
|
||||
@@ -16,33 +18,60 @@
|
||||
|
||||
<main>
|
||||
<section id="service-terms" class="m-platform-document">
|
||||
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>服务条款</h2></div></div>
|
||||
<article v-for="section in platformIntroSections" :key="section.title">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
</article>
|
||||
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>{{ intro.title }}</h2></div></div>
|
||||
<article class="m-managed-rich-text" v-html="intro.body_html"></article>
|
||||
</section>
|
||||
|
||||
<section id="common-questions" class="m-platform-document m-platform-faq">
|
||||
<div class="m-platform-section-title"><span>02</span><div><small>FAQ</small><h2>常见问题</h2></div></div>
|
||||
<article v-for="item in platformFAQs" :key="item.title">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p v-for="paragraph in item.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
</article>
|
||||
<div class="m-platform-section-title"><span>02</span><div><small>FAQ</small><h2>{{ faq?.title || '常见问题' }}</h2></div></div>
|
||||
<article v-if="faq" class="m-managed-rich-text" v-html="faq.body_html"></article>
|
||||
<article v-else><p>暂无常见问题。</p></article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { platformFAQs, platformIntroLead, platformIntroSections } from './platformIntroContent';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
|
||||
interface ManagedContent {
|
||||
title: string;
|
||||
summary: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
const activeSection = ref('service-terms');
|
||||
const intro = ref<ManagedContent>();
|
||||
const faq = ref<ManagedContent>();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id;
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [introData, faqData] = await Promise.all([
|
||||
request<ManagedContent>({ url: '/v1/contents/platform_introduction' }),
|
||||
request<ManagedContent>({ url: '/v1/contents/frequently_asked_questions' }).catch(() => undefined),
|
||||
]);
|
||||
intro.value = introData;
|
||||
faq.value = faqData;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -5,47 +5,93 @@
|
||||
>
|
||||
<PageHeader title="规则说明" />
|
||||
|
||||
<div class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
|
||||
<span>充值赠送金</span>
|
||||
<p><b>充值<strong>500元</strong></b><b>赠送<strong>20元</strong></b></p>
|
||||
<AsyncState :loading="loading" :error="error" :empty="!levels.length" empty-text="暂无生效的会员等级" @retry="load">
|
||||
<div v-if="entryLevel" class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
|
||||
<span>{{ entryLevel.name }}等级配置</span>
|
||||
<p><b>升级价格<strong>{{ yuan(entryLevel.upgrade_amount) }}</strong></b><b>赠送金额<strong>{{ yuan(entryLevel.gift_amount) }}</strong></b></p>
|
||||
</div>
|
||||
|
||||
<div class="m-rules-table" role="table" aria-label="会员规则">
|
||||
<div class="m-rules-row m-rules-head" role="row">
|
||||
<b>充值金额</b><b>会员等级</b><b>佣金比例</b><b>每日代购</b><b>额外奖励</b><b>单次提现</b>
|
||||
<b>升级价格</b><b>会员等级</b><b>订单佣金</b><b>每日接单</b><b>赠送金额</b><b>每日提现次数</b>
|
||||
</div>
|
||||
<div v-for="level in levels" :key="level.name" class="m-rules-row" role="row">
|
||||
<span>{{ level.amount }}</span>
|
||||
<span class="m-rule-level"><img :src="level.icon" alt="" />{{ level.name }}</span>
|
||||
<span>{{ level.rate }}</span>
|
||||
<span>{{ level.orders }}</span>
|
||||
<span>{{ level.bonus }}</span>
|
||||
<span>{{ level.withdrawal }}</span>
|
||||
<div v-for="level in levels" :key="level.id" class="m-rules-row" role="row">
|
||||
<span>≥ {{ yuan(level.upgrade_amount) }}</span>
|
||||
<span class="m-rule-level"><img :src="level.icon_url || fallbackIcon(level.code)" alt="" />{{ level.name }}</span>
|
||||
<span>{{ percent(level.commission_rate) }}</span>
|
||||
<span>{{ level.daily_order_limit }}单</span>
|
||||
<span>{{ yuan(level.gift_amount) }}</span>
|
||||
<span>{{ level.daily_withdraw_limit }}次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-rules-notes">
|
||||
<h2><span>03</span> 规则补充说明</h2>
|
||||
<ul>
|
||||
<li>达到对应单次充值门槛,赠送所列永久会员等级。</li>
|
||||
<li>每日代购数量、额外奖励及提现额度以对应档位为准。</li>
|
||||
<li>星耀、至尊档位原文佣金比例前后不一致,需核实。</li>
|
||||
<li>奖励领取条件、实际结算及提现限制请以正式规则为准。</li>
|
||||
<h2><span>03</span> {{ ruleContent?.title || '规则补充说明' }}</h2>
|
||||
<div v-if="ruleContent" class="m-managed-rich-text" v-html="ruleContent.body_html"></div>
|
||||
<ul v-else>
|
||||
<li>本页展示后台当前生效的会员等级配置,后台调整后重新进入页面即可查看新规则。</li>
|
||||
<li>订单佣金按订单金额 × 匹配时会员等级的佣金比例计算,实际金额以订单详情为准。</li>
|
||||
<li>每日接单、每日提现均为次数限制;赠送金额为等级配置,实际入账以账户明细为准。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const levels = [
|
||||
{ amount: '≥ 1888元', name: '白银', rate: '0.02', orders: '20单', bonus: '88元', withdrawal: '5000以下', icon: mobileAssets.levelSilver },
|
||||
{ amount: '≥ 10000元', name: '黄金', rate: '0.022', orders: '30单', bonus: '188元', withdrawal: '20000以下', icon: mobileAssets.levelGold },
|
||||
{ amount: '≥ 30000元', name: '铂金', rate: '0.03', orders: '40单', bonus: '388元', withdrawal: '30000以下', icon: mobileAssets.levelPlatinum },
|
||||
{ amount: '≥ 50000元', name: '钻石', rate: '0.035', orders: '50单', bonus: '588元', withdrawal: '50000以下', icon: mobileAssets.levelDiamond },
|
||||
{ amount: '≥ 80000元', name: '星耀', rate: '0.04', orders: '80单', bonus: '1888元', withdrawal: '100000以下', icon: mobileAssets.levelStar },
|
||||
{ amount: '≥ 180000元', name: '至尊', rate: '0.05', orders: '100单', bonus: '2888元', withdrawal: '无限制', icon: mobileAssets.levelCrown },
|
||||
];
|
||||
interface MemberLevel {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
icon_url: string;
|
||||
upgrade_amount: number;
|
||||
gift_amount: number;
|
||||
daily_order_limit: number;
|
||||
commission_rate: number;
|
||||
daily_withdraw_limit: number;
|
||||
}
|
||||
|
||||
const levels = ref<MemberLevel[]>([]);
|
||||
const ruleContent = ref<{ title: string; body_html: string }>();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const entryLevel = computed(() => levels.value[0]);
|
||||
const icons: Record<string, string> = {
|
||||
VIP1: mobileAssets.levelSilver,
|
||||
VIP2: mobileAssets.levelSilver,
|
||||
VIP3: mobileAssets.levelGold,
|
||||
VIP4: mobileAssets.levelPlatinum,
|
||||
VIP5: mobileAssets.levelDiamond,
|
||||
VIP6: mobileAssets.levelStar,
|
||||
VIP7: mobileAssets.levelCrown,
|
||||
};
|
||||
|
||||
const yuan = (cents: number) => `${(Number(cents || 0) / 100).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}元`;
|
||||
const percent = (basisPoints: number) => `${(Number(basisPoints || 0) / 100).toFixed(2)}%`;
|
||||
const fallbackIcon = (code: string) => icons[code] || mobileAssets.levelSilver;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [levelData, contentData] = await Promise.all([
|
||||
request<MemberLevel[]>({ url: '/v1/member-levels' }),
|
||||
request<{ title: string; body_html: string }>({ url: '/v1/contents/task_order_rules' }).catch(() => undefined),
|
||||
]);
|
||||
levels.value = levelData;
|
||||
ruleContent.value = contentData;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user