diff --git a/package.json b/package.json index 9a3cacb..ed16a6b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "家长学情是基于 uni-app x Vapor 模式开发的移动端应用。当前实现家长账号的注册、密码登录、验证码登录和密码重置流程。", "main": "index.js", "scripts": { + "serve:web": "node serve-web.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/screenshots/study-report-modules.png b/screenshots/study-report-modules.png deleted file mode 100644 index ad4ac16..0000000 Binary files a/screenshots/study-report-modules.png and /dev/null differ diff --git a/screenshots/vocabulary.png b/screenshots/vocabulary.png deleted file mode 100644 index 3eb39fe..0000000 Binary files a/screenshots/vocabulary.png and /dev/null differ diff --git a/serve-web.js b/serve-web.js new file mode 100644 index 0000000..ba15357 --- /dev/null +++ b/serve-web.js @@ -0,0 +1,157 @@ +const http = require('http') +const fs = require('fs') +const path = require('path') +const { URL } = require('url') + +const projectRoot = __dirname +const defaultWebRoot = path.join(projectRoot, 'unpackage', 'dist', 'build', 'web') +const defaultHost = '127.0.0.1' +const defaultPort = 5500 + +const mimeTypes = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2' +} + +function getOption(name, fallback) { + const prefix = `${name}=` + const argument = process.argv.find((item) => item.startsWith(prefix)) + return argument ? argument.slice(prefix.length) : fallback +} + +function getPort() { + const value = getOption('--port', process.env.PORT || String(defaultPort)) + const port = Number(value) + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`端口无效:${value}`) + } + + return port +} + +function getWebRoot() { + const configuredRoot = getOption('--dir', defaultWebRoot) + return path.resolve(projectRoot, configuredRoot) +} + +function isInsideRoot(root, target) { + const relativePath = path.relative(root, target) + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) +} + +function sendText(response, statusCode, message) { + response.writeHead(statusCode, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store' + }) + response.end(message) +} + +function sendFile(response, filePath) { + const extension = path.extname(filePath).toLowerCase() + const contentType = mimeTypes[extension] || 'application/octet-stream' + + response.writeHead(200, { + 'Content-Type': contentType, + 'Cache-Control': extension === '.html' ? 'no-cache' : 'public, max-age=3600' + }) + fs.createReadStream(filePath).pipe(response) +} + +function tryServeFile(response, webRoot, pathname) { + const decodedPath = decodeURIComponent(pathname) + const relativePath = decodedPath === '/' ? 'index.html' : decodedPath.slice(1) + const filePath = path.resolve(webRoot, relativePath) + + if (!isInsideRoot(webRoot, filePath)) { + sendText(response, 403, 'Forbidden') + return true + } + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return false + } + + sendFile(response, filePath) + return true +} + +function createServer(webRoot) { + return http.createServer((request, response) => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + sendText(response, 405, 'Method Not Allowed') + return + } + + let requestUrl + try { + requestUrl = new URL(request.url, 'http://localhost') + } catch { + sendText(response, 400, 'Bad Request') + return + } + + try { + if (tryServeFile(response, webRoot, requestUrl.pathname)) { + return + } + + // Web 发行项目使用前端路由时,未知路径回退到入口页面。 + if (!path.extname(requestUrl.pathname) && tryServeFile(response, webRoot, '/index.html')) { + return + } + + sendText(response, 404, 'Not Found') + } catch (error) { + console.error(`[serve-web] ${error.message}`) + sendText(response, 500, 'Internal Server Error') + } + }) +} + +function printUsage() { + console.log('用法:node serve-web.js [--port=5500] [--host=127.0.0.1] [--dir=unpackage/dist/build/web]') +} + +function start() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + printUsage() + return + } + + const webRoot = getWebRoot() + const host = getOption('--host', process.env.HOST || defaultHost) + const port = getPort() + + if (!fs.existsSync(webRoot)) { + console.error(`未找到 Web 文件目录:${webRoot}`) + console.error('请先在 HBuilderX 中发行 Web,或使用 --dir 指定已打包的目录。') + process.exitCode = 1 + return + } + + const server = createServer(webRoot) + server.listen(port, host, () => { + console.log(`Web 文件目录:${webRoot}`) + console.log(`本机访问:http://localhost:${port}`) + console.log(`局域网访问:http://<本机IP>:${port}`) + console.log('按 Ctrl+C 停止服务器') + }) +} + +start()