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": {
"@/*": [