front/src/views/home/workplace/components/banner.vue
zzh ef74985ad9
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m34s
feat: 顶栏余额刷新、客户 OpenAPI 凭证与揽收/H5 下单体验优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 17:33:47 +08:00

403 lines
10 KiB
Vue

<template>
<div class="banner" :class="{ 'is-compact': compact }">
<div class="banner-row">
<div class="banner-hello">
<a-avatar :size="compact ? 32 : 40" class="banner-hello__avatar">
<img :src="avatarUrl" alt="" />
</a-avatar>
<p class="banner-hello__line">
<span class="banner-hello__greet">{{ greetText }}</span>
<span class="banner-hello__name">{{ displayName }}</span>
</p>
<div v-if="!compact" class="banner-local-clock" aria-live="polite">
<span class="banner-local-clock__label">{{ t('workplace.clock.local') }}</span>
<span class="banner-local-clock__date">{{ dateText }}</span>
<span class="banner-local-clock__time">{{ timeText }}</span>
</div>
</div>
<button
v-if="showBalance"
type="button"
class="balance-chip"
:class="{ 'balance-chip--low': isLowBalance, 'balance-chip--loading': balanceLoading }"
:title="t('workplace.rechargeNow')"
@click="goRecharge"
>
<span class="balance-chip__label">{{ t('workplace.balance') }}</span>
<span class="balance-chip__value">
<template v-if="balanceLoading">···</template>
<template v-else>{{ balanceDisplay }}</template>
</span>
<span class="balance-chip__cta">{{ t('workplace.rechargeNow') }}</span>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { resolveCurrentCustomer, peekCachedCurrentCustomer } from '@/api/customer/resolve';
import { CUSTOMER_BALANCE_CHANGED_EVENT } from '@/api/customer/accountFlow';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { isValidAvatarUrl, resolveAvatarUrl } from '@/utils/avatar';
defineProps<{
/** 员工工作台:一行问候,避免与关照重复 */
compact?: boolean
}>()
const LOW_BALANCE_THRESHOLD = 100;
const { t, locale } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const rbacUserStore = useRbacUserStore();
const showBalance = computed(() => rbacUserStore.isCustomerPortal());
const customerBalance = ref<number | null>(null);
const balanceLoading = ref(false);
const wechatAvatarRaw = ref('');
const now = ref(new Date());
let timerId: number | undefined;
const displayName = computed(
() => userStore.name?.trim() || rbacUserStore.userInfo?.account || '—',
);
const avatarUrl = computed(() => {
if (showBalance.value && isValidAvatarUrl(wechatAvatarRaw.value)) {
return resolveAvatarUrl(wechatAvatarRaw.value);
}
return resolveAvatarUrl(userStore.avatar || rbacUserStore.userInfo?.avatar);
});
const dateLocale = computed(() =>
String(locale.value).toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN',
);
const dateText = computed(() =>
new Intl.DateTimeFormat(dateLocale.value, {
timeZone: 'Asia/Shanghai',
month: 'short',
day: 'numeric',
weekday: 'short',
}).format(now.value),
);
const timeText = computed(() =>
new Intl.DateTimeFormat(dateLocale.value, {
timeZone: 'Asia/Shanghai',
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(now.value),
);
const balanceDisplay = computed(() => {
const raw = customerBalance.value;
if (raw == null) return '--';
const parsed = Number(raw);
return Number.isFinite(parsed)
? parsed.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '--';
});
const isLowBalance = computed(() => {
const raw = customerBalance.value;
if (raw == null) return false;
return Number(raw) < LOW_BALANCE_THRESHOLD;
});
const greetText = computed(() => {
const hour = now.value.getHours();
if (hour < 6) return t('workplace.greet.night');
if (hour < 12) return t('workplace.greet.morning');
if (hour < 14) return t('workplace.greet.noon');
if (hour < 18) return t('workplace.greet.afternoon');
return t('workplace.greet.evening');
});
function applyWechatAvatar(data: { wechatAvatarUrl?: string; wechatAvatar?: string } | null) {
const next = data?.wechatAvatarUrl || data?.wechatAvatar || '';
wechatAvatarRaw.value = isValidAvatarUrl(next) ? String(next).trim() : '';
}
async function loadCustomerProfile() {
if (!rbacUserStore.isCustomerPortal() || !rbacUserStore.isLoggedIn) {
customerBalance.value = null;
wechatAvatarRaw.value = '';
return;
}
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const cached = peekCachedCurrentCustomer({ mapId, account });
if (cached) {
applyWechatAvatar(cached);
const cachedBal = cached.accountBalance ?? cached.balance;
if (cachedBal != null && cachedBal !== '') {
const parsed = Number(cachedBal);
if (Number.isFinite(parsed)) customerBalance.value = parsed;
}
}
balanceLoading.value = true;
try {
const data = await resolveCurrentCustomer({ mapId, account, force: true });
applyWechatAvatar(data);
const raw = data?.accountBalance ?? data?.balance;
if (raw == null || raw === '') {
customerBalance.value = 0;
return;
}
const parsed = Number(raw);
customerBalance.value = Number.isFinite(parsed) ? parsed : 0;
} catch {
customerBalance.value = null;
if (!isValidAvatarUrl(wechatAvatarRaw.value)) {
wechatAvatarRaw.value = '';
}
} finally {
balanceLoading.value = false;
}
}
function goRecharge() {
router.push({ name: 'VipRechargeDeposit' }).catch(() => undefined);
}
function onBalanceChanged() {
void loadCustomerProfile();
}
onMounted(() => {
timerId = window.setInterval(() => {
now.value = new Date();
}, 1000);
void loadCustomerProfile();
window.addEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, onBalanceChanged);
});
onBeforeUnmount(() => {
if (timerId) {
clearInterval(timerId);
timerId = undefined;
}
window.removeEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, onBalanceChanged);
});
</script>
<style scoped lang="less">
.banner {
width: 100%;
padding: 16px 20px 4px;
background: transparent;
&.is-compact {
padding: 2px 0;
.banner-row {
min-height: 0;
}
.banner-hello__greet {
font-size: 13px;
}
.banner-hello__name {
font-size: 16px;
font-weight: 650;
}
}
}
.banner-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px 24px;
min-height: 56px;
}
.banner-hello {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.banner-hello__avatar {
flex-shrink: 0;
border: 1px solid var(--app-acrylic-border);
background: var(--app-acrylic-bg);
box-shadow: none;
:deep(img) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.banner-hello__line {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
min-width: 0;
}
.banner-hello__greet {
flex-shrink: 0;
color: var(--color-text-3);
font-size: 14px;
line-height: 1.3;
}
.banner-hello__name {
margin: 0;
color: var(--color-text-1);
font-size: 20px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.banner-local-clock {
--aw-ink: #1a2e38;
--aw-soft: rgba(26, 46, 56, 0.48);
display: inline-flex;
align-items: baseline;
gap: 8px;
flex-shrink: 0;
min-width: 0;
margin-left: 4px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid var(--app-acrylic-border-strong);
background: var(--app-acrylic-hover-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
white-space: nowrap;
}
.banner-local-clock__label {
font-size: 12px;
font-weight: 600;
color: var(--aw-soft);
line-height: 1.2;
}
.banner-local-clock__date {
font-size: 12px;
color: var(--aw-soft);
line-height: 1.2;
}
.banner-local-clock__time {
font-size: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
color: var(--aw-ink);
line-height: 1.15;
}
.balance-chip {
display: inline-flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
height: 44px;
padding: 0 12px 0 14px;
border: 1px solid var(--app-acrylic-border);
border-radius: 12px;
background: var(--app-acrylic-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
&:hover {
border-color: var(--app-acrylic-border-strong);
background: var(--app-acrylic-hover-bg);
box-shadow: none;
.balance-chip__cta {
opacity: 1;
}
}
&--low {
border-color: rgba(255, 125, 0, 0.45);
background: rgba(255, 125, 0, 0.06);
.balance-chip__value {
color: #ff7d00;
}
}
&--loading {
.balance-chip__value {
opacity: 0.55;
}
}
&__label {
color: var(--color-text-3);
font-size: 12px;
white-space: nowrap;
}
&__value {
color: var(--app-primary-color, rgb(var(--primary-6)));
font-size: 18px;
font-weight: 650;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: 0.02em;
white-space: nowrap;
}
&__cta {
margin-left: 2px;
padding: 3px 10px;
border-radius: 8px;
background: var(--app-primary-color, rgb(var(--primary-6)));
color: #fff;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
opacity: 0.92;
transition: opacity 0.2s;
}
}
@media (max-width: 640px) {
.banner-row {
flex-wrap: wrap;
align-items: center;
}
.banner-hello {
width: 100%;
}
.balance-chip {
margin-left: auto;
}
}
</style>