<script setup lang="ts">
import { Capacitor } from '@capacitor/core'
import {
  CapacitorBarcodeScanner,
  CapacitorBarcodeScannerCameraDirection,
  CapacitorBarcodeScannerScanOrientation,
  CapacitorBarcodeScannerTypeHint,
} from '@capacitor/barcode-scanner'
import { Geolocation } from '@capacitor/geolocation'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Camera, Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'

const route = useRoute()
const router = useRouter()
const isNativeApp = Capacitor.isNativePlatform()
const attendanceDeviceId = getOrCreateAttendanceDeviceId()
const attendanceDevicePlatform = Capacitor.getPlatform()
const records = ref<any[]>([])
const openActivities = ref<any[]>([])
const scannedActivity = ref<any>(null)
const loading = ref(false)
const scanLoading = ref(false)
const nativeScanLoading = ref(false)
const checkInTargetId = ref('')
const now = ref(Date.now())
const appealDialog = ref(false)
const appealTarget = ref<any>(null)
const appealReason = ref('')

const statusLabels: Record<string, string> = {
  Present: '出勤', Absent: '缺勤', Late: '迟到', Leave: '请假', Excused: '免修',
}
const statusColors: Record<string, 'success' | 'danger' | 'warning' | 'info' | undefined> = {
  Present: 'success', Absent: 'danger', Late: 'warning', Leave: 'info', Excused: undefined,
}
const appealStatusLabels: Record<string, string> = {
  None: '', Pending: '申诉中', Approved: '已通过', Rejected: '已驳回',
}

function getOrCreateAttendanceDeviceId() {
  const storageKey = 'jiaowu_attendance_device_id'
  const existing = localStorage.getItem(storageKey)
  if (existing) return existing
  const created = typeof crypto.randomUUID === 'function'
    ? crypto.randomUUID()
    : `${Date.now().toString(36)}-${crypto.getRandomValues(new Uint32Array(4)).join('-')}`
  localStorage.setItem(storageKey, created)
  return created
}

function deviceAuditPayload() {
  return {
    deviceId: attendanceDeviceId,
    devicePlatform: attendanceDevicePlatform,
  }
}
const courseGroups = computed(() => {
  const groups = new Map<string, any>()
  records.value.forEach((record: any) => {
    const key = record.teachingTaskId ?? `${record.courseCode}-${record.taskNumber}`
    let group = groups.get(key)
    if (!group) {
      group = {
        key,
        courseCode: record.courseCode,
        courseName: record.courseName,
        taskNumber: record.taskNumber,
        teacherNames: [...record.teacherNames],
        records: [],
        presentCount: 0,
        absentCount: 0,
        lateCount: 0,
        leaveCount: 0,
        excusedCount: 0,
        requiredCount: 0,
        attendedCount: 0,
        attendanceRate: null,
        latestAt: 0,
      }
      groups.set(key, group)
    }
    group.records.push(record)
    group.latestAt = Math.max(group.latestAt, new Date(record.attendanceDate).getTime())
    const countKey = `${record.status.charAt(0).toLowerCase()}${record.status.slice(1)}Count`
    if (countKey in group) group[countKey] += 1
    if (record.status !== 'Excused') group.requiredCount += 1
    if (record.status === 'Present' || record.status === 'Late') group.attendedCount += 1
  })
  return [...groups.values()]
    .map(group => ({
      ...group,
      attendanceRate: group.requiredCount > 0
        ? Math.round(group.attendedCount * 1000 / group.requiredCount) / 10
        : null,
    }))
    .sort((a, b) => b.latestAt - a.latestAt)
})
let clockTimer: number | undefined

async function load() {
  loading.value = true
  try {
    const [recordResponse, activityResponse] = await Promise.all([
      http.get('/attendance/my-records'),
      http.get('/attendance/open-check-ins'),
    ])
    records.value = recordResponse.data
    openActivities.value = activityResponse.data
    const token = typeof route.query.token === 'string' ? route.query.token : ''
    if (token) await loadScannedActivity(token)
    else scannedActivity.value = null
  }
  catch (e) { ElMessage.error(apiErrorMessage(e)) }
  finally { loading.value = false }
}

async function loadScannedActivity(token: string) {
  scanLoading.value = true
  try {
    scannedActivity.value = (
      await http.get('/attendance/check-in-info', { params: { token } })
    ).data
  } catch (error: any) {
    scannedActivity.value = null
    if (error?.response?.status === 404) {
      ElMessage.error('签到码无效，或你不在本次课程名单中。')
    } else {
      ElMessage.error(apiErrorMessage(error))
    }
  } finally {
    scanLoading.value = false
  }
}

function extractCheckInToken(value: string) {
  const scannedValue = value.trim()
  const isToken = (token: string) => /^[a-z\d._-]{20,64}$/i.test(token)
  if (isToken(scannedValue)) return scannedValue

  try {
    const scannedUrl = new URL(scannedValue)
    const token = scannedUrl.searchParams.get('token')
    if (token && isToken(token)) return token

    const hashQuery = scannedUrl.hash.includes('?')
      ? scannedUrl.hash.slice(scannedUrl.hash.indexOf('?'))
      : ''
    const hashToken = new URLSearchParams(hashQuery).get('token')
    if (hashToken && isToken(hashToken)) return hashToken
  } catch {
    // 不是网址时继续按签到参数格式解析。
  }

  const queryToken = /(?:[?&]token=)([^&#]+)/i.exec(scannedValue)?.[1]
  if (!queryToken) return ''
  try {
    const decodedToken = decodeURIComponent(queryToken)
    return isToken(decodedToken) ? decodedToken : ''
  } catch {
    return ''
  }
}

async function scanCheckInCode() {
  nativeScanLoading.value = true
  try {
    const result = await CapacitorBarcodeScanner.scanBarcode({
      hint: CapacitorBarcodeScannerTypeHint.QR_CODE,
      scanInstructions: '请扫描教师展示的课堂签到二维码',
      cameraDirection: CapacitorBarcodeScannerCameraDirection.BACK,
      scanOrientation: CapacitorBarcodeScannerScanOrientation.ADAPTIVE,
      cancelButtonAccessibilityLabel: '取消扫码',
      torchButtonOnAccessibilityLabel: '关闭手电筒',
      torchButtonOffAccessibilityLabel: '打开手电筒',
    })
    const token = extractCheckInToken(result.ScanResult)
    if (!token) {
      ElMessage.warning('这不是有效的课堂签到二维码。')
      return
    }
    await router.replace({ path: route.path, query: { token } })
    await loadScannedActivity(token)
  } catch (error: any) {
    const message = String(error?.message ?? error ?? '')
    if (!/cancel/i.test(message)) {
      ElMessage.error('无法启动扫码，请在系统设置中允许本应用使用相机。')
    }
  } finally {
    nativeScanLoading.value = false
  }
}

function serverUtcTime(value: string | null | undefined) {
  if (!value) return Number.NaN
  const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
  return new Date(normalized).getTime()
}

function formatServerTime(value: string) {
  return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
    hour: '2-digit',
    minute: '2-digit',
  })
}

function remainingLabel(activity: any) {
  if (!activity?.checkInEndsAt) return ''
  const remaining = serverUtcTime(activity.checkInEndsAt) - now.value
  if (remaining <= 0) return '签到已结束'
  const minutes = Math.floor(remaining / 60000)
  const seconds = Math.floor((remaining % 60000) / 1000)
  return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
}

async function confirmQrCheckIn() {
  const token = typeof route.query.token === 'string' ? route.query.token : ''
  if (!token || !scannedActivity.value) return
  checkInTargetId.value = scannedActivity.value.sheetId
  try {
    const { data } = await http.post('/attendance/check-in', {
      token,
      ...deviceAuditPayload(),
    })
    ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
    await router.replace({ path: route.path, query: {} })
    await load()
  } catch (error) {
    ElMessage.error(apiErrorMessage(error))
  } finally {
    checkInTargetId.value = ''
  }
}

async function captureLocation() {
  if (isNativeApp) {
    let permission = await Geolocation.checkPermissions()
    if (permission.location !== 'granted') {
      permission = await Geolocation.requestPermissions({ permissions: ['location'] })
    }
    if (permission.location !== 'granted') {
      throw Object.assign(new Error('permission denied'), { code: 1 })
    }
    return await Geolocation.getCurrentPosition({
      enableHighAccuracy: true,
      timeout: 12000,
      maximumAge: 0,
    })
  }
  if (!navigator.geolocation) throw new Error('unsupported')
  return await new Promise<GeolocationPosition>((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, {
      enableHighAccuracy: true,
      timeout: 12000,
      maximumAge: 0,
    })
  })
}

async function locationCheckIn(activity: any) {
  checkInTargetId.value = activity.sheetId
  try {
    const position = await captureLocation()
    const { data } = await http.post('/attendance/check-in', {
      attendanceSheetId: activity.sheetId,
      latitude: position.coords.latitude,
      longitude: position.coords.longitude,
      accuracyMeters: position.coords.accuracy,
      ...deviceAuditPayload(),
    })
    ElMessage.success(data.alreadyCheckedIn
      ? '你已完成本次签到'
      : `签到成功${data.checkInDistanceMeters === null
        ? ''
        : `，距签到点约 ${Math.round(data.checkInDistanceMeters)} 米`}`)
    await load()
  } catch (error: any) {
    if (!error?.response) {
      const message = error?.code === 1
        ? isNativeApp
          ? '定位权限被拒绝，请在系统设置中允许本应用获取精确位置。'
          : '定位权限被拒绝，请在浏览器中允许本网站获取位置。'
        : error?.code === 3
          ? '获取位置超时，请移到信号较好的位置后重试。'
          : '无法获取当前位置，请检查手机定位服务后重试。'
      ElMessage.error(message)
    } else {
      ElMessage.error(apiErrorMessage(error))
    }
  } finally {
    checkInTargetId.value = ''
  }
}

function openAppeal(record: any) {
  appealTarget.value = record
  appealReason.value = ''
  appealDialog.value = true
}

async function submitAppeal() {
  if (!appealReason.value.trim()) { ElMessage.warning('请填写申诉原因'); return }
  try {
    await http.post('/attendance/records/appeal', {
      attendanceSheetId: appealTarget.value.attendanceSheetId,
      reason: appealReason.value,
    })
    appealDialog.value = false
    ElMessage.success('申诉已提交')
    await load()
  } catch (e) { ElMessage.error(apiErrorMessage(e)) }
}

function canAppeal(record: any) {
  return record.appealStatus === 'None' || record.appealStatus === 'Rejected'
}

function formatRate(value: number | null) {
  return value === null ? '暂无' : `${value.toFixed(1)}%`
}

function rateTone(value: number | null) {
  if (value === null) return 'neutral'
  if (value < 80) return 'danger'
  if (value < 90) return 'warning'
  return 'good'
}

function recordDate(value: string) {
  const date = new Date(value)
  return {
    month: `${date.getMonth() + 1}月`,
    day: String(date.getDate()).padStart(2, '0'),
  }
}

onMounted(() => {
  clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
  load()
})
onUnmounted(() => {
  if (clockTimer) window.clearInterval(clockTimer)
})
</script>

<template>
  <div class="page-stack att-page">
    <section class="page-intro">
      <div>
        <span class="section-kicker">ATTENDANCE RECORD</span>
        <h2>我的考勤</h2>
        <p>完成课堂扫码或定位签到，并查看已提交的考勤记录。</p>
      </div>
      <div class="intro-actions">
        <el-button
          v-if="isNativeApp"
          type="primary"
          :icon="Camera"
          :loading="nativeScanLoading"
          @click="scanCheckInCode"
        >扫码签到</el-button>
        <el-button :icon="Refresh" @click="load">刷新</el-button>
      </div>
    </section>

    <section
      v-if="route.query.token || openActivities.length"
      v-loading="loading || scanLoading"
      class="check-in-board"
    >
      <header>
        <div>
          <span>LIVE CHECK-IN</span>
          <strong>待签到</strong>
        </div>
        <small>签到由服务器校验课程名单、有效时间和位置范围</small>
      </header>

      <article v-if="scannedActivity" class="scan-ticket">
        <div class="ticket-mark"><el-icon><Check /></el-icon></div>
        <div class="ticket-course">
          <span>{{ scannedActivity.courseCode }} · {{ scannedActivity.taskNumber }}</span>
          <strong>{{ scannedActivity.courseName }}</strong>
          <p>{{ scannedActivity.sheetName }} · 扫码签到</p>
        </div>
        <div class="ticket-time">
          <Clock />
          <b>{{ remainingLabel(scannedActivity) }}</b>
          <small v-if="scannedActivity.checkInAt">
            已于 {{ formatServerTime(scannedActivity.checkInAt) }} 签到
          </small>
          <small v-else>请确认课程信息后完成签到</small>
        </div>
        <el-button
          type="primary"
          size="large"
          :disabled="!scannedActivity.isOpen || Boolean(scannedActivity.checkInAt)"
          :loading="checkInTargetId === scannedActivity.sheetId"
          @click="confirmQrCheckIn"
        >{{ scannedActivity.checkInAt ? '已签到' : scannedActivity.isOpen ? '确认签到' : '签到已结束' }}</el-button>
      </article>

      <div v-if="openActivities.length" class="location-list">
        <article v-for="activity in openActivities" :key="activity.sheetId">
          <div class="location-pin"><Location /></div>
          <div>
            <span>{{ activity.courseCode }} · {{ activity.taskNumber }}</span>
            <strong>{{ activity.courseName }}</strong>
            <p>{{ activity.sheetName }} · {{ activity.locationRadiusMeters }} 米范围内</p>
          </div>
          <div class="location-clock">
            <b>{{ remainingLabel(activity) }}</b>
            <small v-if="activity.checkInAt">
              已于 {{ formatServerTime(activity.checkInAt) }} 签到
            </small>
            <small v-else>将获取一次当前位置用于本次签到</small>
          </div>
          <el-button
            type="primary"
            plain
            :disabled="Boolean(activity.checkInAt)"
            :loading="checkInTargetId === activity.sheetId"
            @click="locationCheckIn(activity)"
          >{{ activity.checkInAt ? '已签到' : '定位并签到' }}</el-button>
        </article>
      </div>
    </section>

    <section class="record-head">
      <div>
        <strong>课程考勤档案</strong>
        <span>按课程汇总出勤率，仅统计教师已经提交的考勤结果</span>
      </div>
      <span>{{ courseGroups.length }} 门课程 · {{ records.length }} 次点名</span>
    </section>

    <section v-loading="loading" class="course-archive">
      <article v-for="course in courseGroups" :key="course.key" class="course-record">
        <header>
          <div class="course-identity">
            <span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
            <h3>{{ course.courseName }}</h3>
            <small>{{ course.teacherNames.join('、') || '任课教师未登记' }}</small>
          </div>
          <div class="course-rate" :class="rateTone(course.attendanceRate)">
            <span>课程出勤率</span>
            <strong>{{ formatRate(course.attendanceRate) }}</strong>
            <small>{{ course.attendedCount }} / {{ course.requiredCount }} 次到课</small>
          </div>
        </header>

        <div class="course-summary">
          <div><b>{{ course.records.length }}</b><span>点名次数</span></div>
          <div class="present"><b>{{ course.presentCount }}</b><span>出勤</span></div>
          <div class="late"><b>{{ course.lateCount }}</b><span>迟到</span></div>
          <div class="absent"><b>{{ course.absentCount }}</b><span>缺勤</span></div>
          <div><b>{{ course.leaveCount }}</b><span>请假</span></div>
          <div><b>{{ course.excusedCount }}</b><span>免修</span></div>
        </div>

        <div class="session-list">
          <div
            v-for="r in course.records"
            :key="r.attendanceSheetId"
            class="session-row"
          >
            <div class="session-date" aria-hidden="true">
              <span>{{ recordDate(r.attendanceDate).month }}</span>
              <strong>{{ recordDate(r.attendanceDate).day }}</strong>
            </div>
            <div class="session-info">
              <strong>{{ r.sheetName }}</strong>
              <span v-if="r.notes">{{ r.notes }}</span>
              <span v-else>本次点名没有备注</span>
            </div>
            <div class="session-result">
              <el-tag :type="statusColors[r.status]" size="small">
                {{ statusLabels[r.status] }}
              </el-tag>
              <template v-if="r.appealStatus !== 'None'">
                <el-tag
                  size="small"
                  :type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'"
                >
                  {{ appealStatusLabels[r.appealStatus] }}
                </el-tag>
                <span v-if="r.appealReviewComment" class="review-comment">
                  {{ r.appealReviewComment }}
                </span>
              </template>
            </div>
            <el-button
              v-if="canAppeal(r) && (r.status === 'Absent' || r.status === 'Late')"
              size="small"
              type="warning"
              plain
              :icon="Warning"
              @click="openAppeal(r)"
            >申诉</el-button>
          </div>
        </div>
      </article>
      <el-empty v-if="!courseGroups.length" description="还没有已提交的课程考勤记录" />
    </section>

    <el-dialog v-model="appealDialog" title="考勤申诉" width="500px">
      <el-form label-position="top">
        <el-form-item label="申诉原因" required>
          <el-input v-model="appealReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请说明您对该考勤记录的异议原因" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="appealDialog = false">取消</el-button>
        <el-button type="primary" @click="submitAppeal">提交申诉</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<style scoped>
.intro-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}
.check-in-board {
  border: 1px solid #cdd9e5;
  background: #f6f9fc;
}
.check-in-board > header {
  min-height: 58px;
  padding: 10px 16px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  color: white;
  background: #17395e;
}
.check-in-board > header > div { display: grid; }
.check-in-board > header span {
  color: #6de0c0;
  font-size: 9px;
  font-weight: 800;
  letter-spacing: .14em;
}
.check-in-board > header strong { font-size: 17px; }
.check-in-board > header small { font-size: 10px; opacity: .72; }
.scan-ticket {
  margin: 14px;
  padding: 16px;
  display: grid;
  grid-template-columns: 44px minmax(180px, 1fr) minmax(150px, .7fr) auto;
  align-items: center;
  gap: 14px;
  border-left: 4px solid #2d8975;
  background: white;
  box-shadow: 0 4px 14px rgba(23, 43, 77, .07);
}
.ticket-mark {
  width: 42px;
  height: 42px;
  display: grid;
  place-items: center;
  color: #247261;
  font-size: 23px;
  background: #e8f5ef;
}
.ticket-course,
.ticket-time { display: grid; gap: 2px; }
.ticket-course span,
.location-list article > div:nth-child(2) > span {
  color: #176b87;
  font: 700 10px/1.2 Consolas, monospace;
}
.ticket-course strong,
.location-list article strong { color: #172b4d; font-size: 15px; }
.ticket-course p,
.location-list article p { margin: 0; color: var(--muted); font-size: 10px; }
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
.ticket-time svg { width: 14px; color: #176b87; }
.ticket-time b { color: #344054; font-size: 12px; }
.ticket-time small { grid-column: 2; color: var(--muted); font-size: 9px; }
.location-list { border-top: 1px solid #dce4ed; }
.location-list article {
  padding: 13px 16px;
  display: grid;
  grid-template-columns: 38px minmax(180px, 1fr) minmax(160px, .7fr) auto;
  align-items: center;
  gap: 12px;
  border-bottom: 1px solid #e3e9ef;
  background: white;
}
.location-list article:last-child { border-bottom: none; }
.location-pin {
  width: 34px;
  height: 34px;
  display: grid;
  place-items: center;
  color: #176b87;
  background: #e9f3f5;
}
.location-pin svg { width: 19px; }
.location-list article > div:nth-child(2) { display: grid; gap: 2px; }
.location-clock { display: grid; gap: 2px; }
.location-clock b { color: #247261; font-size: 11px; }
.location-clock small { color: var(--muted); font-size: 9px; }
.record-head {
  padding: 11px 15px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  border: 1px solid var(--line);
  background: #fbfcfd;
}
.record-head > div { display: grid; gap: 1px; }
.record-head strong { color: #172b4d; font-size: 14px; }
.record-head span { color: var(--muted); font-size: 10px; }
.course-archive { display: grid; gap: 14px; }
.course-record {
  overflow: hidden;
  border: 1px solid #d8e0e9;
  background: white;
}
.course-record > header {
  min-height: 104px;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 176px;
  border-bottom: 1px solid #dce4ed;
}
.course-identity {
  padding: 18px 20px;
  display: grid;
  align-content: center;
  gap: 2px;
}
.course-identity > span {
  color: #176b87;
  font: 700 10px/1.2 Consolas, monospace;
  letter-spacing: .03em;
}
.course-identity h3 {
  margin: 2px 0;
  color: #172b4d;
  font-size: 20px;
  line-height: 1.25;
}
.course-identity small { color: var(--muted); font-size: 10px; }
.course-rate {
  padding: 14px 18px;
  display: grid;
  align-content: center;
  color: white;
  background: #17395e;
}
.course-rate > span {
  font-size: 9px;
  font-weight: 800;
  letter-spacing: .08em;
  opacity: .75;
}
.course-rate strong {
  margin: 3px 0;
  font: 700 30px/1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.course-rate small { font-size: 9px; opacity: .72; }
.course-rate.good { background: #245f57; }
.course-rate.warning { background: #8b5a18; }
.course-rate.danger { background: #8d403d; }
.course-rate.neutral { background: #526276; }
.course-summary {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  border-bottom: 1px solid #e3e9ef;
  background: #f8fafc;
}
.course-summary > div {
  min-width: 0;
  padding: 10px 12px;
  display: grid;
  gap: 2px;
  border-right: 1px solid #e3e9ef;
}
.course-summary > div:last-child { border-right: none; }
.course-summary b {
  color: #344054;
  font: 700 18px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.course-summary span { color: var(--muted); font-size: 9px; }
.course-summary .present b { color: #247261; }
.course-summary .late b { color: #a66716; }
.course-summary .absent b { color: #a9433e; }
.session-list { display: grid; }
.session-row {
  min-height: 68px;
  padding: 9px 14px;
  display: grid;
  grid-template-columns: 46px minmax(160px, 1fr) minmax(120px, auto) auto;
  align-items: center;
  gap: 12px;
  border-bottom: 1px solid #edf0f4;
}
.session-row:last-child { border-bottom: none; }
.session-date {
  width: 42px;
  height: 46px;
  display: grid;
  align-content: center;
  justify-items: center;
  color: #17395e;
  border: 1px solid #cbd7e3;
  background: #f5f8fb;
}
.session-date span { font-size: 8px; font-weight: 700; }
.session-date strong {
  font: 700 19px/1 "Arial Narrow", sans-serif;
  font-variant-numeric: tabular-nums;
}
.session-info { min-width: 0; display: grid; gap: 3px; }
.session-info strong {
  overflow: hidden;
  color: #344054;
  font-size: 12px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.session-info span,
.review-comment {
  overflow: hidden;
  color: var(--muted);
  font-size: 9px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.session-result {
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 6px;
  flex-wrap: wrap;
}
.review-comment { max-width: 150px; }

@media (max-width: 720px) {
  .check-in-board > header { align-items: flex-start; flex-direction: column; }
  .scan-ticket,
  .location-list article {
    grid-template-columns: 38px minmax(0, 1fr);
  }
  .ticket-time,
  .location-clock,
  .scan-ticket > .el-button,
  .location-list article > .el-button {
    grid-column: 1 / -1;
  }
  .scan-ticket > .el-button,
  .location-list article > .el-button { width: 100%; }
  .ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
  .record-head { align-items: flex-start; flex-direction: column; gap: 5px; }
  .record-head > span { text-align: left; }
  .course-record > header { grid-template-columns: minmax(0, 1fr) 118px; }
  .course-identity { padding: 15px 13px; }
  .course-identity h3 { font-size: 17px; }
  .course-rate { padding: 12px; }
  .course-rate strong { font-size: 22px; }
  .course-summary { grid-template-columns: repeat(3, 1fr); }
  .course-summary > div:nth-child(3) { border-right: none; }
  .course-summary > div:nth-child(-n + 3) { border-bottom: 1px solid #e3e9ef; }
  .session-row {
    grid-template-columns: 42px minmax(0, 1fr) auto;
    gap: 9px;
  }
  .session-result {
    grid-column: 2 / -1;
    justify-content: flex-start;
  }
  .session-row > .el-button {
    grid-column: 2 / -1;
    width: 100%;
  }
}
</style>
