抽离登陆模块
This commit is contained in:
		
							
								
								
									
										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,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
		Reference in New Issue
	
	Block a user