feat: 完善学生生命周期管理与权限范围并发布 1.4.2

This commit is contained in:
LoveChengke committed 2026-10-23 22:55:21 +08:00
1 parent 6347d1e5aa
commit 24e8dd902b
92 files changed
+6801 -2255

No files matched your search

+11 -9
View File
@@ -18,7 +18,6 @@ import {
type ClientPushResult,
type ClientReleaseDto,
type CourseDto,
type CreateArchiveRequest,
type CreateCallRequest,
type CreateClassRequest,
type CreateClientCommandRequest,
@@ -131,8 +130,8 @@ export const classApi = {
setGradeQuery: (id: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.patch(`${API_PATHS.classes}/${id}/student-grade-query`, { enabled }),
/** 学年升级:只改年级,不归档、数据沿用 */
promote: (classIds: string[], grade: string): Promise<{ promoted: number }> =>
api.post(`${API_PATHS.classes}/promote`, { classIds, grade }),
promote: (classIds: string[]): Promise<{ promoted: number }> =>
api.post(`${API_PATHS.classes}/promote`, { classIds }),
};
/* ------------------------------------------------------------------ 课程 */
@@ -317,11 +316,16 @@ export const studentApi = {
api.patch(`${API_PATHS.students}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`),
/** 调班:单个与批量同一接口(studentIds 长度 1 即单个);学号不变、历史可查 */
transfer: (studentIds: string[], toClassId: string | null, note?: string): Promise<{ moved: number }> =>
api.post(`${API_PATHS.students}/transfer`, { studentIds, toClassId, note }),
transfer: (
studentIds: string[],
toClassId: string | null,
note?: string,
allowGradeChange?: boolean,
): Promise<{ moved: number }> =>
api.post(`${API_PATHS.students}/transfer`, { studentIds, toClassId, note, allowGradeChange }),
/** 转出(学籍离开本校,不是调班) */
transferOut: (studentIds: string[], note?: string): Promise<{ transferred: number }> =>
api.post(`${API_PATHS.students}/transfer-out`, { studentIds, note }),
transferOut: (studentIds: string[], note?: string, toSchool?: string): Promise<{ transferred: number }> =>
api.post(`${API_PATHS.students}/transfer-out`, { studentIds, note, toSchool }),
/** 某个学生的调班 / 转出历史 */
transfersOf: (id: string): Promise<PaginatedResult<StudentTransferDto>> =>
api.get(`${API_PATHS.students}/${id}/transfers`),
@@ -380,8 +384,6 @@ export const archiveApi = {
/** 归档班级的作业与通知(只读) */
classContent: (id: string, classId: string, limit = 200): Promise<ArchivedClassContentDto> =>
api.get(`${API_PATHS.archives}/${id}/classes/${classId}/content`, { limit }),
create: (payload: CreateArchiveRequest): Promise<ArchivedYearDetailDto> =>
api.post(API_PATHS.archives, payload),
/** 撤销归档(误操作的退路,不删任何数据) */
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.archives}/${id}`),
};
@@ -0,0 +1,52 @@
<script setup lang="ts">
// 档案编辑器采用与参考一致的细线图标,不依赖系统字体图标。
defineProps<{ name: string }>();
const paths: Record<string, string> = {
archive: 'M3 4h12v7H3z M6 11v8h15v-8H6 M10 14h4 M15 4h6v7',
file: 'M6 3h8l4 4v14H6z M14 3v5h4 M9 12h6 M9 15h6 M9 18h4',
chart: 'M6 3h8l4 4v14H6z M14 3v5h4 M9 17v-4 M12 17v-7 M15 17v-5',
group: 'M8 7H5V4 M16 7h3V4 M8 17H5v3 M16 17h3v3 M9 8h6v8H9z M3 11v2 M21 11v2',
save: 'M4 3h13l3 3v15H4z M8 3v6h8V3 M8 21v-8h9v8',
transfer: 'M13 3h5l3 3v12h-7 M18 3v5h3 M4 16h8 M8 12l4 4-4 4 M4 5h5v5H4z',
help: 'M9 7a3 3 0 1 1 5 2c-2 1-2 2-2 4 M12 17v.2',
refresh: 'M20 7v5h-5 M20 12a8 8 0 1 0-2 5 M20 7l-3-3',
left: 'M20 12H4 M10 6l-6 6 6 6',
right: 'M4 12h16 M14 6l6 6-6 6',
plus: 'M12 4v16 M4 12h16',
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M12 7v5l4 2',
grid: 'M4 4h16v16H4z M4 9h16 M4 14h16 M9 4v16 M15 4v16',
calendar: 'M4 5h16v16H4z M4 10h16 M8 3v4 M16 3v4 M8 14h1 M12 14h1 M16 14h1 M8 17h1 M12 17h1',
list: 'M8 5h13 M8 12h13 M8 19h13 M3 4h1v2H3z M3 11h1v2H3z M3 18h1v2H3z',
settings:
'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M10 3h4l1 3 3 1 3 3v4l-3 1-1 3-3 3h-4l-1-3-3-1-3-3v-4l3-1 1-3z',
close: 'M5 5l14 14 M19 5L5 19',
shield: 'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6',
back: 'M11 5l-7 7 7 7 M4 12h13a3 3 0 0 1 3 3v3',
chevron: 'M6 9l6 6 6-6',
};
</script>
<template>
<svg
class="archive-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="paths[name] || paths.file" />
</svg>
</template>
<style scoped>
.archive-icon {
display: inline-block;
flex: 0 0 auto;
width: 22px;
height: 22px;
vertical-align: middle;
}
</style>
@@ -1,77 +1,297 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { type PromotionPlan } from '@classhelper/shared';
import { api } from '@/api/http';
type Status = {
config: { month: number; day: number; hour: number; classCount: number };
type Run = {
year: number;
pending: boolean;
runs: {
year: number;
status: string;
message: string;
classCount: number;
studentCount: number;
snapshotPath: string;
status: string;
message: string;
classCount: number;
studentCount: number;
snapshotPath: string;
snapshotSha256: string;
operatorName: string;
detailsJson: string;
error: string;
};
type Preview = {
year: number;
planHash: string;
classCount: number;
studentCount: number;
blockers: { code: string; message: string }[];
warnings: string[];
termWeekDeleteCount: number;
termStartDate: string;
tracks: {
id: string;
name: string;
classes: {
id: string;
name: string;
grade: string;
nextGrade: string;
active: number;
inactive: number;
transferred: number;
}[];
newClasses: { name: string; grade: string; code: string }[];
}[];
};
type Status = {
config: { month: number; day: number; hour: number; classCount: number };
plan: PromotionPlan;
year: number;
pending: boolean;
runs: Run[];
};
const status = ref<Status>();
const preview = ref<Preview>();
const year = ref(2026);
const visible = ref(false),
saving = ref(false),
running = ref(false),
loading = ref(false);
async function load() {
status.value = await api.get('/term/promotion');
year.value = status.value!.year;
}
function normalizeStages() {
for (const track of status.value?.plan.tracks ?? [])
track.stages.forEach((stage, i) => {
stage.terminal = i === track.stages.length - 1;
});
}
function addTrack() {
status.value?.plan.tracks.push({
id: `track-${Date.now().toString(36)}`,
name: '新轨道',
entryClassCount: 0,
stages: [{ grade: '', terminal: true }],
});
}
function moveStage(track: PromotionPlan['tracks'][number], index: number, direction: number) {
const next = index + direction;
if (next < 0 || next >= track.stages.length) return;
[track.stages[index], track.stages[next]] = [track.stages[next], track.stages[index]];
normalizeStages();
}
async function save() {
if (status.value) await api.put('/term/promotion', status.value.config);
await load();
}
async function catchUp() {
if (!status.value) return;
const preview = await api.post<{ classCount: number; studentCount: number }>('/term/promotion/run', {
year: status.value.year,
dryRun: true,
});
await ElMessageBox.confirm(
`将影响 ${preview.classCount} 个班、${preview.studentCount} 名学生。执行前自动保存全库快照;高三归档,其余升一级,新建高一骨架。`,
'补跑学年升级',
{ type: 'warning' },
normalizeStages();
saving.value = true;
try {
await api.put('/term/promotion', { ...status.value.config, plan: status.value.plan });
preview.value = undefined;
await load();
ElMessage.success('升级计划已保存');
} finally {
saving.value = false;
}
}
async function showPreview() {
loading.value = true;
try {
preview.value = await api.post('/term/promotion/preview', { year: year.value });
visible.value = true;
} finally {
loading.value = false;
}
}
async function execute() {
if (!preview.value || running.value) return;
const { year: target, planHash } = preview.value;
await ElMessageBox.prompt(
`请输入 ${target} 确认执行。快照可供手工恢复;撤销归档不会回滚年级、新班或周次。`,
'确认学年升级',
{
inputValidator: (value) => value === String(target) || '输入必须与预览学年一致',
confirmButtonText: '执行升级',
type: 'warning',
},
);
await api.post('/term/promotion/run', { year: status.value.year, dryRun: false });
await load();
running.value = true;
try {
await api.post('/term/promotion/run', { year: target, planHash, confirm: true, catchUp: true });
visible.value = false;
await load();
ElMessage.success('学年升级完成,快照与执行明细已保留');
} finally {
running.value = false;
}
}
onMounted(load);
</script>
<template>
<el-card v-if="status">
<h3>每年自动学年升级</h3>
<el-form inline>
<el-form-item label="月">
<el-input-number v-model="status.config.month" :min="1" :max="12" />
</el-form-item>
<el-form-item label="日">
<el-input-number v-model="status.config.day" :min="1" :max="31" />
</el-form-item>
<el-form-item label="时">
<el-input-number v-model="status.config.hour" :min="0" :max="23" />
</el-form-item>
<el-form-item label="新高一班级数">
<el-input-number v-model="status.config.classCount" :min="1" :max="99" />
</el-form-item>
<el-button @click="save">保存升级时间</el-button>
</el-form>
<p>新学年开学日重置为 9 月 1 日,保留总周数上限;新班主任与任课关系由管理员指定。</p>
<el-card v-if="status" shadow="never">
<h3>学年升级计划</h3>
<el-alert
v-if="status.pending"
title="有尚未执行的学年升级,请预览影响后补跑"
type="warning"
title="先保存计划,再预览影响。执行前保存全库快照;恢复整学年需要管理员手工恢复快照。"
type="info"
:closable="false"
/>
<el-button v-if="status.pending" @click="catchUp">预览并补跑 {{ status.year }} 学年</el-button>
<el-form label-position="top" :disabled="saving || running">
<div class="toolbar">
<el-form-item label="执行月份">
<el-input-number v-model="status.config.month" :min="1" :max="12" />
</el-form-item>
<el-form-item label="执行日期">
<el-input-number v-model="status.config.day" :min="1" :max="31" />
</el-form-item>
<el-form-item label="执行小时">
<el-input-number v-model="status.config.hour" :min="0" :max="23" />
</el-form-item>
<el-form-item label="新学期开学日(空则当年 9 月 1 日)">
<el-date-picker
v-model="status.plan.termStartDate"
type="date"
value-format="YYYY-MM-DD"
clearable
/>
</el-form-item>
<el-form-item label="周次区间处理">
<el-switch
v-model="status.plan.resetTermWeeks"
active-text="重置全校及班级周次"
inactive-text="保留原周次"
/>
</el-form-item>
</div>
<el-card v-for="(track, ti) in status.plan.tracks" :key="track.id" shadow="never" class="track-card">
<div class="toolbar">
<el-form-item label="轨道名称"><el-input v-model="track.name" /></el-form-item>
<el-form-item label="起始年级新班数">
<el-input-number v-model="track.entryClassCount" :min="0" :max="99" />
</el-form-item>
<el-button
:disabled="status.plan.tracks.length === 1"
type="danger"
plain
@click="status.plan.tracks.splice(ti, 1)"
>
删除轨道
</el-button>
</div>
<div v-for="(stage, si) in track.stages" :key="si" class="toolbar stage-row">
<el-form-item :label="`第 ${si + 1} 学段`">
<el-input v-model="stage.grade" placeholder="填写年级名称" />
</el-form-item>
<el-tag v-if="si === track.stages.length - 1" type="warning">毕业学段</el-tag>
<el-button :disabled="si === 0" @click="moveStage(track, si, -1)">上移</el-button>
<el-button :disabled="si === track.stages.length - 1" @click="moveStage(track, si, 1)">
下移
</el-button>
<el-button
:disabled="track.stages.length === 1"
@click="
track.stages.splice(si, 1);
normalizeStages();
"
>
移除学段
</el-button>
</div>
<el-button
@click="
track.stages.push({ grade: '', terminal: true });
normalizeStages();
"
>
添加学段
</el-button>
</el-card>
<div class="toolbar">
<el-button @click="addTrack">添加轨道</el-button>
<el-button type="primary" :loading="saving" @click="save">保存计划</el-button>
</div>
</el-form>
<div class="toolbar preview-toolbar">
<el-form-item label="预览学年">
<el-input-number v-model="year" :min="2000" :max="status.year" />
</el-form-item>
<el-button :loading="loading" @click="showPreview">预览已保存计划</el-button>
<el-tag :type="status.pending ? 'warning' : 'success'">
{{ status.pending ? `待执行 ${status.year} 学年` : '应执行学年已完成' }}
</el-tag>
</div>
<h4>执行记录</h4>
<el-table :data="status.runs">
<el-table-column prop="year" label="学年" />
<el-table-column prop="status" label="结果" />
<el-table-column prop="classCount" label="班级数" />
<el-table-column prop="studentCount" label="学生数" />
<el-table-column prop="message" label="执行记录" />
<el-table-column prop="snapshotPath" label="执行前快照" />
<el-table-column prop="year" label="学年" width="90" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="operatorName" label="操作人" min-width="130" />
<el-table-column prop="classCount" label="班级" width="80" />
<el-table-column prop="studentCount" label="在读学生" width="100" />
<el-table-column label="记录与快照" min-width="350">
<template #default="{ row }">
<details>
<summary>{{ row.error || row.message }}</summary>
<p class="digest">
快照:{{ row.snapshotPath }}
<br />
SHA256:{{ row.snapshotSha256 }}
</p>
<pre>{{ row.detailsJson }}</pre>
</details>
</template>
</el-table-column>
</el-table>
<el-drawer v-model="visible" title="学年升级影响预览" size="90%" :close-on-click-modal="!running">
<template v-if="preview">
<p>
{{ preview.year }} 学年 · {{ preview.classCount }} 个班 · {{ preview.studentCount }} 名在读学生 ·
开学日 {{ preview.termStartDate }} · 删除周次 {{ preview.termWeekDeleteCount }} 条
</p>
<el-alert
v-for="block in preview.blockers"
:key="block.message"
:title="block.message"
type="error"
:closable="false"
/>
<el-alert
v-for="warning in preview.warnings"
:key="warning"
:title="warning"
type="warning"
:closable="false"
/>
<section v-for="track in preview.tracks" :key="track.id">
<h4>{{ track.name }}</h4>
<el-table :data="track.classes">
<el-table-column prop="name" label="班级" min-width="150" />
<el-table-column prop="grade" label="当前年级" />
<el-table-column prop="nextGrade" label="处理结果" />
<el-table-column prop="active" label="在读" />
<el-table-column prop="inactive" label="停用" />
<el-table-column prop="transferred" label="转出" />
</el-table>
<el-table :data="track.newClasses" empty-text="不新建班级">
<el-table-column prop="name" label="新班" />
<el-table-column prop="grade" label="年级" />
<el-table-column prop="code" label="班级码" />
</el-table>
</section>
<p class="digest">计划哈希:{{ preview.planHash }}</p>
<el-button type="danger" :disabled="preview.blockers.length > 0" :loading="running" @click="execute">
确认学年并执行
</el-button>
</template>
</el-drawer>
</el-card>
</template>
<style scoped>
.track-card,
.preview-toolbar {
margin-top: 16px;
margin-bottom: 16px;
}
.stage-row {
margin-bottom: 8px;
}
.digest,
pre {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
</style>
@@ -2,30 +2,77 @@
import { onMounted, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { api } from '@/api/http';
import { classApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import type { ClassDto } from '@classhelper/shared';
const auth = useAuthStore();
const students = ref<
{ id: string; studentNo: string; name: string; transferNote: string; class: { name: string } | null }[]
>([]);
const classes = ref<ClassDto[]>([]),
target = ref(''),
restoring = ref(''),
visible = ref(false),
saving = ref(false);
async function load() {
students.value = await api.get('/archives/transferred');
}
async function restore(id: string) {
await ElMessageBox.confirm('撤回后学生恢复到原班,转出历史仍保留。', '撤回转出');
await api.post(`/archives/transferred/${id}/restore`);
await load();
async function openRestore(id: string) {
restoring.value = id;
target.value = '';
classes.value = await classApi.list();
visible.value = true;
}
async function restore() {
await ElMessageBox.confirm(
'恢复到所选在读班级;原转出历史保留。原班已归档时必须指定其他在读班。',
'撤回转出',
);
saving.value = true;
try {
await api.post(
`/archives/transferred/${restoring.value}/restore`,
target.value ? { toClassId: target.value } : {},
);
visible.value = false;
await load();
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<el-card>
<el-card shadow="never">
<h3>已转出</h3>
<el-table :data="students">
<el-table-column prop="studentNo" label="学号" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="class.name" label="原班级" />
<el-table-column prop="transferNote" label="转出说明" />
<el-table-column label="操作">
<template #default="{ row }"><el-button link @click="restore(row.id)">撤回转出</el-button></template>
<el-table-column v-if="auth.can('roster:manage')" label="操作">
<template #default="{ row }">
<el-button link @click="openRestore(row.id)">撤回转出</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="visible" title="撤回转出" width="min(92%, 480px)">
<el-form label-position="top">
<el-form-item label="目标在读班级(留空使用原班)">
<el-select v-model="target" clearable>
<el-option
v-for="cls in classes"
:key="cls.id"
:value="cls.id"
:label="`${cls.name} / ${cls.grade}`"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="restore">确认撤回</el-button>
</template>
</el-dialog>
</el-card>
</template>
+1 -1
View File
@@ -11,7 +11,7 @@ export const MANAGEMENT_GROUPS = [
id: 'school',
title: '班级与人员',
icon: 'School',
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/archives'],
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/promotion', '/archives'],
},
{ id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] },
{
+10 -4
View File
@@ -58,13 +58,13 @@ const routes: RouteRecordRaw[] = [
path: 'classes',
name: 'classes',
component: () => import('@/views/ClassesView.vue'),
meta: { title: '班级管理', icon: 'School', roles: ['ADMIN'] },
meta: { title: '班级管理', icon: 'School', permission: 'class:read' },
},
{
path: 'students',
name: 'students',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生管理', icon: 'User', roles: ['ADMIN'] },
meta: { title: '学生管理', icon: 'User', permission: 'roster:read' },
},
{
path: 'teachers',
@@ -77,14 +77,20 @@ const routes: RouteRecordRaw[] = [
name: 'term-weeks',
component: () => import('@/views/TermWeeksView.vue'),
// 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限
meta: { title: '学期周次', icon: 'Calendar', roles: ['ADMIN'] },
meta: { title: '学期周次', icon: 'Calendar', permission: 'term:read' },
},
{
path: 'promotion',
name: 'promotion',
component: () => import('@/components/PromotionSettings.vue'),
meta: { title: '学年升级', icon: 'Calendar', permission: 'promotion:manage' },
},
{
path: 'archives',
name: 'archives',
component: () => import('@/views/ArchivesView.vue'),
// 毕业归档会改动整届班级与学生的状态,与「班级管理」同一档权限
meta: { title: '毕业归档', icon: 'FolderOpened', roles: ['ADMIN'] },
meta: { title: '毕业归档', icon: 'FolderOpened', permission: 'archive:manage' },
},
{
path: 'schedules',
+13 -1
View File
@@ -85,7 +85,7 @@ async function openContent(row: ArchivedClassDto): Promise<void> {
async function undo(row: ArchivedYearDto): Promise<void> {
await ElMessageBox.confirm(
`撤销「${row.name}」的毕业归档?\n` +
'该届班级会回到在读列表、毕业学生会恢复为在读;作业、通知与成绩一条都不会被删除。',
'仅撤销归档标记:毕业学生恢复在读,转出和停用状态保留。不回滚年级、不删除新班、不恢复已清空周次;完整回滚需手工恢复快照。',
'撤销归档',
{ type: 'warning', confirmButtonText: '确认撤销' },
);
@@ -154,6 +154,7 @@ onMounted(loadYears);
<el-table-column label="毕业生" width="90">
<template #default="{ row }">{{ row.studentCount }}</template>
</el-table-column>
<el-table-column prop="inactiveCount" label="停用学生" width="100" />
<el-table-column label="转出学生" width="100">
<template #default="{ row }">{{ row.transferredCount }}</template>
</el-table-column>
@@ -187,6 +188,10 @@ onMounted(loadYears);
:title="detail.note"
/>
<p v-if="detail">
有转入记录 {{ detail.transferInCount }} 人 · 转出 {{ detail.transferred.length }} 人 · 停用
{{ detail.inactive.length }} 人
</p>
<el-tabs v-model="activeTab">
<el-tab-pane label="班级" name="classes">
<el-table :data="detail?.classes ?? []" size="small" class="mt-12" empty-text="该届没有班级">
@@ -247,6 +252,13 @@ onMounted(loadYears);
<el-table-column prop="transferNote" label="去向 / 备注" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`停用学生(${detail?.inactive.length ?? 0})`" name="inactive">
<el-table :data="detail?.inactive ?? []">
<el-table-column prop="studentNo" label="学号" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="className" label="原班级" />
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</el-drawer>
+67 -43
View File
@@ -17,8 +17,10 @@ import {
type SubjectTeacherDto,
type UpdateClassAccountRequest,
type UserDto,
type PromotionPlan,
} from '@classhelper/shared';
import { classApi, courseApi, teacherApi, termApi } from '@/api';
import { api } from '@/api/http';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { useResponsive } from '@/composables/useResponsive';
@@ -30,7 +32,7 @@ const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
const isAdmin = computed(() => auth.role === 'ADMIN');
const canManageClass = computed(() => auth.can('class:manage'));
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
@@ -39,7 +41,9 @@ const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const maxTermWeeks = ref(getMaxTermWeeks());
onMounted(async () => {
maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks);
if (auth.can('term:read')) maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks);
if (auth.can('promotion:manage'))
promotionPlan.value = (await api.get<{ plan: PromotionPlan }>('/term/promotion')).plan;
});
const keyword = ref('');
@@ -81,7 +85,7 @@ const previewName = computed(() => formatClassName(form.enrollmentYear, form.cla
const staffOptions = ref<UserDto[]>([]);
async function loadStaffOptions(): Promise<void> {
if (!isAdmin.value) return;
if (!canManageClass.value) return;
if (staffOptions.value.length > 0) return;
staffOptions.value = await teacherApi.list().catch(() => []);
}
@@ -125,13 +129,12 @@ async function submitForm(): Promise<void> {
if (editingId.value) {
await classApi.update(editingId.value, {
grade: form.grade,
enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex,
termWeeks: form.termWeeks,
});
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
if (isAdmin.value && form.headTeacherId) {
if (canManageClass.value && form.headTeacherId) {
const target = classes.value.find((item) => item.id === editingId.value);
if (target && target.teacher?.id !== form.headTeacherId) {
await classApi.assignHeadTeacher(editingId.value, form.headTeacherId);
@@ -147,7 +150,7 @@ async function submitForm(): Promise<void> {
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
termWeeks: form.termWeeks,
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
...(canManageClass.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
});
ElMessage.success(`班级 ${previewName.value} 创建成功`);
}
@@ -417,28 +420,32 @@ async function toggleGradeQuery(enabled: boolean): Promise<void> {
}
}
const promoteForm = reactive({ grade: '', saving: false });
/** 学年升级:只改年级,**不归档**,班级记录/学生名单/学号/作业成绩全部沿用 */
const promotionPlan = ref<PromotionPlan>();
const promoteForm = reactive({ saving: false });
const nextStage = computed(() => {
const track = promotionPlan.value?.tracks.find((track) =>
track.stages.some((stage) => stage.grade === detail.value?.grade),
);
const index = track?.stages.findIndex((stage) => stage.grade === detail.value?.grade) ?? -1;
return track && index >= 0
? track.stages[index].terminal
? '毕业归档(请到学年升级页)'
: track.stages[index + 1].grade
: '请先配置升级计划';
});
async function promote(): Promise<void> {
if (!detail.value || !promoteForm.grade.trim()) {
ElMessage.warning('请填写升级后的年级,例如「高二」');
return;
}
const target = promoteForm.grade.trim();
if (!detail.value) return;
await ElMessageBox.confirm(
`把「${detail.value.name}」的年级改为「${target}」?升级只改年级,不归档:` +
'班级记录、学生名单、学号、作业与成绩全部沿用。',
'学年升级',
{ type: 'info', confirmButtonText: '确认升级' },
`将「${detail.value.name}」从 ${detail.value.grade} 升至 ${nextStage.value},保留班级业务数据。`,
'确认升级',
{ type: 'warning' },
);
promoteForm.saving = true;
try {
await classApi.promote([detail.value.id], target);
ElMessage.success('已升级,数据继续沿用');
promoteForm.grade = '';
await classApi.promote([detail.value.id]);
await refreshDetail();
await loadClasses();
ElMessage.success('班级已升至下一学段');
} finally {
promoteForm.saving = false;
}
@@ -470,7 +477,7 @@ onUnmounted(() => {
<div class="page-header">
<div>
<h2 class="page-title">班级管理</h2>
<p class="page-subtitle">教师只能看到并管理自己创建或被分配的班级</p>
<p class="page-subtitle">按授权范围查看班级,管理操作需要相应权限。</p>
</div>
<div class="toolbar">
<el-input
@@ -489,7 +496,9 @@ onUnmounted(() => {
>
导入任课关系
</el-button>
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
<el-button v-if="canManageClass" type="primary" :icon="'Plus'" @click="openCreate">
新建班级
</el-button>
</div>
</div>
@@ -537,9 +546,11 @@ onUnmounted(() => {
<el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="isAdmin" link type="warning" @click="openAccount(row)">班级账号</el-button>
<el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button>
<el-button v-if="canManageClass" link type="warning" @click="openAccount(row)">
班级账号
</el-button>
<el-button v-if="canManageClass" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canManageClass" link type="danger" @click="removeClass(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -596,12 +607,12 @@ onUnmounted(() => {
</span>
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-input v-model="form.grade" placeholder="例如 高一" />
<el-input :disabled="!!editingId" v-model="form.grade" placeholder="例如 高一" />
</el-form-item>
<el-form-item v-if="!editingId" label="班级码">
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
</el-form-item>
<el-form-item v-if="isAdmin" label="班主任">
<el-form-item v-if="canManageClass" label="班主任">
<el-select v-model="form.headTeacherId" placeholder="选择班主任" style="width: 100%" filterable>
<el-option
v-for="item in staffOptions"
@@ -645,7 +656,7 @@ onUnmounted(() => {
</el-descriptions>
<!-- 更换班主任(仅管理员):班主任决定谁能管这个班的课表与成绩 -->
<div v-if="isAdmin && detail" class="toolbar mt-12">
<div v-if="canManageClass && detail" class="toolbar mt-12">
<span class="text-muted">更换班主任</span>
<el-select
:model-value="detail.teacher?.id ?? ''"
@@ -668,7 +679,13 @@ onUnmounted(() => {
<el-tabs v-model="activeTab" class="mt-12">
<el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12">
<el-button type="primary" size="small" :icon="'Plus'" @click="studentFormVisible = true">
<el-button
v-if="auth.can('roster:manage')"
type="primary"
size="small"
:icon="'Plus'"
@click="studentFormVisible = true"
>
添加学生
</el-button>
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
@@ -688,7 +705,9 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
<el-button v-if="auth.can('roster:manage')" link type="danger" @click="removeStudent(row)">
移出
</el-button>
</template>
</el-table-column>
</el-table>
@@ -702,7 +721,9 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
<el-button v-if="auth.can('schedule:write')" link type="danger" @click="removeCourse(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -731,7 +752,7 @@ onUnmounted(() => {
<el-table-column label="任课老师" width="260">
<template #default="{ row }">
<el-select
v-if="isAdmin"
v-if="canManageClass"
:model-value="row.teacherId"
placeholder="选择老师"
style="width: 220px"
@@ -748,14 +769,14 @@ onUnmounted(() => {
<span v-else>{{ row.teacherName || '-' }}</span>
</template>
</el-table-column>
<el-table-column v-if="isAdmin" label="操作" width="110">
<el-table-column v-if="canManageClass" label="操作" width="110">
<template #default="{ row }">
<el-button link type="danger" @click="clearSubjectTeacher(row)">解除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="isAdmin" class="toolbar mt-12">
<div v-if="canManageClass" class="toolbar mt-12">
<el-select
v-model="subjectDraft.subjectName"
placeholder="选择科目"
@@ -796,14 +817,14 @@ onUnmounted(() => {
<el-switch
:model-value="detail?.studentGradeQueryEnabled ?? true"
:loading="gradeQuerySaving"
:disabled="!isAdmin && auth.role !== 'TEACHER'"
:disabled="!auth.can('grade:write')"
@change="(value: boolean) => toggleGradeQuery(value)"
/>
</div>
<el-divider />
<div v-if="isAdmin" class="setting-row">
<div v-if="auth.can('promotion:manage')" class="setting-row">
<div>
<div class="setting-title">学年升级</div>
<div class="text-muted">
@@ -813,12 +834,15 @@ onUnmounted(() => {
</div>
</div>
<div class="toolbar">
<el-input
v-model="promoteForm.grade"
placeholder="升级后的年级,如 高二"
style="width: 200px"
/>
<el-button type="primary" :loading="promoteForm.saving" @click="promote">升级</el-button>
<span>下一学段:{{ nextStage }}</span>
<el-button
type="primary"
:loading="promoteForm.saving"
:disabled="nextStage.includes('毕业') || nextStage.includes('配置')"
@click="promote"
>
升级
</el-button>
</div>
</div>
</el-tab-pane>
+191 -15
View File
@@ -24,6 +24,7 @@ import {
type UserRole,
} from '@classhelper/shared';
import { permissionApi } from '@/api';
import { api } from '@/api/http';
import { useAuthStore } from '@/stores/auth';
import { useResponsive } from '@/composables/useResponsive';
@@ -31,7 +32,7 @@ const { isMobile } = useResponsive();
const auth = useAuthStore();
/** 当前 Tab:权限组 / 成员 */
const activeTab = ref<'groups' | 'members'>('groups');
const activeTab = ref<'groups' | 'members' | 'audit'>('groups');
const loading = ref(false);
/** 加载失败的中文提示(非空 = 页面顶部显示错误态 + 重试按钮) */
@@ -95,11 +96,13 @@ async function loadAll(): Promise<void> {
const [groupList, memberList, catalogData] = await Promise.all([
permissionApi.listGroups(),
permissionApi.listMembers(),
catalog.value ? Promise.resolve(catalog.value) : permissionApi.catalog(),
permissionApi.catalog(),
]);
groups.value = groupList;
members.value = memberList;
catalog.value = catalogData;
scopes.value = await api.get('/permissions/scopes');
await loadAudits();
await auth.ensurePermissions(true);
} catch (error) {
loadError.value = errorMessage(error);
@@ -171,7 +174,10 @@ function defaultRoleHint(key: PermissionKey): string {
* 这里只打一个「高权限」标记提醒配置的人。
*/
function isHighPrivilege(key: PermissionKey): boolean {
return catalog.value?.unremovable.includes(key) ?? false;
return (
(catalog.value?.defaultRoles[key]?.length === 1 && catalog.value.defaultRoles[key][0] === 'ADMIN') ||
(catalog.value?.unremovable.includes(key) ?? false)
);
}
/** 分类勾选状态:全选 / 全不选 / 选了一部分(决定"全选-清空"按钮的文案) */
@@ -203,6 +209,10 @@ async function submitGroup(): Promise<void> {
ElMessage.warning(`已存在名为「${name}」的权限组`);
return;
}
await ElMessageBox.confirm(groupImpact.value, '权限变更影响预览', {
type: 'warning',
confirmButtonText: '确认保存',
});
groupDialog.saving = true;
try {
const payload = {
@@ -264,9 +274,10 @@ const assignDialog = reactive({
});
function openAssign(member: PermissionMemberDto): void {
if (member.fixed) return;
assignDialog.member = member;
assignDialog.groupIds = [...member.groupIds];
assignDialog.groupIds = member.groupIds.length
? [...member.groupIds]
: groups.value.filter((g) => g.defaultRole === member.role).map((g) => g.id);
assignDialog.visible = true;
}
@@ -280,6 +291,10 @@ const roleDefaultCount = computed(() => {
async function submitAssign(): Promise<void> {
const member = assignDialog.member;
if (!member) return;
await ElMessageBox.confirm(assignmentImpact.value, '分组生效差异', {
type: 'warning',
confirmButtonText: '确认分配',
});
assignDialog.saving = true;
try {
// 空数组 = 回到角色默认模板(服务端契约)
@@ -293,15 +308,102 @@ async function submitAssign(): Promise<void> {
assignDialog.saving = false;
}
}
const scopes = ref<{ grades: string[]; divisions: { name: string; grades: string[] }[] }>({
grades: [],
divisions: [],
});
const selectedGrades = computed({
get: () => groupDialog.scopeValue.split(',').filter(Boolean),
set: (value: string[]) => {
groupDialog.scopeValue = value.join(',');
},
});
const memberSearch = ref(''),
memberRole = ref('');
const filteredMembers = computed(() =>
members.value.filter(
(member) =>
(!memberRole.value || member.role === memberRole.value) &&
`${member.name} ${member.username}`.includes(memberSearch.value.trim()),
),
);
type Audit = {
id: string;
actorName: string;
action: string;
targetId: string;
beforeJson: string;
afterJson: string;
createdAt: string;
};
const auditPage = ref(1),
audit = ref<{ items: Audit[]; total: number }>({ items: [], total: 0 });
async function loadAudits() {
audit.value = await api.get('/permissions/audit', { page: auditPage.value, pageSize: 30 });
}
const groupMembersVisible = ref(false),
selectedGroupMembers = ref<PermissionMemberDto[]>([]);
async function showMembers(group: PermissionGroupDto) {
selectedGroupMembers.value = await api.get(`/permissions/groups/${group.id}/members`);
groupMembersVisible.value = true;
}
function copyGroup(group: PermissionGroupDto) {
openEditGroup(group);
groupDialog.id = '';
groupDialog.name = `${group.name}副本`;
}
function effectiveKeys(
member: PermissionMemberDto,
groupIds: string[],
override?: { id: string; keys: PermissionKey[] },
) {
const selected = groups.value.filter((group) =>
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === member.role,
);
return [
...new Set([
...selected.flatMap((group) => (group.id === override?.id ? override.keys : group.keys)),
...(member.role === 'ADMIN' ? (catalog.value?.unremovable ?? []) : []),
]),
];
}
function keyChanges(before: PermissionKey[], after: PermissionKey[]) {
const labels = (keys: PermissionKey[]) =>
keys.map((key) => catalog.value?.labels[key] ?? key).join('、') || '无';
return `新增:${labels(after.filter((key) => !before.includes(key)))};移除:${labels(before.filter((key) => !after.includes(key)))}`;
}
const groupImpact = computed(() => {
const group = groups.value.find((group) => group.id === groupDialog.id);
const affected = members.value.filter((member) =>
member.groupIds.length ? member.groupIds.includes(groupDialog.id) : group?.defaultRole === member.role,
);
const added = new Set<PermissionKey>(),
removed = new Set<PermissionKey>();
for (const member of affected) {
const next = effectiveKeys(member, member.groupIds, { id: groupDialog.id, keys: groupDialog.keys });
next.filter((key) => !member.permissions.includes(key)).forEach((key) => added.add(key));
member.permissions.filter((key) => !next.includes(key)).forEach((key) => removed.add(key));
}
const label = (keys: Set<PermissionKey>) =>
[...keys].map((key) => catalog.value?.labels[key] ?? key).join('、') || '无';
return `将影响 ${affected.length} 名成员。新增:${label(added)};移除:${label(removed)}。范围按当前选择保存,可能改变成员可访问的班级。`;
});
const assignmentImpact = computed(() => {
const member = assignDialog.member;
if (!member) return '';
const next = effectiveKeys(member, assignDialog.groupIds);
const defaults = catalog.value?.roleDefaults[member.role] ?? [];
return `对当前权限:${keyChanges(member.permissions, next)}。对角色默认模板:${keyChanges(defaults, next)}。所选组替代默认模板,如需保留请同时选择同角色默认组。`;
});
</script>
<template>
<div class="page">
<div v-if="auth.can('permission:manage')" class="page">
<div class="page-header">
<div>
<h2 class="page-title">权限组管理</h2>
<p class="page-subtitle">
创建权限组并把账号分配进去,用来给老师额外开通某项能力(也可以反过来收回权限)。
分配的权限组会替代角色默认模板;如需保留默认权限,请同时选择同角色默认组。
</p>
</div>
</div>
@@ -346,6 +448,8 @@ async function submitAssign(): Promise<void> {
<span v-else class="text-muted">(无描述)</span>
</template>
</el-table-column>
<el-table-column prop="scopeLabel" label="范围" min-width="170" />
<el-table-column prop="updatedAt" label="更新时间" min-width="190" />
<el-table-column label="权限数" width="90">
<template #default="{ row }">{{ row.keys.length }}</template>
</el-table-column>
@@ -361,9 +465,11 @@ async function submitAssign(): Promise<void> {
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button link type="primary" @click="openEditGroup(row)">编辑</el-button>
<el-button link @click="copyGroup(row)">复制</el-button>
<el-button link @click="showMembers(row)">成员</el-button>
<!-- 内置组禁止删除:按钮 disabled + tooltip 说明(disabled 元素不发光标事件,外面套一层 span) -->
<el-tooltip :disabled="!row.builtin" content="内置权限组不能删除" placement="top">
<span>
@@ -380,6 +486,13 @@ async function submitAssign(): Promise<void> {
<!-- ------------------------------------------------ Tab 2:成员 -->
<el-tab-pane label="成员" name="members">
<div class="toolbar">
<el-input v-model="memberSearch" placeholder="搜索姓名或工号" clearable style="width: 220px" />
<el-select v-model="memberRole" clearable placeholder="全部角色">
<el-option value="ADMIN" label="管理员" />
<el-option value="TEACHER" label="教师" />
</el-select>
</div>
<div class="toolbar tab-toolbar">
<span class="text-muted">
只列出管理员与教师账号;管理员始终保留权限与系统管理入口,可调整默认组或分配其他权限组。
@@ -387,7 +500,7 @@ async function submitAssign(): Promise<void> {
</div>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="members" empty-text="暂无成员">
<el-table v-loading="loading" :data="filteredMembers" empty-text="暂无成员">
<el-table-column prop="name" label="姓名" min-width="120" show-overflow-tooltip />
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip />
<el-table-column label="角色" width="130">
@@ -409,14 +522,17 @@ async function submitAssign(): Promise<void> {
<span v-else class="text-muted">角色默认模板</span>
</template>
</el-table-column>
<el-table-column label="数据范围" min-width="180">
<template #default="{ row }">{{ row.scopeLabels.join(';') }}</template>
</el-table-column>
<el-table-column label="生效权限数" width="110">
<template #default="{ row }">{{ row.permissionCount }}</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-tooltip :disabled="!row.fixed" content="管理员始终保留权限与系统管理入口" placement="top">
<el-tooltip :disabled="true" content="管理员始终保留权限与系统管理入口" placement="top">
<span>
<el-button link type="primary" :disabled="row.fixed" @click="openAssign(row)">
<el-button link type="primary" :disabled="false" @click="openAssign(row)">
分配权限组
</el-button>
</span>
@@ -426,7 +542,38 @@ async function submitAssign(): Promise<void> {
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="变更记录" name="audit">
<el-table :data="audit.items">
<el-table-column prop="createdAt" label="时间" min-width="190" />
<el-table-column prop="actorName" label="操作人" min-width="120" />
<el-table-column prop="action" label="操作" min-width="120" />
<el-table-column prop="targetId" label="目标" min-width="180" />
<el-table-column label="变更前后" min-width="300">
<template #default="{ row }">
<details>
<summary>查看差异</summary>
<pre>{{ row.beforeJson }}</pre>
<pre>{{ row.afterJson }}</pre>
</details>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="auditPage"
:page-size="30"
:total="audit.total"
layout="prev, pager, next"
@current-change="loadAudits"
/>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="groupMembersVisible" title="生效成员" :size="isMobile ? '92%' : '560px'">
<el-table :data="selectedGroupMembers">
<el-table-column prop="name" label="姓名" />
<el-table-column prop="username" label="工号" />
<el-table-column prop="permissionCount" label="生效权限数" />
</el-table>
</el-drawer>
<!-- ------------------------------------------------ 新建 / 编辑权限组 -->
<el-dialog
@@ -445,15 +592,36 @@ async function submitAssign(): Promise<void> {
/>
</el-form-item>
<el-form-item label="数据范围">
<el-select v-model="groupDialog.scope">
<el-select v-model="groupDialog.scope" @change="groupDialog.scopeValue = ''">
<el-option label="关联班级" value="SELF_CLASS" />
<el-option label="年级" value="GRADE" />
<el-option label="部(多个年级)" value="DIVISION" />
<el-option label="全校" value="ALL" />
</el-select>
</el-form-item>
<el-form-item label="范围值">
<el-input v-model="groupDialog.scopeValue" placeholder="高一 或 高一,高二,高三;空年级范围不授权" />
<el-form-item v-if="groupDialog.scope === 'SELF_CLASS'" label="关联班级规则">
<el-select v-model="groupDialog.scopeValue">
<el-option label="班主任与任课班级" value="" />
<el-option label="仅班主任班" value="HEAD" />
</el-select>
</el-form-item>
<el-form-item v-else-if="groupDialog.scope === 'GRADE'" label="年级" required>
<el-select v-model="groupDialog.scopeValue">
<el-option v-for="grade in scopes.grades" :key="grade" :label="grade" :value="grade" />
</el-select>
</el-form-item>
<el-form-item v-else-if="groupDialog.scope === 'DIVISION'" label="部范围(选择多个年级)" required>
<el-select v-model="selectedGrades" multiple>
<el-option v-for="grade in scopes.grades" :key="grade" :label="grade" :value="grade" />
</el-select>
<el-button
v-for="division in scopes.divisions"
:key="division.name"
link
@click="selectedGrades = division.grades"
>
{{ division.name }}
</el-button>
</el-form-item>
<el-form-item label="描述">
<el-input
@@ -497,6 +665,7 @@ async function submitAssign(): Promise<void> {
</p>
</el-form-item>
</el-form>
<el-alert :title="groupImpact" type="warning" :closable="false" />
<template #footer>
<el-button @click="groupDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="groupDialog.saving" @click="submitGroup">保存</el-button>
@@ -523,11 +692,18 @@ async function submitAssign(): Promise<void> {
filterable
placeholder="不选任何组 = 使用角色默认模板"
>
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id">
<el-option
v-for="group in groups"
:key="group.id"
:label="group.name"
:value="group.id"
:disabled="!!group.defaultRole && group.defaultRole !== assignDialog.member.role"
>
<span>{{ group.name }}</span>
<span class="text-muted">({{ group.keys.length }} 项权限)</span>
</el-option>
</el-select>
<el-alert :title="assignmentImpact" type="warning" :closable="false" />
<p class="assign-hint text-muted">
不选任何组 = 使用角色默认模板({{ roleLabel(assignDialog.member.role) }} 默认
{{ roleDefaultCount }} 项权限);选了组就取各组权限的并集。
File diff suppressed because it is too large. Load diff
+235 -46
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/api/http';
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
@@ -18,13 +19,17 @@ import {
import { callApi, classApi, studentApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
const readOnly = computed(() => useRouteName.value === 'roster');
const useRouteName = computed(() => route.name);
const route = useRoute();
const auth = useAuthStore();
const readOnly = computed(() => !auth.can('roster:manage'));
const loading = ref(false);
const students = ref<StudentDto[]>([]);
const classes = ref<ClassDto[]>([]);
const filter = reactive({ classId: '', keyword: '', status: '' as '' | StudentStatus });
const filter = reactive({
classId: '',
keyword: '',
status: 'active' as '' | StudentStatus,
includeArchived: false,
});
/** 表格勾选(批量调班 / 批量转出用) */
const selection = ref<StudentDto[]>([]);
@@ -51,7 +56,7 @@ function exportRoster() {
const classOptions = computed(() => classes.value);
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
classes.value = auth.can('class:read') ? await classApi.list() : [];
}
let studentRequest = 0;
@@ -59,7 +64,8 @@ async function loadStudents(): Promise<void> {
const request = ++studentRequest;
loading.value = true;
try {
const params: { classId?: string; keyword?: string; status?: StudentStatus } = {};
const params: { classId?: string; keyword?: string; status?: StudentStatus; includeArchived?: boolean } =
{ includeArchived: filter.includeArchived };
if (filter.classId) params.classId = filter.classId;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
if (filter.status) params.status = filter.status;
@@ -125,7 +131,7 @@ async function submitForm(): Promise<void> {
const payload = {
studentNo: form.studentNo.trim(),
name: form.name.trim(),
classId: form.classId || null,
...(!editingId.value ? { classId: form.classId || null } : {}),
gender: form.gender,
guardianPhone: form.guardianPhone.trim(),
};
@@ -142,12 +148,27 @@ async function submitForm(): Promise<void> {
}
async function removeStudent(row: StudentDto): Promise<void> {
await ElMessageBox.confirm(`删除学生「${row.name}(${row.studentNo})」及其成绩、作业记录?`, '危险操作', {
type: 'warning',
confirmButtonText: '确认删除',
});
await ElMessageBox.confirm(
`停用学生「${row.name}(${row.studentNo})」?成绩、作业、已读与历史全部保留;停用后可复学。`,
'危险操作',
{
type: 'warning',
confirmButtonText: '确认停用',
},
);
await studentApi.remove(row.id);
ElMessage.success('学生已删除');
ElMessage.success('学生已停用');
await loadStudents();
}
async function hardDeleteStudent(row: StudentDto): Promise<void> {
await ElMessageBox.prompt(
`仅能删除没有成绩、作业、已读或历史的学生记录。请输入学号 ${row.studentNo} 确认永久删除。`,
'删除空记录',
{ inputValidator: (value) => value === row.studentNo || '学号不一致', type: 'warning' },
);
await api.delete(`/students/${row.id}?hard=true&confirm=true`);
ElMessage.success('空学生记录已删除');
await loadStudents();
}
@@ -158,7 +179,7 @@ async function removeStudent(row: StudentDto): Promise<void> {
const transferVisible = ref(false);
const transferSaving = ref(false);
const transferTargets = ref<StudentDto[]>([]);
const transferForm = reactive({ toClassId: '', note: '' });
const transferForm = reactive({ toClassId: '', note: '', allowGradeChange: false });
function openTransfer(rows: StudentDto[]): void {
if (rows.length === 0) {
@@ -178,6 +199,7 @@ async function submitTransfer(): Promise<void> {
transferTargets.value.map((item) => item.id),
transferForm.toClassId || null,
transferForm.note.trim() || undefined,
transferForm.allowGradeChange,
);
ElMessage.success(`已调动 ${result.moved} 名学生(学号不变,历史可查)`);
transferVisible.value = false;
@@ -192,19 +214,64 @@ async function transferOut(rows: StudentDto[]): Promise<void> {
ElMessage.warning('请先勾选要办理转出的学生');
return;
}
const names = rows.map((item) => item.name).join('、');
const { value } = await ElMessageBox.prompt(
`将 ${rows.length} 名学生(${names})标记为「已转出」。\n` +
'转出表示学籍离开本校:班级归属与历史成绩、作业都会保留,只是不再计入在读名单。',
'办理学生转出',
{ inputPlaceholder: '转出去向 / 原因(可选)', inputValue: '', confirmButtonText: '确认转出' },
);
const result = await studentApi.transferOut(
rows.map((item) => item.id),
(value ?? '').trim() || undefined,
);
ElMessage.success(`已办理 ${result.transferred} 名学生转出`);
await loadStudents();
outTargets.value = rows;
outForm.note = '';
outForm.toSchool = '';
outVisible.value = true;
}
const outTargets = ref<StudentDto[]>([]),
outVisible = ref(false),
outSaving = ref(false);
const outForm = reactive({ toSchool: '', note: '' });
async function submitOut() {
outSaving.value = true;
try {
await studentApi.transferOut(
outTargets.value.map((row) => row.id),
outForm.note,
outForm.toSchool,
);
outVisible.value = false;
await loadStudents();
} finally {
outSaving.value = false;
}
}
const inVisible = ref(false),
inSaving = ref(false);
const inForm = reactive({ studentId: '', studentNo: '', name: '', toClassId: '', fromSchool: '', note: '' });
function openIn(row?: StudentDto) {
Object.assign(inForm, {
studentId: row?.id ?? '',
studentNo: row?.studentNo ?? '',
name: row?.name ?? '',
toClassId: filter.classId,
fromSchool: '',
note: '',
});
inVisible.value = true;
}
async function submitIn() {
if (!inForm.toClassId || !inForm.studentNo || !inForm.name) {
ElMessage.warning('请填写学号、姓名和目标班级');
return;
}
inSaving.value = true;
try {
await api.post('/students/transfer-in', {
...(inForm.studentId
? { studentId: inForm.studentId }
: { studentNo: inForm.studentNo, name: inForm.name }),
toClassId: inForm.toClassId,
fromSchool: inForm.fromSchool,
note: inForm.note,
});
inVisible.value = false;
await loadStudents();
ElMessage.success('转入或复学已完成');
} finally {
inSaving.value = false;
}
}
/* ------------------------------------------------------------ 调班历史 */
@@ -227,6 +294,11 @@ async function openHistory(row: StudentDto): Promise<void> {
}
const TRANSFER_MODE_LABELS: Record<string, string> = {
'class-change': '调班 / 建档',
restore: '撤回 / 复学',
unassign: '移出班级',
deactivate: '停用',
'transfer-in': '转入',
single: '单个调班',
batch: '批量调班',
'transfer-out': '转出',
@@ -303,15 +375,14 @@ async function submitCall(): Promise<void> {
style="width: 170px"
@change="loadStudents"
>
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
<el-select
v-model="filter.status"
placeholder="全部状态"
clearable
style="width: 130px"
@change="loadStudents"
>
<el-select v-model="filter.status" placeholder="在读" style="width: 130px" @change="loadStudents">
<el-option
v-for="status in STUDENT_STATUSES"
:key="status"
@@ -329,8 +400,17 @@ async function submitCall(): Promise<void> {
/>
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
<el-button @click="exportRoster">导出名单</el-button>
<el-switch v-model="filter.includeArchived" active-text="包含归档" @change="loadStudents" />
<el-button v-if="!readOnly" @click="openIn()">转入学生</el-button>
<el-button v-if="!readOnly" type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
<el-button v-if="!readOnly" type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
<el-button
v-if="!readOnly && auth.can('import:students')"
type="warning"
:icon="'Document'"
@click="openImport"
>
导入名单
</el-button>
</div>
</div>
@@ -395,13 +475,59 @@ async function submitCall(): Promise<void> {
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column v-if="!readOnly" label="操作" width="330" fixed="right">
<el-table-column label="操作" width="370" fixed="right">
<template #default="{ row }">
<el-button link type="success" @click="openCall(row)">叫人</el-button>
<el-button link type="primary" @click="openTransfer([row])">调班</el-button>
<el-button link @click="openHistory(row)">调班历史</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
<el-button
v-if="auth.can('call:create') && row.status === 'active'"
link
type="success"
@click="openCall(row)"
>
叫人
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
link
type="primary"
@click="openTransfer([row])"
>
调班
</el-button>
<el-button link @click="openHistory(row)">生命周期历史</el-button>
<el-button
v-if="!readOnly && ['active', 'inactive'].includes(row.status) && !row.archivedYearId"
link
type="primary"
@click="openEdit(row)"
>
编辑
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
link
type="danger"
@click="removeStudent(row)"
>
停用
</el-button>
<el-button
v-if="
auth.role === 'ADMIN' && ['active', 'inactive'].includes(row.status) && !row.archivedYearId
"
link
type="danger"
@click="hardDeleteStudent(row)"
>
删除空记录
</el-button>
<el-button
v-if="!readOnly && ['transferred', 'inactive'].includes(row.status) && !row.archivedYearId"
link
type="primary"
@click="openIn(row)"
>
转入 / 复学
</el-button>
</template>
</el-table-column>
</el-table>
@@ -417,6 +543,11 @@ async function submitCall(): Promise<void> {
<el-form-item label="学生">
<span>{{ transferTargets.length }} 名({{ transferTargets.map((s) => s.name).join('、') }})</span>
</el-form-item>
<el-form-item label="原班 / 年级">
<span>
{{ transferTargets.map((s) => `${s.className || '未分班'} / ${s.grade || ''}`).join('、') }}
</span>
</el-form-item>
<el-form-item label="调入班级">
<el-select
v-model="transferForm.toClassId"
@@ -424,9 +555,17 @@ async function submitCall(): Promise<void> {
clearable
style="width: 100%"
>
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item v-if="auth.role === 'ADMIN'" label="跨年级">
<el-checkbox v-model="transferForm.allowGradeChange">允许跨年级(必须填写备注)</el-checkbox>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="transferForm.note"
@@ -441,6 +580,45 @@ async function submitCall(): Promise<void> {
</template>
</el-dialog>
<el-dialog v-model="outVisible" title="办理转出" width="min(92%, 520px)">
<p>将 {{ outTargets.length }} 名学生转出,原班级和全部业务历史保留。</p>
<el-form label-position="top">
<el-form-item label="去向学校"><el-input v-model="outForm.toSchool" maxlength="200" /></el-form-item>
<el-form-item label="原因 / 备注">
<el-input v-model="outForm.note" type="textarea" maxlength="200" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="outVisible = false">取消</el-button>
<el-button type="danger" :loading="outSaving" @click="submitOut">确认转出</el-button>
</template>
</el-dialog>
<el-dialog v-model="inVisible" title="转入 / 复学" width="min(92%, 520px)">
<el-form label-position="top">
<el-form-item label="学号" required>
<el-input v-model="inForm.studentNo" :disabled="!!inForm.studentId" />
</el-form-item>
<el-form-item label="姓名" required>
<el-input v-model="inForm.name" :disabled="!!inForm.studentId" />
</el-form-item>
<el-form-item label="目标在读班级" required>
<el-select v-model="inForm.toClassId">
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="来源学校"><el-input v-model="inForm.fromSchool" maxlength="200" /></el-form-item>
<el-form-item label="备注"><el-input v-model="inForm.note" maxlength="200" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="inVisible = false">取消</el-button>
<el-button type="primary" :loading="inSaving" @click="submitIn">确认办理</el-button>
</template>
</el-dialog>
<!-- 调班历史 -->
<el-drawer v-model="historyVisible" :title="`调班历史 — ${historyTarget?.name ?? ''}`" size="640px">
<el-table v-loading="historyLoading" :data="historyRows" empty-text="该生还没有调班记录">
@@ -448,7 +626,7 @@ async function submitCall(): Promise<void> {
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="方式" width="100">
<template #default="{ row }">{{ TRANSFER_MODE_LABELS[row.mode] ?? row.mode }}</template>
<template #default="{ row }">{{ TRANSFER_MODE_LABELS[row.kind] ?? row.kind }}</template>
</el-table-column>
<el-table-column label="原班级" width="130">
<template #default="{ row }">{{ row.fromClassName || '未分班' }}</template>
@@ -456,6 +634,12 @@ async function submitCall(): Promise<void> {
<el-table-column label="新班级" width="130">
<template #default="{ row }">{{ row.toClassName || '未分班' }}</template>
</el-table-column>
<el-table-column prop="batchId" label="批次" min-width="200" />
<el-table-column prop="academicYear" label="学年" width="90" />
<el-table-column prop="fromGrade" label="原年级" width="100" />
<el-table-column prop="toGrade" label="新年级" width="100" />
<el-table-column prop="fromSchool" label="来源学校" min-width="120" />
<el-table-column prop="toSchool" label="去向学校" min-width="120" />
<el-table-column prop="operatorName" label="操作人" width="100" />
<el-table-column prop="note" label="备注" show-overflow-tooltip />
</el-table>
@@ -542,9 +726,14 @@ async function submitCall(): Promise<void> {
<el-form-item label="家长手机号">
<el-input v-model="form.guardianPhone" placeholder="可选" />
</el-form-item>
<el-form-item label="班级">
<el-form-item v-if="!editingId" label="班级">
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
+28 -6
View File
@@ -12,6 +12,7 @@
*
* `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
import { useAuthStore } from '@/stores/auth';
import PromotionSettings from '@/components/PromotionSettings.vue';
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
@@ -26,6 +27,7 @@ import {
} from '@classhelper/shared';
import { classApi, termApi } from '@/api';
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const classes = ref<ClassDto[]>([]);
@@ -176,7 +178,7 @@ onMounted(async () => {
<template>
<div class="page">
<PromotionSettings />
<PromotionSettings v-if="auth.can('promotion:manage')" />
<el-form-item v-if="!scope" label="全校学期总周数">
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" />
</el-form-item>
@@ -201,10 +203,24 @@ onMounted(async () => {
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
<el-button type="warning" :icon="'Calendar'" :loading="holidayLoading" @click="fetchHolidays">
<el-button
v-if="auth.can('term:write')"
type="warning"
:icon="'Calendar'"
:loading="holidayLoading"
@click="fetchHolidays"
>
拉取放假安排
</el-button>
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">保存</el-button>
<el-button
v-if="auth.can('term:write')"
type="primary"
:loading="saving"
:disabled="!dirty"
@click="save"
>
保存
</el-button>
</div>
</div>
@@ -229,7 +245,9 @@ onMounted(async () => {
placeholder="第 1 教学周的周一"
style="width: 200px"
/>
<el-button type="primary" plain :loading="saving" @click="autoGenerate">按开学日期生成</el-button>
<el-button v-if="auth.can('term:write')" type="primary" plain :loading="saving" @click="autoGenerate">
按开学日期生成
</el-button>
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
</div>
</el-card>
@@ -254,7 +272,9 @@ onMounted(async () => {
<template #header>
<div class="card-header">
<span>逐周日期区间({{ weeks.length }} 周)</span>
<el-button size="small" :icon="'Plus'" @click="addWeek">添加一周</el-button>
<el-button v-if="auth.can('term:write')" size="small" :icon="'Plus'" @click="addWeek">
添加一周
</el-button>
</div>
</template>
@@ -304,7 +324,9 @@ onMounted(async () => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button link type="danger" @click="removeWeek($index)">删除</el-button>
<el-button v-if="auth.can('term:write')" link type="danger" @click="removeWeek($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>