@@ -0,0 +1,870 @@
|
||||
<template>
|
||||
<div class="ov-page">
|
||||
<!-- 页头:标题 + 统计 -->
|
||||
<div class="ov-pagehead">
|
||||
<div>
|
||||
<h1 class="ov-h1">排课情况</h1>
|
||||
<p class="ov-sub">按班级汇总电子课表与照片课表的编排情况,点击卡片进入该班编排。</p>
|
||||
</div>
|
||||
<div class="ov-head-tools">
|
||||
<div class="ov-stats">
|
||||
<div class="ov-stat"><b class="num">{{ rows.length }}</b><span>班级总数</span></div>
|
||||
<div class="ov-stat"><b class="num">{{ electronicCount }}</b><span>电子版已设置</span></div>
|
||||
<div class="ov-stat"><b class="num">{{ photoCount }}</b><span>照片版已上传</span></div>
|
||||
<div v-if="yearTermText !== '—'" class="ov-stat"><b>{{ yearTermText }}</b><span>当前学年学期</span></div>
|
||||
</div>
|
||||
<button class="ov-btn ghost" type="button" @click="goEdit">返回编排</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="ov-card">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="ov-state">
|
||||
<span class="spinner"></span>正在获取排课情况…
|
||||
</div>
|
||||
<!-- 加载失败 -->
|
||||
<div v-else-if="loadError" class="ov-state">
|
||||
<b>{{ loadError }}</b>
|
||||
<button class="ov-btn ghost" type="button" @click="loadAll">重新加载</button>
|
||||
</div>
|
||||
<!-- 无班级 -->
|
||||
<div v-else-if="!rows.length" class="ov-state">
|
||||
<b>暂无班级数据</b>
|
||||
<span>请确认班级服务可用后刷新重试。</span>
|
||||
</div>
|
||||
|
||||
<!-- 年级 Tab 切换 + 当前年级的班级卡片 -->
|
||||
<div v-else class="ov-scroll">
|
||||
<div v-if="groups.length > 1" class="ov-tabs" role="tablist" aria-label="按年级切换">
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.name"
|
||||
type="button"
|
||||
class="ov-tab"
|
||||
role="tab"
|
||||
:class="{ on: activeGrade === group.name }"
|
||||
:aria-selected="activeGrade === group.name"
|
||||
@click="activeGrade = group.name"
|
||||
>
|
||||
{{ group.name || "未分年级" }}
|
||||
<span class="tab-cnt num">{{ group.items.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="activeGroup" class="ov-group">
|
||||
<header class="ov-group-head">
|
||||
<!-- 年级统一设置:启用 + 使用电子版/照片版(作用于「我的课表」电子/照片视图切换) -->
|
||||
<span v-if="activeGroup.name" class="ov-grade-set" :class="{ busy: savingGrade === activeGroup.name }">
|
||||
<label
|
||||
class="ov-switch"
|
||||
:class="{ on: cfgOf(activeGroup.name).enabled }"
|
||||
title="统一启用/停用该年级课表查看(作用于「我的课表」)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="cfgOf(activeGroup.name).enabled"
|
||||
:disabled="savingGrade === activeGroup.name"
|
||||
@change="toggleGradeEnabled(activeGroup.name)"
|
||||
/>
|
||||
<span class="track"><span class="thumb"></span></span>
|
||||
{{ cfgOf(activeGroup.name).enabled ? "启用" : "未启用" }}
|
||||
</label>
|
||||
<label
|
||||
class="ov-chk"
|
||||
:class="{ on: cfgOf(activeGroup.name).useElectronic, off: !cfgOf(activeGroup.name).enabled }"
|
||||
title="开放「我的课表」电子课表视图"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="cfgOf(activeGroup.name).useElectronic"
|
||||
:disabled="!cfgOf(activeGroup.name).enabled || savingGrade === activeGroup.name"
|
||||
@change="toggleGradeFlag(activeGroup.name, 'useElectronic')"
|
||||
/>
|
||||
使用电子版
|
||||
</label>
|
||||
<label
|
||||
class="ov-chk"
|
||||
:class="{ on: cfgOf(activeGroup.name).usePhoto, off: !cfgOf(activeGroup.name).enabled }"
|
||||
title="开放「我的课表」照片课表视图"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="cfgOf(activeGroup.name).usePhoto"
|
||||
:disabled="!cfgOf(activeGroup.name).enabled || savingGrade === activeGroup.name"
|
||||
@change="toggleGradeFlag(activeGroup.name, 'usePhoto')"
|
||||
/>
|
||||
使用照片版
|
||||
</label>
|
||||
</span>
|
||||
<span class="ov-group-head-right">
|
||||
<button
|
||||
class="ov-dl"
|
||||
type="button"
|
||||
:class="{ busy: downloadingGrade === activeGroup.name }"
|
||||
:disabled="!activeGroup.items.some((c) => c.photoSet)"
|
||||
:title="activeGroup.items.some((c) => c.photoSet)
|
||||
? `将「${activeGroup.name || '未分年级'}」已上传的照片课表打包为 ZIP 下载`
|
||||
: '该年级暂无已上传的照片课表'"
|
||||
@click="downloadGradeZip"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11" /><path d="M7 11l5 5 5-5" /><path d="M4 20h16" /></svg>
|
||||
{{ downloadingGrade === activeGroup.name ? "打包中…" : "图片打包下载" }}
|
||||
</button>
|
||||
<span class="mini num">电子版 {{ activeGroup.items.filter((c) => c.electronicSet).length }} · 照片版 {{ activeGroup.items.filter((c) => c.photoSet).length }}</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="ov-grid">
|
||||
<article
|
||||
v-for="item in activeGroup.items"
|
||||
:key="item.classId"
|
||||
class="ov-card-item"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
:aria-label="fullClassName(item) + ',进入班级编排'"
|
||||
@click="goEdit(item.classId)"
|
||||
@keydown.enter.prevent="goEdit(item.classId)"
|
||||
@keydown.space.prevent="goEdit(item.classId)"
|
||||
>
|
||||
<div class="ci-head">
|
||||
<div class="ci-title">
|
||||
<span class="name">{{ item.className }}</span>
|
||||
</div>
|
||||
<span class="chip" :class="item.electronicSet ? 'ok' : 'off'">电子版:{{ item.electronicSet ? "已设置" : "未设置" }}</span>
|
||||
<span class="chip" :class="item.photoSet ? 'ok' : 'off'">照片版:{{ item.photoSet ? "已上传" : "未上传" }}</span>
|
||||
</div>
|
||||
<div class="ci-body">
|
||||
<div class="ci-pane">
|
||||
<div class="pane-title">电子版</div>
|
||||
<template v-if="item.electronicSet">
|
||||
<div class="pane-line">已排课程</div>
|
||||
<div class="pane-stats num">
|
||||
<span>节次:<b>{{ item.periodCount }}</b> 节</span>
|
||||
<span>科目:<b>{{ item.subjectCount }}</b> 门</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="pane-empty">尚未编排电子课表</div>
|
||||
</div>
|
||||
<div class="ci-pane">
|
||||
<div class="pane-title">照片版</div>
|
||||
<div v-if="item.photoSet && thumbUrl(item)" class="pane-thumb">
|
||||
<img :src="thumbUrl(item)!" :alt="fullClassName(item) + ' 课程表照片'" loading="lazy" @error="onThumbError(item)" />
|
||||
</div>
|
||||
<div v-else class="pane-empty">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h3.5L8 5.5h8L17.5 8H21v11H3z" /><circle cx="12" cy="13.5" r="3.5" /></svg>
|
||||
尚未上传照片
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-foot">点击进入班级编排
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { filePreviewUrl, gradePhotoZipUrl, syllabusApi } from "@/api/module/syllabus";
|
||||
import type { GradeSettingVo, NowYearTermVo, TimetableOverviewVo } from "@/types/syllabus";
|
||||
|
||||
/* ============================================================
|
||||
* 状态
|
||||
* ============================================================ */
|
||||
const rows = ref<TimetableOverviewVo[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const nowYearTerm = ref<NowYearTermVo | null>(null);
|
||||
|
||||
/** 年级设置(启用 + 使用电子版/照片版),按年级名索引;未加载到的年级走默认全启用 */
|
||||
const gradeCfg = ref<Record<string, GradeSettingVo>>({});
|
||||
/** 正在保存年级设置的年级名(保存中禁用该组控件) */
|
||||
const savingGrade = ref("");
|
||||
/** 正在打包下载的年级名(按钮转「打包中…」防重复点击) */
|
||||
const downloadingGrade = ref("");
|
||||
|
||||
/** 加载失败 / 出错后重试前清空重取的附件地址缓存 */
|
||||
const thumbTokens = new Map<number, string>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/* ============================================================
|
||||
* 派生
|
||||
* ============================================================ */
|
||||
const termName = computed(() => (nowYearTerm.value?.term === 2 ? "第二学期" : "第一学期"));
|
||||
const yearTermText = computed(() => (nowYearTerm.value ? `${nowYearTerm.value.year} · ${termName.value}` : "—"));
|
||||
const electronicCount = computed(() => rows.value.filter((r) => r.electronicSet).length);
|
||||
const photoCount = computed(() => rows.value.filter((r) => r.photoSet).length);
|
||||
|
||||
/** 接口已按学生树年级→班级顺序返回,这里把相邻同年级班级归为一组 */
|
||||
const groups = computed<Array<{ name: string; items: TimetableOverviewVo[] }>>(() => {
|
||||
const out: Array<{ name: string; items: TimetableOverviewVo[] }> = [];
|
||||
for (const item of rows.value) {
|
||||
const name = item.gradeName || "";
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.name === name) {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
out.push({ name, items: [item] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
/** 当前选中的年级 Tab(年级名,空串表示未分年级) */
|
||||
const activeGrade = ref("");
|
||||
/** 当前年级的分组数据 */
|
||||
const activeGroup = computed(() => groups.value.find((g) => g.name === activeGrade.value) || null);
|
||||
|
||||
function fullClassName(item: TimetableOverviewVo): string {
|
||||
return (item.gradeName || "") + (item.className || "");
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 年级设置(启用 + 使用电子版/照片版;即改即存)
|
||||
* ============================================================ */
|
||||
function cfgOf(name: string): GradeSettingVo {
|
||||
return (
|
||||
gradeCfg.value[name] || { gradeName: name, enabled: true, useElectronic: true, usePhoto: true }
|
||||
);
|
||||
}
|
||||
|
||||
async function persistGrade(name: string, next: GradeSettingVo) {
|
||||
gradeCfg.value = { ...gradeCfg.value, [name]: next };
|
||||
savingGrade.value = name;
|
||||
try {
|
||||
await syllabusApi.saveGradeSettings([next]);
|
||||
if (next.enabled && !next.useElectronic && !next.usePhoto) {
|
||||
Message.warning(`「${name}」电子版与照片版均未勾选,「我的课表」将不展示该年级课表`);
|
||||
} else {
|
||||
Message.success(`「${name}」年级设置已保存`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存年级设置失败,请重试");
|
||||
console.error("保存年级设置失败", e);
|
||||
} finally {
|
||||
savingGrade.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGradeEnabled(name: string) {
|
||||
const cur = cfgOf(name);
|
||||
void persistGrade(name, { ...cur, enabled: !cur.enabled });
|
||||
}
|
||||
|
||||
function toggleGradeFlag(name: string, key: "useElectronic" | "usePhoto") {
|
||||
const cur = cfgOf(name);
|
||||
void persistGrade(name, { ...cur, [key]: !cur[key] });
|
||||
}
|
||||
|
||||
/** 照片缩略图直链(加载失败过一次的班级换缓存标记绕过浏览器缓存) */
|
||||
function thumbUrl(item: TimetableOverviewVo): string | null {
|
||||
if (!item.photoFileId) return null;
|
||||
const token = thumbTokens.get(item.classId);
|
||||
return filePreviewUrl(item.photoFileId) + (token ? `&t=${token}` : "");
|
||||
}
|
||||
|
||||
function onThumbError(item: TimetableOverviewVo) {
|
||||
thumbTokens.set(item.classId, String(Date.now()));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 操作
|
||||
* ============================================================ */
|
||||
function goEdit(classId?: number) {
|
||||
router.push(classId ? { path: "/timetable", query: { classId } } : "/timetable");
|
||||
}
|
||||
|
||||
/** 年级照片课表打包下载(ZIP;后端取当前学年学期该年级已上传照片,条目按班级命名) */
|
||||
async function downloadGradeZip() {
|
||||
const group = activeGroup.value;
|
||||
if (!group || downloadingGrade.value) return;
|
||||
const withPhoto = group.items.filter((c) => c.photoSet);
|
||||
if (!withPhoto.length) {
|
||||
Message.warning(`「${group.name || "未分年级"}」暂无已上传的照片课表`);
|
||||
return;
|
||||
}
|
||||
downloadingGrade.value = group.name;
|
||||
try {
|
||||
const res = await fetch(gradePhotoZipUrl(group.name), { credentials: "include" });
|
||||
// 业务异常(如附件缺失)返回 JSON,正常返回 ZIP 流
|
||||
if ((res.headers.get("content-type") || "").includes("application/json")) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.msg || "打包下载失败,请稍后重试");
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error("打包下载失败,请稍后重试");
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${group.name || "未分年级"}照片课表_${yearTermText.value.replace(" · ", "")}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
Message.success(`已打包 ${withPhoto.length} 个班级的照片课表`);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "打包下载失败,请稍后重试");
|
||||
console.error("年级照片课表打包下载失败", e);
|
||||
} finally {
|
||||
downloadingGrade.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const [overview, yearTerm, gradeSettings] = await Promise.all([
|
||||
syllabusApi.overview().send(),
|
||||
syllabusApi.nowYearTerm().send().catch(() => null),
|
||||
// 年级设置加载失败不阻断页面:控件按默认全启用展示,改动会重新保存
|
||||
syllabusApi.gradeSettings().send().catch(() => [] as GradeSettingVo[]),
|
||||
]);
|
||||
rows.value = overview || [];
|
||||
nowYearTerm.value = yearTerm;
|
||||
const map: Record<string, GradeSettingVo> = {};
|
||||
for (const g of gradeSettings || []) {
|
||||
map[g.gradeName] = g;
|
||||
}
|
||||
gradeCfg.value = map;
|
||||
// 默认选中第一个年级;刷新后原年级仍存在则保持选中
|
||||
if (!groups.value.some((g) => g.name === activeGrade.value)) {
|
||||
activeGrade.value = groups.value[0]?.name ?? "";
|
||||
}
|
||||
if (!rows.value.length) {
|
||||
Message.info("未获取到班级数据,请确认班级服务可用");
|
||||
}
|
||||
} catch (e: any) {
|
||||
rows.value = [];
|
||||
loadError.value = "排课情况获取失败,请稍后重试";
|
||||
console.error("获取排课情况失败", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAll);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 设计令牌(与课表编排页同源,$color-primary #5979ea)
|
||||
* ============================================================ */
|
||||
.ov-page {
|
||||
--brand-50: #eef1fd;
|
||||
--brand-100: #dde4fb;
|
||||
--brand-400: #7d93ee;
|
||||
--brand: #5979ea;
|
||||
--brand-600: #4560d4;
|
||||
--ink-900: #22262e;
|
||||
--ink-700: #404042;
|
||||
--ink-600: #5a5f6a;
|
||||
--ink-400: #8c8f99;
|
||||
--ink-300: #a8abaf;
|
||||
--ink-200: #d6d9df;
|
||||
--line: #e9e9e9;
|
||||
--line-soft: #f0f1f3;
|
||||
--bg-page: #f4f6fa;
|
||||
--bg-surface: #fff;
|
||||
--bg-subtle: #f7f8fb;
|
||||
--ok: #3fa86c;
|
||||
--warn: #d99a2b;
|
||||
--err: #e05b45;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-xl: 16px;
|
||||
--ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 24px 40px;
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-700);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow: auto;
|
||||
}
|
||||
.ov-page button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==================== 页头 ==================== */
|
||||
.ov-pagehead {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.ov-h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-900);
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.ov-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ink-400);
|
||||
}
|
||||
.ov-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ov-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ov-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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.ov-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 200ms var(--ease);
|
||||
white-space: nowrap;
|
||||
&.ghost {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--ink-200);
|
||||
color: var(--ink-700);
|
||||
&:hover {
|
||||
border-color: var(--brand-400);
|
||||
color: var(--brand-600);
|
||||
background: var(--brand-50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 主卡片 ==================== */
|
||||
.ov-card {
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-xl);
|
||||
box-shadow: 0 4px 14px rgba(34, 38, 46, 0.07);
|
||||
}
|
||||
.ov-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px 24px;
|
||||
color: var(--ink-400);
|
||||
text-align: center;
|
||||
b {
|
||||
font-size: 15px;
|
||||
color: var(--ink-600);
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid var(--brand-100);
|
||||
border-top-color: var(--brand);
|
||||
animation: ov-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ov-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.ov-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
|
||||
/* ==================== 年级 Tab ==================== */
|
||||
.ov-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ov-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-surface);
|
||||
font-size: 13px;
|
||||
color: var(--ink-600);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 200ms var(--ease);
|
||||
.tab-cnt {
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--line-soft);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: var(--ink-400);
|
||||
}
|
||||
&:hover {
|
||||
border-color: var(--brand-400);
|
||||
color: var(--brand-600);
|
||||
background: var(--brand-50);
|
||||
}
|
||||
&.on {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(89, 121, 234, 0.35);
|
||||
.tab-cnt {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 当前年级分组 ==================== */
|
||||
.ov-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
.mini {
|
||||
font-size: 12px;
|
||||
color: var(--ink-400);
|
||||
}
|
||||
}
|
||||
.ov-group-head-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 年级照片打包下载按钮(小号 ghost) */
|
||||
.ov-dl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ink-200);
|
||||
background: var(--bg-surface);
|
||||
font-size: 12px;
|
||||
color: var(--ink-600);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 200ms var(--ease);
|
||||
svg {
|
||||
flex: none;
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--brand-400);
|
||||
color: var(--brand-600);
|
||||
background: var(--brand-50);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&.busy {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 年级统一设置:启用开关 + 使用电子版/照片版(与课表编排页开关同款) */
|
||||
.ov-grade-set {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: 8px;
|
||||
padding: 3px 12px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-subtle);
|
||||
&.busy {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.ov-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-400);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 17px;
|
||||
padding: 2px;
|
||||
border-radius: 99px;
|
||||
background: #d4d7de;
|
||||
transition: background 200ms var(--ease);
|
||||
}
|
||||
.thumb {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 200ms var(--ease);
|
||||
}
|
||||
&.on {
|
||||
color: var(--brand-600);
|
||||
.track {
|
||||
background: var(--brand);
|
||||
}
|
||||
.thumb {
|
||||
transform: translateX(15px);
|
||||
}
|
||||
}
|
||||
}
|
||||
.ov-chk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-400);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--ink-200);
|
||||
background: #fff;
|
||||
transition: all 160ms var(--ease);
|
||||
}
|
||||
&.on {
|
||||
color: var(--brand-600);
|
||||
font-weight: 500;
|
||||
&::before {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
box-shadow: inset 0 0 0 2.5px #fff;
|
||||
}
|
||||
}
|
||||
/* 年级未启用:两个版本复选框随之失效 */
|
||||
&.off {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 班级卡片 ==================== */
|
||||
.ov-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.ov-card-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 14px 16px 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 200ms var(--ease), box-shadow 200ms var(--ease), transform 200ms var(--ease);
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--brand-400);
|
||||
box-shadow: 0 6px 18px rgba(89, 121, 234, 0.16);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
.ci-foot {
|
||||
color: var(--brand-600);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ci-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.ci-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-right: auto;
|
||||
min-width: 0;
|
||||
.name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-900);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
white-space: nowrap;
|
||||
&.ok {
|
||||
background: #e9f7ef;
|
||||
color: var(--ok);
|
||||
border: 1px solid #c8e9d6;
|
||||
}
|
||||
&.off {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--ink-400);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片正文:电子版 / 照片版两栏 */
|
||||
.ci-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 14px;
|
||||
padding: 12px 0 10px;
|
||||
flex: 1;
|
||||
}
|
||||
.ci-pane {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
& + .ci-pane {
|
||||
padding-left: 14px;
|
||||
border-left: 1px dashed var(--line);
|
||||
}
|
||||
}
|
||||
.pane-title {
|
||||
font-size: 12px;
|
||||
color: var(--ink-400);
|
||||
}
|
||||
.pane-line {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-900);
|
||||
}
|
||||
.pane-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-600);
|
||||
b {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-900);
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
.pane-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
.pane-thumb {
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-subtle);
|
||||
overflow: hidden;
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片底部悬浮提示 */
|
||||
.ci-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed var(--line-soft);
|
||||
font-size: 11px;
|
||||
color: var(--ink-300);
|
||||
opacity: 0.8;
|
||||
transition: color 200ms var(--ease);
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -92,7 +92,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||
},
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: 8079,
|
||||
port: 8078,
|
||||
proxy: {
|
||||
// dev 正式模式:serviceHost 为空(同源相对路径 /api/...),经此代理转发到本地后端
|
||||
// (后端 CAS 过滤器对 /api/* 不回 CORS 头,浏览器直连会被拦,必须同源代理)
|
||||
|
||||
Reference in New Issue
Block a user