feat(设备日志/后台配置/密钥检测): 补齐已上线未提交的设备日志与app_config,并合入检测模型解耦
三部分均已部署到双节点(当前线上 JAR 009efc3e),本次补齐仓库状态,避免"已上线未提交" 在后续最小构建比对里被误判。 - 设备日志管理:modules/devicelog + DeviceLogOssProperties(主机B 独立 MinIO,仅内网) + V127 device_log_file/device_log_config + admin-vue 日志管理页;客户端/麦象按 offset 增量上报(X-Internal-Token),查询仅超管 - 后台通用配置:modules/appconfig + V128 app_config 键值表;工作台「开店流程」密码改服务端 校验(POST /api/kd-flow/verify),改密码只需 UPDATE 该行、客户端无需重新发版 - 密钥检测模型解耦:新增 aiimage.user-secret.check-model(env AIIMAGE_USER_SECRET_CHECK_MODEL), 默认 doubao-seed-2-0-lite-260215 —— 同系列 mini 在中继分组下无可用渠道(503 model_not_found), 实测 lite 可路由;UserSecretModule 去掉 LlmTarget 改 resolveLlmHost,探测日志带 model= - 前端:密钥面板「检测配置密钥」按钮不再折行;client-changelog 补 4.0.19/4.0.20/4.0.21 条目 注:application.yml 与 PropertiesConfig 同时承载上述多个部分,故未按功能拆分提交
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 日志文件行(桌面客户端 / 麦象采集机上报)。 */
|
||||
export interface DeviceLogFileRow {
|
||||
id: number
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
username: string | null
|
||||
uid: number | null
|
||||
fileName: string
|
||||
logDate: string
|
||||
uploadedBytes: number
|
||||
partCount: number
|
||||
lastUploadAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogPage {
|
||||
items: DeviceLogFileRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 云端日志保留天数(页面提示用)。 */
|
||||
retentionDays: number
|
||||
}
|
||||
|
||||
export interface DeviceLogContent {
|
||||
fileId: number
|
||||
fileName: string
|
||||
content: string
|
||||
totalBytes: number
|
||||
shownBytes: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface DeviceLogDevice {
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
lastUploadAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogOverride {
|
||||
id: number
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
mode: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogConfigData {
|
||||
globalMode: string
|
||||
overrides: DeviceLogOverride[]
|
||||
}
|
||||
|
||||
export interface DeviceLogQuery {
|
||||
source?: string
|
||||
keyword?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询日志文件列表:GET /api/admin/device-logs/files */
|
||||
export async function fetchDeviceLogFiles(params: DeviceLogQuery): Promise<DeviceLogPage> {
|
||||
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||
if (params.source) query.source = params.source
|
||||
if (params.keyword) query.keyword = params.keyword
|
||||
if (params.startDate) query.startDate = params.startDate
|
||||
if (params.endDate) query.endDate = params.endDate
|
||||
const { data } = await http.get('/api/admin/device-logs/files', { params: query })
|
||||
return unwrap<DeviceLogPage>(data)
|
||||
}
|
||||
|
||||
/** 查看日志尾部内容:GET /api/admin/device-logs/content */
|
||||
export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise<DeviceLogContent> {
|
||||
const query: Record<string, number> = { fileId }
|
||||
if (maxBytes) query.maxBytes = maxBytes
|
||||
const { data } = await http.get('/api/admin/device-logs/content', { params: query })
|
||||
return unwrap<DeviceLogContent>(data)
|
||||
}
|
||||
|
||||
/** 完整日志下载地址(同域 cookie 鉴权,直接给 a[href] 或 window.open 用)。 */
|
||||
export function deviceLogDownloadUrl(fileId: number): string {
|
||||
return `/api/admin/device-logs/download?fileId=${fileId}`
|
||||
}
|
||||
|
||||
/** 删除日志文件(片段与元数据,不可恢复):DELETE /api/admin/device-logs/{id} */
|
||||
export async function deleteDeviceLogFile(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/device-logs/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */
|
||||
export async function fetchDeviceLogConfig(keyword?: string): Promise<DeviceLogConfigData> {
|
||||
const { data } = await http.get('/api/admin/device-logs/config', {
|
||||
params: keyword ? { keyword } : undefined,
|
||||
})
|
||||
return unwrap<DeviceLogConfigData>(data)
|
||||
}
|
||||
|
||||
/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */
|
||||
export async function fetchDeviceLogDevices(): Promise<DeviceLogDevice[]> {
|
||||
const { data } = await http.get('/api/admin/device-logs/devices')
|
||||
return unwrap<DeviceLogDevice[]>(data)
|
||||
}
|
||||
|
||||
/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */
|
||||
export async function updateDeviceLogGlobalMode(mode: string): Promise<void> {
|
||||
const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } })
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */
|
||||
export async function updateDeviceLogOverride(
|
||||
source: string,
|
||||
deviceId: string,
|
||||
deviceName: string | null,
|
||||
mode: string,
|
||||
): Promise<void> {
|
||||
const { data } = await http.put('/api/admin/device-logs/config/device', undefined, {
|
||||
params: { source, deviceId, deviceName: deviceName || undefined, mode },
|
||||
})
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */
|
||||
export async function deleteDeviceLogOverride(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
<script setup lang="ts">
|
||||
/** 日志管理页:桌面客户端 / 麦象采集机上报的日志浏览(仅超管)。
|
||||
* 支持来源/日期/关键字筛选,尾部内容查看(自动滚到底、可向前加载更早内容)、
|
||||
* 完整下载、删除;「采集配置」可调全量/精选模式(全局默认 + 终端覆盖)。 */
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import {
|
||||
deleteDeviceLogFile,
|
||||
deleteDeviceLogOverride,
|
||||
deviceLogDownloadUrl,
|
||||
fetchDeviceLogConfig,
|
||||
fetchDeviceLogContent,
|
||||
fetchDeviceLogDevices,
|
||||
fetchDeviceLogFiles,
|
||||
updateDeviceLogGlobalMode,
|
||||
updateDeviceLogOverride,
|
||||
type DeviceLogContent,
|
||||
type DeviceLogDevice,
|
||||
type DeviceLogFileRow,
|
||||
type DeviceLogOverride,
|
||||
} from '@/api/device-logs'
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: '', label: '全部来源' },
|
||||
{ value: 'client', label: '桌面客户端' },
|
||||
{ value: 'maixiang', label: '麦象采集机' },
|
||||
]
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'client') return '桌面客户端'
|
||||
if (source === 'maixiang') return '麦象采集机'
|
||||
return source || '—'
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let value = bytes / 1024
|
||||
let unitIndex = 0
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<DeviceLogFileRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const sourceFilter = ref('')
|
||||
const keyword = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const retentionDays = ref(7)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchDeviceLogFiles({
|
||||
source: sourceFilter.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
rows.value = result?.items || []
|
||||
total.value = Number(result?.total || 0)
|
||||
if (result?.retentionDays) retentionDays.value = result.retentionDays
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '日志列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
sourceFilter.value = ''
|
||||
keyword.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 内容查看
|
||||
|
||||
const TAIL_STEP = 256 * 1024
|
||||
const TAIL_MAX = 8 * 1024 * 1024
|
||||
|
||||
const viewerVisible = ref(false)
|
||||
const viewerLoading = ref(false)
|
||||
const viewerFile = ref<DeviceLogFileRow | null>(null)
|
||||
const viewerContent = ref<DeviceLogContent | null>(null)
|
||||
const viewerMaxBytes = ref(TAIL_STEP)
|
||||
const viewerPre = ref<HTMLElement | null>(null)
|
||||
|
||||
async function openViewer(row: DeviceLogFileRow) {
|
||||
viewerFile.value = row
|
||||
viewerMaxBytes.value = TAIL_STEP
|
||||
viewerContent.value = null
|
||||
viewerVisible.value = true
|
||||
await loadContent(true)
|
||||
}
|
||||
|
||||
async function loadContent(scrollToBottom: boolean) {
|
||||
if (!viewerFile.value) return
|
||||
viewerLoading.value = true
|
||||
try {
|
||||
viewerContent.value = await fetchDeviceLogContent(viewerFile.value.id, viewerMaxBytes.value)
|
||||
if (scrollToBottom) {
|
||||
await nextTick()
|
||||
if (viewerPre.value) viewerPre.value.scrollTop = viewerPre.value.scrollHeight
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '日志内容加载失败')
|
||||
} finally {
|
||||
viewerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
viewerMaxBytes.value = Math.min(viewerMaxBytes.value * 2, TAIL_MAX)
|
||||
loadContent(false)
|
||||
}
|
||||
|
||||
async function remove(row: DeviceLogFileRow) {
|
||||
if (!window.confirm(`确认删除「${row.fileName}」(${row.deviceName || row.deviceId})的云端日志?删除后不可恢复。`)) return
|
||||
try {
|
||||
await deleteDeviceLogFile(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 采集配置
|
||||
|
||||
const configVisible = ref(false)
|
||||
const configLoading = ref(false)
|
||||
const globalMode = ref('full')
|
||||
const overrides = ref<DeviceLogOverride[]>([])
|
||||
const devices = ref<DeviceLogDevice[]>([])
|
||||
const newOverrideKey = ref('')
|
||||
const newOverrideMode = ref('selected')
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: 'full', label: '全量采集' },
|
||||
{ value: 'selected', label: '精选采集' },
|
||||
]
|
||||
|
||||
function modeLabel(mode: string): string {
|
||||
return mode === 'selected' ? '精选' : '全量'
|
||||
}
|
||||
|
||||
async function openConfig() {
|
||||
configVisible.value = true
|
||||
await loadConfig()
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
configLoading.value = true
|
||||
try {
|
||||
const data = await fetchDeviceLogConfig()
|
||||
globalMode.value = data.globalMode || 'full'
|
||||
overrides.value = data.overrides || []
|
||||
devices.value = (await fetchDeviceLogDevices()) || []
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '采集配置加载失败')
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGlobalMode(mode: string) {
|
||||
if (mode === globalMode.value) return
|
||||
try {
|
||||
await updateDeviceLogGlobalMode(mode)
|
||||
globalMode.value = mode
|
||||
ElMessage.success(`全局采集模式已切换为「${modeLabel(mode)}」`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function addOverride() {
|
||||
if (!newOverrideKey.value) {
|
||||
ElMessage.warning('请先选择终端')
|
||||
return
|
||||
}
|
||||
const [source, deviceId] = newOverrideKey.value.split('|')
|
||||
const device = devices.value.find((item) => item.source === source && item.deviceId === deviceId)
|
||||
try {
|
||||
await updateDeviceLogOverride(source, deviceId, device?.deviceName || null, newOverrideMode.value)
|
||||
ElMessage.success('终端覆盖已保存')
|
||||
newOverrideKey.value = ''
|
||||
await loadConfig()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOverride(row: DeviceLogOverride) {
|
||||
const who = row.deviceName || row.deviceId
|
||||
if (!window.confirm(`确认删除终端「${who}」的采集覆盖(回落到全局默认)?`)) return
|
||||
try {
|
||||
await deleteDeviceLogOverride(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadConfig()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-logs-view">
|
||||
<section class="panel-box">
|
||||
<div class="logs-head">
|
||||
<h3>日志文件列表</h3>
|
||||
<div class="logs-head-tools">
|
||||
<span class="retention-tip">云端仅保留 {{ retentionDays }} 天</span>
|
||||
<button class="btn btn-ghost" type="button" @click="openConfig">采集配置</button>
|
||||
<button class="btn" type="button" @click="load">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row logs-filter-row">
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>来源</label>
|
||||
<select v-model="sourceFilter">
|
||||
<option v-for="option in SOURCE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 220px">
|
||||
<label>设备 / 文件名</label>
|
||||
<input v-model="keyword" type="text" placeholder="模糊搜索设备名、设备ID、文件名" @keyup.enter="search" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>日志日期(起)</label>
|
||||
<input v-model="startDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>日志日期(止)</label>
|
||||
<input v-model="endDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<div class="filter-actions">
|
||||
<button class="btn" type="button" @click="search">查询</button>
|
||||
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px">来源</th>
|
||||
<th style="width: 230px">设备</th>
|
||||
<th style="width: 130px">用户</th>
|
||||
<th style="width: 230px">文件名</th>
|
||||
<th style="width: 110px">日志日期</th>
|
||||
<th style="width: 100px">已收大小</th>
|
||||
<th style="width: 80px">片段数</th>
|
||||
<th style="width: 170px">最后更新</th>
|
||||
<th style="width: 190px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>
|
||||
<span class="source-pill" :class="row.source === 'maixiang' ? 'is-maixiang' : 'is-client'">
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="device-name" :title="row.deviceId">{{ row.deviceName || '—' }}</span>
|
||||
<span class="device-id" :title="row.deviceId">{{ row.deviceId }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.username" class="user-name">{{ row.username }}</span>
|
||||
<span v-else-if="row.uid" class="user-name">UID {{ row.uid }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
|
||||
</td>
|
||||
<td>{{ row.logDate }}</td>
|
||||
<td>{{ formatBytes(row.uploadedBytes) }}</td>
|
||||
<td>{{ row.partCount ?? 0 }}</td>
|
||||
<td>{{ row.lastUploadAt ? formatDateTime(row.lastUploadAt) : '—' }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openViewer(row)">查看</button>
|
||||
<a class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(row.id)" download>下载</a>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="9" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="9" class="empty-tip">
|
||||
{{ keyword || sourceFilter || startDate || endDate ? '暂无匹配日志' : '暂无日志上报(客户端/采集机上报后自动出现在这里)' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="viewerVisible" :title="viewerFile ? `${viewerFile.fileName}(${viewerFile.deviceName || viewerFile.deviceId})` : '日志内容'" width="900px" top="5vh">
|
||||
<div class="viewer-toolbar">
|
||||
<span class="viewer-meta">
|
||||
云端已收 {{ formatBytes(viewerContent?.totalBytes ?? viewerFile?.uploadedBytes) }}
|
||||
<template v-if="viewerContent"> · 当前展示 {{ formatBytes(viewerContent.shownBytes) }}</template>
|
||||
<template v-if="viewerContent?.truncated"> · 更早内容未加载</template>
|
||||
</span>
|
||||
<span class="viewer-actions">
|
||||
<button class="btn btn-sm btn-ghost" type="button" :disabled="viewerLoading" @click="loadContent(true)">刷新</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
type="button"
|
||||
:disabled="viewerLoading || !viewerContent?.truncated || viewerMaxBytes >= TAIL_MAX"
|
||||
@click="loadMore"
|
||||
>加载更早</button>
|
||||
<a v-if="viewerFile" class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(viewerFile.id)" download>下载完整日志</a>
|
||||
</span>
|
||||
</div>
|
||||
<pre ref="viewerPre" class="log-pre">{{ viewerLoading && !viewerContent ? '加载中...' : (viewerContent?.content || '(暂无内容)') }}</pre>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="configVisible" title="采集配置" width="720px">
|
||||
<div class="config-block">
|
||||
<h4>全局默认模式</h4>
|
||||
<p class="config-desc">对未单独配置的终端生效。全量=上传日志目录内全部文件;精选=排除低价值大日志(客户端排除 pywebview;麦象排除 kk-browser / 控制台 / 测试日志)。终端在下一次上报周期(约 1 分钟内)跟随新配置。</p>
|
||||
<div class="mode-switch">
|
||||
<button
|
||||
v-for="option in MODE_OPTIONS"
|
||||
:key="option.value"
|
||||
class="mode-btn"
|
||||
:class="{ 'is-active': globalMode === option.value }"
|
||||
type="button"
|
||||
@click="saveGlobalMode(option.value)"
|
||||
>{{ option.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-block">
|
||||
<h4>终端覆盖</h4>
|
||||
<div class="override-add">
|
||||
<select v-model="newOverrideKey" class="override-select">
|
||||
<option value="">选择终端(最近上报的设备)</option>
|
||||
<option v-for="device in devices" :key="`${device.source}|${device.deviceId}`" :value="`${device.source}|${device.deviceId}`">
|
||||
{{ sourceLabel(device.source) }} · {{ device.deviceName || device.deviceId }}({{ device.deviceId }})
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="newOverrideMode">
|
||||
<option v-for="option in MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm" type="button" @click="addOverride">添加/更新覆盖</button>
|
||||
</div>
|
||||
<table class="override-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px">来源</th>
|
||||
<th>设备</th>
|
||||
<th style="width: 80px">模式</th>
|
||||
<th style="width: 160px">更新时间</th>
|
||||
<th style="width: 90px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in overrides" :key="row.id">
|
||||
<td>{{ sourceLabel(row.source) }}</td>
|
||||
<td>
|
||||
<span class="device-name">{{ row.deviceName || row.deviceId }}</span>
|
||||
<span class="device-id">{{ row.deviceId }}</span>
|
||||
</td>
|
||||
<td>{{ modeLabel(row.mode) }}</td>
|
||||
<td>{{ row.updatedAt ? formatDateTime(row.updatedAt) : '—' }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeOverride(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!overrides.length">
|
||||
<td colspan="5" class="empty-tip">{{ configLoading ? '加载中...' : '暂无终端覆盖(全部跟随全局默认)' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="configVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 沿用「记录与版本」系列的旧后台面板视觉语言。 */
|
||||
.device-logs-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.logs-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.retention-tip {
|
||||
color: #8598ab;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.logs-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: #ffffff;
|
||||
border-color: #c7d7e5;
|
||||
color: #4f78a5;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12.5px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.logs-table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
color: #5b6f83;
|
||||
font-weight: 650;
|
||||
font-size: 12.5px;
|
||||
border-bottom: 1px solid #dbe6f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eaf1f7;
|
||||
vertical-align: top;
|
||||
}
|
||||
.source-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.source-pill.is-client {
|
||||
background: #e8f1fb;
|
||||
color: #37618f;
|
||||
}
|
||||
.source-pill.is-maixiang {
|
||||
background: #eef7ec;
|
||||
color: #3f7a42;
|
||||
}
|
||||
.device-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
.device-id {
|
||||
display: block;
|
||||
color: #8598ab;
|
||||
font-size: 11.5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-name {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
}
|
||||
.dim {
|
||||
color: #9db0c2;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: none;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 26px 0;
|
||||
text-align: center;
|
||||
color: #8598ab;
|
||||
}
|
||||
.dl-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.viewer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.viewer-meta {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.viewer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.log-pre {
|
||||
max-height: 62vh;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 10px;
|
||||
background: #0f1c29;
|
||||
color: #d7e4f1;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
}
|
||||
.config-block {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.config-block h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13.5px;
|
||||
color: #24384d;
|
||||
}
|
||||
.config-desc {
|
||||
margin: 0 0 10px;
|
||||
color: #66798d;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.mode-btn {
|
||||
min-height: 38px;
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 9px;
|
||||
background: #ffffff;
|
||||
color: #4f78a5;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode-btn.is-active {
|
||||
border-color: #4f78a5;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.override-add {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.override-add select {
|
||||
min-height: 38px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
}
|
||||
.override-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.override-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,7 @@ export const adminPages: AdminPageDef[] = [
|
||||
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
|
||||
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
||||
{ path: 'records/tutorial', menuKey: 'admin_tutorial', title: '教程管理', load: () => import('@/pages/records/RecordsTutorialPage.vue') },
|
||||
{ path: 'records/device-logs', menuKey: 'admin_device_logs', title: '日志管理', load: () => import('@/pages/records/DeviceLogsPage.vue') },
|
||||
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
||||
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
||||
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
||||
|
||||
Reference in New Issue
Block a user