feat: 增加操作审计与受控下载
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<router-link to="/build">打包</router-link>
|
||||
<router-link to="/history">历史</router-link>
|
||||
<router-link to="/admin">管理</router-link>
|
||||
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
|
||||
</nav>
|
||||
<div v-if="!isLoggedIn" class="user-info">
|
||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import OperationLogView from '../views/OperationLogView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('OperationLogView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetch
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ total: 4, action_counts: { build_created: 2, ipa_download_requested: 1 }, top_users: [{ name: 'admin', count: 4 }], top_apps: [{ name: '测试App', count: 3 }] }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([{ id: '1', created_at: '2026-07-22T08:00:00', username: 'admin', action: 'build_created', app_name: '测试App', resource_type: '', client_ip: '127.0.0.1' }]) })
|
||||
})
|
||||
|
||||
it('管理员可查看统计和操作明细', async () => {
|
||||
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(true), getToken: () => 'token' } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('操作日志')
|
||||
expect(wrapper.text()).toContain('操作总数')
|
||||
expect(wrapper.text()).toContain('发起打包')
|
||||
expect(wrapper.text()).toContain('测试App')
|
||||
})
|
||||
|
||||
it('普通用户不渲染操作日志页面', async () => {
|
||||
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(false) } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.panel').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import BuildView from '../views/BuildView.vue'
|
||||
import HistoryView from '../views/HistoryView.vue'
|
||||
import ConfigView from '../views/ConfigView.vue'
|
||||
import OperationLogView from '../views/OperationLogView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/build' },
|
||||
{ path: '/build', name: 'Build', component: BuildView },
|
||||
{ path: '/history', name: 'History', component: HistoryView },
|
||||
{ path: '/admin', name: 'Admin', component: ConfigView },
|
||||
{ path: '/operations', name: 'Operations', component: OperationLogView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
||||
:src="task.qr_code_path" alt="QR" class="qr-thumb"
|
||||
@click="showQrPreview(task)">
|
||||
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a>
|
||||
<button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
@@ -308,6 +308,17 @@ const downloadDsym = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/dsym`)
|
||||
}
|
||||
|
||||
const downloadIpa = async (taskId) => {
|
||||
try {
|
||||
const res = await authFetch(`/api/tasks/${taskId}/download-link`)
|
||||
if (!res.ok) throw new Error()
|
||||
const { url } = await res.json()
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
alert('下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadObfMaps = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||
}
|
||||
@@ -506,7 +517,7 @@ const errorCategoryLabel = (cat) => {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
|
||||
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; }
|
||||
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
|
||||
.download-link:hover { text-decoration: underline; }
|
||||
.text-muted { color: #999; font-size: 13px; }
|
||||
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div v-if="isAdmin" class="container">
|
||||
<div class="panel">
|
||||
<div class="header-row"><h2>操作日志</h2><div><select v-model.number="days" @change="load"><option :value="7">近 7 天</option><option :value="30">近 30 天</option><option :value="90">近 90 天</option></select><button @click="exportCsv">导出 CSV</button></div></div>
|
||||
<div class="cards"><div><strong>{{ summary.total || 0 }}</strong><span>操作总数</span></div><div><strong>{{ summary.action_counts?.build_created || 0 }}</strong><span>发起打包</span></div><div><strong>{{ summary.action_counts?.ipa_download_requested || 0 }}</strong><span>发起 IPA 下载</span></div></div>
|
||||
<div class="columns"><section><h3>活跃用户</h3><p v-for="item in summary.top_users || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section><section><h3>App 操作量</h3><p v-for="item in summary.top_apps || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section></div>
|
||||
<table><thead><tr><th>时间</th><th>用户</th><th>操作</th><th>App</th><th>资源</th><th>IP</th></tr></thead><tbody><tr v-for="log in logs" :key="log.id"><td>{{ formatTime(log.created_at) }}</td><td>{{ log.username }}</td><td>{{ actionText(log.action) }}</td><td>{{ log.app_name || '-' }}</td><td>{{ log.resource_type || '-' }}</td><td>{{ log.client_ip || '-' }}</td></tr><tr v-if="!logs.length"><td colspan="6">暂无操作记录</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, ref, onMounted } from 'vue'
|
||||
const isAdmin = inject('isAdmin', ref(false)); const getToken = inject('getToken', () => '')
|
||||
const days = ref(30); const summary = ref({}); const logs = ref([])
|
||||
const authFetch = (url) => fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })
|
||||
const load = async () => { const [summaryRes, logsRes] = await Promise.all([authFetch(`/api/operations/summary?days=${days.value}`), authFetch(`/api/operations?days=${days.value}`)]); if (summaryRes.ok) summary.value = await summaryRes.json(); if (logsRes.ok) logs.value = await logsRes.json() }
|
||||
const exportCsv = async () => { const res = await authFetch(`/api/operations/export?days=${days.value}`); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'operation-logs.csv'; link.click(); URL.revokeObjectURL(url) }
|
||||
const formatTime = value => value ? new Date(value.endsWith('Z') ? value : `${value}Z`).toLocaleString('zh-CN', { hour12: false }) : '-'
|
||||
const actionText = action => ({ build_created: '发起打包', build_cancelled: '取消打包', task_deleted: '删除记录', ipa_download_requested: '发起 IPA 下载', dsym_downloaded: '下载 dSYM', obfuscation_maps_downloaded: '下载混淆映射' }[action] || action)
|
||||
onMounted(load)
|
||||
</script>
|
||||
<style scoped>
|
||||
.container{max-width:1200px;margin:0 auto;padding:24px}.panel{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 8px #00000014}.header-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.header-row h2{margin:0}.header-row select,.header-row button{padding:8px 12px;margin-left:8px;border:1px solid #d9d9d9;border-radius:6px;background:#fff}.header-row button{color:#1890ff;cursor:pointer}.cards,.columns{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.cards div,.columns section{padding:16px;border-radius:8px;background:#fafafa}.cards strong{display:block;font-size:24px;color:#1890ff}.cards span{font-size:13px;color:#666}.columns{grid-template-columns:1fr 1fr}.columns h3{margin:0 0 10px;font-size:14px}.columns p{display:flex;justify-content:space-between;margin:8px 0;color:#555}table{width:100%;border-collapse:collapse}th,td{padding:11px;border-bottom:1px solid #eee;text-align:left;font-size:13px}th{color:#666;background:#fafafa}@media(max-width:700px){.cards{grid-template-columns:1fr}.columns{grid-template-columns:1fr}.header-row{align-items:flex-start;gap:12px;flex-direction:column}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user