新增: 登录页 UI 重构 + 品牌 logo 与 Lottie 启动动画
视觉与品牌 - public/logo.svg、public/login-illustration.svg、favicon 替换为 logo.svg。 - preferences.ts 新增 logo.source 指向 /logo.svg。 - packages/effects/layouts 导出 LoginIllustration 共享组件(浮动插画,加载失败自动隐藏)。 Auth 布局重构 - layouts/auth.vue 替换默认 AuthPageLayout:主题色渐变背景 + 左侧浮动插画 + 品牌卡片 + 右上 LanguageToggle + 圆角登录卡片;KeepAlive 仅缓存 Login (CodeLogin/QrCodeLogin 需要每次刷新验证码/二维码)。 - 表单样式通过 :deep(.login-form-container) 覆盖输入框、选择框、主按钮, 全部走 --primary 变量,与主题色联动。 登录组件 - views/_core/authentication/login.vue: 关闭默认 codeLogin/qrcodeLogin/ thirdPartyLogin/register/docLink,改为手机 / 二维码 / 企微三按钮(IconifyIcon); 三方登录前检查租户,缺失时 message.warning + validateField。 - packages/effects/common-ui/authentication/login.vue + types.ts: 新增 showDocLink prop(默认 true),替代原本 HTML 注释掉 DocLink 的做法。 - sso-callback.vue: 等待提示改为 LottieLoading 动画。 Lottie 启动动画 - 新增 loading.html(注入进 index.html)+ public/loading.json + 运行时拷贝的 public/lottie_light.min.js(.gitignore 忽略)。 - 新增 public/lottie-tint.js:共享主题色适配器(LIGHTNESSES / SHADE_MAP / hslToRgb / patchColors / readPrimary),同时被启动白屏脚本与 Vue 组件使用, 消除两份几乎一致的实现。 - 新增 src/components/lottie-loading/LottieLoading.vue:按需加载 lottie-tint.js 并根据 CSS 变量 --primary 重新上色。 - vite.config.mts 新增 copyLottiePlayer 插件:configResolved 时无条件把 node_modules/lottie-web/.../lottie_light.min.js 拷到 public/,避免 mtime 误判。 - package.json 新增 lottie-web ^5.13.0 依赖。 i18n - 新增 otherLoginMethods / contactSupport / weComLogin 三个文案(zh-CN / en-US)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
apps/web-antd/.gitignore
vendored
Normal file
3
apps/web-antd/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 由 vite.config.mts 的 copyLottiePlayer 插件在 dev/build 时自动从
|
||||
# node_modules/lottie-web 拷贝,不需要进版本控制
|
||||
public/lottie_light.min.js
|
||||
@@ -13,7 +13,7 @@
|
||||
/>
|
||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||
<title><%= VITE_APP_TITLE %></title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<script>
|
||||
var HM_ID = '<%= VITE_APP_BAIDU_CODE %>'
|
||||
if (HM_ID) {
|
||||
|
||||
77
apps/web-antd/loading.html
Normal file
77
apps/web-antd/loading.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<style data-app-loading="inject-css">
|
||||
html {
|
||||
/* same as ant-design-vue/dist/reset.css setting */
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #f4f7f9;
|
||||
}
|
||||
|
||||
.loading.hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: all 0.8s ease-out;
|
||||
}
|
||||
|
||||
.dark .loading {
|
||||
background: #0d0d10;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 24px;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif !important;
|
||||
font-size: 24px;
|
||||
font-weight: 600 !important;
|
||||
color: rgb(0 0 0 / 85%) !important;
|
||||
}
|
||||
|
||||
.dark .title {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
#__app-loading-lottie__ {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
</style>
|
||||
<div class="loading" id="__app-loading__">
|
||||
<div id="__app-loading-lottie__"></div>
|
||||
<div class="title"><%= VITE_APP_TITLE %></div>
|
||||
</div>
|
||||
<script src="/lottie_light.min.js"></script>
|
||||
<script src="/lottie-tint.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var container = document.getElementById('__app-loading-lottie__');
|
||||
if (!container || typeof lottie === 'undefined' || !window.__lottieTint) return;
|
||||
var tint = window.__lottieTint;
|
||||
fetch('/loading.json')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var shades = tint.buildShades(tint.readPrimary());
|
||||
lottie.loadAnimation({
|
||||
container: container,
|
||||
renderer: 'svg',
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: tint.patchColors(data, shades),
|
||||
});
|
||||
})
|
||||
.catch(function (e) { console.error('[app-loading] lottie error', e); });
|
||||
})();
|
||||
</script>
|
||||
@@ -57,6 +57,7 @@
|
||||
"diagram-js": "catalog:",
|
||||
"fast-xml-parser": "catalog:",
|
||||
"highlight.js": "catalog:",
|
||||
"lottie-web": "^5.13.0",
|
||||
"pinia": "catalog:",
|
||||
"steady-xml": "catalog:",
|
||||
"tinymce": "catalog:",
|
||||
|
||||
1
apps/web-antd/public/loading.json
Normal file
1
apps/web-antd/public/loading.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/web-antd/public/login-illustration.svg
Normal file
1
apps/web-antd/public/login-illustration.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 53 KiB |
13
apps/web-antd/public/logo.svg
Normal file
13
apps/web-antd/public/logo.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="800.000000" height="800.000000" viewBox="0 0 800.000000 800.000000" preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<rdf:Description dc:format="image/svg+xml" dc:Label="1" dc:ContentProducer="001191330110MACRLGPT8B00000" dc:ProduceID="418102974" dc:ReservedCode1="FGBF4Y0JzTd3Sjwm8fIlqyFfZ96OhJbTnUggD/vFfXE=" dc:ContentPropagator="001191330110MACRLGPT8B00000" dc:PropagateID="418102974" dc:ReservedCode2="FGBF4Y0JzTd3Sjwm8fIlqyFfZ96OhJbTnUggD/vFfXE="/>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,800.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
|
||||
<path d="M2905 5903 c-179 -24 -370 -155 -458 -314 -63 -112 -77 -191 -77 -424 l0 -203 -57 -7 c-95 -11 -143 -25 -233 -67 -158 -75 -288 -236 -337 -418 -16 -59 -18 -128 -18 -735 l0 -670 28 -80 c78 -221 261 -386 483 -435 82 -18 129 -20 576 -20 l488 0 197 -161 c583 -476 706 -575 721 -580 9 -3 32 -2 50 3 30 8 38 19 77 101 23 51 86 181 139 291 53 109 96 200 96 201 0 5 211 440 216 444 2 3 28 -19 57 -47 l53 -52 430 0 c483 0 490 1 625 71 144 75 260 227 304 398 14 54 16 113 13 380 l-3 316 -33 75 c-119 276 -392 422 -594 319 l-38 -19 -20 40 c-28 55 -87 112 -146 141 -38 18 -69 25 -137 27 -82 4 -93 2 -151 -27 -35 -17 -65 -31 -68 -31 -2 0 -5 91 -7 203 -2 188 -4 206 -26 254 -49 108 -163 183 -280 183 -122 0 -242 -82 -284 -195 -20 -53 -21 -75 -21 -469 l0 -414 -35 -42 c-53 -63 -57 -93 -53 -384 4 -341 -4 -321 221 -535 37 -35 66 -66 65 -70 -2 -3 -15 -31 -30 -61 -14 -30 -100 -208 -190 -395 -90 -187 -179 -373 -198 -412 -19 -40 -39 -73 -43 -73 -4 0 -75 55 -156 123 -160 132 -372 306 -569 466 l-124 101 -522 0 c-449 0 -530 2 -577 16 -159 47 -279 155 -340 309 l-24 60 -3 635 c-2 393 1 652 7 681 6 25 20 71 31 102 28 76 133 188 211 228 103 52 152 59 391 59 l218 0 0 85 0 85 -110 0 -110 0 0 178 c0 258 25 343 131 457 63 67 124 105 210 129 50 15 170 16 1150 14 l1094 -3 79 -38 c89 -42 179 -126 214 -198 48 -99 52 -141 52 -510 l0 -347 83 2 82 1 0 370 c0 326 -3 378 -19 435 -55 198 -209 360 -409 427 l-82 28 -1090 1 c-600 1 -1103 -1 -1120 -3z m1959 -1042 c56 -52 56 -55 56 -495 l0 -406 100 0 100 0 0 109 c0 123 12 164 60 205 67 56 147 58 220 5 56 -41 71 -82 78 -211 l5 -108 92 0 92 0 2 55 c0 31 8 70 17 86 17 33 66 59 111 59 84 0 222 -112 273 -223 46 -97 52 -155 48 -427 -4 -222 -6 -251 -27 -309 -54 -155 -186 -272 -341 -301 -29 -6 -211 -10 -406 -10 l-354 0 -204 190 c-152 142 -208 201 -220 231 -13 32 -16 83 -16 274 0 148 4 236 10 240 6 4 24 30 40 58 l30 52 0 418 c0 459 0 460 61 511 30 25 39 28 91 24 43 -3 64 -10 82 -27z"/>
|
||||
<path d="M2273 4340 c-37 -15 -54 -69 -37 -115 17 -43 40 -45 498 -45 l435 0 26 23 c35 29 37 89 6 121 -21 21 -27 21 -464 23 -243 1 -452 -2 -464 -7z"/>
|
||||
<path d="M2257 3806 c-38 -35 -38 -96 -1 -125 26 -21 33 -21 801 -21 l774 0 24 25 c31 30 33 83 6 116 l-19 24 -779 3 -780 2 -26 -24z"/>
|
||||
<path d="M2251 3274 c-30 -38 -27 -80 8 -115 l29 -29 447 0 c457 0 473 1 502 39 21 27 16 85 -10 109 -23 22 -25 22 -489 22 l-467 0 -20 -26z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
158
apps/web-antd/public/lottie-tint.js
Normal file
158
apps/web-antd/public/lottie-tint.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Lottie 主题色适配器
|
||||
*
|
||||
* 把 public/loading.json 里硬编码的 5 档绿色替换为当前主题色的 5 档亮度,
|
||||
* 供 loading.html 的初始白屏动画和 Vue 内的 LottieLoading 组件共用。
|
||||
*
|
||||
* 暴露为 window.__lottieTint,兼容 ES5,可被 <script src> 直接使用。
|
||||
* Vue 组件通过 <script> 标签注入后也使用这同一份实现。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// JSON 里硬编码的 5 档绿色(取 2 位小数做指纹)-> 亮度索引
|
||||
var SHADE_MAP = {
|
||||
'0.62,0.92,0.49': 0, // 最浅
|
||||
'0.44,0.74,0.08': 1,
|
||||
'0.30,0.62,0.04': 2,
|
||||
'0.18,0.47,0.02': 3,
|
||||
'0.14,0.39,0.02': 4, // 最深
|
||||
};
|
||||
|
||||
// 5 档亮度,从浅到深
|
||||
var LIGHTNESSES = [72, 58, 48, 36, 26];
|
||||
|
||||
// 默认主题色(与 packages/@core/preferences 默认值一致,用作兜底)
|
||||
var FALLBACK_HSL = { h: 37, s: 100, l: 52 };
|
||||
|
||||
// HSL (0-360, 0-100, 0-100) -> RGB (0..1)
|
||||
function hslToRgb(h, s, l) {
|
||||
var hh = (((h % 360) + 360) % 360) / 360;
|
||||
var ss = s / 100;
|
||||
var ll = l / 100;
|
||||
if (ss === 0) return [ll, ll, ll];
|
||||
var q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
|
||||
var p = 2 * ll - q;
|
||||
function f(t) {
|
||||
var tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
}
|
||||
return [f(hh + 1 / 3), f(hh), f(hh - 1 / 3)];
|
||||
}
|
||||
|
||||
function rgbKey(arr) {
|
||||
return [arr[0], arr[1], arr[2]]
|
||||
.map(function (x) {
|
||||
return x.toFixed(2);
|
||||
})
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function buildShades(hsl) {
|
||||
return LIGHTNESSES.map(function (l) {
|
||||
return hslToRgb(hsl.h, hsl.s, l);
|
||||
});
|
||||
}
|
||||
|
||||
// 走一遍 lottie JSON,把匹配的填充色替换为当前主题 5 档亮度
|
||||
function patchColors(data, shades) {
|
||||
function swap(arr) {
|
||||
var idx = SHADE_MAP[rgbKey(arr)];
|
||||
if (idx === undefined) return null;
|
||||
var s = shades[idx];
|
||||
return [s[0], s[1], s[2], arr[3] == null ? 1 : arr[3]];
|
||||
}
|
||||
(data.layers || []).forEach(function (layer) {
|
||||
(layer.shapes || []).forEach(function (group) {
|
||||
if (!group.it) return;
|
||||
group.it.forEach(function (item) {
|
||||
if (item.ty !== 'fl' || !item.c) return;
|
||||
var k = item.c.k;
|
||||
if (Array.isArray(k) && typeof k[0] === 'number') {
|
||||
var v = swap(k);
|
||||
if (v) item.c.k = v;
|
||||
} else if (Array.isArray(k)) {
|
||||
k.forEach(function (kf) {
|
||||
if (kf.s && typeof kf.s[0] === 'number') {
|
||||
var v = swap(kf.s);
|
||||
if (v) kf.s = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// 从 localStorage 的 preferences 缓存读 colorPrimary(loading.html 启动时 Vue 尚未挂载,
|
||||
// 无法读 CSS 变量,只能走 localStorage)
|
||||
function readPrimaryFromLocalStorage() {
|
||||
try {
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var k = localStorage.key(i);
|
||||
if (!k || !k.endsWith('-preferences')) continue;
|
||||
if (k.endsWith('-preferences-theme')) continue;
|
||||
if (k.endsWith('-preferences-locale')) continue;
|
||||
var raw = localStorage.getItem(k);
|
||||
if (!raw) continue;
|
||||
var parsed = JSON.parse(raw);
|
||||
var color =
|
||||
parsed &&
|
||||
parsed.value &&
|
||||
parsed.value.theme &&
|
||||
parsed.value.theme.colorPrimary;
|
||||
if (!color) continue;
|
||||
// 支持 hsl(H S% L%) 和 hsl(H, S%, L%)
|
||||
var m = /hsl\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?\s*\)/i.exec(
|
||||
color,
|
||||
);
|
||||
if (!m) continue;
|
||||
return { h: +m[1], s: +m[2], l: +m[3] };
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vue 挂载后可以直接读 CSS 变量 --primary(格式 "H S% L%")
|
||||
function readPrimaryFromCssVar() {
|
||||
try {
|
||||
var raw = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--primary')
|
||||
.trim();
|
||||
if (!raw) return null;
|
||||
var m = /([\d.]+)(?:deg)?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?/.exec(raw);
|
||||
return m ? { h: +m[1], s: +m[2], l: +m[3] } : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 综合读取当前主题色,优先 CSS 变量,回退 localStorage,最后用默认色 */
|
||||
function readPrimary() {
|
||||
return (
|
||||
readPrimaryFromCssVar() ||
|
||||
readPrimaryFromLocalStorage() ||
|
||||
FALLBACK_HSL
|
||||
);
|
||||
}
|
||||
|
||||
global.__lottieTint = {
|
||||
FALLBACK_HSL: FALLBACK_HSL,
|
||||
LIGHTNESSES: LIGHTNESSES,
|
||||
SHADE_MAP: SHADE_MAP,
|
||||
buildShades: buildShades,
|
||||
hslToRgb: hslToRgb,
|
||||
patchColors: patchColors,
|
||||
readPrimary: readPrimary,
|
||||
readPrimaryFromCssVar: readPrimaryFromCssVar,
|
||||
readPrimaryFromLocalStorage: readPrimaryFromLocalStorage,
|
||||
};
|
||||
})(window);
|
||||
107
apps/web-antd/src/components/lottie-loading/LottieLoading.vue
Normal file
107
apps/web-antd/src/components/lottie-loading/LottieLoading.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AnimationItem } from 'lottie-web';
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import lottie from 'lottie-web/build/player/lottie_light.js';
|
||||
|
||||
interface Props {
|
||||
/** 容器边长,数字按 px 处理,其他字符串原样作为 CSS 长度 */
|
||||
size?: number | string;
|
||||
/** Lottie JSON 的 URL,默认读取 public/loading.json(与 app 全局 loading 共用) */
|
||||
src?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 200,
|
||||
src: '/loading.json',
|
||||
});
|
||||
|
||||
interface LottieTint {
|
||||
buildShades: (hsl: { h: number; l: number; s: number }) => number[][];
|
||||
patchColors: (data: any, shades: number[][]) => any;
|
||||
readPrimary: () => { h: number; l: number; s: number };
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__lottieTint?: LottieTint;
|
||||
}
|
||||
}
|
||||
|
||||
const container = ref<HTMLElement>();
|
||||
let instance: AnimationItem | undefined;
|
||||
|
||||
/**
|
||||
* 确保 /lottie-tint.js 已加载。
|
||||
* 正常场景下 loading.html 在首屏已注入 <script src="/lottie-tint.js">;
|
||||
* 此函数只是 SSR/测试等兜底路径。
|
||||
*/
|
||||
function ensureTintScript(): Promise<LottieTint> {
|
||||
if (window.__lottieTint) return Promise.resolve(window.__lottieTint);
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(
|
||||
'script[data-lottie-tint]',
|
||||
);
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => {
|
||||
window.__lottieTint
|
||||
? resolve(window.__lottieTint)
|
||||
: reject(new Error('lottie-tint loaded but global missing'));
|
||||
});
|
||||
existing.addEventListener('error', () =>
|
||||
reject(new Error('lottie-tint failed to load')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const s = document.createElement('script');
|
||||
s.src = '/lottie-tint.js';
|
||||
s.dataset.lottieTint = '1';
|
||||
s.addEventListener('load', () => {
|
||||
window.__lottieTint
|
||||
? resolve(window.__lottieTint)
|
||||
: reject(new Error('lottie-tint loaded but global missing'));
|
||||
});
|
||||
s.addEventListener('error', () =>
|
||||
reject(new Error('lottie-tint failed to load')),
|
||||
);
|
||||
document.head.append(s);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!container.value) return;
|
||||
try {
|
||||
const [res, tint] = await Promise.all([
|
||||
fetch(props.src),
|
||||
ensureTintScript(),
|
||||
]);
|
||||
const data = await res.json();
|
||||
const shades = tint.buildShades(tint.readPrimary());
|
||||
tint.patchColors(data, shades);
|
||||
instance = lottie.loadAnimation({
|
||||
animationData: data,
|
||||
autoplay: true,
|
||||
container: container.value,
|
||||
loop: true,
|
||||
renderer: 'svg',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[LottieLoading]', error);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
instance?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:style="{
|
||||
width: typeof size === 'number' ? `${size}px` : size,
|
||||
height: typeof size === 'number' ? `${size}px` : size,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
1
apps/web-antd/src/components/lottie-loading/index.ts
Normal file
1
apps/web-antd/src/components/lottie-loading/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LottieLoading } from './LottieLoading.vue';
|
||||
@@ -1,25 +1,175 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthPageLayout } from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { LanguageToggle, LoginIllustration } from '@vben/layouts';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const appName = computed(() => preferences.app.name);
|
||||
const logo = computed(() => preferences.logo.source);
|
||||
const logoDark = computed(() => preferences.logo.sourceDark);
|
||||
const { isDark } = usePreferences();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPageLayout
|
||||
:app-name="appName"
|
||||
:logo="logo"
|
||||
:logo-dark="logoDark"
|
||||
:page-description="$t('authentication.pageDesc')"
|
||||
:page-title="$t('authentication.pageTitle')"
|
||||
<div
|
||||
:class="[isDark ? 'dark' : '']"
|
||||
class="relative flex h-screen w-full overflow-hidden font-sans"
|
||||
>
|
||||
<!-- 自定义工具栏 -->
|
||||
<!-- <template #toolbar></template> -->
|
||||
</AuthPageLayout>
|
||||
<!-- 主题色渐变背景 + 插画(主题色走 CSS 变量 --primary) -->
|
||||
<div class="absolute inset-0 z-0 size-full">
|
||||
<!-- 渐变:主色 -> 主色淡 -> 主色更淡 -->
|
||||
<div
|
||||
class="absolute inset-0 size-full bg-gradient-to-r from-[hsl(var(--primary)/0.85)] via-[hsl(var(--primary)/0.3)] to-[hsl(var(--primary)/0.08)] dark:from-[hsl(var(--primary)/0.85)] dark:via-[hsl(var(--primary)/0.4)] dark:to-[hsl(var(--primary)/0.12)]"
|
||||
>
|
||||
<!-- 浮动插画 - 左侧区域 -->
|
||||
<div
|
||||
class="absolute -left-[5%] top-1/2 hidden h-[48rem] w-[65%] -translate-y-1/2 lg:block"
|
||||
>
|
||||
<LoginIllustration :alt="appName" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左上角品牌标识 -->
|
||||
<div
|
||||
class="absolute left-4 top-4 z-20 flex items-center gap-3 lg:left-6 lg:top-6"
|
||||
@click.prevent
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg bg-white/90 p-0.5 shadow-[0_2px_8px_rgba(0,0,0,0.12)] backdrop-blur-md transition-all hover:bg-white/95 hover:shadow-[0_4px_12px_rgba(0,0,0,0.18)] lg:p-1"
|
||||
>
|
||||
<img
|
||||
v-if="logo"
|
||||
:src="logo"
|
||||
:alt="appName"
|
||||
class="size-10 lg:size-12"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-xl text-[hsl(var(--primary))] lg:text-2xl"
|
||||
>💡</span>
|
||||
</div>
|
||||
<span
|
||||
class="relative top-[1px] text-xl font-semibold tracking-tight text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] lg:top-[2px] lg:text-2xl"
|
||||
>
|
||||
{{ appName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 右上角 Toolbar -->
|
||||
<div
|
||||
class="absolute right-2 top-4 z-20 flex items-center gap-1 rounded-3xl bg-accent px-3 py-1 lg:right-6 lg:top-6"
|
||||
>
|
||||
<LanguageToggle v-if="preferences.widget.languageToggle" />
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
class="relative z-10 flex w-full flex-1 items-center justify-center px-6 py-5 lg:justify-end lg:px-12"
|
||||
>
|
||||
<!-- 登录卡片 - 响应式居中/靠右 -->
|
||||
<div class="w-full md:w-[480px] lg:mr-[10%]">
|
||||
<div
|
||||
class="shadow-[0_30px_60px_-15px_hsl(var(--primary)/0.2)] dark:shadow-[0_30px_60px_-15px_hsl(var(--primary)/0.3)] relative overflow-hidden rounded-[2.5rem] bg-background p-8"
|
||||
>
|
||||
<!-- 登录表单 - RouterView 渲染实际的登录组件
|
||||
KeepAlive 只缓存 Login:CodeLogin/QrCodeLogin 需要每次进入刷新验证码/二维码,不应缓存 -->
|
||||
<div class="login-form-container">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition appear mode="out-in" name="fade">
|
||||
<KeepAlive :include="['Login']">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
|
||||
<!-- 遇到问题提示(纯文本,无跳转;如需接入客服链接再改回 <a>) -->
|
||||
<div class="mt-6 text-center">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ $t('authentication.contactSupport') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 登录表单容器样式覆盖(主题色走 --primary 变量) */
|
||||
:deep(.login-form-container) {
|
||||
/* 标题靠左对齐 */
|
||||
.mb-7,
|
||||
.mb-7 h2,
|
||||
.mb-7 p {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
.vben-input,
|
||||
.vben-input-password input {
|
||||
@apply rounded-2xl border-slate-100 bg-slate-50 px-6 py-4 text-sm transition-all;
|
||||
@apply focus:border-[hsl(var(--primary)/0.3)] focus:bg-white focus:ring-4 focus:ring-[hsl(var(--primary)/0.1)];
|
||||
}
|
||||
|
||||
/* 深色模式输入框 */
|
||||
.dark .vben-input,
|
||||
.dark .vben-input-password input {
|
||||
@apply border-slate-700 bg-slate-800;
|
||||
@apply focus:border-[hsl(var(--primary)/0.5)] focus:bg-slate-900 focus:ring-[hsl(var(--primary)/0.2)];
|
||||
}
|
||||
|
||||
/* 选择框样式 */
|
||||
.vben-select .vben-input {
|
||||
@apply rounded-2xl border-slate-100 bg-slate-50 px-6 py-4 text-sm;
|
||||
}
|
||||
|
||||
.dark .vben-select .vben-input {
|
||||
@apply border-slate-700 bg-slate-800;
|
||||
}
|
||||
|
||||
/* 登录按钮样式 */
|
||||
.vben-button[aria-label='login'] {
|
||||
@apply w-full rounded-2xl bg-[hsl(var(--primary))] py-4 text-base font-bold tracking-wide text-white shadow-lg shadow-[hsl(var(--primary)/0.3)];
|
||||
@apply transition-all hover:-translate-y-1 hover:bg-[hsl(var(--primary)/0.9)] active:translate-y-0;
|
||||
}
|
||||
|
||||
.dark .vben-button[aria-label='login'] {
|
||||
@apply shadow-[hsl(var(--primary)/0.4)];
|
||||
}
|
||||
|
||||
/* 记住我和忘记密码 */
|
||||
.vben-checkbox label {
|
||||
@apply text-xs text-slate-400 dark:text-slate-500;
|
||||
}
|
||||
|
||||
.vben-link {
|
||||
@apply text-xs text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
/* 过渡动画 - 快速响应 */
|
||||
.fade-enter-active {
|
||||
transition:
|
||||
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 0.15s cubic-bezier(0.4, 0, 1, 1),
|
||||
transform 0.15s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,9 @@ export const overridesPreferences = defineOverridesPreferences({
|
||||
/** 首页路径:默认跳转到 IoT 首页(覆盖框架默认的 /analytics) */
|
||||
defaultHomePath: '/iot/home',
|
||||
},
|
||||
logo: {
|
||||
source: '/logo.svg',
|
||||
},
|
||||
footer: {
|
||||
/** 默认关闭 footer 页脚,因为有一定遮挡 */
|
||||
enable: false,
|
||||
|
||||
@@ -8,9 +8,12 @@ import { useRoute } from 'vue-router';
|
||||
|
||||
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
|
||||
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
checkCaptcha,
|
||||
getCaptcha,
|
||||
@@ -96,6 +99,12 @@ async function handleThirdLogin(type: number) {
|
||||
if (type <= 0) {
|
||||
return;
|
||||
}
|
||||
// 多租户模式下,必须先选择租户;触发表单校验以提示用户
|
||||
if (tenantEnable && !accessStore.tenantId) {
|
||||
message.warning($t('authentication.tenantTip'));
|
||||
loginRef.value?.getFormApi().validateField('tenantId');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 计算 redirectUri
|
||||
// tricky: type、redirect 需要先 encode 一次,否则钉钉回调会丢失。配合 social-login.vue#getUrlValue() 使用
|
||||
@@ -170,17 +179,71 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="custom-login-wrapper">
|
||||
<AuthenticationLogin
|
||||
ref="loginRef"
|
||||
:form-schema="formSchema"
|
||||
:loading="authStore.loginLoading"
|
||||
:show-code-login="false"
|
||||
:show-doc-link="false"
|
||||
:show-qrcode-login="false"
|
||||
:show-register="false"
|
||||
:show-third-party-login="false"
|
||||
@submit="handleLogin"
|
||||
@third-login="handleThirdLogin"
|
||||
/>
|
||||
|
||||
<!-- 自定义其他登录方式 -->
|
||||
<div class="mt-8">
|
||||
<div class="relative mb-6 flex justify-center text-xs text-slate-400">
|
||||
<span
|
||||
class="relative z-10 bg-background px-3 font-medium dark:bg-slate-900"
|
||||
>
|
||||
{{ $t('authentication.otherLoginMethods') }}
|
||||
</span>
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div
|
||||
class="w-full border-t border-slate-100 dark:border-slate-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-8">
|
||||
<!-- 手机登录 -->
|
||||
<button
|
||||
class="flex size-12 items-center justify-center rounded-2xl border border-slate-100 bg-slate-50 text-slate-400 transition-all hover:scale-110 hover:bg-white hover:text-slate-600 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
|
||||
:title="$t('authentication.mobileLogin')"
|
||||
type="button"
|
||||
@click="$router.push('/auth/code-login')"
|
||||
>
|
||||
<IconifyIcon icon="lucide:smartphone" class="size-5" />
|
||||
</button>
|
||||
|
||||
<!-- 二维码登录 -->
|
||||
<button
|
||||
class="flex size-12 items-center justify-center rounded-2xl border border-slate-100 bg-slate-50 text-slate-400 transition-all hover:scale-110 hover:bg-white hover:text-slate-600 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
|
||||
:title="$t('authentication.qrcodeLogin')"
|
||||
type="button"
|
||||
@click="$router.push('/auth/qrcode-login')"
|
||||
>
|
||||
<IconifyIcon icon="lucide:qr-code" class="size-6" />
|
||||
</button>
|
||||
|
||||
<!-- 企业微信扫码登录(社交登录 type=30) -->
|
||||
<button
|
||||
class="flex size-12 items-center justify-center rounded-2xl border border-slate-100 bg-slate-50 text-slate-400 transition-all hover:scale-110 hover:bg-white hover:text-[#2BAD13] hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
|
||||
:title="$t('authentication.weComLogin')"
|
||||
type="button"
|
||||
@click="handleThirdLogin(30)"
|
||||
>
|
||||
<IconifyIcon icon="simple-icons:wechat" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Verification
|
||||
ref="verifyRef"
|
||||
v-if="captchaEnable"
|
||||
ref="verifyRef"
|
||||
:captcha-type="captchaType"
|
||||
:check-captcha-api="checkCaptcha"
|
||||
:get-captcha-api="getCaptcha"
|
||||
@@ -190,3 +253,10 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 确保表单容器宽度 */
|
||||
.custom-login-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LOGIN_PATH } from '@vben/constants';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ssoCallback } from '#/api/core/sso';
|
||||
import { LottieLoading } from '#/components/lottie-loading';
|
||||
import {
|
||||
IOT_CLIENT_ID,
|
||||
SSO_CALLBACK_PATH,
|
||||
@@ -83,8 +84,8 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen items-center justify-center text-base">
|
||||
<span v-if="errorMsg">{{ errorMsg }}</span>
|
||||
<span v-else>正在登录,请稍候...</span>
|
||||
<div class="flex h-screen items-center justify-center">
|
||||
<div v-if="errorMsg" class="text-base">{{ errorMsg }}</div>
|
||||
<LottieLoading v-else :size="220" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
import { copyFileSync, mkdirSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from '@vben/vite-config';
|
||||
|
||||
/**
|
||||
* 启动/构建时把 lottie-web 的 light 播放器从 node_modules 拷到 public/
|
||||
* 供 loading.html 以 <script src="/lottie_light.min.js"> 方式加载。
|
||||
*
|
||||
* 每次 configResolved 都无条件覆盖:文件约 160KB,成本可忽略;
|
||||
* 且避免依赖 mtime 比较(pnpm 安装后 node_modules 的 mtime 与上游
|
||||
* 发布时间无关,mtime 比较会误判为"已是最新"而不更新)。
|
||||
*/
|
||||
function copyLottiePlayer(): Plugin {
|
||||
const require = createRequire(import.meta.url);
|
||||
const src = require.resolve('lottie-web/build/player/lottie_light.min.js');
|
||||
const dest = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'public/lottie_light.min.js',
|
||||
);
|
||||
|
||||
return {
|
||||
name: 'web-antd:copy-lottie-player',
|
||||
configResolved: () => {
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
application: {},
|
||||
vite: {
|
||||
plugins: [copyLottiePlayer()],
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user