源代码:https://github.com/aelassas/movinin
演示:https://movinin.dynv6.net:3004
这个想法源于建立无边界的愿望 - 一个完全可定制和可操作的房地产租赁平台,其中每个方面都在您的控制之下:
这是使其成为可能的技术堆栈:
由于 TypeScript 具有众多优点,因此做出了使用 TypeScript 的关键设计决定。 TypeScript 提供强大的类型、工具和集成,从而产生高质量、可扩展、更具可读性和可维护性的代码,并且易于调试和测试。
我选择React是因为它强大的渲染能力,MongoDB是为了灵活的数据建模,而Stripe是为了安全的支付处理。
通过选择此堆栈,您不仅仅是在构建网站和移动应用程序 - 您正在投资一个可以根据您的需求不断发展的基础,并得到强大的开源技术和不断发展的开发者社区的支持。
在本部分中,您将看到前端、管理仪表板和移动应用程序的主页。
在前端,客户可以搜索可用房产、选择房产并结账。
下面是前端的主页,客户可以在其中输入位置点和时间,并搜索可用的属性。
以下是主页的搜索结果,客户可以在其中选择出租房产。
以下是客户可以查看房产详情的页面:
以下是该物业的图片视图:
下面是结帐页面,客户可以在其中设置租赁选项和结帐。如果顾客未注册,可以同时结账和注册。如果他尚未注册,他将收到一封确认和激活电子邮件以设置密码。
以下是登录页面。在生产中,身份验证 cookie 是 httpOnly、签名的、安全且严格的 sameSite。这些选项可防止 XSS、CSRF 和 MITM 攻击。身份验证 cookie 也可以通过自定义中间件免受 XST 攻击。
以下是注册页面。
下面是客户可以查看和管理他的预订的页面。
以下是客户可以查看预订详细信息的页面。
下面是客户可以看到他的通知的页面。
下面是客户可以管理其设置的页面。
以下是客户可以更改密码的页面。
就是这样。这是前端的主要页面。
三类用户:
该平台旨在与多个机构合作。每个机构都可以通过管理仪表板管理其财产、客户和预订。该平台也可以只与一个机构合作。
管理员可以从后端创建和管理代理机构、酒店、地点、客户和预订。
创建新代理机构时,他们会收到一封电子邮件,提示他们创建帐户以访问管理仪表板,以便他们可以管理其财产、客户和预订。
下面是管理仪表板的登录页面。
下面是仪表板页面,管理员和代理机构可以在其中查看和管理预订。
如果预订状态发生变化,相关客户将收到通知和电子邮件。
下面是显示和管理属性的页面。
下面是管理员和机构可以通过提供图像和房产信息来创建新房产的页面。如需免费取消,请将其设置为 0。否则,请设置该选项的价格,或者如果您不想包含它,则将其留空。
下面是管理员和机构可以编辑属性的页面。
以下是管理员可以管理客户的页面。
如果代理机构想要从管理仪表板创建预订,下面是创建预订的页面。否则,当从前端或移动应用程序完成结帐过程时,会自动创建预订。
以下是编辑预订的页面。
以下是管理代理机构的页面。
以下是创建新代理机构的页面。
以下是编辑机构的页面。
以下是查看代理机构属性的页面。
以下是查看客户预订的页面。
下面是管理员和机构可以管理其设置的页面。
还有其他页面,但这些是管理仪表板的主页。
就是这样。这是管理仪表板的主要页面。
API 公开了管理仪表板、前端和移动应用程序所需的所有功能。 API遵循MVC设计模式。 JWT 用于身份验证。有些功能需要身份验证,例如与管理属性、预订和客户相关的功能,而其他功能则不需要身份验证,例如检索未经身份验证的用户的位置和可用属性:
index.ts 是 API 的主要入口点:
import 'dotenv/config' import process from 'node:process' import fs from 'node:fs/promises' import http from 'node:http' import https, { ServerOptions } from 'node:https' import app from './app' import * as databaseHelper from './common/databaseHelper' import * as env from './config/env.config' import * as logger from './common/logger' if ( await databaseHelper.connect(env.DB_URI, env.DB_SSL, env.DB_DEBUG) && await databaseHelper.initialize() ) { let server: http.Server | https.Server if (env.HTTPS) { https.globalAgent.maxSockets = Number.POSITIVE_INFINITY const privateKey = await fs.readFile(env.PRIVATE_KEY, 'utf8') const certificate = await fs.readFile(env.CERTIFICATE, 'utf8') const credentials: ServerOptions = { key: privateKey, cert: certificate } server = https.createServer(credentials, app) server.listen(env.PORT, () => { logger.info('HTTPS server is running on Port', env.PORT) }) } else { server = app.listen(env.PORT, () => { logger.info('HTTP server is running on Port', env.PORT) }) } const close = () => { logger.info('Gracefully stopping...') server.close(async () => { logger.info(`HTTP${env.HTTPS ? 'S' : ''} server closed`) await databaseHelper.close(true) logger.info('MongoDB connection closed') process.exit(0) }) } ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => process.on(signal, close)) }
这是一个使用 Node.js 和 Express 启动服务器的 TypeScript 文件。它导入了多个模块,包括 dotenv、process、fs、http、https、mongoose 和 app。然后,它检查 HTTPS 环境变量是否设置为 true,如果是,则使用 https 模块以及提供的私钥和证书创建 HTTPS 服务器。否则,它使用 http 模块创建一个 HTTP 服务器。服务器监听 PORT 环境变量中指定的端口。
close 函数被定义为在收到终止信号时优雅地停止服务器。它关闭服务器和 MongoDB 连接,然后以状态码 0 退出进程。最后,它注册当进程收到 SIGINT、SIGTERM 或 SIGQUIT 信号时要调用的 close 函数。
app.ts 是 api 的主要入口点:
import express from 'express' import compression from 'compression' import helmet from 'helmet' import nocache from 'nocache' import cookieParser from 'cookie-parser' import i18n from './lang/i18n' import * as env from './config/env.config' import cors from './middlewares/cors' import allowedMethods from './middlewares/allowedMethods' import agencyRoutes from './routes/agencyRoutes' import bookingRoutes from './routes/bookingRoutes' import locationRoutes from './routes/locationRoutes' import notificationRoutes from './routes/notificationRoutes' import propertyRoutes from './routes/propertyRoutes' import userRoutes from './routes/userRoutes' import stripeRoutes from './routes/stripeRoutes' import countryRoutes from './routes/countryRoutes' import * as helper from './common/helper' const app = express() app.use(helmet.contentSecurityPolicy()) app.use(helmet.dnsPrefetchControl()) app.use(helmet.crossOriginEmbedderPolicy()) app.use(helmet.frameguard()) app.use(helmet.hidePoweredBy()) app.use(helmet.hsts()) app.use(helmet.ieNoOpen()) app.use(helmet.noSniff()) app.use(helmet.permittedCrossDomainPolicies()) app.use(helmet.referrerPolicy()) app.use(helmet.xssFilter()) app.use(helmet.originAgentCluster()) app.use(helmet.crossOriginResourcePolicy({ policy: 'cross-origin' })) app.use(helmet.crossOriginOpenerPolicy()) app.use(nocache()) app.use(compression({ threshold: 0 })) app.use(express.urlencoded({ limit: '50mb', extended: true })) app.use(express.json({ limit: '50mb' })) app.use(cors()) app.options('*', cors()) app.use(cookieParser(env.COOKIE_SECRET)) app.use(allowedMethods) app.use('/', agencyRoutes) app.use('/', bookingRoutes) app.use('/', locationRoutes) app.use('/', notificationRoutes) app.use('/', propertyRoutes) app.use('/', userRoutes) app.use('/', stripeRoutes) app.use('/', countryRoutes) i18n.locale = env.DEFAULT_LANGUAGE helper.mkdir(env.CDN_USERS) helper.mkdir(env.CDN_TEMP_USERS) helper.mkdir(env.CDN_PROPERTIES) helper.mkdir(env.CDN_TEMP_PROPERTIES) helper.mkdir(env.CDN_LOCATIONS) helper.mkdir(env.CDN_TEMP_LOCATIONS) export default app
首先,我们检索 MongoDB 连接字符串,然后与 MongoDB 数据库建立连接。然后我们创建一个 Express 应用并加载 cors、compression、helmet 和 nocache 等中间件。我们使用头盔中间件库设置了各种安全措施。我们还为应用程序的不同部分导入各种路由文件,例如:supplierRoutes、bookingRoutes、locationRoutes、notificationRoutes、propertyRoutes 和 userRoutes。最后,我们加载 Express 路线并导出应用程序。
API中有8条路由。每条路线都有自己的控制器,遵循 MVC 设计模式和 SOLID 原则。主要路线如下:
我们不会一一解释每条路线。例如,我们将以 propertyRoutes 为例,看看它是如何制作的。您可以浏览源代码并查看所有路由。
这是 propertyRoutes.ts:
import express from 'express' import multer from 'multer' import routeNames from '../config/propertyRoutes.config' import authJwt from '../middlewares/authJwt' import * as propertyController from '../controllers/propertyController' const routes = express.Router() routes.route(routeNames.create).post(authJwt.verifyToken, propertyController.create) routes.route(routeNames.update).put(authJwt.verifyToken, propertyController.update) routes.route(routeNames.checkProperty).get(authJwt.verifyToken, propertyController.checkProperty) routes.route(routeNames.delete).delete(authJwt.verifyToken, propertyController.deleteProperty) routes.route(routeNames.uploadImage).post([authJwt.verifyToken, multer({ storage: multer.memoryStorage() }).single('image')], propertyController.uploadImage) routes.route(routeNames.deleteImage).post(authJwt.verifyToken, propertyController.deleteImage) routes.route(routeNames.deleteTempImage).post(authJwt.verifyToken, propertyController.deleteTempImage) routes.route(routeNames.getProperty).get(propertyController.getProperty) routes.route(routeNames.getProperties).post(authJwt.verifyToken, propertyController.getProperties) routes.route(routeNames.getBookingProperties).post(authJwt.verifyToken, propertyController.getBookingProperties) routes.route(routeNames.getFrontendProperties).post(propertyController.getFrontendProperties) export default routes
首先,我们创建一个 Express Router。然后,我们使用名称、方法、中间件和控制器创建路由。
routeNames 包含 propertyRoutes 路由名称:
const routes = { create: '/api/create-property', update: '/api/update-property', delete: '/api/delete-property/:id', uploadImage: '/api/upload-property-image', deleteTempImage: '/api/delete-temp-property-image/:fileName', deleteImage: '/api/delete-property-image/:property/:image', getProperty: '/api/property/:id/:language', getProperties: '/api/properties/:page/:size', getBookingProperties: '/api/booking-properties/:page/:size', getFrontendProperties: '/api/frontend-properties/:page/:size', checkProperty: '/api/check-property/:id', } export default routes
propertyController 包含有关位置的主要业务逻辑。我们不会看到控制器的所有源代码,因为它相当大,但我们将以创建控制器函数为例。
以下是房产模型:
import { Schema, model } from 'mongoose' import * as movininTypes from ':movinin-types' import * as env from '../config/env.config' const propertySchema = new Schema<env.Property>( { name: { type: String, required: [true, "can't be blank"], }, type: { type: String, enum: [ movininTypes.PropertyType.House, movininTypes.PropertyType.Apartment, movininTypes.PropertyType.Townhouse, movininTypes.PropertyType.Plot, movininTypes.PropertyType.Farm, movininTypes.PropertyType.Commercial, movininTypes.PropertyType.Industrial, ], required: [true, "can't be blank"], }, agency: { type: Schema.Types.ObjectId, required: [true, "can't be blank"], ref: 'User', index: true, }, description: { type: String, required: [true, "can't be blank"], }, available: { type: Boolean, default: true, }, image: { type: String, }, images: { type: [String], }, bedrooms: { type: Number, required: [true, "can't be blank"], validate: { validator: Number.isInteger, message: '{VALUE} is not an integer value', }, }, bathrooms: { type: Number, required: [true, "can't be blank"], validate: { validator: Number.isInteger, message: '{VALUE} is not an integer value', }, }, kitchens: { type: Number, default: 1, validate: { validator: Number.isInteger, message: '{VALUE} is not an integer value', }, }, parkingSpaces: { type: Number, default: 0, validate: { validator: Number.isInteger, message: '{VALUE} is not an integer value', }, }, size: { type: Number, }, petsAllowed: { type: Boolean, required: [true, "can't be blank"], }, furnished: { type: Boolean, required: [true, "can't be blank"], }, minimumAge: { type: Number, required: [true, "can't be blank"], min: env.MINIMUM_AGE, max: 99, }, location: { type: Schema.Types.ObjectId, ref: 'Location', required: [true, "can't be blank"], }, address: { type: String, }, price: { type: Number, required: [true, "can't be blank"], }, hidden: { type: Boolean, default: false, }, cancellation: { type: Number, default: 0, }, aircon: { type: Boolean, default: false, }, rentalTerm: { type: String, enum: [ movininTypes.RentalTerm.Monthly, movininTypes.RentalTerm.Weekly, movininTypes.RentalTerm.Daily, movininTypes.RentalTerm.Yearly, ], required: [true, "can't be blank"], }, }, { timestamps: true, strict: true, collection: 'Property', }, ) const Property = model<env.Property>('Property', propertySchema) export default Property
以下是房产类型:
export interface Property extends Document { name: string type: movininTypes.PropertyType agency: Types.ObjectId description: string image: string images?: string[] bedrooms: number bathrooms: number kitchens?: number parkingSpaces?: number, size?: number petsAllowed: boolean furnished: boolean minimumAge: number location: Types.ObjectId address?: string price: number hidden?: boolean cancellation?: number aircon?: boolean available?: boolean rentalTerm: movininTypes.RentalTerm }
属性由以下部分组成:
下面是创建控制器函数:
import 'dotenv/config' import process from 'node:process' import fs from 'node:fs/promises' import http from 'node:http' import https, { ServerOptions } from 'node:https' import app from './app' import * as databaseHelper from './common/databaseHelper' import * as env from './config/env.config' import * as logger from './common/logger' if ( await databaseHelper.connect(env.DB_URI, env.DB_SSL, env.DB_DEBUG) && await databaseHelper.initialize() ) { let server: http.Server | https.Server if (env.HTTPS) { https.globalAgent.maxSockets = Number.POSITIVE_INFINITY const privateKey = await fs.readFile(env.PRIVATE_KEY, 'utf8') const certificate = await fs.readFile(env.CERTIFICATE, 'utf8') const credentials: ServerOptions = { key: privateKey, cert: certificate } server = https.createServer(credentials, app) server.listen(env.PORT, () => { logger.info('HTTPS server is running on Port', env.PORT) }) } else { server = app.listen(env.PORT, () => { logger.info('HTTP server is running on Port', env.PORT) }) } const close = () => { logger.info('Gracefully stopping...') server.close(async () => { logger.info(`HTTP${env.HTTPS ? 'S' : ''} server closed`) await databaseHelper.close(true) logger.info('MongoDB connection closed') process.exit(0) }) } ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => process.on(signal, close)) }
前端是一个使用 Node.js、React、MUI 和 TypeScript 构建的 Web 应用程序。在前端,客户可以根据接送点和时间搜索可用的汽车,选择汽车并继续结账:
TypeScript 类型定义在包 ./packages/movinin-types 中定义。
App.tsx 是主要的 React 应用程序:
import express from 'express' import compression from 'compression' import helmet from 'helmet' import nocache from 'nocache' import cookieParser from 'cookie-parser' import i18n from './lang/i18n' import * as env from './config/env.config' import cors from './middlewares/cors' import allowedMethods from './middlewares/allowedMethods' import agencyRoutes from './routes/agencyRoutes' import bookingRoutes from './routes/bookingRoutes' import locationRoutes from './routes/locationRoutes' import notificationRoutes from './routes/notificationRoutes' import propertyRoutes from './routes/propertyRoutes' import userRoutes from './routes/userRoutes' import stripeRoutes from './routes/stripeRoutes' import countryRoutes from './routes/countryRoutes' import * as helper from './common/helper' const app = express() app.use(helmet.contentSecurityPolicy()) app.use(helmet.dnsPrefetchControl()) app.use(helmet.crossOriginEmbedderPolicy()) app.use(helmet.frameguard()) app.use(helmet.hidePoweredBy()) app.use(helmet.hsts()) app.use(helmet.ieNoOpen()) app.use(helmet.noSniff()) app.use(helmet.permittedCrossDomainPolicies()) app.use(helmet.referrerPolicy()) app.use(helmet.xssFilter()) app.use(helmet.originAgentCluster()) app.use(helmet.crossOriginResourcePolicy({ policy: 'cross-origin' })) app.use(helmet.crossOriginOpenerPolicy()) app.use(nocache()) app.use(compression({ threshold: 0 })) app.use(express.urlencoded({ limit: '50mb', extended: true })) app.use(express.json({ limit: '50mb' })) app.use(cors()) app.options('*', cors()) app.use(cookieParser(env.COOKIE_SECRET)) app.use(allowedMethods) app.use('/', agencyRoutes) app.use('/', bookingRoutes) app.use('/', locationRoutes) app.use('/', notificationRoutes) app.use('/', propertyRoutes) app.use('/', userRoutes) app.use('/', stripeRoutes) app.use('/', countryRoutes) i18n.locale = env.DEFAULT_LANGUAGE helper.mkdir(env.CDN_USERS) helper.mkdir(env.CDN_TEMP_USERS) helper.mkdir(env.CDN_PROPERTIES) helper.mkdir(env.CDN_TEMP_PROPERTIES) helper.mkdir(env.CDN_LOCATIONS) helper.mkdir(env.CDN_TEMP_LOCATIONS) export default app
我们使用 React 延迟加载来加载每个路由。
我们不会涵盖前端的每一页,但您可以浏览源代码并查看每一页。
该平台提供适用于 Android 和 iOS 的本机移动应用程序。该移动应用程序是使用 React Native、Expo 和 TypeScript 构建的。与前端一样,移动应用程序允许客户根据接送点和时间搜索可用的汽车,选择汽车并继续结账。
如果他的预订从后端更新,客户会收到推送通知。推送通知是使用 Node.js、Expo Server SDK 和 Firebase 构建的。
TypeScript 类型定义定义于:
./mobile/types/ 加载到 ./mobile/tsconfig.json 中,如下所示:
import 'dotenv/config' import process from 'node:process' import fs from 'node:fs/promises' import http from 'node:http' import https, { ServerOptions } from 'node:https' import app from './app' import * as databaseHelper from './common/databaseHelper' import * as env from './config/env.config' import * as logger from './common/logger' if ( await databaseHelper.connect(env.DB_URI, env.DB_SSL, env.DB_DEBUG) && await databaseHelper.initialize() ) { let server: http.Server | https.Server if (env.HTTPS) { https.globalAgent.maxSockets = Number.POSITIVE_INFINITY const privateKey = await fs.readFile(env.PRIVATE_KEY, 'utf8') const certificate = await fs.readFile(env.CERTIFICATE, 'utf8') const credentials: ServerOptions = { key: privateKey, cert: certificate } server = https.createServer(credentials, app) server.listen(env.PORT, () => { logger.info('HTTPS server is running on Port', env.PORT) }) } else { server = app.listen(env.PORT, () => { logger.info('HTTP server is running on Port', env.PORT) }) } const close = () => { logger.info('Gracefully stopping...') server.close(async () => { logger.info(`HTTP${env.HTTPS ? 'S' : ''} server closed`) await databaseHelper.close(true) logger.info('MongoDB connection closed') process.exit(0) }) } ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => process.on(signal, close)) }
App.tsx 是 React Native 应用程序的主要入口点:
导入'react-native-gesture-handler' 从 'react' 导入 React, { useCallback, useEffect, useRef, useState } 从 'react-native-root-siblings' 导入 { RootSiblingParent } 从'@react-navigation/native'导入{NavigationContainer,NavigationContainerRef} 从“expo-status-bar”导入 { StatusBar as ExpoStatusBar } 从 'react-native-safe-area-context' 导入 { SafeAreaProvider } 从“react-native-paper”导入{Provider} 从“expo-splash-screen”导入 * as SplashScreen 导入 * 作为来自“expo-notifications”的通知 从 '@stripe/stripe-react-native' 导入 { StripeProvider } 从 './components/DrawerNavigator' 导入 DrawerNavigator 从 './common/helper' 导入 * 作为助手 从'./services/NotificationService'导入*作为NotificationService 从 './services/UserService' 导入 * 作为 UserService 从 './context/GlobalContext' 导入 { GlobalProvider } 从 './config/env.config' 导入 * 作为 env 通知.setNotificationHandler({ handleNotification: async() =>; ({ 应该显示警报:真, 应该播放声音:真, 应该设置徽章:真, }), }) // // 防止本机启动画面在应用程序组件声明之前自动隐藏 // SplashScreen.preventAutoHideAsync() .then((结果) => console.log(`SplashScreen.preventAutoHideAsync() 成功:${result}`)) .catch(console.warn) // 最好显式捕获并检查任何错误 const App = () =>; { const [appIsReady, setAppIsReady] = useState(false) const responseListener = useRef<notifications.subscription>() const navigationRef = useRef<navigationcontainerref>>(null) useEffect(() => { const 寄存器 = async() => { const LoggedIn = 等待 UserService.loggedIn() 如果(登录){ const currentUser = 等待 UserService.getCurrentUser() if (当前用户?._id) { 等待 helper.registerPushToken(currentUser._id) } 别的 { helper.error() } } } // // 注册推送通知令牌 // 登记() // // 每当用户点击通知或与通知交互时就会触发此侦听器(当应用程序处于前台、后台或终止时有效) // responseListener.current = Notifications.addNotificationResponseReceivedListener(async (response) => { 尝试 { 如果(navigationRef.current){ const { 数据 } = 响应.通知.请求.内容 如果(数据.预订){ if (data.user && data.notification) { 等待NotificationService.markAsRead(data.user, [data.notification]) } navigationRef.current.navigate('预订', { id: data.booking }) } 别的 { navigationRef.current.navigate('通知', {}) } } } 捕获(错误){ helper.error(错误,错误) } }) 返回() => { Notifications.removeNotificationSubscription(responseListener.current!) } }, []) setTimeout(() => { 设置应用程序已就绪(true) }, 500) const onReady = useCallback(async () => { 如果(应用程序已就绪){ // // 这告诉启动屏幕立即隐藏!如果我们之后调用这个 // `setAppIsReady`,那么当应用程序运行时我们可能会看到一个空白屏幕 // 加载其初始状态并渲染其第一个像素。所以相反, // 一旦我们知道根视图已经隐藏了启动屏幕 // 执行布局。 // 等待 SplashScreen.hideAsync() } }, [应用程序已就绪]) 如果(!appIsReady){ 返回空值 } 返回 ( <全球供应商> <提供商> <stripeproviderpublishablekey>; <rootsiblingparent> <NavigationContainer ref={navigationRef} onReady={onReady}> <p>我们不会涵盖移动应用程序的每个屏幕,但您可以浏览源代码并查看每个屏幕。</p> <h2> 管理仪表板 </h2> <p>管理仪表板是一个使用 Node.js、React、MUI 和 TypeScript 构建的 Web 应用程序。管理员可以从后端创建和管理供应商、汽车、位置、客户和预订。当从后端创建新的供应商时,他们将收到一封电子邮件,提示他们创建一个帐户,以便访问管理仪表板并管理他们的车队和预订。</p> <ul> <li>./backend/assets/ 文件夹包含 CSS 和图像。</li> <li>./backend/pages/ 文件夹包含 React 页面。</li> <li>./backend/components/ 文件夹包含 React 组件。</li> <li>./backend/services/ 包含 api 客户端服务。</li> <li>./backend/App.tsx 是包含路由的主要 React 应用程序。</li> <li>./backend/index.tsx 是管理仪表板的主要入口点。</li> </ul> <p>TypeScript 类型定义在包 ./packages/movinin-types 中定义。</p> <p>管理仪表板的 App.tsx 遵循与前端的 App.tsx 类似的逻辑。</p> <p>我们不会涵盖管理仪表板的每一页,但您可以浏览源代码并查看每一页。</p> <h2> 兴趣点 </h2> <p>使用 React Native 和 Expo 构建移动应用程序非常简单。 Expo 让使用 React Native 进行移动开发变得非常简单。</p> <p>后端、前端和移动端开发使用同一种语言(TypeScript)非常方便。</p> <p>TypeScript 是一门非常有趣的语言,并且有很多优点。通过向 JavaScript 添加静态类型,我们可以避免许多错误并生成高质量、可扩展、更具可读性和可维护性的代码,并且易于调试和测试。</p> <p>就是这样!我希望您喜欢阅读这篇文章。</p> <h2> 资源 </h2> <ol> <li>概述</li> <li>建筑</li> <li>安装(自托管)</li> <li>安装(VPS)</li> <li> 安装(Docker) <ol> <li>Docker 镜像</li> <li>SSL</li> </ol> </li> <li>设置条纹</li> <li>构建移动应用程序</li> <li> 演示数据库 <ol> <li>Windows、Linux 和 macOS</li> <li>码头工人</li> </ol> </li> <li>从源头运行</li> <li> 运行移动应用程序 <ol> <li>先决条件</li> <li>使用说明</li> <li>推送通知</li> </ol> </li> <li>更改货币</li> <li>添加新语言</li> <li>单元测试和覆盖率</li> <li>日志</li> </ol> </rootsiblingparent></stripeproviderpublishablekey></navigationcontainerref></notifications.subscription>
以上是从零到店面:我的房产租赁平台搭建之旅的详细内容。更多信息请关注PHP中文网其他相关文章!