feat(log): 优化操作日志记录和展示功能
- 在OperationLogEntity实体中新增summary和ipAddress字段存储摘要和IP信息 - 修改operationLogService.record方法支持传入操作摘要信息 - 实现客户端IP地址解析功能,支持X-Forwarded-For和X-Real-IP头 - 更新系统更新服务中的数据库表结构迁移逻辑,增加NOT NULL列处理 - 优化前端操作日志页面展示,添加标签分类和详情弹窗功能 - 在系统更新流式响应中增加网络连接异常处理机制 - 添加Nginx代理配置中的缓冲区设置以支持实时日志流式传输
这个提交包含在:
父节点
38e138f955
当前提交
4b13f64966
@ -120,6 +120,8 @@ export interface OpsLogItem {
|
||||
action: string
|
||||
operator: string
|
||||
detailJson: string
|
||||
summary?: string
|
||||
ipAddress?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
@ -6,22 +6,44 @@
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" v-loading="loading" border stripe>
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<el-table-column prop="createdAt" label="时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="操作者" width="140" />
|
||||
<el-table-column prop="operator" label="操作者" width="120" />
|
||||
<el-table-column prop="moduleType" label="模块" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.moduleType }}</el-tag>
|
||||
<el-tag size="small" :type="moduleTagType(row.moduleType)" effect="plain">{{ row.moduleType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="动作" width="180" />
|
||||
<el-table-column prop="resourceType" label="资源类型" width="120" />
|
||||
<el-table-column prop="resourceId" label="资源ID" width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
|
||||
<el-table-column label="详情" min-width="200">
|
||||
<el-table-column label="操作摘要" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<span class="detail-text">{{ formatDetail(row.detailJson) }}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<el-tag size="small" :type="actionTagType(row.action)" effect="dark">{{ row.action }}</el-tag>
|
||||
<span style="font-size:13px;color:#303133">{{ row.summary || row.resourceId || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="ipAddress" label="IP" width="140">
|
||||
<template #default="{ row }">
|
||||
<span style="font-family:monospace;font-size:12px;color:#909399">{{ row.ipAddress || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详情" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-popover v-if="hasDetail(row.detailJson)" placement="left" :width="360" trigger="click">
|
||||
<template #reference>
|
||||
<el-button link type="primary" size="small">查看</el-button>
|
||||
</template>
|
||||
<div style="max-height:400px;overflow-y:auto">
|
||||
<div v-for="(item, idx) in parseDetail(row.detailJson)" :key="idx"
|
||||
style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #f0f0f0;font-size:13px">
|
||||
<span style="flex-shrink:0;width:80px;color:#909399;font-weight:500">{{ item.key }}</span>
|
||||
<span style="flex:1;word-break:break-all;color:#303133">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -43,14 +65,30 @@ const page = ref(0)
|
||||
const size = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
function formatDetail(json: string) {
|
||||
if (!json) return '-'
|
||||
function moduleTagType(m: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
return { CONSOLE: 'info', APP: '', SUB_ACCOUNT: 'warning', SERVICE: 'success', APP_SECRET: 'danger', EMAIL_VERIFY: 'info' }[m] as any ?? ''
|
||||
}
|
||||
|
||||
function actionTagType(a: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
if (a.startsWith('CREATE')) return 'success'
|
||||
if (a.startsWith('DELETE') || a.startsWith('DISABLE')) return 'danger'
|
||||
if (a.startsWith('RESET') || a.startsWith('REGENERATE')) return 'warning'
|
||||
if (a.startsWith('VIEW')) return 'info'
|
||||
return ''
|
||||
}
|
||||
|
||||
function hasDetail(json: string) {
|
||||
if (!json) return false
|
||||
try { const o = JSON.parse(json); return o && typeof o === 'object' && Object.keys(o).length > 0 } catch { return false }
|
||||
}
|
||||
|
||||
function parseDetail(json: string): { key: string; value: string }[] {
|
||||
if (!json) return []
|
||||
try {
|
||||
const obj = JSON.parse(json)
|
||||
return Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(', ')
|
||||
} catch {
|
||||
return json
|
||||
}
|
||||
const o = JSON.parse(json)
|
||||
if (!o || typeof o !== 'object') return []
|
||||
return Object.entries(o).map(([k, v]) => ({ key: k, value: typeof v === 'object' ? JSON.stringify(v) : String(v ?? '') }))
|
||||
} catch { return [{ key: 'raw', value: json }] }
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
@ -68,11 +106,3 @@ async function loadLogs() {
|
||||
|
||||
onMounted(loadLogs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-text {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -9,6 +9,8 @@ export interface TenantOperationLog {
|
||||
action: string
|
||||
operator?: string
|
||||
detailJson?: string
|
||||
summary?: string
|
||||
ipAddress?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
@ -30,17 +30,26 @@ async function streamOperation(
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
onLine(line)
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
onLine(line)
|
||||
}
|
||||
}
|
||||
if (buf) onLine(buf)
|
||||
} catch (e: any) {
|
||||
// Connection drop during RESTART_SELF is expected - don't throw
|
||||
if (e?.name === 'TypeError' || e?.message?.includes('network') || e?.message?.includes('fetch')) {
|
||||
onLine('>>> 连接已中断(服务正在重启)')
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
if (buf) onLine(buf)
|
||||
}
|
||||
|
||||
export async function getRunningServices(): Promise<string[]> {
|
||||
|
||||
@ -26,21 +26,62 @@
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table :data="tenantLogs" v-loading="tenantLoading" border stripe>
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="moduleType" label="模块" width="140">
|
||||
<el-table-column prop="createdAt" label="时间" width="170" sortable>
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ tenantModuleLabel(row.moduleType) }}</el-tag>
|
||||
<span class="time-text">{{ formatTime(row.createdAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="动作" width="180" />
|
||||
<el-table-column prop="resourceType" label="资源类型" width="140" />
|
||||
<el-table-column prop="resourceId" label="资源ID" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="operator" label="操作人" width="180" />
|
||||
<el-table-column label="详情" min-width="280">
|
||||
<el-table-column prop="moduleType" label="模块" width="110">
|
||||
<template #default="{ row }">
|
||||
<span class="detail">{{ formatDetail(row.detailJson) }}</span>
|
||||
<el-tag size="small" :type="moduleTagType(row.moduleType)" effect="plain">
|
||||
{{ tenantModuleLabel(row.moduleType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作摘要" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="summary-cell">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="actionTagType(row.action)"
|
||||
effect="dark"
|
||||
class="action-tag"
|
||||
>
|
||||
{{ actionLabel(row.action) }}
|
||||
</el-tag>
|
||||
<span class="summary-text">{{ row.summary || generateSummary(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="操作人" width="120" />
|
||||
<el-table-column prop="ipAddress" label="IP 地址" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="ip-text">{{ row.ipAddress || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详细数据" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-popover
|
||||
v-if="hasDetail(row.detailJson)"
|
||||
placement="left"
|
||||
:width="380"
|
||||
trigger="click"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button link type="primary" size="small">查看</el-button>
|
||||
</template>
|
||||
<div class="detail-popover">
|
||||
<div
|
||||
v-for="(item, idx) in parseDetail(row.detailJson)"
|
||||
:key="idx"
|
||||
class="detail-row"
|
||||
>
|
||||
<span class="detail-key">{{ item.key }}</span>
|
||||
<span class="detail-value">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
<span v-else class="no-detail">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -78,7 +119,7 @@
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table :data="updateLogs" v-loading="updateLoading" border stripe>
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<el-table-column prop="createdAt" label="时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceType" label="资源类型" width="140">
|
||||
@ -153,7 +194,7 @@ async function loadApps() {
|
||||
updateAppKey.value = apps.value[0].appKey
|
||||
}
|
||||
} catch {
|
||||
// ignore; empty state will be shown in the selector
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@ -201,17 +242,109 @@ function handleUpdateAppChange() {
|
||||
loadUpdateLogs()
|
||||
}
|
||||
|
||||
// ── Label mappings ──────────────────────────────────────────────────────────
|
||||
|
||||
function tenantModuleLabel(moduleType: string) {
|
||||
return {
|
||||
CONSOLE: '控制台',
|
||||
APP: '应用管理',
|
||||
SUB_ACCOUNT: '子账号管理',
|
||||
SUB_ACCOUNT: '子账号',
|
||||
SERVICE: '服务管理',
|
||||
APP_SECRET: '应用密钥',
|
||||
APP_SECRET: '密钥管理',
|
||||
EMAIL_VERIFY: '邮箱验证',
|
||||
}[moduleType] ?? moduleType
|
||||
}
|
||||
|
||||
function moduleTagType(moduleType: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
return {
|
||||
CONSOLE: 'info',
|
||||
APP: '',
|
||||
SUB_ACCOUNT: 'warning',
|
||||
SERVICE: 'success',
|
||||
APP_SECRET: 'danger',
|
||||
EMAIL_VERIFY: 'info',
|
||||
}[moduleType] as any ?? ''
|
||||
}
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return {
|
||||
CREATE_APP: '创建',
|
||||
UPDATE_APP: '编辑',
|
||||
DELETE_APP: '删除',
|
||||
RESET_APP_SECRET: '重置密钥',
|
||||
REQUEST_SECRET_VERIFY: '验证',
|
||||
REVEAL_APP_SECRET: '查看密钥',
|
||||
CREATE_SUB_ACCOUNT: '创建',
|
||||
DISABLE_SUB_ACCOUNT: '禁用',
|
||||
SEND_VERIFY_CODE: '发送验证码',
|
||||
VERIFY_EMAIL: '验证邮箱',
|
||||
DISABLE_SERVICE: '停用',
|
||||
UPDATE_SERVICE_CONFIG: '更新配置',
|
||||
REQUEST_SERVICE_ACTIVATION: '申请开通',
|
||||
REGENERATE_KEY: '重新生成密钥',
|
||||
VIEW_DASHBOARD: '查看',
|
||||
}[action] ?? action
|
||||
}
|
||||
|
||||
function actionTagType(action: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
if (action.startsWith('CREATE')) return 'success'
|
||||
if (action.startsWith('DELETE')) return 'danger'
|
||||
if (action.startsWith('DISABLE')) return 'danger'
|
||||
if (action.startsWith('RESET') || action.startsWith('REGENERATE')) return 'warning'
|
||||
if (action.startsWith('UPDATE') || action.startsWith('REVEAL')) return ''
|
||||
if (action.startsWith('VIEW')) return 'info'
|
||||
if (action.startsWith('SEND') || action.startsWith('VERIFY') || action.startsWith('REQUEST')) return 'info'
|
||||
return ''
|
||||
}
|
||||
|
||||
function generateSummary(row: TenantOperationLog): string {
|
||||
const action = actionLabel(row.action)
|
||||
const resource = row.resourceId || ''
|
||||
return `${action} ${resource}`
|
||||
}
|
||||
|
||||
function hasDetail(detailJson?: string): boolean {
|
||||
if (!detailJson) return false
|
||||
try {
|
||||
const parsed = JSON.parse(detailJson)
|
||||
return parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const DETAIL_KEY_LABELS: Record<string, string> = {
|
||||
name: '应用名称',
|
||||
packageName: '包名',
|
||||
appKey: 'AppKey',
|
||||
username: '用户名',
|
||||
nickname: '昵称',
|
||||
email: '邮箱',
|
||||
platform: '平台',
|
||||
serviceType: '服务类型',
|
||||
purpose: '用途',
|
||||
applyReason: '申请原因',
|
||||
before: '变更前',
|
||||
after: '变更后',
|
||||
appCount: '应用数',
|
||||
serviceCount: '服务数',
|
||||
subAccountCount: '子账号数',
|
||||
}
|
||||
|
||||
function parseDetail(detailJson?: string): { key: string; value: string }[] {
|
||||
if (!detailJson) return []
|
||||
try {
|
||||
const parsed = JSON.parse(detailJson)
|
||||
if (!parsed || typeof parsed !== 'object') return []
|
||||
return Object.entries(parsed).map(([k, v]) => ({
|
||||
key: DETAIL_KEY_LABELS[k] ?? k,
|
||||
value: typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? ''),
|
||||
}))
|
||||
} catch {
|
||||
return [{ key: '原始数据', value: detailJson }]
|
||||
}
|
||||
}
|
||||
|
||||
function updateResourceLabel(resourceType: string) {
|
||||
return {
|
||||
APP_VERSION: '应用版本',
|
||||
@ -255,7 +388,6 @@ function formatDetail(detailJson?: string) {
|
||||
return detailJson
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -265,15 +397,40 @@ function formatDetail(detailJson?: string) {
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.responsive-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.summary-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.summary-text {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ip-text {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
@ -282,17 +439,46 @@ function formatDetail(detailJson?: string) {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail-popover {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-key {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
color: #303133;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.no-detail {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.responsive-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.responsive-toolbar :deep(.el-select),
|
||||
.responsive-toolbar :deep(.el-button),
|
||||
.responsive-toolbar :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-wrap :deep(.el-table) {
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
@ -206,8 +206,8 @@
|
||||
v-model="showUpdateDialog"
|
||||
:title="operationType === 'update' ? '一键更新' : '重置容器'"
|
||||
width="600px"
|
||||
:close-on-click-modal="!updating"
|
||||
:close-on-press-escape="!updating"
|
||||
:close-on-click-modal="!updating && !selfRestarting"
|
||||
:close-on-press-escape="!updating && !selfRestarting"
|
||||
@closed="resetUpdateDialog"
|
||||
>
|
||||
<div v-if="!updating && !updateDone && !updateError" style="color:#606266;">
|
||||
@ -233,7 +233,7 @@
|
||||
|
||||
<div v-if="selfRestarting" class="reconnect-tip">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>正在等待服务重启...</span>
|
||||
<span>正在等待服务重启,页面将在服务恢复后自动刷新...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="updateDone" style="margin-top:12px">
|
||||
@ -497,9 +497,11 @@ async function startOperation() {
|
||||
const streamFn = operationType.value === 'update' ? streamSystemUpdate : streamSystemReset
|
||||
const failMsg = operationType.value === 'update' ? '更新失败' : '重置失败'
|
||||
|
||||
let seenRestartSelf = false
|
||||
try {
|
||||
await streamFn((line) => {
|
||||
if (line === 'RESTART_SELF') {
|
||||
seenRestartSelf = true
|
||||
selfRestarting.value = true
|
||||
updating.value = false
|
||||
pollForRecovery()
|
||||
@ -515,29 +517,50 @@ async function startOperation() {
|
||||
updateDone.value = true
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!selfRestarting.value) {
|
||||
updating.value = false
|
||||
updateError.value = e?.message ?? failMsg
|
||||
// Connection drop during self-restart is expected
|
||||
if (seenRestartSelf || selfRestarting.value) {
|
||||
if (!selfRestarting.value) {
|
||||
selfRestarting.value = true
|
||||
updating.value = false
|
||||
pollForRecovery()
|
||||
}
|
||||
return
|
||||
}
|
||||
updating.value = false
|
||||
updateError.value = e?.message ?? failMsg
|
||||
}
|
||||
}
|
||||
|
||||
async function pollForRecovery() {
|
||||
const deadline = Date.now() + 90_000
|
||||
const deadline = Date.now() + 120_000
|
||||
let attempt = 0
|
||||
while (Date.now() < deadline) {
|
||||
attempt++
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
try {
|
||||
await getDeploymentStatus()
|
||||
selfRestarting.value = false
|
||||
updateDone.value = true
|
||||
updateLog.value.push(operationType.value === 'update' ? '>>> tenant-service 已重启,更新完成 ✓' : '>>> tenant-service 已重启,重置完成 ✓')
|
||||
updateLog.value.push(operationType.value === 'update'
|
||||
? '>>> tenant-service 已重启,更新完成 ✓'
|
||||
: '>>> tenant-service 已重启,重置完成 ✓')
|
||||
// Auto-reload after 2s to refresh all page state
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
return
|
||||
} catch {
|
||||
// still restarting
|
||||
if (attempt % 5 === 0) {
|
||||
updateLog.value.push(`>>> 等待服务恢复中... (第 ${attempt} 次尝试)`)
|
||||
nextTick(() => {
|
||||
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
selfRestarting.value = false
|
||||
updateError.value = '等待 tenant-service 重启超时,请手动刷新页面'
|
||||
updateError.value = '等待服务重启超时(2 分钟),请手动刷新页面确认更新结果'
|
||||
updateLog.value.push('>>> 等待超时,请手动刷新页面检查服务状态')
|
||||
}
|
||||
|
||||
const fmt = formatTime
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户