前端代码
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user