- 修改用户权限时记录旧角色→新角色(中文)和用户名 - 日志显示"查看者 → 高级编辑"格式 - 商业认证日志显示商户名 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
248 lines
8.1 KiB
Vue
248 lines
8.1 KiB
Vue
<template>
|
|
<div class="audit-log">
|
|
<h3 class="page-title">📜 操作日志</h3>
|
|
|
|
<!-- Action Type Filters -->
|
|
<div class="filter-row">
|
|
<span class="filter-label">操作:</span>
|
|
<button
|
|
v-for="action in actionTypes"
|
|
:key="action.value"
|
|
class="filter-btn"
|
|
:class="{ active: selectedAction === action.value }"
|
|
@click="selectedAction = selectedAction === action.value ? '' : action.value"
|
|
>{{ action.label }}</button>
|
|
</div>
|
|
|
|
<!-- User Filters -->
|
|
<div class="filter-row" v-if="uniqueUsers.length > 1">
|
|
<span class="filter-label">用户:</span>
|
|
<button
|
|
v-for="u in uniqueUsers"
|
|
:key="u"
|
|
class="filter-btn"
|
|
:class="{ active: selectedUser === u }"
|
|
@click="selectedUser = selectedUser === u ? '' : u"
|
|
>{{ u }}</button>
|
|
</div>
|
|
|
|
<!-- Target Type Filters -->
|
|
<div class="filter-row">
|
|
<span class="filter-label">对象:</span>
|
|
<button
|
|
v-for="t in targetTypes"
|
|
:key="t.value"
|
|
class="filter-btn"
|
|
:class="{ active: selectedTarget === t.value }"
|
|
@click="selectedTarget = selectedTarget === t.value ? '' : t.value"
|
|
>{{ t.label }}</button>
|
|
</div>
|
|
|
|
<!-- Log List -->
|
|
<div class="log-list">
|
|
<div v-for="log in filteredLogs" :key="log.id" class="log-item">
|
|
<div class="log-header">
|
|
<span class="log-action" :class="actionColorClass(log.action)">{{ actionLabel(log.action) }}</span>
|
|
<span class="log-user">{{ log.user_name || log.username || '系统' }}</span>
|
|
<span class="log-time">{{ formatTime(log.created_at) }}</span>
|
|
</div>
|
|
<div class="log-detail">
|
|
<span v-if="log.target_name" class="log-target-name">{{ log.target_name }}</span>
|
|
<span v-if="parsedDetail(log)" class="log-extra">{{ parsedDetail(log) }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="filteredLogs.length === 0" class="empty-hint">暂无日志记录</div>
|
|
</div>
|
|
|
|
<!-- Load More -->
|
|
<div v-if="hasMore" class="load-more">
|
|
<button class="btn-outline" @click="loadMore" :disabled="loading">
|
|
{{ loading ? '加载中...' : '加载更多' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { api } from '../composables/useApi'
|
|
|
|
const logs = ref([])
|
|
const loading = ref(false)
|
|
const hasMore = ref(true)
|
|
const page = ref(0)
|
|
const pageSize = 100
|
|
const selectedAction = ref('')
|
|
const selectedUser = ref('')
|
|
const selectedTarget = ref('')
|
|
|
|
const ACTION_MAP = {
|
|
create_recipe: '新增配方',
|
|
update_recipe: '编辑配方',
|
|
delete_recipe: '删除配方',
|
|
adopt_recipe: '采纳配方',
|
|
reject_recipe: '拒绝配方',
|
|
undo_delete_recipe: '恢复配方',
|
|
upsert_oil: '编辑精油',
|
|
delete_oil: '删除精油',
|
|
create_tag: '新增标签',
|
|
delete_tag: '删除标签',
|
|
create_user: '创建用户',
|
|
update_user: '修改用户',
|
|
delete_user: '删除用户',
|
|
undo_delete_user: '恢复用户',
|
|
business_apply: '申请商业认证',
|
|
approve_business: '通过商业认证',
|
|
reject_business: '拒绝商业认证',
|
|
grant_business: '开通商业认证',
|
|
revoke_business: '撤销商业认证',
|
|
}
|
|
|
|
const actionTypes = [
|
|
{ value: 'recipe', label: '配方' },
|
|
{ value: 'oil', label: '精油' },
|
|
{ value: 'user', label: '用户' },
|
|
{ value: 'tag', label: '标签' },
|
|
{ value: 'adopt', label: '审核' },
|
|
{ value: 'business', label: '商业认证' },
|
|
]
|
|
|
|
const targetTypes = [
|
|
{ value: 'recipe', label: '配方' },
|
|
{ value: 'oil', label: '精油' },
|
|
{ value: 'user', label: '用户' },
|
|
]
|
|
|
|
const uniqueUsers = computed(() => {
|
|
const names = new Set()
|
|
for (const log of logs.value) {
|
|
const name = log.user_name || log.username
|
|
if (name) names.add(name)
|
|
}
|
|
return [...names].sort()
|
|
})
|
|
|
|
const filteredLogs = computed(() => {
|
|
let result = logs.value
|
|
if (selectedAction.value) {
|
|
result = result.filter(l => l.action.includes(selectedAction.value))
|
|
}
|
|
if (selectedUser.value) {
|
|
result = result.filter(l => (l.user_name || l.username) === selectedUser.value)
|
|
}
|
|
if (selectedTarget.value) {
|
|
result = result.filter(l => l.target_type === selectedTarget.value)
|
|
}
|
|
return result
|
|
})
|
|
|
|
function actionLabel(action) {
|
|
return ACTION_MAP[action] || action
|
|
}
|
|
|
|
function actionColorClass(action) {
|
|
if (action.includes('create') || action.includes('upsert')) return 'color-create'
|
|
if (action.includes('update')) return 'color-update'
|
|
if (action.includes('delete') || action.includes('reject')) return 'color-delete'
|
|
if (action.includes('adopt') || action.includes('undo')) return 'color-approve'
|
|
return ''
|
|
}
|
|
|
|
function parsedDetail(log) {
|
|
if (!log.detail) return ''
|
|
try {
|
|
const d = JSON.parse(log.detail)
|
|
const parts = []
|
|
if (d.from_role && d.to_role) parts.push(`${d.from_role} → ${d.to_role}`)
|
|
if (d.from_user) parts.push(`来自: ${d.from_user}`)
|
|
if (d.reason) parts.push(`原因: ${d.reason}`)
|
|
if (d.business_name) parts.push(`商户: ${d.business_name}`)
|
|
if (d.display_name) parts.push(`显示名: ${d.display_name}`)
|
|
if (d.original_log_id) parts.push(`恢复自 #${d.original_log_id}`)
|
|
if (parts.length) return parts.join(' · ')
|
|
// For deleted users, show username
|
|
if (d.username) return `用户名: ${d.username}`
|
|
return ''
|
|
} catch {
|
|
return log.detail.length > 100 ? log.detail.substring(0, 100) + '...' : log.detail
|
|
}
|
|
}
|
|
|
|
function formatTime(t) {
|
|
if (!t) return ''
|
|
return new Date(t + 'Z').toLocaleString('zh-CN', {
|
|
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
async function fetchLogs() {
|
|
loading.value = true
|
|
try {
|
|
const res = await api(`/api/audit-log?offset=${page.value * pageSize}&limit=${pageSize}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const items = Array.isArray(data) ? data : data.logs || data.items || []
|
|
if (items.length < pageSize) hasMore.value = false
|
|
logs.value.push(...items)
|
|
}
|
|
} catch {
|
|
hasMore.value = false
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
function loadMore() {
|
|
page.value++
|
|
fetchLogs()
|
|
}
|
|
|
|
onMounted(() => fetchLogs())
|
|
</script>
|
|
|
|
<style scoped>
|
|
.audit-log { padding: 0 12px 24px; }
|
|
.page-title { margin: 0 0 16px; font-size: 16px; color: #3e3a44; }
|
|
|
|
.filter-row {
|
|
display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap;
|
|
}
|
|
.filter-label { font-size: 13px; color: #6b6375; font-weight: 500; white-space: nowrap; }
|
|
.filter-btn {
|
|
padding: 4px 12px; border-radius: 16px; border: 1.5px solid #e5e4e7;
|
|
background: #fff; font-size: 12px; cursor: pointer; font-family: inherit; color: #6b6375;
|
|
}
|
|
.filter-btn.active { background: #e8f5e9; border-color: #7ec6a4; color: #2e7d5a; font-weight: 600; }
|
|
.filter-btn:hover { border-color: #d4cfc7; }
|
|
|
|
.log-list { display: flex; flex-direction: column; gap: 4px; }
|
|
.log-item {
|
|
padding: 10px 14px; background: #fff; border: 1.5px solid #e5e4e7; border-radius: 10px;
|
|
}
|
|
.log-item:hover { border-color: #d4cfc7; }
|
|
|
|
.log-header { display: flex; align-items: center; gap: 8px; }
|
|
.log-action {
|
|
padding: 2px 10px; border-radius: 10px; font-size: 11px; font-weight: 600;
|
|
background: #f0eeeb; color: #6b6375; white-space: nowrap;
|
|
}
|
|
.color-create { background: #e8f5e9; color: #2e7d5a; }
|
|
.color-update { background: #e3f2fd; color: #1565c0; }
|
|
.color-delete { background: #ffebee; color: #c62828; }
|
|
.color-approve { background: #f3e5f5; color: #7b1fa2; }
|
|
|
|
.log-user { font-size: 13px; font-weight: 500; color: #3e3a44; }
|
|
.log-time { font-size: 11px; color: #b0aab5; margin-left: auto; white-space: nowrap; }
|
|
.log-detail { font-size: 13px; color: #6b6375; margin-top: 2px; }
|
|
.log-target-name { font-weight: 500; color: #3e3a44; margin-right: 8px; }
|
|
.log-extra { color: #999; font-size: 12px; }
|
|
|
|
.load-more { text-align: center; margin-top: 16px; }
|
|
.btn-outline {
|
|
background: #fff; color: #6b6375; border: 1.5px solid #d4cfc7; border-radius: 10px;
|
|
padding: 9px 28px; font-size: 13px; cursor: pointer; font-family: inherit;
|
|
}
|
|
.btn-outline:hover { background: #f8f7f5; }
|
|
.btn-outline:disabled { opacity: 0.5; cursor: default; }
|
|
.empty-hint { text-align: center; color: #b0aab5; font-size: 13px; padding: 32px 0; }
|
|
</style>
|