front/src/views/login/components/pwd-login/index.vue
zzh 351b465d5b
All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m11s
feat: 客户注册对齐登录壳,并拆分网页/微信注册入口
登录仅保留账号与微信;注册先选渠道,代理码仅 URL 携带时自动绑定。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:07:23 +08:00

698 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="nova-login">
<a
class="nova-login__header"
href="/"
:title="brandHint"
@click.prevent="goOfficialSite"
>
<LoginLockLogo />
<p class="nova-login__brand-hint">{{ brandHint }}</p>
</a>
<form class="nova-login__form" autocomplete="off" @submit.prevent="handleSubmit">
<div class="nova-login__input-group">
<div class="nova-login__field-label">{{ accountFieldLabel }}</div>
<a-input
v-model="loginForm.account"
size="large"
allow-clear
class="nova-login__arco-input"
:placeholder="accountPlaceholder"
autocomplete="username"
@focus="onAccountFocus"
@blur="onAccountBlur"
@press-enter="focusPasswordInput"
/>
<div v-if="accountError" class="nova-login__field-error">{{ accountError }}</div>
</div>
<div class="nova-login__input-group">
<div class="nova-login__field-label">{{ t('login.field.password') }}</div>
<a-input-password
ref="passwordInputRef"
v-model="loginForm.password"
size="large"
allow-clear
class="nova-login__arco-input"
:placeholder="t('login.form.password.placeholder')"
autocomplete="current-password"
@focus="onPasswordFocus"
@blur="onPasswordBlur"
@input="syncPasswordCharacterState"
@press-enter="handleSubmit"
@visibility-change="onPasswordVisibilityChange"
/>
<div v-if="passwordError" class="nova-login__field-error">{{ passwordError }}</div>
</div>
<div v-if="errorBox" class="nova-login__error-box">{{ errorBox }}</div>
<button type="submit" class="nova-login__submit" :disabled="loading">
{{ loading ? t('login.submitting') : t('login.form.login') }}
</button>
<div v-if="isCustomerLogin" class="agreement-area">
<a-checkbox v-model="agreementChecked">
<span class="agreement-line">
{{ t('login.agreement.readPrefix') }}
<a-link @click.stop="openAgreement('privacy')">{{ t('login.agreement.privacyLink') }}</a-link>
{{ t('login.agreement.and') }}
<a-link @click.stop="openAgreement('terms')">{{ t('login.agreement.termsLink') }}</a-link>
</span>
</a-checkbox>
</div>
</form>
<div v-if="props.loginType === OmsLoginType.Customer" class="login-alt-entry">
<div class="login-alt-entry__divider">
<span>{{ t('login.altEntry.or') }}</span>
</div>
<a-tooltip :content="t('login.switchToWechat')">
<button
type="button"
class="wechat-entry-btn"
:aria-label="t('login.switchToWechat')"
@mouseenter="emit('prefetchWechat')"
@focus="emit('prefetchWechat')"
@click="emit('switchWechat')"
>
<icon-wechat :size="28" />
</button>
</a-tooltip>
<p class="login-alt-entry__hint">
{{ t('login.noAccount') }}
<a-link @click="goRegister">{{ t('login.goRegister') }}</a-link>
</p>
</div>
<a-modal
v-model:visible="agreementVisible"
:title="agreementTitle"
:footer="false"
width="860px"
unmount-on-close
>
<div class="agreement-content">{{ agreementContent }}</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, inject, type ShallowRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconWechat } from '@arco-design/web-vue/es/icon';
import { OmsLoginType } from '@/api/rbac';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { setToken } from '@/utils/auth';
import { getDefaultHomePathByType, resolveCustomerEntryPath } from '@/utils/login-portal';
import { enterSystemAfterLogin, applyRbacUserToUserStore } from '@/utils/enter-system';
import { ensureHeavyPlugins } from '@/plugins/heavy-plugins';
import { LOGIN_CHARACTERS_KEY, type LoginCharactersApi } from '../../composables/useLoginCharacters';
import { LOGIN_EXIT_KEY, type LoginExitApi } from '../../composables/useLoginExit';
import LoginLockLogo from '../LoginLockLogo.vue';
const { t } = useI18n();
const props = withDefaults(
defineProps<{
loginType?: OmsLoginType;
}>(),
{
loginType: OmsLoginType.User,
},
);
const emit = defineEmits<{
switchWechat: [];
prefetchWechat: [];
}>();
const route = useRoute();
const router = useRouter();
const rbacUserStore = useRbacUserStore();
function goRegister() {
const query: Record<string, string> = {};
for (const key of ['agent', 'agentCode', 'ref', 'invite', 'advisor', 'redirect'] as const) {
const raw = route.query[key];
const value = typeof raw === 'string' ? raw.trim() : '';
if (value) query[key] = value;
}
void router.push({ path: '/register', query });
}
const passwordInputRef = ref<{ focus?: () => void } | null>(null);
const loading = ref(false);
const showPassword = ref(false);
const accountError = ref('');
const passwordError = ref('');
const errorBox = ref('');
const loginCharactersRef = inject<ShallowRef<LoginCharactersApi | null>>(LOGIN_CHARACTERS_KEY);
const loginExit = inject<LoginExitApi | null>(LOGIN_EXIT_KEY, null);
const loginCharacters = computed(() => loginCharactersRef?.value ?? null);
const isCustomerLogin = computed(() => props.loginType === OmsLoginType.Customer);
const isAgentLogin = computed(() => props.loginType === OmsLoginType.Agent);
const brandHint = computed(() => {
if (isCustomerLogin.value) return t('login.form.brandHint.customer');
if (isAgentLogin.value) return t('login.form.brandHint.agent');
return t('login.form.brandHint.admin');
});
/** Logo / 欢迎文案 → 官网首页 */
function goOfficialSite() {
window.location.href = '/';
}
const accountFieldLabel = computed(() =>
isAgentLogin.value ? t('login.field.account.agent') : t('login.field.account'),
);
const accountPlaceholder = computed(() => {
if (isCustomerLogin.value) return t('login.form.account.placeholder.customer');
if (isAgentLogin.value) return t('login.form.account.placeholder.agent');
return t('login.form.account.placeholder.admin');
});
const accountRequiredMsg = computed(() => {
if (isCustomerLogin.value) return t('login.form.account.errMsg.customer');
if (isAgentLogin.value) return t('login.form.account.errMsg.agent');
return t('login.form.account.errMsg.admin');
});
const agreementChecked = ref(true);
const agreementVisible = ref(false);
const agreementTitle = ref('');
const agreementContent = ref('');
const loginForm = reactive({
account: '',
password: '',
});
const privacyAgreementText = `一、隐私保护告知
(一)前言
欢迎使用本物流公司(以下简称“我方”)平台及物流服务。为保护您的个人隐私和数据安全,依据相关法律法规,结合物流业务实际,现将我方对您信息的处理相关事宜告知如下。您完成注册、登录并使用我方服务,即视为您已阅读、理解并同意本告知全部内容;若您不同意,请立即停止注册、登录及使用我方服务。
(二)信息采集范围及方式
1. 采集范围
遵循“最小必要性”原则,仅采集实现物流服务必需的信息:
• 基本身份信息:个人客户需提供姓名、手机号、身份证号(用于实名认证);企业客户需提供企业名称、统一社会信用代码、联系人信息。
• 注册登录信息:您设置的用户名、加密密码、验证码及登录设备相关信息。
• 物流相关信息:寄收件地址、货物描述、联系方式(将进行去标识化处理,隐藏敏感字段)。
• 服务交互信息:您的订单、查询、投诉、评价等记录及偏好设置。
• 敏感信息:身份证号、银行卡号(仅在必要时采集,将单独加密,需您单独同意)。
2. 采集方式
• 客户主动提供:您在注册、下单、实名认证等过程中自愿提交的信息。
• 系统自动生成:通过我方物流跟踪、订单管理等系统自动生成的日志、轨迹等信息。
• 合作方共享:经您同意,与合法合作方共享必要信息(如支付、订单信息)。
我方严禁违规、过度采集无关信息。
(三)信息存储与保密
1. 存储管理
• 您的信息将存储在合规安全服务器或云平台,严禁存储在个人设备及未授权第三方平台。
• 存储期限:基本身份信息留存至您注销账户后1年;交易物流信息留存至交易完成后3年;服务交互信息留存至服务结束后2年;敏感信息实现采集目的后立即匿名化或销毁。
• 我方将定期备份信息,对超过存储期限的信息,将通过不可逆方式销毁并做好记录。
2. 保密措施
• 实行最小权限授权,定期核查、收回员工信息访问权限。
• 所有接触您信息的员工及合作方,均需签订保密协议,我方将定期开展保密培训。
• 采用加密算法、防火墙等技术,保障信息存储和传输安全,定期进行漏洞扫描及修复。
(四)信息使用规则
我方对您信息的使用,仅用于实现采集时告知的目的,具体包括:提供物流核心服务、账号管理与安全保障、服务优化、合规配合,以及经您同意的营销推送(您可随时取消)。我方严禁违规使用您的信息。
(五)信息共享与披露
1. 信息共享
仅向实现服务必需的合作方(如支付机构、物流合作方、技术服务商)共享您的信息,我方将与合作方签订共享协议,明确保密义务,严禁向无关第三方共享、出售您的信息。
2. 信息披露
未经您同意,我方不会向任何第三方披露您的信息,除非符合法律法规、司法行政机关要求,或为保护您及他人人身财产安全、您自行授权披露。
(六)您的相关权利
您享有对自身信息的知情权、访问权、更正权、删除权、撤回同意权及账号注销权,可通过平台相关入口或联系客服行使上述权利,我方将及时配合处理。
(七)信息安全风险提示
我方已采取相应安全措施,尽力保障您的信息安全,但不排除网络传输等客观风险;请您妥善保管自身账号信息,通过第三方平台登录时,需遵守该第三方的隐私相关规定。
(八)告知内容的更新与修改
我方将根据法律法规及业务变化,不定期更新本告知内容,更新后将通过平台公告通知您,您继续使用我方服务,即视为同意更新后的告知内容。`;
const termsAgreementText = `二、使用须知告知
(一)告知效力
本使用须知仅为我方对您使用平台及相关物流服务的事项告知,您完成注册、登录并使用我方服务,即视为您已阅读、理解并遵守本须知全部内容。
(二)适用范围
本须知适用于您使用我方平台进行注册、登录、查询物流、下单寄件、投诉反馈等所有相关服务的行为,包括我方提供的各类物流增值服务。
(三)您的权利与注意事项
1. 您的权利
您有权使用我方平台提供的物流服务及相关增值服务,自主查询物流轨迹、订单信息,对我方服务进行评价、投诉,维护自身合法权益。
2. 注意事项
• 注册时请提供真实、准确、完整的信息,信息发生变更请及时更新,否则因此产生的服务延误、账号异常等后果,由您自行承担。
• 请妥善保管您的账号、密码、验证码等信息,对账号的所有操作承担责任,若发现账号被盗、冒用,请立即联系我方客服。
• 使用服务时请遵守国家法律法规及我方平台规则,不得利用账号从事违法违规活动(如违法寄递违禁品、欺诈等),否则我方有权暂停或终止为您提供服务,由此产生的损失由您自行承担。
• 下单寄件时,请如实提供寄收件信息及货物描述,配合我方完成实名登记、货物查验,若拒绝配合,我方有权拒绝提供服务。
• 请认可我方平台公示的物流服务收费标准,按时支付相关费用,逾期未支付的,我方有权暂停服务。
(四)我方的服务说明
1. 服务权利
我方有权根据法律法规、业务需要,制定、修改平台规则、收费标准等相关内容,并通过平台公告通知您。
我方有权对您的注册信息、账号操作、货物信息进行核查,若发现您存在违规行为,有权暂停或终止服务。
因不可抗力(如自然灾害、交通管制等)、第三方原因导致无法正常提供服务的,我方有权暂停服务,并及时通知您,不承担相关责任。
2. 服务义务
我方将为您提供安全、便捷、高效的物流服务,及时更新物流轨迹信息,保障货物安全,严格保护您的个人信息。
我方将在平台公示服务流程、收费标准、违禁品清单等相关信息,提供完善的客服服务,及时响应您的咨询、投诉。
(五)账号管理说明
一个客户仅能注册一个平台账号,注册后需完成实名认证方可正常使用下单等服务;账号仅限您本人使用,不得出租、出借、转让;您可通过平台“忘记密码”功能找回密码,也可按提示注销账号(注销后不可恢复)。
(六)服务费用说明
物流服务费用按平台公示标准收取(根据货物重量、体积、运输距离等因素计算),增值服务需另行付费,具体费用以您下单时平台显示金额为准;您可通过平台支持的支付方式付款,逾期支付需按约定支付违约金。
(七)责任说明
1. 您的责任
若您违反本须知约定,提供虚假信息、违规使用账号、逾期付费等,由此产生的损失及法律责任,由您自行承担,我方有权暂停或终止服务。
2. 我方的责任
若因我方自身原因,未按约定提供服务、泄露您的个人信息,给您造成损失的,我方将承担相应责任。
(八)免责说明
因不可抗力、您自身原因、货物本身性质、第三方原因等导致的损失,我方不承担相关责任。
(九)须知更新与服务终止
我方可根据实际情况更新本须知,更新后通过平台公告通知您,您继续使用服务即视为同意更新内容。
您可随时注销账号终止使用服务;若您存在违规行为,我方有权终止服务,服务终止后,我方将按隐私保护告知约定处理您的相关信息。
(十)争议处理
您与我方就服务使用产生的争议,优先友好协商解决;协商不成的,可向我方所在地有管辖权的人民法院提起诉讼。
(十一)其他说明
本须知条款无效不影响其他条款效力;您通过我方平台使用第三方服务,需遵守第三方相关规定;未尽事宜,按国家相关法律法规执行。
更新日期:2026年4月27日`;
function openAgreement(type: 'privacy' | 'terms') {
if (type === 'privacy') {
agreementTitle.value = '隐私保护告知';
agreementContent.value = privacyAgreementText;
} else {
agreementTitle.value = '使用须知告知';
agreementContent.value = termsAgreementText;
}
agreementVisible.value = true;
}
function syncPasswordCharacterState() {
loginCharacters.value?.onPasswordInput(loginForm.password.length, showPassword.value);
}
function onPasswordVisibilityChange(visible: boolean) {
showPassword.value = visible;
syncPasswordCharacterState();
}
function onAccountFocus() {
accountError.value = '';
loginCharacters.value?.onAccountFocus();
void ensureHeavyPlugins();
}
function onAccountBlur() {
loginCharacters.value?.onAccountBlur();
}
function onPasswordFocus() {
passwordError.value = '';
loginCharacters.value?.onPasswordFocus();
void ensureHeavyPlugins();
}
function onPasswordBlur() {
loginCharacters.value?.onPasswordBlur();
}
function focusPasswordInput() {
nextTick(() => {
passwordInputRef.value?.focus();
});
}
function validateForm(): boolean {
accountError.value = '';
passwordError.value = '';
errorBox.value = '';
let valid = true;
if (!loginForm.account.trim()) {
accountError.value = accountRequiredMsg.value;
valid = false;
}
if (!loginForm.password) {
passwordError.value = t('login.form.password.errMsg');
valid = false;
}
if (!valid) {
loginCharacters.value?.onLoginFailed();
}
return valid;
}
async function handleSubmit() {
if (loading.value) return;
if (isCustomerLogin.value && !agreementChecked.value) {
Message.warning('请先阅读并勾选同意隐私政策与使用须知');
return;
}
if (!validateForm()) return;
try {
loading.value = true;
errorBox.value = '';
const success = await rbacUserStore.login(
loginForm.account,
loginForm.password,
props.loginType,
);
if (success && rbacUserStore.userInfo) {
const userInfoData = rbacUserStore.userInfo;
const fallbackAccount = loginForm.account?.trim() || '';
if (rbacUserStore.token) {
setToken(rbacUserStore.token);
}
applyRbacUserToUserStore(userInfoData, fallbackAccount);
const { redirect, ...othersQuery } = router.currentRoute.value.query;
const redirectStr = redirect as string | undefined;
const defaultHomePath = getDefaultHomePathByType(props.loginType);
const redirectParams: Record<string, unknown> = {
query: { ...othersQuery },
};
if (redirectStr) {
if (redirectStr.startsWith('/')) {
redirectParams.path =
props.loginType === OmsLoginType.Customer
? resolveCustomerEntryPath(redirectStr)
: redirectStr;
} else {
redirectParams.name = redirectStr;
}
} else {
redirectParams.path = defaultHomePath;
}
Message.success(t('login.form.login.success'));
await nextTick();
await enterSystemAfterLogin({
target: redirectParams,
fallbackPath: defaultHomePath,
playExit: () => loginExit?.playExitAnimation() ?? Promise.resolve(),
});
} else {
errorBox.value = t('login.form.login.failed');
loginCharacters.value?.onLoginFailed();
}
} catch {
errorBox.value = t('login.form.login.failed');
loginCharacters.value?.onLoginFailed();
} finally {
loading.value = false;
}
}
</script>
<style lang="less" scoped>
.nova-login__header {
display: block;
text-align: center;
margin-bottom: 28px;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.nova-login__brand-hint {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.4;
color: rgba(15, 39, 68, 0.55);
}
.nova-login {
width: 100%;
min-width: 0;
}
.nova-login__form {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
min-width: 0;
}
.nova-login__input-group {
margin-bottom: 20px;
width: 100%;
min-width: 0;
}
.nova-login__field-label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
letter-spacing: 0.3px;
text-transform: uppercase;
}
/* 与 H5 `.h5-login-form` 输入井一致 */
.nova-login__form :deep(.arco-input-wrapper),
.nova-login__form :deep(.arco-input-password) {
border-radius: 12px;
background: #f0f3f4;
border: none;
min-height: 48px;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.08);
transition: box-shadow 0.2s ease, background 0.2s ease;
&:hover {
background: #f0f3f4;
box-shadow: inset 0 3px 10px rgba(15, 23, 42, 0.1);
}
&.arco-input-wrapper-focus,
&.arco-input-focus {
background: #f0f3f4;
border: none;
box-shadow:
inset 0 3px 10px rgba(15, 23, 42, 0.1),
0 0 0 3px color-mix(in srgb, var(--app-primary-color) 12%, transparent);
}
}
/* 覆盖 Chrome/Edge 自动填充的天蓝色底,保持与输入井 #f0f3f4 一致 */
.nova-login__form :deep(input.arco-input):-webkit-autofill,
.nova-login__form :deep(input.arco-input):-webkit-autofill:hover,
.nova-login__form :deep(input.arco-input):-webkit-autofill:focus,
.nova-login__form :deep(input.arco-input):-webkit-autofill:active {
-webkit-text-fill-color: #1e293b;
caret-color: #1e293b;
border: none;
/* 用大 inset 阴影盖住浏览器默认 autofill 背景 */
-webkit-box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
transition: background-color 99999s ease-out 0s;
}
.nova-login__arco-input {
width: 100%;
}
.nova-login__field-error {
margin-top: 4px;
font-size: 13px;
color: #b91c1c;
}
.nova-login__error-box {
padding: 11px 14px;
font-size: 13px;
color: #b91c1c;
background: color-mix(in srgb, #fff1f2 72%, var(--app-acrylic-bg));
border: 1px solid color-mix(in srgb, #fecdd3 80%, var(--app-acrylic-border));
border-radius: 12px;
margin-bottom: 16px;
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
}
.nova-login__submit {
width: 100%;
height: 52px;
margin-top: 4px;
font-size: 15px;
font-weight: 600;
border-radius: 14px;
background: var(--app-primary-color);
border: 1px solid color-mix(in srgb, var(--app-primary-color) 80%, transparent);
color: #fff;
letter-spacing: 0.5px;
cursor: pointer;
box-shadow: none;
transition: transform 0.2s, background 0.2s, opacity 0.2s;
font-family: inherit;
&:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--app-primary-color-hover);
box-shadow: none;
}
&:active:not(:disabled) {
transform: translateY(1px);
opacity: 0.85;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
}
.agreement-area {
margin: 12px 0 0;
width: 100%;
min-width: 0;
container-type: inline-size;
container-name: login-agreement;
:deep(.arco-checkbox) {
max-width: 100%;
align-items: flex-start;
}
/* 勾选框约 14px,首行行高 18px,与「我已阅读」光学对齐 */
:deep(.arco-checkbox-icon-hover) {
margin-top: 2px;
}
:deep(.arco-checkbox-label) {
flex: 1;
min-width: 0;
max-width: 100%;
color: #64748b;
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
line-height: 18px;
font-size: 12px;
}
.agreement-line {
display: inline;
min-width: 0;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
@supports (font-size: 1cqi) {
.agreement-line {
font-size: clamp(10px, 3.2cqi, 12px);
}
}
:deep(.arco-checkbox-checked .arco-checkbox-icon) {
background-color: var(--app-primary-color);
border-color: var(--app-primary-color);
}
:deep(.arco-checkbox-icon-hover::before) {
background-color: color-mix(in srgb, var(--app-primary-color) 12%, transparent);
}
:deep(.agreement-line .arco-link) {
color: var(--app-primary-color);
font-size: inherit;
font-weight: 600;
text-decoration: none;
border-radius: 4px;
padding: 0 2px;
&:hover {
color: var(--app-primary-color-hover);
background-color: color-mix(in srgb, var(--app-primary-color) 10%, transparent);
text-decoration: underline;
text-underline-offset: 2px;
}
}
}
.login-alt-entry {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
margin-top: 18px;
}
.login-alt-entry__divider {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
font-size: 12px;
color: rgba(100, 116, 139, 0.75);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.35);
}
}
.wechat-entry-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
border: none;
border-radius: 50%;
background: #07c160;
color: #fff;
cursor: pointer;
line-height: 1;
transition:
background 0.2s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
&:hover {
background: #06ad56;
color: #fff;
}
&:active {
transform: scale(0.96);
}
:deep(.arco-icon) {
color: #fff;
}
}
.login-alt-entry__hint {
margin: 0;
font-size: 13px;
color: #86909c;
:deep(.arco-link) {
color: var(--app-primary-color);
font-weight: 600;
}
}
.agreement-content {
max-height: 55vh;
overflow-y: auto;
white-space: pre-wrap;
line-height: 1.8;
font-size: 12px;
color: #333;
}
</style>