|
|
|
|
@@ -4,7 +4,7 @@
|
|
|
|
|
|
|
|
|
|
<!-- Action Type Filters -->
|
|
|
|
|
<div class="filter-row">
|
|
|
|
|
<span class="filter-label">操作类型:</span>
|
|
|
|
|
<span class="filter-label">操作:</span>
|
|
|
|
|
<button
|
|
|
|
|
v-for="action in actionTypes"
|
|
|
|
|
:key="action.value"
|
|
|
|
|
@@ -15,7 +15,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- User Filters -->
|
|
|
|
|
<div class="filter-row" v-if="uniqueUsers.length > 0">
|
|
|
|
|
<div class="filter-row" v-if="uniqueUsers.length > 1">
|
|
|
|
|
<span class="filter-label">用户:</span>
|
|
|
|
|
<button
|
|
|
|
|
v-for="u in uniqueUsers"
|
|
|
|
|
@@ -26,26 +26,30 @@
|
|
|
|
|
>{{ 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 || log.id" class="log-item">
|
|
|
|
|
<div v-for="log in filteredLogs" :key="log.id" class="log-item">
|
|
|
|
|
<div class="log-header">
|
|
|
|
|
<span class="log-action" :class="actionClass(log.action)">{{ actionLabel(log.action) }}</span>
|
|
|
|
|
<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_type" class="log-target">{{ log.target_type }}: </span>
|
|
|
|
|
<span class="log-desc">{{ log.description || log.detail || formatDetail(log) }}</span>
|
|
|
|
|
<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 v-if="log.changes" class="log-changes">
|
|
|
|
|
<pre class="changes-pre">{{ typeof log.changes === 'string' ? log.changes : JSON.stringify(log.changes, null, 2) }}</pre>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
v-if="log.undoable"
|
|
|
|
|
class="btn-undo"
|
|
|
|
|
@click="undoLog(log)"
|
|
|
|
|
>↩ 撤销</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="filteredLogs.length === 0" class="empty-hint">暂无日志记录</div>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -61,29 +65,46 @@
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, computed, onMounted } from 'vue'
|
|
|
|
|
import { useAuthStore } from '../stores/auth'
|
|
|
|
|
import { useUiStore } from '../stores/ui'
|
|
|
|
|
import { api } from '../composables/useApi'
|
|
|
|
|
import { showConfirm } from '../composables/useDialog'
|
|
|
|
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
const ui = useUiStore()
|
|
|
|
|
|
|
|
|
|
const logs = ref([])
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const hasMore = ref(true)
|
|
|
|
|
const page = ref(0)
|
|
|
|
|
const pageSize = 50
|
|
|
|
|
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: '恢复用户',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const actionTypes = [
|
|
|
|
|
{ value: 'create', label: '创建' },
|
|
|
|
|
{ value: 'update', label: '更新' },
|
|
|
|
|
{ value: 'delete', label: '删除' },
|
|
|
|
|
{ value: 'login', label: '登录' },
|
|
|
|
|
{ value: 'approve', label: '审核' },
|
|
|
|
|
{ value: 'export', label: '导出' },
|
|
|
|
|
{ value: 'recipe', label: '配方' },
|
|
|
|
|
{ value: 'oil', label: '精油' },
|
|
|
|
|
{ value: 'user', label: '用户' },
|
|
|
|
|
{ value: 'tag', label: '标签' },
|
|
|
|
|
{ value: 'adopt', label: '审核' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const targetTypes = [
|
|
|
|
|
{ value: 'recipe', label: '配方' },
|
|
|
|
|
{ value: 'oil', label: '精油' },
|
|
|
|
|
{ value: 'user', label: '用户' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const uniqueUsers = computed(() => {
|
|
|
|
|
@@ -98,59 +119,55 @@ const uniqueUsers = computed(() => {
|
|
|
|
|
const filteredLogs = computed(() => {
|
|
|
|
|
let result = logs.value
|
|
|
|
|
if (selectedAction.value) {
|
|
|
|
|
result = result.filter(l => l.action === 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
|
|
|
|
|
)
|
|
|
|
|
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) {
|
|
|
|
|
const map = {
|
|
|
|
|
create: '创建',
|
|
|
|
|
update: '更新',
|
|
|
|
|
delete: '删除',
|
|
|
|
|
login: '登录',
|
|
|
|
|
approve: '审核',
|
|
|
|
|
reject: '拒绝',
|
|
|
|
|
export: '导出',
|
|
|
|
|
undo: '撤销',
|
|
|
|
|
}
|
|
|
|
|
return map[action] || action
|
|
|
|
|
return ACTION_MAP[action] || action
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function actionClass(action) {
|
|
|
|
|
return {
|
|
|
|
|
'action-create': action === 'create',
|
|
|
|
|
'action-update': action === 'update',
|
|
|
|
|
'action-delete': action === 'delete' || action === 'reject',
|
|
|
|
|
'action-login': action === 'login',
|
|
|
|
|
'action-approve': action === 'approve',
|
|
|
|
|
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_user) parts.push(`来自: ${d.from_user}`)
|
|
|
|
|
if (d.reason) parts.push(`原因: ${d.reason}`)
|
|
|
|
|
if (d.role) parts.push(`角色: ${d.role}`)
|
|
|
|
|
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 ''
|
|
|
|
|
const d = new Date(t)
|
|
|
|
|
return d.toLocaleString('zh-CN', {
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
second: '2-digit',
|
|
|
|
|
return new Date(t + 'Z').toLocaleString('zh-CN', {
|
|
|
|
|
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDetail(log) {
|
|
|
|
|
if (log.target_name) return log.target_name
|
|
|
|
|
if (log.recipe_name) return log.recipe_name
|
|
|
|
|
if (log.oil_name) return log.oil_name
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchLogs() {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
@@ -158,9 +175,7 @@ async function fetchLogs() {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
if (items.length < pageSize) hasMore.value = false
|
|
|
|
|
logs.value.push(...items)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
@@ -174,207 +189,52 @@ function loadMore() {
|
|
|
|
|
fetchLogs()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function undoLog(log) {
|
|
|
|
|
const ok = await showConfirm('确定撤销此操作?')
|
|
|
|
|
if (!ok) return
|
|
|
|
|
try {
|
|
|
|
|
const id = log._id || log.id
|
|
|
|
|
const res = await api(`/api/audit-log/${id}/undo`, { method: 'POST' })
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
ui.showToast('已撤销')
|
|
|
|
|
// Refresh
|
|
|
|
|
logs.value = []
|
|
|
|
|
page.value = 0
|
|
|
|
|
hasMore.value = true
|
|
|
|
|
await fetchLogs()
|
|
|
|
|
} else {
|
|
|
|
|
ui.showToast('撤销失败')
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
ui.showToast('撤销失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
fetchLogs()
|
|
|
|
|
})
|
|
|
|
|
onMounted(() => fetchLogs())
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.audit-log {
|
|
|
|
|
padding: 0 12px 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.page-title {
|
|
|
|
|
margin: 0 0 16px;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: #3e3a44;
|
|
|
|
|
}
|
|
|
|
|
.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: 10px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
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-label { font-size: 13px; color: #6b6375; font-weight: 500; white-space: nowrap; }
|
|
|
|
|
.filter-btn {
|
|
|
|
|
padding: 5px 14px;
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1.5px solid #e5e4e7;
|
|
|
|
|
background: #fff;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
color: #6b6375;
|
|
|
|
|
transition: all 0.15s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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: 6px;
|
|
|
|
|
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: 12px 14px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
border: 1.5px solid #e5e4e7;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
transition: border-color 0.15s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-item:hover {
|
|
|
|
|
border-color: #d4cfc7;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
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;
|
|
|
|
|
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; }
|
|
|
|
|
|
|
|
|
|
.action-create { background: #e8f5e9; color: #2e7d5a; }
|
|
|
|
|
.action-update { background: #e3f2fd; color: #1565c0; }
|
|
|
|
|
.action-delete { background: #ffebee; color: #c62828; }
|
|
|
|
|
.action-login { background: #fff3e0; color: #e65100; }
|
|
|
|
|
.action-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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-detail {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #6b6375;
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-target {
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
color: #3e3a44;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-changes {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.changes-pre {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
background: #f8f7f5;
|
|
|
|
|
padding: 8px 10px;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: #6b6375;
|
|
|
|
|
font-family: ui-monospace, Consolas, monospace;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
max-height: 120px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn-undo {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
border: 1.5px solid #e5e4e7;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
color: #6b6375;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn-undo:hover {
|
|
|
|
|
border-color: #7ec6a4;
|
|
|
|
|
color: #4a9d7e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.load-more {
|
|
|
|
|
text-align: center;
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
}
|
|
|
|
|
.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;
|
|
|
|
|
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>
|
|
|
|
|
|