front/src/views/h5/customer-center/components/H5CouponsSheet.vue
zzh abfca7a21a feat: add H5 customer center, marketing admin, and employee profile UX
Ship customer portal and marketing pages, user edit drawer with employee archive, IDE employee numbers, channel config refactor, and related nginx/route updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:44:02 +08:00

710 lines
16 KiB
Vue

<template>
<a-drawer
:visible="visible"
placement="bottom"
height="auto"
:footer="false"
:header="false"
unmount-on-close
:drawer-style="drawerStyle"
popup-container="body"
class="h5-coupons-drawer"
@cancel="close"
>
<div class="h5-cp">
<div class="h5-cp__handle" aria-hidden="true" />
<header class="h5-cp__head">
<h3>{{ t('h5.coupons.title') }}</h3>
<button type="button" class="h5-cp__close" @click="close">
<icon-close />
</button>
</header>
<div class="h5-cp__tabs">
<button
v-for="tab in tabs"
:key="tab"
type="button"
class="h5-cp__tab"
:class="{ 'is-active': activeTab === tab }"
@click="switchTab(tab)"
>
{{ t(`h5.coupons.tab.${tab}`) }}
</button>
</div>
<a-spin :loading="loading" class="h5-cp__body">
<H5EmptyState
v-if="!rows.length && !loading"
:icon="IconGift"
:text="t('h5.coupons.empty')"
>
<button type="button" class="h5-cp__empty-cta" @click="goOrder">
{{ t('h5.coupons.use') }}
</button>
</H5EmptyState>
<article
v-for="item in rows"
:key="item.id"
class="coupon-ticket"
:class="{
'is-disabled': item.status !== 1,
'is-newcomer': item.isNewcomer,
'is-expiring': item.isExpiringSoon,
'is-used': item.status === 3,
'is-expired': item.status === 4,
}"
@click="item.status === 1 && goOrder()"
>
<span
v-if="item.status === 3 || item.status === 4"
class="coupon-stamp"
:class="item.status === 3 ? 'is-used' : 'is-expired'"
aria-hidden="true"
>
{{ statusLabel(item.status) }}
</span>
<div class="coupon-ticket__face">
<span v-if="item.isNewcomer" class="coupon-ticket__ribbon">{{
t('h5.coupons.badge.newcomer')
}}</span>
<div class="face-amount">
<template v-if="item.faceValue != null">
<span class="currency">¥</span>
<span class="num">{{ formatAmount(item.faceValue) }}</span>
</template>
<template v-else>
<span class="num rate">{{ formatDiscount(item.discountRate) }}</span>
<span class="unit">{{ t('h5.coupons.discountUnit') }}</span>
</template>
</div>
<p class="face-rule">
{{ t('h5.coupons.minOrder', { amount: formatAmount(item.minOrderAmount) }) }}
</p>
</div>
<div class="coupon-ticket__notch" aria-hidden="true">
<i /><i />
</div>
<div class="coupon-ticket__body">
<div class="body-top">
<h3>{{ item.templateName || t('h5.coupons.unnamed') }}</h3>
<span v-if="item.couponTypeName" class="type-chip">{{ item.couponTypeName }}</span>
</div>
<div class="meta-row meta-row--validity">
<span class="meta-label">{{ t('h5.coupons.validLabel') }}</span>
<strong class="meta-value">
{{ formatDate(item.validStartTime) }} ~ {{ formatDate(item.validEndTime) }}
</strong>
</div>
<div class="meta-row meta-row--how">
<span class="meta-label">{{ t('h5.coupons.howLabel') }}</span>
<p class="meta-how">{{ usageTip(item) }}</p>
</div>
<div v-if="item.status === 1" class="body-foot">
<span v-if="item.isExpiringSoon" class="expire-tag">
{{ t('h5.coupons.expiringSoon') }}
</span>
<span class="use-btn">{{ t('h5.coupons.use') }}</span>
</div>
</div>
</article>
</a-spin>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconClose, IconGift } from '@arco-design/web-vue/es/icon';
import { getMyCoupons } from '@/api/marketing/portal';
import H5EmptyState from './H5EmptyState.vue';
defineOptions({ name: 'H5CouponsSheet' });
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
use: [];
}>();
const { t } = useI18n();
const tabs = ['available', 'used', 'expired'] as const;
const activeTab = ref<(typeof tabs)[number]>('available');
const loading = ref(false);
const rows = ref<any[]>([]);
const statusMap: Record<string, number> = { available: 1, used: 3, expired: 4 };
const drawerStyle = {
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
maxHeight: 'min(92vh, 780px)',
};
function formatAmount(v: unknown) {
const n = Number(v);
if (!Number.isFinite(n)) return '0';
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, '');
}
function formatDiscount(rate: unknown) {
const n = Number(rate);
if (!Number.isFinite(n) || n <= 0) return '--';
return `${(n * 10).toFixed((n * 10) % 1 === 0 ? 0 : 1)}`;
}
function formatDate(value: unknown) {
if (!value) return '--';
return String(value).replace('T', ' ').slice(0, 10);
}
function statusLabel(status: number) {
if (status === 3) return t('h5.coupons.tab.used');
if (status === 4) return t('h5.coupons.tab.expired');
return '';
}
function usageTip(item: {
couponType?: string;
minOrderAmount?: number;
faceValue?: number | null;
}) {
const type = String(item.couponType || '');
const min = formatAmount(item.minOrderAmount ?? 0);
if (type === 'percent_off') {
return t('h5.coupons.how.percent', { amount: min });
}
if (type === 'shipping_off') {
return t('h5.coupons.how.shipping', { amount: min });
}
if (type === 'direct_off') {
return t('h5.coupons.how.direct');
}
return t('h5.coupons.how.fixed', { amount: min });
}
function isExpiringSoon(end: unknown, status: number) {
if (status !== 1 || !end) return false;
const endTime = new Date(String(end)).getTime();
if (!Number.isFinite(endTime)) return false;
const days = (endTime - Date.now()) / (24 * 60 * 60 * 1000);
return days >= 0 && days <= 3;
}
function close() {
emit('update:visible', false);
}
function goOrder() {
emit('use');
close();
}
function switchTab(tab: (typeof tabs)[number]) {
activeTab.value = tab;
void load();
}
async function load() {
loading.value = true;
try {
const res = await getMyCoupons({
page: 1,
pageSize: 50,
status: statusMap[activeTab.value],
});
rows.value = (res.items || []).map((raw: any) => {
const status = Number(raw.status ?? raw.Status ?? 0);
const validEndTime = raw.validEndTime ?? raw.ValidEndTime ?? '';
const grantChannel = String(raw.grantChannel ?? raw.GrantChannel ?? '');
return {
id: raw.id ?? raw.Id,
templateName: raw.templateName ?? raw.TemplateName,
faceValue:
raw.faceValue != null || raw.FaceValue != null
? Number(raw.faceValue ?? raw.FaceValue)
: null,
discountRate:
raw.discountRate != null || raw.DiscountRate != null
? Number(raw.discountRate ?? raw.DiscountRate)
: null,
minOrderAmount: Number(raw.minOrderAmount ?? raw.MinOrderAmount ?? 0),
validStartTime: raw.validStartTime ?? raw.ValidStartTime ?? '',
validEndTime,
status,
couponType: String(raw.couponType ?? raw.CouponType ?? ''),
couponTypeName: raw.couponTypeName ?? raw.CouponTypeName ?? '',
grantChannel,
isNewcomer: grantChannel === 'newcomer',
isExpiringSoon: isExpiringSoon(validEndTime, status),
};
});
} catch {
rows.value = [];
} finally {
loading.value = false;
}
}
watch(
() => props.visible,
(visible) => {
if (visible) {
activeTab.value = 'available';
void load();
}
},
);
</script>
<style lang="less">
.h5-coupons-drawer {
.arco-drawer-content {
border-radius: 20px 20px 0 0;
overflow: hidden;
max-height: min(92vh, 780px);
}
.arco-drawer-body {
padding: 0;
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
}
}
</style>
<style lang="less" scoped>
@coupon-orange: #ff7a18;
@coupon-orange-deep: #e85d04;
@coupon-orange-soft: #fff4e8;
.h5-cp {
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
padding-bottom: env(safe-area-inset-bottom, 0px);
background: #f5f7f8;
}
.h5-cp__handle {
width: 36px;
height: 4px;
margin: 10px auto 0;
border-radius: 999px;
background: #c9cdd4;
flex-shrink: 0;
}
.h5-cp__head {
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 48px;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1d2129;
}
}
.h5-cp__close {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #86909c;
font-size: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.h5-cp__tabs {
display: flex;
gap: 4px;
margin: 0 16px 12px;
padding: 3px;
border-radius: 12px;
background: #eef0f3;
flex-shrink: 0;
}
.h5-cp__tab {
flex: 1;
height: 32px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #86909c;
cursor: pointer;
&.is-active {
background: #fff;
color: #1d2129;
font-weight: 600;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
}
.h5-cp__body {
flex: 1;
min-height: 220px;
overflow: auto;
display: block;
width: 100%;
padding: 0 16px 16px;
box-sizing: border-box;
}
.h5-cp__empty-cta {
margin-top: 16px;
height: 40px;
padding: 0 20px;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, @coupon-orange, @coupon-orange-deep);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 8px 18px rgba(232, 93, 4, 0.28);
}
.coupon-ticket {
position: relative;
display: flex;
align-items: stretch;
min-height: 128px;
margin-bottom: 14px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
background: #fff;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
&:active:not(.is-disabled) {
transform: scale(0.985);
}
&.is-disabled {
cursor: default;
filter: grayscale(0.25);
opacity: 0.88;
.use-btn {
display: none;
}
}
&.is-newcomer .coupon-ticket__face {
background: linear-gradient(150deg, #ff9f1a 0%, @coupon-orange 45%, @coupon-orange-deep 100%);
}
&.is-expiring:not(.is-disabled) {
box-shadow:
0 4px 16px rgba(232, 93, 4, 0.14),
0 0 0 1.5px rgba(232, 93, 4, 0.28);
}
}
.coupon-stamp {
position: absolute;
top: 18px;
right: 10px;
z-index: 3;
width: 64px;
height: 64px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
transform: rotate(18deg);
pointer-events: none;
box-sizing: border-box;
border: 2.5px solid currentColor;
background:
radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.55), transparent 42%),
rgba(255, 255, 255, 0.72);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.5),
0 2px 8px rgba(15, 23, 42, 0.08);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
&::before {
content: '';
position: absolute;
inset: 4px;
border-radius: 50%;
border: 1px dashed currentColor;
opacity: 0.75;
}
&.is-used {
color: #86909c;
}
&.is-expired {
color: #c9cdd4;
}
}
.coupon-ticket__face {
position: relative;
width: 128px;
flex-shrink: 0;
padding: 20px 10px 16px;
color: #fff;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
background: linear-gradient(150deg, #ffb347 0%, @coupon-orange 48%, @coupon-orange-deep 100%);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
-32deg,
transparent,
transparent 8px,
rgba(255, 255, 255, 0.08) 8px,
rgba(255, 255, 255, 0.08) 16px
);
pointer-events: none;
}
}
.coupon-ticket__ribbon {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
height: 18px;
padding: 0 6px;
border-radius: 6px;
background: #fff;
color: @coupon-orange-deep;
font-size: 10px;
font-weight: 800;
line-height: 18px;
}
.face-amount {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 1px;
line-height: 1;
.currency {
font-size: 16px;
font-weight: 800;
margin-bottom: 5px;
}
.num {
font-size: 40px;
font-weight: 900;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
text-shadow: 0 3px 10px rgba(120, 40, 0, 0.22);
&.rate {
font-size: 34px;
}
}
.unit {
font-size: 14px;
font-weight: 700;
margin-bottom: 5px;
}
}
.face-rule {
position: relative;
z-index: 1;
margin: 10px 0 0;
font-size: 11px;
font-weight: 600;
opacity: 0.95;
line-height: 1.3;
}
.coupon-ticket__notch {
position: relative;
width: 14px;
flex-shrink: 0;
background: linear-gradient(#e5e6eb 50%, transparent 50%) top / 1.5px 8px repeat-y;
background-position: center;
i {
position: absolute;
left: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
background: #f5f7f8;
transform: translateX(-50%);
&:first-child {
top: -7px;
}
&:last-child {
bottom: -7px;
}
}
}
.coupon-ticket__body {
flex: 1;
min-width: 0;
padding: 14px 14px 12px 8px;
display: flex;
flex-direction: column;
justify-content: center;
}
.body-top {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
h3 {
flex: 1;
min-width: 0;
margin: 0;
font-size: 15px;
font-weight: 700;
color: #1d2129;
line-height: 1.35;
}
}
.type-chip {
flex-shrink: 0;
height: 20px;
padding: 0 7px;
border-radius: 999px;
background: @coupon-orange-soft;
color: @coupon-orange-deep;
font-size: 11px;
font-weight: 700;
line-height: 20px;
}
.meta-row {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 6px;
&--validity .meta-value {
font-size: 13px;
font-weight: 800;
color: @coupon-orange-deep;
letter-spacing: 0.01em;
line-height: 1.35;
}
&--how .meta-how {
margin: 0;
font-size: 12px;
color: #4e5969;
line-height: 1.45;
font-weight: 500;
}
}
.meta-label {
flex-shrink: 0;
height: 18px;
padding: 0 5px;
border-radius: 4px;
background: rgba(232, 93, 4, 0.1);
color: @coupon-orange-deep;
font-size: 10px;
font-weight: 800;
line-height: 18px;
}
.body-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 12px;
}
.expire-tag {
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: rgba(232, 93, 4, 0.14);
color: @coupon-orange-deep;
font-size: 11px;
font-weight: 800;
line-height: 22px;
animation: coupon-pulse 1.6s ease-in-out infinite;
}
.use-btn {
margin-left: auto;
height: 30px;
padding: 0 16px;
border-radius: 999px;
display: inline-flex;
align-items: center;
font-size: 13px;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, @coupon-orange, @coupon-orange-deep);
box-shadow: 0 6px 14px rgba(232, 93, 4, 0.32);
}
@keyframes coupon-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.62;
}
}
</style>