修复Docker构建:添加被gitignore忽略的logs页面文件,修复前端.gitignore的logs规则
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Logs
|
||||
logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
|
||||
import type { Log } from '@/pages/developer/logs/data/schema'
|
||||
|
||||
function translateDetails(t: Composer['t'], details: string): string {
|
||||
if (!details) return '-'
|
||||
|
||||
const patterns: Array<{
|
||||
pattern: RegExp
|
||||
template: string
|
||||
extract: (match: RegExpMatchArray) => Record<string, string>
|
||||
}> = [
|
||||
{
|
||||
pattern: /^用户注册: (.+)$/,
|
||||
template: 'developer.logs.details.register',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^注册失败: 用户已存在 - (.+)$/,
|
||||
template: 'developer.logs.details.register_failed_exists',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^用户登录: (.+)$/,
|
||||
template: 'developer.logs.details.login',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^登录失败: 用户不存在 - (.+)$/,
|
||||
template: 'developer.logs.details.login_failed_not_found',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^登录失败: 密码错误 - (.+)$/,
|
||||
template: 'developer.logs.details.login_failed_password',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^登录失败: 余额不足 - (.+)$/,
|
||||
template: 'developer.logs.details.login_failed_balance',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^登录失败: 设备绑定数量已达上限 - (.+)$/,
|
||||
template: 'developer.logs.details.login_failed_device_limit',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^登录失败: 多开数量已达上限 - (.+)$/,
|
||||
template: 'developer.logs.details.login_failed_multi_limit',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^用户充值: (.+), 卡密: (.+), 金额: ([\d.]+)$/,
|
||||
template: 'developer.logs.details.recharge',
|
||||
extract: (m) => ({ username: m[1], card: m[2], amount: m[3] }),
|
||||
},
|
||||
{
|
||||
pattern: /^充值失败: 用户不存在 - (.+)$/,
|
||||
template: 'developer.logs.details.recharge_failed_user_not_found',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^充值失败: 用户已是永久会员 - (.+)$/,
|
||||
template: 'developer.logs.details.recharge_failed_permanent',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^充值失败: 卡密不存在 - (.+)$/,
|
||||
template: 'developer.logs.details.recharge_failed_card_not_found',
|
||||
extract: (m) => ({ card: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^充值失败: 卡密已使用 - (.+)$/,
|
||||
template: 'developer.logs.details.recharge_failed_card_used',
|
||||
extract: (m) => ({ card: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^用户解绑设备: (.+)$/,
|
||||
template: 'developer.logs.details.unbind_device',
|
||||
extract: (m) => ({ device: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API充值: 用户(.+), 类型:(.+), 数量:(\d+)$/,
|
||||
template: 'developer.logs.details.extension_recharge',
|
||||
extract: (m) => ({ username: m[1], type: m[2], amount: m[3] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API充值失败: 用户不存在 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_recharge_failed_user_not_found',
|
||||
extract: (m) => ({ userId: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API充值失败: 用户已是永久会员 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_recharge_failed_permanent',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API充值失败: 类型无效 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_recharge_failed_type',
|
||||
extract: (m) => ({ type: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API充值失败: 保存失败 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_recharge_failed_save',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API扣费: 用户(.+), 类型:(.+), 数量:(\d+)$/,
|
||||
template: 'developer.logs.details.extension_deduct',
|
||||
extract: (m) => ({ username: m[1], type: m[2], amount: m[3] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API扣费失败: 用户不存在 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_deduct_failed_user_not_found',
|
||||
extract: (m) => ({ userId: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API扣费失败: 用户已是永久会员 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_deduct_failed_permanent',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API扣费失败: 用户订阅已过期 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_deduct_failed_expired',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API扣费失败: 余额不足 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_deduct_failed_balance',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API扣费失败: 类型无效 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_deduct_failed_type',
|
||||
extract: (m) => ({ type: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^扩展API扣费失败: 保存失败 - (.+)$/,
|
||||
template: 'developer.logs.details.extension_deduct_failed_save',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^创建应用: (.+)$/,
|
||||
template: 'developer.logs.details.create_application',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^更新应用: (.+)$/,
|
||||
template: 'developer.logs.details.update_application',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^删除应用: (.+)$/,
|
||||
template: 'developer.logs.details.delete_application',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^创建用户: (.+) \(应用: (.+)\)$/,
|
||||
template: 'developer.logs.details.create_user',
|
||||
extract: (m) => ({ username: m[1], app: m[2] }),
|
||||
},
|
||||
{
|
||||
pattern: /^更新用户: (.+)$/,
|
||||
template: 'developer.logs.details.update_user',
|
||||
extract: (m) => ({ username: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^删除用户: (.+) \(应用: (.+)\)$/,
|
||||
template: 'developer.logs.details.delete_user',
|
||||
extract: (m) => ({ username: m[1], app: m[2] }),
|
||||
},
|
||||
{
|
||||
pattern: /^解绑设备: (.+) \(应用: (.+)\)$/,
|
||||
template: 'developer.logs.details.unbind_device_app',
|
||||
extract: (m) => ({ device: m[1], app: m[2] }),
|
||||
},
|
||||
{
|
||||
pattern: /^批量解绑设备: (\d+)个$/,
|
||||
template: 'developer.logs.details.batch_unbind',
|
||||
extract: (m) => ({ count: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^创建工单: (.+)$/,
|
||||
template: 'developer.logs.details.create_ticket',
|
||||
extract: (m) => ({ title: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^删除工单: (.+)$/,
|
||||
template: 'developer.logs.details.delete_ticket',
|
||||
extract: (m) => ({ title: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^创建Webhook: (.+)$/,
|
||||
template: 'developer.logs.details.create_webhook',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^删除Webhook: (.+)$/,
|
||||
template: 'developer.logs.details.delete_webhook',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^创建云端常量: (.+)$/,
|
||||
template: 'developer.logs.details.create_cloud_constant',
|
||||
extract: (m) => ({ key: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^更新云端常量: (.+)$/,
|
||||
template: 'developer.logs.details.update_cloud_constant',
|
||||
extract: (m) => ({ key: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^删除云端常量: (.+)$/,
|
||||
template: 'developer.logs.details.delete_cloud_constant',
|
||||
extract: (m) => ({ key: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^创建卡密类型: (.+)$/,
|
||||
template: 'developer.logs.details.create_card_type',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^更新卡密类型: (.+)$/,
|
||||
template: 'developer.logs.details.update_card_type',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^删除卡密类型: (.+)$/,
|
||||
template: 'developer.logs.details.delete_card_type',
|
||||
extract: (m) => ({ name: m[1] }),
|
||||
},
|
||||
]
|
||||
|
||||
for (const { pattern, template, extract } of patterns) {
|
||||
const match = details.match(pattern)
|
||||
if (match) {
|
||||
const params = extract(match)
|
||||
return t(template, params)
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
export function getColumns(t: Composer['t']): ColumnDef<Log>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'log_type',
|
||||
header: () => t('developer.logs.columns.type'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('log_type') as string
|
||||
const typeMap: Record<string, { text: string, variant: any }> = {
|
||||
operation: { text: t('developer.logs.types.operation'), variant: 'default' },
|
||||
verification: { text: t('developer.logs.types.verification'), variant: 'secondary' },
|
||||
exception: { text: t('developer.logs.types.exception'), variant: 'destructive' },
|
||||
}
|
||||
const { text, variant } = typeMap[type] || { text: type, variant: 'default' }
|
||||
return h(Badge, { variant }, () => text)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: () => t('developer.logs.columns.action'),
|
||||
cell: ({ row }) => {
|
||||
const action = row.getValue('action') as string
|
||||
if (!action) return '-'
|
||||
const actionKey = `developer.logs.actions.${action}`
|
||||
const translated = t(actionKey)
|
||||
return translated === actionKey ? action : translated
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'details',
|
||||
header: () => t('developer.logs.columns.detail'),
|
||||
cell: ({ row }) => {
|
||||
const details = row.getValue('details') as string
|
||||
if (!details) return '-'
|
||||
const translated = translateDetails(t, details)
|
||||
return h('div', { class: 'max-w-xs truncate', title: details }, translated)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.logs.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const statusMap: Record<string, { text: string, variant: any }> = {
|
||||
success: { text: t('developer.success'), variant: 'default' },
|
||||
failed: { text: t('developer.failed'), variant: 'destructive' },
|
||||
}
|
||||
const { text, variant } = statusMap[status] || { text: status, variant: 'default' }
|
||||
return h(Badge, { variant }, () => text)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'ip_address',
|
||||
header: () => t('developer.logs.columns.ip'),
|
||||
cell: ({ row }) => {
|
||||
const ip = row.getValue('ip_address') as string
|
||||
return h('code', { class: 'text-xs font-mono' }, ip || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application.name',
|
||||
header: () => t('developer.logs.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.original.application?.name
|
||||
return appName ? h(Badge, { variant: 'outline' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user.username',
|
||||
header: () => t('developer.logs.columns.user'),
|
||||
cell: ({ row }) => {
|
||||
const username = row.original.user?.username || row.original.app_user?.username
|
||||
return username || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.logs.columns.time'),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.getValue('created_at')
|
||||
if (!createdAt) return '-'
|
||||
try {
|
||||
const date = new Date(createdAt as string)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { Log } from '@/pages/admin/logs/data/schema'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<Log>
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.logs.searchPlaceholder')"
|
||||
:model-value="(table.getColumn('action')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@input="table.getColumn('action')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
{{ t('common.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
|
||||
import type { Log } from '@/pages/admin/logs/data/schema'
|
||||
|
||||
import { getColumns } from '@/pages/admin/logs/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/logs/components/data-table-toolbar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
data: Log[]
|
||||
serverPage?: number
|
||||
serverPageSize?: number
|
||||
serverTotal?: number
|
||||
serverTotalPages?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
'page-change': [page: number]
|
||||
'page-size-change': [pageSize: number]
|
||||
}>()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<Log>,
|
||||
...getColumns(t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<Log>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
'select': 'developer.logs.select',
|
||||
'type': 'developer.logs.columns.type',
|
||||
'action': 'developer.logs.columns.action',
|
||||
'details': 'developer.logs.columns.detail',
|
||||
'status': 'developer.logs.columns.status',
|
||||
'ip': 'developer.logs.columns.ip',
|
||||
'application_name': 'developer.logs.columns.application',
|
||||
'user_username': 'developer.logs.columns.user',
|
||||
'created_at': 'developer.logs.columns.time',
|
||||
}
|
||||
|
||||
const serverPagination = computed(() => {
|
||||
if (props.serverPage && props.serverPageSize && props.serverTotal) {
|
||||
return {
|
||||
page: props.serverPage,
|
||||
pageSize: props.serverPageSize,
|
||||
total: props.serverTotal,
|
||||
onPageChange: (page: number) => emit('page-change', page),
|
||||
onPageSizeChange: (pageSize: number) => emit('page-size-change', pageSize),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table :server-pagination @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar :table />
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const logTypeSchema = z.enum(['operation', 'verification', 'exception'])
|
||||
export const logStatusSchema = z.enum(['success', 'failed'])
|
||||
|
||||
export const logSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
log_type: logTypeSchema,
|
||||
action: z.string(),
|
||||
resource: z.string().optional().nullable(),
|
||||
resource_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
details: z.string(),
|
||||
status: logStatusSchema,
|
||||
ip_address: z.string(),
|
||||
user_agent: z.string().optional().nullable(),
|
||||
device_id: z.string().optional().nullable(),
|
||||
level: z.string().optional().nullable(),
|
||||
error_message: z.string().optional().nullable(),
|
||||
application_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
user_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
app_user_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
created_at: z.string(),
|
||||
application: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
}).optional().nullable(),
|
||||
user: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
username: z.string(),
|
||||
}).optional().nullable(),
|
||||
app_user: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
username: z.string(),
|
||||
}).optional().nullable(),
|
||||
})
|
||||
|
||||
export type Log = z.infer<typeof logSchema>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
import type { Log } from '@/pages/admin/logs/data/schema'
|
||||
|
||||
import DataTable from '@/pages/admin/logs/components/data-table.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface LogsResponse {
|
||||
logs: Log[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_page: number
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const logs = ref<Log[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('all')
|
||||
const typeFilter = ref<string>('all')
|
||||
const statusFilter = ref<string>('all')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ label: t('developer.logs.types.all'), value: 'all' },
|
||||
{ label: t('developer.logs.types.operation'), value: 'operation' },
|
||||
{ label: t('developer.logs.types.verification'), value: 'verification' },
|
||||
{ label: t('developer.logs.types.exception'), value: 'exception' },
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.logs.statuses.all'), value: 'all' },
|
||||
{ label: t('developer.success'), value: 'success' },
|
||||
{ label: t('developer.failed'), value: 'failed' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', String(currentPage.value))
|
||||
params.append('page_size', String(pageSize.value))
|
||||
|
||||
if (appFilter.value && appFilter.value !== 'all') {
|
||||
params.append('application_id', appFilter.value)
|
||||
}
|
||||
if (typeFilter.value && typeFilter.value !== 'all') {
|
||||
params.append('type', typeFilter.value)
|
||||
}
|
||||
if (statusFilter.value && statusFilter.value !== 'all') {
|
||||
params.append('status', statusFilter.value)
|
||||
}
|
||||
if (startDate.value) {
|
||||
params.append('start_date', startDate.value)
|
||||
}
|
||||
if (endDate.value) {
|
||||
params.append('end_date', endDate.value)
|
||||
}
|
||||
|
||||
const data = await api.get<LogsResponse>(`/dev/logs?${params.toString()}`)
|
||||
|
||||
if (data) {
|
||||
logs.value = data.logs || []
|
||||
total.value = data.total || 0
|
||||
currentPage.value = data.page || 1
|
||||
totalPages.value = data.total_page || 0
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取日志记录失败:', error)
|
||||
logs.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([appFilter, typeFilter, statusFilter, startDate, endDate], () => {
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.logs.title')"
|
||||
:description="t('developer.logs.description')"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.totalLogs') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:file-text" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ total }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.successCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
{{ logs.filter(log => log.status === 'success').length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.failedCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{{ logs.filter(log => log.status === 'failed').length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.operationCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:activity" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ logs.filter(log => log.log_type === 'operation').length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="logs"
|
||||
:server-page="currentPage"
|
||||
:server-page-size="pageSize"
|
||||
:server-total="total"
|
||||
:server-total-pages="totalPages"
|
||||
@refresh="fetchLogs"
|
||||
@page-change="currentPage = $event; fetchLogs()"
|
||||
@page-size-change="pageSize = $event; currentPage = 1; fetchLogs()"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.logs.columns.application')"
|
||||
:options="[{ label: t('developer.logs.allApplications'), value: 'all' }, ...applicationOptions]"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="typeFilter"
|
||||
:title="t('developer.logs.columns.type')"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.logs.columns.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
|
||||
import type { Log } from '@/pages/developer/logs/data/schema'
|
||||
|
||||
import { getColumns } from '@/pages/developer/logs/components/columns'
|
||||
import DataTableToolbar from '@/pages/developer/logs/components/data-table-toolbar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
data: Log[]
|
||||
serverPage?: number
|
||||
serverPageSize?: number
|
||||
serverTotal?: number
|
||||
serverTotalPages?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
'page-change': [page: number]
|
||||
'page-size-change': [pageSize: number]
|
||||
}>()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<Log>,
|
||||
...getColumns(t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<Log>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
'select': 'developer.logs.select',
|
||||
'type': 'developer.logs.columns.type',
|
||||
'action': 'developer.logs.columns.action',
|
||||
'details': 'developer.logs.columns.detail',
|
||||
'status': 'developer.logs.columns.status',
|
||||
'ip': 'developer.logs.columns.ip',
|
||||
'application_name': 'developer.logs.columns.application',
|
||||
'user_username': 'developer.logs.columns.user',
|
||||
'created_at': 'developer.logs.columns.time',
|
||||
}
|
||||
|
||||
const serverPagination = computed(() => {
|
||||
if (props.serverPage && props.serverPageSize && props.serverTotal) {
|
||||
return {
|
||||
page: props.serverPage,
|
||||
pageSize: props.serverPageSize,
|
||||
total: props.serverTotal,
|
||||
onPageChange: (page: number) => emit('page-change', page),
|
||||
onPageSizeChange: (pageSize: number) => emit('page-size-change', pageSize),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table :server-pagination @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar :table />
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const logTypeSchema = z.enum(['operation', 'verification', 'exception'])
|
||||
export const logStatusSchema = z.enum(['success', 'failed'])
|
||||
|
||||
export const logSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
log_type: logTypeSchema,
|
||||
action: z.string(),
|
||||
resource: z.string().optional().nullable(),
|
||||
resource_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
details: z.string(),
|
||||
status: logStatusSchema,
|
||||
ip_address: z.string(),
|
||||
user_agent: z.string().optional().nullable(),
|
||||
device_id: z.string().optional().nullable(),
|
||||
level: z.string().optional().nullable(),
|
||||
error_message: z.string().optional().nullable(),
|
||||
application_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
user_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
app_user_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
created_at: z.string(),
|
||||
application: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
}).optional().nullable(),
|
||||
user: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
username: z.string(),
|
||||
}).optional().nullable(),
|
||||
app_user: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
username: z.string(),
|
||||
}).optional().nullable(),
|
||||
})
|
||||
|
||||
export type Log = z.infer<typeof logSchema>
|
||||
Reference in New Issue
Block a user