69 Commits
Author SHA1 Message Date
李鹏宇 e5c26c260d 翻译 2026-08-24 18:37:05 +08:00
zhouxufan 52f3a92f6b Merge pull request '完善教育' (#31) from zhou into master
Reviewed-on: #31
2026-08-21 19:31:14 +08:00
李鹏宇 4c03ef6a90 完善教育 2026-08-21 19:30:28 +08:00
zhouxufan 67aabfc2c1 Merge pull request '完善教育' (#30) from zhou into master
Reviewed-on: #30
2026-08-21 19:26:42 +08:00
李鹏宇 b072b7f973 完善教育 2026-08-21 19:25:35 +08:00
zhouxufan dc36d6f3c8 Merge pull request '完善教育' (#29) from zhou into master
Reviewed-on: #29
2026-08-14 17:18:35 +08:00
李鹏宇 87f2003caa 完善教育 2026-08-14 17:17:01 +08:00
zhouxufan 8a653407f2 Merge pull request '完善教育' (#28) from zhou into master
Reviewed-on: #28
2026-08-12 17:12:55 +08:00
李鹏宇 d08668ca8a 完善教育 2026-08-12 17:06:11 +08:00
zhouxufan 522562eaa0 Merge pull request '完善教育' (#26) from zhou into master
Reviewed-on: #26
2026-08-11 18:14:59 +08:00
李鹏宇 ba80a569e9 完善教育 2026-08-11 18:13:51 +08:00
zhouxufan 6452d6b8fc Merge pull request '完善教育' (#25) from zhou into master
Reviewed-on: #25
2026-08-10 15:38:40 +08:00
李鹏宇 9d3f94c966 完善教育 2026-08-10 15:36:57 +08:00
zhouxufan 7cdb9b6651 Merge pull request '完善教育' (#24) from zhou into master
Reviewed-on: #24
2026-08-10 10:49:21 +08:00
李鹏宇 651a7d94ba 完善教育 2026-08-10 10:48:26 +08:00
zhouxufan 10bc7f990e Merge pull request '完善教育' (#23) from zhou into master
Reviewed-on: #23
2026-08-07 19:00:34 +08:00
李鹏宇 df06c81be0 完善教育 2026-08-07 18:59:56 +08:00
zhouxufan 6b166d1f34 Merge pull request '完善教育' (#22) from zhou into master
Reviewed-on: #22
2026-08-07 18:53:46 +08:00
李鹏宇 2ba9f5297a 完善教育 2026-08-07 18:53:16 +08:00
zhouxufan 923a0d7e03 Merge pull request 'zhou' (#21) from zhou into master
Reviewed-on: #21
2026-08-07 18:46:03 +08:00
李鹏宇 64b0e18a74 Merge branch 'master' into zhou 2026-08-07 18:45:34 +08:00
李鹏宇 e98be48f2c 完善教育 2026-08-07 18:45:07 +08:00
wanghaoyang 49d4fb1c5c Merge pull request '菜单显示,登录卡片' (#20) from wanghy into master
Reviewed-on: #20
2026-08-07 18:17:17 +08:00
李鹏宇 0d2c9e890a 菜单显示,登录卡片 2026-08-07 18:16:09 +08:00
zhouxufan 809459e2eb Merge pull request '完善教育' (#19) from zhou into master
Reviewed-on: #19
2026-08-07 16:10:17 +08:00
李鹏宇 ca105fc4ea 完善教育 2026-08-07 16:09:46 +08:00
wanghaoyang 19a294bd2d Merge pull request '菜单显示' (#18) from wanghy into master
Reviewed-on: #18
2026-08-07 15:45:12 +08:00
李鹏宇 f42c3d13ee 菜单显示 2026-08-07 15:40:38 +08:00
wanghaoyang ca60dff018 Merge pull request '加载顺序问题' (#17) from wanghy into master
Reviewed-on: #17
2026-08-07 15:23:41 +08:00
李鹏宇 5ff714f1d6 加载顺序问题 2026-08-07 15:22:04 +08:00
zhouxufan eb7c87fad1 Merge pull request 'zhou' (#16) from zhou into master
Reviewed-on: #16
2026-08-07 12:14:40 +08:00
李鹏宇 9efb4bf916 Merge branch 'refs/heads/master' into zhou
# Conflicts:
#	college/__manifest__.py
#	college/models/__init__.py
#	college/security/ir.model.access.csv
#	college/static/src/img/about.png
2026-08-07 12:13:49 +08:00
李鹏宇 6fcad70326 模块迁移 2026-08-07 11:58:17 +08:00
wanghaoyang 1dd79a0411 Merge pull request '关于' (#14) from wanghy into master
Reviewed-on: #14
2026-08-07 11:50:41 +08:00
李鹏宇 ccdb5f7111 关于 2026-08-07 11:49:59 +08:00
wanghaoyang 068ff4c571 Merge pull request '关于' (#13) from wanghy into master
Reviewed-on: #13
2026-08-07 11:24:37 +08:00
李鹏宇 7adc6fc3c0 关于 2026-08-07 11:23:19 +08:00
wanghaoyang 54eb9c6ac9 Merge pull request '登录页面' (#12) from wanghy into master
Reviewed-on: #12
2026-08-07 10:35:09 +08:00
李鹏宇 ceb085c717 登录页面 2026-08-07 10:32:16 +08:00
wanghaoyang 8707f6d14d Merge pull request '菜单,登录图片,随堂笔记' (#11) from wanghy into master
Reviewed-on: #11
2026-08-06 19:15:35 +08:00
zhouxufan 2733d863be Merge pull request '新增功能' (#10) from zhou into master
Reviewed-on: #10
2026-08-06 19:15:05 +08:00
李鹏宇 a2bd6a5c78 模块迁移 2026-08-06 19:07:15 +08:00
李鹏宇 37d8d3facc 菜单,登录图片,随堂笔记 2026-08-06 19:05:26 +08:00
李鹏宇 dc8cea7659 模块迁移 2026-08-05 19:43:20 +08:00
李鹏宇 dc250f2ae2 模块迁移 2026-08-05 19:36:42 +08:00
李鹏宇 6b0e8fe493 模块迁移 2026-08-05 19:29:35 +08:00
李鹏宇 75c22679aa 模块迁移 2026-08-05 19:20:15 +08:00
李鹏宇 5c9cb7f8ff 模块迁移 2026-08-05 19:13:51 +08:00
李鹏宇 861144f615 模块迁移 2026-08-03 12:08:03 +08:00
李鹏宇 a7165e0ce6 模块迁移 2026-08-03 12:05:33 +08:00
李鹏宇 46c6e5af14 模块迁移 2026-08-03 12:04:46 +08:00
李鹏宇 3a26822e71 模块迁移 2026-08-03 12:03:31 +08:00
李鹏宇 eded38735a 模块迁移 2026-08-03 11:49:31 +08:00
李鹏宇 f453820775 模块迁移 2026-08-03 11:37:50 +08:00
李鹏宇 6efe2130b8 模块迁移 2026-08-03 11:07:03 +08:00
李鹏宇 72e9b84d56 模块迁移 2026-08-03 10:22:37 +08:00
李鹏宇 1a0d1c57e1 模块迁移 2026-08-03 10:19:39 +08:00
李鹏宇 67c61b7cd1 模块迁移 2026-08-03 10:17:53 +08:00
李鹏宇 8f6a9b3381 模块迁移 2026-08-03 10:00:02 +08:00
李鹏宇 59645cbed0 模块迁移 2026-08-03 09:59:10 +08:00
李鹏宇 84c8505cfb 模块迁移 2026-08-03 09:57:51 +08:00
李鹏宇 160c3dcb3a 模块迁移 2026-08-03 09:48:18 +08:00
李鹏宇 fe3585d589 模块迁移 2026-07-31 20:38:35 +08:00
李鹏宇 176da7546b 模块迁移 2026-07-31 20:38:10 +08:00
李鹏宇 cfe31fc044 模块迁移 2026-07-31 18:15:34 +08:00
李鹏宇 0d14744c15 模块迁移 2026-07-31 18:13:34 +08:00
李鹏宇 524aacbacb 模块迁移 2026-07-30 19:56:44 +08:00
李鹏宇 26c311d913 模块迁移 2026-07-28 18:50:48 +08:00
liuhaoran 36b6cb1e0e Merge pull request 'dev_haoran' (#9) from dev_haoran into master
Reviewed-on: #9
2026-06-20 20:04:34 +08:00
85 changed files with 8205 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
from . import controllers
from . import models
+70
View File
@@ -0,0 +1,70 @@
{
'name': '教育',
'version': '18.0.3.0.4',
'summary': 'Odoo 18 教学管理平台 — 师生管理 + 课程管理 + 作业/进展(老师·学生双角色)',
'description': """
Odoo 18 教学管理平台(面向老师与学生,双角色权限)
师生管理:
- 班级 / 老师 / 学生;支持批量导入(CSV 模板)
- 导入时自动创建登录账号(账号=姓名拼音,密码=手机号)
- 选课学员直接取自师���管理中的学生
课程管理:
- 由老师创建并独立管理(学生仅可读,不能新建)
- 课程形式支持「理论 / 实践」,含视频与 PPT 课件
- 每门课程含:课程介绍、课程目标、课程大纲、课程特色
- 列表 / 表单 / 看板(按状态分组)/ 搜索视图
角色权限:
- 老师:创建课程、布置与批改作业、查看全部学习进展、管理组织架构
- 学生:查看自己选课的课程、提交作业、查看自己的学习进展、参与学习交流
学习进展 / 作业习题:按角色区分菜单与数据可见范围。
""",
'category': 'Education',
'sequence': 20,
'depends': ['base', 'web', 'mail', 'base_install_request'],
'external_dependencies': {
'python': ['pypinyin'],
},
'data': [
'security/groups.xml',
'security/ir.model.access.csv',
'data/seed.xml',
'views/student_views.xml',
'views/org_views.xml',
'views/course_views.xml',
'views/chapter_views.xml',
'views/progress_views.xml',
'views/assignment_batch_wizard_views.xml',
'views/import_wizard_views.xml',
'views/homework_views.xml',
'views/exam_views.xml',
'views/exam_take_templates.xml',
'views/training_views.xml',
'views/course_import_wizard_views.xml',
'data/about_pages.xml',
'views/about_page_views.xml',
'views/menus.xml',
'views/shixun_kanban_view.xml',
'data/shixun_module_data.xml',
'data/module_info_data.xml',
'views/module_info_views.xml',
],
'assets': {
'web.assets_backend': [
'college/static/src/scss/college_theme.scss',
],
'web.assets_web_dark': [
'college/static/src/scss/college_theme_dark.scss',
],
'web.assets_backend_lazy_dark': [
'college/static/src/scss/college_theme_dark.scss',
],
},
'application': True,
'installable': True,
'license': 'LGPL-3',
'author': 'Yuthon',
}
+1
View File
@@ -0,0 +1 @@
from . import exam_controller
+185
View File
@@ -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)
+239
View File
@@ -0,0 +1,239 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 系统说明 ===================== -->
<record id="about_page_system" model="yuthon.about.page">
<field name="name">系统说明</field>
<field name="category">system</field>
<field name="sequence">10</field>
<field name="active">True</field>
<field name="content"><![CDATA[
<div class="o_about_page">
<h2>系统说明</h2>
<p>本系统是基于 <b>Odoo 18</b> 构建的在线教学管理平台,面向「老师」与「学生」两类角色,覆盖课程管理、章节资料、作业批改、学习进展跟踪与随堂笔记等教学全流程。</p>
<h3>一、角色与权限</h3>
<ul>
<li><b>管理员</b>:拥有系统全部权限,可管理所有模块、用户与系统配置。</li>
<li><b>老师</b>:创建并管理课程、维护章节与资料、布置与批改作业、记录随堂笔记、查看学习进展、管理师生与组织架构。</li>
<li><b>学生</b>:学习已选课程、查看章节视频与资料、在线提交作业、查看个人学习进展、阅读随堂笔记与说明文档(只读)。</li>
</ul>
<h3>二、主要功能模块</h3>
<ul>
<li><b>课程管理</b>:老师创建课程,支持「理论 / 实践」形式,可上传视频与 PPT 课件,并维护课程介绍、目标、大纲与特色。</li>
<li><b>章节与资料</b>:每门课程包含若干章节,章节下可挂载视频、PPT 与附件,构成完整的学习目录。</li>
<li><b>作业习题</b>:老师布置作业,学生在线提交,老师可批量批改并反馈。</li>
<li><b>学习进展</b>:按章节统计完成情况,师生各自可见对应数据范围。</li>
<li><b>随堂笔记</b>:老师按章节记录富文本笔记,学生端只读。</li>
<li><b>关于</b>:系统说明与操作手册(本页)。</li>
</ul>
<h3>三、账号与登录</h3>
<ul>
<li>师生账号由管理员通过「导入师生」批量创建,登录账号为姓名拼音小写,初始密码为手机号。</li>
<li>首次登录后请尽快修改密码;忘记密码请联系管理员重置。</li>
</ul>
<h3>四、技术说明</h3>
<p>本系统基于 Odoo 18 框架开发,采用 PostgreSQL 数据库,通过 Nginx 反向代理对外提供服务。所有业务数据按角色进行访问控制,确保师生数据隔离。</p>
</div>
]]></field>
</record>
<!-- ===================== 操作手册 ===================== -->
<record id="about_page_operation" model="yuthon.about.page">
<field name="name">操作手册</field>
<field name="category">operation</field>
<field name="sequence">20</field>
<field name="active">True</field>
<field name="content"><![CDATA[
<div class="o_about_page">
<h2>操作手册</h2>
<h3>老师端操作</h3>
<h4>1. 创建课程</h4>
<p>进入「课程管理 → 我的课程」,点击「新建」,填写课程名称、形式(理论 / 实践)、课程介绍、课程目标、课程大纲与课程特色,保存后点击「发布」即可对学生可见。</p>
<h4>2. 维护章节与资料</h4>
<p>在课程表单中切换到「章节」页签,新增章节并填写章节说明;通过章节下的「资料」上传视频链接或 PPT 等附件,构成课程学习目录。</p>
<h4>3. 布置与批改作业</h4>
<p>进入「作业习题 → 作业布置」新建作业并选择对应课程 / 章节;学生在「作业提交」提交后,老师在「作业批改」中查看并提交批改结果,支持批量操作。</p>
<h4>4. 记录随堂笔记</h4>
<p>进入「作业习题 → 随堂笔记」,列表按课程分组,找到对应章节后点击打开,使用富文本编辑器记录课堂要点,保存即可。学生端对该内容只读。</p>
<h4>5. 查看学习进展</h4>
<p>进入「学习管理 → 学习进展」,查看所教课程的整体完成情况及每位学生的进度。</p>
<h3>学生端操作</h3>
<h4>1. 学习课程</h4>
<p>进入「课程学习」,选择已选课程,在左侧章节目录中点击章节,右侧查看视频与资料进行学习。</p>
<h4>2. 提交作业</h4>
<p>进入「作业习题 → 作业提交」,找到对应作业,上传附件或填写内容后点击「提交」。</p>
<h4>3. 查看进展与笔记</h4>
<p>在「学习进展」中查看个人章节完成度;在「随堂笔记」中阅读老师发布的章节笔记。</p>
<h3>通用说明</h3>
<ul>
<li>登录:请使用管理员分配的访问地址进入系统。</li>
<li>密码:首次登录请修改初始密码;忘记密码请联系管理员。</li>
<li>本手册内容由管理员维护,如需补充或更正请联系管理员。</li>
</ul>
</div>
]]></field>
</record>
<!-- ===================== 产品介绍 ===================== -->
<!-- content 为全内联 style 的自包含 H5 网页(只读展示),Html 字段净化器
默认保留 style 属性、剥离 <style> 标签,因此必须用内联 style 而非 class。 -->
<record id="about_page_product" model="yuthon.about.page">
<field name="name">产品介绍</field>
<field name="category">product</field>
<field name="sequence">30</field>
<field name="active">True</field>
<field name="content"><![CDATA[
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;color:#1e293b;line-height:1.65;">
<!-- Hero -->
<div style="position:relative;background:linear-gradient(135deg,#0b1120 0%,#0e3a5c 55%,#0ea5e9 100%);color:#fff;padding:52px 32px 60px;text-align:center;overflow:hidden;">
<div style="position:absolute;top:-60px;right:-40px;width:220px;height:220px;border-radius:50%;background:rgba(56,189,248,0.16);"></div>
<div style="position:absolute;bottom:-80px;left:-30px;width:260px;height:260px;border-radius:50%;background:rgba(250,204,21,0.12);"></div>
<div style="position:relative;z-index:1;display:inline-flex;align-items:center;gap:10px;padding:8px 18px;background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.30);border-radius:999px;font-size:15px;font-weight:600;margin-bottom:20px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:7px;background:#ffd43b;color:#0b1120;font-weight:800;font-family:monospace;">P</span>
python企业实训平台
</div>
<h1 style="position:relative;z-index:1;margin:0 0 12px;font-size:36px;font-weight:800;letter-spacing:1px;color:#fff !important;">从 Python 语法到企业级项目实战</h1>
<p style="position:relative;z-index:1;margin:0 0 22px;font-size:16px;opacity:0.92;font-weight:400;color:#fff !important;">面向高校与职业院校的工程化 Python 实训平台,把企业真实开发场景搬进课堂</p>
<div style="position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:center;gap:10px;">
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">语法基础</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">Web 开发</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">数据分析</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">自动化运维</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">企业项目</span>
</div>
</div>
<!-- 数据条 -->
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:16px;padding:28px 20px 8px;">
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#0ea5e9;">6</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">阶段化实训体系</div>
</div>
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#2563eb;">20+</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">企业级项目案例</div>
</div>
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#7c3aed;">1:1</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">真实企业开发环境</div>
</div>
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#16a34a;">Odoo 18</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">企业级技术底座</div>
</div>
</div>
<!-- 概述 -->
<div style="padding:18px 24px 0;">
<p style="margin:0;font-size:15px;color:#334155;">平台以 <b>Odoo 18</b> 为底座,围绕 Python 工程化实训重构教学链路:把企业的版本控制、代码评审、持续集成与云端编码环境原样搬进课堂。学员不再孤立地「学语法」,而是以<b>真实项目</b>为主线,在导师带领下走完「需求 → 编码 → 评审 → 部署」的完整闭环,让实训成果直接对接企业岗位能力要求。</p>
</div>
<!-- 核心功能卡片墙 -->
<div style="padding:20px 24px 4px;">
<h2 style="margin:0 0 14px;font-size:20px;font-weight:700;color:#0f172a;">核心功能</h2>
<div style="display:flex;flex-wrap:wrap;gap:14px;">
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#38bdf8,#0ea5e9);color:#fff;font-size:16px;">📚</span>
<b style="font-size:15px;color:#0f172a;">阶段化实训课程</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">语法基础 → Web 开发 → 数据分析 → 自动化运维,分阶课程搭配视频 / PPT / 案例,循序渐进。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#ffd43b,#f59e0b);color:#0b1120;font-size:16px;">⌨️</span>
<b style="font-size:15px;color:#0f172a;">在线编码沙箱</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">内置云端 Python 运行环境,免配置即可写代码、跑结果,练习与验证实时完成。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#fb923c,#f97316);color:#fff;font-size:16px;">🏗️</span>
<b style="font-size:15px;color:#0f172a;">企业项目库</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">汇集电商、数据看板、接口服务等 20+ 真实项目案例,学员按岗位方向挑选项目实战。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#a78bfa,#8b5cf6);color:#fff;font-size:16px;">✅</span>
<b style="font-size:15px;color:#0f172a;">作业评审闭环</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">学员提交代码 / 结果,导师在线批阅并给反馈,模拟企业 Code Review 流程。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#60a5fa,#3b82f6);color:#fff;font-size:16px;">📈</span>
<b style="font-size:15px;color:#0f172a;">学习进展看板</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">按项目 / 章节统计完成度与代码质量,师生各有视角,实训进度一目了然。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#4ade80,#22c55e);color:#fff;font-size:16px;">🎯</span>
<b style="font-size:15px;color:#0f172a;">岗位能力画像</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">沉淀学员项目经历与技能标签,生成能力画像,直接对标企业招聘需求。</p>
</div>
</div>
</div>
<!-- 流程 -->
<div style="padding:20px 24px 4px;">
<h2 style="margin:0 0 14px;font-size:20px;font-weight:700;color:#0f172a;">实训链路</h2>
<div style="display:flex;flex-wrap:wrap;gap:12px;">
<div style="flex:1 1 300px;min-width:260px;background:linear-gradient(135deg,#eff6ff,#f0f9ff);border:1px solid #bfdbfe;border-radius:14px;padding:16px 18px;">
<div style="font-size:14px;font-weight:700;color:#1d4ed8;margin-bottom:6px;">导师端</div>
<div style="font-size:14px;color:#334155;">配置阶段课程 → 派发企业项目 → 批阅代码与结果 → 沉淀项目笔记 → 跟踪班级实训进度</div>
</div>
<div style="flex:1 1 300px;min-width:260px;background:linear-gradient(135deg,#f0fdf4,#ecfdf5);border:1px solid #bbf7d0;border-radius:14px;padding:16px 18px;">
<div style="font-size:14px;font-weight:700;color:#15803d;margin-bottom:6px;">学员端</div>
<div style="font-size:14px;color:#334155;">进入项目实战 → 在线编码与运行 → 提交作业等待评审 → 查看能力画像与进阶建议</div>
</div>
</div>
</div>
<!-- 优势 -->
<div style="padding:20px 24px 8px;">
<h2 style="margin:0 0 14px;font-size:20px;font-weight:700;color:#0f172a;">为什么选我们</h2>
<div style="display:flex;flex-wrap:wrap;gap:14px;">
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #0ea5e9;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">真项目真环境</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">复刻企业 Git / CI / 云编码流程,实训即上岗预演。</p>
</div>
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #2563eb;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">成果可量化</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">代码提交、评审、完成度全程留痕,能力看得见。</p>
</div>
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #7c3aed;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">岗位对齐</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">项目方向对标 Python 开发 / 数据分析 / 运维岗位。</p>
</div>
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #16a34a;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">开箱即用</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">基于 Odoo 18,手机 / 平板 / 电脑多端随时实训。</p>
</div>
</div>
</div>
<!-- 页脚 -->
<div style="margin-top:24px;padding:18px 24px;background:#0f172a;color:#94a3b8;font-size:13px;text-align:center;">
python企业实训平台 · 基于 Odoo 18 构建 · 让每一次练习都更接近真实开发
</div>
</div>
]]></field>
</record>
</odoo>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- 为 school 自建模块填充「功能亮点 / 模块介绍」文案(幂等,每次升级执行) -->
<function model="ir.module.module" name="populate_module_info"/>
</odoo>
+185
View File
@@ -0,0 +1,185 @@
<odoo>
<!-- ===================== 登录账号(演示用,密码均为 123456) ===================== -->
<record id="user_teacher_wang" model="res.users">
<field name="name">王老师</field>
<field name="login">teacher_wang</field>
<field name="password">123456</field>
<field name="groups_id" eval="[(6, 0, [ref('base.group_user'), ref('group_teacher')])]"/>
</record>
<record id="user_student_wang" model="res.users">
<field name="name">小王</field>
<field name="login">student_wang</field>
<field name="password">123456</field>
<field name="groups_id" eval="[(6, 0, [ref('base.group_user'), ref('group_student')])]"/>
</record>
<!-- ===================== 老师 ===================== -->
<record id="teacher_wang" model="yuthon.school.teacher">
<field name="name">王老师</field>
<field name="user_id" ref="user_teacher_wang"/>
<field name="teacher_no">T2025001</field>
<field name="title">lecturer</field>
<field name="phone">13900000001</field>
</record>
<!-- ===================== 班级 ===================== -->
<record id="class_se_1" model="yuthon.school.class">
<field name="name">软件工程1班</field>
<field name="head_teacher_id" ref="teacher_wang"/>
</record>
<!-- ===================== 学生 ===================== -->
<record id="student_wang" model="yuthon.school.student">
<field name="name">小王</field>
<field name="user_id" ref="user_student_wang"/>
<field name="student_number">2025010100</field>
<field name="gender">male</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">computer</field>
<field name="phone">13800000000</field>
</record>
<record id="student_01" model="yuthon.school.student">
<field name="name">陈小明</field>
<field name="student_number">2025010101</field>
<field name="gender">male</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">computer</field>
<field name="phone">13800000001</field>
</record>
<record id="student_02" model="yuthon.school.student">
<field name="name">李华</field>
<field name="student_number">2025010102</field>
<field name="gender">female</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">software</field>
<field name="phone">13800000002</field>
</record>
<record id="student_03" model="yuthon.school.student">
<field name="name">王芳</field>
<field name="student_number">2025010103</field>
<field name="gender">female</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">computer</field>
<field name="phone">13800000003</field>
</record>
<record id="student_04" model="yuthon.school.student">
<field name="name">张伟</field>
<field name="student_number">2025010104</field>
<field name="gender">male</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">software</field>
<field name="phone">13800000004</field>
</record>
<!-- ===================== 课程(老师:王老师) ===================== -->
<record id="course_odoo_basic" model="yuthon.school.course">
<field name="name">Odoo 18 框架入门</field>
<field name="code">ODOO-101</field>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="college">computer</field>
<field name="semester">2025_1</field>
<field name="course_type">theory</field>
<field name="hours">32</field>
<field name="start_date">2025-09-01</field>
<field name="end_date">2026-01-15</field>
<field name="introduction"><![CDATA[<p>面向零基础开发者,系统讲解 Odoo 18 的模块结构、模型(Model)、视图(View)与控制器(Controller),带你跑通第一个自定义模块。</p>]]></field>
<field name="objectives"><![CDATA[<ul><li>理解 Odoo 的 MVC 架构</li><li>能独立创建并安装一个模块</li><li>掌握模型字段与关系(M2O / O2M / M2M)</li></ul>]]></field>
<field name="syllabus"><![CDATA[<ol><li>开发环境搭建</li><li>模块清单与目录结构</li><li>模型定义与字段类型</li><li>视图(list / form / kanban)</li><li>菜单与权限(ACL)</li></ol>]]></field>
<field name="features"><![CDATA[<p>配套视频讲解 + 可运行示例代码,支持线下实训环境动手练习。</p>]]></field>
<field name="student_ids" eval="[(6, 0, [ref('student_wang'), ref('student_01'), ref('student_02'), ref('student_03')])]"/>
<field name="state">published</field>
</record>
<record id="course_odoo_views" model="yuthon.school.course">
<field name="name">Odoo 视图与前端进阶</field>
<field name="code">ODOO-201</field>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="college">software</field>
<field name="semester">2025_2</field>
<field name="course_type">practice</field>
<field name="hours">64</field>
<field name="start_date">2026-02-20</field>
<field name="end_date">2026-06-30</field>
<field name="introduction"><![CDATA[<p>深入 Odoo 的视图体系:列表、表单、看板、搜索、图形,以及 CSS 与 JS 资产定制。</p>]]></field>
<field name="objectives"><![CDATA[<ul><li>掌握 list/form/kanban 视图语法</li><li>能用 inherit 扩展已有视图</li><li>理解静态资源(assets)加载机制</li></ul>]]></field>
<field name="syllabus"><![CDATA[<ol><li>视图类型总览</li><li>视图继承与 xpath</li><li>看板与搜索视图</li><li>前端资产与主题定制</li></ol>]]></field>
<field name="features"><![CDATA[<p>视频 + PPT 双形态,含实操演练。</p>]]></field>
<field name="student_ids" eval="[(6, 0, [ref('student_02'), ref('student_04')])]"/>
<field name="state">draft</field>
</record>
<record id="course_odoo_ppt_demo" model="yuthon.school.course">
<field name="name">Odoo 报表开发(PPT 讲义)</field>
<field name="code">ODOO-301</field>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="college">computer</field>
<field name="semester">2026_1</field>
<field name="course_type">theory</field>
<field name="hours">48</field>
<field name="start_date">2026-09-01</field>
<field name="end_date">2027-01-10</field>
<field name="introduction"><![CDATA[<p>以 PPT 讲义为主,讲解 QWeb 报表、PDF 打印与数据导出。</p>]]></field>
<field name="objectives"><![CDATA[<ul><li>掌握 QWeb 报表模板</li><li>能定制 PDF 打印</li></ul>]]></field>
<field name="syllabus"><![CDATA[<ol><li>报表模型与 action</li><li>QWeb 模板语法</li><li>打印与导出</li></ol>]]></field>
<field name="features"><![CDATA[<p>适合线下培训,讲师直接分发 PPT。</p>]]></field>
<field name="state">draft</field>
</record>
<!-- ===================== 课程章节(老师端维护,学生端作为学习目录,同源联动) ===================== -->
<record id="chapter_odoo_basic_1" model="yuthon.school.course.chapter">
<field name="course_id" ref="course_odoo_basic"/>
<field name="name">1. 开发环境搭建</field>
<field name="sequence">10</field>
<field name="description"><![CDATA[<p>安装 PostgreSQL 与 Python 虚拟环境,克隆 Odoo 18 源码并成功启动 <code>odoo-bin</code>。</p>]]></field>
<field name="completed_student_ids" eval="[(6, 0, [ref('student_wang'), ref('student_01')])]"/>
</record>
<record id="chapter_odoo_basic_2" model="yuthon.school.course.chapter">
<field name="course_id" ref="course_odoo_basic"/>
<field name="name">2. 模块清单与目录结构</field>
<field name="sequence">20</field>
<field name="description"><![CDATA[<p>认识 <code>__manifest__.py</code>,掌握 models / views / data 目录约定与加载顺序。</p>]]></field>
</record>
<!-- ===================== 学习进展(progress_rate / status 现由章节完成情况自动计算,不再手动写入) ===================== -->
<record id="progress_01" model="yuthon.school.progress">
<field name="course_id" ref="course_odoo_basic"/>
<field name="student_id" ref="student_wang"/>
<field name="last_study_date">2025-10-01</field>
<field name="score">85</field>
</record>
<record id="progress_02" model="yuthon.school.progress">
<field name="course_id" ref="course_odoo_basic"/>
<field name="student_id" ref="student_01"/>
<field name="score">92</field>
</record>
<record id="progress_03" model="yuthon.school.progress">
<field name="course_id" ref="course_odoo_basic"/>
<field name="student_id" ref="student_02"/>
</record>
<!-- ===================== 作业习题 ===================== -->
<record id="assignment_01" model="yuthon.school.assignment">
<field name="name">搭建第一个 Odoo 模块</field>
<field name="course_id" ref="course_odoo_basic"/>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="description"><![CDATA[<p>参考课程大纲第 3 节,创建一个名为 <code>my_first</code> 的模块,包含至少一个模型和菜单。</p>]]></field>
<field name="due_date">2025-10-15</field>
<field name="state">published</field>
</record>
<record id="submission_01" model="yuthon.school.assignment.submission">
<field name="name">陈小明 - 搭建第一个 Odoo 模块</field>
<field name="assignment_id" ref="assignment_01"/>
<field name="student_id" ref="student_01"/>
<field name="content"><![CDATA[<p>已完成模型定义与菜单,模块可正常安装。</p>]]></field>
<field name="score">90</field>
<field name="feedback">完成度很好,注意命名规范。</field>
<field name="state">graded</field>
</record>
<record id="submission_02" model="yuthon.school.assignment.submission">
<field name="name">李华 - 搭建第一个 Odoo 模块</field>
<field name="assignment_id" ref="assignment_01"/>
<field name="student_id" ref="student_02"/>
<field name="content"><![CDATA[<p>已提交模块压缩包。</p>]]></field>
<field name="state">submitted</field>
</record>
</odoo>
+31
View File
@@ -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>
+14
View File
@@ -0,0 +1,14 @@
from . import teacher
from . import school_class
from . import student
from . import course
from . import chapter
from . import progress
from . import assignment
from . import assignment_batch_wizard
from . import import_wizard
from . import about_page
from . import training
from . import course_import_wizard
from . import ir_module_module_info
from . import exam
+23
View File
@@ -0,0 +1,23 @@
from odoo import fields, models
class YuthonAboutPage(models.Model):
"""关于页:系统说明 / 操作手册等静态富文本内容。
每条记录对应一个说明页面,菜单通过 act_window 的 res_id 直接打开预置记录,
师生均可阅读;老师 / 管理员可编辑内容。
"""
_name = 'yuthon.about.page'
_description = '关于页(系统说明 / 操作手册)'
_order = 'sequence, id'
name = fields.Char(string='标题', required=True, translate=True)
category = fields.Selection([
('system', '系统说明'),
('operation', '操作手册'),
('product', '产品介绍'),
], string='分类', default='system', required=True)
content = fields.Html(string='内容', sanitize=True, translate=True)
sequence = fields.Integer(string='排序', default=10)
active = fields.Boolean(string='已发布', default=True)
+245
View File
@@ -0,0 +1,245 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
import io
import zipfile
import base64
class SchoolAssignment(models.Model):
"""作业习题——教师布置给某门课程的学习任务。"""
_name = 'yuthon.school.assignment'
_description = '作业习题'
_order = 'due_date, id'
name = fields.Char(string='作业标题', required=True)
course_id = fields.Many2one(
'yuthon.school.course',
string='所属课程',
required=True,
ondelete='cascade',
domain="[('state', '=', 'published'), ('teacher_id', '=', uid)]",
help='只能选择自己已发布的课程。')
teacher_id = fields.Many2one(
'res.users', string='布置教师',
default=lambda self: self.env.user, required=True)
description = fields.Html(string='作业说明', sanitize=True)
due_date = fields.Date(string='截止日期')
attachment = fields.Binary(string='附件', attachment=True)
attachment_filename = fields.Char(string='附件名')
state = fields.Selection(
[('draft', '草稿'), ('published', '已发布'), ('closed', '已截止')],
string='状态', default='draft', index=True)
submission_ids = fields.One2many(
'yuthon.school.assignment.submission', 'assignment_id', string='提交记录')
submission_count = fields.Integer(
string='提交数', compute='_compute_submission_count', store=True)
active = fields.Boolean(string='启用', default=True)
@api.depends('submission_ids.state')
def _compute_submission_count(self):
for r in self:
r.submission_count = len(r.submission_ids.filtered(
lambda s: s.state in ('submitted', 'graded')))
def action_publish(self):
"""发布作业,仅草稿状态可发布;发布后自动为选课学员创建待提交记录。"""
for record in self:
if record.state != 'draft':
raise UserError(_('作业「%s」当前状态为 %s,请勿重复发布。') % (record.name or '', record.state))
self.write({'state': 'published'})
self._create_pending_submissions()
return True
def action_publish_batch(self):
"""批量发布:仅处理草稿状态的作业。"""
for record in self:
record.action_publish()
def _create_pending_submissions(self):
"""为课程的每个选课学员创建一条待提交记录(去重)。"""
submission_env = self.env['yuthon.school.assignment.submission'].sudo()
for assignment in self:
course = assignment.course_id
if not course or not course.student_ids:
continue
existing_students = set(
submission_env.search([
('assignment_id', '=', assignment.id),
]).mapped('student_id').ids
)
for student in course.student_ids:
if student.id in existing_students:
continue
submission_env.create({
'assignment_id': assignment.id,
'student_id': student.id,
'state': 'pending',
})
@api.model_create_multi
def create(self, vals_list):
assignments = super().create(vals_list)
assignments._check_overdue()
return assignments
def write(self, vals):
res = super().write(vals)
if any(k in vals for k in ('due_date', 'state')):
self._check_overdue()
return res
def _check_overdue(self):
"""根据截止日期自动调整状态(仅对已发布/已截止生效):
- 截止日期早于今天 → 已截止
- 截止日期为今天或以后 → 已发布(支持把过期作业改回未来日期恢复发布)
草稿状态不受截止日期影响;未填写截止日期则保持已发布。
"""
today = fields.Date.context_today(self)
for record in self:
if record.state in ('published', 'closed') and record.due_date:
target = 'closed' if record.due_date < today else 'published'
if record.state != target:
record.write({'state': target})
class SchoolAssignmentSubmission(models.Model):
"""作业提交——学员针对某次作业的作答与教师批改记录。"""
_name = 'yuthon.school.assignment.submission'
_description = '作业提交'
_order = 'submit_time desc, id'
_rec_name = 'name'
name = fields.Char(string='提交标题', readonly=True, copy=False)
assignment_id = fields.Many2one(
'yuthon.school.assignment', string='作业', required=True, ondelete='cascade')
student_id = fields.Many2one(
'yuthon.school.student', string='学员', required=True, ondelete='cascade',
default=lambda self: self._default_student())
@api.model
def _default_student(self):
return self.env['yuthon.school.student'].search(
[('user_id', '=', self.env.uid)], limit=1)
content = fields.Html(string='作答内容', sanitize=True)
attachment = fields.Binary(string='提交文件', attachment=True)
attachment_filename = fields.Char(string='文件名')
assignment_attachment = fields.Binary(
string='老师布置���附件', related='assignment_id.attachment',
readonly=True)
assignment_attachment_filename = fields.Char(
related='assignment_id.attachment_filename',
readonly=True)
submit_time = fields.Datetime(string='提交时间')
score = fields.Float(string='得分')
feedback = fields.Text(string='评语')
due_date = fields.Date(
string='截止日期',
related='assignment_id.due_date',
readonly=True, store=True)
state = fields.Selection(
[('pending', '待提交'), ('submitted', '待批改'), ('graded', '已批改')],
string='状态', default='pending', index=True)
teacher_id = fields.Many2one(
'res.users', string='批改人',
related='assignment_id.teacher_id',
readonly=True, store=False)
active = fields.Boolean(string='启用', default=True)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
assignment = self.env['yuthon.school.assignment'].browse(
vals.get('assignment_id'))
student = self.env['yuthon.school.student'].browse(
vals.get('student_id'))
a_name = assignment.name or '作业'
s_name = student.name or '学员'
vals['name'] = f'{a_name} - {s_name}的提交'
return super().create(vals_list)
@api.onchange('assignment_id', 'student_id')
def _onchange_submission_name(self):
if self.assignment_id and self.student_id and not self.name:
self.name = f'{self.assignment_id.name} - {self.student_id.name}的提交'
def action_submit(self):
"""学生点击提交:待提交 → 待批改,并记录提交时间。"""
for record in self:
if record.state != 'pending':
raise UserError(_('只有待提交状态的作业可以执行提交。'))
self.write({
'state': 'submitted',
'submit_time': fields.Datetime.now(),
})
def action_open_batch_wizard(self):
"""老师批量批改:打开批量打分向导。"""
if not self.env.user.has_group('college.group_teacher'):
raise UserError(_('只有老师可以进行批量批改。'))
return {
'name': _('批量批改'),
'type': 'ir.actions.act_window',
'res_model': 'yuthon.school.assignment.submission.batch.wizard',
'view_mode': 'form',
'target': 'new',
'context': self.env.context,
}
def action_download_attachments(self):
"""老师批量下载提交文件:把选中的提交记录的附件打包为 zip,
zip 内文件名格式为「学员姓名 + 原文件名」,并触发下载。"""
if not self.env.user.has_group('college.group_teacher'):
raise UserError(_('只有老师可以批量下载提交文件。'))
submissions = self.filtered(lambda s: s.attachment)
if not submissions:
raise UserError(_('选中的提交记录中没有可下载的附件。'))
# 收集每个 zip 内的文件名,防止重复(同一学员多个提交同名文件)
used_names = set()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for sub in submissions:
student_name = sub.student_id.name or '未命名学员'
original_filename = sub.attachment_filename or '附件'
zip_name = f'{student_name}_{original_filename}'
# 防止 zip 内部重名
if zip_name in used_names:
counter = 2
while f'{student_name}_{counter}_{original_filename}' in used_names:
counter += 1
zip_name = f'{student_name}_{counter}_{original_filename}'
used_names.add(zip_name)
zf.writestr(zip_name, base64.b64decode(sub.attachment))
zip_buffer.seek(0)
zip_b64 = base64.b64encode(zip_buffer.read()).decode('ascii')
# 临时附件存下载 zip
attachment = self.env['ir.attachment'].sudo().create({
'name': '提交文件打包.zip',
'datas': zip_b64,
'res_model': 'yuthon.school.assignment.submission',
'res_id': self[0].id if self else 0,
'type': 'binary',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'self',
}
def write(self, vals):
"""老师填写或修改分数时,自动标记为已批改。"""
if 'score' in vals and 'state' not in vals:
vals = dict(vals)
# 仅对待批改状态的记录,填分即自动变已批改
if any(r.state == 'submitted' for r in self):
vals['state'] = 'graded'
return super().write(vals)
@api.onchange('score')
def _onchange_score(self):
"""表单中填写分数后,未保存前状态栏即时显示已批改。"""
if self.state == 'submitted' and self.score:
self.state = 'graded'
+26
View File
@@ -0,0 +1,26 @@
from odoo import fields, models, api
class AssignmentSubmissionBatchWizard(models.TransientModel):
"""批量批改向导——老师选中多条提交后统一填写分数与评语。"""
_name = 'yuthon.school.assignment.submission.batch.wizard'
_description = '批量批改向导'
score = fields.Float(string='得分', required=True)
feedback = fields.Text(string='评语')
def action_apply(self):
active_ids = self.env.context.get('active_ids', [])
if not active_ids:
return {'type': 'ir.actions.act_window_close'}
submissions = self.env['yuthon.school.assignment.submission'].browse(active_ids)
# 批量批改只处理已提交(待批改)的记录
submissions = submissions.filtered(lambda s: s.state == 'submitted')
if submissions:
submissions.write({
'score': self.score,
'feedback': self.feedback,
'state': 'graded',
})
return {'type': 'ir.actions.act_window_close'}
+457
View File
@@ -0,0 +1,457 @@
import re
from odoo import api, fields, models
class CourseChapterAttachment(models.Model):
"""课程章节附件(每个章节可挂多个文件:PDF / PPT / 资料等)。"""
_name = 'yuthon.school.course.chapter.attachment'
_description = '课程章节附件'
_order = 'sequence, id'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节', ondelete='cascade')
file = fields.Binary(string='文件', attachment=True)
name = fields.Char(string='文件名')
download_url = fields.Char(string='下载链接', compute='_compute_download_url')
download_link_html = fields.Html(
string='下载', compute='_compute_download_link_html', sanitize=False)
sequence = fields.Integer(string='排序', default=10)
@api.depends('file')
def _compute_download_url(self):
"""生成附件下载直链。"""
Attachment = self.env['ir.attachment'].sudo()
for rec in self:
if rec.file:
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter.attachment'),
('res_field', '=', 'file'),
('res_id', '=', rec.id)
], limit=1, order='id desc')
rec.download_url = (
f'/web/content/{attachment.id}?download=1'
if attachment else '')
else:
rec.download_url = ''
@api.depends('download_url', 'file')
def _compute_download_link_html(self):
for rec in self:
if rec.download_url:
rec.download_link_html = (
f'<a href="{rec.download_url}" target="_blank" '
f'class="btn btn-sm btn-primary">⬇ 下载</a>'
)
else:
rec.download_link_html = ''
@api.model_create_multi
def create(self, vals_list):
return super().create(vals_list)
def write(self, vals):
return super().write(vals)
class CourseChapterExercise(models.Model):
"""章节训练习题——老师按章节布置的随堂简答题。
一个章节可挂多道;学生可以自由作答(不计分、不强制),
学员的回答存于 `yuthon.school.course.chapter.exercise.answer`。
"""
_name = 'yuthon.school.course.chapter.exercise'
_description = '章节训练习题'
_order = 'chapter_id, sequence, id'
_rec_name = 'exercise_label'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(string='排序', default=10)
question = fields.Html(string='题目', sanitize=True, required=True)
analysis = fields.Html(
string='参考解析', sanitize=True,
help='给学员的参考答案/思路(仅展示,不参与评分)')
exercise_label = fields.Char(
string='显示名', compute='_compute_exercise_label',
help='内嵌列表与表单标题用:第 N 题')
answer_ids = fields.One2many(
'yuthon.school.course.chapter.exercise.answer', 'exercise_id',
string='学员作答')
answer_count = fields.Integer(
string='作答人数', compute='_compute_answer_count', store=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程',
related='chapter_id.course_id', store=True, index=True)
# 当前学员的回答(depends_context('uid') 跟随当前登录用户切换)
my_answer_content = fields.Html(
string='我的回答(全文)',
compute='_compute_my_answer', depends_context='uid')
my_answer_preview = fields.Char(
string='我的回答',
compute='_compute_my_answer_preview', depends_context='uid',
help='去除 Html 标签后的纯文本预览,便于在列表里紧凑显示')
@api.depends('sequence')
def _compute_exercise_label(self):
for rec in self:
rec.exercise_label = f'第 {rec.sequence} 题'
@api.depends('answer_ids')
def _compute_answer_count(self):
for rec in self:
rec.answer_count = len(rec.answer_ids)
@api.depends_context('uid')
def _compute_my_answer(self):
"""当前登录学员对本习题的回答(未作答则为 False)。"""
Answer = self.env['yuthon.school.course.chapter.exercise.answer']
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
answers = {}
if student:
for a in Answer.search([
('student_id', '=', student.id),
('exercise_id', 'in', self.ids),
]):
answers[a.exercise_id.id] = a
for rec in self:
ans = answers.get(rec.id)
rec.my_answer_content = ans.content if ans else False
@api.depends_context('uid')
def _compute_my_answer_preview(self):
"""去除 Html 标签后的纯文本预览(80 字以内)。"""
for rec in self:
content = rec.my_answer_content or ''
text = re.sub(r'<[^>]+>', ' ', content)
text = re.sub(r'\s+', ' ', text).strip()
if not text:
rec.my_answer_preview = ''
elif len(text) > 80:
rec.my_answer_preview = text[:80] + '…'
else:
rec.my_answer_preview = text
def action_answer(self):
"""学生端:弹窗回答本题。
若当前用户已对本题作答过,则打开已有作答;否则新建一条空作答。
"""
self.ensure_one()
Answer = self.env['yuthon.school.course.chapter.exercise.answer']
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
if not student:
return {'type': 'ir.actions.act_window_close'}
answer = Answer.search([
('exercise_id', '=', self.id),
('student_id', '=', student.id),
], limit=1)
if not answer:
answer = Answer.create({
'exercise_id': self.id,
'student_id': student.id,
})
return {
'type': 'ir.actions.act_window',
'name': '作答',
'res_model': 'yuthon.school.course.chapter.exercise.answer',
'res_id': answer.id,
'view_mode': 'form',
'view_id': self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_form').id,
'views': [(self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_form').id,
'form')],
'target': 'new',
}
def action_view_answers(self):
"""老师端:弹窗查看本题所有学员作答。
弹出只读列表(学员 / 回答 / 作答时间),不可编辑(不参与评分)。
"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'学员作答 · {self.exercise_label}',
'res_model': 'yuthon.school.course.chapter.exercise.answer',
'view_mode': 'list,form',
'domain': [('exercise_id', '=', self.id)],
'views': [
(self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_view_tree_teacher').id,
'list'),
(False, 'form'),
],
'target': 'new',
'context': {'default_exercise_id': self.id},
}
class CourseChapterExerciseAnswer(models.Model):
"""学员对章节训练习题的回答(不计分、自由提交、可修改)。"""
_name = 'yuthon.school.course.chapter.exercise.answer'
_description = '习题作答'
_order = 'exercise_id, student_id, id'
_rec_name = 'exercise_id'
exercise_id = fields.Many2one(
'yuthon.school.course.chapter.exercise', string='习题',
required=True, ondelete='cascade', index=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, index=True)
content = fields.Html(
string='我的回答', sanitize=True,
help='学员的作答内容,可随时修改(不计分)')
submit_time = fields.Datetime(
string='最后作答时间', default=fields.Datetime.now)
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
related='exercise_id.chapter_id', store=True, index=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程',
related='exercise_id.course_id', store=True, index=True)
question_display = fields.Html(
string='题目', related='exercise_id.question', readonly=True)
@api.model_create_multi
def create(self, vals_list):
vals_list = [dict(v, submit_time=fields.Datetime.now())
for v in vals_list]
return super().create(vals_list)
def write(self, vals):
if 'content' in vals:
vals['submit_time'] = fields.Datetime.now()
return super().write(vals)
class CourseChapterTask(models.Model):
"""章节任务清单——老师按章节维护的学习任务,学生端可见但不可编辑。"""
_name = 'yuthon.school.course.chapter.task'
_description = '章节任务'
_order = 'chapter_id, sequence, id'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(string='排序', default=10)
name = fields.Char(string='任务名称', required=True)
is_done = fields.Boolean(string='已完成', default=False)
class CourseChapter(models.Model):
"""课程章节——老师端在课程内容里维护,学生端作为学习目录。
章节与课程同源:老师填写的视频、附件直接驱动学生端「课程学习」页面,
天然联动,无需额外同步。
"""
_name = 'yuthon.school.course.chapter'
_description = '课程章节'
_order = 'course_id, sequence, id'
course_id = fields.Many2one(
'yuthon.school.course', string='课程', required=True, ondelete='cascade')
name = fields.Char(string='章节标题', required=True, index=True)
sequence = fields.Integer(string='排序', default=10)
description = fields.Html(string='章节说明', sanitize=True)
class_hours = fields.Integer(string='课时', default=1, help='本章所占课时数')
# 视频与附件
video_file = fields.Binary(string='视频文件', attachment=True)
video_filename = fields.Char(string='视频文件名')
video_filename_display = fields.Char(
string='视频文件名(显示)', compute='_compute_video_filename_display',
store=True)
video_embed_html = fields.Html(
string='视频播放', compute='_compute_video_embed_html', sanitize=False)
attachment_ids = fields.One2many(
'yuthon.school.course.chapter.attachment', 'chapter_id', string='附件')
# 训练习题:一个章节可挂多道简答题(学生可自由作答,不计分)
exercise_ids = fields.One2many(
'yuthon.school.course.chapter.exercise', 'chapter_id',
string='训练习题')
exercise_count = fields.Integer(
string='习题数', compute='_compute_exercise_count', store=True)
@api.depends('exercise_ids')
def _compute_exercise_count(self):
for chapter in self:
chapter.exercise_count = len(chapter.exercise_ids)
# 任务列表:老师按章节维护的任务清单
task_ids = fields.One2many(
'yuthon.school.course.chapter.task', 'chapter_id',
string='任务列表')
# 随堂笔记:老师按章节记录的富文本笔记(与章节编辑表单共用同一字段)
notes = fields.Html(string='随堂笔记', sanitize=True)
@api.depends('video_file', 'video_filename')
def _compute_video_filename_display(self):
"""显示用:从 ir.attachment 读取视频真实文件名并持久化。"""
for chapter in self:
chapter.video_filename_display = chapter.video_filename or ''
@api.depends('video_file')
def _compute_video_embed_html(self):
"""学生端:生成可直接播放的视频 HTML。"""
Attachment = self.env['ir.attachment'].sudo()
for chapter in self:
if not chapter.video_file:
chapter.video_embed_html = False
continue
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter'),
('res_field', '=', 'video_file'),
('res_id', '=', chapter.id)
], limit=1, order='id desc')
if attachment:
chapter.video_embed_html = (
f'<video controls style="width:100%;max-width:720px;" '
f'preload="metadata">'
f'<source src="/web/content/{attachment.id}" '
f'type="video/mp4">'
f'</video>'
)
else:
chapter.video_embed_html = False
# 已完成学员(标记学习完成的学生)
completed_student_ids = fields.Many2many(
'yuthon.school.student', string='已完成学员')
# 当前用户是否已完成(仅用于列表/表单展示,不入库)
is_done_for_me = fields.Boolean(
string='我已学完', compute='_compute_is_done_for_me', store=False)
@api.depends_context('uid')
def _compute_is_done_for_me(self):
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
for r in self:
r.is_done_for_me = bool(student and student in r.completed_student_ids)
def action_open_study(self):
"""学生端:弹窗打开当前章节的学习页(带视频播放/附件下载)。"""
self.ensure_one()
study_form = self.env.ref(
'college.yuthon_school_course_chapter_study_form')
return {
'type': 'ir.actions.act_window',
'name': '学习章节',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'views': [(study_form.id, 'form')],
'target': 'new',
}
def action_open_edit(self):
"""老师端:弹窗打开当前章节的编辑页。"""
self.ensure_one()
edit_form = self.env.ref(
'college.yuthon_school_course_chapter_form_view')
return {
'type': 'ir.actions.act_window',
'name': '编辑章节',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'view_id': edit_form.id,
'views': [(edit_form.id, 'form')],
'target': 'new',
}
def action_toggle_complete(self):
"""学生端:标记 / 取消标记本章节为已完成。"""
self.ensure_one()
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
if not student:
return {'type': 'ir.actions.act_window_close'}
if student in self.completed_student_ids:
self.sudo().write({'completed_student_ids': [(3, student.id)]})
else:
self.sudo().write({'completed_student_ids': [(4, student.id)]})
# 同步更新学习进展的最近学习日期与状态
progress = self.env['yuthon.school.progress'].sudo().search([
('course_id', '=', self.course_id.id),
('student_id', '=', student.id),
], limit=1)
if progress:
progress.sudo().write({'last_study_date': fields.Date.context_today(self)})
progress.sudo().recompute_status()
# 重新打开本章节表单,刷新已完成状态
return {
'type': 'ir.actions.act_window',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'target': 'current',
}
@api.model_create_multi
def create(self, vals_list):
chapters = super().create(vals_list)
chapters._sync_video_filename()
chapters._refresh_course_progress()
return chapters
def write(self, vals):
res = super().write(vals)
if 'video_file' in vals:
self._sync_video_filename()
return res
def _sync_video_filename(self):
"""保持 video_filename 与 ir.attachment.name 一致:
- 视频被清空:清空 video_filename
- 已存在 attachment 且 chapter.video_filename 与之不一致:把附件名同步为 chapter.video_filename
"""
Attachment = self.env['ir.attachment'].sudo()
for chapter in self:
if not chapter.video_file:
# 视频被清空,连带清空文件名
if chapter.video_filename:
chapter.video_filename = ''
continue
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter'),
('res_field', '=', 'video_file'),
('res_id', '=', chapter.id)
], limit=1, order='id desc')
if attachment and chapter.video_filename \
and attachment.name != chapter.video_filename:
attachment.sudo().write({'name': chapter.video_filename})
def unlink(self):
courses = self.mapped('course_id')
res = super().unlink()
Progress = self.env['yuthon.school.progress'].sudo()
for course in courses:
Progress.search([('course_id', '=', course.id)]).recompute_status()
return res
def _refresh_course_progress(self):
"""章节创建后,刷新所属课程所有学员的进展状态。"""
Progress = self.env['yuthon.school.progress'].sudo()
for course in self.mapped('course_id'):
Progress.search([('course_id', '=', course.id)]).recompute_status()
+172
View File
@@ -0,0 +1,172 @@
from odoo import api, fields, models
_COLLEGE_SELECTION = [
('computer', '计算机学院'),
('software', '软件学院'),
('business', '商学院'),
('foreign', '外国语学院'),
('mechanic', '机械工程学院'),
('electric', '电子信息学院'),
('math', '数学学院'),
('other', '其他'),
]
_SEMESTER_SELECTION = [
('2025_1', '2025-2026学年第一学期'),
('2025_2', '2025-2026学年第二学期'),
('2026_1', '2026-2027学年第一学期'),
('2026_2', '2026-2027学年第二学期'),
]
class SchoolCourse(models.Model):
"""课程——每位老师独立管理自己的课程。
学院 / 学期 作为课程上的字段(非独立模型)。
课程模块支持 理论 / 实践。
"""
_name = 'yuthon.school.course'
_description = '课程'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'semester, college, name'
name = fields.Char(string='课程名称', required=True, index=True)
code = fields.Char(string='课程编号', index=True)
teacher_id = fields.Many2one(
'res.users',
string='任课教师',
default=lambda self: self.env.user,
required=True,
index=True,
help='课程归属教师,默认带出当前登录用户;每位老师独立管理自己的课程。',
)
# 学院 / 学期:课程上的字段,不是独立模型
college = fields.Selection(_COLLEGE_SELECTION, string='学院', index=True)
semester = fields.Selection(_SEMESTER_SELECTION, string='学期', index=True)
# 课程模块(参考站:理论 / 实践)
course_type = fields.Selection(
[('theory', '理论'), ('practice', '实践')],
string='课程模块',
default='theory',
required=True,
index=True,
)
hours = fields.Integer(string='课时', required=True, default=32)
start_date = fields.Date(string='开始日期')
end_date = fields.Date(string='结束日期')
is_public = fields.Boolean(
string='公开',
default=False,
help='开启后可在高校优质课堂等公开渠道展示推广。',
)
# 视频 / 课件(PPT 等)
video_file = fields.Binary(string='视频文件', attachment=True)
video_filename = fields.Char(string='视频文件名')
ppt_file = fields.Binary(string='PPT / 课件', attachment=True)
ppt_filename = fields.Char(string='课件文件名')
# 课程详情
introduction = fields.Html(string='课程介绍', sanitize=True)
objectives = fields.Html(string='课程目标', sanitize=True)
syllabus = fields.Html(string='课程大纲', sanitize=True)
features = fields.Html(string='课程特色', sanitize=True)
business_scenario = fields.Html(string='业务情景', sanitize=True)
# 学员分配
student_ids = fields.Many2many(
'yuthon.school.student',
string='选课学员',
help='老师为课程分配的学员(选课名单)。',
)
student_count = fields.Integer(
string='学员人数',
compute='_compute_student_count',
store=True,
)
# 看板状态
state = fields.Selection(
[('draft', '草稿'), ('published', '已发布'), ('archived', '已归档')],
string='状态',
default='draft',
required=True,
index=True,
)
active = fields.Boolean(string='启用', default=True)
# 课程章节(老师端维护,学生端作为学习目录,同源联动)
chapter_ids = fields.One2many(
'yuthon.school.course.chapter', 'course_id', string='课程章节')
chapter_count = fields.Integer(
string='章节数', compute='_compute_chapter_count', store=True)
@api.depends('student_ids')
def _compute_student_count(self):
for record in self:
record.student_count = len(record.student_ids)
@api.depends('chapter_ids')
def _compute_chapter_count(self):
for record in self:
record.chapter_count = len(record.chapter_ids)
@api.model_create_multi
def create(self, vals_list):
courses = super().create(vals_list)
for course in courses:
course._sync_progress_records()
return courses
def write(self, vals):
if 'student_ids' in vals:
# 先记录旧学员, super 写入后再按差异同步学习进展
old_students_map = {course.id: course.student_ids for course in self}
res = super().write(vals)
for course in self:
old_students = old_students_map.get(course.id, self.env['yuthon.school.student'])
course._sync_progress_records(old_students)
return res
return super().write(vals)
def _sync_progress_records(self, old_students=None):
"""根据课程选课学员变化,自动增删学习进展记录。"""
self.ensure_one()
Progress = self.env['yuthon.school.progress'].sudo()
old_students = old_students or self.env['yuthon.school.student']
new_students = self.student_ids
added = new_students - old_students
removed = old_students - new_students
if added:
existing = Progress.search([
('course_id', '=', self.id),
('student_id', 'in', added.ids),
]).mapped('student_id')
to_create = added - existing
Progress.create([{
'course_id': self.id,
'student_id': student.id,
} for student in to_create])
if removed:
Progress.search([
('course_id', '=', self.id),
('student_id', 'in', removed.ids),
]).unlink()
# 状态流转
def action_publish(self):
self.write({'state': 'published'})
def action_archive(self):
self.write({'state': 'archived'})
def action_to_draft(self):
self.write({'state': 'draft'})
+301
View File
@@ -0,0 +1,301 @@
"""课程批量导入向导 —— CSV 模板下载 + 批量创建课程(可选连带章节与章节任务)。
模板列(前 8 列为课程本体,后 5 列为可选章节/任务):
课程名称、课程编号、所属学院、学期、课程类型、课时、课程大纲、业务情景、
章节标题、章节说明、章节课时、任务名称、是否完成。
- 仅填前 8 列:行为与旧版一致,只创建课程;
- 填了「章节标题」:自动按 (课程, 章节标题) 找/建章节;
- 填了「任务名称」:在对应章节下创建任务列表明细行(is_done 由「是否完成」决定)。
同一课程 / 同一章节多行时自动复用,不重复创建。
"""
import base64
import csv
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
COURSE_TYPE_MAP = {
'理论': 'theory',
'实践': 'practice',
'theory': 'theory',
'practice': 'practice',
}
COLLEGE_NAME_MAP = {
'计算机学院': 'computer',
'软件学院': 'software',
'商学院': 'business',
'外国语学院': 'foreign',
'机械工程学院': 'mechanic',
'电子信息学院': 'electric',
'数学学院': 'math',
'其他': 'other',
'computer': 'computer',
'software': 'software',
'business': 'business',
'foreign': 'foreign',
'mechanic': 'mechanic',
'electric': 'electric',
'math': 'math',
'other': 'other',
}
class CourseImportWizard(models.TransientModel):
"""课程批量导入向导。"""
_name = 'yuthon.school.course.import.wizard'
_description = '课程批量导入'
data_file = fields.Binary(string='上传文件')
file_name = fields.Char(string='文件名')
note = fields.Html(
string='导入说明',
readonly=True,
default=lambda self: _(
'<p><b>模板说明:</b></p>'
'<ol>'
'<li>点击下方「下载模板」按钮下载 CSV 模板;</li>'
'<li>用 Excel / WPS 打开模板,按示例填写数据:<br/>'
'<b>课程名称</b>、<b>课程编号</b> 为必填;<br/>'
'<b>所属学院、学期、课程类型、课时、课程大纲、业务情景</b> 为选填;</li>'
'<li>课程类型可选:理论 / 实践</li>'
'<li><b>章节 / 任务(可选)</b>:填「章节标题」会自动建/复用章节,'
'填「任务名称」会在该章节下建一条任务列表明细(是否完成填 是/否);'
'同一课程、同一章节多行会自动合并,不会重复创建;</li>'
'<li>保存为 CSV 后上传并点击「开始导入」;</li>'
'<li>若课程名称已存在则复用,不会重复创建课程。</li>'
'</ol>'
),
)
# ─────────────────── 下载模板 ───────────────────
def action_download_template(self):
"""生成 CSV 模板并提供下载。"""
header = ['课程名称', '课程编号', '所属学院', '学期',
'课程类型(理论/实践)', '课时', '课程大纲', '业务情景',
'章节标题', '章节说明', '章节课时',
'任务名称', '是否完成']
sample = ['Python 编程基础', 'PY101', '计算机学院',
'2025-2026第1学期', '理论', '48',
'Python基础语法、数据结构、面向对象编程',
'通过电商订单管理场景贯穿课程',
'第1章 环境搭建', '本章介绍开发环境', '4',
'安装Python', '否']
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerow(sample)
attachment = self.env['ir.attachment'].sudo().create({
'name': '课程导入模板.csv',
'datas': base64.b64encode(
buf.getvalue().encode('utf-8-sig')
),
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'mimetype': 'text/csv',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'self',
}
# ─────────────────── 导入 ───────────────────
def action_import(self):
"""解析上传的 CSV 并批量创建课程。"""
self.ensure_one()
if not self.data_file:
raise UserError(_('请先上传文件。'))
content = base64.b64decode(self.data_file)
for encoding in ('utf-8-sig', 'utf-8', 'gbk', 'gb2312'):
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise UserError(_('无法识别文件编码,请使用 UTF-8 或 GBK 编码的 CSV。'))
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
raise UserError(_('CSV 文件为空或格式不正确。'))
col_map = self._normalize_columns(reader.fieldnames)
created = 0
reused_course = 0
created_chapter = 0
reused_chapter = 0
created_task = 0
skipped = 0
errors = []
for row_idx, raw_row in enumerate(reader, start=2):
row = {col_map.get(k, k): v.strip() if v else '' for k, v in raw_row.items()}
name = row.get('name', '').strip()
code = row.get('code', '').strip()
if not name:
skipped += 1
errors.append(_('第 %d 行:课程名称为必填,跳过。') % row_idx)
continue
try:
# 课程:按名称优先复用,其次按编号;都不存在才新建
course = self.env['yuthon.school.course'].sudo().search(
[('name', '=', name)], limit=1)
if not course and code:
course = self.env['yuthon.school.course'].sudo().search(
[('code', '=', code)], limit=1)
if not course:
vals = {
'name': name,
'code': code,
'state': 'draft',
}
college_raw = row.get('college', '')
if college_raw:
vals['college'] = COLLEGE_NAME_MAP.get(college_raw, 'other')
semester = row.get('semester', '')
if semester:
vals['semester'] = semester
course_type_raw = row.get('course_type', '')
if course_type_raw:
vals['course_type'] = COURSE_TYPE_MAP.get(
course_type_raw, 'theory')
hours_raw = row.get('hours', '')
if hours_raw:
try:
vals['hours'] = int(float(hours_raw))
except ValueError:
pass
syllabus = row.get('syllabus', '')
if syllabus:
vals['syllabus'] = syllabus
business_scenario = row.get('business_scenario', '')
if business_scenario:
vals['business_scenario'] = business_scenario
course = self.env['yuthon.school.course'].sudo().create(vals)
created += 1
else:
reused_course += 1
# 章节:填了章节标题才处理(按 课程+标题 复用)
chapter_title = row.get('chapter', '').strip()
chapter = None
if chapter_title:
chapter = self.env['yuthon.school.course.chapter'].sudo().search([
('course_id', '=', course.id),
('name', '=', chapter_title),
], limit=1)
if not chapter:
chapter_vals = {'course_id': course.id, 'name': chapter_title}
chapter_desc = row.get('chapter_desc', '').strip()
if chapter_desc:
chapter_vals['description'] = chapter_desc
chapter_hours_raw = row.get('chapter_hours', '').strip()
if chapter_hours_raw:
try:
chapter_vals['class_hours'] = int(float(chapter_hours_raw))
except ValueError:
pass
chapter = self.env['yuthon.school.course.chapter'].sudo().create(
chapter_vals)
created_chapter += 1
else:
reused_chapter += 1
# 任务列表:填了任务名称才在对应章节下创建明细行
task_name = row.get('task_name', '').strip()
if task_name and chapter:
is_done = row.get('is_done', '').strip() in (
'是', 'y', 'yes', 'true', '1', '完成')
task_vals = {
'chapter_id': chapter.id,
'name': task_name,
'is_done': is_done,
'sequence': 10,
}
self.env['yuthon.school.course.chapter.task'].sudo().create(
task_vals)
created_task += 1
except Exception as exc:
skipped += 1
errors.append(
_('第 %d 行「%s」导入失败:%s') % (row_idx, name, str(exc)))
summary = _(
'导入完成!\n'
'新建课程:%d(复用 %d)\n'
'新建章节:%d(复用 %d)\n'
'新建任务:%d\n'
'跳过:%d'
) % (created, reused_course, created_chapter, reused_chapter,
created_task, skipped)
if errors:
summary += '\n' + _('注意事项:\n') + '\n'.join(errors[:10])
if len(errors) > 10:
summary += _('\n... 还有 %d 条,详见日志。') % (len(errors) - 10)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('导入结果'),
'message': summary,
'type': 'info' if not errors else 'warning',
'sticky': True,
'next': {'type': 'ir.actions.act_window_close'},
},
}
def _normalize_columns(self, fieldnames):
"""列名 → 内部 key 映射。"""
mapping = {}
for f in fieldnames:
f_lower = f.strip().lower()
if f_lower in ('课程名称', 'name', '课程名'):
mapping[f] = 'name'
elif f_lower in ('课程编号', 'code', '课程号', '编号'):
mapping[f] = 'code'
elif f_lower in ('所属学院', 'college', '学院'):
mapping[f] = 'college'
elif f_lower in ('学期', 'semester'):
mapping[f] = 'semester'
elif f_lower in ('课程类型', '课程类型(理论/实践)', 'course_type', '类型'):
mapping[f] = 'course_type'
elif f_lower in ('课时', 'hours'):
mapping[f] = 'hours'
elif f_lower in ('课程大纲', 'syllabus', '大纲'):
mapping[f] = 'syllabus'
elif f_lower in ('业务情景', 'business_scenario', 'scenario'):
mapping[f] = 'business_scenario'
elif f_lower in ('章节标题', 'chapter', '章节', 'chapter_name'):
mapping[f] = 'chapter'
elif f_lower in ('章节说明', 'chapter_desc', 'chapter_description'):
mapping[f] = 'chapter_desc'
elif f_lower in ('章节课时', 'chapter_hours', 'chapter_class_hours'):
mapping[f] = 'chapter_hours'
elif f_lower in ('任务名称', 'task_name', '任务', 'task'):
mapping[f] = 'task_name'
elif f_lower in ('是否完成', 'is_done', '完成', 'done'):
mapping[f] = 'is_done'
else:
mapping[f] = f # 保留原名
return mapping
+539
View File
@@ -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',
}
+331
View File
@@ -0,0 +1,331 @@
"""师生批量导入向导 —— Excel/CSV 模板下载 + 解析导入。
模板列:(必填)姓名、角色、手机号;(可选)班级、��院、性别。
导入时自动创建 res.users(登录账号 = 姓名拼音,密码 = 手机号)
并关联到 yuthon.school.teacher / yuthon.school.student。
"""
import base64
import csv
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from pypinyin import lazy_pinyin, Style
HAS_PYPINYIN = True
except ImportError:
HAS_PYPINYIN = False
_logger.warning(
"pypinyin 未安装,导入时账号将使用姓名全拼小写。"
"请在服务器执行: pip install pypinyin"
)
def _to_login(name):
"""把中文姓名转成拼音登录账号。
- 有 pypinyin: 张三 → zhangsan
- 无 pypinyin: 张三 → 张三(保留中文,不做转拼音)
"""
if not name:
return ''
if HAS_PYPINYIN:
return ''.join(lazy_pinyin(name, style=Style.NORMAL)).lower().replace(' ', '')
# 回退:去空格 + 小写(纯英文名也能用)
return name.strip().lower().replace(' ', '')
# 角色可选值
ROLE_SELECTION = [
('teacher', '老师'),
('student', '学生'),
]
# 性别可选值
GENDER_MAP = {
'男': 'male',
'male': 'male',
'女': 'female',
'female': 'female',
}
# 学院中文→代码映射
COLLEGE_MAP = {
'计算机学院': 'computer',
'软件学院': 'software',
'商学院': 'business',
'外国语学院': 'foreign',
'机械工程学院': 'mechanic',
'电子信息学院': 'electric',
'数学学院': 'math',
'其他': 'other',
}
class ImportTeacherStudentWizard(models.TransientModel):
"""师生批量导入向导。"""
_name = 'yuthon.school.import.wizard'
_description = '师生批量导入'
data_file = fields.Binary(string='上传文件')
file_name = fields.Char(string='文件名')
note = fields.Html(
string='导入说明',
readonly=True,
default=lambda self: _(
'<p><b>模板说明:</b></p>'
'<ol>'
'<li>点击下方「下载模板」按钮下载 CSV 模板;</li>'
'<li>用 Excel / WPS 打开模板,按示例填写数据:<br/>'
'<b>姓名</b>、<b>角色</b>(老师/学生)、<b>手机号</b> 为必填;<br/>'
'<b>班级、学院、性别</b> 为选填;</li>'
'<li>保存为 CSV(逗号分隔)后在此上传并点击「开始导入」;</li>'
'<li>系统将自动创建登录账号(账号=姓名拼音,密码=手机号)并关联师生档案;</li>'
'<li>若账号已存在则跳过,不会重复创建。</li>'
'</ol>'
),
)
# ─────────────────── 下载模板 ───────────────────
def action_download_template(self):
"""生成 CSV 模板并提供下载。"""
header = ['姓名', '角色(老师/学生)', '手机号', '班级', '学院', '性别(男/女)']
sample = ['张三', '学生', '13800138001', '计算机2024级1班', '计算机学院', '男']
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerow(sample)
attachment = self.env['ir.attachment'].sudo().create({
'name': '师生导入模板.csv',
'datas': base64.b64encode(
buf.getvalue().encode('utf-8-sig')
),
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'mimetype': 'text/csv',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'self',
}
# ─────────────────── 导入 ───────────────────
def action_import(self):
"""解析上传的 CSV 并批量创建师生。"""
self.ensure_one()
if not self.data_file:
raise UserError(_('请先上传文件。'))
content = base64.b64decode(self.data_file)
# 尝试常见编码
for encoding in ('utf-8-sig', 'utf-8', 'gbk', 'gb2312'):
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise UserError(_('无法识别文件编码,请使用 UTF-8 或 GBK 编码的 CSV。'))
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
raise UserError(_('CSV 文件为空或格式不正确。'))
# 规范化列名(兼容中英文列头)
col_map = self._normalize_columns(reader.fieldnames)
created_users = 0
created_teachers = 0
created_students = 0
skipped = 0
errors = []
for row_idx, raw_row in enumerate(reader, start=2): # 第1行是表头
row = {col_map.get(k, k): v.strip() if v else '' for k, v in raw_row.items()}
name = row.get('name', '')
role = row.get('role', '').strip()
phone = row.get('phone', '').strip()
class_name = row.get('class_name', '').strip()
college_name = row.get('college', '').strip()
gender_raw = row.get('gender', '').strip()
# 校验必填
if not name or not role or not phone:
skipped += 1
errors.append(_('第 %d 行:姓名/角色/手机号为必填,跳过。') % row_idx)
continue
role_lower = role.lower()
if role_lower not in ('老师', 'teacher', '学生', 'student'):
skipped += 1
errors.append(
_('第 %d 行:角色「%s」无效,应为 老师/学生,跳过。') % (row_idx, role))
continue
is_teacher = role_lower in ('老师', 'teacher')
try:
# 1. 创建/获取 res.users
login = _to_login(name)
if not login:
skipped += 1
errors.append(_('第 %d 行:无法生成登录账号,跳过。') % row_idx)
continue
user = self._get_or_create_user(login, name, phone, is_teacher)
if user is None:
skipped += 1
errors.append(
_('第 %d 行:账号 %s 已存在且类型冲突,跳过。') % (row_idx, login))
continue
# 2. 创建师生档案
if is_teacher:
self._create_teacher(user, name, phone)
created_teachers += 1
else:
self._create_student(
user, name, phone, class_name, college_name, gender_raw)
created_students += 1
created_users += 1
except Exception as exc:
skipped += 1
errors.append(
_('第 %d 行「%s」导入失败:%s') % (row_idx, name, str(exc)))
# 汇总消息
summary = _(
'导入完成!\n'
'新建用户:%d\n'
'老师:%d\n'
'学生:%d\n'
'跳过:%d\n'
) % (created_users, created_teachers, created_students, skipped)
if errors:
summary += '\n' + _('注意事项:\n') + '\n'.join(errors[:10])
if len(errors) > 10:
summary += _('\n... 还有 %d 条,详见日志。') % (len(errors) - 10)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('导入结果'),
'message': summary,
'type': 'info' if not errors else 'warning',
'sticky': True,
'next': {'type': 'ir.actions.act_window_close'},
},
}
# ───────────── 辅助方法 ─────────────
def _normalize_columns(self, fieldnames):
"""把各种可能的列名映射到标准 key。"""
mapping = {}
for f in fieldnames:
f_lower = f.strip().lower()
if f_lower in ('姓名', 'name', '名字'):
mapping[f] = 'name'
elif f_lower in ('角色', 'role', '角色(老师/学生)', '身份'):
mapping[f] = 'role'
elif f_lower in ('手机号', 'phone', '电话', '手机', '联系电话'):
mapping[f] = 'phone'
elif f_lower in ('班级', 'class', 'class_name', '所在班级'):
mapping[f] = 'class_name'
elif f_lower in ('学院', 'college', 'college_name', '所属学院'):
mapping[f] = 'college'
elif f_lower in ('性别', '性别(男/女)', 'gender'):
mapping[f] = 'gender'
else:
mapping[f] = f
return mapping
def _get_or_create_user(self, login, name, phone, is_teacher):
"""获取或创建 res.users,返回 user 记录或 None(冲突时)。"""
group_teacher = self.env.ref('college.group_teacher', raise_if_not_found=False)
group_student = self.env.ref('college.group_student', raise_if_not_found=False)
Portal = self.env.ref('base.group_portal', raise_if_not_found=False)
existing = self.env['res.users'].sudo().search(
[('login', '=', login)], limit=1)
if existing:
# 已存在:检查角色兼容
if is_teacher and group_teacher and group_teacher not in existing.groups_id:
existing.sudo().write({'groups_id': [(4, group_teacher.id)]})
elif not is_teacher and group_student and group_student not in existing.groups_id:
existing.sudo().write({'groups_id': [(4, group_student.id)]})
return existing
groups = []
if is_teacher and group_teacher:
groups.append(group_teacher.id)
elif not is_teacher and group_student:
groups.append(group_student.id)
if Portal:
groups.append(Portal.id)
return self.env['res.users'].sudo().create({
'name': name,
'login': login,
'password': phone,
'groups_id': [(6, 0, groups)],
})
def _create_teacher(self, user, name, phone):
"""创建老师档案(去重)。"""
existing = self.env['yuthon.school.teacher'].sudo().search(
[('user_id', '=', user.id)], limit=1)
if existing:
return existing
return self.env['yuthon.school.teacher'].sudo().create({
'name': name,
'user_id': user.id,
'phone': phone,
})
def _create_student(self, user, name, phone, class_name, college_name, gender_raw):
"""创建学生档案(去重)��"""
existing = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', user.id)], limit=1)
if existing:
return existing
vals = {
'name': name,
'user_id': user.id,
'phone': phone,
}
if class_name:
cls = self.env['yuthon.school.class'].sudo().search(
[('name', 'ilike', class_name)], limit=1)
if cls:
vals['class_id'] = cls.id
else:
# 自动创建班级
cls = self.env['yuthon.school.class'].sudo().create({
'name': class_name,
})
vals['class_id'] = cls.id
if college_name:
vals['college'] = COLLEGE_MAP.get(college_name, 'other')
if gender_raw:
vals['gender'] = GENDER_MAP.get(gender_raw, 'male')
return self.env['yuthon.school.student'].sudo().create(vals)
+233
View File
@@ -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)
+69
View File
@@ -0,0 +1,69 @@
from odoo import fields, models, api
class SchoolProgress(models.Model):
"""学习进展——记录每位学员在每门课程中的学习情况。"""
_name = 'yuthon.school.progress'
_description = '学习进展'
_order = 'course_id, student_id'
_rec_name = 'name'
name = fields.Char(string='名称', compute='_compute_name', store=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程', required=True, ondelete='cascade')
student_id = fields.Many2one(
'yuthon.school.student', string='学员', required=True, ondelete='cascade')
teacher_id = fields.Many2one(
'res.users', string='任课教师',
related='course_id.teacher_id', readonly=True, store=True)
# 章节进度(基于课程章节完成情况动态计算,仅用于展示,不入库)
chapter_total = fields.Integer(string='章节总数', compute='_compute_chapter_progress')
chapter_done = fields.Integer(string='已完成章节', compute='_compute_chapter_progress')
progress_rate = fields.Float(string='学习进度(%)', compute='_compute_chapter_progress')
# 状态:普通可写字段(搜索/过滤需用 stored 字段),由章节完成情况触发更新
status = fields.Selection(
[('not_started', '未开始'), ('in_progress', '进行中'), ('completed', '已完成')],
string='状态', default='not_started', index=True, required=True)
last_study_date = fields.Date(string='最近学习日期')
score = fields.Float(string='综合得分')
note = fields.Text(string='备注')
active = fields.Boolean(string='启用', default=True)
def _compute_chapter_progress(self):
"""根据课程章节的「已完成学员」计算该学员的进度(展示用)。"""
for r in self:
chapters = r.course_id.chapter_ids if r.course_id else self.env['yuthon.school.course.chapter']
total = len(chapters)
done = sum(1 for c in chapters if r.student_id in c.completed_student_ids)
r.chapter_total = total
r.chapter_done = done
r.progress_rate = (done / total * 100.0) if total else 0.0
def recompute_status(self):
"""依据章节完成情况刷新状态(在章节完成状态变化时调用)。"""
for r in self:
total = r.chapter_total
done = r.chapter_done
if total == 0 or done == 0:
new_status = 'not_started'
elif done >= total:
new_status = 'completed'
else:
new_status = 'in_progress'
if r.status != new_status:
r.write({'status': new_status})
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
recs.recompute_status()
return recs
@api.depends('course_id', 'student_id')
def _compute_name(self):
for r in self:
r.name = '%s · %s' % (r.course_id.name or '', r.student_id.name or '')
+23
View File
@@ -0,0 +1,23 @@
from odoo import api, fields, models
class SchoolClass(models.Model):
"""班级——归属一名班主任(老师),包含若干学生。"""
_name = 'yuthon.school.class'
_description = '班级'
_order = 'name'
name = fields.Char(string='班级名称', required=True, index=True)
head_teacher_id = fields.Many2one(
'yuthon.school.teacher', string='班主任', ondelete='set null')
student_ids = fields.One2many(
'yuthon.school.student', 'class_id', string='学生')
student_count = fields.Integer(
string='学生人数', compute='_compute_student_count', store=True)
active = fields.Boolean(string='启用', default=True)
@api.depends('student_ids')
def _compute_student_count(self):
for record in self:
record.student_count = len(record.student_ids)
+35
View File
@@ -0,0 +1,35 @@
from odoo import fields, models
_COLLEGE_SELECTION = [
('computer', '计算机学院'),
('software', '软件学院'),
('business', '商学院'),
('foreign', '外国语学院'),
('mechanic', '机械工程学院'),
('electric', '电子信息学院'),
('math', '数学学院'),
('other', '其他'),
]
class SchoolStudent(models.Model):
"""学生档案——来自组织架构,可关联登录账号用于学生端提交/查看。"""
_name = 'yuthon.school.student'
_description = '学生'
_order = 'student_number, name'
name = fields.Char(string='姓名', required=True, index=True)
user_id = fields.Many2one(
'res.users', string='登录账号', ondelete='restrict',
help='关联该学生的 Odoo 登录账号;不填则仅作为选课名单,无法独立登录。')
student_number = fields.Char(string='学号', index=True)
gender = fields.Selection(
[('male', '男'), ('female', '女')], string='性别')
class_id = fields.Many2one(
'yuthon.school.class', string='班级', ondelete='set null')
college = fields.Selection(_COLLEGE_SELECTION, string='学院')
phone = fields.Char(string='联系电话')
email = fields.Char(string='邮箱')
note = fields.Text(string='备注')
active = fields.Boolean(string='启用', default=True)
+24
View File
@@ -0,0 +1,24 @@
from odoo import fields, models
class SchoolTeacher(models.Model):
"""老师档案——关联 Odoo 登录账号,老师可创建课程、批改作业。"""
_name = 'yuthon.school.teacher'
_description = '老师'
_order = 'teacher_no, name'
name = fields.Char(string='姓名', required=True, index=True)
user_id = fields.Many2one(
'res.users', string='登录账号', required=True,
ondelete='restrict', unique=True,
help='关联该老师的 Odoo 登录账号,用于角色权限判断。')
teacher_no = fields.Char(string='工号', index=True)
title = fields.Selection(
[('lecturer', '讲师'), ('associate', '副教授'), ('professor', '教授')],
string='职称')
phone = fields.Char(string='联系电话')
email = fields.Char(string='邮箱')
class_ids = fields.One2many(
'yuthon.school.class', 'head_teacher_id', string='担任班主任的班级')
active = fields.Boolean(string='启用', default=True)
+11
View File
@@ -0,0 +1,11 @@
from odoo import fields, models
class SchoolTraining(models.Model):
"""我的实训(占位模型)。"""
_name = 'yuthon.school.training'
_description = '我的实训'
name = fields.Char(string='实训名称', required=True)
note = fields.Text(string='备注')
+60
View File
@@ -0,0 +1,60 @@
<odoo>
<record id="module_category_education" model="ir.module.category">
<field name="name">教学管理</field>
<field name="sequence">20</field>
</record>
<record id="group_teacher" model="res.groups">
<field name="name">老师</field>
<field name="category_id" ref="module_category_education"/>
<field name="comment">可创建/管理课程、布置与批改作业、查看全部学习进展。</field>
<!-- 让老师拥有「访问权限」级权限(隐含 base.group_erp_manager)。
模块安装的 Python 层 is_admin() 检查 base.group_erp_manager,
因此老师可安装/升级/卸载模块;但老师不是 base.group_system,
所以看不到「设置」菜单。 -->
<field name="implied_ids" eval="[(4, ref('base.group_erp_manager'))]"/>
</record>
<record id="group_student" model="res.groups">
<field name="name">学生</field>
<field name="category_id" ref="module_category_education"/>
<field name="comment">可查看自己选课的课程、提交作业、查看自己的学习进展,不能创建课程。</field>
<!-- 学生仅内部用户,可登录但无模块管理权限 -->
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- 学生只能访问自己的习题作答 -->
<record id="rule_exercise_answer_student_own" model="ir.rule">
<field name="name">习题作答:学生仅能访问自己的回答</field>
<field name="model_id" ref="model_yuthon_school_course_chapter_exercise_answer"/>
<field name="groups" eval="[(4, ref('college.group_student'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 考试模块:学生仅能访问自己的答卷与答题记录 -->
<record id="rule_exam_paper_student_own" model="ir.rule">
<field name="name">考试答卷:学生仅能访问自己的答卷</field>
<field name="model_id" ref="model_yuthon_school_exam_paper"/>
<field name="groups" eval="[(4, ref('college.group_student'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_exam_answer_student_own" model="ir.rule">
<field name="name">考试答题:学生仅能访问自己的答题记录</field>
<field name="model_id" ref="model_yuthon_school_exam_answer"/>
<field name="groups" eval="[(4, ref('college.group_student'))]"/>
<field name="domain_force">[('paper_id.user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
+69
View File
@@ -0,0 +1,69 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_school_course_admin,yuthon.school.course.admin,model_yuthon_school_course,base.group_system,1,1,1,1
access_yuthon_school_course_teacher,yuthon.school.course.teacher,model_yuthon_school_course,college.group_teacher,1,1,1,1
access_yuthon_school_course_student,yuthon.school.course.student,model_yuthon_school_course,college.group_student,1,1,0,0
access_yuthon_school_student_admin,yuthon.school.student.admin,model_yuthon_school_student,base.group_system,1,1,1,1
access_yuthon_school_student_teacher,yuthon.school.student.teacher,model_yuthon_school_student,college.group_teacher,1,1,1,1
access_yuthon_school_student_student,yuthon.school.student.student,model_yuthon_school_student,college.group_student,1,0,0,0
access_yuthon_school_teacher_admin,yuthon.school.teacher.admin,model_yuthon_school_teacher,base.group_system,1,1,1,1
access_yuthon_school_teacher_teacher,yuthon.school.teacher.teacher,model_yuthon_school_teacher,college.group_teacher,1,1,1,1
access_yuthon_school_teacher_student,yuthon.school.teacher.student,model_yuthon_school_teacher,college.group_student,1,0,0,0
access_yuthon_school_class_admin,yuthon.school.class.admin,model_yuthon_school_class,base.group_system,1,1,1,1
access_yuthon_school_class_teacher,yuthon.school.class.teacher,model_yuthon_school_class,college.group_teacher,1,1,1,1
access_yuthon_school_class_student,yuthon.school.class.student,model_yuthon_school_class,college.group_student,1,0,0,0
access_yuthon_school_progress_admin,yuthon.school.progress.admin,model_yuthon_school_progress,base.group_system,1,1,1,1
access_yuthon_school_progress_teacher,yuthon.school.progress.teacher,model_yuthon_school_progress,college.group_teacher,1,1,1,1
access_yuthon_school_progress_student,yuthon.school.progress.student,model_yuthon_school_progress,college.group_student,1,1,0,0
access_yuthon_school_assignment_admin,yuthon.school.assignment.admin,model_yuthon_school_assignment,base.group_system,1,1,1,1
access_yuthon_school_assignment_teacher,yuthon.school.assignment.teacher,model_yuthon_school_assignment,college.group_teacher,1,1,1,1
access_yuthon_school_assignment_student,yuthon.school.assignment.student,model_yuthon_school_assignment,college.group_student,1,0,0,0
access_yuthon_school_assignment_submission_admin,yuthon.school.assignment.submission.admin,model_yuthon_school_assignment_submission,base.group_system,1,1,1,1
access_yuthon_school_assignment_submission_teacher,yuthon.school.assignment.submission.teacher,model_yuthon_school_assignment_submission,college.group_teacher,1,1,1,1
access_yuthon_school_assignment_submission_student,yuthon.school.assignment.submission.student,model_yuthon_school_assignment_submission,college.group_student,1,1,1,0
access_yuthon_school_assignment_submission_batch_wizard_admin,yuthon.school.assignment.submission.batch.wizard.admin,model_yuthon_school_assignment_submission_batch_wizard,base.group_system,1,1,1,1
access_yuthon_school_assignment_submission_batch_wizard_teacher,yuthon.school.assignment.submission.batch.wizard.teacher,model_yuthon_school_assignment_submission_batch_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_import_wizard_admin,yuthon.school.import.wizard.admin,model_yuthon_school_import_wizard,base.group_system,1,1,1,1
access_yuthon_school_import_wizard_teacher,yuthon.school.import.wizard.teacher,model_yuthon_school_import_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_admin,yuthon.school.course.chapter.admin,model_yuthon_school_course_chapter,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_teacher,yuthon.school.course.chapter.teacher,model_yuthon_school_course_chapter,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_student,yuthon.school.course.chapter.student,model_yuthon_school_course_chapter,college.group_student,1,0,0,0
access_yuthon_school_course_chapter_attachment_admin,yuthon.school.course.chapter.attachment.admin,model_yuthon_school_course_chapter_attachment,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_attachment_teacher,yuthon.school.course.chapter.attachment.teacher,model_yuthon_school_course_chapter_attachment,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_attachment_student,yuthon.school.course.chapter.attachment.student,model_yuthon_school_course_chapter_attachment,college.group_student,1,0,0,0
access_yuthon_school_course_chapter_exercise_admin,yuthon.school.course.chapter.exercise.admin,model_yuthon_school_course_chapter_exercise,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_exercise_teacher,yuthon.school.course.chapter.exercise.teacher,model_yuthon_school_course_chapter_exercise,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_exercise_student,yuthon.school.course.chapter.exercise.student,model_yuthon_school_course_chapter_exercise,college.group_student,1,0,0,0
access_yuthon_school_course_import_wizard_admin,yuthon.school.course.import.wizard.admin,model_yuthon_school_course_import_wizard,base.group_system,1,1,1,1
access_yuthon_school_course_import_wizard_teacher,yuthon.school.course.import.wizard.teacher,model_yuthon_school_course_import_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_exercise_answer_admin,yuthon.school.course.chapter.exercise.answer.admin,model_yuthon_school_course_chapter_exercise_answer,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_exercise_answer_teacher,yuthon.school.course.chapter.exercise.answer.teacher,model_yuthon_school_course_chapter_exercise_answer,college.group_teacher,1,0,0,0
access_yuthon_school_course_chapter_exercise_answer_student,yuthon.school.course.chapter.exercise.answer.student,model_yuthon_school_course_chapter_exercise_answer,college.group_student,1,1,1,0
access_yuthon_school_course_chapter_task_admin,yuthon.school.course.chapter.task.admin,model_yuthon_school_course_chapter_task,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_task_teacher,yuthon.school.course.chapter.task.teacher,model_yuthon_school_course_chapter_task,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_task_student,yuthon.school.course.chapter.task.student,model_yuthon_school_course_chapter_task,college.group_student,1,0,0,0
access_yuthon_school_training_admin,yuthon.school.training.admin,model_yuthon_school_training,base.group_system,1,1,1,1
access_yuthon_school_training_teacher,yuthon.school.training.teacher,model_yuthon_school_training,college.group_teacher,1,1,1,1
access_yuthon_school_training_student,yuthon.school.training.student,model_yuthon_school_training,college.group_student,1,1,1,1
access_yuthon_about_page_admin,yuthon.about.page.admin,model_yuthon_about_page,base.group_system,1,1,1,1
access_yuthon_about_page_teacher,yuthon.about.page.teacher,model_yuthon_about_page,college.group_teacher,1,1,1,1
access_yuthon_about_page_student,yuthon.about.page.student,model_yuthon_about_page,college.group_student,1,0,0,0
access_ir_module_module_teacher,ir.module.module.teacher,base.model_ir_module_module,college.group_teacher,1,1,1,1
access_ir_module_module_student,ir.module.module.student.read,base.model_ir_module_module,college.group_student,1,0,0,0
access_yuthon_school_exam_question_admin,yuthon.school.exam.question.admin,model_yuthon_school_exam_question,base.group_system,1,1,1,1
access_yuthon_school_exam_question_teacher,yuthon.school.exam.question.teacher,model_yuthon_school_exam_question,college.group_teacher,1,1,1,1
access_yuthon_school_exam_question_student,yuthon.school.exam.question.student,model_yuthon_school_exam_question,college.group_student,1,0,0,0
access_yuthon_school_exam_question_option_admin,yuthon.school.exam.question.option.admin,model_yuthon_school_exam_question_option,base.group_system,1,1,1,1
access_yuthon_school_exam_question_option_teacher,yuthon.school.exam.question.option.teacher,model_yuthon_school_exam_question_option,college.group_teacher,1,1,1,1
access_yuthon_school_exam_question_option_student,yuthon.school.exam.question.option.student,model_yuthon_school_exam_question_option,college.group_student,1,0,0,0
access_yuthon_school_exam_admin,yuthon.school.exam.admin,model_yuthon_school_exam,base.group_system,1,1,1,1
access_yuthon_school_exam_teacher,yuthon.school.exam.teacher,model_yuthon_school_exam,college.group_teacher,1,1,1,1
access_yuthon_school_exam_student,yuthon.school.exam.student,model_yuthon_school_exam,college.group_student,1,0,0,0
access_yuthon_school_exam_line_admin,yuthon.school.exam.line.admin,model_yuthon_school_exam_line,base.group_system,1,1,1,1
access_yuthon_school_exam_line_teacher,yuthon.school.exam.line.teacher,model_yuthon_school_exam_line,college.group_teacher,1,1,1,1
access_yuthon_school_exam_line_student,yuthon.school.exam.line.student,model_yuthon_school_exam_line,college.group_student,1,0,0,0
access_yuthon_school_exam_paper_admin,yuthon.school.exam.paper.admin,model_yuthon_school_exam_paper,base.group_system,1,1,1,1
access_yuthon_school_exam_paper_teacher,yuthon.school.exam.paper.teacher,model_yuthon_school_exam_paper,college.group_teacher,1,1,1,1
access_yuthon_school_exam_paper_student,yuthon.school.exam.paper.student,model_yuthon_school_exam_paper,college.group_student,1,1,1,0
access_yuthon_school_exam_answer_admin,yuthon.school.exam.answer.admin,model_yuthon_school_exam_answer,base.group_system,1,1,1,1
access_yuthon_school_exam_answer_teacher,yuthon.school.exam.answer.teacher,model_yuthon_school_exam_answer,college.group_teacher,1,1,1,1
access_yuthon_school_exam_answer_student,yuthon.school.exam.answer.student,model_yuthon_school_exam_answer,college.group_student,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_school_course_admin yuthon.school.course.admin model_yuthon_school_course base.group_system 1 1 1 1
3 access_yuthon_school_course_teacher yuthon.school.course.teacher model_yuthon_school_course college.group_teacher 1 1 1 1
4 access_yuthon_school_course_student yuthon.school.course.student model_yuthon_school_course college.group_student 1 1 0 0
5 access_yuthon_school_student_admin yuthon.school.student.admin model_yuthon_school_student base.group_system 1 1 1 1
6 access_yuthon_school_student_teacher yuthon.school.student.teacher model_yuthon_school_student college.group_teacher 1 1 1 1
7 access_yuthon_school_student_student yuthon.school.student.student model_yuthon_school_student college.group_student 1 0 0 0
8 access_yuthon_school_teacher_admin yuthon.school.teacher.admin model_yuthon_school_teacher base.group_system 1 1 1 1
9 access_yuthon_school_teacher_teacher yuthon.school.teacher.teacher model_yuthon_school_teacher college.group_teacher 1 1 1 1
10 access_yuthon_school_teacher_student yuthon.school.teacher.student model_yuthon_school_teacher college.group_student 1 0 0 0
11 access_yuthon_school_class_admin yuthon.school.class.admin model_yuthon_school_class base.group_system 1 1 1 1
12 access_yuthon_school_class_teacher yuthon.school.class.teacher model_yuthon_school_class college.group_teacher 1 1 1 1
13 access_yuthon_school_class_student yuthon.school.class.student model_yuthon_school_class college.group_student 1 0 0 0
14 access_yuthon_school_progress_admin yuthon.school.progress.admin model_yuthon_school_progress base.group_system 1 1 1 1
15 access_yuthon_school_progress_teacher yuthon.school.progress.teacher model_yuthon_school_progress college.group_teacher 1 1 1 1
16 access_yuthon_school_progress_student yuthon.school.progress.student model_yuthon_school_progress college.group_student 1 1 0 0
17 access_yuthon_school_assignment_admin yuthon.school.assignment.admin model_yuthon_school_assignment base.group_system 1 1 1 1
18 access_yuthon_school_assignment_teacher yuthon.school.assignment.teacher model_yuthon_school_assignment college.group_teacher 1 1 1 1
19 access_yuthon_school_assignment_student yuthon.school.assignment.student model_yuthon_school_assignment college.group_student 1 0 0 0
20 access_yuthon_school_assignment_submission_admin yuthon.school.assignment.submission.admin model_yuthon_school_assignment_submission base.group_system 1 1 1 1
21 access_yuthon_school_assignment_submission_teacher yuthon.school.assignment.submission.teacher model_yuthon_school_assignment_submission college.group_teacher 1 1 1 1
22 access_yuthon_school_assignment_submission_student yuthon.school.assignment.submission.student model_yuthon_school_assignment_submission college.group_student 1 1 1 0
23 access_yuthon_school_assignment_submission_batch_wizard_admin yuthon.school.assignment.submission.batch.wizard.admin model_yuthon_school_assignment_submission_batch_wizard base.group_system 1 1 1 1
24 access_yuthon_school_assignment_submission_batch_wizard_teacher yuthon.school.assignment.submission.batch.wizard.teacher model_yuthon_school_assignment_submission_batch_wizard college.group_teacher 1 1 1 1
25 access_yuthon_school_import_wizard_admin yuthon.school.import.wizard.admin model_yuthon_school_import_wizard base.group_system 1 1 1 1
26 access_yuthon_school_import_wizard_teacher yuthon.school.import.wizard.teacher model_yuthon_school_import_wizard college.group_teacher 1 1 1 1
27 access_yuthon_school_course_chapter_admin yuthon.school.course.chapter.admin model_yuthon_school_course_chapter base.group_system 1 1 1 1
28 access_yuthon_school_course_chapter_teacher yuthon.school.course.chapter.teacher model_yuthon_school_course_chapter college.group_teacher 1 1 1 1
29 access_yuthon_school_course_chapter_student yuthon.school.course.chapter.student model_yuthon_school_course_chapter college.group_student 1 0 0 0
30 access_yuthon_school_course_chapter_attachment_admin yuthon.school.course.chapter.attachment.admin model_yuthon_school_course_chapter_attachment base.group_system 1 1 1 1
31 access_yuthon_school_course_chapter_attachment_teacher yuthon.school.course.chapter.attachment.teacher model_yuthon_school_course_chapter_attachment college.group_teacher 1 1 1 1
32 access_yuthon_school_course_chapter_attachment_student yuthon.school.course.chapter.attachment.student model_yuthon_school_course_chapter_attachment college.group_student 1 0 0 0
33 access_yuthon_school_course_chapter_exercise_admin yuthon.school.course.chapter.exercise.admin model_yuthon_school_course_chapter_exercise base.group_system 1 1 1 1
34 access_yuthon_school_course_chapter_exercise_teacher yuthon.school.course.chapter.exercise.teacher model_yuthon_school_course_chapter_exercise college.group_teacher 1 1 1 1
35 access_yuthon_school_course_chapter_exercise_student yuthon.school.course.chapter.exercise.student model_yuthon_school_course_chapter_exercise college.group_student 1 0 0 0
36 access_yuthon_school_course_import_wizard_admin yuthon.school.course.import.wizard.admin model_yuthon_school_course_import_wizard base.group_system 1 1 1 1
37 access_yuthon_school_course_import_wizard_teacher yuthon.school.course.import.wizard.teacher model_yuthon_school_course_import_wizard college.group_teacher 1 1 1 1
38 access_yuthon_school_course_chapter_exercise_answer_admin yuthon.school.course.chapter.exercise.answer.admin model_yuthon_school_course_chapter_exercise_answer base.group_system 1 1 1 1
39 access_yuthon_school_course_chapter_exercise_answer_teacher yuthon.school.course.chapter.exercise.answer.teacher model_yuthon_school_course_chapter_exercise_answer college.group_teacher 1 0 0 0
40 access_yuthon_school_course_chapter_exercise_answer_student yuthon.school.course.chapter.exercise.answer.student model_yuthon_school_course_chapter_exercise_answer college.group_student 1 1 1 0
41 access_yuthon_school_course_chapter_task_admin yuthon.school.course.chapter.task.admin model_yuthon_school_course_chapter_task base.group_system 1 1 1 1
42 access_yuthon_school_course_chapter_task_teacher yuthon.school.course.chapter.task.teacher model_yuthon_school_course_chapter_task college.group_teacher 1 1 1 1
43 access_yuthon_school_course_chapter_task_student yuthon.school.course.chapter.task.student model_yuthon_school_course_chapter_task college.group_student 1 0 0 0
44 access_yuthon_school_training_admin yuthon.school.training.admin model_yuthon_school_training base.group_system 1 1 1 1
45 access_yuthon_school_training_teacher yuthon.school.training.teacher model_yuthon_school_training college.group_teacher 1 1 1 1
46 access_yuthon_school_training_student yuthon.school.training.student model_yuthon_school_training college.group_student 1 1 1 1
47 access_yuthon_about_page_admin yuthon.about.page.admin model_yuthon_about_page base.group_system 1 1 1 1
48 access_yuthon_about_page_teacher yuthon.about.page.teacher model_yuthon_about_page college.group_teacher 1 1 1 1
49 access_yuthon_about_page_student yuthon.about.page.student model_yuthon_about_page college.group_student 1 0 0 0
50 access_ir_module_module_teacher ir.module.module.teacher base.model_ir_module_module college.group_teacher 1 1 1 1
51 access_ir_module_module_student ir.module.module.student.read base.model_ir_module_module college.group_student 1 0 0 0
52 access_yuthon_school_exam_question_admin yuthon.school.exam.question.admin model_yuthon_school_exam_question base.group_system 1 1 1 1
53 access_yuthon_school_exam_question_teacher yuthon.school.exam.question.teacher model_yuthon_school_exam_question college.group_teacher 1 1 1 1
54 access_yuthon_school_exam_question_student yuthon.school.exam.question.student model_yuthon_school_exam_question college.group_student 1 0 0 0
55 access_yuthon_school_exam_question_option_admin yuthon.school.exam.question.option.admin model_yuthon_school_exam_question_option base.group_system 1 1 1 1
56 access_yuthon_school_exam_question_option_teacher yuthon.school.exam.question.option.teacher model_yuthon_school_exam_question_option college.group_teacher 1 1 1 1
57 access_yuthon_school_exam_question_option_student yuthon.school.exam.question.option.student model_yuthon_school_exam_question_option college.group_student 1 0 0 0
58 access_yuthon_school_exam_admin yuthon.school.exam.admin model_yuthon_school_exam base.group_system 1 1 1 1
59 access_yuthon_school_exam_teacher yuthon.school.exam.teacher model_yuthon_school_exam college.group_teacher 1 1 1 1
60 access_yuthon_school_exam_student yuthon.school.exam.student model_yuthon_school_exam college.group_student 1 0 0 0
61 access_yuthon_school_exam_line_admin yuthon.school.exam.line.admin model_yuthon_school_exam_line base.group_system 1 1 1 1
62 access_yuthon_school_exam_line_teacher yuthon.school.exam.line.teacher model_yuthon_school_exam_line college.group_teacher 1 1 1 1
63 access_yuthon_school_exam_line_student yuthon.school.exam.line.student model_yuthon_school_exam_line college.group_student 1 0 0 0
64 access_yuthon_school_exam_paper_admin yuthon.school.exam.paper.admin model_yuthon_school_exam_paper base.group_system 1 1 1 1
65 access_yuthon_school_exam_paper_teacher yuthon.school.exam.paper.teacher model_yuthon_school_exam_paper college.group_teacher 1 1 1 1
66 access_yuthon_school_exam_paper_student yuthon.school.exam.paper.student model_yuthon_school_exam_paper college.group_student 1 1 1 0
67 access_yuthon_school_exam_answer_admin yuthon.school.exam.answer.admin model_yuthon_school_exam_answer base.group_system 1 1 1 1
68 access_yuthon_school_exam_answer_teacher yuthon.school.exam.answer.teacher model_yuthon_school_exam_answer college.group_teacher 1 1 1 1
69 access_yuthon_school_exam_answer_student yuthon.school.exam.answer.student model_yuthon_school_exam_answer college.group_student 1 1 1 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+269
View File
@@ -0,0 +1,269 @@
// ============================================================
// college 看板主题适配
// 使用 Odoo SCSS 变量,自动跟随 udoo_om_ux 等主题的浅色/深色色板。
// 需同时注册到 web.assets_backend(浅色)与 web.assets_web_dark
// / web.assets_backend_lazy_dark(深色)。
// ============================================================
.college-course-kanban {
.college-kanban-card {
background-color: $o-view-background-color;
color: $o-main-text-color;
border: 1px solid rgba($o-main-text-color, 0.08);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
border-radius: 8px;
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.college-kanban-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: $o-main-text-color;
}
.college-kanban-meta {
font-size: 13px;
color: $o-gray-600;
&.text-muted,
.text-muted {
color: $o-gray-600 !important;
}
.fa {
color: $o-gray-500;
}
}
.college-kanban-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
}
// ---------------- 老师端:蓝调专业风 ----------------
&.college-kanban-teacher .college-kanban-card {
.college-kanban-badge-hours {
background: rgba($o-info, 0.15);
color: $o-main-text-color;
}
.college-kanban-badge-students {
background: rgba($o-success, 0.15);
color: $o-main-text-color;
}
}
// ---------------- 学生-我的课程:书本青蓝风 ----------------
&.college-kanban-my .college-kanban-card {
.college-kanban-badge-type {
background: rgba($o-info, 0.85);
color: #ffffff;
}
.college-kanban-badge-public {
background: rgba($o-success, 0.85);
color: #ffffff;
}
}
// ---------------- 学生-课程学习:活力橙风 ----------------
&.college-kanban-study .college-kanban-card {
.college-kanban-badge-type {
background: rgba($o-view-background-color, 0.92);
color: $o-danger;
}
.college-kanban-badge-hours {
background: rgba($o-warning, 0.15);
color: $o-main-text-color;
}
.college-kanban-badge-chapters {
background: rgba($o-info, 0.15);
color: $o-main-text-color;
}
}
// ---------------- 学习进展:蓝青科技风 ----------------
&.college-kanban-progress .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #0e7490, #22d3ee) !important;
}
.college-kanban-title {
color: #ffffff;
}
.college-kanban-badge-score {
background: rgba($o-view-background-color, 0.92);
color: $o-info;
}
.college-kanban-progress-rate {
color: $o-main-text-color;
font-weight: 600;
}
}
// ---------------- 我的实训:紫罗兰 ----------------
&.college-kanban-training .college-kanban-card {
.college-kanban-title {
color: #ffffff;
}
.college-kanban-badge-purple {
background: rgba($o-info, 0.15);
color: $o-main-text-color;
}
.college-kanban-badge-pink {
background: rgba($o-danger, 0.15);
color: $o-main-text-color;
}
}
// ---------------- 作业提交:珊瑚红 ----------------
&.college-kanban-homework .college-kanban-card {
.college-kanban-title {
color: #ffffff;
}
.college-kanban-badge-score {
background: rgba($o-view-background-color, 0.9);
color: $o-danger;
}
.college-kanban-badge-state {
background: rgba($o-danger, 0.15);
color: $o-main-text-color;
}
}
}
// ============================================================
// 课程学习:章节列表看板化(网格卡片墙)
// 把内嵌 kanban 渲染成自适应的卡片网格,去掉默认列/分组样式。
// ============================================================
.o_college_chapter_kanban {
background: transparent !important;
border: none !important;
// 去掉 kanban 默认分组列包裹,让卡片直接成为网格项
.o_kanban_renderer {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important;
gap: 16px !important;
align-items: stretch !important;
background: transparent !important;
padding: 0 !important;
}
// 单列分组包裹层不参与布局,使其子卡片成为 renderer 的 grid item
.o_kanban_group {
display: contents !important;
}
.o_kanban_record {
width: 100% !important;
margin: 0 !important;
border: none !important;
padding: 0 !important;
background: transparent !important;
}
.o_college_chapter_card {
height: 100%;
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
background-color: $o-view-background-color !important;
border: 1px solid rgba($o-main-text-color, 0.08) !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
padding: 16px 18px !important;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1) !important;
}
.o_college_chapter_head {
display: flex !important;
align-items: center !important;
gap: 8px !important;
.o_college_chapter_title {
font-size: 15px;
font-weight: 600;
color: $o-main-text-color !important;
}
}
.o_college_chapter_video {
font-size: 13px;
color: $o-gray-600 !important;
}
.o_college_chapter_foot {
margin-top: auto !important;
text-align: right !important;
}
}
}
// ============================================================
// 关于页(产品介绍等):只读 H5 撑满内容区,去掉四周大留白
// content 字段内容已为全内联 style 的自包含 H5,这里只做容器铺满。
// ============================================================
.o_about_full_form {
// 去掉 form 默认内边距,让内容贴边
padding: 0 !important;
.o_form_renderer,
.o_form_renderer > .o_form_field {
padding: 0 !important;
margin: 0 !important;
}
}
.o_about_full.o_field_html {
// 只读展示时铺满,无编辑器工具栏
display: block !important;
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
background: transparent !important;
border: none !important;
.o_readonly {
padding: 0 !important;
}
}
// 系统说明 / 操作手册 仍为普通富文本(保留基础样式)
.o_about_page {
max-width: 900px;
margin: 0 auto;
font-size: 14px;
line-height: 1.8;
color: $o-main-text-color;
h2 { font-size: 22px; margin: 8px 0 16px; }
h3 { font-size: 17px; margin: 22px 0 10px; }
h4 { font-size: 15px; margin: 16px 0 8px; }
p, li { color: $o-main-text-color; }
}
@@ -0,0 +1,302 @@
// ============================================================
// college 看板「深色模式」专属样式
// 本文件只注册在 web.assets_web_dark / web.assets_backend_lazy_dark,
// 这两个 bundle 仅当用户开启深色主题时才会加载,因此下面的颜色
// 全部硬编码,不依赖任何主题 SCSS 变量(避免跨模块编译顺序导致
// 变量未切换成深色值的问题)。
// 深色页面底色约 #111827,卡片用略亮的 slate-800 (#1e293b) 形成层次。
// ============================================================
// 顶部同级菜单段(如「我的课程 / 全部课程」)夜间白字加粗
.nav-segment .nav-link {
color: #f1f5f9 !important;
font-weight: 600 !important;
}
.nav-segment .nav-link.active {
color: #ffffff !important;
font-weight: 700 !important;
}
.college-course-kanban {
.college-kanban-card {
background-color: #1e293b;
color: #f1f5f9;
border: 1px solid #334155;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
border-radius: 8px;
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
}
.college-kanban-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: #f8fafc !important;
}
.college-kanban-body {
background-color: #1e293b;
}
.college-kanban-meta {
font-size: 13px;
color: #94a3b8 !important;
&.text-muted,
.text-muted {
color: #94a3b8 !important;
}
.fa {
color: #64748b !important;
}
}
.college-kanban-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
}
// ---------------- 老师端:蓝调专业风 ----------------
&.college-kanban-teacher .college-kanban-card {
// 头部渐变稍压暗,避免与深色页面「糊」在一起,白字依旧清晰
> div:first-child {
background: linear-gradient(135deg, #2563eb, #4f46e5) !important;
}
.college-kanban-badge-hours {
background: rgba(96, 165, 250, 0.18);
color: #93c5fd !important;
}
.college-kanban-badge-students {
background: rgba(52, 211, 153, 0.18);
color: #6ee7b7 !important;
}
}
// ---------------- 学生-我的课程:书本青蓝风 ----------------
&.college-kanban-my .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #0ea5e9, #06b6d4) !important;
}
.college-kanban-badge-type {
background: #38bdf8;
color: #0f172a !important;
}
.college-kanban-badge-public {
background: #34d399;
color: #0f172a !important;
}
}
// ---------------- 学生-课程学习:活力橙风 ----------------
&.college-kanban-study .college-kanban-card {
> div:first-child {
background: linear-gradient(120deg, #f97316, #ef4444) !important;
}
.college-kanban-badge-type {
background: rgba(251, 146, 60, 0.22);
color: #fdba74 !important;
}
.college-kanban-badge-hours {
background: rgba(251, 191, 36, 0.18);
color: #fcd34d !important;
}
.college-kanban-badge-chapters {
background: rgba(96, 165, 250, 0.18);
color: #93c5fd !important;
}
}
// ---------------- 学习进展:蓝青科技风 ----------------
&.college-kanban-progress .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #0e7490, #22d3ee) !important;
}
.college-kanban-title {
color: #f8fafc !important;
}
.college-kanban-badge-score {
background: rgba(34, 211, 238, 0.2);
color: #67e8f9 !important;
}
.college-kanban-progress-rate {
color: #f1f5f9 !important;
font-weight: 600;
}
}
// ---------------- 我的实训:紫罗兰 ----------------
&.college-kanban-training .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #7c3aed, #c026d3) !important;
}
.college-kanban-title {
color: #f8fafc !important;
}
.college-kanban-badge-purple {
background: rgba(167, 139, 250, 0.2);
color: #c4b5fd !important;
}
.college-kanban-badge-pink {
background: rgba(244, 114, 182, 0.2);
color: #f9a8d4 !important;
}
}
// ---------------- 作业提交:珊瑚红 ----------------
&.college-kanban-homework .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #e11d48, #f97316) !important;
}
.college-kanban-title {
color: #f8fafc !important;
}
.college-kanban-badge-score {
background: rgba(248, 113, 113, 0.2);
color: #fca5a5 !important;
}
.college-kanban-badge-state {
background: rgba(248, 113, 113, 0.18);
color: #fca5a5 !important;
}
}
}
// ============================================================
// 课程学习:章节列表看板化(深色模式网格卡片墙)
// ============================================================
.o_college_chapter_kanban {
background: transparent !important;
border: none !important;
.o_kanban_renderer {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important;
gap: 16px !important;
align-items: stretch !important;
background: transparent !important;
padding: 0 !important;
}
.o_kanban_group {
display: contents !important;
}
.o_kanban_record {
width: 100% !important;
margin: 0 !important;
border: none !important;
padding: 0 !important;
background: transparent !important;
}
.o_college_chapter_card {
height: 100%;
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
background-color: #1e293b !important;
border: 1px solid #334155 !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35) !important;
padding: 16px 18px !important;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45) !important;
}
.o_college_chapter_head {
display: flex !important;
align-items: center !important;
gap: 8px !important;
.o_college_chapter_title {
font-size: 15px;
font-weight: 600;
color: #f8fafc !important;
}
}
.o_college_chapter_video {
font-size: 13px;
color: #94a3b8 !important;
}
.o_college_chapter_foot {
margin-top: auto !important;
text-align: right !important;
}
}
}
// ============================================================
// 关于页(产品介绍等):只读 H5 撑满内容区,去掉四周大留白
// content 字段内容已为全内联 style 的自包含 H5,这里只做容器铺满。
// ============================================================
.o_about_full_form {
padding: 0 !important;
.o_form_renderer,
.o_form_renderer > .o_form_field {
padding: 0 !important;
margin: 0 !important;
}
}
.o_about_full.o_field_html {
display: block !important;
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
background: transparent !important;
border: none !important;
.o_readonly {
padding: 0 !important;
}
}
// 系统说明 / 操作手册 仍为普通富文本(深色下基础样式)
.o_about_page {
max-width: 900px;
margin: 0 auto;
font-size: 14px;
line-height: 1.8;
color: #e2e8f0;
h2 { font-size: 22px; margin: 8px 0 16px; color: #f8fafc; }
h3 { font-size: 17px; margin: 22px 0 10px; color: #f8fafc; }
h4 { font-size: 15px; margin: 16px 0 8px; color: #f8fafc; }
p, li { color: #cbd5e1; }
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 关于页:表单 ===================== -->
<!-- content 为只读 html 字段,里面放一个自包含(全内联 style)的 H5 网页,
表单去掉 sheet、撑满内容区,避免四周大留白。 -->
<record id="yuthon_about_page_form" model="ir.ui.view">
<field name="name">yuthon.about.page.form</field>
<field name="model">yuthon.about.page</field>
<field name="arch" type="xml">
<form class="o_about_full_form">
<field name="content" widget="html" readonly="1" class="o_about_full"/>
</form>
</field>
</record>
<!-- ===================== 关于页:列表(供管理员在设置中维护) ===================== -->
<record id="yuthon_about_page_list" model="ir.ui.view">
<field name="name">yuthon.about.page.list</field>
<field name="model">yuthon.about.page</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="category"/>
<field name="active"/>
</list>
</field>
</record>
</odoo>
@@ -0,0 +1,27 @@
<odoo>
<!-- 批量批改向导动作(由列表按钮通过对象方法打开,无需绑定到模型 Action 菜单) -->
<record id="action_yuthon_school_assignment_submission_batch_wizard" model="ir.actions.act_window">
<field name="name">批量批改</field>
<field name="res_model">yuthon.school.assignment.submission.batch.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- 批量批改向导表单 -->
<record id="yuthon_school_assignment_submission_batch_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.batch.wizard.form</field>
<field name="model">yuthon.school.assignment.submission.batch.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<field name="score"/>
<field name="feedback"/>
</group>
<footer>
<button name="action_apply" type="object" string="确认批改" class="btn-primary"/>
<button string="取消" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>
+303
View File
@@ -0,0 +1,303 @@
<odoo>
<!-- ===================== 章节:老师编辑表单(默认) ===================== -->
<record id="yuthon_school_course_chapter_form_view" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.form.view</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">30</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<group>
<group>
<field name="name"/>
<field name="course_id" readonly="1"/>
<field name="sequence" invisible="1"/>
</group>
<group>
<field name="class_hours" string="课时"/>
</group>
</group>
<group>
<field name="description" widget="html" string="章节说明"/>
<field name="notes" widget="html" string="随堂笔记"/>
</group>
<separator string="训练习题(均为简答题,可添加多道;学生可自由作答,不计分)"/>
<field name="exercise_ids" string="训练习题">
<list editable="bottom" decoration-muted="sequence &lt; 0">
<field name="sequence" widget="handle" string="排序"/>
<field name="question" string="题目"/>
<field name="analysis" string="参考解析" optional="show"/>
<field name="answer_count" string="作答人数"/>
<button name="action_view_answers" type="object"
string="查看作答" class="btn-link"
icon="fa-comments"
title="查看本题所有学员的回答"/>
</list>
</field>
<separator string="任务列表"/>
<field name="task_ids" string="任务列表">
<list editable="bottom" decoration-muted="is_done == True">
<field name="sequence" widget="handle" string="排序"/>
<field name="name" string="任务名称"/>
<field name="is_done" string="已完成" optional="show"/>
</list>
</field>
<group>
<field name="video_file" filename="video_filename"/>
<field name="video_filename" invisible="1"/>
<field name="video_filename_display" invisible="1"/>
</group>
<field name="attachment_ids" string="章节资料">
<list editable="bottom">
<field name="name" string="文件名"/>
<field name="file" filename="name"/>
</list>
</field>
</form>
</field>
</record>
<!-- ===================== 章节:学生学习表单(弹窗播放视频/下载附件) ===================== -->
<record id="yuthon_school_course_chapter_study_form" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.study.form</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">20</field>
<field name="groups_id" eval="[(4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<header>
<button name="action_toggle_complete" type="object"
string="标记已学完" class="btn-primary"
invisible="is_done_for_me"/>
<button name="action_toggle_complete" type="object"
string="取消完成" class="btn-secondary"
invisible="not is_done_for_me"/>
<field name="is_done_for_me" invisible="1"/>
</header>
<sheet>
<h2><field name="name" readonly="1"/></h2>
<div class="text-muted mb-3">
所属课程:<field name="course_id" readonly="1"/>
</div>
<group>
<field name="description" widget="html" readonly="1"/>
</group>
<!-- 有视频时直接播放 -->
<field name="video_embed_html" widget="html" readonly="1"
invisible="not video_file"/>
<!-- 没有视频但有附件时,只显示附件 -->
<separator string="章节资料" invisible="not attachment_ids"/>
<field name="attachment_ids" readonly="1"
invisible="not attachment_ids">
<list editable="bottom" create="0" delete="0">
<field name="name" string="文件名" readonly="1"/>
<field name="download_link_html" widget="html"
nolabel="1"/>
</list>
</field>
<!-- 训练习题:可自由作答,不计分 -->
<separator string="训练习题(可自由作答,不计分)" invisible="not exercise_ids"/>
<field name="exercise_ids" readonly="1"
invisible="not exercise_ids">
<list editable="bottom" create="0" delete="0">
<field name="sequence" string="序号"/>
<field name="question" string="题目" readonly="1"/>
<field name="my_answer_preview" string="我的回答"
placeholder="(未作答)" optional="show"/>
<field name="my_answer_content" string="我的回答(详细)"
optional="hide"/>
<field name="answer_count" string="已作答人数" optional="show"/>
<button name="action_answer" type="object"
string="作答 / 修改" class="btn-primary"
icon="fa-pencil"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学员作答:弹窗表单 ===================== -->
<record id="yuthon_school_course_chapter_exercise_answer_form" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.exercise.answer.form</field>
<field name="model">yuthon.school.course.chapter.exercise.answer</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="exercise_id" invisible="1"/>
<field name="student_id" invisible="1"/>
<field name="user_id" invisible="1"/>
<field name="submit_time" invisible="1"/>
</group>
<div class="text-muted small mb-1">
所属课程:<field name="course_id" readonly="1" nolabel="1" class="oe_inline"/>
· 所属章节:<field name="chapter_id" readonly="1" nolabel="1" class="oe_inline"/>
</div>
<separator string="题目"/>
<field name="question_display" widget="html" readonly="1" nolabel="1"/>
<separator string="我的回答(自由作答,不计分,可随时修改)"/>
<field name="content" widget="html" nolabel="1"
placeholder="请输入你的回答..."/>
<div class="text-muted small mt-2" invisible="not submit_time">
上次作答时间:<field name="submit_time" readonly="1" nolabel="1" class="oe_inline"/>
</div>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学员作答:老师只读列表(弹窗查看用) ===================== -->
<record id="yuthon_school_course_chapter_exercise_answer_view_tree_teacher" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.exercise.answer.tree.teacher</field>
<field name="model">yuthon.school.course.chapter.exercise.answer</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<list editable="bottom" create="0" delete="0">
<field name="student_id" string="学员" readonly="1"/>
<field name="content" string="回答内容" readonly="1"/>
<field name="submit_time" string="作答时间" readonly="1"/>
</list>
</field>
</record>
<!-- ===================== 课程学习:学生专用表单(仅学生可见,避免覆盖老师默认表单) ===================== -->
<record id="yuthon_school_course_study_form" model="ir.ui.view">
<field name="name">yuthon.school.course.study.form</field>
<field name="model">yuthon.school.course</field>
<field name="priority">10</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<form>
<header>
<field name="name" readonly="1"/>
<field name="chapter_count" readonly="1" string="章节数"/>
</header>
<sheet>
<div class="text-muted mb-3">
任课教师:<field name="teacher_id" readonly="1"/>
· 课时:<field name="hours" readonly="1"/>
</div>
<group>
<field name="introduction" widget="html" readonly="1"/>
</group>
<separator string="课程章节(点击「去学习」观看视频与资料)"/>
<field name="chapter_ids" readonly="1">
<kanban class="o_college_chapter_kanban">
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click o_college_chapter_card">
<div class="o_college_chapter_head">
<span class="o_college_chapter_title"><field name="name"/></span>
</div>
<div class="o_college_chapter_video" t-if="record.video_filename_display.raw_value">
<i class="fa fa-play-circle mr-1"/>
<field name="video_filename_display"/>
</div>
<div class="o_college_chapter_foot">
<button name="action_open_study" type="object"
class="btn btn-primary btn-sm" icon="fa-play">去学习</button>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学生「课程学习」入口动作 ===================== -->
<record id="action_yuthon_school_course_study" model="ir.actions.act_window">
<field name="name">课程学习</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('student_ids.user_id', '=', uid), ('state', '=', 'published')]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban_study')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_study_form')})]"/>
<field name="target">current</field>
</record>
<!-- ===================== 学生「我的课程」入口(用老师完整表单 + 只看自己的课程) ===================== -->
<record id="action_yuthon_school_my_course_student" model="ir.actions.act_window">
<field name="name">我的课程</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('student_ids.user_id', '=', uid), ('state', '=', 'published')]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban_student')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_view_form_student')})]"/>
<field name="target">current</field>
</record>
<!-- ===================== 随堂笔记:列表(按课程分组) ===================== -->
<record id="yuthon_school_course_chapter_notes_list" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.notes.list</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">40</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<list>
<field name="course_id"/>
<field name="name"/>
<field name="sequence"/>
</list>
</field>
</record>
<!-- ===================== 随堂笔记:编辑表单 ===================== -->
<record id="yuthon_school_course_chapter_notes_form" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.notes.form</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">40</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<sheet>
<h2><field name="name" readonly="1"/></h2>
<div class="text-muted mb-3">
所属课程:<field name="course_id" readonly="1"/>
</div>
<group>
<field name="notes" widget="html" groups="college.group_teacher,base.group_system"/>
<field name="notes" widget="html" readonly="1" groups="college.group_student"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 随堂笔记:搜索视图(按课程分组) ===================== -->
<record id="yuthon_school_course_chapter_notes_search" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.notes.search</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">40</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<search>
<field name="course_id"/>
<field name="name"/>
<filter name="group_by_course" string="按课程分组" context="{'group_by': 'course_id'}"/>
</search>
</field>
</record>
<!-- ===================== 随堂笔记:动作 ===================== -->
<record id="action_yuthon_school_class_notes" model="ir.actions.act_window">
<field name="name">随堂笔记</field>
<field name="res_model">yuthon.school.course.chapter</field>
<field name="view_mode">list,form</field>
<field name="domain">['|', ('course_id.teacher_id', '=', uid), ('course_id.student_ids.user_id', '=', uid)]</field>
<field name="context">{'search_default_group_by_course': 1}</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_chapter_notes_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_chapter_notes_form')})]"/>
<field name="search_view_id" ref="yuthon_school_course_chapter_notes_search"/>
<field name="target">current</field>
</record>
</odoo>
@@ -0,0 +1,25 @@
<odoo>
<!-- ===================== 课程批量导入向导表单 ===================== -->
<record id="yuthon_school_course_import_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.course.import.wizard.form</field>
<field name="model">yuthon.school.course.import.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="note" widget="html" readonly="1"/>
<group>
<field name="data_file" filename="file_name"/>
<field name="file_name" invisible="1"/>
</group>
</sheet>
<footer>
<button name="action_download_template" type="object"
string="下载模板" class="btn-secondary"/>
<button name="action_import" type="object"
string="开始导入" class="btn-primary"/>
<button string="取消" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>
+408
View File
@@ -0,0 +1,408 @@
<odoo>
<!-- ===================== 课程:列表 ===================== -->
<record id="yuthon_school_course_view_list" model="ir.ui.view">
<field name="name">yuthon.school.course.list</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<list string="课程">
<field name="name"/>
<field name="code"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="hours"/>
<field name="teacher_id" column_invisible="1"/>
<field name="student_count"/>
<field name="chapter_count"/>
<field name="state"/>
</list>
</field>
</record>
<!-- ===================== 课程:表单 ===================== -->
<record id="yuthon_school_course_view_form" model="ir.ui.view">
<field name="name">yuthon.school.course.form</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_publish" type="object" string="发布"
class="btn-primary"
invisible="[('state', '!=', 'draft')]"/>
<button name="action_archive" type="object" string="归档"
invisible="[('state', '!=', 'published')]"/>
<button name="action_to_draft" type="object" string="转为草稿"
invisible="[('state', '=', 'draft')]"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,published,archived"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="teacher_id" invisible="1"/>
<field name="college"/>
<field name="semester"/>
</group>
<group>
<field name="course_type"/>
<field name="hours"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="is_public"/>
</group>
</group>
<notebook>
<page string="课程内容">
<group>
<field name="syllabus" string="课程大纲"/>
</group>
<separator string="课程章节(每章含视频与资料)"/>
<field name="chapter_ids">
<list editable="top">
<field name="name"/>
<field name="class_hours" string="课时"/>
<field name="video_filename_display" string="视频"/>
<button name="action_open_edit" type="object"
string="编辑章节" icon="fa-pencil"
class="btn-link"
groups="college.group_teacher,base.group_system"/>
</list>
<form>
<group>
<field name="name"/>
<field name="sequence" invisible="1"/>
</group>
<group>
<field name="description" widget="html"
string="章节说明"/>
</group>
<group>
<field name="video_file" filename="video_filename"/>
<field name="video_filename" invisible="1"/>
</group>
<field name="attachment_ids" string="章节资料">
<list editable="bottom">
<field name="name" string="文件名"/>
<field name="file" filename="name"/>
</list>
</field>
</form>
</field>
</page>
<page string="选课学员" groups="college.group_teacher,base.group_system">
<field name="student_ids" widget="many2many"
context="{'default_name': name}"/>
</page>
<page string="课程介绍">
<field name="introduction"/>
</page>
<page string="课程目标">
<field name="objectives"/>
</page>
<page string="课程特色">
<field name="features"/>
</page>
<page string="业务情景">
<field name="business_scenario"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===================== 课程:我的课程学生只读表单(含 Chatter) ===================== -->
<record id="yuthon_school_course_view_form_student" model="ir.ui.view">
<field name="name">yuthon.school.course.form.student</field>
<field name="model">yuthon.school.course</field>
<field name="priority">15</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<form edit="0" create="0" delete="0" duplicate="0">
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="code" readonly="1" string="课程编号"/>
<field name="college" readonly="1"/>
<field name="semester" readonly="1"/>
</group>
<group>
<field name="teacher_id" readonly="1"/>
<field name="course_type" readonly="1" string="课程模块"/>
<field name="hours" readonly="1"/>
<field name="start_date" readonly="1"/>
<field name="end_date" readonly="1"/>
<field name="is_public" readonly="1"/>
</group>
</group>
<notebook>
<page string="课程内容">
<field name="syllabus" readonly="1" string="课程大纲"/>
<separator string="课程章节(按顺序学习视频与资料)"/>
<field name="chapter_ids" readonly="1">
<list>
<field name="name"/>
<field name="class_hours" string="课时"/>
</list>
</field>
</page>
<page string="选课学员" groups="college.group_teacher,base.group_system">
<field name="student_ids" readonly="1"/>
</page>
<page string="课程介绍">
<field name="introduction" readonly="1"/>
</page>
<page string="课程目标">
<field name="objectives" readonly="1"/>
</page>
<page string="课程特色">
<field name="features" readonly="1"/>
</page>
<page string="业务情景">
<field name="business_scenario" readonly="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===================== 课程:搜索 ===================== -->
<record id="yuthon_school_course_view_search" model="ir.ui.view">
<field name="name">yuthon.school.course.search</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<search>
<field name="name" string="课程名称"
filter_domain="[('name', 'ilike', self)]"/>
<field name="college"/>
<field name="semester"/>
<filter name="theory" string="理论"
domain="[('course_type', '=', 'theory')]"/>
<filter name="practice" string="实践"
domain="[('course_type', '=', 'practice')]"/>
<filter name="my_courses" string="我的课程"
domain="[('teacher_id', '=', uid)]"/>
<filter name="published" string="已发布"
domain="[('state', '=', 'published')]"/>
<group expand="0" string="分组">
<filter name="group_college" string="学院"
context="{'group_by': 'college'}"/>
<filter name="group_semester" string="学期"
context="{'group_by': 'semester'}"/>
<filter name="group_state" string="状态"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!-- ===================== 课程:看板 ===================== -->
<record id="yuthon_school_course_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.course.kanban</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<kanban default_group_by="state"
default_order="semester,college,name"
class="college-course-kanban college-kanban-teacher o_kanban_mobile">
<field name="name"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="teacher_id"/>
<field name="student_count"/>
<field name="chapter_count"/>
<field name="state"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<!-- 顶帽:蓝调 + 课程编号 -->
<div style="background:linear-gradient(135deg,#3B82F6,#6366F1); padding:10px 14px; display:flex; align-items:center; justify-content:space-between;">
<div style="color:#fff; font-size:11px; opacity:.85; letter-spacing:.5px;">
<field name="code"/>
</div>
<span class="badge text-white"
style="font-size:10px; background:rgba(255,255,255,.25); padding:3px 8px; border-radius:6px;">
<field name="course_type"/>
</span>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="college-kanban-title">
<field name="name"/>
</div>
<div class="college-kanban-meta text-muted small mb-1">
<i class="fa fa-building"/> <field name="college"/> · <field name="semester"/>
</div>
<div class="college-kanban-meta small mb-1">
<i class="fa fa-user text-muted"/> <field name="teacher_id"/>
</div>
<div style="display:flex; gap:8px; margin-top:8px;">
<span class="college-kanban-badge college-kanban-badge-hours">
<i class="fa fa-clock-o"/> <field name="hours"/> 课时
</span>
<span class="college-kanban-badge college-kanban-badge-students">
<i class="fa fa-users"/> <field name="student_count"/> 学员
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 课程:我的课程学生专属看板(按学期分组,书本风格) ===================== -->
<record id="yuthon_school_course_view_kanban_student" model="ir.ui.view">
<field name="name">yuthon.school.course.kanban.student</field>
<field name="model">yuthon.school.course</field>
<field name="priority">20</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<kanban default_group_by="semester"
default_order="college,name"
class="college-course-kanban college-kanban-my o_kanban_mobile">
<field name="name"/>
<field name="code"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="teacher_id"/>
<field name="hours"/>
<field name="chapter_count"/>
<field name="is_public"/>
<progressbar field="chapter_count" title="章节进度"
colors='{"done": "success", "warning": "warning"}'/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<!-- 顶帽:彩色条 + 课程编号 -->
<div style="background:linear-gradient(135deg,#5B86E5,#36D1DC); padding:10px 14px;">
<div style="color:#fff; font-size:11px; opacity:.85; letter-spacing:.5px;">
<field name="code"/>
</div>
<div style="color:#fff; font-size:15px; font-weight:600; margin-top:2px;">
<field name="name"/>
</div>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="mb-2">
<span class="college-kanban-badge college-kanban-badge-type">
<field name="course_type"/>
</span>
<t t-if="record.is_public.raw_value">
<span class="college-kanban-badge college-kanban-badge-public ml-1">公开</span>
</t>
</div>
<div class="college-kanban-meta text-muted small mb-1">
<field name="college"/>
</div>
<div class="college-kanban-meta small mb-1">
<i class="fa fa-user-circle-o text-muted"/> <field name="teacher_id" widget="many2one_avatar_user"/>
</div>
<div class="college-kanban-meta small text-muted">
<i class="fa fa-clock-o"/> <field name="hours"/> 课时 · 共 <field name="chapter_count"/> 章节
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 课程:课程学习学生专属看板(按学院分组,活力橙风格) ===================== -->
<record id="yuthon_school_course_view_kanban_study" model="ir.ui.view">
<field name="name">yuthon.school.course.kanban.study</field>
<field name="model">yuthon.school.course</field>
<field name="priority">25</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<kanban default_group_by="college"
default_order="semester,name"
class="college-course-kanban college-kanban-study o_kanban_mobile">
<field name="name"/>
<field name="code"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="teacher_id"/>
<field name="hours"/>
<field name="chapter_count"/>
<field name="is_public"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:10px; overflow:hidden;">
<!-- 顶帽:橙色渐变 + 课程类型徽章 -->
<div style="background:linear-gradient(120deg,#FF9966,#FF5E62); padding:8px 14px; display:flex; align-items:center; justify-content:space-between;">
<div style="color:#fff; font-size:11px; opacity:.92;">
<i class="fa fa-book"/> <field name="code"/>
</div>
<span class="college-kanban-badge college-kanban-badge-type">
<field name="course_type"/>
</span>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:12px 14px;">
<div class="college-kanban-title" style="min-height:36px;">
<field name="name"/>
</div>
<div class="college-kanban-meta text-muted small mb-2">
<i class="fa fa-calendar"/> <field name="semester"/>
</div>
<div class="college-kanban-meta small mb-1">
<i class="fa fa-user text-muted"/> <field name="teacher_id" widget="many2one_avatar_user"/>
</div>
<div style="display:flex; gap:8px; margin-top:8px;">
<span class="college-kanban-badge college-kanban-badge-hours">
<i class="fa fa-clock-o"/> <field name="hours"/> 课时
</span>
<span class="college-kanban-badge college-kanban-badge-chapters">
<i class="fa fa-list"/> <field name="chapter_count"/> 章
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 课程:动作(后置定义,显式绑定老师视图,避免学生视图靠优先级抢占) ===================== -->
<record id="action_yuthon_school_course_my" model="ir.actions.act_window">
<field name="name">我的课程</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('teacher_id', '=', uid)]</field>
<field name="context">{'search_default_my_courses': 1}</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_view_form')})]"/>
<field name="target">current</field>
</record>
<record id="action_yuthon_school_course_all" model="ir.actions.act_window">
<field name="name">全部课程</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_view_form')})]"/>
<field name="target">current</field>
</record>
</odoo>
+672
View File
@@ -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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; res.result.status === 'ok') {
window.location.href = res.result.redirect;
} else {
var err = (res.result &amp;&amp; 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>
+485
View File
@@ -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>
+265
View File
@@ -0,0 +1,265 @@
<odoo>
<!-- ===================== 作业布置 ===================== -->
<record id="action_yuthon_school_assignment" model="ir.actions.act_window">
<field name="name">作业布置</field>
<field name="res_model">yuthon.school.assignment</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_my_assignments': 1}</field>
</record>
<record id="yuthon_school_assignment_view_list" model="ir.ui.view">
<field name="name">yuthon.school.assignment.list</field>
<field name="model">yuthon.school.assignment</field>
<field name="arch" type="xml">
<list string="作业">
<header>
<button name="action_publish_batch" type="object" string="批量发布"
class="btn-primary" groups="college.group_teacher"/>
</header>
<field name="name"/>
<field name="course_id"/>
<field name="teacher_id"/>
<field name="due_date"/>
<field name="submission_count"/>
<field name="state"/>
</list>
</field>
</record>
<record id="yuthon_school_assignment_view_form" model="ir.ui.view">
<field name="name">yuthon.school.assignment.form</field>
<field name="model">yuthon.school.assignment</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_publish" type="object" string="发布"
class="btn-primary"
invisible="state != 'draft'"/>
<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="teacher_id" readonly="1"/>
<field name="due_date"/>
</group>
<group>
<field name="attachment" filename="attachment_filename"/>
<field name="attachment_filename" invisible="1"/>
</group>
</group>
<notebook>
<page string="作业说明">
<field name="description"/>
</page>
<page string="提交记录">
<field name="submission_ids" readonly="1">
<list>
<field name="student_id"/>
<field name="submit_time"/>
<field name="score"/>
<field name="state"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="yuthon_school_assignment_view_search" model="ir.ui.view">
<field name="name">yuthon.school.assignment.search</field>
<field name="model">yuthon.school.assignment</field>
<field name="arch" type="xml">
<search>
<field name="name" string="作业标题"
filter_domain="[('name', 'ilike', self)]"/>
<field name="course_id" string="课程"/>
<field name="teacher_id" string="布置教师"/>
<filter name="my_assignments" string="我布置的"
domain="[('teacher_id', '=', uid)]"/>
<filter name="published" string="已发布"
domain="[('state', '=', 'published')]"/>
<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="action_yuthon_school_assignment_submission" model="ir.actions.act_window">
<field name="name">提交与批改</field>
<field name="res_model">yuthon.school.assignment.submission</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_group_state': 1}</field>
</record>
<!-- ===================== 作业提交(学生端) ===================== -->
<record id="action_yuthon_school_my_submission" model="ir.actions.act_window">
<field name="name">作业提交</field>
<field name="res_model">yuthon.school.assignment.submission</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">['&amp;', ('student_id.user_id', '=', uid), ('assignment_id.course_id.state', '=', 'published')]</field>
<field name="context">{'search_default_my_submissions': 1}</field>
</record>
<record id="yuthon_school_assignment_submission_view_list" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.list</field>
<field name="model">yuthon.school.assignment.submission</field>
<field name="arch" type="xml">
<list string="作业提交" multi_edit="1">
<header>
<button name="action_open_batch_wizard"
type="object" string="批量批改"
class="btn-primary"
groups="college.group_teacher"/>
<button name="action_download_attachments"
type="object" string="批量下载附件"
class="btn-secondary"
groups="college.group_teacher"/>
</header>
<field name="assignment_id"/>
<field name="student_id" string="姓名" groups="college.group_teacher"/>
<field name="score"/>
<field name="state"/>
<field name="submit_time"/>
<field name="due_date"/>
</list>
</field>
</record>
<record id="yuthon_school_assignment_submission_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.kanban</field>
<field name="model">yuthon.school.assignment.submission</field>
<field name="arch" type="xml">
<kanban default_group_by="state"
default_order="due_date desc, id"
class="college-course-kanban college-kanban-homework o_kanban_mobile">
<field name="assignment_id"/>
<field name="student_id"/>
<field name="score"/>
<field name="state"/>
<field name="due_date"/>
<field name="submit_time"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<!-- 顶帽:珊瑚渐变 + 得分 -->
<div style="background:linear-gradient(135deg,#F56565,#F6AD55); padding:10px 14px; display:flex; align-items:center; justify-content:space-between;">
<div class="college-kanban-title" style="color:#fff; font-size:13px; max-width:70%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
<field name="assignment_id"/>
</div>
<span class="college-kanban-badge college-kanban-badge-score">
<field name="score"/> 分
</span>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="college-kanban-meta small mb-2" groups="college.group_teacher">
<i class="fa fa-user text-muted"/> <field name="student_id"/>
</div>
<div class="mb-2" style="display:flex; gap:6px; flex-wrap:wrap;">
<span class="college-kanban-badge college-kanban-badge-state">
<field name="state"/>
</span>
</div>
<div class="college-kanban-meta small text-muted" style="line-height:1.5;">
<div><i class="fa fa-calendar-check-o"/> 截止:<field name="due_date"/></div>
<t t-if="record.submit_time.raw_value">
<div><i class="fa fa-clock-o"/> 提交:<field name="submit_time"/></div>
</t>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="yuthon_school_assignment_submission_view_form" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.form</field>
<field name="model">yuthon.school.assignment.submission</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_submit" type="object" string="提交"
class="btn-primary"
invisible="state != 'pending'"/>
<field name="state" widget="statusbar"
statusbar_visible="pending,submitted,graded"/>
</header>
<sheet>
<field name="name" invisible="1"/>
<group>
<field name="assignment_id" readonly="state in ('submitted', 'graded')"/>
<field name="student_id" invisible="1"/>
<field name="teacher_id" invisible="1"/>
</group>
<group string="老师布置的附件">
<field name="assignment_attachment"
filename="assignment_attachment_filename"
readonly="1"
string="附件"/>
<field name="assignment_attachment_filename" invisible="1"/>
</group>
<group>
<field name="due_date" readonly="1"/>
<field name="submit_time" readonly="1"/>
</group>
<group groups="college.group_teacher">
<field name="score"/>
</group>
<group>
<field name="content" readonly="state in ('submitted', 'graded')"/>
</group>
<group>
<field name="attachment" filename="attachment_filename" readonly="state in ('submitted', 'graded')"/>
<field name="attachment_filename" invisible="1"/>
</group>
<group groups="college.group_teacher">
<field name="feedback"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="yuthon_school_assignment_submission_view_search" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.search</field>
<field name="model">yuthon.school.assignment.submission</field>
<field name="arch" type="xml">
<search>
<field name="assignment_id" string="作业"/>
<field name="student_id" string="学员"/>
<filter name="my_submissions" string="我的提交"
domain="[('student_id.user_id', '=', uid)]"/>
<filter name="pending" string="待提交"
domain="[('state', '=', 'pending')]"/>
<filter name="submitted" string="待批改"
domain="[('state', '=', 'submitted')]"/>
<filter name="graded" string="已批改"
domain="[('state', '=', 'graded')]"/>
<group expand="0" string="分组">
<filter name="group_assignment" string="作业"
context="{'group_by': 'assignment_id'}"/>
<filter name="group_state" string="状态"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
</odoo>
+44
View File
@@ -0,0 +1,44 @@
<odoo>
<!-- ===================== 师生批量导入向导 ===================== -->
<record id="yuthon_school_import_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.import.wizard.form</field>
<field name="model">yuthon.school.import.wizard</field>
<field name="arch" type="xml">
<form string="导入师生">
<sheet>
<field name="note" widget="html" nolabel="1"/>
<group string="下载模板">
<div class="d-flex align-items-center"
style="padding: 12px 0;">
<button name="action_download_template"
type="object" string="📥 下载模板"
class="btn-primary"/>
<span class="text-muted ms-2 small">
模板为 CSV 格式,可用 Excel / WPS 打开编辑
</span>
</div>
</group>
<group string="上传文件">
<field name="data_file" filename="file_name"/>
<field name="file_name" invisible="1"/>
</group>
</sheet>
<footer>
<button name="action_import" type="object"
string="开始导入" class="btn-primary"
invisible="not data_file"/>
<button string="取消" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- 导入动作 -->
<record id="action_yuthon_school_import_wizard" model="ir.actions.act_window">
<field name="name">导入师生</field>
<field name="res_model">yuthon.school.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
+329
View File
@@ -0,0 +1,329 @@
<odoo>
<!-- ===================== 产品介绍(顶级菜单,置顶,位于「我的实训」上方) ===================== -->
<!-- 在当前页面的内容区以表单形式打开预置记录 about_page_product,
其 content 富文本字段即为 H5 页面内容(介绍本教育产品本身)。 -->
<record id="action_yuthon_product_intro" model="ir.actions.act_window">
<field name="name">产品介绍</field>
<field name="res_model">yuthon.about.page</field>
<field name="view_mode">form</field>
<field name="res_id" ref="about_page_product"/>
<field name="target">current</field>
</record>
<menuitem id="menu_yuthon_product_intro"
name="产品介绍"
web_icon="college,static/src/img/product_intro.png"
sequence="0"
action="action_yuthon_product_intro"
groups="college.group_teacher,college.group_student,base.group_system"/>
<!-- ===================== 我的实训(顶级菜单,置顶) ===================== -->
<menuitem id="menu_yuthon_training"
name="我的实训"
web_icon="college,static/src/img/training.png"
sequence="1"
action="action_yuthon_school_training"
groups="college.group_teacher,college.group_student,base.group_system"/>
<!-- ===================== 我的课程(统一入口,按用户角色路由) ===================== -->
<!-- 老师 / 管理员 → action_yuthon_school_course_my(老师端我的课程) -->
<!-- 学生 → action_yuthon_school_my_course_student(学生端我的课程) -->
<!-- 这样管理员只看到一个「我的课程」菜单,避免与学生端重复 -->
<record id="action_yuthon_school_course_router" model="ir.actions.server">
<field name="name">我的课程(按角色路由)</field>
<field name="model_id" search="[('model', '=', 'yuthon.school.progress')]"/>
<field name="state">code</field>
<field name="code"><![CDATA[
if env.user.has_group('college.group_teacher') or env.user.has_group('base.group_system'):
action = env.ref('college.action_yuthon_school_course_my').sudo().read()[0]
else:
action = env.ref('college.action_yuthon_school_my_course_student').sudo().read()[0]
]]></field>
</record>
<!-- ===================== 我的课程(统一菜单,老师/学生/管理员都看到同一个入口) ===================== -->
<menuitem id="menu_yuthon_course_my"
name="我的课程"
web_icon="college,static/src/img/my_course.png"
action="action_yuthon_school_course_router"
sequence="3"
groups="college.group_teacher,college.group_student,base.group_system"/>
<!-- ===================== 隐藏 Odoo 默认「讨论」菜单 ===================== -->
<record id="mail.menu_root_discuss" model="ir.ui.menu">
<field name="active" eval="False"/>
</record>
<!-- ===================== 「设置」菜单:仅超级管理员可见 ===================== -->
<record id="base.menu_management" model="ir.ui.menu">
<field name="active" eval="True"/>
<field name="groups_id" eval="[(6, 0, [ref('base.group_system')])]"/>
</record>
<!-- ===================== 原生「应用」菜单:师生可见 =====================
让老师、学生都能在左侧看到 Odoo 默认的「应用」菜单(模块列表)。
由于「应用」挂在「设置」下,这里单独给 base.menu_apps 追加
teacher/student 组,菜单会脱离隐藏的父菜单提升到顶层。
权限区分:
- 老师:group_teacher 隐含 base.group_erp_manager,可安装/升级/卸载模块
(模块按钮已放开到 group_erp_manager),但看不到「设置」菜单;
- 学生:仅 group_user,只读浏览,无法安装/升级/卸载。 -->
<record id="base.menu_apps" model="ir.ui.menu">
<field name="groups_id" eval="[
(6, 0, [
ref('base.group_system'),
ref('college.group_teacher'),
ref('college.group_student'),
])
]"/>
</record>
<!-- ===================== 隐藏学生的「请求访问」按钮 =====================
base_install_request 给所有非系统管理员都加了「Request Access」按钮,
学生点了会发起安装申请。这里把该按钮限定为仅管理员可见,让学生彻底只读。 -->
<record id="view_module_kanban_hide_request_access" model="ir.ui.view">
<field name="name">ir.module.module.kanban.hide.request.access</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base_install_request.ir_module_module_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_open_install_request']" position="attributes">
<attribute name="groups">base.group_system</attribute>
</xpath>
</field>
</record>
<!-- ===================== 模块按钮放开给「访问权限」组 =====================
原生模块安装/升级/卸载按钮 groups="base.group_system",导致只有设置组
(管理员)可见。为了让老师(group_erp_manager)能安装模块、又不用进
「设置」,把这些按钮的 groups 从 group_system 改为 group_erp_manager。
老师属于 group_erp_manager,可见可点;学生只属于 group_user,仍不可见。 -->
<!-- 看板视图(应用列表卡片) -->
<record id="view_module_kanban_teacher_install" model="ir.ui.view">
<field name="name">ir.module.module.kanban.teacher.install</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='button_immediate_install']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//a[@name='button_immediate_upgrade']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//a[@name='button_uninstall_wizard']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_uninstall_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_install_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
</field>
</record>
<!-- 表单视图(模块详情页) -->
<record id="view_module_form_teacher_install" model="ir.ui.view">
<field name="name">ir.module.module.form.teacher.install</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='button_immediate_install']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_immediate_upgrade']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_uninstall_wizard']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_uninstall_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_upgrade_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_install_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
</field>
</record>
<!-- ===================== 教育(根) ===================== -->
<menuitem id="menu_yuthon_education_root"
name="学习管理"
web_icon="college,static/src/img/education.png"
sequence="5"/>
<!-- ===================== 课程管理(直接指向全部课程的 action,不再有子菜单) ===================== -->
<menuitem id="menu_yuthon_course_root"
name="课程管理"
web_icon="college,static/src/img/Course Management.png"
action="action_yuthon_school_course_all"
sequence="10"
groups="college.group_teacher,base.group_system"/>
<!-- ===================== 考试(与课程管理同级,顶级菜单,无 parent 才能带 web_icon) ===================== -->
<menuitem id="menu_yuthon_exam_root"
name="考试"
web_icon="college,static/src/img/exam.png"
sequence="15"/>
<menuitem id="menu_yuthon_exam_question"
name="题库"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_question"
sequence="10"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_manage"
name="考试管理"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam"
sequence="20"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_grade"
name="评阅试卷"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_paper_grade"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_result"
name="考试结果"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_result"
sequence="40"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_my"
name="我的考试"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_paper_student"
sequence="50"
groups="college.group_student"/>
<!-- 我的学习:学生端专用(简单表单 + 去学习按钮) -->
<menuitem id="menu_yuthon_course_study"
name="我的学习"
web_icon="college,static/src/img/course_study.png"
action="action_yuthon_school_course_study"
sequence="2"
groups="college.group_student"/>
<!-- ===================== 师生管理(顶级菜单,与「教育」平级,仅老师/管理员) ===================== -->
<menuitem id="menu_yuthon_org_root"
name="师生管理"
web_icon="college,static/src/img/organization.png"
sequence="20"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_class"
name="班级"
parent="menu_yuthon_org_root"
action="action_yuthon_school_class"
sequence="10"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_teacher"
name="老师"
parent="menu_yuthon_org_root"
action="action_yuthon_school_teacher"
sequence="20"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_student"
name="学生"
parent="menu_yuthon_org_root"
action="action_yuthon_school_student"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<!-- ===================== 学习进展(统一入口,服务端按角色路由) ===================== -->
<menuitem id="menu_yuthon_progress"
name="学习进展"
parent="menu_yuthon_education_root"
action="action_yuthon_school_progress_router"
sequence="20"
groups="college.group_teacher,college.group_student,base.group_system"/>
<!-- ===================== 作业习题 ===================== -->
<menuitem id="menu_yuthon_homework"
name="作业习题"
parent="menu_yuthon_education_root"
sequence="30"/>
<!-- 作业布置:仅老师 -->
<menuitem id="menu_yuthon_homework_assignment"
name="作业布置"
parent="menu_yuthon_homework"
action="action_yuthon_school_assignment"
sequence="10"
groups="college.group_teacher,base.group_system"/>
<!-- 作业提交:仅学生 -->
<menuitem id="menu_yuthon_homework_submit"
name="作业提交"
parent="menu_yuthon_homework"
action="action_yuthon_school_my_submission"
sequence="20"
groups="college.group_student,base.group_system"/>
<!-- 作业批改:仅老师(无提交入口) -->
<menuitem id="menu_yuthon_homework_grade"
name="作业批改"
parent="menu_yuthon_homework"
action="action_yuthon_school_assignment_submission"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_class_notes"
name="随堂笔记"
parent="menu_yuthon_homework"
action="action_yuthon_school_class_notes"
sequence="40"
groups="college.group_teacher,college.group_student,base.group_system"/>
<!-- ===================== 关于系统说明 ===================== -->
<!-- 系统说明:打开预置记录 about_page_system -->
<record id="action_yuthon_about_system" model="ir.actions.act_window">
<field name="name">系统说明</field>
<field name="res_model">yuthon.about.page</field>
<field name="view_mode">form</field>
<field name="res_id" ref="about_page_system"/>
<field name="target">current</field>
</record>
<!-- 操作手册:打开预置记录 about_page_operation -->
<record id="action_yuthon_about_operation" model="ir.actions.act_window">
<field name="name">操作手册</field>
<field name="res_model">yuthon.about.page</field>
<field name="view_mode">form</field>
<field name="res_id" ref="about_page_operation"/>
<field name="target">current</field>
</record>
<!-- 课程导入动作(关联 yuthon.school.course.import.wizard) -->
<record id="action_yuthon_school_course_import_wizard" model="ir.actions.act_window">
<field name="name">课程导入</field>
<field name="res_model">yuthon.school.course.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- ===================== 关于(顶级菜单,排在最后) ===================== -->
<menuitem id="menu_yuthon_about"
name="关于"
web_icon="college,static/src/img/about.png"
sequence="99"
groups="college.group_teacher,college.group_student,base.group_system"/>
<menuitem id="menu_yuthon_about_system"
name="系统说明"
parent="menu_yuthon_about"
action="action_yuthon_about_system"
sequence="10"/>
<menuitem id="menu_yuthon_about_operation"
name="操作手册"
parent="menu_yuthon_about"
action="action_yuthon_about_operation"
sequence="20"/>
<menuitem id="menu_yuthon_import_wizard"
name="导入师生"
parent="menu_yuthon_about"
action="action_yuthon_school_import_wizard"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_course_import_wizard"
name="课程导入"
parent="menu_yuthon_about"
action="action_yuthon_school_course_import_wizard"
sequence="40"
groups="college.group_teacher,base.group_system"/>
</odoo>
+60
View File
@@ -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>
+99
View File
@@ -0,0 +1,99 @@
<odoo>
<!-- ===================== 老师 ===================== -->
<record id="action_yuthon_school_teacher" model="ir.actions.act_window">
<field name="name">老师</field>
<field name="res_model">yuthon.school.teacher</field>
<field name="view_mode">list,form</field>
</record>
<record id="yuthon_school_teacher_view_list" model="ir.ui.view">
<field name="name">yuthon.school.teacher.list</field>
<field name="model">yuthon.school.teacher</field>
<field name="arch" type="xml">
<list string="老师">
<field name="name"/>
<field name="teacher_no"/>
<field name="title"/>
<field name="user_id"/>
<field name="phone"/>
</list>
</field>
</record>
<record id="yuthon_school_teacher_view_form" model="ir.ui.view">
<field name="name">yuthon.school.teacher.form</field>
<field name="model">yuthon.school.teacher</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="teacher_no"/>
<field name="title"/>
</group>
<group>
<field name="phone"/>
<field name="email"/>
</group>
<notebook>
<page string="担任班主任的班级">
<field name="class_ids">
<list>
<field name="name"/>
<field name="student_count"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ===================== 班级 ===================== -->
<record id="action_yuthon_school_class" model="ir.actions.act_window">
<field name="name">班级</field>
<field name="res_model">yuthon.school.class</field>
<field name="view_mode">list,form</field>
</record>
<record id="yuthon_school_class_view_list" model="ir.ui.view">
<field name="name">yuthon.school.class.list</field>
<field name="model">yuthon.school.class</field>
<field name="arch" type="xml">
<list string="班级">
<field name="name"/>
<field name="head_teacher_id"/>
<field name="student_count"/>
</list>
</field>
</record>
<record id="yuthon_school_class_view_form" model="ir.ui.view">
<field name="name">yuthon.school.class.form</field>
<field name="model">yuthon.school.class</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="head_teacher_id"/>
</group>
<notebook>
<page string="学生名单">
<field name="student_ids">
<list>
<field name="name"/>
<field name="student_number"/>
<field name="gender"/>
<field name="user_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>
+197
View File
@@ -0,0 +1,197 @@
<odoo>
<!-- ===================== 学习进展:动作(老师用,无限制) ===================== -->
<record id="action_yuthon_school_progress" model="ir.actions.act_window">
<field name="name">学习进展</field>
<field name="res_model">yuthon.school.progress</field>
<field name="view_mode">kanban,list,form</field>
</record>
<!-- ===================== 学习进展:统一入口(按角色动态路由) ===================== -->
<record id="action_yuthon_school_progress_router" model="ir.actions.server">
<field name="name">学习进展</field>
<field name="model_id" ref="model_yuthon_school_progress"/>
<field name="state">code</field>
<field name="code"><![CDATA[
# 老师 / 管理员 -> 查看全部学习进展
# 学生 -> 仅查看自己已发布课程的学习进展
if env.user.has_group('college.group_teacher') or env.user.has_group('base.group_system'):
action = env.ref('college.action_yuthon_school_progress').sudo().read()[0]
else:
action = env.ref('college.action_yuthon_school_progress_student').sudo().read()[0]
]]></field>
</record>
<!-- ===================== 学习进展:列表 ===================== -->
<record id="yuthon_school_progress_view_list" model="ir.ui.view">
<field name="name">yuthon.school.progress.list</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<list string="学习进展">
<field name="course_id"/>
<field name="student_id" groups="college.group_teacher"/>
<field name="teacher_id"/>
<field name="chapter_done"/>
<field name="chapter_total"/>
<field name="progress_rate"/>
<field name="status"/>
<field name="last_study_date"/>
<field name="score"/>
</list>
</field>
</record>
<!-- ===================== 学习进展:表单(老师/管理员可编辑) ===================== -->
<record id="yuthon_school_progress_view_form" model="ir.ui.view">
<field name="name">yuthon.school.progress.form</field>
<field name="model">yuthon.school.progress</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="course_id"/>
<field name="student_id"/>
<field name="teacher_id"/>
</group>
<group>
<field name="chapter_done"/>
<field name="chapter_total"/>
<field name="progress_rate"/>
<field name="status"/>
<field name="last_study_date"/>
<field name="score"/>
</group>
<group>
<field name="note"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学习进展:表单(学生只读) ===================== -->
<record id="yuthon_school_progress_view_form_student" model="ir.ui.view">
<field name="name">yuthon.school.progress.form.student</field>
<field name="model">yuthon.school.progress</field>
<field name="priority">5</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<form edit="0" create="0" delete="0" duplicate="0">
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group string="课程信息">
<field name="course_id" readonly="1"/>
<field name="teacher_id" readonly="1"/>
</group>
<group string="学习进度">
<field name="chapter_done" readonly="1"/>
<field name="chapter_total" readonly="1"/>
<field name="progress_rate" readonly="1"/>
<field name="status" readonly="1"/>
</group>
</group>
<group>
<group>
<field name="last_study_date" readonly="1"/>
<field name="score" readonly="1"/>
</group>
</group>
<group string="备注">
<field name="note" readonly="1" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学习进展:搜索 ===================== -->
<record id="yuthon_school_progress_view_search" model="ir.ui.view">
<field name="name">yuthon.school.progress.search</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<search>
<field name="course_id" string="课程"/>
<field name="student_id" string="学员"/>
<field name="teacher_id" string="任课教师"/>
<filter name="not_started" string="未开始"
domain="[('status', '=', 'not_started')]"/>
<filter name="in_progress" string="进行中"
domain="[('status', '=', 'in_progress')]"/>
<filter name="completed" string="已完成"
domain="[('status', '=', 'completed')]"/>
<group expand="0" string="分组">
<filter name="group_course" string="课程"
context="{'group_by': 'course_id'}"/>
<filter name="group_status" string="状态"
context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<!-- ===================== 学习进展:看板 ===================== -->
<record id="yuthon_school_progress_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.progress.kanban</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<kanban default_group_by="status" class="college-course-kanban college-kanban-progress o_kanban_mobile">
<field name="course_id"/>
<field name="student_id"/>
<field name="teacher_id"/>
<field name="chapter_done"/>
<field name="chapter_total"/>
<field name="progress_rate"/>
<field name="score"/>
<field name="status"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<div style="background:linear-gradient(135deg,#0e7490,#22d3ee); padding:10px 14px; display:flex; align-items:center; justify-content:space-between;">
<div class="college-kanban-title" style="color:#fff; font-size:14px;">
<field name="course_id"/>
</div>
<span class="college-kanban-badge college-kanban-badge-score">
<field name="score"/> 分
</span>
</div>
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="college-kanban-meta small mb-2" groups="college.group_teacher">
<i class="fa fa-user text-muted"/> <field name="student_id"/>
</div>
<div class="mb-2">
<div style="display:flex; align-items:center; gap:8px;">
<span class="college-kanban-meta small text-muted">章节 <field name="chapter_done"/>/<field name="chapter_total"/></span>
<span style="flex:1;"/>
<span class="college-kanban-progress-rate small"><field name="progress_rate"/>%</span>
</div>
</div>
<div class="college-kanban-meta small text-muted">
<i class="fa fa-user-circle"/> <field name="teacher_id"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 学习进展:学生专用(仅看自己选课且已发布课程) ===================== -->
<!-- 注意:必须放在上方所有视图定义之后,否则加载时 ref() 找不到尚未注册的视图 xml_id -->
<record id="action_yuthon_school_progress_student" model="ir.actions.act_window">
<field name="name">学习进展</field>
<field name="res_model">yuthon.school.progress</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">['&amp;', ('student_id.user_id', '=', uid), ('course_id.state', '=', 'published')]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_progress_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_progress_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_progress_view_form_student')})]"/>
</record>
</odoo>
+51
View File
@@ -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>
+49
View File
@@ -0,0 +1,49 @@
<odoo>
<record id="action_yuthon_school_student" model="ir.actions.act_window">
<field name="name">学员库</field>
<field name="res_model">yuthon.school.student</field>
<field name="view_mode">list,form</field>
<field name="target">current</field>
</record>
<record id="yuthon_school_student_view_list" model="ir.ui.view">
<field name="name">yuthon.school.student.list</field>
<field name="model">yuthon.school.student</field>
<field name="arch" type="xml">
<list string="学生">
<field name="name"/>
<field name="student_number"/>
<field name="gender"/>
<field name="class_id"/>
<field name="college"/>
<field name="user_id"/>
<field name="phone"/>
<field name="email"/>
</list>
</field>
</record>
<record id="yuthon_school_student_view_form" model="ir.ui.view">
<field name="name">yuthon.school.student.form</field>
<field name="model">yuthon.school.student</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="student_number"/>
<field name="gender"/>
<field name="user_id"/>
<field name="class_id"/>
<field name="college"/>
</group>
<group>
<field name="phone"/>
<field name="email"/>
<field name="note"/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>
+80
View File
@@ -0,0 +1,80 @@
<odoo>
<!-- ===================== 我的实训:看板(占位) ===================== -->
<record id="yuthon_school_training_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.training.kanban</field>
<field name="model">yuthon.school.training</field>
<field name="arch" type="xml">
<kanban default_group_by="name" class="college-course-kanban college-kanban-training o_kanban_mobile">
<field name="name"/>
<field name="note"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:10px; overflow:hidden;">
<!-- 顶帽:紫色渐变 -->
<div style="background:linear-gradient(135deg,#8B5CF6,#D946EF); padding:14px; text-align:center;">
<div style="font-size:28px; opacity:.25; color:#fff; margin-bottom:4px;">🏋️</div>
<div class="college-kanban-title" style="color:#fff; font-size:16px;">
<field name="name"/>
</div>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:12px 14px;">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<span class="college-kanban-badge college-kanban-badge-purple">
<i class="fa fa-graduation-cap"/> 实训中
</span>
<span class="college-kanban-badge college-kanban-badge-pink">
<i class="fa fa-clock-o"/> 进行中
</span>
</div>
<div class="college-kanban-meta text-muted small mt-2" style="line-height:1.4; max-height:40px; overflow:hidden;">
<field name="note"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 我的实训:列表 ===================== -->
<record id="yuthon_school_training_view_list" model="ir.ui.view">
<field name="name">yuthon.school.training.list</field>
<field name="model">yuthon.school.training</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="note"/>
</list>
</field>
</record>
<!-- ===================== 我的实训:表单 ===================== -->
<record id="yuthon_school_training_view_form" model="ir.ui.view">
<field name="name">yuthon.school.training.form</field>
<field name="model">yuthon.school.training</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="note"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 我的实训:动作 ===================== -->
<record id="action_yuthon_school_training" model="ir.actions.act_window">
<field name="name">我的实训</field>
<field name="res_model">yuthon.school.training</field>
<field name="view_mode">kanban,list,form</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_training_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_training_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_training_view_form')})]"/>
</record>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import models
+33
View File
@@ -0,0 +1,33 @@
{
'name': 'Yuthon 出勤管理',
'version': '18.0.1.0.0',
'summary': '打卡出勤:签到/签退,自动计算工时、迟到与状态',
'description': """
教学模块②(关系层):yuthon_attendance
======================================
依赖 yuthon_employee。定义出勤记录模型 yuthon.attendance(挂在员工下,
一对一 N:1),并演示计算字段(working_hours / status / late_minutes)
与模块组合(_inherit yuthon.employee 追加反向 attendance_ids)。
教学要点:
- Many2one 关系:employee_id 外键 + ondelete='cascade'
- computed field:@api.depends 计算工时、迟到分钟、出勤状态
- 约束:_sql_constraints(一人一天一条)、check_in < check_out
- 分析视图:graph / pivot 统计出勤
""",
'author': 'Yuthon',
'website': '',
'category': 'Education',
'depends': ['yuthon_employee', 'web'],
'data': [
'security/ir.model.access.csv',
'views/attendance_views.xml',
],
'demo': [
'data/demo.xml',
],
'license': 'LGPL-3',
'installable': True,
'application': True,
'auto_install': False,
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 演示数据:出勤记录。employee_id 通过 ref 引用 yuthon_employee 的 demo。 -->
<!-- 张三:9月2日正常、9月3日迟到 -->
<record id="attendance_zhang_0902" model="yuthon.attendance">
<field name="employee_id" ref="yuthon_employee.employee_zhang"/>
<field name="date">2024-09-02</field>
<field name="check_in" eval="datetime.datetime(2024, 9, 2, 8, 30, 0)"/>
<field name="check_out" eval="datetime.datetime(2024, 9, 2, 17, 30, 0)"/>
</record>
<record id="attendance_zhang_0903" model="yuthon.attendance">
<field name="employee_id" ref="yuthon_employee.employee_zhang"/>
<field name="date">2024-09-03</field>
<field name="check_in" eval="datetime.datetime(2024, 9, 3, 9, 35, 0)"/>
<field name="check_out" eval="datetime.datetime(2024, 9, 3, 17, 30, 0)"/>
</record>
<!-- 李四:9月2日缺勤(无签到) -->
<record id="attendance_li_0902" model="yuthon.attendance">
<field name="employee_id" ref="yuthon_employee.employee_li"/>
<field name="date">2024-09-02</field>
</record>
<!-- 王五:9月2日正常 -->
<record id="attendance_wang_0902" model="yuthon.attendance">
<field name="employee_id" ref="yuthon_employee.employee_wang"/>
<field name="date">2024-09-02</field>
<field name="check_in" eval="datetime.datetime(2024, 9, 2, 8, 50, 0)"/>
<field name="check_out" eval="datetime.datetime(2024, 9, 2, 18, 0, 0)"/>
</record>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import attendance
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
from datetime import timedelta
# 教学用:规定上班时间为 09:00。
# 注意:Odoo 数据库里的 Datetime 以 UTC 存储,这里直接用字段的“小时”做演示比较;
# 生产环境应先用 fields.Datetime.context_timestamp() 把时间转换到用户时区再判断。
WORK_START_HOUR = 9
class Attendance(models.Model):
"""教职工出勤记录。"""
_name = 'yuthon.attendance'
_description = '出勤记录'
_order = 'date desc, employee_id'
employee_id = fields.Many2one(
'yuthon.employee',
string='教职工',
required=True,
ondelete='cascade',
)
date = fields.Date(
string='日期',
required=True,
default=fields.Date.today,
)
check_in = fields.Datetime(string='签到时间')
check_out = fields.Datetime(string='签退时间')
# ---- 计算字段 ----
working_hours = fields.Float(
string='工时(小时)',
compute='_compute_working_hours',
store=True,
)
status = fields.Selection(
[
('normal', '正常'),
('late', '迟到'),
('absent', '缺勤'),
],
string='状态',
compute='_compute_status',
store=True,
)
late_minutes = fields.Integer(
string='迟到分钟',
compute='_compute_status',
store=True,
)
# ---- 约束 ----
_sql_constraints = [
('uniq_employee_date', 'unique(employee_id, date)',
'同一教职工一天只能有一条出勤记录!'),
]
@api.constrains('check_in', 'check_out')
def _check_time_order(self):
for rec in self:
if rec.check_in and rec.check_out and rec.check_out < rec.check_in:
raise models.ValidationError('签退时间不能早于签到时间。')
@api.depends('check_in', 'check_out')
def _compute_working_hours(self):
for rec in self:
if rec.check_in and rec.check_out and rec.check_out >= rec.check_in:
delta = rec.check_out - rec.check_in
rec.working_hours = round(delta.total_seconds() / 3600.0, 2)
else:
rec.working_hours = 0.0
@api.depends('check_in')
def _compute_status(self):
for rec in self:
if not rec.check_in:
rec.status = 'absent'
rec.late_minutes = 0
continue
check_in_hour = rec.check_in.hour + rec.check_in.minute / 60.0
if check_in_hour > WORK_START_HOUR:
rec.status = 'late'
threshold = rec.check_in.replace(
hour=WORK_START_HOUR, minute=0, second=0, microsecond=0)
rec.late_minutes = max(0, int(
(rec.check_in - threshold).total_seconds() // 60))
else:
rec.status = 'normal'
rec.late_minutes = 0
class Employee(models.Model):
"""通过 _inherit 反向扩展 yuthon.employee,把出勤记录挂上去。
这是「模块组合」的关键示范:yuthon_attendance 不修改 yuthon_employee 的源码,
而是继承它追加 One2many 反向关联,让两个模块保持解耦。
"""
_inherit = 'yuthon.employee'
attendance_ids = fields.One2many(
'yuthon.attendance',
'employee_id',
string='出勤记录',
)
attendance_count = fields.Integer(
string='出勤记录数',
compute='_compute_attendance_count',
)
@api.depends('attendance_ids')
def _compute_attendance_count(self):
for rec in self:
rec.attendance_count = len(rec.attendance_ids)
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_attendance,yuthon.attendance,yuthon_attendance.model_yuthon_attendance,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_attendance yuthon.attendance yuthon_attendance.model_yuthon_attendance base.group_user 1 1 1 1
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 列表视图 ============ -->
<record id="view_yuthon_attendance_list" model="ir.ui.view">
<field name="name">yuthon.attendance.list</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<list string="出勤记录" decoration-danger="status == 'absent'"
decoration-warning="status == 'late'">
<field name="employee_id"/>
<field name="date"/>
<field name="check_in"/>
<field name="check_out"/>
<field name="working_hours"/>
<field name="status"/>
<field name="late_minutes"/>
</list>
</field>
</record>
<!-- ============ 表单视图 ============ -->
<record id="view_yuthon_attendance_form" model="ir.ui.view">
<field name="name">yuthon.attendance.form</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<form string="出勤记录">
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="date"/>
<field name="check_in"/>
<field name="check_out"/>
</group>
<group>
<field name="working_hours"/>
<field name="status"/>
<field name="late_minutes"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============ 搜索视图 ============ -->
<record id="view_yuthon_attendance_search" model="ir.ui.view">
<field name="name">yuthon.attendance搜索</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<search string="搜索出勤">
<field name="employee_id"/>
<field name="date"/>
<separator/>
<filter name="filter_late" string="迟到"
domain="[('status', '=', 'late')]"/>
<filter name="filter_absent" string="缺勤"
domain="[('status', '=', 'absent')]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_employee" string="按教职工"
context="{'group_by': 'employee_id'}"/>
<filter name="groupby_status" string="按状态"
context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<!-- ============ 图形(迟到分钟汇总) ============ -->
<record id="view_yuthon_attendance_graph" model="ir.ui.view">
<field name="name">yuthon.attendance.graph</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<graph string="各教职工迟到分钟数" type="bar">
<field name="employee_id" type="row"/>
<field name="late_minutes" type="measure"/>
</graph>
</field>
</record>
<!-- ============ 透视表 ============ -->
<record id="view_yuthon_attendance_pivot" model="ir.ui.view">
<field name="name">yuthon.attendance.pivot</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<pivot string="出勤透视">
<field name="employee_id" type="row"/>
<field name="status" type="col"/>
<field name="late_minutes" type="measure"/>
</pivot>
</field>
</record>
<!-- ============ 动作 ============ -->
<record id="action_yuthon_attendance" model="ir.actions.act_window">
<field name="name">出勤记录</field>
<field name="res_model">yuthon.attendance</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="search_view_id" ref="view_yuthon_attendance_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
登记第一条出勤记录
</p>
</field>
</record>
<!-- ============ 子菜单(挂载到 yuthon_employee 的根菜单) ============ -->
<menuitem id="menu_yuthon_attendance" name="出勤"
parent="yuthon_employee.menu_yuthon_hr_root"
action="action_yuthon_attendance" sequence="20"/>
<!-- ============ 注入「教职工」视图(模块组合演示) ============ -->
<!-- 列表:追加出勤记录数列 -->
<record id="view_yuthon_employee_list_attendance" model="ir.ui.view">
<field name="name">yuthon.employee.list.inherit.attendance</field>
<field name="model">yuthon.employee</field>
<field name="inherit_id" ref="yuthon_employee.view_yuthon_employee_list"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='base_salary']" position="after">
<field name="attendance_count"/>
</xpath>
</field>
</record>
<!-- 表单:注入出勤记录页 -->
<record id="view_yuthon_employee_form_attendance" model="ir.ui.view">
<field name="name">yuthon.employee.form.inherit.attendance</field>
<field name="model">yuthon.employee</field>
<field name="inherit_id" ref="yuthon_employee.view_yuthon_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<notebook>
<page string="出勤记录">
<field name="attendance_ids">
<list>
<field name="date"/>
<field name="check_in"/>
<field name="check_out"/>
<field name="working_hours"/>
<field name="status"/>
<field name="late_minutes"/>
</list>
</field>
</page>
</notebook>
</xpath>
</field>
</record>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import models
+33
View File
@@ -0,0 +1,33 @@
{
'name': 'Yuthon 员工管理',
'version': '18.0.1.0.0',
'summary': '大学人事基础:教职工档案(工号/部门/岗位/基本工资)',
'description': """
教学模块①(基础层):yuthon_employee
======================================
定义教职工(员工)主数据模型 yuthon.employee,是整个「出勤-薪资」体系的
数据根(root)。后续 yuthon_attendance、yuthon_salary 都依赖本模块。
教学要点:
- 模型定义:_name / _description
- 字段类型:Char / Selection / Float / Date / Boolean
- 唯一约束:_sql_constraints(工号不可重复)
- 模块组合:被其它模块通过 _inherit 追加反向关联(attendance_ids / payslip_ids)
- ACL:ir.model.access.csv 授权
""",
'author': 'Yuthon',
'website': '',
'category': 'Education',
'depends': ['base', 'web'],
'data': [
'security/ir.model.access.csv',
'views/employee_views.xml',
],
'demo': [
'data/demo.xml',
],
'license': 'LGPL-3',
'installable': True,
'application': True,
'auto_install': False,
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 演示数据:3 名教职工(数据根)。出勤/薪资 demo 通过 ref 引用这里的 id。 -->
<record id="employee_zhang" model="yuthon.employee">
<field name="employee_no">T2024001</field>
<field name="name">张三</field>
<field name="gender">male</field>
<field name="department">teaching</field>
<field name="job_title">讲师</field>
<field name="phone">13800000001</field>
<field name="email">zhangsan@school.edu</field>
<field name="base_salary">8000.0</field>
<field name="hire_date">2024-09-01</field>
</record>
<record id="employee_li" model="yuthon.employee">
<field name="employee_no">A2024002</field>
<field name="name">李四</field>
<field name="gender">female</field>
<field name="department">admin</field>
<field name="job_title">教务员</field>
<field name="phone">13800000002</field>
<field name="email">lisi@school.edu</field>
<field name="base_salary">6000.0</field>
<field name="hire_date">2024-09-01</field>
</record>
<record id="employee_wang" model="yuthon.employee">
<field name="employee_no">T2024003</field>
<field name="name">王五</field>
<field name="gender">male</field>
<field name="department">teaching</field>
<field name="job_title">副教授</field>
<field name="phone">13800000003</field>
<field name="email">wangwu@school.edu</field>
<field name="base_salary">12000.0</field>
<field name="hire_date">2024-09-01</field>
</record>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import employee
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
class Employee(models.Model):
"""教职工(员工)主数据模型。
这是「出勤-薪资」体系的**数据根**:出勤记录挂在员工下,
工资条在月底把「员工基本工资 + 当月出勤聚合结果」join 在一起算出来。
"""
_name = 'yuthon.employee'
_description = '教职工(员工)'
_order = 'employee_no'
# ---- 基础信息 ----
employee_no = fields.Char(
string='工号',
required=True,
index=True,
help='唯一工号,如 T2024001(教师)/ A2024002(行政)',
)
name = fields.Char(
string='姓名',
required=True,
)
gender = fields.Selection(
[('male', '男'), ('female', '女')],
string='性别',
)
department = fields.Selection(
[
('teaching', '教学部'),
('admin', '行政部'),
('logistics', '后勤部'),
('support', '教辅部'),
],
string='部门',
required=True,
default='teaching',
)
job_title = fields.Char(
string='岗位 / 职称',
help='如:讲师、副教授、教务员',
)
phone = fields.Char(string='联系电话')
email = fields.Char(string='邮箱')
# ---- 薪资相关(被 yuthon_salary 读取)----
base_salary = fields.Float(
string='基本工资',
default=0.0,
help='教学演示用 Float;生产环境建议改用 Monetary + currency_id',
)
hire_date = fields.Date(string='入职日期')
active = fields.Boolean(
string='在职',
default=True,
)
# ---- 反向关联(由 yuthon_attendance / yuthon_salary 通过 _inherit 追加)----
# attendance_ids / payslip_ids 不在此定义,避免基础模块反向依赖业务模块。
# 这是「模块解耦」的核心:根模块不知道子模块的存在。
# ---- 约束 ----
_sql_constraints = [
('uniq_employee_no', 'unique(employee_no)', '工号已存在,不可重复!'),
]
@api.constrains('base_salary')
def _check_base_salary(self):
for rec in self:
if rec.base_salary < 0:
raise models.ValidationError('基本工资不能为负数。')
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_employee,yuthon.employee,yuthon_employee.model_yuthon_employee,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_employee yuthon.employee yuthon_employee.model_yuthon_employee base.group_user 1 1 1 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 列表视图 ============ -->
<record id="view_yuthon_employee_list" model="ir.ui.view">
<field name="name">yuthon.employee.list</field>
<field name="model">yuthon.employee</field>
<field name="arch" type="xml">
<list string="教职工">
<field name="employee_no"/>
<field name="name"/>
<field name="gender"/>
<field name="department"/>
<field name="job_title"/>
<field name="base_salary"/>
<field name="active" optional="hide"/>
</list>
</field>
</record>
<!-- ============ 表单视图 ============ -->
<record id="view_yuthon_employee_form" model="ir.ui.view">
<field name="name">yuthon.employee.form</field>
<field name="model">yuthon.employee</field>
<field name="arch" type="xml">
<form string="教职工">
<sheet>
<group>
<group>
<field name="employee_no"/>
<field name="name"/>
<field name="gender"/>
<field name="department"/>
<field name="job_title"/>
</group>
<group>
<field name="phone"/>
<field name="email"/>
<field name="base_salary"/>
<field name="hire_date"/>
<field name="active"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============ 搜索视图 ============ -->
<record id="view_yuthon_employee_search" model="ir.ui.view">
<field name="name">yuthon.employee搜索</field>
<field name="model">yuthon.employee</field>
<field name="arch" type="xml">
<search string="搜索教职工">
<field name="employee_no"/>
<field name="name"/>
<field name="department"/>
<separator/>
<filter name="filter_teaching" string="教学部"
domain="[('department', '=', 'teaching')]"/>
<filter name="filter_active" string="在职"
domain="[('active', '=', True)]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_department" string="按部门"
context="{'group_by': 'department'}"/>
</group>
</search>
</field>
</record>
<!-- ============ 动作 ============ -->
<record id="action_yuthon_employee" model="ir.actions.act_window">
<field name="name">教职工</field>
<field name="res_model">yuthon.employee</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_employee_search"/>
</record>
<!-- ============ 根菜单(人事管理) ============ -->
<menuitem id="menu_yuthon_hr_root" name="人事管理"
web_icon="yuthon_employee,static/description/icon.png"
sequence="10"/>
<menuitem id="menu_yuthon_employee" name="教职工"
parent="menu_yuthon_hr_root"
action="action_yuthon_employee" sequence="20"/>
</odoo>
View File
+23
View File
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
{
'name': '校园登录主题',
'summary': '把 Odoo 登录页美化成教育网站风格',
'description': '''
纯前端主题模块,只改登录页外观,不新增任何业务数据:
- 整页渐变背景(教育紫)
- 玻璃质感登录卡片 + 品牌标题
- 登录按钮与主色统一
''',
'category': 'Education',
'version': '1.0',
'depends': ['web'],
'data': [
'views/login_templates.xml',
],
'assets': {
'web.assets_frontend': [
'yuthon_login_theme/static/src/css/login.css',
],
},
'installable': True,
}
+19
View File
@@ -0,0 +1,19 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * yuthon_login_theme
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. module: web
#. odoo-javascript
#: code:addons/web/static/src/webclient/user_menu/user_menu_items.js:0
#: model_terms:ir.ui.view,arch_db:web.login_successful
msgid "Log out"
msgstr "退出登录"
+133
View File
@@ -0,0 +1,133 @@
/* ===========================================================
校园登录主题 · 教育网站风格
仅作用于登录页(body.bg-100 / .o_database_list / .oe_login_form / .edu-*)
注意:本实例装了第三方主题 udoo_om_ux,其登录卡片样式作用在
body.light-dm 作用域下,优先级较高。此处用同等/更高 specificity
的选择器 + !important 覆盖,确保白底与层级生效。
=========================================================== */
/* 整页背景图:教育背景图铺满 */
html body.bg-100 {
background-image: url('/yuthon_login_theme/static/src/img/login_bg.jpg') !important;
background-repeat: no-repeat !important;
background-position: center center !important;
background-attachment: fixed !important;
background-size: cover !important;
background-color: transparent !important;
min-height: 100vh;
}
/* 全屏背景层:沉到最底层,避免盖住登录卡片 */
.edu-login-bg {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: -1;
pointer-events: none;
background: url('/yuthon_login_theme/static/src/img/login_bg.jpg') no-repeat center center;
background-size: cover;
}
/* 登录卡片:核心修复
真相(用户 F12 确认):
.o_database_list 是 <div class="card ...">,内嵌 <div class="card-body">
udoo_om_ux/style_login_page.scss:
- line 12-14: .o_database_list { border-radius: 16px !important; border; box-shadow }
- line 16-17: .card-body { border-radius: 16px !important }
两层都有 border-radius + padding → 视觉"壳子"。
修复策略:让 .o_database_list 完全透明(无 border/box-shadow/bg),所有视觉
样式给 .card-body,card 与 card-body 融为一体。 */
html body.light-dm main:has(.o_database_list) .o_database_list,
html body.light-dm .oe_login_form,
.edu-login-card {
position: relative !important;
z-index: 10 !important;
max-width: 420px !important;
margin: 0 auto !important;
margin-top: 0 !important;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* 所有视觉样式集中在 .card-body:白底 + 圆角 + 阴影 + padding */
html body.light-dm main:has(.o_database_list) .o_database_list > .card-body {
background-color: #ffffff !important;
border-radius: 22px !important;
border: 1px solid rgba(226, 232, 240, 0.8) !important;
box-shadow: 0 24px 70px rgba(79, 70, 229, 0.22) !important;
padding: 2rem 1.5rem !important;
overflow: hidden;
}
/* 品牌标题区(我们注入的内容,不受主题作用域影响) */
.edu-login-head {
text-align: center;
margin-bottom: 20px;
}
.edu-login-brand {
font-size: 13px;
letter-spacing: 3px;
color: #8b5cf6;
font-weight: 600;
}
.edu-login-title {
font-size: 26px;
font-weight: 700;
color: #1e1b4b;
margin: 8px 0 4px;
}
.edu-login-sub {
font-size: 13px;
color: #64748b;
margin: 0;
}
/* 输入框聚焦高亮(提高优先级覆盖主题作用域) */
html body.light-dm .oe_login_form .form-control:focus,
html body.light-dm main:has(.o_database_list) .o_database_list .form-control:focus {
border-color: #8b5cf6 !important;
box-shadow: 0 0 0 0.2rem rgba(139, 92, 246, 0.20) !important;
}
/* 登录按钮:主色 + 通栏 */
html body.light-dm .oe_login_form .btn-primary,
html body.light-dm main:has(.o_database_list) .o_database_list .btn-primary {
background-color: #4f46e5 !important;
border-color: #4f46e5 !important;
width: 100%;
padding: 10px 0;
font-size: 15px;
}
html body.light-dm .oe_login_form .btn-primary:hover,
html body.light-dm main:has(.o_database_list) .o_database_list .btn-primary:hover {
background-color: #4338ca !important;
border-color: #4338ca !important;
}
/* 卡片内分隔线柔和一点 */
html body.light-dm .oe_login_form .border-top,
html body.light-dm .oe_login_form .border-bottom,
html body.light-dm main:has(.o_database_list) .o_database_list .border-top,
html body.light-dm main:has(.o_database_list) .o_database_list .border-bottom {
border-color: rgba(99, 102, 241, 0.12) !important;
}
/* 移动端:卡片占满、留边距 */
@media (max-width: 480px) {
html body.light-dm main:has(.o_database_list) .o_database_list,
html body.light-dm .oe_login_form {
max-width: 92% !important;
}
}
/* ===========================================================
隐藏 Odoo 默认 logo 和底部"管理数据库 | 由Odoo提供支持"
注:login_templates.xml 里的 xpath 方案因 web.login_layout
内部 <t t-call> 调用链导致深层元素不可匹配,改为 CSS 隐藏
=========================================================== */
.o_database_list .card-body > .text-center.pb-3,
.o_database_list .card-body > .text-center.small {
display: none !important;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,18 @@
<odoo>
<template id="yuthon_login_branding" name="校园登录品牌标题" inherit_id="web.login">
<!-- 在表单最顶部插入品牌标题 -->
<xpath expr="//form[@role='form']/input[@name='csrf_token']" position="before">
<div class="edu-login-head">
<div class="edu-login-brand">Python企业级实训平台</div>
<h1 class="edu-login-title">欢迎登录</h1>
</div>
</xpath>
</template>
<template id="yuthon_login_background" name="校园登录背景层" inherit_id="web.login">
<!-- 在登录表单之前插入全屏背景层,绕过 body 背景被第三方主题覆盖的问题 -->
<xpath expr="//form[@role='form']" position="before">
<div class="edu-login-bg"/>
</xpath>
</template>
</odoo>
+2
View File
@@ -0,0 +1,2 @@
from . import models
from . import wizards
+35
View File
@@ -0,0 +1,35 @@
{
'name': 'Yuthon 薪资管理',
'version': '18.0.1.0.0',
'summary': '工资条:聚合出勤迟到/缺勤,自动计算扣款与实发',
'description': """
教学模块③(汇总层):yuthon_wage
======================================
依赖 yuthon_employee + yuthon_attendance。定义工资条模型 yuthon.payslip,
在月底把「员工基本工资 + 当月出勤聚合结果」join 在一起算出实发工资;
并提供「生成月度工资条」向导做批量处理。
教学要点:
- 跨模型聚合:env['yuthon.attendance'].search_count(...) 统计迟到/缺勤
- computed field 多级联动:统计 -> 扣款 -> 实发
- related 字段:basic_wage 取自员工基本工资
- TransientModel 向导:批量生成工资条(讲解 wizard 与批量处理)
- 状态机:draft -> done
""",
'author': 'Yuthon',
'website': '',
'category': 'Education',
'depends': ['yuthon_employee', 'yuthon_attendance', 'web'],
'data': [
'security/ir.model.access.csv',
'views/payslip_views.xml',
'views/payslip_generate_views.xml',
],
'demo': [
'data/demo.xml',
],
'license': 'LGPL-3',
'installable': True,
'application': True,
'auto_install': False,
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 工资条聚合“2026-07”整月的出勤记录(来自 yuthon_attendance 的 demo) -->
<record id="payslip_zhang_2026_07" model="yuthon.payslip">
<field name="employee_id" ref="yuthon_employee.employee_zhang"/>
<field name="period_start">2026-07-01</field>
<field name="period_end">2026-07-31</field>
<field name="allowance">500.0</field>
</record>
<record id="payslip_li_2026_07" model="yuthon.payslip">
<field name="employee_id" ref="yuthon_employee.employee_li"/>
<field name="period_start">2026-07-01</field>
<field name="period_end">2026-07-31</field>
<field name="allowance">300.0</field>
</record>
<record id="payslip_wang_2026_07" model="yuthon.payslip">
<field name="employee_id" ref="yuthon_employee.employee_wang"/>
<field name="period_start">2026-07-01</field>
<field name="period_end">2026-07-31</field>
<field name="allowance">800.0</field>
</record>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import payslip
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
# 教学用扣款规则(可在 production 改为可配置的 salary.rule 模型)
LATE_PENALTY = 50.0 # 迟到一次扣 50
DAYS_PER_MONTH = 21.75 # 月计薪天数,用于把“缺勤天数”折算成金额
class Payslip(models.Model):
"""工资条:聚合员工当月出勤,自动计算扣款与实发。"""
_name = 'yuthon.payslip'
_description = '工资条'
_order = 'period_end desc, employee_id'
employee_id = fields.Many2one(
'yuthon.employee',
string='教职工',
required=True,
ondelete='cascade',
)
period_start = fields.Date(string='起始日期', required=True)
period_end = fields.Date(string='结束日期', required=True)
# ---- 金额 ----
basic_wage = fields.Float(
string='基本工资',
related='employee_id.base_salary',
store=True,
readonly=True,
)
allowance = fields.Float(string='津贴/补贴', default=0.0)
# ---- 出勤聚合(自动统计)----
late_count = fields.Integer(
string='迟到次数', compute='_compute_attendance_stats', store=True)
absent_days = fields.Integer(
string='缺勤天数', compute='_compute_attendance_stats', store=True)
# ---- 扣款(自动计算)----
late_deduction = fields.Float(
string='迟到扣款', compute='_compute_deductions', store=True)
absent_deduction = fields.Float(
string='缺勤扣款', compute='_compute_deductions', store=True)
attendance_deduction = fields.Float(
string='出勤扣款合计', compute='_compute_deductions', store=True)
# ---- 实发 ----
net_salary = fields.Float(
string='实发工资', compute='_compute_net', store=True)
state = fields.Selection(
[('draft', '草稿'), ('done', '已确认')],
string='状态',
default='draft',
)
# ---- 计算逻辑 ----
@api.depends('employee_id', 'period_start', 'period_end')
def _compute_attendance_stats(self):
Attendance = self.env['yuthon.attendance']
for rec in self:
domain = [('employee_id', '=', rec.employee_id.id)]
if rec.period_start:
domain.append(('date', '>=', rec.period_start))
if rec.period_end:
domain.append(('date', '<=', rec.period_end))
# sudo() 确保在后台重算时不受当前用户 ACL 影响
rec.late_count = Attendance.sudo().search_count(
domain + [('status', '=', 'late')])
rec.absent_days = Attendance.sudo().search_count(
domain + [('status', '=', 'absent')])
@api.depends('late_count', 'absent_days', 'basic_wage')
def _compute_deductions(self):
for rec in self:
rec.late_deduction = rec.late_count * LATE_PENALTY
daily = (rec.basic_wage / DAYS_PER_MONTH) if rec.basic_wage else 0.0
rec.absent_deduction = rec.absent_days * daily
rec.attendance_deduction = rec.late_deduction + rec.absent_deduction
@api.depends('basic_wage', 'allowance', 'attendance_deduction')
def _compute_net(self):
for rec in self:
rec.net_salary = rec.basic_wage + rec.allowance - rec.attendance_deduction
# ---- 业务动作 ----
def action_confirm(self):
self.write({'state': 'done'})
def action_draft(self):
self.write({'state': 'draft'})
class Employee(models.Model):
"""_inherit 反向扩展:把工资条挂到员工上。"""
_inherit = 'yuthon.employee'
payslip_ids = fields.One2many(
'yuthon.payslip', 'employee_id', string='工资条')
payslip_count = fields.Integer(
string='工资条数', compute='_compute_payslip_count')
@api.depends('payslip_ids')
def _compute_payslip_count(self):
for rec in self:
rec.payslip_count = len(rec.payslip_ids)
+3
View File
@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_payslip,yuthon.payslip,yuthon_wage.model_yuthon_payslip,base.group_user,1,1,1,1
access_yuthon_payslip_generate,yuthon.payslip.yuthon_wage.wizard,yuthon_wage.model_yuthon_payslip_generate_wizard,base.group_user,1,0,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_payslip yuthon.payslip yuthon_wage.model_yuthon_payslip base.group_user 1 1 1 1
3 access_yuthon_payslip_generate yuthon.payslip.yuthon_wage.wizard yuthon_wage.model_yuthon_payslip_generate_wizard base.group_user 1 0 1 0
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 向导表单 ============ -->
<record id="view_yuthon_payslip_generate_form" model="ir.ui.view">
<field name="name">yuthon.payslip.generate.form</field>
<field name="model">yuthon.payslip.generate.wizard</field>
<field name="arch" type="xml">
<form string="生成月度工资条">
<group>
<field name="period_start"/>
<field name="period_end"/>
<field name="allowance"/>
<field name="employee_ids" widget="many2many_tags"/>
</group>
<footer>
<button name="action_generate" type="object"
string="生成" class="btn-primary"/>
<button string="取消" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ============ 向导动作 ============ -->
<record id="action_yuthon_payslip_generate" model="ir.actions.act_window">
<field name="name">生成月度工资条</field>
<field name="res_model">yuthon.payslip.generate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- ============ 子菜单:放在“薪资”下,方便一键批量生成 ============ -->
<menuitem id="menu_yuthon_payslip_generate"
name="生成月度工资条"
parent="yuthon_employee.menu_yuthon_hr_root"
action="action_yuthon_payslip_generate" sequence="31"/>
</odoo>
+142
View File
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 列表视图 ============ -->
<record id="view_yuthon_payslip_list" model="ir.ui.view">
<field name="name">yuthon.payslip.list</field>
<field name="model">yuthon.payslip</field>
<field name="arch" type="xml">
<list string="工资条" decoration-info="state == 'draft'">
<field name="employee_id"/>
<field name="period_start"/>
<field name="period_end"/>
<field name="basic_wage"/>
<field name="allowance"/>
<field name="late_count"/>
<field name="absent_days"/>
<field name="attendance_deduction"/>
<field name="net_salary" decoration-bf="1"/>
<field name="state"/>
</list>
</field>
</record>
<!-- ============ 表单视图 ============ -->
<record id="view_yuthon_payslip_form" model="ir.ui.view">
<field name="name">yuthon.payslip.form</field>
<field name="model">yuthon.payslip</field>
<field name="arch" type="xml">
<form string="工资条">
<header>
<button name="action_confirm" type="object"
string="确认" class="btn-primary" invisible="state != 'draft'"/>
<button name="action_draft" type="object"
string="转草稿" invisible="state != 'done'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="period_start"/>
<field name="period_end"/>
<field name="state" invisible="1"/>
</group>
<group>
<field name="basic_wage"/>
<field name="allowance"/>
</group>
</group>
<group>
<group string="出勤聚合">
<field name="late_count"/>
<field name="absent_days"/>
</group>
<group string="扣款与实发">
<field name="late_deduction"/>
<field name="absent_deduction"/>
<field name="attendance_deduction"/>
<field name="net_salary" decoration-bf="1"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============ 搜索视图 ============ -->
<record id="view_yuthon_payslip_search" model="ir.ui.view">
<field name="name">yuthon.payslip搜索</field>
<field name="model">yuthon.payslip</field>
<field name="arch" type="xml">
<search string="搜索工资条">
<field name="employee_id"/>
<field name="period_start"/>
<field name="period_end"/>
<separator/>
<filter name="filter_draft" string="草稿"
domain="[('state', '=', 'draft')]"/>
<filter name="filter_done" string="已确认"
domain="[('state', '=', 'done')]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_employee" string="按教职工"
context="{'group_by': 'employee_id'}"/>
</group>
</search>
</field>
</record>
<!-- ============ 动作 ============ -->
<record id="action_yuthon_payslip" model="ir.actions.act_window">
<field name="name">工资条</field>
<field name="res_model">yuthon.payslip</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_payslip_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
先用「生成月度工资条」创建第一张工资条
</p>
</field>
</record>
<!-- ============ 子菜单(挂载到员工模块的根菜单) ============ -->
<menuitem id="menu_yuthon_payslip" name="薪资"
parent="yuthon_employee.menu_yuthon_hr_root"
action="action_yuthon_payslip" sequence="30"/>
<!-- ============ 注入「教职工」视图(模块组合演示) ============ -->
<!-- 列表:追加工资条数列 -->
<record id="view_yuthon_employee_list_salary" model="ir.ui.view">
<field name="name">yuthon.employee.list.inherit.salary</field>
<field name="model">yuthon.employee</field>
<field name="inherit_id" ref="yuthon_employee.view_yuthon_employee_list"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='attendance_count']" position="after">
<field name="payslip_count"/>
</xpath>
</field>
</record>
<!-- 表单:注入工资条页 -->
<record id="view_yuthon_employee_form_salary" model="ir.ui.view">
<field name="name">yuthon.employee.form.inherit.salary</field>
<field name="model">yuthon.employee</field>
<field name="inherit_id" ref="yuthon_employee.view_yuthon_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="工资条">
<field name="payslip_ids">
<list>
<field name="period_start"/>
<field name="period_end"/>
<field name="net_salary"/>
<field name="state"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import payslip_generate
+41
View File
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
class PayslipGenerateWizard(models.TransientModel):
"""生成月度工资条向导:批量给选定教职工创建 yuthon.payslip。
教学要点:TransientModel(向导模型,数据临时、自动清理)+ 批量创建。
"""
_name = 'yuthon.payslip.generate.wizard'
_description = '生成月度工资条向导'
period_start = fields.Date(string='起始日期', required=True)
period_end = fields.Date(string='结束日期', required=True)
allowance = fields.Float(string='津贴/补贴', default=0.0)
employee_ids = fields.Many2many(
'yuthon.employee',
string='教职工',
)
def action_generate(self):
"""为每位选定教职工创建一条工资条(带默认值,算出迟到/缺勤/实发)。"""
self.ensure_one()
Payslip = self.env['yuthon.payslip']
employees = self.employee_ids or self.env['yuthon.employee'].search([])
created = Payslip
for emp in employees:
created |= Payslip.create({
'employee_id': emp.id,
'period_start': self.period_start,
'period_end': self.period_end,
'allowance': self.allowance,
})
# 返回刚生成的工资条列表视图
return {
'type': 'ir.actions.act_window',
'name': '工资条',
'res_model': 'yuthon.payslip',
'view_mode': 'list,form',
'domain': [('id', 'in', created.ids)],
}