From 2ae2b3ab4c00243443c454b8f8c7536ba4c9933c Mon Sep 17 00:00:00 2001 From: abearxiong Date: Fri, 28 Nov 2025 20:05:31 +0800 Subject: [PATCH] update add login modules --- packages/kv-login/index.html | 84 ++++ packages/kv-login/package.json | 22 + packages/kv-login/src/main.ts | 2 + packages/kv-login/src/modules/login-handle.ts | 188 ++++++++ packages/kv-login/src/modules/query.ts | 10 + packages/kv-login/src/modules/wx-mp/qr.ts | 57 +++ packages/kv-login/src/modules/wx/load-js.ts | 21 + .../src/modules/wx/tencent-captcha.ts | 70 +++ packages/kv-login/src/modules/wx/ws-login.ts | 61 +++ packages/kv-login/src/pages/kv-login.ts | 449 ++++++++++++++++++ packages/kv-login/src/pages/kv-message.ts | 351 ++++++++++++++ packages/kv-login/vite.config.ts | 15 + .../src/user/login/modules/WeChatMpLogin.tsx | 1 - pnpm-lock.yaml | 100 ++-- tsconfig.json | 2 + 15 files changed, 1365 insertions(+), 68 deletions(-) create mode 100644 packages/kv-login/index.html create mode 100644 packages/kv-login/package.json create mode 100644 packages/kv-login/src/main.ts create mode 100644 packages/kv-login/src/modules/login-handle.ts create mode 100644 packages/kv-login/src/modules/query.ts create mode 100644 packages/kv-login/src/modules/wx-mp/qr.ts create mode 100644 packages/kv-login/src/modules/wx/load-js.ts create mode 100644 packages/kv-login/src/modules/wx/tencent-captcha.ts create mode 100644 packages/kv-login/src/modules/wx/ws-login.ts create mode 100644 packages/kv-login/src/pages/kv-login.ts create mode 100644 packages/kv-login/src/pages/kv-message.ts create mode 100644 packages/kv-login/vite.config.ts diff --git a/packages/kv-login/index.html b/packages/kv-login/index.html new file mode 100644 index 0000000..bcae02a --- /dev/null +++ b/packages/kv-login/index.html @@ -0,0 +1,84 @@ + + + + + + + KvMessage Demo + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/packages/kv-login/package.json b/packages/kv-login/package.json new file mode 100644 index 0000000..3034748 --- /dev/null +++ b/packages/kv-login/package.json @@ -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 (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" + } +} \ No newline at end of file diff --git a/packages/kv-login/src/main.ts b/packages/kv-login/src/main.ts new file mode 100644 index 0000000..c4d68ba --- /dev/null +++ b/packages/kv-login/src/main.ts @@ -0,0 +1,2 @@ +import './pages/kv-login' +import './pages/kv-message' \ No newline at end of file diff --git a/packages/kv-login/src/modules/login-handle.ts b/packages/kv-login/src/modules/login-handle.ts new file mode 100644 index 0000000..c3b1751 --- /dev/null +++ b/packages/kv-login/src/modules/login-handle.ts @@ -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 = `

正在准备跳转到微信公众号授权页面...

`; + 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); \ No newline at end of file diff --git a/packages/kv-login/src/modules/query.ts b/packages/kv-login/src/modules/query.ts new file mode 100644 index 0000000..43b43a4 --- /dev/null +++ b/packages/kv-login/src/modules/query.ts @@ -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, +}) + diff --git a/packages/kv-login/src/modules/wx-mp/qr.ts b/packages/kv-login/src/modules/wx-mp/qr.ts new file mode 100644 index 0000000..bbdb2b4 --- /dev/null +++ b/packages/kv-login/src/modules/wx-mp/qr.ts @@ -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 }; +}; \ No newline at end of file diff --git a/packages/kv-login/src/modules/wx/load-js.ts b/packages/kv-login/src/modules/wx/load-js.ts new file mode 100644 index 0000000..a02c12c --- /dev/null +++ b/packages/kv-login/src/modules/wx/load-js.ts @@ -0,0 +1,21 @@ +// + +export const dynimicLoadTcapTcha = async (): Promise => { + 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) + }) +} diff --git a/packages/kv-login/src/modules/wx/tencent-captcha.ts b/packages/kv-login/src/modules/wx/tencent-captcha.ts new file mode 100644 index 0000000..a1169b2 --- /dev/null +++ b/packages/kv-login/src/modules/wx/tencent-captcha.ts @@ -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 => { + 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', + }); + } + }); +}; diff --git a/packages/kv-login/src/modules/wx/ws-login.ts b/packages/kv-login/src/modules/wx/ws-login.ts new file mode 100644 index 0000000..bdfbeba --- /dev/null +++ b/packages/kv-login/src/modules/wx/ws-login.ts @@ -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); + }; +} diff --git a/packages/kv-login/src/pages/kv-login.ts b/packages/kv-login/src/pages/kv-login.ts new file mode 100644 index 0000000..4ed9b63 --- /dev/null +++ b/packages/kv-login/src/pages/kv-login.ts @@ -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` + + ` + } + + private renderPhoneForm() { + return html` + + ` + } + + private renderWechatForm() { + return html` + + ` + } + private renderWechatMpForm() { + const that = this + setTimeout(() => { + const qrcode = that.shadowRoot!.querySelector('#qrcode'); + const { clear } = useCreateLoginQRCode(qrcode as HTMLCanvasElement); + that.#clearTimer = clear; + }, 0) + return html` + + ` + } + + 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` + + + + ` + + render(template, this.shadowRoot) + } +} + +customElements.define('kv-login', KvLogin) \ No newline at end of file diff --git a/packages/kv-login/src/pages/kv-message.ts b/packages/kv-login/src/pages/kv-message.ts new file mode 100644 index 0000000..3cbd9f7 --- /dev/null +++ b/packages/kv-login/src/pages/kv-message.ts @@ -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`
` + default: + return '' + } + } + + const template: TemplateResult = html` + + +
+
+ ${getTypeIcon()} +
+
${message}
+ ${closable ? html` + + ` : ''} +
+ ` + + 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 \ No newline at end of file diff --git a/packages/kv-login/vite.config.ts b/packages/kv-login/vite.config.ts new file mode 100644 index 0000000..c56a679 --- /dev/null +++ b/packages/kv-login/vite.config.ts @@ -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, + } + } + } +}); diff --git a/packages/user-login/src/user/login/modules/WeChatMpLogin.tsx b/packages/user-login/src/user/login/modules/WeChatMpLogin.tsx index 96adb14..265c625 100644 --- a/packages/user-login/src/user/login/modules/WeChatMpLogin.tsx +++ b/packages/user-login/src/user/login/modules/WeChatMpLogin.tsx @@ -15,7 +15,6 @@ const useCreateLoginQRCode = (config: Config) => { margin: 1, width: 300, }; - const [state, setState] = useState(''); let timer = useRef(null); const loginUrl = config?.wxmpLogin?.loginUrl || ''; if (!loginUrl) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d99ce96..52e73ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/tsconfig.json b/tsconfig.json index 9793a93..cf5ab24 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "@kevisual/types/json/frontend.json", "compilerOptions": { + "module": "esnext", + "target": "esnext", "baseUrl": ".", "paths": { "@/*": [