Files
kevisual-login/packages/kv-login/src/pages/kv-login.ts
2025-12-28 16:31:57 +08:00

590 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { render, html } from 'lit-html'
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'
import { loginHandle, checkWechat, getQrCode, checkMpQrCodeLogin, redirectHome } from '../modules/login-handle.ts'
import { setWxerwma } from '../modules/wx/ws-login.ts';
import { useCreateLoginQRCode } from '../modules/wx-mp/qr.ts';
import { eventEmitter } from '../modules/mitt.ts';
import { useContextKey } from '@kevisual/context'
export const loginEmitter = useContextKey('login-emitter', eventEmitter);
export const WX_MP_APP_ID = "wxff97d569b1db16b6";
interface LoginMethod {
id: LoginMethods
name: string
icon: any
appid?: string
}
const wxmpSvg = `<svg t="1764510467010" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1958" width="32" height="32"><path d="M615.904 388.48c8.8 0 17.536 0.64 26.176 1.6-23.52-109.536-140.608-190.912-274.272-190.912C218.4 199.2 96 301.056 96 430.4c0 74.656 40.736 135.936 108.768 183.488l-27.2 81.792 95.04-47.648c33.984 6.72 61.28 13.632 95.2 13.632 8.544 0 16.992-0.416 25.376-1.088a202.496 202.496 0 0 1-8.384-56.96c0-118.752 101.984-215.136 231.104-215.136zM469.76 314.784c20.48 0 34.016 13.472 34.016 33.92 0 20.352-13.536 34.016-34.016 34.016-20.384 0-40.832-13.664-40.832-34.016 0-20.448 20.448-33.92 40.832-33.92zM279.52 382.72c-20.384 0-40.928-13.664-40.928-34.016 0-20.448 20.544-33.92 40.928-33.92 20.352 0 33.92 13.472 33.92 33.92 0 20.384-13.568 34.016-33.92 34.016z" fill="" p-id="1959"></path><path d="M864 600.352c0-108.672-108.736-197.28-230.88-197.28-129.344 0-231.2 88.576-231.2 197.28 0 108.864 101.856 197.248 231.2 197.248 27.072 0 54.368-6.816 81.568-13.632l74.56 40.8-20.448-67.904C823.328 715.936 864 661.664 864 600.352z m-305.856-34.016c-13.536 0-27.2-13.44-27.2-27.2 0-13.568 13.664-27.2 27.2-27.2 20.576 0 34.016 13.632 34.016 27.2 0 13.76-13.44 27.2-34.016 27.2z m149.536 0c-13.44 0-27.008-13.44-27.008-27.2 0-13.568 13.568-27.2 27.008-27.2 20.352 0 34.016 13.632 34.016 27.2 0 13.76-13.664 27.2-34.016 27.2z" fill="" p-id="1960"></path></svg>`
const wxOpenSvg = `<svg t="1764511395617" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3882" width="32" height="32"><path d="M256 259.584c-29.184 0-51.2 14.848-51.2 44.032s29.184 44.032 51.2 44.032c29.184 0 44.032-14.848 44.032-44.032s-22.016-44.032-44.032-44.032zM541.184 303.616c0-29.184-14.848-44.032-44.032-44.032-29.184 0-51.2 14.848-51.2 44.032s29.184 44.032 51.2 44.032c29.696 0 44.032-22.016 44.032-44.032zM614.4 508.416c-14.848 0-36.352 14.848-36.352 36.352 0 14.848 14.848 36.352 36.352 36.352 29.184 0 44.032-14.848 44.032-36.352 0-14.336-14.848-36.352-44.032-36.352z" p-id="3883"></path><path d="M1024 625.152c0-138.752-124.416-256-285.184-270.848-29.184-153.6-189.952-263.168-373.248-263.168C160.768 91.648 0 230.4 0 406.016c0 95.232 44.032 175.616 138.752 241.152L109.568 742.4c0 7.168 0 14.848 7.168 22.016h14.848l117.248-58.368h14.848c36.352 7.168 66.048 14.848 109.568 14.848 14.848 0 44.032-7.168 44.032-7.168C460.8 822.784 578.048 896 716.8 896c36.352 0 73.216-7.168 102.4-14.848l87.552 51.2h14.848c7.168-7.168 7.168-7.168 7.168-14.848l-22.016-87.552c80.896-58.368 117.248-131.584 117.248-204.8z m-621.568 51.2h-36.352c-36.352 0-66.048-7.168-95.232-14.848l-22.016-7.168h-7.168L153.6 698.368l22.016-66.048c0-7.168 0-14.848-7.168-14.848C80.384 559.616 36.352 486.4 36.352 398.848 36.352 245.248 182.784 128 358.4 128c160.768 0 300.032 95.232 329.216 226.816-168.448 0-300.032 117.248-300.032 263.168 7.168 22.016 14.848 44.032 14.848 58.368z m467.968 132.096c-7.168 7.168-7.168 7.168-7.168 14.848l14.848 51.2L819.2 844.8h-14.848c-29.184 7.168-66.048 14.848-95.232 14.848-146.432 0-270.848-102.4-270.848-226.816 0-131.584 124.416-233.984 270.848-233.984s270.848 102.4 270.848 226.816c0 65.536-36.352 123.904-109.568 182.784z" p-id="3884"></path><path d="M804.352 508.416c-14.848 0-36.352 14.848-36.352 36.352 0 14.848 14.848 36.352 36.352 36.352 29.184 0 44.032-14.848 44.032-36.352 0-14.336-14.336-36.352-44.032-36.352z" p-id="3885"></path></svg>`
const phone = `<svg t="1764511425462" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5097" width="32" height="32"><path d="M820.409449 797.228346q0 25.19685-10.07874 46.866142t-27.716535 38.299213-41.322835 26.204724-50.897638 9.574803l-357.795276 0q-27.212598 0-50.897638-9.574803t-41.322835-26.204724-27.716535-38.299213-10.07874-46.866142l0-675.275591q0-25.19685 10.07874-47.370079t27.716535-38.80315 41.322835-26.204724 50.897638-9.574803l357.795276 0q27.212598 0 50.897638 9.574803t41.322835 26.204724 27.716535 38.80315 10.07874 47.370079l0 675.275591zM738.771654 170.330709l-455.559055 0 0 577.511811 455.559055 0 0-577.511811zM510.992126 776.062992q-21.165354 0-36.787402 15.11811t-15.622047 37.291339q0 21.165354 15.622047 36.787402t36.787402 15.622047q22.173228 0 37.291339-15.622047t15.11811-36.787402q0-22.173228-15.11811-37.291339t-37.291339-15.11811zM591.622047 84.661417q0-8.062992-5.03937-12.598425t-11.086614-4.535433l-128 0q-5.03937 0-10.582677 4.535433t-5.543307 12.598425 5.03937 12.598425 11.086614 4.535433l128 0q6.047244 0 11.086614-4.535433t5.03937-12.598425z" p-id="5098"></path></svg>`
const pwd = `<svg t="1764511500570" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10511" width="32" height="32"><path d="M768.9216 422.72768 372.06016 422.72768C378.88 365.21984 329.37984 131.42016 512.2048 125.72672c173.83424-6.59456 146.78016 213.34016 146.78016 213.34016l85.13536 0.57344c0 0 24.73984-294.4-231.91552-295.8336C232.09984 58.01984 297.82016 377.18016 289.28 422.72768c1.98656 0 4.56704 0 7.29088 0-55.88992 0-101.21216 45.34272-101.21216 101.21216l0 337.38752c0 55.88992 45.34272 101.21216 101.21216 101.21216l472.35072 0c55.88992 0 101.21216-45.34272 101.21216-101.21216L870.13376 523.93984C870.13376 468.0704 824.79104 422.72768 768.9216 422.72768zM566.4768 717.02528l0 76.84096c0 18.57536-15.1552 33.73056-33.73056 33.73056-18.57536 0-33.73056-15.1552-33.73056-33.73056l0-76.84096c-20.09088-11.69408-33.73056-33.21856-33.73056-58.12224 0-37.2736 30.208-67.4816 67.4816-67.4816 37.2736 0 67.4816 30.208 67.4816 67.4816C600.22784 683.80672 586.58816 705.3312 566.4768 717.02528z" fill="#272636" p-id="10512"></path></svg>`
const web = `<svg t="1764511538113" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11994" width="32" height="32"><path d="M512 85.333333C264.533333 85.333333 64 285.866667 64 533.333333s200.533333 448 448 448 448-200.533333 448-448S759.466667 85.333333 512 85.333333z m0 810.666667c-200.533333 0-362.666667-162.133333-362.666667-362.666667S311.466667 170.666667 512 170.666667s362.666667 162.133333 362.666667 362.666667-162.133333 362.666667-362.666667 362.666667z" p-id="11995"></path><path d="M512 298.666667c-119.466667 0-216.533333 97.066667-216.533333 216.533333s97.066667 216.533333 216.533333 216.533333 216.533333-97.066667 216.533333-216.533333-97.066667-216.533333-216.533333-216.533333z m0 362.666666c-80.853333 0-146.133333-65.28-146.133333-146.133333s65.28-146.133333 146.133333-146.133333 146.133333 65.28 146.133333 146.133333-65.28 146.133333-146.133333 146.133333z" p-id="11996"></path></svg>`
const icons: any = {
pwd,
web,
phone,
wxmpSvg,
wxOpenSvg
}
const DefaultLoginMethods: LoginMethod[] = [
{ id: 'password', name: '密码登录', icon: 'pwd' },
{ id: 'web', name: '网页登录', icon: 'web' },
{ id: 'wechat', name: '微信登录', icon: 'wxmpSvg', appid: "wx9378885c8390e09b" },
{ id: 'wechat-mp', name: '微信公众号', icon: 'wxOpenSvg', appid: WX_MP_APP_ID },
{ id: 'wechat-mp-ticket', name: '微信公众号', icon: 'wxOpenSvg' },
{ id: 'phone', name: '手机号登录', icon: 'phone' }
]
const LoginMethods = ['password', 'web', 'phone', 'wechat', 'wechat-mp', 'wechat-mp-ticket'] as const;
type LoginMethods = 'password' | 'web' | 'phone' | 'wechat' | 'wechat-mp' | 'wechat-mp-ticket';
const getLoginMethodByDomain = (): LoginMethod[] => {
let domain = window.location.host
let methods: LoginMethods[] = []
const has51 = domain.includes('localhost') && (domain.endsWith('51515') || domain.endsWith('51015'));
if (has51) {
domain = 'localhost'
}
switch (domain) {
case 'kevisual.xiongxiao.me':
methods = ['password', 'wechat-mp']
break;
case 'kevisual.cn':
methods = ['password', 'wechat-mp-ticket', 'wechat',]
break;
case 'localhost':
methods = ['password', 'web']
break
default:
methods = ['password', 'web', 'phone', 'wechat', 'wechat-mp', 'wechat-mp-ticket']
break;
}
return DefaultLoginMethods.filter(method => methods.includes(method.id))
}
const getLoginMethod = (methods: LoginMethods[]): LoginMethod[] => {
return DefaultLoginMethods.filter(method => methods.includes(method.id))
}
class KvLogin extends HTMLElement {
private selectedMethod: LoginMethods = 'password'
private loginMethods: LoginMethod[] = getLoginMethodByDomain();
setLoginMethods(methods: LoginMethod[]) {
this.loginMethods = methods
this.render()
}
constructor() {
super()
}
connectedCallback() {
this.attachShadow({ mode: 'open' })
this.render()
this.bindEvents()
checkWechat()
const method = this.getAttribute('method');
if (method) {
const methods = method ? method.split(',') as LoginMethods[] : [];
if (methods.length > 0) {
const loginMethods = methods.filter(m => LoginMethods.includes(m));
if (loginMethods.length > 0) {
this.loginMethods = getLoginMethod(loginMethods)
this.selectedMethod = loginMethods[0]
return;
}
}
this.loginMethods = getLoginMethodByDomain();
this.selectedMethod = this.loginMethods[0].id;
}
}
#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()
}
})
loginEmitter.on('login-success', () => {
console.log('收到登录成功事件,处理后续逻辑')
});
}
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 'web':
return {}
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 renderWebForm() {
return html`
<div class="web-login">
<button type="button" class="refresh-button" @click=${this.handleLogin.bind(this)}>点击登录</button>
<slot></slot>
</div>
`
}
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 renderWechatMpTicketForm() {
const that = this;
setTimeout(async () => {
const data = await getQrCode();
if (!data) return;
const imgEl = that.shadowRoot!.querySelector('.qrcode') as HTMLImageElement;
if (data.url) {
imgEl.src = data.url;
// TODO: 轮询检测登录状态
const clear = checkMpQrCodeLogin(data.ticket)
// 当切换登录方式时,停止轮询
that.#clearTimer = clear
}
}, 0)
return html`
<div class="wechat-login">
<div class="qr-container">
<div class="qr-placeholder">
<img class="qrcode" width="300" height="300" data-appid="" data-size="200" data-ticket=""></img>
<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 'web':
return this.renderWebForm()
case 'phone':
return this.renderPhoneForm()
case 'wechat':
setWxerwma({ appid: data?.appid! || "" });
return this.renderWechatForm()
case 'wechat-mp':
return this.renderWechatMpForm()
case 'wechat-mp-ticket':
return this.renderWechatMpTicketForm()
default:
return this.renderPasswordForm()
}
}
render() {
if (!this.shadowRoot) return
const renderIcon = (icon: any) => {
// 如果是emoji字符直接返回
if (typeof icon === 'string' && !icons[icon]) {
return html`<span class="method-icon-emoji">${icon}</span>`
}
// 如果是SVG引用从icons对象获取
if (typeof icon === 'string' && icons[icon]) {
return html`<span class="method-icon-svg">${unsafeHTML(icons[icon])}</span>`
}
// 如果直接是SVG内容
if (typeof icon === 'string' && (icon.includes('<svg') || icon.includes('<?xml'))) {
return html`<span class="method-icon-svg">${unsafeHTML(icon)}</span>`
}
// 默认情况
return html`<span class="method-icon-emoji">${icon}</span>`
}
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: #f5f5f5;
border-bottom: 1px solid #000000;
}
.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: #d0d0d0;
}
.login-method.active {
background: white;
color: #000000;
}
.login-method.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: #000000;
}
.method-icon {
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.method-icon-emoji {
font-size: 20px;
line-height: 1;
}
.method-icon-svg {
display: flex;
align-items: center;
justify-content: center;
}
.method-icon-svg svg {
width: 32px;
height: 32px;
display: block;
}
.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 #cccccc;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #000000;
}
.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: #000000;
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: #333333;
}
.wechat-login {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.qr-container {
width: 340px;
height: 340px;
border: 2px solid #000000;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-placeholder {
text-align: center;
color: #333333;
}
.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;
}
.method-icon svg {
width: 24px;
height: 24px;
}
</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}"
>
${renderIcon(method.icon)}
<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)