All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m56s
Co-authored-by: Cursor <cursoragent@cursor.com>
513 lines
13 KiB
Vue
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>
|