update add login modules

This commit is contained in:
2025-11-28 20:05:31 +08:00
parent b310413bfc
commit 2ae2b3ab4c
15 changed files with 1365 additions and 68 deletions

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KvMessage Demo</title>
<script type="module" src="./src/main.ts"></script>
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.demo-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 40px 20px;
}
.button-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.demo-button {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.demo-button.success {
background: #52c41a;
color: white;
}
.demo-button.success:hover {
background: #389e0d;
}
.demo-button.error {
background: #ff4d4f;
color: white;
}
.demo-button.error:hover {
background: #cf1322;
}
.demo-button.loading {
background: #1890ff;
color: white;
}
.demo-button.loading:hover {
background: #096dd9;
}
.login-section {
text-align: center;
}
</style>
</head>
<body>
<div class="demo-container">
<div class="login-section">
<h2>登录组件</h2>
<kv-login id="loginComponent">
<div id="weixinLogin"></div>
</kv-login>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,22 @@
{
"name": "kv-login",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"prepub": "rm -rf ./dist && pnpm run build",
"pub":"ev deploy ./dist -k kv-login-test -v 0.0.1 -u -y yes"
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"packageManager": "pnpm@10.19.0",
"type": "module",
"dependencies": {
"@kevisual/query-login": "^0.0.6",
"lit-html": "^3.3.1",
"qrcode": "^1.5.4"
}
}

View File

@@ -0,0 +1,2 @@
import './pages/kv-login'
import './pages/kv-message'

View File

@@ -0,0 +1,188 @@
import { query } from './query.ts';
import { createMessage } from '../pages/kv-message.ts';
import { WX_MP_APP_ID } from '../pages/kv-login.ts';
export const message = createMessage();
type LoginOpts = {
loginMethod: 'password' | 'phone' | 'wechat' | 'wechat-mp',
data: any,
el: HTMLElement
}
export const redirectHome = () => {
console.log('重定向到首页')
const href = window.location.href;
const url = new URL(href);
const redirect = url.searchParams.get('redirect');
if (redirect) {
const href = decodeURIComponent(redirect);
window.open(href, '_self');
} else {
window.open('/root/home', '_self');
}
}
export const loginHandle = async (opts: LoginOpts) => {
const { loginMethod, data, el } = opts
switch (loginMethod) {
case 'password':
await loginByPassword(data)
break
case 'phone':
await loginByPhone(data)
break
case 'wechat-mp':
await loginByWeChatMp(data)
break
case 'wechat':
await loginByWeChat(data)
break
default:
console.warn('未知的登录方式:', loginMethod)
}
}
const loginByPassword = async (data: { username: string, password: string }) => {
console.log('使用用户名密码登录:', data)
let needLogin = true; // 这里可以根据实际情况决定是否需要登录, 只能判断密码登录和手机号登录
const isLogin = await query.checkLocalToken()
if (isLogin) {
const loginUser = await query.checkLocalUser()
if (loginUser?.username === data?.username) {
const res = await query.getMe()
if (res.code === 200) {
needLogin = false
console.log('已登录,跳过登录步骤')
message.success('已登录')
}
}
}
if (!needLogin) {
redirectHome()
return;
}
const res = await query.login({
username: data.username,
password: data.password
})
if (res.code === 200) {
console.log('登录成功')
message.success('登录成功')
redirectHome()
} else {
message.error(`登录失败: ${res.message}`)
}
}
const loginByPhone = async (data: { phone: string, code: string }) => {
console.log('使用手机号登录:', data)
}
const loginByWeChat = async (data: { wechatCode: string }) => {
console.log('使用微信登录:', data)
}
const loginByWeChatMp = async (data: { wechatMpCode: string }) => {
console.log('使用微信公众号登录:', data)
}
const clearCode = () => {
const url = new URL(window.location.href);
// 清理 URL 中的 code 参数
url.searchParams.delete('code');
url.searchParams.delete('state');
window.history.replaceState({}, document.title, url.toString());
}
export const checkWechat = async () => {
const url = new URL(window.location.href);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (state?.includes?.('-')) {
// 公众号登录流程,不在这里处理
return;
}
if (!code) {
return;
}
const res = await query.loginByWechat({ code });
if (res.code === 200) {
message.success('登录成功');
redirectHome();
} else {
message.error(res.message || '登录失败');
clearCode();
}
};
export const checkMpWechat = async () => {
const url = new URL(window.location.href);
const originState = url.searchParams.get('state');
const [mpLogin, state] = originState ? originState.split('-') : [null, null];
console.log('检查微信公众号登录流程:', mpLogin, state, originState);
if (mpLogin === '1') {
// 手机端扫描的时候访问的链接,跳转到微信公众号授权页面
checkMpWechatInWx()
} else if (mpLogin === '2') {
const code = url.searchParams.get('code');
// 推送登录成功状态到扫码端
const res2 = await query.post({
path: 'wx',
key: 'mplogin',
state,
code
})
if (res2.code === 200) {
message.success('登录成功');
} else {
message.error(res2.message || '登录失败');
}
closePage();
}
}
const isWechat = () => {
const ua = navigator.userAgent.toLowerCase();
return /micromessenger/i.test(ua);
};
const closePage = (time = 2000) => {
if (!isWechat()) {
setTimeout(() => {
window.close();
}, time);
return;
}
// @ts-ignore
if (window.WeixinJSBridge) {
setTimeout(() => {
// @ts-ignore
window.WeixinJSBridge.call('closeWindow');
}, time);
} else {
setTimeout(() => {
window.close();
}, time);
}
};
const checkMpWechatInWx = async () => {
const wxAuthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect`
const appid = WX_MP_APP_ID;
const url = new URL(window.location.href);
const originState = url.searchParams.get('state');
let [mpLogin, state] = originState ? originState.split('-') : [null, null];
const redirectURL = new URL(url.pathname, url.origin);
state = '2-' + state; // 标记为第二步登录
const redirect_uri = encodeURIComponent(redirectURL.toString())
document.body.innerHTML = `<p>正在准备跳转到微信公众号授权页面...</p>`;
const scope = `snsapi_userinfo`
if (!state) {
alert('Invalid state. Please try again later.');
closePage();
return;
}
const link = wxAuthUrl.replace('APPID', appid).replace('REDIRECT_URI', redirect_uri).replace('SCOPE', scope).replace('STATE', state);
setTimeout(() => {
window.location.href = link;
}, 100);
}
setTimeout(() => {
checkMpWechat();
}, 100);

View File

@@ -0,0 +1,10 @@
import { Query } from '@kevisual/query'
import { QueryLoginBrowser } from '@kevisual/query-login';
export const queryBase = new Query()
export const query = new QueryLoginBrowser({
query: queryBase,
})

View File

@@ -0,0 +1,57 @@
import QRCode, { QRCodeToDataURLOptions } from 'qrcode';
import { redirectHome } from '../login-handle.ts';
import { query } from '../query.ts';
export const useCreateLoginQRCode = (el?: HTMLCanvasElement) => {
var opts: QRCodeToDataURLOptions = {
errorCorrectionLevel: 'H',
type: 'image/jpeg',
margin: 1,
width: 300,
};
let timer: any = null;
const createQrcode = async (state: string) => {
const url = new URL(window.location.href);
const loginUrl = new URL(url.pathname, url.origin);
loginUrl.searchParams.set('state', '1-' + state);
console.log('生成登录二维码链接:', loginUrl.toString());
var img = el || document.getElementById('qrcode')! as HTMLCanvasElement;
const res = await QRCode.toDataURL(img!, loginUrl.toString(), opts);
};
const checkLogin = async (state: string) => {
const res = await fetch(`/api/router?path=wx&key=checkLogin&state=${state}`).then((res) => res.json());
if (res.code === 200) {
console.log(res);
const token = res.data;
if (token) {
localStorage.setItem('token', token.accessToken);
await query.setLoginToken(token);
}
clear();
setTimeout(() => {
redirectHome();
}, 1000);
} else {
timer = setTimeout(() => {
checkLogin(state);
console.log('继续检测登录状态');
}, 2000);
}
};
// 随机生成一个state
const state = Math.random().toString(36).substring(2, 15);
createQrcode(state);
checkLogin(state);
const timer2 = setInterval(() => {
const state = Math.random().toString(36).substring(2, 15);
clearTimeout(timer); // 清除定时器
createQrcode(state); // 90秒后更新二维码
checkLogin(state);
console.log('更新二维码');
}, 90000);
const clear = () => {
clearTimeout(timer);
clearInterval(timer2);
console.log('停止检测登录状态');
}
return { createQrcode, clear };
};

View File

@@ -0,0 +1,21 @@
// <script src="https://turing.captcha.qcloud.com/TCaptcha.js"></script>
export const dynimicLoadTcapTcha = async (): Promise<boolean> => {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.type = 'text/javascript'
script.id = 'tencent-captcha'
if (document.getElementById('tencent-captcha')) {
resolve(true)
return
}
script.src = 'https://turing.captcha.qcloud.com/TCaptcha.js'
script.onload = () => {
resolve(true)
}
script.onerror = (error) => {
reject(error)
}
document.body.appendChild(script)
})
}

View File

@@ -0,0 +1,70 @@
// 定义回调函数
export function callback(res: any) {
// 第一个参数传入回调结果,结果如下:
// ret Int 验证结果0验证成功。2用户主动关闭验证码。
// ticket String 验证成功的票据,当且仅当 ret = 0 时 ticket 有值。
// CaptchaAppId String 验证码应用ID。
// bizState Any 自定义透传参数。
// randstr String 本次验证的随机串,后续票据校验时需传递该参数。
console.log('callback:', res);
// res用户主动关闭验证码= {ret: 2, ticket: null}
// res验证成功 = {ret: 0, ticket: "String", randstr: "String"}
// res请求验证码发生错误验证码自动返回terror_前缀的容灾票据 = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"}
// 此处代码仅为验证结果的展示示例真实业务接入建议基于ticket和errorCode情况做不同的业务处理
if (res.ret === 0) {
// 复制结果至剪切板
var str = '【randstr】->【' + res.randstr + '】 【ticket】->【' + res.ticket + '】';
var ipt = document.createElement('input');
ipt.value = str;
document.body.appendChild(ipt);
ipt.select();
document.body.removeChild(ipt);
alert('1. 返回结果randstr、ticket已复制到剪切板ctrl+v 查看。 2. 打开浏览器控制台,查看完整返回结果。');
}
}
export type TencentCaptcha = {
actionDuration?: number;
appid?: string;
bizState?: any;
randstr?: string;
ret: number;
sid?: string;
ticket?: string;
errorCode?: number;
errorMessage?: string;
verifyDuration?: number;
};
// 定义验证码触发事件
export const checkCaptcha = (captchaAppId: string): Promise<TencentCaptcha> => {
return new Promise((resolve, reject) => {
const callback = (res: TencentCaptcha) => {
console.log('callback:', res);
if (res.ret === 0) {
resolve(res);
} else {
reject(res);
}
};
const appid = captchaAppId;
try {
// 生成一个验证码对象
// CaptchaAppId登录验证码控制台从【验证管理】页面进行查看。如果未创建过验证请先新建验证。注意不可使用客户端类型为小程序的CaptchaAppId会导致数据统计错误。
//callback定义的回调函数
// @ts-ignore
var captcha = new TencentCaptcha(appid, callback, {});
// 调用方法,显示验证码
captcha.show();
} catch (error) {
// 加载异常调用验证码js加载错误处理函数
var ticket = 'terror_1001_' + appid + '_' + Math.floor(new Date().getTime() / 1000);
// 生成容灾票据或自行做其它处理
callback({
ret: 0,
randstr: '@' + Math.random().toString(36).substring(2),
ticket: ticket,
errorCode: 1001,
errorMessage: 'jsload_error',
});
}
});
};

View File

@@ -0,0 +1,61 @@
type WxLoginConfig = {
redirect_uri?: string;
appid?: string;
scope?: string;
state?: string;
style?: string;
};
export const createLogin = async (config?: WxLoginConfig) => {
let redirect_uri = config?.redirect_uri;
const { appid } = config || {};
if (!redirect_uri) {
redirect_uri = window.location.href;
}
const url = new URL(redirect_uri); // remove code and state params
url.searchParams.delete('code');
url.searchParams.delete('state');
redirect_uri = url.toString();
console.log('redirect_uri', redirect_uri);
if (!appid) {
console.error('appid is not cant be empty');
return;
}
// @ts-ignore
const obj = new WxLogin({
self_redirect: false,
id: 'weixinLogin', // 需要显示的容器id
appid: appid, // 微信开放平台appid wx*******
scope: 'snsapi_login', // 网页默认即可 snsapi_userinfo
redirect_uri: encodeURIComponent(redirect_uri), // 授权成功后回调的url
state: Math.ceil(Math.random() * 1000), // 可设置为简单的随机数加session用来校验
stylelite: true, // 是否使用简洁模式
// https://juejin.cn/post/6982473580063752223
href: "data:text/css;base64,LmltcG93ZXJCb3ggLnFyY29kZSB7d2lkdGg6IDIwMHB4O30NCi5pbXBvd2VyQm94IC50aXRsZSB7ZGlzcGxheTogbm9uZTt9DQouaW1wb3dlckJveCAuaW5mbyB7d2lkdGg6IDIwMHB4O30NCi5zdGF0dXNfaWNvbiB7ZGlzcGxheTogbm9uZX0NCi5pbXBvd2VyQm94IC5zdGF0dXMge3RleHQtYWxpZ246IGNlbnRlcjt9"
});
const login = document.querySelector('#weixinLogin')
if (login) {
// login 下的 iframe 样式调整
const iframe = login.querySelector('iframe');
if (iframe) {
// iframe.style.width = '200px';
iframe.style.height = '300px';
}
}
return obj;
};
export const wxId = 'weixinLogin';
export function setWxerwma(config?: WxLoginConfig) {
const s = document.createElement('script');
s.type = 'text/javascript';
s.src = '//res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js';
s.id = 'weixinLogin-js';
if (document.getElementById('weixinLogin-js')) {
createLogin(config);
return;
}
const wxElement = document.body.appendChild(s);
wxElement.onload = function () {
createLogin(config);
};
}

View File

@@ -0,0 +1,449 @@
import { render, html } from 'lit-html'
import { loginHandle, checkWechat } from '../modules/login-handle.ts'
import { setWxerwma } from '../modules/wx/ws-login.ts';
import { useCreateLoginQRCode } from '../modules/wx-mp/qr.ts';
export const WX_MP_APP_ID = "wxff97d569b1db16b6";
interface LoginMethod {
id: LoginMethods
name: string
icon: string
appid?: string
}
const DefaultLoginMethods: LoginMethod[] = [
{ id: 'password', name: '密码登录', icon: '🔒' },
{ id: 'wechat', name: '微信登录', icon: '💬', appid: "wx9378885c8390e09b" },
{ id: 'wechat-mp', name: '微信公众号登录', icon: '💬', appid: WX_MP_APP_ID },
// { id: 'phone', name: '手机号登录', icon: '📱' }
]
type LoginMethods = 'password' | 'phone' | 'wechat' | 'wechat-mp'
class KvLogin extends HTMLElement {
private selectedMethod: LoginMethods = 'password'
private loginMethods: LoginMethod[] = DefaultLoginMethods
setLoginMethods(methods: LoginMethod[]) {
this.loginMethods = methods
this.render()
}
constructor() {
super()
}
connectedCallback() {
this.attachShadow({ mode: 'open' })
this.render()
this.bindEvents()
checkWechat()
}
#clearTimer: any = null;
private selectLoginMethod(methodId: LoginMethods) {
this.selectedMethod = methodId
this.render()
if (this.#clearTimer) {
this.#clearTimer();
this.#clearTimer = null;
}
}
private getMethodData(methodId: LoginMethods): LoginMethod | undefined {
return this.loginMethods.find(method => method.id === methodId);
}
private bindEvents() {
if (!this.shadowRoot) return
// 使用事件委托来处理登录方式切换
this.shadowRoot.addEventListener('click', (e) => {
const target = e.target as HTMLElement
const methodButton = target.closest('.login-method')
if (methodButton) {
const methodId = methodButton.getAttribute('data-method') as LoginMethods
if (methodId) {
this.selectLoginMethod(methodId)
}
}
})
// 使用事件委托来处理表单提交
this.shadowRoot.addEventListener('submit', (e) => {
const target = e.target as HTMLElement
if (target && target.id === 'loginForm') {
e.preventDefault()
this.handleLogin()
}
})
}
private handleLogin() {
const formData = this.getFormData()
// console.log('登录方式:', this.selectedMethod)
// console.log('登录数据:', formData)
loginHandle({
loginMethod: this.selectedMethod,
data: formData,
el: this
})
// 这里可以触发自定义事件,通知父组件
this.dispatchEvent(new CustomEvent('login', {
detail: {
method: this.selectedMethod,
data: formData
},
bubbles: true
}))
}
private getFormData(): any {
if (!this.shadowRoot) return {}
switch (this.selectedMethod) {
case 'password':
const username = this.shadowRoot.querySelector('#username') as HTMLInputElement
const password = this.shadowRoot.querySelector('#password') as HTMLInputElement
return {
username: username?.value || '',
password: password?.value || ''
}
case 'phone':
const phone = this.shadowRoot.querySelector('#phone') as HTMLInputElement
const code = this.shadowRoot.querySelector('#code') as HTMLInputElement
return {
phone: phone?.value || '',
code: code?.value || ''
}
case 'wechat':
return {
wechatCode: 'mock_wechat_code'
}
case 'wechat-mp':
return {
wechatMpCode: 'mock_wechat_mp_code'
}
default:
return {}
}
}
private renderPasswordForm() {
return html`
<form id="loginForm" class="login-form">
<div class="form-group">
<input
type="text"
id="username"
name="username"
placeholder="请输入用户名"
autocomplete="username"
required
/>
</div>
<div class="form-group">
<input
type="password"
id="password"
name="password"
placeholder="请输入密码"
autocomplete="current-password"
required
/>
</div>
<button type="submit" class="login-button">登录</button>
</form>
`
}
private renderPhoneForm() {
return html`
<form id="loginForm" class="login-form">
<div class="form-group">
<input
type="tel"
id="phone"
name="phone"
placeholder="请输入手机号"
pattern="[0-9]{11}"
autocomplete="tel"
required
/>
</div>
<div class="form-group code-group">
<input
type="text"
id="code"
name="code"
placeholder="请输入验证码"
autocomplete="one-time-code"
required
/>
<button type="button" class="code-button" @click=${this.sendVerificationCode}>获取验证码</button>
</div>
<button type="submit" class="login-button">登录</button>
</form>
`
}
private renderWechatForm() {
return html`
<div class="wechat-login">
<slot></slot>
</div>
`
}
private renderWechatMpForm() {
const that = this
setTimeout(() => {
const qrcode = that.shadowRoot!.querySelector('#qrcode');
const { clear } = useCreateLoginQRCode(qrcode as HTMLCanvasElement);
that.#clearTimer = clear;
}, 0)
return html`
<div class="wechat-login">
<div class="qr-container">
<div class="qr-placeholder">
<canvas id='qrcode' width='300' height='300'></canvas>
<p class="qr-desc">请使用微信扫描二维码登录</p>
</div>
</div>
</div>
`
}
private sendVerificationCode() {
console.log('发送验证码')
// 这里可以实现发送验证码的逻辑
}
private refreshQR() {
console.log('刷新二维码')
// 这里可以实现刷新二维码的逻辑
}
private renderLoginForm() {
const data = this.getMethodData(this.selectedMethod);
switch (this.selectedMethod) {
case 'password':
return this.renderPasswordForm()
case 'phone':
return this.renderPhoneForm()
case 'wechat':
setWxerwma({ appid: data?.appid! || "" });
return this.renderWechatForm()
case 'wechat-mp':
return this.renderWechatMpForm()
default:
return this.renderPasswordForm()
}
}
render() {
if (!this.shadowRoot) return
const template = html`
<style>
:host {
display: block;
width: 100%;
min-width: 400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.login-sidebar {
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.login-methods {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.login-method {
flex: 1;
padding: 16px 8px;
border: none;
background: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transition: all 0.3s ease;
position: relative;
}
.login-method:hover {
background: #e9ecef;
}
.login-method.active {
background: white;
color: #007bff;
}
.login-method.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: #007bff;
}
.method-icon {
font-size: 20px;
}
.method-name {
font-size: 12px;
font-weight: 500;
}
.login-content {
padding: 32px 24px;
}
.impowerBox .qrcode {
width: 200px !important;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
position: relative;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #007bff;
}
.code-group {
display: flex;
gap: 12px;
}
.code-group input {
flex: 1;
}
.code-button {
padding: 0 16px;
background: #6c757d;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.3s ease;
}
.code-button:hover {
background: #5a6268;
}
.login-button {
padding: 12px;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s ease;
}
.login-button:hover {
background: #0056b3;
}
.wechat-login {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.qr-container {
width: 400px;
height: 400px;
border: 2px dashed #e9ecef;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-placeholder {
text-align: center;
color: #6c757d;
}
.qr-icon {
font-size: 48px;
margin-bottom: 8px;
}
.qr-desc {
font-size: 12px;
margin-top: 4px;
}
.refresh-button {
padding: 8px 16px;
background: #6c757d;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.refresh-button:hover {
background: #5a6268;
}
</style>
<div class="login-sidebar">
<div class="login-methods">
${this.loginMethods.map(method => html`
<button
class="login-method ${this.selectedMethod === method.id ? 'active' : ''}"
data-method="${method.id}"
>
<span class="method-icon">${method.icon}</span>
<span class="method-name">${method.name}</span>
</button>
`)}
</div>
<div class="login-content">
${this.renderLoginForm()}
</div>
</div>
`
render(template, this.shadowRoot)
}
}
customElements.define('kv-login', KvLogin)

View File

@@ -0,0 +1,351 @@
import { html, render, TemplateResult } from 'lit-html'
export interface KvMessageOptions {
type?: 'success' | 'error' | 'loading'
message: string
duration?: number
closable?: boolean
position?: 'center' | 'right'
}
class KvMessage extends HTMLElement {
private options: KvMessageOptions
private timer: number | null = null
constructor() {
super()
this.options = {
type: 'success',
message: '',
duration: 2000,
closable: true
}
}
connectedCallback() {
this.render()
}
setOptions(options: KvMessageOptions) {
this.options = { ...this.options, ...options }
this.render()
}
private render() {
const { type, message, closable } = this.options
const getTypeIcon = () => {
switch (type) {
case 'success':
return '✓'
case 'error':
return '✕'
case 'loading':
return html`<div class="loading-spinner"></div>`
default:
return ''
}
}
const template: TemplateResult = html`
<style>
:host {
display: block;
margin-bottom: 12px;
animation: slideIn 0.3s ease-out;
}
.message-container {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: white;
position: relative;
min-width: 300px;
max-width: 500px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
}
.message-container.success {
border-left: 4px solid #52c41a;
}
.message-container.error {
border-left: 4px solid #ff4d4f;
}
.message-container.loading {
border-left: 4px solid #1890ff;
}
.message-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.success .message-icon {
color: #52c41a;
font-weight: bold;
}
.error .message-icon {
color: #ff4d4f;
font-weight: bold;
}
.loading .message-icon {
color: #1890ff;
}
.loading-spinner {
width: 14px;
height: 14px;
border: 2px solid #f3f3f3;
border-top: 2px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.message-content {
flex: 1;
color: #333;
}
.message-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
cursor: pointer;
color: #999;
background: none;
border: none;
font-size: 12px;
border-radius: 50%;
transition: all 0.2s;
}
.message-close:hover {
color: #666;
background: #f0f0f0;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.removing {
animation: slideOut 0.3s ease-out forwards;
}
</style>
<div class="message-container ${type}">
<div class="message-icon">
${getTypeIcon()}
</div>
<div class="message-content">${message}</div>
${closable ? html`
<button class="message-close" @click=${() => this.remove()}>&times;</button>
` : ''}
</div>
`
render(template, this)
if (type !== 'loading' && this.options.duration && this.options.duration > 0) {
this.setTimer()
}
}
private setTimer() {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = window.setTimeout(() => {
this.remove()
}, this.options.duration)
}
remove() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
this.classList.add('removing')
setTimeout(() => {
if (this.parentNode) {
this.parentNode.removeChild(this)
}
}, 300)
}
disconnectedCallback() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
}
customElements.define('kv-message', KvMessage)
export class KvMessageManager {
private static instance: KvMessageManager
private container: HTMLElement | null = null
private defaultPosition: 'center' | 'right' = 'center'
static getInstance(): KvMessageManager {
if (!KvMessageManager.instance) {
KvMessageManager.instance = new KvMessageManager()
}
return KvMessageManager.instance
}
setDefaultPosition(position: 'center' | 'right') {
this.defaultPosition = position
}
private getContainer(position?: 'center' | 'right'): HTMLElement {
const finalPosition = position || this.defaultPosition
if (!this.container) {
this.container = document.getElementById('messages')
if (!this.container) {
this.container = document.createElement('div')
this.container.id = 'messages'
if (finalPosition === 'center') {
this.container.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
gap: 8px;
flex-direction: column;
align-items: center;
pointer-events: none;
`
} else {
this.container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
gap: 8px;
flex-direction: column;
align-items: flex-end;
pointer-events: none;
`
}
document.body.appendChild(this.container)
}
}
return this.container
}
show(options: KvMessageOptions): KvMessage {
const container = this.getContainer(options.position)
const message = document.createElement('kv-message') as KvMessage
message.setOptions(options)
message.style.cssText = 'pointer-events: auto;'
container.appendChild(message)
return message
}
success(message: string, options?: { duration?: number; position?: 'center' | 'right'; closable?: boolean }): KvMessage {
return this.show({
type: 'success',
message,
duration: options?.duration || 2000,
position: options?.position,
closable: options?.closable
})
}
error(message: string, options?: { duration?: number; position?: 'center' | 'right'; closable?: boolean }): KvMessage {
return this.show({
type: 'error',
message,
duration: options?.duration || 3000,
position: options?.position,
closable: options?.closable
})
}
loading(message: string, options?: { position?: 'center' | 'right'; closable?: boolean }): KvMessage {
return this.show({
type: 'loading',
message,
duration: 0,
position: options?.position,
closable: options?.closable
})
}
remove(message: KvMessage) {
message.remove()
}
clear() {
const container = this.getContainer()
const messages = container.querySelectorAll('kv-message')
messages.forEach(message => {
(message as KvMessage).remove()
})
}
}
export const createMessage = () => KvMessageManager.getInstance()
// 将 createMessage 暴露到全局,以便 HTML 中的 JavaScript 可以使用
declare global {
interface Window {
createMessage: typeof createMessage
}
}
window.createMessage = createMessage

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
const idDev = process.env.NODE_ENV === 'development';
export default defineConfig({
base: idDev ? '/' : '/root/kv-login-test/',
server: {
proxy: {
'/api': {
target: 'https://kevisual.xiongxiao.me',
changeOrigin: true,
secure: false,
}
}
}
});

View File

@@ -15,7 +15,6 @@ const useCreateLoginQRCode = (config: Config) => {
margin: 1,
width: 300,
};
const [state, setState] = useState('');
let timer = useRef<any>(null);
const loginUrl = config?.wxmpLogin?.loginUrl || '';
if (!loginUrl) {

100
pnpm-lock.yaml generated
View File

@@ -115,6 +115,18 @@ importers:
specifier: ^7.2.2
version: 7.2.2(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)
packages/kv-login:
dependencies:
'@kevisual/query-login':
specifier: ^0.0.6
version: 0.0.6(@kevisual/query@0.0.29(ws@8.18.0)(zod@3.25.76))(rollup@4.52.5)(tslib@2.8.1)(typescript@5.8.3)
lit-html:
specifier: ^3.3.1
version: 3.3.1
qrcode:
specifier: ^1.5.4
version: 1.5.4
packages/user-login:
dependencies:
'@floating-ui/dom':
@@ -931,15 +943,6 @@ packages:
tslib:
optional: true
'@rollup/pluginutils@5.1.4':
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
engines: {node: '>=14.0.0'}
@@ -983,67 +986,56 @@ packages:
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.5':
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.5':
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.5':
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.5':
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.5':
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.5':
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.5':
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.5':
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
@@ -1224,9 +1216,6 @@ packages:
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1279,6 +1268,9 @@ packages:
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -1691,14 +1683,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fdir@6.4.4:
resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1993,6 +1977,9 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
lit-html@3.3.1:
resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -2019,9 +2006,6 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2343,10 +2327,6 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
picomatch@4.0.2:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
@@ -3146,7 +3126,7 @@ snapshots:
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
@@ -3697,19 +3677,19 @@ snapshots:
'@rollup/plugin-commonjs@28.0.3(rollup@4.52.5)':
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.52.5)
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
commondir: 1.0.1
estree-walker: 2.0.2
fdir: 6.4.4(picomatch@4.0.2)
fdir: 6.5.0(picomatch@4.0.3)
is-reference: 1.2.1
magic-string: 0.30.17
picomatch: 4.0.2
magic-string: 0.30.21
picomatch: 4.0.3
optionalDependencies:
rollup: 4.52.5
'@rollup/plugin-node-resolve@16.0.1(rollup@4.52.5)':
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.52.5)
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
'@types/resolve': 1.20.2
deepmerge: 4.3.1
is-module: 1.0.0
@@ -3719,21 +3699,13 @@ snapshots:
'@rollup/plugin-typescript@12.1.2(rollup@4.52.5)(tslib@2.8.1)(typescript@5.8.3)':
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.52.5)
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
resolve: 1.22.10
typescript: 5.8.3
optionalDependencies:
rollup: 4.52.5
tslib: 2.8.1
'@rollup/pluginutils@5.1.4(rollup@4.52.5)':
dependencies:
'@types/estree': 1.0.7
estree-walker: 2.0.2
picomatch: 4.0.2
optionalDependencies:
rollup: 4.52.5
'@rollup/pluginutils@5.3.0(rollup@4.52.5)':
dependencies:
'@types/estree': 1.0.8
@@ -3973,8 +3945,6 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
'@types/estree@1.0.7': {}
'@types/estree@1.0.8': {}
'@types/fontkit@2.0.8':
@@ -4029,6 +3999,8 @@ snapshots:
dependencies:
'@types/node': 22.10.3
'@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -4525,10 +4497,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
fdir@6.4.4(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -4795,7 +4763,7 @@ snapshots:
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.7
'@types/estree': 1.0.8
is-wsl@3.1.0:
dependencies:
@@ -4866,6 +4834,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
lit-html@3.3.1:
dependencies:
'@types/trusted-types': 2.0.7
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@@ -4888,10 +4860,6 @@ snapshots:
dependencies:
react: 19.2.0
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5466,8 +5434,6 @@ snapshots:
picomatch@2.3.1: {}
picomatch@4.0.2: {}
picomatch@4.0.3: {}
pngjs@5.0.0: {}
@@ -5687,7 +5653,7 @@ snapshots:
rollup-plugin-dts@6.2.1(rollup@4.52.5)(typescript@5.8.3):
dependencies:
magic-string: 0.30.17
magic-string: 0.30.21
rollup: 4.52.5
typescript: 5.8.3
optionalDependencies:

View File

@@ -1,6 +1,8 @@
{
"extends": "@kevisual/types/json/frontend.json",
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"baseUrl": ".",
"paths": {
"@/*": [