trae-bot commited on
Commit
d629cd4
·
1 Parent(s): eba4f59

Track frontend files directly and fix Docker package copy

Browse files
frontend DELETED
@@ -1 +0,0 @@
1
- Subproject commit 1a5a16cecf142db89f5b4f8afa50849c9283949f
 
 
frontend/.eslintrc.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "extends": ["next/core-web-vitals", "next/typescript"]
3
+ }
frontend/.gitignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+ .yarn/install-state.gz
8
+
9
+ # testing
10
+ /coverage
11
+
12
+ # next.js
13
+ /.next/
14
+ /out/
15
+
16
+ # production
17
+ /build
18
+
19
+ # misc
20
+ .DS_Store
21
+ *.pem
22
+
23
+ # debug
24
+ npm-debug.log*
25
+ yarn-debug.log*
26
+ yarn-error.log*
27
+
28
+ # local env files
29
+ .env*.local
30
+
31
+ # vercel
32
+ .vercel
33
+
34
+ # typescript
35
+ *.tsbuildinfo
36
+ next-env.d.ts
frontend/README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
+
3
+ ## Getting Started
4
+
5
+ First, run the development server:
6
+
7
+ ```bash
8
+ npm run dev
9
+ # or
10
+ yarn dev
11
+ # or
12
+ pnpm dev
13
+ # or
14
+ bun dev
15
+ ```
16
+
17
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
+
19
+ You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
+
21
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
+
23
+ ## Learn More
24
+
25
+ To learn more about Next.js, take a look at the following resources:
26
+
27
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
+
30
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
+
32
+ ## Deploy on Vercel
33
+
34
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
+
36
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
frontend/next.config.mjs ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const backendInternalUrl = process.env.BACKEND_INTERNAL_URL || "http://127.0.0.1:3001";
3
+
4
+ const nextConfig = {
5
+ eslint: {
6
+ ignoreDuringBuilds: true,
7
+ },
8
+ async rewrites() {
9
+ return [
10
+ {
11
+ source: "/api/:path*",
12
+ destination: `${backendInternalUrl}/api/:path*`,
13
+ },
14
+ {
15
+ source: "/uploads/:path*",
16
+ destination: `${backendInternalUrl}/uploads/:path*`,
17
+ },
18
+ ];
19
+ },
20
+ };
21
+
22
+ export default nextConfig;
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "axios": "^1.13.6",
13
+ "class-variance-authority": "^0.7.1",
14
+ "clsx": "^2.1.1",
15
+ "lucide-react": "^0.577.0",
16
+ "next": "14.2.35",
17
+ "react": "^18",
18
+ "react-dom": "^18",
19
+ "tailwind-merge": "^3.5.0",
20
+ "zustand": "^5.0.12"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20",
24
+ "@types/react": "^18",
25
+ "@types/react-dom": "^18",
26
+ "eslint": "^8",
27
+ "eslint-config-next": "14.2.35",
28
+ "postcss": "^8",
29
+ "tailwindcss": "^3.4.1",
30
+ "typescript": "^5"
31
+ }
32
+ }
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ },
6
+ };
7
+
8
+ export default config;
frontend/src/app/admin/page.tsx ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { api } from "@/lib/api";
6
+ import { useAuthStore } from "@/lib/store";
7
+ import { Plus, LayoutDashboard, ShoppingCart, Settings } from "lucide-react";
8
+
9
+ export default function AdminDashboard() {
10
+ const router = useRouter();
11
+ const { user, token } = useAuthStore();
12
+ const [activeTab, setActiveTab] = useState<'courses' | 'orders'>('courses');
13
+ const [courses, setCourses] = useState<any[]>([]);
14
+ const [orders, setOrders] = useState<any[]>([]);
15
+
16
+ // Course Form
17
+ const [title, setTitle] = useState('');
18
+ const [description, setDescription] = useState('');
19
+ const [coverImage, setCoverImage] = useState('');
20
+ const [driveLink, setDriveLink] = useState('');
21
+ const [price, setPrice] = useState('');
22
+ const [category, setCategory] = useState('');
23
+ const [showAddForm, setShowAddForm] = useState(false);
24
+ const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
25
+
26
+ useEffect(() => {
27
+ if (!token || user?.role !== 'admin') {
28
+ router.push('/login');
29
+ return;
30
+ }
31
+ fetchData();
32
+ }, [token, user, activeTab]);
33
+
34
+ const fetchData = async () => {
35
+ try {
36
+ if (activeTab === 'courses') {
37
+ const res = await api.get('/api/courses'); // In real app, might want a specific admin endpoint to see inactive ones too
38
+ if (res.success) setCourses(res.data);
39
+ } else {
40
+ const res = await api.get('/api/admin/orders');
41
+ if (res.success) setOrders(res.data);
42
+ }
43
+ } catch (err) {
44
+ console.error(err);
45
+ }
46
+ };
47
+
48
+ const handleSubmitCourse = async (e: React.FormEvent) => {
49
+ e.preventDefault();
50
+ try {
51
+ const payload = {
52
+ title,
53
+ description,
54
+ coverImage,
55
+ driveLink,
56
+ price: Number(price),
57
+ category
58
+ };
59
+
60
+ let res;
61
+ if (editingCourseId) {
62
+ res = await api.put(`/api/admin/courses/${editingCourseId}`, payload) as any;
63
+ } else {
64
+ res = await api.post('/api/admin/courses', payload) as any;
65
+ }
66
+
67
+ if (res.success) {
68
+ alert(editingCourseId ? '课程更新成功' : '课程创建成功');
69
+ setShowAddForm(false);
70
+ setEditingCourseId(null);
71
+ fetchData();
72
+ // Reset form
73
+ setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory('');
74
+ }
75
+ } catch (err) {
76
+ console.error(err);
77
+ alert(editingCourseId ? '更新课程失败' : '创建课程失败');
78
+ }
79
+ };
80
+
81
+ const handleEdit = (course: any) => {
82
+ setEditingCourseId(course.id);
83
+ setTitle(course.title);
84
+ setDescription(course.description || '');
85
+ setCoverImage(course.coverImage || '');
86
+ setDriveLink(course.driveLink || '');
87
+ setPrice(course.price ? course.price.toString() : '');
88
+ setCategory(course.category || '');
89
+ setShowAddForm(true);
90
+ };
91
+
92
+ return (
93
+ <div className="flex flex-col md:flex-row gap-8 min-h-[calc(100vh-12rem)]">
94
+ {/* Sidebar */}
95
+ <div className="w-full md:w-64 bg-white rounded-2xl shadow-sm border border-gray-100 p-6 self-start">
96
+ <div className="flex items-center gap-3 mb-8 px-2">
97
+ <div className="w-10 h-10 bg-blue-100 text-blue-600 rounded-xl flex items-center justify-center font-bold text-lg">
98
+ A
99
+ </div>
100
+ <div>
101
+ <h3 className="font-semibold text-gray-900">管理后台</h3>
102
+ <p className="text-xs text-gray-500">管理您的店铺</p>
103
+ </div>
104
+ </div>
105
+
106
+ <nav className="space-y-2">
107
+ <button
108
+ onClick={() => setActiveTab('courses')}
109
+ className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'courses' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
110
+ >
111
+ <LayoutDashboard className="w-5 h-5" />
112
+ 课程管理
113
+ </button>
114
+ <button
115
+ onClick={() => setActiveTab('orders')}
116
+ className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'orders' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
117
+ >
118
+ <ShoppingCart className="w-5 h-5" />
119
+ 订单管理
120
+ </button>
121
+ </nav>
122
+ </div>
123
+
124
+ {/* Main Content */}
125
+ <div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
126
+ {activeTab === 'courses' && (
127
+ <div>
128
+ <div className="flex justify-between items-center mb-8">
129
+ <h2 className="text-2xl font-bold text-gray-900">课程管理</h2>
130
+ <button
131
+ onClick={() => {
132
+ if (showAddForm) {
133
+ setShowAddForm(false);
134
+ setEditingCourseId(null);
135
+ setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory('');
136
+ } else {
137
+ setShowAddForm(true);
138
+ }
139
+ }}
140
+ className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
141
+ >
142
+ {showAddForm ? '取消' : <><Plus className="w-4 h-4" /> 添加课程</>}
143
+ </button>
144
+ </div>
145
+
146
+ {showAddForm && (
147
+ <form onSubmit={handleSubmitCourse} className="bg-gray-50 p-6 rounded-xl mb-8 space-y-4">
148
+ <div className="grid grid-cols-2 gap-4">
149
+ <div>
150
+ <label className="block text-sm font-medium text-gray-700 mb-1">标题</label>
151
+ <input type="text" required value={title} onChange={e => setTitle(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
152
+ </div>
153
+ <div>
154
+ <label className="block text-sm font-medium text-gray-700 mb-1">价格 (¥)</label>
155
+ <input type="number" required value={price} onChange={e => setPrice(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
156
+ </div>
157
+ <div className="col-span-2">
158
+ <label className="block text-sm font-medium text-gray-700 mb-1">描述</label>
159
+ <textarea rows={3} value={description} onChange={e => setDescription(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200"></textarea>
160
+ </div>
161
+ <div className="col-span-2">
162
+ <label className="block text-sm font-medium text-gray-700 mb-1">封面图片URL</label>
163
+ <div className="flex gap-2">
164
+ <input type="text" required value={coverImage} onChange={e => setCoverImage(e.target.value)} className="flex-1 px-4 py-2 rounded-lg border border-gray-200" placeholder="请输入图片URL或点击右侧上传" />
165
+ <label className="cursor-pointer px-4 py-2 bg-gray-100 text-gray-700 rounded-lg border border-gray-200 hover:bg-gray-200 transition-colors whitespace-nowrap flex items-center justify-center">
166
+ 上传图片
167
+ <input
168
+ type="file"
169
+ accept="image/*"
170
+ className="hidden"
171
+ onChange={async (e) => {
172
+ const file = e.target.files?.[0];
173
+ if (!file) return;
174
+ const formData = new FormData();
175
+ formData.append('file', file);
176
+ try {
177
+ const res = await api.post('/api/upload', formData, {
178
+ headers: {
179
+ 'Content-Type': 'multipart/form-data'
180
+ }
181
+ }) as any;
182
+ if (res.success) {
183
+ setCoverImage(res.url);
184
+ } else {
185
+ alert('上传失败');
186
+ }
187
+ } catch (err) {
188
+ console.error(err);
189
+ alert('上传失败');
190
+ }
191
+ }}
192
+ />
193
+ </label>
194
+ </div>
195
+ </div>
196
+ <div className="col-span-2">
197
+ <label className="block text-sm font-medium text-gray-700 mb-1">分类</label>
198
+ <input type="text" value={category} onChange={e => setCategory(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
199
+ </div>
200
+ <div className="col-span-2">
201
+ <label className="block text-sm font-medium text-gray-700 mb-1">网盘链接 (受保护)</label>
202
+ <input type="text" required value={driveLink} onChange={e => setDriveLink(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
203
+ </div>
204
+ </div>
205
+ <div className="flex justify-end pt-4">
206
+ <button type="submit" className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700">
207
+ {editingCourseId ? '更新课程' : '保存课程'}
208
+ </button>
209
+ </div>
210
+ </form>
211
+ )}
212
+
213
+ <div className="overflow-x-auto">
214
+ <table className="w-full text-left">
215
+ <thead>
216
+ <tr className="border-b border-gray-200 text-sm text-gray-500">
217
+ <th className="pb-3 font-medium">ID</th>
218
+ <th className="pb-3 font-medium">课程标题</th>
219
+ <th className="pb-3 font-medium">价格</th>
220
+ <th className="pb-3 font-medium">分类</th>
221
+ <th className="pb-3 font-medium">操作</th>
222
+ </tr>
223
+ </thead>
224
+ <tbody className="divide-y divide-gray-100">
225
+ {courses.map(course => (
226
+ <tr key={course.id} className="text-sm">
227
+ <td className="py-4 text-gray-500">#{course.id}</td>
228
+ <td className="py-4 font-medium text-gray-900">{course.title}</td>
229
+ <td className="py-4">¥{course.price}</td>
230
+ <td className="py-4"><span className="px-2 py-1 bg-gray-100 rounded-md text-xs">{course.category || 'N/A'}</span></td>
231
+ <td className="py-4">
232
+ <button onClick={() => handleEdit(course)} className="text-blue-600 hover:underline mr-3">编辑</button>
233
+ </td>
234
+ </tr>
235
+ ))}
236
+ {courses.length === 0 && (
237
+ <tr>
238
+ <td colSpan={5} className="py-8 text-center text-gray-500">未找到课程</td>
239
+ </tr>
240
+ )}
241
+ </tbody>
242
+ </table>
243
+ </div>
244
+ </div>
245
+ )}
246
+
247
+ {activeTab === 'orders' && (
248
+ <div>
249
+ <h2 className="text-2xl font-bold text-gray-900 mb-8">订单管理</h2>
250
+ <div className="overflow-x-auto">
251
+ <table className="w-full text-left">
252
+ <thead>
253
+ <tr className="border-b border-gray-200 text-sm text-gray-500">
254
+ <th className="pb-3 font-medium">订单号</th>
255
+ <th className="pb-3 font-medium">课程</th>
256
+ <th className="pb-3 font-medium">金额</th>
257
+ <th className="pb-3 font-medium">状态</th>
258
+ <th className="pb-3 font-medium">日期</th>
259
+ </tr>
260
+ </thead>
261
+ <tbody className="divide-y divide-gray-100">
262
+ {orders.map(order => (
263
+ <tr key={order.id} className="text-sm">
264
+ <td className="py-4 text-gray-500">{order.orderNo}</td>
265
+ <td className="py-4 font-medium text-gray-900 line-clamp-1 max-w-[200px]">{order.course?.title}</td>
266
+ <td className="py-4">¥{order.amount}</td>
267
+ <td className="py-4">
268
+ <span className={`px-2 py-1 rounded-md text-xs font-medium ${
269
+ order.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
270
+ }`}>
271
+ {order.status.toUpperCase()}
272
+ </span>
273
+ </td>
274
+ <td className="py-4 text-gray-500">{new Date(order.createdAt).toLocaleDateString()}</td>
275
+ </tr>
276
+ ))}
277
+ {orders.length === 0 && (
278
+ <tr>
279
+ <td colSpan={5} className="py-8 text-center text-gray-500">未找到订单</td>
280
+ </tr>
281
+ )}
282
+ </tbody>
283
+ </table>
284
+ </div>
285
+ </div>
286
+ )}
287
+ </div>
288
+ </div>
289
+ );
290
+ }
frontend/src/app/client-layout.tsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useAuthStore } from "@/lib/store";
5
+ import { useRouter } from "next/navigation";
6
+
7
+ export default function ClientLayout({ children }: { children: React.ReactNode }) {
8
+ const { user, logout } = useAuthStore();
9
+ const router = useRouter();
10
+
11
+ const handleLogout = () => {
12
+ logout();
13
+ router.push('/');
14
+ };
15
+
16
+ return (
17
+ <>
18
+ <header className="bg-white border-b sticky top-0 z-50">
19
+ <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
20
+ <Link href="/" className="text-xl font-bold text-blue-600">课程中心</Link>
21
+ <nav className="flex items-center gap-6">
22
+ <Link href="/" className="text-gray-600 hover:text-blue-600 font-medium">发现课程</Link>
23
+
24
+ {user ? (
25
+ <>
26
+ <Link href="/user/courses" className="text-gray-600 hover:text-blue-600 font-medium">我的学习</Link>
27
+ {user.role === 'admin' && (
28
+ <Link href="/admin" className="text-gray-600 hover:text-blue-600 font-medium">管理后台</Link>
29
+ )}
30
+ <div className="flex items-center gap-4 border-l pl-6 ml-2">
31
+ <span className="text-sm font-medium text-gray-900">{user.nickname}</span>
32
+ <button
33
+ onClick={handleLogout}
34
+ className="text-sm text-gray-500 hover:text-gray-900"
35
+ >
36
+ 退出登录
37
+ </button>
38
+ </div>
39
+ </>
40
+ ) : (
41
+ <Link href="/login" className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm">
42
+ 登录 / 注册
43
+ </Link>
44
+ )}
45
+ </nav>
46
+ </div>
47
+ </header>
48
+
49
+ <main className="flex-1 max-w-7xl mx-auto w-full px-4 py-8">
50
+ {children}
51
+ </main>
52
+
53
+ <footer className="bg-white border-t py-8 mt-auto">
54
+ <div className="max-w-7xl mx-auto px-4 text-center text-gray-500 text-sm">
55
+ © 2024 课程中心. 保留所有权利。
56
+ </div>
57
+ </footer>
58
+ </>
59
+ );
60
+ }
frontend/src/app/course/[id]/page.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import { api } from "@/lib/api";
6
+ import { ArrowLeft, CheckCircle } from "lucide-react";
7
+ import Link from "next/link";
8
+ import { useAuthStore } from "@/lib/store";
9
+
10
+ export default function CourseDetail() {
11
+ const { id } = useParams();
12
+ const router = useRouter();
13
+ const { token } = useAuthStore();
14
+
15
+ const [course, setCourse] = useState<any>(null);
16
+ const [loading, setLoading] = useState(true);
17
+
18
+ useEffect(() => {
19
+ const fetchCourse = async () => {
20
+ try {
21
+ const res = await api.get(`/api/courses/${id}`);
22
+ if (res.success) {
23
+ setCourse(res.data);
24
+ }
25
+ } catch (err) {
26
+ console.error(err);
27
+ } finally {
28
+ setLoading(false);
29
+ }
30
+ };
31
+
32
+ fetchCourse();
33
+ }, [id]);
34
+
35
+ const handleBuy = async () => {
36
+ if (!token) {
37
+ router.push('/login');
38
+ return;
39
+ }
40
+
41
+ try {
42
+ const res = await api.post('/api/orders/create', {
43
+ courseId: Number(id),
44
+ price: Number(course.price)
45
+ });
46
+
47
+ if (res.success) {
48
+ router.push(`/payment/${res.data.orderId}`);
49
+ }
50
+ } catch (err) {
51
+ console.error(err);
52
+ alert('创建订单失败');
53
+ }
54
+ };
55
+
56
+ if (loading) {
57
+ return <div className="animate-pulse h-96 bg-white rounded-xl"></div>;
58
+ }
59
+
60
+ if (!course) {
61
+ return <div className="text-center py-20">课程未找到</div>;
62
+ }
63
+
64
+ return (
65
+ <div className="max-w-4xl mx-auto space-y-8">
66
+ <Link href="/" className="inline-flex items-center text-sm text-gray-500 hover:text-gray-900">
67
+ <ArrowLeft className="w-4 h-4 mr-1" /> 返回课程列表
68
+ </Link>
69
+
70
+ <div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
71
+ <div className="aspect-video w-full bg-gray-100">
72
+ <img
73
+ src={course.coverImage || "https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=educational%20course%20cover%20design&image_size=landscape_16_9"}
74
+ alt={course.title}
75
+ className="w-full h-full object-cover"
76
+ />
77
+ </div>
78
+
79
+ <div className="p-8">
80
+ <div className="flex items-start justify-between mb-6">
81
+ <div>
82
+ <div className="inline-block px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-sm font-medium mb-3">
83
+ {course.category || '通用'}
84
+ </div>
85
+ <h1 className="text-3xl font-bold text-gray-900">{course.title}</h1>
86
+ </div>
87
+ <div className="text-right">
88
+ <div className="text-3xl font-bold text-blue-600">¥{course.price}</div>
89
+ </div>
90
+ </div>
91
+
92
+ <div className="prose max-w-none text-gray-600 mb-8">
93
+ <h3 className="text-xl font-semibold text-gray-900 mb-4">关于本课程</h3>
94
+ <p className="whitespace-pre-line">{course.description}</p>
95
+ </div>
96
+
97
+ <div className="bg-gray-50 rounded-xl p-6 mb-8">
98
+ <h4 className="font-semibold text-gray-900 mb-4">你将获得:</h4>
99
+ <ul className="space-y-3">
100
+ {['终身访问权限', '高质量学习资料', '支持手机和电视访问', '直达网盘链接'].map((item, i) => (
101
+ <li key={i} className="flex items-center text-gray-600">
102
+ <CheckCircle className="w-5 h-5 text-green-500 mr-3" />
103
+ {item}
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ </div>
108
+
109
+ <div className="flex justify-end pt-6 border-t">
110
+ <button
111
+ onClick={handleBuy}
112
+ className="bg-blue-600 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200"
113
+ >
114
+ 立即购买
115
+ </button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
frontend/src/app/favicon.ico ADDED
frontend/src/app/fonts/GeistMonoVF.woff ADDED
Binary file (67.9 kB). View file
 
frontend/src/app/fonts/GeistVF.woff ADDED
Binary file (66.3 kB). View file
 
frontend/src/app/globals.css ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --background: #ffffff;
7
+ --foreground: #171717;
8
+ }
9
+
10
+ @media (prefers-color-scheme: dark) {
11
+ :root {
12
+ --background: #0a0a0a;
13
+ --foreground: #ededed;
14
+ }
15
+ }
16
+
17
+ body {
18
+ color: var(--foreground);
19
+ background: var(--background);
20
+ font-family: Arial, Helvetica, sans-serif;
21
+ }
22
+
23
+ @layer utilities {
24
+ .text-balance {
25
+ text-wrap: balance;
26
+ }
27
+ }
frontend/src/app/layout.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+ import ClientLayout from "./client-layout";
5
+
6
+ const inter = Inter({ subsets: ["latin"] });
7
+
8
+ export const metadata: Metadata = {
9
+ title: "课程订阅平台",
10
+ description: "学习并与我们的优质课程一起成长",
11
+ };
12
+
13
+ export default function RootLayout({
14
+ children,
15
+ }: Readonly<{
16
+ children: React.ReactNode;
17
+ }>) {
18
+ return (
19
+ <html lang="zh-CN">
20
+ <body className={`${inter.className} min-h-screen bg-gray-50 flex flex-col antialiased`}>
21
+ <ClientLayout>
22
+ {children}
23
+ </ClientLayout>
24
+ </body>
25
+ </html>
26
+ );
27
+ }
frontend/src/app/login/page.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { api } from "@/lib/api";
6
+ import { useAuthStore } from "@/lib/store";
7
+
8
+ export default function Login() {
9
+ const router = useRouter();
10
+ const { setAuth } = useAuthStore();
11
+ const [isLogin, setIsLogin] = useState(true);
12
+ const [phone, setPhone] = useState("");
13
+ const [password, setPassword] = useState("");
14
+ const [smsCode, setSmsCode] = useState("");
15
+ const [loading, setLoading] = useState(false);
16
+
17
+ const handleSubmit = async (e: React.FormEvent) => {
18
+ e.preventDefault();
19
+ setLoading(true);
20
+
21
+ try {
22
+ if (isLogin) {
23
+ const res = await api.post('/api/auth/login', { phone, password });
24
+ if (res.success) {
25
+ setAuth(
26
+ { id: res.data.userId, phone, nickname: res.data.nickname, role: res.data.role },
27
+ res.data.token
28
+ );
29
+ router.push('/');
30
+ }
31
+ } else {
32
+ const res = await api.post('/api/auth/register', { phone, password, smsCode: smsCode || '123456' });
33
+ if (res.success) {
34
+ setAuth(
35
+ { id: res.data.userId, phone, nickname: res.data.nickname, role: res.data.role },
36
+ res.data.token
37
+ );
38
+ router.push('/');
39
+ }
40
+ }
41
+ } catch (err: any) {
42
+ console.error(err);
43
+ alert(err.response?.data?.message || '认证失败');
44
+ } finally {
45
+ setLoading(false);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div className="max-w-md mx-auto mt-20">
51
+ <div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
52
+ <h1 className="text-2xl font-bold text-center text-gray-900 mb-8">
53
+ {isLogin ? '欢迎回来' : '创建账号'}
54
+ </h1>
55
+
56
+ <form onSubmit={handleSubmit} className="space-y-5">
57
+ <div>
58
+ <label className="block text-sm font-medium text-gray-700 mb-1">手机号码</label>
59
+ <input
60
+ type="text"
61
+ required
62
+ value={phone}
63
+ onChange={(e) => setPhone(e.target.value)}
64
+ className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all"
65
+ placeholder="请输入手机号码"
66
+ />
67
+ </div>
68
+
69
+ <div>
70
+ <label className="block text-sm font-medium text-gray-700 mb-1">密码</label>
71
+ <input
72
+ type="password"
73
+ required
74
+ value={password}
75
+ onChange={(e) => setPassword(e.target.value)}
76
+ className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all"
77
+ placeholder="请输入密码"
78
+ />
79
+ </div>
80
+
81
+ {!isLogin && (
82
+ <div>
83
+ <label className="block text-sm font-medium text-gray-700 mb-1">短信验证码 (模拟: 123456)</label>
84
+ <div className="flex gap-3">
85
+ <input
86
+ type="text"
87
+ required
88
+ value={smsCode}
89
+ onChange={(e) => setSmsCode(e.target.value)}
90
+ className="flex-1 px-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all"
91
+ placeholder="6位验证码"
92
+ />
93
+ <button type="button" className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors whitespace-nowrap">
94
+ 发送验证码
95
+ </button>
96
+ </div>
97
+ </div>
98
+ )}
99
+
100
+ <button
101
+ type="submit"
102
+ disabled={loading}
103
+ className="w-full bg-blue-600 text-white py-3.5 rounded-xl font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 mt-4"
104
+ >
105
+ {loading ? '处理中...' : (isLogin ? '登录' : '注册')}
106
+ </button>
107
+ </form>
108
+
109
+ <div className="mt-6 text-center text-sm text-gray-500">
110
+ {isLogin ? "没有账号? " : "已有账号? "}
111
+ <button
112
+ onClick={() => setIsLogin(!isLogin)}
113
+ className="text-blue-600 font-medium hover:underline"
114
+ >
115
+ {isLogin ? '去注册' : '去登录'}
116
+ </button>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
frontend/src/app/page.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { api } from "@/lib/api";
5
+ import Link from "next/link";
6
+ import { BookOpen, User } from "lucide-react";
7
+
8
+ export default function Home() {
9
+ const [courses, setCourses] = useState<any[]>([]);
10
+ const [loading, setLoading] = useState(true);
11
+
12
+ useEffect(() => {
13
+ const fetchCourses = async () => {
14
+ try {
15
+ const res = await api.get('/api/courses');
16
+ if (res.success) {
17
+ setCourses(res.data);
18
+ }
19
+ } catch (err) {
20
+ console.error(err);
21
+ } finally {
22
+ setLoading(false);
23
+ }
24
+ };
25
+
26
+ fetchCourses();
27
+ }, []);
28
+
29
+ return (
30
+ <div className="space-y-12">
31
+ {/* Hero Section */}
32
+ <section className="bg-gradient-to-r from-blue-600 to-blue-800 rounded-2xl p-12 text-white shadow-xl">
33
+ <div className="max-w-2xl">
34
+ <h1 className="text-4xl font-bold mb-4">获取优质课程,提升你的技能</h1>
35
+ <p className="text-blue-100 text-lg mb-8">访问由行业专家精心打造的高质量课程资料。一次购买,终身受益。</p>
36
+ <button className="bg-white text-blue-600 px-6 py-3 rounded-lg font-medium hover:bg-blue-50 transition-colors">
37
+ 浏览课程
38
+ </button>
39
+ </div>
40
+ </section>
41
+
42
+ {/* Course List */}
43
+ <section>
44
+ <div className="flex items-center justify-between mb-6">
45
+ <h2 className="text-2xl font-bold text-gray-900">精选课程</h2>
46
+ </div>
47
+
48
+ {loading ? (
49
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
50
+ {[1, 2, 3].map((i) => (
51
+ <div key={i} className="bg-white rounded-xl h-80 animate-pulse"></div>
52
+ ))}
53
+ </div>
54
+ ) : courses.length > 0 ? (
55
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
56
+ {courses.map((course) => (
57
+ <Link href={`/course/${course.id}`} key={course.id}>
58
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow group">
59
+ <div className="aspect-video w-full bg-gray-200 relative overflow-hidden">
60
+ <img
61
+ src={course.coverImage || "https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=educational%20course%20cover%20design%20with%20books%20and%20laptop%20clean%20style&image_size=landscape_16_9"}
62
+ alt={course.title}
63
+ className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
64
+ />
65
+ </div>
66
+ <div className="p-5">
67
+ <div className="text-xs font-medium text-blue-600 mb-2">{course.category || '通用'}</div>
68
+ <h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-1">{course.title}</h3>
69
+ <p className="text-gray-500 text-sm mb-4 line-clamp-2">{course.description}</p>
70
+ <div className="flex items-center justify-between mt-auto">
71
+ <span className="text-xl font-bold text-gray-900">¥{course.price}</span>
72
+ <span className="text-sm text-blue-600 font-medium">了解详情 →</span>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </Link>
77
+ ))}
78
+ </div>
79
+ ) : (
80
+ <div className="text-center py-12 bg-white rounded-xl border border-gray-100">
81
+ <BookOpen className="w-12 h-12 text-gray-300 mx-auto mb-3" />
82
+ <p className="text-gray-500">暂无可用课程。</p>
83
+ </div>
84
+ )}
85
+ </section>
86
+ </div>
87
+ );
88
+ }
frontend/src/app/payment/[id]/page.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import { api } from "@/lib/api";
6
+ import { CreditCard, Smartphone } from "lucide-react";
7
+
8
+ export default function Payment() {
9
+ const { id } = useParams();
10
+ const router = useRouter();
11
+ const [payType, setPayType] = useState<'wechat' | 'alipay'>('wechat');
12
+ const [loading, setLoading] = useState(false);
13
+
14
+ const handlePay = async () => {
15
+ setLoading(true);
16
+ try {
17
+ const res = await api.post('/api/payment/prepare', {
18
+ orderId: id as string,
19
+ payType
20
+ });
21
+
22
+ if (res.success) {
23
+ // Mock payment flow
24
+ alert('模拟支付成功: ' + payType);
25
+
26
+ // Mock webhook callback
27
+ await api.post('/api/payment/callback', {
28
+ paymentNo: res.data.paymentNo,
29
+ status: 'SUCCESS'
30
+ });
31
+
32
+ router.push('/user/courses');
33
+ }
34
+ } catch (err) {
35
+ console.error(err);
36
+ alert('支付失败');
37
+ } finally {
38
+ setLoading(false);
39
+ }
40
+ };
41
+
42
+ return (
43
+ <div className="max-w-xl mx-auto mt-10">
44
+ <div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
45
+ <h1 className="text-2xl font-bold text-gray-900 mb-6 text-center">完成支付</h1>
46
+
47
+ <div className="space-y-4 mb-8">
48
+ <label
49
+ className={`flex items-center p-4 border rounded-xl cursor-pointer transition-colors ${payType === 'wechat' ? 'border-green-500 bg-green-50' : 'hover:bg-gray-50'}`}
50
+ >
51
+ <input
52
+ type="radio"
53
+ name="payType"
54
+ value="wechat"
55
+ checked={payType === 'wechat'}
56
+ onChange={() => setPayType('wechat')}
57
+ className="hidden"
58
+ />
59
+ <Smartphone className={`w-8 h-8 mr-4 ${payType === 'wechat' ? 'text-green-600' : 'text-gray-400'}`} />
60
+ <div className="flex-1">
61
+ <div className="font-semibold text-gray-900">微信支付</div>
62
+ <div className="text-sm text-gray-500">使用微信APP支付</div>
63
+ </div>
64
+ <div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${payType === 'wechat' ? 'border-green-500' : 'border-gray-300'}`}>
65
+ {payType === 'wechat' && <div className="w-3 h-3 rounded-full bg-green-500" />}
66
+ </div>
67
+ </label>
68
+
69
+ <label
70
+ className={`flex items-center p-4 border rounded-xl cursor-pointer transition-colors ${payType === 'alipay' ? 'border-blue-500 bg-blue-50' : 'hover:bg-gray-50'}`}
71
+ >
72
+ <input
73
+ type="radio"
74
+ name="payType"
75
+ value="alipay"
76
+ checked={payType === 'alipay'}
77
+ onChange={() => setPayType('alipay')}
78
+ className="hidden"
79
+ />
80
+ <CreditCard className={`w-8 h-8 mr-4 ${payType === 'alipay' ? 'text-blue-600' : 'text-gray-400'}`} />
81
+ <div className="flex-1">
82
+ <div className="font-semibold text-gray-900">支付宝</div>
83
+ <div className="text-sm text-gray-500">使用支付宝APP支付</div>
84
+ </div>
85
+ <div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${payType === 'alipay' ? 'border-blue-500' : 'border-gray-300'}`}>
86
+ {payType === 'alipay' && <div className="w-3 h-3 rounded-full bg-blue-500" />}
87
+ </div>
88
+ </label>
89
+ </div>
90
+
91
+ <button
92
+ onClick={handlePay}
93
+ disabled={loading}
94
+ className="w-full bg-blue-600 text-white py-4 rounded-xl font-semibold text-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
95
+ >
96
+ {loading ? '处理中...' : '立即支付'}
97
+ </button>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
frontend/src/app/user/courses/page.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { api } from "@/lib/api";
6
+ import { useAuthStore } from "@/lib/store";
7
+ import { BookOpen, ExternalLink, Clock } from "lucide-react";
8
+ import Link from "next/link";
9
+
10
+ export default function MyCourses() {
11
+ const router = useRouter();
12
+ const { token } = useAuthStore();
13
+ const [courses, setCourses] = useState<any[]>([]);
14
+ const [loading, setLoading] = useState(true);
15
+
16
+ useEffect(() => {
17
+ if (!token) {
18
+ router.push('/login');
19
+ return;
20
+ }
21
+
22
+ const fetchMyCourses = async () => {
23
+ try {
24
+ // Here we use a different endpoint or filter, for mock we just fetch all
25
+ // In real backend, we need an endpoint /api/courses/my
26
+ const res = await api.get('/api/courses/my');
27
+ if (res.success) {
28
+ setCourses(res.data);
29
+ }
30
+ } catch (err) {
31
+ console.error(err);
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ };
36
+
37
+ fetchMyCourses();
38
+ }, [token, router]);
39
+
40
+ const handleAccess = async (courseId: number) => {
41
+ try {
42
+ const res = await api.get(`/api/courses/${courseId}/access`);
43
+ if (res.success && res.data.hasAccess) {
44
+ window.open(res.data.driveLink, '_blank');
45
+ }
46
+ } catch (err) {
47
+ console.error(err);
48
+ alert('获取学习链接失败');
49
+ }
50
+ };
51
+
52
+ if (loading) {
53
+ return <div className="animate-pulse h-64 bg-white rounded-xl"></div>;
54
+ }
55
+
56
+ return (
57
+ <div className="max-w-5xl mx-auto space-y-8">
58
+ <h1 className="text-3xl font-bold text-gray-900">我的学习</h1>
59
+
60
+ {courses.length > 0 ? (
61
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
62
+ {courses.map((course) => (
63
+ <div key={course.id} className="bg-white rounded-xl shadow-sm border border-gray-100 flex overflow-hidden">
64
+ <div className="w-1/3 bg-gray-100 flex-shrink-0">
65
+ <img
66
+ src={course.coverImage || "https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=educational%20course%20cover%20design&image_size=landscape_16_9"}
67
+ alt={course.title}
68
+ className="w-full h-full object-cover"
69
+ />
70
+ </div>
71
+ <div className="p-5 flex-1 flex flex-col">
72
+ <h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-2">{course.title}</h3>
73
+
74
+ <div className="mt-auto space-y-4">
75
+ <div className="flex items-center text-sm text-gray-500">
76
+ <Clock className="w-4 h-4 mr-1.5" />
77
+ 到期时间: {new Date(course.expiredAt).toLocaleDateString()}
78
+ </div>
79
+
80
+ <div className="flex gap-3">
81
+ <Link href={`/course/${course.id}`} className="px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
82
+ 课程详情
83
+ </Link>
84
+ <button
85
+ onClick={() => handleAccess(course.id)}
86
+ className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors flex items-center justify-center"
87
+ >
88
+ <ExternalLink className="w-4 h-4 mr-1.5" />
89
+ 打开网盘
90
+ </button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ ) : (
98
+ <div className="text-center py-20 bg-white rounded-2xl border border-gray-100">
99
+ <BookOpen className="w-16 h-16 text-gray-300 mx-auto mb-4" />
100
+ <h3 className="text-xl font-medium text-gray-900 mb-2">暂无课程</h3>
101
+ <p className="text-gray-500 mb-6">您还没有购买过任何课程。</p>
102
+ <Link href="/" className="inline-flex px-6 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 transition-colors">
103
+ 浏览课程
104
+ </Link>
105
+ </div>
106
+ )}
107
+ </div>
108
+ );
109
+ }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ const client = axios.create({
4
+ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '/api',
5
+ timeout: 10000,
6
+ });
7
+
8
+ client.interceptors.request.use((config) => {
9
+ if (typeof window !== 'undefined') {
10
+ const token = localStorage.getItem('token');
11
+ if (token && config.headers) {
12
+ config.headers.Authorization = `Bearer ${token}`;
13
+ }
14
+ }
15
+ return config;
16
+ });
17
+
18
+ client.interceptors.response.use(undefined, (error) => Promise.reject(error));
19
+
20
+ export const api = {
21
+ get: (...args: Parameters<typeof client.get>) => client.get(...args).then((response) => response.data as any),
22
+ post: (...args: Parameters<typeof client.post>) => client.post(...args).then((response) => response.data as any),
23
+ put: (...args: Parameters<typeof client.put>) => client.put(...args).then((response) => response.data as any),
24
+ delete: (...args: Parameters<typeof client.delete>) => client.delete(...args).then((response) => response.data as any),
25
+ };
frontend/src/lib/store.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand';
2
+
3
+ interface User {
4
+ id: number;
5
+ phone: string;
6
+ nickname: string;
7
+ role: string;
8
+ }
9
+
10
+ interface AuthState {
11
+ user: User | null;
12
+ token: string | null;
13
+ setAuth: (user: User, token: string) => void;
14
+ logout: () => void;
15
+ }
16
+
17
+ export const useAuthStore = create<AuthState>((set) => ({
18
+ user: null,
19
+ token: null,
20
+ setAuth: (user, token) => {
21
+ localStorage.setItem('token', token);
22
+ set({ user, token });
23
+ },
24
+ logout: () => {
25
+ localStorage.removeItem('token');
26
+ set({ user: null, token: null });
27
+ },
28
+ }));
frontend/src/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
frontend/tailwind.config.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: [
5
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8
+ ],
9
+ theme: {
10
+ extend: {
11
+ colors: {
12
+ background: "var(--background)",
13
+ foreground: "var(--foreground)",
14
+ },
15
+ },
16
+ },
17
+ plugins: [],
18
+ };
19
+ export default config;
frontend/tsconfig.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["dom", "dom.iterable", "esnext"],
4
+ "allowJs": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "module": "esnext",
10
+ "moduleResolution": "bundler",
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "jsx": "preserve",
14
+ "incremental": true,
15
+ "plugins": [
16
+ {
17
+ "name": "next"
18
+ }
19
+ ],
20
+ "paths": {
21
+ "@/*": ["./src/*"]
22
+ }
23
+ },
24
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25
+ "exclude": ["node_modules"]
26
+ }