feat: 完善学生生命周期管理与权限范围并发布 1.4.2
This commit is contained in:
1 parent
6347d1e5aa
commit
24e8dd902b
92 files changed
+6801
-2255
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/web-admin",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||
"type": "module",
|
||||
|
||||
@@ -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>
|
||||
@@ -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'] },
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user