update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
46
src/components/html.astro
Normal file
46
src/components/html.astro
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
export interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
lang?: string;
|
||||
charset?: string;
|
||||
}
|
||||
|
||||
const { title = 'Light Code', description = 'A lightweight code editor', lang = 'zh-CN', charset = 'UTF-8' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset={charset} />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<meta name='description' content={description} />
|
||||
<title>{title}</title>
|
||||
<!-- 样式 -->
|
||||
<slot name='head' />
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
|
||||
<!-- 脚本 -->
|
||||
<slot name='scripts' />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
html {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
17
src/content.config.ts
Normal file
17
src/content.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// @ts-ignore
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob, file } from 'astro/loaders'; // 不适用于旧版 API
|
||||
|
||||
const docs = defineCollection({
|
||||
// loader: glob({ pattern: '**/*.md', base: './src/data/blogs' }),
|
||||
loader: glob({ pattern: '**/[^_]*.md', base: './src/data/docs' }),
|
||||
schema: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
// pubDate: z.coerce.date(),
|
||||
// updatedDate: z.coerce.date().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
export const collections = { docs };
|
||||
16
src/data/docs/home.md
Normal file
16
src/data/docs/home.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 首页描述
|
||||
|
||||
所有的页面的登录入口在当前页面,
|
||||
|
||||
## 功能设计
|
||||
|
||||
- 登录
|
||||
- 记录文本
|
||||
- 上传文件和内容
|
||||
- 导航配置
|
||||
- 命令执行
|
||||
- 历史记录
|
||||
|
||||
## 任务管理
|
||||
|
||||
- [ ] 登录功能
|
||||
8
src/data/docs/simpalte-template.md
Normal file
8
src/data/docs/simpalte-template.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: 'astro 例子'
|
||||
tags: ['astro', 'simple', 'template']
|
||||
---
|
||||
|
||||
## astro-simplate-template
|
||||
|
||||
astro 是一个非常好的
|
||||
24
src/layouts/blank.astro
Normal file
24
src/layouts/blank.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import '../styles/theme.css';
|
||||
---
|
||||
|
||||
<html lang='zh-CN'>
|
||||
<head>
|
||||
<meta charset='UTF-8' />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Pages</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
61
src/layouts/mdx.astro
Normal file
61
src/layouts/mdx.astro
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
export interface Props {
|
||||
children: any;
|
||||
}
|
||||
import '../styles/global.css';
|
||||
import '../styles/theme.css';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
export interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
lang?: string;
|
||||
charset?: string;
|
||||
}
|
||||
|
||||
const { title = 'Light Code', description = 'A lightweight code editor', lang = 'zh-CN', charset = 'UTF-8' } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang='zh-CN'>
|
||||
<head>
|
||||
<meta charset='UTF-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<title>Docs</title>
|
||||
<link
|
||||
rel='stylesheet'
|
||||
href='https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown-light.min.css'
|
||||
integrity='sha512-X175XRJAO6PHAUi8AA7GP8uUF5Wiv+w9bOi64i02CHKDQBsO1yy0jLSKaUKg/NhRCDYBmOLQCfKaTaXiyZlLrw=='
|
||||
crossorigin='anonymous'
|
||||
referrerpolicy='no-referrer'
|
||||
/>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<slot name='header' />
|
||||
</div>
|
||||
<main class='p-2 flex-1 overflow-hidden'>
|
||||
<div class='markdown-body h-full scrollbar border-gray-200 overflow-auto px-6 py-2 w-[800px] border my-4 rounded-md shadow-md'>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<slot name='footer'>
|
||||
<p>Copyrignt © 2025</p>
|
||||
</slot>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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 +1,4 @@
|
||||
// @ts-ignore
|
||||
export const basename = DEV_SERVER ? '/' : BASE_NAME;
|
||||
export const basename = BASE_NAME;
|
||||
|
||||
console.log(basename);
|
||||
@@ -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;
|
||||
});
|
||||
9
src/pages/demos/base.astro
Normal file
9
src/pages/demos/base.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import Html from '@/components/html.astro';
|
||||
---
|
||||
|
||||
<Html>
|
||||
<main>
|
||||
|
||||
</main>
|
||||
</Html>
|
||||
23
src/pages/docs/[...id].astro
Normal file
23
src/pages/docs/[...id].astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Main from '@/layouts/mdx.astro';
|
||||
// 1. 为每个集合条目生成一个新路径
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('docs');
|
||||
return posts.map((post) => ({
|
||||
params: { id: post.id },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
type Post = {
|
||||
data: { title: string };
|
||||
};
|
||||
// 2. 对于你的模板,你可以直接从 prop 获取条目
|
||||
const { post } = Astro.props as { post: Post };
|
||||
const { Content } = await render(post);
|
||||
---
|
||||
|
||||
<Main>
|
||||
<!-- <h1 slot={'header'}>{post.data.title}</h1> -->
|
||||
<Content />
|
||||
</Main>
|
||||
23
src/pages/docs/index.astro
Normal file
23
src/pages/docs/index.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
const posts = await getCollection('docs');
|
||||
console.log('post', posts);
|
||||
import { basename } from '@/modules/basename';
|
||||
import Blank from '@/layouts/blank.astro';
|
||||
---
|
||||
|
||||
<Blank>
|
||||
<main class='max-w-3xl mx-auto'>
|
||||
<h1>My posts</h1>
|
||||
<ul class='p-2 m-2'>
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
{/* <a href={`${basename}/demo/${post.id}`}>{post.data.title}</a> */}
|
||||
<a href={`/docs/${post.id}/`}>{post.data.title}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</main>
|
||||
</Blank>
|
||||
46
src/pages/index.astro
Normal file
46
src/pages/index.astro
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
console.log('Hello from index.astro');
|
||||
import '../styles/global.css';
|
||||
---
|
||||
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<title>Home</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1 onclick="{onClick}">Welcome to my website!</h1>
|
||||
<div class='bg-amber-50 w-20 h-20 rounded-full'></div>
|
||||
<div id='root'></div>
|
||||
<script type='importmap' data-vite-ignore is:inline>
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@19.1.0",
|
||||
"react-dom": "https://esm.sh/react-dom@19.1.0/client.js",
|
||||
"react-toastify": "https://esm.sh/react-toastify@11.0.5"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type='module' data-vite-ignore is:inline>
|
||||
import { Button, message } from 'https://esm.sh/antd?standalone';
|
||||
import React from 'react';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import { createRoot } from 'react-dom';
|
||||
setTimeout(() => {
|
||||
toast.loading('Hello from index.astro');
|
||||
window.toast = toast;
|
||||
console.log('message', toast);
|
||||
}, 1000);
|
||||
console.log('Hello from index.astro', Button);
|
||||
const root = document.getElementById('root');
|
||||
const render = createRoot(root);
|
||||
const App = () => {
|
||||
const button = React.createElement(Button, null, 'Hello');
|
||||
const messageEl = React.createElement(ToastContainer, null, 'Hello');
|
||||
const wrapperMessage = React.createElement('div', null, [button, messageEl]);
|
||||
return wrapperMessage;
|
||||
};
|
||||
// render.render(React.createElement(Button, null, 'Hello'), root);
|
||||
render.render(App(), root);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
120
src/styles/global.css
Normal file
120
src/styles/global.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
98
src/styles/theme.css
Normal file
98
src/styles/theme.css
Normal file
@@ -0,0 +1,98 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
/* --color-primary: #ffc107;
|
||||
--color-secondary: #ffa000;
|
||||
--color-text-primary: #000000;
|
||||
--color-text-secondary: #000000;
|
||||
--color-success: #28a745; */
|
||||
|
||||
--color-scrollbar-thumb: #999999;
|
||||
--color-scrollbar-track: rgba(0, 0, 0, 0.1);
|
||||
--color-scrollbar-thumb-hover: #666666;
|
||||
--scrollbar-color: #ffc107;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
}
|
||||
|
||||
/* font-family */
|
||||
@utility font-family-mon {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
}
|
||||
|
||||
@utility font-family-rob {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
@utility font-family-int {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
@utility font-family-orb {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
}
|
||||
|
||||
@utility font-family-din {
|
||||
font-family: 'DIN', sans-serif;
|
||||
}
|
||||
|
||||
@utility flex-row-center {
|
||||
@apply flex flex-row items-center justify-center;
|
||||
}
|
||||
|
||||
@utility flex-col-center {
|
||||
@apply flex flex-col items-center justify-center;
|
||||
}
|
||||
|
||||
@utility scrollbar {
|
||||
overflow: auto;
|
||||
|
||||
/* 整个滚动条 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: var(--color-scrollbar-track);
|
||||
}
|
||||
|
||||
/* 滚动条有滑块的轨道部分 */
|
||||
&::-webkit-scrollbar-track-piece {
|
||||
background-color: transparent;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 滚动条滑块(竖向:vertical 横向:horizontal) */
|
||||
&::-webkit-scrollbar-thumb {
|
||||
cursor: pointer;
|
||||
background-color: var(--color-scrollbar-thumb);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* 滚动条滑块hover */
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* 同时有垂直和水平滚动条时交汇的部分 */
|
||||
&::-webkit-scrollbar-corner {
|
||||
display: block;
|
||||
/* 修复交汇时出现的白块 */
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
menu {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
@@ -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,81 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import LogoBannerH from '@/assets/logo-baner-h.png';
|
||||
|
||||
import { Beian } from '@/modules/beian/beian';
|
||||
import { useUserStore } from '../store';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useEffect } from 'react';
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
export const Layout = (props: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full h-full sm:bg-amber-100 flex sm:items-center sm:justify-center justify-normal items-start relative overflow-hidden',
|
||||
props?.className,
|
||||
)}>
|
||||
<div className='w-full h-full overflow-scroll sm:overflow-hidden sm:scrollbar flex sm:items-center sm:justify-center'>{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MainLayout = (props: Props) => {
|
||||
const config = useUserStore((state) => state.config);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'login-main w-[450px] min-h-[660px] bg-white mt-10 sm:mt-0 sm:shadow-lg rounded-md flex items-center flex-col relative ',
|
||||
props?.className,
|
||||
)}>
|
||||
{props.children}
|
||||
<p className='mt-5 text-xs text-center text-[#e69c36]'>
|
||||
登录即表示同意 <span className='font-medium text-[#e69c36]'>服务条款</span> 和 <span className='font-medium text-[#e69c36]'>隐私政策</span>
|
||||
</p>
|
||||
<Beian className=' mb-4' text={config?.beian} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoginWrapper = (props: Props) => {
|
||||
const config = useUserStore((state) => state.config);
|
||||
const loginWay = config?.loginWay || ['account'];
|
||||
const store = useUserStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
loginByWechat: state.loginByWechat,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const checkWechat = () => {
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
if (code && state) {
|
||||
store.loginByWechat(code);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
checkWechat();
|
||||
}, []);
|
||||
return (
|
||||
<Layout>
|
||||
<MainLayout className=''>
|
||||
<div
|
||||
className='mt-4'
|
||||
style={{
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
overflow: 'hidden',
|
||||
...config?.logoStyle,
|
||||
}}>
|
||||
<img src={config?.logo || LogoBannerH} />
|
||||
</div>
|
||||
<div className='mt-4 text-[#F39800] font-bold text-xl'>欢迎回来</div>
|
||||
{loginWay.length > 1 && <div className='text-sm text-yellow-400 mt-2'>请选择登陆方式</div>}
|
||||
<div>{props.children}</div>
|
||||
</MainLayout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -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,80 +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,
|
||||
};
|
||||
const [state, setState] = useState('');
|
||||
let timer = useRef<any>(null);
|
||||
const loginUrl = config?.wxmpLogin?.loginUrl || '';
|
||||
if (!loginUrl) {
|
||||
message.error('没有配置微信登陆配置');
|
||||
return;
|
||||
}
|
||||
const createQrcode = async (state: string) => {
|
||||
const text = `${loginUrl}?state=${state}`;
|
||||
var img = document.getElementById('qrcode')! as HTMLCanvasElement;
|
||||
// img.src = url;
|
||||
const res = await QRCode.toDataURL(img, text, opts);
|
||||
};
|
||||
const checkLogin = async (state: string) => {
|
||||
const res = await fetch(`/api/router?path=wx&key=checkLogin&state=${state}`).then((res) => res.json());
|
||||
if (res.code === 200) {
|
||||
console.log(res);
|
||||
const token = res.data;
|
||||
if (token) {
|
||||
localStorage.setItem('token', token.accessToken);
|
||||
await queryLogin.setLoginToken(token);
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectURL;
|
||||
}, 1000);
|
||||
} else {
|
||||
timer.current = setTimeout(() => {
|
||||
checkLogin(state);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// 随机生成一个state
|
||||
const state = Math.random().toString(36).substring(2, 15);
|
||||
createQrcode(state);
|
||||
checkLogin(state);
|
||||
const timer2 = setInterval(() => {
|
||||
const state = Math.random().toString(36).substring(2, 15);
|
||||
clearTimeout(timer.current); // 清除定时器
|
||||
createQrcode(state); // 90秒后更新二维码
|
||||
checkLogin(state);
|
||||
}, 90000);
|
||||
return () => {
|
||||
clearInterval(timer.current);
|
||||
clearInterval(timer2);
|
||||
};
|
||||
}, []);
|
||||
return { createQrcode };
|
||||
};
|
||||
export const WeChatMpLogin = () => {
|
||||
const userStore = useUserStore(
|
||||
useShallow((state) => {
|
||||
return { config: state.config! };
|
||||
}),
|
||||
);
|
||||
useCreateLoginQRCode(userStore.config);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<canvas id='qrcode' width='300' height='300'></canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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
src/vite-env.d.ts
vendored
7
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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user