Compare commits
17 Commits
84775dd878
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23fdbf2105 | |||
| dfae42065a | |||
| 271cc06e41 | |||
| d828a491ee | |||
| 4ff8f7a119 | |||
| 17f1e0d45d | |||
| 593e5dd670 | |||
| b256ef20bf | |||
| 3654746f6a | |||
| 8a5e707fd8 | |||
| 7c9072c594 | |||
| 78927afd7a | |||
| bc2298ff83 | |||
| 47ce1962c0 | |||
| 35d7272872 | |||
| d217c8cec1 | |||
| a51a366f00 |
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=https://localhost:51515
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ node_modules
|
|||||||
.astro
|
.astro
|
||||||
|
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
.env
|
||||||
|
!.env*example
|
||||||
@@ -2,17 +2,14 @@ import { defineConfig } from 'astro/config';
|
|||||||
import mdx from '@astrojs/mdx';
|
import mdx from '@astrojs/mdx';
|
||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
import baseSSL from '@vitejs/plugin-basic-ssl';
|
|
||||||
import pkgs from './package.json';
|
import pkgs from './package.json';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
let target = process.env.VITE_API_URL || 'http://localhost:51015';
|
let target = process.env.VITE_API_URL || 'http://localhost:51515';
|
||||||
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
||||||
let proxy = {
|
let proxy = {
|
||||||
'/root/': {
|
'/root/': apiProxy,
|
||||||
target: `${target}/root/`,
|
|
||||||
},
|
|
||||||
'/api': apiProxy,
|
'/api': apiProxy,
|
||||||
'/client': apiProxy,
|
'/client': apiProxy,
|
||||||
};
|
};
|
||||||
@@ -25,14 +22,15 @@ export default defineConfig({
|
|||||||
react(), //
|
react(), //
|
||||||
// sitemap(), // sitemap must be site has a domain
|
// sitemap(), // sitemap must be site has a domain
|
||||||
],
|
],
|
||||||
|
server: {
|
||||||
|
port: 7008,
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
define: {
|
define: {
|
||||||
basename: JSON.stringify(basename || ''),
|
BASE_NAME: JSON.stringify(basename || ''),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 7008,
|
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
proxy,
|
proxy,
|
||||||
|
|||||||
53
package.json
53
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevisual/kevisual-home",
|
"name": "@kevisual/kevisual-home",
|
||||||
"version": "0.0.5",
|
"version": "0.0.9",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"basename": "/root/home",
|
"basename": "/root/home",
|
||||||
@@ -9,53 +9,58 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"ui": "pnpm dlx shadcn@latest add ",
|
"ui": "pnpm dlx shadcn@latest add ",
|
||||||
"pub": "envision deploy ./dist -k home -v 0.0.5 -u"
|
"prepub": "pnpm run build",
|
||||||
|
"pub": "envision deploy ./dist -k home -v 0.0.9 -u -y yes"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.10",
|
"@ant-design/x": "^2.2.1",
|
||||||
|
"@astrojs/mdx": "^4.3.13",
|
||||||
"@astrojs/react": "^4.4.2",
|
"@astrojs/react": "^4.4.2",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.7.0",
|
||||||
"@floating-ui/dom": "^1.7.4",
|
"@floating-ui/dom": "^1.7.5",
|
||||||
|
"@kevisual/ai": "^0.0.24",
|
||||||
"@kevisual/context": "^0.0.4",
|
"@kevisual/context": "^0.0.4",
|
||||||
"@kevisual/kv-login": "^0.0.1",
|
"@kevisual/kv-login": "^0.1.4",
|
||||||
"@kevisual/query": "0.0.29",
|
"@kevisual/query": "0.0.39",
|
||||||
"@kevisual/query-login": "^0.0.6",
|
"@kevisual/query-login": "^0.0.7",
|
||||||
"@kevisual/registry": "^0.0.1",
|
"@kevisual/registry": "^0.0.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "^5.15.8",
|
"astro": "^5.17.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"es-toolkit": "^1.42.0",
|
"es-toolkit": "^1.44.0",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.9.0",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.563.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kevisual/router": "0.0.33",
|
"@kevisual/router": "0.0.70",
|
||||||
"@kevisual/store": "0.0.9",
|
"@kevisual/store": "0.0.9",
|
||||||
"@kevisual/types": "^0.0.10",
|
"@kevisual/types": "^0.0.12",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.11",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
"@vitejs/plugin-basic-ssl": "^2.1.4",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vite": "^7.2.2"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
|
||||||
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>KvMessage Demo</title>
|
|
||||||
<script type="module" src="./src/main.ts"></script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-button {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-button.success {
|
|
||||||
background: #52c41a;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-button.success:hover {
|
|
||||||
background: #389e0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-button.error {
|
|
||||||
background: #ff4d4f;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-button.error:hover {
|
|
||||||
background: #cf1322;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-button.loading {
|
|
||||||
background: #1890ff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-button.loading:hover {
|
|
||||||
background: #096dd9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-section {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="demo-container">
|
|
||||||
<div class="login-section">
|
|
||||||
<h2>登录组件</h2>
|
|
||||||
<kv-login id="loginComponent">
|
|
||||||
<div id="weixinLogin"></div>
|
|
||||||
</kv-login>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@kevisual/kv-login",
|
|
||||||
"version": "0.0.3",
|
|
||||||
"description": "",
|
|
||||||
"main": "src/main.ts",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build --config vite-lib.config.ts",
|
|
||||||
"build:test": "vite build",
|
|
||||||
"prepub": "rm -rf ./dist && pnpm run build:test",
|
|
||||||
"pub": "ev deploy ./dist -k kv-login-test -v 0.0.2 -u -y yes"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
|
||||||
"license": "MIT",
|
|
||||||
"packageManager": "pnpm@10.19.0",
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
},
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@kevisual/query-login": "^0.0.6",
|
|
||||||
"lit-html": "^3.3.1",
|
|
||||||
"qrcode": "^1.5.4"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": "./dist/kv-login.es.js",
|
|
||||||
"./kv-login.es.js": "./dist/kv-login.es.js",
|
|
||||||
"./kv-login.umd.js": "./dist/kv-login.umd.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# 可视化登录组件
|
|
||||||
|
|
||||||
## 主题色
|
|
||||||
|
|
||||||
黑白
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import './pages/kv-login'
|
|
||||||
import './pages/kv-message'
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { query } from './query.ts';
|
|
||||||
import { createMessage } from '../pages/kv-message.ts';
|
|
||||||
import { WX_MP_APP_ID } from '../pages/kv-login.ts';
|
|
||||||
export const message = createMessage();
|
|
||||||
type LoginOpts = {
|
|
||||||
loginMethod: 'password' | 'phone' | 'wechat' | 'wechat-mp' | 'wechat-mp-ticket',
|
|
||||||
data: any,
|
|
||||||
el: HTMLElement
|
|
||||||
}
|
|
||||||
export const redirectHome = () => {
|
|
||||||
console.log('重定向到首页')
|
|
||||||
const href = window.location.href;
|
|
||||||
const url = new URL(href);
|
|
||||||
const redirect = url.searchParams.get('redirect');
|
|
||||||
if (redirect) {
|
|
||||||
const href = decodeURIComponent(redirect);
|
|
||||||
window.open(href, '_self');
|
|
||||||
} else {
|
|
||||||
window.open('/root/home', '_self');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export const loginHandle = async (opts: LoginOpts) => {
|
|
||||||
const { loginMethod, data, el } = opts
|
|
||||||
switch (loginMethod) {
|
|
||||||
case 'password':
|
|
||||||
await loginByPassword(data)
|
|
||||||
break
|
|
||||||
case 'phone':
|
|
||||||
await loginByPhone(data)
|
|
||||||
break
|
|
||||||
case 'wechat-mp':
|
|
||||||
await loginByWeChatMp(data)
|
|
||||||
break
|
|
||||||
case 'wechat':
|
|
||||||
await loginByWeChat(data)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
console.warn('未知的登录方式:', loginMethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 使用用户名和密码登录
|
|
||||||
* @param data
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const loginByPassword = async (data: { username: string, password: string }) => {
|
|
||||||
console.log('使用用户名密码登录:', data)
|
|
||||||
let needLogin = true; // 这里可以根据实际情况决定是否需要登录, 只能判断密码登录和手机号登录
|
|
||||||
|
|
||||||
const isLogin = await query.checkLocalToken()
|
|
||||||
if (isLogin) {
|
|
||||||
const loginUser = await query.checkLocalUser()
|
|
||||||
if (loginUser?.username === data?.username) {
|
|
||||||
const res = await query.getMe()
|
|
||||||
if (res.code === 200) {
|
|
||||||
needLogin = false
|
|
||||||
console.log('已登录,跳过登录步骤')
|
|
||||||
message.success('已登录')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!needLogin) {
|
|
||||||
redirectHome()
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await query.login({
|
|
||||||
username: data.username,
|
|
||||||
password: data.password
|
|
||||||
})
|
|
||||||
if (res.code === 200) {
|
|
||||||
console.log('登录成功')
|
|
||||||
message.success('登录成功')
|
|
||||||
redirectHome()
|
|
||||||
} else {
|
|
||||||
message.error(`登录失败: ${res.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginByPhone = async (data: { phone: string, code: string }) => {
|
|
||||||
console.log('使用手机号登录:', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginByWeChat = async (data: { wechatCode: string }) => {
|
|
||||||
console.log('使用微信登录:', data)
|
|
||||||
}
|
|
||||||
const loginByWeChatMp = async (data: { wechatMpCode: string }) => {
|
|
||||||
console.log('使用微信公众号登录:', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearCode = () => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
// 清理 URL 中的 code 参数
|
|
||||||
url.searchParams.delete('code');
|
|
||||||
url.searchParams.delete('state');
|
|
||||||
window.history.replaceState({}, document.title, url.toString());
|
|
||||||
}
|
|
||||||
export const checkWechat = async () => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
const code = url.searchParams.get('code');
|
|
||||||
const state = url.searchParams.get('state');
|
|
||||||
if (state?.includes?.('-')) {
|
|
||||||
// 公众号登录流程,不在这里处理
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!code) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await query.loginByWechat({ code });
|
|
||||||
if (res.code === 200) {
|
|
||||||
message.success('登录成功');
|
|
||||||
redirectHome();
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '登录失败');
|
|
||||||
clearCode();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkMpWechat = async () => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
const originState = url.searchParams.get('state');
|
|
||||||
const [mpLogin, state] = originState ? originState.split('-') : [null, null];
|
|
||||||
console.log('检查微信公众号登录流程:', mpLogin, state, originState);
|
|
||||||
if (mpLogin === '1') {
|
|
||||||
// 手机端扫描的时候访问的链接,跳转到微信公众号授权页面
|
|
||||||
checkMpWechatInWx()
|
|
||||||
} else if (mpLogin === '2') {
|
|
||||||
const code = url.searchParams.get('code');
|
|
||||||
// 推送登录成功状态到扫码端
|
|
||||||
const res2 = await query.post({
|
|
||||||
path: 'wx',
|
|
||||||
key: 'mplogin',
|
|
||||||
state,
|
|
||||||
code
|
|
||||||
})
|
|
||||||
if (res2.code === 200) {
|
|
||||||
message.success('登录成功');
|
|
||||||
} else {
|
|
||||||
message.error(res2.message || '登录失败');
|
|
||||||
}
|
|
||||||
closePage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isWechat = () => {
|
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
|
||||||
return /micromessenger/i.test(ua);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePage = (time = 2000) => {
|
|
||||||
if (!isWechat()) {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.close();
|
|
||||||
}, time);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
if (window.WeixinJSBridge) {
|
|
||||||
setTimeout(() => {
|
|
||||||
// @ts-ignore
|
|
||||||
window.WeixinJSBridge.call('closeWindow');
|
|
||||||
}, time);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.close();
|
|
||||||
}, time);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const checkMpWechatInWx = async () => {
|
|
||||||
const wxAuthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect`
|
|
||||||
const appid = WX_MP_APP_ID;
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
const originState = url.searchParams.get('state');
|
|
||||||
let [mpLogin, state] = originState ? originState.split('-') : [null, null];
|
|
||||||
|
|
||||||
const redirectURL = new URL(url.pathname, url.origin);
|
|
||||||
state = '2-' + state; // 标记为第二步登录
|
|
||||||
const redirect_uri = encodeURIComponent(redirectURL.toString())
|
|
||||||
document.body.innerHTML = `<p>正在准备跳转到微信公众号授权页面...</p>`;
|
|
||||||
const scope = `snsapi_userinfo`
|
|
||||||
if (!state) {
|
|
||||||
alert('Invalid state. Please try again later.');
|
|
||||||
closePage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const link = wxAuthUrl.replace('APPID', appid).replace('REDIRECT_URI', redirect_uri).replace('SCOPE', scope).replace('STATE', state);
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = link;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
checkMpWechat();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
export const getQrCode = async () => {
|
|
||||||
const res = await query.post({
|
|
||||||
path: 'wx',
|
|
||||||
key: 'get-qrcode-ticket'
|
|
||||||
})
|
|
||||||
if (res.code !== 200) {
|
|
||||||
message.error('获取二维码失败');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return res?.data as { ticket: string, url: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkMpQrCodeLogin = (ticket: string) => {
|
|
||||||
let run = true;
|
|
||||||
const fetchLoginStatus = async () => {
|
|
||||||
const res = await query.post({
|
|
||||||
path: 'wx',
|
|
||||||
key: 'check-qrcode-login',
|
|
||||||
payload: { ticket }
|
|
||||||
})
|
|
||||||
if (res.code === 200) {
|
|
||||||
message.success('登录成功');
|
|
||||||
clearTimeout(timer);
|
|
||||||
redirectHome();
|
|
||||||
} else {
|
|
||||||
// message.error(res.message || '登录失败');
|
|
||||||
if (res.code === 401) {
|
|
||||||
console.log('等待扫码登录...');
|
|
||||||
} else {
|
|
||||||
console.log('扫码登录状态:', res);
|
|
||||||
}
|
|
||||||
if (run) {
|
|
||||||
setTimeout(fetchLoginStatus, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const timer = setTimeout(fetchLoginStatus, 2000);
|
|
||||||
const close = () => {
|
|
||||||
console.log('停止检测扫码登录状态');
|
|
||||||
clearTimeout(timer);
|
|
||||||
run = false
|
|
||||||
}
|
|
||||||
return close;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Query } from '@kevisual/query'
|
|
||||||
import { QueryLoginBrowser } from '@kevisual/query-login';
|
|
||||||
|
|
||||||
|
|
||||||
export const queryBase = new Query()
|
|
||||||
|
|
||||||
export const query = new QueryLoginBrowser({
|
|
||||||
query: queryBase,
|
|
||||||
})
|
|
||||||
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import QRCode, { QRCodeToDataURLOptions } from 'qrcode';
|
|
||||||
import { redirectHome } from '../login-handle.ts';
|
|
||||||
import { query } from '../query.ts';
|
|
||||||
export const useCreateLoginQRCode = (el?: HTMLCanvasElement) => {
|
|
||||||
var opts: QRCodeToDataURLOptions = {
|
|
||||||
errorCorrectionLevel: 'H',
|
|
||||||
type: 'image/jpeg',
|
|
||||||
margin: 1,
|
|
||||||
width: 300,
|
|
||||||
};
|
|
||||||
let timer: any = null;
|
|
||||||
const createQrcode = async (state: string) => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
const loginUrl = new URL(url.pathname, url.origin);
|
|
||||||
loginUrl.searchParams.set('state', '1-' + state);
|
|
||||||
console.log('生成登录二维码链接:', loginUrl.toString());
|
|
||||||
var img = el || document.getElementById('qrcode')! as HTMLCanvasElement;
|
|
||||||
const res = await QRCode.toDataURL(img!, loginUrl.toString(), opts);
|
|
||||||
};
|
|
||||||
const checkLogin = async (state: string) => {
|
|
||||||
const res = await fetch(`/api/router?path=wx&key=checkLogin&state=${state}`).then((res) => res.json());
|
|
||||||
if (res.code === 200) {
|
|
||||||
console.log(res);
|
|
||||||
const token = res.data;
|
|
||||||
if (token) {
|
|
||||||
localStorage.setItem('token', token.accessToken);
|
|
||||||
await query.setLoginToken(token);
|
|
||||||
}
|
|
||||||
clear();
|
|
||||||
setTimeout(() => {
|
|
||||||
redirectHome();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
checkLogin(state);
|
|
||||||
console.log('继续检测登录状态');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 随机生成一个state
|
|
||||||
const state = Math.random().toString(36).substring(2, 15);
|
|
||||||
createQrcode(state);
|
|
||||||
checkLogin(state);
|
|
||||||
const timer2 = setInterval(() => {
|
|
||||||
const state = Math.random().toString(36).substring(2, 15);
|
|
||||||
clearTimeout(timer); // 清除定时器
|
|
||||||
createQrcode(state); // 90秒后更新二维码
|
|
||||||
checkLogin(state);
|
|
||||||
console.log('更新二维码');
|
|
||||||
}, 90000);
|
|
||||||
const clear = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
clearInterval(timer2);
|
|
||||||
console.log('停止检测登录状态');
|
|
||||||
}
|
|
||||||
return { createQrcode, clear };
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// <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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// 定义回调函数
|
|
||||||
export function callback(res: any) {
|
|
||||||
// 第一个参数传入回调结果,结果如下:
|
|
||||||
// ret Int 验证结果,0:验证成功。2:用户主动关闭验证码。
|
|
||||||
// ticket String 验证成功的票据,当且仅当 ret = 0 时 ticket 有值。
|
|
||||||
// CaptchaAppId String 验证码应用ID。
|
|
||||||
// bizState Any 自定义透传参数。
|
|
||||||
// randstr String 本次验证的随机串,后续票据校验时需传递该参数。
|
|
||||||
console.log('callback:', res);
|
|
||||||
// res(用户主动关闭验证码)= {ret: 2, ticket: null}
|
|
||||||
// res(验证成功) = {ret: 0, ticket: "String", randstr: "String"}
|
|
||||||
// res(请求验证码发生错误,验证码自动返回terror_前缀的容灾票据) = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"}
|
|
||||||
// 此处代码仅为验证结果的展示示例,真实业务接入,建议基于ticket和errorCode情况做不同的业务处理
|
|
||||||
if (res.ret === 0) {
|
|
||||||
// 复制结果至剪切板
|
|
||||||
var str = '【randstr】->【' + res.randstr + '】 【ticket】->【' + res.ticket + '】';
|
|
||||||
var ipt = document.createElement('input');
|
|
||||||
ipt.value = str;
|
|
||||||
document.body.appendChild(ipt);
|
|
||||||
ipt.select();
|
|
||||||
document.body.removeChild(ipt);
|
|
||||||
alert('1. 返回结果(randstr、ticket)已复制到剪切板,ctrl+v 查看。 2. 打开浏览器控制台,查看完整返回结果。');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export type TencentCaptcha = {
|
|
||||||
actionDuration?: number;
|
|
||||||
appid?: string;
|
|
||||||
bizState?: any;
|
|
||||||
randstr?: string;
|
|
||||||
ret: number;
|
|
||||||
sid?: string;
|
|
||||||
ticket?: string;
|
|
||||||
errorCode?: number;
|
|
||||||
errorMessage?: string;
|
|
||||||
verifyDuration?: number;
|
|
||||||
};
|
|
||||||
// 定义验证码触发事件
|
|
||||||
export const checkCaptcha = (captchaAppId: string): Promise<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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
type WxLoginConfig = {
|
|
||||||
redirect_uri?: string;
|
|
||||||
appid?: string;
|
|
||||||
scope?: string;
|
|
||||||
state?: string;
|
|
||||||
style?: string;
|
|
||||||
};
|
|
||||||
export const createLogin = async (config?: WxLoginConfig) => {
|
|
||||||
let redirect_uri = config?.redirect_uri;
|
|
||||||
const { appid } = config || {};
|
|
||||||
if (!redirect_uri) {
|
|
||||||
redirect_uri = window.location.href;
|
|
||||||
}
|
|
||||||
const url = new URL(redirect_uri); // remove code and state params
|
|
||||||
url.searchParams.delete('code');
|
|
||||||
url.searchParams.delete('state');
|
|
||||||
redirect_uri = url.toString();
|
|
||||||
|
|
||||||
console.log('redirect_uri', redirect_uri);
|
|
||||||
if (!appid) {
|
|
||||||
console.error('appid is not cant be empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
const obj = new WxLogin({
|
|
||||||
self_redirect: false,
|
|
||||||
id: 'weixinLogin', // 需要显示的容器id
|
|
||||||
appid: appid, // 微信开放平台appid wx*******
|
|
||||||
scope: 'snsapi_login', // 网页默认即可 snsapi_userinfo
|
|
||||||
redirect_uri: encodeURIComponent(redirect_uri), // 授权成功后回调的url
|
|
||||||
state: Math.ceil(Math.random() * 1000), // 可设置为简单的随机数加session用来校验
|
|
||||||
stylelite: true, // 是否使用简洁模式
|
|
||||||
// https://juejin.cn/post/6982473580063752223
|
|
||||||
href: "data:text/css;base64,LmltcG93ZXJCb3ggLnFyY29kZSB7d2lkdGg6IDIwMHB4O30NCi5pbXBvd2VyQm94IC50aXRsZSB7ZGlzcGxheTogbm9uZTt9DQouaW1wb3dlckJveCAuaW5mbyB7d2lkdGg6IDIwMHB4O30NCi5zdGF0dXNfaWNvbiB7ZGlzcGxheTogbm9uZX0NCi5pbXBvd2VyQm94IC5zdGF0dXMge3RleHQtYWxpZ246IGNlbnRlcjt9"
|
|
||||||
});
|
|
||||||
const login = document.querySelector('#weixinLogin')
|
|
||||||
if (login) {
|
|
||||||
// login 下的 iframe 样式调整
|
|
||||||
const iframe = login.querySelector('iframe');
|
|
||||||
if (iframe) {
|
|
||||||
// iframe.style.width = '200px';
|
|
||||||
iframe.style.height = '300px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
};
|
|
||||||
export const wxId = 'weixinLogin';
|
|
||||||
export function setWxerwma(config?: WxLoginConfig) {
|
|
||||||
const s = document.createElement('script');
|
|
||||||
s.type = 'text/javascript';
|
|
||||||
s.src = '//res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js';
|
|
||||||
s.id = 'weixinLogin-js';
|
|
||||||
if (document.getElementById('weixinLogin-js')) {
|
|
||||||
createLogin(config);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wxElement = document.body.appendChild(s);
|
|
||||||
wxElement.onload = function () {
|
|
||||||
createLogin(config);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
import { render, html } from 'lit-html'
|
|
||||||
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'
|
|
||||||
import { loginHandle, checkWechat, getQrCode, checkMpQrCodeLogin } from '../modules/login-handle.ts'
|
|
||||||
import { setWxerwma } from '../modules/wx/ws-login.ts';
|
|
||||||
import { useCreateLoginQRCode } from '../modules/wx-mp/qr.ts';
|
|
||||||
export const WX_MP_APP_ID = "wxff97d569b1db16b6";
|
|
||||||
interface LoginMethod {
|
|
||||||
id: LoginMethods
|
|
||||||
name: string
|
|
||||||
icon: any
|
|
||||||
appid?: string
|
|
||||||
}
|
|
||||||
const wxmpSvg = `<svg t="1764510467010" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1958" width="32" height="32"><path d="M615.904 388.48c8.8 0 17.536 0.64 26.176 1.6-23.52-109.536-140.608-190.912-274.272-190.912C218.4 199.2 96 301.056 96 430.4c0 74.656 40.736 135.936 108.768 183.488l-27.2 81.792 95.04-47.648c33.984 6.72 61.28 13.632 95.2 13.632 8.544 0 16.992-0.416 25.376-1.088a202.496 202.496 0 0 1-8.384-56.96c0-118.752 101.984-215.136 231.104-215.136zM469.76 314.784c20.48 0 34.016 13.472 34.016 33.92 0 20.352-13.536 34.016-34.016 34.016-20.384 0-40.832-13.664-40.832-34.016 0-20.448 20.448-33.92 40.832-33.92zM279.52 382.72c-20.384 0-40.928-13.664-40.928-34.016 0-20.448 20.544-33.92 40.928-33.92 20.352 0 33.92 13.472 33.92 33.92 0 20.384-13.568 34.016-33.92 34.016z" fill="" p-id="1959"></path><path d="M864 600.352c0-108.672-108.736-197.28-230.88-197.28-129.344 0-231.2 88.576-231.2 197.28 0 108.864 101.856 197.248 231.2 197.248 27.072 0 54.368-6.816 81.568-13.632l74.56 40.8-20.448-67.904C823.328 715.936 864 661.664 864 600.352z m-305.856-34.016c-13.536 0-27.2-13.44-27.2-27.2 0-13.568 13.664-27.2 27.2-27.2 20.576 0 34.016 13.632 34.016 27.2 0 13.76-13.44 27.2-34.016 27.2z m149.536 0c-13.44 0-27.008-13.44-27.008-27.2 0-13.568 13.568-27.2 27.008-27.2 20.352 0 34.016 13.632 34.016 27.2 0 13.76-13.664 27.2-34.016 27.2z" fill="" p-id="1960"></path></svg>`
|
|
||||||
const wxOpenSvg = `<svg t="1764511395617" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3882" width="32" height="32"><path d="M256 259.584c-29.184 0-51.2 14.848-51.2 44.032s29.184 44.032 51.2 44.032c29.184 0 44.032-14.848 44.032-44.032s-22.016-44.032-44.032-44.032zM541.184 303.616c0-29.184-14.848-44.032-44.032-44.032-29.184 0-51.2 14.848-51.2 44.032s29.184 44.032 51.2 44.032c29.696 0 44.032-22.016 44.032-44.032zM614.4 508.416c-14.848 0-36.352 14.848-36.352 36.352 0 14.848 14.848 36.352 36.352 36.352 29.184 0 44.032-14.848 44.032-36.352 0-14.336-14.848-36.352-44.032-36.352z" p-id="3883"></path><path d="M1024 625.152c0-138.752-124.416-256-285.184-270.848-29.184-153.6-189.952-263.168-373.248-263.168C160.768 91.648 0 230.4 0 406.016c0 95.232 44.032 175.616 138.752 241.152L109.568 742.4c0 7.168 0 14.848 7.168 22.016h14.848l117.248-58.368h14.848c36.352 7.168 66.048 14.848 109.568 14.848 14.848 0 44.032-7.168 44.032-7.168C460.8 822.784 578.048 896 716.8 896c36.352 0 73.216-7.168 102.4-14.848l87.552 51.2h14.848c7.168-7.168 7.168-7.168 7.168-14.848l-22.016-87.552c80.896-58.368 117.248-131.584 117.248-204.8z m-621.568 51.2h-36.352c-36.352 0-66.048-7.168-95.232-14.848l-22.016-7.168h-7.168L153.6 698.368l22.016-66.048c0-7.168 0-14.848-7.168-14.848C80.384 559.616 36.352 486.4 36.352 398.848 36.352 245.248 182.784 128 358.4 128c160.768 0 300.032 95.232 329.216 226.816-168.448 0-300.032 117.248-300.032 263.168 7.168 22.016 14.848 44.032 14.848 58.368z m467.968 132.096c-7.168 7.168-7.168 7.168-7.168 14.848l14.848 51.2L819.2 844.8h-14.848c-29.184 7.168-66.048 14.848-95.232 14.848-146.432 0-270.848-102.4-270.848-226.816 0-131.584 124.416-233.984 270.848-233.984s270.848 102.4 270.848 226.816c0 65.536-36.352 123.904-109.568 182.784z" p-id="3884"></path><path d="M804.352 508.416c-14.848 0-36.352 14.848-36.352 36.352 0 14.848 14.848 36.352 36.352 36.352 29.184 0 44.032-14.848 44.032-36.352 0-14.336-14.336-36.352-44.032-36.352z" p-id="3885"></path></svg>`
|
|
||||||
const phone = `<svg t="1764511425462" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5097" width="32" height="32"><path d="M820.409449 797.228346q0 25.19685-10.07874 46.866142t-27.716535 38.299213-41.322835 26.204724-50.897638 9.574803l-357.795276 0q-27.212598 0-50.897638-9.574803t-41.322835-26.204724-27.716535-38.299213-10.07874-46.866142l0-675.275591q0-25.19685 10.07874-47.370079t27.716535-38.80315 41.322835-26.204724 50.897638-9.574803l357.795276 0q27.212598 0 50.897638 9.574803t41.322835 26.204724 27.716535 38.80315 10.07874 47.370079l0 675.275591zM738.771654 170.330709l-455.559055 0 0 577.511811 455.559055 0 0-577.511811zM510.992126 776.062992q-21.165354 0-36.787402 15.11811t-15.622047 37.291339q0 21.165354 15.622047 36.787402t36.787402 15.622047q22.173228 0 37.291339-15.622047t15.11811-36.787402q0-22.173228-15.11811-37.291339t-37.291339-15.11811zM591.622047 84.661417q0-8.062992-5.03937-12.598425t-11.086614-4.535433l-128 0q-5.03937 0-10.582677 4.535433t-5.543307 12.598425 5.03937 12.598425 11.086614 4.535433l128 0q6.047244 0 11.086614-4.535433t5.03937-12.598425z" p-id="5098"></path></svg>`
|
|
||||||
const pwd = `<svg t="1764511500570" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10511" width="32" height="32"><path d="M768.9216 422.72768 372.06016 422.72768C378.88 365.21984 329.37984 131.42016 512.2048 125.72672c173.83424-6.59456 146.78016 213.34016 146.78016 213.34016l85.13536 0.57344c0 0 24.73984-294.4-231.91552-295.8336C232.09984 58.01984 297.82016 377.18016 289.28 422.72768c1.98656 0 4.56704 0 7.29088 0-55.88992 0-101.21216 45.34272-101.21216 101.21216l0 337.38752c0 55.88992 45.34272 101.21216 101.21216 101.21216l472.35072 0c55.88992 0 101.21216-45.34272 101.21216-101.21216L870.13376 523.93984C870.13376 468.0704 824.79104 422.72768 768.9216 422.72768zM566.4768 717.02528l0 76.84096c0 18.57536-15.1552 33.73056-33.73056 33.73056-18.57536 0-33.73056-15.1552-33.73056-33.73056l0-76.84096c-20.09088-11.69408-33.73056-33.21856-33.73056-58.12224 0-37.2736 30.208-67.4816 67.4816-67.4816 37.2736 0 67.4816 30.208 67.4816 67.4816C600.22784 683.80672 586.58816 705.3312 566.4768 717.02528z" fill="#272636" p-id="10512"></path></svg>`
|
|
||||||
|
|
||||||
const icons: any = {
|
|
||||||
pwd,
|
|
||||||
phone,
|
|
||||||
wxmpSvg,
|
|
||||||
wxOpenSvg
|
|
||||||
}
|
|
||||||
const DefaultLoginMethods: LoginMethod[] = [
|
|
||||||
{ id: 'password', name: '密码登录', icon: 'pwd' },
|
|
||||||
{ id: 'wechat', name: '微信登录', icon: 'wxmpSvg', appid: "wx9378885c8390e09b" },
|
|
||||||
{ id: 'wechat-mp', name: '微信公众号', icon: 'wxOpenSvg', appid: WX_MP_APP_ID },
|
|
||||||
{ id: 'wechat-mp-ticket', name: '微信公众号', icon: 'wxOpenSvg' },
|
|
||||||
{ id: 'phone', name: '手机号登录', icon: 'phone' }
|
|
||||||
]
|
|
||||||
type LoginMethods = 'password' | 'phone' | 'wechat' | 'wechat-mp' | 'wechat-mp-ticket';
|
|
||||||
|
|
||||||
const getLoginMethodByDomain = (): LoginMethod[] => {
|
|
||||||
const domain = window.location.hostname
|
|
||||||
let methods: LoginMethods[] = []
|
|
||||||
switch (domain) {
|
|
||||||
case 'kevisual.xiongxiao.me':
|
|
||||||
methods = ['password', 'wechat-mp']
|
|
||||||
break;
|
|
||||||
case 'kevisual.cn':
|
|
||||||
methods = ['password', 'wechat', 'wechat-mp-ticket']
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
methods = ['password', 'phone', 'wechat', 'wechat-mp', 'wechat-mp-ticket']
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return DefaultLoginMethods.filter(method => methods.includes(method.id))
|
|
||||||
}
|
|
||||||
console.log('可用登录方式:', getLoginMethodByDomain().map(m => m.name).join(', '));
|
|
||||||
class KvLogin extends HTMLElement {
|
|
||||||
private selectedMethod: LoginMethods = 'password'
|
|
||||||
|
|
||||||
private loginMethods: LoginMethod[] = getLoginMethodByDomain();
|
|
||||||
setLoginMethods(methods: LoginMethod[]) {
|
|
||||||
this.loginMethods = methods
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.attachShadow({ mode: 'open' })
|
|
||||||
this.render()
|
|
||||||
this.bindEvents()
|
|
||||||
checkWechat()
|
|
||||||
|
|
||||||
}
|
|
||||||
#clearTimer: any = null;
|
|
||||||
private selectLoginMethod(methodId: LoginMethods) {
|
|
||||||
this.selectedMethod = methodId
|
|
||||||
this.render()
|
|
||||||
if (this.#clearTimer) {
|
|
||||||
this.#clearTimer();
|
|
||||||
this.#clearTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private getMethodData(methodId: LoginMethods): LoginMethod | undefined {
|
|
||||||
return this.loginMethods.find(method => method.id === methodId);
|
|
||||||
}
|
|
||||||
private bindEvents() {
|
|
||||||
if (!this.shadowRoot) return
|
|
||||||
|
|
||||||
// 使用事件委托来处理登录方式切换
|
|
||||||
this.shadowRoot.addEventListener('click', (e) => {
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
const methodButton = target.closest('.login-method')
|
|
||||||
if (methodButton) {
|
|
||||||
const methodId = methodButton.getAttribute('data-method') as LoginMethods
|
|
||||||
if (methodId) {
|
|
||||||
this.selectLoginMethod(methodId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用事件委托来处理表单提交
|
|
||||||
this.shadowRoot.addEventListener('submit', (e) => {
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
if (target && target.id === 'loginForm') {
|
|
||||||
e.preventDefault()
|
|
||||||
this.handleLogin()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleLogin() {
|
|
||||||
const formData = this.getFormData()
|
|
||||||
// console.log('登录方式:', this.selectedMethod)
|
|
||||||
// console.log('登录数据:', formData)
|
|
||||||
loginHandle({
|
|
||||||
loginMethod: this.selectedMethod,
|
|
||||||
data: formData,
|
|
||||||
el: this
|
|
||||||
})
|
|
||||||
// 这里可以触发自定义事件,通知父组件
|
|
||||||
this.dispatchEvent(new CustomEvent('login', {
|
|
||||||
detail: {
|
|
||||||
method: this.selectedMethod,
|
|
||||||
data: formData
|
|
||||||
},
|
|
||||||
bubbles: true
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFormData(): any {
|
|
||||||
if (!this.shadowRoot) return {}
|
|
||||||
|
|
||||||
switch (this.selectedMethod) {
|
|
||||||
case 'password':
|
|
||||||
const username = this.shadowRoot.querySelector('#username') as HTMLInputElement
|
|
||||||
const password = this.shadowRoot.querySelector('#password') as HTMLInputElement
|
|
||||||
return {
|
|
||||||
username: username?.value || '',
|
|
||||||
password: password?.value || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'phone':
|
|
||||||
const phone = this.shadowRoot.querySelector('#phone') as HTMLInputElement
|
|
||||||
const code = this.shadowRoot.querySelector('#code') as HTMLInputElement
|
|
||||||
return {
|
|
||||||
phone: phone?.value || '',
|
|
||||||
code: code?.value || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'wechat':
|
|
||||||
return {
|
|
||||||
wechatCode: 'mock_wechat_code'
|
|
||||||
}
|
|
||||||
case 'wechat-mp':
|
|
||||||
return {
|
|
||||||
wechatMpCode: 'mock_wechat_mp_code'
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderPasswordForm() {
|
|
||||||
return html`
|
|
||||||
<form id="loginForm" class="login-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
autocomplete="username"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="login-button">登录</button>
|
|
||||||
</form>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderPhoneForm() {
|
|
||||||
return html`
|
|
||||||
<form id="loginForm" class="login-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
id="phone"
|
|
||||||
name="phone"
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
pattern="[0-9]{11}"
|
|
||||||
autocomplete="tel"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group code-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="code"
|
|
||||||
name="code"
|
|
||||||
placeholder="请输入验证码"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button type="button" class="code-button" @click=${this.sendVerificationCode}>获取验证码</button>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="login-button">登录</button>
|
|
||||||
</form>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderWechatForm() {
|
|
||||||
return html`
|
|
||||||
<div class="wechat-login">
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
private renderWechatMpForm() {
|
|
||||||
const that = this
|
|
||||||
setTimeout(() => {
|
|
||||||
const qrcode = that.shadowRoot!.querySelector('#qrcode');
|
|
||||||
const { clear } = useCreateLoginQRCode(qrcode as HTMLCanvasElement);
|
|
||||||
that.#clearTimer = clear;
|
|
||||||
}, 0)
|
|
||||||
return html`
|
|
||||||
<div class="wechat-login">
|
|
||||||
<div class="qr-container">
|
|
||||||
<div class="qr-placeholder">
|
|
||||||
<canvas id='qrcode' width='300' height='300'></canvas>
|
|
||||||
<p class="qr-desc">请使用微信扫描二维码登录</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
private renderWechatMpTicketForm() {
|
|
||||||
const that = this;
|
|
||||||
setTimeout(async () => {
|
|
||||||
const data = await getQrCode();
|
|
||||||
if (!data) return;
|
|
||||||
const imgEl = that.shadowRoot!.querySelector('.qrcode') as HTMLImageElement;
|
|
||||||
if (data.url) {
|
|
||||||
imgEl.src = data.url;
|
|
||||||
// TODO: 轮询检测登录状态
|
|
||||||
const clear = checkMpQrCodeLogin(data.ticket)
|
|
||||||
// 当切换登录方式时,停止轮询
|
|
||||||
that.#clearTimer = clear
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
return html`
|
|
||||||
<div class="wechat-login">
|
|
||||||
<div class="qr-container">
|
|
||||||
<div class="qr-placeholder">
|
|
||||||
<img class="qrcode" width="300" height="300" data-appid="" data-size="200" data-ticket=""></img>
|
|
||||||
<p class="qr-desc">请使用微信扫描二维码登录</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendVerificationCode() {
|
|
||||||
console.log('发送验证码')
|
|
||||||
// 这里可以实现发送验证码的逻辑
|
|
||||||
}
|
|
||||||
|
|
||||||
private refreshQR() {
|
|
||||||
console.log('刷新二维码')
|
|
||||||
// 这里可以实现刷新二维码的逻辑
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private renderLoginForm() {
|
|
||||||
const data = this.getMethodData(this.selectedMethod);
|
|
||||||
switch (this.selectedMethod) {
|
|
||||||
case 'password':
|
|
||||||
return this.renderPasswordForm()
|
|
||||||
case 'phone':
|
|
||||||
return this.renderPhoneForm()
|
|
||||||
case 'wechat':
|
|
||||||
setWxerwma({ appid: data?.appid! || "" });
|
|
||||||
return this.renderWechatForm()
|
|
||||||
case 'wechat-mp':
|
|
||||||
return this.renderWechatMpForm()
|
|
||||||
case 'wechat-mp-ticket':
|
|
||||||
return this.renderWechatMpTicketForm()
|
|
||||||
default:
|
|
||||||
return this.renderPasswordForm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.shadowRoot) return
|
|
||||||
|
|
||||||
const renderIcon = (icon: any) => {
|
|
||||||
// 如果是emoji字符,直接返回
|
|
||||||
if (typeof icon === 'string' && !icons[icon]) {
|
|
||||||
return html`<span class="method-icon-emoji">${icon}</span>`
|
|
||||||
}
|
|
||||||
// 如果是SVG引用,从icons对象获取
|
|
||||||
if (typeof icon === 'string' && icons[icon]) {
|
|
||||||
return html`<span class="method-icon-svg">${unsafeHTML(icons[icon])}</span>`
|
|
||||||
}
|
|
||||||
// 如果直接是SVG内容
|
|
||||||
if (typeof icon === 'string' && (icon.includes('<svg') || icon.includes('<?xml'))) {
|
|
||||||
return html`<span class="method-icon-svg">${unsafeHTML(icon)}</span>`
|
|
||||||
}
|
|
||||||
// 默认情况
|
|
||||||
return html`<span class="method-icon-emoji">${icon}</span>`
|
|
||||||
}
|
|
||||||
const template = html`
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 400px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-sidebar {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-methods {
|
|
||||||
display: flex;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-method {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px 8px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-method:hover {
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-method.active {
|
|
||||||
background: white;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-method.active::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.method-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.method-icon-emoji {
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.method-icon-svg {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.method-icon-svg svg {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.method-name {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-content {
|
|
||||||
padding: 32px 24px;
|
|
||||||
}
|
|
||||||
.impowerBox .qrcode {
|
|
||||||
width: 200px !important;
|
|
||||||
}
|
|
||||||
.login-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 2px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.3s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-group input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-button {
|
|
||||||
padding: 0 16px;
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-button:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
padding: 12px;
|
|
||||||
background: #000000;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover {
|
|
||||||
background: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wechat-login {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-container {
|
|
||||||
width: 340px;
|
|
||||||
height: 340px;
|
|
||||||
border: 2px dashed #cccccc;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-placeholder {
|
|
||||||
text-align: center;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-button:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
.method-icon svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="login-sidebar">
|
|
||||||
<div class="login-methods">
|
|
||||||
${this.loginMethods.map(method => html`
|
|
||||||
<button
|
|
||||||
class="login-method ${this.selectedMethod === method.id ? 'active' : ''}"
|
|
||||||
data-method="${method.id}"
|
|
||||||
>
|
|
||||||
${renderIcon(method.icon)}
|
|
||||||
<span class="method-name">${method.name}</span>
|
|
||||||
</button>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="login-content">
|
|
||||||
${this.renderLoginForm()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
render(template, this.shadowRoot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('kv-login', KvLogin)
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
import { html, render, TemplateResult } from 'lit-html'
|
|
||||||
|
|
||||||
export interface KvMessageOptions {
|
|
||||||
type?: 'success' | 'error' | 'loading'
|
|
||||||
message: string
|
|
||||||
duration?: number
|
|
||||||
closable?: boolean
|
|
||||||
position?: 'center' | 'right'
|
|
||||||
}
|
|
||||||
|
|
||||||
class KvMessage extends HTMLElement {
|
|
||||||
private options: KvMessageOptions
|
|
||||||
private timer: number | null = null
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
this.options = {
|
|
||||||
type: 'success',
|
|
||||||
message: '',
|
|
||||||
duration: 2000,
|
|
||||||
closable: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
setOptions(options: KvMessageOptions) {
|
|
||||||
this.options = { ...this.options, ...options }
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
private render() {
|
|
||||||
const { type, message, closable } = this.options
|
|
||||||
|
|
||||||
const getTypeIcon = () => {
|
|
||||||
switch (type) {
|
|
||||||
case 'success':
|
|
||||||
return '✓'
|
|
||||||
case 'error':
|
|
||||||
return '✕'
|
|
||||||
case 'loading':
|
|
||||||
return html`<div class="loading-spinner"></div>`
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const template: TemplateResult = html`
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
background: white;
|
|
||||||
position: relative;
|
|
||||||
min-width: 300px;
|
|
||||||
max-width: 500px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container.success {
|
|
||||||
border-left: 4px solid #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container.error {
|
|
||||||
border-left: 4px solid #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container.loading {
|
|
||||||
border-left: 4px solid #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success .message-icon {
|
|
||||||
color: #52c41a;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error .message-icon {
|
|
||||||
color: #ff4d4f;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading .message-icon {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid #f3f3f3;
|
|
||||||
border-top: 2px solid #1890ff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
flex: 1;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-close {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #999;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-close:hover {
|
|
||||||
color: #666;
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOut {
|
|
||||||
from {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.removing {
|
|
||||||
animation: slideOut 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="message-container ${type}">
|
|
||||||
<div class="message-icon">
|
|
||||||
${getTypeIcon()}
|
|
||||||
</div>
|
|
||||||
<div class="message-content">${message}</div>
|
|
||||||
${closable ? html`
|
|
||||||
<button class="message-close" @click=${() => this.remove()}>×</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
render(template, this)
|
|
||||||
|
|
||||||
if (type !== 'loading' && this.options.duration && this.options.duration > 0) {
|
|
||||||
this.setTimer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setTimer() {
|
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timer = window.setTimeout(() => {
|
|
||||||
this.remove()
|
|
||||||
}, this.options.duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer)
|
|
||||||
this.timer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this.classList.add('removing')
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.parentNode) {
|
|
||||||
this.parentNode.removeChild(this)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer)
|
|
||||||
this.timer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('kv-message', KvMessage)
|
|
||||||
|
|
||||||
export class KvMessageManager {
|
|
||||||
private static instance: KvMessageManager
|
|
||||||
private container: HTMLElement | null = null
|
|
||||||
private defaultPosition: 'center' | 'right' = 'center'
|
|
||||||
|
|
||||||
static getInstance(): KvMessageManager {
|
|
||||||
if (!KvMessageManager.instance) {
|
|
||||||
KvMessageManager.instance = new KvMessageManager()
|
|
||||||
}
|
|
||||||
return KvMessageManager.instance
|
|
||||||
}
|
|
||||||
|
|
||||||
setDefaultPosition(position: 'center' | 'right') {
|
|
||||||
this.defaultPosition = position
|
|
||||||
}
|
|
||||||
|
|
||||||
private getContainer(position?: 'center' | 'right'): HTMLElement {
|
|
||||||
const finalPosition = position || this.defaultPosition
|
|
||||||
|
|
||||||
if (!this.container) {
|
|
||||||
this.container = document.getElementById('messages')
|
|
||||||
if (!this.container) {
|
|
||||||
this.container = document.createElement('div')
|
|
||||||
this.container.id = 'messages'
|
|
||||||
|
|
||||||
if (finalPosition === 'center') {
|
|
||||||
this.container.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
pointer-events: none;
|
|
||||||
`
|
|
||||||
} else {
|
|
||||||
this.container.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
pointer-events: none;
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(this.container)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.container
|
|
||||||
}
|
|
||||||
|
|
||||||
show(options: KvMessageOptions): KvMessage {
|
|
||||||
const container = this.getContainer(options.position)
|
|
||||||
|
|
||||||
const message = document.createElement('kv-message') as KvMessage
|
|
||||||
message.setOptions(options)
|
|
||||||
|
|
||||||
message.style.cssText = 'pointer-events: auto;'
|
|
||||||
|
|
||||||
container.appendChild(message)
|
|
||||||
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
|
|
||||||
success(message: string, options?: { duration?: number; position?: 'center' | 'right'; closable?: boolean }): KvMessage {
|
|
||||||
return this.show({
|
|
||||||
type: 'success',
|
|
||||||
message,
|
|
||||||
duration: options?.duration || 2000,
|
|
||||||
position: options?.position,
|
|
||||||
closable: options?.closable
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
error(message: string, options?: { duration?: number; position?: 'center' | 'right'; closable?: boolean }): KvMessage {
|
|
||||||
return this.show({
|
|
||||||
type: 'error',
|
|
||||||
message,
|
|
||||||
duration: options?.duration || 3000,
|
|
||||||
position: options?.position,
|
|
||||||
closable: options?.closable
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
loading(message: string, options?: { position?: 'center' | 'right'; closable?: boolean }): KvMessage {
|
|
||||||
return this.show({
|
|
||||||
type: 'loading',
|
|
||||||
message,
|
|
||||||
duration: 0,
|
|
||||||
position: options?.position,
|
|
||||||
closable: options?.closable
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(message: KvMessage) {
|
|
||||||
message.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
const container = this.getContainer()
|
|
||||||
const messages = container.querySelectorAll('kv-message')
|
|
||||||
messages.forEach(message => {
|
|
||||||
(message as KvMessage).remove()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createMessage = () => KvMessageManager.getInstance()
|
|
||||||
|
|
||||||
// 将 createMessage 暴露到全局,以便 HTML 中的 JavaScript 可以使用
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
createMessage: typeof createMessage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.createMessage = createMessage
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
const entry = './src/main.ts';
|
|
||||||
export default defineConfig({
|
|
||||||
build: {
|
|
||||||
lib: {
|
|
||||||
entry,
|
|
||||||
name: 'KvLogin',
|
|
||||||
fileName: (format) => `kv-login.${format}.js`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
const idDev = process.env.NODE_ENV === 'development';
|
|
||||||
export default defineConfig({
|
|
||||||
base: idDev ? '/' : '/root/kv-login-test/',
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'https://kevisual.xiongxiao.me',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
2
packages/user-login/.gitignore
vendored
2
packages/user-login/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# app-template
|
|
||||||
|
|
||||||
|
|
||||||
`/system/lib/app.js` 包函的模块是 `QueryRouterServer` 和 `Page` 和 `useConfigKey`
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>杭州余杭逸文设计工作室</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>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@kevisual/kevisual-login",
|
|
||||||
"version": "0.0.5",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"basename": "/root/login",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"pub": "envision deploy ./dist -k login -v 0.0.5 -u "
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
|
||||||
"license": "MIT",
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@floating-ui/dom": "^1.7.4",
|
|
||||||
"@kevisual/query": "0.0.29",
|
|
||||||
"@kevisual/query-login": "^0.0.6",
|
|
||||||
"@kevisual/system-lib": "^0.0.22",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"dayjs": "^1.11.19",
|
|
||||||
"lodash-es": "^4.17.21",
|
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"react-toastify": "^11.0.5",
|
|
||||||
"zustand": "^5.0.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@kevisual/router": "0.0.30",
|
|
||||||
"@kevisual/ssl": "^0.0.1",
|
|
||||||
"@kevisual/store": "0.0.9",
|
|
||||||
"@kevisual/types": "^0.0.10",
|
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
|
||||||
"@types/qrcode": "^1.5.6",
|
|
||||||
"@types/react": "^19.2.2",
|
|
||||||
"@types/react-dom": "^19.2.2",
|
|
||||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
|
||||||
"cross-env": "^10.1.0",
|
|
||||||
"esbuild": "^0.27.0",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"vite": "^7.2.2"
|
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"@tailwindcss/oxide",
|
|
||||||
"esbuild"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2445
packages/user-login/pnpm-lock.yaml
generated
2445
packages/user-login/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
NGWvli5lGpEkByyt
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
if (isKevisual || isKevisualXiongxiao) {
|
|
||||||
// document.title = '杭州余杭逸文设计工作室';
|
|
||||||
} else {
|
|
||||||
document.title = 'SilkyAI';
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { App } from './user/index.tsx';
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
createRoot(document.getElementById('root')!).render(<App />);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// @ts-ignore
|
|
||||||
export const basename = DEV_SERVER ? '/' : BASE_NAME;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
.beian2 {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 99;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.beiann2 {
|
|
||||||
position: static !important; /* 或者 relative,根据需求修改 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// import { message } from '@kevisual/system-ui/dist/message';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
export const message = toast;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import './index.css';
|
|
||||||
import { Login } from './login';
|
|
||||||
import { ToastContainer } from 'react-toastify';
|
|
||||||
export const App = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Login />
|
|
||||||
<ToastContainer position='top-center' autoClose={5000} draggable />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
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} />
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
const onTestAccountLogin = () => {
|
|
||||||
setAccountName('demo');
|
|
||||||
setPassword('123456');
|
|
||||||
};
|
|
||||||
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
|
|
||||||
className='text-xs text-gray-400/60 mt-2 hover:text-gray-500 cursor-pointer'
|
|
||||||
onClick={() => {
|
|
||||||
onTestAccountLogin();
|
|
||||||
}}>
|
|
||||||
试用账号登录
|
|
||||||
</div>
|
|
||||||
</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]);
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// <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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
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 inIframeToDo = async (config?: Config) => {
|
|
||||||
const isInIframe = window !== window.parent && !window.opener;
|
|
||||||
|
|
||||||
if (isInIframe && config) {
|
|
||||||
try {
|
|
||||||
// 检查是否同源
|
|
||||||
const isSameOrigin = (() => {
|
|
||||||
try {
|
|
||||||
// 尝试访问父窗口的 location.origin,如果能访问则是同源
|
|
||||||
return window.parent.location.origin === window.location.origin;
|
|
||||||
} catch (e) {
|
|
||||||
// 如果出现跨域错误,则不是同源
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
const isLocalhost = window.location.hostname === 'localhost';
|
|
||||||
const isKevisual = window.location.hostname.includes('kevisual');
|
|
||||||
if (isSameOrigin || isLocalhost || isKevisual) {
|
|
||||||
// 同源情况下,可以直接向父窗口传递配置
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'kevisual-login',
|
|
||||||
data: config,
|
|
||||||
},
|
|
||||||
window.location.origin,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('已向父窗口传递登录配置信息');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('向父窗口传递配置信息失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInIframe;
|
|
||||||
};
|
|
||||||
export const redirectToSuccess = async (config: Config) => {
|
|
||||||
const href = location.href;
|
|
||||||
const url = new URL(href);
|
|
||||||
const check = await inIframeToDo(config);
|
|
||||||
if (check) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
packages/user-login/src/vite-env.d.ts
vendored
7
packages/user-login/src/vite-env.d.ts
vendored
@@ -1,7 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
type SimpleObject = {
|
|
||||||
[key: string | number]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare let DEV_SERVER: boolean;
|
|
||||||
declare let BASE_NAME: string;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// <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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// 定义回调函数
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
4266
pnpm-lock.yaml
generated
4266
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
|||||||
packages:
|
|
||||||
- "packages/user-login"
|
|
||||||
20
src/apps/auth.tsx
Normal file
20
src/apps/auth.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { queryLogin } from "@/modules/query"
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
export const AuthProvider = (props: { children: React.ReactNode }) => {
|
||||||
|
const [isLogin, setIsLogin] = useState<boolean>(false);
|
||||||
|
const init = async () => {
|
||||||
|
const token = await queryLogin.checkLocalToken();
|
||||||
|
if (token) {
|
||||||
|
console.log('User is logged in');
|
||||||
|
setIsLogin(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isLogin ? props.children : <div>Please log in to access this application.</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/apps/beian.tsx
Normal file
24
src/apps/beian.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
const beianList = [
|
||||||
|
{
|
||||||
|
hostname: 'https://kevisual.xiongxiao.me',
|
||||||
|
beian: '蜀ICP备16031039号-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hostname: 'https://kevisual.cn',
|
||||||
|
beian: '浙ICP备2025158778号'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Beian = () => {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const beianInfo = beianList.find(item => hostname.includes(item.hostname.replace('https://', '').replace('http://', '')));
|
||||||
|
if (!beianInfo) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-center text-sm text-gray-500 my-2 fixed bottom-0 w-full bg-white/70 backdrop-blur-sm py-1">
|
||||||
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||||
|
{beianInfo.beian}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/apps/config/firstLogin.tsx
Normal file
180
src/apps/config/firstLogin.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AuthProvider } from "../auth";
|
||||||
|
import { useFirstStore } from "./store";
|
||||||
|
// @ts-ignore
|
||||||
|
import UserNameBg from '../../assets/user-name-bg.jpg'
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
|
console.log(UserNameBg);
|
||||||
|
const src = UserNameBg.src;
|
||||||
|
|
||||||
|
// 炫光边框卡片组件 - 黑白色系
|
||||||
|
const GlowingCard = ({ children, className = "" }: { children: React.ReactNode; className?: string }) => {
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{/* 炫光边框 - 外层发光(黑白色系) */}
|
||||||
|
<div className="absolute -inset-[2px] rounded-2xl opacity-50 blur-xl">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-linear-to-r from-gray-100 via-white to-gray-200 animate-gradient-xy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 边框渐变层(半透明白色) */}
|
||||||
|
<div className="absolute -inset-[1px] rounded-2xl bg-linear-to-r from-white/60 via-white/80 to-white/60 opacity-90" />
|
||||||
|
|
||||||
|
{/* 内容层 - 更透明 */}
|
||||||
|
<div className="relative backdrop-blur-xl bg-black/30 rounded-2xl p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const App = () => {
|
||||||
|
const firstStore = useFirstStore();
|
||||||
|
const [username, setUsername] = useState<string>("");
|
||||||
|
const [nickname, setNickname] = useState<string>("");
|
||||||
|
const [password, setPassword] = useState<string>("");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
firstStore.getMe().finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstStore.userInfo) {
|
||||||
|
setUsername(firstStore.userInfo.username);
|
||||||
|
setNickname(firstStore.userInfo.nickname);
|
||||||
|
}
|
||||||
|
}, [firstStore.userInfo]);
|
||||||
|
|
||||||
|
const canChange = firstStore.userInfo?.canChangeUsername ?? false;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// TODO: 实现更新用户名和昵称的逻辑
|
||||||
|
// console.log("Update username to:", username, "nickname to:", nickname);
|
||||||
|
const res = await firstStore.updateUserInfo({
|
||||||
|
username,
|
||||||
|
nickname,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
{/* 背景图层 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${src})` }}
|
||||||
|
/>
|
||||||
|
{/* 模糊和遮罩层 */}
|
||||||
|
<div className="absolute inset-0 backdrop-blur-sm bg-black/30" />
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div className="relative min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-white text-lg font-medium">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
{/* 背景图层 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{ backgroundImage: `url(${src})` }}
|
||||||
|
/>
|
||||||
|
{/* 模糊和遮罩层 */}
|
||||||
|
<div className="absolute inset-0 backdrop-blur-md bg-black/40" />
|
||||||
|
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div className="relative min-h-screen flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* 头像 */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 bg-white/30 blur-2xl rounded-full" />
|
||||||
|
<img
|
||||||
|
src={firstStore.getAvatar()}
|
||||||
|
alt={firstStore.userInfo?.nickname || firstStore.userInfo?.username || "avatar"}
|
||||||
|
className="relative w-28 h-28 rounded-full border-4 border-white/60 shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户信息卡片 - 炫光边框效果 */}
|
||||||
|
<GlowingCard>
|
||||||
|
<h1 className="text-3xl font-bold text-white text-center mb-3 drop-shadow-lg">
|
||||||
|
{nickname || firstStore.userInfo?.username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/80 text-center mb-8 text-sm" title="只有第一次可以修改用户名哦~,其他联系管理员修改。">
|
||||||
|
{'只有第一次可以修改用户名哦~'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 用户名输入表单 */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/90 mb-3">
|
||||||
|
昵称
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nickname ?? ""}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
disabled={!canChange}
|
||||||
|
className="w-full px-5 py-3 rounded-xl border-2 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/50 backdrop-blur-sm bg-white/5 text-white placeholder-white/40 border-white/20 disabled:bg-white/5 disabled:cursor-not-allowed disabled:border-white/10 disabled:text-white/40 transition-all duration-200"
|
||||||
|
placeholder="输入昵称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/90 mb-3">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username ?? ""}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={!canChange}
|
||||||
|
className="w-full px-5 py-3 rounded-xl border-2 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/50 backdrop-blur-sm bg-white/5 text-white placeholder-white/40 border-white/20 disabled:bg-white/5 disabled:cursor-not-allowed disabled:border-white/10 disabled:text-white/40 transition-all duration-200"
|
||||||
|
placeholder="输入用户名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/90 mb-3">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password ?? ""}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={!canChange}
|
||||||
|
className="w-full px-5 py-3 rounded-xl border-2 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/50 backdrop-blur-sm bg-white/5 text-white placeholder-white/40 border-white/20 disabled:bg-white/5 disabled:cursor-not-allowed disabled:border-white/10 disabled:text-white/40 transition-all duration-200"
|
||||||
|
placeholder="输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canChange}
|
||||||
|
className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${canChange
|
||||||
|
? "bg-linear-to-r from-white/90 to-gray-200 text-black hover:from-white hover:to-gray-100 hover:shadow-white/30 hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
: "bg-white/10 text-white/30 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{canChange ? "保存修改" : "不可修改"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlowingCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppProvider = () => {
|
||||||
|
return <AuthProvider>
|
||||||
|
<App />
|
||||||
|
<ToastContainer />
|
||||||
|
</AuthProvider>;
|
||||||
|
}
|
||||||
111
src/apps/config/store.ts
Normal file
111
src/apps/config/store.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { queryLogin } from '@/modules/query';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
type UserInfo = {
|
||||||
|
avatar: string | null;
|
||||||
|
canChangeUsername: boolean;
|
||||||
|
description: string;
|
||||||
|
id: string;
|
||||||
|
needChangePassword: boolean;
|
||||||
|
nickname: string;
|
||||||
|
orgs: string[];
|
||||||
|
type: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
interface FirstState {
|
||||||
|
userInfo?: UserInfo;
|
||||||
|
getMe(): Promise<void>;
|
||||||
|
getAvatar(): string;
|
||||||
|
updateUserInfo: (opts?: { username: string; nickname: string; password?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFirstStore = create<FirstState>((set, get) => ({
|
||||||
|
userInfo: undefined,
|
||||||
|
getMe: async () => {
|
||||||
|
const res = await queryLogin.getMe();
|
||||||
|
console.log('User info:', res);
|
||||||
|
set({ userInfo: res.data });
|
||||||
|
},
|
||||||
|
getAvatar: () => {
|
||||||
|
const { userInfo } = get();
|
||||||
|
|
||||||
|
if (!userInfo) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已有 avatar,直接返回
|
||||||
|
if (userInfo.avatar) {
|
||||||
|
return userInfo.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 nickname 或 username 的第一个字符
|
||||||
|
const firstChar = (userInfo.nickname || userInfo.username || 'U')
|
||||||
|
.trim()
|
||||||
|
.charAt(0)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
// 根据用户名生成稳定的颜色
|
||||||
|
const stringToColor = (str: string): string => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const hue = Math.abs(hash % 360);
|
||||||
|
return `hsl(${hue}, 65%, 55%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundColor = stringToColor(userInfo.username + userInfo.id);
|
||||||
|
|
||||||
|
// 创建 SVG 头像
|
||||||
|
const svg = `
|
||||||
|
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="100" height="100" fill="${backgroundColor}"/>
|
||||||
|
<text x="50" y="50" font-family="Arial, sans-serif" font-size="50"
|
||||||
|
font-weight="bold" fill="white" text-anchor="middle"
|
||||||
|
dominant-baseline="central">${firstChar}</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 转换为 data URL(现代方法,不使用已弃用的 unescape)
|
||||||
|
const svgBase64 = btoa(
|
||||||
|
encodeURIComponent(svg)
|
||||||
|
.replace(/%([0-9A-F]{2})/g, (_, p1) =>
|
||||||
|
String.fromCharCode(parseInt(p1, 16))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return `data:image/svg+xml;base64,${svgBase64}`;
|
||||||
|
},
|
||||||
|
updateUserInfo: async (opts?: { username: string; nickname: string; password?: string }) => {
|
||||||
|
if (!opts) return;
|
||||||
|
const newUsername = opts.username;
|
||||||
|
const newNickname = opts.nickname;
|
||||||
|
const update = {
|
||||||
|
username: newUsername,
|
||||||
|
nickname: newNickname
|
||||||
|
}
|
||||||
|
if (opts.password) {
|
||||||
|
(update as any).password = opts.password;
|
||||||
|
}
|
||||||
|
const res = await queryLogin.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'updateSelf',
|
||||||
|
data: update,
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
const currentInfo = get().userInfo;
|
||||||
|
if (currentInfo) {
|
||||||
|
set({
|
||||||
|
userInfo: {
|
||||||
|
...currentInfo,
|
||||||
|
username: newUsername,
|
||||||
|
nickname: newNickname
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success('更新用户信息成功, 请手动关闭页面');
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '更新用户信息失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
15
src/apps/home/chat.ts
Normal file
15
src/apps/home/chat.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
export const postChat = async (question: string) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'noco-life',
|
||||||
|
key: 'chat',
|
||||||
|
payload: { question },
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data?.content;
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || 'Failed to get chat response');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
91
src/apps/home/index.tsx
Normal file
91
src/apps/home/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { app } from '../ai';
|
||||||
|
import { Sender, XProvider, Bubble } from '@ant-design/x';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { postChat } from './chat';
|
||||||
|
import { Nav } from '../nav';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import { useHomeStore } from './store';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { Beian } from '../beian';
|
||||||
|
const useFocus = () => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
// Focus the input element inside Sender component
|
||||||
|
const focusInput = () => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
const input = inputRef.current.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus on mount
|
||||||
|
focusInput();
|
||||||
|
|
||||||
|
// Also focus after a short delay to ensure everything is rendered
|
||||||
|
const timeoutId = setTimeout(focusInput, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return inputRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
const inputRef = useFocus();
|
||||||
|
const [content, setContent] = useState<string>('');
|
||||||
|
const { inputValue, setInputValue, isLoading, setLoading, messages, addMessage } = useHomeStore();
|
||||||
|
|
||||||
|
const msg = useMemo(() => {
|
||||||
|
return <Bubble.List items={messages} autoScroll ></Bubble.List>
|
||||||
|
}, [messages]);
|
||||||
|
return <div className='container mx-auto px-4 py-4 md:p-4 flex flex-col' style={{ height: 'calc(100vh - 64px)' }}>
|
||||||
|
<div className='flex-shrink-0 mb-4 w-full mx-auto px-4 md:px-0' ref={inputRef}>
|
||||||
|
<Sender
|
||||||
|
allowSpeech
|
||||||
|
value={inputValue}
|
||||||
|
onChange={setInputValue}
|
||||||
|
loading={isLoading}
|
||||||
|
onSubmit={async (message) => {
|
||||||
|
console.log('Submitted', message);
|
||||||
|
setLoading(true);
|
||||||
|
addMessage({ role: 'user', content: message, key: nanoid() });
|
||||||
|
|
||||||
|
const res = await postChat(message);
|
||||||
|
setContent(res || ' ');
|
||||||
|
setLoading(false);
|
||||||
|
addMessage({ role: 'assistant', content: res || ' ', key: nanoid() });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 overflow-y-auto px-2 md:px-0 mb-4'>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
</div >;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const AppProvider = () => {
|
||||||
|
return <XProvider
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#000000',
|
||||||
|
colorBgBase: '#ffffff',
|
||||||
|
colorTextBase: '#000000',
|
||||||
|
colorBorder: '#d9d9d9',
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
colorBgElevated: '#ffffff',
|
||||||
|
colorBgLayout: '#ffffff',
|
||||||
|
colorText: '#000000',
|
||||||
|
colorTextSecondary: '#666666',
|
||||||
|
colorTextTertiary: '#999999',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Nav />
|
||||||
|
<App />
|
||||||
|
<ToastContainer />
|
||||||
|
<Beian />
|
||||||
|
</XProvider>;
|
||||||
|
}
|
||||||
33
src/apps/home/store.ts
Normal file
33
src/apps/home/store.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @title Home Store
|
||||||
|
* @description 管理 home 页面的输入框数据和加载状态
|
||||||
|
* @tags zustand, state-management, input, loading
|
||||||
|
* @createdAt 2025-12-04
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface HomeState {
|
||||||
|
// 输入框内容
|
||||||
|
inputValue: string;
|
||||||
|
// 加载状态
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setInputValue: (value: string) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
messages: { role: 'user' | 'assistant'; content: string, key: string }[];
|
||||||
|
addMessage: (message: { role: 'user' | 'assistant'; content: string, key: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHomeStore = create<HomeState>((set) => ({
|
||||||
|
inputValue: '',
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
setInputValue: (value) => set({ inputValue: value }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
messages: [],
|
||||||
|
addMessage: (message) => {
|
||||||
|
set((state) => ({ messages: [...state.messages, message] }))
|
||||||
|
}
|
||||||
|
}));
|
||||||
112
src/apps/nav/index.tsx
Normal file
112
src/apps/nav/index.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useUserStore } from "./store.ts";
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import '@kevisual/kv-login';
|
||||||
|
import { clearCode } from '@kevisual/kv-login'
|
||||||
|
import { useContextKey } from "@kevisual/context";
|
||||||
|
import { toast as message } from 'react-toastify';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { queryLogin as query } from '@/modules/query';
|
||||||
|
export const checkPluginLogin = async () => {
|
||||||
|
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 query.getMe();
|
||||||
|
if (me.code === 200) {
|
||||||
|
message.success('登录插件中...');
|
||||||
|
const token = await query.cacheStore.getAccessToken();
|
||||||
|
const newRedirectUrl = new URL(redirectUrl);
|
||||||
|
newRedirectUrl.searchParams.set('token', token + '');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.open(newRedirectUrl.toString(), '_blank');
|
||||||
|
clearCode();
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
clearCode();
|
||||||
|
const state = useUserStore.getState()
|
||||||
|
state.setOpen(true);
|
||||||
|
}
|
||||||
|
// 刷新token失败,登陆页自己跳转
|
||||||
|
}
|
||||||
|
console.log('checkKey', checkKey, redirectUrl);
|
||||||
|
}
|
||||||
|
export const LoginComponent = ({ onLoginSuccess }: { onLoginSuccess: () => void }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
// 监听登录成功事件
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
console.log('监听到登录成功事件,关闭弹窗');
|
||||||
|
onLoginSuccess();
|
||||||
|
|
||||||
|
};
|
||||||
|
const loginEmitter = useContextKey('login-emitter')
|
||||||
|
console.log('KvLogin Types:', loginEmitter);
|
||||||
|
|
||||||
|
loginEmitter.on('login-success', handleLoginSuccess);
|
||||||
|
|
||||||
|
// 清理监听器
|
||||||
|
return () => {
|
||||||
|
loginEmitter.off('login-success', handleLoginSuccess);
|
||||||
|
};
|
||||||
|
}, [onLoginSuccess]);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return (<kv-login><div id="weixinLogin"></div></kv-login>)
|
||||||
|
}
|
||||||
|
export const Nav = () => {
|
||||||
|
const store = useUserStore(useShallow((state) => ({
|
||||||
|
user: state.user,
|
||||||
|
open: state.open,
|
||||||
|
setOpen: state.setOpen,
|
||||||
|
setUser: state.setUser,
|
||||||
|
clearUser: state.clearUser,
|
||||||
|
queryUser: state.queryUser,
|
||||||
|
init: state.init,
|
||||||
|
})));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
store.queryUser();
|
||||||
|
store.init();
|
||||||
|
checkPluginLogin();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
store.setOpen(false);
|
||||||
|
store.queryUser();
|
||||||
|
};
|
||||||
|
return <header>
|
||||||
|
<nav className="bg-black p-4 text-white flex justify-between">
|
||||||
|
<div className="text-lg font-bold">人生可视化助手</div>
|
||||||
|
<div>
|
||||||
|
{store.user ? (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{store.user.avatar && <img src={store.user.avatar} alt="Avatar" className="w-8 h-8 rounded-full" />}
|
||||||
|
<span>{store.user.username}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => store.clearUser()}
|
||||||
|
className="bg-gray-700 text-white px-3 py-1 rounded hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Dialog open={store.open} onOpenChange={store.setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button className="bg-gray-700 text-white px-3 py-1 rounded hover:bg-gray-600 transition-colors">
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="text-black text-xl font-bold border-b border-black pb-3">登录</DialogHeader>
|
||||||
|
<LoginComponent onLoginSuccess={handleLoginSuccess} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
67
src/apps/nav/store.ts
Normal file
67
src/apps/nav/store.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { basename, wrapBasename } from '@/modules/basename';
|
||||||
|
import { queryLogin } from '@/modules/query';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
user: {
|
||||||
|
avatar?: string;
|
||||||
|
description?: string;
|
||||||
|
id?: string;
|
||||||
|
needChangePassword?: boolean;
|
||||||
|
orgs?: string[];
|
||||||
|
type?: string;
|
||||||
|
username?: string;
|
||||||
|
canChangeUsername?: boolean;
|
||||||
|
} | null;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
setUser: (user: UserState['user']) => void;
|
||||||
|
clearUser: () => Promise<void>;
|
||||||
|
queryUser: () => void;
|
||||||
|
queryMe: (token?: string) => void;
|
||||||
|
init: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserStore = create<UserState>((set, get) => ({
|
||||||
|
user: null,
|
||||||
|
open: false,
|
||||||
|
setOpen: (open) => set({ open }),
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
clearUser: async () => {
|
||||||
|
await queryLogin.logout()
|
||||||
|
set({ user: null });
|
||||||
|
},
|
||||||
|
queryUser: async () => {
|
||||||
|
const user = await queryLogin.checkLocalUser();
|
||||||
|
console.log('查询到的用户信息:', user);
|
||||||
|
if (!user) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
get().queryMe(token);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({ user });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
queryMe: async (token?: string) => {
|
||||||
|
const res = await queryLogin.getMe(token);
|
||||||
|
console.log('获取到的用户信息:', res);
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ user: res.data || null });
|
||||||
|
const canChangeUsername = res.data?.canChangeUsername ?? false;
|
||||||
|
if (canChangeUsername) {
|
||||||
|
// 打开修改用户名的页面
|
||||||
|
window.open(wrapBasename('/first'), '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init: () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
if (code && state) {
|
||||||
|
set({ open: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { app } from '../ai';
|
import { app } from '../ai';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { local } from '@/modules/query';
|
import { local } from '@/modules/query';
|
||||||
import '@kevisual/kv-login'
|
import { AuthProvider } from '../auth';
|
||||||
|
|
||||||
const getAppRoutes = () => {
|
const getAppRoutes = () => {
|
||||||
const appRoutes = app.routes.map((route) => {
|
const appRoutes = app.routes.map((route) => {
|
||||||
return {
|
return {
|
||||||
@@ -14,12 +15,6 @@ const getAppRoutes = () => {
|
|||||||
return appRoutes;
|
return appRoutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchTest = async () => {
|
|
||||||
const url = 'http://localhost:51015/api/router';
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('Fetch Test Data:', data);
|
|
||||||
}
|
|
||||||
const fetchLocal = async () => {
|
const fetchLocal = async () => {
|
||||||
const res = await local.post({
|
const res = await local.post({
|
||||||
path: 'client',
|
path: 'client',
|
||||||
@@ -34,6 +29,7 @@ const dynamicImport = async () => {
|
|||||||
console.log('Test Function Output:', module.test());
|
console.log('Test Function Output:', module.test());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const [appRoutes, setAppRoutes] = useState(getAppRoutes());
|
const [appRoutes, setAppRoutes] = useState(getAppRoutes());
|
||||||
|
|
||||||
@@ -53,18 +49,12 @@ export const App = () => {
|
|||||||
setAppRoutes(getAppRoutes());
|
setAppRoutes(getAppRoutes());
|
||||||
}
|
}
|
||||||
}>{JSON.stringify(appRoutes, null, 2)}</pre>
|
}>{JSON.stringify(appRoutes, null, 2)}</pre>
|
||||||
|
|
||||||
<kv-login>
|
|
||||||
<div id="weixinLogin"></div>
|
|
||||||
</kv-login>
|
|
||||||
</div >;
|
</div >;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom element to JSX namespace for TypeScript
|
|
||||||
declare global {
|
export const AppProvider = () => {
|
||||||
namespace JSX {
|
return <AuthProvider>
|
||||||
interface IntrinsicElements {
|
<App />
|
||||||
'kv-login': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
|
</AuthProvider>
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
BIN
src/assets/user-name-bg.jpg
Normal file
BIN
src/assets/user-name-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
145
src/components/ui/dialog.tsx
Normal file
145
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { VisuallyHidden } from "./visually-hidden"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||||
|
</VisuallyHidden>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
168
src/components/ui/navigation-menu.tsx
Normal file
168
src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
}
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
16
src/components/ui/visually-hidden.tsx
Normal file
16
src/components/ui/visually-hidden.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function VisuallyHidden({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute h-px w-px p-0 -m-px overflow-hidden whitespace-nowrap border-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { VisuallyHidden }
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export const basename = BASE_NAME;
|
export const basename = BASE_NAME;
|
||||||
|
|
||||||
console.log(basename);
|
console.log(basename);
|
||||||
|
|
||||||
|
export const wrapBasename = (path: string) => {
|
||||||
|
const hasEnd = path.endsWith('/')
|
||||||
|
if (basename) {
|
||||||
|
return `${basename}${path}` + (hasEnd ? '' : '/');
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Query } from "@kevisual/query";
|
import { QueryClient } from "@kevisual/query";
|
||||||
|
import { QueryLoginBrowser } from '@kevisual/query-login';
|
||||||
|
|
||||||
|
export const query = new QueryClient();
|
||||||
|
|
||||||
export const query = new Query();
|
export const queryLogin = new QueryLoginBrowser({
|
||||||
|
query
|
||||||
export const local = new Query({
|
})
|
||||||
|
export const local = new QueryClient({
|
||||||
url: '/client/router'
|
url: '/client/router'
|
||||||
});
|
});
|
||||||
10
src/pages/first.astro
Normal file
10
src/pages/first.astro
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
import Html from '@/components/html.astro';
|
||||||
|
import { AppProvider } from '@/apps/config/firstLogin';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Html title='可视化平台'>
|
||||||
|
<main>
|
||||||
|
<AppProvider client:only />
|
||||||
|
</main>
|
||||||
|
</Html>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
import Html from '@/components/html.astro';
|
import Html from '@/components/html.astro';
|
||||||
import { App } from '@/apps/web-command';
|
import { AppProvider } from '@/apps/home';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Html>
|
<Html title='可视化平台'>
|
||||||
<main>
|
<main>
|
||||||
<App client:only/>
|
<AppProvider client:only />
|
||||||
</main>
|
</main>
|
||||||
</Html>
|
</Html>
|
||||||
|
|||||||
Reference in New Issue
Block a user