资讯动态

Python+Django驾校线上学习系统开发实战:约课、学时与刷题全解析

发布时间:2026/10/21 3:17:45 来源:尧图企业网站定制
前阵子我把一个驾校线上学习管理系统从需求到部署完整做了一遍技术栈就是标题里明确写到的 Python Django。做完以后我最大的感受是这类项目真正难的地方不是某个页面写不出来而是约课、学时、刷题这三件事怎么串成一条能自洽的数据链路。这篇文章从需求边界讲到数据模型再到核心功能代码、部署方案以及我在开发过程中踩过的四个具体坑完整复盘一遍。想拿类似题目做毕业设计、课程设计或者想把一个成体系Web项目写进简历的都可以直接对照参考。1. 需求边界驾校要的线上学习系统核心就三件事1.1 约课、学时、刷题为什么必须放进同一个系统接手这个项目之前我是先站在驾校运营者的角度把业务流程捋了一遍的。传统驾校的信息管理普遍存在三个痛点第一学员约实操课靠电话或微信群教练时间被切成碎片撞车、漏记时有发生第二学时统计依赖教练手工填表学员练了多久、科目二练够没有往往月底才对账第三科目一和科目四的理论学习基本靠学员自己刷题驾校无法掌握真实学习进度。超能驾校线上学习管理系统要解决的就是把这三个痛点统一收口。学员在系统里约课约课记录自动关联教练、时间、场地学员观看线上理论视频后台按秒记录学习时长学员在线刷题系统留存每一次练习和模拟考试成绩。教练和管理员看到的是同一份数据不再需要多方核对。标题里这几个关键词拆开看“线上学习”对应的是视频课程、刷题、模拟考试这是给理论培训用的“管理系统”对应的是用户、课程、预约、学时统计这四类基础数据。这几块看似各自独立实际上都在一个闭环里报名之后先学理论理论够了才能约实操实操学时满了才有资格去预约正式考试。1.2 三种角色和完整业务流理清角色是这类系统的第一步我在设计里定了三种用户类型管理员、学员、教练。教练本身也可以是某种意义上的“课程资源”因为实操课必须绑定一个教练管理员则负责创建课程、审核数据、统计整体学时。角色核心权限典型操作管理员所有权限课程管理、学员管理、教练管理、学时审核、后台数据查看学员线上学习、约课、刷题观看视频、模拟考试、预约实操课、查看个人学时教练查看日程、确认约课查看自己的课时安排、标记学员到场情况业务流大致是管理员录入教练和课程 → 学员注册登录 → 学员选择线上理论课程学习 → 学时达标后预约线下实操课 → 教练在后台确认 → 系统记录实操学时 → 学员学时满后进入模拟考试和正式考试预约环节。这个流程不是一天设计出来的而是把一张纸质业务流程图翻译成了系统功能清单。1.3 功能范围收敛第一版不做哪些做系统最忌讳一上来就什么都想要。我给这个项目做减法时砍掉了三个常见需求在线支付、视频直播、教练评价。原因很明确第一版核心验证的是“约课 学时 刷题”这条主链路支付涉及对账和第三方接口直播对服务器带宽要求高教练评价和主流程没有强关联。这些不是不做而是放在第二期。功能边界清晰以后数据模型和接口设计才能稳下来。下面开始讲技术选型为什么我最终选择了 Django 而不是其他框架。2. 技术选型Python Django 这套组合为什么合适2.1 Django 对比 Flask这一类业务里省了多少事我不是说 Flask 不好但单独说这个项目Django 的优势实在太明显。驾校管理系统这种项目天然需要用户认证、后台管理、数据库迁移、表单处理而这些 Django 全部内置。最省时间的是 Django Admin。默认情况下注册好模型之后后台增删改查页面直接就有甚至不需要写 View。对管理员来说他们不需要接触前端只需要在后台维护教练、课程、学员数据。我在项目里把 User、Course、Enrollment、StudyRecord 这些模型全部注册到了 Admin课程管理的开发成本几乎降为零。另外Django 的 ORM 对复杂查询支持很好。比如学员学时排行可以直接用 annotate Sum 完成不需要手写 SQL。对比下来如果用 Flask用户认证大概率要引入 Flask-Login后台要引入 Flask-Admin数据库迁移要 Flask-Migrate这些组件单独看都不错但组合在一起的版本兼容性维护成本得自己背。2.2 版本和环境搭建我使用的版本组合如下Python 3.10Django 4.2 LTS数据库在开发阶段用 SQLite部署时切到 MySQL 8。选择 Django 4.2 是因为它是 LTS 版本修复周期长网上资料也多。环境搭建过程直接给命令mkdir super_driving_school cd super_driving_school python -m venv venv source venv/bin/activate pip install django4.2.* pip install gunicorn mysqlclient创建项目和应用django-admin startproject config . python manage.py startapp users python manage.py startapp courses python manage.py startapp study python manage.py startapp exams我习惯把项目配置目录叫 config而不是默认的 project 同名目录这样更清晰。应用按业务拆users 管用户、courses 管课程与预约、study 管学习记录、exams 管刷题与考试。2.3 项目目录怎么分 App拆 App 的原则是“业务边界唯一”。很多人喜欢把所有 models 写在一个 app 里短时间没问题但项目一旦超过十张表维护就开始头疼。我这里的划分是users: 自定义用户模型、角色判断装饰器courses: Course、Enrollment、课程列表、约课请求study: StudyRecord、视频学习页、学时统计接口exams: Question、ExamRecord、刷题页面这样划分还有一个好处权限控制的代码集中在 users 里其他 app 只需要导入使用不会出现互相循环引用。如果以后要接入小程序每个 app 都能独立提供接口不用重构。2.4 依赖清单最终 requirements.txt 大概是Django4.2.* gunicorn21.* mysqlclient2.* Pillow10.*Pillow 是用来处理头像和图片上传的如果课程封面也用 ImageField这个库就必须在。生产环境多了一个 gunicorn开发阶段用 runserver 即可。3. 数据模型设计先把业务变成一张张表数据模型是整个系统里最需要耐心的地方。我最初画了六张表后来逐渐调整成八张每次调整都伴随着业务认知的加深。3.1 自定义用户模型Django 自带 User 模型但驾校系统里需要区分学员和教练还要存手机号所以我用 AbstractUser 扩展了它。这里有个重要的经验如果你知道自己必然要扩展用户字段就从一开始设置 AUTH_USER_MODEL不要等项目跑到一半再改。from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): USER_TYPE_CHOICES ( (admin, 管理员), (student, 学员), (coach, 教练), ) user_type models.CharField(用户类型, max_length10, choicesUSER_TYPE_CHOICES, defaultstudent) phone models.CharField(手机号, max_length11, uniqueTrue) avatar models.ImageField(头像, upload_toavatars/, blankTrue, nullTrue) class Meta: verbose_name 用户 verbose_name_plural 用户 def __str__(self): return f{self.username}({self.get_user_type_display()})手机号设置了 unique目的是以后做短信登录时可以直接作为账号。因为设置了自定义用户模型配置里必须加一句AUTH_USER_MODEL users.User这句话必须在第一次 migrate 之前写好否则后面改会因为外键关系变得很麻烦。3.2 课程、预约与教练排课课程表是系统核心。我设计时把线上理论课和线下实操课放在同一张表里通过 course_type 区分。这样做有一个明显好处学员查看“我的课程”时可以同时看到理论课进度和实操预约记录不需要两个页面来回跳。class Course(models.Model): SUBJECT_CHOICES ( (1, 科目一), (2, 科目二), (3, 科目三), (4, 科目四), ) TYPE_CHOICES ( (online, 线上理论课), (practical, 线下实操课), ) name models.CharField(课程名称, max_length100) subject models.PositiveSmallIntegerField(所属科目, choicesSUBJECT_CHOICES) course_type models.CharField(课程类型, max_length10, choicesTYPE_CHOICES) coach models.ForeignKey(User, on_deletemodels.SET_NULL, nullTrue, blankTrue, related_namecoach_courses, verbose_name教练) start_time models.DateTimeField(开始时间, nullTrue, blankTrue) end_time models.DateTimeField(结束时间, nullTrue, blankTrue) video_url models.FileField(视频文件, upload_tocourse_videos/, blankTrue, nullTrue) capacity models.PositiveIntegerField(名额上限, default5) created_at models.DateTimeField(auto_now_addTrue) class Meta: verbose_name 课程 verbose_name_plural 课程线上课程不需要 start_time 和 end_time但实操课必须设置。这里注意教练字段没有做成单独一张教练表而是复用 User 表里的 user_typecoach 用户外键关联到 User 即可。预约表记录学员和课程的关系同时记录状态方便教练确认。class Enrollment(models.Model): STATUS_CHOICES ( (pending, 待确认), (confirmed, 已确认), (cancelled, 已取消), (completed, 已完成), ) student models.ForeignKey(User, on_deletemodels.CASCADE, related_nameenrollments, verbose_name学员) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_nameenrollment_records, verbose_name课程) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultpending) created_at models.DateTimeField(auto_now_addTrue) class Meta: verbose_name 预约记录 verbose_name_plural 预约记录 unique_together (student, course)unique_together 的意思是同一学员不能重复预约同一门课程避免前端重复点击造成脏数据。3.3 学时流水与刷题记录学时记录是驾校最在乎的数据我单独建了一张 StudyRecord 表。每条记录代表学员在某一天对某一门课程产生的学习时长按秒存储方便后续统计成小时。class StudyRecord(models.Model): student models.ForeignKey(User, on_deletemodels.CASCADE, related_namestudy_records, verbose_name学员) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_namestudy_records, verbose_name课程) study_date models.DateField(学习日期) duration_seconds models.IntegerField(学习时长秒, default0) last_position models.IntegerField(上次观看位置秒, default0) updated_at models.DateTimeField(auto_nowTrue) class Meta: verbose_name 学习记录 verbose_name_plural 学习记录 unique_together (student, course, study_date)unique_together 保证了同一学员同一课程同一天只有一条累计记录前端每上报一次心跳就在原基础上累加秒数。这样查询某月总学时就非常快直接按日期过滤再 Sum 即可。刷题部分涉及两张表Question 和 ExamRecord。class Question(models.Model): SUBJECT_CHOICES Course.SUBJECT_CHOICES content models.TextField(题目内容) option_a models.CharField(选项A, max_length200) option_b models.CharField(选项B, max_length200) option_c models.CharField(选项C, max_length200, blankTrue, nullTrue) option_d models.CharField(选项D, max_length200, blankTrue, nullTrue) correct_answer models.CharField(正确答案, max_length1) explanation models.TextField(解析, blankTrue, default) subject models.PositiveSmallIntegerField(所属科目, choicesSUBJECT_CHOICES, default1) class Meta: verbose_name 题库 verbose_name_plural 题库ExamRecord 保存每次模拟考试的结果detail 字段用 JSON 格式记录学员每一题的答题情况方便考后查看错题。class ExamRecord(models.Model): student models.ForeignKey(User, on_deletemodels.CASCADE, related_nameexam_records) subject models.PositiveSmallIntegerField(科目, choicesCourse.SUBJECT_CHOICES) score models.IntegerField(得分) total models.IntegerField(总分) detail models.JSONField(答题详情, defaultlist) created_at models.DateTimeField(auto_now_addTrue)3.4 模型关系总览与迁移顺序简单梳理关系User 和 Course 通过教练外键关联Enrollment 是学员和课程的中间表StudyRecord 关联学员和课程ExamRecord 关联学员和科目。整体是一套很常见的“用户 - 业务”模型没有过度设计。做完模型以后执行迁移要按顺序python manage.py makemigrations users python manage.py makemigrations courses study exams python manage.py migrate先迁移 users 是为了让自定义用户表先建立后续课程表里的外键才能正常指向它。如果一次性 makemigrations 所有 appDjango 通常能自动处理依赖顺序但分步执行能让你更清楚每张表的生成过程排查问题也更快。4. 从约课到学进度四个核心功能模块的实现细节4.1 注册登录和角色权限控制登录注册我没有做花哨的东西直接使用 Django 自带的 authenticate 和 login 方法。注册时要判断 user_type如果是教练注册需要管理员审核学员注册则直接通过。权限控制的重点在于装饰器。我写了一个公共的角色装饰器from functools import wraps from django.shortcuts import redirect def role_required(*roles): def decorator(view_func): wraps(view_func) def wrapper(request, *args, **kwargs): if not request.user.is_authenticated: return redirect(login) if request.user.user_type not in roles: from django.http import HttpResponseForbidden return HttpResponseForbidden(无权访问) return view_func(request, *args, **kwargs) return wrapper return decorator使用方式很简单role_required(admin) def coach_list(request): ...这样学员访问后台管理页面时会被直接拦住不需要在每个视图里重复写 if 判断。Django 自带的 login_required 只能判断是否登录不能判断角色所以这个装饰器是必须的。4.2 在线约课的冲突检测与事务处理约课是并发风险最高的功能。两个学员同时约同一个教练的相邻时段或者同一个学员同时约了两个重叠时段都是需要挡住的情况。冲突检测的核心是两个时间区间是否有交集一个时间段 [A.start, A.end] 与 [B.start, B.end] 有交集的条件是 A.start B.end 且 A.end B.start。放在查询里就是from django.db import transaction from django.shortcuts import get_object_or_404, redirect login_required role_required(student) def reserve_course(request, course_id): course get_object_or_404(Course, pkcourse_id, course_typepractical) with transaction.atomic(): # 锁住教练这一行保证并发请求串行 coach User.objects.select_for_update().get(pkcourse.coach_id) conflict Enrollment.objects.filter( studentrequest.user, status__in[pending, confirmed], course__coachcoach, course__start_time__ltcourse.end_time, course__end_time__gtcourse.start_time, ).exclude(coursecourse).exists() if conflict: return redirect(course_detail, course_idcourse.id, error该时段已与其他课程冲突) Enrollment.objects.create(studentrequest.user, coursecourse, statusconfirmed) return redirect(my_courses)select_for_update 是这里的关键。它锁住教练记录那一行让后面的事务必须等前一个事务提交后再执行。假如没有这个锁同一时刻两个请求都查到“没有冲突”然后都插入成功就出现了重复预约。实际开发中这个坑我踩过后面会专门说。4.3 学时记录用“心跳上报 按天汇总”实现统计线上理论课的学时不能光靠学员自己点“开始学习”“结束学习”因为有人会打开页面就挂着。我的做法是前端播放器每 30 秒向后端发送一次心跳带上当前视频播放位置后端根据当前记录里的 last_position 计算实际增量。后端的接口逻辑如下from datetime import date from django.http import JsonResponse from django.views.decorators.http import require_POST login_required require_POST def heartbeat(request, course_id): course get_object_or_404(Course, pkcourse_id, course_typeonline) position int(request.POST.get(position, 0)) record, created StudyRecord.objects.get_or_create( studentrequest.user, coursecourse, study_datedate.today(), defaults{last_position: position}, ) # 如果新位置比上一次大说明有实际观看动作 if not created and position record.last_position: delta position - record.last_position record.duration_seconds delta record.last_position position record.save() return JsonResponse({status: ok})这里不做复杂的播放行为分析只做最基本的增量累加。每 30 秒上报一次如果用户快进Django 端也能感知到位置跳变但第一版不做拦截。统计页面直接查 Sumfrom django.db.models import Sum from django.db.models.functions import TruncDate def study_summary(request): records (StudyRecord.objects .filter(studentrequest.user) .values(study_date) .annotate(total_secondsSum(duration_seconds)) .order_by(study_date)) return render(request, study/summary.html, {records: records})页面展示时把秒数换算成小时和分钟用模板过滤器或者视图里处理都可以。4.4 刷题、模拟考试与错题收集刷题模块我做了三个入口顺序练习、随机练习、模拟考试。前两个用的是同一个答题页面只是题目筛选条件不同。模拟考试则是从题库里随机抽 50 题提交后判分并保存答题详情。随机抽题的写法import random from django.shortcuts import render def exam_start(request, subject): questions list(Question.objects.filter(subjectsubject)) exam_questions random.sample(questions, min(50, len(questions))) request.session[exam_questions] [q.id for q in exam_questions] request.session[exam_subject] subject return render(request, exams/exam.html, {questions: exam_questions, subject: subject})判分放在提交视图里login_required def exam_submit(request): if request.method ! POST: return redirect(exam_start) question_ids request.session.get(exam_questions, []) answers request.POST score 0 detail [] for qid in question_ids: q Question.objects.get(pkqid) user_answer answers.get(fquestion_{qid}, ) correct user_answer.upper() q.correct_answer if correct: score 2 detail.append({ question_id: q.id, user_answer: user_answer, correct_answer: q.correct_answer, is_correct: correct, explanation: q.explanation, }) ExamRecord.objects.create( studentrequest.user, subjectrequest.session.get(exam_subject, 1), scorescore, totallen(question_ids) * 2, detaildetail, ) return render(request, exams/result.html, {score: score, detail: detail})错题收集我第一版没有单独建表而是从 ExamRecord 的 detail 字段里读取筛选出 is_correct 为 False 的记录。这样做少一张表查询稍微繁琐一点但完全够用。如果题库量大到需要专门复习再做错题表也不迟。4.5 Django Admin 后台定制Django Admin 是这个项目效率最高的部分。管理员维护教练和课程几乎不需要额外写页面。我只是对默认界面做了一些针对性定制from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import User admin.register(User) class CustomUserAdmin(UserAdmin): list_display (username, user_type, phone, is_active) list_filter (user_type, is_active) search_fields (username, phone) fieldsets UserAdmin.fieldsets ( (额外字段, {fields: (user_type, phone, avatar)}), )Enrollment 后台定制了状态筛选管理员能一眼看到哪些预约待确认也可以用 actions 批量确认。Admin 不是摆设它是给驾校工作人员用的必需品。5. 踩坑实录四个坑每一个都值得记下来5.1 学员并发预约出现重单第一次测试时我同时开了两个浏览器请求预约同一门课结果出现两条预约记录。原因就是我前面提到的问题两个请求同时进入视图先各自查了一次冲突发现都无冲突然后同时插入。解决方案就是 select_for_update 锁住教练行让第二次预约必须等第一次提交。如果不想用数据库行锁也可以给 Enrollment 加一个基于课程和学员的唯一约束但这只能防止同一个课程重复预约挡不住两个不同课程的时段冲突。所以正确解法还是事务加锁。这个坑让我明白一个道理凡是有“先查后插”逻辑的地方都要考虑并发场景到底有多少。管理系统虽然不像抢票系统那么极端但同一个时段的课程名额有限冲突数据一旦产生管理员清理成本非常高。5.2 学时统计总是少一天其实是时区问题部署到服务器后学员反馈昨天的学时在统计页面看不到。查数据库发现 StudyRecord 的 study_date 存成了 UTC 日期和本地时间差了 8 小时。晚上 10 点学习时UTC 时间还是当天但本地已经过了午夜记录就存到了“昨天”统计时按本地日期过滤当然查不到。解决办法是在保存 study_date 时统一使用本地时区日期不使用默认的 UTC 日期from django.utils import timezone study_datetimezone.localdate()同时在 settings.py 里保持 USE_TZ TrueTIME_ZONE Asia/Shanghai。Django 的 DateTimeField 存 UTC 没问题但所有面向用户展示的日期和时间都应该转成本地时区。这个坑做任何带学习记录的系统都会遇到越早统一越好。5.3 视频上传后浏览器播放不了学员反馈线上课程的视频打不开。检查后发现不是文件损坏而是视频编码是 MPEG-2大部分浏览器不支持。Django 的 FileField 只负责存文件不负责保证格式能被播放。我的处理是在课程上传说明里明确要求 MP4 格式H.264 编码同时在视频上传视图里做扩展名校验from django.core.validators import FileExtensionValidator class Course(models.Model): ... video_url models.FileField( 视频文件, upload_tocourse_videos/, blankTrue, nullTrue, validators[FileExtensionValidator(allowed_extensions[mp4, webm, ogg])] )这只拦了文件后缀没有真正校验内部编码。更进一步的做法是用 ffprobe 在保存后读一遍视频元数据但第一版没必要。关键是给驾校管理员一个明确的格式指引不然他们随便上传一个手机录屏格式学员那边就会出问题。5.4 后台列表越查越慢N1查询了解一下课程列表页在数据量达到几百条时开始变慢。打开调试工具看到 SQL 执行了上百次原因是在模板里循环遍历 Enrollment每一条都去查一次 student 和 course这就是经典的 N1 查询。Django ORM 解决方案很简单enrollments (Enrollment.objects .filter(statusconfirmed) .select_related(student, course))select_related 会通过 JOIN 把 student 和 course 查出来一次查询解决所有关联数据。如果是多对多外键还需要 prefetch_related。这个优化做之前课程列表页响应时间约 900ms做完之后降到 90ms 左右非常明显。6. 部署上线从本机跑通到服务器正常访问6.1 服务器环境准备部署我用的是 Linux 服务器 Gunicorn Nginx。先创建虚拟环境并安装依赖然后把本地代码同步到服务器目录或者用版本仓库拉取。python -m venv venv source venv/bin/activate pip install -r requirements.txt数据库用 MySQL需要先创建库和用户CREATE DATABASE driving_school DEFAULT CHARACTER SET utf8mb4; CREATE USER django_userlocalhost IDENTIFIED BY 你的密码; GRANT ALL PRIVILEGES ON driving_school.* TO django_userlocalhost;然后在 Django 的 settings.py 里配置 MySQL 连接注意 SECRET_KEY 不要写死在代码里建议放在环境变量或者 .env 文件中读取。6.2 Gunicorn Nginx 配置Gunicorn 作为进程管理工具运行 Django 应用gunicorn config.wsgi:application -b 127.0.0.1:8000 -w 4-w 是 worker 数量通常可以设为 CPU 核心数的两倍。如果服务器只有 2 核开 4 个 worker 就够。生产环境不要用 runserver它的性能和安全性都不够。Nginx 配置反代server { listen 80; server_name your_domain_or_ip; location /static/ { alias /home/super_driving/static/; } location /media/ { alias /home/super_driving/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }静态文件和媒体文件都让 Nginx 直接处理Django 只负责动态数据这样压力最小。6.3 静态文件、媒体文件和域名配置部署前必须先执行 collectstaticpython manage.py collectstatic --noinput这句话会把所有 app 的静态资源收集到 STATIC_ROOT也就是 Nginx alias 指向的目录。媒体文件不用收集管理员上传的课程视频、图片都存在 MEDIA_ROOTNginx 里那一段 media alias 就是指向它的。如果以后启用 HTTPS只需要在 Nginx 里挂上证书然后把 80 端口重定向到 443Django 侧把 SECURE_PROXY_SSL_HEADER 配置好其他逻辑不用改。6.4 上线后的安全检查上线后最重要的是把 settings.py 里的 DEBUG 改成 False以及配置 ALLOWED_HOSTS。我见过不少项目 DEBUG 没改直接上线结果错误信息被完整暴露到前端这是非常危险的事。另外要把数据库的账号密码、SECRET_KEY 都换成高强度随机值不要继续用开发阶段的默认值。登录接口最好加一个简单的访问频率限制比如同一个 IP 一分钟内最多尝试 5 次登录。Django 项目里可以自己写一个基于 Redis 的限流也可以用 Django-cache 的方案第一版用缓存计数就够了。7. 项目做完之后我的迭代建议整个系统跑通以后我手里这份“超能驾校线上学习管理系统”已经可以正常完成注册、约课、学习、刷题、统计学时这条完整链路。如果后续要继续扩展我个人觉得优先级最高的是移动端适配。现在很多教练和学员都用手机访问但课程播放页在手机上体验只能算及格下一步应该做一个响应式优化甚至直接封装一个轻量小程序。另一个可以提升的点是学时真实性校验。当前方案是前端定时上报位置严格来说还是可以被模拟点击绕过。如果要用于正规学时认定需要后端配合视频播放器的防作弊策略比如随机校验当前播放进度、关键节点弹题。最后就是错题复习模块。题库和模拟考试已经有了但错题集合、分类专项训练还没做。对于科目一、科目四这种以刷题为主的学习模式错题本是最能体现“学习效果”的功能。到时候可以基于现有 ExamRecord 的 detail 数据独立生成一张错题关联表让学员真正形成“练习 - 测评 - 复习”的闭环。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑