@svton/cli 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +7 -4
- package/dist/index.mjs +7 -4
- package/package.json +2 -1
- package/templates/apps/admin/next-env.d.ts +2 -0
- package/templates/apps/admin/next.config.js +15 -0
- package/templates/apps/admin/package.json.tpl +54 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/app/globals.css +37 -0
- package/templates/apps/admin/src/app/layout.tsx +19 -0
- package/templates/apps/admin/src/app/login/page.tsx +96 -0
- package/templates/apps/admin/src/app/page.tsx +8 -0
- package/templates/apps/admin/src/app/users/page.tsx +165 -0
- package/templates/apps/admin/src/components/ui/switch.tsx +29 -0
- package/templates/apps/admin/src/hooks/useAPI.ts +130 -0
- package/templates/apps/admin/src/lib/api-client.ts +100 -0
- package/templates/apps/admin/tailwind.config.js +54 -0
- package/templates/apps/admin/tsconfig.json +22 -0
- package/templates/apps/backend/.env.example +14 -0
- package/templates/apps/backend/nest-cli.json +8 -0
- package/templates/apps/backend/package.json.tpl +57 -0
- package/templates/apps/backend/prisma/schema.prisma +72 -0
- package/templates/apps/backend/prisma/seed.ts +32 -0
- package/templates/apps/backend/src/app.controller.ts +15 -0
- package/templates/apps/backend/src/app.module.ts +19 -0
- package/templates/apps/backend/src/app.service.ts +12 -0
- package/templates/apps/backend/src/auth/auth.controller.ts +31 -0
- package/templates/apps/backend/src/auth/auth.module.ts +27 -0
- package/templates/apps/backend/src/auth/auth.service.ts +89 -0
- package/templates/apps/backend/src/auth/jwt-auth.guard.ts +5 -0
- package/templates/apps/backend/src/auth/jwt.strategy.ts +27 -0
- package/templates/apps/backend/src/main.ts +40 -0
- package/templates/apps/backend/src/prisma/prisma.module.ts +9 -0
- package/templates/apps/backend/src/prisma/prisma.service.ts +13 -0
- package/templates/apps/backend/src/user/user.controller.ts +50 -0
- package/templates/apps/backend/src/user/user.module.ts +12 -0
- package/templates/apps/backend/src/user/user.service.ts +117 -0
- package/templates/apps/backend/tsconfig.json +23 -0
- package/templates/apps/mobile/babel.config.js +8 -0
- package/templates/apps/mobile/config/index.ts +65 -0
- package/templates/apps/mobile/package.json.tpl +48 -0
- package/templates/apps/mobile/project.config.json.tpl +17 -0
- package/templates/apps/mobile/src/app.config.ts +9 -0
- package/templates/apps/mobile/src/app.scss +4 -0
- package/templates/apps/mobile/src/app.ts +8 -0
- package/templates/apps/mobile/src/pages/index/index.scss +7 -0
- package/templates/apps/mobile/src/pages/index/index.tsx +49 -0
- package/templates/apps/mobile/tsconfig.json +21 -0
- package/templates/packages/types/package.json.tpl +16 -0
- package/templates/packages/types/src/api.ts +88 -0
- package/templates/packages/types/src/common.ts +89 -0
- package/templates/packages/types/src/index.ts +3 -0
- package/templates/packages/types/tsconfig.json +16 -0
package/dist/index.js
CHANGED
|
@@ -74,9 +74,12 @@ var import_fs_extra = __toESM(require("fs-extra"));
|
|
|
74
74
|
var import_path = __toESM(require("path"));
|
|
75
75
|
async function copyTemplateFiles(config) {
|
|
76
76
|
const { template, projectPath } = config;
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
const cliPackageRoot = import_path.default.dirname(__dirname);
|
|
78
|
+
let templateDir = import_path.default.join(cliPackageRoot, "templates");
|
|
79
|
+
if (!await import_fs_extra.default.pathExists(templateDir)) {
|
|
80
|
+
const frameworkRoot = import_path.default.dirname(import_path.default.dirname(cliPackageRoot));
|
|
81
|
+
templateDir = import_path.default.join(frameworkRoot, "templates");
|
|
82
|
+
}
|
|
80
83
|
logger.debug(`Copying template files from: ${templateDir}`);
|
|
81
84
|
if (!await import_fs_extra.default.pathExists(templateDir)) {
|
|
82
85
|
logger.warn("Template directory not found, using built-in minimal templates");
|
|
@@ -745,7 +748,7 @@ async function createProjectFromTemplate(config) {
|
|
|
745
748
|
}
|
|
746
749
|
|
|
747
750
|
// package.json
|
|
748
|
-
var version = "1.0.
|
|
751
|
+
var version = "1.0.4";
|
|
749
752
|
|
|
750
753
|
// src/index.ts
|
|
751
754
|
async function cli() {
|
package/dist/index.mjs
CHANGED
|
@@ -47,9 +47,12 @@ import fs from "fs-extra";
|
|
|
47
47
|
import path from "path";
|
|
48
48
|
async function copyTemplateFiles(config) {
|
|
49
49
|
const { template, projectPath } = config;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
const cliPackageRoot = path.dirname(__dirname);
|
|
51
|
+
let templateDir = path.join(cliPackageRoot, "templates");
|
|
52
|
+
if (!await fs.pathExists(templateDir)) {
|
|
53
|
+
const frameworkRoot = path.dirname(path.dirname(cliPackageRoot));
|
|
54
|
+
templateDir = path.join(frameworkRoot, "templates");
|
|
55
|
+
}
|
|
53
56
|
logger.debug(`Copying template files from: ${templateDir}`);
|
|
54
57
|
if (!await fs.pathExists(templateDir)) {
|
|
55
58
|
logger.warn("Template directory not found, using built-in minimal templates");
|
|
@@ -718,7 +721,7 @@ async function createProjectFromTemplate(config) {
|
|
|
718
721
|
}
|
|
719
722
|
|
|
720
723
|
// package.json
|
|
721
|
-
var version = "1.0.
|
|
724
|
+
var version = "1.0.4";
|
|
722
725
|
|
|
723
726
|
// src/index.ts
|
|
724
727
|
async function cli() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svton/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Svton CLI - Create full-stack applications with NestJS, Next.js, and Taro",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
32
|
"bin",
|
|
33
|
+
"templates",
|
|
33
34
|
"README.md",
|
|
34
35
|
"LICENSE"
|
|
35
36
|
],
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** @type {import('next').NextConfig} */
|
|
2
|
+
const nextConfig = {
|
|
3
|
+
reactStrictMode: true,
|
|
4
|
+
transpilePackages: ['@svton/hooks'],
|
|
5
|
+
images: {
|
|
6
|
+
remotePatterns: [
|
|
7
|
+
{
|
|
8
|
+
protocol: 'https',
|
|
9
|
+
hostname: '**',
|
|
10
|
+
},
|
|
11
|
+
],
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
module.exports = nextConfig;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{ORG_NAME}}/admin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "{{PROJECT_NAME}} 管理后台",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev -p 3001",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start -p 3001",
|
|
9
|
+
"lint": "next lint",
|
|
10
|
+
"type-check": "tsc --noEmit",
|
|
11
|
+
"clean": "rm -rf .next"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@hookform/resolvers": "^3.3.3",
|
|
15
|
+
"@radix-ui/react-avatar": "^1.0.4",
|
|
16
|
+
"@radix-ui/react-dialog": "^1.0.5",
|
|
17
|
+
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
|
18
|
+
"@radix-ui/react-label": "^2.0.2",
|
|
19
|
+
"@radix-ui/react-select": "^2.0.0",
|
|
20
|
+
"@radix-ui/react-separator": "^1.0.3",
|
|
21
|
+
"@radix-ui/react-slot": "^1.0.2",
|
|
22
|
+
"@radix-ui/react-switch": "^1.2.6",
|
|
23
|
+
"@radix-ui/react-tabs": "^1.0.4",
|
|
24
|
+
"@radix-ui/react-toast": "^1.1.5",
|
|
25
|
+
"@svton/api-client": "^1.0.0",
|
|
26
|
+
"@svton/hooks": "^1.0.0",
|
|
27
|
+
"@svton/types": "^1.0.0",
|
|
28
|
+
"axios": "^1.7.9",
|
|
29
|
+
"class-variance-authority": "^0.7.1",
|
|
30
|
+
"clsx": "^2.1.1",
|
|
31
|
+
"dayjs": "^1.11.13",
|
|
32
|
+
"lucide-react": "^0.462.0",
|
|
33
|
+
"next": "^15.0.0",
|
|
34
|
+
"react": "^19.0.0",
|
|
35
|
+
"react-dom": "^19.0.0",
|
|
36
|
+
"react-hook-form": "^7.49.0",
|
|
37
|
+
"swr": "^2.2.5",
|
|
38
|
+
"tailwind-merge": "^3.0.0",
|
|
39
|
+
"zod": "^3.22.4",
|
|
40
|
+
"zustand": "^5.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"@types/react": "^19.0.0",
|
|
45
|
+
"@types/react-dom": "^19.0.0",
|
|
46
|
+
"autoprefixer": "^10.4.22",
|
|
47
|
+
"eslint": "^9.0.0",
|
|
48
|
+
"eslint-config-next": "^15.0.0",
|
|
49
|
+
"postcss": "^8.5.0",
|
|
50
|
+
"tailwindcss": "^3.4.0",
|
|
51
|
+
"tailwindcss-animate": "^1.0.7",
|
|
52
|
+
"typescript": "^5.7.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 222.2 84% 4.9%;
|
|
9
|
+
--card: 0 0% 100%;
|
|
10
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
11
|
+
--popover: 0 0% 100%;
|
|
12
|
+
--popover-foreground: 222.2 84% 4.9%;
|
|
13
|
+
--primary: 222.2 47.4% 11.2%;
|
|
14
|
+
--primary-foreground: 210 40% 98%;
|
|
15
|
+
--secondary: 210 40% 96.1%;
|
|
16
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
17
|
+
--muted: 210 40% 96.1%;
|
|
18
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
19
|
+
--accent: 210 40% 96.1%;
|
|
20
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
21
|
+
--destructive: 0 84.2% 60.2%;
|
|
22
|
+
--destructive-foreground: 210 40% 98%;
|
|
23
|
+
--border: 214.3 31.8% 91.4%;
|
|
24
|
+
--input: 214.3 31.8% 91.4%;
|
|
25
|
+
--ring: 222.2 84% 4.9%;
|
|
26
|
+
--radius: 0.5rem;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@layer base {
|
|
31
|
+
* {
|
|
32
|
+
@apply border-border;
|
|
33
|
+
}
|
|
34
|
+
body {
|
|
35
|
+
@apply bg-background text-foreground;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import './globals.css';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: '管理后台',
|
|
6
|
+
description: '项目管理后台',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<html lang="zh-CN">
|
|
16
|
+
<body>{children}</body>
|
|
17
|
+
</html>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { usePersistFn } from '@svton/hooks';
|
|
6
|
+
import type { LoginDto } from '{{ORG_NAME}}/types';
|
|
7
|
+
|
|
8
|
+
export default function LoginPage() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [form, setForm] = useState<LoginDto>({ phone: '', password: '' });
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
|
|
14
|
+
const handleSubmit = usePersistFn(async (e: React.FormEvent) => {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
setError('');
|
|
17
|
+
setLoading(true);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
// 这里应该使用 @svton/api-client 的 API
|
|
21
|
+
// const response = await apiClient.auth.login(form);
|
|
22
|
+
// localStorage.setItem('token', response.data.accessToken);
|
|
23
|
+
|
|
24
|
+
// 模拟登录成功
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
26
|
+
router.push('/users');
|
|
27
|
+
} catch (err: any) {
|
|
28
|
+
setError(err.message || '登录失败');
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
36
|
+
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
|
|
37
|
+
<div>
|
|
38
|
+
<h2 className="text-center text-3xl font-bold text-gray-900">
|
|
39
|
+
管理后台
|
|
40
|
+
</h2>
|
|
41
|
+
<p className="mt-2 text-center text-sm text-gray-600">
|
|
42
|
+
请登录您的账号
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
47
|
+
{error && (
|
|
48
|
+
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded">
|
|
49
|
+
{error}
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<div className="space-y-4">
|
|
54
|
+
<div>
|
|
55
|
+
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
|
56
|
+
手机号
|
|
57
|
+
</label>
|
|
58
|
+
<input
|
|
59
|
+
id="phone"
|
|
60
|
+
type="tel"
|
|
61
|
+
required
|
|
62
|
+
value={form.phone}
|
|
63
|
+
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
|
64
|
+
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
65
|
+
placeholder="请输入手机号"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div>
|
|
70
|
+
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
|
71
|
+
密码
|
|
72
|
+
</label>
|
|
73
|
+
<input
|
|
74
|
+
id="password"
|
|
75
|
+
type="password"
|
|
76
|
+
required
|
|
77
|
+
value={form.password}
|
|
78
|
+
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
79
|
+
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
80
|
+
placeholder="请输入密码"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<button
|
|
86
|
+
type="submit"
|
|
87
|
+
disabled={loading}
|
|
88
|
+
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
89
|
+
>
|
|
90
|
+
{loading ? '登录中...' : '登录'}
|
|
91
|
+
</button>
|
|
92
|
+
</form>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { usePersistFn } from '@svton/hooks';
|
|
5
|
+
import type { UserVo, UserListParams } from '{{ORG_NAME}}/types';
|
|
6
|
+
|
|
7
|
+
export default function UsersPage() {
|
|
8
|
+
const [users, setUsers] = useState<UserVo[]>([]);
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
const [params, setParams] = useState<UserListParams>({
|
|
11
|
+
page: 1,
|
|
12
|
+
pageSize: 10,
|
|
13
|
+
});
|
|
14
|
+
const [total, setTotal] = useState(0);
|
|
15
|
+
|
|
16
|
+
const fetchUsers = usePersistFn(async () => {
|
|
17
|
+
setLoading(true);
|
|
18
|
+
try {
|
|
19
|
+
// 这里应该使用 @svton/api-client 的 API
|
|
20
|
+
// const response = await apiClient.users.list(params);
|
|
21
|
+
// setUsers(response.data.list);
|
|
22
|
+
// setTotal(response.data.total);
|
|
23
|
+
|
|
24
|
+
// 模拟数据
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
26
|
+
setUsers([
|
|
27
|
+
{
|
|
28
|
+
id: 1,
|
|
29
|
+
phone: '13800138000',
|
|
30
|
+
nickname: '测试用户',
|
|
31
|
+
avatar: '',
|
|
32
|
+
role: 'user',
|
|
33
|
+
status: 1,
|
|
34
|
+
createdAt: new Date().toISOString(),
|
|
35
|
+
updatedAt: new Date().toISOString(),
|
|
36
|
+
},
|
|
37
|
+
]);
|
|
38
|
+
setTotal(1);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('获取用户列表失败', error);
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
fetchUsers();
|
|
48
|
+
}, [params]);
|
|
49
|
+
|
|
50
|
+
const handlePageChange = usePersistFn((page: number) => {
|
|
51
|
+
setParams({ ...params, page });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="min-h-screen bg-gray-50">
|
|
56
|
+
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
57
|
+
<div className="px-4 py-6 sm:px-0">
|
|
58
|
+
<div className="bg-white rounded-lg shadow">
|
|
59
|
+
<div className="px-6 py-4 border-b border-gray-200">
|
|
60
|
+
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{loading ? (
|
|
64
|
+
<div className="p-8 text-center text-gray-500">加载中...</div>
|
|
65
|
+
) : (
|
|
66
|
+
<>
|
|
67
|
+
<div className="overflow-x-auto">
|
|
68
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
69
|
+
<thead className="bg-gray-50">
|
|
70
|
+
<tr>
|
|
71
|
+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
72
|
+
ID
|
|
73
|
+
</th>
|
|
74
|
+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
75
|
+
手机号
|
|
76
|
+
</th>
|
|
77
|
+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
78
|
+
昵称
|
|
79
|
+
</th>
|
|
80
|
+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
81
|
+
角色
|
|
82
|
+
</th>
|
|
83
|
+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
84
|
+
状态
|
|
85
|
+
</th>
|
|
86
|
+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
87
|
+
创建时间
|
|
88
|
+
</th>
|
|
89
|
+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
90
|
+
操作
|
|
91
|
+
</th>
|
|
92
|
+
</tr>
|
|
93
|
+
</thead>
|
|
94
|
+
<tbody className="bg-white divide-y divide-gray-200">
|
|
95
|
+
{users.map((user) => (
|
|
96
|
+
<tr key={user.id}>
|
|
97
|
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
98
|
+
{user.id}
|
|
99
|
+
</td>
|
|
100
|
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
101
|
+
{user.phone}
|
|
102
|
+
</td>
|
|
103
|
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
104
|
+
{user.nickname}
|
|
105
|
+
</td>
|
|
106
|
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
107
|
+
{user.role}
|
|
108
|
+
</td>
|
|
109
|
+
<td className="px-6 py-4 whitespace-nowrap">
|
|
110
|
+
<span
|
|
111
|
+
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
|
112
|
+
user.status === 1
|
|
113
|
+
? 'bg-green-100 text-green-800'
|
|
114
|
+
: 'bg-red-100 text-red-800'
|
|
115
|
+
}`}
|
|
116
|
+
>
|
|
117
|
+
{user.status === 1 ? '启用' : '禁用'}
|
|
118
|
+
</span>
|
|
119
|
+
</td>
|
|
120
|
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
121
|
+
{new Date(user.createdAt).toLocaleDateString()}
|
|
122
|
+
</td>
|
|
123
|
+
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
124
|
+
<button className="text-blue-600 hover:text-blue-900 mr-4">
|
|
125
|
+
编辑
|
|
126
|
+
</button>
|
|
127
|
+
<button className="text-red-600 hover:text-red-900">
|
|
128
|
+
删除
|
|
129
|
+
</button>
|
|
130
|
+
</td>
|
|
131
|
+
</tr>
|
|
132
|
+
))}
|
|
133
|
+
</tbody>
|
|
134
|
+
</table>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
|
138
|
+
<div className="text-sm text-gray-700">
|
|
139
|
+
共 {total} 条记录
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex gap-2">
|
|
142
|
+
<button
|
|
143
|
+
onClick={() => handlePageChange(params.page! - 1)}
|
|
144
|
+
disabled={params.page === 1}
|
|
145
|
+
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
146
|
+
>
|
|
147
|
+
上一页
|
|
148
|
+
</button>
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => handlePageChange(params.page! + 1)}
|
|
151
|
+
disabled={params.page! * params.pageSize! >= total}
|
|
152
|
+
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
153
|
+
>
|
|
154
|
+
下一页
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
|
|
8
|
+
const Switch = React.forwardRef<
|
|
9
|
+
React.ElementRef<typeof SwitchPrimitives.Root>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
|
11
|
+
>(({ className, ...props }, ref) => (
|
|
12
|
+
<SwitchPrimitives.Root
|
|
13
|
+
className={cn(
|
|
14
|
+
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
15
|
+
className
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
ref={ref}
|
|
19
|
+
>
|
|
20
|
+
<SwitchPrimitives.Thumb
|
|
21
|
+
className={cn(
|
|
22
|
+
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
|
23
|
+
)}
|
|
24
|
+
/>
|
|
25
|
+
</SwitchPrimitives.Root>
|
|
26
|
+
))
|
|
27
|
+
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
28
|
+
|
|
29
|
+
export { Switch }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin 端 useAPI Hook
|
|
3
|
+
* 基于 SWR 提供高级功能:
|
|
4
|
+
* - 自动缓存和重新验证
|
|
5
|
+
* - 乐观更新
|
|
6
|
+
* - 依赖刷新
|
|
7
|
+
* - 条件请求
|
|
8
|
+
* - 分页支持
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import useSWR, { type SWRConfiguration, type SWRResponse } from 'swr';
|
|
12
|
+
import useSWRInfinite, { type SWRInfiniteConfiguration } from 'swr/infinite';
|
|
13
|
+
import useSWRMutation, { type SWRMutationConfiguration } from 'swr/mutation';
|
|
14
|
+
import { apiAsync } from '@/lib/api-client';
|
|
15
|
+
import type { ApiName, ApiParams, ApiResponse } from '@svton/api-client';
|
|
16
|
+
// 引入类型定义以启用模块增强
|
|
17
|
+
import '@svton/types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 生成 SWR key
|
|
21
|
+
*/
|
|
22
|
+
function generateKey<K extends ApiName>(apiName: K, params?: ApiParams<K>): string | null {
|
|
23
|
+
if (params === undefined || params === null) {
|
|
24
|
+
return null; // 条件请求:params 为空时不发起请求
|
|
25
|
+
}
|
|
26
|
+
return JSON.stringify([apiName, params]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SWR fetcher
|
|
31
|
+
*/
|
|
32
|
+
async function fetcher<K extends ApiName>(key: string): Promise<ApiResponse<K>> {
|
|
33
|
+
const [apiName, params] = JSON.parse(key);
|
|
34
|
+
return (
|
|
35
|
+
params !== undefined
|
|
36
|
+
? await (apiAsync as any)(apiName, params)
|
|
37
|
+
: await (apiAsync as any)(apiName)
|
|
38
|
+
) as ApiResponse<K>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* useQuery Hook - 用于数据获取(GET 请求)
|
|
43
|
+
*
|
|
44
|
+
* 特性:
|
|
45
|
+
* - ✅ 自动缓存:相同请求自动复用缓存
|
|
46
|
+
* - ✅ 自动重新验证:窗口聚焦、网络恢复时自动刷新
|
|
47
|
+
* - ✅ 依赖刷新:params 变化时自动重新请求
|
|
48
|
+
* - ✅ 条件请求:params 为 null 时不发起请求
|
|
49
|
+
* - ✅ 乐观更新:支持 mutate 进行本地更新
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```tsx
|
|
53
|
+
* function ContentList() {
|
|
54
|
+
* const { data, error, isLoading, mutate } = useQuery('GET:/contents', {
|
|
55
|
+
* page: 1,
|
|
56
|
+
* pageSize: 20
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* if (isLoading) return <div>加载中...</div>;
|
|
60
|
+
* if (error) return <div>错误: {error.message}</div>;
|
|
61
|
+
*
|
|
62
|
+
* return <div>{data?.items.map(...)}</div>;
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function useQuery<K extends ApiName>(
|
|
67
|
+
apiName: K,
|
|
68
|
+
params?: ApiParams<K> | null,
|
|
69
|
+
config?: SWRConfiguration<ApiResponse<K>>,
|
|
70
|
+
): SWRResponse<ApiResponse<K>> {
|
|
71
|
+
const key = generateKey(apiName, params as ApiParams<K>);
|
|
72
|
+
|
|
73
|
+
return useSWR<ApiResponse<K>>(key, fetcher, {
|
|
74
|
+
revalidateOnFocus: true, // 窗口聚焦时重新验证
|
|
75
|
+
revalidateOnReconnect: true, // 网络恢复时重新验证
|
|
76
|
+
dedupingInterval: 2000, // 2秒内去重
|
|
77
|
+
...config,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* useMutation Hook - 用于数据提交(POST/PUT/DELETE 请求)
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```tsx
|
|
86
|
+
* function CreateContent() {
|
|
87
|
+
* const { trigger, isMutating } = useMutation('POST:/contents');
|
|
88
|
+
*
|
|
89
|
+
* const handleSubmit = async (formData) => {
|
|
90
|
+
* try {
|
|
91
|
+
* const result = await trigger({
|
|
92
|
+
* title: formData.title,
|
|
93
|
+
* body: formData.body,
|
|
94
|
+
* });
|
|
95
|
+
* console.log('Created:', result);
|
|
96
|
+
* } catch (error) {
|
|
97
|
+
* console.error('Failed:', error);
|
|
98
|
+
* }
|
|
99
|
+
* };
|
|
100
|
+
*
|
|
101
|
+
* return <Button onClick={handleSubmit} loading={isMutating}>提交</Button>;
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function useMutation<K extends ApiName>(
|
|
106
|
+
apiName: K,
|
|
107
|
+
config?: SWRMutationConfiguration<ApiResponse<K>, Error, string, ApiParams<K>>,
|
|
108
|
+
) {
|
|
109
|
+
const key = apiName; // mutation 使用 apiName 作为 key
|
|
110
|
+
|
|
111
|
+
return useSWRMutation<ApiResponse<K>, Error, string, ApiParams<K>>(
|
|
112
|
+
key,
|
|
113
|
+
async (_key, { arg }) => {
|
|
114
|
+
return (
|
|
115
|
+
arg !== undefined ? await (apiAsync as any)(apiName, arg) : await (apiAsync as any)(apiName)
|
|
116
|
+
) as ApiResponse<K>;
|
|
117
|
+
},
|
|
118
|
+
config,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 手动刷新指定 key 的缓存
|
|
124
|
+
*/
|
|
125
|
+
export { mutate } from 'swr';
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 全局 SWR 配置
|
|
129
|
+
*/
|
|
130
|
+
export type { SWRConfiguration };
|