All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 10s
Test / e2e-test (push) Successful in 4m24s
Endpoint fixes: - AuditLog: /api/audit-logs → /api/audit-log - BugTracker: /api/bugs → /api/bug-reports, create → /api/bug-report - BugTracker: fix create body (content+priority, not title/description) - MyDiary: /api/brand-settings → /api/brand - MyDiary: /api/me/display-name → PUT /api/me - RecipeSearch: /api/category-modules → /api/categories Test improvements: - Remove blanket uncaught:exception swallow (only ignore ResizeObserver) - Add endpoint-parity.cy.js: intercept-based test that verifies correct API endpoints are called and wrong ones are NOT called Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
381 lines
8.1 KiB
Vue
381 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 > 0">
|
|
<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>
|
|
|
|
<!-- Log List -->
|
|
<div class="log-list">
|
|
<div v-for="log in filteredLogs" :key="log._id || log.id" class="log-item">
|
|
<div class="log-header">
|
|
<span class="log-action" :class="actionClass(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>
|
|
</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>
|
|
|
|
<!-- 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 { 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 selectedAction = ref('')
|
|
const selectedUser = ref('')
|
|
|
|
const actionTypes = [
|
|
{ value: 'create', label: '创建' },
|
|
{ value: 'update', label: '更新' },
|
|
{ value: 'delete', label: '删除' },
|
|
{ value: 'login', label: '登录' },
|
|
{ value: 'approve', label: '审核' },
|
|
{ value: 'export', 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 === selectedAction.value)
|
|
}
|
|
if (selectedUser.value) {
|
|
result = result.filter(l =>
|
|
(l.user_name || l.username) === selectedUser.value
|
|
)
|
|
}
|
|
return result
|
|
})
|
|
|
|
function actionLabel(action) {
|
|
const map = {
|
|
create: '创建',
|
|
update: '更新',
|
|
delete: '删除',
|
|
login: '登录',
|
|
approve: '审核',
|
|
reject: '拒绝',
|
|
export: '导出',
|
|
undo: '撤销',
|
|
}
|
|
return 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 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',
|
|
})
|
|
}
|
|
|
|
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 {
|
|
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()
|
|
}
|
|
|
|
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()
|
|
})
|
|
</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: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.log-action {
|
|
padding: 2px 10px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
background: #f0eeeb;
|
|
color: #6b6375;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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>
|