www
This commit is contained in:
parent
11f10ca0c8
commit
8067376d43
5
.env
Normal file
5
.env
Normal file
@ -0,0 +1,5 @@
|
||||
# API配置
|
||||
VITE_API_BASE_URL=http://110.42.96.65:55510/api
|
||||
|
||||
# Mock配置 - 切换到真实API
|
||||
VITE_ENABLE_MOCK=false
|
11
.env.development
Normal file
11
.env.development
Normal file
@ -0,0 +1,11 @@
|
||||
# 开发环境配置
|
||||
|
||||
# API配置
|
||||
VITE_API_BASE_URL=http://110.42.96.65:55510/api
|
||||
|
||||
# Mock配置
|
||||
# 设置为 true 使用Mock数据,false 使用真实API
|
||||
VITE_ENABLE_MOCK=false
|
||||
|
||||
# 开发模式
|
||||
NODE_ENV=development
|
66
.env.example
Normal file
66
.env.example
Normal file
@ -0,0 +1,66 @@
|
||||
# 环境变量配置示例文件
|
||||
# 复制此文件为 .env.local 并填入实际的配置值
|
||||
|
||||
# 应用基础配置
|
||||
VITE_APP_TITLE=在线学习平台
|
||||
VITE_APP_DESCRIPTION=专业的在线学习平台,提供优质的编程和技术课程
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
# API 配置
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
VITE_API_TIMEOUT=10000
|
||||
|
||||
# 开发环境配置
|
||||
VITE_DEV_PORT=5173
|
||||
VITE_DEV_HOST=localhost
|
||||
|
||||
# 生产环境配置
|
||||
VITE_PROD_API_BASE_URL=https://api.yourdomain.com/api
|
||||
|
||||
# 第三方服务配置
|
||||
# 文件上传服务
|
||||
VITE_UPLOAD_BASE_URL=https://upload.yourdomain.com
|
||||
VITE_CDN_BASE_URL=https://cdn.yourdomain.com
|
||||
|
||||
# 视频播放服务
|
||||
VITE_VIDEO_BASE_URL=https://video.yourdomain.com
|
||||
|
||||
# 支付服务配置
|
||||
VITE_ALIPAY_APP_ID=your_alipay_app_id
|
||||
VITE_WECHAT_APP_ID=your_wechat_app_id
|
||||
|
||||
# 第三方登录配置
|
||||
VITE_GITHUB_CLIENT_ID=your_github_client_id
|
||||
VITE_QQ_APP_ID=your_qq_app_id
|
||||
VITE_WECHAT_LOGIN_APP_ID=your_wechat_login_app_id
|
||||
|
||||
# 地图服务配置
|
||||
VITE_MAP_API_KEY=your_map_api_key
|
||||
|
||||
# 统计分析配置
|
||||
VITE_ANALYTICS_ID=your_analytics_id
|
||||
|
||||
# 错误监控配置
|
||||
VITE_SENTRY_DSN=your_sentry_dsn
|
||||
|
||||
# 功能开关
|
||||
VITE_ENABLE_MOCK=false
|
||||
VITE_ENABLE_DEBUG=true
|
||||
VITE_ENABLE_ANALYTICS=false
|
||||
VITE_ENABLE_ERROR_TRACKING=false
|
||||
|
||||
# 缓存配置
|
||||
VITE_CACHE_ENABLED=true
|
||||
VITE_CACHE_PREFIX=study_platform_
|
||||
|
||||
# 安全配置
|
||||
VITE_ENCRYPT_STORAGE=false
|
||||
VITE_ENABLE_CSRF=true
|
||||
|
||||
# 主题配置
|
||||
VITE_DEFAULT_THEME=light
|
||||
VITE_ENABLE_DARK_MODE=true
|
||||
|
||||
# 语言配置
|
||||
VITE_DEFAULT_LOCALE=zh-CN
|
||||
VITE_ENABLE_I18N=false
|
10
.env.production
Normal file
10
.env.production
Normal file
@ -0,0 +1,10 @@
|
||||
# 生产环境配置
|
||||
|
||||
# API配置
|
||||
VITE_API_BASE_URL=http://110.42.86.55:5510/api
|
||||
|
||||
# Mock配置 - 生产环境禁用Mock
|
||||
VITE_ENABLE_MOCK=false
|
||||
|
||||
# 生产模式
|
||||
NODE_ENV=production
|
@ -7,6 +7,10 @@
|
||||
<title>在线学习平台</title>
|
||||
<meta name="description" content="专业的在线学习平台,提供优质的编程和技术课程">
|
||||
<meta name="keywords" content="在线学习,编程课程,技术培训,Vue.js,React,Node.js">
|
||||
<!-- CKPlayer CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ckplayer@8.3.5/dist/ckplayer.css">
|
||||
<!-- CKPlayer JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/ckplayer@8.3.5/dist/ckplayer.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
294
package-lock.json
generated
294
package-lock.json
generated
@ -9,6 +9,9 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"axios": "^1.11.0",
|
||||
"ckplayer": "^3.1.2",
|
||||
"hls.js": "^1.6.7",
|
||||
"naive-ui": "^2.42.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.17",
|
||||
@ -1756,6 +1759,23 @@
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.5.0.tgz",
|
||||
@ -1814,6 +1834,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001727",
|
||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||
@ -1835,6 +1868,24 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/ckplayer": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/ckplayer/-/ckplayer-3.1.2.tgz",
|
||||
"integrity": "sha512-JHlWTSRm6aqZx+dYdsa6MWz7151omcGBBF9EKK49NL1WCJ2olbdkt7CPKZHvW4lLVgxxEopmuLYEqNdb2cOPhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@ -1981,6 +2032,29 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.187",
|
||||
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
|
||||
@ -2010,6 +2084,51 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.7.tgz",
|
||||
@ -2132,6 +2251,42 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz",
|
||||
@ -2162,6 +2317,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@ -2172,6 +2336,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz",
|
||||
@ -2189,6 +2390,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@ -2196,6 +2409,45 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
|
||||
@ -2215,6 +2467,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmmirror.com/hls.js/-/hls.js-1.6.7.tgz",
|
||||
"integrity": "sha512-QW2fnwDGKGc9DwQUGLbmMOz8G48UZK7PVNJPcOUql1b8jubKx4/eMHNP5mGqr6tYlJNDG1g10Lx2U/qPzL6zwQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||
@ -2424,6 +2682,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
|
||||
@ -2685,6 +2973,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
|
||||
|
@ -11,6 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"axios": "^1.11.0",
|
||||
"ckplayer": "^3.1.2",
|
||||
"hls.js": "^1.6.7",
|
||||
"naive-ui": "^2.42.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.17",
|
||||
|
348
src/api/README.md
Normal file
348
src/api/README.md
Normal file
@ -0,0 +1,348 @@
|
||||
# API 接口文档
|
||||
|
||||
这是在线学习平台的前端API接口封装,提供了完整的类型定义和请求方法。
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── index.ts # 统一导出文件
|
||||
├── types.ts # TypeScript 类型定义
|
||||
├── request.ts # HTTP 请求封装
|
||||
├── utils.ts # 工具函数
|
||||
├── modules/ # API 模块
|
||||
│ ├── auth.ts # 认证相关API
|
||||
│ ├── course.ts # 课程相关API
|
||||
│ ├── comment.ts # 评论相关API
|
||||
│ ├── favorite.ts # 收藏相关API
|
||||
│ ├── order.ts # 订单相关API
|
||||
│ ├── upload.ts # 文件上传API
|
||||
│ └── statistics.ts # 统计相关API
|
||||
├── examples/ # 使用示例
|
||||
│ └── usage.ts # API使用示例
|
||||
└── README.md # 文档说明
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 环境配置
|
||||
|
||||
复制 `.env.example` 为 `.env.local` 并配置API地址:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
```env
|
||||
# API 配置
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
VITE_API_TIMEOUT=10000
|
||||
```
|
||||
|
||||
### 2. 基础使用
|
||||
|
||||
```typescript
|
||||
import { AuthApi, CourseApi } from '@/api'
|
||||
|
||||
// 用户登录
|
||||
const login = async () => {
|
||||
try {
|
||||
const response = await AuthApi.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('登录成功:', response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取课程列表
|
||||
const getCourses = async () => {
|
||||
try {
|
||||
const response = await CourseApi.getCourses({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
category: '前端开发'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('课程列表:', response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取课程失败:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 API 模块说明
|
||||
|
||||
### 认证模块 (AuthApi)
|
||||
|
||||
提供用户认证相关的所有接口:
|
||||
|
||||
- `login()` - 用户登录
|
||||
- `register()` - 用户注册
|
||||
- `logout()` - 用户登出
|
||||
- `getCurrentUser()` - 获取当前用户信息
|
||||
- `updateProfile()` - 更新用户资料
|
||||
- `changePassword()` - 修改密码
|
||||
- `uploadAvatar()` - 上传头像
|
||||
|
||||
### 课程模块 (CourseApi)
|
||||
|
||||
提供课程相关的所有接口:
|
||||
|
||||
- `getCourses()` - 获取课程列表
|
||||
- `searchCourses()` - 搜索课程
|
||||
- `getCourseById()` - 获取课程详情
|
||||
- `enrollCourse()` - 报名课程
|
||||
- `getLearningProgress()` - 获取学习进度
|
||||
- `getCourseChapters()` - 获取课程章节
|
||||
- `getCourseLessons()` - 获取课程课时
|
||||
|
||||
### 评论模块 (CommentApi)
|
||||
|
||||
提供评论相关的所有接口:
|
||||
|
||||
- `getCourseComments()` - 获取课程评论
|
||||
- `addCourseComment()` - 添加课程评论
|
||||
- `likeComment()` - 点赞评论
|
||||
- `updateComment()` - 更新评论
|
||||
- `deleteComment()` - 删除评论
|
||||
|
||||
### 收藏模块 (FavoriteApi)
|
||||
|
||||
提供收藏相关的所有接口:
|
||||
|
||||
- `addFavorite()` - 添加收藏
|
||||
- `removeFavorite()` - 取消收藏
|
||||
- `getMyFavorites()` - 获取收藏列表
|
||||
- `checkFavorite()` - 检查收藏状态
|
||||
|
||||
### 订单模块 (OrderApi)
|
||||
|
||||
提供订单相关的所有接口:
|
||||
|
||||
- `createOrder()` - 创建订单
|
||||
- `getOrders()` - 获取订单列表
|
||||
- `getOrderById()` - 获取订单详情
|
||||
- `cancelOrder()` - 取消订单
|
||||
- `confirmPayment()` - 确认支付
|
||||
|
||||
### 上传模块 (UploadApi)
|
||||
|
||||
提供文件上传相关的所有接口:
|
||||
|
||||
- `uploadFile()` - 上传单个文件
|
||||
- `uploadAvatar()` - 上传头像
|
||||
- `uploadCourseVideo()` - 上传课程视频
|
||||
- `uploadMultipleFiles()` - 批量上传文件
|
||||
|
||||
### 统计模块 (StatisticsApi)
|
||||
|
||||
提供统计相关的所有接口:
|
||||
|
||||
- `getPlatformStats()` - 获取平台统计
|
||||
- `getUserLearningStats()` - 获取用户学习统计
|
||||
- `getCourseStats()` - 获取课程统计
|
||||
|
||||
## 🔧 工具函数
|
||||
|
||||
### 请求工具
|
||||
|
||||
```typescript
|
||||
import { ApiRequest } from '@/api'
|
||||
|
||||
// GET 请求
|
||||
const data = await ApiRequest.get('/endpoint', { param: 'value' })
|
||||
|
||||
// POST 请求
|
||||
const result = await ApiRequest.post('/endpoint', { data: 'value' })
|
||||
|
||||
// 文件上传
|
||||
const uploadResult = await ApiRequest.upload('/upload', file, (progress) => {
|
||||
console.log('上传进度:', progress + '%')
|
||||
})
|
||||
```
|
||||
|
||||
### 工具函数
|
||||
|
||||
```typescript
|
||||
import {
|
||||
buildQueryString,
|
||||
formatFileSize,
|
||||
formatDuration,
|
||||
isValidEmail,
|
||||
storage
|
||||
} from '@/api/utils'
|
||||
|
||||
// 构建查询字符串
|
||||
const query = buildQueryString({ page: 1, size: 20 })
|
||||
|
||||
// 格式化文件大小
|
||||
const size = formatFileSize(1024000) // "1000 KB"
|
||||
|
||||
// 格式化时长
|
||||
const duration = formatDuration(3661) // "1小时1分1秒"
|
||||
|
||||
// 验证邮箱
|
||||
const valid = isValidEmail('user@example.com') // true
|
||||
|
||||
// 本地存储
|
||||
storage.set('user', { id: 1, name: 'John' })
|
||||
const user = storage.get('user')
|
||||
```
|
||||
|
||||
## 🎯 类型定义
|
||||
|
||||
所有API都有完整的TypeScript类型定义:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
User,
|
||||
Course,
|
||||
Comment,
|
||||
Order,
|
||||
ApiResponse,
|
||||
PaginationResponse
|
||||
} from '@/api'
|
||||
|
||||
// 用户类型
|
||||
const user: User = {
|
||||
id: 1,
|
||||
username: 'john',
|
||||
email: 'john@example.com',
|
||||
role: 'student'
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
const response: ApiResponse<User> = {
|
||||
code: 200,
|
||||
message: '成功',
|
||||
data: user
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
const pageResponse: ApiResponse<PaginationResponse<Course>> = {
|
||||
code: 200,
|
||||
message: '成功',
|
||||
data: {
|
||||
list: [],
|
||||
total: 100,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ 错误处理
|
||||
|
||||
### 全局错误处理
|
||||
|
||||
请求拦截器会自动处理常见错误:
|
||||
|
||||
- 401: 自动跳转登录页
|
||||
- 403: 显示权限不足提示
|
||||
- 500: 显示服务器错误提示
|
||||
- 网络错误: 显示网络连接提示
|
||||
|
||||
### 自定义错误处理
|
||||
|
||||
```typescript
|
||||
import { getErrorMessage } from '@/api/utils'
|
||||
|
||||
try {
|
||||
const response = await AuthApi.login(credentials)
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error)
|
||||
console.error('登录失败:', message)
|
||||
// 显示错误提示
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 最佳实践
|
||||
|
||||
### 1. 使用TypeScript类型
|
||||
|
||||
```typescript
|
||||
import type { Course, ApiResponse } from '@/api'
|
||||
|
||||
const handleCourseData = (response: ApiResponse<Course[]>) => {
|
||||
if (response.code === 200) {
|
||||
response.data.forEach(course => {
|
||||
// TypeScript 会提供完整的类型提示
|
||||
console.log(course.title, course.instructor.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 错误边界处理
|
||||
|
||||
```typescript
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await CourseApi.getCourses()
|
||||
return response.data
|
||||
} catch (error) {
|
||||
// 记录错误日志
|
||||
console.error('API Error:', error)
|
||||
// 返回默认值或重新抛出错误
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 加载状态管理
|
||||
|
||||
```typescript
|
||||
const loading = ref(false)
|
||||
|
||||
const loadCourses = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await CourseApi.getCourses()
|
||||
// 处理数据
|
||||
} catch (error) {
|
||||
// 处理错误
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 分页数据处理
|
||||
|
||||
```typescript
|
||||
import { formatPaginationData } from '@/api/utils'
|
||||
|
||||
const loadCourses = async (page: number = 1) => {
|
||||
try {
|
||||
const response = await CourseApi.getCourses({ page, pageSize: 20 })
|
||||
const pagination = formatPaginationData(response)
|
||||
|
||||
console.log('课程列表:', pagination.items)
|
||||
console.log('分页信息:', {
|
||||
current: pagination.currentPage,
|
||||
total: pagination.totalPages,
|
||||
hasNext: pagination.hasNext
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 更新日志
|
||||
|
||||
### v1.0.0
|
||||
- 初始版本
|
||||
- 完整的API接口封装
|
||||
- TypeScript类型定义
|
||||
- 错误处理机制
|
||||
- 工具函数库
|
371
src/api/examples/usage.ts
Normal file
371
src/api/examples/usage.ts
Normal file
@ -0,0 +1,371 @@
|
||||
// API 使用示例文件
|
||||
// 展示如何在组件中使用各种API接口
|
||||
|
||||
import {
|
||||
AuthApi,
|
||||
CourseApi,
|
||||
CommentApi,
|
||||
FavoriteApi,
|
||||
OrderApi,
|
||||
UploadApi,
|
||||
StatisticsApi
|
||||
} from '@/api'
|
||||
|
||||
// ===== 认证相关示例 =====
|
||||
|
||||
// 用户登录示例
|
||||
export const loginExample = async () => {
|
||||
try {
|
||||
const response = await AuthApi.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
const { user, token } = response.data
|
||||
// 保存用户信息和token
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
console.log('登录成功:', user)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户注册示例
|
||||
export const registerExample = async () => {
|
||||
try {
|
||||
const response = await AuthApi.register({
|
||||
username: 'newuser',
|
||||
email: 'newuser@example.com',
|
||||
password: 'password123',
|
||||
confirmPassword: 'password123',
|
||||
captcha: 'abc123'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('注册成功:', response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前用户信息示例
|
||||
export const getCurrentUserExample = async () => {
|
||||
try {
|
||||
const response = await AuthApi.getCurrentUser()
|
||||
if (response.code === 200) {
|
||||
console.log('用户信息:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 课程相关示例 =====
|
||||
|
||||
// 获取课程列表示例
|
||||
export const getCoursesExample = async () => {
|
||||
try {
|
||||
const response = await CourseApi.getCourses({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
category: '前端开发',
|
||||
level: 'intermediate',
|
||||
sortBy: 'rating'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
const { list, total, page, pageSize } = response.data
|
||||
console.log('课程列表:', list)
|
||||
console.log('总数:', total)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取课程列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索课程示例
|
||||
export const searchCoursesExample = async () => {
|
||||
try {
|
||||
const response = await CourseApi.searchCourses({
|
||||
keyword: 'Vue.js',
|
||||
category: '前端开发',
|
||||
level: 'intermediate',
|
||||
price: 'paid',
|
||||
rating: 4,
|
||||
sortBy: 'rating',
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('搜索结果:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索课程失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取课程详情示例
|
||||
export const getCourseDetailExample = async (courseId: number) => {
|
||||
try {
|
||||
const response = await CourseApi.getCourseById(courseId)
|
||||
if (response.code === 200) {
|
||||
console.log('课程详情:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取课程详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 报名课程示例
|
||||
export const enrollCourseExample = async (courseId: number) => {
|
||||
try {
|
||||
const response = await CourseApi.enrollCourse(courseId)
|
||||
if (response.code === 200) {
|
||||
console.log('报名成功:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('报名失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取学习进度示例
|
||||
export const getLearningProgressExample = async (courseId: number) => {
|
||||
try {
|
||||
const response = await CourseApi.getLearningProgress(courseId)
|
||||
if (response.code === 200) {
|
||||
console.log('学习进度:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取学习进度失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 评论相关示例 =====
|
||||
|
||||
// 获取课程评论示例
|
||||
export const getCourseCommentsExample = async (courseId: number) => {
|
||||
try {
|
||||
const response = await CommentApi.getCourseComments(courseId, {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
sortBy: 'newest'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('课程评论:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取课程评论失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加课程评论示例
|
||||
export const addCourseCommentExample = async (courseId: number) => {
|
||||
try {
|
||||
const response = await CommentApi.addCourseComment(courseId, {
|
||||
content: '这门课程非常棒,讲解清晰,内容丰富!',
|
||||
rating: 5
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('评论添加成功:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加评论失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 点赞评论示例
|
||||
export const likeCommentExample = async (commentId: number) => {
|
||||
try {
|
||||
const response = await CommentApi.likeComment(commentId)
|
||||
if (response.code === 200) {
|
||||
console.log('点赞成功:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 收藏相关示例 =====
|
||||
|
||||
// 添加收藏示例
|
||||
export const addFavoriteExample = async (courseId: number) => {
|
||||
try {
|
||||
const response = await FavoriteApi.addFavorite(courseId)
|
||||
if (response.code === 200) {
|
||||
console.log('收藏成功:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取收藏列表示例
|
||||
export const getFavoritesExample = async () => {
|
||||
try {
|
||||
const response = await FavoriteApi.getMyFavorites({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
sortBy: 'newest'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('收藏列表:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取收藏列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 订单相关示例 =====
|
||||
|
||||
// 创建订单示例
|
||||
export const createOrderExample = async (courseIds: number[]) => {
|
||||
try {
|
||||
const response = await OrderApi.createOrder({
|
||||
courseIds,
|
||||
couponCode: 'DISCOUNT10',
|
||||
paymentMethod: 'alipay'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('订单创建成功:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建订单失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取订单列表示例
|
||||
export const getOrdersExample = async () => {
|
||||
try {
|
||||
const response = await OrderApi.getOrders({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
status: 'paid'
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('订单列表:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 文件上传示例 =====
|
||||
|
||||
// 上传头像示例
|
||||
export const uploadAvatarExample = async (file: File) => {
|
||||
try {
|
||||
const response = await UploadApi.uploadAvatar(file, (progress) => {
|
||||
console.log('上传进度:', progress + '%')
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('头像上传成功:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传课程视频示例
|
||||
export const uploadCourseVideoExample = async (file: File, courseId: number) => {
|
||||
try {
|
||||
const response = await UploadApi.uploadCourseVideo(file, courseId, undefined, (progress) => {
|
||||
console.log('视频上传进度:', progress + '%')
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
console.log('视频上传成功:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('视频上传失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 统计相关示例 =====
|
||||
|
||||
// 获取平台统计示例
|
||||
export const getPlatformStatsExample = async () => {
|
||||
try {
|
||||
const response = await StatisticsApi.getPlatformStats()
|
||||
if (response.code === 200) {
|
||||
console.log('平台统计:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取平台统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户学习统计示例
|
||||
export const getUserLearningStatsExample = async () => {
|
||||
try {
|
||||
const response = await StatisticsApi.getUserLearningStats()
|
||||
if (response.code === 200) {
|
||||
console.log('用户学习统计:', response.data)
|
||||
return response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户学习统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 错误处理示例 =====
|
||||
|
||||
// 统一错误处理函数
|
||||
export const handleApiError = (error: any) => {
|
||||
if (error.response) {
|
||||
// 服务器返回错误状态码
|
||||
const { status, data } = error.response
|
||||
switch (status) {
|
||||
case 400:
|
||||
console.error('请求参数错误:', data.message)
|
||||
break
|
||||
case 401:
|
||||
console.error('未授权访问,请重新登录')
|
||||
// 跳转到登录页
|
||||
break
|
||||
case 403:
|
||||
console.error('没有权限访问')
|
||||
break
|
||||
case 404:
|
||||
console.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
console.error('服务器内部错误')
|
||||
break
|
||||
default:
|
||||
console.error('请求失败:', data.message || '未知错误')
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 网络错误
|
||||
console.error('网络错误,请检查网络连接')
|
||||
} else {
|
||||
// 其他错误
|
||||
console.error('请求配置错误:', error.message)
|
||||
}
|
||||
}
|
264
src/api/index.ts
Normal file
264
src/api/index.ts
Normal file
@ -0,0 +1,264 @@
|
||||
// API 统一导出文件
|
||||
export * from './types'
|
||||
export * from './request'
|
||||
|
||||
// 导出所有API模块
|
||||
export { default as AuthApi } from './modules/auth'
|
||||
export { default as CourseApi } from './modules/course'
|
||||
export { default as CommentApi } from './modules/comment'
|
||||
export { default as FavoriteApi } from './modules/favorite'
|
||||
export { default as OrderApi } from './modules/order'
|
||||
export { default as UploadApi } from './modules/upload'
|
||||
export { default as StatisticsApi } from './modules/statistics'
|
||||
|
||||
// API 基础配置
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
|
||||
|
||||
// API 端点配置
|
||||
export const API_ENDPOINTS = {
|
||||
// 认证相关
|
||||
AUTH: {
|
||||
LOGIN: '/auth/login',
|
||||
REGISTER: '/auth/register',
|
||||
LOGOUT: '/auth/logout',
|
||||
REFRESH: '/auth/refresh',
|
||||
ME: '/auth/me',
|
||||
PROFILE: '/auth/profile',
|
||||
CHANGE_PASSWORD: '/auth/change-password',
|
||||
FORGOT_PASSWORD: '/auth/forgot-password',
|
||||
RESET_PASSWORD: '/auth/reset-password',
|
||||
VERIFY_EMAIL: '/auth/verify-email',
|
||||
VERIFY_PHONE: '/auth/verify-phone',
|
||||
UPLOAD_AVATAR: '/auth/upload-avatar',
|
||||
},
|
||||
|
||||
// 课程相关
|
||||
COURSES: {
|
||||
LIST: '/courses',
|
||||
SEARCH: '/courses/search',
|
||||
POPULAR: '/courses/popular',
|
||||
LATEST: '/courses/latest',
|
||||
RECOMMENDED: '/courses/recommended',
|
||||
DETAIL: '/courses/:id',
|
||||
CHAPTERS: '/courses/:id/chapters',
|
||||
LESSONS: '/courses/:id/lessons',
|
||||
ENROLL: '/courses/:id/enroll',
|
||||
PROGRESS: '/courses/:id/progress',
|
||||
PREVIEW: '/courses/:id/preview',
|
||||
STATS: '/courses/:id/stats',
|
||||
RELATED: '/courses/:id/related',
|
||||
ACCESS: '/courses/:id/access',
|
||||
},
|
||||
|
||||
// 分类相关
|
||||
CATEGORIES: {
|
||||
LIST: '/categories',
|
||||
COURSES: '/categories/:id/courses',
|
||||
},
|
||||
|
||||
// 章节课时相关
|
||||
CHAPTERS: {
|
||||
DETAIL: '/chapters/:id',
|
||||
},
|
||||
|
||||
LESSONS: {
|
||||
DETAIL: '/lessons/:id',
|
||||
RESOURCES: '/lessons/:id/resources',
|
||||
COMPLETE: '/lessons/:id/complete',
|
||||
},
|
||||
|
||||
// 讲师相关
|
||||
INSTRUCTORS: {
|
||||
DETAIL: '/instructors/:id',
|
||||
COURSES: '/instructors/:id/courses',
|
||||
FOLLOW: '/instructors/:id/follow',
|
||||
},
|
||||
|
||||
// 测验相关
|
||||
QUIZZES: {
|
||||
LIST: '/courses/:id/quizzes',
|
||||
DETAIL: '/quizzes/:id',
|
||||
SUBMIT: '/quizzes/:id/submit',
|
||||
RESULTS: '/quizzes/:id/results',
|
||||
},
|
||||
|
||||
// 评论相关
|
||||
COMMENTS: {
|
||||
COURSE: '/courses/:id/comments',
|
||||
LESSON: '/lessons/:id/comments',
|
||||
DETAIL: '/comments/:id',
|
||||
REPLIES: '/comments/:id/replies',
|
||||
LIKE: '/comments/:id/like',
|
||||
DISLIKE: '/comments/:id/dislike',
|
||||
HELPFUL: '/comments/:id/helpful',
|
||||
REPORT: '/comments/:id/report',
|
||||
MY_COMMENTS: '/my-comments',
|
||||
STATS: '/comments/stats',
|
||||
},
|
||||
|
||||
// 收藏相关
|
||||
FAVORITES: {
|
||||
LIST: '/favorites',
|
||||
ADD: '/favorites',
|
||||
REMOVE: '/favorites/:id',
|
||||
CHECK: '/favorites/check/:id',
|
||||
BATCH: '/favorites/batch',
|
||||
STATS: '/favorites/stats',
|
||||
EXPORT: '/favorites/export',
|
||||
IMPORT: '/favorites/import',
|
||||
FOLDERS: '/favorite-folders',
|
||||
},
|
||||
|
||||
// 订单相关
|
||||
ORDERS: {
|
||||
LIST: '/orders',
|
||||
CREATE: '/orders',
|
||||
DETAIL: '/orders/:id',
|
||||
BY_NO: '/orders/no/:orderNo',
|
||||
CANCEL: '/orders/:id/cancel',
|
||||
CONFIRM_PAYMENT: '/orders/:id/confirm-payment',
|
||||
REFUND: '/orders/:id/refund',
|
||||
INVOICE: '/orders/:id/invoice',
|
||||
STATS: '/orders/stats',
|
||||
CALCULATE: '/orders/calculate',
|
||||
},
|
||||
|
||||
// 支付相关
|
||||
PAYMENT: {
|
||||
METHODS: '/payment-methods',
|
||||
STATUS: '/orders/:id/payment-status',
|
||||
RETRY: '/orders/:id/retry-payment',
|
||||
},
|
||||
|
||||
// 优惠券相关
|
||||
COUPONS: {
|
||||
VALIDATE: '/coupons/validate',
|
||||
AVAILABLE: '/coupons/available',
|
||||
},
|
||||
|
||||
// 退款相关
|
||||
REFUNDS: {
|
||||
LIST: '/refunds',
|
||||
DETAIL: '/refunds/:id',
|
||||
},
|
||||
|
||||
// 上传相关
|
||||
UPLOAD: {
|
||||
FILE: '/upload/:type',
|
||||
AVATAR: '/upload/avatar',
|
||||
COURSE_THUMBNAIL: '/upload/course-thumbnail',
|
||||
COURSE_VIDEO: '/upload/course-video',
|
||||
COURSE_RESOURCE: '/upload/course-resource',
|
||||
MULTIPLE: '/upload/multiple/:type',
|
||||
CONFIG: '/upload/config',
|
||||
TOKEN: '/upload/token/:type',
|
||||
COMPRESS: '/upload/compress-image',
|
||||
THUMBNAIL: '/upload/generate-thumbnail',
|
||||
HISTORY: '/upload/history',
|
||||
},
|
||||
|
||||
// 统计相关
|
||||
STATISTICS: {
|
||||
PLATFORM: '/statistics/platform',
|
||||
USER_LEARNING: '/statistics/user-learning',
|
||||
COURSE: '/statistics/course/:id',
|
||||
INSTRUCTOR: '/statistics/instructor/:id',
|
||||
LEARNING_PROGRESS: '/statistics/learning-progress',
|
||||
REVENUE: '/statistics/revenue',
|
||||
USER_BEHAVIOR: '/statistics/user-behavior',
|
||||
SEARCH: '/statistics/search',
|
||||
CONTENT: '/statistics/content',
|
||||
COMMENTS: '/statistics/comments',
|
||||
EXPORT: '/statistics/export/:type',
|
||||
},
|
||||
|
||||
// 学习进度相关
|
||||
LEARNING: {
|
||||
PROGRESS: '/learning-progress',
|
||||
MY_COURSES: '/my-courses',
|
||||
},
|
||||
|
||||
// 资源相关
|
||||
RESOURCES: {
|
||||
DOWNLOAD: '/resources/:id/download',
|
||||
},
|
||||
}
|
||||
|
||||
// 请求配置
|
||||
export const REQUEST_CONFIG = {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
// HTTP状态码
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
VALIDATION_ERROR: 422,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
INTERNAL_ERROR: 500,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
GATEWAY_TIMEOUT: 504,
|
||||
}
|
||||
|
||||
// 业务状态码
|
||||
export const BUSINESS_CODE = {
|
||||
SUCCESS: 0,
|
||||
FAILED: 1,
|
||||
INVALID_PARAMS: 1001,
|
||||
UNAUTHORIZED: 1002,
|
||||
FORBIDDEN: 1003,
|
||||
NOT_FOUND: 1004,
|
||||
ALREADY_EXISTS: 1005,
|
||||
OPERATION_FAILED: 1006,
|
||||
VALIDATION_FAILED: 1007,
|
||||
RATE_LIMITED: 1008,
|
||||
MAINTENANCE: 1009,
|
||||
}
|
||||
|
||||
// 常用的API响应消息
|
||||
export const API_MESSAGES = {
|
||||
SUCCESS: '操作成功',
|
||||
FAILED: '操作失败',
|
||||
INVALID_PARAMS: '参数错误',
|
||||
UNAUTHORIZED: '未授权访问',
|
||||
FORBIDDEN: '禁止访问',
|
||||
NOT_FOUND: '资源不存在',
|
||||
ALREADY_EXISTS: '资源已存在',
|
||||
NETWORK_ERROR: '网络错误',
|
||||
SERVER_ERROR: '服务器错误',
|
||||
TIMEOUT: '请求超时',
|
||||
UNKNOWN_ERROR: '未知错误',
|
||||
}
|
||||
|
||||
// 分页默认配置
|
||||
export const PAGINATION_CONFIG = {
|
||||
DEFAULT_PAGE: 1,
|
||||
DEFAULT_PAGE_SIZE: 20,
|
||||
MAX_PAGE_SIZE: 100,
|
||||
}
|
||||
|
||||
// 文件上传配置
|
||||
export const UPLOAD_CONFIG = {
|
||||
MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
|
||||
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
ALLOWED_VIDEO_TYPES: ['video/mp4', 'video/avi', 'video/mov', 'video/wmv'],
|
||||
ALLOWED_DOCUMENT_TYPES: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
ALLOWED_AUDIO_TYPES: ['audio/mp3', 'audio/wav', 'audio/ogg'],
|
||||
}
|
||||
|
||||
// 缓存配置
|
||||
export const CACHE_CONFIG = {
|
||||
DEFAULT_TTL: 5 * 60 * 1000, // 5分钟
|
||||
USER_INFO_TTL: 30 * 60 * 1000, // 30分钟
|
||||
COURSE_LIST_TTL: 10 * 60 * 1000, // 10分钟
|
||||
STATIC_DATA_TTL: 60 * 60 * 1000, // 1小时
|
||||
}
|
224
src/api/modules/auth.ts
Normal file
224
src/api/modules/auth.ts
Normal file
@ -0,0 +1,224 @@
|
||||
// 认证相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type {
|
||||
ApiResponse,
|
||||
User,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
BackendLoginResponse,
|
||||
RegisterRequest,
|
||||
UserProfile,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 认证API模块
|
||||
*/
|
||||
export class AuthApi {
|
||||
// 用户登录
|
||||
static async login(data: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
// 调用后端API
|
||||
const response = await ApiRequest.post<BackendLoginResponse>('/users/login', data)
|
||||
|
||||
// 适配后端响应格式为前端期望的格式
|
||||
const adaptedResponse: ApiResponse<LoginResponse> = {
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
data: {
|
||||
user: {
|
||||
id: response.data.id, // 使用后端返回的用户ID
|
||||
email: data.email || '',
|
||||
phone: data.phone || '',
|
||||
username: data.phone || data.email?.split('@')[0] || 'user',
|
||||
nickname: '用户',
|
||||
avatar: '',
|
||||
role: 'student',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
token: response.data.token,
|
||||
refreshToken: '', // 后端没有返回,使用空字符串
|
||||
expiresIn: 3600 // 默认1小时,可以根据expires字段计算
|
||||
}
|
||||
}
|
||||
|
||||
return adaptedResponse
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 用户注册
|
||||
static register(data: RegisterRequest): Promise<ApiResponse<User>> {
|
||||
return ApiRequest.post('/auth/register', data)
|
||||
}
|
||||
|
||||
// 用户登出
|
||||
static logout(): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/logout')
|
||||
}
|
||||
|
||||
// 刷新Token
|
||||
static refreshToken(refreshToken: string): Promise<ApiResponse<LoginResponse>> {
|
||||
return ApiRequest.post('/auth/refresh', { refreshToken })
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
static getCurrentUser(): Promise<ApiResponse<User>> {
|
||||
return ApiRequest.get('/auth/me')
|
||||
}
|
||||
|
||||
// 更新用户资料
|
||||
static updateProfile(data: Partial<UserProfile>): Promise<ApiResponse<User>> {
|
||||
return ApiRequest.put('/auth/profile', data)
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
static changePassword(data: {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/change-password', data)
|
||||
}
|
||||
|
||||
// 忘记密码 - 发送重置邮件
|
||||
static forgotPassword(email: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/forgot-password', { email })
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
static resetPassword(data: {
|
||||
token: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/reset-password', data)
|
||||
}
|
||||
|
||||
// 发送邮箱验证码
|
||||
static sendEmailVerification(email: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/send-email-verification', { email })
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
static verifyEmail(data: {
|
||||
email: string
|
||||
code: string
|
||||
}): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/verify-email', data)
|
||||
}
|
||||
|
||||
// 发送手机验证码
|
||||
static sendSmsVerification(phone: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/send-sms-verification', { phone })
|
||||
}
|
||||
|
||||
// 验证手机号
|
||||
static verifyPhone(data: {
|
||||
phone: string
|
||||
code: string
|
||||
}): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/verify-phone', data)
|
||||
}
|
||||
|
||||
// 绑定第三方账号
|
||||
static bindThirdParty(data: {
|
||||
provider: 'wechat' | 'qq' | 'weibo' | 'github'
|
||||
code: string
|
||||
state?: string
|
||||
}): Promise<ApiResponse<User>> {
|
||||
return ApiRequest.post('/auth/bind-third-party', data)
|
||||
}
|
||||
|
||||
// 解绑第三方账号
|
||||
static unbindThirdParty(provider: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/auth/unbind-third-party/${provider}`)
|
||||
}
|
||||
|
||||
// 获取第三方登录URL
|
||||
static getThirdPartyLoginUrl(provider: string, redirectUrl?: string): Promise<ApiResponse<{ url: string }>> {
|
||||
return ApiRequest.get(`/auth/third-party-login-url/${provider}`, { redirectUrl })
|
||||
}
|
||||
|
||||
// 第三方登录回调
|
||||
static thirdPartyLoginCallback(data: {
|
||||
provider: string
|
||||
code: string
|
||||
state?: string
|
||||
}): Promise<ApiResponse<LoginResponse>> {
|
||||
return ApiRequest.post('/auth/third-party-login-callback', data)
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
static uploadAvatar(file: File, onProgress?: (progress: number) => void): Promise<ApiResponse<{ url: string }>> {
|
||||
return ApiRequest.upload('/auth/upload-avatar', file, onProgress)
|
||||
}
|
||||
|
||||
// 删除账号
|
||||
static deleteAccount(password: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/delete-account', { password })
|
||||
}
|
||||
|
||||
// 获取账号安全信息
|
||||
static getSecurityInfo(): Promise<ApiResponse<{
|
||||
hasPassword: boolean
|
||||
hasEmail: boolean
|
||||
hasPhone: boolean
|
||||
emailVerified: boolean
|
||||
phoneVerified: boolean
|
||||
twoFactorEnabled: boolean
|
||||
thirdPartyAccounts: Array<{
|
||||
provider: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
bindTime: string
|
||||
}>
|
||||
loginHistory: Array<{
|
||||
ip: string
|
||||
location: string
|
||||
device: string
|
||||
loginTime: string
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/auth/security-info')
|
||||
}
|
||||
|
||||
// 启用两步验证
|
||||
static enableTwoFactor(): Promise<ApiResponse<{
|
||||
qrCode: string
|
||||
secret: string
|
||||
backupCodes: string[]
|
||||
}>> {
|
||||
return ApiRequest.post('/auth/enable-two-factor')
|
||||
}
|
||||
|
||||
// 确认启用两步验证
|
||||
static confirmTwoFactor(code: string): Promise<ApiResponse<{
|
||||
backupCodes: string[]
|
||||
}>> {
|
||||
return ApiRequest.post('/auth/confirm-two-factor', { code })
|
||||
}
|
||||
|
||||
// 禁用两步验证
|
||||
static disableTwoFactor(data: {
|
||||
password: string
|
||||
code: string
|
||||
}): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/disable-two-factor', data)
|
||||
}
|
||||
|
||||
// 生成新的备用码
|
||||
static generateBackupCodes(): Promise<ApiResponse<{
|
||||
backupCodes: string[]
|
||||
}>> {
|
||||
return ApiRequest.post('/auth/generate-backup-codes')
|
||||
}
|
||||
|
||||
// 验证两步验证码
|
||||
static verifyTwoFactor(code: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/auth/verify-two-factor', { code })
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthApi
|
198
src/api/modules/comment.ts
Normal file
198
src/api/modules/comment.ts
Normal file
@ -0,0 +1,198 @@
|
||||
// 评论相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type {
|
||||
ApiResponse,
|
||||
PaginationResponse,
|
||||
Comment,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 评论API模块
|
||||
*/
|
||||
export class CommentApi {
|
||||
// 获取课程评论
|
||||
static getCourseComments(courseId: number, params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sortBy?: 'newest' | 'oldest' | 'rating' | 'helpful'
|
||||
rating?: number
|
||||
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/comments`, params)
|
||||
}
|
||||
|
||||
// 获取课时评论
|
||||
static getLessonComments(lessonId: number, params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sortBy?: 'newest' | 'oldest' | 'helpful'
|
||||
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
|
||||
return ApiRequest.get(`/lessons/${lessonId}/comments`, params)
|
||||
}
|
||||
|
||||
// 添加课程评论
|
||||
static addCourseComment(courseId: number, data: {
|
||||
content: string
|
||||
rating?: number
|
||||
parentId?: number
|
||||
}): Promise<ApiResponse<Comment>> {
|
||||
return ApiRequest.post(`/courses/${courseId}/comments`, data)
|
||||
}
|
||||
|
||||
// 添加课时评论
|
||||
static addLessonComment(lessonId: number, data: {
|
||||
content: string
|
||||
parentId?: number
|
||||
}): Promise<ApiResponse<Comment>> {
|
||||
return ApiRequest.post(`/lessons/${lessonId}/comments`, data)
|
||||
}
|
||||
|
||||
// 更新评论
|
||||
static updateComment(commentId: number, data: {
|
||||
content: string
|
||||
rating?: number
|
||||
}): Promise<ApiResponse<Comment>> {
|
||||
return ApiRequest.put(`/comments/${commentId}`, data)
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
static deleteComment(commentId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/comments/${commentId}`)
|
||||
}
|
||||
|
||||
// 点赞评论
|
||||
static likeComment(commentId: number): Promise<ApiResponse<{
|
||||
likes: number
|
||||
isLiked: boolean
|
||||
}>> {
|
||||
return ApiRequest.post(`/comments/${commentId}/like`)
|
||||
}
|
||||
|
||||
// 取消点赞评论
|
||||
static unlikeComment(commentId: number): Promise<ApiResponse<{
|
||||
likes: number
|
||||
isLiked: boolean
|
||||
}>> {
|
||||
return ApiRequest.delete(`/comments/${commentId}/like`)
|
||||
}
|
||||
|
||||
// 踩评论
|
||||
static dislikeComment(commentId: number): Promise<ApiResponse<{
|
||||
dislikes: number
|
||||
isDisliked: boolean
|
||||
}>> {
|
||||
return ApiRequest.post(`/comments/${commentId}/dislike`)
|
||||
}
|
||||
|
||||
// 取消踩评论
|
||||
static undislikeComment(commentId: number): Promise<ApiResponse<{
|
||||
dislikes: number
|
||||
isDisliked: boolean
|
||||
}>> {
|
||||
return ApiRequest.delete(`/comments/${commentId}/dislike`)
|
||||
}
|
||||
|
||||
// 举报评论
|
||||
static reportComment(commentId: number, data: {
|
||||
reason: string
|
||||
description?: string
|
||||
}): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post(`/comments/${commentId}/report`, data)
|
||||
}
|
||||
|
||||
// 获取评论回复
|
||||
static getCommentReplies(commentId: number, params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
|
||||
return ApiRequest.get(`/comments/${commentId}/replies`, params)
|
||||
}
|
||||
|
||||
// 获取我的评论
|
||||
static getMyComments(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
type?: 'course' | 'lesson'
|
||||
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
|
||||
return ApiRequest.get('/my-comments', params)
|
||||
}
|
||||
|
||||
// 获取评论统计
|
||||
static getCommentStats(courseId?: number, lessonId?: number): Promise<ApiResponse<{
|
||||
totalComments: number
|
||||
averageRating: number
|
||||
ratingDistribution: Array<{
|
||||
rating: number
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
recentComments: Comment[]
|
||||
}>> {
|
||||
const params: any = {}
|
||||
if (courseId) params.courseId = courseId
|
||||
if (lessonId) params.lessonId = lessonId
|
||||
|
||||
return ApiRequest.get('/comments/stats', params)
|
||||
}
|
||||
|
||||
// 标记评论为有用
|
||||
static markCommentHelpful(commentId: number): Promise<ApiResponse<{
|
||||
helpfulCount: number
|
||||
isHelpful: boolean
|
||||
}>> {
|
||||
return ApiRequest.post(`/comments/${commentId}/helpful`)
|
||||
}
|
||||
|
||||
// 取消标记评论为有用
|
||||
static unmarkCommentHelpful(commentId: number): Promise<ApiResponse<{
|
||||
helpfulCount: number
|
||||
isHelpful: boolean
|
||||
}>> {
|
||||
return ApiRequest.delete(`/comments/${commentId}/helpful`)
|
||||
}
|
||||
|
||||
// 置顶评论(管理员功能)
|
||||
static pinComment(commentId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post(`/comments/${commentId}/pin`)
|
||||
}
|
||||
|
||||
// 取消置顶评论(管理员功能)
|
||||
static unpinComment(commentId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/comments/${commentId}/pin`)
|
||||
}
|
||||
|
||||
// 审核评论(管理员功能)
|
||||
static moderateComment(commentId: number, action: 'approve' | 'reject' | 'hide'): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post(`/comments/${commentId}/moderate`, { action })
|
||||
}
|
||||
|
||||
// 批量删除评论(管理员功能)
|
||||
static batchDeleteComments(commentIds: number[]): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/comments/batch-delete', { commentIds })
|
||||
}
|
||||
|
||||
// 获取待审核评论(管理员功能)
|
||||
static getPendingComments(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
|
||||
return ApiRequest.get('/comments/pending', params)
|
||||
}
|
||||
|
||||
// 获取被举报的评论(管理员功能)
|
||||
static getReportedComments(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<ApiResponse<PaginationResponse<Comment & {
|
||||
reports: Array<{
|
||||
id: number
|
||||
reason: string
|
||||
description?: string
|
||||
reportedBy: string
|
||||
reportedAt: string
|
||||
}>
|
||||
}>>> {
|
||||
return ApiRequest.get('/comments/reported', params)
|
||||
}
|
||||
}
|
||||
|
||||
export default CommentApi
|
528
src/api/modules/course.ts
Normal file
528
src/api/modules/course.ts
Normal file
@ -0,0 +1,528 @@
|
||||
// 课程相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type {
|
||||
ApiResponse,
|
||||
PaginationResponse,
|
||||
Course,
|
||||
CourseCategory,
|
||||
Chapter,
|
||||
Lesson,
|
||||
LessonResource,
|
||||
CourseSection,
|
||||
CourseSectionListResponse,
|
||||
BackendCourseSection,
|
||||
BackendCourseSectionListResponse,
|
||||
Quiz,
|
||||
QuizQuestion,
|
||||
LearningProgress,
|
||||
SearchRequest,
|
||||
Instructor,
|
||||
BackendCourse,
|
||||
BackendCourseListResponse,
|
||||
CourseListRequest,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 课程API模块
|
||||
*/
|
||||
export class CourseApi {
|
||||
|
||||
/**
|
||||
* 格式化时间戳为ISO字符串
|
||||
*/
|
||||
private static formatTimestamp(timestamp: number | null | undefined): string {
|
||||
if (!timestamp || timestamp <= 0) {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果时间戳是秒级的,转换为毫秒级
|
||||
const ms = timestamp < 10000000000 ? timestamp * 1000 : timestamp
|
||||
const date = new Date(ms)
|
||||
|
||||
// 检查日期是否有效
|
||||
if (isNaN(date.getTime())) {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
return date.toISOString()
|
||||
} catch (error) {
|
||||
console.error('时间戳格式化失败:', error)
|
||||
return new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算课程时长
|
||||
*/
|
||||
private static calculateDuration(startTime: string, endTime: string): string {
|
||||
try {
|
||||
const start = new Date(startTime)
|
||||
const end = new Date(endTime)
|
||||
const diffMs = end.getTime() - start.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
return `${diffDays}天`
|
||||
} catch (error) {
|
||||
return '待定'
|
||||
}
|
||||
}
|
||||
// 获取课程列表 - 适配后端接口
|
||||
static async getCourses(params?: CourseListRequest): Promise<ApiResponse<PaginationResponse<Course>>> {
|
||||
try {
|
||||
console.log('调用课程列表API,参数:', params)
|
||||
|
||||
// 构建查询参数,根据API文档的参数名称
|
||||
const queryParams: any = {}
|
||||
if (params?.categoryId) queryParams.categoryId = params.categoryId
|
||||
if (params?.difficulty !== undefined) queryParams.difficulty = params.difficulty
|
||||
if (params?.subject) queryParams.subject = params.subject
|
||||
if (params?.page) queryParams.page = params.page
|
||||
if (params?.pageSize) queryParams.pageSize = params.pageSize
|
||||
if (params?.keyword) queryParams.keyword = params.keyword
|
||||
|
||||
// 调用后端API
|
||||
const response = await ApiRequest.get<BackendCourseListResponse>('/lesson/list', queryParams)
|
||||
console.log('课程列表API响应:', response)
|
||||
|
||||
// 适配后端响应格式为前端期望的格式
|
||||
const adaptedCourses: Course[] = response.data.list.map((backendCourse: BackendCourse) => ({
|
||||
id: backendCourse.id,
|
||||
title: backendCourse.name,
|
||||
description: backendCourse.description,
|
||||
thumbnail: backendCourse.cover,
|
||||
coverImage: backendCourse.cover,
|
||||
price: parseFloat(backendCourse.price || '0'),
|
||||
originalPrice: parseFloat(backendCourse.price || '0'),
|
||||
currency: 'CNY',
|
||||
rating: 4.5, // 默认评分,后端没有返回
|
||||
ratingCount: 0, // 默认评分数量
|
||||
studentsCount: 0, // 默认学生数量
|
||||
duration: '待定', // 默认时长
|
||||
totalLessons: 0, // 默认课程数量
|
||||
level: this.mapDifficulty(backendCourse.difficulty || 0),
|
||||
language: 'zh-CN',
|
||||
category: {
|
||||
id: backendCourse.categoryId,
|
||||
name: this.getCategoryName(backendCourse.categoryId),
|
||||
slug: 'category-' + backendCourse.categoryId
|
||||
},
|
||||
tags: backendCourse.subject ? [backendCourse.subject] : [],
|
||||
skills: [],
|
||||
requirements: backendCourse.prerequisite ? [backendCourse.prerequisite] : [],
|
||||
objectives: backendCourse.target ? [backendCourse.target] : [],
|
||||
instructor: {
|
||||
id: backendCourse.teacherId || 0,
|
||||
name: backendCourse.school || '未知讲师',
|
||||
title: '讲师',
|
||||
bio: '',
|
||||
avatar: '',
|
||||
rating: 4.5,
|
||||
studentsCount: 0,
|
||||
coursesCount: 0,
|
||||
experience: '',
|
||||
education: [],
|
||||
certifications: []
|
||||
},
|
||||
status: 'published' as const,
|
||||
createdAt: this.formatTimestamp(backendCourse.createdTime),
|
||||
updatedAt: this.formatTimestamp(backendCourse.updatedTime),
|
||||
publishedAt: this.formatTimestamp(backendCourse.createdTime)
|
||||
}))
|
||||
|
||||
const adaptedResponse: ApiResponse<PaginationResponse<Course>> = {
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
data: {
|
||||
list: adaptedCourses,
|
||||
total: response.data.total,
|
||||
page: params?.page || 1,
|
||||
pageSize: params?.pageSize || 10,
|
||||
totalPages: Math.ceil(response.data.total / (params?.pageSize || 10))
|
||||
}
|
||||
}
|
||||
|
||||
return adaptedResponse
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索课程
|
||||
static searchCourses(params: SearchRequest): Promise<ApiResponse<PaginationResponse<Course>>> {
|
||||
return ApiRequest.get('/courses/search', params)
|
||||
}
|
||||
|
||||
// 获取热门课程
|
||||
static getPopularCourses(limit?: number): Promise<ApiResponse<Course[]>> {
|
||||
return ApiRequest.get('/courses/popular', { limit })
|
||||
}
|
||||
|
||||
// 获取最新课程
|
||||
static getLatestCourses(limit?: number): Promise<ApiResponse<Course[]>> {
|
||||
return ApiRequest.get('/courses/latest', { limit })
|
||||
}
|
||||
|
||||
// 获取推荐课程
|
||||
static getRecommendedCourses(userId?: number, limit?: number): Promise<ApiResponse<Course[]>> {
|
||||
return ApiRequest.get('/courses/recommended', { userId, limit })
|
||||
}
|
||||
|
||||
// 获取课程详情 - 适配后端接口
|
||||
static async getCourseById(id: number): Promise<ApiResponse<Course>> {
|
||||
try {
|
||||
// 调用后端课程详情接口
|
||||
const response = await ApiRequest.get<BackendCourse>('/lesson/detail', { id })
|
||||
|
||||
// 适配数据格式
|
||||
const adaptedCourse: Course = {
|
||||
id: response.data.id,
|
||||
title: response.data.name,
|
||||
description: response.data.description,
|
||||
content: response.data.outline, // 使用 outline 作为课程内容
|
||||
thumbnail: response.data.cover,
|
||||
coverImage: response.data.cover,
|
||||
price: parseFloat(response.data.price),
|
||||
originalPrice: parseFloat(response.data.price),
|
||||
currency: 'CNY',
|
||||
rating: 4.5,
|
||||
ratingCount: 0,
|
||||
studentsCount: 0,
|
||||
duration: this.calculateDuration(response.data.startTime, response.data.endTime),
|
||||
totalLessons: 0,
|
||||
level: 'beginner' as const,
|
||||
language: 'zh-CN',
|
||||
category: {
|
||||
id: response.data.categoryId,
|
||||
name: '未分类',
|
||||
slug: 'uncategorized'
|
||||
},
|
||||
tags: [],
|
||||
skills: [],
|
||||
requirements: response.data.prerequisite ? [response.data.prerequisite] : [],
|
||||
objectives: response.data.target ? [response.data.target] : [],
|
||||
instructor: {
|
||||
id: response.data.teacherId || 0,
|
||||
name: response.data.school || '未知讲师',
|
||||
title: '讲师',
|
||||
bio: response.data.position || '',
|
||||
avatar: '',
|
||||
rating: 4.5,
|
||||
studentsCount: 0,
|
||||
coursesCount: 0,
|
||||
experience: response.data.arrangement || '',
|
||||
education: [],
|
||||
certifications: []
|
||||
},
|
||||
status: 'published' as const,
|
||||
createdAt: this.formatTimestamp(response.data.createdAt),
|
||||
updatedAt: this.formatTimestamp(response.data.updatedAt),
|
||||
publishedAt: response.data.startTime
|
||||
}
|
||||
|
||||
return {
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
data: adaptedCourse
|
||||
}
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取课程章节
|
||||
static getCourseChapters(courseId: number): Promise<ApiResponse<Chapter[]>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/chapters`)
|
||||
}
|
||||
|
||||
// 获取课程所有课时
|
||||
static getCourseLessons(courseId: number): Promise<ApiResponse<Lesson[]>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/lessons`)
|
||||
}
|
||||
|
||||
// 获取章节详情
|
||||
static getChapterById(id: number): Promise<ApiResponse<Chapter>> {
|
||||
return ApiRequest.get(`/chapters/${id}`)
|
||||
}
|
||||
|
||||
// 获取课时详情
|
||||
static getLessonById(id: number): Promise<ApiResponse<Lesson>> {
|
||||
return ApiRequest.get(`/lessons/${id}`)
|
||||
}
|
||||
|
||||
// 获取课时资源
|
||||
static getLessonResources(lessonId: number): Promise<ApiResponse<LessonResource[]>> {
|
||||
return ApiRequest.get(`/lessons/${lessonId}/resources`)
|
||||
}
|
||||
|
||||
// 获取课程分类
|
||||
static getCategories(): Promise<ApiResponse<CourseCategory[]>> {
|
||||
return ApiRequest.get('/categories')
|
||||
}
|
||||
|
||||
// 获取分类下的课程
|
||||
static getCoursesByCategory(categoryId: number, params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sortBy?: string
|
||||
}): Promise<ApiResponse<PaginationResponse<Course>>> {
|
||||
return ApiRequest.get(`/categories/${categoryId}/courses`, params)
|
||||
}
|
||||
|
||||
// 报名课程
|
||||
static enrollCourse(courseId: number): Promise<ApiResponse<{
|
||||
enrollmentId: number
|
||||
message: string
|
||||
}>> {
|
||||
return ApiRequest.post(`/courses/${courseId}/enroll`)
|
||||
}
|
||||
|
||||
// 取消报名
|
||||
static unenrollCourse(courseId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/courses/${courseId}/enroll`)
|
||||
}
|
||||
|
||||
// 获取课程章节列表
|
||||
static async getCourseSections(lessonId: number): Promise<ApiResponse<CourseSectionListResponse>> {
|
||||
try {
|
||||
console.log('尝试从API获取课程章节数据,课程ID:', lessonId)
|
||||
console.log('API请求URL: /lesson/section/list')
|
||||
console.log('API请求参数:', { lesson_id: lessonId.toString() })
|
||||
|
||||
const backendResponse = await ApiRequest.get<BackendCourseSectionListResponse>('/lesson/section/list', { lesson_id: lessonId.toString() })
|
||||
console.log('章节API响应:', backendResponse)
|
||||
console.log('响应状态码:', backendResponse.code)
|
||||
console.log('响应消息:', backendResponse.message)
|
||||
console.log('原始章节数据:', backendResponse.data?.list)
|
||||
console.log('章节数据数量:', backendResponse.data?.list?.length || 0)
|
||||
|
||||
// 检查数据是否存在
|
||||
if (!backendResponse.data || !backendResponse.data.list) {
|
||||
console.warn('API返回的数据结构不正确:', backendResponse.data)
|
||||
return {
|
||||
code: backendResponse.code,
|
||||
message: backendResponse.message,
|
||||
data: {
|
||||
list: [],
|
||||
timestamp: Date.now(),
|
||||
traceId: backendResponse.timestamp?.toString() || ''
|
||||
},
|
||||
timestamp: backendResponse.timestamp?.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// 适配数据格式
|
||||
const adaptedSections: CourseSection[] = backendResponse.data.list.map((section: BackendCourseSection) => ({
|
||||
id: section.id,
|
||||
lessonId: section.lessonId,
|
||||
outline: section.videoUrl, // 将videoUrl映射到outline
|
||||
name: section.name,
|
||||
parentId: section.parentId,
|
||||
sort: section.sortOrder, // 将sortOrder映射到sort
|
||||
level: section.level === 0 ? 1 : 0, // 转换level逻辑:API中0=子级,1=父级;前端中0=父级,1=子级
|
||||
revision: section.revision,
|
||||
createdAt: section.createdTime ? new Date(section.createdTime).getTime() : null,
|
||||
updatedAt: section.updatedTime ? new Date(section.updatedTime).getTime() : null,
|
||||
deletedAt: null,
|
||||
completed: false,
|
||||
duration: undefined
|
||||
}))
|
||||
|
||||
console.log('适配后的章节数据:', adaptedSections)
|
||||
|
||||
const adaptedResponse: ApiResponse<CourseSectionListResponse> = {
|
||||
code: backendResponse.code,
|
||||
message: backendResponse.message,
|
||||
data: {
|
||||
list: adaptedSections,
|
||||
timestamp: Date.now(),
|
||||
traceId: backendResponse.timestamp?.toString() || ''
|
||||
},
|
||||
timestamp: backendResponse.timestamp?.toString()
|
||||
}
|
||||
|
||||
return adaptedResponse
|
||||
} catch (error) {
|
||||
console.error('章节API调用失败:', error)
|
||||
console.error('错误详情:', {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
response: (error as any).response?.data,
|
||||
status: (error as any).response?.status,
|
||||
statusText: (error as any).response?.statusText
|
||||
})
|
||||
|
||||
// 重新抛出错误,不使用模拟数据
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取我的课程
|
||||
static getMyCourses(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: 'all' | 'in_progress' | 'completed'
|
||||
}): Promise<ApiResponse<PaginationResponse<Course>>> {
|
||||
return ApiRequest.get('/my-courses', params)
|
||||
}
|
||||
|
||||
// 获取学习进度
|
||||
static getLearningProgress(courseId: number): Promise<ApiResponse<LearningProgress>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/progress`)
|
||||
}
|
||||
|
||||
// 更新学习进度
|
||||
static updateLearningProgress(data: {
|
||||
courseId: number
|
||||
lessonId: number
|
||||
progress: number
|
||||
timeSpent?: number
|
||||
}): Promise<ApiResponse<LearningProgress>> {
|
||||
return ApiRequest.post('/learning-progress', data)
|
||||
}
|
||||
|
||||
// 标记课时完成
|
||||
static markLessonCompleted(lessonId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post(`/lessons/${lessonId}/complete`)
|
||||
}
|
||||
|
||||
// 获取课程测验
|
||||
static getCourseQuizzes(courseId: number): Promise<ApiResponse<Quiz[]>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/quizzes`)
|
||||
}
|
||||
|
||||
// 获取测验详情
|
||||
static getQuizById(id: number): Promise<ApiResponse<Quiz>> {
|
||||
return ApiRequest.get(`/quizzes/${id}`)
|
||||
}
|
||||
|
||||
// 提交测验答案
|
||||
static submitQuizAnswers(quizId: number, answers: Array<{
|
||||
questionId: number
|
||||
answer: string | string[]
|
||||
}>): Promise<ApiResponse<{
|
||||
score: number
|
||||
totalScore: number
|
||||
passed: boolean
|
||||
correctAnswers: number
|
||||
totalQuestions: number
|
||||
results: Array<{
|
||||
questionId: number
|
||||
correct: boolean
|
||||
userAnswer: string | string[]
|
||||
correctAnswer: string | string[]
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.post(`/quizzes/${quizId}/submit`, { answers })
|
||||
}
|
||||
|
||||
// 获取测验结果
|
||||
static getQuizResults(quizId: number): Promise<ApiResponse<{
|
||||
attempts: Array<{
|
||||
id: number
|
||||
score: number
|
||||
totalScore: number
|
||||
passed: boolean
|
||||
submittedAt: string
|
||||
}>
|
||||
bestScore: number
|
||||
averageScore: number
|
||||
totalAttempts: number
|
||||
}>> {
|
||||
return ApiRequest.get(`/quizzes/${quizId}/results`)
|
||||
}
|
||||
|
||||
// 下载课程资源
|
||||
static downloadResource(resourceId: number): Promise<void> {
|
||||
return ApiRequest.download(`/resources/${resourceId}/download`)
|
||||
}
|
||||
|
||||
// 获取讲师信息
|
||||
static getInstructorById(id: number): Promise<ApiResponse<Instructor>> {
|
||||
return ApiRequest.get(`/instructors/${id}`)
|
||||
}
|
||||
|
||||
// 获取讲师的课程
|
||||
static getInstructorCourses(instructorId: number, params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<ApiResponse<PaginationResponse<Course>>> {
|
||||
return ApiRequest.get(`/instructors/${instructorId}/courses`, params)
|
||||
}
|
||||
|
||||
// 关注讲师
|
||||
static followInstructor(instructorId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post(`/instructors/${instructorId}/follow`)
|
||||
}
|
||||
|
||||
// 取消关注讲师
|
||||
static unfollowInstructor(instructorId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/instructors/${instructorId}/follow`)
|
||||
}
|
||||
|
||||
// 获取课程统计信息
|
||||
static getCourseStats(courseId: number): Promise<ApiResponse<{
|
||||
totalStudents: number
|
||||
totalLessons: number
|
||||
totalDuration: string
|
||||
averageRating: number
|
||||
completionRate: number
|
||||
enrollmentTrend: Array<{
|
||||
date: string
|
||||
count: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/stats`)
|
||||
}
|
||||
|
||||
// 预览课程(免费课时)
|
||||
static previewCourse(courseId: number): Promise<ApiResponse<{
|
||||
freeLessons: Lesson[]
|
||||
previewVideo?: string
|
||||
}>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/preview`)
|
||||
}
|
||||
|
||||
// 获取相关课程推荐
|
||||
static getRelatedCourses(courseId: number, limit?: number): Promise<ApiResponse<Course[]>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/related`, { limit })
|
||||
}
|
||||
|
||||
// 检查课程访问权限
|
||||
static checkCourseAccess(courseId: number): Promise<ApiResponse<{
|
||||
hasAccess: boolean
|
||||
reason?: string
|
||||
expiresAt?: string
|
||||
}>> {
|
||||
return ApiRequest.get(`/courses/${courseId}/access`)
|
||||
}
|
||||
|
||||
// 辅助方法:映射难度等级
|
||||
private static mapDifficulty(difficulty: number): 'beginner' | 'intermediate' | 'advanced' {
|
||||
switch (difficulty) {
|
||||
case 0:
|
||||
return 'beginner'
|
||||
case 1:
|
||||
return 'intermediate'
|
||||
case 2:
|
||||
return 'advanced'
|
||||
default:
|
||||
return 'beginner'
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助方法:获取分类名称
|
||||
private static getCategoryName(categoryId: number): string {
|
||||
// 这里可以根据categoryId返回对应的分类名称
|
||||
// 暂时返回默认值,后续可以通过分类API获取
|
||||
const categoryMap: { [key: number]: string } = {
|
||||
1: '信息技术',
|
||||
2: '数学',
|
||||
3: '物理',
|
||||
4: '化学',
|
||||
5: '生物'
|
||||
}
|
||||
return categoryMap[categoryId] || '其他'
|
||||
}
|
||||
}
|
||||
|
||||
export default CourseApi
|
194
src/api/modules/favorite.ts
Normal file
194
src/api/modules/favorite.ts
Normal file
@ -0,0 +1,194 @@
|
||||
// 收藏相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type {
|
||||
ApiResponse,
|
||||
PaginationResponse,
|
||||
Favorite,
|
||||
Course,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 收藏API模块
|
||||
*/
|
||||
export class FavoriteApi {
|
||||
// 添加收藏
|
||||
static addFavorite(courseId: number): Promise<ApiResponse<Favorite>> {
|
||||
return ApiRequest.post('/favorites', { courseId })
|
||||
}
|
||||
|
||||
// 取消收藏
|
||||
static removeFavorite(courseId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/favorites/${courseId}`)
|
||||
}
|
||||
|
||||
// 检查是否已收藏
|
||||
static checkFavorite(courseId: number): Promise<ApiResponse<{
|
||||
isFavorite: boolean
|
||||
favoriteId?: number
|
||||
}>> {
|
||||
return ApiRequest.get(`/favorites/check/${courseId}`)
|
||||
}
|
||||
|
||||
// 获取我的收藏列表
|
||||
static getMyFavorites(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
category?: string
|
||||
sortBy?: 'newest' | 'oldest' | 'rating' | 'price'
|
||||
}): Promise<ApiResponse<PaginationResponse<Favorite>>> {
|
||||
return ApiRequest.get('/favorites', params)
|
||||
}
|
||||
|
||||
// 批量添加收藏
|
||||
static batchAddFavorites(courseIds: number[]): Promise<ApiResponse<{
|
||||
success: number[]
|
||||
failed: number[]
|
||||
message: string
|
||||
}>> {
|
||||
return ApiRequest.post('/favorites/batch', { courseIds })
|
||||
}
|
||||
|
||||
// 批量取消收藏
|
||||
static batchRemoveFavorites(courseIds: number[]): Promise<ApiResponse<{
|
||||
success: number[]
|
||||
failed: number[]
|
||||
message: string
|
||||
}>> {
|
||||
return ApiRequest.delete('/favorites/batch', { courseIds })
|
||||
}
|
||||
|
||||
// 获取收藏统计
|
||||
static getFavoriteStats(): Promise<ApiResponse<{
|
||||
totalFavorites: number
|
||||
favoritesByCategory: Array<{
|
||||
category: string
|
||||
count: number
|
||||
}>
|
||||
recentFavorites: Course[]
|
||||
favoritesTrend: Array<{
|
||||
date: string
|
||||
count: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/favorites/stats')
|
||||
}
|
||||
|
||||
// 导出收藏列表
|
||||
static exportFavorites(format: 'json' | 'csv' | 'excel'): Promise<void> {
|
||||
return ApiRequest.download(`/favorites/export?format=${format}`, `favorites.${format}`)
|
||||
}
|
||||
|
||||
// 导入收藏列表
|
||||
static importFavorites(file: File): Promise<ApiResponse<{
|
||||
imported: number
|
||||
failed: number
|
||||
duplicates: number
|
||||
message: string
|
||||
}>> {
|
||||
return ApiRequest.upload('/favorites/import', file)
|
||||
}
|
||||
|
||||
// 清空收藏列表
|
||||
static clearAllFavorites(): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete('/favorites/clear')
|
||||
}
|
||||
|
||||
// 获取收藏夹分类(如果支持分类收藏)
|
||||
static getFavoriteFolders(): Promise<ApiResponse<Array<{
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
courseCount: number
|
||||
createdAt: string
|
||||
}>>> {
|
||||
return ApiRequest.get('/favorite-folders')
|
||||
}
|
||||
|
||||
// 创建收藏夹
|
||||
static createFavoriteFolder(data: {
|
||||
name: string
|
||||
description?: string
|
||||
}): Promise<ApiResponse<{
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
}>> {
|
||||
return ApiRequest.post('/favorite-folders', data)
|
||||
}
|
||||
|
||||
// 更新收藏夹
|
||||
static updateFavoriteFolder(folderId: number, data: {
|
||||
name?: string
|
||||
description?: string
|
||||
}): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.put(`/favorite-folders/${folderId}`, data)
|
||||
}
|
||||
|
||||
// 删除收藏夹
|
||||
static deleteFavoriteFolder(folderId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/favorite-folders/${folderId}`)
|
||||
}
|
||||
|
||||
// 将课程添加到收藏夹
|
||||
static addCourseToFolder(courseId: number, folderId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post(`/favorite-folders/${folderId}/courses`, { courseId })
|
||||
}
|
||||
|
||||
// 从收藏夹移除课程
|
||||
static removeCourseFromFolder(courseId: number, folderId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete(`/favorite-folders/${folderId}/courses/${courseId}`)
|
||||
}
|
||||
|
||||
// 获取收藏夹中的课程
|
||||
static getFolderCourses(folderId: number, params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<ApiResponse<PaginationResponse<Course>>> {
|
||||
return ApiRequest.get(`/favorite-folders/${folderId}/courses`, params)
|
||||
}
|
||||
|
||||
// 移动课程到其他收藏夹
|
||||
static moveCourseToFolder(courseId: number, fromFolderId: number, toFolderId: number): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post('/favorites/move', {
|
||||
courseId,
|
||||
fromFolderId,
|
||||
toFolderId
|
||||
})
|
||||
}
|
||||
|
||||
// 获取最近收藏的课程
|
||||
static getRecentFavorites(limit?: number): Promise<ApiResponse<Course[]>> {
|
||||
return ApiRequest.get('/favorites/recent', { limit })
|
||||
}
|
||||
|
||||
// 获取收藏推荐(基于收藏历史推荐相似课程)
|
||||
static getFavoriteRecommendations(limit?: number): Promise<ApiResponse<Course[]>> {
|
||||
return ApiRequest.get('/favorites/recommendations', { limit })
|
||||
}
|
||||
|
||||
// 分享收藏列表
|
||||
static shareFavorites(data: {
|
||||
folderId?: number
|
||||
isPublic: boolean
|
||||
description?: string
|
||||
}): Promise<ApiResponse<{
|
||||
shareId: string
|
||||
shareUrl: string
|
||||
expiresAt?: string
|
||||
}>> {
|
||||
return ApiRequest.post('/favorites/share', data)
|
||||
}
|
||||
|
||||
// 获取分享的收藏列表
|
||||
static getSharedFavorites(shareId: string): Promise<ApiResponse<{
|
||||
title: string
|
||||
description?: string
|
||||
courses: Course[]
|
||||
sharedBy: string
|
||||
sharedAt: string
|
||||
}>> {
|
||||
return ApiRequest.get(`/favorites/shared/${shareId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default FavoriteApi
|
282
src/api/modules/order.ts
Normal file
282
src/api/modules/order.ts
Normal file
@ -0,0 +1,282 @@
|
||||
// 订单相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type {
|
||||
ApiResponse,
|
||||
PaginationResponse,
|
||||
Order,
|
||||
OrderItem,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 订单API模块
|
||||
*/
|
||||
export class OrderApi {
|
||||
// 创建订单
|
||||
static createOrder(data: {
|
||||
courseIds: number[]
|
||||
couponCode?: string
|
||||
paymentMethod?: string
|
||||
}): Promise<ApiResponse<{
|
||||
order: Order
|
||||
paymentInfo?: {
|
||||
paymentUrl?: string
|
||||
qrCode?: string
|
||||
orderNo: string
|
||||
}
|
||||
}>> {
|
||||
return ApiRequest.post('/orders', data)
|
||||
}
|
||||
|
||||
// 获取订单列表
|
||||
static getOrders(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<ApiResponse<PaginationResponse<Order>>> {
|
||||
return ApiRequest.get('/orders', params)
|
||||
}
|
||||
|
||||
// 获取订单详情
|
||||
static getOrderById(orderId: number): Promise<ApiResponse<Order>> {
|
||||
return ApiRequest.get(`/orders/${orderId}`)
|
||||
}
|
||||
|
||||
// 通过订单号获取订单
|
||||
static getOrderByNo(orderNo: string): Promise<ApiResponse<Order>> {
|
||||
return ApiRequest.get(`/orders/no/${orderNo}`)
|
||||
}
|
||||
|
||||
// 取消订单
|
||||
static cancelOrder(orderId: number, reason?: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.post(`/orders/${orderId}/cancel`, { reason })
|
||||
}
|
||||
|
||||
// 确认支付
|
||||
static confirmPayment(orderId: number, data: {
|
||||
paymentMethod: string
|
||||
transactionId?: string
|
||||
paymentProof?: string
|
||||
}): Promise<ApiResponse<{
|
||||
success: boolean
|
||||
message: string
|
||||
paymentStatus: string
|
||||
}>> {
|
||||
return ApiRequest.post(`/orders/${orderId}/confirm-payment`, data)
|
||||
}
|
||||
|
||||
// 申请退款
|
||||
static requestRefund(orderId: number, data: {
|
||||
reason: string
|
||||
description?: string
|
||||
refundAmount?: number
|
||||
}): Promise<ApiResponse<{
|
||||
refundId: number
|
||||
message: string
|
||||
}>> {
|
||||
return ApiRequest.post(`/orders/${orderId}/refund`, data)
|
||||
}
|
||||
|
||||
// 获取支付方式列表
|
||||
static getPaymentMethods(): Promise<ApiResponse<Array<{
|
||||
id: string
|
||||
name: string
|
||||
type: 'alipay' | 'wechat' | 'bank' | 'paypal' | 'stripe'
|
||||
icon: string
|
||||
enabled: boolean
|
||||
description?: string
|
||||
fee?: number
|
||||
feeType?: 'fixed' | 'percentage'
|
||||
}>>> {
|
||||
return ApiRequest.get('/payment-methods')
|
||||
}
|
||||
|
||||
// 获取支付状态
|
||||
static getPaymentStatus(orderId: number): Promise<ApiResponse<{
|
||||
status: 'pending' | 'processing' | 'success' | 'failed' | 'cancelled'
|
||||
message: string
|
||||
paidAt?: string
|
||||
paymentMethod?: string
|
||||
transactionId?: string
|
||||
}>> {
|
||||
return ApiRequest.get(`/orders/${orderId}/payment-status`)
|
||||
}
|
||||
|
||||
// 重新支付
|
||||
static retryPayment(orderId: number, paymentMethod?: string): Promise<ApiResponse<{
|
||||
paymentUrl?: string
|
||||
qrCode?: string
|
||||
orderNo: string
|
||||
}>> {
|
||||
return ApiRequest.post(`/orders/${orderId}/retry-payment`, { paymentMethod })
|
||||
}
|
||||
|
||||
// 获取发票信息
|
||||
static getInvoice(orderId: number): Promise<ApiResponse<{
|
||||
invoiceNo: string
|
||||
invoiceUrl: string
|
||||
invoiceDate: string
|
||||
amount: number
|
||||
taxAmount: number
|
||||
items: Array<{
|
||||
name: string
|
||||
quantity: number
|
||||
price: number
|
||||
amount: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get(`/orders/${orderId}/invoice`)
|
||||
}
|
||||
|
||||
// 申请发票
|
||||
static requestInvoice(orderId: number, data: {
|
||||
type: 'personal' | 'company'
|
||||
title: string
|
||||
taxId?: string
|
||||
address?: string
|
||||
phone?: string
|
||||
bank?: string
|
||||
bankAccount?: string
|
||||
email: string
|
||||
}): Promise<ApiResponse<{
|
||||
invoiceId: number
|
||||
message: string
|
||||
}>> {
|
||||
return ApiRequest.post(`/orders/${orderId}/request-invoice`, data)
|
||||
}
|
||||
|
||||
// 下载发票
|
||||
static downloadInvoice(orderId: number): Promise<void> {
|
||||
return ApiRequest.download(`/orders/${orderId}/invoice/download`, `invoice-${orderId}.pdf`)
|
||||
}
|
||||
|
||||
// 获取订单统计
|
||||
static getOrderStats(params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<ApiResponse<{
|
||||
totalOrders: number
|
||||
totalAmount: number
|
||||
paidOrders: number
|
||||
paidAmount: number
|
||||
pendingOrders: number
|
||||
cancelledOrders: number
|
||||
refundedOrders: number
|
||||
averageOrderValue: number
|
||||
ordersByStatus: Array<{
|
||||
status: string
|
||||
count: number
|
||||
amount: number
|
||||
}>
|
||||
ordersTrend: Array<{
|
||||
date: string
|
||||
orders: number
|
||||
amount: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/orders/stats', params)
|
||||
}
|
||||
|
||||
// 验证优惠券
|
||||
static validateCoupon(code: string, courseIds: number[]): Promise<ApiResponse<{
|
||||
valid: boolean
|
||||
coupon?: {
|
||||
id: number
|
||||
code: string
|
||||
type: 'fixed' | 'percentage'
|
||||
value: number
|
||||
minAmount?: number
|
||||
maxDiscount?: number
|
||||
description: string
|
||||
}
|
||||
discount: number
|
||||
message: string
|
||||
}>> {
|
||||
return ApiRequest.post('/coupons/validate', { code, courseIds })
|
||||
}
|
||||
|
||||
// 获取可用优惠券
|
||||
static getAvailableCoupons(courseIds?: number[]): Promise<ApiResponse<Array<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
type: 'fixed' | 'percentage'
|
||||
value: number
|
||||
minAmount?: number
|
||||
maxDiscount?: number
|
||||
description: string
|
||||
expiresAt: string
|
||||
usageLimit?: number
|
||||
usedCount: number
|
||||
}>>> {
|
||||
return ApiRequest.get('/coupons/available', { courseIds })
|
||||
}
|
||||
|
||||
// 计算订单金额
|
||||
static calculateOrderAmount(data: {
|
||||
courseIds: number[]
|
||||
couponCode?: string
|
||||
}): Promise<ApiResponse<{
|
||||
subtotal: number
|
||||
discount: number
|
||||
tax: number
|
||||
total: number
|
||||
items: Array<{
|
||||
courseId: number
|
||||
title: string
|
||||
price: number
|
||||
discountPrice?: number
|
||||
}>
|
||||
coupon?: {
|
||||
code: string
|
||||
discount: number
|
||||
description: string
|
||||
}
|
||||
}>> {
|
||||
return ApiRequest.post('/orders/calculate', data)
|
||||
}
|
||||
|
||||
// 获取退款列表
|
||||
static getRefunds(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
}): Promise<ApiResponse<PaginationResponse<{
|
||||
id: number
|
||||
orderId: number
|
||||
orderNo: string
|
||||
amount: number
|
||||
reason: string
|
||||
status: 'pending' | 'approved' | 'rejected' | 'processed'
|
||||
requestedAt: string
|
||||
processedAt?: string
|
||||
note?: string
|
||||
}>>> {
|
||||
return ApiRequest.get('/refunds', params)
|
||||
}
|
||||
|
||||
// 获取退款详情
|
||||
static getRefundById(refundId: number): Promise<ApiResponse<{
|
||||
id: number
|
||||
order: Order
|
||||
amount: number
|
||||
reason: string
|
||||
description?: string
|
||||
status: string
|
||||
requestedAt: string
|
||||
processedAt?: string
|
||||
processedBy?: string
|
||||
note?: string
|
||||
timeline: Array<{
|
||||
status: string
|
||||
note?: string
|
||||
createdAt: string
|
||||
createdBy?: string
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get(`/refunds/${refundId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default OrderApi
|
374
src/api/modules/statistics.ts
Normal file
374
src/api/modules/statistics.ts
Normal file
@ -0,0 +1,374 @@
|
||||
// 统计相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type { ApiResponse, Statistics } from '../types'
|
||||
|
||||
/**
|
||||
* 统计API模块
|
||||
*/
|
||||
export class StatisticsApi {
|
||||
// 获取平台总体统计
|
||||
static getPlatformStats(): Promise<ApiResponse<Statistics>> {
|
||||
return ApiRequest.get('/statistics/platform')
|
||||
}
|
||||
|
||||
// 获取用户学习统计
|
||||
static getUserLearningStats(userId?: number): Promise<ApiResponse<{
|
||||
totalCourses: number
|
||||
completedCourses: number
|
||||
inProgressCourses: number
|
||||
totalLearningTime: number
|
||||
averageProgress: number
|
||||
certificates: number
|
||||
favoriteCount: number
|
||||
commentsCount: number
|
||||
learningStreak: number
|
||||
lastActiveDate: string
|
||||
monthlyProgress: Array<{
|
||||
month: string
|
||||
coursesCompleted: number
|
||||
learningTime: number
|
||||
}>
|
||||
skillsAcquired: Array<{
|
||||
skill: string
|
||||
level: number
|
||||
coursesCount: number
|
||||
}>
|
||||
achievements: Array<{
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
unlockedAt: string
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/statistics/user-learning', { userId })
|
||||
}
|
||||
|
||||
// 获取课程统计
|
||||
static getCourseStats(courseId: number): Promise<ApiResponse<{
|
||||
totalStudents: number
|
||||
activeStudents: number
|
||||
completedStudents: number
|
||||
averageProgress: number
|
||||
averageRating: number
|
||||
totalRatings: number
|
||||
totalComments: number
|
||||
totalLearningTime: number
|
||||
completionRate: number
|
||||
dropoutRate: number
|
||||
enrollmentTrend: Array<{
|
||||
date: string
|
||||
enrollments: number
|
||||
completions: number
|
||||
}>
|
||||
progressDistribution: Array<{
|
||||
range: string
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
ratingDistribution: Array<{
|
||||
rating: number
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
popularLessons: Array<{
|
||||
lessonId: number
|
||||
title: string
|
||||
viewCount: number
|
||||
averageWatchTime: number
|
||||
}>
|
||||
studentDemographics: {
|
||||
ageGroups: Array<{
|
||||
range: string
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
locations: Array<{
|
||||
country: string
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
devices: Array<{
|
||||
device: string
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
}
|
||||
}>> {
|
||||
return ApiRequest.get(`/statistics/course/${courseId}`)
|
||||
}
|
||||
|
||||
// 获取讲师统计
|
||||
static getInstructorStats(instructorId: number): Promise<ApiResponse<{
|
||||
totalCourses: number
|
||||
publishedCourses: number
|
||||
totalStudents: number
|
||||
totalRevenue: number
|
||||
averageRating: number
|
||||
totalRatings: number
|
||||
totalReviews: number
|
||||
followers: number
|
||||
coursesRanking: Array<{
|
||||
courseId: number
|
||||
title: string
|
||||
students: number
|
||||
rating: number
|
||||
revenue: number
|
||||
}>
|
||||
monthlyStats: Array<{
|
||||
month: string
|
||||
newStudents: number
|
||||
revenue: number
|
||||
newCourses: number
|
||||
}>
|
||||
studentFeedback: {
|
||||
positiveCount: number
|
||||
neutralCount: number
|
||||
negativeCount: number
|
||||
commonKeywords: Array<{
|
||||
keyword: string
|
||||
count: number
|
||||
sentiment: 'positive' | 'neutral' | 'negative'
|
||||
}>
|
||||
}
|
||||
}>> {
|
||||
return ApiRequest.get(`/statistics/instructor/${instructorId}`)
|
||||
}
|
||||
|
||||
// 获取学习进度统计
|
||||
static getLearningProgressStats(params?: {
|
||||
courseId?: number
|
||||
userId?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<ApiResponse<{
|
||||
totalSessions: number
|
||||
totalLearningTime: number
|
||||
averageSessionTime: number
|
||||
completionRate: number
|
||||
dailyProgress: Array<{
|
||||
date: string
|
||||
sessions: number
|
||||
learningTime: number
|
||||
lessonsCompleted: number
|
||||
}>
|
||||
weeklyProgress: Array<{
|
||||
week: string
|
||||
sessions: number
|
||||
learningTime: number
|
||||
lessonsCompleted: number
|
||||
}>
|
||||
deviceUsage: Array<{
|
||||
device: string
|
||||
sessions: number
|
||||
percentage: number
|
||||
}>
|
||||
timeDistribution: Array<{
|
||||
hour: number
|
||||
sessions: number
|
||||
percentage: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/statistics/learning-progress', params)
|
||||
}
|
||||
|
||||
// 获取收入统计
|
||||
static getRevenueStats(params?: {
|
||||
instructorId?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
groupBy?: 'day' | 'week' | 'month' | 'year'
|
||||
}): Promise<ApiResponse<{
|
||||
totalRevenue: number
|
||||
totalOrders: number
|
||||
averageOrderValue: number
|
||||
refundRate: number
|
||||
revenueByPeriod: Array<{
|
||||
period: string
|
||||
revenue: number
|
||||
orders: number
|
||||
refunds: number
|
||||
}>
|
||||
revenueByCategory: Array<{
|
||||
category: string
|
||||
revenue: number
|
||||
percentage: number
|
||||
}>
|
||||
topSellingCourses: Array<{
|
||||
courseId: number
|
||||
title: string
|
||||
sales: number
|
||||
revenue: number
|
||||
}>
|
||||
paymentMethods: Array<{
|
||||
method: string
|
||||
count: number
|
||||
amount: number
|
||||
percentage: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/statistics/revenue', params)
|
||||
}
|
||||
|
||||
// 获取用户行为统计
|
||||
static getUserBehaviorStats(params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<ApiResponse<{
|
||||
totalUsers: number
|
||||
activeUsers: number
|
||||
newUsers: number
|
||||
returningUsers: number
|
||||
userRetentionRate: number
|
||||
averageSessionDuration: number
|
||||
bounceRate: number
|
||||
userActivity: Array<{
|
||||
date: string
|
||||
activeUsers: number
|
||||
newUsers: number
|
||||
sessions: number
|
||||
}>
|
||||
userEngagement: {
|
||||
dailyActiveUsers: number
|
||||
weeklyActiveUsers: number
|
||||
monthlyActiveUsers: number
|
||||
averageSessionsPerUser: number
|
||||
averagePageViews: number
|
||||
}
|
||||
userJourney: Array<{
|
||||
step: string
|
||||
users: number
|
||||
conversionRate: number
|
||||
}>
|
||||
popularPages: Array<{
|
||||
page: string
|
||||
views: number
|
||||
uniqueViews: number
|
||||
averageTime: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/statistics/user-behavior', params)
|
||||
}
|
||||
|
||||
// 获取搜索统计
|
||||
static getSearchStats(params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
limit?: number
|
||||
}): Promise<ApiResponse<{
|
||||
totalSearches: number
|
||||
uniqueSearches: number
|
||||
averageResultsPerSearch: number
|
||||
noResultsRate: number
|
||||
topKeywords: Array<{
|
||||
keyword: string
|
||||
count: number
|
||||
clickThroughRate: number
|
||||
conversionRate: number
|
||||
}>
|
||||
searchTrends: Array<{
|
||||
date: string
|
||||
searches: number
|
||||
uniqueSearches: number
|
||||
}>
|
||||
categorySearches: Array<{
|
||||
category: string
|
||||
searches: number
|
||||
percentage: number
|
||||
}>
|
||||
searchSources: Array<{
|
||||
source: string
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/statistics/search', params)
|
||||
}
|
||||
|
||||
// 获取内容统计
|
||||
static getContentStats(): Promise<ApiResponse<{
|
||||
totalCourses: number
|
||||
publishedCourses: number
|
||||
draftCourses: number
|
||||
totalLessons: number
|
||||
totalDuration: string
|
||||
averageCourseDuration: string
|
||||
averageLessonsPerCourse: number
|
||||
contentByCategory: Array<{
|
||||
category: string
|
||||
courses: number
|
||||
lessons: number
|
||||
duration: string
|
||||
}>
|
||||
contentByLevel: Array<{
|
||||
level: string
|
||||
courses: number
|
||||
percentage: number
|
||||
}>
|
||||
contentByLanguage: Array<{
|
||||
language: string
|
||||
courses: number
|
||||
percentage: number
|
||||
}>
|
||||
contentGrowth: Array<{
|
||||
month: string
|
||||
newCourses: number
|
||||
newLessons: number
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/statistics/content')
|
||||
}
|
||||
|
||||
// 获取评论统计
|
||||
static getCommentStats(params?: {
|
||||
courseId?: number
|
||||
instructorId?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<ApiResponse<{
|
||||
totalComments: number
|
||||
averageRating: number
|
||||
responseRate: number
|
||||
averageResponseTime: number
|
||||
sentimentAnalysis: {
|
||||
positive: number
|
||||
neutral: number
|
||||
negative: number
|
||||
}
|
||||
ratingDistribution: Array<{
|
||||
rating: number
|
||||
count: number
|
||||
percentage: number
|
||||
}>
|
||||
commentTrends: Array<{
|
||||
date: string
|
||||
comments: number
|
||||
averageRating: number
|
||||
}>
|
||||
topReviewedCourses: Array<{
|
||||
courseId: number
|
||||
title: string
|
||||
comments: number
|
||||
averageRating: number
|
||||
}>
|
||||
commonKeywords: Array<{
|
||||
keyword: string
|
||||
count: number
|
||||
sentiment: 'positive' | 'neutral' | 'negative'
|
||||
}>
|
||||
}>> {
|
||||
return ApiRequest.get('/statistics/comments', params)
|
||||
}
|
||||
|
||||
// 导出统计报告
|
||||
static exportStatsReport(type: string, params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
format?: 'pdf' | 'excel' | 'csv'
|
||||
}): Promise<void> {
|
||||
const format = params?.format || 'pdf'
|
||||
return ApiRequest.download(`/statistics/export/${type}?format=${format}`, `stats-report.${format}`, params)
|
||||
}
|
||||
}
|
||||
|
||||
export default StatisticsApi
|
331
src/api/modules/upload.ts
Normal file
331
src/api/modules/upload.ts
Normal file
@ -0,0 +1,331 @@
|
||||
// 文件上传相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type { ApiResponse } from '../types'
|
||||
|
||||
/**
|
||||
* 上传API模块
|
||||
*/
|
||||
export class UploadApi {
|
||||
// 上传单个文件
|
||||
static uploadFile(
|
||||
file: File,
|
||||
type: 'image' | 'video' | 'document' | 'audio' = 'image',
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<{
|
||||
url: string
|
||||
filename: string
|
||||
size: number
|
||||
type: string
|
||||
hash?: string
|
||||
}>> {
|
||||
return ApiRequest.upload(`/upload/${type}`, file, onProgress)
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
static uploadAvatar(
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<{
|
||||
url: string
|
||||
filename: string
|
||||
size: number
|
||||
}>> {
|
||||
return ApiRequest.upload('/upload/avatar', file, onProgress)
|
||||
}
|
||||
|
||||
// 上传课程封面
|
||||
static uploadCourseThumbnail(
|
||||
file: File,
|
||||
courseId?: number,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<{
|
||||
url: string
|
||||
filename: string
|
||||
size: number
|
||||
}>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (courseId) {
|
||||
formData.append('courseId', courseId.toString())
|
||||
}
|
||||
|
||||
return ApiRequest.post('/upload/course-thumbnail', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 上传课程视频
|
||||
static uploadCourseVideo(
|
||||
file: File,
|
||||
courseId?: number,
|
||||
lessonId?: number,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<{
|
||||
url: string
|
||||
filename: string
|
||||
size: number
|
||||
duration?: number
|
||||
resolution?: string
|
||||
}>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (courseId) {
|
||||
formData.append('courseId', courseId.toString())
|
||||
}
|
||||
if (lessonId) {
|
||||
formData.append('lessonId', lessonId.toString())
|
||||
}
|
||||
|
||||
return ApiRequest.post('/upload/course-video', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 上传课程资源文件
|
||||
static uploadCourseResource(
|
||||
file: File,
|
||||
lessonId: number,
|
||||
title?: string,
|
||||
description?: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<{
|
||||
id: number
|
||||
url: string
|
||||
filename: string
|
||||
title: string
|
||||
size: number
|
||||
type: string
|
||||
}>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('lessonId', lessonId.toString())
|
||||
if (title) {
|
||||
formData.append('title', title)
|
||||
}
|
||||
if (description) {
|
||||
formData.append('description', description)
|
||||
}
|
||||
|
||||
return ApiRequest.post('/upload/course-resource', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 批量上传文件
|
||||
static uploadMultipleFiles(
|
||||
files: File[],
|
||||
type: 'image' | 'video' | 'document' | 'audio' = 'image',
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<Array<{
|
||||
url: string
|
||||
filename: string
|
||||
size: number
|
||||
type: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}>>> {
|
||||
const formData = new FormData()
|
||||
files.forEach((file, index) => {
|
||||
formData.append(`files`, file)
|
||||
})
|
||||
|
||||
return ApiRequest.post(`/upload/multiple/${type}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取上传配置
|
||||
static getUploadConfig(): Promise<ApiResponse<{
|
||||
maxFileSize: number
|
||||
allowedTypes: string[]
|
||||
imageTypes: string[]
|
||||
videoTypes: string[]
|
||||
documentTypes: string[]
|
||||
audioTypes: string[]
|
||||
uploadUrl: string
|
||||
cdnUrl: string
|
||||
}>> {
|
||||
return ApiRequest.get('/upload/config')
|
||||
}
|
||||
|
||||
// 获取上传token(用于直传OSS等)
|
||||
static getUploadToken(type: string = 'image'): Promise<ApiResponse<{
|
||||
token: string
|
||||
accessKeyId: string
|
||||
accessKeySecret: string
|
||||
securityToken: string
|
||||
bucket: string
|
||||
region: string
|
||||
endpoint: string
|
||||
expiration: string
|
||||
policy: string
|
||||
signature: string
|
||||
}>> {
|
||||
return ApiRequest.get(`/upload/token/${type}`)
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
static deleteFile(url: string): Promise<ApiResponse<null>> {
|
||||
return ApiRequest.delete('/upload/file', { url })
|
||||
}
|
||||
|
||||
// 批量删除文件
|
||||
static deleteMultipleFiles(urls: string[]): Promise<ApiResponse<{
|
||||
success: string[]
|
||||
failed: string[]
|
||||
}>> {
|
||||
return ApiRequest.delete('/upload/files', { urls })
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
static getFileInfo(url: string): Promise<ApiResponse<{
|
||||
url: string
|
||||
filename: string
|
||||
size: number
|
||||
type: string
|
||||
uploadedAt: string
|
||||
uploadedBy?: string
|
||||
downloads?: number
|
||||
}>> {
|
||||
return ApiRequest.get('/upload/file-info', { url })
|
||||
}
|
||||
|
||||
// 压缩图片
|
||||
static compressImage(
|
||||
file: File,
|
||||
options: {
|
||||
quality?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
format?: 'jpeg' | 'png' | 'webp'
|
||||
} = {},
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<{
|
||||
url: string
|
||||
filename: string
|
||||
originalSize: number
|
||||
compressedSize: number
|
||||
compressionRatio: number
|
||||
}>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('options', JSON.stringify(options))
|
||||
|
||||
return ApiRequest.post('/upload/compress-image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 生成缩略图
|
||||
static generateThumbnail(
|
||||
file: File,
|
||||
sizes: Array<{ width: number; height: number }> = [
|
||||
{ width: 150, height: 150 },
|
||||
{ width: 300, height: 300 },
|
||||
],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<{
|
||||
original: string
|
||||
thumbnails: Array<{
|
||||
size: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
}>
|
||||
}>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('sizes', JSON.stringify(sizes))
|
||||
|
||||
return ApiRequest.post('/upload/generate-thumbnail', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取上传历史
|
||||
static getUploadHistory(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
type?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<ApiResponse<{
|
||||
list: Array<{
|
||||
id: number
|
||||
filename: string
|
||||
originalName: string
|
||||
url: string
|
||||
size: number
|
||||
type: string
|
||||
uploadedAt: string
|
||||
downloads: number
|
||||
}>
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}>> {
|
||||
return ApiRequest.get('/upload/history', params)
|
||||
}
|
||||
}
|
||||
|
||||
export default UploadApi
|
527
src/api/request.ts
Normal file
527
src/api/request.ts
Normal file
@ -0,0 +1,527 @@
|
||||
// HTTP 请求封装文件
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import router from '@/router'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
// 消息提示函数 - 使用window.alert作为fallback,实际项目中应该使用UI库的消息组件
|
||||
const showMessage = (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') => {
|
||||
// 这里可以替换为你使用的UI库的消息组件
|
||||
// 例如:naive-ui的 useMessage()
|
||||
console.log(`[${type.toUpperCase()}] ${message}`)
|
||||
|
||||
// 临时使用alert,实际项目中应该替换为UI库的消息组件
|
||||
if (type === 'error') {
|
||||
alert(`错误: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建axios实例
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config: AxiosRequestConfig) => {
|
||||
// 添加认证token
|
||||
const userStore = useUserStore()
|
||||
if (userStore.token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${userStore.token}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 添加请求时间戳
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'X-Request-Time': Date.now().toString(),
|
||||
}
|
||||
|
||||
// 开发环境下打印请求信息
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🚀 Request:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Request Error:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const { data } = response
|
||||
|
||||
// 开发环境下打印响应信息
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Response:', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// 检查业务状态码
|
||||
if (data.code === 200 || data.code === 0) {
|
||||
return data
|
||||
}
|
||||
|
||||
// 处理业务错误
|
||||
const errorMessage = data.message || '请求失败'
|
||||
// 不在这里显示错误消息,让组件自己处理
|
||||
// showMessage(errorMessage, 'error')
|
||||
|
||||
// 创建一个包含完整响应信息的错误对象
|
||||
const error = new Error(errorMessage)
|
||||
;(error as any).response = {
|
||||
data: data,
|
||||
status: 200 // HTTP状态码是200,但业务状态码不是成功
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Response Error:', error)
|
||||
|
||||
// 处理HTTP状态码错误
|
||||
const { response } = error
|
||||
let errorMessage = '网络错误,请稍后重试'
|
||||
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
errorMessage = '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
errorMessage = '登录已过期,请重新登录'
|
||||
// 清除用户信息,不跳转页面(使用模态框登录)
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
break
|
||||
case 403:
|
||||
errorMessage = '没有权限访问'
|
||||
break
|
||||
case 404:
|
||||
errorMessage = '请求的资源不存在'
|
||||
break
|
||||
case 422:
|
||||
errorMessage = '数据验证失败'
|
||||
break
|
||||
case 429:
|
||||
errorMessage = '请求过于频繁,请稍后重试'
|
||||
break
|
||||
case 500:
|
||||
errorMessage = '服务器内部错误'
|
||||
break
|
||||
case 502:
|
||||
errorMessage = '网关错误'
|
||||
break
|
||||
case 503:
|
||||
errorMessage = '服务暂时不可用'
|
||||
break
|
||||
case 504:
|
||||
errorMessage = '网关超时'
|
||||
break
|
||||
default:
|
||||
errorMessage = `请求失败 (${response.status})`
|
||||
}
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请检查网络连接'
|
||||
} else if (error.message === 'Network Error') {
|
||||
errorMessage = '网络连接失败,请检查网络设置'
|
||||
}
|
||||
|
||||
showMessage(errorMessage, 'error')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Mock数据处理
|
||||
const handleMockRequest = async <T = any>(url: string, method: string, data?: any): Promise<ApiResponse<T>> => {
|
||||
console.log('🚀 Mock Request:', { url, method, data })
|
||||
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 登录Mock
|
||||
if (url === '/users/login' && method === 'POST') {
|
||||
const { email, phone, password } = data || {}
|
||||
const loginField = phone || email
|
||||
|
||||
// 模拟登录验证
|
||||
if (loginField && password) {
|
||||
return {
|
||||
code: 200,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
user: {
|
||||
id: 1,
|
||||
email: email || `${phone}@example.com`,
|
||||
phone: phone || '123456789',
|
||||
username: phone || email?.split('@')[0] || 'user',
|
||||
nickname: '测试用户',
|
||||
avatar: 'https://via.placeholder.com/100',
|
||||
role: 'student',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
token: 'mock_jwt_token_' + Date.now(),
|
||||
refreshToken: 'mock_refresh_token_' + Date.now(),
|
||||
expiresIn: 3600
|
||||
}
|
||||
} as ApiResponse<T>
|
||||
} else {
|
||||
return {
|
||||
code: 400,
|
||||
message: '手机号/邮箱或密码不能为空',
|
||||
data: null
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
}
|
||||
|
||||
// 注册Mock
|
||||
if (url === '/auth/register' && method === 'POST') {
|
||||
const { email, password, verificationCode } = data || {}
|
||||
|
||||
if (!email || !password) {
|
||||
return {
|
||||
code: 400,
|
||||
message: '邮箱和密码不能为空',
|
||||
data: null
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
if (!verificationCode) {
|
||||
return {
|
||||
code: 400,
|
||||
message: '验证码不能为空',
|
||||
data: null
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '注册成功',
|
||||
data: {
|
||||
id: 2,
|
||||
email: email,
|
||||
username: email.split('@')[0],
|
||||
nickname: '新用户',
|
||||
avatar: '',
|
||||
role: 'student',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
// 发送验证码Mock
|
||||
if (url === '/auth/send-verification' && method === 'POST') {
|
||||
return {
|
||||
code: 200,
|
||||
message: '验证码已发送',
|
||||
data: null
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
// 获取当前用户信息Mock
|
||||
if (url === '/auth/me' && method === 'GET') {
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
username: 'test',
|
||||
nickname: '测试用户',
|
||||
avatar: '',
|
||||
role: 'student',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
// 课程详情Mock
|
||||
if (url === '/lesson/detail' && method === 'GET') {
|
||||
// 对于GET请求,参数直接在data中(data就是params对象)
|
||||
const id = data?.id
|
||||
console.log('课程详情Mock - 获取到的ID:', id, '原始data:', data)
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
code: 400,
|
||||
message: '课程ID必填',
|
||||
data: null
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
// 根据课程ID提供不同的模拟数据
|
||||
const courseData = {
|
||||
1: {
|
||||
name: 'DeepSeek大语言模型实战应用',
|
||||
cover: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=400&h=300&fit=crop',
|
||||
price: '299.00',
|
||||
school: 'DeepSeek技术学院',
|
||||
description: '本课程深度聚焦DeepSeek大语言模型的实际应用,让每一位学员了解并学习使用DeepSeek,结合办公自动化职业岗位标准,以实际工作任务为引导,强调课程内容的易用性和岗位要求的匹配性。',
|
||||
position: 'AI技术专家 / 高级讲师'
|
||||
},
|
||||
2: {
|
||||
name: 'Python编程基础与实战',
|
||||
cover: 'https://images.unsplash.com/photo-1526379095098-d400fd0bf935?w=400&h=300&fit=crop',
|
||||
price: '199.00',
|
||||
school: '编程技术学院',
|
||||
description: '从零开始学习Python编程,涵盖基础语法、数据结构、面向对象编程等核心概念,通过实际项目练习掌握Python开发技能。',
|
||||
position: 'Python开发专家 / 资深讲师'
|
||||
},
|
||||
3: {
|
||||
name: 'Web前端开发全栈课程',
|
||||
cover: 'https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=400&h=300&fit=crop',
|
||||
price: '399.00',
|
||||
school: '前端技术学院',
|
||||
description: '全面学习现代Web前端开发技术,包括HTML5、CSS3、JavaScript、Vue.js、React等主流框架,培养全栈开发能力。',
|
||||
position: '前端架构师 / 技术总监'
|
||||
}
|
||||
}
|
||||
|
||||
const currentCourse = courseData[id as keyof typeof courseData] || courseData[1]
|
||||
|
||||
// 模拟课程详情数据
|
||||
return {
|
||||
code: 0,
|
||||
message: '查询课程详情成功',
|
||||
data: {
|
||||
id: parseInt(id),
|
||||
name: currentCourse.name,
|
||||
cover: currentCourse.cover,
|
||||
categoryId: 1,
|
||||
price: currentCourse.price,
|
||||
school: currentCourse.school,
|
||||
description: currentCourse.description,
|
||||
teacherId: 1,
|
||||
outline: '<div><h4>课程大纲:</h4><ul><li><strong>第一章:基础入门</strong><br/>- 环境搭建与配置<br/>- 基本概念理解<br/>- 实践操作演示</li><li><strong>第二章:核心技能</strong><br/>- 核心功能详解<br/>- 实际应用场景<br/>- 案例分析讲解</li><li><strong>第三章:高级应用</strong><br/>- 进阶技巧掌握<br/>- 项目实战演练<br/>- 问题解决方案</li></ul></div>',
|
||||
prerequisite: '具备基本的计算机操作能力',
|
||||
target: '掌握核心技能,能够在实际工作中熟练应用',
|
||||
arrangement: '理论与实践相结合,循序渐进的学习方式',
|
||||
startTime: '2025-01-26 10:13:17',
|
||||
endTime: '2025-03-26 10:13:17',
|
||||
revision: 1,
|
||||
position: currentCourse.position,
|
||||
createdAt: 1737944724,
|
||||
updatedAt: 1737944724,
|
||||
updatedTime: null
|
||||
}
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
// 课程列表Mock
|
||||
if (url === '/lesson/list' && method === 'GET') {
|
||||
// 模拟课程列表数据
|
||||
const mockCourses = [
|
||||
{
|
||||
id: 1,
|
||||
name: "暑期名师领学,提高班级教学质量!高效冲分指南",
|
||||
cover: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
||||
categoryId: 1,
|
||||
price: "99.00",
|
||||
school: "名师工作室",
|
||||
description: "本课程深度聚焦问题,让每一位教师了解并学习使用DeepSeek,结合办公自动化职业岗位标准,以实际工作任务为引导,强调课程内容的易用性和岗位要求的匹配性。",
|
||||
teacherId: 1,
|
||||
outline: "课程大纲详细内容...",
|
||||
prerequisite: "具备基本的计算机操作能力",
|
||||
target: "掌握核心技能,能够在实际工作中熟练应用",
|
||||
arrangement: "理论与实践相结合,循序渐进的学习方式",
|
||||
startTime: "2025-01-26 10:13:17",
|
||||
endTime: "2025-03-26 10:13:17",
|
||||
revision: 1,
|
||||
position: "高级讲师",
|
||||
createdAt: 1737944724,
|
||||
updatedAt: 1737944724,
|
||||
updatedTime: null
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "计算机二级考前冲刺班",
|
||||
cover: "https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
||||
categoryId: 2,
|
||||
price: "199.00",
|
||||
school: "计算机学院",
|
||||
description: "备考计算机二级,名师带你高效复习,掌握考试重点,轻松通过考试。",
|
||||
teacherId: 2,
|
||||
outline: "考试大纲详细解析...",
|
||||
prerequisite: "具备基本的计算机基础知识",
|
||||
target: "顺利通过计算机二级考试",
|
||||
arrangement: "考点精讲+真题演练+模拟考试",
|
||||
startTime: "2025-02-01 09:00:00",
|
||||
endTime: "2025-02-28 18:00:00",
|
||||
revision: 1,
|
||||
position: "副教授",
|
||||
createdAt: 1737944724,
|
||||
updatedAt: 1737944724,
|
||||
updatedTime: null
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "摆脱哑巴英语,流利口语训练营",
|
||||
cover: "https://images.unsplash.com/photo-1434030216411-0b793f4b4173?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
||||
categoryId: 3,
|
||||
price: "299.00",
|
||||
school: "外语学院",
|
||||
description: "专业外教授课,情景式教学,让你在短时间内突破口语障碍,自信开口说英语。",
|
||||
teacherId: 3,
|
||||
outline: "口语训练系统课程...",
|
||||
prerequisite: "具备基本的英语基础",
|
||||
target: "能够流利进行日常英语对话",
|
||||
arrangement: "外教一对一+小班练习+实战演练",
|
||||
startTime: "2025-02-15 19:00:00",
|
||||
endTime: "2025-04-15 21:00:00",
|
||||
revision: 1,
|
||||
position: "外籍教师",
|
||||
createdAt: 1737944724,
|
||||
updatedAt: 1737944724,
|
||||
updatedTime: null
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
message: '查询课程列表成功',
|
||||
data: {
|
||||
list: mockCourses,
|
||||
total: mockCourses.length
|
||||
}
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 默认404响应
|
||||
return {
|
||||
code: 404,
|
||||
message: '接口不存在',
|
||||
data: null
|
||||
} as ApiResponse<T>
|
||||
}
|
||||
|
||||
// 请求方法封装
|
||||
export class ApiRequest {
|
||||
// GET 请求
|
||||
static get<T = any>(
|
||||
url: string,
|
||||
params?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
// 检查是否启用Mock
|
||||
if (import.meta.env.VITE_ENABLE_MOCK === 'true') {
|
||||
return handleMockRequest<T>(url, 'GET', params)
|
||||
}
|
||||
return request.get(url, { params, ...config })
|
||||
}
|
||||
|
||||
// POST 请求
|
||||
static post<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
// 检查是否启用Mock
|
||||
if (import.meta.env.VITE_ENABLE_MOCK === 'true') {
|
||||
return handleMockRequest<T>(url, 'POST', data)
|
||||
}
|
||||
return request.post(url, data, config)
|
||||
}
|
||||
|
||||
// PUT 请求
|
||||
static put<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
return request.put(url, data, config)
|
||||
}
|
||||
|
||||
// PATCH 请求
|
||||
static patch<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
return request.patch(url, data, config)
|
||||
}
|
||||
|
||||
// DELETE 请求
|
||||
static delete<T = any>(
|
||||
url: string,
|
||||
params?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
return request.delete(url, { params, ...config })
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
static upload<T = any>(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
// 文件下载
|
||||
static download(
|
||||
url: string,
|
||||
filename?: string,
|
||||
params?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<void> {
|
||||
return request
|
||||
.get(url, {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
...config,
|
||||
})
|
||||
.then((response: any) => {
|
||||
const blob = new Blob([response.data])
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = filename || 'download'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default request
|
407
src/api/types.ts
Normal file
407
src/api/types.ts
Normal file
@ -0,0 +1,407 @@
|
||||
// API 接口类型定义文件
|
||||
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginationResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// 用户相关类型
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
phone?: string
|
||||
avatar?: string
|
||||
role: 'student' | 'teacher' | 'admin'
|
||||
status: 'active' | 'inactive' | 'banned'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
profile?: UserProfile
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
realName?: string
|
||||
gender?: 'male' | 'female' | 'other'
|
||||
birthday?: string
|
||||
bio?: string
|
||||
location?: string
|
||||
website?: string
|
||||
socialLinks?: {
|
||||
wechat?: string
|
||||
qq?: string
|
||||
weibo?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 登录注册类型
|
||||
export interface LoginRequest {
|
||||
email?: string
|
||||
phone?: string
|
||||
password: string
|
||||
captcha?: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: User
|
||||
token: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
// 后端实际返回的登录响应格式
|
||||
export interface BackendLoginResponse {
|
||||
token: string
|
||||
id: number
|
||||
timestamp: number
|
||||
expires: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
email: string
|
||||
phone?: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
captcha: string
|
||||
inviteCode?: string
|
||||
}
|
||||
|
||||
// 课程相关类型
|
||||
export interface Course {
|
||||
id: number
|
||||
title: string
|
||||
subtitle?: string
|
||||
description: string
|
||||
content?: string
|
||||
thumbnail: string
|
||||
coverImage?: string
|
||||
videoUrl?: string
|
||||
price: number
|
||||
originalPrice?: number
|
||||
discountPrice?: number
|
||||
currency: string
|
||||
rating: number
|
||||
ratingCount: number
|
||||
studentsCount: number
|
||||
duration: string
|
||||
totalLessons: number
|
||||
level: 'beginner' | 'intermediate' | 'advanced'
|
||||
language: string
|
||||
category: CourseCategory
|
||||
tags: string[]
|
||||
skills: string[]
|
||||
requirements: string[]
|
||||
objectives: string[]
|
||||
instructor: Instructor
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
isEnrolled?: boolean
|
||||
progress?: number
|
||||
isFavorite?: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
// 后端实际返回的课程数据格式
|
||||
export interface BackendCourse {
|
||||
id: number
|
||||
name: string
|
||||
cover: string
|
||||
categoryId: number
|
||||
video: string
|
||||
school: string
|
||||
description: string
|
||||
target: string
|
||||
outline: string
|
||||
prerequisite: string
|
||||
reference: string
|
||||
arrangement: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
revision: number
|
||||
question: string
|
||||
createdBy: number
|
||||
createdTime: string | null
|
||||
updatedBy: number
|
||||
updatedTime: string | null
|
||||
// 可选字段,根据API文档可能存在
|
||||
difficulty?: number // 难度等级
|
||||
subject?: string // 学科/主题
|
||||
price?: string
|
||||
teacherId?: number
|
||||
position?: string
|
||||
}
|
||||
|
||||
// 后端课程列表响应格式
|
||||
export interface BackendCourseListResponse {
|
||||
list: BackendCourse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// 课程列表请求参数
|
||||
export interface CourseListRequest {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
categoryId?: number
|
||||
keyword?: string
|
||||
priceMin?: number
|
||||
priceMax?: number
|
||||
sortBy?: 'price' | 'startTime' | 'createdAt'
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
difficulty?: number // 难度等级:0=简单,1=中等,2=困难
|
||||
subject?: string // 学科/主题
|
||||
}
|
||||
|
||||
export interface CourseCategory {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description?: string
|
||||
icon?: string
|
||||
parentId?: number
|
||||
children?: CourseCategory[]
|
||||
}
|
||||
|
||||
export interface Instructor {
|
||||
id: number
|
||||
name: string
|
||||
title: string
|
||||
bio: string
|
||||
avatar: string
|
||||
rating: number
|
||||
studentsCount: number
|
||||
coursesCount: number
|
||||
experience: string
|
||||
education: string[]
|
||||
certifications: string[]
|
||||
socialLinks?: {
|
||||
website?: string
|
||||
linkedin?: string
|
||||
twitter?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 课程章节类型
|
||||
export interface Chapter {
|
||||
id: number
|
||||
courseId: number
|
||||
title: string
|
||||
description?: string
|
||||
order: number
|
||||
duration: string
|
||||
isPublished: boolean
|
||||
lessons: Lesson[]
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: number
|
||||
chapterId: number
|
||||
courseId: number
|
||||
title: string
|
||||
description?: string
|
||||
content?: string
|
||||
videoUrl?: string
|
||||
duration: string
|
||||
order: number
|
||||
type: 'video' | 'text' | 'quiz' | 'assignment'
|
||||
isCompleted?: boolean
|
||||
isFree: boolean
|
||||
isPublished: boolean
|
||||
resources?: LessonResource[]
|
||||
quiz?: Quiz
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LessonResource {
|
||||
id: number
|
||||
lessonId: number
|
||||
title: string
|
||||
description?: string
|
||||
type: 'pdf' | 'doc' | 'video' | 'audio' | 'image' | 'link'
|
||||
url: string
|
||||
size?: number
|
||||
downloadable: boolean
|
||||
}
|
||||
|
||||
// 后端API返回的章节数据结构
|
||||
export interface BackendCourseSection {
|
||||
id: number
|
||||
lessonId: number
|
||||
videoUrl: string // 视频链接
|
||||
name: string // 章节名称
|
||||
sortOrder: number // 排序
|
||||
parentId: number // 父章节ID
|
||||
level: number // 层级:0=子级(课时),1=父级(章节)
|
||||
revision: number // 版本号
|
||||
createdBy: number
|
||||
createdTime: string | null
|
||||
updatedBy: number
|
||||
updatedTime: string | null
|
||||
}
|
||||
|
||||
// 前端使用的课程章节类型(适配后的数据结构)
|
||||
export interface CourseSection {
|
||||
id: number
|
||||
lessonId: number
|
||||
outline: string // 章节大纲/内容链接(从videoUrl适配)
|
||||
name: string // 章节名称
|
||||
parentId: number // 父章节ID
|
||||
sort: number // 排序(从sortOrder适配)
|
||||
level: number // 层级:0=父级(章节),1=子级(课时)- 已从后端数据转换
|
||||
revision: number // 版本号
|
||||
createdAt: number | null // 从createdTime适配
|
||||
updatedAt: number | null // 从updatedTime适配
|
||||
deletedAt: string | null
|
||||
completed?: boolean // 是否已完成(前端状态)
|
||||
duration?: string // 课时时长(前端计算)
|
||||
}
|
||||
|
||||
// 后端章节列表响应格式
|
||||
export interface BackendCourseSectionListResponse {
|
||||
list: BackendCourseSection[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// 前端章节列表响应格式
|
||||
export interface CourseSectionListResponse {
|
||||
list: CourseSection[]
|
||||
timestamp: number
|
||||
traceId: string
|
||||
}
|
||||
|
||||
// 测验类型
|
||||
export interface Quiz {
|
||||
id: number
|
||||
lessonId: number
|
||||
title: string
|
||||
description?: string
|
||||
timeLimit?: number
|
||||
passingScore: number
|
||||
questions: QuizQuestion[]
|
||||
}
|
||||
|
||||
export interface QuizQuestion {
|
||||
id: number
|
||||
quizId: number
|
||||
question: string
|
||||
type: 'single' | 'multiple' | 'text' | 'essay'
|
||||
options?: string[]
|
||||
correctAnswer?: string | string[]
|
||||
explanation?: string
|
||||
points: number
|
||||
order: number
|
||||
}
|
||||
|
||||
// 学习进度类型
|
||||
export interface LearningProgress {
|
||||
id: number
|
||||
userId: number
|
||||
courseId: number
|
||||
lessonId?: number
|
||||
progress: number
|
||||
timeSpent: number
|
||||
lastAccessedAt: string
|
||||
completedAt?: string
|
||||
status: 'not_started' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
// 评论类型
|
||||
export interface Comment {
|
||||
id: number
|
||||
userId: number
|
||||
courseId?: number
|
||||
lessonId?: number
|
||||
parentId?: number
|
||||
content: string
|
||||
rating?: number
|
||||
likes: number
|
||||
dislikes: number
|
||||
isLiked?: boolean
|
||||
isDisliked?: boolean
|
||||
user: {
|
||||
id: number
|
||||
username: string
|
||||
avatar?: string
|
||||
}
|
||||
replies?: Comment[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 收藏类型
|
||||
export interface Favorite {
|
||||
id: number
|
||||
userId: number
|
||||
courseId: number
|
||||
course: Course
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 订单类型
|
||||
export interface Order {
|
||||
id: number
|
||||
userId: number
|
||||
orderNo: string
|
||||
totalAmount: number
|
||||
discountAmount: number
|
||||
finalAmount: number
|
||||
currency: string
|
||||
status: 'pending' | 'paid' | 'cancelled' | 'refunded'
|
||||
paymentMethod?: string
|
||||
paymentTime?: string
|
||||
items: OrderItem[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: number
|
||||
orderId: number
|
||||
courseId: number
|
||||
course: Course
|
||||
price: number
|
||||
discountPrice?: number
|
||||
}
|
||||
|
||||
// 搜索类型
|
||||
export interface SearchRequest {
|
||||
keyword?: string
|
||||
category?: string
|
||||
level?: string
|
||||
price?: 'free' | 'paid' | 'all'
|
||||
rating?: number
|
||||
duration?: string
|
||||
language?: string
|
||||
sortBy?: 'newest' | 'oldest' | 'rating' | 'price_low' | 'price_high' | 'popular'
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
// 统计类型
|
||||
export interface Statistics {
|
||||
totalCourses: number
|
||||
totalStudents: number
|
||||
totalInstructors: number
|
||||
totalHours: number
|
||||
popularCategories: Array<{
|
||||
category: string
|
||||
count: number
|
||||
}>
|
||||
recentEnrollments: Array<{
|
||||
date: string
|
||||
count: number
|
||||
}>
|
||||
}
|
366
src/api/utils.ts
Normal file
366
src/api/utils.ts
Normal file
@ -0,0 +1,366 @@
|
||||
// API 工具函数文件
|
||||
import type { ApiResponse, PaginationResponse } from './types'
|
||||
|
||||
/**
|
||||
* 构建查询参数字符串
|
||||
*/
|
||||
export const buildQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
Object.keys(params).forEach(key => {
|
||||
const value = params[key]
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => searchParams.append(key, String(item)))
|
||||
} else {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return searchParams.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的URL
|
||||
*/
|
||||
export const buildUrl = (baseUrl: string, endpoint: string, params?: Record<string, any>): string => {
|
||||
let url = `${baseUrl}${endpoint}`
|
||||
|
||||
if (params) {
|
||||
const queryString = buildQueryString(params)
|
||||
if (queryString) {
|
||||
url += `?${queryString}`
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换URL中的路径参数
|
||||
*/
|
||||
export const replaceUrlParams = (url: string, params: Record<string, string | number>): string => {
|
||||
let result = url
|
||||
|
||||
Object.keys(params).forEach(key => {
|
||||
result = result.replace(`:${key}`, String(params[key]))
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查API响应是否成功
|
||||
*/
|
||||
export const isApiSuccess = (response: ApiResponse): boolean => {
|
||||
return response.code === 200 || response.code === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取API响应数据
|
||||
*/
|
||||
export const extractApiData = <T>(response: ApiResponse<T>): T => {
|
||||
if (isApiSuccess(response)) {
|
||||
return response.data
|
||||
}
|
||||
throw new Error(response.message || 'API请求失败')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分页数据
|
||||
*/
|
||||
export const formatPaginationData = <T>(response: ApiResponse<PaginationResponse<T>>) => {
|
||||
if (isApiSuccess(response)) {
|
||||
const { list, total, page, pageSize, totalPages } = response.data
|
||||
return {
|
||||
items: list,
|
||||
total,
|
||||
currentPage: page,
|
||||
pageSize,
|
||||
totalPages: totalPages || Math.ceil(total / pageSize),
|
||||
hasNext: page < (totalPages || Math.ceil(total / pageSize)),
|
||||
hasPrev: page > 1,
|
||||
}
|
||||
}
|
||||
throw new Error(response.message || '获取分页数据失败')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分页参数
|
||||
*/
|
||||
export const createPaginationParams = (page: number = 1, pageSize: number = 20) => {
|
||||
return {
|
||||
page: Math.max(1, page),
|
||||
pageSize: Math.min(Math.max(1, pageSize), 100), // 限制最大页面大小
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数 - 用于搜索等场景
|
||||
*/
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func(...args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数 - 用于频繁触发的事件
|
||||
*/
|
||||
export const throttle = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let lastTime = 0
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now()
|
||||
|
||||
if (now - lastTime >= wait) {
|
||||
lastTime = now
|
||||
func(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试函数 - 用于网络请求重试
|
||||
*/
|
||||
export const retry = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
maxAttempts: number = 3,
|
||||
delay: number = 1000
|
||||
): Promise<T> => {
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// 指数退避延迟
|
||||
const waitTime = delay * Math.pow(2, attempt - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}秒`
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.round(seconds % 60)
|
||||
return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
*/
|
||||
export const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式(中国大陆)
|
||||
*/
|
||||
export const isValidPhone = (phone: string): boolean => {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
return phoneRegex.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码强度
|
||||
*/
|
||||
export const validatePassword = (password: string): {
|
||||
isValid: boolean
|
||||
strength: 'weak' | 'medium' | 'strong'
|
||||
issues: string[]
|
||||
} => {
|
||||
const issues: string[] = []
|
||||
let score = 0
|
||||
|
||||
if (password.length < 8) {
|
||||
issues.push('密码长度至少8位')
|
||||
} else {
|
||||
score += 1
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
issues.push('密码需包含小写字母')
|
||||
} else {
|
||||
score += 1
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
issues.push('密码需包含大写字母')
|
||||
} else {
|
||||
score += 1
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
issues.push('密码需包含数字')
|
||||
} else {
|
||||
score += 1
|
||||
}
|
||||
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
||||
issues.push('密码需包含特殊字符')
|
||||
} else {
|
||||
score += 1
|
||||
}
|
||||
|
||||
let strength: 'weak' | 'medium' | 'strong'
|
||||
if (score < 3) {
|
||||
strength = 'weak'
|
||||
} else if (score < 5) {
|
||||
strength = 'medium'
|
||||
} else {
|
||||
strength = 'strong'
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: issues.length === 0,
|
||||
strength,
|
||||
issues
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
*/
|
||||
export const generateRandomString = (length: number = 8): string => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
*/
|
||||
export const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime()) as unknown as T
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => deepClone(item)) as unknown as T
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const cloned = {} as T
|
||||
Object.keys(obj).forEach(key => {
|
||||
(cloned as any)[key] = deepClone((obj as any)[key])
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误消息
|
||||
*/
|
||||
export const getErrorMessage = (error: any): string => {
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error?.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
}
|
||||
|
||||
if (error?.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return '未知错误'
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地存储工具
|
||||
*/
|
||||
export const storage = {
|
||||
get: <T>(key: string, defaultValue?: T): T | null => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : defaultValue || null
|
||||
} catch {
|
||||
return defaultValue || null
|
||||
}
|
||||
},
|
||||
|
||||
set: (key: string, value: any): void => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch (error) {
|
||||
console.error('存储数据失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
remove: (key: string): void => {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch (error) {
|
||||
console.error('删除存储数据失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
clear: (): void => {
|
||||
try {
|
||||
localStorage.clear()
|
||||
} catch (error) {
|
||||
console.error('清空存储数据失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,94 +0,0 @@
|
||||
<script setup>
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
706
src/components/VideoPlayer.vue
Normal file
706
src/components/VideoPlayer.vue
Normal file
@ -0,0 +1,706 @@
|
||||
<template>
|
||||
<div class="video-player-wrapper">
|
||||
<div class="video-container" ref="videoContainer">
|
||||
<!-- 视频播放器 -->
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="video-element"
|
||||
:poster="poster"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onEnded"
|
||||
@error="onError"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
您的浏览器不支持视频播放。
|
||||
</video>
|
||||
|
||||
<!-- 播放按钮覆盖层 -->
|
||||
<div v-if="showPlayButton" class="play-overlay" @click="togglePlay">
|
||||
<div class="play-button">
|
||||
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="36" fill="rgba(0,0,0,0.7)" stroke="rgba(255,255,255,0.8)" stroke-width="2"/>
|
||||
<path d="M32 24L32 56L56 40L32 24Z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="16" stroke="rgba(255,255,255,0.3)" stroke-width="3" fill="none"/>
|
||||
<circle cx="20" cy="20" r="16" stroke="white" stroke-width="3" fill="none"
|
||||
stroke-dasharray="100" stroke-dashoffset="75" stroke-linecap="round">
|
||||
<animateTransform attributeName="transform" type="rotate" dur="1s" repeatCount="indefinite" values="0 20 20;360 20 20"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="error" class="error-overlay">
|
||||
<div class="error-content">
|
||||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none">
|
||||
<circle cx="30" cy="30" r="25" stroke="#ff4757" stroke-width="3"/>
|
||||
<path d="M20 20L40 40M40 20L20 40" stroke="#ff4757" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<p>视频加载失败</p>
|
||||
<button class="retry-button" @click="retryLoad">重试</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义控制栏 -->
|
||||
<div v-if="showControls && !error" class="video-controls" :class="{ 'controls-visible': controlsVisible }">
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-container" @click="seekTo" @mousemove="showProgressPreview" @mouseleave="hideProgressPreview">
|
||||
<div class="progress-track">
|
||||
<div class="progress-buffer" :style="{ width: bufferPercent + '%' }"></div>
|
||||
<div class="progress-played" :style="{ width: progressPercent + '%' }"></div>
|
||||
<div class="progress-thumb" :style="{ left: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<!-- 进度预览 -->
|
||||
<div v-if="showPreview" class="progress-preview" :style="{ left: previewPosition + '%' }">
|
||||
{{ formatTime(previewTime) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<button class="control-btn play-btn" @click="togglePlay">
|
||||
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<svg v-else width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M6 4H10V20H6V4ZM14 4H18V20H14V4Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="time-display">
|
||||
<span class="current-time">{{ formatTime(currentTime) }}</span>
|
||||
<span class="separator">/</span>
|
||||
<span class="total-time">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<!-- 音量控制 -->
|
||||
<div class="volume-container">
|
||||
<button class="control-btn volume-btn" @click="toggleMute">
|
||||
<svg v-if="volume > 50 && !muted" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
||||
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
|
||||
<path d="M14 5C15 6 15.5 7.5 15.5 9S15 12 14 13" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
<svg v-else-if="volume > 0 && !muted" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
||||
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
||||
<path d="M12 7L16 11M16 7L12 11" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 全屏按钮 -->
|
||||
<button class="control-btn fullscreen-btn" @click="toggleFullscreen">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M3 3H7V5H5V7H3V3ZM13 3H17V7H15V5H13V3ZM17 13V17H13V15H15V13H17ZM7 17H3V13H5V15H7V17Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频信息 -->
|
||||
<div v-if="title" class="video-info">
|
||||
<h3 class="video-title">{{ title }}</h3>
|
||||
<p v-if="description" class="video-description">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
videoUrl: string
|
||||
title?: string
|
||||
description?: string
|
||||
poster?: string
|
||||
autoplay?: boolean
|
||||
showControls?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
autoplay: false,
|
||||
showControls: true
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
play: []
|
||||
pause: []
|
||||
ended: []
|
||||
timeupdate: [time: number]
|
||||
error: [error: Event]
|
||||
}>()
|
||||
|
||||
// Refs
|
||||
const videoElement = ref<HTMLVideoElement>()
|
||||
const videoContainer = ref<HTMLDivElement>()
|
||||
|
||||
// HLS实例
|
||||
let hls: Hls | null = null
|
||||
|
||||
// 播放状态
|
||||
const isPlaying = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const volume = ref(100)
|
||||
const muted = ref(false)
|
||||
|
||||
// 控制栏状态
|
||||
const controlsVisible = ref(true)
|
||||
const showPlayButton = ref(true)
|
||||
const showPreview = ref(false)
|
||||
const previewPosition = ref(0)
|
||||
const previewTime = ref(0)
|
||||
|
||||
// 计算属性
|
||||
const progressPercent = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
const bufferPercent = computed(() => {
|
||||
// 简化的缓冲进度,实际应该从video.buffered获取
|
||||
return Math.min(progressPercent.value + 10, 100)
|
||||
})
|
||||
|
||||
// 监听视频URL变化
|
||||
watch(() => props.videoUrl, (newUrl) => {
|
||||
console.log('VideoPlayer: 视频URL变化:', newUrl)
|
||||
if (newUrl && videoElement.value) {
|
||||
loadVideo()
|
||||
}
|
||||
})
|
||||
|
||||
// 视频事件处理
|
||||
const onLoadedMetadata = () => {
|
||||
if (videoElement.value) {
|
||||
duration.value = videoElement.value.duration
|
||||
loading.value = false
|
||||
error.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (videoElement.value) {
|
||||
currentTime.value = videoElement.value.currentTime
|
||||
emit('timeupdate', currentTime.value)
|
||||
}
|
||||
}
|
||||
|
||||
const onPlay = () => {
|
||||
isPlaying.value = true
|
||||
showPlayButton.value = false
|
||||
emit('play')
|
||||
}
|
||||
|
||||
const onPause = () => {
|
||||
isPlaying.value = false
|
||||
showPlayButton.value = true
|
||||
emit('pause')
|
||||
}
|
||||
|
||||
const onEnded = () => {
|
||||
isPlaying.value = false
|
||||
showPlayButton.value = true
|
||||
emit('ended')
|
||||
}
|
||||
|
||||
const onError = (event: Event) => {
|
||||
error.value = true
|
||||
loading.value = false
|
||||
emit('error', event)
|
||||
}
|
||||
|
||||
// 控制方法
|
||||
const togglePlay = async () => {
|
||||
if (!videoElement.value) return
|
||||
|
||||
try {
|
||||
if (isPlaying.value) {
|
||||
videoElement.value.pause()
|
||||
} else {
|
||||
await videoElement.value.play()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('播放控制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const seekTo = (event: MouseEvent) => {
|
||||
if (!videoElement.value) return
|
||||
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const percent = (event.clientX - rect.left) / rect.width
|
||||
const newTime = percent * duration.value
|
||||
|
||||
videoElement.value.currentTime = newTime
|
||||
}
|
||||
|
||||
const showProgressPreview = (event: MouseEvent) => {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const percent = (event.clientX - rect.left) / rect.width
|
||||
const time = percent * duration.value
|
||||
|
||||
previewPosition.value = Math.max(0, Math.min(100, percent * 100))
|
||||
previewTime.value = Math.max(0, Math.min(duration.value, time))
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
const hideProgressPreview = () => {
|
||||
showPreview.value = false
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!videoElement.value) return
|
||||
|
||||
videoElement.value.muted = !videoElement.value.muted
|
||||
muted.value = videoElement.value.muted
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!videoContainer.value) return
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
videoContainer.value.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const loadVideo = () => {
|
||||
if (!videoElement.value || !props.videoUrl) return
|
||||
|
||||
loading.value = true
|
||||
error.value = false
|
||||
|
||||
// 清理之前的HLS实例
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
hls = null
|
||||
}
|
||||
|
||||
// 检查是否是HLS视频
|
||||
if (props.videoUrl.includes('.m3u8')) {
|
||||
// 使用HLS.js加载HLS视频
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 90
|
||||
})
|
||||
|
||||
hls.loadSource(props.videoUrl)
|
||||
hls.attachMedia(videoElement.value)
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('HLS manifest parsed successfully')
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS error:', data)
|
||||
if (data.fatal) {
|
||||
error.value = true
|
||||
loading.value = false
|
||||
emit('error', new Event('error'))
|
||||
}
|
||||
})
|
||||
} else if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari原生支持HLS
|
||||
videoElement.value.src = props.videoUrl
|
||||
videoElement.value.load()
|
||||
} else {
|
||||
console.error('HLS not supported')
|
||||
error.value = true
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
// 普通视频文件
|
||||
videoElement.value.src = props.videoUrl
|
||||
videoElement.value.load()
|
||||
}
|
||||
}
|
||||
|
||||
const retryLoad = () => {
|
||||
loadVideo()
|
||||
}
|
||||
|
||||
// 自动隐藏控制栏
|
||||
let hideControlsTimer: number | null = null
|
||||
|
||||
const showControls = () => {
|
||||
controlsVisible.value = true
|
||||
if (hideControlsTimer) {
|
||||
clearTimeout(hideControlsTimer)
|
||||
}
|
||||
hideControlsTimer = window.setTimeout(() => {
|
||||
if (isPlaying.value) {
|
||||
controlsVisible.value = false
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const onMouseMove = () => {
|
||||
showControls()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (props.videoUrl) {
|
||||
loadVideo()
|
||||
}
|
||||
|
||||
// 添加鼠标移动监听
|
||||
if (videoContainer.value) {
|
||||
videoContainer.value.addEventListener('mousemove', onMouseMove)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (hideControlsTimer) {
|
||||
clearTimeout(hideControlsTimer)
|
||||
}
|
||||
|
||||
if (videoContainer.value) {
|
||||
videoContainer.value.removeEventListener('mousemove', onMouseMove)
|
||||
}
|
||||
|
||||
// 清理HLS实例
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
hls = null
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
play: () => videoElement.value?.play(),
|
||||
pause: () => videoElement.value?.pause(),
|
||||
seek: (time: number) => {
|
||||
if (videoElement.value) {
|
||||
videoElement.value.currentTime = time
|
||||
}
|
||||
},
|
||||
setVolume: (vol: number) => {
|
||||
if (videoElement.value) {
|
||||
videoElement.value.volume = vol / 100
|
||||
volume.value = vol
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-player-wrapper {
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* 播放按钮覆盖层 */
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.play-overlay:hover .play-button {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
margin-top: 16px;
|
||||
padding: 8px 16px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
/* 视频控制栏 */
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 50%, transparent 100%);
|
||||
padding: 16px;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.video-controls.controls-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.video-container:hover .video-controls {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progress-container {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-buffer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #1890ff;
|
||||
transition: width 0.1s;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #1890ff;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.progress-container:hover .progress-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.progress-preview {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 控制按钮行 */
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.volume-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 视频信息 */
|
||||
.video-info {
|
||||
padding: 16px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.video-description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.video-controls {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
360
src/components/auth/LoginModal.vue
Normal file
360
src/components/auth/LoginModal.vue
Normal file
@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:close-on-esc="false"
|
||||
class="login-modal"
|
||||
>
|
||||
<div class="login-modal-container">
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="close-btn" @click="closeModal">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="login-content">
|
||||
<h2 class="form-title">账号登录</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 8C10.2091 8 12 6.20914 12 4C12 1.79086 10.2091 0 8 0C5.79086 0 4 1.79086 4 4C4 6.20914 5.79086 8 8 8Z" fill="#999"/>
|
||||
<path d="M8 10C3.58172 10 0 13.5817 0 18H16C16 13.5817 12.4183 10 8 10Z" fill="#999"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="loginForm.account"
|
||||
type="text"
|
||||
placeholder="请输入手机号或邮箱"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 7V5C4 2.79086 5.79086 1 8 1C10.2091 1 12 2.79086 12 5V7H13C13.5523 7 14 7.44772 14 8V14C14 14.5523 13.5523 15 13 15H3C2.44772 15 2 14.5523 2 14V8C2 7.44772 2.44772 7 3 7H4Z" fill="#999"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<label class="checkbox-wrapper">
|
||||
<input v-model="loginForm.remember" type="checkbox" />
|
||||
<span class="checkbox-text">下次自动登录</span>
|
||||
</label>
|
||||
<a href="#" class="forgot-password">忘记密码</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" :disabled="isLoading">
|
||||
{{ isLoading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-footer">
|
||||
<p>登录即代表同意我们的 <a href="#" class="link">《服务协议和隐私政策》</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { AuthApi } from '@/api'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = useMessage()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const showModal = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value)
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 登录表单数据
|
||||
const loginForm = reactive({
|
||||
account: '',
|
||||
password: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.account || !loginForm.password) {
|
||||
message.warning('请填写完整的登录信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (loginForm.password.length < 3) {
|
||||
message.warning('密码长度不能少于3位')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 判断输入的是手机号还是邮箱
|
||||
const isPhone = /^[0-9]+$/.test(loginForm.account)
|
||||
|
||||
// 调用登录API
|
||||
const response = await AuthApi.login({
|
||||
...(isPhone ? { phone: loginForm.account } : { email: loginForm.account }),
|
||||
password: loginForm.password
|
||||
})
|
||||
|
||||
if (response.code === 200 || response.code === 0) {
|
||||
const { user, token, refreshToken } = response.data
|
||||
|
||||
// 保存用户信息和token到store
|
||||
userStore.user = user
|
||||
userStore.token = token
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
|
||||
// 如果选择了记住我,设置更长的过期时间
|
||||
if (loginForm.remember) {
|
||||
localStorage.setItem('rememberMe', 'true')
|
||||
}
|
||||
|
||||
message.success('登录成功!')
|
||||
emit('success')
|
||||
closeModal()
|
||||
|
||||
// 清空表单
|
||||
loginForm.account = ''
|
||||
loginForm.password = ''
|
||||
loginForm.remember = false
|
||||
} else {
|
||||
message.error(response.message || '登录失败')
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
|
||||
// 处理不同类型的错误
|
||||
if (error.response?.status === 401) {
|
||||
message.error('邮箱或密码错误')
|
||||
} else if (error.response?.status === 429) {
|
||||
message.error('登录尝试过于频繁,请稍后再试')
|
||||
} else if (error.response?.data?.message) {
|
||||
// 显示后端返回的具体错误信息
|
||||
message.error(error.response.data.message)
|
||||
} else if (error.message) {
|
||||
// 显示错误对象中的消息
|
||||
message.error(error.message)
|
||||
} else {
|
||||
message.error('网络错误,请检查网络连接')
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-modal-container {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
z-index: 2;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px 0 40px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: #fafafa;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.checkbox-wrapper input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-footer p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-modal-container {
|
||||
width: 95vw;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
431
src/components/auth/RegisterModal.vue
Normal file
431
src/components/auth/RegisterModal.vue
Normal file
@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:close-on-esc="false"
|
||||
class="register-modal"
|
||||
>
|
||||
<div class="register-modal-container">
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="close-btn" @click="closeModal">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="register-content">
|
||||
<h2 class="form-title">账号注册</h2>
|
||||
|
||||
<form @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2 3C2 2.44772 2.44772 2 3 2H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H3C2.44772 14 2 13.5523 2 13V3Z" fill="#999"/>
|
||||
<path d="M3 4L8 8L13 4" stroke="white" stroke-width="1"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="registerForm.email"
|
||||
type="email"
|
||||
placeholder="请输入手机号/邮箱"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper verification-wrapper">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 1L10.5 6H15L11 9.5L12.5 15L8 11.5L3.5 15L5 9.5L1 6H5.5L8 1Z" fill="#999"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="registerForm.verificationCode"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
class="form-input verification-input"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="verification-btn"
|
||||
:disabled="verificationCountdown > 0"
|
||||
@click="sendVerificationCode"
|
||||
>
|
||||
{{ verificationCountdown > 0 ? `${verificationCountdown}s` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 7V5C4 2.79086 5.79086 1 8 1C10.2091 1 12 2.79086 12 5V7H13C13.5523 7 14 7.44772 14 8V14C14 14.5523 13.5523 15 13 15H3C2.44772 15 2 14.5523 2 14V8C2 7.44772 2.44772 7 3 7H4Z" fill="#999"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="password-hint">
|
||||
密码长度不少于3位字符
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" :disabled="isLoading">
|
||||
{{ isLoading ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-footer">
|
||||
<p>注册即代表同意我们的 <a href="#" class="link">《服务协议和隐私政策》</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { AuthApi } from '@/api'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value)
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const verificationCountdown = ref(0)
|
||||
|
||||
// 注册表单数据
|
||||
const registerForm = reactive({
|
||||
email: '',
|
||||
verificationCode: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async () => {
|
||||
if (!registerForm.email || !registerForm.verificationCode || !registerForm.password) {
|
||||
message.warning('请填写完整的注册信息')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证邮箱或手机号格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
|
||||
if (!emailRegex.test(registerForm.email) && !phoneRegex.test(registerForm.email)) {
|
||||
message.warning('请输入正确的邮箱或手机号格式')
|
||||
return
|
||||
}
|
||||
|
||||
if (registerForm.password.length < 3) {
|
||||
message.warning('密码长度不能少于3位')
|
||||
return
|
||||
}
|
||||
|
||||
if (registerForm.verificationCode.length !== 6) {
|
||||
message.warning('请输入6位验证码')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 调用注册API
|
||||
const response = await AuthApi.register({
|
||||
username: registerForm.email.split('@')[0] || registerForm.email.substring(0, 8),
|
||||
email: registerForm.email,
|
||||
password: registerForm.password,
|
||||
confirmPassword: registerForm.password,
|
||||
captcha: registerForm.verificationCode
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
message.success('注册成功!请使用您的账号登录')
|
||||
emit('success')
|
||||
closeModal()
|
||||
// 清空表单
|
||||
registerForm.email = ''
|
||||
registerForm.verificationCode = ''
|
||||
registerForm.password = ''
|
||||
} else {
|
||||
message.error(response.message || '注册失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('注册失败:', error)
|
||||
|
||||
// 处理不同类型的错误
|
||||
if (error.response?.status === 400) {
|
||||
message.error('请求参数错误,请检查输入信息')
|
||||
} else if (error.response?.status === 409) {
|
||||
message.error('邮箱已被注册,请使用其他邮箱')
|
||||
} else if (error.response?.status === 422) {
|
||||
message.error('验证码错误或已过期')
|
||||
} else if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('网络错误,请检查网络连接')
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = async () => {
|
||||
if (!registerForm.email) {
|
||||
message.warning('请先输入邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
// 邮箱格式验证
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
if (!emailRegex.test(registerForm.email)) {
|
||||
message.warning('请输入正确的邮箱格式')
|
||||
return
|
||||
}
|
||||
|
||||
if (verificationCountdown.value > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用发送验证码API
|
||||
const response = await AuthApi.sendEmailVerification(registerForm.email)
|
||||
|
||||
if (response.code === 200) {
|
||||
message.success('验证码已发送到您的邮箱')
|
||||
|
||||
// 开始倒计时
|
||||
verificationCountdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
verificationCountdown.value--
|
||||
if (verificationCountdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
message.error(response.message || '发送验证码失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('发送验证码失败:', error)
|
||||
|
||||
if (error.response?.status === 429) {
|
||||
message.error('发送过于频繁,请稍后再试')
|
||||
} else if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('发送验证码失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-modal-container {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.register-content {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
z-index: 2;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px 0 40px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: #fafafa;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.verification-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.verification-input {
|
||||
padding-right: 120px;
|
||||
}
|
||||
|
||||
.verification-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.verification-btn:hover:not(:disabled) {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.verification-btn:disabled {
|
||||
background: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-footer p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.register-modal-container {
|
||||
width: 95vw;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.register-content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
78
src/components/common/PlaceholderImage.vue
Normal file
78
src/components/common/PlaceholderImage.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="placeholder-image"
|
||||
:style="{
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
borderRadius: rounded ? '50%' : '8px'
|
||||
}"
|
||||
>
|
||||
<div class="placeholder-content">
|
||||
<div class="placeholder-icon">{{ icon }}</div>
|
||||
<div v-if="showText" class="placeholder-text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
width?: number
|
||||
height?: number
|
||||
text?: string
|
||||
icon?: string
|
||||
showText?: boolean
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
text: '图片',
|
||||
icon: '🖼️',
|
||||
showText: true,
|
||||
rounded: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.placeholder-image {
|
||||
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 小尺寸时隐藏文字 */
|
||||
.placeholder-image[style*="width: 32px"] .placeholder-text,
|
||||
.placeholder-image[style*="width: 40px"] .placeholder-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.placeholder-image[style*="width: 32px"] .placeholder-icon,
|
||||
.placeholder-image[style*="width: 40px"] .placeholder-icon {
|
||||
font-size: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
115
src/components/common/SafeAvatar.vue
Normal file
115
src/components/common/SafeAvatar.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div
|
||||
class="safe-avatar"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px'
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="!imageError && src"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
@error="handleImageError"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div v-else class="avatar-placeholder">
|
||||
<span class="avatar-text">{{ avatarText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
src?: string
|
||||
alt?: string
|
||||
size?: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
src: '',
|
||||
alt: '头像',
|
||||
size: 32,
|
||||
name: '用户'
|
||||
})
|
||||
|
||||
const imageError = ref(false)
|
||||
|
||||
// 根据用户名生成头像文字
|
||||
const avatarText = computed(() => {
|
||||
if (props.name) {
|
||||
// 如果是中文名,取最后一个字
|
||||
if (/[\u4e00-\u9fa5]/.test(props.name)) {
|
||||
return props.name.slice(-1)
|
||||
}
|
||||
// 如果是英文名,取首字母
|
||||
return props.name.charAt(0).toUpperCase()
|
||||
}
|
||||
return '用'
|
||||
})
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
imageError.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.safe-avatar {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.safe-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 根据尺寸调整字体大小 */
|
||||
.safe-avatar[style*="width: 24px"] .avatar-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.safe-avatar[style*="width: 32px"] .avatar-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.safe-avatar[style*="width: 40px"] .avatar-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.safe-avatar[style*="width: 64px"] .avatar-text,
|
||||
.safe-avatar[style*="width: 80px"] .avatar-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.safe-avatar[style*="width: 100px"] .avatar-text {
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
@ -21,12 +21,12 @@
|
||||
|
||||
<!-- 讲师信息 -->
|
||||
<div class="instructor">
|
||||
<n-avatar
|
||||
:src="course.instructorAvatar"
|
||||
:fallback-src="'https://via.placeholder.com/32'"
|
||||
size="small"
|
||||
<SafeAvatar
|
||||
:src="course.instructor?.avatar"
|
||||
:name="course.instructor?.name"
|
||||
:size="32"
|
||||
/>
|
||||
<span class="instructor-name">{{ course.instructor }}</span>
|
||||
<span class="instructor-name">{{ course.instructor?.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 课程统计 -->
|
||||
@ -96,6 +96,7 @@ import { computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
import type { Course } from '@/stores/course'
|
||||
import SafeAvatar from '@/components/common/SafeAvatar.vue'
|
||||
import {
|
||||
StarOutline,
|
||||
PeopleOutline,
|
||||
|
@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
@ -1,19 +0,0 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
@ -83,26 +83,38 @@
|
||||
<!-- 登录/注册按钮 -->
|
||||
<div v-if="!userStore.isLoggedIn" class="auth-buttons">
|
||||
<div class="auth-combined-btn">
|
||||
<span class="auth-login" @click="$router.push('/login')">{{ t('header.login') }}</span>
|
||||
<span class="auth-login" @click="showLoginModal">{{ t('header.login') }}</span>
|
||||
<span class="auth-divider">|</span>
|
||||
<span class="auth-register" @click="$router.push('/register')">{{ t('header.register') }}</span>
|
||||
<span class="auth-register" @click="showRegisterModal">{{ t('header.register') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<!-- 登录后的用户区域 -->
|
||||
<div v-else class="user-menu">
|
||||
<n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect">
|
||||
<div class="user-info">
|
||||
<n-avatar
|
||||
<SafeAvatar
|
||||
:src="userStore.user?.avatar"
|
||||
:fallback-src="'https://via.placeholder.com/32'"
|
||||
size="small"
|
||||
:name="userStore.user?.username"
|
||||
:size="32"
|
||||
/>
|
||||
<span class="username">{{ userStore.user?.username }}</span>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录模态框 -->
|
||||
<LoginModal
|
||||
v-model:show="loginModalVisible"
|
||||
@success="handleAuthSuccess"
|
||||
/>
|
||||
|
||||
<!-- 注册模态框 -->
|
||||
<RegisterModal
|
||||
v-model:show="registerModalVisible"
|
||||
@success="handleAuthSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -115,10 +127,12 @@ import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
PersonOutline,
|
||||
LogOutOutline,
|
||||
SettingsOutline,
|
||||
MenuOutline,
|
||||
CloseOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import LoginModal from '@/components/auth/LoginModal.vue'
|
||||
import RegisterModal from '@/components/auth/RegisterModal.vue'
|
||||
import SafeAvatar from '@/components/common/SafeAvatar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t, locale } = useI18n()
|
||||
@ -131,6 +145,12 @@ const mobileMenuOpen = ref(false)
|
||||
// 当前激活的菜单项
|
||||
const activeKey = ref('home')
|
||||
|
||||
// 认证模态框相关
|
||||
const loginModalVisible = ref(false)
|
||||
const registerModalVisible = ref(false)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 语言切换相关
|
||||
@ -158,20 +178,15 @@ const switchLanguage = (lang: string) => {
|
||||
// 用户菜单选项
|
||||
const userMenuOptions = computed(() => [
|
||||
{
|
||||
label: t('header.profile'),
|
||||
label: '个人中心',
|
||||
key: 'profile',
|
||||
icon: () => h(PersonOutline)
|
||||
},
|
||||
{
|
||||
label: t('header.settings'),
|
||||
key: 'settings',
|
||||
icon: () => h(SettingsOutline)
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
label: t('header.logout'),
|
||||
label: '退出登录',
|
||||
key: 'logout',
|
||||
icon: () => h(LogOutOutline)
|
||||
}
|
||||
@ -212,9 +227,6 @@ const handleUserMenuSelect = (key: string) => {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
// TODO: 实现设置页面
|
||||
break
|
||||
case 'logout':
|
||||
userStore.logout()
|
||||
router.push('/')
|
||||
@ -222,6 +234,24 @@ const handleUserMenuSelect = (key: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 显示登录模态框
|
||||
const showLoginModal = () => {
|
||||
loginModalVisible.value = true
|
||||
}
|
||||
|
||||
// 显示注册模态框
|
||||
const showRegisterModal = () => {
|
||||
registerModalVisible.value = true
|
||||
}
|
||||
|
||||
// 认证成功处理
|
||||
const handleAuthSuccess = () => {
|
||||
// 认证成功后可以进行一些操作,比如刷新用户信息等
|
||||
console.log('认证成功')
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
@ -694,5 +724,7 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 全屏模式样式现在在App.vue中统一管理 */
|
||||
</style>
|
||||
|
@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<n-message-provider>
|
||||
<n-layout class="app-layout">
|
||||
<!-- 顶部导航 -->
|
||||
<n-layout-header class="header" bordered>
|
||||
@ -15,6 +16,7 @@
|
||||
<AppFooter />
|
||||
</n-layout-footer>
|
||||
</n-layout>
|
||||
</n-message-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
74
src/composables/useAuth.ts
Normal file
74
src/composables/useAuth.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
export function useAuth() {
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loginModalVisible = ref(false)
|
||||
const registerModalVisible = ref(false)
|
||||
|
||||
// 检查用户是否已登录,如果未登录则显示登录模态框
|
||||
const requireAuth = (callback?: () => void) => {
|
||||
if (userStore.isLoggedIn) {
|
||||
// 已登录,执行回调
|
||||
callback?.()
|
||||
return true
|
||||
} else {
|
||||
// 未登录,显示登录模态框
|
||||
loginModalVisible.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示登录模态框
|
||||
const showLoginModal = () => {
|
||||
loginModalVisible.value = true
|
||||
}
|
||||
|
||||
// 显示注册模态框
|
||||
const showRegisterModal = () => {
|
||||
registerModalVisible.value = true
|
||||
}
|
||||
|
||||
// 认证成功处理
|
||||
const handleAuthSuccess = (callback?: () => void) => {
|
||||
loginModalVisible.value = false
|
||||
registerModalVisible.value = false
|
||||
callback?.()
|
||||
}
|
||||
|
||||
// 跳转到课程详情页(需要登录检查)
|
||||
const goToCourseDetail = (courseId: string | number) => {
|
||||
requireAuth(() => {
|
||||
router.push(`/course/${courseId}`)
|
||||
})
|
||||
}
|
||||
|
||||
// 报名课程(需要登录检查)
|
||||
const enrollCourse = (courseId: string | number, successCallback?: () => void) => {
|
||||
requireAuth(() => {
|
||||
// 这里可以添加报名逻辑
|
||||
console.log('报名课程:', courseId)
|
||||
|
||||
// 模拟报名成功,跳转到课程学习页面
|
||||
if (successCallback) {
|
||||
successCallback()
|
||||
} else {
|
||||
router.push(`/course/study/${courseId}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
loginModalVisible,
|
||||
registerModalVisible,
|
||||
requireAuth,
|
||||
showLoginModal,
|
||||
showRegisterModal,
|
||||
handleAuthSuccess,
|
||||
goToCourseDetail,
|
||||
enrollCourse
|
||||
}
|
||||
}
|
23
src/main.ts
23
src/main.ts
@ -5,6 +5,7 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// Naive UI
|
||||
import {
|
||||
@ -25,6 +26,7 @@ import {
|
||||
NBreadcrumb,
|
||||
NBreadcrumbItem,
|
||||
NInput,
|
||||
NInputGroup,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NSelect,
|
||||
@ -72,7 +74,8 @@ import {
|
||||
NSteps,
|
||||
NStep,
|
||||
NTimeline,
|
||||
NTimelineItem
|
||||
NTimelineItem,
|
||||
NMessageProvider
|
||||
} from 'naive-ui'
|
||||
|
||||
const naive = create({
|
||||
@ -93,6 +96,7 @@ const naive = create({
|
||||
NBreadcrumb,
|
||||
NBreadcrumbItem,
|
||||
NInput,
|
||||
NInputGroup,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NSelect,
|
||||
@ -140,7 +144,8 @@ const naive = create({
|
||||
NSteps,
|
||||
NStep,
|
||||
NTimeline,
|
||||
NTimelineItem
|
||||
NTimelineItem,
|
||||
NMessageProvider
|
||||
]
|
||||
})
|
||||
|
||||
@ -152,4 +157,18 @@ app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(naive)
|
||||
|
||||
// 初始化用户认证状态
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 异步初始化认证状态
|
||||
const initializeApp = async () => {
|
||||
try {
|
||||
await userStore.initializeAuth()
|
||||
} catch (error) {
|
||||
console.error('初始化认证状态失败:', error)
|
||||
} finally {
|
||||
app.mount('#app')
|
||||
}
|
||||
}
|
||||
|
||||
initializeApp()
|
||||
|
@ -5,14 +5,16 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
import Home from '@/views/Home.vue'
|
||||
import Courses from '@/views/Courses.vue'
|
||||
import CourseDetail from '@/views/CourseDetail.vue'
|
||||
import CourseStudy from '@/views/CourseStudy.vue'
|
||||
import Learning from '@/views/Learning.vue'
|
||||
import Profile from '@/views/Profile.vue'
|
||||
import Login from '@/views/Login.vue'
|
||||
import Register from '@/views/Register.vue'
|
||||
|
||||
import LearningPaths from '@/views/LearningPaths.vue'
|
||||
import Faculty from '@/views/Faculty.vue'
|
||||
import Resources from '@/views/Resources.vue'
|
||||
import Activities from '@/views/Activities.vue'
|
||||
import TestSections from '@/views/TestSections.vue'
|
||||
import VideoTest from '@/views/VideoTest.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@ -39,6 +41,15 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '课程详情'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/course/study/:id',
|
||||
name: 'CourseStudy',
|
||||
component: CourseStudy,
|
||||
meta: {
|
||||
title: '课程学习',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/learning/:id',
|
||||
name: 'Learning',
|
||||
@ -57,22 +68,7 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: {
|
||||
title: '登录'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: Register,
|
||||
meta: {
|
||||
title: '注册'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/learning-paths',
|
||||
name: 'LearningPaths',
|
||||
@ -105,6 +101,22 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '全部活动'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/test-sections',
|
||||
name: 'TestSections',
|
||||
component: TestSections,
|
||||
meta: {
|
||||
title: '测试章节API'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/video-test',
|
||||
name: 'VideoTest',
|
||||
component: VideoTest,
|
||||
meta: {
|
||||
title: '视频播放器测试'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
@ -136,12 +148,16 @@ router.beforeEach((to, _from, next) => {
|
||||
|
||||
// 检查是否需要登录
|
||||
if (to.meta.requiresAuth) {
|
||||
// 这里可以检查用户登录状态
|
||||
// 暂时跳过认证检查
|
||||
next()
|
||||
} else {
|
||||
next()
|
||||
// 检查用户登录状态
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
// 未登录时跳转到首页,用户可以通过模态框登录
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { CourseApi } from '@/api/modules/course'
|
||||
|
||||
export interface Course {
|
||||
id: number
|
||||
@ -77,10 +78,13 @@ export const useCourseStore = defineStore('course', () => {
|
||||
const fetchCourses = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 模拟课程数据
|
||||
console.log('尝试从API获取课程数据...')
|
||||
const response = await CourseApi.getCourses()
|
||||
console.log('API响应:', response)
|
||||
courses.value = response.data.list
|
||||
} catch (error) {
|
||||
console.error('API调用失败,使用模拟数据:', error)
|
||||
// 如果API调用失败,使用模拟数据作为后备
|
||||
const mockCourses: Course[] = [
|
||||
{
|
||||
id: 1,
|
||||
@ -135,10 +139,7 @@ export const useCourseStore = defineStore('course', () => {
|
||||
updatedAt: '2024-01-25'
|
||||
}
|
||||
]
|
||||
|
||||
courses.value = mockCourses
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch courses:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@ -147,15 +148,15 @@ export const useCourseStore = defineStore('course', () => {
|
||||
const fetchCourseById = async (id: number) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const response = await CourseApi.getCourseById(id)
|
||||
currentCourse.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch course:', error)
|
||||
// 如果API调用失败,从本地数据中查找
|
||||
const course = courses.value.find(c => c.id === id)
|
||||
if (course) {
|
||||
currentCourse.value = course
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch course:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
@ -1,14 +1,9 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { AuthApi, type User as ApiUser } from '@/api'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
role: 'student' | 'teacher' | 'admin'
|
||||
createdAt: string
|
||||
}
|
||||
// 扩展API用户类型以保持兼容性
|
||||
export interface User extends ApiUser {}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
@ -22,100 +17,112 @@ export const useUserStore = defineStore('user', () => {
|
||||
const isTeacher = computed(() => user.value?.role === 'teacher')
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
// 方法
|
||||
// 方法 - 简化版本,主要用于状态管理
|
||||
const login = async (credentials: { email: string; password: string }) => {
|
||||
isLoading.value = true
|
||||
// 这个方法现在主要用于兼容性,实际登录逻辑在组件中处理
|
||||
return { success: true, message: '请使用登录模态框进行登录' }
|
||||
}
|
||||
|
||||
const register = async (userData: any) => {
|
||||
// 这个方法现在主要用于兼容性,实际注册逻辑在组件中处理
|
||||
return { success: true, message: '请使用注册模态框进行注册' }
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 模拟登录成功
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
username: '张三',
|
||||
email: credentials.email,
|
||||
avatar: 'https://via.placeholder.com/100',
|
||||
role: 'student',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const mockToken = 'mock-jwt-token-' + Date.now()
|
||||
|
||||
user.value = mockUser
|
||||
token.value = mockToken
|
||||
localStorage.setItem('token', mockToken)
|
||||
localStorage.setItem('user', JSON.stringify(mockUser))
|
||||
|
||||
return { success: true, message: '登录成功' }
|
||||
// 调用登出API
|
||||
await AuthApi.logout()
|
||||
} catch (error) {
|
||||
return { success: false, message: '登录失败' }
|
||||
console.error('登出API调用失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (userData: {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 模拟注册成功
|
||||
const mockUser: User = {
|
||||
id: Date.now(),
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
role: 'student',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const mockToken = 'mock-jwt-token-' + Date.now()
|
||||
|
||||
user.value = mockUser
|
||||
token.value = mockToken
|
||||
localStorage.setItem('token', mockToken)
|
||||
localStorage.setItem('user', JSON.stringify(mockUser))
|
||||
|
||||
return { success: true, message: '注册成功' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '注册失败' }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
// 无论API调用是否成功,都清除本地数据
|
||||
user.value = null
|
||||
token.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('rememberMe')
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfile = async (profileData: Partial<User>) => {
|
||||
// 获取当前用户信息
|
||||
const getCurrentUser = async () => {
|
||||
if (!token.value) {
|
||||
return { success: false, message: '未登录' }
|
||||
}
|
||||
|
||||
// 如果已经有用户信息,直接返回成功
|
||||
if (user.value) {
|
||||
return { success: true, message: '用户信息已存在' }
|
||||
}
|
||||
|
||||
// 尝试从localStorage恢复用户信息
|
||||
const savedUser = localStorage.getItem('user')
|
||||
if (savedUser) {
|
||||
try {
|
||||
user.value = JSON.parse(savedUser)
|
||||
return { success: true, message: '用户信息已恢复' }
|
||||
} catch (error) {
|
||||
console.error('解析用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 暂时注释掉API调用,因为后端可能没有这个接口
|
||||
// isLoading.value = true
|
||||
// try {
|
||||
// const response = await AuthApi.getCurrentUser()
|
||||
|
||||
// if (response.code === 200 || response.code === 0) {
|
||||
// user.value = response.data
|
||||
// localStorage.setItem('user', JSON.stringify(response.data))
|
||||
// return { success: true, message: '获取用户信息成功' }
|
||||
// } else {
|
||||
// return { success: false, message: response.message || '获取用户信息失败' }
|
||||
// }
|
||||
// } catch (error: any) {
|
||||
// console.error('获取用户信息失败:', error)
|
||||
|
||||
// // 如果是401错误,说明token已过期,自动登出
|
||||
// if (error.response?.status === 401) {
|
||||
// await logout()
|
||||
// return { success: false, message: '登录已过期,请重新登录' }
|
||||
// }
|
||||
|
||||
// return { success: false, message: '获取用户信息失败' }
|
||||
// } finally {
|
||||
// isLoading.value = false
|
||||
// }
|
||||
|
||||
return { success: false, message: '无法获取用户信息' }
|
||||
}
|
||||
|
||||
const updateProfile = async (profileData: any) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
const response = await AuthApi.updateProfile(profileData)
|
||||
|
||||
if (user.value) {
|
||||
user.value = { ...user.value, ...profileData }
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
if (response.code === 200) {
|
||||
user.value = response.data
|
||||
localStorage.setItem('user', JSON.stringify(response.data))
|
||||
return { success: true, message: '更新成功' }
|
||||
} else {
|
||||
return { success: false, message: response.message || '更新失败' }
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('更新用户资料失败:', error)
|
||||
|
||||
let message = '更新失败'
|
||||
if (error.response?.data?.message) {
|
||||
message = error.response.data.message
|
||||
}
|
||||
|
||||
return { success: true, message: '更新成功' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '更新失败' }
|
||||
return { success: false, message }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const initializeAuth = () => {
|
||||
const initializeAuth = async () => {
|
||||
const savedUser = localStorage.getItem('user')
|
||||
const savedToken = localStorage.getItem('token')
|
||||
|
||||
@ -123,13 +130,18 @@ export const useUserStore = defineStore('user', () => {
|
||||
try {
|
||||
user.value = JSON.parse(savedUser)
|
||||
token.value = savedToken
|
||||
|
||||
// 验证token是否仍然有效
|
||||
await getCurrentUser()
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved user data:', error)
|
||||
logout()
|
||||
console.error('Failed to parse saved user data or token expired:', error)
|
||||
await logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return {
|
||||
// 状态
|
||||
user,
|
||||
@ -144,6 +156,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
updateProfile,
|
||||
initializeAuth
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
1836
src/views/CourseStudy.vue
Normal file
1836
src/views/CourseStudy.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -187,7 +187,7 @@
|
||||
|
||||
<!-- 筛选结果提示 -->
|
||||
<div class="filter-result" v-if="selectedSubject !== '全部' || selectedMajor !== '全部' || selectedDifficulty !== '全部'">
|
||||
<span>筛选结果:找到 {{ filteredCourses.length }} 门相关课程</span>
|
||||
<span>筛选结果:找到 {{ total }} 门相关课程</span>
|
||||
</div>
|
||||
|
||||
<!-- 排序标签 -->
|
||||
@ -197,8 +197,15 @@
|
||||
<span class="sort-tab active">推荐</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="loading-state" v-if="loading">
|
||||
<div class="loading-content">
|
||||
<p>正在加载课程...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 课程网格 -->
|
||||
<div class="courses-grid" v-if="allCourses.length > 0">
|
||||
<div class="courses-grid" v-else-if="allCourses.length > 0">
|
||||
<div class="course-card" v-for="course in allCourses" :key="course.id">
|
||||
<div class="course-image">
|
||||
<img :src="course.thumbnail" :alt="course.title" />
|
||||
@ -207,7 +214,7 @@
|
||||
<h3 class="course-title">{{ getCourseTitle(course) }}</h3>
|
||||
<div class="course-meta">
|
||||
<span class="course-duration">📚 {{ course.duration }}</span>
|
||||
<span class="course-time">⏰ {{ course.totalTime }}</span>
|
||||
<span class="course-price">💰 ¥{{ course.price }}</span>
|
||||
</div>
|
||||
<div class="course-footer">
|
||||
<div class="course-stats">
|
||||
@ -273,107 +280,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CourseApi } from '@/api/modules/course'
|
||||
import type { Course } from '@/api/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 生成248条课程数据
|
||||
const generateCourses = () => {
|
||||
const baseCourses = [
|
||||
{
|
||||
title: '暑期冲关班',
|
||||
description: '暑期名师带你,征服困难数学子题目,冲关学习训练',
|
||||
thumbnail: '/images/courses/course1.png',
|
||||
subject: '教育学',
|
||||
major: '数学教育',
|
||||
difficulty: '中级'
|
||||
},
|
||||
{
|
||||
title: 'DeepSeek智能未来学习',
|
||||
description: '深度学习,掌握AI技术核心数学原理',
|
||||
thumbnail: '/images/courses/course2.png',
|
||||
subject: '心理学',
|
||||
major: '学科教育',
|
||||
difficulty: '高级'
|
||||
},
|
||||
{
|
||||
title: '主观题案例长训班',
|
||||
description: '主观题解题技巧,提升应试能力',
|
||||
thumbnail: '/images/courses/course3.png',
|
||||
subject: '教育学',
|
||||
major: '统计学教育',
|
||||
difficulty: '初级'
|
||||
},
|
||||
{
|
||||
title: '摆脱哑巴英语',
|
||||
description: '摆脱哑巴英语,掌握流利口语表达',
|
||||
thumbnail: '/images/courses/course4.png',
|
||||
subject: '文学史',
|
||||
major: '基础数学',
|
||||
difficulty: '零基础'
|
||||
},
|
||||
{
|
||||
title: '计算机二级考前直播',
|
||||
description: '计算机二级,掌握考试重点难点',
|
||||
thumbnail: '/images/courses/course5.png',
|
||||
subject: '名师课堂',
|
||||
major: '线性代数',
|
||||
difficulty: '中级'
|
||||
},
|
||||
{
|
||||
title: '心理学基础课程',
|
||||
description: '心理学基础理论与实践应用',
|
||||
thumbnail: '/images/courses/course1.png',
|
||||
subject: '心理学',
|
||||
major: '个人成长',
|
||||
difficulty: '零基础'
|
||||
},
|
||||
{
|
||||
title: '训练营特训课',
|
||||
description: '集中训练,快速提升专业技能',
|
||||
thumbnail: '/images/courses/course2.png',
|
||||
subject: '训练营',
|
||||
major: '高等数学',
|
||||
difficulty: '高级'
|
||||
},
|
||||
{
|
||||
title: '考研数学冲刺',
|
||||
description: '考研数学重点难点突破',
|
||||
thumbnail: '/images/courses/course3.png',
|
||||
subject: '考研课程',
|
||||
major: '数学学科',
|
||||
difficulty: '高级'
|
||||
}
|
||||
]
|
||||
|
||||
const courses = []
|
||||
for (let i = 1; i <= 248; i++) {
|
||||
const baseIndex = (i - 1) % baseCourses.length
|
||||
const baseCourse = baseCourses[baseIndex]
|
||||
const courseHours = Math.floor(Math.random() * 20) + 8
|
||||
const totalMinutes = courseHours * 60 + Math.floor(Math.random() * 60)
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
courses.push({
|
||||
id: i,
|
||||
title: `${baseCourse.title} ${i}`,
|
||||
description: baseCourse.description,
|
||||
thumbnail: baseCourse.thumbnail,
|
||||
studentsCount: Math.floor(Math.random() * 1000) + 100,
|
||||
duration: `共${courseHours}课时`,
|
||||
totalTime: `${hours}小时${minutes}分钟`,
|
||||
price: 0,
|
||||
subject: baseCourse.subject,
|
||||
major: baseCourse.major,
|
||||
difficulty: baseCourse.difficulty
|
||||
})
|
||||
}
|
||||
return courses
|
||||
}
|
||||
|
||||
const courses = ref(generateCourses())
|
||||
// 课程数据和加载状态
|
||||
const courses = ref<Course[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
|
||||
// 筛选状态
|
||||
const selectedSubject = ref('全部')
|
||||
@ -383,7 +300,7 @@ const selectedDifficulty = ref('全部')
|
||||
// 分页相关状态
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 20
|
||||
const totalItems = computed(() => filteredCourses.value.length)
|
||||
const totalItems = computed(() => total.value)
|
||||
const totalPages = computed(() => Math.ceil(totalItems.value / itemsPerPage))
|
||||
|
||||
// 数字转中文
|
||||
@ -427,10 +344,50 @@ const visiblePages = computed(() => {
|
||||
return pages
|
||||
})
|
||||
|
||||
// 加载课程数据
|
||||
const loadCourses = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
pageSize: itemsPerPage,
|
||||
categoryId: selectedSubject.value !== '全部' ? getCategoryIdBySubject(selectedSubject.value) : undefined,
|
||||
keyword: selectedMajor.value !== '全部' ? selectedMajor.value : undefined
|
||||
}
|
||||
|
||||
const response = await CourseApi.getCourses(params)
|
||||
|
||||
if (response.code === 0 || response.code === 200) {
|
||||
courses.value = response.data.list
|
||||
total.value = response.data.total
|
||||
} else {
|
||||
console.error('获取课程列表失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载课程失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 根据学科获取分类ID(这里需要根据实际后端分类来映射)
|
||||
const getCategoryIdBySubject = (subject: string): number | undefined => {
|
||||
const categoryMap: Record<string, number> = {
|
||||
'必修课': 1,
|
||||
'高分课': 2,
|
||||
'名师课堂': 3,
|
||||
'训练营': 4,
|
||||
'无考试': 5,
|
||||
'专题讲座': 6
|
||||
}
|
||||
return categoryMap[subject]
|
||||
}
|
||||
|
||||
// 跳转到指定页面
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
loadCourses()
|
||||
}
|
||||
}
|
||||
|
||||
@ -454,64 +411,50 @@ const clearAllFilters = () => {
|
||||
selectedMajor.value = '全部'
|
||||
selectedDifficulty.value = '全部'
|
||||
currentPage.value = 1
|
||||
loadCourses()
|
||||
}
|
||||
|
||||
// 筛选功能
|
||||
const selectSubject = (subject: string) => {
|
||||
selectedSubject.value = subject
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
loadCourses()
|
||||
}
|
||||
|
||||
const selectMajor = (major: string) => {
|
||||
selectedMajor.value = major
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
loadCourses()
|
||||
}
|
||||
|
||||
const selectDifficulty = (difficulty: string) => {
|
||||
selectedDifficulty.value = difficulty
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
loadCourses()
|
||||
}
|
||||
|
||||
// 筛选后的课程数据
|
||||
const filteredCourses = computed(() => {
|
||||
return courses.value.filter(course => {
|
||||
const subjectMatch = selectedSubject.value === '全部' || course.subject === selectedSubject.value
|
||||
const majorMatch = selectedMajor.value === '全部' || course.major === selectedMajor.value
|
||||
const difficultyMatch = selectedDifficulty.value === '全部' || course.difficulty === selectedDifficulty.value
|
||||
|
||||
return subjectMatch && majorMatch && difficultyMatch
|
||||
})
|
||||
})
|
||||
|
||||
// 当前页显示的课程数据
|
||||
// 当前页显示的课程数据(直接使用从API获取的数据)
|
||||
const allCourses = computed(() => {
|
||||
const startIndex = (currentPage.value - 1) * itemsPerPage
|
||||
const endIndex = startIndex + itemsPerPage
|
||||
return filteredCourses.value.slice(startIndex, endIndex)
|
||||
return courses.value
|
||||
})
|
||||
|
||||
// 获取课程标题的函数
|
||||
const getCourseTitle = (course: any) => {
|
||||
const titles = [
|
||||
'Python语言基础与应用',
|
||||
'PPT课件的设计与制作基础',
|
||||
'暑期名师带学,提高班级数学学科!高效冲分指南',
|
||||
'机器学习算法实战训练营',
|
||||
'数据分析与可视化进阶',
|
||||
'前端开发技术栈全解析',
|
||||
'人工智能基础理论与实践',
|
||||
'计算机网络原理与应用'
|
||||
]
|
||||
return titles[(course.id - 1) % titles.length] || '暑期名师带学,提高班级数学学科!高效冲分指南'
|
||||
const getCourseTitle = (course: Course) => {
|
||||
return course.title
|
||||
}
|
||||
|
||||
// 跳转到课程详情页
|
||||
const goToCourseDetail = (course: any) => {
|
||||
const goToCourseDetail = (course: Course) => {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { id: course.id }
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadCourses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -594,6 +537,19 @@ const goToCourseDetail = (course: any) => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-content p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -767,8 +723,9 @@ const goToCourseDetail = (course: any) => {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.course-time {
|
||||
color: #666;
|
||||
.course-price {
|
||||
color: #ff4d4f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.course-stats {
|
||||
|
@ -77,7 +77,7 @@
|
||||
<h3 class="course-title">{{ course.title }}</h3>
|
||||
<div class="course-meta">
|
||||
<span class="course-students">{{ course.studentsCount }}{{ t('home.popularCourses.studentsEnrolled') }}</span>
|
||||
<button class="enroll-btn">{{ t('home.popularCourses.enroll') }}</button>
|
||||
<button class="enroll-btn" @click="handleEnrollCourse(course.id)">{{ t('home.popularCourses.enroll') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -203,17 +203,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 登录模态框 -->
|
||||
<LoginModal
|
||||
v-model:show="loginModalVisible"
|
||||
@success="handleAuthSuccess"
|
||||
/>
|
||||
|
||||
<!-- 注册模态框 -->
|
||||
<RegisterModal
|
||||
v-model:show="registerModalVisible"
|
||||
@success="handleAuthSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import LoginModal from '@/components/auth/LoginModal.vue'
|
||||
import RegisterModal from '@/components/auth/RegisterModal.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const courseStore = useCourseStore()
|
||||
const { loginModalVisible, registerModalVisible, enrollCourse, handleAuthSuccess } = useAuth()
|
||||
|
||||
// 轮播图根据语言动态切换
|
||||
const bannerImage = computed(() => {
|
||||
@ -360,6 +377,12 @@ const featuredReviews = computed(() => [
|
||||
}
|
||||
])
|
||||
|
||||
// 处理课程报名 - 跳转到课程详情页面
|
||||
const handleEnrollCourse = (courseId: number) => {
|
||||
// 跳转到课程详情页面,在那里进行登录状态判断和报名
|
||||
router.push(`/course/${courseId}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await courseStore.fetchCourses()
|
||||
})
|
||||
|
@ -101,7 +101,12 @@
|
||||
|
||||
<!-- 侧边图片 -->
|
||||
<div class="login-image">
|
||||
<img src="https://via.placeholder.com/600x800" alt="登录" />
|
||||
<PlaceholderImage
|
||||
:width="600"
|
||||
:height="800"
|
||||
text="登录背景图"
|
||||
icon="🎨"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -112,6 +117,7 @@ import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, type FormInst, type FormRules } from 'naive-ui'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import PlaceholderImage from '@/components/common/PlaceholderImage.vue'
|
||||
import {
|
||||
MailOutline,
|
||||
LockClosedOutline,
|
||||
@ -119,6 +125,7 @@ import {
|
||||
LogoGoogle,
|
||||
LogoWechat
|
||||
} from '@vicons/ionicons5'
|
||||
import { AuthApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
@ -154,8 +161,8 @@ const rules: FormRules = {
|
||||
trigger: ['input', 'blur']
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: '密码长度不能少于6位',
|
||||
min: 3,
|
||||
message: '密码长度不能少于3位',
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
]
|
||||
@ -168,21 +175,55 @@ const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
const result = await userStore.login({
|
||||
// 显示加载状态
|
||||
userStore.isLoading = true
|
||||
|
||||
// 调用登录API
|
||||
const response = await AuthApi.login({
|
||||
email: formData.email,
|
||||
password: formData.password
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
message.success(result.message)
|
||||
if (response.code === 200) {
|
||||
const { user, token, refreshToken } = response.data
|
||||
|
||||
// 保存用户信息和token到store
|
||||
userStore.user = user
|
||||
userStore.token = token
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
|
||||
// 如果选择了记住我,设置更长的过期时间
|
||||
if (rememberMe.value) {
|
||||
localStorage.setItem('rememberMe', 'true')
|
||||
}
|
||||
|
||||
message.success('登录成功!')
|
||||
|
||||
// 登录成功后跳转到首页或之前的页面
|
||||
const redirect = router.currentRoute.value.query.redirect as string
|
||||
router.push(redirect || '/')
|
||||
} else {
|
||||
message.error(result.message)
|
||||
message.error(response.message || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
|
||||
// 处理不同类型的错误
|
||||
if (error.response?.status === 401) {
|
||||
message.error('邮箱或密码错误')
|
||||
} else if (error.response?.status === 429) {
|
||||
message.error('登录尝试过于频繁,请稍后再试')
|
||||
} else if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('网络错误,请检查网络连接')
|
||||
}
|
||||
} finally {
|
||||
userStore.isLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -25,10 +25,10 @@
|
||||
>
|
||||
<n-form-item label="头像">
|
||||
<div class="avatar-section">
|
||||
<n-avatar
|
||||
<SafeAvatar
|
||||
:src="userStore.user?.avatar"
|
||||
:fallback-src="'https://via.placeholder.com/100'"
|
||||
size="large"
|
||||
:name="userStore.user?.username"
|
||||
:size="100"
|
||||
/>
|
||||
<n-button size="small" @click="handleAvatarUpload">
|
||||
更换头像
|
||||
@ -156,6 +156,7 @@ import { useMessage, type FormInst, type FormRules } from 'naive-ui'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
import CourseCard from '@/components/course/CourseCard.vue'
|
||||
import SafeAvatar from '@/components/common/SafeAvatar.vue'
|
||||
import {
|
||||
PersonOutline,
|
||||
BookOutline,
|
||||
|
@ -1,307 +0,0 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<div class="register-container">
|
||||
<div class="register-form">
|
||||
<div class="form-header">
|
||||
<h1>注册</h1>
|
||||
<p>加入我们,开始您的学习之旅</p>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<n-form-item path="username" label="用户名">
|
||||
<n-input
|
||||
v-model:value="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<PersonOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="email" label="邮箱">
|
||||
<n-input
|
||||
v-model:value="formData.email"
|
||||
placeholder="请输入邮箱地址"
|
||||
type="email"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<MailOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="password" label="密码">
|
||||
<n-input
|
||||
v-model:value="formData.password"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<LockClosedOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="confirmPassword" label="确认密码">
|
||||
<n-input
|
||||
v-model:value="formData.confirmPassword"
|
||||
placeholder="请再次输入密码"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<LockClosedOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="agreement">
|
||||
<n-checkbox v-model:checked="formData.agreement">
|
||||
我已阅读并同意
|
||||
<n-button text type="primary">用户协议</n-button>
|
||||
和
|
||||
<n-button text type="primary">隐私政策</n-button>
|
||||
</n-checkbox>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item>
|
||||
<n-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="userStore.isLoading"
|
||||
attr-type="submit"
|
||||
>
|
||||
注册
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<p>
|
||||
已有账号?
|
||||
<n-button text type="primary" @click="$router.push('/login')">
|
||||
立即登录
|
||||
</n-button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边图片 -->
|
||||
<div class="register-image">
|
||||
<img src="https://via.placeholder.com/600x800" alt="注册" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, type FormInst, type FormRules } from 'naive-ui'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
PersonOutline,
|
||||
MailOutline,
|
||||
LockClosedOutline
|
||||
} from '@vicons/ionicons5'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入用户名',
|
||||
trigger: ['input', 'blur']
|
||||
},
|
||||
{
|
||||
min: 2,
|
||||
max: 20,
|
||||
message: '用户名长度应在2-20个字符之间',
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
],
|
||||
email: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入邮箱地址',
|
||||
trigger: ['input', 'blur']
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: '请输入有效的邮箱地址',
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: ['input', 'blur']
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: '密码长度不能少于6位',
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
],
|
||||
confirmPassword: [
|
||||
{
|
||||
required: true,
|
||||
message: '请确认密码',
|
||||
trigger: ['input', 'blur']
|
||||
},
|
||||
{
|
||||
validator: (_rule, value) => {
|
||||
return value === formData.password
|
||||
},
|
||||
message: '两次输入的密码不一致',
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
],
|
||||
agreement: [
|
||||
{
|
||||
validator: (_rule, value) => {
|
||||
return value === true
|
||||
},
|
||||
message: '请同意用户协议和隐私政策',
|
||||
trigger: ['change']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
const result = await userStore.register({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
confirmPassword: formData.confirmPassword
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
message.success(result.message)
|
||||
// 注册成功后跳转到首页
|
||||
router.push('/')
|
||||
} else {
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.register-form {
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-header h1 {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.register-image {
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.register-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.register-container {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.register-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
padding: 40px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
197
src/views/TestSections.vue
Normal file
197
src/views/TestSections.vue
Normal file
@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="test-sections">
|
||||
<h1>测试课程章节API</h1>
|
||||
|
||||
<div class="test-controls">
|
||||
<label>课程ID:</label>
|
||||
<input v-model.number="testLessonId" type="number" placeholder="输入课程ID" />
|
||||
<button @click="testGetSections">获取章节列表</button>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<h3>API调用结果:</h3>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<p>正在加载...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error">
|
||||
<p>错误: {{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sections.length > 0" class="success">
|
||||
<h4>章节列表 (共{{ sections.length }}个):</h4>
|
||||
<div class="section-list">
|
||||
<div v-for="section in sections" :key="section.id" class="section-item">
|
||||
<div class="section-info">
|
||||
<strong>ID:</strong> {{ section.id }} <br>
|
||||
<strong>标题:</strong> {{ section.title }} <br>
|
||||
<strong>课程ID:</strong> {{ section.lessonId }} <br>
|
||||
<strong>排序:</strong> {{ section.sortOrder }} <br>
|
||||
<strong>链接:</strong> {{ section.sectionId }} <br>
|
||||
<strong>层级:</strong> {{ section.level }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-data">
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
|
||||
<div class="raw-response" v-if="rawResponse">
|
||||
<h4>原始响应:</h4>
|
||||
<pre>{{ JSON.stringify(rawResponse, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { CourseApi } from '@/api/modules/course'
|
||||
import type { CourseSection } from '@/api/types'
|
||||
|
||||
const testLessonId = ref(1)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sections = ref<CourseSection[]>([])
|
||||
const rawResponse = ref<any>(null)
|
||||
|
||||
const testGetSections = async () => {
|
||||
if (!testLessonId.value) {
|
||||
error.value = '请输入课程ID'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
sections.value = []
|
||||
rawResponse.value = null
|
||||
|
||||
console.log('测试API调用,课程ID:', testLessonId.value)
|
||||
|
||||
const response = await CourseApi.getCourseSections(testLessonId.value)
|
||||
console.log('API响应:', response)
|
||||
|
||||
rawResponse.value = response
|
||||
|
||||
if (response.code === 0 || response.code === 200) {
|
||||
sections.value = response.data.list || []
|
||||
console.log('章节数据:', sections.value)
|
||||
} else {
|
||||
error.value = response.message || '获取章节失败'
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('API调用失败:', err)
|
||||
error.value = err.message || '网络错误'
|
||||
rawResponse.value = err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-sections {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.test-controls label {
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.test-controls input {
|
||||
margin-right: 12px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.test-controls button {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.test-controls button:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.results {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.section-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.section-item {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.section-info {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.raw-response {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.raw-response pre {
|
||||
background: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
242
src/views/VideoTest.vue
Normal file
242
src/views/VideoTest.vue
Normal file
@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="video-test-page">
|
||||
<div class="container">
|
||||
<h1>视频播放器测试</h1>
|
||||
|
||||
<div class="test-controls">
|
||||
<div class="url-input">
|
||||
<label>视频URL:</label>
|
||||
<input
|
||||
v-model="testVideoUrl"
|
||||
type="text"
|
||||
placeholder="输入视频URL"
|
||||
class="url-field"
|
||||
/>
|
||||
<button @click="loadTestVideo" class="load-btn">加载视频</button>
|
||||
</div>
|
||||
|
||||
<div class="preset-videos">
|
||||
<h3>预设视频:</h3>
|
||||
<button @click="loadPresetVideo('hls')" class="preset-btn">HLS测试视频</button>
|
||||
<button @click="loadPresetVideo('mp4')" class="preset-btn">MP4测试视频</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-section">
|
||||
<VideoPlayer
|
||||
:video-url="currentVideoUrl"
|
||||
:title="videoTitle"
|
||||
:description="videoDescription"
|
||||
:autoplay="false"
|
||||
:show-controls="true"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onEnded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@error="onError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="debug-info">
|
||||
<h3>调试信息:</h3>
|
||||
<div class="debug-item">
|
||||
<strong>当前URL:</strong> {{ currentVideoUrl }}
|
||||
</div>
|
||||
<div class="debug-item">
|
||||
<strong>播放状态:</strong> {{ isPlaying ? '播放中' : '已暂停' }}
|
||||
</div>
|
||||
<div class="debug-item">
|
||||
<strong>当前时间:</strong> {{ formatTime(currentTime) }}
|
||||
</div>
|
||||
<div class="debug-item">
|
||||
<strong>错误信息:</strong> {{ errorMessage || '无' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
|
||||
// 测试数据
|
||||
const testVideoUrl = ref('http://110.42.96.65:55513/learn/hotgo/attachment/2025-07-26/30137510-ce75-433a-971c-52aae015c1b7.m3u8')
|
||||
const currentVideoUrl = ref('')
|
||||
const videoTitle = ref('测试视频')
|
||||
const videoDescription = ref('这是一个测试视频,用于验证视频播放器功能')
|
||||
|
||||
// 播放状态
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 预设视频
|
||||
const presetVideos = {
|
||||
hls: 'http://110.42.96.65:55513/learn/hotgo/attachment/2025-07-26/30137510-ce75-433a-971c-52aae015c1b7.m3u8',
|
||||
mp4: 'https://vjs.zencdn.net/v/oceans.mp4'
|
||||
}
|
||||
|
||||
// 方法
|
||||
const loadTestVideo = () => {
|
||||
if (testVideoUrl.value.trim()) {
|
||||
currentVideoUrl.value = testVideoUrl.value.trim()
|
||||
errorMessage.value = ''
|
||||
console.log('加载测试视频:', currentVideoUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPresetVideo = (type: 'hls' | 'mp4') => {
|
||||
currentVideoUrl.value = presetVideos[type]
|
||||
testVideoUrl.value = presetVideos[type]
|
||||
errorMessage.value = ''
|
||||
videoTitle.value = type === 'hls' ? 'HLS测试视频' : 'MP4测试视频'
|
||||
console.log('加载预设视频:', type, currentVideoUrl.value)
|
||||
}
|
||||
|
||||
// 视频事件处理
|
||||
const onPlay = () => {
|
||||
isPlaying.value = true
|
||||
console.log('视频开始播放')
|
||||
}
|
||||
|
||||
const onPause = () => {
|
||||
isPlaying.value = false
|
||||
console.log('视频暂停')
|
||||
}
|
||||
|
||||
const onEnded = () => {
|
||||
isPlaying.value = false
|
||||
console.log('视频播放结束')
|
||||
}
|
||||
|
||||
const onTimeUpdate = (time: number) => {
|
||||
currentTime.value = time
|
||||
}
|
||||
|
||||
const onError = (error: Event) => {
|
||||
console.error('视频播放错误:', error)
|
||||
errorMessage.value = '视频播放失败,请检查URL或网络连接'
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
// 工具方法
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadPresetVideo('hls')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-test-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.url-input {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.url-input label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.url-field {
|
||||
width: 70%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.load-btn, .preset-btn {
|
||||
padding: 8px 16px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.load-btn:hover, .preset-btn:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.preset-videos h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.video-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.debug-info h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.debug-item {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.debug-item strong {
|
||||
color: #333;
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
155
响应式设计说明.md
155
响应式设计说明.md
@ -1,155 +0,0 @@
|
||||
# 响应式设计说明
|
||||
|
||||
## 🎯 设计目标
|
||||
|
||||
网站现已实现完全的全屏占满和响应式设计,能够在各种设备上提供最佳的用户体验。
|
||||
|
||||
## 📱 响应式断点
|
||||
|
||||
### 断点定义
|
||||
- **超大屏幕**: ≥1400px (大显示器、4K屏幕)
|
||||
- **大屏幕**: 1200px-1399px (桌面显示器)
|
||||
- **中等屏幕**: 992px-1199px (小桌面、大平板横屏)
|
||||
- **小屏幕**: 768px-991px (平板竖屏)
|
||||
- **移动设备**: 576px-767px (大手机横屏、小平板)
|
||||
- **小移动设备**: ≤575px (手机竖屏)
|
||||
|
||||
## 🖥️ 各断点适配详情
|
||||
|
||||
### 超大屏幕 (≥1400px)
|
||||
- **容器宽度**: 最大1400px
|
||||
- **内边距**: 32px
|
||||
- **课程网格**: 5列
|
||||
- **试听网格**: 4列
|
||||
- **统计网格**: 5列
|
||||
- **最佳体验**: 大显示器用户
|
||||
|
||||
### 大屏幕 (1200px-1399px)
|
||||
- **容器宽度**: 最大1200px
|
||||
- **内边距**: 24px
|
||||
- **课程网格**: 4列
|
||||
- **试听网格**: 4列
|
||||
- **统计网格**: 5列
|
||||
- **适用设备**: 标准桌面显示器
|
||||
|
||||
### 中等屏幕 (992px-1199px)
|
||||
- **容器宽度**: 最大960px
|
||||
- **内边距**: 20px
|
||||
- **课程网格**: 3列
|
||||
- **试听网格**: 3列
|
||||
- **统计网格**: 3列 + 特殊卡片独占一行
|
||||
- **适用设备**: 小桌面、大平板横屏
|
||||
|
||||
### 小屏幕 (768px-991px)
|
||||
- **容器宽度**: 最大720px
|
||||
- **内边距**: 16px
|
||||
- **横幅**: 保持双列布局
|
||||
- **课程网格**: 2列
|
||||
- **试听网格**: 2列
|
||||
- **统计网格**: 2列
|
||||
- **标题字体**: 减小到24px
|
||||
- **适用设备**: 平板竖屏
|
||||
|
||||
### 移动设备 (576px-767px)
|
||||
- **容器宽度**: 最大540px
|
||||
- **内边距**: 12px
|
||||
- **横幅**: 改为单列布局,居中对齐
|
||||
- **课程网格**: 2列
|
||||
- **试听网格**: 2列
|
||||
- **统计网格**: 2列
|
||||
- **学习路径**: 单列
|
||||
- **讲师展示**: 单列
|
||||
- **标题字体**: 22px
|
||||
- **适用设备**: 大手机横屏
|
||||
|
||||
### 小移动设备 (≤575px)
|
||||
- **容器宽度**: 100%
|
||||
- **内边距**: 8px
|
||||
- **横幅**: 单列,紧凑布局
|
||||
- **所有网格**: 单列显示
|
||||
- **统计**: 居中对齐
|
||||
- **标题字体**: 20px
|
||||
- **横幅标题**: 1.8rem
|
||||
- **内容间距**: 减小到40px
|
||||
- **适用设备**: 手机竖屏
|
||||
|
||||
## 🎨 布局特性
|
||||
|
||||
### 全屏占满
|
||||
- **HTML/Body**: 100%高度和宽度
|
||||
- **App容器**: 弹性布局,占满视口
|
||||
- **防止横向滚动**: overflow-x: hidden
|
||||
- **布局组件**: 弹性布局,头部和底部固定,内容区域自适应
|
||||
|
||||
### 网格系统
|
||||
- **自适应网格**: 使用CSS Grid的auto-fit和minmax
|
||||
- **最小宽度**: 每个卡片都有合理的最小宽度
|
||||
- **自动换行**: 内容自动适应屏幕宽度
|
||||
- **间距调整**: 不同屏幕尺寸使用不同的gap值
|
||||
|
||||
### 导航栏适配
|
||||
- **大屏**: 完整导航菜单 + 搜索框 + 用户信息
|
||||
- **中屏**: 隐藏部分菜单项,保留核心功能
|
||||
- **小屏**: 隐藏导航菜单,缩小搜索框
|
||||
- **手机**: 最小化布局,隐藏用户名显示
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### CSS Grid 响应式
|
||||
```css
|
||||
.courses-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
### 媒体查询策略
|
||||
- **移动优先**: 基础样式适配小屏幕
|
||||
- **渐进增强**: 大屏幕添加更多功能
|
||||
- **断点覆盖**: 确保所有尺寸都有适配
|
||||
|
||||
### 弹性布局
|
||||
- **Flexbox**: 用于组件内部对齐
|
||||
- **Grid**: 用于整体布局和卡片网格
|
||||
- **混合使用**: 根据需求选择最佳方案
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
### 图片适配
|
||||
- **占位符**: 使用图标替代图片,减少加载时间
|
||||
- **响应式图片**: 为不同屏幕准备不同尺寸
|
||||
- **懒加载**: 可在后续添加图片懒加载
|
||||
|
||||
### 字体缩放
|
||||
- **相对单位**: 使用rem和em确保可访问性
|
||||
- **合理层级**: 不同屏幕使用合适的字体大小
|
||||
- **行高调整**: 保持良好的阅读体验
|
||||
|
||||
## 🎯 用户体验
|
||||
|
||||
### 触摸友好
|
||||
- **按钮大小**: 移动端按钮足够大,易于点击
|
||||
- **间距合理**: 避免误触,提供舒适的操作空间
|
||||
- **手势支持**: 为触摸设备优化交互
|
||||
|
||||
### 内容优先
|
||||
- **重要信息**: 在小屏幕上优先显示核心内容
|
||||
- **渐进披露**: 根据屏幕空间逐步展示更多信息
|
||||
- **简化操作**: 移动端简化复杂操作流程
|
||||
|
||||
## 🚀 测试建议
|
||||
|
||||
### 设备测试
|
||||
1. **桌面**: Chrome DevTools 各种尺寸
|
||||
2. **平板**: iPad (768px) 和 iPad Pro (1024px)
|
||||
3. **手机**: iPhone SE (375px) 到 iPhone Pro Max (428px)
|
||||
4. **超宽屏**: 1440px+ 显示器
|
||||
|
||||
### 功能测试
|
||||
- **导航**: 各尺寸下导航功能正常
|
||||
- **搜索**: 搜索框在小屏幕下可用
|
||||
- **卡片**: 内容卡片在各尺寸下显示完整
|
||||
- **交互**: 按钮和链接在触摸设备上易用
|
||||
|
||||
现在网站已经完全实现了全屏占满和完整的响应式设计!🎉
|
188
缩放兼容性修复说明.md
188
缩放兼容性修复说明.md
@ -1,188 +0,0 @@
|
||||
# 浏览器缩放兼容性修复说明
|
||||
|
||||
## 🎯 问题描述
|
||||
|
||||
您遇到的问题是在浏览器缩放比例为100%或大于25%时,导航栏和轮播图部分看不到。这是一个常见的CSS布局问题,通常由以下原因导致:
|
||||
|
||||
1. **视口单位问题**: 使用`100vh`在缩放时计算错误
|
||||
2. **固定定位问题**: `position: sticky`在缩放时位置异常
|
||||
3. **布局溢出**: 元素在缩放时超出可视区域
|
||||
4. **Z-index层级**: 元素被其他层级覆盖
|
||||
|
||||
## ✅ 已修复的问题
|
||||
|
||||
### 1. 视口单位修复
|
||||
**问题**: `100vh`在浏览器缩放时计算不准确
|
||||
**解决方案**:
|
||||
```css
|
||||
/* 修改前 */
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 修改后 */
|
||||
#app {
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 固定定位修复
|
||||
**问题**: `position: sticky`在缩放时位置异常
|
||||
**解决方案**:
|
||||
```css
|
||||
/* 修改前 */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* 修改后 */
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 布局容器修复
|
||||
**问题**: 容器高度和宽度在缩放时异常
|
||||
**解决方案**:
|
||||
```css
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 导航栏高度固定
|
||||
**问题**: 导航栏高度使用`height: 100%`导致缩放异常
|
||||
**解决方案**:
|
||||
```css
|
||||
.header-container {
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 轮播图区域修复
|
||||
**问题**: 轮播图在缩放时被隐藏或位置异常
|
||||
**解决方案**:
|
||||
```css
|
||||
.hero-banner {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 技术修复详情
|
||||
|
||||
### CSS缩放兼容性
|
||||
```css
|
||||
/* 确保在所有缩放级别下都能正常显示 */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
zoom: 1;
|
||||
}
|
||||
|
||||
/* 强制显示 - 防止在极端缩放下隐藏 */
|
||||
.home,
|
||||
.hero-banner,
|
||||
.header-container {
|
||||
visibility: visible !important;
|
||||
display: flex !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
```
|
||||
|
||||
### 布局结构优化
|
||||
```css
|
||||
.app-layout {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
min-height: calc(100% - 64px - 200px);
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 缩放测试结果
|
||||
|
||||
### 测试的缩放级别
|
||||
- ✅ **25%**: 导航栏和轮播图正常显示
|
||||
- ✅ **50%**: 所有元素正常显示
|
||||
- ✅ **75%**: 布局完整,功能正常
|
||||
- ✅ **100%**: 标准显示,完美呈现
|
||||
- ✅ **125%**: 放大显示,元素清晰
|
||||
- ✅ **150%**: 大字体模式,可访问性良好
|
||||
- ✅ **200%**: 极大放大,依然可用
|
||||
|
||||
### 浏览器兼容性
|
||||
- ✅ **Chrome**: 所有缩放级别完美支持
|
||||
- ✅ **Firefox**: 缩放功能正常
|
||||
- ✅ **Safari**: 响应式缩放良好
|
||||
- ✅ **Edge**: 完全兼容
|
||||
|
||||
## 🎨 视觉效果保持
|
||||
|
||||
### 在所有缩放级别下保持
|
||||
- ✅ **导航栏**: 始终在页面顶部可见
|
||||
- ✅ **轮播图**: 完整显示,比例正确
|
||||
- ✅ **内容区域**: 正常滚动,无溢出
|
||||
- ✅ **响应式**: 断点正常工作
|
||||
- ✅ **交互**: 所有按钮和链接可点击
|
||||
|
||||
### 布局特性
|
||||
- **弹性布局**: 使用Flexbox确保元素正确排列
|
||||
- **相对定位**: 避免固定定位的缩放问题
|
||||
- **最小高度**: 确保内容区域有足够空间
|
||||
- **Z-index管理**: 正确的层级关系
|
||||
|
||||
## 🚀 性能优化
|
||||
|
||||
### 渲染性能
|
||||
- **GPU加速**: 使用transform属性优化渲染
|
||||
- **重绘最小化**: 避免频繁的布局重计算
|
||||
- **内存优化**: 合理的CSS选择器使用
|
||||
|
||||
### 用户体验
|
||||
- **平滑缩放**: 所有元素在缩放时平滑过渡
|
||||
- **内容可见**: 确保重要内容始终可见
|
||||
- **交互保持**: 缩放不影响用户交互
|
||||
|
||||
## 📋 使用建议
|
||||
|
||||
### 开发者建议
|
||||
1. **避免使用100vh**: 在需要全屏高度时使用100%
|
||||
2. **谨慎使用sticky**: 考虑使用relative替代
|
||||
3. **测试多缩放**: 开发时测试不同缩放级别
|
||||
4. **使用相对单位**: em、rem比px更适合缩放
|
||||
|
||||
### 用户建议
|
||||
1. **推荐缩放**: 100%-150%获得最佳体验
|
||||
2. **极端缩放**: 25%和200%+可能影响可读性
|
||||
3. **浏览器选择**: 现代浏览器支持更好
|
||||
|
||||
## 🔍 问题排查
|
||||
|
||||
如果仍然遇到缩放问题,请检查:
|
||||
|
||||
1. **浏览器版本**: 确保使用最新版本
|
||||
2. **缓存清理**: 清除浏览器缓存
|
||||
3. **开发者工具**: 使用F12检查元素位置
|
||||
4. **控制台错误**: 查看是否有JavaScript错误
|
||||
|
||||
现在您的网站已经完全支持所有浏览器缩放级别!🎉
|
124
首页设计说明.md
124
首页设计说明.md
@ -1,124 +0,0 @@
|
||||
# 首页设计说明
|
||||
|
||||
## 🎨 设计概述
|
||||
|
||||
我已经根据您提供的图片,完全重新设计了首页,使其与原图样式完全一致。新的首页包含以下几个主要区域:
|
||||
|
||||
## 📋 页面结构
|
||||
|
||||
### 1. 主横幅区域 (Hero Banner)
|
||||
- **蓝色渐变背景**: 使用了从 `#4facfe` 到 `#00f2fe` 的渐变效果
|
||||
- **左侧内容**:
|
||||
- 限时优惠标签 (半透明白色背景)
|
||||
- 主标题: "考前冲刺课" (大字体,白色)
|
||||
- 副标题: "名师授课 · 79元"
|
||||
- 橙色CTA按钮: "立即抢购"
|
||||
- **右侧内容**:
|
||||
- 4个讲师头像,采用2x2网格布局
|
||||
- 圆形头像,带白色边框和阴影效果
|
||||
|
||||
### 2. 数据统计区域 (Stats Section)
|
||||
- **白色背景**,带阴影效果
|
||||
- **5列网格布局**:
|
||||
- 前4列: 图标 + 数据展示
|
||||
- 精品课程: 1000+ (蓝色图标)
|
||||
- 名师团队: 100+ (绿色图标)
|
||||
- 学员好评: 5000+ (黄色图标)
|
||||
- 学习时长: 8000+ (红色图标)
|
||||
- 第5列: 特殊卡片 "2024年全新升级" (紫色渐变背景)
|
||||
|
||||
### 3. 热门课程区域 (Popular Courses)
|
||||
- **白色背景**
|
||||
- **标题**: "热门课程" + "查看全部"链接
|
||||
- **5列网格布局**,每个课程卡片包含:
|
||||
- 课程缩略图
|
||||
- 课程分类标签
|
||||
- 课程标题
|
||||
- 学习人数和评分
|
||||
- 价格信息 (当前价格 + 原价)
|
||||
|
||||
### 4. 免费试听区域 (Free Trial)
|
||||
- **浅灰色背景** (#f8f9fa)
|
||||
- **标题**: "免费试听" + "查看全部"链接
|
||||
- **4列网格布局**,每个试听卡片包含:
|
||||
- 视频缩略图 + 播放按钮
|
||||
- 视频标题和描述
|
||||
- 时长和观看次数
|
||||
|
||||
### 5. 学习路径区域 (Learning Paths)
|
||||
- **白色背景**
|
||||
- **标题**: "学习路径" + "查看全部"链接
|
||||
- **3列网格布局**,每个路径卡片包含:
|
||||
- 路径图标和基本信息
|
||||
- 课程数量和学员数量
|
||||
- 技能标签
|
||||
- "开始学习"按钮
|
||||
|
||||
### 6. 精品讲师区域 (Featured Instructors)
|
||||
- **浅灰色背景** (#f8f9fa)
|
||||
- **标题**: "精品讲师"
|
||||
- **3列网格布局**,每个讲师卡片包含:
|
||||
- 圆形讲师头像
|
||||
- 讲师姓名和职位
|
||||
- 个人简介
|
||||
- 课程数量和学员数量
|
||||
|
||||
## 🎯 设计特点
|
||||
|
||||
### 颜色方案
|
||||
- **主色调**: 蓝色渐变 (#4facfe → #00f2fe)
|
||||
- **强调色**: 橙色 (#ff6b35) 用于CTA按钮
|
||||
- **背景色**: 白色和浅灰色 (#f8f9fa) 交替
|
||||
- **文字色**: 深灰色 (#333) 主文字,中灰色 (#666) 辅助文字
|
||||
|
||||
### 布局特点
|
||||
- **响应式设计**: 支持桌面端、平板和移动端
|
||||
- **网格布局**: 使用CSS Grid实现灵活的多列布局
|
||||
- **卡片设计**: 统一的卡片样式,带圆角和阴影
|
||||
- **悬停效果**: 所有交互元素都有悬停动画
|
||||
|
||||
### 交互效果
|
||||
- **卡片悬停**: 向上移动 + 阴影加深
|
||||
- **按钮悬停**: 颜色变化和过渡动画
|
||||
- **图片加载**: 占位符图片,等待真实素材替换
|
||||
|
||||
## 📱 响应式适配
|
||||
|
||||
### 桌面端 (>1200px)
|
||||
- 完整的多列布局
|
||||
- 所有内容正常显示
|
||||
|
||||
### 平板端 (768px-1200px)
|
||||
- 适当减少列数
|
||||
- 保持良好的视觉效果
|
||||
|
||||
### 移动端 (<768px)
|
||||
- 单列或双列布局
|
||||
- 优化触摸交互
|
||||
- 调整字体大小和间距
|
||||
|
||||
## 🔄 待替换内容
|
||||
|
||||
目前使用的是占位符内容,等您提供真实素材后需要替换:
|
||||
|
||||
1. **图片素材**:
|
||||
- 讲师头像
|
||||
- 课程缩略图
|
||||
- 视频预览图
|
||||
- 学习路径图标
|
||||
|
||||
2. **文字内容**:
|
||||
- 课程标题和描述
|
||||
- 讲师信息
|
||||
- 价格信息
|
||||
- 统计数据
|
||||
|
||||
## 🚀 技术实现
|
||||
|
||||
- **Vue 3 Composition API**: 现代化的组件开发
|
||||
- **TypeScript**: 类型安全
|
||||
- **Naive UI**: 统一的UI组件
|
||||
- **CSS Grid & Flexbox**: 灵活的布局系统
|
||||
- **CSS变量**: 便于主题定制
|
||||
|
||||
页面已经完全按照您提供的图片样式实现,现在可以访问 http://localhost:3000 查看效果!
|
Loading…
x
Reference in New Issue
Block a user