front/src/views/login/index.vue
zzh b178a2a35d
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m56s
feat: 支付选券、优惠入应收展示、申报产品标识编码、创建9610订单等
- 支付弹窗(PC/H5)支持选券与批量支付体验优化,运单管理复用我的订单支付弹窗;立即支付仅允许待支付订单
- 订单详情费用明细展示优惠券抵扣/会员折扣负数行与「含优惠」;价格配置与手工加费过滤抵扣费用项
- 申报明细新增产品标识编码/非标产品标识编码/标准产品标识编码(PC/H5 下单、改单、详情、开放接口文档、公式目录、Excel 模板)
- 9610 页「批量申报」改为「创建9610订单」:选运单并补充出口资质备案、电商平台
- 店铺订单批量按钮按动作独立 loading,获取面单限并发并显示进度
- 另含:国家管理、登录门户切换、监控与工作台、公告弹窗、nginx/vite 配置等改动
2026-10-19 16:34:06 +08:00

646 lines
16 KiB
Vue

<template>
<div
:class="[
'login-container',
{
'login-container--light': isLightMode,
'login-container--exit': isExiting,
},
]"
ref="containerRef"
>
<div ref="leftSplitRef" class="login-split login-split--left" aria-hidden="true" />
<div ref="rightSplitRef" class="login-split login-split--right" aria-hidden="true" />
<LoginCareBanner
v-if="shellReady && !isCustomerLogin"
:light="isLightMode"
/>
<div v-if="shellReady" ref="leftPaneRef" class="login-pane login-pane--left">
<div
v-if="!isCustomerLogin"
class="login-brand-corner"
:class="{ 'login-brand-corner--light': isLightMode }"
>
<span class="login-brand-corner__name">{{ t('brand.name') }}</span>
<span class="login-brand-corner__suffix">· {{ t('brand.fullName') }}</span>
</div>
<aside v-if="isCustomerLogin" class="login-hero-panel">
<LoginHero
:light="isLightMode"
portal="customer"
/>
</aside>
<div v-if="isCustomerLogin" class="login-characters-panel">
<LoginCharacters v-if="showCharacters" @register="registerLoginCharacters" />
</div>
</div>
<aside
v-if="shellReady && !isCustomerLogin"
class="login-hero-panel login-hero-panel--admin"
>
<LoginHero
:light="isLightMode"
portal="admin"
/>
</aside>
<div
v-if="shellReady && !isCustomerLogin"
class="login-characters-panel login-characters-panel--admin"
>
<LoginCharacters v-if="showCharacters" @register="registerLoginCharacters" />
</div>
<div v-if="shellReady" ref="rightPaneRef" class="login-pane login-pane--right">
<div ref="loginFormRef" :class="['login-form', { 'login-form--ready': shellReady }]">
<div class="login-form-toolbar">
<LoginLangToggle />
<a-tooltip :content="isLightMode ? t('login.theme.toNight') : t('login.theme.toDay')">
<a-button
shape="circle"
type="text"
class="theme-toggle-btn"
:aria-label="isLightMode ? t('login.theme.toNight') : t('login.theme.toDay')"
@click="toggleAppearance"
>
<template #icon>
<icon-sun-fill v-if="!isLightMode" />
<icon-moon-fill v-else />
</template>
</a-button>
</a-tooltip>
</div>
<div class="login-content">
<LoginForm
v-if="activeKey === 'pwd'"
:login-type="loginType"
@switch-wechat="setLoginMode('wechat')"
@prefetch-wechat="prefetchWechatQr"
/>
<WechatLogin
v-if="isCustomerLogin && (activeKey === 'wechat' || wechatMounted)"
v-show="activeKey === 'wechat'"
ref="wechatLoginRef"
@switch-pwd="setLoginMode('pwd')"
/>
</div>
<LoginPortalSwitch :target="isCustomerLogin ? 'admin' : 'customer'" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
ref,
computed,
onMounted,
onBeforeUnmount,
provide,
shallowRef,
defineAsyncComponent,
} from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OmsLoginType } from '@/api/rbac';
import { getLoginTypeFromRouteName } from '@/utils/login-portal';
import { IconSunFill, IconMoonFill } from '@arco-design/web-vue/es/icon';
import LoginForm from './components/pwd-login/index.vue';
import LoginHero from './components/LoginHero.vue';
import LoginLangToggle from './components/LoginLangToggle.vue';
import LoginPortalSwitch from './components/LoginPortalSwitch.vue';
import {
LOGIN_CHARACTERS_KEY,
type LoginCharactersApi,
} from './composables/useLoginCharacters';
import { LOGIN_EXIT_KEY, type LoginExitApi } from './composables/useLoginExit';
/** Admin login never mounts WeChat; keep it out of the critical path */
const WechatLogin = defineAsyncComponent(
() => import('./components/wechat-login.vue'),
);
/** Characters + gsap are decorative; async so /admin first paint stays light */
const LoginCharacters = defineAsyncComponent(
() => import('./components/LoginCharacters.vue'),
);
const LoginCareBanner = defineAsyncComponent(
() => import('./components/LoginCareBanner.vue'),
);
const LOGIN_APPEARANCE_KEY = 'login-appearance';
const LOGIN_PAGE_SCROLL_LOCK_CLASS = 'login-page-no-scroll';
defineOptions({ name: 'Login' });
const { t } = useI18n();
const route = useRoute();
const loginType = computed(() => getLoginTypeFromRouteName(route.name as string));
const isCustomerLogin = computed(() => loginType.value === OmsLoginType.Customer);
/** 角色动画偏重,首屏延后一帧再挂,管理端也挂但异步分包 */
const showCharacters = ref(false);
const activeKey = ref<'pwd' | 'wechat'>('pwd');
const wechatMounted = ref(false);
const wechatLoginRef = ref<{ prefetch?: () => void } | null>(null);
const shellReady = ref(false);
const isExiting = ref(false);
const loginCharactersApi = shallowRef<LoginCharactersApi | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const leftSplitRef = ref<HTMLElement | null>(null);
const rightSplitRef = ref<HTMLElement | null>(null);
const leftPaneRef = ref<HTMLElement | null>(null);
const rightPaneRef = ref<HTMLElement | null>(null);
const loginFormRef = ref<HTMLElement | null>(null);
function collectExitTargets(side: 'left' | 'right'): HTMLElement[] {
if (side === 'left') {
return [leftSplitRef.value, leftPaneRef.value].filter((el): el is HTMLElement => !!el);
}
return [rightSplitRef.value, rightPaneRef.value].filter((el): el is HTMLElement => !!el);
}
provide(LOGIN_EXIT_KEY, {
playExitAnimation(): Promise<void> {
if (isExiting.value) {
return Promise.resolve();
}
// Flips login-container--exit: hides admin overlays outside pane clip-paths
isExiting.value = true;
const leftTargets = collectExitTargets('left');
const rightTargets = collectExitTargets('right');
const formEl = loginFormRef.value;
return new Promise((resolve) => {
if (!leftTargets.length && !rightTargets.length) {
resolve();
return;
}
void import('gsap').then(({ default: gsap }) => {
gsap.set([...leftTargets, ...rightTargets], {
x: 0,
opacity: 1,
force3D: true,
});
const tl = gsap.timeline({
defaults: { ease: 'power2.inOut' },
onComplete: resolve,
});
if (formEl) {
tl.to(
formEl,
{
opacity: 0,
duration: 0.22,
ease: 'power1.out',
},
0,
);
}
tl.to(
leftTargets,
{
x: '-100%',
opacity: 0.96,
duration: 0.32,
ease: 'power3.inOut',
},
0.02,
);
tl.to(
rightTargets,
{
x: '100%',
opacity: 0.96,
duration: 0.32,
ease: 'power3.inOut',
},
0.04,
);
if (containerRef.value) {
const pageBg =
getComputedStyle(document.documentElement).getPropertyValue('--app-page-bg').trim() ||
'#f0f3f4';
tl.to(
containerRef.value,
{
backgroundColor: pageBg,
duration: 0.28,
ease: 'power1.inOut',
},
0.02,
);
}
});
});
},
} satisfies LoginExitApi);
provide(LOGIN_CHARACTERS_KEY, loginCharactersApi);
function registerLoginCharacters(api: LoginCharactersApi) {
loginCharactersApi.value = api;
}
function readStoredAppearance(): boolean {
try {
return localStorage.getItem(LOGIN_APPEARANCE_KEY) === 'light';
} catch {
return false;
}
}
const isLightMode = ref(readStoredAppearance());
function toggleAppearance() {
isLightMode.value = !isLightMode.value;
try {
localStorage.setItem(LOGIN_APPEARANCE_KEY, isLightMode.value ? 'light' : 'dark');
} catch {
/* ignore */
}
}
function setLoginMode(mode: 'pwd' | 'wechat') {
if (mode === 'wechat' && !isCustomerLogin.value) return;
if (mode === 'wechat') {
wechatMounted.value = true;
}
activeKey.value = mode;
}
function prefetchWechatQr() {
if (!isCustomerLogin.value) return;
wechatMounted.value = true;
// 下一帧组件已挂载后再触发预取(若已在加载则内部会跳过)
requestAnimationFrame(() => {
wechatLoginRef.value?.prefetch?.();
});
}
onMounted(() => {
document.documentElement.classList.add(LOGIN_PAGE_SCROLL_LOCK_CLASS);
shellReady.value = true;
const idle = window.requestIdleCallback
? (cb: () => void, timeout = 1200) => window.requestIdleCallback(cb, { timeout })
: (cb: () => void, timeout = 1200) => window.setTimeout(cb, Math.min(timeout, 400));
// 表单先出,空闲后再挂角色,避免和首屏 JS/图抢带宽
idle(() => {
showCharacters.value = true;
}, 1600);
idle(() => {
void import('@/locale').then(({ ensureFullLocaleMessages }) => {
void ensureFullLocaleMessages();
});
}, 2500);
});
onBeforeUnmount(() => {
document.documentElement.classList.remove(LOGIN_PAGE_SCROLL_LOCK_CLASS);
});
</script>
<style lang="less" scoped>
.login-container {
--entry-btn-bg: rgba(255, 255, 255, 0.08);
--entry-btn-bg-hover: rgba(255, 255, 255, 0.14);
--entry-account-color: rgba(255, 255, 255, 0.88);
/* 斜线:顶 62% → 底 38%,右侧为上窄下宽的倒梯形 */
--login-split-top: 62%;
--login-split-bottom: 38%;
--login-left-bg: #142637;
--login-right-bg: var(--app-page-bg, #f0f3f4);
box-sizing: border-box;
height: 100dvh;
max-height: 100dvh;
min-height: 100dvh;
position: relative;
overflow: hidden;
overscroll-behavior: none;
}
.login-container--light {
--entry-btn-bg: rgba(0, 0, 0, 0.05);
--entry-btn-bg-hover: rgba(0, 0, 0, 0.09);
--entry-account-color: rgba(26, 45, 74, 0.85);
--login-left-bg: #e8eef5;
--login-right-bg: var(--app-page-bg, #f0f3f4);
}
.login-container--exit {
pointer-events: none;
/* Safety net: admin overlays are outside pane clip-paths */
.login-hero-panel--admin,
.login-characters-panel--admin,
:deep(.login-care-banner) {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none;
}
}
.login-split {
position: absolute;
inset: 0;
z-index: 0;
will-change: transform;
}
/* 左:正梯形(上宽下窄) */
.login-split--left {
background: var(--login-left-bg);
clip-path: polygon(0 0, var(--login-split-top) 0, var(--login-split-bottom) 100%, 0 100%);
}
/* 右:倒梯形(上窄下宽) */
.login-split--right {
background: var(--login-right-bg);
clip-path: polygon(
var(--login-split-top) 0,
100% 0,
100% 100%,
var(--login-split-bottom) 100%
);
}
.login-pane {
position: absolute;
inset: 0;
z-index: 2;
will-change: transform;
}
.login-pane--left {
clip-path: polygon(0 0, var(--login-split-top) 0, var(--login-split-bottom) 100%, 0 100%);
pointer-events: none;
}
.login-brand-corner {
position: absolute;
top: 28px;
left: 32px;
z-index: 8;
display: flex;
align-items: baseline;
gap: 0.35em;
line-height: 1;
color: #fff;
pointer-events: none;
}
.login-brand-corner__name {
font-size: clamp(1.6rem, 2.4vw, 2.25rem);
font-weight: 800;
letter-spacing: 0.12em;
}
.login-brand-corner__suffix {
font-size: clamp(0.95rem, 1.55vw, 1.375rem);
font-weight: 600;
letter-spacing: 0.04em;
opacity: 0.88;
}
.login-brand-corner--light {
color: #1a2d4a;
}
.login-pane--right {
clip-path: polygon(
var(--login-split-top) 0,
100% 0,
100% 100%,
var(--login-split-bottom) 100%
);
pointer-events: none;
}
.login-pane--right .login-form {
position: absolute;
top: 50%;
left: calc((var(--login-split-top) + var(--login-split-bottom) + 100% + 100%) / 4);
transform: translate(-50%, calc(-50% + 16px));
pointer-events: auto;
}
.login-pane--right .login-form--ready {
transform: translate(-50%, -50%);
}
.login-hero-panel {
position: absolute;
top: 50%;
left: calc((var(--login-split-top) + var(--login-split-bottom)) / 4);
transform: translate(-50%, -50%);
width: min(920px, calc((var(--login-split-top) + var(--login-split-bottom)) / 2 - 24px));
max-width: calc(100% - 32px);
pointer-events: auto;
}
.login-hero-panel--admin {
inset: 0;
top: 0;
left: 0;
bottom: 0;
right: 0;
transform: none;
z-index: 3;
display: block;
width: auto;
max-width: none;
pointer-events: none;
}
.login-hero-panel--admin .login-hero {
width: 100%;
height: 100%;
}
.login-characters-panel {
position: absolute;
left: 0;
bottom: 0;
right: auto;
z-index: 6;
pointer-events: auto;
padding: 0 0 16px 8px;
}
.login-characters-panel--admin {
left: auto;
right: 0;
bottom: 0;
z-index: 6;
padding: 0;
transform: scale(0.82);
transform-origin: bottom right;
opacity: 1;
}
.login-characters-panel--admin :deep(.login-characters__stage) {
width: 450px;
transform-origin: bottom right;
}
.login-form-toolbar {
position: absolute;
top: 14px;
right: 14px;
z-index: 2;
display: flex;
align-items: center;
gap: 4px;
}
.login-form-toolbar :deep(.theme-toggle-btn.arco-btn),
.login-form-toolbar :deep(.login-lang-toggle),
.login-form-toolbar :deep(.contact-service-trigger--login) {
width: 32px;
height: 32px;
flex-shrink: 0;
border: none !important;
box-shadow: none !important;
background: transparent !important;
color: rgba(26, 45, 74, 0.72) !important;
}
.login-form-toolbar :deep(.theme-toggle-btn.arco-btn:hover),
.login-form-toolbar :deep(.login-lang-toggle:hover),
.login-form-toolbar :deep(.contact-service-trigger--login:hover) {
border: none !important;
box-shadow: none !important;
background: var(--app-acrylic-hover-bg) !important;
color: var(--app-primary-color) !important;
}
.theme-toggle-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.login-form {
position: relative;
z-index: 10;
width: 100%;
max-width: 430px;
min-height: auto;
padding: 51px 32px 45px;
background: var(--app-acrylic-bg);
border: 1px solid var(--app-acrylic-border);
border-radius: 24px;
box-shadow: none;
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
display: flex;
flex-direction: column;
opacity: 1;
transform: none;
transition: border-color 0.2s ease, background 0.2s ease;
}
.login-form:hover {
border-color: var(--app-acrylic-border-strong);
background: var(--app-acrylic-hover-bg);
box-shadow: none;
}
.login-form--ready {
opacity: 1;
transform: none;
}
.login-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.login-form :deep(.nova-login__header) {
padding: 0;
}
.login-form :deep(.account-entry-btn) {
background: var(--app-acrylic-bg);
border-color: var(--app-acrylic-border);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
}
.login-form :deep(.account-entry-btn:hover) {
background: var(--app-acrylic-hover-bg);
border-color: var(--app-acrylic-border-strong);
}
@media screen and (max-width: 1024px) {
.login-characters-panel {
display: none;
}
}
@media screen and (max-width: 1250px) {
.login-container {
--login-split-top: 0%;
--login-split-bottom: 0%;
}
.login-split--left,
.login-pane--left {
display: none;
}
.login-pane--right .login-form {
position: relative;
top: auto;
left: auto;
transform: none;
margin: auto;
}
.login-pane--right .login-form--ready {
transform: none;
}
.login-split--right,
.login-pane--right {
clip-path: none;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
}
@media screen and (max-width: 600px) {
.login-form {
max-width: 97% !important;
padding: 43px 20px 39px;
}
}
</style>
<style lang="less">
html.login-page-no-scroll,
html.login-page-no-scroll body {
overflow: hidden !important;
height: 100% !important;
margin: 0;
}
html.login-page-no-scroll #app {
height: 100%;
overflow: hidden;
}
</style>