Files
oil-formula-calculator/frontend/src/views/AuditLog.vue
Hera Zhao 3a7e52360c
All checks were successful
Test / unit-test (push) Successful in 7s
Test / build-check (push) Successful in 6s
Test / e2e-test (push) Successful in 50s
fix: 操作日志详细记录权限变更
- 修改用户权限时记录旧角色→新角色(中文)和用户名
- 日志显示"查看者 → 高级编辑"格式
- 商业认证日志显示商户名

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:53:22 +00:00

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>