前端代码

This commit is contained in:
2026-08-13 22:35:13 +08:00
parent 9044bafdff
commit f1a0b17bb0
412 changed files with 36769 additions and 6617 deletions
+295
View File
@@ -0,0 +1,295 @@
<template>
<div>
<div class="kra-search-box">
<el-form :inline="true" :model="searchInfo">
<el-form-item label="用户ID">
<el-input v-model.number="searchInfo.userId" placeholder="搜索用户ID" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchInfo.status" placeholder="请选择" clearable>
<el-option label="有效" :value="true" />
<el-option label="无效" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list">
<el-button type="primary" icon="plus" @click="openDrawer">签发</el-button>
</div>
<el-table
:data="tableData"
style="width: 100%"
tooltip-effect="dark"
row-key="ID"
>
<el-table-column align="left" label="ID" prop="ID" width="80" />
<el-table-column align="left" label="用户" min-width="150">
<template #default="scope">
{{ scope.row.user.nickName }} ({{ scope.row.user.userName }})
</template>
</el-table-column>
<el-table-column align="left" label="角色ID" prop="authorityId" width="100" />
<el-table-column align="left" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status ? 'success' : 'danger'">
{{ scope.row.status ? '有效' : '已作废' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="过期时间" width="180">
<template #default="scope">{{ formatDate(scope.row.expiresAt) }}</template>
</el-table-column>
<el-table-column align="left" label="备注" prop="remark" min-width="150" show-overflow-tooltip />
<el-table-column align="left" label="操作" width="220">
<template #default="scope">
<el-button type="primary" link icon="link" @click="openCurl(scope.row)">Curl示例</el-button>
<el-popover v-if="scope.row.status" v-model:visible="scope.row.visible" placement="top" width="160">
<p>确定要作废吗?</p>
<div style="text-align: right; margin: 0">
<el-button size="small" type="primary" link @click="scope.row.visible = false">取消</el-button>
<el-button size="small" type="primary" @click="invalidateToken(scope.row)">确定</el-button>
</div>
<template #reference>
<el-button icon="delete" type="danger" link @click="scope.row.visible = true">作废</el-button>
</template>
</el-popover>
</template>
</el-table-column>
</el-table>
<div class="kra-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-drawer v-model="drawerVisible" size="400px" title="签发 API Token">
<el-form ref="formRef" :model="form" label-width="80px">
<el-form-item label="用户" required>
<el-select
v-model="form.userId"
placeholder="请选择用户"
filterable
style="width:100%"
@change="handleUserChange"
>
<el-option
v-for="item in userOptions"
:key="item.ID"
:label="`${item.nickName} (${item.userName})`"
:value="item.ID"
/>
</el-select>
</el-form-item>
<el-form-item label="角色" required>
<el-select v-model="form.authorityId" placeholder="请选择角色" style="width:100%" :disabled="!form.userId">
<el-option
v-for="item in authorityOptions"
:key="item.authorityId"
:label="`${item.authorityName} (${item.authorityId})`"
:value="item.authorityId"
/>
</el-select>
</el-form-item>
<el-form-item label="有效期">
<el-select v-model="form.days" placeholder="请选择" style="width:100%">
<el-option label="1天" :value="1" />
<el-option label="7天" :value="7" />
<el-option label="30天" :value="30" />
<el-option label="90天" :value="90" />
<el-option label="永久" :value="-1" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<div style="flex: auto">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="submitIssuer">签发JWT</el-button>
</div>
</template>
</el-drawer>
<el-dialog v-model="tokenDialogVisible" title="签发成功" width="500px">
<div style="text-align: center; margin-bottom: 20px;">
<el-alert title="请立即复制保存,关闭后将无法再次查看完整Token" type="warning" :closable="false" show-icon />
</div>
<el-input type="textarea" :rows="6" v-model="tokenResult" readonly />
<template #footer>
<el-button @click="copyText(tokenResult)">复制</el-button>
<el-button type="primary" @click="tokenDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-drawer v-model="curlDrawerVisible" size="500px" title="Curl 示例">
<div style="padding: 10px;">
<p style="margin-bottom: 10px;">Header 方式:</p>
<el-input type="textarea" :rows="4" v-model="curlHeader" readonly />
<el-button style="margin-top: 5px;" size="small" @click="copyText(curlHeader)">复制</el-button>
<el-divider />
<p style="margin-bottom: 10px;">Cookie 方式:</p>
<el-input type="textarea" :rows="4" v-model="curlCookie" readonly />
<el-button style="margin-top: 5px;" size="small" @click="copyText(curlCookie)">复制</el-button>
</div>
</el-drawer>
</div>
</template>
<script setup>
import {
getApiTokenList,
createApiToken,
deleteApiToken
} from '@/api/sysApiToken'
import { getUserList } from '@/api/user'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { formatDate } from '@/utils/format'
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({})
const drawerVisible = ref(false)
const tokenDialogVisible = ref(false)
const tokenResult = ref('')
const curlDrawerVisible = ref(false)
const curlHeader = ref('')
const curlCookie = ref('')
const form = ref({
userId: '',
authorityId: '',
days: 30,
remark: ''
})
const userOptions = ref([])
const authorityOptions = ref([])
const getTableData = async () => {
const table = await getApiTokenList({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
const openDrawer = async () => {
form.value = { userId: '', authorityId: '', days: 30, remark: '' }
authorityOptions.value = []
drawerVisible.value = true
if (userOptions.value.length === 0) {
const res = await getUserList({ page: 1, pageSize: 999 })
if (res.code === 0) {
userOptions.value = res.data.list
}
}
}
const handleUserChange = (val) => {
form.value.authorityId = ''
const user = userOptions.value.find(u => u.ID === val)
if (user) {
authorityOptions.value = user.authorities || []
// 默认选中第一个
if (authorityOptions.value.length > 0) {
form.value.authorityId = authorityOptions.value[0].authorityId
}
} else {
authorityOptions.value = []
}
}
const submitIssuer = async () => {
if (!form.value.userId || !form.value.authorityId) {
ElMessage.warning("请选择用户和角色")
return
}
const res = await createApiToken(form.value)
if (res.code === 0) {
tokenResult.value = res.data.token
drawerVisible.value = false
tokenDialogVisible.value = true
getTableData()
}
}
const invalidateToken = async (row) => {
row.visible = false
const res = await deleteApiToken({ ID: row.ID })
if (res.code === 0) {
ElMessage.success("作废成功")
getTableData()
}
}
const openCurl = (row) => {
// 假设 API Host 为当前 origin
const origin = window.location.origin
// 构造示例 URL
const url = `${origin}/api/menu/getMenu`
curlHeader.value = `curl -X POST "${url}" -H "x-token: ${row.token}" -H "Content-Type: application/json"`
curlCookie.value = `curl -X POST "${url}" -b "x-token=${row.token}" -H "Content-Type: application/json"`
curlDrawerVisible.value = true
}
const copyText = (text) => {
if (!text) return
const input = document.createElement('textarea')
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
ElMessage.success('复制成功')
}
const onSubmit = () => {
page.value = 1
pageSize.value = 10
getTableData()
}
const onReset = () => {
searchInfo.value = {}
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
getTableData()
</script>
<style scoped>
</style>
@@ -0,0 +1,659 @@
<template>
<div
ref="rootRef"
class="p-2 flex flex-col overflow-hidden bg-main text-base-text"
:style="{ height: rootHeight }"
>
<section class="kra-table-box !my-0 !p-0 h-full min-h-0 flex-1 overflow-hidden rounded-[var(--kra-radius)] border border-border shadow-card md:grid md:grid-cols-[240px_minmax(0,1fr)]">
<aside class="hidden min-h-0 border-r border-border md:flex md:flex-col">
<div class="border-b border-border p-3">
<div class="mb-3 flex items-center justify-between">
<el-tooltip content="上个月" placement="top">
<button type="button" class="kra-tool-btn h-8 w-8" aria-label="上个月" @click="changeMonth(-1)">
<svg-icon icon="lucide:chevron-left" class="h-4 w-4" />
</button>
</el-tooltip>
<strong class="text-sm font-semibold tracking-wide">{{ monthLabel }}</strong>
<el-tooltip content="下个月" placement="top">
<button type="button" class="kra-tool-btn h-8 w-8" aria-label="下个月" @click="changeMonth(1)">
<svg-icon icon="lucide:chevron-right" class="h-4 w-4" />
</button>
</el-tooltip>
</div>
<div class="grid grid-cols-7 gap-1 text-center text-xs text-muted-foreground">
<span v-for="weekday in weekdays" :key="weekday" class="py-1 font-medium">{{ weekday }}</span>
</div>
<div v-loading="loadingDates" class="grid min-h-48 grid-cols-7 gap-1">
<button
v-for="cell in calendarCells"
:key="cell.date"
type="button"
class="relative aspect-square min-h-7 rounded-md border border-transparent text-xs transition-colors"
:class="calendarCellClass(cell)"
:disabled="!cell.inMonth || !cell.available"
:aria-label="cell.available ? `查看 ${cell.date} 日志` : `${cell.date} 无日志`"
@click="selectDate(cell.date)"
>
{{ cell.day }}
<i
v-if="cell.inMonth && cell.available && cell.date !== selectedDate"
class="absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-success"
/>
</button>
</div>
<div class="mt-3 flex items-center gap-4 text-xs text-muted-foreground">
<span class="flex items-center gap-1.5"><i class="h-1.5 w-1.5 rounded-full bg-success" />有日志</span>
<span class="flex items-center gap-1.5"><i class="h-2.5 w-2.5 rounded-[4px] bg-primary" />已选择</span>
</div>
</div>
<div class="min-h-0 flex-1 p-3">
<div class="mb-2 flex items-center justify-between px-1 text-xs text-muted-foreground">
<span class="font-medium">{{ selectedDate || '未选择日期' }}</span>
<span class="rounded-full bg-muted px-2 py-0.5">{{ files.length }} 个文件</span>
</div>
<div v-loading="loadingFiles" class="h-full overflow-y-auto">
<button
v-for="file in files"
:key="file.path"
type="button"
class="mb-1 flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors hover:bg-black/5 dark:hover:bg-white/5"
:class="file.path === activePath ? 'bg-primary-50 font-medium text-primary dark:bg-primary/15' : 'text-base-text'"
@click="openFile(file.path)"
>
<svg-icon icon="lucide:file-text" class="h-4 w-4 shrink-0" :class="file.path === activePath ? 'text-primary' : 'text-muted-foreground'" />
<span class="min-w-0 flex-1 truncate" :title="file.path">{{ file.path }}</span>
<span class="shrink-0 tabular-nums text-muted-foreground">{{ formatFileSize(file.size) }}</span>
</button>
<div v-if="selectedDate && !loadingFiles && files.length === 0" class="flex h-32 flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
<svg-icon icon="lucide:folder-open" class="h-6 w-6 opacity-50" />
该日期暂无日志文件
</div>
</div>
</div>
</aside>
<main class="flex h-full min-h-0 min-w-0 flex-col">
<div class="border-b border-border p-3 md:hidden">
<div class="mb-3 flex items-center justify-between">
<button type="button" class="kra-tool-btn h-8 w-8" aria-label="上个月" @click="changeMonth(-1)">
<svg-icon icon="lucide:chevron-left" class="h-4 w-4" />
</button>
<strong class="text-sm">{{ monthLabel }}</strong>
<button type="button" class="kra-tool-btn h-8 w-8" aria-label="下个月" @click="changeMonth(1)">
<svg-icon icon="lucide:chevron-right" class="h-4 w-4" />
</button>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<el-select
:model-value="selectedDate"
placeholder="选择有日志的日期"
:loading="loadingDates"
@change="selectDate"
>
<el-option v-for="item in availableDates" :key="item.date" :label="`${item.date} (${item.fileCount})`" :value="item.date" />
</el-select>
<el-select
:model-value="activePath"
placeholder="选择日志文件"
:loading="loadingFiles"
:disabled="files.length === 0"
@change="openFile"
>
<el-option v-for="file in files" :key="file.path" :label="file.path" :value="file.path" />
</el-select>
</div>
</div>
<el-tabs
:model-value="activePath"
type="card"
closable
class="log-tabs min-w-0 border-b border-border px-3 pb-2 pt-2"
@tab-change="openFile"
@tab-remove="closeTab"
>
<el-tab-pane
v-for="path in openedPaths"
:key="path"
:name="path"
>
<template #label>
<span class="block max-w-52 truncate" :title="path">{{ path }}</span>
</template>
</el-tab-pane>
</el-tabs>
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
<div class="flex min-w-0 flex-1 items-center gap-3">
<el-input v-model="searchText" clearable placeholder="搜索已加载内容" class="max-w-72" :disabled="!content">
<template #prefix>
<svg-icon icon="lucide:search" class="h-4 w-4" />
</template>
</el-input>
<label class="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<el-switch v-model="wrapLines" aria-label="自动换行" />
自动换行
</label>
</div>
<div class="flex items-center gap-1">
<span class="mr-2 hidden rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground sm:inline">{{ contentMeta }}</span>
<el-tooltip content="复制已加载日志" placement="top">
<button type="button" class="kra-tool-btn h-8 w-8" aria-label="复制已加载日志" :disabled="!content" @click="copyContent">
<svg-icon icon="lucide:copy" class="h-4 w-4" />
</button>
</el-tooltip>
<el-tooltip content="刷新当前日志" placement="top">
<button
type="button"
class="kra-tool-btn h-8 w-8"
aria-label="刷新当前日志"
:disabled="!activePath || contentBusy"
@click="refreshContent"
>
<svg-icon icon="lucide:refresh-cw" class="h-4 w-4" :class="{ 'animate-spin': contentBusy }" />
</button>
</el-tooltip>
</div>
</div>
<div v-if="activeFile" class="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-border px-3 py-2 text-xs text-muted-foreground">
<span class="truncate font-medium text-base-text" :title="activeFile.path">{{ activeFile.path }}</span>
<span class="tabular-nums">{{ formatFileSize(activeFile.size) }}</span>
<span>{{ formatDate(activeFile.modifiedAt) }}</span>
<span v-if="limitedByBytes" class="text-warning">本段已达到 2 MiB 上限</span>
</div>
<div class="relative min-h-0 flex-1 bg-base-text text-container">
<div v-if="hasMore" class="absolute inset-x-0 top-0 z-10 flex justify-center py-2">
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-full bg-primary px-4 text-xs text-white shadow-lg transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="contentBusy"
@click="loadOlder"
>
<svg-icon icon="lucide:history" class="h-4 w-4" :class="{ 'animate-spin': loadingOlder }" />
加载更早日志
</button>
</div>
<div
ref="contentScrollRef"
id="log-viewer-content"
role="tabpanel"
v-loading="loadingContent"
class="h-full overflow-auto font-mono text-xs leading-6"
:class="hasMore ? 'pt-12' : 'pt-2'"
>
<div v-if="displayLines.length" :class="wrapLines ? 'min-w-0' : 'min-w-max'">
<div
v-for="line in displayLines"
:key="line.sourceIndex"
:data-log-line-index="line.sourceIndex"
class="grid grid-cols-[3.5rem_minmax(0,1fr)] px-3 transition-colors hover:bg-container/5"
>
<span class="select-none border-r border-container/10 pr-3 text-right text-muted-foreground">{{ line.number }}</span>
<span class="pl-3" :class="[lineTone(line.text), wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre']">
<template v-for="(segment, index) in highlightedSegments(line.text)" :key="`${line.number}-${index}`">
<mark v-if="segment.match" class="rounded-sm bg-warning-300 px-0.5 text-black">{{ segment.text }}</mark>
<template v-else>{{ segment.text }}</template>
</template>
</span>
</div>
</div>
<div v-else-if="!loadingContent" class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<svg-icon icon="lucide:terminal" class="h-8 w-8 opacity-40" />
<span>{{ emptyContentText }}</span>
</div>
</div>
</div>
</main>
</section>
</div>
</template>
<script setup>
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onMounted,
ref
} from 'vue'
import { ElMessage } from 'element-plus'
import { getLogContent, getLogDates, getLogFiles } from '@/api/logViewer'
import { formatDate } from '@/utils/format'
defineOptions({ name: 'LogViewer' })
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
const MAX_LOADED_LINES = 5000
const MAX_LOADED_CHARS = 10 * 1024 * 1024
const now = new Date()
const todayText = formatLocalDate(now)
const visibleMonth = ref(formatMonth(now))
const availableDates = ref([])
const selectedDate = ref('')
const files = ref([])
const openedPaths = ref([])
const activePath = ref('')
const content = ref('')
const nextCursor = ref(0)
const hasMore = ref(false)
const limitedByBytes = ref(false)
const windowLimited = ref(false)
const searchText = ref('')
const wrapLines = ref(false)
const loadingDates = ref(false)
const loadingFiles = ref(false)
const loadingContent = ref(false)
const loadingOlder = ref(false)
const rootRef = ref(null)
const rootHeight = ref('auto')
const contentScrollRef = ref(null)
const contentBusy = computed(() => loadingContent.value || loadingOlder.value)
let dateRequestId = 0
let fileRequestId = 0
let contentRequestId = 0
const availableDateSet = computed(() => new Set(availableDates.value.map(item => item.date)))
const monthLabel = computed(() => {
const [year, month] = visibleMonth.value.split('-')
return `${year} 年 ${Number(month)} 月`
})
const activeFile = computed(() => files.value.find(file => file.path === activePath.value) || null)
const contentMeta = computed(() => {
if (!activePath.value) return ''
const suffix = hasMore.value ? ' · 仍有更早内容' : ''
const windowSuffix = windowLimited.value ? ' · 已限制显示窗口' : ''
return `已加载 ${allLines.value.length} 行${suffix}${windowSuffix}`
})
const calendarCells = computed(() => {
const [year, month] = visibleMonth.value.split('-').map(Number)
const firstDay = new Date(year, month - 1, 1)
const mondayOffset = (firstDay.getDay() + 6) % 7
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(year, month - 1, 1 - mondayOffset + index)
const dateText = formatLocalDate(date)
return {
date: dateText,
day: date.getDate(),
inMonth: date.getMonth() === month - 1,
available: availableDateSet.value.has(dateText)
}
})
})
const allLines = computed(() => {
if (!content.value) return []
const lines = content.value.split('\n')
if (lines[lines.length - 1] === '') lines.pop()
return lines.map((text, index) => ({ number: index + 1, sourceIndex: index, text }))
})
const displayLines = computed(() => {
const keyword = searchText.value.trim().toLowerCase()
if (!keyword) return allLines.value
return allLines.value.filter(line => line.text.toLowerCase().includes(keyword))
})
const emptyContentText = computed(() => {
if (!activePath.value) return '请先选择日志日期和文件'
if (searchText.value && allLines.value.length) return '当前已加载内容中没有匹配结果'
return '日志文件为空'
})
function formatMonth(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
function formatLocalDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function formatFileSize(size) {
if (!Number.isFinite(size) || size <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1)
const value = size / (1024 ** unitIndex)
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
}
function calendarCellClass(cell) {
if (!cell.inMonth) return 'cursor-default text-muted-foreground opacity-30'
if (cell.date === selectedDate.value) return 'border-primary bg-primary text-white font-semibold shadow-sm'
if (cell.available) {
return cell.date === todayText
? 'border-primary/50 text-success font-semibold hover:bg-success-50'
: 'text-success font-medium hover:bg-success-50 hover:border-success/40'
}
if (cell.date === todayText) return 'border-primary/50 font-semibold text-base-text'
return 'cursor-not-allowed text-muted-foreground opacity-50'
}
function lineTone(text) {
if (/\b(ERROR|DPANIC|PANIC|FATAL)\b/i.test(text)) return 'text-error'
if (/\bWARN\b/i.test(text)) return 'text-warning'
if (/\bDEBUG\b/i.test(text)) return 'text-info'
return 'text-container'
}
function highlightedSegments(text) {
const keyword = searchText.value.trim()
if (!keyword) return [{ text, match: false }]
const lowerText = text.toLowerCase()
const lowerKeyword = keyword.toLowerCase()
const segments = []
let cursor = 0
let matchIndex = lowerText.indexOf(lowerKeyword)
while (matchIndex !== -1) {
if (matchIndex > cursor) segments.push({ text: text.slice(cursor, matchIndex), match: false })
segments.push({ text: text.slice(matchIndex, matchIndex + keyword.length), match: true })
cursor = matchIndex + keyword.length
matchIndex = lowerText.indexOf(lowerKeyword, cursor)
}
if (cursor < text.length) segments.push({ text: text.slice(cursor), match: false })
return segments.length ? segments : [{ text, match: false }]
}
function resetContentState() {
contentRequestId++
loadingContent.value = false
loadingOlder.value = false
activePath.value = ''
content.value = ''
nextCursor.value = 0
hasMore.value = false
limitedByBytes.value = false
windowLimited.value = false
searchText.value = ''
}
async function loadDates() {
const requestId = ++dateRequestId
loadingDates.value = true
try {
const result = await getLogDates({ month: visibleMonth.value })
if (requestId !== dateRequestId || result.code !== 0) return
availableDates.value = result.data.dates || []
const latestDate = availableDates.value.at(-1)?.date || ''
if (latestDate) {
await selectDate(latestDate)
} else {
fileRequestId++
selectedDate.value = ''
files.value = []
openedPaths.value = []
resetContentState()
}
} finally {
if (requestId === dateRequestId) loadingDates.value = false
}
}
async function changeMonth(offset) {
const [year, month] = visibleMonth.value.split('-').map(Number)
fileRequestId++
visibleMonth.value = formatMonth(new Date(year, month - 1 + offset, 1))
selectedDate.value = ''
files.value = []
openedPaths.value = []
resetContentState()
await loadDates()
}
async function selectDate(date) {
if (!date || !availableDateSet.value.has(date)) return
const requestId = ++fileRequestId
selectedDate.value = date
files.value = []
openedPaths.value = []
resetContentState()
loadingFiles.value = true
try {
const result = await getLogFiles({ date })
if (requestId !== fileRequestId || selectedDate.value !== date || result.code !== 0) return
files.value = result.data.files || []
const defaultFile = files.value.find(file => file.path === 'info.log') || files.value[0]
if (defaultFile) await openFile(defaultFile.path)
} finally {
if (requestId === fileRequestId) loadingFiles.value = false
}
}
async function openFile(path) {
if (!path || !files.value.some(file => file.path === path)) return
if (!openedPaths.value.includes(path)) openedPaths.value.push(path)
activePath.value = path
searchText.value = ''
await fetchLatestContent(path)
}
async function fetchLatestContent(path) {
const requestId = ++contentRequestId
const date = selectedDate.value
loadingOlder.value = false
loadingContent.value = true
try {
const result = await getLogContent({ date, path })
if (requestId !== contentRequestId || activePath.value !== path || selectedDate.value !== date) return
if (result.code !== 0) {
await reloadFilesAfterContentFailure(path)
return
}
applyContentResult(result.data)
await nextTick()
if (contentScrollRef.value) contentScrollRef.value.scrollTop = contentScrollRef.value.scrollHeight
} finally {
if (requestId === contentRequestId) loadingContent.value = false
}
}
function applyContentResult(data) {
const bounded = boundLoadedContent(data.content || '')
content.value = bounded.content
windowLimited.value = bounded.limited
nextCursor.value = data.nextCursor || 0
hasMore.value = Boolean(data.hasMore)
limitedByBytes.value = Boolean(data.limitedByBytes)
const file = files.value.find(item => item.path === activePath.value)
if (file) {
file.size = data.size
file.modifiedAt = data.modifiedAt
}
}
async function loadOlder() {
if (!activePath.value || !hasMore.value || contentBusy.value) return
const requestId = ++contentRequestId
const date = selectedDate.value
const path = activePath.value
const scrollElement = contentScrollRef.value
const previousHeight = scrollElement?.scrollHeight || 0
const anchor = captureScrollAnchor(scrollElement)
loadingOlder.value = true
try {
const result = await getLogContent({ date, path, cursor: nextCursor.value })
if (requestId !== contentRequestId || activePath.value !== path || selectedDate.value !== date) return
if (result.code !== 0) {
await reloadFilesAfterContentFailure(path)
return
}
const olderContent = result.data.content || ''
const addedLineBreaks = countLineBreaks(olderContent)
const bounded = boundLoadedContent(olderContent + content.value)
content.value = bounded.content
windowLimited.value = windowLimited.value || bounded.limited
nextCursor.value = result.data.nextCursor || 0
hasMore.value = Boolean(result.data.hasMore)
limitedByBytes.value = Boolean(result.data.limitedByBytes)
await nextTick()
restoreScrollAnchor(scrollElement, anchor, addedLineBreaks, previousHeight)
} finally {
if (requestId === contentRequestId) loadingOlder.value = false
}
}
async function refreshContent() {
if (activePath.value && !contentBusy.value) await fetchLatestContent(activePath.value)
}
async function reloadFilesAfterContentFailure(failedPath) {
const date = selectedDate.value
if (!date) return
const requestId = ++fileRequestId
loadingFiles.value = true
resetContentState()
try {
const result = await getLogFiles({ date })
if (requestId !== fileRequestId || selectedDate.value !== date || result.code !== 0) return
files.value = result.data.files || []
const validPaths = new Set(files.value.map(file => file.path))
if (validPaths.has(failedPath)) {
openedPaths.value = openedPaths.value.filter(path => validPaths.has(path))
return
}
openedPaths.value = openedPaths.value.filter(path => path !== failedPath && validPaths.has(path))
const nextPath = openedPaths.value[0] || files.value[0]?.path || ''
if (nextPath) await openFile(nextPath)
} finally {
if (requestId === fileRequestId) loadingFiles.value = false
}
}
function boundLoadedContent(value) {
let bounded = value
let limited = false
if (bounded.length > MAX_LOADED_CHARS) {
bounded = bounded.slice(0, MAX_LOADED_CHARS)
limited = true
}
let lineBreaks = 0
for (let index = 0; index < bounded.length; index++) {
if (bounded[index] !== '\n') continue
lineBreaks++
if (lineBreaks === MAX_LOADED_LINES && index + 1 < bounded.length) {
bounded = bounded.slice(0, index + 1)
limited = true
break
}
}
return { content: bounded, limited }
}
function countLineBreaks(value) {
let count = 0
for (let index = 0; index < value.length; index++) {
if (value[index] === '\n') count++
}
return count
}
function captureScrollAnchor(scrollElement) {
if (!scrollElement) return null
const viewportTop = scrollElement.getBoundingClientRect().top
const lineElements = scrollElement.querySelectorAll('[data-log-line-index]')
const anchor = Array.from(lineElements).find(element => element.getBoundingClientRect().bottom >= viewportTop)
if (!anchor) return null
return {
sourceIndex: Number(anchor.dataset.logLineIndex),
top: anchor.getBoundingClientRect().top
}
}
function restoreScrollAnchor(scrollElement, anchor, addedLineBreaks, previousHeight) {
if (!scrollElement) return
if (anchor) {
const targetIndex = anchor.sourceIndex + addedLineBreaks
const target = scrollElement.querySelector(`[data-log-line-index="${targetIndex}"]`)
if (target) {
scrollElement.scrollTop += target.getBoundingClientRect().top - anchor.top
return
}
}
scrollElement.scrollTop = Math.max(0, scrollElement.scrollTop + scrollElement.scrollHeight - previousHeight)
}
function closeTab(path) {
const index = openedPaths.value.indexOf(path)
if (index === -1) return
openedPaths.value.splice(index, 1)
if (path !== activePath.value) return
const nextPath = openedPaths.value[index] || openedPaths.value[index - 1] || ''
resetContentState()
if (nextPath) openFile(nextPath)
}
async function copyContent() {
if (!content.value) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(content.value)
} else {
const textarea = document.createElement('textarea')
textarea.value = content.value
textarea.setAttribute('readonly', '')
textarea.className = 'fixed left-[-9999px] top-0'
document.body.appendChild(textarea)
textarea.select()
if (!document.execCommand('copy')) throw new Error('copy failed')
document.body.removeChild(textarea)
}
ElMessage.success('日志内容已复制')
} catch {
ElMessage.error('复制失败')
}
}
const updateRootHeight = () => {
const element = rootRef.value
if (!element) return
const top = element.getBoundingClientRect().top
rootHeight.value = `${Math.max(320, window.innerHeight - top - 32)}px`
}
onMounted(() => {
nextTick(updateRootHeight)
window.addEventListener('resize', updateRootHeight)
loadDates()
})
onActivated(updateRootHeight)
onBeforeUnmount(() => window.removeEventListener('resize', updateRootHeight))
</script>
<style scoped>
/* el-tabs 卡片样式重写为轻量 chip:去掉默认硬边框与底线,选中态主色浅底 */
.log-tabs :deep(.el-tabs--card > .el-tabs__header) {
margin-bottom: 0;
border-bottom: none;
}
.log-tabs :deep(.el-tabs--card > .el-tabs__header .el-tabs__nav-wrap::after) {
content: none;
}
.log-tabs :deep(.el-tabs--card > .el-tabs__header .el-tabs__nav) {
border: none;
border-radius: 0;
}
.log-tabs :deep(.el-tabs--card > .el-tabs__header .el-tabs__item) {
height: 32px;
margin-right: 4px;
padding: 0 12px;
border: none;
border-radius: 6px;
line-height: 32px;
font-size: 12px;
color: rgb(var(--muted-foreground-color));
transition: color 0.2s ease, background-color 0.2s ease;
}
.log-tabs :deep(.el-tabs--card > .el-tabs__header .el-tabs__item:hover) {
color: rgb(var(--primary-color));
}
.log-tabs :deep(.el-tabs--card > .el-tabs__header .el-tabs__item.is-active) {
background: rgb(var(--primary-color) / 0.1);
color: rgb(var(--primary-color));
font-weight: 500;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<template>
<div>
<div class="kra-search-box">
<el-form :inline="true" :model="searchInfo">
<el-form-item label="用户名">
<el-input v-model="searchInfo.username" placeholder="搜索用户名" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchInfo.status" placeholder="请选择" clearable>
<el-option label="成功" :value="true" />
<el-option label="失败" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list">
<el-button
icon="delete"
style="margin-left: 10px;"
:disabled="!multipleSelection.length"
@click="onDelete"
>删除</el-button>
</div>
<el-table
ref="multipleTable"
:data="tableData"
style="width: 100%"
tooltip-effect="dark"
row-key="ID"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column align="left" label="ID" prop="ID" width="80" />
<el-table-column align="left" label="用户名" prop="username" width="150" />
<el-table-column align="left" label="登录IP" prop="ip" width="150" />
<el-table-column align="left" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status ? 'success' : 'danger'">
{{ scope.row.status ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="详情" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.status ? '登录成功' : scope.row.errorMessage }}
</template>
</el-table-column>
<el-table-column align="left" label="浏览器/设备" prop="agent" show-overflow-tooltip />
<el-table-column align="left" label="登录时间" width="180">
<template #default="scope">{{ formatDate(scope.row.CreatedAt) }}</template>
</el-table-column>
<el-table-column align="left" label="操作" width="120">
<template #default="scope">
<el-popover v-model:visible="scope.row.visible" placement="top" width="160">
<p>确定要删除吗?</p>
<div style="text-align: right; margin: 0">
<el-button size="small" type="primary" link @click="scope.row.visible = false">取消</el-button>
<el-button size="small" type="primary" @click="deleteRow(scope.row)">确定</el-button>
</div>
<template #reference>
<el-button icon="delete" type="primary" link @click="scope.row.visible = true">删除</el-button>
</template>
</el-popover>
</template>
</el-table-column>
</el-table>
<div class="kra-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
getLoginLogList,
deleteLoginLog,
deleteLoginLogByIds
} from '@/api/sysLoginLog'
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatDate } from '@/utils/format'
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({})
const multipleSelection = ref([])
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
const getTableData = async () => {
const table = await getLoginLogList({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
const deleteRow = async (row) => {
row.visible = false
const res = await deleteLoginLog(row)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === 1 && page.value > 1) {
page.value--
}
getTableData()
}
}
const onDelete = async() => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async() => {
const ids = multipleSelection.value.map(item => item.ID)
const res = await deleteLoginLogByIds({ ids })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === ids.length && page.value > 1) {
page.value--
}
getTableData()
}
})
}
const onSubmit = () => {
page.value = 1
pageSize.value = 10
getTableData()
}
const onReset = () => {
searchInfo.value = {}
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 首次加载
getTableData()
</script>
<style scoped>
</style>
@@ -0,0 +1,430 @@
<template>
<div>
<div class="kra-search-box">
<el-form
ref="elSearchFormRef"
:inline="true"
:model="searchInfo"
class="demo-form-inline"
@keyup.enter="onSubmit"
>
<el-form-item label="创建日期" prop="createdAtRange">
<template #label>
<span>
创建日期
<el-tooltip
content="搜索范围是开始日期(包含)至结束日期(不包含)"
>
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<el-date-picker
v-model="searchInfo.createdAtRange"
class="!w-380px"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item label="错误来源" prop="form">
<el-input v-model="searchInfo.form" placeholder="搜索条件" />
</el-form-item>
<el-form-item label="错误内容" prop="info">
<el-input v-model="searchInfo.info" placeholder="搜索条件" />
</el-form-item>
<template v-if="showAllQuery">
<!-- 将需要控制显示状态的查询条件添加到此范围内 -->
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit"
>查询</el-button
>
<el-button icon="refresh" @click="onReset">重置</el-button>
<el-button
link
type="primary"
icon="arrow-down"
@click="showAllQuery = true"
v-if="!showAllQuery"
>展开</el-button
>
<el-button
link
type="primary"
icon="arrow-up"
@click="showAllQuery = false"
v-else
>收起</el-button
>
</el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list">
<el-button
icon="delete"
style="margin-left: 10px"
:disabled="!multipleSelection.length"
@click="onDelete"
>删除</el-button
>
</div>
<el-table
ref="multipleTable"
style="width: 100%"
tooltip-effect="dark"
:data="tableData"
row-key="ID"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column
sortable
align="left"
label="日期"
prop="CreatedAt"
width="180"
>
<template #default="scope">{{
formatDate(scope.row.CreatedAt)
}}</template>
</el-table-column>
<el-table-column
align="left"
label="错误来源"
prop="form"
width="120"
/>
<el-table-column
align="left"
label="错误等级"
prop="level"
width="120"
>
<template #default="scope">
<el-tag
effect="dark"
:type="levelTagMap[scope.row.level] || 'info'"
>
{{ levelLabelMap[scope.row.level] || defaultLevelLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column
align="left"
label="处理状态"
prop="status"
width="140"
>
<template #default="scope">
<el-tag
effect="light"
:type="statusTagMap[scope.row.status] || 'info'"
>
{{ statusLabelMap[scope.row.status] || defaultStatusLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column
align="left"
label="错误内容"
prop="info"
show-overflow-tooltip
width="240"
/>
<el-table-column
align="left"
label="解决方案"
show-overflow-tooltip
prop="solution"
width="120"
/>
<el-table-column
align="left"
label="操作"
fixed="right"
:min-width="appStore.operateMinWith"
>
<template #default="scope">
<el-button
type="primary"
link
class="table-button"
@click="getDetails(scope.row)"
><el-icon style="margin-right: 5px"><InfoFilled /></el-icon
>查看</el-button
>
<el-button
type="primary"
link
icon="delete"
@click="deleteRow(scope.row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="kra-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-drawer
destroy-on-close
:size="appStore.drawerSize"
v-model="detailShow"
:show-close="true"
:before-close="closeDetailShow"
title="查看"
>
<el-descriptions :column="2" border direction="vertical">
<el-descriptions-item label="错误来源">
{{ detailForm.form }}
</el-descriptions-item>
<el-descriptions-item label="错误等级">
<el-tag
effect="dark"
:type="levelTagMap[detailForm.level] || 'info'"
>
{{ levelLabelMap[detailForm.level] || defaultLevelLabel }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处理状态">
<el-tag
effect="light"
:type="statusTagMap[detailForm.status] || 'info'"
>
{{ statusLabelMap[detailForm.status] || defaultStatusLabel }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="错误内容" :span="2">
<pre class="whitespace-pre-wrap break-words">{{ detailForm.info }}</pre>
</el-descriptions-item>
<el-descriptions-item label="解决方案" :span="2">
<pre class="whitespace-pre-wrap break-words">{{ detailForm.solution }}</pre>
</el-descriptions-item>
</el-descriptions>
</el-drawer>
</div>
</template>
<script setup>
import {
deleteSysError,
deleteSysErrorByIds,
findSysError,
getSysErrorList
} from '@/api/system/sysError'
import { formatDate } from '@/utils/format'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'SysError'
})
const appStore = useAppStore()
// 控制更多查询条件显示/隐藏状态
const showAllQuery = ref(false)
const elSearchFormRef = ref()
// =========== 表格控制部分 ===========
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({})
// 重置
const onReset = () => {
searchInfo.value = {}
getTableData()
}
// 搜索
const onSubmit = () => {
elSearchFormRef.value?.validate(async (valid) => {
if (!valid) return
page.value = 1
getTableData()
})
}
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
// 修改页面容量
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 查询
const getTableData = async () => {
const table = await getSysErrorList({
page: page.value,
pageSize: pageSize.value,
...searchInfo.value
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
getTableData()
// ============== 表格控制部分结束 ===============
// 获取需要的字典 可能为空 按需保留
const setOptions = async () => {}
// 获取需要的字典 可能为空 按需保留
setOptions()
// 多选数据
const multipleSelection = ref([])
// 多选
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
// 删除行
const deleteRow = (row) => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteSysErrorFunc(row)
})
}
// 多选删除
const onDelete = async () => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const IDs = []
if (multipleSelection.value.length === 0) {
ElMessage({
type: 'warning',
message: '请选择要删除的数据'
})
return
}
multipleSelection.value &&
multipleSelection.value.map((item) => {
IDs.push(item.ID)
})
const res = await deleteSysErrorByIds({ IDs })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === IDs.length && page.value > 1) {
page.value--
}
getTableData()
}
})
}
// 删除行
const deleteSysErrorFunc = async (row) => {
const res = await deleteSysError({ ID: row.ID })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === 1 && page.value > 1) {
page.value--
}
getTableData()
}
}
const detailForm = ref({})
// 查看详情控制标记
const detailShow = ref(false)
// 打开详情弹窗
const openDetailShow = () => {
detailShow.value = true
}
// 打开详情
const getDetails = async (row) => {
// 打开弹窗
const res = await findSysError({ ID: row.ID })
if (res.code === 0) {
detailForm.value = res.data
openDetailShow()
}
}
// 关闭详情弹窗
const closeDetailShow = () => {
detailShow.value = false
detailForm.value = {}
}
const statusLabelMap = {
未处理: '未处理',
处理中: '处理中',
处理完成: '处理完成',
处理失败: '处理失败'
}
const statusTagMap = {
未处理: 'info',
处理中: 'warning',
处理完成: 'success',
处理失败: 'danger'
}
const defaultStatusLabel = '未处理'
const levelLabelMap = {
fatal: '致命错误',
error: '一般错误'
}
const levelTagMap = {
fatal: 'danger',
error: 'warning'
}
const defaultLevelLabel = '一般错误'
</script>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="kra-table-box config-layout">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-xl font-semibold">系统配置</h2>
<p class="text-sm text-gray-500 mt-1">管理 Kra 后台运行参数和邮件服务。</p>
</div>
<div class="flex gap-2">
<el-button @click="reload">重载服务</el-button>
<el-button type="primary" :loading="saving" @click="update">保存配置</el-button>
</div>
</div>
<el-tabs v-model="activeKey">
<el-tab-pane label="基础设置" name="basic">
<el-form label-position="top" class="grid grid-cols-1 md:grid-cols-2 gap-x-5">
<el-form-item label="路由前缀">
<el-input v-model.trim="config.admin.routerPrefix" placeholder="例如 /api;留空表示无前缀" />
</el-form-item>
<el-form-item label="JWT 签发者">
<el-input v-model.trim="config.admin.jwt.issuer" />
</el-form-item>
<el-form-item label="JWT 签名密钥">
<el-input v-model="config.admin.jwt.signingKey" show-password />
</el-form-item>
<el-form-item label="本地文件目录">
<el-input v-model.trim="config.admin.local.storePath" />
</el-form-item>
<el-form-item label="文件访问前缀">
<el-input v-model.trim="config.admin.local.pathPrefix" />
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="邮件设置" name="email">
<el-form label-position="top" class="grid grid-cols-1 md:grid-cols-2 gap-x-5">
<el-form-item label="默认收件人">
<el-input v-model.trim="config.email.to" placeholder="多个地址用英文逗号分隔" />
</el-form-item>
<el-form-item label="发件人">
<el-input v-model.trim="config.email.from" />
</el-form-item>
<el-form-item label="SMTP 主机">
<el-input v-model.trim="config.email.host" />
</el-form-item>
<el-form-item label="SMTP 端口">
<el-input-number v-model="config.email.port" :min="1" :max="65535" class="!w-full" />
</el-form-item>
<el-form-item label="SMTP 密钥">
<el-input v-model="config.email.secret" show-password />
</el-form-item>
<el-form-item label="发件人昵称">
<el-input v-model.trim="config.email.nickname" />
</el-form-item>
<el-form-item label="连接方式">
<div class="flex gap-5">
<el-switch v-model="config.email['is-ssl']" active-text="SSL" />
<el-switch v-model="config.email['is-loginauth']" active-text="LOGIN 认证" />
</div>
</el-form-item>
<el-form-item label="连通性测试">
<el-button :loading="testing" @click="testEmail">发送测试邮件</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSystemConfig, reloadSystem, setSystemConfig } from '@/api/system'
import { emailTest } from '@/api/email'
defineOptions({ name: 'Config' })
const activeKey = ref('basic')
const saving = ref(false)
const testing = ref(false)
const config = ref({
admin: {
routerPrefix: '',
jwt: { signingKey: '******', issuer: 'kra' },
local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }
},
email: {
to: '', from: '', host: '', secret: '******', nickname: '', port: 465,
'is-ssl': true, 'is-loginauth': false
}
})
const initForm = async () => {
const res = await getSystemConfig()
if (res.code === 0) {
config.value = {
admin: { ...config.value.admin, ...res.data.config.admin,
jwt: { ...config.value.admin.jwt, ...res.data.config.admin?.jwt },
local: { ...config.value.admin.local, ...res.data.config.admin?.local }
},
email: { ...config.value.email, ...res.data.config.email }
}
}
}
const update = async () => {
saving.value = true
try {
const res = await setSystemConfig({ config: config.value })
if (res.code === 0) {
ElMessage.success('配置保存成功')
await initForm()
}
} finally {
saving.value = false
}
}
const reload = async () => {
await ElMessageBox.confirm('确定要重载服务配置吗?', '提示')
const res = await reloadSystem()
if (res.code === 0) ElMessage.success('配置已重载')
}
const testEmail = async () => {
testing.value = true
try {
const res = await emailTest()
if (res.code === 0) ElMessage.success('测试邮件发送成功')
} finally {
testing.value = false
}
}
initForm()
</script>
<style scoped>
.config-layout { min-height: 520px; }
</style>
@@ -0,0 +1,429 @@
<template>
<div>
<div class="kra-search-box">
<el-form :inline="true" :model="searchInfo">
<el-form-item label="任务名">
<el-input v-model="searchInfo.name" placeholder="搜索任务名" />
</el-form-item>
<el-form-item label="执行器">
<el-select v-model="searchInfo.executorType" placeholder="请选择" clearable style="width: 120px">
<el-option label="注册方法" value="method" />
<el-option label="HTTP" value="http" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list">
<el-button type="primary" icon="plus" @click="openForm()">新增任务</el-button>
</div>
<el-table :data="tableData" style="width: 100%" row-key="ID">
<el-table-column align="left" label="ID" prop="ID" width="70" />
<el-table-column align="left" label="任务名" prop="name" min-width="140" show-overflow-tooltip />
<el-table-column align="left" label="说明" prop="description" min-width="180" show-overflow-tooltip />
<el-table-column align="left" label="cron" prop="spec" width="120" />
<el-table-column align="left" label="执行器" width="100">
<template #default="scope">
<el-tag :type="scope.row.executorType === 'method' ? 'primary' : 'warning'">
{{ scope.row.executorType === 'method' ? '注册方法' : 'HTTP' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="启用" width="90">
<template #default="scope">
<el-switch :model-value="scope.row.enabled" @change="(v) => onToggle(scope.row, v)" />
</template>
</el-table-column>
<el-table-column align="left" label="下次执行" width="170">
<template #default="scope">
{{ scope.row.nextRunAt ? formatDate(scope.row.nextRunAt) : '—' }}
</template>
</el-table-column>
<el-table-column align="left" label="操作" width="280" fixed="right">
<template #default="scope">
<el-button type="primary" link icon="video-play" @click="onTrigger(scope.row)">触发</el-button>
<el-button type="primary" link icon="document" @click="openLogs(scope.row)">日志</el-button>
<el-button type="primary" link icon="edit" @click="openForm(scope.row)">编辑</el-button>
<el-popover v-model:visible="scope.row.deleteVisible" placement="top" width="170">
<p>确定删除该任务吗?</p>
<div style="text-align: right; margin: 0">
<el-button size="small" type="primary" link @click="scope.row.deleteVisible = false">取消</el-button>
<el-button size="small" type="primary" @click="onDelete(scope.row)">确定</el-button>
</div>
<template #reference>
<el-button type="danger" link icon="delete" @click="scope.row.deleteVisible = true">删除</el-button>
</template>
</el-popover>
</template>
</el-table-column>
</el-table>
<div class="kra-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 任务表单抽屉(标准抽屉格式) -->
<el-drawer v-model="formVisible" :size="appStore.drawerSize" :show-close="false">
<template #header>
<div class="flex justify-between items-center">
<span class="text-base">{{ formTitle }}</span>
<div>
<el-button @click="formVisible = false">取 消</el-button>
<el-button type="primary" @click="submitForm">确 定</el-button>
</div>
</div>
</template>
<el-form ref="formRef" :model="form" label-width="110px">
<el-form-item label="任务名" required>
<el-input v-model="form.name" placeholder="唯一任务名" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务用途说明" />
</el-form-item>
<el-form-item label="cron 表达式" required>
<div class="flex gap-2 w-full">
<el-input v-model="form.spec" placeholder="如 @daily 或 */30 * * * *" />
<el-select placeholder="常用模板" style="width: 160px" @change="(v) => (form.spec = v)">
<el-option label="每天零点 @daily" value="@daily" />
<el-option label="每小时 @hourly" value="@hourly" />
<el-option label="每周 @weekly" value="@weekly" />
<el-option label="每30分钟" value="*/30 * * * *" />
<el-option label="每分钟" value="* * * * *" />
<el-option label="工作日9点" value="0 9 * * 1-5" />
</el-select>
</div>
<div class="text-xs text-gray-400 mt-1">合法性由后端校验;保存失败时错误会回显</div>
</el-form-item>
<el-form-item label="含秒位">
<el-switch v-model="form.withSeconds" />
<span class="text-xs text-gray-400 ml-2">开启后为六位表达式(秒 分 时 日 月 周)</span>
</el-form-item>
<el-form-item label="执行器" required>
<el-radio-group v-model="form.executorType">
<el-radio value="method">调用已注册方法</el-radio>
<el-radio value="http">HTTP 调用</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.executorType === 'method'">
<el-form-item label="方法" required>
<el-select v-model="form.methodName" placeholder="选择已注册方法" style="width: 100%">
<el-option v-for="m in methodOptions" :key="m.name" :label="`${m.name} — ${m.description}`" :value="m.name" />
</el-select>
<div class="text-xs text-gray-400 mt-1 flex items-center flex-wrap">
<span>没有想要的方法?需在后端 server/initialize/timer.go 的 Timer() 中注册,重启服务后即可选择</span>
<el-popover placement="top" :width="560" trigger="click">
<template #reference>
<el-button type="primary" link size="small">查看注册模板</el-button>
</template>
<pre class="text-xs leading-5 whitespace-pre overflow-x-auto m-0 p-2 rounded bg-gray-50 dark:bg-gray-800">{{ methodRegisterTemplate }}</pre>
<div class="text-right mt-2">
<el-button size="small" type="primary" @click="copyRegisterTemplate">复制模板</el-button>
</div>
</el-popover>
</div>
</el-form-item>
<el-form-item label="参数(JSON)">
<el-input v-model="paramsText" type="textarea" :rows="4" placeholder='自由 JSON, 由方法自行解析, 如 {"days": 30}; 留空则不传' />
</el-form-item>
</template>
<template v-if="form.executorType === 'http'">
<el-form-item label="URL" required>
<el-input v-model="form.httpUrl" placeholder="https://example.com/hook" />
</el-form-item>
<el-form-item label="Method">
<el-select v-model="form.httpMethod" style="width: 140px">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item label="Header(JSON)">
<el-input v-model="headerText" type="textarea" :rows="2" placeholder='{"Authorization":"Bearer xxx"}; 留空则不加' />
</el-form-item>
<el-form-item label="Body">
<el-input v-model="form.httpBody" type="textarea" :rows="3" placeholder="请求体原文" />
</el-form-item>
<el-form-item label="允许内网">
<el-switch v-model="form.httpAllowPrivate" />
<span class="text-xs text-gray-400 ml-2">默认禁止访问内网/环回地址(SSRF 防护),仅确有需要时开启</span>
</el-form-item>
</template>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
</el-drawer>
<!-- 执行日志抽屉(标准抽屉格式) -->
<el-drawer v-model="logVisible" :size="appStore.drawerSize" :show-close="false">
<template #header>
<div class="flex justify-between items-center">
<span class="text-base">执行日志:{{ logTaskName }}</span>
<div>
<el-button @click="logVisible = false">关 闭</el-button>
</div>
</div>
</template>
<el-table :data="logData" style="width: 100%">
<el-table-column type="expand">
<template #default="scope">
<div class="px-4 py-2">
<p v-if="scope.row.errorMsg" class="text-red-500 break-all">错误:{{ scope.row.errorMsg }}</p>
<p v-if="scope.row.output" class="break-all mt-1">输出:{{ scope.row.output }}</p>
<p v-if="!scope.row.errorMsg && !scope.row.output" class="text-gray-400">无详情</p>
</div>
</template>
</el-table-column>
<el-table-column align="left" label="触发" width="80">
<template #default="scope">
<el-tag :type="scope.row.triggerType === 'auto' ? 'info' : 'warning'">
{{ scope.row.triggerType === 'auto' ? '调度' : '手动' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="状态" width="90">
<template #default="scope">
<el-tag :type="scope.row.status === 'success' ? 'success' : 'danger'">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="开始时间" width="170">
<template #default="scope">{{ formatDate(scope.row.startedAt) }}</template>
</el-table-column>
<el-table-column align="left" label="耗时(ms)" prop="durationMs" width="100" />
</el-table>
<div class="kra-pagination">
<el-pagination
:current-page="logPage"
:page-size="logPageSize"
:total="logTotal"
layout="total, prev, pager, next"
@current-change="handleLogPageChange"
/>
</div>
</el-drawer>
</div>
</template>
<script setup>
import {
createTimedTask,
updateTimedTask,
deleteTimedTask,
toggleTimedTask,
triggerTimedTask,
getTimedTaskList,
getTimedTaskLogList,
getRegisteredMethods
} from '@/api/timedTask'
import { useTimedTaskAlert } from './useAlertStream'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { formatDate } from '@/utils/format'
import { useAppStore } from '@/pinia'
defineOptions({ name: 'TimedTask' })
const appStore = useAppStore()
useTimedTaskAlert() // 页内订阅失败告警
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({})
const getTableData = async () => {
const res = await getTimedTaskList({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
if (res.code === 0) {
tableData.value = res.data.list
total.value = res.data.total
}
}
const onSubmit = () => {
page.value = 1
getTableData()
}
const onReset = () => {
searchInfo.value = {}
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// ------- 表单抽屉 -------
const formVisible = ref(false)
const formRef = ref(null)
const emptyForm = () => ({
ID: 0,
name: '',
description: '',
spec: '',
withSeconds: false,
executorType: 'method',
methodName: '',
params: null,
httpUrl: '',
httpMethod: 'GET',
httpHeader: null,
httpBody: '',
httpAllowPrivate: false,
enabled: true
})
const form = ref(emptyForm())
const paramsText = ref('')
const headerText = ref('')
const methodOptions = ref([])
const formTitle = computed(() => (form.value.ID ? '编辑定时任务' : '新增定时任务'))
// 注册方法代码模板: 引导二开在 server/initialize/timer.go 的 Timer() 中追加 task.Register
const methodRegisterTemplate = `// 位置: server/initialize/timer.go -> Timer()
task.Register("MyTask", "任务说明(展示在方法下拉中)", func(ctx context.Context, params json.RawMessage) error {
// params 为面板「参数(JSON)」原文, 按需解析; 无参数可忽略
// var p struct {
// Days int \`json:"days"\`
// }
// if len(params) > 0 {
// if err := json.Unmarshal(params, &p); err != nil {
// return err
// }
// }
// TODO: 业务逻辑; 数据库操作请使用 global.KRA_DB.WithContext(ctx)
return nil
})`
const copyRegisterTemplate = () => {
const input = document.createElement('textarea')
input.value = methodRegisterTemplate
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
ElMessage.success('已复制, 请粘贴到 server/initialize/timer.go 的 Timer() 中, 重启后生效')
}
const openForm = async (row) => {
form.value = row ? { ...emptyForm(), ...row } : emptyForm()
paramsText.value = row && row.params ? JSON.stringify(row.params, null, 2) : ''
headerText.value = row && row.httpHeader ? JSON.stringify(row.httpHeader, null, 2) : ''
formVisible.value = true
if (methodOptions.value.length === 0) {
const res = await getRegisteredMethods()
if (res.code === 0) {
methodOptions.value = res.data.methods || []
}
}
}
// parseJsonField 本地 JSON.parse 校验; 空串返回 null
const parseJsonField = (text, label) => {
if (!text || !text.trim()) return { ok: true, value: null }
try {
return { ok: true, value: JSON.parse(text) }
} catch {
ElMessage.error(`${label} 不是合法 JSON`)
return { ok: false, value: null }
}
}
const submitForm = async () => {
if (!form.value.name || !form.value.spec) {
ElMessage.warning('任务名与 cron 表达式必填')
return
}
const p = parseJsonField(paramsText.value, '参数')
if (!p.ok) return
const h = parseJsonField(headerText.value, 'Header')
if (!h.ok) return
const payload = { ...form.value, params: p.value, httpHeader: h.value }
const res = form.value.ID ? await updateTimedTask(payload) : await createTimedTask(payload)
if (res.code === 0) {
ElMessage.success(form.value.ID ? '更新成功' : '创建成功')
formVisible.value = false
getTableData()
}
}
// ------- 行操作 -------
const onToggle = async (row, enabled) => {
const res = await toggleTimedTask({ ID: row.ID, enabled })
if (res.code === 0) {
ElMessage.success(enabled ? '已启用' : '已停用')
getTableData()
}
}
const onTrigger = async (row) => {
const res = await triggerTimedTask({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('已触发, 稍后可在日志中查看结果')
}
}
const onDelete = async (row) => {
row.deleteVisible = false
const res = await deleteTimedTask({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('删除成功')
if (tableData.value.length === 1 && page.value > 1) page.value--
getTableData()
}
}
// ------- 日志抽屉 -------
const logVisible = ref(false)
const logData = ref([])
const logTotal = ref(0)
const logPage = ref(1)
const logPageSize = ref(10)
const logTaskId = ref(0)
const logTaskName = ref('')
const loadLogs = async () => {
const res = await getTimedTaskLogList({ page: logPage.value, pageSize: logPageSize.value, taskId: logTaskId.value })
if (res.code === 0) {
logData.value = res.data.list
logTotal.value = res.data.total
}
}
const openLogs = (row) => {
logTaskId.value = row.ID
logTaskName.value = row.name
logPage.value = 1
logVisible.value = true
loadLogs()
}
const handleLogPageChange = (val) => {
logPage.value = val
loadLogs()
}
getTableData()
</script>
<style scoped></style>
@@ -0,0 +1,43 @@
import { onMounted, onBeforeUnmount } from 'vue'
import { ElNotification } from 'element-plus'
// 订阅定时任务失败告警(SSE)。鉴权走同源 cookie x-token
// (服务端 utils/claims.go GetToken 支持 cookie 回退, EventSource 无需自定义 header)。
export const useTimedTaskAlert = () => {
let es = null
let failures = 0
const connect = () => {
const base = import.meta.env.VITE_BASE_API
es = new EventSource(`${base}/timedTask/alertStream`)
es.addEventListener('timedTask:alert', (e) => {
try {
const data = JSON.parse(e.data)
ElNotification({
title: `定时任务失败:${data.name}`,
message: data.error,
type: 'error',
duration: 10000
})
} catch {
/* 忽略非法负载 */
}
})
es.onopen = () => {
failures = 0
}
es.onerror = () => {
failures += 1
// 无权限(403)或持续失败: 关闭并放弃, 避免无限重连刷请求
if (failures >= 3 && es) {
es.close()
es = null
}
}
}
onMounted(connect)
onBeforeUnmount(() => {
if (es) es.close()
})
}
@@ -0,0 +1,998 @@
<template>
<div>
<div class="kra-search-box">
<el-form ref="elSearchFormRef" :inline="true" :model="searchInfo" class="demo-form-inline"
@keyup.enter="onSubmit">
<el-form-item label="创建日期" prop="createdAtRange">
<template #label>
<span>
创建日期
<el-tooltip content="搜索范围是开始日期(包含)至结束日期(不包含)">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-date-picker v-model="searchInfo.createdAtRange" class="w-[380px]" type="datetimerange" range-separator="至"
start-placeholder="开始时间" end-placeholder="结束时间" />
</el-form-item>
<el-form-item label="版本名称" prop="versionName">
<el-input v-model="searchInfo.versionName" placeholder="搜索条件" />
</el-form-item>
<el-form-item label="版本号" prop="versionCode">
<el-input v-model="searchInfo.versionCode" placeholder="搜索条件" />
</el-form-item>
<template v-if="showAllQuery">
<!-- 将需要控制显示状态的查询条件添加到此范围内 -->
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
<el-button link type="primary" icon="arrow-down" @click="showAllQuery = true"
v-if="!showAllQuery">展开</el-button>
<el-button link type="primary" icon="arrow-up" @click="showAllQuery = false" v-else>收起</el-button>
</el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list">
<el-button type="primary" icon="download" @click="openExportDialog">创建发版</el-button>
<el-button type="primary" icon="upload" @click="openImportDialog">导入版本</el-button>
<el-button icon="delete" style="margin-left: 10px;" :disabled="!multipleSelection.length"
@click="onDelete">删除</el-button>
</div>
<el-table ref="multipleTable" style="width: 100%" tooltip-effect="dark" :data="tableData" row-key="ID"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column sortable align="left" label="日期" prop="CreatedAt" width="180">
<template #default="scope">{{ formatDate(scope.row.CreatedAt) }}</template>
</el-table-column>
<el-table-column align="left" label="版本名称" prop="versionName" width="120" />
<el-table-column align="left" label="版本号" prop="versionCode" width="120" />
<el-table-column align="left" label="操作" fixed="right" min-width="320">
<template #default="scope">
<el-button type="primary" link class="table-button" @click="getDetails(scope.row)"><el-icon
style="margin-right: 5px">
<InfoFilled />
</el-icon>查看</el-button>
<el-button type="success" link icon="download" class="table-button"
@click="downloadJson(scope.row)">下载发版包</el-button>
<el-button type="primary" link icon="delete" @click="deleteRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="kra-pagination">
<el-pagination layout="total, sizes, prev, pager, next, jumper" :current-page="page" :page-size="pageSize"
:page-sizes="[10, 30, 50, 100]" :total="total" @current-change="handleCurrentChange"
@size-change="handleSizeChange" />
</div>
</div>
<el-drawer destroy-on-close :size="appStore.drawerSize" v-model="detailShow" :show-close="true"
:before-close="closeDetailShow" title="查看">
<el-descriptions :column="1" border>
<el-descriptions-item label="版本名称">
{{ detailForm.versionName }}
</el-descriptions-item>
<el-descriptions-item label="版本号">
{{ detailForm.versionCode }}
</el-descriptions-item>
<el-descriptions-item label="版本描述">
{{ detailForm.description }}
</el-descriptions-item>
</el-descriptions>
</el-drawer>
<!-- 导出版本抽屉 -->
<el-drawer v-model="exportDialogVisible" title="创建发版" direction="rtl" size="80%" :before-close="closeExportDialog"
:show-close="false">
<template #header>
<div class="flex justify-between items-center">
<span class="text-base">创建发版</span>
<div>
<el-button @click="closeExportDialog">取消</el-button>
<el-button type="primary" @click="handleExport" :loading="exportLoading">创建发版</el-button>
</div>
</div>
</template>
<el-form :model="exportForm" label-width="100px">
<el-form-item label="版本名称" required>
<el-input v-model="exportForm.versionName" placeholder="请输入版本名称" />
</el-form-item>
<el-form-item label="版本号" required>
<el-input v-model="exportForm.versionCode" placeholder="请输入版本号" />
</el-form-item>
<el-form-item label="版本描述">
<el-input v-model="exportForm.description" type="textarea" placeholder="请输入版本描述" />
</el-form-item>
<el-form-item label="发版信息">
<div class="flex gap-3 w-full">
<!-- 菜单选择 -->
<div class="card-col card-vertical">
<div class="card-header">
<span class="card-title">选择菜单</span>
</div>
<div class="card-filter">
<el-input v-model="menuFilterText" placeholder="输入关键字进行过滤" clearable size="small" />
</div>
<div class="card-body">
<el-tree ref="menuTreeRef" :data="menuTreeData" :default-checked-keys="selectedMenuIds"
:props="menuTreeProps" default-expand-all highlight-current node-key="ID" show-checkbox
:filter-node-method="filterMenuNode" @check="onMenuCheck" class="menu-tree">
<template #default="{ node }">
<span class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ node.label }}</span>
</span>
</template>
</el-tree>
</div>
</div>
<!-- API选择 -->
<div class="card-col card-vertical">
<div class="card-header">
<span class="card-title">选择API</span>
</div>
<div class="card-filter">
<el-input v-model="apiFilterTextName" placeholder="按名称过滤" clearable size="small"
style="margin-bottom: 8px" />
<el-input v-model="apiFilterTextPath" placeholder="按路径过滤" clearable size="small" />
</div>
<div class="card-body">
<el-tree ref="apiTreeRef" :data="apiTreeData" :default-checked-keys="selectedApiIds"
:props="apiTreeProps" default-expand-all highlight-current node-key="onlyId" show-checkbox
:filter-node-method="filterApiNode" @check="onApiCheck" class="api-tree">
<template #default="{ data }">
<div class="flex items-center justify-between w-full pr-1">
<span>{{ data.description }}</span>
<el-tooltip :content="data.path">
<span
class="max-w-[240px] break-all overflow-ellipsis overflow-hidden text-gray-500 dark:text-gray-400">
{{ data.path }}
</span>
</el-tooltip>
</div>
</template>
</el-tree>
</div>
</div>
<!-- 字典选择 -->
<div class="card-col card-vertical">
<div class="card-header">
<span class="card-title">选择字典</span>
</div>
<div class="card-filter">
<el-input v-model="dictFilterText" placeholder="输入关键字进行过滤" clearable size="small" />
</div>
<div class="card-body">
<el-tree ref="dictTreeRef" :data="dictTreeData" :default-checked-keys="selectedDictIds"
:props="dictTreeProps" default-expand-all highlight-current node-key="ID" show-checkbox
:filter-node-method="filterDictNode" @check="onDictCheck" class="dict-tree">
<template #default="{ data }">
<div class="flex items-center justify-between w-full pr-1">
<span>{{ data.name || data.label }}</span>
<el-tooltip :content="data.desc || (data.value ? `值: ${data.value}` : '')">
<span class="text-gray-500 dark:text-gray-400 text-xs ml-2">
{{ data.type || (data.value ? `值: ${data.value}` : '') }}
</span>
</el-tooltip>
</div>
</template>
</el-tree>
</div>
</div>
</div>
</el-form-item>
</el-form>
</el-drawer>
<!-- 导入版本抽屉 -->
<el-drawer v-model="importDialogVisible" title="导入版本" direction="rtl" size="80%" :before-close="closeImportDialog"
:show-close="false">
<template #header>
<div class="flex justify-between items-center">
<span class="text-base">导入版本</span>
<div>
<el-button @click="closeImportDialog">取消</el-button>
<el-button type="primary" @click="handleImport" :loading="importLoading"
:disabled="!importJsonContent.trim()">导入</el-button>
</div>
</div>
</template>
<el-form label-width="100px">
<el-form-item label="上传文件">
<el-upload ref="uploadRef" :auto-upload="false" :show-file-list="true" :limit="1" accept=".json"
:on-change="handleFileChange" :on-remove="handleFileRemove" drag>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将JSON文件拖到此处,或<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传JSON文件
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="版本JSON">
<el-input v-model="importJsonContent" type="textarea" :rows="20" placeholder="请粘贴版本JSON"
@input="handleJsonContentChange" />
</el-form-item>
<el-form-item label="预览内容" v-if="importPreviewData">
<div class="preview-wrap">
<div class="flex gap-3 w-full">
<div class="card-col">
<div class="card-vertical">
<div class="card-header">
<h3 class="card-title">菜单 ({{ getTotalMenuCount() }}项)</h3>
</div>
<div class="card-body">
<el-tree :data="previewMenuTreeData" :props="menuTreeProps" node-key="name"
:expand-on-click-node="false" :check-on-click-node="false" :show-checkbox="false"
default-expand-all>
<template #default="{ data }">
<div class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ data.meta?.title || data.title }}</span>
<span class="text-gray-500 dark:text-gray-400 text-xs ml-2">{{ data.path }}</span>
</div>
</template>
</el-tree>
</div>
</div>
</div>
<div class="card-col">
<div class="card-vertical">
<div class="card-header">
<h3 class="card-title">API ({{ importPreviewData.apis?.length || 0 }}项)</h3>
</div>
<div class="card-body">
<el-tree :data="previewApiTreeData" :props="apiTreeProps" node-key="ID"
:expand-on-click-node="false" :check-on-click-node="false" :show-checkbox="false"
default-expand-all>
<template #default="{ data }">
<div class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ data.description }}</span>
<span class="text-gray-500 dark:text-gray-400 text-xs ml-2">{{ data.path }} [{{ data.method
}}]</span>
</div>
</template>
</el-tree>
</div>
</div>
</div>
<div class="card-col">
<div class="card-vertical">
<div class="card-header">
<h3 class="card-title">字典 ({{ importPreviewData.dictionaries?.length || 0 }}项)</h3>
</div>
<div class="card-body">
<el-tree :data="previewDictTreeData" :props="dictTreeProps" node-key="ID"
:expand-on-click-node="false" :check-on-click-node="false" :show-checkbox="false"
default-expand-all>
<template #default="{ data }">
<div class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ data.name || data.label }}</span>
<span class="text-gray-500 dark:text-gray-400 text-xs ml-2">
{{ data.type || (data.value ? `值: ${data.value}` : '') }}
</span>
</div>
</template>
</el-tree>
</div>
</div>
</div>
</div>
</div>
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script setup>
import {
deleteSysVersion,
deleteSysVersionByIds,
findSysVersion,
getSysVersionList,
exportVersion,
importVersion,
downloadVersionJson
} from '@/api/version'
// 导入菜单和API相关接口
import { getMenuList } from '@/api/menu'
import { getApiList } from '@/api/api'
import { getSysDictionaryList } from '@/api/sysDictionary'
// 全量引入格式化工具 请按需保留
import { formatDate } from '@/utils/format'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { useAppStore } from "@/pinia"
defineOptions({
name: 'SysVersion'
})
const appStore = useAppStore()
// 控制更多查询条件显示/隐藏状态
const showAllQuery = ref(false)
// 导出相关数据
const exportDialogVisible = ref(false)
const exportLoading = ref(false)
const exportForm = ref({
versionName: '',
versionCode: '',
description: '',
menuIds: [],
apiIds: [],
dictIds: []
})
// 树形结构相关数据
const menuTreeData = ref([])
const apiTreeData = ref([])
const dictTreeData = ref([])
const selectedMenuIds = ref([])
const selectedApiIds = ref([])
const selectedDictIds = ref([])
const menuFilterText = ref('')
const apiFilterTextName = ref('')
const apiFilterTextPath = ref('')
const dictFilterText = ref('')
// 树形组件引用
const menuTreeRef = ref(null)
const apiTreeRef = ref(null)
const dictTreeRef = ref(null)
// 树形属性配置
const menuTreeProps = ref({
children: 'children',
label: function (data) {
return data.meta?.title || data.title
}
})
const apiTreeProps = ref({
children: 'children',
label: 'description'
})
const dictTreeProps = ref({
children: 'sysDictionaryDetails',
label: function (data) {
// 如果是字典主项,显示字典名称
if (data.name) {
return data.name
}
// 如果是字典详情项,显示标签
if (data.label) {
return data.label
}
return '未知项'
}
})
// 导入相关数据
const importDialogVisible = ref(false)
const importLoading = ref(false)
const importJsonContent = ref('')
const importPreviewData = ref(null)
const uploadRef = ref(null)
const previewMenuTreeData = ref([])
const previewApiTreeData = ref([])
const previewDictTreeData = ref([])
const elSearchFormRef = ref()
// =========== 表格控制部分 ===========
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({})
// 重置
const onReset = () => {
searchInfo.value = {}
getTableData()
}
// 搜索
const onSubmit = () => {
elSearchFormRef.value?.validate(async (valid) => {
if (!valid) return
page.value = 1
getTableData()
})
}
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
// 修改页面容量
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 查询
const getTableData = async () => {
const table = await getSysVersionList({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
getTableData()
// ============== 表格控制部分结束 ===============
// 多选数据
const multipleSelection = ref([])
// 多选
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
// 删除行
const deleteRow = (row) => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteSysVersionFunc(row)
})
}
// 多选删除
const onDelete = async () => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const IDs = []
if (multipleSelection.value.length === 0) {
ElMessage({
type: 'warning',
message: '请选择要删除的数据'
})
return
}
multipleSelection.value &&
multipleSelection.value.map(item => {
IDs.push(item.ID)
})
const res = await deleteSysVersionByIds({ IDs })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === IDs.length && page.value > 1) {
page.value--
}
getTableData()
}
})
}
// 删除行
const deleteSysVersionFunc = async (row) => {
const res = await deleteSysVersion({ ID: row.ID })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === 1 && page.value > 1) {
page.value--
}
getTableData()
}
}
const detailForm = ref({})
// 查看详情控制标记
const detailShow = ref(false)
// 打开详情弹窗
const openDetailShow = () => {
detailShow.value = true
}
// 打开详情
const getDetails = async (row) => {
// 打开弹窗
const res = await findSysVersion({ ID: row.ID })
if (res.code === 0) {
detailForm.value = res.data
openDetailShow()
}
}
// 关闭详情弹窗
const closeDetailShow = () => {
detailShow.value = false
detailForm.value = {}
}
// 获取菜单和API列表
const getMenuAndApiList = async () => {
try {
// 获取菜单列表
const menuRes = await getMenuList()
if (menuRes.code === 0) {
menuTreeData.value = menuRes.data || []
}
// 获取API列表
const apiRes = await getApiList({ page: 1, pageSize: 9999 })
if (apiRes.code === 0) {
console.log('原始API数据:', apiRes.data)
const apis = apiRes.data.list || []
apiTreeData.value = buildApiTree(apis)
}
} catch (error) {
console.error('获取数据失败:', error)
ElMessage.error('获取菜单或API数据失败')
}
}
// 获取字典列表
const getDictList = async () => {
try {
const dictRes = await getSysDictionaryList({ page: 1, pageSize: 9999 })
if (dictRes.code === 0) {
dictTreeData.value = dictRes.data || []
}
} catch (error) {
console.error('获取字典数据失败:', error)
ElMessage.error('获取字典数据失败')
}
}
// 构建API树形结构
const buildApiTree = (apis) => {
const apiObj = {}
apis.forEach((item) => {
item.onlyId = 'p:' + item.path + 'm:' + item.method
if (Object.prototype.hasOwnProperty.call(apiObj, item.apiGroup)) {
apiObj[item.apiGroup].push(item)
} else {
Object.assign(apiObj, { [item.apiGroup]: [item] })
}
})
const apiTree = []
for (const key in apiObj) {
const treeNode = {
ID: key,
description: key + '组',
children: apiObj[key]
}
apiTree.push(treeNode)
}
return apiTree
}
// 树形组件事件处理方法
const filterMenuNode = (value, data) => {
if (!value) return true
const title = data.meta?.title || data.title || ''
return title.indexOf(value) !== -1
}
const filterApiNode = (value, data) => {
if (!apiFilterTextName.value && !apiFilterTextPath.value) return true
let matchesName, matchesPath
if (!apiFilterTextName.value) {
matchesName = true
} else {
matchesName = data.description && data.description.includes(apiFilterTextName.value)
}
if (!apiFilterTextPath.value) {
matchesPath = true
} else {
matchesPath = data.path && data.path.includes(apiFilterTextPath.value)
}
return matchesName && matchesPath
}
const filterDictNode = (value, data) => {
if (!value) return true
const name = data.name || ''
const type = data.type || ''
const desc = data.desc || ''
const label = data.label || ''
const dataValue = data.value || ''
return name.indexOf(value) !== -1 ||
type.indexOf(value) !== -1 ||
desc.indexOf(value) !== -1 ||
label.indexOf(value) !== -1 ||
dataValue.indexOf(value) !== -1
}
const onMenuCheck = (data, checked) => {
if (checked.checkedKeys) {
selectedMenuIds.value = checked.checkedKeys
}
}
const onApiCheck = (data, checked) => {
if (checked.checkedKeys) {
selectedApiIds.value = checked.checkedKeys
}
}
const onDictCheck = (data, checked) => {
if (checked.checkedKeys) {
selectedDictIds.value = checked.checkedKeys
}
}
// 监听过滤文本变化
watch(menuFilterText, (val) => {
if (menuTreeRef.value) {
menuTreeRef.value.filter(val)
}
})
watch([apiFilterTextName, apiFilterTextPath], () => {
if (apiTreeRef.value) {
apiTreeRef.value.filter('')
}
})
watch(dictFilterText, (val) => {
if (dictTreeRef.value) {
dictTreeRef.value.filter(val)
}
})
// 导出相关方法
const openExportDialog = async () => {
exportDialogVisible.value = true
await getMenuAndApiList()
await getDictList()
}
const closeExportDialog = () => {
exportDialogVisible.value = false
exportForm.value = {
versionName: '',
versionCode: '',
description: '',
menuIds: [],
apiIds: [],
dictIds: []
}
selectedMenuIds.value = []
selectedApiIds.value = []
selectedDictIds.value = []
menuFilterText.value = ''
apiFilterTextName.value = ''
apiFilterTextPath.value = ''
dictFilterText.value = ''
}
const handleExport = async () => {
if (!exportForm.value.versionName || !exportForm.value.versionCode) {
ElMessage.warning('请填写版本名称和版本号')
return
}
exportLoading.value = true
try {
// 获取选中的菜单、API和字典
const checkedMenus = menuTreeRef.value ? menuTreeRef.value.getCheckedNodes(false, true) : []
const checkedApis = apiTreeRef.value ? apiTreeRef.value.getCheckedNodes(true) : []
const checkedDicts = dictTreeRef.value ? dictTreeRef.value.getCheckedNodes(true) : []
const menuIds = checkedMenus.map(menu => menu.ID)
const apiIds = checkedApis.map(api => api.ID)
const dictIds = checkedDicts.map(dict => dict.ID)
exportForm.value.menuIds = menuIds
exportForm.value.apiIds = apiIds
exportForm.value.dictIds = dictIds
const res = await exportVersion(exportForm.value)
if (res.code !== 0) {
ElMessage.error(res.msg || '创建发版失败')
return
}
ElMessage.success('创建发版成功')
closeExportDialog()
getTableData() // 刷新表格数据
} catch (error) {
console.error('创建发版失败:', error)
ElMessage.error('创建发版失败')
} finally {
exportLoading.value = false
}
}
// 导入相关方法
const openImportDialog = () => {
importDialogVisible.value = true
}
const closeImportDialog = () => {
importDialogVisible.value = false
importJsonContent.value = ''
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
// 清理上传文件
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
}
// 文件上传处理函数
const handleFileChange = (file) => {
if (!file.raw) return
// 验证文件类型
if (!file.name.toLowerCase().endsWith('.json')) {
ElMessage.error('只能上传JSON文件')
uploadRef.value.clearFiles()
return
}
// 读取文件内容
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target.result
// 验证JSON格式
JSON.parse(content)
importJsonContent.value = content
handleJsonContentChange()
ElMessage.success('文件上传成功')
} catch (error) {
ElMessage.error('JSON文件格式错误')
uploadRef.value.clearFiles()
}
}
reader.readAsText(file.raw)
}
const handleFileRemove = () => {
importJsonContent.value = ''
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
}
// 计算菜单总数(递归计算所有菜单项)
const getTotalMenuCount = () => {
if (!importPreviewData.value?.menus) return 0
const countMenus = (menus) => {
let count = 0
menus.forEach(menu => {
count += 1 // 当前菜单
if (menu.children && menu.children.length > 0) {
count += countMenus(menu.children) // 递归计算子菜单
}
})
return count
}
return countMenus(importPreviewData.value.menus)
}
const handleJsonContentChange = () => {
if (!importJsonContent.value.trim()) {
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
previewDictTreeData.value = []
return
}
try {
const data = JSON.parse(importJsonContent.value)
// 构建预览数据
importPreviewData.value = {
menus: data.menus || [],
apis: data.apis || [],
dictionaries: data.dictionaries || []
}
// 直接使用菜单数据,因为它已经是树形结构(包含children字段)
if (data.menus && data.menus.length > 0) {
previewMenuTreeData.value = data.menus
} else {
previewMenuTreeData.value = []
}
// 构建API树形数据(按分组组织)
if (data.apis && data.apis.length > 0) {
const apiGroups = {}
data.apis.forEach(api => {
const group = api.apiGroup || '未分组'
if (!apiGroups[group]) {
apiGroups[group] = {
ID: `group_${group}`,
description: group,
path: '',
method: '',
children: []
}
}
apiGroups[group].children.push(api)
})
previewApiTreeData.value = Object.values(apiGroups)
} else {
previewApiTreeData.value = []
}
// 处理字典数据
if (data.dictionaries && data.dictionaries.length > 0) {
previewDictTreeData.value = data.dictionaries
} else {
previewDictTreeData.value = []
}
} catch (error) {
console.error('JSON解析失败:', error)
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
previewDictTreeData.value = []
}
}
const handleImport = async () => {
if (!importJsonContent.value.trim()) {
ElMessage.warning('请输入版本JSON')
return
}
try {
JSON.parse(importJsonContent.value)
} catch (error) {
ElMessage.error('JSON格式错误,请检查输入内容')
return
}
importLoading.value = true
try {
const data = JSON.parse(importJsonContent.value)
const res = await importVersion(data)
if (res.code === 0) {
ElMessage.success('导入成功')
closeImportDialog()
getTableData() // 刷新表格数据
} else {
ElMessage.error(res.msg || '导入失败')
}
} catch (error) {
console.error('导入失败:', error)
ElMessage.error('导入失败')
} finally {
importLoading.value = false
}
}
// 下载版本JSON
const downloadJson = async (row) => {
try {
const res = await downloadVersionJson({ ID: row.ID })
// 处理axios响应,获取实际的blob数据
// 当responseType为blob时,axios拦截器会返回完整的response对象
let blob
if (res instanceof Blob) {
blob = res
} else if (res.data instanceof Blob) {
blob = res.data
} else {
// 如果不是blob,可能是错误响应,尝试从response中获取
blob = res
}
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${row.versionName}_${row.versionCode}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('下载成功')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败')
}
}
</script>
<style scoped>
/* 复用卡片样式(支持暗色) */
.card-col {
@apply border border-gray-300 dark:border-gray-600 rounded overflow-hidden flex-1 bg-white dark:bg-gray-900;
}
.card-vertical {
@apply flex flex-col h-full;
}
.card-header {
@apply flex justify-between items-center px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-300 dark:border-gray-600;
}
.card-title {
@apply m-0 text-gray-800 dark:text-gray-200 text-base font-medium;
}
.card-filter {
@apply px-4 py-3 border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800;
}
.card-body {
@apply flex-1 p-2 min-h-[300px] max-h-[400px] overflow-y-auto;
}
.preview-wrap {
@apply flex flex-col flex-1 gap-4 border border-gray-300 dark:border-gray-600 rounded p-4 bg-gray-50 dark:bg-gray-900;
}
/* Element Plus 树形组件样式优化 */
:deep(.el-tree) {
background-color: transparent;
}
:deep(.el-tree-node__content) {
height: 32px;
line-height: 32px;
}
:deep(.el-tree-node__label) {
font-size: 14px;
}
:deep(.el-scrollbar__view) {
padding: 0;
}
</style>