Files
Academic-Affairs-System/web/src/views/ApprovalCenterView.vue
T
2026-08-11 11:46:26 +08:00

757 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
Check,
Clock,
Close,
DocumentChecked,
Plus,
Refresh,
Search,
Switch,
} from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
interface CourseOption {
teachingTaskId: string
courseId: string
taskNumber: string
courseCode: string
courseName: string
credits: number
academicTermName: string
teacherNames: string[]
hasPublishedSchedule: boolean
schedulingMode: 'Standard' | 'Flexible'
}
interface GradeOption {
courseId: string
courseCode: string
courseName: string
credits: number
totalScore: number | null
gradePoint: number | null
examStatus: string
academicTermName: string
taskNumber: string
publishedAt: string | null
}
interface SubstitutionTarget {
courseId: string
courseCode: string
courseName: string
credits: number
source: 'assigned' | 'failed'
detail: string
}
const auth = useAuthStore()
const isManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)) ?? false)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
const pending = ref<any[]>([])
const pendingPage = ref(1)
const pendingTotal = ref(0)
const pendingPageSize = 20
const loading = ref(false)
const submitting = ref(false)
const tab = ref(isManager.value ? 'pending' : 'mine')
const dialog = ref(false)
const dialogType = ref('')
const courseKeyword = ref('')
const originalKeyword = ref('')
const substituteKeyword = ref('')
const rejectDialog = ref(false)
const rejectItem = ref<any>(null)
const rejectComment = ref('')
const enrolledCourses = ref<CourseOption[]>([])
const gradeRecords = ref<any[]>([])
const myGrades = ref<GradeOption[]>([])
const myExemptions = ref<any[]>([])
const myDeferred = ref<any[]>([])
const mySubstitutions = ref<any[]>([])
const form = reactive({
teachingTaskId: '',
reason: '',
originalCourseId: '',
substituteCourseId: '',
gradeRecordId: '',
requestedScore: 0,
})
const dialogTitle = computed(() => ({
exemption: '发起免修申请',
deferred: '发起缓考申请',
gradeMod: '成绩修改申请',
substitution: '发起课程替代申请',
}[dialogType.value] ?? '发起申请'))
const selectedCourse = computed(() =>
enrolledCourses.value.find(course => course.teachingTaskId === form.teachingTaskId))
const filteredCourses = computed(() => {
const keyword = courseKeyword.value.trim().toLocaleLowerCase()
if (!keyword) return enrolledCourses.value
return enrolledCourses.value.filter(course =>
[course.courseCode, course.courseName, course.taskNumber, ...course.teacherNames]
.some(value => value?.toLocaleLowerCase().includes(keyword)))
})
function uniqueGrades(predicate: (grade: GradeOption) => boolean) {
const result = new Map<string, GradeOption>()
for (const grade of myGrades.value) {
if (predicate(grade) && !result.has(grade.courseId)) result.set(grade.courseId, grade)
}
return [...result.values()]
}
const failedGrades = computed(() =>
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) < 60))
const passedGrades = computed(() =>
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) >= 60))
const substitutionTargets = computed<SubstitutionTarget[]>(() => {
const result = new Map<string, SubstitutionTarget>()
for (const course of enrolledCourses.value) {
result.set(course.courseId, {
courseId: course.courseId,
courseCode: course.courseCode,
courseName: course.courseName,
credits: course.credits,
source: 'assigned',
detail: `${course.academicTermName} · ${course.taskNumber}`,
})
}
for (const grade of failedGrades.value) {
if (!result.has(grade.courseId)) {
result.set(grade.courseId, {
courseId: grade.courseId,
courseCode: grade.courseCode,
courseName: grade.courseName,
credits: grade.credits,
source: 'failed',
detail: `${grade.academicTermName} · ${grade.totalScore} 分`,
})
}
}
return [...result.values()]
})
function matchesCourse(item: { courseCode: string; courseName: string }, keyword: string) {
const normalized = keyword.trim().toLocaleLowerCase()
return !normalized ||
item.courseCode.toLocaleLowerCase().includes(normalized) ||
item.courseName.toLocaleLowerCase().includes(normalized)
}
function selectOriginal(courseId: string) {
form.originalCourseId = courseId
if (form.substituteCourseId === courseId) form.substituteCourseId = ''
}
const filteredOriginalCourses = computed(() =>
substitutionTargets.value.filter(item => matchesCourse(item, originalKeyword.value)))
const filteredSubstituteCourses = computed(() =>
passedGrades.value.filter(item => matchesCourse(item, substituteKeyword.value)))
const selectedOriginal = computed(() =>
substitutionTargets.value.find(item => item.courseId === form.originalCourseId))
const selectedSubstitute = computed(() =>
passedGrades.value.find(item => item.courseId === form.substituteCourseId))
const recordCount = computed(() =>
myExemptions.value.length + myDeferred.value.length + mySubstitutions.value.length)
async function load() {
loading.value = true
try {
if (isManager.value) {
const { data } = await http.get('/approvals/pending', {
params: { page: pendingPage.value, pageSize: pendingPageSize },
})
pending.value = data.items
pendingTotal.value = data.total
if (!pending.value.length && pendingPage.value > 1) {
pendingPage.value--
return await load()
}
}
if (isStudent.value) {
const [courses, grades, ex, df, substitutions] = await Promise.all([
http.get('/approvals/my-courses'),
http.get('/approvals/my-grades'),
http.get('/approvals/exemptions/mine'),
http.get('/approvals/deferred/mine'),
http.get('/approvals/substitutions/mine'),
])
enrolledCourses.value = courses.data
myGrades.value = grades.data
myExemptions.value = ex.data
myDeferred.value = df.data
mySubstitutions.value = substitutions.data
}
if (isTeacher.value) gradeRecords.value = (await http.get('/approvals/grade-records')).data
} catch (e) {
ElMessage.error(apiErrorMessage(e))
} finally {
loading.value = false
}
}
function openDialog(type: string) {
dialogType.value = type
courseKeyword.value = ''
originalKeyword.value = ''
substituteKeyword.value = ''
Object.assign(form, {
teachingTaskId: '',
reason: '',
originalCourseId: '',
substituteCourseId: '',
gradeRecordId: '',
requestedScore: 0,
})
dialog.value = true
}
async function submitForm() {
if (!form.reason.trim()) {
ElMessage.warning('请填写申请原因')
return
}
submitting.value = true
try {
if (dialogType.value === 'exemption') {
if (!form.teachingTaskId) {
ElMessage.warning('请选择申请免修的课程')
return
}
await http.post('/approvals/exemptions', {
teachingTaskId: form.teachingTaskId,
reason: form.reason,
})
} else if (dialogType.value === 'deferred') {
if (!form.teachingTaskId) {
ElMessage.warning('请选择申请缓考的课程')
return
}
await http.post('/approvals/deferred', {
teachingTaskId: form.teachingTaskId,
reason: form.reason,
})
} else if (dialogType.value === 'gradeMod') {
if (!form.gradeRecordId) {
ElMessage.warning('请选择成绩记录')
return
}
await http.post('/approvals/grade-modifications', {
gradeRecordId: form.gradeRecordId,
requestedScore: form.requestedScore,
reason: form.reason,
})
} else if (dialogType.value === 'substitution') {
if (!form.originalCourseId || !form.substituteCourseId) {
ElMessage.warning('请完整选择被替代课程和替代课程')
return
}
await http.post('/approvals/substitutions', {
originalCourseId: form.originalCourseId,
substituteCourseId: form.substituteCourseId,
reason: form.reason,
})
}
dialog.value = false
ElMessage.success('申请已提交')
await load()
} catch (e) {
ElMessage.error(apiErrorMessage(e))
} finally {
submitting.value = false
}
}
async function approveItem(item: any) {
try {
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`
if (ep) {
await http.post(`${ep}/${item.id}/approve`)
ElMessage.success('已通过')
await load()
}
} catch (e) {
ElMessage.error(apiErrorMessage(e))
}
}
function openReject(item: any) {
rejectItem.value = item
rejectComment.value = ''
rejectDialog.value = true
}
async function rejectSubmit() {
if (!rejectItem.value) return
try {
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`
if (ep) {
await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value })
rejectDialog.value = false
ElMessage.success('已驳回')
await load()
}
} catch (e) {
ElMessage.error(apiErrorMessage(e))
}
}
function statusText(status: string) {
return status === 'Submitted' ? '待审核' : status === 'Approved' ? '已通过' : '已驳回'
}
function statusType(status: string) {
return status === 'Approved' ? 'success' : status === 'Rejected' ? 'danger' : 'warning'
}
function courseScheduleText(course: CourseOption) {
if (course.schedulingMode === 'Flexible') return '非排时课程'
return course.hasPublishedSchedule ? '课表已发布' : '等待课表发布'
}
onMounted(load)
</script>
<template>
<div class="page-stack appr-page">
<section class="page-intro">
<div>
<span class="section-kicker">APPROVAL CENTER</span>
<h2>审批中心</h2>
<p>{{ isStudent ? '从实际修读课程发起申请,查看审核进度与处理结果。' : '集中处理教务申请与审核任务。' }}</p>
</div>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</section>
<section v-if="isStudent" v-loading="loading" class="application-launcher">
<button class="launch-card exemption" type="button" @click="openDialog('exemption')">
<span class="launch-icon"><DocumentChecked /></span>
<span class="launch-copy">
<small>COURSE EXEMPTION</small>
<b>申请免修</b>
<em>从当前或已发布课表的修读课程中选择</em>
</span>
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
</button>
<button class="launch-card deferred" type="button" @click="openDialog('deferred')">
<span class="launch-icon"><Clock /></span>
<span class="launch-copy">
<small>DEFERRED EXAM</small>
<b>申请缓考</b>
<em>课程与教学班信息一并提交</em>
</span>
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
</button>
<button class="launch-card substitution" type="button" @click="openDialog('substitution')">
<span class="launch-icon"><Switch /></span>
<span class="launch-copy">
<small>COURSE SUBSTITUTION</small>
<b>申请课程替代</b>
<em>修读中或未通过课程 → 已通过课程</em>
</span>
<span class="launch-meta">{{ passedGrades.length }} 门可替代 <Plus /></span>
</button>
</section>
<div class="approval-tabs">
<el-segmented
v-model="tab"
:options="[
...(isManager ? [{ label: `待审批 (${pendingTotal})`, value: 'pending' }] : []),
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
]"
/>
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
</div>
<section v-if="tab === 'pending'" v-loading="loading" class="appr-list">
<article v-for="item in pending" :key="`${item.type}-${item.id}`" class="appr-card">
<el-tag size="small" :type="item.label === '考勤申诉' ? 'warning' : 'primary'">{{ item.label }}</el-tag>
<div class="appr-body">
<b>{{ item.title }}</b>
<p>{{ item.desc }}</p>
<small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small>
</div>
<div v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)" class="appr-actions">
<el-button size="small" type="danger" plain :icon="Close" @click="openReject(item)">驳回</el-button>
<el-button size="small" type="success" :icon="Check" @click="approveItem(item)">通过</el-button>
</div>
<div v-else class="appr-actions">
<el-button
size="small"
text
type="primary"
@click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')"
>
查看 →
</el-button>
</div>
</article>
<el-empty v-if="!pending.length" description="暂无待审批" />
<el-pagination
v-if="pendingTotal > pendingPageSize"
v-model:current-page="pendingPage"
class="approval-pagination"
layout="prev, pager, next"
:page-size="pendingPageSize"
:total="pendingTotal"
@current-change="() => load()"
/>
</section>
<section v-if="tab === 'mine'" v-loading="loading">
<template v-if="isStudent">
<div v-if="recordCount" class="record-columns">
<section class="record-group">
<header><span class="record-mark exemption"></span><b>免修申请</b><em>{{ myExemptions.length }}</em></header>
<div class="appr-list">
<article v-for="item in myExemptions" :key="item.id" class="appr-card compact">
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
<div class="appr-body">
<b>{{ item.courseName }}</b>
<p>{{ item.reason }}</p>
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
</div>
</article>
<p v-if="!myExemptions.length" class="record-empty">尚未提交免修申请</p>
</div>
</section>
<section class="record-group">
<header><span class="record-mark deferred"></span><b>缓考申请</b><em>{{ myDeferred.length }}</em></header>
<div class="appr-list">
<article v-for="item in myDeferred" :key="item.id" class="appr-card compact">
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
<div class="appr-body">
<b>{{ item.courseName }}</b>
<p>{{ item.reason }}</p>
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
</div>
</article>
<p v-if="!myDeferred.length" class="record-empty">尚未提交缓考申请</p>
</div>
</section>
<section class="record-group substitution-records">
<header><span class="record-mark substitution"></span><b>课程替代申请</b><em>{{ mySubstitutions.length }}</em></header>
<div class="appr-list">
<article v-for="item in mySubstitutions" :key="item.id" class="appr-card compact">
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
<div class="appr-body">
<b>{{ item.substituteCourseCode }} {{ item.substituteCourseName }} → {{ item.originalCourseCode }} {{ item.originalCourseName }}</b>
<p>{{ item.reason }}</p>
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
</div>
</article>
<p v-if="!mySubstitutions.length" class="record-empty">尚未提交课程替代申请</p>
</div>
</section>
</div>
<el-empty v-else description="还没有申请记录">
<el-button type="primary" @click="openDialog('exemption')">发起第一项申请</el-button>
</el-empty>
</template>
</section>
<el-dialog v-model="dialog" :title="dialogTitle" width="min(780px, calc(100vw - 24px))" class="application-dialog" destroy-on-close>
<el-form label-position="top">
<template v-if="dialogType === 'exemption' || dialogType === 'deferred'">
<div class="dialog-lead">
<span :class="['dialog-lead-icon', dialogType]">
<DocumentChecked v-if="dialogType === 'exemption'" />
<Clock v-else />
</span>
<div>
<b>{{ dialogType === 'exemption' ? '选择申请免修的教学班' : '选择申请缓考的教学班' }}</b>
<p>列表包含当前学期课程,以及仍未归档且课表已发布的行政班或已选课程。</p>
</div>
</div>
<el-form-item label="课程" required>
<el-input v-model="courseKeyword" :prefix-icon="Search" clearable placeholder="搜索课程名称、代码、教学班号或教师" />
<div v-if="filteredCourses.length" class="course-picker">
<button
v-for="course in filteredCourses"
:key="course.teachingTaskId"
type="button"
:class="['course-choice', { selected: form.teachingTaskId === course.teachingTaskId }]"
@click="form.teachingTaskId = course.teachingTaskId"
>
<span class="course-code">{{ course.courseCode }}</span>
<span class="course-main">
<b>{{ course.courseName }}</b>
<small>{{ course.taskNumber }} · {{ course.academicTermName }}</small>
<em>{{ course.teacherNames.length ? course.teacherNames.join('、') : '教师待定' }} · {{ course.credits }} 学分</em>
</span>
<span :class="['schedule-state', { ready: course.hasPublishedSchedule || course.schedulingMode === 'Flexible' }]">
{{ courseScheduleText(course) }}
</span>
<span class="choice-check"><Check /></span>
</button>
</div>
<el-empty
v-else
:description="enrolledCourses.length ? '没有匹配的课程' : '暂无可申请课程'"
:image-size="76"
>
<p v-if="!enrolledCourses.length" class="empty-explanation">
课程需为已发布教学任务,并分配到您的行政班或已完成选课;当前学期课程或已发布课表的课程均会显示。
</p>
</el-empty>
</el-form-item>
</template>
<template v-if="dialogType === 'substitution'">
<el-alert
title="替代关系须同时满足两个条件"
description="被替代课程应为当前或已发布课表的修读课程,或已有未通过成绩的课程;替代课程必须已有正式发布且不低于 60 分的成绩。"
type="info"
:closable="false"
show-icon
/>
<div class="substitution-flow">
<section class="substitution-column">
<header><span>1</span><div><b>被替代课程</b><small>修读课程 / 历史未通过</small></div></header>
<el-input v-model="originalKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
<div v-if="filteredOriginalCourses.length" class="mini-course-list">
<button
v-for="course in filteredOriginalCourses"
:key="course.courseId"
type="button"
:class="{ selected: form.originalCourseId === course.courseId }"
@click="selectOriginal(course.courseId)"
>
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.detail }} · {{ course.credits }} 学分</small></span>
<el-tag size="small" :type="course.source === 'assigned' ? 'primary' : 'danger'">
{{ course.source === 'assigned' ? '修读' : '未通过' }}
</el-tag>
</button>
</div>
<el-empty v-else :image-size="62" description="暂无可被替代课程" />
</section>
<div class="flow-arrow">→</div>
<section class="substitution-column">
<header><span>2</span><div><b>替代课程</b><small>已发布且成绩及格</small></div></header>
<el-input v-model="substituteKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
<div v-if="filteredSubstituteCourses.length" class="mini-course-list">
<button
v-for="course in filteredSubstituteCourses"
:key="course.courseId"
type="button"
:disabled="course.courseId === form.originalCourseId"
:class="{ selected: form.substituteCourseId === course.courseId }"
@click="form.substituteCourseId = course.courseId"
>
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.academicTermName }} · {{ course.credits }} 学分</small></span>
<strong>{{ course.totalScore }}<small>分</small></strong>
</button>
</div>
<el-empty v-else :image-size="62" description="暂无已通过课程">
<p class="empty-explanation">已排课但尚未发布成绩的课程不属于可替代课程。</p>
</el-empty>
</section>
</div>
</template>
<template v-if="dialogType === 'gradeMod'">
<el-form-item label="成绩记录" required>
<el-select v-model="form.gradeRecordId" filterable placeholder="选择已发布成绩">
<el-option
v-for="record in gradeRecords"
:key="record.id"
:label="`${record.studentName}(${record.studentNumber}) — ${record.courseName} — ${record.totalScore}分`"
:value="record.id"
/>
</el-select>
</el-form-item>
<el-form-item label="修改为" required>
<el-input-number v-model="form.requestedScore" :min="0" :max="100" />
</el-form-item>
</template>
<el-form-item class="reason-field" label="申请原因" required>
<el-input
v-model="form.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
:placeholder="dialogType === 'deferred' ? '请说明无法按时参加考试的原因及相关情况' : dialogType === 'substitution' ? '请说明两门课程在内容、学分或培养要求上的对应关系' : '请说明申请依据及相关情况'"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<span v-if="selectedCourse">{{ selectedCourse.courseCode }} · {{ selectedCourse.courseName }}</span>
<span v-else-if="selectedOriginal || selectedSubstitute">
{{ selectedSubstitute?.courseName ?? '请选择替代课程' }} → {{ selectedOriginal?.courseName ?? '请选择被替代课程' }}
</span>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm">提交申请</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="rejectDialog" title="驳回申请" width="min(460px, calc(100vw - 24px))">
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写驳回原因" />
<template #footer>
<el-button @click="rejectDialog = false">取消</el-button>
<el-button type="danger" @click="rejectSubmit">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.application-launcher {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.launch-card {
position: relative;
min-height: 132px;
padding: 20px;
display: flex;
align-items: flex-start;
gap: 14px;
overflow: hidden;
text-align: left;
color: #1e293b;
background: linear-gradient(145deg, #fff 48%, #f6f8fb);
border: 1px solid #dfe4eb;
border-radius: 14px;
cursor: pointer;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.launch-card::after {
content: "";
position: absolute;
right: -26px;
bottom: -50px;
width: 120px;
height: 120px;
border: 18px solid rgba(67, 97, 238, .05);
border-radius: 50%;
}
.launch-card:hover {
transform: translateY(-2px);
border-color: #aebbd0;
box-shadow: 0 12px 28px rgba(30, 41, 59, .08);
}
.launch-icon, .dialog-lead-icon {
width: 42px;
height: 42px;
flex: 0 0 auto;
display: grid;
place-items: center;
color: #3556b5;
background: #edf2ff;
border-radius: 12px;
}
.launch-icon svg, .dialog-lead-icon svg { width: 21px; }
.deferred .launch-icon, .dialog-lead-icon.deferred { color: #a85d00; background: #fff3dc; }
.substitution .launch-icon { color: #087f5b; background: #e8f8f1; }
.launch-copy { display: grid; gap: 3px; min-width: 0; }
.launch-copy small { color: #8791a3; font: 600 9px/1.2 ui-monospace, monospace; letter-spacing: .1em; }
.launch-copy b { margin-top: 3px; font-size: 17px; }
.launch-copy em { color: #667085; font-size: 11px; font-style: normal; line-height: 1.5; }
.launch-meta { position: absolute; left: 76px; bottom: 17px; display: flex; align-items: center; gap: 5px; color: #506079; font-size: 10px; }
.launch-meta svg { width: 12px; }
.approval-tabs { display: flex; justify-content: space-between; gap: 12px; }
.appr-list { display: grid; gap: 8px; }
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 10px; }
.appr-card.compact { align-items: flex-start; padding: 13px; }
.appr-body { flex: 1; min-width: 0; }
.appr-body b { display: block; margin-bottom: 4px; font-size: 13px; }
.appr-body p { margin: 0 0 4px; color: #606266; font-size: 12px; line-height: 1.55; }
.appr-body small { color: var(--muted); font-size: 10px; }
.appr-actions { display: flex; flex-shrink: 0; gap: 6px; }
.record-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; }
.record-group { padding: 16px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
.record-group.substitution-records { grid-column: 1 / -1; }
.record-group > header { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.record-group > header b { font-size: 13px; }
.record-group > header em { margin-left: auto; min-width: 22px; padding: 3px 7px; color: #667085; background: #fff; border-radius: 10px; font-size: 10px; font-style: normal; text-align: center; }
.record-mark { width: 8px; height: 8px; background: #4b6fd8; border-radius: 50%; box-shadow: 0 0 0 4px #e8edfb; }
.record-mark.deferred { background: #d5841c; box-shadow: 0 0 0 4px #fff0d8; }
.record-mark.substitution { background: #13916a; box-shadow: 0 0 0 4px #dff5ed; }
.record-empty { margin: 20px 0; color: #98a2b3; font-size: 11px; text-align: center; }
.dialog-lead { margin-bottom: 18px; padding: 14px; display: flex; align-items: center; gap: 12px; background: #f7f9fc; border: 1px solid #e6eaf1; border-radius: 10px; }
.dialog-lead b { display: block; margin-bottom: 3px; font-size: 13px; }
.dialog-lead p { margin: 0; color: #667085; font-size: 11px; }
.course-picker { width: 100%; max-height: 330px; margin-top: 10px; display: grid; gap: 8px; overflow: auto; }
.course-choice { width: 100%; padding: 13px; display: grid; grid-template-columns: 68px minmax(0, 1fr) auto 20px; align-items: center; gap: 12px; text-align: left; background: #fff; border: 1px solid #e1e6ee; border-radius: 10px; cursor: pointer; transition: border-color .15s, background .15s; }
.course-choice:hover { border-color: #9aabd4; }
.course-choice.selected { background: #f4f7ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
.course-code { color: #3651a3; font: 700 11px/1.3 ui-monospace, monospace; }
.course-main { display: grid; min-width: 0; gap: 3px; }
.course-main b { color: #1e293b; font-size: 13px; }
.course-main small, .course-main em { overflow: hidden; color: #7b8495; font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.schedule-state { padding: 4px 7px; color: #8a6116; background: #fff4d9; border-radius: 6px; font-size: 9px; }
.schedule-state.ready { color: #087f5b; background: #e5f7ef; }
.choice-check { display: grid; place-items: center; width: 18px; height: 18px; color: transparent; border: 1px solid #cfd5df; border-radius: 50%; }
.choice-check svg { width: 11px; }
.course-choice.selected .choice-check { color: #fff; background: #4b6fd8; border-color: #4b6fd8; }
.empty-explanation { max-width: 450px; margin: -10px auto 0; color: #8992a3; font-size: 10px; line-height: 1.6; }
.substitution-flow { margin-top: 16px; display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); align-items: stretch; gap: 10px; }
.substitution-column { min-width: 0; padding: 14px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
.substitution-column > header { margin-bottom: 12px; display: flex; align-items: center; gap: 9px; }
.substitution-column > header > span { width: 25px; height: 25px; display: grid; place-items: center; color: #fff; background: #405fb7; border-radius: 8px; font: 700 11px/1 ui-monospace, monospace; }
.substitution-column > header div { display: grid; gap: 2px; }
.substitution-column > header b { font-size: 12px; }
.substitution-column > header small { color: #8a94a5; font-size: 9px; }
.flow-arrow { display: grid; place-items: center; color: #7d8fbf; font-size: 18px; }
.mini-course-list { max-height: 255px; margin-top: 9px; display: grid; align-content: start; gap: 6px; overflow: auto; }
.mini-course-list button { padding: 10px; display: flex; align-items: center; gap: 8px; text-align: left; background: #fff; border: 1px solid #e2e7ee; border-radius: 8px; cursor: pointer; }
.mini-course-list button:hover { border-color: #a9b5d3; }
.mini-course-list button.selected { background: #f0f4ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
.mini-course-list button:disabled { opacity: .45; cursor: not-allowed; }
.mini-course-list button > span { min-width: 0; display: grid; flex: 1; gap: 3px; }
.mini-course-list button b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.mini-course-list button span small { color: #8a94a5; font-size: 9px; }
.mini-course-list button > strong { color: #087f5b; font-size: 16px; }
.mini-course-list button > strong small { margin-left: 1px; font-size: 8px; font-weight: 500; }
.reason-field { margin-top: 18px; }
.dialog-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.dialog-footer > span { margin-right: auto; overflow: hidden; color: #667085; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 860px) {
.application-launcher { grid-template-columns: 1fr; }
.launch-card { min-height: 112px; }
.record-columns { grid-template-columns: 1fr; }
.record-group.substitution-records { grid-column: auto; }
.substitution-flow { grid-template-columns: 1fr; }
.flow-arrow { transform: rotate(90deg); }
}
@media (max-width: 600px) {
.page-intro { align-items: flex-start; }
.approval-tabs { align-items: flex-start; }
.course-choice { grid-template-columns: 58px minmax(0, 1fr) 18px; }
.schedule-state { grid-column: 2; justify-self: start; }
.appr-card { align-items: flex-start; flex-wrap: wrap; }
.appr-actions { width: 100%; justify-content: flex-end; }
}
</style>