v2.2更新

This commit is contained in:
2026-05-28 15:38:32 +08:00
parent f84c9d3efb
commit ca53fdc349
38 changed files with 688 additions and 686 deletions

2
.gitignore vendored
View File

@@ -52,5 +52,5 @@ docs/guide/student.pdf
docs/guide/teacher.pdf
qrcode.png
# 展示内容
# example
example

View File

@@ -272,7 +272,7 @@ classmanager/
| v1.7 | 2026.5.21 | 全量一致性审计:前后端配置统一(.env.example/config.py/config.php、清理废弃全局变量、角色权限表精确化 |
| v1.8 | 2026.5.22 | 科目管理融入作业管理页、科目删除数据依赖检查、加减分记录类型区分manual/homework/attendance、学生端作业详情优化 |
| v2.0.1 | 2026.5.23 | 操作列折叠优化、扣分类型大类区分、科目选择修复、改名作业扣分、记录人优化、家长端优化、学期管理优化 |
| v2.1 | 2025.7.14 | CSS变量化统一配色方案、简化按钮系统、操作列按钮风格统一、清理内联颜色、修复科目管理面板无法展开、数据库索引优化、清理init.sql冗余迁移代码、安全审计通过 |
| v2.1 | 2026.5.26 | CSS变量化统一配色方案、简化按钮系统、操作列按钮风格统一、清理内联颜色、修复科目管理面板无法展开、数据库索引优化、清理init.sql冗余迁移代码、安全审计通过 |
## 许可证

View File

@@ -1 +1 @@
2.1
2.2

View File

@@ -21,7 +21,7 @@ from utils.logger import setup_logger, log_access
from utils.database import init_db_pool, close_db_pool
from utils.redis_client import init_redis_pool, close_redis_pool
from middleware.auth_middleware import AuthMiddleware
from routes import auth, student, parent, admin, subject, semester, debug
from routes import auth, student, parent, admin, subject, semester, debug, upgrade
from routes.config import router as config_router
@@ -119,6 +119,7 @@ app.include_router(admin.router, prefix="/api/admin", tags=["管理端"])
app.include_router(subject.router, prefix="/api/subject", tags=["科目管理"])
app.include_router(semester.router, prefix="/api/semester", tags=["学期管理"])
app.include_router(config_router, prefix="/api/config", tags=["配置"])
app.include_router(upgrade.router, prefix="/api/upgrade", tags=["升级管理"])
app.include_router(debug.router, tags=["调试"])

View File

@@ -209,6 +209,7 @@ class ConductModel:
student_id: int = None,
start_date: str = None,
end_date: str = None,
related_type: str = None,
page: int = 1,
page_size: int = 20
) -> Dict[str, Any]:
@@ -217,6 +218,8 @@ class ConductModel:
start_date = None
if end_date == "":
end_date = None
if related_type == "":
related_type = None
conditions = ["cr.is_revoked = 0"]
params = []
@@ -230,6 +233,9 @@ class ConductModel:
if end_date:
conditions.append("cr.created_at <= %s")
params.append(end_date + ' 23:59:59')
if related_type:
conditions.append("cr.related_type = %s")
params.append(related_type)
where_clause = " AND ".join(conditions)

View File

@@ -1,117 +0,0 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, Dict, Any, List
from utils.database import execute_one, execute_query, execute_insert, execute_update
class HomeworkModel:
"""作业数据模型"""
@staticmethod
async def get_all_assignments() -> List[Dict[str, Any]]:
sql = """
SELECT a.*, s.subject_name, u.real_name as created_by_name
FROM assignments a
JOIN subjects s ON a.subject_id = s.subject_id
JOIN users u ON a.created_by = u.user_id
ORDER BY a.deadline ASC, a.created_at DESC
"""
return await execute_query(sql)
@staticmethod
async def get_assignments_by_subjects(subject_ids: List[int]) -> List[Dict[str, Any]]:
if not subject_ids:
return []
placeholders = ','.join(['%s'] * len(subject_ids))
sql = f"""
SELECT a.*, s.subject_name, u.real_name as created_by_name
FROM assignments a
JOIN subjects s ON a.subject_id = s.subject_id
JOIN users u ON a.created_by = u.user_id
WHERE a.subject_id IN ({placeholders})
ORDER BY a.deadline ASC, a.created_at DESC
"""
return await execute_query(sql, tuple(subject_ids))
@staticmethod
async def get_student_homework(student_id: int) -> List[Dict[str, Any]]:
sql = """
SELECT a.assignment_id, a.title, a.description, a.deadline, a.created_at,
s.subject_name, hs.status, hs.submit_time, hs.comments, hs.deduction_applied,
cr.points_change AS points
FROM assignments a
JOIN subjects s ON a.subject_id = s.subject_id
LEFT JOIN homework_submissions hs ON a.assignment_id = hs.assignment_id AND hs.student_id = %s
LEFT JOIN conduct_records cr ON cr.related_type = 'homework'
AND cr.related_id = a.assignment_id AND cr.student_id = %s AND cr.is_revoked = 0
GROUP BY a.assignment_id
ORDER BY a.deadline ASC, a.created_at DESC
"""
return await execute_query(sql, (student_id, student_id))
@staticmethod
async def get_submission(submission_id: int) -> Optional[Dict[str, Any]]:
sql = """
SELECT hs.*, a.title, a.subject_id, a.assignment_id, s.name as student_name
FROM homework_submissions hs
JOIN assignments a ON hs.assignment_id = a.assignment_id
JOIN students s ON hs.student_id = s.student_id
WHERE hs.submission_id = %s
"""
return await execute_one(sql, (submission_id,))
@staticmethod
async def create_assignment(
subject_id: int,
title: str,
description: str,
deadline: str,
created_by: int
) -> int:
sql = """
INSERT INTO assignments (subject_id, title, description, deadline, created_by)
VALUES (%s, %s, %s, %s, %s)
"""
assignment_id = await execute_insert(sql, (subject_id, title, description, deadline, created_by))
# 为所有学生创建提交记录
from models.student import StudentModel
students = await StudentModel.get_all(include_disabled=False)
for student in students:
sql_sub = """
INSERT INTO homework_submissions (assignment_id, student_id, status)
VALUES (%s, %s, 'not_submitted')
"""
await execute_insert(sql_sub, (assignment_id, student["student_id"]))
return assignment_id
@staticmethod
async def update_submission(
submission_id: int,
status: str,
comments: str = None,
updated_by: int = None
) -> bool:
sql = """
UPDATE homework_submissions
SET status = %s, comments = %s, updated_by = %s, updated_at = NOW()
WHERE submission_id = %s
"""
result = await execute_update(sql, (status, comments, updated_by, submission_id))
return result > 0
@staticmethod
async def mark_deduction_applied(submission_id: int) -> bool:
sql = "UPDATE homework_submissions SET deduction_applied = 1 WHERE submission_id = %s"
result = await execute_update(sql, (submission_id,))
return result > 0

View File

@@ -53,6 +53,18 @@ class StudentModel:
sql += " ORDER BY student_no"
return await execute_query(sql)
@staticmethod
async def get_dormitory_list() -> List[str]:
"""获取所有不重复的宿舍号列表"""
sql = """
SELECT DISTINCT dormitory_number
FROM students
WHERE status = 1 AND dormitory_number IS NOT NULL AND dormitory_number != ''
ORDER BY dormitory_number
"""
rows = await execute_query(sql)
return [row["dormitory_number"] for row in rows]
@staticmethod
async def create(
student_no: str,

View File

@@ -20,16 +20,15 @@ from middleware.permission import (
)
from services.admin_service import AdminService
from services.conduct_service import ConductService
from services.homework_service import HomeworkService
from services.attendance_service import AttendanceService
from services.log_service import LogService
from utils.redis_client import RedisClient
from schemas.admin import (
AddPointsRequest, RevokeRequest, AddAdminRequest,
AddStudentRequest, UpdateStudentRequest,
UpdateHomeworkStatusRequest, AddAttendanceRequest,
AddAttendanceRequest,
UpdateAdminRequest, DeleteAdminRequest, ResetPasswordRequest,
CreateAssignmentRequest, UnlockUserRequest
UnlockUserRequest
)
from utils.response import success_response, error_response
from utils.logger import get_logger
@@ -41,18 +40,31 @@ logger = get_logger(__name__)
# ========== 学生管理 ==========
@router.get("/students/dormitories")
async def get_dormitory_list(request: Request):
"""获取宿舍号列表"""
user = await get_current_user(request)
if user["user_type"] != "admin":
return error_response(message="仅管理员可查看", code=403)
from models.student import StudentModel
dormitories = await StudentModel.get_dormitory_list()
return success_response(data={"dormitories": dormitories})
@router.get("/students")
async def get_students(
request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=1000),
search: Optional[str] = None
search: Optional[str] = None,
dormitory_number: Optional[str] = None
):
"""获取所有学生列表(单班级)"""
user = await get_current_user(request)
if user["user_type"] != "admin":
return error_response(message="仅管理员可查看学生列表", code=403)
result = await AdminService.get_students(page=page, page_size=page_size, search=search)
result = await AdminService.get_students(page=page, page_size=page_size, search=search, dormitory_number=dormitory_number)
return success_response(data=result)
@@ -139,7 +151,8 @@ async def update_student(request: Request, student_id: int, req: UpdateStudentRe
result = await AdminService.update_student(
student_id=student_id,
name=req.name,
parent_phone=req.parent_phone
parent_phone=req.parent_phone,
dormitory_number=req.dormitory_number
)
if result["success"]:
await LogService.write_operation_log(
@@ -207,6 +220,9 @@ async def reset_student_password(request: Request, student_id: int, req: ResetPa
async def add_conduct_points(request: Request, req: AddPointsRequest):
"""批量加减分"""
user = await get_current_user(request)
# 仅管理员(班主任/班干部)可操作
if user["user_type"] != "admin":
return error_response(message="无权进行此操作", code=403)
result = await ConductService.add_points(
student_ids=req.student_ids,
points_change=req.points_change,
@@ -236,6 +252,9 @@ async def add_conduct_points(request: Request, req: AddPointsRequest):
async def revoke_conduct_record(request: Request, req: RevokeRequest):
"""撤销扣分记录"""
user = await get_current_user(request)
# 仅管理员(班主任/班干部)可操作
if user["user_type"] != "admin":
return error_response(message="无权进行此操作", code=403)
result = await ConductService.revoke_record(
record_id=req.record_id,
revoker_id=user["user_id"]
@@ -264,6 +283,9 @@ async def revoke_conduct_record(request: Request, req: RevokeRequest):
async def restore_conduct_record(request: Request, req: RevokeRequest):
"""反撤销(恢复)已撤销的记录"""
user = await get_current_user(request)
# 仅管理员(班主任/班干部)可操作
if user["user_type"] != "admin":
return error_response(message="无权进行此操作", code=403)
result = await ConductService.restore_record(
record_id=req.record_id,
restorer_id=user["user_id"]
@@ -319,86 +341,6 @@ async def get_conduct_history(
return error_response(message=f"获取历史记录失败: {str(e)}")
# ========== 作业管理 ==========
@router.get("/homework/assignments")
async def get_assignments(request: Request):
"""获取作业列表"""
user = await get_current_user(request)
role = await PermissionChecker.get_user_role(user["user_id"])
if role not in ["班主任", "学习委员"]:
return error_response(message="无权限", code=403)
result = await HomeworkService.get_assignments(user["user_id"])
return success_response(data=result)
@router.get("/homework/submissions/{assignment_id}")
async def get_submissions(request: Request, assignment_id: int):
"""获取作业提交记录"""
user = await get_current_user(request)
role = await PermissionChecker.get_user_role(user["user_id"])
if role not in ["班主任", "学习委员"]:
return error_response(message="无权限", code=403)
result = await HomeworkService.get_submissions(
assignment_id=assignment_id,
user_id=user["user_id"]
)
return success_response(data=result)
@router.post("/homework/assignment")
async def create_assignment(request: Request, req: CreateAssignmentRequest):
"""发布作业(班主任)"""
user = await get_current_user(request)
is_teacher = await PermissionChecker.check_is_teacher(user["user_id"])
if not is_teacher:
return error_response(message="仅班主任可发布作业", code=403)
result = await HomeworkService.create_assignment(
subject_id=req.subject_id,
title=req.title,
description=req.description,
deadline=req.deadline,
created_by=user["user_id"]
)
if result["success"]:
await LogService.write_operation_log(
operator_id=user["user_id"], operator_name=user["real_name"],
operator_role="班主任", operation_type="create_assignment",
target_type="homework",
details=f"发布作业: {title}",
ip=request.client.host
)
return success_response(data=result, message="作业发布成功")
else:
return error_response(message=result["message"])
@router.put("/homework/submission")
async def update_submission_status(request: Request, req: UpdateHomeworkStatusRequest):
"""更新作业提交状态(班主任或学习委员)"""
user = await get_current_user(request)
role = await PermissionChecker.get_user_role(user["user_id"])
if role not in ["班主任", "学习委员"]:
return error_response(message="无权进行此操作", code=403)
result = await HomeworkService.update_submission_status(
submission_id=req.submission_id,
status=req.status,
comments=req.comments,
apply_deduction=req.apply_deduction,
operator_id=user["user_id"]
)
if result["success"]:
await LogService.write_operation_log(
operator_id=user["user_id"], operator_name=user["real_name"],
operator_role=role, operation_type="update_submission",
target_type="homework", target_id=req.submission_id,
details=f"状态: {req.status}",
ip=request.client.host
)
return success_response(message="状态更新成功")
else:
return error_response(message=result["message"])
# ========== 考勤管理 ==========

View File

@@ -36,21 +36,6 @@ async def get_child_conduct(request: Request):
return success_response(data=result)
@router.get("/child/homework")
async def get_child_homework(request: Request):
"""
获取子女作业情况
"""
user = await get_current_user(request)
if user["user_type"] != "parent":
return error_response(message="仅限家长访问", code=403)
result = await ParentService.get_child_homework(user["user_id"])
return success_response(data=result)
@router.get("/child/attendance")
async def get_child_attendance(request: Request):
"""

231
backend/routes/upgrade.py Normal file
View File

@@ -0,0 +1,231 @@
# ===========================================
# 班级操行分管理系统 - 升级管理路由
#
# 开发者: Canglan
# 版权归属: Sea Network Technology Studio
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import APIRouter, Request
from utils.database import execute_query, execute_update, get_pool
from utils.response import success_response, error_response
from utils.logger import setup_logger
from middleware.permission import PermissionChecker
import os
import re
logger = setup_logger()
router = APIRouter()
# 版本列表(按顺序)
ALL_VERSIONS = {
'1.7': 'v1.7.sql',
'1.8': 'v1.8.sql',
'2.0': 'v2.0.sql',
'2.0.1': 'v2.0.1.sql',
'2.1': 'v2.1.sql',
'2.2': 'v2.2.sql',
}
@router.get("/check")
async def check_upgrade(request: Request):
"""检查数据库版本是否需要升级"""
# 权限检查:仅班主任可执行升级操作
user_type = getattr(request.state, 'user_type', None)
if user_type != 'admin':
return error_response(message="仅管理员可执行升级操作", code=403)
is_teacher = await PermissionChecker.check_is_teacher(
getattr(request.state, 'user_id', 0)
)
if not is_teacher:
return error_response(message="仅班主任可执行升级操作", code=403)
user_id = request.state.user.get('user_id') if hasattr(request.state, 'user') else getattr(request.state, 'user_id', None)
# 检测当前数据库版本
current_version = '0.0.0'
try:
row = await execute_query(
"SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"
)
if row:
current_version = row[0]['setting_value']
except Exception:
pass # 表不存在时使用默认值
# 读取目标版本(从 VERSION 文件)
version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'VERSION')
version_file = os.path.normpath(version_file)
target_version = '0.0.0'
try:
if os.path.exists(version_file):
with open(version_file, 'r') as f:
target_version = f.read().strip()
except Exception:
pass
# 计算需要升级的步骤
needs_upgrade = _compare_versions(target_version, current_version) > 0
steps = []
for version, file_name in sorted(ALL_VERSIONS.items(), key=lambda x: _version_tuple(x[0])):
if _compare_versions(version, current_version) > 0 and _compare_versions(version, target_version) <= 0:
steps.append({'version': version, 'file': file_name})
return success_response(data={
'needs_upgrade': needs_upgrade,
'current': current_version,
'target': target_version,
'steps': steps
})
@router.post("/step")
async def execute_upgrade_step(request: Request):
"""执行单个升级步骤"""
# 权限检查:仅班主任可执行升级操作
user_type = getattr(request.state, 'user_type', None)
if user_type != 'admin':
return error_response(message="仅管理员可执行升级操作", code=403)
is_teacher = await PermissionChecker.check_is_teacher(
getattr(request.state, 'user_id', 0)
)
if not is_teacher:
return error_response(message="仅班主任可执行升级操作", code=403)
user_id = request.state.user.get('user_id') if hasattr(request.state, 'user') else getattr(request.state, 'user_id', None)
body = await request.json()
version = body.get('version', '')
if not version:
return error_response(message='缺少版本号参数', code=400)
if version not in ALL_VERSIONS:
return error_response(message=f'未知版本: {version}', code=400)
# SQL 文件路径
sql_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'sql', 'upgrades')
sql_file = os.path.normpath(os.path.join(sql_dir, ALL_VERSIONS[version]))
if not os.path.exists(sql_file):
return error_response(message=f'SQL 文件不存在: {ALL_VERSIONS[version]}', code=500)
try:
# 读取并执行 SQL
with open(sql_file, 'r', encoding='utf-8') as f:
sql_content = f.read().strip()
if sql_content and sql_content != '--':
# 使用 aiomysql 直接执行多条 SQL
pool = get_pool()
async with pool.acquire() as conn:
async with conn.cursor() as cursor:
# 分割 SQL 语句(按 DELIMITER 处理存储过程)
await _execute_sql_content(cursor, sql_content)
await conn.commit()
# 更新版本号
await execute_update(
"INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', %s) "
"ON DUPLICATE KEY UPDATE setting_value = %s",
(version, version)
)
# 重新检测版本
new_version = '0.0.0'
try:
row = await execute_query(
"SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"
)
if row:
new_version = row[0]['setting_value']
except Exception:
pass
logger.info(f"数据库升级成功: v{version} ({ALL_VERSIONS[version]})")
return success_response(data={
'success': True,
'version': version,
'message': f"升级至 v{version} 成功 ({ALL_VERSIONS[version]})",
'current': new_version
})
except Exception as e:
logger.error(f"数据库升级失败: v{version} - {str(e)}")
return error_response(message=f"升级至 v{version} 失败: {str(e)}", code=500)
def _compare_versions(v1: str, v2: str) -> int:
"""比较两个版本号,返回 1/0/-1"""
t1 = _version_tuple(v1)
t2 = _version_tuple(v2)
if t1 > t2:
return 1
elif t1 < t2:
return -1
return 0
def _version_tuple(v: str) -> tuple:
"""将版本字符串转为可比较的元组"""
parts = []
for p in v.split('.'):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
return tuple(parts)
async def _execute_sql_content(cursor, sql_content: str):
"""执行 SQL 内容,处理存储过程中的 DELIMITER"""
# 如果包含 DELIMITER需要特殊处理
if 'DELIMITER' in sql_content:
# 移除 DELIMITER 行,按 $$ 分割存储过程
lines = sql_content.split('\n')
current_block = []
in_procedure = False
for line in lines:
stripped = line.strip()
if stripped.upper().startswith('DELIMITER $$'):
in_procedure = True
current_block = []
continue
elif stripped.upper() == 'DELIMITER ;':
# 执行累积的存储过程块
if current_block:
proc_sql = '\n'.join(current_block).strip()
if proc_sql:
await cursor.execute(proc_sql)
in_procedure = False
current_block = []
continue
elif stripped.upper().startswith('DELIMITER'):
continue
if in_procedure:
current_block.append(line)
else:
# 普通SQL按分号分割执行
if stripped and not stripped.startswith('--'):
# 简单的按分号分割
for stmt in stripped.split(';'):
stmt = stmt.strip()
if stmt:
await cursor.execute(stmt)
else:
# 无 DELIMITER简单执行
# 按 CREATE 分割以支持多语句
# 分割 SQL 语句
statements = re.split(r';\s*\n', sql_content)
for stmt in statements:
stmt = stmt.strip()
if stmt and stmt != '--':
await cursor.execute(stmt)

View File

@@ -64,14 +64,6 @@ class AddAdminResponse(BaseModel):
message: str
class UpdateHomeworkStatusRequest(BaseModel):
"""更新作业状态请求"""
submission_id: int = Field(..., gt=0, description="提交记录ID")
status: str = Field(..., pattern=r'^(submitted|not_submitted|late|excused)$', description="状态")
comments: Optional[str] = Field(None, max_length=500, description="评语")
apply_deduction: bool = False
class AddStudentRequest(BaseModel):
"""新增学生请求"""
student_no: str = Field(..., min_length=1, max_length=20, pattern=r'^[a-zA-Z0-9]+$', description="学号")
@@ -114,14 +106,6 @@ class UpdateStudentRequest(BaseModel):
dormitory_number: Optional[str] = Field(None, max_length=20, description="宿舍号")
class CreateAssignmentRequest(BaseModel):
"""创建作业请求"""
subject_id: int = Field(..., gt=0, description="科目ID")
title: str = Field(..., min_length=1, max_length=200, description="作业标题")
description: Optional[str] = Field(None, max_length=1000, description="作业描述")
deadline: str = Field(..., min_length=1, max_length=20, description="截止日期")
class UnlockUserRequest(BaseModel):
"""解除用户登录锁定请求"""
username: str = Field(..., min_length=1, max_length=50, description="用户名")

View File

@@ -47,18 +47,6 @@ class ConductHistoryResponse(BaseModel):
records: List[ConductRecord]
class HomeworkSubmission(BaseModel):
"""作业提交情况"""
assignment_id: int
title: str
subject: str
deadline: date
status: str
submit_time: Optional[datetime] = None
comments: Optional[str] = None
deduction_applied: bool
class AttendanceRecord(BaseModel):
"""考勤记录"""
attendance_id: int

View File

@@ -27,14 +27,15 @@ class AdminService:
async def get_students(
page: int = 1,
page_size: int = 20,
search: str = None
search: str = None,
dormitory_number: str = None
) -> Dict[str, Any]:
"""获取所有学生列表"""
offset = (page - 1) * page_size
sql = """
SELECT student_id, student_no, name, total_points, parent_phone, status
FROM students
SELECT student_id, student_no, name, total_points, parent_phone, dormitory_number, status
FROM students
WHERE status = 1
"""
params = []
@@ -43,6 +44,10 @@ class AdminService:
sql += " AND (student_no LIKE %s OR name LIKE %s)"
params.extend([f"%{search}%", f"%{search}%"])
if dormitory_number:
sql += " AND dormitory_number = %s"
params.append(dormitory_number)
sql += " ORDER BY student_no LIMIT %s OFFSET %s"
params.extend([page_size, offset])
@@ -50,12 +55,17 @@ class AdminService:
# 获取总数
count_sql = "SELECT COUNT(*) as total FROM students WHERE status = 1"
count_params = []
if search:
count_sql += " AND (student_no LIKE %s OR name LIKE %s)"
total_result = await execute_one(count_sql, (f"%{search}%", f"%{search}%"))
count_params.extend([f"%{search}%", f"%{search}%"])
if dormitory_number:
count_sql += " AND dormitory_number = %s"
count_params.append(dormitory_number)
if count_params:
total_result = await execute_one(count_sql, tuple(count_params))
else:
total_result = await execute_one(count_sql)
total = total_result["total"] if total_result else 0
return {

View File

@@ -235,6 +235,7 @@ class ConductService:
student_id=student_id,
start_date=start_date,
end_date=end_date,
related_type=related_type,
page=page,
page_size=page_size
)

View File

@@ -1,135 +0,0 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, List, Optional
from datetime import datetime
from models.homework import HomeworkModel
from models.student import StudentModel
from models.conduct import ConductModel
from middleware.permission import PermissionChecker
from config import settings
from utils.logger import get_logger
logger = get_logger(__name__)
class HomeworkService:
"""作业服务"""
@staticmethod
async def get_assignments(user_id: int) -> Dict[str, Any]:
"""获取作业列表"""
role = await PermissionChecker.get_user_role(user_id)
if role == "班主任":
assignments = await HomeworkModel.get_all_assignments()
elif role == "学习委员":
subject_ids = await PermissionChecker.get_user_subject_ids(user_id)
assignments = await HomeworkModel.get_assignments_by_subjects(subject_ids)
else:
assignments = []
return {"assignments": assignments}
@staticmethod
async def create_assignment(
subject_id: int,
title: str,
description: Optional[str],
deadline: str,
created_by: int
) -> Dict[str, Any]:
"""创建作业"""
assignment_id = await HomeworkModel.create_assignment(
subject_id=subject_id,
title=title,
description=description,
deadline=deadline,
created_by=created_by
)
if assignment_id:
logger.info(f"用户[{created_by}] 创建作业[{assignment_id}]: {title}")
return {"success": True, "assignment_id": assignment_id}
else:
return {"success": False, "message": "创建作业失败"}
@staticmethod
async def update_submission_status(
submission_id: int,
status: str,
comments: Optional[str],
apply_deduction: bool,
operator_id: int
) -> Dict[str, Any]:
"""更新作业提交状态"""
# 获取提交记录信息
submission = await HomeworkModel.get_submission(submission_id)
if not submission:
return {"success": False, "message": "提交记录不存在"}
# 检查权限
role = await PermissionChecker.get_user_role(operator_id)
if role == "学习委员":
# 检查是否管理该科目
subject_ids = await PermissionChecker.get_user_subject_ids(operator_id)
if submission["subject_id"] not in subject_ids:
return {"success": False, "message": "无权操作此作业"}
elif role != "班主任":
return {"success": False, "message": "无权进行此操作"}
# 更新状态
result = await HomeworkModel.update_submission(
submission_id=submission_id,
status=status,
comments=comments,
updated_by=operator_id
)
if not result:
return {"success": False, "message": "更新失败"}
# 应用扣分
if apply_deduction and status in ["not_submitted", "late"]:
# 确定扣分数值
if status == "not_submitted":
points_change = -settings.DEDUCTION_HOMEWORK_NOT_SUBMIT
else:
points_change = -settings.DEDUCTION_HOMEWORK_LATE
# 创建扣分记录
student = await StudentModel.get_by_id(submission["student_id"])
if student:
# 获取操作人姓名
from models.user import UserModel
user = await UserModel.get_by_user_id(operator_id)
recorder_name = user.get("real_name", "班主任") if user else "班主任"
await ConductModel.create_record(
student_id=submission["student_id"],
points_change=points_change,
reason=f"作业未提交/迟交: {submission['title']}",
recorder_id=operator_id,
recorder_name=recorder_name,
related_type="homework",
related_id=submission["assignment_id"]
)
# 更新学生总分
await StudentModel.update_total_points(submission["student_id"], points_change)
# 标记已应用扣分
await HomeworkModel.mark_deduction_applied(submission_id)
logger.info(f"用户[{operator_id}] 更新作业提交状态[{submission_id}] -> {status}")
return {"success": True, "message": "状态更新成功"}

View File

@@ -15,7 +15,6 @@ from typing import Dict, Any, Optional, List
from models.user import UserModel
from models.student import StudentModel
from models.conduct import ConductModel
from models.homework import HomeworkModel
from models.attendance import AttendanceModel
from utils.logger import get_logger
@@ -46,24 +45,6 @@ class ParentService:
}
@staticmethod
async def get_child_homework(parent_id: int) -> Dict[str, Any]:
"""获取子女作业情况"""
user = await UserModel.get_by_user_id(parent_id)
if not user or not user["student_id"]:
return {"error": "未关联学生"}
student = await StudentModel.get_by_id(user["student_id"])
if not student:
return {"error": "学生不存在"}
homework = await HomeworkModel.get_student_homework(user["student_id"])
return {
"student_id": student["student_id"],
"student_name": student["name"],
"homework": homework
}
@staticmethod
async def get_child_attendance(parent_id: int) -> Dict[str, Any]:
"""获取子女考勤记录"""
user = await UserModel.get_by_user_id(parent_id)

View File

@@ -14,9 +14,9 @@ from datetime import datetime, timedelta
from models.student import StudentModel
from models.conduct import ConductModel
from models.homework import HomeworkModel
from models.attendance import AttendanceModel
from middleware.permission import PermissionChecker
from utils.database import execute_query
from utils.logger import get_logger
logger = get_logger(__name__)
@@ -57,29 +57,33 @@ class StudentService:
@staticmethod
async def get_homework_status(student_id: int) -> Dict[str, Any]:
"""获取学生作业情况"""
"""获取学生作业扣分记录"""
student = await StudentModel.get_by_id(student_id)
if not student:
return {"error": "学生不存在"}
homework = await HomeworkModel.get_student_homework(student_id)
# 查询作业相关的操行分记录
sql = """
SELECT cr.record_id, cr.points_change, cr.reason, cr.created_at,
cr.related_type, cr.recorder_name
FROM conduct_records cr
WHERE cr.student_id = %s AND cr.related_type = 'homework' AND cr.is_revoked = 0
ORDER BY cr.created_at DESC
"""
records = await execute_query(sql, (student_id,))
# 统计
total = len(homework)
submitted = sum(1 for h in homework if h["status"] == "submitted")
not_submitted = sum(1 for h in homework if h["status"] == "not_submitted")
late = sum(1 for h in homework if h["status"] == "late")
total = len(records)
deductions = sum(1 for r in records if r["points_change"] < 0)
return {
"student_id": student_id,
"student_name": student["name"],
"statistics": {
"total": total,
"submitted": submitted,
"not_submitted": not_submitted,
"late": late
"deductions": deductions
},
"homework": homework
"homework": records
}
@staticmethod

View File

@@ -35,6 +35,7 @@ include __DIR__ . '/../includes/header.php';
<div class="action-bar">
<div class="action-buttons">
<button class="btn btn-primary" onclick="showBatchPointsModal()">批量加减分</button>
<button class="btn btn-secondary" onclick="showDormitoryPointsModal()">宿舍加分</button>
<?php if ($role === '班主任'): ?>
<button class="btn btn-secondary" onclick="exportMoralityRecords()">导出德育分记录</button>
<?php endif; ?>
@@ -110,4 +111,40 @@ include __DIR__ . '/../includes/header.php';
</div>
</div>
<!-- 宿舍集体加分模态框 -->
<div id="dormitoryPointsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>宿舍集体加分</h3>
<button class="modal-close" onclick="closeModal('dormitoryPointsModal')">&times;</button>
</div>
<form onsubmit="event.preventDefault(); submitDormitoryPoints()">
<div class="form-group">
<label>选择宿舍</label>
<select id="dormitorySelect" onchange="onDormitorySelected()" required>
<option value="">-- 请选择宿舍 --</option>
</select>
</div>
<div class="form-group" id="dormitoryStudentsGroup" style="display:none;">
<label>宿舍成员</label>
<div id="dormitoryStudentsList" style="max-height: 150px; overflow-y: auto; border: 1px solid var(--border-color); border-radius: 4px; padding: 8px;">
</div>
<small id="dormitoryStudentsCount"></small>
</div>
<div class="form-group">
<label>分数变动</label>
<input type="number" id="dormitoryPointsChange" required placeholder="正数为加分,负数为扣分">
</div>
<div class="form-group">
<label>原因</label>
<textarea id="dormitoryPointsReason" required rows="3" placeholder="请填写加减分原因"></textarea>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">确认提交</button>
<button type="button" class="btn" onclick="closeModal('dormitoryPointsModal')">取消</button>
</div>
</form>
</div>
</div>
<?php include __DIR__ . '/../includes/footer.php'; ?>

View File

@@ -154,7 +154,7 @@ include __DIR__ . '/../includes/header.php';
}
if (iconEl) iconEl.textContent = '⟳';
fetch('/upgrade.php?action=step&version=' + encodeURIComponent(step.version), { method: 'POST' })
fetch('/api/execute_upgrade.php?action=step&version=' + encodeURIComponent(step.version), { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {

View File

@@ -54,7 +54,7 @@ include __DIR__ . '/../includes/header.php';
<div class="filter-group" style="min-width:auto;">
<label>&nbsp;</label>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px;">
<input type="checkbox" id="historyGrouped" onchange="loadHistory(1)"> 批次合并
<input type="checkbox" id="historyGrouped" checked onchange="loadHistory(1)"> 批次合并
</label>
</div>
<?php if ($role === '班主任'): ?>

View File

@@ -1,7 +1,6 @@
<?php
/**
* 检查数据库版本是否需要升级
* 返回 JSON: {needs_upgrade: bool, current: string, target: string}
* 检查数据库版本是否需要升级(代理至后端 API
*/
require_once __DIR__ . '/../config.php';
@@ -20,84 +19,43 @@ if ($role !== '班主任') {
exit();
}
// 读取后端 .env 获取数据库配置
$envPath = __DIR__ . '/../../backend/.env';
if (!file_exists($envPath)) {
echo json_encode(['error' => '数据库配置文件不存在']);
// 从 session 获取 JWT token
$token = $_SESSION['jwt_token'] ?? '';
if (empty($token)) {
echo json_encode(['error' => '会话已过期,请重新登录']);
exit();
}
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$dbConfig = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || strpos($line, '#') === 0) {
continue;
}
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$dbConfig[trim($key)] = trim($value);
}
// 调用后端 API
$apiUrl = API_BASE_URL . '/api/upgrade/check';
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $apiUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => API_TIMEOUT,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0
]);
$apiResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || empty($apiResponse)) {
echo json_encode(['error' => '无法连接升级服务']);
exit();
}
$required = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
foreach ($required as $key) {
if (!isset($dbConfig[$key]) || $dbConfig[$key] === '') {
echo json_encode(['error' => "缺少数据库配置: {$key}"]);
exit();
}
$result = json_decode($apiResponse, true);
if (!$result || !isset($result['success']) || !$result['success']) {
echo json_encode(['error' => $result['message'] ?? '升级检查失败']);
exit();
}
try {
$dsn = "mysql:host={$dbConfig['DB_HOST']};port={$dbConfig['DB_PORT']};dbname={$dbConfig['DB_NAME']};charset=utf8mb4";
$pdo = new PDO($dsn, $dbConfig['DB_USER'], $dbConfig['DB_PASSWORD'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// 检测当前版本
$currentVersion = '0.0.0';
try {
$stmt = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$currentVersion = $row['setting_value'];
}
} catch (PDOException $e) {
// 表不存在,使用默认值
}
// 读取目标版本
$versionFile = __DIR__ . '/../../VERSION';
if (!file_exists($versionFile)) {
echo json_encode(['error' => 'VERSION 文件不存在']);
exit();
}
$targetVersion = trim(file_get_contents($versionFile));
$needsUpgrade = version_compare($targetVersion, $currentVersion, '>');
$allVersions = [
'1.7' => 'v1.7.sql',
'1.8' => 'v1.8.sql',
'2.0' => 'v2.0.sql',
'2.0.1' => 'v2.0.1.sql',
'2.1' => 'v2.1.sql',
];
$steps = [];
foreach ($allVersions as $version => $file) {
if (version_compare($version, $currentVersion, '>') &&
version_compare($version, $targetVersion, '<=')) {
$steps[] = ['version' => $version, 'file' => $file];
}
}
usort($steps, function($a, $b) { return version_compare($a['version'], $b['version']); });
echo json_encode([
'needs_upgrade' => $needsUpgrade,
'current' => $currentVersion,
'target' => $targetVersion,
'steps' => $steps
]);
} catch (PDOException $e) {
echo json_encode(['error' => '数据库连接失败: ' . $e->getMessage()]);
}
// 转发后端返回的升级数据
echo json_encode($result['data']);

View File

@@ -0,0 +1,89 @@
<?php
/**
* 执行单个升级步骤(代理至后端 API
*/
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config.php';
// 验证登录和权限
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
http_response_code(401);
echo json_encode(['success' => false, 'error' => '未授权']);
exit();
}
$role = $_SESSION['role'] ?? '';
if ($role !== '班主任') {
http_response_code(403);
echo json_encode(['success' => false, 'error' => '权限不足']);
exit();
}
// 只接受 POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => '无效请求']);
exit();
}
$stepVersion = $_GET['version'] ?? '';
if (empty($stepVersion)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => '缺少版本号参数']);
exit();
}
// 从 session 获取 JWT token
$token = $_SESSION['jwt_token'] ?? '';
if (empty($token)) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => '会话已过期,请重新登录']);
exit();
}
// 调用后端 API
$apiUrl = API_BASE_URL . '/api/upgrade/step';
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $apiUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['version' => $stepVersion]),
CURLOPT_TIMEOUT => API_TIMEOUT,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0
]);
$apiResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || empty($apiResponse)) {
http_response_code(500);
echo json_encode([
'success' => false,
'version' => $stepVersion,
'error' => '无法连接升级服务'
]);
exit();
}
$result = json_decode($apiResponse, true);
if (!$result || !isset($result['success']) || !$result['success']) {
http_response_code(500);
echo json_encode([
'success' => false,
'version' => $stepVersion,
'error' => $result['message'] ?? '升级失败'
]);
exit();
}
// 转发后端返回的数据
echo json_encode($result['data']);

View File

@@ -160,6 +160,7 @@ $_SESSION['username'] = $data['username'];
$_SESSION['real_name'] = $data['real_name'] ?? '';
$_SESSION['role'] = $data['role'] ?? '';
$_SESSION['login_time'] = time();
$_SESSION['jwt_token'] = $token;
// 如果是学生,额外设置 student_id
if ($data['user_type'] === 'student') {

View File

@@ -132,13 +132,8 @@ function formatDateTime(dateStr) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function getStatusBadge(status, type = 'homework') {
function getStatusBadge(status, type = 'attendance') {
const statusMap = {
homework: {
'submitted': '已提交',
'not_submitted': '未提交',
'late': '迟交'
},
attendance: {
'present': '出勤',
'absent': '缺勤',
@@ -146,15 +141,13 @@ function getStatusBadge(status, type = 'homework') {
'leave': '请假'
}
};
const texts = statusMap[type] || statusMap.homework;
const texts = statusMap[type] || statusMap.attendance;
const text = texts[status] || status;
let className = 'status-badge ';
switch (status) {
case 'submitted':
case 'present':
className += 'status-submitted';
break;
case 'not_submitted':
case 'absent':
className += 'status-not_submitted';
break;

View File

@@ -118,11 +118,113 @@ async function exportMoralityRecords() {
console.error('导出失败:', err);
}
}
// 宿舍集体加分相关
var dormitoryStudentIds = [];
async function showDormitoryPointsModal() {
dormitoryStudentIds = [];
document.getElementById('dormitorySelect').innerHTML = '<option value="">-- 请选择宿舍 --</option>';
document.getElementById('dormitoryStudentsGroup').style.display = 'none';
document.getElementById('dormitoryStudentsList').innerHTML = '';
document.getElementById('dormitoryPointsChange').value = '';
document.getElementById('dormitoryPointsReason').value = '';
// 加载宿舍列表
const res = await apiGet('/api/admin/students/dormitories');
if (res && res.success && res.data.dormitories) {
const select = document.getElementById('dormitorySelect');
res.data.dormitories.forEach(d => {
const option = document.createElement('option');
option.value = d;
option.textContent = d;
select.appendChild(option);
});
}
document.getElementById('dormitoryPointsModal').style.display = 'flex';
}
async function onDormitorySelected() {
const dormitory = document.getElementById('dormitorySelect').value;
const studentsGroup = document.getElementById('dormitoryStudentsGroup');
const studentsList = document.getElementById('dormitoryStudentsList');
const studentsCount = document.getElementById('dormitoryStudentsCount');
dormitoryStudentIds = [];
studentsList.innerHTML = '';
if (!dormitory) {
studentsGroup.style.display = 'none';
return;
}
// 加载该宿舍的学生
const res = await apiGet('/api/admin/students', { dormitory_number: dormitory, page_size: 1000 });
if (res && res.success && res.data.students) {
const students = res.data.students;
if (students.length === 0) {
studentsList.innerHTML = '<p style="color: var(--text-secondary);">该宿舍暂无学生</p>';
studentsCount.textContent = '';
} else {
students.forEach(s => {
dormitoryStudentIds.push(s.student_id);
const div = document.createElement('div');
div.style.cssText = 'display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border-color);';
div.innerHTML = `<span>${escapeHtml(s.name)}</span><span style="color: var(--text-secondary);">${escapeHtml(s.student_no)}</span>`;
studentsList.appendChild(div);
});
studentsCount.textContent = `${students.length}`;
}
studentsGroup.style.display = 'block';
} else {
studentsList.innerHTML = '<p style="color: var(--text-secondary);">加载失败</p>';
studentsGroup.style.display = 'block';
}
}
async function submitDormitoryPoints() {
if (dormitoryStudentIds.length === 0) {
showToast('该宿舍没有学生', 'warning');
return;
}
const pointsChange = parseInt(document.getElementById('dormitoryPointsChange').value);
const reason = document.getElementById('dormitoryPointsReason').value;
if (isNaN(pointsChange) || pointsChange === 0) {
showToast('分值不能为0', 'error');
return;
}
if (!reason.trim()) {
showToast('请填写原因', 'error');
return;
}
const data = {
student_ids: dormitoryStudentIds,
points_change: pointsChange,
reason: reason
};
const res = await apiPost('/api/admin/conduct/add', data);
if (res && res.success) {
showToast(`操作成功: ${res.data.success_count} 人成功`);
closeModal('dormitoryPointsModal');
loadStudents();
} else {
showToast(res?.message || '操作失败', 'error');
}
}
loadStudents();
window.loadStudents = loadStudents;
window.showSinglePointsModal = showSinglePointsModal;
window.exportMoralityRecords = exportMoralityRecords;
window.showDormitoryPointsModal = showDormitoryPointsModal;
window.onDormitorySelected = onDormitorySelected;
window.submitDormitoryPoints = submitDormitoryPoints;
})();

View File

@@ -25,9 +25,15 @@ async function loadDashboard() {
}
let quickActions = '';
if (role === '班主任' || role === '班长' || role === '劳动委员' || role === '志愿委员') {
if (role === '班主任' || role === '班长' || role === '学习委员' || role === '劳动委员' || role === '志愿委员') {
quickActions += '<button class="btn btn-primary" onclick="location.href=\'/admin/conduct.php\'">操行分管理</button>';
}
if (role === '班主任' || role === '学习委员') {
quickActions += '<button class="btn btn-primary" onclick="location.href=\'/admin/homework.php\'">作业扣分</button>';
}
if (role === '班主任' || role === '考勤委员') {
quickActions += '<button class="btn btn-primary" onclick="location.href=\'/admin/attendance.php\'">考勤管理</button>';
}
if (role === '班主任') {
quickActions += '<button class="btn btn-outline" onclick="location.href=\'/admin/students.php\'">导入学生</button>';
quickActions += '<button class="btn btn-secondary" onclick="location.href=\'/admin/conduct.php\'">导出德育分记录</button>';

View File

@@ -40,7 +40,7 @@ async function loadHistory(page = 1) {
end_date: endDate
};
if (studentId) params.student_id = studentId;
if (relatedType && !isGrouped) params.related_type = relatedType;
if (relatedType) params.related_type = relatedType;
if (isGrouped) params.grouped = true;
const res = await apiGet('/api/admin/conduct/history', params);

View File

@@ -146,7 +146,7 @@
const students = data.students || [];
let html = '<h4>预览数据</h4><div class="table-wrapper"><table><thead><tr>';
html += '<th>学号</th><th>姓名</th><th>家长手机号</th><th>初始密码</th>';
html += '<th>学号</th><th>姓名</th><th>家长手机号</th><th>宿舍号</th><th>初始密码</th>';
html += '</tr></thead><tbody>';
students.forEach(s => {
@@ -154,6 +154,7 @@
<td>${escapeHtml(s.student_no || '')}</td>
<td>${escapeHtml(s.name || '')}</td>
<td>${escapeHtml(s.parent_phone || '')}</td>
<td>${escapeHtml(s.dormitory_number || '-')}</td>
<td>${escapeHtml(s.password || '123456')}</td>
</tr>`;
});

View File

@@ -16,25 +16,18 @@ async function loadHomework() {
const res = await apiGet(`/api/student/homework/${STUDENT_ID}`);
if (res && res.success) {
let html = '';
res.data.homework.forEach(hw => {
// 提交状态
let statusDisplay = '-';
if (hw.status) {
statusDisplay = getStatusBadge(hw.status, 'homework');
}
// 扣分显示
const pointsDisplay = hw.points ? `<span class="text-danger">${hw.points}分</span>` : '-';
res.data.homework.forEach(record => {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus';
const pointsColor = record.points_change > 0 ? '#38a169' : '#e53e3e';
html += `<tr>
<td>${escapeHtml(hw.title)}</td>
<td>${escapeHtml(hw.subject_name)}</td>
<td>${hw.deadline || '-'}</td>
<td>${statusDisplay}</td>
<td>${pointsDisplay}</td>
<td>${formatDateTime(record.created_at)}</td>
<td style="color: ${pointsColor}; font-weight: bold;">${record.points_change > 0 ? '+' : ''}${record.points_change}</td>
<td>${escapeHtml(record.reason)}</td>
<td>${escapeHtml(record.recorder_name || '-')}</td>
</tr>`;
});
if (res.data.homework.length === 0) {
html = '<tr><td colspan="5" style="text-align:center; padding: 40px; color: #999;">📝 暂无作业记录</td></tr>';
html = '<tr><td colspan="4" style="text-align:center; padding: 40px; color: #999;">📝 暂无作业扣分记录</td></tr>';
}
document.getElementById('homeworkList').innerHTML = html;
}

View File

@@ -1,102 +0,0 @@
/**
* 班级操行分管理系统 - 科目管理页JS
*
* 开发者: Canglan
* 版权归属: Sea Network Technology Studio
*
* 版权所有 © Sea Network Technology Studio
*/
(function() {
'use strict';
async function loadSubjects() {
const res = await apiGet('/api/subject/list');
if (res && res.success) {
let html = '';
res.data.subjects.forEach(sub => {
html += `
<div class="subject-item">
<span class="subject-name">${escapeHtml(sub.subject_name)}</span>
<span class="subject-code">${escapeHtml(sub.subject_code || '')}</span>
<span class="subject-status ${sub.is_active ? 'subject-status-active' : 'subject-status-inactive'}">
${sub.is_active ? '启用' : '禁用'}
</span>
<button class="btn btn-sm btn-outline" onclick="showEditSubjectModal(${sub.subject_id}, '${escapeHtml(sub.subject_name)}', '${escapeHtml(sub.subject_code || '')}', ${sub.sort_order || 0})">编辑</button>
<button class="btn btn-sm btn-ghost" onclick="toggleSubject(${sub.subject_id}, ${!sub.is_active})">
${sub.is_active ? '禁用' : '启用'}
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteSubject(${sub.subject_id})">删除</button>
</div>
`;
});
if (res.data.subjects.length === 0) {
html = '<p style="text-align:center;padding:40px;">暂无科目,请点击"添加科目"</p>';
}
document.getElementById('subjectList').innerHTML = html;
}
}
async function toggleSubject(subjectId, enable) {
const res = await apiPut(`/api/subject/update/${subjectId}`, { is_active: enable });
if (res && res.success) {
showToast(enable ? '科目已启用' : '科目已禁用');
loadSubjects();
} else {
showToast(res?.message || '操作失败', 'error');
}
}
async function deleteSubject(subjectId) {
if (!confirm('确定要删除该科目吗?')) return;
const res = await apiDelete('/api/subject/delete/' + subjectId);
if (res && res.success) {
showToast('科目删除成功');
loadSubjects();
} else {
showToast(res?.message || '删除失败', 'error');
}
}
function showEditSubjectModal(subjectId, name, code, sortOrder) {
document.getElementById('editSubjectId').value = subjectId;
document.getElementById('editSubjectName').value = name;
document.getElementById('editSubjectCode').value = code;
document.getElementById('editSubjectSortOrder').value = sortOrder;
document.getElementById('editSubjectModal').style.display = 'flex';
}
async function submitEditSubject() {
const subjectId = document.getElementById('editSubjectId').value;
const subjectName = document.getElementById('editSubjectName').value.trim();
const subjectCode = document.getElementById('editSubjectCode').value.trim();
const sortOrder = document.getElementById('editSubjectSortOrder').value;
if (!subjectName) {
showToast('请填写科目名称', 'warning');
return;
}
const data = { subject_name: subjectName };
if (subjectCode) data.subject_code = subjectCode;
if (sortOrder !== '') data.sort_order = parseInt(sortOrder);
const res = await apiPut(`/api/subject/update/${subjectId}`, data);
if (res && res.success) {
showToast('科目更新成功');
closeModal('editSubjectModal');
loadSubjects();
} else {
showToast(res?.message || '更新失败', 'error');
}
}
loadSubjects();
window.loadSubjects = loadSubjects;
window.toggleSubject = toggleSubject;
window.deleteSubject = deleteSubject;
window.showEditSubjectModal = showEditSubjectModal;
window.submitEditSubject = submitEditSubject;
})();

View File

@@ -7,37 +7,42 @@
"_comment6": " student_no - 必填,学生学号,唯一标识",
"_comment7": " name - 必填,学生姓名",
"_comment8": " parent_phone - 可选家长手机号11位手机号",
"_comment9": " password - 可选,初始密码,不填则默认 123456",
"_comment10": "================================================",
"_comment11": "导入规则:",
"_comment12": " 1. 学生操行分初始值 = 60分",
"_comment13": " 2. 学生账号 = 学号,密码 = 指定的password或123456",
"_comment14": " 3. 家长账号 = 手机号若parent_phone有值,密码 = 指定的password或123456",
"_comment15": " 4. 家长姓名默认显示为 '学生姓名家长'",
"_comment16": "================================================",
"_comment9": " dormitory_number - 可选,宿舍号(支持字母数字组合,如 301-A",
"_comment10": " password - 可选,初始密码,不填则默认 123456",
"_comment11": "================================================",
"_comment12": "导入规则:",
"_comment13": " 1. 学生操行分初始值 = 60分",
"_comment14": " 2. 学生账号 = 学号,密码 = 指定的password或123456",
"_comment15": " 3. 家长账号 = 手机号若parent_phone有值密码 = 指定的password或123456",
"_comment16": " 4. 家长姓名默认显示为 '学生姓名家长'",
"_comment17": "================================================",
"students": [
{
"student_no": "20240001",
"name": "张三",
"parent_phone": "13800138001",
"dormitory_number": "301-A",
"password": "123456"
},
{
"student_no": "20240002",
"name": "李四",
"parent_phone": "13800138002",
"dormitory_number": "205",
"password": "123456"
},
{
"student_no": "20240003",
"name": "王五",
"parent_phone": "",
"dormitory_number": "",
"password": ""
},
{
"student_no": "20240004",
"name": "赵六",
"parent_phone": "13800138004",
"dormitory_number": "102-B",
"password": ""
}
]

View File

@@ -1,7 +1,7 @@
<div class="nav">
<a href="/admin/dashboard.php" class="nav-item<?php echo $current_page === 'dashboard' ? ' active' : ''; ?>">首页</a>
<a href="/admin/students.php" class="nav-item<?php echo $current_page === 'students' ? ' active' : ''; ?>">学生管理</a>
<?php if ($role === '班主任' || $role === '班长' || $role === '考勤委员' || $role === '劳动委员' || $role === '志愿委员'): ?>
<?php if ($role === '班主任' || $role === '班长' || $role === '学习委员' || $role === '考勤委员' || $role === '劳动委员' || $role === '志愿委员'): ?>
<a href="/admin/conduct.php" class="nav-item<?php echo $current_page === 'conduct' ? ' active' : ''; ?>">操行分管理</a>
<?php endif; ?>
<?php if ($role === '班主任' || $role === '学习委员'): ?>

View File

@@ -42,10 +42,6 @@ include __DIR__ . '/../includes/header.php';
<div class="stat-label">班级排名</div>
<div class="stat-value" id="studentRank">--</div>
</div>
<div class="stat-card">
<div class="stat-label">缺交次数</div>
<div class="stat-value" id="homeworkMissing">--</div>
</div>
</div>
<div class="initial-points-hint" id="initialPointsHint"></div>
</div>
@@ -93,15 +89,6 @@ async function loadDashboard() {
}
}
// 加载作业缺交次数
const hwRes = await apiGet('/api/parent/child/homework');
if (hwRes && hwRes.success && hwRes.data.homework) {
const homework = hwRes.data.homework;
const total = homework.length;
const notSubmitted = homework.filter(h => h.status === 'not_submitted' || h.status === 'late').length;
document.getElementById('homeworkMissing').textContent = `缺交 ${notSubmitted}/${total} 次`;
}
// 显示初始分提示
const initialPoints = window.STUDENT_INITIAL_POINTS || 60;
document.getElementById('initialPointsHint').textContent = `初始操行分为 ${initialPoints} 分`;

View File

@@ -94,7 +94,7 @@ include __DIR__ . '/../includes/header.php';
<div class="stat-value" id="studentRank">--</div>
</div>
<div class="stat-card">
<div class="stat-label">缺交次数</div>
<div class="stat-label">作业扣分</div>
<div class="stat-value" id="homeworkRate">--</div>
</div>
<div class="stat-card">
@@ -140,11 +140,10 @@ include __DIR__ . '/../includes/header.php';
<table>
<thead>
<tr>
<th>科目</th>
<th>时间</th>
<th>分值</th>
<th>备注</th>
<th>作业</th>
<th>原因</th>
<th>操作人</th>
</tr>
</thead>
<tbody id="homeworkList"></tbody>
@@ -319,14 +318,13 @@ include __DIR__ . '/../includes/header.php';
document.getElementById('studentRank').textContent = '--';
}
}
// 获取作业统计 - 缺交次数
// 获取作业扣分统计
const homeworkRes = await apiGet(`/api/student/homework/${STUDENT_ID}`);
if (homeworkRes && homeworkRes.success) {
const stats = homeworkRes.data.statistics;
const notSubmitted = (stats.not_submitted || 0) + (stats.late || 0);
const deductions = stats.deductions || 0;
const total = stats.total || 0;
document.getElementById('homeworkRate').textContent = `缺交 ${notSubmitted}/${total} 次`;
}
document.getElementById('homeworkRate').textContent = `${deductions} 次扣分`;
}
// 获取考勤统计
@@ -394,25 +392,24 @@ include __DIR__ . '/../includes/header.php';
const res = await apiGet(`/api/student/homework/${STUDENT_ID}`);
if (res && res.success) {
let html = '';
res.data.homework.forEach(hw => {
const pointsDisplay = hw.points ? hw.points + '分' : '-';
res.data.homework.forEach(record => {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus';
html += `
<tr>
<td>${escapeHtml(hw.subject_name)}</td>
<td>${hw.deadline || hw.created_at}</td>
<td>${pointsDisplay}</td>
<td>${escapeHtml(hw.comments || '-')}</td>
<td>${escapeHtml(hw.title)}</td>
<td>${formatDateTime(record.created_at)}</td>
<td class="record-points ${pointsClass}">${record.points_change > 0 ? '+' : ''}${record.points_change}</td>
<td>${escapeHtml(record.reason)}</td>
<td>${escapeHtml(record.recorder_name || '-')}</td>
</tr>
`;
});
if (res.data.homework.length === 0) {
html = '<tr><td colspan="5" style="text-align:center;">暂无作业</td></tr>';
html = '<tr><td colspan="4" style="text-align:center;">暂无作业扣分记录</td></tr>';
}
document.getElementById('homeworkList').innerHTML = html;
}
} catch (error) {
console.error('加载作业失败:', error);
console.error('加载作业记录失败:', error);
}
}

View File

@@ -36,7 +36,7 @@ include __DIR__ . '/../includes/header.php';
<div class="table-wrapper">
<table class="table">
<thead>
<tr><th>作业名称</th><th>科目</th><th>截止时间</th><th>提交状态</th><th>扣分</th></tr>
<tr><th>时间</th><th>分值</th><th>原因</th><th>操作人</th></tr>
</thead>
<tbody id="homeworkList"></tbody>
</table>

View File

@@ -202,6 +202,26 @@ CREATE TABLE IF NOT EXISTS `semester_archives` (
FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 系统设置表
CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(50) PRIMARY KEY,
`setting_value` VARCHAR(255) NOT NULL,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 性能索引v2.0
CREATE INDEX `idx_conduct_semester` ON `conduct_records`(`semester_id`);
CREATE INDEX `idx_attendance_semester` ON `attendance_records`(`semester_id`);
CREATE INDEX `idx_conduct_student` ON `conduct_records`(`student_id`);
-- 性能索引v2.1
CREATE INDEX `idx_student_created` ON `conduct_records`(`student_id`, `created_at`);
CREATE INDEX `idx_recorder_id` ON `conduct_records`(`recorder_id`);
CREATE INDEX `idx_date` ON `attendance_records`(`date`);
CREATE INDEX `idx_username_created` ON `login_logs`(`username`, `created_at`);
CREATE INDEX `idx_operator_created` ON `operation_logs`(`operator_id`, `created_at`);
CREATE INDEX `idx_semester_id` ON `semester_archives`(`semester_id`);
SET FOREIGN_KEY_CHECKS = 1;
-- 插入初始科目(仅语数英,如不存在)
@@ -210,4 +230,10 @@ INSERT IGNORE INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VAL
('数学', 'MATH', 2),
('英语', 'ENG', 3);
SELECT '数据库初始化完成!' AS message;
-- 初始化系统版本号
INSERT INTO `system_settings` (`setting_key`, `setting_value`)
VALUES ('db_version', '2.2')
ON DUPLICATE KEY UPDATE `setting_value` = '2.2';
-- 控制台输出初始化结果(含版本号)
SELECT CONCAT('数据库初始化完成!版本: v', (SELECT setting_value FROM system_settings WHERE setting_key = 'db_version')) AS message;

11
sql/upgrades/v2.2.sql Normal file
View File

@@ -0,0 +1,11 @@
-- ===========================================
-- 班级操行分管理系统 - v2.1 → v2.2 升级脚本
-- 字符集: utf8mb4
--
-- 说明: v2.2 为安全修复与功能增强版本,无数据库 schema 变更。
-- 主要变更:
-- 1. 修复管理员操作越权漏洞(加分/撤销/恢复操作增加权限校验)
-- 2. 新增宿舍集体加分功能(前端+后端)
-- 3. 学生导入支持宿舍号字段
-- 4. 导入预览表格显示宿舍号列
-- ===========================================

View File

@@ -10,6 +10,16 @@
// 辅助函数
// ===========================================
// 版本升级列表(唯一数据源)
$UPGRADE_VERSIONS = [
'1.7' => __DIR__ . '/sql/upgrades/v1.7.sql',
'1.8' => __DIR__ . '/sql/upgrades/v1.8.sql',
'2.0' => __DIR__ . '/sql/upgrades/v2.0.sql',
'2.0.1' => __DIR__ . '/sql/upgrades/v2.0.1.sql',
'2.1' => __DIR__ . '/sql/upgrades/v2.1.sql',
'2.2' => __DIR__ . '/sql/upgrades/v2.2.sql',
];
/**
* 读取 backend/.env 文件并解析数据库配置
*/
@@ -59,16 +69,10 @@ function detectCurrentVersion($pdo) {
* 获取需要执行的升级步骤
*/
function getUpgradeSteps($currentVersion, $targetVersion) {
$allVersions = [
'1.7' => __DIR__ . '/sql/upgrades/v1.7.sql',
'1.8' => __DIR__ . '/sql/upgrades/v1.8.sql',
'2.0' => __DIR__ . '/sql/upgrades/v2.0.sql',
'2.0.1' => __DIR__ . '/sql/upgrades/v2.0.1.sql',
'2.1' => __DIR__ . '/sql/upgrades/v2.1.sql',
];
global $UPGRADE_VERSIONS;
$steps = [];
foreach ($allVersions as $version => $sqlFile) {
foreach ($UPGRADE_VERSIONS as $version => $sqlFile) {
if (version_compare($version, $currentVersion, '>') &&
version_compare($version, $targetVersion, '<=')) {
$steps[$version] = $sqlFile;
@@ -128,19 +132,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_GET['action'] ?? '') === 'step')
]);
// 获取该版本对应的 SQL 文件
$allVersions = [
'1.7' => __DIR__ . '/sql/upgrades/v1.7.sql',
'1.8' => __DIR__ . '/sql/upgrades/v1.8.sql',
'2.0' => __DIR__ . '/sql/upgrades/v2.0.sql',
'2.0.1' => __DIR__ . '/sql/upgrades/v2.0.1.sql',
'2.1' => __DIR__ . '/sql/upgrades/v2.1.sql',
];
if (!isset($allVersions[$stepVersion])) {
if (!isset($UPGRADE_VERSIONS[$stepVersion])) {
throw new RuntimeException("未知版本: {$stepVersion}");
}
$sqlFile = $allVersions[$stepVersion];
$sqlFile = $UPGRADE_VERSIONS[$stepVersion];
$shortFile = basename($sqlFile);
executeUpgrade($pdo, $stepVersion, $sqlFile);
@@ -432,6 +428,15 @@ try {
<div class="error-box">
<strong>错误:</strong><?php echo htmlspecialchars($errorMessage); ?>
</div>
<?php if ($hasError && strpos($errorMessage, '配置文件不存在') !== false): ?>
<div class="warning-box">
<strong>💡 解决方法:</strong><br>
1. 进入 <code>backend/</code> 目录<br>
2. 复制配置模板:<code>cp .env.example .env</code><br>
3. 编辑 <code>.env</code> 文件,填入实际的数据库连接信息<br>
4. 刷新此页面
</div>
<?php endif; ?>
<?php elseif ($isUpToDate): ?>
<div class="success-box">
✓ 数据库已是最新版本,无需升级。