抽离登陆模块
This commit is contained in:
commit
9e6649afa0
12
.devcontainer/devcontainer.json
Normal file
12
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "kevisual dev",
|
||||||
|
"image": "docker.cnb.cool/kevisual/dev-env:latest",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"dbaeumer.vscode-eslint"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postCreateCommand": ""
|
||||||
|
}
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
4
README.md
Normal file
4
README.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# app-template
|
||||||
|
|
||||||
|
|
||||||
|
`/system/lib/app.js` 包函的模块是 `QueryRouterServer` 和 `Page` 和 `useConfigKey`
|
11
config/esbuild.config.mjs
Normal file
11
config/esbuild.config.mjs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { build } from 'esbuild';
|
||||||
|
|
||||||
|
build({
|
||||||
|
entryPoints: ['src/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
outfile: 'dist/index.js',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'esnext',
|
||||||
|
sourcemap: false,
|
||||||
|
format: 'esm',
|
||||||
|
}).catch(() => process.exit(1));
|
33
index.html
Normal file
33
index.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI Apps</title>
|
||||||
|
<link rel="stylesheet" href="./src/assets/index.css">
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
#ai-root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
<script src="./src/main.tsx" type="module"></script>
|
||||||
|
|
||||||
|
</html>
|
54
package.json
Normal file
54
package.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@kevisual/kevisual-login",
|
||||||
|
"version": "0.0.4",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"basename": "/user/login",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:web": "cross-env WEB_DEV=true vite --mode web",
|
||||||
|
"build": "vite build",
|
||||||
|
"esbuild": "node esbuild.config.mjs",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"pub": "envision deploy ./dist -k login -v 0.0.4 -u -o user"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.0",
|
||||||
|
"@kevisual/query": "0.0.18",
|
||||||
|
"@kevisual/query-login": "^0.0.6",
|
||||||
|
"@kevisual/system-lib": "^0.0.22",
|
||||||
|
"@kevisual/system-ui": "^0.0.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"zustand": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@kevisual/router": "0.0.20",
|
||||||
|
"@kevisual/store": "0.0.4",
|
||||||
|
"@kevisual/types": "^0.0.10",
|
||||||
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react": "^19.1.4",
|
||||||
|
"@types/react-dom": "^19.1.5",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"esbuild": "^0.25.4",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"tailwindcss": "^4.1.7",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@tailwindcss/oxide",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
2522
pnpm-lock.yaml
generated
Normal file
2522
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/MP_verify_NGWvli5lGpEkByyt.txt
Normal file
1
public/MP_verify_NGWvli5lGpEkByyt.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
NGWvli5lGpEkByyt
|
52
public/config.js
Normal file
52
public/config.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
export const silkyConfig = {
|
||||||
|
captchaAppId: '196626989',
|
||||||
|
wxLogin: {
|
||||||
|
appid: 'wxea912643e8747b44',
|
||||||
|
redirect_uri: 'https://kevisual.silkyai.cn/api/wx/login',
|
||||||
|
},
|
||||||
|
wxmpLogin: {
|
||||||
|
loginUrl: `https://kevisual.xiongxiao.me/root/mini-web/login.html`,
|
||||||
|
},
|
||||||
|
loginWay: ['account', 'wechat', 'phone', 'wechat-mp'],
|
||||||
|
loginSuccess: '/root/center/',
|
||||||
|
loginSuccessIsNew: '/root/center/',
|
||||||
|
beian: '浙ICP备2024137660号-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const redirect = currentUrl.origin + currentUrl.pathname;
|
||||||
|
export const kevisualConfig = {
|
||||||
|
loginWay: ['account', 'wechat'],
|
||||||
|
loginSuccess: '/root/center/',
|
||||||
|
loginSuccessIsNew: '/root/center/',
|
||||||
|
wxLogin: {
|
||||||
|
appid: 'wx9378885c8390e09b',
|
||||||
|
redirect_uri: redirect,
|
||||||
|
},
|
||||||
|
beian: '浙ICP备2025158778号',
|
||||||
|
logo: 'https://kevisual.xiongxiao.me/root/center/panda.png',
|
||||||
|
logoStyle: {
|
||||||
|
borderRadius: '50%',
|
||||||
|
marginTop: '10px',
|
||||||
|
margin: '30px auto',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const kevisualXiongxiaoConfig = {
|
||||||
|
...kevisualConfig,
|
||||||
|
loginWay: ['account', 'wechat-mp'],
|
||||||
|
wxLogin: {
|
||||||
|
appid: 'wxff97d569b1db16b6',
|
||||||
|
redirect_uri: redirect,
|
||||||
|
},
|
||||||
|
wxmpLogin: {
|
||||||
|
loginUrl: `${currentUrl.origin}/root/mini-web/login.html`,
|
||||||
|
},
|
||||||
|
logo: 'https://kevisual.xiongxiao.me/root/center/panda.png',
|
||||||
|
beian: '蜀ICP备16031039号-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const isKevisual = url.hostname === 'kevisual.cn';
|
||||||
|
const isKevisualXiongxiao = url.hostname === 'kevisual.xiongxiao.me';
|
||||||
|
export const config = isKevisual ? kevisualConfig : isKevisualXiongxiao ? kevisualXiongxiaoConfig : silkyConfig;
|
0
src/app.ts
Normal file
0
src/app.ts
Normal file
16
src/assets/index.css
Normal file
16
src/assets/index.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.test-loading {
|
||||||
|
@apply w-20 h-20 bg-gray-300 rounded-full animate-spin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ai-bot-root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: -100px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
BIN
src/assets/logo-baner-h.png
Normal file
BIN
src/assets/logo-baner-h.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
5
src/main.tsx
Normal file
5
src/main.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './user/index.tsx';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
createRoot(document.getElementById('root')!).render(<App />);
|
2
src/modules/basename.ts
Normal file
2
src/modules/basename.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
export const basename = DEV_SERVER ? '/' : BASE_NAME;
|
11
src/modules/beian/beian.css
Normal file
11
src/modules/beian/beian.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.beian2 {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.beiann2 {
|
||||||
|
position: static !important; /* 或者 relative,根据需求修改 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
15
src/modules/beian/beian.tsx
Normal file
15
src/modules/beian/beian.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import './beian.css';
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
export const Beian = (props: Props) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx('beian text-sm w-full flex justify-center text-[#e69c36]', props.className)}>
|
||||||
|
<a href='//beian.miit.gov.cn' target='_blank'>
|
||||||
|
{props.text}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
3
src/modules/message.ts
Normal file
3
src/modules/message.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { message } from '@kevisual/system-ui/dist/message';
|
||||||
|
|
||||||
|
export { message };
|
25
src/modules/query.ts
Normal file
25
src/modules/query.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { QueryClient } from '@kevisual/query';
|
||||||
|
import { QueryLoginBrowser } from '@kevisual/query-login';
|
||||||
|
|
||||||
|
export const query = new QueryClient();
|
||||||
|
|
||||||
|
export const queryLogin = new QueryLoginBrowser({
|
||||||
|
query: query as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
query.after(async (res, ctx) => {
|
||||||
|
if (res.code === 401) {
|
||||||
|
if (query.stop) {
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
success: false,
|
||||||
|
message: '登录已过期.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
query.stop = true;
|
||||||
|
const result = await queryLogin.afterCheck401ToRefreshToken(res, ctx);
|
||||||
|
query.stop = false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
102
src/user/Info.tsx
Normal file
102
src/user/Info.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
import { Layout, MainLayout } from './layout/UserLayout';
|
||||||
|
import { useUserStore } from './store';
|
||||||
|
import { message } from '@/modules/message';
|
||||||
|
export const Info = () => {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<MainLayout className='bg-yellow-50'>
|
||||||
|
<ProfileForm />
|
||||||
|
</MainLayout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const ProfileForm: React.FC = () => {
|
||||||
|
const [nickname, setNickname] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [avatar, setAvatar] = useState<string | null>(null);
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
// 如果文件大于 2MB,提示用户
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
message.error('文件大小不能超过 2MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setAvatar(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// alert(`昵称: ${nickname}, 姓名: ${name}`)
|
||||||
|
userStore.updateUser({
|
||||||
|
nickname,
|
||||||
|
data: {
|
||||||
|
personalname: name,
|
||||||
|
},
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
userStore.getUpdateUser();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (userStore.data) {
|
||||||
|
setNickname(userStore.data.nickname);
|
||||||
|
setName(userStore.data?.data?.personalname);
|
||||||
|
setAvatar(userStore.data.avatar);
|
||||||
|
}
|
||||||
|
}, [userStore.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full h-full mx-auto p-6 rounded-lg'>
|
||||||
|
<h2 className='text-center text-[#F39800] text-2xl font-bold mb-2'>完善个人信息</h2>
|
||||||
|
<p className='text-center text-yellow-400 mb-6'>请设置您的基本信息</p>
|
||||||
|
|
||||||
|
{/* Avatar Section */}
|
||||||
|
<div className='text-center mb-6'>
|
||||||
|
<label className='block'>
|
||||||
|
<div className='w-24 h-24 my-2 mx-auto rounded-full bg-yellow-200 flex items-center justify-center text-4xl text-yellow-500 cursor-pointer'>
|
||||||
|
{avatar ? <img src={avatar} alt='Avatar' className='rounded-full w-full h-full object-cover' /> : <span>👤</span>}
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-gray-500 mt-2'>点击更换头像</p>
|
||||||
|
<input type='file' accept='image/*' className='hidden' onChange={handleAvatarChange} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Fields */}
|
||||||
|
<div className='mb-4'>
|
||||||
|
<label className='block text-[#F39800] mb-2'>昵称 *</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='请设置您的昵称'
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
className='w-full border-[#FBBF24] rounded-lg p-2 border focus:outline-none focus:ring-2 focus:ring-[#F39800]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mb-6'>
|
||||||
|
<label className='block text-[#F39800] mb-2'>姓名 *</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='请输入您的姓名'
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className='w-full border-[#FBBF24] rounded-lg p-2 border focus:outline-none focus:ring-2 focus:ring-[#F39800]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button onClick={handleSubmit} className='w-full py-2 bg-[#F39800] text-white rounded hover:bg-orange-600'>
|
||||||
|
完成设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
13
src/user/index.css
Normal file
13
src/user/index.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
@media (min-width: 1000px) and (max-width: 1440px) {
|
||||||
|
.login-main {
|
||||||
|
/* background-color: red !important; */
|
||||||
|
scale: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1500px) and (max-width: 2000px) {
|
||||||
|
.login-main {
|
||||||
|
/* background-color: red !important; */
|
||||||
|
scale: 1.2;
|
||||||
|
}
|
||||||
|
}
|
6
src/user/index.tsx
Normal file
6
src/user/index.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import './index.css';
|
||||||
|
import { Login } from './login';
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
return <Login />;
|
||||||
|
};
|
81
src/user/layout/UserLayout.tsx
Normal file
81
src/user/layout/UserLayout.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import LogoBannerH from '@/assets/logo-baner-h.png';
|
||||||
|
|
||||||
|
import { Beian } from '@/modules/beian/beian';
|
||||||
|
import { useUserStore } from '../store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
type Props = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
export const Layout = (props: Props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'w-full h-full sm:bg-amber-100 flex sm:items-center sm:justify-center justify-normal items-start relative overflow-hidden',
|
||||||
|
props?.className,
|
||||||
|
)}>
|
||||||
|
<div className='w-full h-full overflow-scroll sm:overflow-hidden sm:scrollbar flex sm:items-center sm:justify-center'>{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainLayout = (props: Props) => {
|
||||||
|
const config = useUserStore((state) => state.config);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'login-main w-[450px] min-h-[660px] bg-white mt-10 sm:mt-0 sm:shadow-lg rounded-md flex items-center flex-col relative ',
|
||||||
|
props?.className,
|
||||||
|
)}>
|
||||||
|
{props.children}
|
||||||
|
<p className='mt-5 text-xs text-center text-[#e69c36]'>
|
||||||
|
登录即表示同意 <span className='font-medium text-[#e69c36]'>服务条款</span> 和 <span className='font-medium text-[#e69c36]'>隐私政策</span>
|
||||||
|
</p>
|
||||||
|
<Beian className=' mb-4' text={config?.beian} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoginWrapper = (props: Props) => {
|
||||||
|
const config = useUserStore((state) => state.config);
|
||||||
|
const loginWay = config?.loginWay || ['account'];
|
||||||
|
const store = useUserStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
return {
|
||||||
|
loginByWechat: state.loginByWechat,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const checkWechat = () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
if (code && state) {
|
||||||
|
store.loginByWechat(code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
checkWechat();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<MainLayout className=''>
|
||||||
|
<div
|
||||||
|
className='mt-4'
|
||||||
|
style={{
|
||||||
|
width: '90px',
|
||||||
|
height: '90px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...config?.logoStyle,
|
||||||
|
}}>
|
||||||
|
<img src={config?.logo || LogoBannerH} />
|
||||||
|
</div>
|
||||||
|
<div className='mt-4 text-[#F39800] font-bold text-xl'>欢迎回来</div>
|
||||||
|
{loginWay.length > 1 && <div className='text-sm text-yellow-400 mt-2'>请选择登陆方式</div>}
|
||||||
|
<div>{props.children}</div>
|
||||||
|
</MainLayout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
331
src/user/login/Login.tsx
Normal file
331
src/user/login/Login.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useUserStore } from '../store';
|
||||||
|
import { setWxerwma, wxId } from '@/wx/ws-login.ts';
|
||||||
|
import { checkCaptcha } from '@/wx/tencent-captcha.ts';
|
||||||
|
import { dynimicLoadTcapTcha } from '@/wx/load-js.ts';
|
||||||
|
import { message } from '@/modules/message';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { WeChatMpLogin } from './modules/WeChatMpLogin';
|
||||||
|
const WeChatLogin: React.FC = () => {
|
||||||
|
const userStore = useUserStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
return { config: state.config! };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setWxerwma({
|
||||||
|
...userStore.config.wxLogin,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div id={wxId} className='max-w-sm mx-auto bg-white rounded-lg text-center'></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VerificationCodeInputProps = {
|
||||||
|
onGetCode: () => void;
|
||||||
|
verificationCode: string;
|
||||||
|
setVerificationCode: (value: string) => void;
|
||||||
|
};
|
||||||
|
const VerificationCodeInput = forwardRef(({ onGetCode, verificationCode, setVerificationCode }: VerificationCodeInputProps, ref) => {
|
||||||
|
// const [verificationCode, setVerificationCode] = useState('')
|
||||||
|
const [isCounting, setIsCounting] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(60);
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
isCounting,
|
||||||
|
setIsCounting,
|
||||||
|
setCountdown,
|
||||||
|
}));
|
||||||
|
const handleGetCode = () => {
|
||||||
|
if (!isCounting) {
|
||||||
|
// setIsCounting(true)
|
||||||
|
// setCountdown(60)
|
||||||
|
onGetCode(); // 调用父组件传入的获取验证码逻辑
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer;
|
||||||
|
if (isCounting) {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
setIsCounting(false);
|
||||||
|
clearInterval(timer);
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isCounting]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mb-4 items-center'>
|
||||||
|
<label className='block text-[#F39800] py-1 mb-1'>验证码</label>
|
||||||
|
<div className='flex'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='border-[#FBBF24] rounded-lg p-2 border focus:outline-none focus:ring-2 focus:ring-[#F39800]'
|
||||||
|
placeholder='请输入验证码'
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`ml-2 px-4 py-2 w-[120px] rounded-md text-white ${isCounting ? 'bg-gray-400 cursor-not-allowed' : 'bg-[#F39800] hover:bg-yellow-400'}`}
|
||||||
|
onClick={handleGetCode}
|
||||||
|
disabled={isCounting}>
|
||||||
|
{isCounting ? `${countdown}s 后重试` : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function PhoneNumberValidation({ phoneNumber, setPhoneNumber }) {
|
||||||
|
// const [phoneNumber, setPhoneNumber] = useState('')
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const validatePhoneNumber = (number) => {
|
||||||
|
// 假设手机号的格式为中国的11位数字
|
||||||
|
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||||
|
if (!phoneRegex.test(number)) {
|
||||||
|
setErrorMessage('请输入有效的手机号');
|
||||||
|
} else {
|
||||||
|
setErrorMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setPhoneNumber(value);
|
||||||
|
validatePhoneNumber(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=''>
|
||||||
|
<label className='block text-[#F39800] py-1 mb-1'>手机号</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className={`w-full border rounded-lg p-2 focus:outline-none focus:ring-2 ${
|
||||||
|
errorMessage ? 'border-red-500 focus:ring-red-500' : 'border-[#FBBF24] focus:ring-[#F39800]'
|
||||||
|
}`}
|
||||||
|
placeholder='请输入手机号'
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
{errorMessage && <p className='text-red-500 text-xs mt-1'>{errorMessage}</p>}
|
||||||
|
{!errorMessage && <p className='text-gray-500 text-xs mt-1 invisible'>请输入11位手机号</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountLogin({ accountName, setAccountName, password, setPassword }) {
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const validateAccountName = (name) => {
|
||||||
|
if (name.length < 3) {
|
||||||
|
setErrorMessage('账户名至少需要3个字符');
|
||||||
|
} else {
|
||||||
|
setErrorMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountChange = (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setAccountName(value);
|
||||||
|
validateAccountName(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<label className='block text-[#F39800] py-1 mb-1'>账户名</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className={`w-full border rounded-lg p-2 focus:outline-none focus:ring-2 ${
|
||||||
|
errorMessage ? 'border-red-500 focus:ring-red-500' : 'border-[#FBBF24] focus:ring-[#F39800]'
|
||||||
|
}`}
|
||||||
|
placeholder='请输入账户名'
|
||||||
|
value={accountName}
|
||||||
|
onChange={handleAccountChange}
|
||||||
|
/>
|
||||||
|
{errorMessage && <p className='text-red-500 text-xs mt-1'>{errorMessage}</p>}
|
||||||
|
{!errorMessage && <p className='text-gray-500 text-xs mt-1 invisible'>账户名至少需要3个字符</p>}
|
||||||
|
|
||||||
|
<label className='block text-[#F39800] py-1 mb-1 mt-2'>密码</label>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
className='w-full border-[#FBBF24] rounded-lg p-2 border focus:outline-none focus:ring-2 focus:ring-[#F39800]'
|
||||||
|
placeholder='请输入密码'
|
||||||
|
value={password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginForm: React.FC = () => {
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState('');
|
||||||
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
|
const [accountName, setAccountName] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'phone' | 'wechat' | 'wechat-mp' | 'account'>('phone');
|
||||||
|
const userStore = useUserStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
return {
|
||||||
|
config: state.config! || {},
|
||||||
|
getCode: state.getCode,
|
||||||
|
login: state.login,
|
||||||
|
loginByAccount: state.loginByAccount,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ref = useRef<any>(null);
|
||||||
|
const handleGetCode = async () => {
|
||||||
|
const loaded = await dynimicLoadTcapTcha();
|
||||||
|
if (!loaded) {
|
||||||
|
message.error('验证码加载失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const captcha = await checkCaptcha(userStore.config.captchaAppId);
|
||||||
|
if (captcha.ret !== 0) {
|
||||||
|
message.error('验证码发送失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.current.setIsCounting(true);
|
||||||
|
ref.current.setCountdown(60);
|
||||||
|
userStore.getCode(phoneNumber, captcha);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
dynimicLoadTcapTcha();
|
||||||
|
if (userStore.config.loginWay?.length > 0) {
|
||||||
|
setActiveTab(userStore.config.loginWay[0]);
|
||||||
|
}
|
||||||
|
}, [userStore.config.loginWay]);
|
||||||
|
const handleLogin = () => {
|
||||||
|
// alert(`登录中:手机号: ${phoneNumber}, 验证码: ${verificationCode}`)
|
||||||
|
userStore.login(phoneNumber, verificationCode);
|
||||||
|
};
|
||||||
|
const inLoginWay = (way: string) => {
|
||||||
|
const loginWay = userStore.config?.loginWay || [];
|
||||||
|
return loginWay.includes(way);
|
||||||
|
};
|
||||||
|
const handleAccountLogin = () => {
|
||||||
|
if (!accountName || !password) {
|
||||||
|
message.error('请输入账户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userStore.loginByAccount(accountName, password);
|
||||||
|
};
|
||||||
|
useListenEnter({ active: activeTab === 'phone', handleLogin });
|
||||||
|
useListenEnter({ active: activeTab === 'account', handleLogin: handleAccountLogin });
|
||||||
|
const tab = useMemo(() => {
|
||||||
|
const phoneCom = (
|
||||||
|
<button
|
||||||
|
key='phone'
|
||||||
|
className={`flex-1 py-2 font-medium ${activeTab === 'phone' ? 'border-[#F39800] text-[#F39800] border-b-2' : ''}`}
|
||||||
|
onClick={() => setActiveTab('phone')}>
|
||||||
|
手机号登录
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const wechatCom = (
|
||||||
|
<button
|
||||||
|
key='wechat'
|
||||||
|
className={`flex-1 py-2 font-medium ${activeTab === 'wechat' ? 'border-[#F39800] text-[#F39800] border-b-2' : ''}`}
|
||||||
|
onClick={() => setActiveTab('wechat')}>
|
||||||
|
微信登录
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const wechatMpCom = (
|
||||||
|
<button
|
||||||
|
key='wechat-mp'
|
||||||
|
className={`flex-1 py-2 font-medium ${activeTab === 'wechat-mp' ? 'border-[#F39800] text-[#F39800] border-b-2' : ''}`}
|
||||||
|
onClick={() => setActiveTab('wechat-mp')}>
|
||||||
|
微信公众号登录
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const accountCom = (
|
||||||
|
<button
|
||||||
|
key='account'
|
||||||
|
className={`flex-1 py-2 font-medium ${activeTab === 'account' ? 'border-[#F39800] text-[#F39800] border-b-2' : ''}`}
|
||||||
|
onClick={() => setActiveTab('account')}>
|
||||||
|
账号登录
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const coms: React.ReactNode[] = [];
|
||||||
|
for (const way of userStore.config.loginWay) {
|
||||||
|
if (way === 'phone') {
|
||||||
|
coms.push(phoneCom);
|
||||||
|
} else if (way === 'wechat') {
|
||||||
|
coms.push(wechatCom);
|
||||||
|
} else if (way === 'account') {
|
||||||
|
coms.push(accountCom);
|
||||||
|
} else if (way === 'wechat-mp') {
|
||||||
|
coms.push(wechatMpCom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coms;
|
||||||
|
}, [userStore.config.loginWay, activeTab]);
|
||||||
|
return (
|
||||||
|
<div className='max-w-sm mx-auto p-6 bg-white rounded-lg flex flex-col items-center justify-center'>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className='flex text-gray-400 min-w-[360px]'>{tab}</div>
|
||||||
|
|
||||||
|
<div className='mt-4 min-h-[300px] w-full relative'>
|
||||||
|
{/* Phone Login Form */}
|
||||||
|
{activeTab === 'phone' && inLoginWay('phone') && (
|
||||||
|
<div className='mt-4 pt-4 '>
|
||||||
|
<PhoneNumberValidation phoneNumber={phoneNumber} setPhoneNumber={setPhoneNumber} />
|
||||||
|
<VerificationCodeInput ref={ref} onGetCode={handleGetCode} verificationCode={verificationCode} setVerificationCode={setVerificationCode} />
|
||||||
|
<button className='w-full mt-3 py-2 bg-[#F39800] text-white rounded-lg hover:bg-yellow-400' onClick={handleLogin}>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WeChat Login Placeholder */}
|
||||||
|
{activeTab === 'wechat' && inLoginWay('wechat') && (
|
||||||
|
<div className='-mt-2 w-[310px] ml-[12px] flex flex-col justify-center text-center text-gray-500 absolute top-0 left-0 z-index-10'>
|
||||||
|
<WeChatLogin />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'wechat-mp' && inLoginWay('wechat-mp') && (
|
||||||
|
<div className='mt-2 w-[310px] ml-[12px] flex flex-col justify-center text-center '>
|
||||||
|
<WeChatMpLogin />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'account' && inLoginWay('account') && (
|
||||||
|
<div className='mt-4 pt-4 w-full '>
|
||||||
|
<AccountLogin accountName={accountName} setAccountName={setAccountName} password={password} setPassword={setPassword} />
|
||||||
|
<button className='w-full mt-3 py-2 bg-[#F39800] text-white rounded-lg hover:bg-yellow-400' onClick={handleAccountLogin}>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
|
|
||||||
|
export const useListenEnter = (opts?: { active: boolean; handleLogin: () => void }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opts?.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handleEnter = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
opts?.handleLogin?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEnter);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleEnter);
|
||||||
|
};
|
||||||
|
}, [opts?.active, opts?.handleLogin]);
|
||||||
|
};
|
49
src/user/login/index.tsx
Normal file
49
src/user/login/index.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
import { Layout, LoginWrapper, MainLayout } from '../layout/UserLayout';
|
||||||
|
import LoginForm from './Login';
|
||||||
|
import { useUserStore } from '../store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export const Login = () => {
|
||||||
|
const userStore = useUserStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
return {
|
||||||
|
config: state.config,
|
||||||
|
setConfig: state.setConfig,
|
||||||
|
loadedConfig: state.loadedConfig,
|
||||||
|
setLoadedConfig: state.setLoadedConfig,
|
||||||
|
queryCheck: state.queryCheck,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
userStore.queryCheck();
|
||||||
|
}, []);
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('./config.js');
|
||||||
|
const configScript = await res.text();
|
||||||
|
const blob = new Blob([configScript], { type: 'application/javascript' });
|
||||||
|
const moduleUrl = URL.createObjectURL(blob);
|
||||||
|
const module = await import(/* @vite-ignore */ moduleUrl);
|
||||||
|
URL.revokeObjectURL(moduleUrl); // Clean up the object URL
|
||||||
|
userStore.setConfig(module.config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div></div>}>
|
||||||
|
{userStore.loadedConfig ? (
|
||||||
|
<LoginWrapper>
|
||||||
|
<LoginForm />
|
||||||
|
</LoginWrapper>
|
||||||
|
) : (
|
||||||
|
<div>Loading...</div>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
80
src/user/login/modules/WeChatMpLogin.tsx
Normal file
80
src/user/login/modules/WeChatMpLogin.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { useUserStore, Config } from '@/user/store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import QRCode, { QRCodeToDataURLOptions } from 'qrcode';
|
||||||
|
import { message } from '@/modules/message';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { query, queryLogin } from '@/modules/query';
|
||||||
|
const useCreateLoginQRCode = (config: Config) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const redirect = url.searchParams.get('redirect');
|
||||||
|
const loginSuccessUrl = config.loginSuccess;
|
||||||
|
const redirectURL = redirect ? decodeURIComponent(redirect) : loginSuccessUrl;
|
||||||
|
var opts: QRCodeToDataURLOptions = {
|
||||||
|
errorCorrectionLevel: 'H',
|
||||||
|
type: 'image/jpeg',
|
||||||
|
margin: 1,
|
||||||
|
width: 300,
|
||||||
|
};
|
||||||
|
const [state, setState] = useState('');
|
||||||
|
let timer = useRef<any>(null);
|
||||||
|
const loginUrl = config?.wxmpLogin?.loginUrl || '';
|
||||||
|
if (!loginUrl) {
|
||||||
|
message.error('没有配置微信登陆配置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const createQrcode = async (state: string) => {
|
||||||
|
const text = `${loginUrl}?state=${state}`;
|
||||||
|
var img = document.getElementById('qrcode')! as HTMLCanvasElement;
|
||||||
|
// img.src = url;
|
||||||
|
const res = await QRCode.toDataURL(img, text, 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 queryLogin.setLoginToken(token);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = redirectURL;
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
checkLogin(state);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
// 随机生成一个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.current); // 清除定时器
|
||||||
|
createQrcode(state); // 90秒后更新二维码
|
||||||
|
checkLogin(state);
|
||||||
|
}, 90000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer.current);
|
||||||
|
clearInterval(timer2);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return { createQrcode };
|
||||||
|
};
|
||||||
|
export const WeChatMpLogin = () => {
|
||||||
|
const userStore = useUserStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
return { config: state.config! };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
useCreateLoginQRCode(userStore.config);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<canvas id='qrcode' width='300' height='300'></canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
21
src/user/module/load-js.ts
Normal file
21
src/user/module/load-js.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
209
src/user/store/index.ts
Normal file
209
src/user/store/index.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { query, queryLogin } from '@/modules/query';
|
||||||
|
import { TencentCaptcha } from '@/wx/tencent-captcha.ts';
|
||||||
|
import { message } from '@/modules/message';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
loginWay: any[];
|
||||||
|
wxLogin: {
|
||||||
|
appid: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
};
|
||||||
|
wxmpLogin: {
|
||||||
|
loginUrl?: string; // 微信公众号的网页授权登陆
|
||||||
|
appid?: string; // 微信公众号的appid
|
||||||
|
redirect_uri?: string; // 微信公众号的网页授权登陆
|
||||||
|
};
|
||||||
|
captchaAppId: string;
|
||||||
|
loginSuccess: string;
|
||||||
|
loginSuccessIsNew: string;
|
||||||
|
logo: string;
|
||||||
|
logoStyle: {
|
||||||
|
borderRadius: string;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
};
|
||||||
|
beian: string;
|
||||||
|
};
|
||||||
|
export const redirectToSuccess = async (config: Config) => {
|
||||||
|
const href = location.href;
|
||||||
|
const url = new URL(href);
|
||||||
|
const redirect = url.searchParams.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
const href = decodeURIComponent(redirect);
|
||||||
|
window.open(href, '_self');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(true);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
if (config?.loginSuccess) {
|
||||||
|
location.href = config?.loginSuccess;
|
||||||
|
} else {
|
||||||
|
location.href = '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
type UserStore = {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
qrCodeUrl: string;
|
||||||
|
checkAuthStatus: () => void;
|
||||||
|
getCode: (phone: string, captcha: TencentCaptcha) => void;
|
||||||
|
/**
|
||||||
|
* 手机号登录
|
||||||
|
* @param phone 手机号
|
||||||
|
* @param code 验证码
|
||||||
|
*/
|
||||||
|
login: (phone: string, code: string) => void;
|
||||||
|
updateUser: (data: any, opts?: { needRedirect?: boolean }) => void;
|
||||||
|
getUpdateUser: () => void;
|
||||||
|
data: any;
|
||||||
|
setData: (data: any) => void;
|
||||||
|
config: Config | null;
|
||||||
|
setConfig: (config: any) => void;
|
||||||
|
loadedConfig: boolean;
|
||||||
|
setLoadedConfig: (loadedConfig: boolean) => void;
|
||||||
|
/**
|
||||||
|
* 账号密码登录
|
||||||
|
* @param username 账号
|
||||||
|
* @param password 密码
|
||||||
|
*/
|
||||||
|
loginByAccount: (username: string, password: string) => void;
|
||||||
|
/**
|
||||||
|
* 检查是否需要跳转, 插件登陆
|
||||||
|
*/
|
||||||
|
queryCheck: () => void;
|
||||||
|
loginByWechat: (code: string) => void;
|
||||||
|
};
|
||||||
|
export const useUserStore = create<UserStore>((set, get) => ({
|
||||||
|
isAuthenticated: false,
|
||||||
|
qrCodeUrl: '',
|
||||||
|
checkAuthStatus: () => {
|
||||||
|
//
|
||||||
|
},
|
||||||
|
getCode: async (phone, captcha) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'sms',
|
||||||
|
key: 'send',
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
captcha,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
// do something
|
||||||
|
message.success('验证码发送成功');
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '验证码发送失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
login: async (phone, code) => {
|
||||||
|
const config = get().config!;
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'sms',
|
||||||
|
key: 'login',
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('登录成功');
|
||||||
|
set({ isAuthenticated: true });
|
||||||
|
redirectToSuccess(config);
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '登录失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateUser: async (data, opts) => {
|
||||||
|
const config = get().config!;
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'updateInfo',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('更新成功');
|
||||||
|
if (opts?.needRedirect) {
|
||||||
|
setTimeout(() => {
|
||||||
|
location.href = config?.loginSuccess;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '更新失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getUpdateUser: async () => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'getUpdateInfo',
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ data: res.data });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '获取用户信息失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
setData: (data) => set({ data }),
|
||||||
|
loadedConfig: false,
|
||||||
|
setLoadedConfig: (loadedConfig) => set({ loadedConfig }),
|
||||||
|
config: null,
|
||||||
|
setConfig: (config) => set({ config, loadedConfig: true }),
|
||||||
|
loginByAccount: async (username, password) => {
|
||||||
|
const config = get().config!;
|
||||||
|
const isEmail = username.includes('@');
|
||||||
|
const data: any = { password };
|
||||||
|
if (isEmail) {
|
||||||
|
data.email = username;
|
||||||
|
} else {
|
||||||
|
data.username = username;
|
||||||
|
}
|
||||||
|
const res = await queryLogin.login(data);
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('登录成功');
|
||||||
|
set({ isAuthenticated: true });
|
||||||
|
redirectToSuccess(config);
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '登录失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
queryCheck: async () => {
|
||||||
|
// const
|
||||||
|
const userCheck = 'user-check';
|
||||||
|
const url = new URL(location.href);
|
||||||
|
const redirect = url.searchParams.get('redirect');
|
||||||
|
const redirectUrl = redirect ? decodeURIComponent(redirect) : '';
|
||||||
|
const checkKey = url.searchParams.get(userCheck);
|
||||||
|
if (redirect && checkKey) {
|
||||||
|
// 通过refresh_token 刷新token
|
||||||
|
const me = await queryLogin.getMe();
|
||||||
|
if (me.code === 200) {
|
||||||
|
message.success('登录插件中...');
|
||||||
|
const token = await queryLogin.cacheStore.getAccessToken();
|
||||||
|
const newRedirectUrl = new URL(redirectUrl);
|
||||||
|
newRedirectUrl.searchParams.set('token', token + '');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.open(newRedirectUrl.toString(), '_blank');
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 刷新token失败,登陆页自己跳转
|
||||||
|
}
|
||||||
|
console.log('checkKey', checkKey, redirectUrl);
|
||||||
|
},
|
||||||
|
loginByWechat: async (code) => {
|
||||||
|
const config = get().config!;
|
||||||
|
if (!code) {
|
||||||
|
message.error('code is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await queryLogin.loginByWechat({ code });
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('登录成功');
|
||||||
|
redirectToSuccess(config);
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '登录失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
type SimpleObject = {
|
||||||
|
[key: string | number]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare let DEV_SERVER: boolean;
|
||||||
|
declare let BASE_NAME: string;
|
21
src/wx/load-js.ts
Normal file
21
src/wx/load-js.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
70
src/wx/tencent-captcha.ts
Normal file
70
src/wx/tencent-captcha.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// 定义回调函数
|
||||||
|
export function callback(res) {
|
||||||
|
// 第一个参数传入回调结果,结果如下:
|
||||||
|
// 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
44
src/wx/ws-login.ts
Normal file
44
src/wx/ws-login.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 = new URL(window.location.href).origin + '/api/s1/wx/login';
|
||||||
|
}
|
||||||
|
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, // 是否使用简洁模式
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"typeRoots": [
|
||||||
|
"node_modules/@types",
|
||||||
|
"node_modules/@kevisual/types",
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
"typings.d.ts",
|
||||||
|
"snippets"
|
||||||
|
]
|
||||||
|
}
|
50
vite.config.mjs
Normal file
50
vite.config.mjs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import basicSsl from '@vitejs/plugin-basic-ssl';
|
||||||
|
// import react from '@vitejs/plugin-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import path from 'path';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import pkgs from './package.json' with { type: 'json' };
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const isWebDev = process.env.WEB_DEV === 'true';
|
||||||
|
const BUILD_TIME = dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
const basename = pkgs.basename;
|
||||||
|
let plugins = [tailwindcss()];
|
||||||
|
|
||||||
|
if (!isWebDev) {
|
||||||
|
// 在bolt的web开发环境下不需要ssl
|
||||||
|
plugins.push(basicSsl());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: plugins,
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: isDev ? '/' : basename,
|
||||||
|
define: {
|
||||||
|
DEV_SERVER: JSON.stringify(isDev),
|
||||||
|
BUILD_TIME: JSON.stringify(BUILD_TIME),
|
||||||
|
BASE_NAME: JSON.stringify(basename),
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// exclude: ['react'], // 排除 react 和 react-dom 以避免打包
|
||||||
|
},
|
||||||
|
// esbuild: {
|
||||||
|
// jsxFactory: 'h',
|
||||||
|
// jsxFragment: 'Fragment',
|
||||||
|
// },
|
||||||
|
server: {
|
||||||
|
port: 6025,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://kevisual.xiongxiao.me',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user