Files
BYW-VoiceHub/app/components/Admin/ScheduleTablePrint.vue
T

377 lines
11 KiB
Vue

<template>
<table class="schedule-timetable">
<thead>
<tr>
<th v-if="settings.showSequence" class="col-sequence">{{ locale.sequence }}</th>
<th v-for="date in dates" :key="date" class="col-date">
{{ formatDate(date) }}
</th>
</tr>
</thead>
<template v-if="hasPlayTimes">
<tbody v-for="pt in sortedPlayTimes" :key="pt.key">
<tr class="playtime-header-row">
<td :colspan="dates.length + (settings.showSequence ? 1 : 0)" class="playtime-header">
{{ pt.key }}
</td>
</tr>
<tr v-for="rowIndex in getMaxRowsForPlayTime(pt.key)" :key="`${pt.key}-${rowIndex}`">
<td v-if="settings.showSequence" class="col-sequence">{{ rowIndex }}</td>
<td v-for="date in dates" :key="date" class="song-cell">
<div v-if="getSchedule(date, pt.key, rowIndex - 1)" class="song-item">
<img
v-if="settings.showCover && getSchedule(date, pt.key, rowIndex - 1).song.cover"
:src="convertToHttps(getSchedule(date, pt.key, rowIndex - 1).song.cover)"
:data-original-src="convertToHttps(getSchedule(date, pt.key, rowIndex - 1).song.cover)"
class="song-cover"
@error="handleImageError"
>
<div class="song-details">
<div v-if="settings.showTitle" class="song-title">
<span class="title-text">{{ getSchedule(date, pt.key, rowIndex - 1).song.title }}</span>
<!-- 重播标识 -->
<span v-if="getSchedule(date, pt.key, rowIndex - 1).replayRequestId != null" class="replay-badge-print"> {{ locale.replay }} </span>
<!-- 跨学期标识 -->
<span v-if="settings.currentSemester && getSchedule(date, pt.key, rowIndex - 1).song.semester && getSchedule(date, pt.key, rowIndex - 1).song.semester !== settings.currentSemester" class="cross-semester-badge-print"> {{ locale.crossSemester }} </span>
</div>
<div v-if="settings.showArtist" class="song-artist">
{{ getSchedule(date, pt.key, rowIndex - 1).song.artist }}
</div>
<div v-if="settings.showRequester || settings.showVotes" class="song-meta">
<span v-if="settings.showRequester" class="song-requester">
{{ getSchedule(date, pt.key, rowIndex - 1).song.requester }}
</span>
<span v-if="settings.showRequester && settings.showVotes" class="meta-divider">|</span>
<span v-if="settings.showVotes" class="song-votes">
{{ formatVotes(getSchedule(date, pt.key, rowIndex - 1)) }}
</span>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</template>
<template v-else>
<tbody>
<tr v-for="rowIndex in maxRowsAll" :key="rowIndex">
<td v-if="settings.showSequence" class="col-sequence">{{ rowIndex }}</td>
<td v-for="date in dates" :key="date" class="song-cell">
<div v-if="getScheduleAll(date, rowIndex - 1)" class="song-item">
<img
v-if="settings.showCover && getScheduleAll(date, rowIndex - 1).song.cover"
:src="convertToHttps(getScheduleAll(date, rowIndex - 1).song.cover)"
:data-original-src="convertToHttps(getScheduleAll(date, rowIndex - 1).song.cover)"
class="song-cover"
@error="handleImageError"
>
<div class="song-details">
<div v-if="settings.showTitle" class="song-title">
<span class="title-text">{{ getScheduleAll(date, rowIndex - 1).song.title }}</span>
<!-- 重播标识 -->
<span v-if="getScheduleAll(date, rowIndex - 1).replayRequestId != null" class="replay-badge-print"> {{ locale.replay }} </span>
<!-- 跨学期标识 -->
<span v-if="settings.currentSemester && getScheduleAll(date, rowIndex - 1).song.semester && getScheduleAll(date, rowIndex - 1).song.semester !== settings.currentSemester" class="cross-semester-badge-print"> {{ locale.crossSemester }} </span>
</div>
<div v-if="settings.showArtist" class="song-artist">
{{ getScheduleAll(date, rowIndex - 1).song.artist }}
</div>
<div v-if="settings.showRequester || settings.showVotes" class="song-meta">
<span v-if="settings.showRequester" class="song-requester">
{{ getScheduleAll(date, rowIndex - 1).song.requester }}
</span>
<span v-if="settings.showRequester && settings.showVotes" class="meta-divider">|</span>
<span v-if="settings.showVotes" class="song-votes">
{{ formatVotes(getScheduleAll(date, rowIndex - 1)) }}
</span>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</template>
</table>
</template>
<script setup>
import { computed } from 'vue'
import { convertToHttps } from '~/utils/url'
import { useLocale } from '~/utils/locale'
const { admin } = useLocale()
const locale = computed(() => admin.value.schedulePrinter)
const formatVotes = (schedule) => schedule.replayRequestId != null
? locale.value.replayCountShort(schedule.song.replayRequestCount)
: locale.value.popularityCountShort(schedule.song.voteCount || 0)
const props = defineProps({
groupedSchedules: {
type: Object,
required: true
},
settings: {
type: Object,
required: true
}
})
const dates = computed(() => Object.keys(props.groupedSchedules).sort())
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = dateStr.includes('-') && !dateStr.includes('T')
? new Date(dateStr.replace(/-/g, '/'))
: new Date(dateStr)
const month = date.getMonth() + 1
const day = date.getDate()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[date.getDay()]
return `${month}月${day}日 ${weekday}`
}
// playTimes logic
const playTimesMap = computed(() => {
const map = {}
Object.values(props.groupedSchedules).forEach(dateGroup => {
Object.entries(dateGroup.playTimes).forEach(([key, value]) => {
if (!map[key]) {
map[key] = { key, playTime: value.playTime }
}
})
})
return map
})
const getPlayTimeSortWeight = (playTime) => {
if (!playTime || !playTime.startTime) return 9999
const [hours, minutes] = playTime.startTime.split(':').map(Number)
return hours * 60 + minutes
}
const sortedPlayTimes = computed(() => {
return Object.values(playTimesMap.value).sort((a, b) => {
return getPlayTimeSortWeight(a.playTime) - getPlayTimeSortWeight(b.playTime)
})
})
const hasPlayTimes = computed(() => {
if (!props.settings.showPlayTime) return false
const keys = Object.keys(playTimesMap.value)
if (keys.length > 1) return true
if (keys.length === 1 && keys[0] !== '未指定时段') return true
return false
})
const getMaxRowsForPlayTime = (playTimeKey) => {
let max = 0
dates.value.forEach(date => {
const pt = props.groupedSchedules[date].playTimes[playTimeKey]
if (pt && pt.schedules.length > max) {
max = pt.schedules.length
}
})
return max
}
const getSchedule = (date, playTimeKey, index) => {
const pt = props.groupedSchedules[date]?.playTimes[playTimeKey]
if (pt && pt.schedules[index]) return pt.schedules[index]
return null
}
const maxRowsAll = computed(() => {
let max = 0
dates.value.forEach(date => {
const len = props.groupedSchedules[date].allSchedules.length
if (len > max) max = len
})
return max
})
const getScheduleAll = (date, index) => {
const group = props.groupedSchedules[date]
if (group && group.allSchedules[index]) return group.allSchedules[index]
return null
}
const handleImageError = (event) => {
event.target.style.display = 'none'
}
</script>
<style scoped>
.schedule-timetable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
page-break-inside: auto;
table-layout: fixed;
}
.schedule-timetable tr {
page-break-inside: avoid;
page-break-after: auto;
}
.schedule-timetable th,
.schedule-timetable td {
border: 1px solid #d1d5db;
padding: 8px;
vertical-align: top;
overflow: hidden;
}
.schedule-timetable th {
background: #f3f4f6;
font-weight: bold;
text-align: center;
color: #1a1a1a;
}
.col-sequence {
width: 30px;
text-align: center !important;
vertical-align: middle !important;
}
.col-date {
min-width: 120px;
font-size: 15px;
padding: 12px 8px !important;
text-align: center !important;
vertical-align: middle !important;
}
.playtime-header-row {
background: #f8f9fa;
}
.playtime-header {
font-weight: bold;
color: #2563eb;
text-align: center !important;
padding: 6px !important;
}
.song-item {
display: flex;
gap: 8px;
align-items: flex-start;
}
.song-cover {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.song-details {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.song-title {
font-weight: bold;
color: #1a1a1a;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
}
.title-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 打印用重播标识 */
.replay-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f8f9fa;
border: 1px solid #0b5afe;
border-radius: 3px;
color: #2563eb;
font-size: 10px;
font-weight: bold;
flex-shrink: 0;
margin-left: 4px;
}
/* 跨学期标识 */
.cross-semester-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f5f5f5;
border: 1px solid #d1d5db;
color: #636366;
font-size: 10px;
border-radius: 2px;
font-weight: normal;
margin-left: 4px;
flex-shrink: 0;
}
.song-artist {
color: #636366;
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-meta {
display: flex;
align-items: center;
gap: 4px;
color: #636366;
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-requester,
.song-votes {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta-divider {
color: #d1d5db;
font-size: 8px;
}
@media print {
.schedule-timetable {
color: #1a1a1a !important;
}
.schedule-timetable th,
.schedule-timetable td {
border-color: #d1d5db !important;
}
.schedule-timetable th {
background: #f3f4f6 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.playtime-header-row {
background: #f8f9fa !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>