LogDownloadPage.vue 8.5 KB
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vuestic-ui'
import systemToolsApi, { LogFileListItem } from '../../services/systemTools'

const { t } = useI18n()
const { init: notify } = useToast()

const isLoading = ref(false)
const isDownloading = ref(false)
const logFiles = ref<LogFileListItem[]>([])
const selectedFileNames = ref<string[]>([])

const formatDateTimeLocal = (date: Date) => {
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  const hour = String(date.getHours()).padStart(2, '0')
  const minute = String(date.getMinutes()).padStart(2, '0')
  return `${year}-${month}-${day}T${hour}:${minute}`
}

const now = new Date()
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const startTime = ref(formatDateTimeLocal(oneDayAgo))
const endTime = ref(formatDateTimeLocal(now))

const canSearch = computed(() => Boolean(startTime.value && endTime.value) && !isLoading.value)
const hasFiles = computed(() => logFiles.value.length > 0)
const hasSelection = computed(() => selectedFileNames.value.length > 0)
const allSelected = computed(
  () => logFiles.value.length > 0 && selectedFileNames.value.length === logFiles.value.length,
)

const unwrapData = <T,>(res: unknown): T | undefined => {
  if (!res || typeof res !== 'object') {
    return undefined
  }
  const value = res as { data?: T; Data?: T }
  return value.data ?? value.Data
}

const unwrapMessage = (res: unknown): string | undefined => {
  if (!res || typeof res !== 'object') {
    return undefined
  }
  const value = res as { message?: string; Message?: string }
  return value.message ?? value.Message
}

const validateRange = () => {
  if (!startTime.value || !endTime.value) {
    notify({ message: t('logDownload.messages.missingTimeRange'), color: 'warning' })
    return false
  }

  const start = new Date(startTime.value)
  const end = new Date(endTime.value)
  if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
    notify({ message: t('logDownload.messages.invalidTimeRange'), color: 'warning' })
    return false
  }

  if (start > end) {
    notify({ message: t('logDownload.messages.invalidTimeOrder'), color: 'warning' })
    return false
  }

  return true
}

const formatFileSize = (sizeBytes: number) => {
  if (sizeBytes < 1024) return `${sizeBytes} B`
  if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(2)} KB`
  if (sizeBytes < 1024 * 1024 * 1024) return `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB`
  return `${(sizeBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}

const formatDisplayTime = (time: string) => {
  const date = new Date(time)
  if (Number.isNaN(date.getTime())) return time
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  const hour = String(date.getHours()).padStart(2, '0')
  const minute = String(date.getMinutes()).padStart(2, '0')
  const second = String(date.getSeconds()).padStart(2, '0')
  return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}

const isSelected = (fileName: string) => selectedFileNames.value.includes(fileName)

const toggleSelected = (fileName: string, selected: boolean) => {
  if (selected) {
    if (!selectedFileNames.value.includes(fileName)) {
      selectedFileNames.value.push(fileName)
    }
    return
  }

  selectedFileNames.value = selectedFileNames.value.filter((name) => name !== fileName)
}

const selectAll = () => {
  selectedFileNames.value = logFiles.value.map((item) => item.fileName)
}

const clearSelection = () => {
  selectedFileNames.value = []
}

const refreshLogFiles = async () => {
  if (!validateRange()) {
    return
  }

  isLoading.value = true
  try {
    const res = await systemToolsApi.listLogFiles({
      startTime: startTime.value,
      endTime: endTime.value,
    })

    const list = Array.isArray(res) ? res : (unwrapData<LogFileListItem[]>(res) ?? [])
    logFiles.value = Array.isArray(list) ? list : []

    const validNames = new Set(logFiles.value.map((item) => item.fileName))
    selectedFileNames.value = selectedFileNames.value.filter((name) => validNames.has(name))
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : undefined
    notify({ message: message || t('logDownload.messages.fetchFailed'), color: 'danger' })
  } finally {
    isLoading.value = false
  }
}

const downloadSelected = async () => {
  if (!validateRange()) {
    return
  }

  if (!hasSelection.value) {
    notify({ message: t('logDownload.messages.selectAtLeastOne'), color: 'warning' })
    return
  }

  isDownloading.value = true
  try {
    const res = await systemToolsApi.downloadLogFiles(selectedFileNames.value)
    const fileName = res.fileName || `logs-${Date.now()}.zip`
    const url = window.URL.createObjectURL(res.blob)
    const link = document.createElement('a')
    link.href = url
    link.download = fileName
    link.click()
    window.URL.revokeObjectURL(url)
    notify({ message: t('logDownload.messages.downloadSuccess'), color: 'success' })
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : unwrapMessage(err) || t('logDownload.messages.downloadFailed')
    notify({ message, color: 'danger' })
  } finally {
    isDownloading.value = false
  }
}
</script>

<template>
  <VaCard class="log-download-page">
    <VaCardTitle>{{ t('logDownload.title') }}</VaCardTitle>
    <VaCardContent>
      <div class="form-row">
        <VaInput v-model="startTime" type="datetime-local" :label="t('logDownload.form.startTime')" class="field" />
        <VaInput v-model="endTime" type="datetime-local" :label="t('logDownload.form.endTime')" class="field" />
        <VaButton color="primary" :loading="isLoading" :disabled="!canSearch" @click="refreshLogFiles">
          {{ t('logDownload.actions.search') }}
        </VaButton>
      </div>

      <div class="action-row">
        <VaButton preset="secondary" :disabled="!hasFiles || allSelected" @click="selectAll">
          {{ t('logDownload.actions.selectAll') }}
        </VaButton>
        <VaButton preset="secondary" :disabled="!hasSelection" @click="clearSelection">
          {{ t('logDownload.actions.clearSelection') }}
        </VaButton>
        <VaButton
          color="success"
          :loading="isDownloading"
          :disabled="!hasSelection || isLoading"
          @click="downloadSelected"
        >
          {{ t('logDownload.actions.downloadSelected') }}
        </VaButton>
      </div>

      <div v-if="!hasFiles" class="empty-state">
        {{ t('logDownload.messages.noFiles') }}
      </div>

      <div v-else class="table-wrapper">
        <table class="log-table">
          <thead>
            <tr>
              <th class="col-select">{{ t('logDownload.table.select') }}</th>
              <th>{{ t('logDownload.table.fileName') }}</th>
              <th>{{ t('logDownload.table.fileDate') }}</th>
              <th>{{ t('logDownload.table.fileSize') }}</th>
              <th>{{ t('logDownload.table.lastWriteTime') }}</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in logFiles" :key="item.fileName">
              <td class="col-select">
                <VaCheckbox
                  :model-value="isSelected(item.fileName)"
                  @update:modelValue="(value) => toggleSelected(item.fileName, Boolean(value))"
                />
              </td>
              <td>{{ item.fileName }}</td>
              <td>{{ item.fileDate }}</td>
              <td>{{ formatFileSize(item.sizeBytes) }}</td>
              <td>{{ formatDisplayTime(item.lastWriteTime) }}</td>
            </tr>
          </tbody>
        </table>
      </div>
    </VaCardContent>
  </VaCard>
</template>

<style scoped lang="scss">
.log-download-page {
  width: 100%;
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr auto;
  gap: 0.75rem;
  align-items: end;
}

.field {
  width: 100%;
}

.action-row {
  display: flex;
  gap: 0.75rem;
  margin-top: 1rem;
  margin-bottom: 1rem;
  flex-wrap: wrap;
}

.empty-state {
  color: var(--va-secondary);
  padding: 1rem 0;
}

.table-wrapper {
  overflow-x: auto;
}

.log-table {
  width: 100%;
  border-collapse: collapse;

  th,
  td {
    border-bottom: 1px solid var(--va-background-border);
    padding: 0.5rem 0.75rem;
    text-align: left;
    white-space: nowrap;
  }

  th {
    font-weight: 600;
  }

  .col-select {
    width: 88px;
  }
}

@media (max-width: 900px) {
  .form-row {
    grid-template-columns: 1fr;
  }
}
</style>