front/src/views/user/marketing/member/MemberPanel.vue
zzh 4f96f7459f
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m56s
feat: 落地滚动会员与券邀请分成,列表状态改为 Tab
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 17:40:24 +08:00

513 lines
13 KiB
Vue

<template>
<div class="marketing-member-panel portal-panel">
<a-spin :loading="loading" class="member-spin">
<section v-if="overview" class="member-card portal-glass portal-section">
<span class="portal-glass__blob" aria-hidden="true" />
<span class="portal-glass__shine" aria-hidden="true" />
<div class="portal-glass__body member-card__body">
<div class="level-row">
<div class="level-main">
<p class="level-eyebrow">{{ t('marketing.portal.member.currentLevel') }}</p>
<div class="level-name-row">
<h2 class="level-name">{{ levelName }}</h2>
</div>
</div>
<a-button type="primary" size="small" class="member-cta" @click="goPlaceOrder">
{{ t('marketing.portal.member.goOrder') }}
</a-button>
</div>
<p class="benefit-desc">{{ levelNotice }}</p>
<div class="points-grid" :class="{ 'has-discount': !!extraDiscountText }">
<div class="points-item">
<span class="points-label">{{ t('marketing.portal.member.totalPoints') }}</span>
<span class="points-value">{{ formatNumber(rollingPoints) }}</span>
</div>
<div v-if="extraDiscountText" class="points-item">
<span class="points-label">{{ t('marketing.portal.member.extraDiscount') }}</span>
<span class="points-value accent">{{ extraDiscountText }}</span>
</div>
</div>
<div v-if="nextLevelName" class="progress-block">
<div class="progress-head">
<span>{{ t('marketing.portal.member.nextLevel', { name: nextLevelName }) }}</span>
<span class="progress-meta">
{{ progressPercent }}% ·
{{
t('marketing.portal.member.needPoints', {
points: formatNumber(pointsToNext),
})
}}
</span>
</div>
<a-progress
:percent="progressPercent / 100"
:show-text="false"
:color="'var(--app-primary-color)'"
size="small"
/>
</div>
<div v-else class="progress-block progress-block--done">
<p class="progress-tip">{{ t('marketing.portal.member.topLevelTip') }}</p>
</div>
<p v-if="benefitDesc" class="benefit-desc">{{ benefitDesc }}</p>
</div>
</section>
<PortalHowTo
:title="t('marketing.portal.member.howTitle')"
:hint="t('marketing.portal.member.howHint')"
:steps="howSteps"
storage-key="portal-howto-member"
/>
<section class="portal-detail-section">
<div class="portal-detail-section__head">
<h2 class="portal-section__title">{{ t('marketing.portal.member.pointsFlowTitle') }}</h2>
<span class="portal-section__hint">{{ t('marketing.portal.member.flowHint') }}</span>
</div>
<div v-if="!flowRows.length && !flowLoading" class="portal-empty">
<p class="portal-empty__title">{{ t('marketing.portal.member.emptyFlowTitle') }}</p>
<p class="portal-empty__desc">{{ t('marketing.portal.member.emptyFlowDesc') }}</p>
<a-button type="primary" @click="goPlaceOrder">
{{ t('marketing.portal.member.goOrder') }}
</a-button>
</div>
<a-spin v-else :loading="flowLoading">
<div class="portal-list">
<div v-for="(row, index) in flowRows" :key="`${row.createTime}-${index}`" class="portal-list-item">
<div class="flow-dot" :class="row.points >= 0 ? 'is-plus' : 'is-minus'" />
<div class="portal-list-item__main">
<div class="flow-top">
<span class="flow-type">{{ row.changeTypeName || t('marketing.portal.member.flow.changeType') }}</span>
<span class="flow-points" :class="row.points >= 0 ? 'is-plus' : 'is-minus'">
{{ row.points >= 0 ? '+' : '' }}{{ formatNumber(row.points) }}
</span>
</div>
<div class="flow-bottom">
<span v-if="row.remark" class="flow-remark">{{ row.remark }}</span>
<span class="flow-time">{{ row.createTime }}</span>
</div>
</div>
</div>
</div>
<div v-if="flowTotal > flowPageSize" class="list-pagination">
<PaginationPro
:total-count="flowTotal"
v-model:current-page="flowPage"
v-model:page-size="flowPageSize"
@page-change="loadPointsFlow"
@page-size-change="onFlowPageSizeChange"
/>
</div>
</a-spin>
</section>
</a-spin>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { getMemberOverview, getMemberPointsFlow } from '@/api/marketing/portal'
import PortalHowTo from '../components/PortalHowTo.vue'
import {
PORTAL_DRAWER_KEY,
type PortalDrawerContext,
} from '@/views/user/portal-drawer/types'
defineOptions({ name: 'MarketingPortalMemberPanel' })
const portalDrawer = inject<PortalDrawerContext | null>(PORTAL_DRAWER_KEY, null)
interface FlowRow {
changeTypeName: string
points: number
remark: string
createTime: string
}
const { t } = useI18n()
const router = useRouter()
const loading = ref(false)
const flowLoading = ref(false)
const overview = ref<Record<string, any> | null>(null)
const flowRows = ref<FlowRow[]>([])
const flowTotal = ref(0)
const flowPage = ref(1)
const flowPageSize = ref(10)
const howSteps = computed(() => [
{
title: t('marketing.portal.member.how1Title'),
desc: t('marketing.portal.member.how1Desc'),
},
{
title: t('marketing.portal.member.how2Title'),
desc: t('marketing.portal.member.how2Desc'),
},
{
title: t('marketing.portal.member.how3Title'),
desc: t('marketing.portal.member.how3Desc'),
},
])
const levelName = computed(
() =>
String(overview.value?.memberLevelName ?? overview.value?.MemberLevelName ?? '') ||
t('marketing.portal.member.defaultLevel'),
)
const rollingPoints = computed(() =>
Number(
overview.value?.rollingPoints ??
overview.value?.RollingPoints ??
overview.value?.totalPoints ??
overview.value?.TotalPoints ??
0,
),
)
const nextLevelName = computed(() =>
String(overview.value?.nextLevelName ?? overview.value?.NextLevelName ?? ''),
)
const pointsToNext = computed(() =>
Number(overview.value?.pointsToNextLevel ?? overview.value?.PointsToNextLevel ?? 0),
)
const benefitDesc = computed(() =>
String(overview.value?.benefitDesc ?? overview.value?.BenefitDesc ?? ''),
)
const extraDiscountRate = computed(() =>
Number(overview.value?.extraDiscountRate ?? overview.value?.ExtraDiscountRate ?? 0),
)
const extraDiscountText = computed(() => {
if (!extraDiscountRate.value) return t('marketing.memberLevel.discount.original')
return t('marketing.memberLevel.discount.fold', {
fold: Math.round((1 - extraDiscountRate.value) * 100),
})
})
const levelNotice = computed(() => t('marketing.portal.member.subtitle'))
const progressPercent = computed(() => {
if (!nextLevelName.value) return 100
const need = pointsToNext.value
const total = rollingPoints.value
const target = total + need
if (!target) return 100
return Math.min(Math.round((total / target) * 100), 100)
})
function formatNumber(val: unknown) {
const n = Number(val ?? 0)
return Number.isFinite(n) ? n.toLocaleString() : '0'
}
function mapFlowRow(item: Record<string, unknown>): FlowRow {
return {
changeTypeName: String(item.changeTypeName ?? item.ChangeTypeName ?? ''),
points: Number(item.points ?? item.Points ?? item.pointsDelta ?? item.PointsDelta ?? 0),
remark: String(item.remark ?? item.Remark ?? item.bizNo ?? item.BizNo ?? ''),
createTime: String(item.createTime ?? item.CreateTime ?? '').replace('T', ' ').slice(0, 19),
}
}
function goPlaceOrder() {
portalDrawer?.close()
router.push('/order/placeOrder')
}
async function loadOverview() {
loading.value = true
try {
overview.value = (await getMemberOverview()) as Record<string, any>
} catch (e: any) {
Message.error(e?.message || t('common.loadFailed'))
} finally {
loading.value = false
}
}
async function loadPointsFlow() {
flowLoading.value = true
try {
const { items, total } = await getMemberPointsFlow({
page: flowPage.value,
pageSize: flowPageSize.value,
})
flowRows.value = (items || []).map((r) => mapFlowRow(r as Record<string, unknown>))
flowTotal.value = Number(total || 0)
} catch {
flowRows.value = []
flowTotal.value = 0
} finally {
flowLoading.value = false
}
}
function onFlowPageSizeChange() {
flowPage.value = 1
loadPointsFlow()
}
onMounted(async () => {
await loadOverview()
await loadPointsFlow()
})
</script>
<style scoped lang="less">
@import '../styles/portal-shared.less';
.portal-panel {
min-height: 0;
width: 100%;
}
.member-spin {
display: block !important;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.portal-detail-section :deep(.arco-spin) {
display: block !important;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.member-card {
--liquid-a: rgba(0, 121, 121, 0.16);
--liquid-b: rgba(64, 128, 255, 0.1);
}
.member-card__body {
padding: 14px 12px 12px;
}
.level-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.level-main {
min-width: 0;
}
.level-eyebrow {
margin: 0 0 2px;
font-size: 12px;
color: var(--color-text-3);
}
.level-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.level-name {
margin: 0;
font-size: 22px;
font-weight: 760;
color: var(--color-text-1);
line-height: 1.15;
}
.tier-badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, var(--app-card-bg));
border: 1px solid color-mix(in srgb, var(--app-primary-color) 25%, var(--app-card-bg));
white-space: nowrap;
}
.member-cta {
flex-shrink: 0;
}
.points-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
&.has-discount {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.points-item {
min-width: 0;
padding: 10px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.85);
}
.points-label {
display: block;
margin-bottom: 4px;
font-size: 11px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.points-value {
display: block;
font-size: 18px;
font-weight: 700;
color: var(--color-text-1);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.accent {
color: var(--app-primary-color);
}
}
.progress-block {
margin-bottom: 0;
}
.progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
font-size: 12px;
color: var(--color-text-2);
min-width: 0;
}
.progress-meta {
flex-shrink: 0;
max-width: 58%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-3);
font-size: 12px;
text-align: right;
}
.progress-tip {
margin: 0;
font-size: 12px;
color: var(--color-text-3);
}
.progress-block--done .progress-tip {
color: var(--app-primary-color);
}
.benefit-desc {
margin: 10px 0 0;
padding-top: 10px;
border-top: 1px dashed color-mix(in srgb, var(--app-primary-color) 20%, var(--color-border-2));
font-size: 12px;
color: var(--color-text-2);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.flow-dot {
width: 10px;
height: 10px;
margin-top: 6px;
border-radius: 50%;
flex: 0 0 auto;
background: var(--color-text-4);
&.is-plus {
background: var(--app-primary-color);
}
&.is-minus {
background: var(--color-text-3);
}
}
.flow-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.flow-type {
font-size: 14px;
color: var(--color-text-1);
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-points {
font-size: 15px;
font-weight: 700;
color: var(--color-text-1);
flex: 0 0 auto;
&.is-plus {
color: var(--app-primary-color);
}
&.is-minus {
color: var(--color-text-2);
}
}
.flow-bottom {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: var(--color-text-3);
min-width: 0;
}
.flow-remark {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.flow-time {
flex: 0 0 auto;
}
.list-pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>