完善教育
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from . import exam_controller
|
||||
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http, fields
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExamController(http.Controller):
|
||||
"""学生答题卡式考试页面控制器。"""
|
||||
|
||||
def _check_paper_access(self, paper_id, states=None):
|
||||
"""校验当前登录用户是否为该答卷的学员本人。"""
|
||||
paper = request.env['yuthon.school.exam.paper'].sudo().browse(paper_id)
|
||||
if not paper.exists():
|
||||
raise http.NotFound()
|
||||
if paper.user_id.id != request.env.user.id:
|
||||
raise AccessError('您无权访问该答卷。')
|
||||
if states and paper.state not in states:
|
||||
raise UserError('当前答卷状态不允许该操作。')
|
||||
return paper
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 开始考试:初始化答题记录并跳转到答题页面
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/exam/start/<int:paper_id>', type='http', auth='user')
|
||||
def exam_start(self, paper_id, **kw):
|
||||
paper = self._check_paper_access(paper_id, states=('pending', 'in_progress'))
|
||||
try:
|
||||
paper.with_user(request.env.user).action_start()
|
||||
except UserError as e:
|
||||
return request.render('college.exam_error_page', {'message': str(e)})
|
||||
return request.redirect('/exam/page/%s' % paper_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. 答题主页面
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/exam/page/<int:paper_id>', type='http', auth='user', website=False)
|
||||
def exam_page(self, paper_id, **kw):
|
||||
paper = self._check_paper_access(paper_id)
|
||||
if paper.state == 'pending':
|
||||
# 未开始则自动开始
|
||||
try:
|
||||
paper.with_user(request.env.user).action_start()
|
||||
except UserError as e:
|
||||
return request.render('college.exam_error_page', {'message': str(e)})
|
||||
|
||||
if paper.state not in ('in_progress', 'submitted', 'graded'):
|
||||
return request.render('college.exam_error_page', {
|
||||
'message': '该答卷当前状态为 %s,无法进入答题页面。' % dict(paper._fields['state'].selection).get(paper.state)
|
||||
})
|
||||
|
||||
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
|
||||
exam = paper.exam_id
|
||||
|
||||
# 计算剩余时间(秒)
|
||||
remaining_seconds = 0
|
||||
if paper.start_time and exam.duration_minutes:
|
||||
end_dt = paper.start_time + fields.Datetime.timedelta(minutes=exam.duration_minutes)
|
||||
remaining_seconds = max(0, int((end_dt - fields.Datetime.now()).total_seconds()))
|
||||
|
||||
values = {
|
||||
'paper': paper,
|
||||
'exam': exam,
|
||||
'course': paper.course_id,
|
||||
'chapters': paper.chapter_ids,
|
||||
'student': paper.student_id,
|
||||
'answers': answers,
|
||||
'answer_json': json.dumps([self._answer_to_dict(a) for a in answers]),
|
||||
'remaining_seconds': remaining_seconds,
|
||||
'duration_minutes': exam.duration_minutes or 0,
|
||||
'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection),
|
||||
}
|
||||
return request.render('college.exam_take_page', values)
|
||||
|
||||
def _answer_to_dict(self, answer):
|
||||
"""将答题记录转为前端 JSON 可用的字典。"""
|
||||
q = answer.question_id
|
||||
return {
|
||||
'id': answer.id,
|
||||
'sequence': answer.sequence or 0,
|
||||
'question_type': answer.question_type,
|
||||
'question_text': answer.question_text or '',
|
||||
'question_text_short': answer.question_text_short or '',
|
||||
'max_score': answer.max_score,
|
||||
'options': [{
|
||||
'id': opt.id,
|
||||
'label': opt.label,
|
||||
'content': opt.content or '',
|
||||
'text': opt.name or '%s. %s' % (opt.label, opt._strip_html(opt.content or '')),
|
||||
} for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id))],
|
||||
# 当前作答
|
||||
'selected_option_id': answer.selected_option_id.id or False,
|
||||
'selected_option_ids': answer.selected_option_ids.ids,
|
||||
'answer_choice': answer.answer_choice or False,
|
||||
'answer_text': answer.answer_text or '',
|
||||
'is_answered': bool(
|
||||
answer.selected_option_id or
|
||||
answer.selected_option_ids or
|
||||
answer.answer_choice or
|
||||
(answer.answer_text and answer._strip_html(answer.answer_text))
|
||||
),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. AJAX 保存单题答案
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/exam/save', type='json', auth='user', methods=['POST'])
|
||||
def exam_save_answer(self, **kw):
|
||||
# 注意:type='json' 路由的参数在 request.params(JSON-RPC 内层 params),
|
||||
# 而非 request.jsonrequest(那是 envelope {jsonrpc, method, params, id})。
|
||||
params = request.params or {}
|
||||
paper_id = params.get('paper_id')
|
||||
answer_id = params.get('answer_id')
|
||||
data = params.get('data', {})
|
||||
|
||||
paper = self._check_paper_access(paper_id, states=('in_progress',))
|
||||
answer = request.env['yuthon.school.exam.answer'].sudo().browse(int(answer_id))
|
||||
if not answer.exists() or answer.paper_id.id != paper.id:
|
||||
return {'status': 'error', 'message': '题目不属于当前答卷。'}
|
||||
|
||||
qtype = answer.question_type
|
||||
vals = {}
|
||||
if qtype == 'single_choice':
|
||||
vals['selected_option_id'] = int(data.get('selected_option_id') or 0) or False
|
||||
if vals['selected_option_id']:
|
||||
vals['selected_option_ids'] = [(6, 0, [vals['selected_option_id']])]
|
||||
else:
|
||||
vals['selected_option_ids'] = [(5,)]
|
||||
elif qtype == 'multiple_choice':
|
||||
opt_ids = [int(x) for x in data.get('selected_option_ids', []) if x]
|
||||
vals['selected_option_ids'] = [(6, 0, opt_ids)]
|
||||
vals['selected_option_id'] = opt_ids[0] if opt_ids else False
|
||||
elif qtype == 'true_false':
|
||||
vals['answer_choice'] = data.get('answer_choice') or False
|
||||
elif qtype in ('fill_blank', 'essay'):
|
||||
vals['answer_text'] = data.get('answer_text') or False
|
||||
|
||||
answer.write(vals)
|
||||
return {
|
||||
'status': 'ok',
|
||||
'is_answered': bool(
|
||||
answer.selected_option_id or
|
||||
answer.selected_option_ids or
|
||||
answer.answer_choice or
|
||||
(answer.answer_text and answer._strip_html(answer.answer_text))
|
||||
),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. AJAX 提交整卷
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/exam/submit', type='json', auth='user', methods=['POST'])
|
||||
def exam_submit(self, **kw):
|
||||
params = request.params or {}
|
||||
paper_id = params.get('paper_id')
|
||||
paper = self._check_paper_access(paper_id, states=('in_progress',))
|
||||
try:
|
||||
paper.with_user(request.env.user).action_submit()
|
||||
except UserError as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
return {
|
||||
'status': 'ok',
|
||||
'redirect': '/exam/result/%s' % paper.id,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. 结果页面
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/exam/result/<int:paper_id>', type='http', auth='user', website=False)
|
||||
def exam_result(self, paper_id, **kw):
|
||||
paper = self._check_paper_access(paper_id)
|
||||
if paper.state not in ('submitted', 'graded'):
|
||||
return request.redirect('/exam/page/%s' % paper_id)
|
||||
|
||||
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
|
||||
values = {
|
||||
'paper': paper,
|
||||
'exam': paper.exam_id,
|
||||
'answers': answers,
|
||||
'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection),
|
||||
}
|
||||
return request.render('college.exam_result_page', values)
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- 为 school 自建模块填充「功能亮点 / 模块介绍」文案(幂等,每次升级执行) -->
|
||||
<function model="ir.module.module" name="populate_module_info"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ===================== 动作:打开应用列表(ir.module.module) ===================== -->
|
||||
<!-- kanban 是第一个 view_mode,直接用 view_id 指定实训模块专用看板视图;
|
||||
list/form 走系统默认,无需额外的 ir.actions.act_window.view 记录 -->
|
||||
<record id="action_shixun_module" model="ir.actions.act_window">
|
||||
<field name="name">实训模块</field>
|
||||
<field name="res_model">ir.module.module</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="view_id" ref="shixun_module_kanban_view"/>
|
||||
<!-- 过滤掉与教育/物管实训无关的类目,使应用总数收敛到 50(limit=50 时不再出现「加载更多」翻页)。
|
||||
category_id 为 M2O,domain 在客户端用 Python eval 解析,无法使用 ref(),故此处用真实类目 id:
|
||||
13=Education(教育) 54=Lucky Theme Backend(主题后端)
|
||||
71=preview_attachment 15=Data Cleaning(数据清理)
|
||||
94=Administration(后台) 6=Tools(工具)
|
||||
10=Productivity(生产力) 11=Calendar(日历)
|
||||
20=Uncategorized(未分类)
|
||||
如需保留/恢复某类目,从下面列表中删除对应 id 即可。 -->
|
||||
<field name="domain">[('category_id', 'not in', [13, 54, 71, 15, 94, 6, 10, 11, 20])]</field>
|
||||
<field name="context">{'search_default_app': 1}</field>
|
||||
<field name="limit">50</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 菜单:一级菜单,点进去显示 ir.module.module 的应用列表 ===================== -->
|
||||
<menuitem id="menu_shixun_module"
|
||||
name="实训模块"
|
||||
web_icon="college,static/src/img/shixun_module.png"
|
||||
action="action_shixun_module"
|
||||
sequence="30"
|
||||
groups="college.group_teacher,college.group_student,base.group_system"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,539 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
_QUESTION_TYPES = [
|
||||
('single_choice', '单选题'),
|
||||
('multiple_choice', '多选题'),
|
||||
('true_false', '判断题'),
|
||||
('fill_blank', '填空题'),
|
||||
('essay', '简答题'),
|
||||
]
|
||||
|
||||
_DIFFICULTY_LEVELS = [
|
||||
('easy', '简单'),
|
||||
('medium', '中等'),
|
||||
('hard', '困难'),
|
||||
]
|
||||
|
||||
|
||||
class ExamQuestion(models.Model):
|
||||
"""题库——老师出题,支持单选/多选/判断/填空/简答。
|
||||
|
||||
每道题可挂正确答案与答案说明(解析),并关联到课程/章节,
|
||||
后续组卷时按课程或章节快速筛选。
|
||||
"""
|
||||
|
||||
_name = 'yuthon.school.exam.question'
|
||||
_description = '题库'
|
||||
_order = 'course_id, chapter_id, sequence, id'
|
||||
_rec_name = 'question_text_short'
|
||||
|
||||
sequence = fields.Integer(string='排序', default=10)
|
||||
name = fields.Char(string='题目标题', help='内部管理用,不展示给学生')
|
||||
question_text = fields.Html(string='题干', sanitize=True, required=True)
|
||||
question_text_short = fields.Char(
|
||||
string='题干摘要', compute='_compute_question_text_short', store=True)
|
||||
|
||||
question_type = fields.Selection(
|
||||
_QUESTION_TYPES, string='题型', required=True, default='single_choice', index=True)
|
||||
difficulty = fields.Selection(
|
||||
_DIFFICULTY_LEVELS, string='难度', default='medium')
|
||||
|
||||
course_id = fields.Many2one(
|
||||
'yuthon.school.course', string='所属课程', ondelete='restrict', index=True)
|
||||
chapter_id = fields.Many2one(
|
||||
'yuthon.school.course.chapter', string='所属章节', ondelete='restrict', index=True,
|
||||
domain="[('course_id', '=', course_id)]")
|
||||
teacher_id = fields.Many2one(
|
||||
'res.users', string='出题人', default=lambda self: self.env.user, required=True)
|
||||
|
||||
option_ids = fields.One2many(
|
||||
'yuthon.school.exam.question.option', 'question_id', string='选项')
|
||||
correct_answer = fields.Char(
|
||||
string='正确答案',
|
||||
help='判断题填 True/False;填空题填标准答案(多个空用 | 分隔)。选择题由「选项」的「正确答案」标记决定。')
|
||||
reference_answer = fields.Html(
|
||||
string='参考答案', sanitize=True,
|
||||
help='简答题的参考答案,供老师评阅与学生查看解析时参考。')
|
||||
explanation = fields.Html(
|
||||
string='答案说明', sanitize=True,
|
||||
help='答案解析:学生交卷后可查看本题解析。')
|
||||
|
||||
score = fields.Float(string='默认分值', default=1.0, required=True)
|
||||
active = fields.Boolean(string='启用', default=True)
|
||||
|
||||
@api.depends('question_text')
|
||||
def _compute_question_text_short(self):
|
||||
import re
|
||||
for q in self:
|
||||
text = re.sub(r'<[^>]+>', ' ', q.question_text or '')
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
q.question_text_short = (text[:40] + '…') if len(text) > 40 else text
|
||||
|
||||
@api.onchange('question_type')
|
||||
def _onchange_question_type(self):
|
||||
"""切换题型时,对判断题给出默认正确答案提示。"""
|
||||
if self.question_type == 'true_false':
|
||||
self.correct_answer = 'True'
|
||||
|
||||
def _get_correct_options(self):
|
||||
"""返回本题的正确选项记录(仅选择题有效)。"""
|
||||
self.ensure_one()
|
||||
return self.option_ids.filtered(lambda o: o.is_correct)
|
||||
|
||||
|
||||
class ExamQuestionOption(models.Model):
|
||||
"""题目选项——用于单选题/多选题。"""
|
||||
|
||||
_name = 'yuthon.school.exam.question.option'
|
||||
_description = '题目选项'
|
||||
_order = 'sequence, id'
|
||||
_rec_name = 'name'
|
||||
|
||||
question_id = fields.Many2one(
|
||||
'yuthon.school.exam.question', string='题目', required=True, ondelete='cascade')
|
||||
sequence = fields.Integer(string='排序', default=10)
|
||||
label = fields.Char(string='选项标签', default='A', required=True)
|
||||
content = fields.Html(string='选项内容', sanitize=True, required=True)
|
||||
name = fields.Char(string='选项', compute='_compute_name', store=True)
|
||||
is_correct = fields.Boolean(string='正确答案', default=False)
|
||||
|
||||
@api.depends('label', 'content')
|
||||
def _compute_name(self):
|
||||
for opt in self:
|
||||
text = opt._strip_html(opt.content or '')
|
||||
opt.name = f"{opt.label}. {text[:60]}"
|
||||
|
||||
def _strip_html(self, html):
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', ' ', html)
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
|
||||
class Exam(models.Model):
|
||||
"""考试发布——老师从题库组卷并发布给课程学员。"""
|
||||
|
||||
_name = 'yuthon.school.exam'
|
||||
_description = '考试'
|
||||
_order = 'start_time desc, id desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(string='考试名称', required=True, index=True)
|
||||
course_id = fields.Many2one(
|
||||
'yuthon.school.course', string='所属课程', required=True, ondelete='cascade', index=True,
|
||||
domain="[('state', '=', 'published'), ('teacher_id', '=', uid)]")
|
||||
chapter_ids = fields.Many2many(
|
||||
'yuthon.school.course.chapter', 'yuthon_exam_chapter_rel',
|
||||
string='关联章节',
|
||||
domain="[('course_id', '=', course_id)]",
|
||||
help='可选:指定本次考试覆盖的章节,用于知识点定位。')
|
||||
teacher_id = fields.Many2one(
|
||||
'res.users', string='发布教师', default=lambda self: self.env.user, required=True)
|
||||
|
||||
description = fields.Html(string='考试说明', sanitize=True)
|
||||
start_time = fields.Datetime(string='开始时间')
|
||||
end_time = fields.Datetime(string='结束时间')
|
||||
duration_minutes = fields.Integer(
|
||||
string='考试时长(分钟)', default=60,
|
||||
help='0 表示不限制时长。')
|
||||
pass_score = fields.Float(string='及格分', default=60.0)
|
||||
|
||||
state = fields.Selection(
|
||||
[('draft', '草稿'), ('published', '已发布'), ('closed', '已截止')],
|
||||
string='状态', default='draft', index=True)
|
||||
active = fields.Boolean(string='启用', default=True)
|
||||
|
||||
line_ids = fields.One2many(
|
||||
'yuthon.school.exam.line', 'exam_id', string='试卷题目')
|
||||
total_score = fields.Float(
|
||||
string='总分', compute='_compute_total_score', store=True)
|
||||
|
||||
paper_ids = fields.One2many(
|
||||
'yuthon.school.exam.paper', 'exam_id', string='答卷')
|
||||
paper_count = fields.Integer(
|
||||
string='答卷数', compute='_compute_paper_count', store=True)
|
||||
submitted_count = fields.Integer(
|
||||
string='已交卷数', compute='_compute_paper_count', store=True)
|
||||
graded_count = fields.Integer(
|
||||
string='已评阅数', compute='_compute_paper_count', store=True)
|
||||
|
||||
@api.depends('line_ids.score')
|
||||
def _compute_total_score(self):
|
||||
for exam in self:
|
||||
exam.total_score = sum(exam.line_ids.mapped('score'))
|
||||
|
||||
@api.depends('paper_ids.state')
|
||||
def _compute_paper_count(self):
|
||||
for exam in self:
|
||||
papers = exam.paper_ids
|
||||
exam.paper_count = len(papers)
|
||||
exam.submitted_count = len(papers.filtered(lambda p: p.state in ('submitted', 'graded')))
|
||||
exam.graded_count = len(papers.filtered(lambda p: p.state == 'graded'))
|
||||
|
||||
def action_publish(self):
|
||||
"""发布考试:草稿 → 已发布,并为课程选课学员生成答卷。"""
|
||||
for exam in self:
|
||||
if exam.state != 'draft':
|
||||
raise UserError(_('考试「%s」当前状态为 %s,请勿重复发布。') % (exam.name or '', exam.state))
|
||||
if not exam.line_ids:
|
||||
raise UserError(_('考试「%s」尚未添加题目,不能发布。') % (exam.name or ''))
|
||||
self.write({'state': 'published'})
|
||||
self._create_papers()
|
||||
return True
|
||||
|
||||
def action_close(self):
|
||||
"""手动截止考试。"""
|
||||
return self.write({'state': 'closed'})
|
||||
|
||||
def action_reopen(self):
|
||||
"""重新开放:已截止 → 已发布(已有答卷保留)。"""
|
||||
return self.write({'state': 'published'})
|
||||
|
||||
def action_draft(self):
|
||||
"""撤回草稿:已发布但未产生答卷时可撤回;有答卷则禁止。"""
|
||||
for exam in self:
|
||||
if exam.paper_ids.filtered(lambda p: p.state != 'pending'):
|
||||
raise UserError(_('考试已有学员开始作答或提交,不能撤回草稿。'))
|
||||
self.mapped('paper_ids').unlink()
|
||||
return self.write({'state': 'draft'})
|
||||
|
||||
def _create_papers(self):
|
||||
"""为课程的每个选课学员创建一张答卷(去重)。"""
|
||||
Paper = self.env['yuthon.school.exam.paper'].sudo()
|
||||
for exam in self:
|
||||
course = exam.course_id
|
||||
if not course or not course.student_ids:
|
||||
continue
|
||||
existing_students = set(Paper.search([
|
||||
('exam_id', '=', exam.id),
|
||||
]).mapped('student_id').ids)
|
||||
for student in course.student_ids:
|
||||
if student.id in existing_students:
|
||||
continue
|
||||
Paper.create({
|
||||
'exam_id': exam.id,
|
||||
'student_id': student.id,
|
||||
'state': 'pending',
|
||||
})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
exams = super().create(vals_list)
|
||||
exams._check_time_window()
|
||||
return exams
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if any(k in vals for k in ('start_time', 'end_time', 'state')):
|
||||
self._check_time_window()
|
||||
return res
|
||||
|
||||
def _check_time_window(self):
|
||||
"""根据时间窗口自动切换已发布考试的状态(仅对 published/closed 生效)。"""
|
||||
now = fields.Datetime.now()
|
||||
for exam in self:
|
||||
if exam.state == 'published' and exam.end_time and exam.end_time < now:
|
||||
exam.write({'state': 'closed'})
|
||||
elif exam.state == 'closed' and exam.end_time and exam.end_time >= now:
|
||||
exam.write({'state': 'published'})
|
||||
|
||||
|
||||
class ExamLine(models.Model):
|
||||
"""考试-题目关联(组卷明细)。"""
|
||||
|
||||
_name = 'yuthon.school.exam.line'
|
||||
_description = '考试题目'
|
||||
_order = 'sequence, id'
|
||||
|
||||
exam_id = fields.Many2one(
|
||||
'yuthon.school.exam', string='考试', required=True, ondelete='cascade')
|
||||
sequence = fields.Integer(string='排序', default=10)
|
||||
question_id = fields.Many2one(
|
||||
'yuthon.school.exam.question', string='题目', required=True, ondelete='restrict')
|
||||
question_type = fields.Selection(
|
||||
related='question_id.question_type', string='题型', store=True, readonly=True)
|
||||
question_text = fields.Html(
|
||||
related='question_id.question_text', string='题干', readonly=True)
|
||||
score = fields.Float(string='本题分值', required=True, default=1.0)
|
||||
|
||||
@api.onchange('question_id')
|
||||
def _onchange_question_id(self):
|
||||
if self.question_id:
|
||||
self.score = self.question_id.score
|
||||
|
||||
|
||||
class ExamPaper(models.Model):
|
||||
"""学生答卷——一次考试对应一名学员的一份答卷。"""
|
||||
|
||||
_name = 'yuthon.school.exam.paper'
|
||||
_description = '答卷'
|
||||
_order = 'submit_time desc, id desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='答卷标题', compute='_compute_name', store=True)
|
||||
exam_id = fields.Many2one(
|
||||
'yuthon.school.exam', string='考试', required=True, ondelete='cascade', index=True)
|
||||
course_id = fields.Many2one(
|
||||
'yuthon.school.course', string='所属课程', related='exam_id.course_id', store=True, readonly=True)
|
||||
chapter_ids = fields.Many2many(
|
||||
'yuthon.school.course.chapter', string='关联章节', related='exam_id.chapter_ids', readonly=True)
|
||||
description = fields.Html(string='考试说明', related='exam_id.description', readonly=True)
|
||||
student_id = fields.Many2one(
|
||||
'yuthon.school.student', string='学员', required=True, ondelete='cascade', index=True)
|
||||
user_id = fields.Many2one(
|
||||
'res.users', string='账号', related='student_id.user_id', store=True)
|
||||
|
||||
state = fields.Selection(
|
||||
[('pending', '待考试'), ('in_progress', '考试中'), ('submitted', '待评阅'), ('graded', '已评阅')],
|
||||
string='状态', default='pending', index=True)
|
||||
|
||||
start_time = fields.Datetime(string='开始考试时间')
|
||||
submit_time = fields.Datetime(string='交卷时间')
|
||||
score = fields.Float(string='得分', compute='_compute_score', store=True)
|
||||
total_score = fields.Float(string='总分', related='exam_id.total_score', store=True)
|
||||
pass_score = fields.Float(string='及格分', related='exam_id.pass_score', store=True)
|
||||
is_passed = fields.Boolean(string='是否及格', compute='_compute_score', store=True)
|
||||
|
||||
answer_ids = fields.One2many(
|
||||
'yuthon.school.exam.answer', 'paper_id', string='答题记录')
|
||||
|
||||
@api.depends('exam_id', 'student_id')
|
||||
def _compute_name(self):
|
||||
for paper in self:
|
||||
exam_name = paper.exam_id.name or '考试'
|
||||
student_name = paper.student_id.name or '学员'
|
||||
paper.name = f'{exam_name} - {student_name}'
|
||||
|
||||
@api.depends('answer_ids.score')
|
||||
def _compute_score(self):
|
||||
for paper in self:
|
||||
total = sum(paper.answer_ids.mapped('score'))
|
||||
paper.score = total
|
||||
paper.is_passed = total >= (paper.pass_score or 0)
|
||||
|
||||
def action_start(self):
|
||||
"""学生开始考试:生成答题记录(尚未答题)并记录开始时间。"""
|
||||
Answer = self.env['yuthon.school.exam.answer'].sudo()
|
||||
for paper in self:
|
||||
if paper.state not in ('pending', 'in_progress'):
|
||||
raise UserError(_('当前答卷状态为 %s,不能开始考试。') % paper.state)
|
||||
# 检查时间窗口
|
||||
exam = paper.exam_id
|
||||
now = fields.Datetime.now()
|
||||
if exam.start_time and now < exam.start_time:
|
||||
raise UserError(_('考试尚未开始。'))
|
||||
if exam.end_time and now > exam.end_time:
|
||||
raise UserError(_('考试已结束。'))
|
||||
if not paper.start_time:
|
||||
paper.start_time = now
|
||||
if paper.state == 'pending':
|
||||
paper.write({'state': 'in_progress'})
|
||||
# 为每道题目创建空答题记录
|
||||
existing_qids = set(paper.answer_ids.mapped('question_id').ids)
|
||||
for line in exam.line_ids:
|
||||
if line.question_id.id in existing_qids:
|
||||
continue
|
||||
Answer.create({
|
||||
'paper_id': paper.id,
|
||||
'line_id': line.id,
|
||||
'question_id': line.question_id.id,
|
||||
})
|
||||
return True
|
||||
|
||||
def action_submit(self):
|
||||
"""学生交卷。"""
|
||||
for paper in self:
|
||||
if paper.state not in ('in_progress', 'pending'):
|
||||
raise UserError(_('只有进行中的考试可以交卷。'))
|
||||
self.write({
|
||||
'state': 'submitted',
|
||||
'submit_time': fields.Datetime.now(),
|
||||
})
|
||||
self._auto_grade()
|
||||
# 纯客观题(无简答题)交卷即自动判分完毕,直接标记已评阅;
|
||||
# 含简答题的卷子保留「待评阅」,等老师评阅后标记。
|
||||
for paper in self:
|
||||
if not any(a.question_type == 'essay' for a in paper.answer_ids):
|
||||
paper.write({'state': 'graded'})
|
||||
return True
|
||||
|
||||
def action_grade(self):
|
||||
"""老师评阅:客观题自动判分,简答题由老师打分后标记已评阅。"""
|
||||
self._auto_grade()
|
||||
# 所有题目(含简答题)均标记已评阅后,整张卷子标记为「已评阅」
|
||||
for paper in self:
|
||||
for ans in paper.answer_ids:
|
||||
if ans.question_type == 'essay':
|
||||
ans.is_graded = True
|
||||
if all(a.is_graded for a in paper.answer_ids):
|
||||
paper.write({'state': 'graded'})
|
||||
return True
|
||||
|
||||
def action_open_take_page(self):
|
||||
"""学生端:打开答题卡式考试页面。"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'name': '开始考试',
|
||||
'url': '/exam/start/%s' % self.id,
|
||||
'target': 'self',
|
||||
}
|
||||
|
||||
def action_view_answers(self):
|
||||
"""老师/学生打开答卷详情。"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': '答卷详情',
|
||||
'res_model': 'yuthon.school.exam.paper',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'view_id': self.env.ref('college.yuthon_school_exam_paper_form').id,
|
||||
'views': [(self.env.ref('college.yuthon_school_exam_paper_form').id, 'form')],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _auto_grade(self):
|
||||
"""客观题自动判分。"""
|
||||
for paper in self:
|
||||
for answer in paper.answer_ids:
|
||||
answer._auto_grade()
|
||||
|
||||
|
||||
class ExamAnswer(models.Model):
|
||||
"""学生对一道题的作答与评分结果。"""
|
||||
|
||||
_name = 'yuthon.school.exam.answer'
|
||||
_description = '答题记录'
|
||||
_order = 'line_id, id'
|
||||
|
||||
paper_id = fields.Many2one(
|
||||
'yuthon.school.exam.paper', string='答卷', required=True, ondelete='cascade')
|
||||
line_id = fields.Many2one(
|
||||
'yuthon.school.exam.line', string='考试题目', required=True, ondelete='cascade')
|
||||
question_id = fields.Many2one(
|
||||
'yuthon.school.exam.question', string='题目', required=True, ondelete='cascade')
|
||||
|
||||
sequence = fields.Integer(string='排序', related='line_id.sequence', store=True)
|
||||
question_type = fields.Selection(
|
||||
related='question_id.question_type', string='题型', store=True, readonly=True)
|
||||
question_text = fields.Html(
|
||||
related='question_id.question_text', string='题干', readonly=True)
|
||||
question_text_short = fields.Char(
|
||||
related='question_id.question_text_short', string='题干摘要', store=True, readonly=True)
|
||||
max_score = fields.Float(string='本题满分', related='line_id.score', readonly=True)
|
||||
explanation = fields.Html(
|
||||
string='答案说明', related='question_id.explanation', readonly=True)
|
||||
reference_answer = fields.Html(
|
||||
string='参考答案', related='question_id.reference_answer', readonly=True)
|
||||
|
||||
# 选择题作答:单选用 Many2one,多选用 Many2many
|
||||
selected_option_id = fields.Many2one(
|
||||
'yuthon.school.exam.question.option', string='所选选项(单选)',
|
||||
domain="[('question_id', '=', question_id)]")
|
||||
selected_option_ids = fields.Many2many(
|
||||
'yuthon.school.exam.question.option', 'yuthon_exam_answer_option_rel',
|
||||
string='所选选项(多选)',
|
||||
domain="[('question_id', '=', question_id)]")
|
||||
answer_choice = fields.Selection(
|
||||
[('True', '正确'), ('False', '错误')],
|
||||
string='判断作答')
|
||||
answer_text = fields.Html(string='作答内容', sanitize=True)
|
||||
|
||||
is_correct = fields.Boolean(string='是否正确', compute='_compute_is_correct', store=True)
|
||||
score = fields.Float(string='得分')
|
||||
is_graded = fields.Boolean(string='是否已评阅本题', default=False, readonly=True,
|
||||
help='客观题自动判分后置 True;简答题需老师评阅后置 True。'
|
||||
'整张卷子仅当所有题目均 is_graded 才标记「已评阅」。')
|
||||
teacher_comment = fields.Text(string='评语')
|
||||
answer_display = fields.Char(
|
||||
string='作答摘要', compute='_compute_answer_display', store=True)
|
||||
|
||||
@api.depends('selected_option_id', 'selected_option_ids', 'answer_choice', 'answer_text')
|
||||
def _compute_answer_display(self):
|
||||
for ans in self:
|
||||
qtype = ans.question_type
|
||||
if qtype == 'single_choice':
|
||||
opt = ans.selected_option_id
|
||||
ans.answer_display = f"{opt.label}. {opt.content}" if opt else ''
|
||||
elif qtype == 'multiple_choice':
|
||||
labels = sorted(ans.selected_option_ids.mapped('label'))
|
||||
ans.answer_display = ', '.join(labels) if labels else ''
|
||||
elif qtype == 'true_false':
|
||||
ans.answer_display = dict(self._fields['answer_choice'].selection).get(ans.answer_choice, '')
|
||||
else:
|
||||
text = ans._strip_html(ans.answer_text or '')
|
||||
ans.answer_display = (text[:60] + '…') if len(text) > 60 else text
|
||||
|
||||
@api.depends('selected_option_id', 'selected_option_ids', 'answer_choice', 'answer_text', 'question_id')
|
||||
def _compute_is_correct(self):
|
||||
for ans in self:
|
||||
ans.is_correct = ans._check_correct()
|
||||
|
||||
@api.onchange('selected_option_id')
|
||||
def _onchange_selected_option_id(self):
|
||||
"""单选变更时同步到多选字段,方便统一显示/评分。"""
|
||||
if self.selected_option_id:
|
||||
self.selected_option_ids = [(6, 0, [self.selected_option_id.id])]
|
||||
else:
|
||||
self.selected_option_ids = [(5,)]
|
||||
|
||||
def _check_correct(self):
|
||||
"""检查当前作答是否正确(仅客观题)。"""
|
||||
self.ensure_one()
|
||||
q = self.question_id
|
||||
qtype = q.question_type
|
||||
if qtype == 'single_choice':
|
||||
correct = q._get_correct_options()
|
||||
if not correct or not self.selected_option_id:
|
||||
return False
|
||||
return self.selected_option_id in correct
|
||||
if qtype == 'multiple_choice':
|
||||
correct = q._get_correct_options()
|
||||
chosen = self.selected_option_ids
|
||||
if not chosen:
|
||||
return False
|
||||
return set(chosen.ids) == set(correct.ids)
|
||||
if qtype == 'true_false':
|
||||
return (self.answer_choice or '').strip().lower() == (q.correct_answer or '').strip().lower()
|
||||
if qtype == 'fill_blank':
|
||||
return self._strip_html(self.answer_text or '').lower() == (q.correct_answer or '').strip().lower()
|
||||
# 简答题默认不正确,需老师打分
|
||||
return False
|
||||
|
||||
def _strip_html(self, html):
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', ' ', html)
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
def _auto_grade(self):
|
||||
"""客观题自动给分;简答题留给老师手动评分。"""
|
||||
self.ensure_one()
|
||||
if self.question_type == 'essay':
|
||||
return
|
||||
self.is_graded = True
|
||||
if self._check_correct():
|
||||
self.score = self.max_score
|
||||
else:
|
||||
self.score = 0.0
|
||||
|
||||
@api.onchange('score')
|
||||
def _onchange_score(self):
|
||||
"""老师打分后,若得分大于等于满分则标记为正确。"""
|
||||
if self.score is not False and self.max_score and self.score >= self.max_score:
|
||||
self.is_correct = True
|
||||
|
||||
def action_view_answer_form(self):
|
||||
"""打开答题记录表单(学生作答 / 老师评阅共用)。"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': '答题详情',
|
||||
'res_model': 'yuthon.school.exam.answer',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'view_id': self.env.ref('college.yuthon_school_exam_answer_view_form').id,
|
||||
'views': [(self.env.ref('college.yuthon_school_exam_answer_view_form').id, 'form')],
|
||||
'target': 'new',
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class IrModuleModule(models.Model):
|
||||
_inherit = 'ir.module.module'
|
||||
|
||||
introduction = fields.Html(
|
||||
string='模块介绍',
|
||||
sanitize=True,
|
||||
help='在「模块信息 / 功能与介绍」页展示的模块说明(由 college 维护)',
|
||||
)
|
||||
features = fields.Html(
|
||||
string='功能亮点',
|
||||
sanitize=True,
|
||||
help='在「模块信息 / 功能与介绍」页展示的功能清单(由 college 维护)',
|
||||
)
|
||||
knowledge_points = fields.Html(
|
||||
string='知识点',
|
||||
sanitize=True,
|
||||
help='在「模块信息 / 知识点」页展示的核心概念与使用要点(由 college 维护)',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def populate_module_info(self):
|
||||
"""为 school 自建模块写入「功能亮点 / 模块介绍」文案。
|
||||
|
||||
由 college 的 data 文件通过 <function> 调用,每次升级幂等执行。
|
||||
"""
|
||||
data = {
|
||||
'college': {
|
||||
'introduction': (
|
||||
"<p>「教育」是面向高校教学的 Odoo 18 综合管理模块,围绕<strong>老师</strong>与"
|
||||
"<strong>学生</strong>双角色设计,打通从师生档案、课程建设到作业与学习进展的全流程。</p>"
|
||||
"<p>它既是师生管理后台,也是学生端的学习入口,所有数据按角色自动隔离可见范围。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>师生管理</strong>:班级 / 老师 / 学生档案,支持 CSV 模板批量导入,"
|
||||
"导入时自动创建登录账号;</li>"
|
||||
"<li><strong>课程管理</strong>:老师创建并独立管理课程,支持理论 / 实践两种形式,"
|
||||
"含视频与 PPT 课件,以及课程介绍、目标、大纲、特色;</li>"
|
||||
"<li><strong>作业习题</strong>:老师布置与批改,学生在线提交;</li>"
|
||||
"<li><strong>学习进展</strong>:按角色区分的进展看板,老师看全局、学生看自己;</li>"
|
||||
"<li><strong>双角色权限</strong>:老师可管理,学生仅可读与提交。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>角色隔离</strong>:老师与学生看到的数据范围由权限组控制,"
|
||||
"同一菜单、同一视图会根据登录角色自动过滤;</li>"
|
||||
"<li><strong>CSV 导入规则</strong>:导入学生/老师时会按模板字段自动创建用户并绑定 partner,"
|
||||
"确保账号与档案一一对应;</li>"
|
||||
"<li><strong>课程归属</strong>:课程由创建老师负责维护,其他老师默认不可编辑;</li>"
|
||||
"<li><strong>进展看板</strong>:学生的提交、成绩、学习行为会汇总到进展视图,"
|
||||
"老师可查看全班统计。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'edu_base': {
|
||||
'introduction': (
|
||||
"<p>「教育基础权限」是整套教学体系的权限基座,统一管理跨模块复用的通用角色权限组。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li>预置<strong>学生 / 教师 / 教务管理员</strong>三组权限;</li>"
|
||||
"<li>作为基础依赖,供 college、learning_center 等模块直接引用;</li>"
|
||||
"<li>不绑定具体业务,只负责角色与授权。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>权限组继承</strong>:edu_base 只定义基础组,业务模块通过 group_id 字段"
|
||||
"或菜单 access 引用,实现权限分层;</li>"
|
||||
"<li><strong>角色与用户的区别</strong>:组(Group)是权限集合,用户通过分配到组获得权限;</li>"
|
||||
"<li><strong>安装顺序</strong>:依赖 edu_base 的模块会自动触发安装,通常无需单独启用。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'learning_center': {
|
||||
'introduction': (
|
||||
"<p>「学习中心」提供课程的基础信息结构与完整教学链路,是课程内容建设的核心。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>课程 / 章节</strong>:课程下挂载章节,章节可挂资源;</li>"
|
||||
"<li><strong>章节资源</strong>:视频、文档等课件管理;</li>"
|
||||
"<li><strong>课程作业</strong>:布置作业与学员提交;</li>"
|
||||
"<li><strong>成绩管理</strong>:成绩项定义与学员成绩;</li>"
|
||||
"<li><strong>课程大纲 / 教学班</strong>:syllabus 与 teaching class 组织。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>课程 ≠ 教学班</strong>:课程是内容模板,教学班是学期/班级维度的开课实例;</li>"
|
||||
"<li><strong>章节资源类型</strong>:支持视频、文档等多种资源,资源通过关系模型挂到章节下;</li>"
|
||||
"<li><strong>作业与成绩联动</strong>:作业提交状态会作为成绩项的数据来源之一;</li>"
|
||||
"<li><strong>大纲复用</strong>:课程大纲(syllabus)可被多个教学班引用,减少重复录入。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'permission_manager': {
|
||||
'introduction': (
|
||||
"<p>「权限管理」提供可视化的角色与权限配置界面。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>角色(Role)</strong>定义与维护;</li>"
|
||||
"<li>基于角色的权限分配视图;</li>"
|
||||
"<li>与 edu_base 权限基座配合,细化授权。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>Role 模型</strong>:本模块新增独立的 Role 业务对象,可与 Odoo 原生组做映射;</li>"
|
||||
"<li><strong>权限分配入口</strong>:通过表单/列表直接为角色勾选模型级 CRUD 权限;</li>"
|
||||
"<li><strong>与 edu_base 的关系</strong>:edu_base 提供组定义,permission_manager 负责"
|
||||
"把这些组分配给具体用户或角色。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'student_organization': {
|
||||
'introduction': (
|
||||
"<p>「学生组织」是学生档案与年级 / 班级结构的基础模块,依赖 hr,"
|
||||
"为教学体系提供组织骨架。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>学生组织</strong>:年级、班级等层级管理;</li>"
|
||||
"<li><strong>学生信息</strong>:学生基本档案;</li>"
|
||||
"<li><strong>基础年级</strong>:年级基础数据初始化。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>与 hr 的关系</strong>:学生档案复用 employee 模型扩展字段,"
|
||||
"因此依赖 hr 模块;</li>"
|
||||
"<li><strong>年级 → 班级 → 学生</strong>:组织层级自上而下引用,删除上级会受外键约束;</li>"
|
||||
"<li><strong>基础数据</strong>:「基础年级」用于初始化常见年级字典,后续可在学生组织中引用。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'yuthon_attendance': {
|
||||
'introduction': (
|
||||
"<p>「Yuthon 出勤管理」面向教职工的打卡出勤模块,自动计算工时、迟到与出勤状态。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>签到 / 签退</strong>:基于员工记录出勤;</li>"
|
||||
"<li><strong>自动计算</strong>:工时、迟到分钟、出勤状态由计算字段得出;</li>"
|
||||
"<li><strong>约束校验</strong>:一人一天一条,签到早于签退;</li>"
|
||||
"<li><strong>出勤分析</strong>:graph / pivot 统计。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>唯一约束</strong>:同一员工同一天只能有一条出勤记录,避免重复打卡;</li>"
|
||||
"<li><strong>状态计算逻辑</strong>:出勤状态由签到/签退时间与阈值比较得出,无需手动选择;</li>"
|
||||
"<li><strong>工时公式</strong>:实际工时 = 签退时间 − 签到时间 − 扣除规则;</li>"
|
||||
"<li><strong>与薪资联动</strong>:出勤结果会作为 yuthon_wage 计算月度工资的依据。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'yuthon_employee': {
|
||||
'introduction': (
|
||||
"<p>「Yuthon 员工管理」是大学人事基础,管理教职工主数据,"
|
||||
"是整个出勤—薪资体系的数据根。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>教职工档案</strong>:工号 / 部门 / 岗位 / 基本工资;</li>"
|
||||
"<li><strong>唯一约束</strong>:工号不可重复;</li>"
|
||||
"<li><strong>模块组合</strong>:被出勤、薪资模块通过 _inherit 扩展反向关联。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>工号是核心键</strong>:工号全局唯一,是出勤、薪资多条记录关联员工的基础;</li>"
|
||||
"<li><strong>基本工资用途</strong>:基本工资字段写入员工档案,薪资模块每月读取并聚合;</li>"
|
||||
"<li><strong>反向扩展模式</strong>:出勤、薪资模块通过 _inherit 员工模型添加字段,"
|
||||
"保持主数据单一来源。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'yuthon_login_theme': {
|
||||
'introduction': (
|
||||
"<p>「校园登录主题」是纯前端外观模块,将 Odoo 登录页美化为教育网站风格,"
|
||||
"不新增任何业务数据。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>渐变背景</strong>:教育紫整页渐变;</li>"
|
||||
"<li><strong>玻璃质感</strong>:登录卡片 + 品牌标题;</li>"
|
||||
"<li><strong>主色统一</strong>:登录按钮与主题色一致。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>纯前端模块</strong>:只包含 SCSS / XML 视图资源,没有模型或业务逻辑;</li>"
|
||||
"<li><strong>登录页覆盖</strong>:通过继承 Odoo 登录模板并覆盖样式实现;</li>"
|
||||
"<li><strong>主题色变量</strong>:主色在 SCSS 变量中统一定义,修改一处即可全局生效。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
'yuthon_wage': {
|
||||
'introduction': (
|
||||
"<p>「Yuthon 薪资管理」在月底把员工基本工资与当月出勤结果聚合,"
|
||||
"自动计算扣款与实发工资。</p>"
|
||||
),
|
||||
'features': (
|
||||
"<ul>"
|
||||
"<li><strong>月度工资条</strong>:基本工资 + 出勤聚合;</li>"
|
||||
"<li><strong>自动扣款</strong>:迟到 / 缺勤按规则计算;</li>"
|
||||
"<li><strong>批量生成</strong>:向导批量生成工资条;</li>"
|
||||
"<li><strong>状态机</strong>:草稿 → 完成。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
'knowledge_points': (
|
||||
"<ul>"
|
||||
"<li><strong>工资条生成时机</strong>:通常每月月底运行向导,批量生成上月工资条;</li>"
|
||||
"<li><strong>聚合逻辑</strong>:系统按员工汇总当月出勤记录的迟到、缺勤数据,"
|
||||
"再与基本工资做扣款计算;</li>"
|
||||
"<li><strong>状态流转</strong>:草稿状态可重新计算,完成后建议锁定,防止误改;</li>"
|
||||
"<li><strong>与出勤模块的关系</strong>:yuthon_wage 依赖 yuthon_attendance,"
|
||||
"考勤数据是薪资计算的关键输入。</li>"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
}
|
||||
for name, vals in data.items():
|
||||
rec = self.search([('name', '=', name)], limit=1)
|
||||
if rec:
|
||||
rec.write(vals)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,672 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================
|
||||
学生答题卡式考试页面模板
|
||||
============================================================ -->
|
||||
|
||||
<template id="exam_take_page" name="考试答题页面">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<t t-set="head">
|
||||
<title><t t-esc="exam.name"/> - 在线考试</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
</t>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--exam-primary: #2563eb;
|
||||
--exam-primary-dark: #1d4ed8;
|
||||
--exam-success: #22c55e;
|
||||
--exam-danger: #ef4444;
|
||||
--exam-warn: #f59e0b;
|
||||
--exam-bg: #f3f4f6;
|
||||
--exam-card: #ffffff;
|
||||
--exam-border: #e5e7eb;
|
||||
--exam-text: #1f2937;
|
||||
--exam-muted: #6b7280;
|
||||
}
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--exam-bg);
|
||||
color: var(--exam-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.exam-header {
|
||||
background: var(--exam-card);
|
||||
border-bottom: 1px solid var(--exam-border);
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
.exam-header-left h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.exam-header-meta {
|
||||
font-size: 13px;
|
||||
color: var(--exam-muted);
|
||||
}
|
||||
.exam-header-meta span { margin-right: 16px; }
|
||||
.exam-timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--exam-text);
|
||||
background: #eff6ff;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.exam-timer.warning { color: var(--exam-danger); background: #fef2f2; }
|
||||
.exam-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: calc(100% - 80px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.exam-main {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.exam-sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exam-card {
|
||||
background: var(--exam-card);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.exam-question-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.exam-question-index {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.exam-question-type {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.exam-question-score {
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
color: var(--exam-warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
.exam-question-body {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.exam-question-body img { max-width: 100%; height: auto; }
|
||||
.exam-options { display: flex; flex-direction: column; gap: 12px; }
|
||||
.exam-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--exam-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all .15s ease;
|
||||
}
|
||||
.exam-option:hover { border-color: var(--exam-primary); background: #f8fafc; }
|
||||
.exam-option.selected {
|
||||
border-color: var(--exam-primary);
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 0 0 1px var(--exam-primary);
|
||||
}
|
||||
.exam-option-marker {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.exam-option.selected .exam-option-marker {
|
||||
border-color: var(--exam-primary);
|
||||
background: var(--exam-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.exam-option-checkbox .exam-option-marker { border-radius: 5px; }
|
||||
.exam-option-content { flex: 1; font-size: 14px; line-height: 1.6; }
|
||||
.exam-option-content img { max-width: 100%; }
|
||||
.exam-text-answer textarea {
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--exam-border);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
.exam-text-answer textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--exam-primary);
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
|
||||
}
|
||||
.exam-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.exam-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all .15s ease;
|
||||
}
|
||||
.exam-btn-primary { background: var(--exam-primary); color: #fff; }
|
||||
.exam-btn-primary:hover { background: var(--exam-primary-dark); }
|
||||
.exam-btn-default { background: #fff; color: var(--exam-text); border: 1px solid var(--exam-border); }
|
||||
.exam-btn-default:hover { background: #f9fafb; }
|
||||
.exam-btn-success { background: var(--exam-success); color: #fff; }
|
||||
.exam-btn-success:hover { background: #16a34a; }
|
||||
.exam-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.exam-sidebar-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.exam-legend {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--exam-muted);
|
||||
}
|
||||
.exam-legend-item { display: flex; align-items: center; gap: 6px; }
|
||||
.exam-legend-dot {
|
||||
width: 12px; height: 12px; border-radius: 3px;
|
||||
border: 1px solid var(--exam-border);
|
||||
}
|
||||
.exam-legend-dot.current { background: var(--exam-primary); border-color: var(--exam-primary); }
|
||||
.exam-legend-dot.answered { background: var(--exam-success); border-color: var(--exam-success); }
|
||||
.exam-legend-dot.unanswered { background: #fff; }
|
||||
.exam-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.exam-card-btn {
|
||||
height: 40px;
|
||||
border: 1px solid var(--exam-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all .15s ease;
|
||||
}
|
||||
.exam-card-btn:hover { border-color: var(--exam-primary); }
|
||||
.exam-card-btn.current { background: var(--exam-primary); color: #fff; border-color: var(--exam-primary); }
|
||||
.exam-card-btn.answered { background: var(--exam-success); color: #fff; border-color: var(--exam-success); }
|
||||
.exam-card-btn.unanswered { color: var(--exam-muted); }
|
||||
.exam-submit-area {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.exam-submit-area .exam-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.exam-toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
.exam-toast.show { opacity: 1; }
|
||||
.exam-progress {
|
||||
font-size: 13px;
|
||||
color: var(--exam-muted);
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.exam-container { flex-direction: column; }
|
||||
.exam-sidebar { width: 100%; }
|
||||
.exam-header { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="exam-header">
|
||||
<div class="exam-header-left">
|
||||
<h1><t t-esc="exam.name"/></h1>
|
||||
<div class="exam-header-meta">
|
||||
<span>课程:<t t-esc="course.name or '-'"/></span>
|
||||
<span t-if="chapters">章节:<t t-esc="', '.join(chapters.mapped('name'))"/></span>
|
||||
<span>学员:<t t-esc="student.name or '-'"/></span>
|
||||
<span>满分:<t t-esc="paper.total_score"/> 分</span>
|
||||
<span>及格:<t t-esc="paper.pass_score"/> 分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exam-timer" id="exam-timer" t-att-data-seconds="remaining_seconds">
|
||||
<i class="fa fa-clock-o"/>
|
||||
<span id="timer-text">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-container">
|
||||
<div class="exam-main">
|
||||
<div class="exam-card" id="question-card">
|
||||
<div class="exam-question-header">
|
||||
<span class="exam-question-index" id="q-index">第 1 题</span>
|
||||
<span class="exam-question-type" id="q-type">单选题</span>
|
||||
<span class="exam-question-score" id="q-score">5 分</span>
|
||||
</div>
|
||||
<div class="exam-question-body" id="q-body">
|
||||
<!-- 题干 -->
|
||||
</div>
|
||||
<div class="exam-options" id="q-options">
|
||||
<!-- 选项 / 输入区 -->
|
||||
</div>
|
||||
<div class="exam-actions">
|
||||
<button class="exam-btn exam-btn-default" id="btn-prev" type="button">上一题</button>
|
||||
<button class="exam-btn exam-btn-primary" id="btn-next" type="button">下一题</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-sidebar">
|
||||
<div class="exam-card">
|
||||
<h3 class="exam-sidebar-title">答题卡</h3>
|
||||
<div class="exam-legend">
|
||||
<div class="exam-legend-item"><span class="exam-legend-dot current"/>当前</div>
|
||||
<div class="exam-legend-item"><span class="exam-legend-dot answered"/>已做</div>
|
||||
<div class="exam-legend-item"><span class="exam-legend-dot unanswered"/>未做</div>
|
||||
</div>
|
||||
<div class="exam-card-grid" id="answer-card-grid">
|
||||
<!-- 题号按钮 -->
|
||||
</div>
|
||||
<div class="exam-progress" id="answer-progress">已完成 0 / 0 题</div>
|
||||
<div class="exam-submit-area">
|
||||
<button class="exam-btn exam-btn-success" id="btn-submit" type="button">提交答卷</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-toast" id="exam-toast"></div>
|
||||
<!-- 将作答数据放入 data 属性:属性值中的实体会被正确解码,且可避免 HTML 题干里 </script> 造成脚本截断 -->
|
||||
<div id="exam-meta"
|
||||
t-att-data-answers="answer_json"
|
||||
t-att-data-paper="paper.id"
|
||||
style="display:none"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function() {
|
||||
var metaEl = document.getElementById('exam-meta');
|
||||
var answers = JSON.parse(metaEl.dataset.answers || '[]');
|
||||
var paperId = parseInt(metaEl.dataset.paper, 10) || 0;
|
||||
var currentIdx = 0;
|
||||
var saveQueue = Promise.resolve();
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function showToast(msg) {
|
||||
var t = $('exam-toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(function() { t.classList.remove('show'); }, 1500);
|
||||
}
|
||||
function stripHtml(html) {
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
}
|
||||
function isAnswered(ans) {
|
||||
if (!ans) return false;
|
||||
if (ans.question_type === 'single_choice') return !!ans.selected_option_id;
|
||||
if (ans.question_type === 'multiple_choice') return (ans.selected_option_ids || []).length > 0;
|
||||
if (ans.question_type === 'true_false') return !!ans.answer_choice;
|
||||
return !!(ans.answer_text && stripHtml(ans.answer_text).trim());
|
||||
}
|
||||
function renderQuestion(idx) {
|
||||
currentIdx = idx;
|
||||
var ans = answers[idx];
|
||||
$('q-index').textContent = '第 ' + (idx + 1) + ' 题';
|
||||
$('q-type').textContent = ans.question_type_label || '';
|
||||
$('q-score').textContent = (ans.max_score || 0) + ' 分';
|
||||
$('q-body').innerHTML = ans.question_text || '';
|
||||
var optsEl = $('q-options');
|
||||
optsEl.innerHTML = '';
|
||||
|
||||
if (ans.question_type === 'single_choice') {
|
||||
ans.options.forEach(function(opt) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'exam-option' + (ans.selected_option_id === opt.id ? ' selected' : '');
|
||||
div.dataset.id = opt.id;
|
||||
div.innerHTML = '<span class="exam-option-marker">' + opt.label + '</span>' +
|
||||
'<span class="exam-option-content">' + (opt.content || '') + '</span>';
|
||||
div.addEventListener('click', function() {
|
||||
ans.selected_option_id = opt.id;
|
||||
ans.selected_option_ids = [opt.id];
|
||||
renderQuestion(idx);
|
||||
saveAnswer(ans);
|
||||
});
|
||||
optsEl.appendChild(div);
|
||||
});
|
||||
} else if (ans.question_type === 'multiple_choice') {
|
||||
ans.options.forEach(function(opt) {
|
||||
var div = document.createElement('div');
|
||||
var selected = (ans.selected_option_ids || []).indexOf(opt.id) >= 0;
|
||||
div.className = 'exam-option exam-option-checkbox' + (selected ? ' selected' : '');
|
||||
div.dataset.id = opt.id;
|
||||
div.innerHTML = '<span class="exam-option-marker">' + (selected ? '✓' : '') + '</span>' +
|
||||
'<span class="exam-option-content">' + (opt.content || '') + '</span>';
|
||||
div.addEventListener('click', function() {
|
||||
var ids = ans.selected_option_ids || [];
|
||||
var pos = ids.indexOf(opt.id);
|
||||
if (pos >= 0) ids.splice(pos, 1); else ids.push(opt.id);
|
||||
ans.selected_option_ids = ids;
|
||||
ans.selected_option_id = ids.length ? ids[0] : false;
|
||||
renderQuestion(idx);
|
||||
saveAnswer(ans);
|
||||
});
|
||||
optsEl.appendChild(div);
|
||||
});
|
||||
} else if (ans.question_type === 'true_false') {
|
||||
[['True', '正确'], ['False', '错误']].forEach(function(pair) {
|
||||
var val = pair[0], label = pair[1];
|
||||
var div = document.createElement('div');
|
||||
div.className = 'exam-option' + (ans.answer_choice === val ? ' selected' : '');
|
||||
div.innerHTML = '<span class="exam-option-marker">' + (ans.answer_choice === val ? '✓' : '') + '</span>' +
|
||||
'<span class="exam-option-content">' + label + '</span>';
|
||||
div.addEventListener('click', function() {
|
||||
ans.answer_choice = val;
|
||||
renderQuestion(idx);
|
||||
saveAnswer(ans);
|
||||
});
|
||||
optsEl.appendChild(div);
|
||||
});
|
||||
} else {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'exam-text-answer';
|
||||
var ta = document.createElement('textarea');
|
||||
ta.placeholder = ans.question_type === 'essay' ? '请输入简答内容...' : '请输入答案...';
|
||||
ta.value = ans.answer_text || '';
|
||||
ta.addEventListener('input', function() {
|
||||
ans.answer_text = ta.value;
|
||||
});
|
||||
ta.addEventListener('blur', function() {
|
||||
saveAnswer(ans);
|
||||
renderCard();
|
||||
});
|
||||
wrap.appendChild(ta);
|
||||
optsEl.appendChild(wrap);
|
||||
}
|
||||
|
||||
$('btn-prev').disabled = idx === 0;
|
||||
$('btn-next').textContent = idx === answers.length - 1 ? '返回第一题' : '下一题';
|
||||
renderCard();
|
||||
}
|
||||
function renderCard() {
|
||||
var grid = $('answer-card-grid');
|
||||
grid.innerHTML = '';
|
||||
var answeredCount = 0;
|
||||
answers.forEach(function(ans, idx) {
|
||||
var answered = isAnswered(ans);
|
||||
if (answered) answeredCount++;
|
||||
var btn = document.createElement('div');
|
||||
btn.className = 'exam-card-btn';
|
||||
if (idx === currentIdx) btn.classList.add('current');
|
||||
else if (answered) btn.classList.add('answered');
|
||||
else btn.classList.add('unanswered');
|
||||
btn.textContent = idx + 1;
|
||||
btn.addEventListener('click', function() { renderQuestion(idx); });
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
$('answer-progress').textContent = '已完成 ' + answeredCount + ' / ' + answers.length + ' 题';
|
||||
}
|
||||
function saveAnswer(ans) {
|
||||
var payload = {
|
||||
paper_id: paperId,
|
||||
answer_id: ans.id,
|
||||
data: {}
|
||||
};
|
||||
if (ans.question_type === 'single_choice') payload.data.selected_option_id = ans.selected_option_id;
|
||||
else if (ans.question_type === 'multiple_choice') payload.data.selected_option_ids = ans.selected_option_ids || [];
|
||||
else if (ans.question_type === 'true_false') payload.data.answer_choice = ans.answer_choice;
|
||||
else payload.data.answer_text = ans.answer_text || '';
|
||||
|
||||
saveQueue = saveQueue.then(function() {
|
||||
return fetch('/exam/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: payload, id: Math.random() })
|
||||
}).then(function(r) { return r.json(); }).then(function(res) {
|
||||
if (res.result && res.result.status === 'ok') {
|
||||
// 静默保存,不打扰
|
||||
}
|
||||
}).catch(function() {
|
||||
// 网络异常忽略,允许学生继续作答
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$('btn-prev').addEventListener('click', function() {
|
||||
if (currentIdx > 0) renderQuestion(currentIdx - 1);
|
||||
});
|
||||
$('btn-next').addEventListener('click', function() {
|
||||
if (currentIdx < answers.length - 1) renderQuestion(currentIdx + 1);
|
||||
else renderQuestion(0);
|
||||
});
|
||||
$('btn-submit').addEventListener('click', function() {
|
||||
var unAnswered = answers.filter(function(a) { return !isAnswered(a); }).length;
|
||||
var msg = unAnswered > 0
|
||||
? '您还有 ' + unAnswered + ' 道题未作答,确定要提交吗?'
|
||||
: '提交后不能再修改,是否继续?';
|
||||
if (!confirm(msg)) return;
|
||||
fetch('/exam/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { paper_id: paperId }, id: Math.random() })
|
||||
}).then(function(r) { return r.json(); }).then(function(res) {
|
||||
if (res.result && res.result.status === 'ok') {
|
||||
window.location.href = res.result.redirect;
|
||||
} else {
|
||||
var err = (res.result && res.result.message) || '提交失败';
|
||||
alert(err);
|
||||
}
|
||||
}).catch(function() { alert('提交失败,请检查网络后重试。'); });
|
||||
});
|
||||
|
||||
// 倒计时
|
||||
var timerEl = $('exam-timer');
|
||||
var seconds = parseInt(timerEl.dataset.seconds || '0', 10);
|
||||
if (seconds > 0) {
|
||||
function fmt(s) {
|
||||
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
return [h, m, sec].map(function(n){ return n < 10 ? '0'+n : n; }).join(':');
|
||||
}
|
||||
$('timer-text').textContent = fmt(seconds);
|
||||
var interval = setInterval(function() {
|
||||
seconds--;
|
||||
$('timer-text').textContent = fmt(seconds);
|
||||
if (seconds < 300) timerEl.classList.add('warning');
|
||||
if (seconds <= 0) {
|
||||
clearInterval(interval);
|
||||
alert('考试时间已结束,系统将自动提交。');
|
||||
$('btn-submit').click();
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
$('timer-text').textContent = '不限时';
|
||||
}
|
||||
|
||||
// 题型标签翻译
|
||||
var typeLabels = {
|
||||
'single_choice': '单选题',
|
||||
'multiple_choice': '多选题',
|
||||
'true_false': '判断题',
|
||||
'fill_blank': '填空题',
|
||||
'essay': '简答题'
|
||||
};
|
||||
answers.forEach(function(a) { a.question_type_label = typeLabels[a.question_type] || a.question_type; });
|
||||
|
||||
if (answers.length) renderQuestion(0);
|
||||
else {
|
||||
$('q-body').innerHTML = '<p>该试卷暂无题目。</p>';
|
||||
$('btn-submit').style.display = 'none';
|
||||
}
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- 结果页面 -->
|
||||
<template id="exam_result_page" name="考试结果页面">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<t t-set="head">
|
||||
<title><t t-esc="exam.name"/> - 考试结果</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
</t>
|
||||
<style>
|
||||
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
|
||||
.result-wrap { max-width:900px; margin:40px auto; padding:0 20px; }
|
||||
.result-card { background:#fff; border-radius:16px; padding:32px; box-shadow:0 2px 8px rgba(0,0,0,0.06); margin-bottom:20px; }
|
||||
.result-title { font-size:22px; font-weight:700; margin:0 0 8px; }
|
||||
.result-sub { color:#6b7280; font-size:14px; margin-bottom:20px; }
|
||||
.result-score { display:flex; align-items:center; gap:20px; }
|
||||
.score-circle { width:110px; height:110px; border-radius:50%; display:flex; flex-direction:column; align-items:center; justify-content:center; font-weight:700; }
|
||||
.score-circle.pass { background:#dcfce7; color:#15803d; }
|
||||
.score-circle.fail { background:#fee2e2; color:#b91c1c; }
|
||||
.score-circle .num { font-size:34px; line-height:1; }
|
||||
.score-circle .label { font-size:13px; margin-top:4px; }
|
||||
.result-meta { flex:1; }
|
||||
.result-meta-row { display:flex; gap:30px; font-size:14px; margin-bottom:8px; }
|
||||
.result-answer { border-top:1px solid #e5e7eb; padding-top:18px; margin-top:18px; }
|
||||
.result-answer-title { font-weight:700; margin-bottom:12px; }
|
||||
.result-question { background:#f9fafb; border-radius:10px; padding:16px; margin-bottom:12px; }
|
||||
.result-question-header { display:flex; gap:10px; align-items:center; margin-bottom:8px; font-size:14px; }
|
||||
.result-tag { background:#e0e7ff; color:#3730a3; font-size:12px; padding:2px 8px; border-radius:10px; }
|
||||
.result-correct { color:#15803d; font-weight:600; }
|
||||
.result-wrong { color:#b91c1c; font-weight:600; }
|
||||
.result-pending { color:#b45309; font-weight:600; }
|
||||
.result-btn { display:inline-block; margin-top:20px; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; font-weight:600; }
|
||||
</style>
|
||||
<div class="result-wrap">
|
||||
<div class="result-card">
|
||||
<h1 class="result-title"><t t-esc="exam.name"/></h1>
|
||||
<div class="result-sub">
|
||||
课程:<t t-esc="paper.course_id.name or '-'"/> | 学员:<t t-esc="paper.student_id.name or '-'"/>
|
||||
</div>
|
||||
<div class="result-score">
|
||||
<div t-att-class="'score-circle ' + ('pass' if paper.is_passed else 'fail')">
|
||||
<span class="num"><t t-esc="paper.score"/></span>
|
||||
<span class="label">/<t t-esc="paper.total_score"/> 分</span>
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<div class="result-meta-row"><strong>是否及格:</strong><t t-esc="'及格' if paper.is_passed else '不及格'"/></div>
|
||||
<div class="result-meta-row"><strong>及格分:</strong><t t-esc="paper.pass_score"/> 分</div>
|
||||
<div class="result-meta-row"><strong>状态:</strong><t t-esc="dict(paper._fields['state'].selection).get(paper.state)"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<div class="result-answer-title">答题详情</div>
|
||||
<t t-foreach="answers" t-as="ans">
|
||||
<div class="result-question">
|
||||
<div class="result-question-header">
|
||||
<span>第 <t t-esc="ans_index + 1"/> 题</span>
|
||||
<span class="result-tag"><t t-esc="question_types.get(ans.question_type, ans.question_type)"/></span>
|
||||
<span><t t-esc="ans.score"/> / <t t-esc="ans.max_score"/> 分</span>
|
||||
<t t-if="ans.question_type != 'essay'">
|
||||
<span t-att-class="'result-' + ('correct' if ans.is_correct else 'wrong')">
|
||||
<t t-esc="'正确' if ans.is_correct else '错误'"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="result-pending">待评阅 / 已评阅</span>
|
||||
</t>
|
||||
</div>
|
||||
<div t-raw="ans.question_text"/>
|
||||
<div style="margin-top:8px; color:#374151;">
|
||||
<strong>你的作答:</strong><t t-esc="ans.answer_display or '未作答'"/>
|
||||
</div>
|
||||
<div t-if="ans.explanation" style="margin-top:10px; padding:10px; background:#fff; border-radius:6px;">
|
||||
<strong>答案说明:</strong>
|
||||
<div t-raw="ans.explanation"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<a href="/web" class="result-btn">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- 错误提示页 -->
|
||||
<template id="exam_error_page" name="考试错误提示">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<t t-set="head"><title>提示</title></t>
|
||||
<style>
|
||||
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
|
||||
.err-wrap { max-width:480px; margin:80px auto; background:#fff; border-radius:16px; padding:40px; text-align:center; box-shadow:0 2px 8px rgba(0,0,0,0.06); }
|
||||
.err-icon { font-size:48px; color:#f59e0b; margin-bottom:16px; }
|
||||
.err-msg { font-size:16px; color:#374151; margin-bottom:24px; }
|
||||
.err-btn { display:inline-block; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; }
|
||||
</style>
|
||||
<div class="err-wrap">
|
||||
<div class="err-icon">⚠️</div>
|
||||
<div class="err-msg"><t t-esc="message"/></div>
|
||||
<a href="/web" class="err-btn">返回首页</a>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,485 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================
|
||||
考试模块视图:题库 / 考试发布 / 答卷 / 答题记录
|
||||
============================================================ -->
|
||||
|
||||
<!-- ===================== 题库:列表 ===================== -->
|
||||
<record id="yuthon_school_exam_question_view_tree" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.question.tree</field>
|
||||
<field name="model">yuthon.school.exam.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="question_text_short" string="题干"/>
|
||||
<field name="question_type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="course_id"/>
|
||||
<field name="chapter_id" optional="show"/>
|
||||
<field name="score"/>
|
||||
<field name="teacher_id" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 题库:表单 ===================== -->
|
||||
<record id="yuthon_school_exam_question_view_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.question.form</field>
|
||||
<field name="model">yuthon.school.exam.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="question_type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="score"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="course_id"/>
|
||||
<field name="chapter_id"/>
|
||||
<field name="teacher_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="question_text" widget="html" string="题干"/>
|
||||
<notebook>
|
||||
<page string="选项" name="options"
|
||||
invisible="question_type not in ('single_choice','multiple_choice')">
|
||||
<field name="option_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="label"/>
|
||||
<field name="content"/>
|
||||
<field name="is_correct" string="正确答案"/>
|
||||
</list>
|
||||
</field>
|
||||
<div class="text-muted mt-2">
|
||||
单选题请只勾选一个正确答案;多选题可勾选多个正确答案。
|
||||
</div>
|
||||
</page>
|
||||
<page string="答案与解析" name="answer">
|
||||
<group>
|
||||
<field name="correct_answer"
|
||||
invisible="question_type not in ('true_false','fill_blank')"
|
||||
required="question_type in ('true_false','fill_blank')"/>
|
||||
<field name="reference_answer" widget="html"
|
||||
invisible="question_type != 'essay'"/>
|
||||
</group>
|
||||
<field name="explanation" widget="html" string="答案说明 / 解析" nolabel="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 题库:搜索 ===================== -->
|
||||
<record id="yuthon_school_exam_question_view_search" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.question.search</field>
|
||||
<field name="model">yuthon.school.exam.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="question_text"/>
|
||||
<field name="course_id"/>
|
||||
<field name="chapter_id"/>
|
||||
<filter name="mine" string="我的题目" domain="[('teacher_id','=',uid)]"/>
|
||||
<filter name="single_choice" string="单选题" domain="[('question_type','=','single_choice')]"/>
|
||||
<filter name="multiple_choice" string="多选题" domain="[('question_type','=','multiple_choice')]"/>
|
||||
<filter name="true_false" string="判断题" domain="[('question_type','=','true_false')]"/>
|
||||
<filter name="fill_blank" string="填空题" domain="[('question_type','=','fill_blank')]"/>
|
||||
<filter name="essay" string="简答题" domain="[('question_type','=','essay')]"/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_course" string="按课程" context="{'group_by':'course_id'}"/>
|
||||
<filter name="group_type" string="按题型" context="{'group_by':'question_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 考试发布:列表 ===================== -->
|
||||
<record id="yuthon_school_exam_view_tree" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.tree</field>
|
||||
<field name="model">yuthon.school.exam</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-info="state == 'published'" decoration-muted="state == 'closed'">
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<field name="start_time"/>
|
||||
<field name="end_time"/>
|
||||
<field name="duration_minutes"/>
|
||||
<field name="total_score"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'published'"
|
||||
decoration-muted="state == 'closed'"
|
||||
decoration-warning="state == 'draft'"/>
|
||||
<field name="paper_count" string="答卷"/>
|
||||
<field name="submitted_count" string="已交"/>
|
||||
<field name="graded_count" string="已阅"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 考试发布:表单 ===================== -->
|
||||
<record id="yuthon_school_exam_view_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.form</field>
|
||||
<field name="model">yuthon.school.exam</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_publish" type="object" string="发布考试"
|
||||
class="oe_highlight" invisible="state != 'draft'"/>
|
||||
<button name="action_close" type="object" string="截止考试"
|
||||
invisible="state != 'published'"/>
|
||||
<button name="action_reopen" type="object" string="重新开放"
|
||||
invisible="state != 'closed'"/>
|
||||
<button name="action_draft" type="object" string="撤回草稿"
|
||||
invisible="state != 'draft' and state != 'published'"
|
||||
confirm="撤回草稿将删除已生成的待考答卷,是否继续?"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,published,closed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="考试名称"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="course_id"/>
|
||||
<field name="chapter_ids" widget="many2many_tags"/>
|
||||
<field name="teacher_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="start_time"/>
|
||||
<field name="end_time"/>
|
||||
<field name="duration_minutes"/>
|
||||
<field name="pass_score"/>
|
||||
<field name="total_score" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description" widget="html" string="考试说明"/>
|
||||
<notebook>
|
||||
<page string="试卷题目" name="lines">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="question_id"/>
|
||||
<field name="question_type" readonly="1"/>
|
||||
<field name="score"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="答卷" name="papers">
|
||||
<field name="paper_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="student_id"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="score"/>
|
||||
<field name="total_score"/>
|
||||
<field name="is_passed" widget="boolean_toggle" readonly="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 考试发布:搜索 ===================== -->
|
||||
<record id="yuthon_school_exam_view_search" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.search</field>
|
||||
<field name="model">yuthon.school.exam</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<filter name="mine" string="我发布的" domain="[('teacher_id','=',uid)]"/>
|
||||
<filter name="published" string="已发布" domain="[('state','=','published')]"/>
|
||||
<filter name="closed" string="已截止" domain="[('state','=','closed')]"/>
|
||||
<filter name="draft" string="草稿" domain="[('state','=','draft')]"/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_course" string="按课程" context="{'group_by':'course_id'}"/>
|
||||
<filter name="group_state" string="按状态" context="{'group_by':'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 答卷:列表 ===================== -->
|
||||
<record id="yuthon_school_exam_paper_view_tree" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.paper.tree</field>
|
||||
<field name="model">yuthon.school.exam.paper</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-success="is_passed == True" decoration-danger="is_passed == False">
|
||||
<field name="name" string="考试/学员"/>
|
||||
<field name="exam_id" string="考试名称"/>
|
||||
<field name="course_id" string="所属课程" optional="show"/>
|
||||
<field name="chapter_ids" widget="many2many_tags" string="关联章节" optional="show"/>
|
||||
<field name="student_id" optional="show"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="score"/>
|
||||
<field name="total_score"/>
|
||||
<field name="is_passed"/>
|
||||
<field name="submit_time" optional="show"/>
|
||||
<button name="action_open_take_page" type="object" string="开始考试" icon="fa-play"
|
||||
class="btn-primary" invisible="state != 'pending'"/>
|
||||
<button name="action_open_take_page" type="object" string="继续考试" icon="fa-play-circle"
|
||||
class="btn-info" invisible="state != 'in_progress'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 答卷:老师评阅表单 ===================== -->
|
||||
<record id="yuthon_school_exam_paper_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.paper.form.teacher</field>
|
||||
<field name="model">yuthon.school.exam.paper</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_grade" type="object" string="自动评阅"
|
||||
class="oe_highlight" invisible="state != 'submitted'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,in_progress,submitted,graded"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="exam_id" string="考试名称" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="course_id" string="所属课程" readonly="1"/>
|
||||
<field name="chapter_ids" widget="many2many_tags" string="关联章节" readonly="1"/>
|
||||
<field name="student_id" readonly="1"/>
|
||||
<field name="start_time" readonly="1"/>
|
||||
<field name="submit_time" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="score" readonly="1"/>
|
||||
<field name="total_score" readonly="1"/>
|
||||
<field name="pass_score" readonly="1"/>
|
||||
<field name="is_passed" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="answer_ids" readonly="0" mode="list">
|
||||
<list>
|
||||
<field name="sequence" invisible="1"/>
|
||||
<field name="question_text_short" string="题干"/>
|
||||
<field name="question_type"/>
|
||||
<field name="answer_display" string="学生作答"/>
|
||||
<field name="score"/>
|
||||
<field name="max_score"/>
|
||||
<field name="is_correct"/>
|
||||
<button name="action_view_answer_form" type="object" string="评阅" icon="fa-pencil"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 答卷:学生考试表单 ===================== -->
|
||||
<record id="yuthon_school_exam_paper_form_student" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.paper.form.student</field>
|
||||
<field name="model">yuthon.school.exam.paper</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_open_take_page" type="object" string="开始考试"
|
||||
class="oe_highlight" invisible="state != 'pending'"/>
|
||||
<button name="action_submit" type="object" string="提交答卷"
|
||||
class="oe_highlight" invisible="state != 'in_progress'"
|
||||
confirm="提交后不能再修改,是否继续?"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,in_progress,submitted,graded"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" string="考试/学员" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="exam_id" string="考试名称" readonly="1"/>
|
||||
<field name="course_id" string="所属课程" readonly="1"/>
|
||||
<field name="chapter_ids" widget="many2many_tags" string="关联章节" readonly="1"/>
|
||||
<field name="student_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="total_score" readonly="1"/>
|
||||
<field name="pass_score" readonly="1"/>
|
||||
<field name="score" readonly="1" invisible="state == 'in_progress'"/>
|
||||
<field name="is_passed" readonly="1" invisible="state == 'in_progress'"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description" widget="html" string="考试说明" readonly="1" nolabel="1"/>
|
||||
<separator string="试卷题目" invisible="state == 'pending'"/>
|
||||
<field name="answer_ids" readonly="0" mode="list"
|
||||
invisible="state in ('pending',)">
|
||||
<list>
|
||||
<field name="sequence" invisible="1"/>
|
||||
<field name="question_text_short" string="题干"/>
|
||||
<field name="question_type"/>
|
||||
<field name="answer_display" string="我的作答"/>
|
||||
<field name="score" readonly="1"/>
|
||||
<field name="max_score" readonly="1"/>
|
||||
<button name="action_view_answer_form" type="object" string="作答" icon="fa-pencil"
|
||||
invisible="parent.state not in ('in_progress',)"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 答卷:搜索 ===================== -->
|
||||
<record id="yuthon_school_exam_paper_view_search" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.paper.search</field>
|
||||
<field name="model">yuthon.school.exam.paper</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="exam_id"/>
|
||||
<field name="student_id"/>
|
||||
<filter name="submitted" string="待评阅" domain="[('state','=','submitted')]"/>
|
||||
<filter name="graded" string="已评阅" domain="[('state','=','graded')]"/>
|
||||
<filter name="mine" string="我的答卷" domain="[('user_id','=',uid)]"/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_exam" string="按考试" context="{'group_by':'exam_id'}"/>
|
||||
<filter name="group_state" string="按状态" context="{'group_by':'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 答题记录:表单(作答/评阅弹窗) ===================== -->
|
||||
<record id="yuthon_school_exam_answer_view_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.answer.form</field>
|
||||
<field name="model">yuthon.school.exam.answer</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="question_type" readonly="1"/>
|
||||
<field name="max_score" readonly="1"/>
|
||||
<field name="is_correct" readonly="1"/>
|
||||
</group>
|
||||
<field name="question_text" widget="html" readonly="1" string="题干" nolabel="1"/>
|
||||
<separator string="学生作答"/>
|
||||
<group>
|
||||
<field name="selected_option_id" string="选择答案"
|
||||
invisible="question_type != 'single_choice'"
|
||||
required="question_type == 'single_choice'"/>
|
||||
<field name="selected_option_ids" widget="many2many_checkboxes" string="选择答案"
|
||||
invisible="question_type != 'multiple_choice'"
|
||||
required="question_type == 'multiple_choice'"/>
|
||||
<field name="answer_choice" string="判断"
|
||||
invisible="question_type != 'true_false'"
|
||||
required="question_type == 'true_false'"/>
|
||||
<field name="answer_text" string="作答"
|
||||
invisible="question_type not in ('fill_blank','essay')"
|
||||
required="question_type in ('fill_blank','essay')"/>
|
||||
</group>
|
||||
<separator string="评阅" groups="college.group_teacher,base.group_system"/>
|
||||
<group groups="college.group_teacher,base.group_system">
|
||||
<field name="score" string="得分"/>
|
||||
<field name="teacher_comment" string="评语"/>
|
||||
</group>
|
||||
<separator string="答案说明"/>
|
||||
<field name="explanation" widget="html" readonly="1" string="解析" nolabel="1"/>
|
||||
<field name="reference_answer" widget="html" readonly="1" string="参考答案" nolabel="1"
|
||||
invisible="question_type != 'essay'"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 答题记录:老师评阅列表 ===================== -->
|
||||
<record id="yuthon_school_exam_answer_view_tree_grade" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.answer.tree.grade</field>
|
||||
<field name="model">yuthon.school.exam.answer</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="paper_id" readonly="1"/>
|
||||
<field name="question_text_short" string="题干"/>
|
||||
<field name="question_type"/>
|
||||
<field name="answer_display" string="学生作答"/>
|
||||
<field name="score"/>
|
||||
<field name="max_score"/>
|
||||
<field name="is_correct"/>
|
||||
<field name="teacher_comment"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 答题记录:搜索 ===================== -->
|
||||
<record id="yuthon_school_exam_answer_view_search" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.exam.answer.search</field>
|
||||
<field name="model">yuthon.school.exam.answer</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="paper_id"/>
|
||||
<field name="question_id"/>
|
||||
<filter name="submitted" string="待评阅" domain="[('paper_id.state','=','submitted')]"/>
|
||||
<filter name="essay" string="简答题" domain="[('question_type','=','essay')]"/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_paper" string="按答卷" context="{'group_by':'paper_id'}"/>
|
||||
<filter name="group_type" string="按题型" context="{'group_by':'question_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 动作 ===================== -->
|
||||
<record id="action_yuthon_school_exam_question" model="ir.actions.act_window">
|
||||
<field name="name">题库</field>
|
||||
<field name="res_model">yuthon.school.exam.question</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="yuthon_school_exam_question_view_search"/>
|
||||
<field name="context">{'search_default_mine': 1}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_yuthon_school_exam" model="ir.actions.act_window">
|
||||
<field name="name">考试管理</field>
|
||||
<field name="res_model">yuthon.school.exam</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="yuthon_school_exam_view_search"/>
|
||||
<field name="context">{'search_default_mine': 1}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_yuthon_school_exam_paper_grade" model="ir.actions.act_window">
|
||||
<field name="name">评阅试卷</field>
|
||||
<field name="res_model">yuthon.school.exam.paper</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="yuthon_school_exam_paper_form"/>
|
||||
<field name="search_view_id" ref="yuthon_school_exam_paper_view_search"/>
|
||||
<field name="domain">[('state','in',['submitted','graded'])]</field>
|
||||
</record>
|
||||
|
||||
<record id="action_yuthon_school_exam_paper_student" model="ir.actions.act_window">
|
||||
<field name="name">我的考试</field>
|
||||
<field name="res_model">yuthon.school.exam.paper</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="yuthon_school_exam_paper_form_student"/>
|
||||
<field name="search_view_id" ref="yuthon_school_exam_paper_view_search"/>
|
||||
<field name="domain">[('user_id','=',uid)]</field>
|
||||
<field name="context">{'search_default_mine': 1}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_yuthon_school_exam_result" model="ir.actions.act_window">
|
||||
<field name="name">考试结果</field>
|
||||
<field name="res_model">yuthon.school.exam.paper</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="yuthon_school_exam_paper_form"/>
|
||||
<field name="search_view_id" ref="yuthon_school_exam_paper_view_search"/>
|
||||
<field name="domain">[('state','=','graded')]</field>
|
||||
</record>
|
||||
|
||||
<record id="action_yuthon_school_exam_answer_grade" model="ir.actions.act_window">
|
||||
<field name="name">逐题评阅</field>
|
||||
<field name="res_model">yuthon.school.exam.answer</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="yuthon_school_exam_answer_view_tree_grade"/>
|
||||
<field name="search_view_id" ref="yuthon_school_exam_answer_view_search"/>
|
||||
<field name="domain">[('paper_id.state','=','submitted')]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ===================== 模块详情表单:增强「模块信息」 ===================== -->
|
||||
<record id="ir_module_module_form_info" model="ir.ui.view">
|
||||
<field name="name">ir.module.module.form.module.info</field>
|
||||
<field name="model">ir.module.module</field>
|
||||
<field name="inherit_id" ref="base.module_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- 隐藏模块作者的 website 外链 -->
|
||||
<field name="website" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</field>
|
||||
<!-- 去掉表单头部的 odoo.com 定价外链 -->
|
||||
<xpath expr="//a[contains(@href,'odoo.com/pricing')]" position="replace"/>
|
||||
<!-- 在头部之后插入「介绍 / 功能 / 知识点」三个页签 -->
|
||||
<xpath expr="//div[hasclass('clearfix')]" position="after">
|
||||
<notebook>
|
||||
<page string="介绍" name="introduction">
|
||||
<group string="模块介绍" colspan="2">
|
||||
<field name="introduction" readonly="1" nolabel="1" class="oe_styling_v8"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="功能" name="features">
|
||||
<group string="功能亮点" colspan="2">
|
||||
<field name="features" readonly="1" nolabel="1" class="oe_styling_v8"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="知识点" name="knowledge_points">
|
||||
<group string="核心知识点" colspan="2">
|
||||
<field name="knowledge_points" readonly="1" nolabel="1" class="oe_styling_v8"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 应用列表:去掉 website 列 ===================== -->
|
||||
<record id="ir_module_module_list_info" model="ir.ui.view">
|
||||
<field name="name">ir.module.module.list.module.info</field>
|
||||
<field name="model">ir.module.module</field>
|
||||
<field name="inherit_id" ref="base.module_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="website" position="attributes">
|
||||
<attribute name="column_invisible">1</attribute>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 隐藏「应用」菜单下的外部商店入口 ===================== -->
|
||||
<record id="base.menu_third_party" model="ir.ui.menu">
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
<record id="base.menu_theme_store" model="ir.ui.menu">
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
<record id="base.theme_store" model="ir.ui.menu">
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ===================== 实训模块专用看板(独立视图,不继承原生 Apps 看板) =====================
|
||||
仅「实训模块」动作使用;原生 Apps 看板保持原样。
|
||||
卡片只保留「模块信息」入口,去掉 Learn More 与 odoo.com 定价外链;
|
||||
安装/升级/卸载按钮权限直接放开给老师组(base.group_erp_manager),
|
||||
不依赖 menus.xml 对原生 base.module_view_kanban 的继承。 -->
|
||||
<record id="shixun_module_kanban_view" model="ir.ui.view">
|
||||
<field name="name">实训模块应用看板</field>
|
||||
<field name="model">ir.module.module</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban create="false" can_open="0" class="o_modules_kanban">
|
||||
<field name="to_buy"/>
|
||||
<field name="name"/>
|
||||
<field name="state"/>
|
||||
<field name="summary"/>
|
||||
<field name="website"/>
|
||||
<field name="application"/>
|
||||
<templates>
|
||||
<t t-name="menu">
|
||||
<t t-set="installed" t-value="record.state.raw_value == 'installed'"/>
|
||||
<a type="open" class="dropdown-item">模块信息</a>
|
||||
<a t-if="installed" name="button_immediate_upgrade" type="object" role="menuitem" class="dropdown-item" groups="base.group_erp_manager">升级</a>
|
||||
<a t-if="installed" name="button_uninstall_wizard" type="object" role="menuitem" class="dropdown-item" groups="base.group_erp_manager">卸载</a>
|
||||
</t>
|
||||
<t t-name="card" class="flex-row align-items-center">
|
||||
<aside>
|
||||
<field name="icon" widget="image_url" options="{'size': [50, 50]}" alt="Icon"/>
|
||||
<field t-if="record.icon_flag" name="icon_flag" class="oe_module_flag"/>
|
||||
</aside>
|
||||
<main class="me-4" t-att-title="record.shortdesc.value">
|
||||
<field class="fw-bold fs-5" name="shortdesc"/>
|
||||
<p class="text-muted small my-0 lh-sm">
|
||||
<field groups="!base.group_no_one" name="summary"/>
|
||||
<code groups="base.group_no_one"><field name="name"/></code>
|
||||
</p>
|
||||
<footer class="w-100 justify-content-between">
|
||||
<button type="object" class="btn btn-primary btn-sm" name="button_immediate_install" invisible="state != 'uninstalled'" t-if="! record.to_buy.raw_value" groups="base.group_erp_manager">安装</button>
|
||||
<div t-if="installed" class="d-flex align-items-center text-muted float-start">已安装</div>
|
||||
<a type="edit" class="btn btn-secondary btn-sm float-end" role="button">模块信息</a>
|
||||
<button invisible="state != 'to remove'" type="object" class="btn btn-sm btn-primary" name="button_uninstall_cancel" groups="base.group_erp_manager">取消卸载</button>
|
||||
<button invisible="state != 'to install'" type="object" class="btn btn-sm btn-primary" name="button_install_cancel" groups="base.group_erp_manager">取消安装</button>
|
||||
</footer>
|
||||
</main>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user