Spaces:
Sleeping
Sleeping
trae-bot commited on
Commit ·
d629cd4
1
Parent(s): eba4f59
Track frontend files directly and fix Docker package copy
Browse files- frontend +0 -1
- frontend/.eslintrc.json +3 -0
- frontend/.gitignore +36 -0
- frontend/README.md +36 -0
- frontend/next.config.mjs +22 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +32 -0
- frontend/postcss.config.mjs +8 -0
- frontend/src/app/admin/page.tsx +290 -0
- frontend/src/app/client-layout.tsx +60 -0
- frontend/src/app/course/[id]/page.tsx +121 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/fonts/GeistMonoVF.woff +0 -0
- frontend/src/app/fonts/GeistVF.woff +0 -0
- frontend/src/app/globals.css +27 -0
- frontend/src/app/layout.tsx +27 -0
- frontend/src/app/login/page.tsx +121 -0
- frontend/src/app/page.tsx +88 -0
- frontend/src/app/payment/[id]/page.tsx +101 -0
- frontend/src/app/user/courses/page.tsx +109 -0
- frontend/src/lib/api.ts +25 -0
- frontend/src/lib/store.ts +28 -0
- frontend/src/lib/utils.ts +6 -0
- frontend/tailwind.config.ts +19 -0
- frontend/tsconfig.json +26 -0
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 |
+
}
|