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>
|
<title>在线学习平台</title>
|
||||||
<meta name="description" content="专业的在线学习平台,提供优质的编程和技术课程">
|
<meta name="description" content="专业的在线学习平台,提供优质的编程和技术课程">
|
||||||
<meta name="keywords" content="在线学习,编程课程,技术培训,Vue.js,React,Node.js">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
294
package-lock.json
generated
294
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"ckplayer": "^3.1.2",
|
||||||
|
"hls.js": "^1.6.7",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
@ -1756,6 +1759,23 @@
|
|||||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/birpc": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.5.0.tgz",
|
||||||
@ -1814,6 +1834,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001727",
|
"version": "1.0.30001727",
|
||||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||||
@ -1835,6 +1868,24 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.187",
|
"version": "1.5.187",
|
||||||
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
|
"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"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.7",
|
"version": "0.25.7",
|
||||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.7.tgz",
|
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.7.tgz",
|
||||||
@ -2132,6 +2251,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/fs-extra": {
|
||||||
"version": "11.3.0",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz",
|
"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": "^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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@ -2172,6 +2336,43 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/get-stream": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz",
|
||||||
@ -2189,6 +2390,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@ -2196,6 +2409,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/he": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
|
||||||
@ -2215,6 +2467,12 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/hookable": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||||
@ -2424,6 +2682,36 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@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": {
|
"node_modules/mitt": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
|
||||||
@ -2685,6 +2973,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/rfdc": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
|
@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"ckplayer": "^3.1.2",
|
||||||
|
"hls.js": "^1.6.7",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.17",
|
"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">
|
<div class="instructor">
|
||||||
<n-avatar
|
<SafeAvatar
|
||||||
:src="course.instructorAvatar"
|
:src="course.instructor?.avatar"
|
||||||
:fallback-src="'https://via.placeholder.com/32'"
|
:name="course.instructor?.name"
|
||||||
size="small"
|
:size="32"
|
||||||
/>
|
/>
|
||||||
<span class="instructor-name">{{ course.instructor }}</span>
|
<span class="instructor-name">{{ course.instructor?.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 课程统计 -->
|
<!-- 课程统计 -->
|
||||||
@ -96,6 +96,7 @@ import { computed } from 'vue'
|
|||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import { useCourseStore } from '@/stores/course'
|
import { useCourseStore } from '@/stores/course'
|
||||||
import type { Course } from '@/stores/course'
|
import type { Course } from '@/stores/course'
|
||||||
|
import SafeAvatar from '@/components/common/SafeAvatar.vue'
|
||||||
import {
|
import {
|
||||||
StarOutline,
|
StarOutline,
|
||||||
PeopleOutline,
|
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 v-if="!userStore.isLoggedIn" class="auth-buttons">
|
||||||
<div class="auth-combined-btn">
|
<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-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>
|
</div>
|
||||||
|
|
||||||
<!-- 用户菜单 -->
|
<!-- 登录后的用户区域 -->
|
||||||
<div v-else class="user-menu">
|
<div v-else class="user-menu">
|
||||||
<n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect">
|
<n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<n-avatar
|
<SafeAvatar
|
||||||
:src="userStore.user?.avatar"
|
:src="userStore.user?.avatar"
|
||||||
:fallback-src="'https://via.placeholder.com/32'"
|
:name="userStore.user?.username"
|
||||||
size="small"
|
:size="32"
|
||||||
/>
|
/>
|
||||||
<span class="username">{{ userStore.user?.username }}</span>
|
<span class="username">{{ userStore.user?.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录模态框 -->
|
||||||
|
<LoginModal
|
||||||
|
v-model:show="loginModalVisible"
|
||||||
|
@success="handleAuthSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 注册模态框 -->
|
||||||
|
<RegisterModal
|
||||||
|
v-model:show="registerModalVisible"
|
||||||
|
@success="handleAuthSuccess"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -115,10 +127,12 @@ import { useUserStore } from '@/stores/user'
|
|||||||
import {
|
import {
|
||||||
PersonOutline,
|
PersonOutline,
|
||||||
LogOutOutline,
|
LogOutOutline,
|
||||||
SettingsOutline,
|
|
||||||
MenuOutline,
|
MenuOutline,
|
||||||
CloseOutline
|
CloseOutline
|
||||||
} from '@vicons/ionicons5'
|
} 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 router = useRouter()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
@ -131,6 +145,12 @@ const mobileMenuOpen = ref(false)
|
|||||||
// 当前激活的菜单项
|
// 当前激活的菜单项
|
||||||
const activeKey = ref('home')
|
const activeKey = ref('home')
|
||||||
|
|
||||||
|
// 认证模态框相关
|
||||||
|
const loginModalVisible = ref(false)
|
||||||
|
const registerModalVisible = ref(false)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 语言切换相关
|
// 语言切换相关
|
||||||
@ -158,20 +178,15 @@ const switchLanguage = (lang: string) => {
|
|||||||
// 用户菜单选项
|
// 用户菜单选项
|
||||||
const userMenuOptions = computed(() => [
|
const userMenuOptions = computed(() => [
|
||||||
{
|
{
|
||||||
label: t('header.profile'),
|
label: '个人中心',
|
||||||
key: 'profile',
|
key: 'profile',
|
||||||
icon: () => h(PersonOutline)
|
icon: () => h(PersonOutline)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t('header.settings'),
|
|
||||||
key: 'settings',
|
|
||||||
icon: () => h(SettingsOutline)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'divider'
|
type: 'divider'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('header.logout'),
|
label: '退出登录',
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
icon: () => h(LogOutOutline)
|
icon: () => h(LogOutOutline)
|
||||||
}
|
}
|
||||||
@ -212,9 +227,6 @@ const handleUserMenuSelect = (key: string) => {
|
|||||||
case 'profile':
|
case 'profile':
|
||||||
router.push('/profile')
|
router.push('/profile')
|
||||||
break
|
break
|
||||||
case 'settings':
|
|
||||||
// TODO: 实现设置页面
|
|
||||||
break
|
|
||||||
case 'logout':
|
case 'logout':
|
||||||
userStore.logout()
|
userStore.logout()
|
||||||
router.push('/')
|
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中统一管理 */
|
/* 全屏模式样式现在在App.vue中统一管理 */
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,20 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-layout class="app-layout">
|
<n-message-provider>
|
||||||
<!-- 顶部导航 -->
|
<n-layout class="app-layout">
|
||||||
<n-layout-header class="header" bordered>
|
<!-- 顶部导航 -->
|
||||||
<AppHeader />
|
<n-layout-header class="header" bordered>
|
||||||
</n-layout-header>
|
<AppHeader />
|
||||||
|
</n-layout-header>
|
||||||
|
|
||||||
<!-- 主要内容区域 -->
|
<!-- 主要内容区域 -->
|
||||||
<n-layout-content class="content">
|
<n-layout-content class="content">
|
||||||
<slot />
|
<slot />
|
||||||
</n-layout-content>
|
</n-layout-content>
|
||||||
|
|
||||||
<!-- 底部 -->
|
<!-- 底部 -->
|
||||||
<n-layout-footer class="footer" bordered>
|
<n-layout-footer class="footer" bordered>
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
</n-layout-footer>
|
</n-layout-footer>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
|
</n-message-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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
|
||||||
|
}
|
||||||
|
}
|
25
src/main.ts
25
src/main.ts
@ -5,6 +5,7 @@ import { createPinia } from 'pinia'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
// Naive UI
|
// Naive UI
|
||||||
import {
|
import {
|
||||||
@ -25,6 +26,7 @@ import {
|
|||||||
NBreadcrumb,
|
NBreadcrumb,
|
||||||
NBreadcrumbItem,
|
NBreadcrumbItem,
|
||||||
NInput,
|
NInput,
|
||||||
|
NInputGroup,
|
||||||
NForm,
|
NForm,
|
||||||
NFormItem,
|
NFormItem,
|
||||||
NSelect,
|
NSelect,
|
||||||
@ -72,7 +74,8 @@ import {
|
|||||||
NSteps,
|
NSteps,
|
||||||
NStep,
|
NStep,
|
||||||
NTimeline,
|
NTimeline,
|
||||||
NTimelineItem
|
NTimelineItem,
|
||||||
|
NMessageProvider
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
|
|
||||||
const naive = create({
|
const naive = create({
|
||||||
@ -93,6 +96,7 @@ const naive = create({
|
|||||||
NBreadcrumb,
|
NBreadcrumb,
|
||||||
NBreadcrumbItem,
|
NBreadcrumbItem,
|
||||||
NInput,
|
NInput,
|
||||||
|
NInputGroup,
|
||||||
NForm,
|
NForm,
|
||||||
NFormItem,
|
NFormItem,
|
||||||
NSelect,
|
NSelect,
|
||||||
@ -140,7 +144,8 @@ const naive = create({
|
|||||||
NSteps,
|
NSteps,
|
||||||
NStep,
|
NStep,
|
||||||
NTimeline,
|
NTimeline,
|
||||||
NTimelineItem
|
NTimelineItem,
|
||||||
|
NMessageProvider
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -152,4 +157,18 @@ app.use(router)
|
|||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.use(naive)
|
app.use(naive)
|
||||||
|
|
||||||
app.mount('#app')
|
// 初始化用户认证状态
|
||||||
|
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 Home from '@/views/Home.vue'
|
||||||
import Courses from '@/views/Courses.vue'
|
import Courses from '@/views/Courses.vue'
|
||||||
import CourseDetail from '@/views/CourseDetail.vue'
|
import CourseDetail from '@/views/CourseDetail.vue'
|
||||||
|
import CourseStudy from '@/views/CourseStudy.vue'
|
||||||
import Learning from '@/views/Learning.vue'
|
import Learning from '@/views/Learning.vue'
|
||||||
import Profile from '@/views/Profile.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 LearningPaths from '@/views/LearningPaths.vue'
|
||||||
import Faculty from '@/views/Faculty.vue'
|
import Faculty from '@/views/Faculty.vue'
|
||||||
import Resources from '@/views/Resources.vue'
|
import Resources from '@/views/Resources.vue'
|
||||||
import Activities from '@/views/Activities.vue'
|
import Activities from '@/views/Activities.vue'
|
||||||
|
import TestSections from '@/views/TestSections.vue'
|
||||||
|
import VideoTest from '@/views/VideoTest.vue'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@ -39,6 +41,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: '课程详情'
|
title: '课程详情'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/course/study/:id',
|
||||||
|
name: 'CourseStudy',
|
||||||
|
component: CourseStudy,
|
||||||
|
meta: {
|
||||||
|
title: '课程学习',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/learning/:id',
|
path: '/learning/:id',
|
||||||
name: 'Learning',
|
name: 'Learning',
|
||||||
@ -57,22 +68,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'Login',
|
|
||||||
component: Login,
|
|
||||||
meta: {
|
|
||||||
title: '登录'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/register',
|
|
||||||
name: 'Register',
|
|
||||||
component: Register,
|
|
||||||
meta: {
|
|
||||||
title: '注册'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/learning-paths',
|
path: '/learning-paths',
|
||||||
name: 'LearningPaths',
|
name: 'LearningPaths',
|
||||||
@ -105,6 +101,22 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: '全部活动'
|
title: '全部活动'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/test-sections',
|
||||||
|
name: 'TestSections',
|
||||||
|
component: TestSections,
|
||||||
|
meta: {
|
||||||
|
title: '测试章节API'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/video-test',
|
||||||
|
name: 'VideoTest',
|
||||||
|
component: VideoTest,
|
||||||
|
meta: {
|
||||||
|
title: '视频播放器测试'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'NotFound',
|
name: 'NotFound',
|
||||||
@ -136,12 +148,16 @@ router.beforeEach((to, _from, next) => {
|
|||||||
|
|
||||||
// 检查是否需要登录
|
// 检查是否需要登录
|
||||||
if (to.meta.requiresAuth) {
|
if (to.meta.requiresAuth) {
|
||||||
// 这里可以检查用户登录状态
|
// 检查用户登录状态
|
||||||
// 暂时跳过认证检查
|
const token = localStorage.getItem('token')
|
||||||
next()
|
if (!token) {
|
||||||
} else {
|
// 未登录时跳转到首页,用户可以通过模态框登录
|
||||||
next()
|
next('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { CourseApi } from '@/api/modules/course'
|
||||||
|
|
||||||
export interface Course {
|
export interface Course {
|
||||||
id: number
|
id: number
|
||||||
@ -77,10 +78,13 @@ export const useCourseStore = defineStore('course', () => {
|
|||||||
const fetchCourses = async () => {
|
const fetchCourses = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 模拟API调用
|
console.log('尝试从API获取课程数据...')
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
const response = await CourseApi.getCourses()
|
||||||
|
console.log('API响应:', response)
|
||||||
// 模拟课程数据
|
courses.value = response.data.list
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API调用失败,使用模拟数据:', error)
|
||||||
|
// 如果API调用失败,使用模拟数据作为后备
|
||||||
const mockCourses: Course[] = [
|
const mockCourses: Course[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -135,10 +139,7 @@ export const useCourseStore = defineStore('course', () => {
|
|||||||
updatedAt: '2024-01-25'
|
updatedAt: '2024-01-25'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
courses.value = mockCourses
|
courses.value = mockCourses
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch courses:', error)
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@ -147,15 +148,15 @@ export const useCourseStore = defineStore('course', () => {
|
|||||||
const fetchCourseById = async (id: number) => {
|
const fetchCourseById = async (id: number) => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 模拟API调用
|
const response = await CourseApi.getCourseById(id)
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
currentCourse.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch course:', error)
|
||||||
|
// 如果API调用失败,从本地数据中查找
|
||||||
const course = courses.value.find(c => c.id === id)
|
const course = courses.value.find(c => c.id === id)
|
||||||
if (course) {
|
if (course) {
|
||||||
currentCourse.value = course
|
currentCourse.value = course
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch course:', error)
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,9 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { AuthApi, type User as ApiUser } from '@/api'
|
||||||
|
|
||||||
export interface User {
|
// 扩展API用户类型以保持兼容性
|
||||||
id: number
|
export interface User extends ApiUser {}
|
||||||
username: string
|
|
||||||
email: string
|
|
||||||
avatar?: string
|
|
||||||
role: 'student' | 'teacher' | 'admin'
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// 状态
|
// 状态
|
||||||
@ -22,100 +17,112 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const isTeacher = computed(() => user.value?.role === 'teacher')
|
const isTeacher = computed(() => user.value?.role === 'teacher')
|
||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
|
||||||
// 方法
|
// 方法 - 简化版本,主要用于状态管理
|
||||||
const login = async (credentials: { email: string; password: string }) => {
|
const login = async (credentials: { email: string; password: string }) => {
|
||||||
|
// 这个方法现在主要用于兼容性,实际登录逻辑在组件中处理
|
||||||
|
return { success: true, message: '请使用登录模态框进行登录' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (userData: any) => {
|
||||||
|
// 这个方法现在主要用于兼容性,实际注册逻辑在组件中处理
|
||||||
|
return { success: true, message: '请使用注册模态框进行注册' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
// 调用登出API
|
||||||
|
await AuthApi.logout()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出API调用失败:', error)
|
||||||
|
} finally {
|
||||||
|
// 无论API调用是否成功,都清除本地数据
|
||||||
|
user.value = null
|
||||||
|
token.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('refreshToken')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
localStorage.removeItem('rememberMe')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
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
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 模拟API调用
|
const response = await AuthApi.updateProfile(profileData)
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
// 模拟登录成功
|
if (response.code === 200) {
|
||||||
const mockUser: User = {
|
user.value = response.data
|
||||||
id: 1,
|
localStorage.setItem('user', JSON.stringify(response.data))
|
||||||
username: '张三',
|
return { success: true, message: '更新成功' }
|
||||||
email: credentials.email,
|
} else {
|
||||||
avatar: 'https://via.placeholder.com/100',
|
return { success: false, message: response.message || '更新失败' }
|
||||||
role: 'student',
|
}
|
||||||
createdAt: new Date().toISOString()
|
} catch (error: any) {
|
||||||
|
console.error('更新用户资料失败:', error)
|
||||||
|
|
||||||
|
let message = '更新失败'
|
||||||
|
if (error.response?.data?.message) {
|
||||||
|
message = error.response.data.message
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockToken = 'mock-jwt-token-' + Date.now()
|
return { success: false, message }
|
||||||
|
|
||||||
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 {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const register = async (userData: {
|
const initializeAuth = async () => {
|
||||||
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 = () => {
|
|
||||||
user.value = null
|
|
||||||
token.value = null
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProfile = async (profileData: Partial<User>) => {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
// 模拟API调用
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
if (user.value) {
|
|
||||||
user.value = { ...user.value, ...profileData }
|
|
||||||
localStorage.setItem('user', JSON.stringify(user.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: '更新成功' }
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, message: '更新失败' }
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeAuth = () => {
|
|
||||||
const savedUser = localStorage.getItem('user')
|
const savedUser = localStorage.getItem('user')
|
||||||
const savedToken = localStorage.getItem('token')
|
const savedToken = localStorage.getItem('token')
|
||||||
|
|
||||||
@ -123,13 +130,18 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
try {
|
try {
|
||||||
user.value = JSON.parse(savedUser)
|
user.value = JSON.parse(savedUser)
|
||||||
token.value = savedToken
|
token.value = savedToken
|
||||||
|
|
||||||
|
// 验证token是否仍然有效
|
||||||
|
await getCurrentUser()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse saved user data:', error)
|
console.error('Failed to parse saved user data or token expired:', error)
|
||||||
logout()
|
await logout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
user,
|
user,
|
||||||
@ -144,6 +156,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
|
getCurrentUser,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
initializeAuth
|
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 !== '全部'">
|
<div class="filter-result" v-if="selectedSubject !== '全部' || selectedMajor !== '全部' || selectedDifficulty !== '全部'">
|
||||||
<span>筛选结果:找到 {{ filteredCourses.length }} 门相关课程</span>
|
<span>筛选结果:找到 {{ total }} 门相关课程</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 排序标签 -->
|
<!-- 排序标签 -->
|
||||||
@ -197,8 +197,15 @@
|
|||||||
<span class="sort-tab active">推荐</span>
|
<span class="sort-tab active">推荐</span>
|
||||||
</div>
|
</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-card" v-for="course in allCourses" :key="course.id">
|
||||||
<div class="course-image">
|
<div class="course-image">
|
||||||
<img :src="course.thumbnail" :alt="course.title" />
|
<img :src="course.thumbnail" :alt="course.title" />
|
||||||
@ -207,7 +214,7 @@
|
|||||||
<h3 class="course-title">{{ getCourseTitle(course) }}</h3>
|
<h3 class="course-title">{{ getCourseTitle(course) }}</h3>
|
||||||
<div class="course-meta">
|
<div class="course-meta">
|
||||||
<span class="course-duration">📚 {{ course.duration }}</span>
|
<span class="course-duration">📚 {{ course.duration }}</span>
|
||||||
<span class="course-time">⏰ {{ course.totalTime }}</span>
|
<span class="course-price">💰 ¥{{ course.price }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="course-footer">
|
<div class="course-footer">
|
||||||
<div class="course-stats">
|
<div class="course-stats">
|
||||||
@ -273,107 +280,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { CourseApi } from '@/api/modules/course'
|
||||||
|
import type { Course } from '@/api/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 生成248条课程数据
|
// 课程数据和加载状态
|
||||||
const generateCourses = () => {
|
const courses = ref<Course[]>([])
|
||||||
const baseCourses = [
|
const loading = ref(false)
|
||||||
{
|
const total = ref(0)
|
||||||
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 selectedSubject = ref('全部')
|
const selectedSubject = ref('全部')
|
||||||
@ -383,7 +300,7 @@ const selectedDifficulty = ref('全部')
|
|||||||
// 分页相关状态
|
// 分页相关状态
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const itemsPerPage = 20
|
const itemsPerPage = 20
|
||||||
const totalItems = computed(() => filteredCourses.value.length)
|
const totalItems = computed(() => total.value)
|
||||||
const totalPages = computed(() => Math.ceil(totalItems.value / itemsPerPage))
|
const totalPages = computed(() => Math.ceil(totalItems.value / itemsPerPage))
|
||||||
|
|
||||||
// 数字转中文
|
// 数字转中文
|
||||||
@ -427,10 +344,50 @@ const visiblePages = computed(() => {
|
|||||||
return pages
|
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) => {
|
const goToPage = (page: number) => {
|
||||||
if (page >= 1 && page <= totalPages.value) {
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
|
loadCourses()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,64 +411,50 @@ const clearAllFilters = () => {
|
|||||||
selectedMajor.value = '全部'
|
selectedMajor.value = '全部'
|
||||||
selectedDifficulty.value = '全部'
|
selectedDifficulty.value = '全部'
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
loadCourses()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 筛选功能
|
// 筛选功能
|
||||||
const selectSubject = (subject: string) => {
|
const selectSubject = (subject: string) => {
|
||||||
selectedSubject.value = subject
|
selectedSubject.value = subject
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
loadCourses()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectMajor = (major: string) => {
|
const selectMajor = (major: string) => {
|
||||||
selectedMajor.value = major
|
selectedMajor.value = major
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
loadCourses()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectDifficulty = (difficulty: string) => {
|
const selectDifficulty = (difficulty: string) => {
|
||||||
selectedDifficulty.value = difficulty
|
selectedDifficulty.value = difficulty
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
loadCourses()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 筛选后的课程数据
|
// 当前页显示的课程数据(直接使用从API获取的数据)
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 当前页显示的课程数据
|
|
||||||
const allCourses = computed(() => {
|
const allCourses = computed(() => {
|
||||||
const startIndex = (currentPage.value - 1) * itemsPerPage
|
return courses.value
|
||||||
const endIndex = startIndex + itemsPerPage
|
|
||||||
return filteredCourses.value.slice(startIndex, endIndex)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取课程标题的函数
|
// 获取课程标题的函数
|
||||||
const getCourseTitle = (course: any) => {
|
const getCourseTitle = (course: Course) => {
|
||||||
const titles = [
|
return course.title
|
||||||
'Python语言基础与应用',
|
|
||||||
'PPT课件的设计与制作基础',
|
|
||||||
'暑期名师带学,提高班级数学学科!高效冲分指南',
|
|
||||||
'机器学习算法实战训练营',
|
|
||||||
'数据分析与可视化进阶',
|
|
||||||
'前端开发技术栈全解析',
|
|
||||||
'人工智能基础理论与实践',
|
|
||||||
'计算机网络原理与应用'
|
|
||||||
]
|
|
||||||
return titles[(course.id - 1) % titles.length] || '暑期名师带学,提高班级数学学科!高效冲分指南'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到课程详情页
|
// 跳转到课程详情页
|
||||||
const goToCourseDetail = (course: any) => {
|
const goToCourseDetail = (course: Course) => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'CourseDetail',
|
name: 'CourseDetail',
|
||||||
params: { id: course.id }
|
params: { id: course.id }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 组件挂载时加载数据
|
||||||
|
onMounted(() => {
|
||||||
|
loadCourses()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -594,6 +537,19 @@ const goToCourseDetail = (course: any) => {
|
|||||||
font-size: 14px;
|
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 {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -767,8 +723,9 @@ const goToCourseDetail = (course: any) => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-time {
|
.course-price {
|
||||||
color: #666;
|
color: #ff4d4f;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-stats {
|
.course-stats {
|
||||||
|
@ -77,7 +77,7 @@
|
|||||||
<h3 class="course-title">{{ course.title }}</h3>
|
<h3 class="course-title">{{ course.title }}</h3>
|
||||||
<div class="course-meta">
|
<div class="course-meta">
|
||||||
<span class="course-students">{{ course.studentsCount }}{{ t('home.popularCourses.studentsEnrolled') }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -203,17 +203,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 登录模态框 -->
|
||||||
|
<LoginModal
|
||||||
|
v-model:show="loginModalVisible"
|
||||||
|
@success="handleAuthSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 注册模态框 -->
|
||||||
|
<RegisterModal
|
||||||
|
v-model:show="registerModalVisible"
|
||||||
|
@success="handleAuthSuccess"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, computed } from 'vue'
|
import { onMounted, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useCourseStore } from '@/stores/course'
|
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 { t, locale } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
const courseStore = useCourseStore()
|
const courseStore = useCourseStore()
|
||||||
|
const { loginModalVisible, registerModalVisible, enrollCourse, handleAuthSuccess } = useAuth()
|
||||||
|
|
||||||
// 轮播图根据语言动态切换
|
// 轮播图根据语言动态切换
|
||||||
const bannerImage = computed(() => {
|
const bannerImage = computed(() => {
|
||||||
@ -360,6 +377,12 @@ const featuredReviews = computed(() => [
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 处理课程报名 - 跳转到课程详情页面
|
||||||
|
const handleEnrollCourse = (courseId: number) => {
|
||||||
|
// 跳转到课程详情页面,在那里进行登录状态判断和报名
|
||||||
|
router.push(`/course/${courseId}`)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await courseStore.fetchCourses()
|
await courseStore.fetchCourses()
|
||||||
})
|
})
|
||||||
|
@ -101,7 +101,12 @@
|
|||||||
|
|
||||||
<!-- 侧边图片 -->
|
<!-- 侧边图片 -->
|
||||||
<div class="login-image">
|
<div class="login-image">
|
||||||
<img src="https://via.placeholder.com/600x800" alt="登录" />
|
<PlaceholderImage
|
||||||
|
:width="600"
|
||||||
|
:height="800"
|
||||||
|
text="登录背景图"
|
||||||
|
icon="🎨"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -112,6 +117,7 @@ import { ref, reactive } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useMessage, type FormInst, type FormRules } from 'naive-ui'
|
import { useMessage, type FormInst, type FormRules } from 'naive-ui'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import PlaceholderImage from '@/components/common/PlaceholderImage.vue'
|
||||||
import {
|
import {
|
||||||
MailOutline,
|
MailOutline,
|
||||||
LockClosedOutline,
|
LockClosedOutline,
|
||||||
@ -119,6 +125,7 @@ import {
|
|||||||
LogoGoogle,
|
LogoGoogle,
|
||||||
LogoWechat
|
LogoWechat
|
||||||
} from '@vicons/ionicons5'
|
} from '@vicons/ionicons5'
|
||||||
|
import { AuthApi } from '@/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@ -154,8 +161,8 @@ const rules: FormRules = {
|
|||||||
trigger: ['input', 'blur']
|
trigger: ['input', 'blur']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
min: 6,
|
min: 3,
|
||||||
message: '密码长度不能少于6位',
|
message: '密码长度不能少于3位',
|
||||||
trigger: ['input', 'blur']
|
trigger: ['input', 'blur']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -168,21 +175,55 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
|
|
||||||
const result = await userStore.login({
|
// 显示加载状态
|
||||||
|
userStore.isLoading = true
|
||||||
|
|
||||||
|
// 调用登录API
|
||||||
|
const response = await AuthApi.login({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password
|
password: formData.password
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (response.code === 200) {
|
||||||
message.success(result.message)
|
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
|
const redirect = router.currentRoute.value.query.redirect as string
|
||||||
router.push(redirect || '/')
|
router.push(redirect || '/')
|
||||||
} else {
|
} else {
|
||||||
message.error(result.message)
|
message.error(response.message || '登录失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('表单验证失败:', error)
|
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>
|
</script>
|
||||||
|
@ -25,10 +25,10 @@
|
|||||||
>
|
>
|
||||||
<n-form-item label="头像">
|
<n-form-item label="头像">
|
||||||
<div class="avatar-section">
|
<div class="avatar-section">
|
||||||
<n-avatar
|
<SafeAvatar
|
||||||
:src="userStore.user?.avatar"
|
:src="userStore.user?.avatar"
|
||||||
:fallback-src="'https://via.placeholder.com/100'"
|
:name="userStore.user?.username"
|
||||||
size="large"
|
:size="100"
|
||||||
/>
|
/>
|
||||||
<n-button size="small" @click="handleAvatarUpload">
|
<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 { useUserStore } from '@/stores/user'
|
||||||
import { useCourseStore } from '@/stores/course'
|
import { useCourseStore } from '@/stores/course'
|
||||||
import CourseCard from '@/components/course/CourseCard.vue'
|
import CourseCard from '@/components/course/CourseCard.vue'
|
||||||
|
import SafeAvatar from '@/components/common/SafeAvatar.vue'
|
||||||
import {
|
import {
|
||||||
PersonOutline,
|
PersonOutline,
|
||||||
BookOutline,
|
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