commit
build / build (push) Failing after 4m11s

This commit is contained in:
wanghu
2026-10-14 08:29:44 +08:00
parent 15762faeac
commit 9d387692b4
17 changed files with 1316 additions and 361 deletions
+2 -3
View File
@@ -1,12 +1,11 @@
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
# (.env.base/.env.ld/.env.prod 已删,线上由 /api/env-config?target=front 运行时下发)。
# ⚠️ VITE_* 仅本地开发生效。
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8105),
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8109),
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
# 留空 → serviceHost 走同源相对 /api(经 vite 代理到本地后端 8105);填绝对地址会绕过代理直连该域
# 留空 → serviceHost 走同源相对 /api(经 vite 代理到本地后端 8109);填绝对地址会绕过代理直连该域
VITE_SERVICE_HOST=
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
+9 -2
View File
@@ -1,11 +1,11 @@
{
"name": "dorm-notice",
"name": "acasyllabu",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dorm-notice",
"name": "acasyllabu",
"version": "0.0.0",
"dependencies": {
"@open-file-viewer/core": "^0.1.43",
@@ -15,6 +15,7 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"alova": "^3.4.0",
"axios": "^0.24.0",
"html-to-image": "^1.11.13",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"pdfjs-dist": "^4.10.38",
@@ -6456,6 +6457,12 @@
"node": ">= 12"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-void-elements": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"alova": "^3.4.0",
"axios": "^0.24.0",
"html-to-image": "^1.11.13",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"pdfjs-dist": "^4.10.38",
+7 -2
View File
@@ -25,6 +25,9 @@ function roleOfPath(p: string): "teacher" | "parent" {
}
async function initShell() {
// 深链/刷新时初始导航是异步懒加载,不等就绪会读到初始路径 "/",
// 下方「登录后跳转第一个模块」就会把 /schedule-status 等直达路由弹回首页
await router.isReady();
const role = roleOfPath(router.currentRoute.value.path);
try {
// 端分离:先按 URL 前缀切换当前视角(teacher/parent),再拉身份与菜单
@@ -61,8 +64,10 @@ async function initShell() {
isLogin.value = true;
// 登录后跳转第一个可用模块("/" 本身即我发布的页,无需跳转)
if (router.currentRoute.value.path === "/") {
// 登录后跳转第一个可用模块(仅限落在默认页:"/" 与其静态重定向 /timetable;
// 深链到 /schedule-status 等具体页时保持原位不动)
const curPath = router.currentRoute.value.path;
if (curPath === "/" || curPath === "/timetable") {
const firstModule = (modList || [])[0];
const firstRaw = firstModule?.children?.length
? firstModule.children[0]
+49 -9
View File
@@ -4,14 +4,16 @@ import conf from "@/assets/config/host.config";
import type {
ClassTreeNode,
EffectiveSettingVo,
GradeSettingVo,
ModNode,
MyClassVo,
MyClassesVo,
MyTimetableVo,
NowYearTermVo,
SettingGetVo,
SubjectVo,
TemplateSaveRequest,
TimeSettingVo,
TimetableOverviewVo,
TimetableSaveRequest,
TimetableVo,
UpFileResultVo,
@@ -27,8 +29,6 @@ import type {
*
* 约定:
* - postJSON 用于 @RequestBody 接口;postForm 用于 @RequestParam 接口(表单提交)。
* - postForm 同时把参数拼到 URL query:后端 @RequestParam 都收得到,且本地
* vite-plugin-mock 不解析 urlencoded body,靠 query 才能拿到参数(不影响线上)。
*/
const BASE = "/api/base";
@@ -56,13 +56,11 @@ function postJSON<T = any>(url: string, data?: Record<string, any> | any[]): Met
function postForm<T = any>(url: string, data?: Record<string, any>): Method<T> {
const pairs = data ? Object.entries(data).filter(([, v]) => v !== undefined && v !== null) : [];
const form = pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
// B1 修复:mock 只解析 query,故仅 mock 模式把参数拼进 URL;真后端只发 body,
// 否则 Spring @RequestParam 会把 query 与 body 同名值以逗号合并(单值变 "X,X"、数字变 "1,1" 直接 500)。
const fullUrl = conf.isMock && form ? `${url}${url.includes("?") ? "&" : "?"}${form}` : url;
// 只发 body,不拼 URL query:Spring @RequestParam 会把 query 与 body 同名值以逗号合并(单值变 "X,X" 直接 500)
return new Method<T>(
"POST",
alovaInstance,
fullUrl,
url,
{ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" } },
form || undefined
);
@@ -83,6 +81,26 @@ export function fileUploadUrl(): string {
return `${conf.serviceHost}${FILE}/Upload`;
}
/** 年级照片课表打包下载地址(ZIP,条目按年级+班级名命名;前端 fetch 取 Blob 后落盘) */
export function gradePhotoZipUrl(gradeName: string): string {
return `${conf.serviceHost}${TIMETABLE}/DownloadGradePhotos?gradeName=${encodeURIComponent(gradeName)}`;
}
/** 课表导入模板下载地址(按班级年级生效节次模板生成 xlsx) */
export function timetableImportTemplateUrl(classId: number): string {
return `${conf.serviceHost}${TIMETABLE}/ImportTemplate?classId=${classId}`;
}
/** 班级电子课表导出地址(xlsx,当前学年学期) */
export function timetableExportUrl(classId: number): string {
return `${conf.serviceHost}${TIMETABLE}/Export?classId=${classId}`;
}
/** 课表导入上传地址(FormData:classId + file,整表替换该班当前课表) */
export function timetableImportUrl(): string {
return `${conf.serviceHost}${TIMETABLE}/Import`;
}
class BaseApi {
// ========== 用户信息 & 模块权限 ==========
/** 获取当前登录用户信息 */
@@ -140,10 +158,16 @@ class SyllabusApi {
return get<NowYearTermVo>(`${TIMETABLE}/NowYearTerm`);
}
// ========== 排课情况 ==========
/** 全校排课情况汇总(每班电子版 / 照片版编排状态,按年级→班级排序) */
overview() {
return get<TimetableOverviewVo[]>(`${TIMETABLE}/Overview`);
}
// ========== 我的课表(按角色过滤的只读视图) ==========
/** 可查看班级列表(管理员/校领导=全部;班主任=所带;任教=任教班级) */
/** 可查看班级列表 + 身份标识(管理员/班主任/任教老师,前端按身份切换视图) */
myClasses() {
return get<MyClassVo[]>(`${TIMETABLE}/MyClasses`);
return get<MyClassesVo>(`${TIMETABLE}/MyClasses`);
}
/** 班级课表(权限校验;任教老师只返回任教科目格) */
myGet(classId: number) {
@@ -171,10 +195,26 @@ class SyllabusApi {
deleteTemplate(id: number) {
return postForm<void>(`${SETTING}/DeleteTemplate`, { id });
}
/** 保存节次模板的「显示全部/只显示节次」偏好(绑定模板记忆,预览课表/我的课表同随此值) */
saveTemplateShowBreakRows(id: number, showBreakRows: boolean) {
return postForm<void>(`${SETTING}/SaveTemplateShowBreakRows`, { id, showBreakRows });
}
/** 保存节次模板的「节次名称 显示/隐藏」偏好(绑定模板记忆,预览课表/我的课表同随此值) */
saveTemplateShowPeriodName(id: number, showPeriodName: boolean) {
return postForm<void>(`${SETTING}/SaveTemplateShowPeriodName`, { id, showPeriodName });
}
/** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(term 1=上学期 2=下学期) */
effectiveForGrade(gradeName: string, term?: number) {
return get<EffectiveSettingVo>(`${SETTING}/ForGrade`, { gradeName, term });
}
/** 年级设置列表(启用 + 使用电子版/照片版;未配置的年级默认全启用) */
gradeSettings() {
return get<GradeSettingVo[]>(`${SETTING}/Grades`);
}
/** 批量保存年级设置(按年级名 upsert;作用于「我的课表」电子/照片视图切换) */
saveGradeSettings(data: GradeSettingVo[]) {
return postJSON<void>(`${SETTING}/SaveGrades`, data);
}
// ========== 附件 ==========
fileDownUrl = fileDownUrl;
+1 -1
View File
@@ -2,7 +2,7 @@ import config from "./host.config";
import { AxiosRequestConfig } from "axios";
import { PcDebugUser } from "@/types/Basics";
const serviceUrl = config.isMock ? "" : config.serviceHost,
const serviceUrl = config.serviceHost,
timeout: number = 15000;
let baseConfig: AxiosRequestConfig = {
+4 -6
View File
@@ -5,7 +5,7 @@ import { BaseConf } from "@/types/Basics";
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/front/,
* 故为 front,对应后端键 yh.env-config.source.front,projman 配置管理维护)写入
* ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev、--mode mock 读 .env.mock);
* ② import.meta.env.VITE_*:仅本地开发(vite dev --mode dev 读 .env.dev);
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
* 本层自动跳过,产物不含任何环境值
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
@@ -14,7 +14,7 @@ import { BaseConf } from "@/types/Basics";
const env = import.meta.env;
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/front/ → contextPath,即后端上下文),
// 与服务端 context-path 对齐(本地无、base/prod /familyactivity);本地 dev/mock 在根路径运行(无段),
// 与服务端 context-path 对齐(本地无、base/prod /familyactivity);本地 dev 在根路径运行(无段),
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
const seg = window.location.pathname.split("/").filter(Boolean);
const origin = window.location.origin;
@@ -27,7 +27,6 @@ let conf: BaseConf = {
ziyuankuHost: "",
zhuanxiangHost: "",
},
isMock: false,
isDebug: env.DEV,
};
@@ -41,16 +40,15 @@ const applyConf = (target: any, source: any) => {
});
};
// ② 本地开发/演示(build 产物中恒 undefined → 自动跳过)
// ② 本地开发(build 产物中恒 undefined → 自动跳过)
applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST,
serviceHost: env.VITE_SERVICE_HOST,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
},
isMock: env.VITE_IS_MOCK === "true",
});
// ① 线上运行时配置(最高优先,覆盖前面所有);relayConf 为嵌套对象,按子键合并,
+4 -11
View File
@@ -6,7 +6,6 @@
// biome-ignore lint: disable
export {}
declare global {
const $$userConfigMap: typeof import('./api/index')['$$userConfigMap']
const Button: typeof import('view-ui-plus')['Button']
const EffectScope: typeof import('vue')['EffectScope']
const Form: typeof import('view-ui-plus')['Form']
@@ -16,23 +15,18 @@ declare global {
const Modal: typeof import('view-ui-plus')['Modal']
const Select: typeof import('view-ui-plus')['Select']
const Table: typeof import('view-ui-plus')['Table']
const alovaInstance: typeof import('./api/index')['alovaInstance']
const api: typeof import('./api/index')['default']
const apiDefinitions: typeof import('./api/apiDefinitions')['default']
const baseApi: typeof import('./api/module/syllabus')['baseApi']
const computed: typeof import('vue')['computed']
const createApis: typeof import('./api/createApis')['createApis']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const dorm: typeof import('./api/module/dorm')['default']
const dormApi: typeof import('./api/module/dorm')['dormApi']
const effectScope: typeof import('vue')['effectScope']
const filePreviewUrl: typeof import('./api/module/syllabus')['filePreviewUrl']
const fileUploadUrl: typeof import('./api/module/syllabus')['fileUploadUrl']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const gradePhotoZipUrl: typeof import('./api/module/syllabus')['gradePhotoZipUrl']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
@@ -40,9 +34,7 @@ declare global {
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const mountApis: typeof import('./api/createApis')['mountApis']
const nextTick: typeof import('vue')['nextTick']
const noticeApi: typeof import('./api/module/dorm')['noticeApi']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
@@ -64,12 +56,14 @@ declare global {
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const settingApi: typeof import('./api/module/dorm')['settingApi']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const syllabus: typeof import('./api/module/syllabus')['default']
const syllabusApi: typeof import('./api/module/syllabus')['syllabusApi']
const timetableExportUrl: typeof import('./api/module/syllabus')['timetableExportUrl']
const timetableImportTemplateUrl: typeof import('./api/module/syllabus')['timetableImportTemplateUrl']
const timetableImportUrl: typeof import('./api/module/syllabus')['timetableImportUrl']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
@@ -94,7 +88,6 @@ declare global {
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const withConfigType: typeof import('./api/createApis')['withConfigType']
}
// for type re-export
declare global {
+1 -1
View File
@@ -42,7 +42,7 @@ export default {
data() {
return {
isMultiple: !this.multiple || false,
uploadFileUrl: (config.isMock ? "" : config.serviceHost) + "/api/base/FileUpload",
uploadFileUrl: config.serviceHost + "/api/base/FileUpload",
formatArr: ["jpg", "gif", "png", "bmp", "xls", "doc", "txt", "docx", "xlsx", "zip", "rar", "pdf", "ppt", "pptx", "wps"],
maxFileSize: "200MB",
maxSize: 204800,
+1 -1
View File
@@ -40,7 +40,7 @@ const previewName = ref("");
const fileType = ref("");
const blob = ref<Blob | null>(null);
const fileServiceBase = config.isMock ? "" : config.serviceHost;
const fileServiceBase = config.serviceHost;
/** 内联流地址(<video> 等场景用;Down 是 attachment 头,视频流不可用) */
const previewUrl = computed(() => (props.fileId ? `${fileServiceBase}/api/file/Preview?id=${props.fileId}` : ""));
+8
View File
@@ -18,6 +18,14 @@ const routes: RouteRecordRaw[] = [
meta: { title: "课程表编排", modId: 107 },
},
// 排课情况(全校班级电子版/照片版编排状态总览;parentId 挂靠课表编排菜单高亮)
{
path: "/schedule-status",
name: "timetable_overview",
component: () => import("@/views/syllabus/TimetableOverview.vue"),
meta: { title: "排课情况", parentId: 107 },
},
// 我的课表(按角色只读查看:管理员/校领导=全部班级,班主任=所带班级,任教老师=任教班级任教科目)
{
path: "/mine",
-1
View File
@@ -29,7 +29,6 @@ export interface BaseConf {
ssoHost: string;
serviceHost: string;
relayConf: RelayConf;
isMock: Boolean;
isDebug?: Boolean;
[keys: string]: any; //任意属性
}
+60
View File
@@ -73,6 +73,23 @@ export interface TimetableSaveRequest {
slots: TimetableSlotVo[];
}
/** 排课情况(按班级汇总电子版 / 照片版编排状态) */
export interface TimetableOverviewVo {
classId: number;
gradeName: string | null;
className: string | null;
/** 电子版是否已设置(当前学年学期已保存过节次) */
electronicSet: boolean;
/** 已排节次数 */
periodCount: number;
/** 已排科目数(去重) */
subjectCount: number;
/** 照片版是否已上传 */
photoSet: boolean;
/** 照片附件ID(缩略图预览用,未上传为 null) */
photoFileId: number | null;
}
// ==================== 我的课表(按角色过滤的只读视图) ====================
/**
@@ -97,6 +114,21 @@ export interface MyTimetableVo extends TimetableVo {
mySubjects: string[];
}
/**
* 「我的课表」班级列表 + 当前用户身份标识。
* 身份可叠加(如管理员同时是班主任/任教),前端按 管理员&gt;班主任&gt;任教老师 优先级展示默认视图。
*/
export interface MyClassesVo {
/** 是否管理员/校领导身份 */
admin: boolean;
/** 是否班主任身份(任一班的正/副班主任是本人) */
headTeacher: boolean;
/** 是否任教老师身份(任一班有任教科目) */
subjectTeacher: boolean;
/** 可查看班级(管理员=全校;班主任/任教=所带∪任教;学生=本人班级) */
classes: MyClassVo[];
}
// ==================== 应用设置(时段判定 + 节次模板) ====================
/**
@@ -150,6 +182,10 @@ export interface PeriodTemplateVo {
periodMinutes: number;
/** 课间时长(分钟),模板加空白行的默认用时 */
breakMinutes: number;
/** 课表页是否显示空白行(课间/午休):false=只显示节次,随模板保存(预览课表/我的课表同随此值) */
showBreakRows: boolean;
/** 课表页是否显示「节次」列(节次名称):false=隐藏,随模板保存(预览课表/我的课表同随此值) */
showPeriodName: boolean;
rows: TemplateRowVo[];
}
@@ -175,6 +211,10 @@ export interface TemplateSaveRequest {
periodMinutes: number;
/** 课间时长(分钟) */
breakMinutes: number;
/** 课表页是否显示空白行(课间/午休):false=只显示节次,缺省沿用现值 */
showBreakRows?: boolean;
/** 课表页是否显示「节次」列(节次名称):false=隐藏,缺省沿用现值 */
showPeriodName?: boolean;
rows: TemplateRowVo[];
}
@@ -186,6 +226,26 @@ export interface EffectiveSettingVo {
teachDays: string;
/** 生效模板名称(如 春),无启用模板时为空 */
templateName: string | null;
/** 生效模板ID(「显示全部/只显示节次」开关回存用),无启用模板时为空 */
templateId: number | null;
/** 课表页是否显示空白行(课间/午休):false=只显示节次,随生效模板记忆 */
showBreakRows: boolean;
/** 课表页是否显示「节次」列(节次名称):false=隐藏,随生效模板记忆 */
showPeriodName: boolean;
/** 年级是否启用课表查看(年级设置,未配置视为启用) */
gradeEnabled: boolean;
/** 该年级是否使用电子课表(未配置视为使用) */
useElectronic: boolean;
/** 该年级是否使用照片课表(未配置视为使用) */
usePhoto: boolean;
}
/** 年级应用设置(启用 + 使用电子版/照片版;作用于「我的课表」电子/照片视图切换) */
export interface GradeSettingVo {
gradeName: string;
enabled: boolean;
useElectronic: boolean;
usePhoto: boolean;
}
// ==================== 通用 ====================
+139 -26
View File
@@ -25,13 +25,15 @@
<th>启用时段</th>
<th>节次数</th>
<th>空白行</th>
<th>课表显示</th>
<th>节次名称</th>
<th>启用</th>
<th class="st-col-op">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="!templates.length">
<td colspan="8">
<td colspan="10">
<div class="st-empty">
<b>还没有节次模板</b><br />点击「新增模板」创建(每个启用时段默认一条节次),启用后课表编排页即按模板生成节次。
</div>
@@ -44,6 +46,28 @@
<td>{{ bandsText(tpl.enabledBands) }}</td>
<td class="num">{{ periodCount(tpl.rows) }} 节</td>
<td class="num">{{ breakCount(tpl.rows) }} 行</td>
<td>
<label
class="st-switch"
:class="{ on: tpl.showBreakRows !== false }"
title="课表编排/预览课表/我的课表是否显示课间等空白行,切换即保存"
>
<input type="checkbox" :checked="tpl.showBreakRows !== false" @change="toggleShowBreakRows(tpl)" />
<span class="track"><span class="thumb"></span></span>
{{ tpl.showBreakRows !== false ? "显示全部" : "只显示节次" }}
</label>
</td>
<td>
<label
class="st-switch"
:class="{ on: tpl.showPeriodName !== false }"
title="课表编排预览/我的课表是否显示「节次」列(第一节/课间等名称),切换即保存"
>
<input type="checkbox" :checked="tpl.showPeriodName !== false" @change="toggleShowPeriodName(tpl)" />
<span class="track"><span class="thumb"></span></span>
{{ tpl.showPeriodName !== false ? "显示" : "隐藏" }}
</label>
</td>
<td>
<label class="st-switch" :class="{ on: tpl.enabled }">
<input type="checkbox" :checked="tpl.enabled" @change="toggleEnabled(tpl)" />
@@ -61,7 +85,7 @@
</table>
</div>
<div class="st-hintbar">
<span>停用时段的节次行不在此维护、课表页也不显示;节次名称可自定义,留空按时段内顺序显示第X节;空白行(课间 / 午休)同样落在时段里,可自定义名称、备注与时间,不占用节次序号、不能排课程。</span>
<span>停用时段的节次行不在此维护、课表页也不显示;节次名称可自定义,留空按时段内顺序显示第X节;空白行(课间 / 午休)同样落在时段里,可自定义名称、备注与时间,不占用节次序号、不能排课程;「课表显示」决定课表编排 / 预览课表 / 我的课表是否显示空白行,「节次名称」决定预览课表 / 我的课表是否显示「节次」列,切换即存。</span>
</div>
</template>
@@ -102,14 +126,14 @@
{{ g }}
</label>
</div>
<div class="st-field st-days sp5">
<div class="st-field st-days sp4">
<span>教学日</span>
<label v-for="(d, i) in DAY_SHORT" :key="d" class="st-day" :class="{ on: draft.teachDays[i] }">
<input type="checkbox" :checked="draft.teachDays[i]" @change="toggleDay(i)" />
{{ d }}
</label>
</div>
<div class="st-field st-days sp5">
<div class="st-field st-days sp4">
<span>时段启用</span>
<label
v-for="b in BANDS"
@@ -123,14 +147,24 @@
<i></i>{{ b.label }}
</label>
</div>
<div class="st-display-prefs">
<div class="st-field st-switch-field">
<span>模板启用</span>
<label class="st-switch" :class="{ on: draft.enabled }">
<input type="checkbox" :checked="draft.enabled" @change="draft.enabled = !draft.enabled" />
<span title="课表编排预览/我的课表是否显示「节次」列(第一节/课间等名称)">节次名称</span>
<label class="st-switch" :class="{ on: draft.showPeriodName }" title="显示:课表出现「节次」列;隐藏:只看时段/学科/时间更简洁,预览课表与我的课表同随此值">
<input type="checkbox" :checked="draft.showPeriodName" @change="draft.showPeriodName = !draft.showPeriodName" />
<span class="track"><span class="thumb"></span></span>
{{ draft.enabled ? "启用" : "停用" }}
{{ draft.showPeriodName ? "显示" : "隐藏" }}
</label>
</div>
<div class="st-field st-switch-field">
<span title="课表编排/预览课表/我的课表是否显示课间等空白行">课表显示</span>
<label class="st-switch" :class="{ on: draft.showBreakRows }" title="显示全部:课表页显示课间等空白行;只显示节次:隐藏空白行更紧凑,预览课表与我的课表同随此值">
<input type="checkbox" :checked="draft.showBreakRows" @change="draft.showBreakRows = !draft.showBreakRows" />
<span class="track"><span class="thumb"></span></span>
{{ draft.showBreakRows ? "显示全部" : "只显示节次" }}
</label>
</div>
</div>
</div>
</div>
<div class="st-scroll st-editor-scroll">
@@ -226,6 +260,14 @@
{{ fullScreen ? "退出全屏" : "全屏" }}
</button>
<span class="st-flex"></span>
<div class="st-field st-switch-field">
<span>模板启用</span>
<label class="st-switch" :class="{ on: draft.enabled }" title="启用后学期重叠(全年与上/下学期互通)且年级有交叉的其它模板自动停用">
<input type="checkbox" :checked="draft.enabled" @change="draft.enabled = !draft.enabled" />
<span class="track"><span class="thumb"></span></span>
{{ draft.enabled ? "启用" : "停用" }}
</label>
</div>
<button class="st-btn ghost" type="button" title="预览该模板生成的节次骨架(含教学日列)" @click="previewOpen = true">预览</button>
<button class="st-btn primary" type="button" :disabled="savingTpl" @click="saveDraft">
{{ savingTpl ? "保存中…" : "保存模板" }}
@@ -255,7 +297,7 @@
<thead>
<tr>
<th class="h-band">时段</th>
<th class="h-slot">节次</th>
<th v-if="previewShowName" class="h-slot">节次</th>
<!-- 星期列即该模板绑定的教学日:只列教学日,且不做周末特殊标记 -->
<th v-for="d in previewDays" :key="d">{{ DAY_SHORT[d] }}</th>
<th class="h-time">时间</th>
@@ -263,22 +305,22 @@
</thead>
<tbody>
<tr v-if="!draft.rows.length">
<td :colspan="previewDays.length + 3" class="st-sheet-empty">还没有行,添加节次或空白行后可在此预览。</td>
<td :colspan="previewDays.length + (previewShowName ? 3 : 2)" class="st-sheet-empty">还没有行,添加节次或空白行后可在此预览。</td>
</tr>
<template v-for="(er, i) in editorRows" :key="i">
<tr v-if="er.rowType === 'period'">
<td v-if="er.bandFirst" :rowspan="er.bandRows" class="rail" :style="bandStyle(er.band)">{{ bandLabel(er.band) }}</td>
<td class="slot">{{ draft.rows[i].name || "第" + cnNum((er.bandIndex || er.periodNo) - 1) + "节" }}</td>
<template v-for="(p, idx) in previewRows" :key="idx">
<tr v-if="p.er.rowType === 'period'">
<td v-if="p.er.bandFirst" :rowspan="p.er.bandRows" class="rail" :style="bandStyle(p.er.band)">{{ bandLabel(p.er.band) }}</td>
<td v-if="previewShowName" class="slot">{{ p.row.name || "第" + cnNum((p.er.bandIndex || p.er.periodNo) - 1) + "节" }}</td>
<td v-for="d in previewDays" :key="d" class="blank">—</td>
<td class="time num">{{ draft.rows[i].start || "--:--" }} – {{ draft.rows[i].end || "--:--" }}</td>
<td class="time num">{{ p.row.start || "--:--" }} – {{ p.row.end || "--:--" }}</td>
</tr>
<tr v-else class="brk">
<td v-if="er.bandFirst" :rowspan="er.bandRows" class="rail" :style="bandStyle(er.band)">{{ bandLabel(er.band) }}</td>
<td :colspan="er.band !== null ? previewDays.length + 1 : previewDays.length + 2" class="brk">
<span class="tag"><i></i>{{ draft.rows[i].name || "课间" }}</span>
<span v-if="draft.rows[i].remark" class="cnt" :class="{ long: isLongRemark(draft.rows[i].remark) }">{{ draft.rows[i].remark }}</span>
<td v-if="p.er.bandFirst" :rowspan="p.er.bandRows" class="rail" :style="bandStyle(p.er.band)">{{ bandLabel(p.er.band) }}</td>
<td :colspan="(p.er.band !== null ? previewDays.length + 1 : previewDays.length + 2) - (previewShowName ? 0 : 1)" class="brk">
<span class="tag"><i></i>{{ p.row.name || "课间" }}</span>
<span v-if="p.row.remark" class="cnt" :class="{ long: isLongRemark(p.row.remark) }">{{ p.row.remark }}</span>
</td>
<td class="time num">{{ draft.rows[i].start || "--:--" }} – {{ draft.rows[i].end || "--:--" }}</td>
<td class="time num">{{ p.row.start || "--:--" }} – {{ p.row.end || "--:--" }}</td>
</tr>
</template>
</tbody>
@@ -348,6 +390,10 @@ interface DraftTemplate {
periodMinutes: number;
/** 课间时长(分钟):模板加空白行的默认用时 */
breakMinutes: number;
/** 课表页是否显示空白行(课间/午休):false=只显示节次,随模板保存 */
showBreakRows: boolean;
/** 课表页是否显示「节次」列(节次名称):false=隐藏,随模板保存 */
showPeriodName: boolean;
rows: DraftRow[];
}
@@ -387,6 +433,8 @@ const previewMeta = computed(() => {
`启用时段:${bandsText(d.enabledBands)}`,
`教学日:${days}`,
`节次 ${d.periodMinutes} 分钟 / 课间 ${d.breakMinutes} 分钟`,
`课表显示:${d.showBreakRows ? "显示全部" : "只显示节次"}`,
`节次名称:${d.showPeriodName ? "显示" : "隐藏"}`,
].join(" · ");
});
@@ -502,15 +550,14 @@ function rawBandIndex(start: string): number | null {
}
/**
* 编辑器行派生:每行附时段与分组信息(与 draft.rows 逐位对齐)。
* 编辑器行分组:每行附时段与分组信息(与入参行序逐位对齐)。
* 行的所属时段取自行数据(band),开始时间不参与时段归属;
* 相邻同时段的行(节次与空白行都算)归入一组,组内首行以 rowspan 竖排合并显示时段名
* ——空白行同样落在时段里,不单独脱离分组。
* bandIndex:节次在该时段组内的顺序(1 起),节次默认名按此时段内自增。
*/
const editorRows = computed(() => {
function groupEditorRows(rows: DraftRow[]) {
type Entry = { i: number; band: BandKey | null; gi: number | null };
const rows = draft.value?.rows || [];
const entries: Entry[] = [];
let gi = 0;
let prev: BandKey | null = null;
@@ -561,8 +608,22 @@ const editorRows = computed(() => {
periodNo,
};
});
}
/** 编辑器行表(与 draft.rows 逐位对齐) */
const editorRows = computed(() => groupEditorRows(draft.value?.rows || []));
/** 模板预览行:跟随「课表显示」偏好——只显示节次时按剔除空白行后的行重新分组(时段 rowspan 相应重算) */
const previewRows = computed(() => {
const rows = draft.value?.rows || [];
const shown = draft.value?.showBreakRows === false ? rows.filter((r) => r.rowType === "period") : rows;
// groupEditorRows 输出与入参行逐位对齐,按下标配对取回原始行
return groupEditorRows(shown).map((er, i) => ({ er, row: shown[i] }));
});
/** 模板预览是否显示「节次」列:跟随「节次名称」偏好(与预览课表/我的课表观感一致) */
const previewShowName = computed(() => draft.value?.showPeriodName !== false);
/**
* 行用时默认值(分钟):取当前模板的「节次时间 / 课间时间」,
* 编辑对象即该模板,时长自然绑定模板。
@@ -783,6 +844,9 @@ function openNew() {
enabledBands: bands,
periodMinutes,
breakMinutes,
// 显示偏好以参照的全年模板为准(无则显示全部、显示节次名称)
showBreakRows: src ? src.showBreakRows !== false : true,
showPeriodName: src ? src.showPeriodName !== false : true,
rows: src ? preparedRows(toDraftRows(src.rows), bands) : defaultRows(),
};
}
@@ -800,6 +864,8 @@ function openCopy(tpl: PeriodTemplateVo) {
enabledBands: bands,
periodMinutes: templateMinutes(tpl.periodMinutes, 45),
breakMinutes: templateMinutes(tpl.breakMinutes, 10),
showBreakRows: tpl.showBreakRows !== false,
showPeriodName: tpl.showPeriodName !== false,
rows: preparedRows(toDraftRows(tpl.rows), bands),
};
}
@@ -816,6 +882,8 @@ function openEdit(tpl: PeriodTemplateVo) {
enabledBands: bands,
periodMinutes: templateMinutes(tpl.periodMinutes, 45),
breakMinutes: templateMinutes(tpl.breakMinutes, 10),
showBreakRows: tpl.showBreakRows !== false,
showPeriodName: tpl.showPeriodName !== false,
rows: preparedRows(toDraftRows(tpl.rows), bands),
};
}
@@ -894,6 +962,40 @@ function removeTemplate(tpl: PeriodTemplateVo) {
});
}
/**
* 切换模板的「课表显示」(显示全部/只显示节次):轻量接口直接落库到该模板,
* 课表编排页开关、预览课表与我的课表同随此值。
*/
async function toggleShowBreakRows(tpl: PeriodTemplateVo) {
const next = tpl.showBreakRows === false;
try {
await syllabusApi.saveTemplateShowBreakRows(tpl.id, next);
Message.success(`「${tpl.name}」课表页已设为${next ? "显示全部" : "只显示节次"}`);
await loadSetting();
} catch (e) {
console.error("保存课表显示偏好失败", e);
Message.error("课表显示保存失败,请重试");
await loadSetting();
}
}
/**
* 切换模板的「节次名称」(显示/隐藏):轻量接口直接落库到该模板,
* 课表编排预览课表与我的课表同随此值(课表编排编辑表不受影响)。
*/
async function toggleShowPeriodName(tpl: PeriodTemplateVo) {
const next = tpl.showPeriodName === false;
try {
await syllabusApi.saveTemplateShowPeriodName(tpl.id, next);
Message.success(`「${tpl.name}」课表已${next ? "显示" : "隐藏"}节次名称`);
await loadSetting();
} catch (e) {
console.error("保存节次名称显示偏好失败", e);
Message.error("节次名称保存失败,请重试");
await loadSetting();
}
}
/* ============================================================
* 模板编辑器
* ============================================================ */
@@ -1233,6 +1335,9 @@ async function saveDraft() {
enabledBands: d.enabledBands,
periodMinutes: pMin,
breakMinutes: bMin,
// 课表页显示偏好随模板保存
showBreakRows: d.showBreakRows,
showPeriodName: d.showPeriodName,
rows: d.rows.map((r) => ({
rowType: r.rowType,
// 所属时段随行保存(节次与空白行都落在时段里)
@@ -1739,7 +1844,7 @@ onBeforeUnmount(() => {
align-items: center;
/* 两行各 12 栏铺满:
① 名称3 · 学期2 · 节次时间2 · 课间时间2 · 年级3
② 教学日5 · 时段启用5 · 模板启用2 */
② 教学日5 · 时段启用5 · 显示偏好2(「节次名称」「课表显示」上下两行,「模板启用」已移至底部操作栏) */
> .st-field {
grid-column: span 2;
min-width: 0;
@@ -1747,15 +1852,23 @@ onBeforeUnmount(() => {
> .st-field.sp3 {
grid-column: span 3;
}
> .st-field.sp5 {
> .st-field.sp4 {
grid-column: span 5;
flex-wrap: wrap;
}
> .st-display-prefs {
grid-column: span 2;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
}
@media (max-width: 900px) {
.st-editor-fields > .st-field,
.st-editor-fields > .st-field.sp3,
.st-editor-fields > .st-field.sp5 {
.st-editor-fields > .st-field.sp4,
.st-editor-fields > .st-display-prefs {
grid-column: 1 / -1;
}
}
+293 -215
View File
@@ -1,34 +1,30 @@
<template>
<div class="mt-page">
<!-- 页头:标题 + 统计 -->
<div class="mt-pagehead">
<div>
<h1 class="mt-h1">我的课表</h1>
<p class="mt-sub">管理员/校领导查看全校班级,班主任查看所带班级,任教老师查看任教班级的任教科目,学生查看自己班级。</p>
</div>
<div class="mt-stats">
<div class="mt-stat"><b>{{ yearTermText }}</b><span>当前学年学期</span></div>
<div class="mt-stat"><b class="num">{{ classes.length }} 个</b><span>可查看班级</span></div>
<div class="mt-stat"><b>{{ scopeText }}</b><span>查看范围</span></div>
<div class="mt-stat"><b>{{ activeClassName || "未选择" }}</b><span>当前班级</span></div>
<div class="mt-stat"><b class="num">{{ filledCount }} 门</b><span>已排课程</span></div>
<div class="mt-stat"><b>{{ photo ? "已上传" : "未上传" }}</b><span>照片课表</span></div>
</div>
</div>
<section class="mt-card">
<!-- 工具条:班级 tab + 操作 -->
<!-- 工具条:身份视图切换 + 班级 tab + 操作 -->
<div class="mt-card-tools">
<!-- 身份视图切换:管理员 > 班主任 > 任教老师(多身份时展示,单身份固定无需切换) -->
<div v-if="showViewSwitcher" class="mt-idtabs" role="tablist" aria-label="身份视图切换">
<button
v-for="v in availableViews"
:key="v.key"
role="tab"
type="button"
:aria-selected="viewMode === v.key"
:class="{ on: viewMode === v.key }"
@click="selectView(v.key)"
>{{ v.label }}</button>
</div>
<div class="mt-classtabs" role="tablist" aria-label="班级切换">
<template v-if="classes.length">
<button
v-for="cls in classes"
v-for="cls in viewClasses"
:key="cls.classId"
role="tab"
type="button"
:aria-selected="cls.classId === activeId"
:class="{ on: cls.classId === activeId }"
:title="cls.viewAllSubjects ? '可查看全部科目' : '仅显示任教科目:' + (cls.subjects || []).join('、')"
:title="tabTitle(cls)"
@click="selectClass(cls.classId)"
>
{{ classLabel(cls) }}
@@ -38,8 +34,12 @@
<span v-else-if="!loading" class="mt-tabs-empty">暂无可查看的班级</span>
</div>
<div class="mt-tools-group">
<button v-if="photo" class="mt-btn ghost" type="button" @click="photoOpen = true">照片课表</button>
<button class="mt-btn primary" type="button" :disabled="!activeId" @click="print">打印课表</button>
<button v-if="gradeEnabled && useElectronic && view === 'electronic'" class="mt-btn primary" type="button" :disabled="!activeId" @click="print">打印课表</button>
<!-- 电子课表 / 照片课表 视图切换:按年级设置勾选项展示(只开一个时无需切换) -->
<div v-if="showViewTabs" class="mt-viewtabs" role="tablist" aria-label="课表版本切换">
<button role="tab" type="button" :aria-selected="view === 'electronic'" :class="{ on: view === 'electronic' }" @click="view = 'electronic'">电子课表</button>
<button role="tab" type="button" :aria-selected="view === 'photo'" :class="{ on: view === 'photo' }" @click="view = 'photo'">照片课表</button>
</div>
</div>
</div>
@@ -51,30 +51,59 @@
管理员/校领导需确认学校已建班级;老师需确认已在班级服务中维护班主任或任教关系;学生需确认学籍班级已维护。
</div>
<div v-else-if="!activeId" class="mt-empty"><b>请选择班级</b></div>
<!-- 年级设置(排课情况页按年级统一配置):未启用 / 电子版照片版均未勾选时不展示课表 -->
<div v-else-if="!gradeEnabled" class="mt-empty">
<b>当前年级未启用课表查看</b><br />
请联系管理员在「排课情况」对应年级打开「启用」开关。
</div>
<div v-else-if="!useElectronic && !usePhoto" class="mt-empty">
<b>当前年级未开放课表查看</b><br />
请联系管理员在「排课情况」对应年级勾选「使用电子版 / 使用照片版」。
</div>
<!-- 照片课表视图 -->
<div v-else-if="view === 'photo'" class="mt-photo-pane">
<img v-if="photoUrl" :src="photoUrl" :alt="(activeClassName || '') + ' 课程表照片'" />
<div v-else class="mt-empty">
<b>本班尚未上传照片课表</b><br />
请到「课程表编排 → 照片版」上传后再查看。
</div>
</div>
<!-- 电子课表视图 -->
<div v-else class="mt-table-scroll mt-print-area">
<table class="mt-sheet">
<caption>
<div class="cap-row">
<span class="t">{{ activeClassName || "未选班级" }} 课程表</span>
<span class="s">{{ [yearTermShort, "生成时间 " + stamp()].filter(Boolean).join(" · ") }}</span>
<span v-if="yearTermShort" class="s">{{ yearTermShort }}</span>
<!-- 当前身份视图徽标 + 时段色标图例:与班级名称同一行靠右 -->
<span class="cap-legend">
<span class="mt-scope-chip" :class="{ all: viewMode !== 'teacher' }">
<span class="dot"></span>{{ viewChipText }}
</span>
<span v-for="r in bandLegend" :key="r.key" class="rule">
<i :style="{ background: PERIODS[r.key].color }"></i>{{ r.text }}
</span>
</span>
</div>
</caption>
<thead>
<tr>
<th class="h-empty"><span class="a">时段</span><span class="b">星期</span></th>
<th style="min-width: 70px">节次</th>
<th v-if="showPeriodName" style="min-width: 70px">节次</th>
<th v-for="ci in dayColumns" :key="ci" :class="{ 'is-weekend': ci >= 5 }">{{ DAYS[ci] }}</th>
<th class="h-time" style="min-width: 118px">时间</th>
</tr>
</thead>
<tbody>
<tr v-if="!renderRows.length">
<td :colspan="dayColumns.length + 3">
<td :colspan="dayColumns.length + (showPeriodName ? 3 : 2)">
<div class="mt-empty">当前年级未启用节次模板,请到「课程表编排」或「应用设置」配置后再查看。</div>
</td>
</tr>
<template v-for="(row, idx) in renderRows" :key="idx">
<tr v-if="row.kind === 'slot'">
<td v-if="row.bandFirst" :rowspan="row.bandRows" class="rail" :style="railStyle(row.period)">{{ PERIODS[row.period].label }}</td>
<td class="slot">{{ row.name || "第" + cnNum(row.bandIndex - 1) + "节" }}</td>
<td v-if="showPeriodName" class="slot">{{ row.name || "第" + cnNum(row.bandIndex - 1) + "节" }}</td>
<td v-for="ci in dayColumns" :key="ci" :class="displaySlots[row.ri]?.subjects[ci] ? 'subject' : 'blank'" :style="displaySlots[row.ri]?.subjects[ci] ? subjectStyle(displaySlots[row.ri].subjects[ci]) : undefined">
{{ displaySlots[row.ri]?.subjects[ci] || "—" }}
</td>
@@ -82,15 +111,19 @@
</tr>
<tr v-else class="brk" :class="{ 'in-band': row.inBand }">
<td v-if="row.bandFirst" :rowspan="row.bandRows" class="rail" :style="railStyle(row.period!)">{{ PERIODS[row.period!].label }}</td>
<!-- 空白行拆两块:节次列显名称、星期列跨列显备注,均居中 -->
<template v-if="row.inBand">
<!-- 空白行拆两块:节次列显名称、星期列跨列显备注,均居中;隐藏节次列时名称与备注并为一格 -->
<template v-if="row.inBand && showPeriodName">
<td class="brk b-name"><span class="tag"><i></i>{{ row.name }}</span></td>
<td :colspan="dayColumns.length" class="brk b-remark">
<span v-if="row.remark" class="cnt" :class="{ long: isLongRemark(row.remark) }">{{ row.remark }}</span>
</td>
</template>
<td v-else-if="row.inBand" :colspan="dayColumns.length" class="brk b-name">
<span class="tag"><i></i>{{ row.name }}</span>
<span v-if="row.remark" class="cnt" :class="{ long: isLongRemark(row.remark) }">{{ row.remark }}</span>
</td>
<!-- 无时段兜底:整行合并一块,维持旧观感 -->
<td v-else :colspan="dayColumns.length + 2" class="brk merged">
<td v-else :colspan="dayColumns.length + (showPeriodName ? 2 : 1)" class="brk merged">
<span class="tag"><i></i>{{ row.name }}</span>
<span v-if="row.remark" class="cnt" :class="{ long: isLongRemark(row.remark) }">{{ row.remark }}</span>
</td>
@@ -106,35 +139,10 @@
</span>
<span class="mt-scope-note">本班仅显示您任教的学科,其余节次留空。</span>
</div>
<div v-else class="mt-hintbar">
<span class="mt-scope-chip all">
<span class="dot"></span>{{ activeClass?.headTeacher ? "班主任视图" : "全部科目" }}
</span>
<span v-for="r in bandLegend" :key="r.key" class="rule">
<i :style="{ background: PERIODS[r.key].color }"></i>{{ r.text }}
</span>
</div>
</div>
</div>
</section>
<!-- 照片课表弹窗 -->
<div v-if="photoOpen" class="mt-overlay" @mousedown.self="photoOpen = false">
<div class="mt-modal" role="dialog" aria-modal="true" aria-labelledby="mtPhotoTitle">
<div class="mt-modal-head">
<h2 id="mtPhotoTitle">{{ activeClassName || "本班" }} 课程表照片</h2>
<button class="x" type="button" aria-label="关闭照片预览" @click="photoOpen = false">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="mt-modal-body">
<div class="mt-photo-view"><img :src="photoUrl" :alt="(activeClassName || '') + ' 课程表照片'" /></div>
</div>
<div class="mt-modal-foot">
<button class="mt-btn primary" type="button" @click="photoOpen = false">关闭</button>
</div>
</div>
</div>
</div>
</template>
@@ -142,7 +150,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import { syllabusApi, filePreviewUrl } from "@/api/module/syllabus";
import type { MyClassVo, MyTimetableVo, NowYearTermVo, TemplateRowVo, TimeSettingVo } from "@/types/syllabus";
import type { MyClassesVo, MyClassVo, MyTimetableVo, NowYearTermVo, TemplateRowVo, TimeSettingVo } from "@/types/syllabus";
/* ============================================================
* 常量(与课程表编排页同源的视觉/数据约定)
@@ -227,15 +235,29 @@ const activeId = ref<number | null>(null);
const nowYearTerm = ref<NowYearTermVo | null>(null);
const timetable = ref<MyTimetableVo | null>(null);
const photo = ref<{ photoFileId: number; photoName: string | null } | null>(null);
const photoOpen = ref(false);
const loading = ref(false);
/** 课表视图:电子课表 / 照片课表(年级设置两者都勾选时展示切换 tab) */
const view = ref<"electronic" | "photo">("electronic");
/** 身份视图:管理员 > 班主任 > 任教老师(多身份可点击切换;null=单一身份/学生,固定展示) */
type ViewMode = "admin" | "head" | "teacher";
const viewMode = ref<ViewMode | null>(null);
/** 是否管理员/校领导身份(后端身份标识;班主任/任教身份按班级数据推导) */
const isAdmin = ref(false);
/** 当前班级年级设置(ForGrade 带出;默认全启用) */
const gradeFlags = ref({ gradeEnabled: true, useElectronic: true, usePhoto: true });
/** 年级生效模板行(节次+空白行);无启用模板时回退内置默认 10 节 */
const tplRows = ref<TemplateRowVo[]>(defaultTemplateRows());
/** 时段判定边界(旧模板行兜底推时段用) */
const bands = ref<TimeSettingVo>({ ...DEFAULT_BANDS });
/** 生效模板教学日(7 位 0/1 串,周一~周日);无模板 7 天全开 */
const teachDaysStr = ref("1111111");
/** 是否显示空白行(课间/午休):随生效模板记忆(课表编排页维护),默认显示全部 */
const showBreakRows = ref(true);
/** 是否显示「节次」列(节次名称):随生效模板记忆(应用设置维护),默认显示 */
const showPeriodName = ref(true);
function defaultTemplateRows(): TemplateRowVo[] {
return DEFAULT_ROWS.map((r) => ({ rowType: "period" as const, name: "", start: r.start, end: r.end }));
@@ -246,12 +268,52 @@ function defaultTemplateRows(): TemplateRowVo[] {
* ============================================================ */
const activeClass = computed(() => classes.value.find((c) => c.classId === activeId.value) || null);
/** 年级设置派生(undefined 视为启用/使用,兜底旧数据) */
const gradeEnabled = computed(() => gradeFlags.value.gradeEnabled !== false);
const useElectronic = computed(() => gradeFlags.value.useElectronic !== false);
const usePhoto = computed(() => gradeFlags.value.usePhoto !== false);
/** 电子 + 照片都开放时才需要版本切换 tab */
const showViewTabs = computed(() => gradeEnabled.value && useElectronic.value && usePhoto.value);
const activeClassName = computed(() => (activeClass.value ? classLabel(activeClass.value) : ""));
/** 科目可见范围以后端判定为准(班主任/管理员=全部;任教=任教科目) */
const viewAll = computed(() => (timetable.value ? timetable.value.viewAllSubjects : true));
/** 科目可见范围:任教视图恒为任教科目;其余视图以后端判定为准(班主任/管理员=全部) */
const viewAll = computed(() =>
viewMode.value === "teacher" ? false : timetable.value ? timetable.value.viewAllSubjects : true
);
const mySubjectText = computed(() => (timetable.value?.mySubjects || []).join(" · "));
/** 身份视图可用性:班主任/任教按班级身份数据推导(后端对管理员会话同样下发) */
const headAvailable = computed(() => classes.value.some((c) => c.headTeacher));
const teachAvailable = computed(() => classes.value.some((c) => (c.subjects || []).length > 0));
/** 可用身份视图(按 管理员>班主任>任教老师 优先级排序) */
const availableViews = computed<Array<{ key: ViewMode; label: string }>>(() => {
const out: Array<{ key: ViewMode; label: string }> = [];
if (isAdmin.value) out.push({ key: "admin", label: "管理员视图" });
if (headAvailable.value) out.push({ key: "head", label: "班主任视图" });
if (teachAvailable.value) out.push({ key: "teacher", label: "任教老师视图" });
return out;
});
/** 多身份才需要切换;单身份固定展示无需切换 */
const showViewSwitcher = computed(() => availableViews.value.length > 1);
/** 当前身份视图下的班级:管理员=全部;班主任=所带班级;任教=任教班级 */
const viewClasses = computed<MyClassVo[]>(() => {
if (viewMode.value === "head") return classes.value.filter((c) => c.headTeacher);
if (viewMode.value === "teacher") return classes.value.filter((c) => (c.subjects || []).length > 0);
return classes.value;
});
/** 题注身份视图徽标文案(无身份视图时兜底「全部科目」,如学生) */
const viewChipText = computed(() => availableViews.value.find((v) => v.key === viewMode.value)?.label || "全部科目");
/** 科目过滤集合:任教视图取班级任教科目(管理员会话后端不下发 mySubjects,本地过滤);其余视图取后端结果 */
const restrictSubjects = computed(() => {
if (viewMode.value === "teacher") return activeClass.value?.subjects || [];
return timetable.value?.mySubjects || [];
});
const mySubjectText = computed(() => restrictSubjects.value.join(" · "));
/** 班级 tab 显示名:年级名 + 班级名(班级名已含年级时去重) */
function classLabel(c: MyClassVo): string {
@@ -261,14 +323,14 @@ function classLabel(c: MyClassVo): string {
return g + n;
}
const scopeText = computed(() => {
if (!classes.value.length) return "—";
// 班主任/管理员的班级全部科目;仅任教的班级只显示任教科目
return activeClass.value && !activeClass.value.viewAllSubjects ? "任教科目" : "全部科目";
});
/** 班级 tab 悬停提示:随当前身份视图说明科目范围(任教视图下即使后端放行也只显示任教科目) */
function tabTitle(c: MyClassVo): string {
const subs = (c.subjects || []).join("、");
if (viewMode.value === "teacher" || !c.viewAllSubjects) return subs ? "仅显示任教科目:" + subs : "仅显示任教科目";
return "可查看全部科目";
}
const termName = computed(() => (nowYearTerm.value?.term === 2 ? "第二学期" : "第一学期"));
const yearTermText = computed(() => (nowYearTerm.value ? `${nowYearTerm.value.year} · ${termName.value}` : "—"));
const yearTermShort = computed(() => (nowYearTerm.value ? `${nowYearTerm.value.year}学年${termName.value}` : ""));
/** 教学日列下标(0~6 → 周一~周日) */
@@ -283,13 +345,11 @@ const bandLegend = computed(() =>
.map((k) => ({ key: k, text: PERIODS[k].label }))
);
const filledCount = computed(() => displaySlots.value.reduce((n, s) => n + s.subjects.filter(Boolean).length, 0));
const photoUrl = computed(() => (photo.value ? filePreviewUrl(photo.value.photoFileId) : ""));
/** 展示节次:节次/时间取自模板行,学科按节次序号对齐课表数据(任教视角再滤一次非任教科目) */
/** 展示节次:节次/时间取自模板行,学科按节次序号对齐课表数据(任教视图再滤一次非任教科目) */
const displaySlots = computed<SlotRow[]>(() => {
const mySet = new Set((timetable.value?.mySubjects || []).map((s) => s.trim()));
const mySet = new Set(restrictSubjects.value.map((s) => s.trim()));
const restrict = !viewAll.value;
return tplRows.value
.filter((r) => r.rowType === "period")
@@ -362,7 +422,10 @@ function buildRows(source: TemplateRowVo[]): RenderRow[] {
return out;
}
const renderRows = computed<RenderRow[]>(() => buildRows(tplRows.value));
/** 课表视图行:跟随生效模板的「显示全部/只显示节次」偏好,隐藏空白行后同时段节次并为一组 */
const renderRows = computed<RenderRow[]>(() =>
showBreakRows.value ? buildRows(tplRows.value) : buildRows(tplRows.value.filter((r) => r.rowType !== "break"))
);
/* ============================================================
* 工具函数
@@ -427,12 +490,6 @@ function isLongRemark(remark: string): boolean {
return (remark || "").length > REMARK_INLINE_MAX;
}
function stamp(): string {
const t = new Date();
const z = (n: number) => String(n).padStart(2, "0");
return `${t.getFullYear()}-${z(t.getMonth() + 1)}-${z(t.getDate())} ${z(t.getHours())}:${z(t.getMinutes())}`;
}
/* ============================================================
* 数据加载
* ============================================================ */
@@ -447,9 +504,21 @@ async function loadYearTerm() {
async function loadClasses() {
try {
classes.value = (await syllabusApi.myClasses().send()) || [];
const vo: MyClassesVo | null = await syllabusApi.myClasses().send();
classes.value = vo?.classes || [];
isAdmin.value = !!vo?.admin;
// 默认身份视图按 管理员 > 班主任 > 任教老师 优先级取
viewMode.value = isAdmin.value
? "admin"
: classes.value.some((c) => c.headTeacher)
? "head"
: classes.value.some((c) => (c.subjects || []).length > 0)
? "teacher"
: null;
} catch (e: any) {
classes.value = [];
isAdmin.value = false;
viewMode.value = null;
console.error("获取可查看班级失败", e);
Message.error("获取可查看班级失败,请刷新重试");
}
@@ -474,10 +543,21 @@ async function loadTimetable() {
if (vo?.rows?.length) {
tplRows.value = vo.rows;
teachDaysStr.value = vo.teachDays || "1111111";
showBreakRows.value = vo.showBreakRows !== false;
showPeriodName.value = vo.showPeriodName !== false;
} else {
tplRows.value = defaultTemplateRows();
teachDaysStr.value = "1111111";
showBreakRows.value = true;
showPeriodName.value = true;
}
// 年级设置随生效设置带出;默认视图取开放的那个(都开放则电子优先)
gradeFlags.value = {
gradeEnabled: vo?.gradeEnabled !== false,
useElectronic: vo?.useElectronic !== false,
usePhoto: vo?.usePhoto !== false,
};
view.value = !useElectronic.value && usePhoto.value ? "photo" : "electronic";
return vo;
})
.catch((e) => {
@@ -485,6 +565,10 @@ async function loadTimetable() {
bands.value = { ...DEFAULT_BANDS };
tplRows.value = defaultTemplateRows();
teachDaysStr.value = "1111111";
showBreakRows.value = true;
showPeriodName.value = true;
gradeFlags.value = { gradeEnabled: true, useElectronic: true, usePhoto: true };
view.value = "electronic";
return null;
}),
syllabusApi
@@ -515,11 +599,24 @@ async function selectClass(classId: number) {
await loadTimetable();
}
/** 切换身份视图:当前班级不在新视图范围内时自动选中该视图首个班级并重取课表 */
async function selectView(mode: ViewMode) {
if (viewMode.value === mode) return;
viewMode.value = mode;
if (!viewClasses.value.some((c) => c.classId === activeId.value)) {
const first = viewClasses.value[0];
if (first) {
activeId.value = first.classId;
await loadTimetable();
}
}
}
/* ============================================================
* 打印
* ============================================================ */
function print() {
if (!activeId.value) return;
if (!activeId.value || view.value !== "electronic") return;
document.body.classList.add("mt-printing");
window.print();
}
@@ -528,27 +625,25 @@ function afterPrint() {
document.body.classList.remove("mt-printing");
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") photoOpen.value = false;
}
/* ============================================================
* 生命周期
* ============================================================ */
onMounted(async () => {
document.addEventListener("keydown", onKeydown);
window.addEventListener("afterprint", afterPrint);
await Promise.all([loadYearTerm(), loadClasses()]);
if (!classes.value.length) {
Message.warning("暂无可查看的班级,请确认角色、任教关系或学籍班级已配置");
return;
}
activeId.value = classes.value[0].classId;
// 默认视图(管理员>班主任>任教)下的首个班级
const first = viewClasses.value[0];
if (first) {
activeId.value = first.classId;
await loadTimetable();
}
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("afterprint", afterPrint);
});
</script>
@@ -601,57 +696,6 @@ onBeforeUnmount(() => {
font-size: inherit;
color: inherit;
}
.num {
font-variant-numeric: tabular-nums;
}
/* ==================== 页头 ==================== */
.mt-pagehead {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
padding-bottom: 20px;
}
.mt-h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--ink-900);
letter-spacing: -0.2px;
}
.mt-sub {
margin: 4px 0 0;
font-size: 13px;
color: var(--ink-400);
}
.mt-stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.mt-stat {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 92px;
padding: 8px 16px;
background: var(--bg-surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: 0 1px 2px rgba(34, 38, 46, 0.05);
b {
font-size: 18px;
font-weight: 600;
color: var(--ink-900);
line-height: 1.3;
}
span {
font-size: 12px;
color: var(--ink-400);
}
}
/* ==================== 主卡片 + 工具条 ==================== */
.mt-card {
@@ -734,6 +778,69 @@ onBeforeUnmount(() => {
gap: 8px;
margin-left: auto;
}
/* 身份视图切换(管理员/班主任/任教老师多身份时展示;决定下方班级 tab 范围) */
.mt-idtabs {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px;
border: 1px solid var(--line-soft);
border-radius: 8px;
background: var(--bg-subtle);
button {
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 6px;
background: transparent;
font-size: 13px;
color: var(--ink-600);
cursor: pointer;
white-space: nowrap;
transition: all 200ms var(--ease);
&:hover {
color: var(--brand-600);
background: var(--brand-50);
}
&.on {
background: var(--brand);
color: #fff;
font-weight: 500;
box-shadow: 0 1px 6px rgba(89, 121, 234, 0.32);
}
}
}
/* 电子课表 / 照片课表 版本切换(年级设置同时勾选两个版本时展示) */
.mt-viewtabs {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px;
border: 1px solid var(--line-soft);
border-radius: 8px;
background: var(--bg-subtle);
button {
height: 28px;
padding: 0 14px;
border: 0;
border-radius: 6px;
background: transparent;
font-size: 12.5px;
color: var(--ink-600);
cursor: pointer;
white-space: nowrap;
transition: all 200ms var(--ease);
&:hover {
color: var(--brand-600);
}
&.on {
background: var(--brand);
color: #fff;
font-weight: 500;
box-shadow: 0 1px 6px rgba(89, 121, 234, 0.32);
}
}
}
.mt-btn {
display: inline-flex;
align-items: center;
@@ -807,6 +914,12 @@ onBeforeUnmount(() => {
caption-side: top;
text-align: left;
padding: 0 2px 16px;
.cap-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 12px;
}
.t {
font-size: 19px;
font-weight: 600;
@@ -814,7 +927,16 @@ onBeforeUnmount(() => {
letter-spacing: 0.5px;
}
.s {
margin-left: 12px;
font-size: 12px;
color: var(--ink-400);
}
/* 视图范围徽标 + 时段图例:同行靠右 */
.cap-legend {
margin-left: auto;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 12px;
font-size: 12px;
color: var(--ink-400);
}
@@ -827,20 +949,27 @@ onBeforeUnmount(() => {
background: #eef1f8;
border-radius: var(--r-md);
&.h-empty {
background: linear-gradient(135deg, #eef1f8 0 50%, #fdf3d9 50% 100%);
/* 斜角分色沿主对角线:右上半(星期侧)同星期表头浅蓝,左下半(时段侧)同米黄 */
background: linear-gradient(45deg, #fdf3d9 0 50%, #eef1f8 50% 100%);
position: relative;
min-width: 52px;
.a {
position: absolute;
left: 8px;
bottom: 6px;
color: var(--ink-900);
left: 7px;
bottom: 5px;
font-size: 11px;
line-height: 1;
/* 时段落在黄色半区,用时间列的字色 */
color: #8a6105;
}
.b {
position: absolute;
right: 8px;
top: 6px;
color: #8a6105;
right: 7px;
top: 5px;
font-size: 11px;
line-height: 1;
/* 星期落在浅蓝半区,用星期表头的字色 */
color: var(--ink-900);
}
}
&.h-time {
@@ -853,12 +982,14 @@ onBeforeUnmount(() => {
}
}
td {
padding: 10px 6px;
padding: 5px 3px;
text-align: center;
font-size: 13px;
background: #fbfcfe;
border-radius: var(--r-md);
color: var(--ink-600);
/* 单元格被撑高时文字垂直居中(全局 reset 把 td 设成 baseline) */
vertical-align: middle;
&.rail {
background: var(--c-bg);
color: var(--c-fg);
@@ -893,7 +1024,7 @@ onBeforeUnmount(() => {
color: var(--ink-400);
font-size: 12px;
text-align: center;
padding: 6px 12px;
padding: 2px 4px;
.tag {
display: inline-flex;
align-items: center;
@@ -942,7 +1073,7 @@ onBeforeUnmount(() => {
}
}
/* 表格下方提示条(科目范围 / 时段图例) */
/* 表格下方提示条(任教科目范围) */
.mt-hintbar {
display: flex;
align-items: center;
@@ -951,6 +1082,12 @@ onBeforeUnmount(() => {
padding: 12px 4px 0;
font-size: 12px;
color: var(--ink-400);
.mt-scope-note {
font-size: 12px;
color: var(--ink-400);
}
}
/* 视图范围徽标(课表标题行 / 任教提示条共用) */
.mt-scope-chip {
display: inline-flex;
align-items: center;
@@ -976,93 +1113,34 @@ onBeforeUnmount(() => {
background: var(--ok);
}
}
.mt-scope-note {
font-size: 12px;
color: var(--ink-400);
}
/* 时段色标图例 */
.rule {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--ink-400);
i {
width: 9px;
height: 9px;
border-radius: 3px;
}
}
}
/* ==================== 照片课表弹窗 ==================== */
.mt-overlay {
position: fixed;
inset: 0;
z-index: 1000;
/* ==================== 照片课表视图 ==================== */
.mt-photo-pane {
flex: 1;
min-height: 180px;
display: grid;
place-items: center;
padding: 24px;
background: rgba(23, 27, 35, 0.45);
backdrop-filter: blur(2px);
}
.mt-modal {
display: flex;
flex-direction: column;
width: min(920px, 100%);
max-height: calc(100vh - 80px);
background: var(--bg-surface);
border-radius: var(--r-xl);
box-shadow: 0 18px 50px rgba(23, 27, 35, 0.28);
overflow: hidden;
}
.mt-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--line-soft);
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--ink-900);
}
.x {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--ink-400);
cursor: pointer;
&:hover {
background: var(--bg-subtle);
color: var(--ink-900);
}
}
}
.mt-modal-body {
padding: 16px 20px;
overflow: auto;
}
.mt-modal-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--line-soft);
background: var(--bg-subtle);
}
.mt-photo-view {
display: grid;
place-items: center;
padding: 16px;
padding: 8px 4px;
img {
max-width: 100%;
max-height: calc(100vh - 300px);
max-height: calc(100vh - 260px);
object-fit: contain;
border-radius: 6px;
box-shadow: 0 4px 18px rgba(23, 27, 35, 0.12);
}
}
</style>
File diff suppressed because it is too large Load Diff
-1
View File
@@ -6,7 +6,6 @@ interface ImportMetaEnv {
readonly VITE_SERVICE_HOST: string;
readonly VITE_ZIYUANKU_HOST: string;
readonly VITE_ZHUANXIANG_HOST: string;
readonly VITE_IS_MOCK: string;
}
interface ImportMeta {