first commit

This commit is contained in:
2025-11-24 16:06:44 +08:00
commit e57d497751
165 changed files with 59349 additions and 0 deletions

View File

@@ -0,0 +1,919 @@
<template>
<ListPageLayout
title="API调用记录管理"
subtitle="管理系统内所有用户的API调用记录"
>
<!-- 单用户模式显示 -->
<template #stats v-if="singleUserMode">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>当前用户{{ currentUser?.company_name || currentUser?.phone }}</span>
<span class="text-gray-400">(仅显示当前用户)</span>
</div>
</template>
<!-- 单用户模式操作按钮 -->
<template #actions v-if="singleUserMode">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>{{ currentUser?.company_name || currentUser?.phone }}</span>
</div>
<el-button size="small" @click="exitSingleUserMode">
<Close class="w-4 h-4 mr-1" />
取消
</el-button>
<el-button size="small" type="primary" @click="goBackToUsers">
<Back class="w-4 h-4 mr-1" />
返回用户管理
</el-button>
</div>
</template>
<!-- 筛选区域 -->
<template #filters>
<FilterSection>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<FilterItem label="企业名称" v-if="!singleUserMode">
<el-input
v-model="filters.company_name"
placeholder="输入企业名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="产品名称">
<el-input
v-model="filters.product_name"
placeholder="输入产品名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="交易ID">
<el-input
v-model="filters.transaction_id"
placeholder="输入交易ID"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="调用状态">
<el-select
v-model="filters.status"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="全部" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="处理中" value="pending" />
</el-select>
</FilterItem>
<FilterItem label="调用时间" class="md:col-span-2">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleTimeRangeChange"
class="w-full"
/>
</FilterItem>
</div>
<template #stats>
共找到 {{ total }} 条调用记录
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadApiCalls">应用筛选</el-button>
<el-button type="success" @click="showExportDialog">
<Download class="w-4 h-4 mr-1" />
导出数据
</el-button>
</template>
</FilterSection>
</template>
<!-- 表格区域 -->
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="apiCalls"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="transaction_id" label="交易ID" min-width="180">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.transaction_id }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="企业名称" min-width="150" v-if="!singleUserMode">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.company_name || '未知企业' }}</div>
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="product_name" label="接口名称" min-width="150">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.product_name || '未知接口' }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag
:type="getStatusType(row.status)"
size="small"
effect="light"
>
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error_msg" label="错误信息" min-width="200">
<template #default="{ row }">
<div v-if="row.translated_error_msg" class="error-info-cell">
<div class="translated-error">
{{ row.translated_error_msg }}
</div>
</div>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="cost" label="费用" width="100">
<template #default="{ row }">
<span v-if="row.cost" class="font-semibold text-red-600">¥{{ formatPrice(row.cost) }}</span>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="client_ip" label="客户端IP" width="140">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.client_ip }}</span>
</template>
</el-table-column>
<el-table-column prop="start_at" label="调用时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.start_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.start_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="end_at" label="完成时间" width="160">
<template #default="{ row }">
<div v-if="row.end_at" class="text-sm">
<div class="text-gray-900">{{ formatDate(row.end_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.end_at) }}</div>
</div>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button
size="small"
type="primary"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- 分页 -->
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<!-- API调用详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="API调用详情"
width="800px"
class="api-call-detail-dialog"
>
<div v-if="selectedApiCall" class="space-y-6">
<!-- 基本信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">交易ID</span>
<span class="info-value font-mono">{{ selectedApiCall.transaction_id }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<el-tag :type="getStatusType(selectedApiCall.status)" size="small">
{{ getStatusText(selectedApiCall.status) }}
</el-tag>
</span>
</div>
<div class="info-item">
<span class="info-label">接口名称</span>
<span class="info-value">{{ selectedApiCall.product_name || '未知接口' }}</span>
</div>
<div class="info-item">
<span class="info-label">费用</span>
<span class="info-value">
<span v-if="selectedApiCall.cost" class="text-red-600 font-semibold">¥{{ formatPrice(selectedApiCall.cost) }}</span>
<span v-else class="text-gray-400">-</span>
</span>
</div>
<div class="info-item">
<span class="info-label">客户端IP</span>
<span class="info-value font-mono">{{ selectedApiCall.client_ip }}</span>
</div>
<div class="info-item" v-if="!singleUserMode">
<span class="info-label">企业名称</span>
<span class="info-value">{{ selectedApiCall.company_name || '未知企业' }}</span>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">时间信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">调用时间</span>
<span class="info-value">{{ formatDateTime(selectedApiCall.start_at) }}</span>
</div>
<div class="info-item">
<span class="info-label">完成时间</span>
<span class="info-value">
<span v-if="selectedApiCall.end_at">{{ formatDateTime(selectedApiCall.end_at) }}</span>
<span v-else class="text-gray-400">-</span>
</span>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="selectedApiCall.translated_error_msg" class="error-info">
<h4 class="error-title">错误信息</h4>
<div class="error-content">
<div class="error-message">
<div class="translated-error">
{{ selectedApiCall.translated_error_msg }}
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</ListPageLayout>
<!-- 导出弹窗 -->
<el-dialog
v-model="exportDialogVisible"
title="导出API调用记录"
width="600px"
:close-on-click-modal="false"
>
<div class="space-y-4">
<!-- 企业选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">选择企业</label>
<el-select
v-model="exportOptions.companyIds"
multiple
filterable
remote
reserve-keyword
placeholder="搜索并选择企业(不选则导出所有)"
class="w-full"
clearable
:remote-method="handleCompanySearch"
:loading="companyLoading"
@focus="loadCompanyOptions"
@visible-change="handleCompanyVisibleChange"
>
<el-option
v-for="company in companyOptions"
:key="company.id"
:label="company.company_name"
:value="company.id"
/>
<div v-if="companyLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
<!-- 产品选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">选择产品</label>
<el-select
v-model="exportOptions.productIds"
multiple
filterable
remote
reserve-keyword
placeholder="搜索并选择产品(不选则导出所有)"
class="w-full"
clearable
:remote-method="handleProductSearch"
:loading="productLoading"
@focus="loadProductOptions"
@visible-change="handleProductVisibleChange"
>
<el-option
v-for="product in productOptions"
:key="product.id"
:label="product.name"
:value="product.id"
/>
<div v-if="productLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
<!-- 时间范围 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">时间范围</label>
<el-date-picker
v-model="exportOptions.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-full"
/>
</div>
<!-- 导出格式 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">导出格式</label>
<el-radio-group v-model="exportOptions.format">
<el-radio value="excel">Excel (.xlsx)</el-radio>
<el-radio value="csv">CSV (.csv)</el-radio>
</el-radio-group>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="exportDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="exportLoading"
@click="handleExport"
>
确认导出
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { apiCallApi, productApi, userApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { Back, Close, Download, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 响应式数据
const loading = ref(false)
const apiCalls = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const detailDialogVisible = ref(false)
const selectedApiCall = ref(null)
const dateRange = ref([])
// 单用户模式
const singleUserMode = ref(false)
const currentUser = ref(null)
// 导出相关
const exportDialogVisible = ref(false)
const exportLoading = ref(false)
const exportOptions = reactive({
companyIds: [],
productIds: [],
dateRange: [],
format: 'excel'
})
// 企业选项
const companyOptions = ref([])
const companyLoading = ref(false)
const companySearchKeyword = ref('')
// 产品选项
const productOptions = ref([])
const productLoading = ref(false)
const productSearchKeyword = ref('')
// 筛选条件
const filters = reactive({
company_name: '',
product_name: '',
transaction_id: '',
status: '',
start_time: '',
end_time: ''
})
// 初始化
onMounted(async () => {
await checkSingleUserMode()
await loadApiCalls()
})
// 检查单用户模式
const checkSingleUserMode = async () => {
const userId = route.query.user_id
if (userId) {
singleUserMode.value = true
await loadUserInfo(userId)
}
}
// 加载用户信息
const loadUserInfo = async (userId) => {
try {
const response = await userApi.getUserDetail(userId)
currentUser.value = response.data
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败')
}
}
// 加载API调用记录
const loadApiCalls = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
// 单用户模式添加用户ID筛选
if (singleUserMode.value && currentUser.value?.id) {
params.user_id = currentUser.value.id
}
const response = await apiCallApi.getAdminApiCalls(params)
apiCalls.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载API调用记录失败:', error)
ElMessage.error('加载API调用记录失败')
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 格式化日期时间
const formatDateTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 获取状态类型
const getStatusType = (status) => {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'danger'
case 'pending':
return 'warning'
default:
return 'info'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'pending':
return '处理中'
default:
return '未知'
}
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadApiCalls()
}
// 处理时间范围变化
const handleTimeRangeChange = (range) => {
if (range && range.length === 2) {
filters.start_time = range[0]
filters.end_time = range[1]
} else {
filters.start_time = ''
filters.end_time = ''
}
currentPage.value = 1
loadApiCalls()
}
// 重置筛选
const resetFilters = () => {
Object.keys(filters).forEach(key => {
filters[key] = ''
})
dateRange.value = []
currentPage.value = 1
loadApiCalls()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadApiCalls()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadApiCalls()
}
// 退出单用户模式
const exitSingleUserMode = () => {
singleUserMode.value = false
currentUser.value = null
router.replace({ name: 'AdminApiCalls' })
loadApiCalls()
}
// 返回用户管理
const goBackToUsers = () => {
router.push({ name: 'AdminUsers' })
}
// 查看详情
const handleViewDetail = (apiCall) => {
selectedApiCall.value = apiCall
detailDialogVisible.value = true
}
// 监听路由变化
watch(() => route.query.user_id, async (newUserId) => {
if (newUserId) {
singleUserMode.value = true
await loadUserInfo(newUserId)
} else {
singleUserMode.value = false
currentUser.value = null
}
await loadApiCalls()
})
// 导出相关方法
const showExportDialog = () => {
exportDialogVisible.value = true
}
const loadCompanyOptions = async () => {
if (companyLoading.value) return
try {
companyLoading.value = true
const response = await userApi.getUserList({
page: 1,
page_size: 1000,
is_certified: true, // 只加载已认证用户
company_name: companySearchKeyword.value
})
companyOptions.value = response.data?.items?.map(user => ({
id: user.id,
company_name: user.enterprise_info?.company_name || user.phone || '未知企业'
})) || []
} catch (error) {
console.error('加载企业选项失败:', error)
} finally {
companyLoading.value = false
}
}
const loadProductOptions = async () => {
if (productLoading.value) return
try {
productLoading.value = true
const response = await productApi.getProducts({
page: 1,
page_size: 1000,
name: productSearchKeyword.value
})
productOptions.value = response.data?.items?.map(product => ({
id: product.id,
name: product.name
})) || []
} catch (error) {
console.error('加载产品选项失败:', error)
} finally {
productLoading.value = false
}
}
const handleCompanySearch = (keyword) => {
companySearchKeyword.value = keyword
loadCompanyOptions()
}
const handleProductSearch = (keyword) => {
productSearchKeyword.value = keyword
loadProductOptions()
}
const handleCompanyVisibleChange = (visible) => {
if (visible && companyOptions.value.length === 0) {
loadCompanyOptions()
}
}
const handleProductVisibleChange = (visible) => {
if (visible && productOptions.value.length === 0) {
loadProductOptions()
}
}
const handleExport = async () => {
try {
exportLoading.value = true
// 构建导出参数
const params = {
format: exportOptions.format
}
// 添加企业筛选
if (exportOptions.companyIds.length > 0) {
params.user_ids = exportOptions.companyIds.join(',')
}
// 添加产品筛选
if (exportOptions.productIds.length > 0) {
params.product_ids = exportOptions.productIds.join(',')
}
// 添加时间范围筛选
if (exportOptions.dateRange && exportOptions.dateRange.length === 2) {
params.start_time = exportOptions.dateRange[0]
params.end_time = exportOptions.dateRange[1]
}
// 调用导出API
const response = await apiCallApi.exportAdminApiCalls(params)
// 创建下载链接
const blob = new Blob([response], {
type: exportOptions.format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv;charset=utf-8'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `API调用记录.${exportOptions.format === 'excel' ? 'xlsx' : 'csv'}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
exportDialogVisible.value = false
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败,请稍后重试')
} finally {
exportLoading.value = false
}
}
</script>
<style scoped>
.info-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.info-label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
min-width: 100px;
}
.info-value {
font-size: 0.875rem;
color: #111827;
flex: 1;
}
/* 错误信息样式 */
.error-info {
background: rgba(254, 242, 242, 0.8);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 16px;
}
.error-title {
font-size: 16px;
font-weight: 600;
color: #dc2626;
margin-bottom: 12px;
}
.error-content {
space-y: 2;
}
.error-message {
font-size: 14px;
color: #7f1d1d;
line-height: 1.5;
}
.translated-error {
font-weight: 500;
color: #dc2626;
margin-bottom: 4px;
}
.error-info-cell {
max-width: 200px;
}
.error-info-cell .translated-error {
font-weight: 500;
color: #dc2626;
margin-bottom: 4px;
font-size: 13px;
line-height: 1.4;
}
.api-call-detail-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.api-call-detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.api-call-detail-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.api-call-detail-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.info-label {
min-width: auto;
}
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div>
<!-- 页面头部 -->
<div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">文章分类管理</h1>
<p class="mt-1 text-sm text-gray-500">管理文章分类信息</p>
</div>
<div class="flex space-x-3">
<el-button type="primary" @click="handleCreateCategory">
<el-icon class="mr-1"><PlusIcon /></el-icon>
新增分类
</el-button>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 分类列表 -->
<div>
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">分类列表</h3>
</div>
<el-table
v-loading="loading"
:data="categories"
stripe
class="w-full"
>
<el-table-column prop="name" label="分类名称" min-width="150">
<template #default="{ row }">
<span class="font-medium text-gray-900">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="分类描述" min-width="200">
<template #default="{ row }">
<span class="text-gray-600">{{ row.description || '暂无描述' }}</span>
</template>
</el-table-column>
<el-table-column prop="article_count" label="文章数量" width="100" align="center">
<template #default="{ row }">
<span class="text-gray-600">{{ row.article_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="flex space-x-2">
<el-button
type="primary"
size="small"
@click="handleEditCategory(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteCategory(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 分类编辑对话框 -->
<CategoryEditDialog
v-model="showEditDialog"
:category="currentCategory"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup>
import { articleApi } from '@/api'
import { PlusIcon } from '@heroicons/vue/24/outline'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import CategoryEditDialog from './components/CategoryEditDialog.vue'
// 响应式数据
const loading = ref(false)
const categories = ref([])
// 对话框控制
const showEditDialog = ref(false)
const currentCategory = ref(null)
// 获取分类列表
const loadCategories = async () => {
loading.value = true
try {
const response = await articleApi.getCategories()
// 后端返回 { items, total },表格需要数组
categories.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
ElMessage.error('获取分类列表失败')
console.error('获取分类列表失败:', error)
} finally {
loading.value = false
}
}
// 新增分类
const handleCreateCategory = () => {
currentCategory.value = null
showEditDialog.value = true
}
// 编辑分类
const handleEditCategory = (category) => {
currentCategory.value = category
showEditDialog.value = true
}
// 删除分类
const handleDeleteCategory = async (category) => {
try {
await ElMessageBox.confirm(
`确定要删除分类"${category.name}"吗?删除后无法恢复!`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await articleApi.deleteCategory(category.id)
ElMessage.success('分类删除成功')
loadCategories()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除分类失败')
console.error('删除分类失败:', error)
}
}
}
// 编辑成功回调
const handleEditSuccess = () => {
loadCategories()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 页面初始化
onMounted(() => {
loadCategories()
})
</script>

View File

@@ -0,0 +1,224 @@
<template>
<el-dialog
v-model="dialogVisible"
title="文章详情"
width="70%"
:close-on-click-modal="false"
@open="handleDialogOpen"
v-loading="loading"
>
<div v-if="article" class="space-y-6">
<!-- 基本信息 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="文章标题">
<span class="font-medium">{{ article.title }}</span>
</el-descriptions-item>
<el-descriptions-item label="文章状态">
<el-tag :type="getStatusType(article.status)">
{{ getStatusText(article.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文章分类">
{{ article.category?.name || '未分类' }}
</el-descriptions-item>
<el-descriptions-item label="推荐状态">
<el-tag :type="article.is_featured ? 'success' : 'info'">
{{ article.is_featured ? '推荐' : '普通' }}
</el-tag>
</el-descriptions-item>
<!-- <el-descriptions-item label="阅读量">
{{ article.view_count || 0 }}
</el-descriptions-item> -->
<el-descriptions-item label="创建时间">
{{ formatDate(article.created_at) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ formatDate(article.updated_at) }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ article.published_at ? formatDate(article.published_at) : '-' }}
</el-descriptions-item>
<el-descriptions-item v-if="article.scheduled_at" label="定时发布时间">
{{ formatDate(article.scheduled_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 文章摘要 -->
<div v-if="article.summary" class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章摘要</h3>
<p class="text-gray-700 leading-relaxed">{{ article.summary }}</p>
</div>
<!-- 封面图片 -->
<div v-if="article.cover_image" class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">封面图片</h3>
<div class="flex justify-center">
<img
:src="article.cover_image"
:alt="article.title"
class="max-w-full h-auto max-h-64 rounded-lg shadow-sm"
@error="handleImageError"
/>
</div>
</div>
<!-- 文章标签 -->
<div v-if="article.tags && article.tags.length > 0" class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章标签</h3>
<div class="flex flex-wrap gap-2">
<el-tag
v-for="tag in article.tags"
:key="tag.id"
:style="{ backgroundColor: tag.color + '20', color: tag.color, borderColor: tag.color }"
>
{{ tag.name }}
</el-tag>
</div>
</div>
<!-- 文章内容 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章内容</h3>
<div class="bg-white p-4 rounded border">
<div class="prose max-w-none">
<div v-html="article.content" class="text-gray-700 leading-relaxed"></div>
</div>
</div>
</div>
</div>
<div v-else class="flex justify-center items-center py-8">
<el-empty description="暂无文章数据" />
</div>
<template #footer>
<div class="flex justify-end">
<el-button @click="dialogVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api'
import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue'])
// 响应式数据
const loading = ref(false)
const articleDetail = ref(null)
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 获取文章详情
const fetchArticleDetail = async (articleId) => {
if (!articleId) return
loading.value = true
try {
const response = await articleApi.getArticleDetail(articleId)
articleDetail.value = response.data
} catch (error) {
ElMessage.error('获取文章详情失败')
console.error('获取文章详情失败:', error)
} finally {
loading.value = false
}
}
// 对话框打开时获取详情
const handleDialogOpen = () => {
if (props.article?.id) {
fetchArticleDetail(props.article.id)
}
}
// 监听文章变化
watch(() => props.article, (newArticle) => {
if (newArticle?.id && dialogVisible.value) {
fetchArticleDetail(newArticle.id)
}
}, { immediate: true })
// 使用详情数据或props数据
const article = computed(() => {
return articleDetail.value || props.article
})
// 状态类型映射
const getStatusType = (status) => {
const statusMap = {
draft: 'info',
published: 'success',
archived: 'warning'
}
return statusMap[status] || 'info'
}
// 状态文本映射
const getStatusText = (status) => {
const statusMap = {
draft: '草稿',
published: '已发布',
archived: '已归档'
}
return statusMap[status] || status
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 图片加载错误处理
const handleImageError = (event) => {
event.target.style.display = 'none'
}
</script>
<style scoped>
.prose {
font-size: 14px;
line-height: 1.6;
}
.prose pre {
font-family: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
</style>

View File

@@ -0,0 +1,339 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑文章' : '新增文章'"
width="80%"
:close-on-click-modal="false"
@open="handleDialogOpen"
v-loading="loading"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="space-y-6">
<!-- 基本信息 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">基本信息</h3>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="文章标题" prop="title">
<el-input v-model="form.title" placeholder="请输入文章标题" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="文章分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="选择分类" clearable class="w-full">
<el-option v-for="category in categories" :key="category.id" :label="category.name"
:value="category.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="文章摘要" prop="summary">
<el-input v-model="form.summary" type="textarea" :rows="3" placeholder="请输入文章摘要" maxlength="500"
show-word-limit />
</el-form-item>
<el-form-item label="封面图片" prop="cover_image">
<el-input v-model="form.cover_image" placeholder="请输入封面图片URL" />
</el-form-item>
<el-form-item label="文章标签" prop="tag_ids">
<el-select v-model="form.tag_ids" multiple placeholder="选择标签" clearable class="w-full">
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id">
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-2" :style="{ backgroundColor: tag.color }"></div>
{{ tag.name }}
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="推荐状态" prop="is_featured">
<el-switch v-model="form.is_featured" active-text="推荐" inactive-text="普通" />
</el-form-item>
</div>
<!-- 文章内容 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章内容</h3>
<el-form-item label="文章内容" prop="content">
<Editor style="width: 100%;" v-model="form.content" :init="editorInit"
tinymceScriptSrc="https://cdn.jsdelivr.net/npm/tinymce@7.9.1/tinymce.min.js" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api';
import Editor from '@tinymce/tinymce-vue';
import { ElMessage } from 'element-plus';
import { computed, reactive, ref, watch } from 'vue';
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
},
categories: {
type: Array,
default: () => []
},
tags: {
type: Array,
default: () => []
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const formRef = ref(null)
const loading = ref(false)
const articleDetail = ref(null)
// 表单数据
const form = reactive({
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tag_ids: [],
is_featured: false
})
// 表单验证规则
const rules = {
title: [
{ required: true, message: '请输入文章标题', trigger: 'blur' },
{ min: 1, max: 200, message: '标题长度在 1 到 200 个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入文章内容', trigger: 'blur' }
],
summary: [
{ max: 500, message: '摘要长度不能超过 500 个字符', trigger: 'blur' }
]
}
// TinyMCE 配置
const editorInit = {
menubar: false,
dragdrop: true, // 启用拖拽图片功能
valid_elements: '*[*]',
valid_elements: 'section[*],*[*]', // 允许 section 及其属性
valid_children: '+body[section],+section[p,div,span]', // 允许 body 包含 sectionsection 包含段落等
file_picker_types: 'image',
invalid_elements: 'script',
statusbar: false,
placeholder: '开始编写吧', // 占位符
theme: 'silver', // 主题 必须引入
license_key: 'gpl', // 使用开源许可
paste_as_text: false, // 允许 HTML 粘贴
paste_enable_default_filters: false, // 禁用默认 HTML 过滤
paste_webkit_styles: 'all', // 允许所有 Webkit 内联样式
paste_retain_style_properties: 'all', // 保留所有 inline style
extended_valid_elements: '*[*]', // 确保所有 HTML 属性都被保留
promotion: false, // 移除 Upgrade 按钮
branding: false, // 移除 TinyMCE 品牌信息
toolbar_mode: 'wrap',
contextmenu: 'styleControls | insertBefore insertAfter | copyElement | removeIndent | deleteElement | image link',
content_style: `
body::-webkit-scrollbar {
width: 8px;
}
body::-webkit-scrollbar-track {
background: #f8fafc;
}
body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.element-highlight {
outline: 1px solid #3b82f6 !important;
}
`,
// 设置工具栏样式
toolbar_location: 'top',
// 如果需要固定工具栏
toolbar_sticky: true,
toolbar: [
'undo redo bold italic underline forecolor backcolor alignleft aligncenter alignright image insertBeforeSection insertSection styleControls mySaveBtn help_article'
]
,
plugins: [
'anchor', 'autolink', 'charmap', 'codesample', 'emoticons', 'image', 'link',
'lists', 'media', 'searchreplace', 'table', 'visualblocks', 'wordcount'
],
setup: function (editor) {
editor.on('paste', function (e) {
e.preventDefault(); // 阻止默认粘贴行为
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.types.indexOf('text/html') > -1) {
// 获取原始HTML内容
const htmlContent = clipboardData.getData('text/html');
// 直接插入原始HTML避免修改
editor.insertContent(htmlContent);
} else {
// 如果没有HTML回退到纯文本
const text = clipboardData.getData('text/plain');
editor.insertContent(text);
}
});
}
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.article)
// 获取文章详情
const fetchArticleDetail = async (articleId) => {
if (!articleId) return
loading.value = true
try {
const response = await articleApi.getArticleDetail(articleId)
articleDetail.value = response.data
// 使用详情数据填充表单
fillFormWithDetail(articleDetail.value)
} catch (error) {
ElMessage.error('获取文章详情失败')
console.error('获取文章详情失败:', error)
} finally {
loading.value = false
}
}
// 使用详情数据填充表单
const fillFormWithDetail = (detail) => {
if (!detail) return
Object.assign(form, {
title: detail.title || '',
content: detail.content || '',
summary: detail.summary || '',
cover_image: detail.cover_image || '',
category_id: detail.category_id || '',
tag_ids: detail.tags ? detail.tags.map(tag => tag.id) : [],
is_featured: detail.is_featured || false
})
}
// 对话框打开时获取详情
const handleDialogOpen = () => {
if (props.article?.id && isEdit.value) {
fetchArticleDetail(props.article.id)
}
}
// 监听文章数据变化,初始化表单
watch(() => props.article, (newArticle) => {
if (newArticle && isEdit.value) {
// 编辑模式如果有详情数据则使用详情数据否则使用props数据
if (articleDetail.value) {
fillFormWithDetail(articleDetail.value)
} else {
// 使用props数据作为临时填充
Object.assign(form, {
title: newArticle.title || '',
content: newArticle.content || '',
summary: newArticle.summary || '',
cover_image: newArticle.cover_image || '',
category_id: newArticle.category_id || '',
tag_ids: newArticle.tag_ids || [],
is_featured: newArticle.is_featured || false
})
}
} else {
// 新增模式,重置表单
resetForm()
}
}, { immediate: true })
// 重置表单(使用函数声明,避免提升前调用报错)
function resetForm() {
Object.assign(form, {
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tag_ids: [],
is_featured: false
})
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
if (isEdit.value) {
// 编辑模式
await articleApi.updateArticle(props.article.id, form)
ElMessage.success('文章更新成功')
} else {
// 新增模式
await articleApi.createArticle(form)
ElMessage.success('文章创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
ElMessage.error(isEdit.value ? '更新文章失败' : '创建文章失败')
}
console.error('提交表单失败:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-blue-50 rounded-md flex items-center justify-center">
<el-icon class="text-blue-600 text-sm"><DocumentTextIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">总文章数</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.total_articles || 0 }}</p>
</div>
</div>
</div>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-green-50 rounded-md flex items-center justify-center">
<el-icon class="text-green-600 text-sm"><CheckCircleIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">已发布</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.published_articles || 0 }}</p>
</div>
</div>
</div>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-yellow-50 rounded-md flex items-center justify-center">
<el-icon class="text-yellow-600 text-sm"><ClockIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">草稿</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.draft_articles || 0 }}</p>
</div>
</div>
</div>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-orange-50 rounded-md flex items-center justify-center">
<el-icon class="text-orange-600 text-sm"><ArchiveBoxIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">已归档</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.archived_articles || 0 }}</p>
</div>
</div>
</div>
<!-- <div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-purple-50 rounded-md flex items-center justify-center">
<el-icon class="text-purple-600 text-sm"><EyeIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">总阅读量</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.total_views || 0 }}</p>
</div>
</div>
</div> -->
</div>
</template>
<script setup>
import { ArchiveBoxIcon, CheckCircleIcon, ClockIcon, DocumentTextIcon } from '@heroicons/vue/24/outline';
defineProps({
stats: {
type: Object,
default: () => ({})
}
})
</script>

View File

@@ -0,0 +1,160 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑分类' : '新增分类'"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入分类名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
category: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const formRef = ref(null)
const loading = ref(false)
// 表单数据
const form = reactive({
name: '',
description: ''
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 1, max: 100, message: '名称长度在 1 到 100 个字符', trigger: 'blur' }
],
description: [
{ max: 500, message: '描述长度不能超过 500 个字符', trigger: 'blur' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.category)
// 重置表单
const resetForm = () => {
Object.assign(form, {
name: '',
description: ''
})
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 监听分类数据变化,初始化表单
watch(() => props.category, (newCategory) => {
if (newCategory) {
// 编辑模式,填充表单数据
Object.assign(form, {
name: newCategory.name || '',
description: newCategory.description || ''
})
} else {
// 新增模式,重置表单
resetForm()
}
}, { immediate: true })
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
if (isEdit.value) {
// 编辑模式
await articleApi.updateCategory(props.category.id, form)
ElMessage.success('分类更新成功')
} else {
// 新增模式
await articleApi.createCategory(form)
ElMessage.success('分类创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
ElMessage.error(isEdit.value ? '更新分类失败' : '创建分类失败')
}
console.error('提交表单失败:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="article?.scheduled_at ? '修改定时发布时间' : '定时发布文章'"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-form-item label="文章标题">
<div class="text-gray-600">{{ article?.title }}</div>
</el-form-item>
<el-form-item label="文章标签" v-if="article?.tags && article.tags.length > 0">
<div class="flex flex-wrap gap-1">
<el-tag
v-for="tag in article.tags"
:key="tag.id"
:color="tag.color"
size="small"
class="text-xs"
>
{{ tag.name }}
</el-tag>
</div>
</el-form-item>
<el-form-item label="定时发布日期" prop="scheduled_date">
<el-date-picker
v-model="form.scheduled_date"
type="date"
placeholder="选择定时发布日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
class="w-full"
/>
</el-form-item>
<el-form-item label="定时发布时间" prop="scheduled_time">
<el-time-picker
v-model="form.scheduled_time"
placeholder="选择定时发布时间"
format="HH:mm:ss"
value-format="HH:mm:ss"
:disabled="!form.scheduled_date"
class="w-full"
/>
</el-form-item>
<el-form-item label="提示信息">
<div class="text-sm text-gray-500">
<p> 定时发布日期不能早于今天</p>
<p> 设置后文章将保持草稿状态到指定时间自动发布</p>
<p> 可以随时取消定时发布重新设置</p>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ article?.scheduled_at ? '确认修改' : '确认设置' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api/article'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const loading = ref(false)
const formRef = ref()
// 表单数据
const form = reactive({
scheduled_date: '',
scheduled_time: ''
})
// 表单验证规则
const rules = {
scheduled_date: [
{ required: true, message: '请选择定时发布日期', trigger: 'change' }
],
scheduled_time: [
{ required: true, message: '请选择定时发布时间', trigger: 'change' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 禁用过去的日期
const disabledDate = (time) => {
const today = new Date()
today.setHours(0, 0, 0, 0) // 设置为今天的开始时间
return time.getTime() < today.getTime() // 禁用今天之前的日期
}
// 监听对话框显示状态
watch(() => props.modelValue, (visible) => {
if (visible && props.article) {
if (props.article.scheduled_at) {
// 如果已有定时时间,使用现有时间
const scheduledDate = new Date(props.article.scheduled_at)
// 使用本地时间格式化,避免时区问题
const year = scheduledDate.getFullYear()
const month = String(scheduledDate.getMonth() + 1).padStart(2, '0')
const day = String(scheduledDate.getDate()).padStart(2, '0')
form.scheduled_date = `${year}-${month}-${day}`
const hours = String(scheduledDate.getHours()).padStart(2, '0')
const minutes = String(scheduledDate.getMinutes()).padStart(2, '0')
const seconds = String(scheduledDate.getSeconds()).padStart(2, '0')
form.scheduled_time = `${hours}:${minutes}:${seconds}`
} else {
// 设置默认日期为今天,使用本地时间
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
form.scheduled_date = `${year}-${month}-${day}`
// 设置默认时间为当前时间后1小时
const defaultTime = new Date()
defaultTime.setHours(defaultTime.getHours() + 1)
const hours = String(defaultTime.getHours()).padStart(2, '0')
const minutes = String(defaultTime.getMinutes()).padStart(2, '0')
const seconds = String(defaultTime.getSeconds()).padStart(2, '0')
form.scheduled_time = `${hours}:${minutes}:${seconds}`
}
}
})
// 处理关闭
const handleClose = () => {
dialogVisible.value = false
form.scheduled_date = ''
form.scheduled_time = ''
}
// 处理提交
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (error) {
return
}
loading.value = true
try {
// 根据是否已有定时时间来选择不同的API
if (props.article.scheduled_at) {
// 修改定时发布时间
await articleApi.updateSchedulePublishArticle(props.article.id, {
scheduled_time: `${form.scheduled_date} ${form.scheduled_time}`
})
} else {
// 设置定时发布
await articleApi.schedulePublishArticle(props.article.id, {
scheduled_time: `${form.scheduled_date} ${form.scheduled_time}`
})
}
ElMessage.success(props.article.scheduled_at ? '定时发布时间修改成功' : '定时发布设置成功')
emit('success')
handleClose()
} catch (error) {
ElMessage.error(error.message || '设置定时发布失败')
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑标签' : '新增标签'"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="标签名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入标签名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="标签颜色" prop="color">
<div class="flex items-center space-x-3">
<el-color-picker
v-model="form.color"
show-alpha
:predefine="predefineColors"
/>
<el-input
v-model="form.color"
placeholder="请输入颜色值"
class="flex-1"
/>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
tag: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const formRef = ref(null)
const loading = ref(false)
// 表单数据
const form = reactive({
name: '',
color: '#1890ff'
})
// 预定义颜色
const predefineColors = [
'#1890ff',
'#52c41a',
'#faad14',
'#f5222d',
'#722ed1',
'#13c2c2',
'#eb2f96',
'#fa8c16',
'#a0d911',
'#2f54eb'
]
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入标签名称', trigger: 'blur' },
{ min: 1, max: 50, message: '名称长度在 1 到 50 个字符', trigger: 'blur' }
],
color: [
{ required: true, message: '请选择标签颜色', trigger: 'blur' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.tag)
// 重置表单
const resetForm = () => {
Object.assign(form, {
name: '',
color: '#1890ff'
})
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 监听标签数据变化,初始化表单
watch(() => props.tag, (newTag) => {
if (newTag) {
// 编辑模式,填充表单数据
Object.assign(form, {
name: newTag.name || '',
color: newTag.color || '#1890ff'
})
} else {
// 新增模式,重置表单
resetForm()
}
}, { immediate: true })
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
if (isEdit.value) {
// 编辑模式
await articleApi.updateTag(props.tag.id, form)
ElMessage.success('标签更新成功')
} else {
// 新增模式
await articleApi.createTag(form)
ElMessage.success('标签创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
ElMessage.error(isEdit.value ? '更新标签失败' : '创建标签失败')
}
console.error('提交表单失败:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,654 @@
<template>
<ListPageLayout
title="文章管理"
subtitle="管理系统中的所有文章内容"
>
<template #actions>
<el-button @click="showCategoryDialog = true">
<el-icon class="mr-1"><TagIcon /></el-icon>
分类管理
</el-button>
<el-button @click="showTagDialog = true">
<el-icon class="mr-1"><TagIcon /></el-icon>
标签管理
</el-button>
<el-button type="primary" @click="handleCreateArticle">
<el-icon class="mr-1"><PlusIcon /></el-icon>
新增文章
</el-button>
</template>
<template #filters>
<FilterSection>
<FilterItem label="文章状态">
<el-select
v-model="filters.status"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
<el-option label="已归档" value="archived" />
</el-select>
</FilterItem>
<FilterItem label="文章分类">
<el-select
v-model="filters.category_id"
placeholder="选择分类"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</FilterItem>
<FilterItem label="推荐状态">
<el-select
v-model="filters.is_featured"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="推荐" :value="true" />
<el-option label="普通" :value="false" />
</el-select>
</FilterItem>
<FilterItem label="标题关键词">
<el-input
v-model="filters.title"
placeholder="输入文章标题关键词"
clearable
@input="handleSearch"
class="w-full"
>
<template #prefix>
<el-icon><MagnifyingGlassIcon /></el-icon>
</template>
</el-input>
</FilterItem>
<template #stats>
共找到 {{ total }} 篇文章
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadArticles">应用筛选</el-button>
</template>
</FilterSection>
</template>
<template #table>
<!-- 统计卡片 -->
<div class="mb-6">
<ArticleStats :stats="stats" />
</div>
<!-- 文章列表表格 -->
<el-table
v-loading="loading"
:data="articles"
stripe
class="w-full"
>
<el-table-column prop="title" label="文章标题" min-width="200">
<template #default="{ row }">
<div class="flex items-center">
<span class="font-medium text-blue-600 cursor-pointer hover:text-blue-800" @click="handleViewArticle(row)">
{{ row.title }}
</span>
<el-tag v-if="row.is_featured" type="success" size="small" class="ml-2">推荐</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="category.name" label="分类" width="120">
<template #default="{ row }">
{{ row.category?.name || '未分类' }}
</template>
</el-table-column>
<el-table-column prop="tags" label="标签" width="200">
<template #default="{ row }">
<div class="flex flex-wrap gap-1">
<el-tag
v-for="tag in row.tags"
:key="tag.id"
:color="tag.color"
size="small"
class="text-xs"
>
{{ tag.name }}
</el-tag>
<span v-if="!row.tags || row.tags.length === 0" class="text-gray-400 text-xs">
无标签
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<div class="flex flex-col gap-1">
<el-tag
:type="getStatusType(row.status, row.scheduled_at)"
size="small"
>
{{ getStatusText(row.status, row.scheduled_at) }}
</el-tag>
<!-- 定时发布信息 -->
<div v-if="row.scheduled_at" class="text-xs text-gray-500">
<div>定时: {{ formatDate(row.scheduled_at) }}</div>
</div>
</div>
</template>
</el-table-column>
<!-- <el-table-column prop="view_count" label="阅读量" width="100" align="center">
<template #default="{ row }">
<span class="text-gray-600">{{ row.view_count || 0 }}</span>
</template>
</el-table-column> -->
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column prop="published_at" label="发布时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ row.published_at ? formatDate(row.published_at) : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="200" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<!-- 主要操作按钮 -->
<el-button
v-if="row.status === 'draft'"
type="success"
size="small"
@click="handlePublishArticle(row)"
>
发布
</el-button>
<el-button
v-if="row.status === 'published'"
type="warning"
size="small"
@click="handleArchiveArticle(row)"
>
归档
</el-button>
<el-button
type="primary"
size="small"
@click="handleEditArticle(row)"
>
编辑
</el-button>
<!-- 更多操作下拉菜单 -->
<el-dropdown @command="(command) => handleMoreAction(command, row)">
<el-button size="small" type="info">
更多<el-icon class="el-icon--right"><ChevronDownIcon /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<!-- 定时发布相关操作 -->
<el-dropdown-item
v-if="row.status === 'draft' && !row.scheduled_at"
command="schedule-publish"
>
<div class="flex items-center gap-2">
<ClockIcon class="w-4 h-4" />
<span>定时发布</span>
</div>
</el-dropdown-item>
<el-dropdown-item
v-if="row.status === 'draft' && row.scheduled_at"
command="schedule-publish"
>
<div class="flex items-center gap-2">
<ClockIcon class="w-4 h-4" />
<span>修改时间</span>
</div>
</el-dropdown-item>
<el-dropdown-item
v-if="row.status === 'draft' && row.scheduled_at"
command="cancel-schedule"
divided
>
<div class="flex items-center gap-2">
<XMarkIcon class="w-4 h-4" />
<span>取消定时</span>
</div>
</el-dropdown-item>
<!-- 查看操作 -->
<el-dropdown-item command="view">
<div class="flex items-center gap-2">
<EyeIcon class="w-4 h-4" />
<span>查看</span>
</div>
</el-dropdown-item>
<!-- 删除操作 -->
<el-dropdown-item command="delete" divided>
<div class="flex items-center gap-2">
<TrashIcon class="w-4 h-4" />
<span>删除</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</template>
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- 文章编辑对话框 -->
<ArticleEditDialog
v-model="showEditDialog"
:article="currentArticle"
:categories="categories"
:tags="tags"
@success="handleEditSuccess"
/>
<!-- 文章详情对话框 -->
<ArticleDetailDialog
v-model="showDetailDialog"
:article="currentArticle"
/>
<!-- 定时发布对话框 -->
<SchedulePublishDialog
v-model="showScheduleDialog"
:article="currentArticle"
@success="handleScheduleSuccess"
/>
<!-- 分类管理对话框 -->
<el-dialog
v-model="showCategoryDialog"
title="分类管理"
width="80%"
:close-on-click-modal="false"
@close="handleCategoryDialogClose"
>
<Categories />
</el-dialog>
<!-- 标签管理对话框 -->
<el-dialog
v-model="showTagDialog"
title="标签管理"
width="80%"
:close-on-click-modal="false"
@close="handleTagDialogClose"
>
<Tags />
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { articleApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { ChevronDownIcon, ClockIcon, EyeIcon, MagnifyingGlassIcon, PlusIcon, TagIcon, TrashIcon, XMarkIcon } from '@heroicons/vue/24/outline'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import Categories from './categories.vue'
import ArticleDetailDialog from './components/ArticleDetailDialog.vue'
import ArticleEditDialog from './components/ArticleEditDialog.vue'
import ArticleStats from './components/ArticleStats.vue'
import SchedulePublishDialog from './components/SchedulePublishDialog.vue'
import Tags from './tags.vue'
// 响应式数据
const loading = ref(false)
const articles = ref([])
const categories = ref([])
const tags = ref([])
const total = ref(0)
const stats = ref({})
// 筛选器
const filters = reactive({
status: '',
category_id: '',
is_featured: '',
title: ''
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 10
})
// 搜索防抖
let searchTimer = null
// 对话框控制
const showEditDialog = ref(false)
const showDetailDialog = ref(false)
const showCategoryDialog = ref(false)
const showTagDialog = ref(false)
const showScheduleDialog = ref(false)
const currentArticle = ref(null)
// 获取文章列表
const loadArticles = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...filters
}
// 处理推荐状态参数
if (params.is_featured === '') {
delete params.is_featured
}
const response = await articleApi.getArticlesForAdmin(params)
articles.value = response.data.items || []
total.value = response.data.total || 0
} catch (error) {
console.error('获取文章列表失败:', error)
} finally {
loading.value = false
}
}
// 获取统计数据
const loadStats = async () => {
try {
const response = await articleApi.getArticleStats()
stats.value = response.data || {}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 获取分类列表
const loadCategories = async () => {
try {
const response = await articleApi.getCategories()
categories.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
console.error('获取分类列表失败:', error)
}
}
// 获取标签列表
const loadTags = async () => {
try {
const response = await articleApi.getTags()
tags.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
console.error('获取标签列表失败:', error)
}
}
// 筛选器变化处理
const handleFilterChange = () => {
pagination.page = 1
loadArticles()
}
// 搜索处理
const handleSearch = () => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
pagination.page = 1
loadArticles()
}, 500)
}
// 重置筛选器
const resetFilters = () => {
Object.keys(filters).forEach(key => {
filters[key] = ''
})
pagination.page = 1
loadArticles()
}
// 分页处理
const handleSizeChange = (size) => {
pagination.pageSize = size
pagination.page = 1
loadArticles()
}
const handleCurrentChange = (page) => {
pagination.page = page
loadArticles()
}
// 新增文章
const handleCreateArticle = () => {
currentArticle.value = null
showEditDialog.value = true
}
// 编辑文章
const handleEditArticle = (article) => {
currentArticle.value = { id: article.id } // 只传递ID让编辑弹窗自己获取完整数据
showEditDialog.value = true
}
// 查看文章详情
const handleViewArticle = (article) => {
currentArticle.value = { id: article.id } // 只传递ID让详情弹窗自己获取完整数据
showDetailDialog.value = true
}
// 发布文章
const handlePublishArticle = async (article) => {
try {
await ElMessageBox.confirm('确定要发布这篇文章吗?', '确认发布', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.publishArticle(article.id)
ElMessage.success('文章发布成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('发布文章失败:', error)
}
}
}
// 归档文章
const handleArchiveArticle = async (article) => {
try {
await ElMessageBox.confirm('确定要归档这篇文章吗?', '确认归档', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.archiveArticle(article.id)
ElMessage.success('文章归档成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('归档文章失败:', error)
}
}
}
// 删除文章
const handleDeleteArticle = async (article) => {
try {
await ElMessageBox.confirm('确定要删除这篇文章吗?删除后无法恢复!', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.deleteArticle(article.id)
ElMessage.success('文章删除成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('删除文章失败:', error)
}
}
}
// 编辑成功回调
const handleEditSuccess = () => {
loadArticles()
loadStats()
}
// 定时发布文章
const handleSchedulePublish = (article) => {
currentArticle.value = article
showScheduleDialog.value = true
}
// 定时发布成功回调
const handleScheduleSuccess = () => {
loadArticles()
loadStats()
}
// 取消定时发布
const handleCancelSchedule = async (article) => {
try {
await ElMessageBox.confirm('确定要取消这篇文章的定时发布吗?', '确认取消', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.cancelSchedulePublishArticle(article.id)
ElMessage.success('取消定时发布成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('取消定时发布失败:', error)
}
}
}
// 处理更多操作
const handleMoreAction = (command, article) => {
switch (command) {
case 'schedule-publish':
handleSchedulePublish(article)
break
case 'cancel-schedule':
handleCancelSchedule(article)
break
case 'view':
handleViewArticle(article)
break
case 'delete':
handleDeleteArticle(article)
break
default:
console.warn('未知的操作命令:', command)
}
}
// 分类管理对话框关闭回调
const handleCategoryDialogClose = () => {
loadCategories()
}
// 标签管理对话框关闭回调
const handleTagDialogClose = () => {
loadTags()
}
// 状态类型映射
const getStatusType = (status, scheduledAt) => {
if (status === 'draft' && scheduledAt) {
return 'warning' // 定时发布状态
}
const statusMap = {
draft: 'info',
published: 'success',
archived: 'warning'
}
return statusMap[status] || 'info'
}
// 状态文本映射
const getStatusText = (status, scheduledAt) => {
if (status === 'draft' && scheduledAt) {
return '定时发布'
}
const statusMap = {
draft: '草稿',
published: '已发布',
archived: '已归档'
}
return statusMap[status] || status
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 页面初始化
onMounted(() => {
loadArticles()
loadStats()
loadCategories()
loadTags()
})
</script>

View File

@@ -0,0 +1,187 @@
<template>
<div>
<!-- 页面头部 -->
<div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">文章标签管理</h1>
<p class="mt-1 text-sm text-gray-500">管理文章标签信息</p>
</div>
<div class="flex space-x-3">
<el-button type="primary" @click="handleCreateTag">
<el-icon class="mr-1"><PlusIcon /></el-icon>
新增标签
</el-button>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 标签列表 -->
<div>
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">标签列表</h3>
</div>
<el-table
v-loading="loading"
:data="tags"
stripe
class="w-full"
>
<el-table-column prop="name" label="标签名称" min-width="150">
<template #default="{ row }">
<div class="flex items-center">
<div
class="w-4 h-4 rounded mr-2"
:style="{ backgroundColor: row.color }"
></div>
<span class="font-medium text-gray-900">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="color" label="标签颜色" width="120">
<template #default="{ row }">
<div class="flex items-center">
<div
class="w-6 h-6 rounded mr-2"
:style="{ backgroundColor: row.color }"
></div>
<span class="text-gray-600">{{ row.color }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="article_count" label="文章数量" width="100" align="center">
<template #default="{ row }">
<span class="text-gray-600">{{ row.article_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="flex space-x-2">
<el-button
type="primary"
size="small"
@click="handleEditTag(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteTag(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 标签编辑对话框 -->
<TagEditDialog
v-model="showEditDialog"
:tag="currentTag"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup>
import { articleApi } from '@/api'
import { PlusIcon } from '@heroicons/vue/24/outline'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import TagEditDialog from './components/TagEditDialog.vue'
// 响应式数据
const loading = ref(false)
const tags = ref([])
// 对话框控制
const showEditDialog = ref(false)
const currentTag = ref(null)
// 获取标签列表
const loadTags = async () => {
loading.value = true
try {
const response = await articleApi.getTags()
// 后端返回 { items, total },表格需要数组
tags.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
ElMessage.error('获取标签列表失败')
console.error('获取标签列表失败:', error)
} finally {
loading.value = false
}
}
// 新增标签
const handleCreateTag = () => {
currentTag.value = null
showEditDialog.value = true
}
// 编辑标签
const handleEditTag = (tag) => {
currentTag.value = tag
showEditDialog.value = true
}
// 删除标签
const handleDeleteTag = async (tag) => {
try {
await ElMessageBox.confirm(
`确定要删除标签"${tag.name}"吗?删除后无法恢复!`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await articleApi.deleteTag(tag.id)
ElMessage.success('标签删除成功')
loadTags()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除标签失败')
console.error('删除标签失败:', error)
}
}
}
// 编辑成功回调
const handleEditSuccess = () => {
loadTags()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 页面初始化
onMounted(() => {
loadTags()
})
</script>

View File

@@ -0,0 +1,307 @@
<template>
<ListPageLayout
title="分类管理"
subtitle="管理产品分类"
>
<template #actions>
<el-button type="primary" @click="handleCreateCategory">
新增分类
</el-button>
</template>
<template #table>
<el-table
v-loading="loading"
:data="categories"
stripe
class="w-full"
>
<el-table-column prop="code" label="分类编号" width="120" />
<el-table-column prop="name" label="分类名称" min-width="200" />
<el-table-column prop="description" label="分类描述" min-width="300" />
<el-table-column prop="sort" label="排序" width="120" />
<el-table-column prop="is_enabled" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'" size="small">
{{ row.is_enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_visible" label="展示" width="120">
<template #default="{ row }">
<el-tag :type="row.is_visible ? 'success' : 'warning'" size="small">
{{ row.is_visible ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<el-button
type="primary"
size="small"
@click="handleEditCategory(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteCategory(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</template>
<template #extra>
<!-- 分类表单弹窗 -->
<el-dialog
v-model="formDialogVisible"
:title="isEdit ? '编辑分类' : '新增分类'"
width="600px"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="分类编号" prop="code">
<el-input
v-model="form.code"
placeholder="请输入分类编号"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入分类名称"
/>
</el-form-item>
<el-form-item label="分类描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="form.sort"
:min="0"
:max="999"
placeholder="排序值"
class="w-full"
/>
</el-form-item>
<el-form-item label="是否启用" prop="is_enabled">
<el-switch v-model="form.is_enabled" />
</el-form-item>
<el-form-item label="是否展示" prop="is_visible">
<el-switch v-model="form.is_visible" />
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? '保存修改' : '创建分类' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { productAdminApi } from '@/api'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 响应式数据
const loading = ref(false)
const categories = ref([])
const formDialogVisible = ref(false)
const submitting = ref(false)
const editingCategory = ref(null)
const formRef = ref(null)
// 表单初始值
const initialFormData = {
code: '',
name: '',
description: '',
sort: 0,
is_enabled: true,
is_visible: true
}
// 表单数据 - 严格按照后端CreateCategoryCommand和UpdateCategoryCommand的字段
const form = reactive({ ...initialFormData })
// 表单验证规则
const rules = {
code: [
{ required: true, message: '请输入分类编号', trigger: 'blur' },
{ min: 2, max: 20, message: '分类编号长度在 2 到 20 个字符', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入分类描述', trigger: 'blur' }
]
}
// 计算属性
const isEdit = computed(() => !!editingCategory.value)
// 初始化
onMounted(() => {
loadCategories()
})
// 加载分类列表
const loadCategories = async () => {
loading.value = true
try {
const response = await productAdminApi.getCategories({ page: 1, page_size: 10 })
categories.value = response.data?.items || []
} catch (error) {
console.error('加载分类失败:', error)
ElMessage.error('加载分类失败')
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 新增分类
const handleCreateCategory = () => {
editingCategory.value = null
resetForm()
formDialogVisible.value = true
}
// 编辑分类
const handleEditCategory = (category) => {
editingCategory.value = { ...category }
Object.keys(form).forEach(key => {
if (category[key] !== undefined) {
form[key] = category[key]
}
})
formDialogVisible.value = true
}
// 删除分类
const handleDeleteCategory = async (category) => {
try {
await ElMessageBox.confirm(
`确定要删除分类"${category.name}"吗?此操作不可撤销。`,
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await productAdminApi.deleteCategory(category.id)
ElMessage.success('分类删除成功')
await loadCategories()
} catch (error) {
if (error !== 'cancel') {
console.error('删除分类失败:', error)
ElMessage.error('删除分类失败')
}
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
const submitData = { ...form }
if (isEdit.value) {
await productAdminApi.updateCategory(editingCategory.value.id, submitData)
ElMessage.success('分类更新成功')
} else {
await productAdminApi.createCategory(submitData)
ElMessage.success('分类创建成功')
}
formDialogVisible.value = false
await loadCategories()
} catch (error) {
if (error !== false) { // 不是表单验证错误
console.error('提交失败:', error)
ElMessage.error(isEdit.value ? '更新分类失败' : '创建分类失败')
}
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
// 先重置数据
form.code = ''
form.name = ''
form.description = ''
form.sort = 0
form.is_enabled = true
form.is_visible = true
// 然后清除表单验证状态
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate()
}
})
}
// 取消操作
const handleCancel = () => {
formDialogVisible.value = false
// 延迟重置,避免在弹窗关闭动画期间重置
setTimeout(() => {
resetForm()
editingCategory.value = null
}, 300)
}
</script>
<style scoped>
/* 页面特定样式可以在这里添加 */
</style>

View File

@@ -0,0 +1,524 @@
<template>
<ListPageLayout
title="消费记录管理"
subtitle="管理系统内所有用户的消费记录"
>
<!-- 单用户模式显示 -->
<template #stats v-if="singleUserMode">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>当前用户{{ currentUser?.company_name || currentUser?.phone }}</span>
<span class="text-gray-400">(仅显示当前用户)</span>
</div>
</template>
<!-- 单用户模式操作按钮 -->
<template #actions v-if="singleUserMode">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>{{ currentUser?.company_name || currentUser?.phone }}</span>
</div>
<el-button size="small" @click="exitSingleUserMode">
<Close class="w-4 h-4 mr-1" />
取消
</el-button>
<el-button size="small" type="primary" @click="goBackToUsers">
<Back class="w-4 h-4 mr-1" />
返回用户管理
</el-button>
</div>
</template>
<!-- 筛选区域 -->
<template #filters>
<FilterSection>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<FilterItem label="企业名称" v-if="!singleUserMode">
<el-input
v-model="filters.company_name"
placeholder="输入企业名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="产品名称">
<el-input
v-model="filters.product_name"
placeholder="输入产品名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="交易ID">
<el-input
v-model="filters.transaction_id"
placeholder="输入交易ID"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="消费时间" class="md:col-span-2">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleTimeRangeChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="金额范围">
<div class="flex gap-2">
<el-input
v-model="filters.min_amount"
placeholder="最小金额"
clearable
@input="handleFilterChange"
class="flex-1"
/>
<span class="text-gray-400 self-center">-</span>
<el-input
v-model="filters.max_amount"
placeholder="最大金额"
clearable
@input="handleFilterChange"
class="flex-1"
/>
</div>
</FilterItem>
</div>
<template #stats>
共找到 {{ total }} 条消费记录
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadTransactions">应用筛选</el-button>
</template>
</FilterSection>
</template>
<!-- 表格区域 -->
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="transactions"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="transaction_id" label="交易ID" min-width="180">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.transaction_id }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="企业名称" min-width="150" v-if="!singleUserMode">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.company_name || '未知企业' }}</div>
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="product_name" label="产品名称" min-width="150">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.product_name || '未知产品' }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="amount" label="消费金额" width="120">
<template #default="{ row }">
<span class="font-semibold text-red-600">¥{{ formatPrice(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="消费时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.created_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button
size="small"
type="primary"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- 分页 -->
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<!-- 消费记录详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="消费记录详情"
width="800px"
class="transaction-detail-dialog"
>
<div v-if="selectedTransaction" class="space-y-6">
<!-- 基本信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">交易ID</span>
<span class="info-value font-mono">{{ selectedTransaction.transaction_id }}</span>
</div>
<div class="info-item">
<span class="info-label">产品名称</span>
<span class="info-value">{{ selectedTransaction.product_name || '未知产品' }}</span>
</div>
<div class="info-item">
<span class="info-label">消费金额</span>
<span class="info-value text-red-600 font-semibold">¥{{ formatPrice(selectedTransaction.amount) }}</span>
</div>
<div class="info-item" v-if="!singleUserMode">
<span class="info-label">企业名称</span>
<span class="info-value">{{ selectedTransaction.company_name || '未知企业' }}</span>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">时间信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">消费时间</span>
<span class="info-value">{{ formatDateTime(selectedTransaction.created_at) }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formatDateTime(selectedTransaction.updated_at) }}</span>
</div>
</div>
</div>
</div>
</el-dialog>
</ListPageLayout>
</template>
<script setup>
import { userApi, walletTransactionApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { Back, Close, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 响应式数据
const loading = ref(false)
const transactions = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const detailDialogVisible = ref(false)
const selectedTransaction = ref(null)
const dateRange = ref([])
// 单用户模式
const singleUserMode = ref(false)
const currentUser = ref(null)
// 筛选条件
const filters = reactive({
company_name: '',
product_name: '',
transaction_id: '',
min_amount: '',
max_amount: '',
start_time: '',
end_time: ''
})
// 初始化
onMounted(async () => {
await checkSingleUserMode()
await loadTransactions()
})
// 检查单用户模式
const checkSingleUserMode = async () => {
const userId = route.query.user_id
if (userId) {
singleUserMode.value = true
await loadUserInfo(userId)
}
}
// 加载用户信息
const loadUserInfo = async (userId) => {
try {
const response = await userApi.getUserDetail(userId)
currentUser.value = response.data
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败')
}
}
// 加载消费记录
const loadTransactions = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
// 单用户模式添加用户ID筛选
if (singleUserMode.value && currentUser.value?.id) {
params.user_id = currentUser.value.id
}
const response = await walletTransactionApi.getAdminWalletTransactions(params)
transactions.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载消费记录失败:', error)
ElMessage.error('加载消费记录失败')
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 格式化日期时间
const formatDateTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadTransactions()
}
// 处理时间范围变化
const handleTimeRangeChange = (range) => {
if (range && range.length === 2) {
filters.start_time = range[0]
filters.end_time = range[1]
} else {
filters.start_time = ''
filters.end_time = ''
}
currentPage.value = 1
loadTransactions()
}
// 重置筛选
const resetFilters = () => {
Object.keys(filters).forEach(key => {
filters[key] = ''
})
dateRange.value = []
currentPage.value = 1
loadTransactions()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadTransactions()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadTransactions()
}
// 退出单用户模式
const exitSingleUserMode = () => {
singleUserMode.value = false
currentUser.value = null
router.replace({ name: 'AdminConsumption' })
loadTransactions()
}
// 返回用户管理
const goBackToUsers = () => {
router.push({ name: 'AdminUsers' })
}
// 查看详情
const handleViewDetail = (transaction) => {
selectedTransaction.value = transaction
detailDialogVisible.value = true
}
// 监听路由变化
watch(() => route.query.user_id, async (newUserId) => {
if (newUserId) {
singleUserMode.value = true
await loadUserInfo(newUserId)
} else {
singleUserMode.value = false
currentUser.value = null
}
await loadTransactions()
})
</script>
<style scoped>
.info-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.info-label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
min-width: 100px;
}
.info-value {
font-size: 0.875rem;
color: #111827;
flex: 1;
}
.transaction-detail-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.transaction-detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.transaction-detail-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.transaction-detail-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.info-label {
min-width: auto;
}
}
</style>

View File

@@ -0,0 +1,944 @@
<template>
<ListPageLayout
title="发票管理"
subtitle="管理用户的发票申请"
>
<!-- 统计信息 -->
<!-- <template #actions>
<div class="flex gap-4">
<div class="stat-item">
<div class="stat-value">{{ stats.total || 0 }}</div>
<div class="stat-label">总申请数</div>
</div>
<div class="stat-item pending">
<div class="stat-value">{{ stats.pending || 0 }}</div>
<div class="stat-label">待处理</div>
</div>
<div class="stat-item completed">
<div class="stat-value">{{ stats.completed || 0 }}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-item rejected">
<div class="stat-value">{{ stats.rejected || 0 }}</div>
<div class="stat-label">已拒绝</div>
</div>
</div>
</template> -->
<template #filters>
<FilterSection>
<FilterItem label="状态筛选">
<el-select v-model="filters.status" placeholder="全部状态" clearable @change="handleFilterChange">
<el-option label="全部状态" value="" />
<el-option label="待处理" value="pending" />
<el-option label="已完成" value="completed" />
<el-option label="已拒绝" value="rejected" />
</el-select>
</FilterItem>
<FilterItem label="申请时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleDateRangeChange"
class="w-full"
/>
</FilterItem>
<template #stats>
共找到 {{ total }} 个申请
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadApplications">应用筛选</el-button>
</template>
</FilterSection>
</template>
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else-if="applications.length === 0" class="text-center py-12">
<el-empty description="暂无发票申请" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="applications"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="id" label="申请编号" >
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column prop="invoice_type" label="发票类型" width="160">
<template #default="{ row }">
<el-tag size="small" :type="row.invoice_type === 'special' ? 'warning' : 'info'" effect="light">
{{ getInvoiceTypeText(row.invoice_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="申请金额" width="120">
<template #default="{ row }">
<span class="font-semibold text-green-600">¥{{ formatPrice(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="申请公司" min-width="250">
<template #default="{ row }">
<div class="font-medium text-gray-900">{{ row.company_name }}</div>
<div class="text-sm text-gray-500">{{ row.taxpayer_id }}</div>
<div class="text-sm text-gray-500">{{ row.receiving_email }}</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag size="small" :type="getStatusTagType(row.status)" effect="light">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.created_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="220" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button
v-if="row.status === 'pending'"
size="small"
type="success"
@click="handleApprove(row)"
>
通过
</el-button>
<el-button
v-if="row.status === 'pending'"
size="small"
type="danger"
@click="handleReject(row)"
>
拒绝
</el-button>
<el-button
v-if="row.status === 'completed'"
size="small"
type="primary"
@click="handleDownload(row)"
>
下载
</el-button>
<el-button
size="small"
type="info"
@click="handleViewDetails(row)"
>
详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- 通过申请弹窗 -->
<el-dialog
v-model="approveDialogVisible"
title="通过发票申请"
width="500px"
class="approve-dialog"
>
<div v-if="selectedApplication" class="space-y-6">
<div class="approve-info">
<div class="info-item">
<span class="info-label">申请编号:</span>
<span class="info-value">{{ selectedApplication.id }}</span>
</div>
<div class="info-item">
<span class="info-label">申请公司:</span>
<span class="info-value">{{ selectedApplication.company_name }}</span>
</div>
<div class="info-item">
<span class="info-label">申请金额:</span>
<span class="info-value">¥{{ formatPrice(selectedApplication.amount) }}</span>
</div>
</div>
<el-form ref="approveFormRef" :model="approveForm" :rules="approveRules" label-width="100px">
<el-form-item label="上传发票" prop="file">
<el-upload
ref="uploadRef"
:auto-upload="false"
:on-change="handleFileChange"
:file-list="fileList"
accept=".pdf,.jpg,.jpeg,.png"
class="upload-demo"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">
支持 PDFJPGPNG 格式文件大小不超过 10MB
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="管理员备注">
<el-input
v-model="approveForm.admin_notes"
type="textarea"
:rows="3"
placeholder="可选:添加备注信息"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="approveDialogVisible = false">取消</el-button>
<el-button type="success" @click="confirmApprove" :loading="approveLoading">
确认通过
</el-button>
</div>
</template>
</el-dialog>
<!-- 拒绝申请弹窗 -->
<el-dialog
v-model="rejectDialogVisible"
title="拒绝发票申请"
width="500px"
class="reject-dialog"
>
<div v-if="selectedApplication" class="space-y-6">
<div class="reject-info">
<div class="info-item">
<span class="info-label">申请编号:</span>
<span class="info-value">{{ selectedApplication.id }}</span>
</div>
<div class="info-item">
<span class="info-label">申请公司:</span>
<span class="info-value">{{ selectedApplication.company_name }}</span>
</div>
<div class="info-item">
<span class="info-label">申请金额:</span>
<span class="info-value">¥{{ formatPrice(selectedApplication.amount) }}</span>
</div>
</div>
<el-form ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="100px">
<el-form-item label="拒绝原因" prop="reason">
<el-input
v-model="rejectForm.reason"
type="textarea"
:rows="4"
placeholder="请输入拒绝原因"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" @click="confirmReject" :loading="rejectLoading">
确认拒绝
</el-button>
</div>
</template>
</el-dialog>
<!-- 申请详情弹窗 -->
<el-dialog
v-model="detailsDialogVisible"
title="申请详情"
width="600px"
class="details-dialog"
>
<div v-if="selectedApplication" class="space-y-6">
<div class="details-grid">
<div class="detail-card">
<div class="detail-value">{{ selectedApplication.id }}</div>
<div class="detail-label">申请编号</div>
</div>
<div class="detail-card">
<div class="detail-value">¥{{ formatPrice(selectedApplication.amount) }}</div>
<div class="detail-label">申请金额</div>
</div>
</div>
<div class="details-info">
<div class="info-item">
<span class="info-label">发票类型:</span>
<span class="info-value">{{ getInvoiceTypeText(selectedApplication.invoice_type) }}</span>
</div>
<div class="info-item">
<span class="info-label">申请公司:</span>
<span class="info-value">{{ selectedApplication.company_name }}</span>
</div>
<div class="info-item">
<span class="info-label">纳税人识别号:</span>
<span class="info-value">{{ selectedApplication.taxpayer_id }}</span>
</div>
<div class="info-item">
<span class="info-label">接收邮箱:</span>
<span class="info-value">{{ selectedApplication.receiving_email }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">开户银行:</span>
<span class="info-value">{{ selectedApplication.bank_name }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">银行账号:</span>
<span class="info-value">{{ selectedApplication.bank_account }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">企业地址:</span>
<span class="info-value">{{ selectedApplication.company_address }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">企业电话:</span>
<span class="info-value">{{ selectedApplication.company_phone }}</span>
</div>
<div class="info-item">
<span class="info-label">申请时间:</span>
<span class="info-value">{{ formatDate(selectedApplication.created_at) }}</span>
</div>
<div v-if="selectedApplication.processed_at" class="info-item">
<span class="info-label">处理时间:</span>
<span class="info-value">{{ formatDate(selectedApplication.processed_at) }}</span>
</div>
<div v-if="selectedApplication.reject_reason" class="info-item">
<span class="info-label">拒绝原因:</span>
<span class="info-value reject-reason">{{ selectedApplication.reject_reason }}</span>
</div>
</div>
</div>
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { adminInvoiceApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 响应式数据
const loading = ref(false)
const applications = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const dateRange = ref([])
// 统计数据
const stats = ref({
total: 0,
pending: 0,
completed: 0,
rejected: 0
})
// 筛选条件
const filters = reactive({
status: ''
})
// 弹窗状态
const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const detailsDialogVisible = ref(false)
const selectedApplication = ref(null)
// 表单数据
const approveFormRef = ref()
const rejectFormRef = ref()
const uploadRef = ref()
const fileList = ref([])
const approveForm = reactive({
file: null,
admin_notes: ''
})
const rejectForm = reactive({
reason: ''
})
// 加载状态
const approveLoading = ref(false)
const rejectLoading = ref(false)
// 表单验证规则
const approveRules = {
file: [
{ required: true, message: '请选择发票文件', trigger: 'change' }
]
}
const rejectRules = {
reason: [
{ required: true, message: '请输入拒绝原因', trigger: 'blur' },
{ min: 5, message: '拒绝原因至少5个字符', trigger: 'blur' }
]
}
// 初始化
onMounted(() => {
loadApplications()
loadStats()
})
// 加载申请列表
const loadApplications = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
status: filters.status,
}
// 添加时间范围(转换为后端需要的格式)
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = `${dateRange.value[0]} 00:00:00`
params.end_time = `${dateRange.value[1]} 23:59:59`
}
const response = await adminInvoiceApi.getPendingApplications(params)
if (response && response.data) {
applications.value = response.data.applications || []
total.value = response.data.total || 0
}
} catch (error) {
console.error('加载申请列表失败:', error)
} finally {
loading.value = false
}
}
// 加载统计数据
const loadStats = async () => {
try {
// 这里可以调用专门的统计API或者从列表数据中计算
// 暂时使用模拟数据
stats.value = {
total: total.value,
pending: applications.value.filter(app => app.status === 'pending').length,
completed: applications.value.filter(app => app.status === 'completed').length,
rejected: applications.value.filter(app => app.status === 'rejected').length
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 获取发票类型文本
const getInvoiceTypeText = (type) => {
switch (type) {
case 'general':
return '普通发票'
case 'special':
return '专用发票'
default:
return '未知类型'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'pending':
return '待处理'
case 'completed':
return '已完成'
case 'rejected':
return '已拒绝'
default:
return '未知状态'
}
}
// 获取状态标签类型
const getStatusTagType = (status) => {
switch (status) {
case 'pending':
return 'warning'
case 'completed':
return 'success'
case 'rejected':
return 'danger'
default:
return 'info'
}
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadApplications()
}
// 处理日期范围变化
const handleDateRangeChange = () => {
currentPage.value = 1
loadApplications()
}
// 重置筛选
const resetFilters = () => {
filters.status = ''
dateRange.value = []
currentPage.value = 1
loadApplications()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadApplications()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadApplications()
}
// 处理通过申请
const handleApprove = (application) => {
selectedApplication.value = application
approveDialogVisible.value = true
fileList.value = []
approveForm.file = null
approveForm.admin_notes = ''
}
// 处理拒绝申请
const handleReject = (application) => {
selectedApplication.value = application
rejectDialogVisible.value = true
rejectForm.reason = ''
}
// 处理查看详情
const handleViewDetails = (application) => {
selectedApplication.value = application
detailsDialogVisible.value = true
}
// 处理下载
const handleDownload = async (application) => {
try {
const response = await adminInvoiceApi.downloadInvoiceFile(application.id)
if (response && response.data) {
// 创建blob URL
const blob = new Blob([response.data], { type: 'application/pdf' })
const url = window.URL.createObjectURL(blob)
// 创建下载链接
const link = document.createElement('a')
link.href = url
link.download = `invoice_${application.id}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 清理blob URL
window.URL.revokeObjectURL(url)
ElMessage.success('开始下载发票文件')
}
} catch (error) {
console.error('下载发票失败:', error)
ElMessage.error('下载发票失败,请稍后重试')
}
}
// 处理文件选择
const handleFileChange = (file) => {
fileList.value = [file]
approveForm.file = file.raw // 更新表单数据
}
// 确认通过
const confirmApprove = async () => {
if (!approveFormRef.value) return
try {
await approveFormRef.value.validate()
approveLoading.value = true
const formData = new FormData()
formData.append('file', approveForm.file)
formData.append('admin_notes', approveForm.admin_notes)
const response = await adminInvoiceApi.approveInvoiceApplication(
selectedApplication.value.id,
formData
)
if (response) {
ElMessage.success('申请已通过')
approveDialogVisible.value = false
loadApplications()
loadStats()
}
} catch (error) {
console.error('通过申请失败:', error)
} finally {
approveLoading.value = false
}
}
// 确认拒绝
const confirmReject = async () => {
if (!rejectFormRef.value) return
try {
await rejectFormRef.value.validate()
rejectLoading.value = true
const response = await adminInvoiceApi.rejectInvoiceApplication(
selectedApplication.value.id,
{ reason: rejectForm.reason }
)
if (response) {
ElMessage.success('申请已拒绝')
rejectDialogVisible.value = false
loadApplications()
loadStats()
}
} catch (error) {
console.error('拒绝申请失败:', error)
ElMessage.error('拒绝申请失败')
} finally {
rejectLoading.value = false
}
}
</script>
<style scoped>
/* 统计项样式 */
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 12px;
min-width: 120px;
transition: all 0.3s ease;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-item.pending {
border-color: rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
}
.stat-item.completed {
border-color: rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.05);
}
.stat-item.rejected {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1e293b;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #64748b;
font-weight: 500;
}
/* 弹窗样式 */
.approve-dialog :deep(.el-dialog),
.reject-dialog :deep(.el-dialog),
.details-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.approve-dialog :deep(.el-dialog__header),
.reject-dialog :deep(.el-dialog__header),
.details-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.approve-dialog :deep(.el-dialog__title),
.reject-dialog :deep(.el-dialog__title),
.details-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.approve-dialog :deep(.el-dialog__body),
.reject-dialog :deep(.el-dialog__body),
.details-dialog :deep(.el-dialog__body) {
padding: 24px;
}
.approve-dialog :deep(.el-dialog__footer),
.reject-dialog :deep(.el-dialog__footer),
.details-dialog :deep(.el-dialog__footer) {
background: rgba(248, 250, 252, 0.5);
border-top: 1px solid rgba(226, 232, 240, 0.4);
padding: 16px 24px;
}
/* 申请信息样式 */
.approve-info,
.reject-info {
background: rgba(248, 250, 252, 0.5);
border: 1px solid rgba(226, 232, 240, 0.4);
border-radius: 8px;
padding: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(226, 232, 240, 0.3);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.info-value {
font-size: 14px;
color: #1e293b;
font-weight: 600;
}
.reject-reason {
color: #dc2626;
font-weight: 500;
}
/* 详情网格样式 */
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.detail-card {
background: rgba(248, 250, 252, 0.8);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 12px;
padding: 20px;
text-align: center;
transition: all 0.2s ease;
}
.detail-card:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(59, 130, 246, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.detail-value {
font-size: 24px;
font-weight: 700;
color: #1e293b;
line-height: 1;
margin-bottom: 8px;
}
.detail-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
/* 详情信息样式 */
.details-info {
background: rgba(248, 250, 252, 0.5);
border: 1px solid rgba(226, 232, 240, 0.4);
border-radius: 8px;
padding: 16px;
}
/* 上传组件样式 */
.upload-demo {
width: 100%;
}
.el-upload__tip {
color: #64748b;
font-size: 12px;
margin-top: 8px;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stat-item {
padding: 12px 16px;
min-width: 100px;
}
.stat-value {
font-size: 20px;
}
.stat-label {
font-size: 12px;
}
.details-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.detail-card {
padding: 16px;
}
.detail-value {
font-size: 20px;
}
.detail-label {
font-size: 13px;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.info-label {
width: auto;
}
}
</style>

View File

@@ -0,0 +1,472 @@
<template>
<ListPageLayout
title="产品管理"
subtitle="管理系统中的所有数据产品"
>
<template #actions>
<el-button type="primary" @click="handleCreateProduct">
新增产品
</el-button>
</template>
<template #filters>
<FilterSection>
<FilterItem label="产品分类">
<el-select
v-model="filters.category_id"
placeholder="选择分类"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</FilterItem>
<FilterItem label="启用状态">
<el-select
v-model="filters.is_enabled"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="已启用" :value="true" />
<el-option label="已禁用" :value="false" />
</el-select>
</FilterItem>
<FilterItem label="产品类型">
<el-select
v-model="filters.is_package"
placeholder="选择类型"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="单品" :value="false" />
<el-option label="组合包" :value="true" />
</el-select>
</FilterItem>
<FilterItem label="搜索产品">
<el-input
v-model="filters.keyword"
placeholder="输入产品名称或编号"
clearable
@input="handleSearch"
class="w-full"
/>
</FilterItem>
<template #stats>
共找到 {{ total }} 个产品
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadProducts">应用筛选</el-button>
</template>
</FilterSection>
</template>
<template #table>
<el-table
v-loading="loading"
:data="products"
stripe
class="w-full"
>
<el-table-column prop="code" label="产品编号" width="120" />
<el-table-column prop="name" label="产品名称" min-width="150">
<template #default="{ row }">
<div class="flex items-center">
<span class="font-medium text-blue-600">{{ row.name }}</span>
<el-tag v-if="row.is_package" type="success" size="small" class="ml-2">组合包</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="category.name" label="分类" width="120">
<template #default="{ row }">
{{ row.category?.name || '未分类' }}
</template>
</el-table-column>
<el-table-column prop="price" label="价格" width="120">
<template #default="{ row }">
<span class="text-red-600 font-semibold">¥{{ formatPrice(row.price) }}</span>
</template>
</el-table-column>
<el-table-column prop="cost_price" label="成本价" width="120">
<template #default="{ row }">
<span class="text-gray-600">¥{{ formatPrice(row.cost_price) }}</span>
</template>
</el-table-column>
<el-table-column prop="is_enabled" label="启用状态" width="120">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'" size="small">
{{ row.is_enabled ? '已启用' : '已禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_visible" label="展示状态" width="120">
<template #default="{ row }">
<el-tag :type="row.is_visible ? 'success' : 'warning'" size="small">
{{ row.is_visible ? '已展示' : '已隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="350" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<el-button
type="primary"
size="small"
@click="handleEditProduct(row)"
>
编辑
</el-button>
<el-button
type="info"
size="small"
@click="handleViewProduct(row)"
>
查看
</el-button>
<el-button
type="success"
size="small"
@click="handleConfigDocumentation(row)"
>
配置文档
</el-button>
<el-button
v-if="row.is_enabled"
type="warning"
size="small"
@click="handleToggleEnabled(row, false)"
>
禁用
</el-button>
<el-button
v-else
type="success"
size="small"
@click="handleToggleEnabled(row, true)"
>
启用
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteProduct(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</template>
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- 产品表单弹窗 -->
<ProductFormDialog
v-model="dialogs.form.visible"
:product="dialogs.form.product"
:categories="categories"
@success="handleFormSuccess"
/>
<!-- API配置弹窗 -->
<ProductApiConfigDialog
v-model="dialogs.apiConfig.visible"
:product="dialogs.apiConfig.product"
@success="handleApiConfigSuccess"
/>
<!-- 文档配置弹窗 -->
<ProductDocumentationDialog
v-model="dialogs.documentation.visible"
:product="dialogs.documentation.product"
@success="handleDocumentationSuccess"
/>
</template>
</ListPageLayout>
</template>
<script setup>
import { categoryApi, productAdminApi } from '@/api'
import ProductApiConfigDialog from '@/components/admin/ProductApiConfigDialog.vue'
import ProductDocumentationDialog from '@/components/admin/ProductDocumentationDialog.vue'
import ProductFormDialog from '@/components/admin/ProductFormDialog.vue'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
// 响应式数据
const loading = ref(false)
const products = ref([])
const categories = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
// 弹窗状态管理
const dialogs = reactive({
form: {
visible: false,
product: null
},
apiConfig: {
visible: false,
product: null
},
documentation: {
visible: false,
product: null
}
})
// 筛选条件
const filters = reactive({
category_id: null,
is_enabled: null,
is_package: null,
keyword: ''
})
// 搜索防抖
let searchTimer = null
// 初始化
onMounted(() => {
loadCategories()
loadProducts()
})
// 加载产品分类
const loadCategories = async () => {
try {
const response = await categoryApi.getCategories({ page: 1, page_size: 100 })
categories.value = response.data?.items || []
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 加载产品列表
const loadProducts = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
const response = await productAdminApi.getProducts(params)
products.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载产品失败:', error)
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadProducts()
}
// 处理搜索
const handleSearch = () => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
currentPage.value = 1
loadProducts()
}, 500)
}
// 重置筛选
const resetFilters = () => {
filters.category_id = null
filters.is_enabled = null
filters.is_package = null
filters.keyword = ''
currentPage.value = 1
loadProducts()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadProducts()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadProducts()
}
// 弹窗管理方法
const openDialog = (dialogType, product = null) => {
// 先关闭所有弹窗
Object.keys(dialogs).forEach(key => {
dialogs[key].visible = false
dialogs[key].product = null
})
// 打开指定弹窗
dialogs[dialogType].visible = true
dialogs[dialogType].product = product
}
const closeDialog = (dialogType) => {
dialogs[dialogType].visible = false
dialogs[dialogType].product = null
}
// 新增产品
const handleCreateProduct = () => {
openDialog('form')
}
// 编辑产品
const handleEditProduct = (product) => {
openDialog('form', product)
}
// 查看产品
const handleViewProduct = (product) => {
router.push(`/products/${product.id}`)
}
// 切换产品启用状态
const handleToggleEnabled = async (product, enabled) => {
try {
const action = enabled ? '启用' : '禁用'
await ElMessageBox.confirm(
`确定要${action}产品"${product.name}"吗?`,
`确认${action}`,
{
confirmButtonText: `确定${action}`,
cancelButtonText: '取消',
type: 'warning'
}
)
// 更新产品状态
await productAdminApi.updateProduct(product.id, {
...product,
is_enabled: enabled
})
ElMessage.success(`产品已${action}`)
await loadProducts()
} catch (error) {
if (error !== 'cancel') {
console.error('切换状态失败:', error)
ElMessage.error('切换状态失败')
}
}
}
// 删除产品
const handleDeleteProduct = async (product) => {
try {
await ElMessageBox.confirm(
`确定要删除产品"${product.name}"吗?此操作不可撤销。`,
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await productAdminApi.deleteProduct(product.id)
ElMessage.success('产品删除成功')
await loadProducts()
} catch (error) {
if (error !== 'cancel') {
console.error('删除产品失败:', error)
}
}
}
// 配置API
const handleConfigApi = (product) => {
openDialog('apiConfig', product)
}
// API配置成功
const handleApiConfigSuccess = () => {
closeDialog('apiConfig')
ElMessage.success('API配置操作成功')
}
// 配置文档
const handleConfigDocumentation = (product) => {
openDialog('documentation', product)
}
// 文档配置成功
const handleDocumentationSuccess = () => {
closeDialog('documentation')
}
// 表单提交成功
const handleFormSuccess = () => {
closeDialog('form')
loadProducts()
}
</script>
<style scoped>
/* 页面特定样式可以在这里添加 */
</style>

View File

@@ -0,0 +1,732 @@
<template>
<ListPageLayout title="充值记录管理" subtitle="管理系统内所有用户的充值记录">
<!-- 单用户模式显示 -->
<template #stats v-if="singleUserMode">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>当前用户{{ currentUser?.company_name || currentUser?.phone }}</span>
<span class="text-gray-400">(仅显示当前用户)</span>
</div>
</template>
<!-- 单用户模式操作按钮 -->
<template #actions v-if="singleUserMode">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>{{ currentUser?.company_name || currentUser?.phone }}</span>
</div>
<el-button size="small" @click="exitSingleUserMode">
<Close class="w-4 h-4 mr-1" />
取消
</el-button>
<el-button size="small" type="primary" @click="goBackToUsers">
<Back class="w-4 h-4 mr-1" />
返回用户管理
</el-button>
</div>
</template>
<!-- 筛选区域 -->
<template #filters>
<FilterSection>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<FilterItem label="企业名称" v-if="!singleUserMode">
<el-input
v-model="filters.company_name"
placeholder="输入企业名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="充值类型">
<el-select
v-model="filters.recharge_type"
placeholder="选择充值类型"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="支付宝充值" value="alipay" />
<el-option label="对公转账" value="transfer" />
<el-option label="赠送" value="gift" />
</el-select>
</FilterItem>
<FilterItem label="状态">
<el-select
v-model="filters.status"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="全部" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="处理中" value="pending" />
</el-select>
</FilterItem>
<FilterItem label="充值时间" class="md:col-span-2">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleTimeRangeChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="金额范围">
<div class="flex gap-2">
<el-input
v-model="filters.min_amount"
placeholder="最小金额"
clearable
@input="handleFilterChange"
class="flex-1"
/>
<span class="text-gray-400 self-center">-</span>
<el-input
v-model="filters.max_amount"
placeholder="最大金额"
clearable
@input="handleFilterChange"
class="flex-1"
/>
</div>
</FilterItem>
</div>
<template #stats> 共找到 {{ total }} 条充值记录 </template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadRechargeRecords">应用筛选</el-button>
<el-button type="success" @click="showExportDialog">
<Download class="w-4 h-4 mr-1" />
导出数据
</el-button>
</template>
</FilterSection>
</template>
<!-- 表格区域 -->
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="rechargeRecords"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px',
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b',
}"
>
<el-table-column prop="id" label="记录ID" min-width="120">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column
prop="company_name"
label="企业名称"
min-width="150"
v-if="!singleUserMode"
>
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.company_name || '未知企业' }}</div>
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="amount" label="充值金额" width="120">
<template #default="{ row }">
<span class="font-semibold text-green-600">¥{{ formatPrice(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="recharge_type" label="充值类型" width="120">
<template #default="{ row }">
<el-tag :type="getRechargeTypeTag(row.recharge_type)" size="small" effect="light">
{{ getRechargeTypeText(row.recharge_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small" effect="light">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="充值时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.created_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button size="small" type="primary" @click="handleViewDetail(row)">
查看详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- 分页 -->
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- 充值记录详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="充值记录详情"
width="800px"
class="recharge-detail-dialog"
>
<div v-if="selectedRechargeRecord" class="space-y-6">
<!-- 基本信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">记录ID</span>
<span class="info-value font-mono">{{ selectedRechargeRecord?.id || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">充值金额</span>
<span class="info-value text-green-600 font-semibold"
>¥{{ formatPrice(selectedRechargeRecord?.amount) }}</span
>
</div>
<div class="info-item">
<span class="info-label">充值类型</span>
<span class="info-value">
<el-tag
:type="getRechargeTypeTag(selectedRechargeRecord?.recharge_type)"
size="small"
>
{{ getRechargeTypeText(selectedRechargeRecord?.recharge_type) }}
</el-tag>
</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<el-tag :type="getStatusType(selectedRechargeRecord?.status)" size="small">
{{ getStatusText(selectedRechargeRecord?.status) }}
</el-tag>
</span>
</div>
<div class="info-item" v-if="!singleUserMode">
<span class="info-label">企业名称</span>
<span class="info-value">{{
selectedRechargeRecord?.company_name || '未知企业'
}}</span>
</div>
</div>
</div>
<!-- 订单信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">订单信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">订单号</span>
<span class="info-value font-mono">{{
selectedRechargeRecord?.order_id || '-'
}}</span>
</div>
<div class="info-item">
<span class="info-label">支付流水号</span>
<span class="info-value font-mono">{{
selectedRechargeRecord?.payment_id || '-'
}}</span>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">时间信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">充值时间</span>
<span class="info-value">{{
formatDateTime(selectedRechargeRecord?.created_at)
}}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{
formatDateTime(selectedRechargeRecord?.updated_at)
}}</span>
</div>
</div>
</div>
</div>
<div v-else class="flex justify-center items-center py-8">
<el-loading size="large" />
</div>
</el-dialog>
</template>
</ListPageLayout>
<!-- 导出弹窗 -->
<ExportDialog
v-model="exportDialogVisible"
title="导出充值记录"
:loading="exportLoading"
:show-company-select="true"
:show-product-select="false"
:show-recharge-type-select="true"
:show-status-select="true"
:show-date-range="true"
@confirm="handleExport"
/>
</template>
<script setup>
import { rechargeRecordApi, userApi } from '@/api'
import ExportDialog from '@/components/common/ExportDialog.vue'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { Back, Close, Download, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 响应式数据
const loading = ref(false)
const rechargeRecords = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const detailDialogVisible = ref(false)
const selectedRechargeRecord = ref(null)
const dateRange = ref([])
// 单用户模式
const singleUserMode = ref(false)
const currentUser = ref(null)
// 导出相关
const exportDialogVisible = ref(false)
const exportLoading = ref(false)
// 筛选条件
const filters = reactive({
company_name: '',
recharge_type: '',
status: '',
min_amount: '',
max_amount: '',
start_time: '',
end_time: '',
})
// 初始化
onMounted(async () => {
await checkSingleUserMode()
await loadRechargeRecords()
})
// 检查单用户模式
const checkSingleUserMode = async () => {
const userId = route.query.user_id
if (userId) {
singleUserMode.value = true
await loadUserInfo(userId)
}
}
// 加载用户信息
const loadUserInfo = async (userId) => {
try {
const response = await userApi.getUserDetail(userId)
currentUser.value = response.data
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败')
}
}
// 加载充值记录
const loadRechargeRecords = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters,
}
// 单用户模式添加用户ID筛选
if (singleUserMode.value && currentUser.value?.id) {
params.user_id = currentUser.value.id
}
const response = await rechargeRecordApi.getAdminRechargeRecords(params)
rechargeRecords.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载充值记录失败:', error)
ElMessage.error('加载充值记录失败')
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
// 格式化日期时间
const formatDateTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 获取充值类型标签
const getRechargeTypeTag = (type) => {
switch (type) {
case 'alipay':
return 'primary'
case 'wechat':
return 'success'
case 'bank':
return 'warning'
case 'balance':
return 'info'
default:
return 'info'
}
}
// 获取充值类型文本
const getRechargeTypeText = (type) => {
switch (type) {
case 'alipay':
return '支付宝充值'
case 'transfer':
return '对公转账'
case 'gift':
return '赠送充值'
default:
return '未知类型'
}
}
// 获取状态类型
const getStatusType = (status) => {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'danger'
case 'pending':
return 'warning'
default:
return 'info'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'pending':
return '处理中'
default:
return '未知'
}
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadRechargeRecords()
}
// 处理时间范围变化
const handleTimeRangeChange = (range) => {
if (range && range.length === 2) {
filters.start_time = range[0]
filters.end_time = range[1]
} else {
filters.start_time = ''
filters.end_time = ''
}
currentPage.value = 1
loadRechargeRecords()
}
// 重置筛选
const resetFilters = () => {
Object.keys(filters).forEach((key) => {
filters[key] = ''
})
dateRange.value = []
currentPage.value = 1
loadRechargeRecords()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadRechargeRecords()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadRechargeRecords()
}
// 退出单用户模式
const exitSingleUserMode = () => {
singleUserMode.value = false
currentUser.value = null
router.replace({ name: 'AdminRechargeRecords' })
loadRechargeRecords()
}
// 返回用户管理
const goBackToUsers = () => {
router.push({ name: 'AdminUsers' })
}
// 查看详情
const handleViewDetail = (rechargeRecord) => {
selectedRechargeRecord.value = rechargeRecord
detailDialogVisible.value = true
console.log('detailDialogVisible', detailDialogVisible.value)
}
// 监听路由变化
watch(
() => route.query.user_id,
async (newUserId) => {
if (newUserId) {
singleUserMode.value = true
await loadUserInfo(newUserId)
} else {
singleUserMode.value = false
currentUser.value = null
}
await loadRechargeRecords()
},
)
// 导出相关方法
const showExportDialog = () => {
exportDialogVisible.value = true
}
const handleExport = async (options) => {
try {
exportLoading.value = true
// 构建导出参数
const params = {
format: options.format
}
// 添加企业筛选
if (options.companyIds.length > 0) {
params.user_ids = options.companyIds.join(',')
}
// 添加充值类型筛选
if (options.rechargeType) {
params.recharge_type = options.rechargeType
}
// 添加状态筛选
if (options.status) {
params.status = options.status
}
// 添加时间范围筛选
if (options.dateRange && options.dateRange.length === 2) {
params.start_time = options.dateRange[0]
params.end_time = options.dateRange[1]
}
// 调用导出API
const response = await rechargeRecordApi.exportAdminRechargeRecords(params)
// 创建下载链接
const blob = new Blob([response], {
type: options.format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv;charset=utf-8'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `充值记录.${options.format === 'excel' ? 'xlsx' : 'csv'}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
exportDialogVisible.value = false
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败,请稍后重试')
} finally {
exportLoading.value = false
}
}
</script>
<style scoped>
.info-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.info-label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
min-width: 100px;
}
.info-value {
font-size: 0.875rem;
color: #111827;
flex: 1;
}
.recharge-detail-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.recharge-detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.recharge-detail-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.recharge-detail-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.info-label {
min-width: auto;
}
}
</style>

View File

@@ -0,0 +1,551 @@
<template>
<div class="admin-statistics-page">
<div class="page-header">
<h1>统计管理</h1>
<p>管理员专用统计管理界面</p>
</div>
<!-- 仪表板管理 -->
<el-card class="dashboard-card" shadow="hover">
<template #header>
<div class="card-header">
<span>仪表板管理</span>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
创建仪表板
</el-button>
</div>
</template>
<!-- 筛选条件 -->
<div class="filter-section">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="用户角色">
<el-select v-model="filterForm.user_role" placeholder="选择角色" clearable>
<el-option label="全部" value="" />
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
<el-option label="经理" value="manager" />
<el-option label="分析师" value="analyst" />
</el-select>
</el-form-item>
<el-form-item label="访问级别">
<el-select v-model="filterForm.access_level" placeholder="选择级别" clearable>
<el-option label="全部" value="" />
<el-option label="私有" value="private" />
<el-option label="公开" value="public" />
<el-option label="共享" value="shared" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.is_active" placeholder="选择状态" clearable>
<el-option label="全部" value="" />
<el-option label="激活" :value="true" />
<el-option label="停用" :value="false" />
</el-select>
</el-form-item>
<el-form-item label="默认">
<el-select v-model="filterForm.is_default" placeholder="是否默认" clearable>
<el-option label="全部" value="" />
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadDashboards">
<el-icon><SearchIcon /></el-icon>
搜索
</el-button>
<el-button @click="resetFilter">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 仪表板列表 -->
<el-table
v-loading="loading"
:data="dashboards"
stripe
style="width: 100%"
>
<el-table-column prop="name" label="仪表板名称" min-width="150" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="user_role" label="用户角色" width="100">
<template #default="{ row }">
<el-tag :type="getRoleTagType(row.user_role)">
{{ getRoleName(row.user_role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="access_level" label="访问级别" width="100">
<template #default="{ row }">
<el-tag :type="getAccessLevelTagType(row.access_level)">
{{ getAccessLevelName(row.access_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_default" label="默认" width="80">
<template #default="{ row }">
<el-tag :type="row.is_default ? 'success' : 'info'">
{{ row.is_default ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'">
{{ row.is_active ? '激活' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="refresh_interval" label="刷新间隔(秒)" width="120" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDashboard(row)">
<el-icon><ViewIcon /></el-icon>
查看
</el-button>
<el-button size="small" type="primary" @click="editDashboard(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" @click="deleteDashboard(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 创建/编辑仪表板对话框 -->
<el-dialog
v-model="showCreateDialog"
:title="editingDashboard ? '编辑仪表板' : '创建仪表板'"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="dashboardFormRef"
:model="dashboardForm"
:rules="dashboardRules"
label-width="100px"
>
<el-form-item label="仪表板名称" prop="name">
<el-input v-model="dashboardForm.name" placeholder="请输入仪表板名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="dashboardForm.description"
type="textarea"
:rows="3"
placeholder="请输入仪表板描述"
/>
</el-form-item>
<el-form-item label="用户角色" prop="user_role">
<el-select v-model="dashboardForm.user_role" placeholder="选择用户角色">
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
<el-option label="经理" value="manager" />
<el-option label="分析师" value="analyst" />
</el-select>
</el-form-item>
<el-form-item label="访问级别" prop="access_level">
<el-select v-model="dashboardForm.access_level" placeholder="选择访问级别">
<el-option label="私有" value="private" />
<el-option label="公开" value="public" />
<el-option label="共享" value="shared" />
</el-select>
</el-form-item>
<el-form-item label="刷新间隔" prop="refresh_interval">
<el-input-number
v-model="dashboardForm.refresh_interval"
:min="30"
:max="3600"
:step="30"
placeholder="刷新间隔(秒)"
/>
</el-form-item>
<el-form-item label="是否默认">
<el-switch v-model="dashboardForm.is_default" />
</el-form-item>
<el-form-item label="是否激活">
<el-switch v-model="dashboardForm.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="saveDashboard">
{{ editingDashboard ? '更新' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import {
adminCreateDashboard,
adminDeleteDashboard,
adminGetDashboards,
adminUpdateDashboard
} from '@/api/statistics'
import {
Delete,
Edit,
Plus,
Refresh,
Search as SearchIcon,
View as ViewIcon
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'AdminStatisticsPage',
components: {
Plus,
SearchIcon,
Refresh,
ViewIcon,
Edit,
Delete
},
setup() {
const router = useRouter()
const loading = ref(false)
const dashboards = ref([])
const showCreateDialog = ref(false)
const creating = ref(false)
const editingDashboard = ref(null)
const dashboardFormRef = ref()
// 筛选表单
const filterForm = reactive({
user_role: '',
access_level: '',
is_active: '',
is_default: ''
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 仪表板表单
const dashboardForm = reactive({
name: '',
description: '',
user_role: 'user',
access_level: 'private',
refresh_interval: 300,
is_default: false,
is_active: true
})
// 表单验证规则
const dashboardRules = {
name: [{ required: true, message: '请输入仪表板名称', trigger: 'blur' }],
description: [{ required: true, message: '请输入仪表板描述', trigger: 'blur' }],
user_role: [{ required: true, message: '请选择用户角色', trigger: 'change' }],
access_level: [{ required: true, message: '请选择访问级别', trigger: 'change' }],
refresh_interval: [{ required: true, message: '请输入刷新间隔', trigger: 'blur' }]
}
// 加载仪表板列表
const loadDashboards = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...filterForm
}
// 移除空值参数
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await adminGetDashboards(params)
if (response.success) {
dashboards.value = response.data.items || []
pagination.total = response.data.total || 0
} else {
ElMessage.error(response.message || '获取仪表板列表失败')
}
} catch (error) {
console.error('获取仪表板列表失败:', error)
ElMessage.error('获取仪表板列表失败')
} finally {
loading.value = false
}
}
// 重置筛选条件
const resetFilter = () => {
Object.keys(filterForm).forEach(key => {
filterForm[key] = ''
})
pagination.page = 1
loadDashboards()
}
// 查看仪表板
const viewDashboard = (dashboard) => {
// 跳转到仪表板详情页面
router.push(`/statistics/dashboard/${dashboard.id}`)
}
// 编辑仪表板
const editDashboard = (dashboard) => {
editingDashboard.value = dashboard
Object.assign(dashboardForm, {
name: dashboard.name,
description: dashboard.description,
user_role: dashboard.user_role,
access_level: dashboard.access_level,
refresh_interval: dashboard.refresh_interval,
is_default: dashboard.is_default,
is_active: dashboard.is_active
})
showCreateDialog.value = true
}
// 删除仪表板
const deleteDashboard = async (dashboard) => {
try {
await ElMessageBox.confirm(
`确定要删除仪表板"${dashboard.name}"吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await adminDeleteDashboard(dashboard.id)
if (response.success) {
ElMessage.success('删除成功')
loadDashboards()
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除仪表板失败:', error)
ElMessage.error('删除失败')
}
}
}
// 保存仪表板
const saveDashboard = async () => {
try {
await dashboardFormRef.value.validate()
creating.value = true
const data = { ...dashboardForm }
let response
if (editingDashboard.value) {
response = await adminUpdateDashboard(editingDashboard.value.id, data)
} else {
response = await adminCreateDashboard(data)
}
if (response.success) {
ElMessage.success(editingDashboard.value ? '更新成功' : '创建成功')
showCreateDialog.value = false
resetForm()
loadDashboards()
} else {
ElMessage.error(response.message || '操作失败')
}
} catch (error) {
console.error('保存仪表板失败:', error)
ElMessage.error('操作失败')
} finally {
creating.value = false
}
}
// 重置表单
const resetForm = () => {
editingDashboard.value = null
Object.assign(dashboardForm, {
name: '',
description: '',
user_role: 'user',
access_level: 'private',
refresh_interval: 300,
is_default: false,
is_active: true
})
dashboardFormRef.value?.resetFields()
}
// 分页处理
const handleSizeChange = (val) => {
pagination.pageSize = val
pagination.page = 1
loadDashboards()
}
const handleCurrentChange = (val) => {
pagination.page = val
loadDashboards()
}
// 工具函数
const getRoleName = (role) => {
const roleNames = {
admin: '管理员',
user: '普通用户',
manager: '经理',
analyst: '分析师'
}
return roleNames[role] || role
}
const getRoleTagType = (role) => {
const tagTypes = {
admin: 'danger',
user: 'primary',
manager: 'warning',
analyst: 'success'
}
return tagTypes[role] || 'info'
}
const getAccessLevelName = (level) => {
const levelNames = {
private: '私有',
public: '公开',
shared: '共享'
}
return levelNames[level] || level
}
const getAccessLevelTagType = (level) => {
const tagTypes = {
private: 'info',
public: 'success',
shared: 'warning'
}
return tagTypes[level] || 'info'
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 生命周期
onMounted(() => {
loadDashboards()
})
return {
loading,
dashboards,
showCreateDialog,
creating,
editingDashboard,
dashboardFormRef,
filterForm,
pagination,
dashboardForm,
dashboardRules,
loadDashboards,
resetFilter,
viewDashboard,
editDashboard,
deleteDashboard,
saveDashboard,
handleSizeChange,
handleCurrentChange,
getRoleName,
getRoleTagType,
getAccessLevelName,
getAccessLevelTagType,
formatDate
}
}
}
</script>
<style scoped>
.admin-statistics-page {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-header h1 {
margin: 0 0 8px 0;
color: #303133;
}
.page-header p {
margin: 0;
color: #909399;
}
.dashboard-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-section {
margin-bottom: 20px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 4px;
}
.filter-form {
margin: 0;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,591 @@
<template>
<div class="dashboard-management">
<!-- 页面头部 -->
<div class="page-header">
<h1>仪表板管理</h1>
<p>管理系统统计仪表板</p>
</div>
<!-- 筛选区域 -->
<el-card class="filter-card">
<template v-slot:header>
<div class="card-header">
<span>筛选条件</span>
</div>
</template>
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="仪表板名称">
<el-input
v-model="filterForm.name"
placeholder="请输入仪表板名称"
clearable
></el-input>
</el-form-item>
<el-form-item label="用户角色">
<el-select v-model="filterForm.user_role" placeholder="请选择用户角色" clearable>
<el-option label="管理员" value="admin"></el-option>
<el-option label="用户" value="user"></el-option>
<el-option label="经理" value="manager"></el-option>
<el-option label="分析师" value="analyst"></el-option>
</el-select>
</el-form-item>
<el-form-item label="访问级别">
<el-select v-model="filterForm.access_level" placeholder="请选择访问级别" clearable>
<el-option label="私有" value="private"></el-option>
<el-option label="公开" value="public"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 仪表板列表 -->
<el-card class="dashboards-card">
<template v-slot:header>
<div class="card-header">
<span>仪表板列表</span>
<div class="header-actions">
<span class="total-count"> {{ total }} 条记录</span>
<el-button
type="primary"
size="small"
@click="showCreateDialog = true"
>
创建新仪表板
</el-button>
</div>
</div>
</template>
<el-table
:data="dashboards"
v-loading="loading"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column prop="name" label="仪表板名称" min-width="200" sortable="custom">
<template v-slot="scope">
<div class="dashboard-name">
<span class="name">{{ scope.row.name }}</span>
<div class="description" v-if="scope.row.description">
{{ scope.row.description }}
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="user_role" label="用户角色" width="120">
<template v-slot="scope">
<el-tag :type="getRoleTagType(scope.row.user_role)">
{{ getRoleText(scope.row.user_role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="access_level" label="访问级别" width="120">
<template v-slot="scope">
<el-tag :type="scope.row.access_level === 'public' ? 'success' : 'info'">
{{ scope.row.access_level === 'public' ? '公开' : '私有' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="refresh_interval" label="刷新间隔" width="120">
<template v-slot="scope">
{{ scope.row.refresh_interval }}
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="100">
<template v-slot="scope">
<el-switch
v-model="scope.row.is_active"
@change="toggleDashboardStatus(scope.row)"
:loading="scope.row.updating"
></el-switch>
</template>
</el-table-column>
<el-table-column prop="is_default" label="默认" width="80">
<template v-slot="scope">
<el-tag :type="scope.row.is_default ? 'success' : 'info'">
{{ scope.row.is_default ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" sortable="custom">
<template v-slot="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" sortable="custom">
<template v-slot="scope">
{{ formatDate(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template v-slot="scope">
<el-button @click="editDashboard(scope.row)" type="text" size="small">编辑</el-button>
<el-button @click="previewDashboard(scope.row)" type="text" size="small">预览</el-button>
<el-button @click="deleteDashboard(scope.row)" type="text" size="small" style="color: #F56C6C">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
background
>
</el-pagination>
</div>
</el-card>
<!-- 创建/编辑仪表板对话框 -->
<el-dialog
:title="isEdit ? '编辑仪表板' : '创建新仪表板'"
v-model="showCreateDialog"
width="600px"
@close="resetForm"
>
<el-form :model="form" :rules="formRules" ref="form" label-width="100px">
<el-form-item label="仪表板名称" prop="name">
<el-input v-model="form.name" placeholder="请输入仪表板名称"></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入仪表板描述"
></el-input>
</el-form-item>
<el-form-item label="用户角色" prop="user_role">
<el-select v-model="form.user_role" placeholder="请选择用户角色">
<el-option label="管理员" value="admin"></el-option>
<el-option label="用户" value="user"></el-option>
<el-option label="经理" value="manager"></el-option>
<el-option label="分析师" value="analyst"></el-option>
</el-select>
</el-form-item>
<el-form-item label="访问级别" prop="access_level">
<el-select v-model="form.access_level" placeholder="请选择访问级别">
<el-option label="私有" value="private"></el-option>
<el-option label="公开" value="public"></el-option>
</el-select>
</el-form-item>
<el-form-item label="刷新间隔" prop="refresh_interval">
<el-input-number
v-model="form.refresh_interval"
:min="60"
:max="3600"
:step="60"
></el-input-number>
<span class="form-tip"></span>
</el-form-item>
<el-form-item label="布局配置" prop="layout">
<el-input
v-model="form.layout"
type="textarea"
:rows="3"
placeholder="请输入布局配置JSON"
></el-input>
</el-form-item>
<el-form-item label="组件配置" prop="widgets">
<el-input
v-model="form.widgets"
type="textarea"
:rows="3"
placeholder="请输入组件配置JSON"
></el-input>
</el-form-item>
<el-form-item label="设置配置" prop="settings">
<el-input
v-model="form.settings"
type="textarea"
:rows="3"
placeholder="请输入设置配置JSON"
></el-input>
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active"></el-switch>
</el-form-item>
<el-form-item label="设为默认" prop="is_default">
<el-switch v-model="form.is_default"></el-switch>
</el-form-item>
</el-form>
<template v-slot:footer>
<div class="dialog-footer">
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="saveDashboard" :loading="saving">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
<!-- 仪表板预览对话框 -->
<el-dialog
:title="previewDashboard?.name || '仪表板预览'"
v-model="previewDialogVisible"
fullscreen
>
<StatisticsDashboard
v-if="previewDashboard"
:dashboard-id="previewDashboard.id"
:user-role="previewDashboard.user_role"
:auto-refresh="true"
:refresh-interval="previewDashboard.refresh_interval * 1000"
/>
</el-dialog>
</div>
</template>
<script>
import { adminCreateDashboard, adminDeleteDashboard, adminGetDashboard, adminGetDashboards, adminUpdateDashboard } from '@/api/statistics'
import StatisticsDashboard from '@/components/statistics/StatisticsDashboard.vue'
export default {
name: 'DashboardManagement',
components: {
StatisticsDashboard
},
data() {
return {
loading: false,
dashboards: [],
total: 0,
filterForm: {
name: '',
user_role: '',
access_level: ''
},
pagination: {
page: 1,
pageSize: 10,
total: 0
},
sortField: '',
sortOrder: '',
showCreateDialog: false,
isEdit: false,
saving: false,
form: {
id: null,
name: '',
description: '',
user_role: 'user',
access_level: 'private',
refresh_interval: 300,
layout: JSON.stringify({ columns: 3, rows: 4 }),
widgets: JSON.stringify([]),
settings: JSON.stringify({ theme: 'light', auto_refresh: true }),
is_active: true,
is_default: false
},
formRules: {
name: [{ required: true, message: '请输入仪表板名称', trigger: 'blur' }],
description: [{ required: true, message: '请输入仪表板描述', trigger: 'blur' }],
user_role: [{ required: true, message: '请选择用户角色', trigger: 'change' }],
access_level: [{ required: true, message: '请选择访问级别', trigger: 'change' }]
},
previewDialogVisible: false,
previewDashboard: null
}
},
mounted() {
this.loadDashboards()
},
methods: {
async loadDashboards() {
this.loading = true
try {
const params = {
...this.filterForm,
page: this.pagination.page,
limit: this.pagination.pageSize,
sort_field: this.sortField,
sort_order: this.sortOrder
}
const response = await adminGetDashboards(params)
if (response.success) {
this.dashboards = response.data.items || []
this.pagination.total = response.data.total || 0
this.total = this.pagination.total
} else {
this.$message.error(response.message || '获取仪表板列表失败')
}
} catch (error) {
console.error('获取仪表板列表失败:', error)
this.$message.error('获取仪表板列表失败')
} finally {
this.loading = false
}
},
async editDashboard(dashboard) {
try {
const response = await adminGetDashboard(dashboard.id)
if (response.success) {
this.form = { ...response.data }
this.isEdit = true
this.showCreateDialog = true
} else {
this.$message.error(response.message || '获取仪表板详情失败')
}
} catch (error) {
console.error('获取仪表板详情失败:', error)
this.$message.error('获取仪表板详情失败')
}
},
async saveDashboard() {
try {
await this.$refs.form.validate()
this.saving = true
const data = {
...this.form,
created_by: this.$store.getters.userId
}
const response = this.isEdit
? await adminUpdateDashboard(this.form.id, data)
: await adminCreateDashboard(data)
if (response.success) {
this.$message.success(this.isEdit ? '仪表板更新成功' : '仪表板创建成功')
this.showCreateDialog = false
this.loadDashboards()
} else {
this.$message.error(response.message || (this.isEdit ? '仪表板更新失败' : '仪表板创建失败'))
}
} catch (error) {
console.error('保存仪表板失败:', error)
this.$message.error('保存仪表板失败')
} finally {
this.saving = false
}
},
async toggleDashboardStatus(dashboard) {
try {
dashboard.updating = true
const response = await adminUpdateDashboard(dashboard.id, {
is_active: dashboard.is_active
})
if (response.success) {
this.$message.success('状态更新成功')
} else {
this.$message.error(response.message || '状态更新失败')
dashboard.is_active = !dashboard.is_active // 回滚状态
}
} catch (error) {
console.error('更新仪表板状态失败:', error)
this.$message.error('更新仪表板状态失败')
dashboard.is_active = !dashboard.is_active // 回滚状态
} finally {
dashboard.updating = false
}
},
previewDashboard(dashboard) {
this.previewDashboard = dashboard
this.previewDialogVisible = true
},
async deleteDashboard(dashboard) {
try {
await this.$confirm(`确定要删除仪表板 "${dashboard.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const response = await adminDeleteDashboard(dashboard.id)
if (response.success) {
this.$message.success('仪表板删除成功')
this.loadDashboards()
} else {
this.$message.error(response.message || '删除仪表板失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除仪表板失败:', error)
this.$message.error('删除仪表板失败')
}
}
},
handleFilter() {
this.pagination.page = 1
this.loadDashboards()
},
resetFilter() {
this.filterForm = {
name: '',
user_role: '',
access_level: ''
}
this.pagination.page = 1
this.loadDashboards()
},
handleSortChange({ prop, order }) {
this.sortField = prop
this.sortOrder = order === 'ascending' ? 'asc' : 'desc'
this.loadDashboards()
},
handleSizeChange(val) {
this.pagination.pageSize = val
this.loadDashboards()
},
handleCurrentChange(val) {
this.pagination.page = val
this.loadDashboards()
},
resetForm() {
this.form = {
id: null,
name: '',
description: '',
user_role: 'user',
access_level: 'private',
refresh_interval: 300,
layout: JSON.stringify({ columns: 3, rows: 4 }),
widgets: JSON.stringify([]),
settings: JSON.stringify({ theme: 'light', auto_refresh: true }),
is_active: true,
is_default: false
}
this.isEdit = false
this.$refs.form?.resetFields()
},
formatDate(dateString) {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
},
getRoleTagType(role) {
const typeMap = {
admin: 'danger',
manager: 'warning',
analyst: 'info',
user: 'success'
}
return typeMap[role] || 'info'
},
getRoleText(role) {
const textMap = {
admin: '管理员',
manager: '经理',
analyst: '分析师',
user: '用户'
}
return textMap[role] || role
}
}
}
</script>
<style scoped>
.dashboard-management {
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
margin: 0 0 10px 0;
color: #303133;
font-size: 28px;
}
.page-header p {
margin: 0;
color: #606266;
font-size: 14px;
}
.filter-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-form {
margin-bottom: 0;
}
.dashboards-card {
margin-bottom: 20px;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.dashboard-name .name {
font-weight: 500;
color: #303133;
}
.dashboard-name .description {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.pagination-wrapper {
margin-top: 20px;
text-align: right;
}
.form-tip {
margin-left: 8px;
color: #909399;
font-size: 12px;
}
.dialog-footer {
text-align: right;
}
</style>

View File

@@ -0,0 +1,558 @@
<template>
<div class="metrics-management">
<!-- 页面头部 -->
<div class="page-header">
<h1>指标管理</h1>
<p>管理系统统计指标</p>
</div>
<!-- 筛选区域 -->
<el-card class="filter-card">
<template v-slot:header>
<div class="card-header">
<span>筛选条件</span>
</div>
</template>
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="指标名称">
<el-input
v-model="filterForm.name"
placeholder="请输入指标名称"
clearable
></el-input>
</el-form-item>
<el-form-item label="指标类型">
<el-select v-model="filterForm.metric_type" placeholder="请选择指标类型" clearable>
<el-option label="API调用" value="api_calls"></el-option>
<el-option label="用户统计" value="users"></el-option>
<el-option label="财务统计" value="finance"></el-option>
<el-option label="产品统计" value="products"></el-option>
<el-option label="认证统计" value="certification"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.is_active" placeholder="请选择状态" clearable>
<el-option label="活跃" :value="true"></el-option>
<el-option label="禁用" :value="false"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 指标列表 -->
<el-card class="metrics-card">
<template v-slot:header>
<div class="card-header">
<span>指标列表</span>
<div class="header-actions">
<span class="total-count"> {{ total }} 条记录</span>
<el-button
type="primary"
size="small"
@click="showCreateDialog = true"
>
创建新指标
</el-button>
</div>
</div>
</template>
<el-table
:data="metrics"
v-loading="loading"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column prop="name" label="指标名称" min-width="200" sortable="custom">
<template v-slot="scope">
<div class="metric-name">
<span class="name">{{ scope.row.name }}</span>
<div class="description" v-if="scope.row.description">
{{ scope.row.description }}
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="metric_type" label="指标类型" width="150">
<template v-slot="scope">
<el-tag :type="getMetricTypeTagType(scope.row.metric_type)">
{{ getMetricTypeText(scope.row.metric_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="value" label="当前值" width="120" sortable="custom">
<template v-slot="scope">
{{ scope.row.value ? scope.row.value.toLocaleString() : '0' }}
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="80">
<template v-slot="scope">
{{ scope.row.unit || '-' }}
</template>
</el-table-column>
<el-table-column prop="aggregation_type" label="聚合类型" width="120">
<template v-slot="scope">
{{ getAggregationTypeText(scope.row.aggregation_type) }}
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="100">
<template v-slot="scope">
<el-switch
v-model="scope.row.is_active"
@change="toggleMetricStatus(scope.row)"
:loading="scope.row.updating"
></el-switch>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" sortable="custom">
<template v-slot="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" sortable="custom">
<template v-slot="scope">
{{ formatDate(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template v-slot="scope">
<el-button @click="editMetric(scope.row)" type="text" size="small">编辑</el-button>
<el-button @click="deleteMetric(scope.row)" type="text" size="small" style="color: #F56C6C">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
background
>
</el-pagination>
</div>
</el-card>
<!-- 创建/编辑指标对话框 -->
<el-dialog
:title="isEdit ? '编辑指标' : '创建新指标'"
v-model="showCreateDialog"
width="600px"
@close="resetForm"
>
<el-form :model="form" :rules="formRules" ref="form" label-width="100px">
<el-form-item label="指标名称" prop="name">
<el-input v-model="form.name" placeholder="请输入指标名称"></el-input>
</el-form-item>
<el-form-item label="指标类型" prop="metric_type">
<el-select v-model="form.metric_type" placeholder="请选择指标类型">
<el-option label="API调用" value="api_calls"></el-option>
<el-option label="用户统计" value="users"></el-option>
<el-option label="财务统计" value="finance"></el-option>
<el-option label="产品统计" value="products"></el-option>
<el-option label="认证统计" value="certification"></el-option>
</el-select>
</el-form-item>
<el-form-item label="指标描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入指标描述"
></el-input>
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="请输入单位"></el-input>
</el-form-item>
<el-form-item label="初始值" prop="value">
<el-input-number
v-model="form.value"
:min="0"
:precision="2"
placeholder="请输入初始值"
></el-input-number>
</el-form-item>
<el-form-item label="聚合类型" prop="aggregation_type">
<el-select v-model="form.aggregation_type" placeholder="请选择聚合类型">
<el-option label="求和" value="sum"></el-option>
<el-option label="平均值" value="avg"></el-option>
<el-option label="最大值" value="max"></el-option>
<el-option label="最小值" value="min"></el-option>
<el-option label="计数" value="count"></el-option>
</el-select>
</el-form-item>
<el-form-item label="时间粒度" prop="time_granularity">
<el-select v-model="form.time_granularity" placeholder="请选择时间粒度">
<el-option label="分钟" value="minute"></el-option>
<el-option label="小时" value="hour"></el-option>
<el-option label="天" value="day"></el-option>
<el-option label="周" value="week"></el-option>
<el-option label="月" value="month"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active"></el-switch>
</el-form-item>
<el-form-item label="设为默认" prop="is_default">
<el-switch v-model="form.is_default"></el-switch>
</el-form-item>
</el-form>
<template v-slot:footer>
<div class="dialog-footer">
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="saveMetric" :loading="saving">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script>
import { adminCreateMetric, adminDeleteMetric, adminGetMetric, adminGetMetrics, adminUpdateMetric } from '@/api/statistics'
export default {
name: 'MetricsManagement',
data() {
return {
loading: false,
metrics: [],
total: 0,
filterForm: {
name: '',
metric_type: '',
is_active: null
},
pagination: {
page: 1,
pageSize: 10,
total: 0
},
sortField: '',
sortOrder: '',
showCreateDialog: false,
isEdit: false,
saving: false,
form: {
id: null,
name: '',
metric_type: '',
description: '',
unit: '',
value: 0,
aggregation_type: 'sum',
time_granularity: 'hour',
is_active: true,
is_default: false
},
formRules: {
name: [{ required: true, message: '请输入指标名称', trigger: 'blur' }],
metric_type: [{ required: true, message: '请选择指标类型', trigger: 'change' }],
description: [{ required: true, message: '请输入指标描述', trigger: 'blur' }],
aggregation_type: [{ required: true, message: '请选择聚合类型', trigger: 'change' }],
time_granularity: [{ required: true, message: '请选择时间粒度', trigger: 'change' }]
}
}
},
mounted() {
this.loadMetrics()
},
methods: {
async loadMetrics() {
this.loading = true
try {
const params = {
...this.filterForm,
page: this.pagination.page,
limit: this.pagination.pageSize,
sort_field: this.sortField,
sort_order: this.sortOrder
}
const response = await adminGetMetrics(params)
if (response.success) {
this.metrics = response.data.items || []
this.pagination.total = response.data.total || 0
this.total = this.pagination.total
} else {
this.$message.error(response.message || '获取指标列表失败')
}
} catch (error) {
console.error('获取指标列表失败:', error)
this.$message.error('获取指标列表失败')
} finally {
this.loading = false
}
},
async editMetric(metric) {
try {
const response = await adminGetMetric(metric.id)
if (response.success) {
this.form = { ...response.data }
this.isEdit = true
this.showCreateDialog = true
} else {
this.$message.error(response.message || '获取指标详情失败')
}
} catch (error) {
console.error('获取指标详情失败:', error)
this.$message.error('获取指标详情失败')
}
},
async saveMetric() {
try {
await this.$refs.form.validate()
this.saving = true
const data = {
...this.form,
created_by: this.$store.getters.userId
}
const response = this.isEdit
? await adminUpdateMetric(this.form.id, data)
: await adminCreateMetric(data)
if (response.success) {
this.$message.success(this.isEdit ? '指标更新成功' : '指标创建成功')
this.showCreateDialog = false
this.loadMetrics()
} else {
this.$message.error(response.message || (this.isEdit ? '指标更新失败' : '指标创建失败'))
}
} catch (error) {
console.error('保存指标失败:', error)
this.$message.error('保存指标失败')
} finally {
this.saving = false
}
},
async toggleMetricStatus(metric) {
try {
metric.updating = true
const response = await adminUpdateMetric(metric.id, {
is_active: metric.is_active
})
if (response.success) {
this.$message.success('状态更新成功')
} else {
this.$message.error(response.message || '状态更新失败')
metric.is_active = !metric.is_active // 回滚状态
}
} catch (error) {
console.error('更新指标状态失败:', error)
this.$message.error('更新指标状态失败')
metric.is_active = !metric.is_active // 回滚状态
} finally {
metric.updating = false
}
},
async deleteMetric(metric) {
try {
await this.$confirm(`确定要删除指标 "${metric.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const response = await adminDeleteMetric(metric.id)
if (response.success) {
this.$message.success('指标删除成功')
this.loadMetrics()
} else {
this.$message.error(response.message || '删除指标失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除指标失败:', error)
this.$message.error('删除指标失败')
}
}
},
handleFilter() {
this.pagination.page = 1
this.loadMetrics()
},
resetFilter() {
this.filterForm = {
name: '',
metric_type: '',
is_active: null
}
this.pagination.page = 1
this.loadMetrics()
},
handleSortChange({ prop, order }) {
this.sortField = prop
this.sortOrder = order === 'ascending' ? 'asc' : 'desc'
this.loadMetrics()
},
handleSizeChange(val) {
this.pagination.pageSize = val
this.loadMetrics()
},
handleCurrentChange(val) {
this.pagination.page = val
this.loadMetrics()
},
resetForm() {
this.form = {
id: null,
name: '',
metric_type: '',
description: '',
unit: '',
value: 0,
aggregation_type: 'sum',
time_granularity: 'hour',
is_active: true,
is_default: false
}
this.isEdit = false
this.$refs.form?.resetFields()
},
formatDate(dateString) {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
},
getMetricTypeTagType(type) {
const typeMap = {
api_calls: 'primary',
users: 'success',
finance: 'warning',
products: 'info',
certification: 'danger'
}
return typeMap[type] || 'info'
},
getMetricTypeText(type) {
const textMap = {
api_calls: 'API调用',
users: '用户统计',
finance: '财务统计',
products: '产品统计',
certification: '认证统计'
}
return textMap[type] || type
},
getAggregationTypeText(type) {
const textMap = {
sum: '求和',
avg: '平均值',
max: '最大值',
min: '最小值',
count: '计数'
}
return textMap[type] || type
}
}
}
</script>
<style scoped>
.metrics-management {
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
margin: 0 0 10px 0;
color: #303133;
font-size: 28px;
}
.page-header p {
margin: 0;
color: #606266;
font-size: 14px;
}
.filter-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-form {
margin-bottom: 0;
}
.metrics-card {
margin-bottom: 20px;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.metric-name .name {
font-weight: 500;
color: #303133;
}
.metric-name .description {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.pagination-wrapper {
margin-top: 20px;
text-align: right;
}
.dialog-footer {
text-align: right;
}
</style>

View File

@@ -0,0 +1,660 @@
<template>
<div class="reports-management">
<!-- 页面头部 -->
<div class="page-header">
<h1>报告管理</h1>
<p>管理系统统计报告</p>
</div>
<!-- 筛选区域 -->
<el-card class="filter-card">
<template v-slot:header>
<div class="card-header">
<span>筛选条件</span>
</div>
</template>
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="报告名称">
<el-input
v-model="filterForm.name"
placeholder="请输入报告名称"
clearable
></el-input>
</el-form-item>
<el-form-item label="报告类型">
<el-select v-model="filterForm.report_type" placeholder="请选择报告类型" clearable>
<el-option label="用户统计" value="user_statistics"></el-option>
<el-option label="API调用" value="api_calls"></el-option>
<el-option label="财务报告" value="finance_report"></el-option>
<el-option label="产品统计" value="product_statistics"></el-option>
<el-option label="认证统计" value="certification_statistics"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="请选择状态" clearable>
<el-option label="已完成" value="completed"></el-option>
<el-option label="生成中" value="generating"></el-option>
<el-option label="已过期" value="expired"></el-option>
<el-option label="生成失败" value="failed"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 报告列表 -->
<el-card class="reports-card">
<template v-slot:header>
<div class="card-header">
<span>报告列表</span>
<div class="header-actions">
<span class="total-count"> {{ total }} 条记录</span>
<el-button
type="primary"
size="small"
@click="showGenerateDialog = true"
>
生成新报告
</el-button>
</div>
</div>
</template>
<el-table
:data="reports"
v-loading="loading"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column prop="name" label="报告名称" min-width="200" sortable="custom">
<template v-slot="scope">
<div class="report-name">
<span class="name">{{ scope.row.name }}</span>
<div class="description" v-if="scope.row.description">
{{ scope.row.description }}
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="report_type" label="报告类型" width="150">
<template v-slot="scope">
<el-tag :type="getReportTypeTagType(scope.row.report_type)">
{{ getReportTypeText(scope.row.report_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template v-slot="scope">
<el-tag :type="getStatusTagType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="period" label="统计周期" width="120">
<template v-slot="scope">
{{ getPeriodText(scope.row.period) }}
</template>
</el-table-column>
<el-table-column prop="generated_by" label="生成人" width="120">
<template v-slot="scope">
{{ scope.row.generated_by || '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" sortable="custom">
<template v-slot="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="expires_at" label="过期时间" width="180">
<template v-slot="scope">
<span v-if="scope.row.expires_at">{{ formatDate(scope.row.expires_at) }}</span>
<span v-else class="text-muted">永不过期</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template v-slot="scope">
<el-button
@click="viewReport(scope.row)"
type="text"
size="small"
:disabled="scope.row.status !== 'completed'"
>
查看
</el-button>
<el-button
@click="downloadReport(scope.row)"
type="text"
size="small"
:disabled="scope.row.status !== 'completed'"
>
下载
</el-button>
<el-button
@click="regenerateReport(scope.row)"
type="text"
size="small"
v-if="scope.row.status === 'failed'"
>
重新生成
</el-button>
<el-button
@click="deleteReport(scope.row)"
type="text"
size="small"
style="color: #F56C6C"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
background
>
</el-pagination>
</div>
</el-card>
<!-- 生成报告对话框 -->
<el-dialog
title="生成报告"
v-model="showGenerateDialog"
width="600px"
@close="resetGenerateForm"
>
<el-form :model="generateForm" :rules="generateRules" ref="generateForm" label-width="100px">
<el-form-item label="报告名称" prop="name">
<el-input v-model="generateForm.name" placeholder="请输入报告名称"></el-input>
</el-form-item>
<el-form-item label="报告类型" prop="report_type">
<el-select v-model="generateForm.report_type" placeholder="请选择报告类型">
<el-option label="用户统计" value="user_statistics"></el-option>
<el-option label="API调用" value="api_calls"></el-option>
<el-option label="财务报告" value="finance_report"></el-option>
<el-option label="产品统计" value="product_statistics"></el-option>
<el-option label="认证统计" value="certification_statistics"></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="generateForm.description"
type="textarea"
:rows="3"
placeholder="请输入报告描述"
></el-input>
</el-form-item>
<el-form-item label="统计周期" prop="period">
<el-select v-model="generateForm.period" placeholder="请选择统计周期">
<el-option label="日" value="daily"></el-option>
<el-option label="周" value="weekly"></el-option>
<el-option label="月" value="monthly"></el-option>
<el-option label="季度" value="quarterly"></el-option>
<el-option label="年" value="yearly"></el-option>
</el-select>
</el-form-item>
<el-form-item label="时间范围" prop="dateRange">
<el-date-picker
v-model="generateForm.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
></el-date-picker>
</el-form-item>
<el-form-item label="报告格式" prop="format">
<el-checkbox-group v-model="generateForm.format">
<el-checkbox label="pdf">PDF</el-checkbox>
<el-checkbox label="excel">Excel</el-checkbox>
<el-checkbox label="csv">CSV</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-select v-model="generateForm.priority" placeholder="请选择优先级">
<el-option label="低" value="low"></el-option>
<el-option label="中" value="medium"></el-option>
<el-option label="高" value="high"></el-option>
</el-select>
</el-form-item>
<el-form-item label="过期时间" prop="expires_at">
<el-date-picker
v-model="generateForm.expires_at"
type="datetime"
placeholder="选择过期时间"
value-format="YYYY-MM-DD HH:mm:ss"
></el-date-picker>
</el-form-item>
</el-form>
<template v-slot:footer>
<div class="dialog-footer">
<el-button @click="showGenerateDialog = false">取消</el-button>
<el-button type="primary" @click="generateReport" :loading="generating">
生成报告
</el-button>
</div>
</template>
</el-dialog>
<!-- 报告详情对话框 -->
<el-dialog
title="报告详情"
v-model="showDetailDialog"
width="800px"
>
<div v-if="selectedReport">
<el-descriptions :column="2" border>
<el-descriptions-item label="报告名称">{{ selectedReport.name }}</el-descriptions-item>
<el-descriptions-item label="报告类型">{{ getReportTypeText(selectedReport.report_type) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(selectedReport.status)">
{{ getStatusText(selectedReport.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="统计周期">{{ getPeriodText(selectedReport.period) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(selectedReport.created_at) }}</el-descriptions-item>
<el-descriptions-item label="过期时间">
{{ selectedReport.expires_at ? formatDate(selectedReport.expires_at) : '永不过期' }}
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedReport.description || '无' }}</el-descriptions-item>
</el-descriptions>
<div v-if="selectedReport.status === 'completed'" class="report-content">
<h4>报告内容</h4>
<div class="report-data" v-html="selectedReport.content"></div>
</div>
<div v-if="selectedReport.status === 'failed'" class="error-content">
<h4>错误信息</h4>
<el-alert
:title="selectedReport.error_message || '生成失败'"
type="error"
:closable="false"
></el-alert>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { adminDeleteReport, adminGetReport, adminGetReports } from '@/api/statistics'
export default {
name: 'ReportsManagement',
data() {
return {
loading: false,
reports: [],
total: 0,
filterForm: {
name: '',
report_type: '',
status: ''
},
pagination: {
page: 1,
pageSize: 10,
total: 0
},
sortField: '',
sortOrder: '',
showGenerateDialog: false,
generating: false,
generateForm: {
name: '',
report_type: '',
description: '',
period: 'monthly',
dateRange: [],
format: ['pdf'],
priority: 'medium',
expires_at: ''
},
generateRules: {
name: [{ required: true, message: '请输入报告名称', trigger: 'blur' }],
report_type: [{ required: true, message: '请选择报告类型', trigger: 'change' }],
description: [{ required: true, message: '请输入报告描述', trigger: 'blur' }],
period: [{ required: true, message: '请选择统计周期', trigger: 'change' }],
dateRange: [{ required: true, message: '请选择时间范围', trigger: 'change' }]
},
showDetailDialog: false,
selectedReport: null
}
},
mounted() {
this.loadReports()
},
methods: {
async loadReports() {
this.loading = true
try {
const params = {
...this.filterForm,
page: this.pagination.page,
limit: this.pagination.pageSize,
sort_field: this.sortField,
sort_order: this.sortOrder
}
const response = await adminGetReports(params)
if (response.success) {
this.reports = response.data.items || []
this.pagination.total = response.data.total || 0
this.total = this.pagination.total
} else {
this.$message.error(response.message || '获取报告列表失败')
}
} catch (error) {
console.error('获取报告列表失败:', error)
this.$message.error('获取报告列表失败')
} finally {
this.loading = false
}
},
async generateReport() {
try {
await this.$refs.generateForm.validate()
this.generating = true
// TODO: 实现报告生成功能 - 后端暂无此接口
this.$message.info('报告生成功能暂未实现')
this.showGenerateDialog = false
} catch (error) {
console.error('生成报告失败:', error)
this.$message.error('生成报告失败')
} finally {
this.generating = false
}
},
async viewReport(report) {
try {
const response = await adminGetReport(report.id)
if (response.success) {
this.selectedReport = response.data
this.showDetailDialog = true
} else {
this.$message.error(response.message || '获取报告详情失败')
}
} catch (error) {
console.error('获取报告详情失败:', error)
this.$message.error('获取报告详情失败')
}
},
downloadReport(report) {
// TODO: 实现报告下载逻辑
this.$message.info('下载功能开发中')
},
async regenerateReport(report) {
try {
await this.$confirm('确定要重新生成这个报告吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
// TODO: 实现报告重新生成功能 - 后端暂无此接口
this.$message.info('报告重新生成功能暂未实现')
} catch (error) {
if (error !== 'cancel') {
console.error('重新生成报告失败:', error)
this.$message.error('重新生成报告失败')
}
}
},
async deleteReport(report) {
try {
await this.$confirm(`确定要删除报告 "${report.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const response = await adminDeleteReport(report.id)
if (response.success) {
this.$message.success('报告删除成功')
this.loadReports()
} else {
this.$message.error(response.message || '删除报告失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除报告失败:', error)
this.$message.error('删除报告失败')
}
}
},
handleFilter() {
this.pagination.page = 1
this.loadReports()
},
resetFilter() {
this.filterForm = {
name: '',
report_type: '',
status: ''
}
this.pagination.page = 1
this.loadReports()
},
handleSortChange({ prop, order }) {
this.sortField = prop
this.sortOrder = order === 'ascending' ? 'asc' : 'desc'
this.loadReports()
},
handleSizeChange(val) {
this.pagination.pageSize = val
this.loadReports()
},
handleCurrentChange(val) {
this.pagination.page = val
this.loadReports()
},
resetGenerateForm() {
this.generateForm = {
name: '',
report_type: '',
description: '',
period: 'monthly',
dateRange: [],
format: ['pdf'],
priority: 'medium',
expires_at: ''
}
this.$refs.generateForm?.resetFields()
},
formatDate(dateString) {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
},
getReportTypeTagType(type) {
const typeMap = {
user_statistics: 'primary',
api_calls: 'success',
finance_report: 'warning',
product_statistics: 'info',
certification_statistics: 'danger'
}
return typeMap[type] || 'info'
},
getReportTypeText(type) {
const textMap = {
user_statistics: '用户统计',
api_calls: 'API调用',
finance_report: '财务报告',
product_statistics: '产品统计',
certification_statistics: '认证统计'
}
return textMap[type] || type
},
getStatusTagType(status) {
const typeMap = {
completed: 'success',
generating: 'info',
expired: 'warning',
failed: 'danger'
}
return typeMap[status] || 'info'
},
getStatusText(status) {
const textMap = {
completed: '已完成',
generating: '生成中',
expired: '已过期',
failed: '生成失败'
}
return textMap[status] || status
},
getPeriodText(period) {
const textMap = {
daily: '日',
weekly: '周',
monthly: '月',
quarterly: '季度',
yearly: '年'
}
return textMap[period] || period
}
}
}
</script>
<style scoped>
.reports-management {
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
margin: 0 0 10px 0;
color: #303133;
font-size: 28px;
}
.page-header p {
margin: 0;
color: #606266;
font-size: 14px;
}
.filter-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-form {
margin-bottom: 0;
}
.reports-card {
margin-bottom: 20px;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.report-name .name {
font-weight: 500;
color: #303133;
}
.report-name .description {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.text-muted {
color: #C0C4CC;
}
.pagination-wrapper {
margin-top: 20px;
text-align: right;
}
.report-content {
margin-top: 20px;
}
.report-content h4 {
margin: 0 0 15px 0;
color: #303133;
}
.report-data {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
border: 1px solid #e4e7ed;
}
.error-content {
margin-top: 20px;
}
.error-content h4 {
margin: 0 0 15px 0;
color: #303133;
}
.dialog-footer {
text-align: right;
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<div class="statistics-settings">
<!-- 页面头部 -->
<div class="page-header">
<h1>统计设置</h1>
<p>配置统计系统参数</p>
</div>
<!-- 设置内容 -->
<el-card class="settings-card">
<template v-slot:header>
<div class="card-header">
<span>统计设置</span>
<el-button
type="text"
icon="el-icon-refresh"
@click="loadSettings"
>
刷新
</el-button>
</div>
</template>
<el-tabs v-model="activeSettingTab" type="border-card">
<!-- 基础设置 -->
<el-tab-pane label="基础设置" name="general">
<template v-slot:label>
<el-icon><Setting /></el-icon> 基础设置
</template>
<div class="tab-pane-content">
<el-form :model="generalSettings" label-width="150px">
<el-form-item label="数据保留天数">
<el-input-number
v-model="generalSettings.data_retention_days"
:min="30"
:max="3650"
></el-input-number>
<span class="form-tip"></span>
</el-form-item>
<el-form-item label="默认仪表板ID">
<el-input
v-model="generalSettings.default_dashboard_id"
placeholder="请输入默认仪表板ID"
></el-input>
</el-form-item>
<el-form-item label="启用实时统计">
<el-switch v-model="generalSettings.enable_realtime_statistics"></el-switch>
</el-form-item>
<el-form-item label="统计精度">
<el-select v-model="generalSettings.precision" placeholder="请选择统计精度">
<el-option label="分钟级" value="minute"></el-option>
<el-option label="小时级" value="hour"></el-option>
<el-option label="天级" value="day"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveGeneralSettings">保存</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 聚合设置 -->
<el-tab-pane label="聚合设置" name="aggregation">
<template v-slot:label>
<el-icon><Timer /></el-icon> 聚合设置
</template>
<div class="tab-pane-content">
<el-form :model="aggregationSettings" label-width="150px">
<el-form-item label="小时聚合间隔">
<el-input-number
v-model="aggregationSettings.hourly_interval_minutes"
:min="1"
:max="60"
></el-input-number>
<span class="form-tip">分钟</span>
</el-form-item>
<el-form-item label="日聚合时间">
<el-time-picker
v-model="aggregationSettings.daily_aggregation_time"
placeholder="选择时间"
value-format="HH:mm"
></el-time-picker>
</el-form-item>
<el-form-item label="启用自动聚合">
<el-switch v-model="aggregationSettings.enable_auto_aggregation"></el-switch>
</el-form-item>
<el-form-item label="聚合线程数">
<el-input-number
v-model="aggregationSettings.aggregation_threads"
:min="1"
:max="10"
></el-input-number>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveAggregationSettings">保存</el-button>
<el-button @click="triggerManualAggregation">手动触发聚合</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 告警设置 -->
<el-tab-pane label="告警设置" name="alerts">
<template v-slot:label>
<el-icon><Bell /></el-icon> 告警设置
</template>
<div class="tab-pane-content">
<el-form :model="alertSettings" label-width="150px">
<el-form-item label="启用异常告警">
<el-switch v-model="alertSettings.enable_anomaly_alerts"></el-switch>
</el-form-item>
<el-form-item label="告警阈值 (%)">
<el-input-number
v-model="alertSettings.alert_threshold_percent"
:min="1"
:max="100"
></el-input-number>
<span class="form-tip">%</span>
</el-form-item>
<el-form-item label="告警接收邮箱">
<el-input
v-model="alertSettings.alert_recipients_email"
placeholder="多个邮箱用逗号分隔"
></el-input>
</el-form-item>
<el-form-item label="告警频率限制">
<el-input-number
v-model="alertSettings.alert_frequency_limit"
:min="1"
:max="60"
></el-input-number>
<span class="form-tip">分钟</span>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveAlertSettings">保存</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 性能设置 -->
<el-tab-pane label="性能设置" name="performance">
<template v-slot:label>
<el-icon><Cpu /></el-icon> 性能设置
</template>
<div class="tab-pane-content">
<el-form :model="performanceSettings" label-width="150px">
<el-form-item label="缓存过期时间">
<el-input-number
v-model="performanceSettings.cache_expiration_seconds"
:min="60"
:max="86400"
></el-input-number>
<span class="form-tip"></span>
</el-form-item>
<el-form-item label="启用数据压缩">
<el-switch v-model="performanceSettings.enable_data_compression"></el-switch>
</el-form-item>
<el-form-item label="查询超时时间">
<el-input-number
v-model="performanceSettings.query_timeout_seconds"
:min="5"
:max="300"
></el-input-number>
<span class="form-tip"></span>
</el-form-item>
<el-form-item label="批量处理大小">
<el-input-number
v-model="performanceSettings.batch_size"
:min="100"
:max="10000"
></el-input-number>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="savePerformanceSettings">保存</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 系统状态 -->
<el-tab-pane label="系统状态" name="status">
<template v-slot:label>
<el-icon><Monitor /></el-icon> 系统状态
</template>
<div class="tab-pane-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="系统状态">
<el-tag :type="systemStatus.healthy ? 'success' : 'danger'">
{{ systemStatus.healthy ? '正常' : '异常' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据总量">
{{ systemStatus.total_records || 0 }}
</el-descriptions-item>
<el-descriptions-item label="活跃指标">
{{ systemStatus.active_metrics || 0 }}
</el-descriptions-item>
<el-descriptions-item label="仪表板数量">
{{ systemStatus.dashboard_count || 0 }}
</el-descriptions-item>
<el-descriptions-item label="最后聚合时间">
{{ systemStatus.last_aggregation_time || '-' }}
</el-descriptions-item>
<el-descriptions-item label="缓存命中率">
{{ systemStatus.cache_hit_rate || 0 }}%
</el-descriptions-item>
</el-descriptions>
<div class="status-actions">
<el-button @click="refreshSystemStatus">刷新状态</el-button>
<el-button @click="clearCache">清理缓存</el-button>
<el-button @click="rebuildIndex">重建索引</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script>
import { adminGetSystemStatistics, adminTriggerAggregation } from '@/api/statistics'
import { Bell, Cpu, Monitor, Setting, Timer } from '@element-plus/icons-vue'
export default {
name: 'StatisticsSettings',
components: {
Setting,
Timer,
Bell,
Cpu,
Monitor
},
data() {
return {
activeSettingTab: 'general',
generalSettings: {
data_retention_days: 365,
default_dashboard_id: '',
enable_realtime_statistics: true,
precision: 'hour'
},
aggregationSettings: {
hourly_interval_minutes: 60,
daily_aggregation_time: '02:00',
enable_auto_aggregation: true,
aggregation_threads: 4
},
alertSettings: {
enable_anomaly_alerts: true,
alert_threshold_percent: 10,
alert_recipients_email: '',
alert_frequency_limit: 15
},
performanceSettings: {
cache_expiration_seconds: 3600,
enable_data_compression: true,
query_timeout_seconds: 30,
batch_size: 1000
},
systemStatus: {
healthy: true,
total_records: 0,
active_metrics: 0,
dashboard_count: 0,
last_aggregation_time: '',
cache_hit_rate: 0
}
}
},
mounted() {
this.loadSettings()
this.refreshSystemStatus()
},
methods: {
async loadSettings() {
try {
const response = await adminGetSystemStatistics({ period: 'overall' })
if (response.success && response.data) {
// 更新系统状态
this.systemStatus = {
...this.systemStatus,
...response.data
}
}
this.$message.success('统计设置加载完成')
} catch (error) {
console.error('加载统计设置失败:', error)
this.$message.error('加载统计设置失败')
}
},
saveGeneralSettings() {
this.$message.success('基础设置已保存')
console.log('保存基础设置:', this.generalSettings)
// TODO: 调用后端API保存设置
},
saveAggregationSettings() {
this.$message.success('聚合设置已保存')
console.log('保存聚合设置:', this.aggregationSettings)
// TODO: 调用后端API保存设置
},
async triggerManualAggregation() {
try {
const response = await adminTriggerAggregation({ period: 'daily', force: true })
if (response.success) {
this.$message.success('手动触发聚合成功')
} else {
this.$message.error(response.message || '手动触发聚合失败')
}
} catch (error) {
console.error('手动触发聚合失败:', error)
this.$message.error('手动触发聚合失败')
}
},
saveAlertSettings() {
this.$message.success('告警设置已保存')
console.log('保存告警设置:', this.alertSettings)
// TODO: 调用后端API保存设置
},
savePerformanceSettings() {
this.$message.success('性能设置已保存')
console.log('保存性能设置:', this.performanceSettings)
// TODO: 调用后端API保存设置
},
async refreshSystemStatus() {
try {
const response = await adminGetSystemStatistics({ period: 'overall' })
if (response.success && response.data) {
this.systemStatus = {
...this.systemStatus,
...response.data
}
this.$message.success('系统状态已刷新')
} else {
this.$message.error(response.message || '获取系统状态失败')
}
} catch (error) {
console.error('获取系统状态失败:', error)
this.$message.error('获取系统状态失败')
}
},
clearCache() {
this.$message.info('缓存清理功能开发中')
// TODO: 实现缓存清理功能
},
rebuildIndex() {
this.$message.info('索引重建功能开发中')
// TODO: 实现索引重建功能
}
}
}
</script>
<style scoped>
.statistics-settings {
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
margin: 0 0 10px 0;
color: #303133;
font-size: 28px;
}
.page-header p {
margin: 0;
color: #606266;
font-size: 14px;
}
.settings-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.tab-pane-content {
padding: 20px;
}
.form-tip {
margin-left: 8px;
color: #909399;
font-size: 12px;
}
.status-actions {
margin-top: 20px;
text-align: center;
}
.status-actions .el-button {
margin: 0 8px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
<template>
<ListPageLayout
title="消费记录管理"
subtitle="管理系统内所有用户的消费记录"
>
<!-- 单用户模式显示 -->
<template #stats v-if="singleUserMode">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>当前用户{{ currentUser?.company_name || currentUser?.phone }}</span>
<span class="text-gray-400">(仅显示当前用户)</span>
</div>
</template>
<!-- 单用户模式操作按钮 -->
<template #actions v-if="singleUserMode">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>{{ currentUser?.company_name || currentUser?.phone }}</span>
</div>
<el-button size="small" @click="exitSingleUserMode">
<Close class="w-4 h-4 mr-1" />
取消
</el-button>
<el-button size="small" type="primary" @click="goBackToUsers">
<Back class="w-4 h-4 mr-1" />
返回用户管理
</el-button>
</div>
</template>
<!-- 筛选区域 -->
<template #filters>
<FilterSection>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<FilterItem label="企业名称" v-if="!singleUserMode">
<el-input
v-model="filters.company_name"
placeholder="输入企业名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="产品名称">
<el-input
v-model="filters.product_name"
placeholder="输入产品名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="交易ID">
<el-input
v-model="filters.transaction_id"
placeholder="输入交易ID"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="消费时间" class="md:col-span-2">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleTimeRangeChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="金额范围">
<div class="flex gap-2">
<el-input
v-model="filters.min_amount"
placeholder="最小金额"
clearable
@input="handleFilterChange"
class="flex-1"
/>
<span class="text-gray-400 self-center">-</span>
<el-input
v-model="filters.max_amount"
placeholder="最大金额"
clearable
@input="handleFilterChange"
class="flex-1"
/>
</div>
</FilterItem>
</div>
<template #stats>
共找到 {{ total }} 条消费记录
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadTransactions">应用筛选</el-button>
<el-button type="success" @click="showExportDialog">
<Download class="w-4 h-4 mr-1" />
导出数据
</el-button>
</template>
</FilterSection>
</template>
<!-- 表格区域 -->
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="transactions"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="transaction_id" label="交易ID" min-width="180">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.transaction_id }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="企业名称" min-width="150" v-if="!singleUserMode">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.company_name || '未知企业' }}</div>
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="product_name" label="产品名称" min-width="150">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.product_name || '未知产品' }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="amount" label="消费金额" width="120">
<template #default="{ row }">
<span class="font-semibold text-red-600">¥{{ formatPrice(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="消费时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.created_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button
size="small"
type="primary"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- 分页 -->
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- 导出数据弹窗 -->
<el-dialog
v-model="exportDialogVisible"
title="导出消费记录"
width="600px"
class="export-dialog"
>
<div class="space-y-6">
<!-- 导出范围设置 -->
<div class="export-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">筛选条件</h4>
<div class="grid grid-cols-1 gap-4">
<!-- 企业选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">选择企业</label>
<el-select
v-model="exportOptions.companyIds"
multiple
filterable
remote
reserve-keyword
placeholder="搜索并选择企业(不选则导出所有)"
class="w-full"
clearable
:remote-method="handleCompanySearch"
:loading="companyLoading"
@focus="loadCompanyOptions"
@visible-change="handleCompanyVisibleChange"
>
<el-option
v-for="company in companyOptions"
:key="company.id"
:label="company.company_name"
:value="company.id"
/>
<div v-if="companyLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
<!-- 日期范围 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">时间范围</label>
<el-date-picker
v-model="exportOptions.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-full"
/>
</div>
<!-- 产品选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">选择产品</label>
<el-select
v-model="exportOptions.productIds"
multiple
filterable
remote
reserve-keyword
placeholder="搜索并选择产品(不选则导出所有)"
class="w-full"
clearable
:remote-method="handleProductSearch"
:loading="productLoading"
@focus="loadProductOptions"
@visible-change="handleProductVisibleChange"
>
<el-option
v-for="product in productOptions"
:key="product.id"
:label="product.name"
:value="product.id"
/>
<div v-if="productLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
</div>
</div>
<!-- 导出格式选择 -->
<div class="export-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">导出格式</h4>
<el-radio-group v-model="exportOptions.format">
<el-radio value="excel">Excel (.xlsx)</el-radio>
<el-radio value="csv">CSV (.csv)</el-radio>
</el-radio-group>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="exportDialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleExport"
:loading="exportLoading"
>
<Download class="w-4 h-4 mr-1" />
开始导出
</el-button>
</div>
</template>
</el-dialog>
<!-- 消费记录详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="消费记录详情"
width="800px"
class="transaction-detail-dialog"
>
<div v-if="selectedTransaction" class="space-y-6">
<!-- 基本信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">交易ID</span>
<span class="info-value font-mono">{{ selectedTransaction?.transaction_id || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">产品名称</span>
<span class="info-value">{{ selectedTransaction?.product_name || '未知产品' }}</span>
</div>
<div class="info-item">
<span class="info-label">消费金额</span>
<span class="info-value text-red-600 font-semibold">¥{{ formatPrice(selectedTransaction?.amount) }}</span>
</div>
<div class="info-item" v-if="!singleUserMode">
<span class="info-label">企业名称</span>
<span class="info-value">{{ selectedTransaction?.company_name || '未知企业' }}</span>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">时间信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">消费时间</span>
<span class="info-value">{{ formatDateTime(selectedTransaction?.created_at) }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formatDateTime(selectedTransaction?.updated_at) }}</span>
</div>
</div>
</div>
</div>
<div v-else class="flex justify-center items-center py-8">
<el-loading size="large" />
</div>
</el-dialog>
</template>
</ListPageLayout>
<!-- 导出弹窗 -->
<ExportDialog
v-model="exportDialogVisible"
title="导出钱包交易记录"
:loading="exportLoading"
:show-company-select="true"
:show-product-select="true"
:show-recharge-type-select="false"
:show-status-select="false"
:show-date-range="true"
@confirm="handleExport"
/>
</template>
<script setup>
import { userApi, walletTransactionApi } from '@/api'
import ExportDialog from '@/components/common/ExportDialog.vue'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { Back, Close, Download, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 响应式数据
const loading = ref(false)
const transactions = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const detailDialogVisible = ref(false)
const selectedTransaction = ref(null)
const dateRange = ref([])
// 单用户模式
const singleUserMode = ref(false)
const currentUser = ref(null)
// 导出相关
const exportDialogVisible = ref(false)
const exportLoading = ref(false)
// 筛选条件
const filters = reactive({
company_name: '',
product_name: '',
transaction_id: '',
min_amount: '',
max_amount: '',
start_time: '',
end_time: ''
})
// 初始化
onMounted(async () => {
await checkSingleUserMode()
await loadTransactions()
})
// 检查单用户模式
const checkSingleUserMode = async () => {
const userId = route.query.user_id
if (userId) {
singleUserMode.value = true
await loadUserInfo(userId)
}
}
// 加载用户信息
const loadUserInfo = async (userId) => {
try {
const response = await userApi.getUserDetail(userId)
currentUser.value = response.data
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败')
}
}
// 加载消费记录
const loadTransactions = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
// 单用户模式添加用户ID筛选
if (singleUserMode.value && currentUser.value?.id) {
params.user_id = currentUser.value.id
}
const response = await walletTransactionApi.getAdminWalletTransactions(params)
transactions.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载消费记录失败:', error)
ElMessage.error('加载消费记录失败')
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 格式化日期时间
const formatDateTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadTransactions()
}
// 处理时间范围变化
const handleTimeRangeChange = (range) => {
if (range && range.length === 2) {
filters.start_time = range[0]
filters.end_time = range[1]
} else {
filters.start_time = ''
filters.end_time = ''
}
currentPage.value = 1
loadTransactions()
}
// 重置筛选
const resetFilters = () => {
Object.keys(filters).forEach(key => {
filters[key] = ''
})
dateRange.value = []
currentPage.value = 1
loadTransactions()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadTransactions()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadTransactions()
}
// 退出单用户模式
const exitSingleUserMode = () => {
singleUserMode.value = false
currentUser.value = null
router.replace({ name: 'AdminTransactions' })
loadTransactions()
}
// 返回用户管理
const goBackToUsers = () => {
router.push({ name: 'AdminUsers' })
}
// 查看详情
const handleViewDetail = (transaction) => {
selectedTransaction.value = transaction
detailDialogVisible.value = true
}
// 导出相关方法
const showExportDialog = () => {
exportDialogVisible.value = true
}
const handleExport = async (options) => {
try {
exportLoading.value = true
// 构建导出参数
const params = {
format: options.format
}
// 添加企业筛选
if (options.companyIds.length > 0) {
params.user_ids = options.companyIds.join(',')
}
// 添加产品筛选
if (options.productIds.length > 0) {
params.product_ids = options.productIds.join(',')
}
// 添加时间范围筛选
if (options.dateRange && options.dateRange.length === 2) {
params.start_time = options.dateRange[0]
params.end_time = options.dateRange[1]
}
// 调用导出API
const response = await walletTransactionApi.exportAdminWalletTransactions(params)
// 创建下载链接
const blob = new Blob([response], {
type: options.format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv;charset=utf-8'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `钱包交易记录.${options.format === 'excel' ? 'xlsx' : 'csv'}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
exportDialogVisible.value = false
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败,请稍后重试')
} finally {
exportLoading.value = false
}
}
// 监听路由变化
watch(() => route.query.user_id, async (newUserId) => {
if (newUserId) {
singleUserMode.value = true
await loadUserInfo(newUserId)
} else {
singleUserMode.value = false
currentUser.value = null
}
await loadTransactions()
})
</script>
<style scoped>
.info-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.info-label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
min-width: 100px;
}
.info-value {
font-size: 0.875rem;
color: #111827;
flex: 1;
}
.transaction-detail-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.transaction-detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.transaction-detail-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.transaction-detail-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
/* 导出弹窗样式 */
.export-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.export-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.export-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.export-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
.export-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
background: #f9fafb;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.info-label {
min-width: auto;
}
}
</style>

View File

@@ -0,0 +1,746 @@
<template>
<ListPageLayout
title="API调用记录管理"
subtitle="管理系统内所有用户的API调用记录"
>
<!-- 单用户模式显示 -->
<template #stats v-if="singleUserMode">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>当前用户{{ currentUser?.company_name || currentUser?.phone }}</span>
<span class="text-gray-400">(仅显示当前用户)</span>
</div>
</template>
<!-- 单用户模式操作按钮 -->
<template #actions v-if="singleUserMode">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>{{ currentUser?.company_name || currentUser?.phone }}</span>
</div>
<el-button size="small" @click="exitSingleUserMode">
<Close class="w-4 h-4 mr-1" />
取消
</el-button>
<el-button size="small" type="primary" @click="goBackToUsers">
<Back class="w-4 h-4 mr-1" />
返回用户管理
</el-button>
</div>
</template>
<!-- 筛选区域 -->
<template #filters>
<FilterSection>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<FilterItem label="企业名称" v-if="!singleUserMode">
<el-input
v-model="filters.company_name"
placeholder="输入企业名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="产品名称">
<el-input
v-model="filters.product_name"
placeholder="输入产品名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="交易ID">
<el-input
v-model="filters.transaction_id"
placeholder="输入交易ID"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="调用状态">
<el-select
v-model="filters.status"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="全部" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="处理中" value="pending" />
</el-select>
</FilterItem>
<FilterItem label="调用时间" class="md:col-span-2">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleTimeRangeChange"
class="w-full"
/>
</FilterItem>
</div>
<template #stats>
共找到 {{ total }} 条调用记录
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadApiCalls">应用筛选</el-button>
<el-button type="success" @click="showExportDialog">
<Download class="w-4 h-4 mr-1" />
导出数据
</el-button>
</template>
</FilterSection>
</template>
<!-- 表格区域 -->
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="apiCalls"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="transaction_id" label="交易ID" min-width="180">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.transaction_id }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="企业名称" min-width="150" v-if="!singleUserMode">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.company_name || '未知企业' }}</div>
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="product_name" label="接口名称" min-width="150">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.product_name || '未知接口' }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag
:type="getStatusType(row.status)"
size="small"
effect="light"
>
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error_msg" label="错误信息" min-width="200">
<template #default="{ row }">
<div v-if="row.translated_error_msg" class="error-info-cell">
<div class="translated-error">
{{ row.translated_error_msg }}
</div>
</div>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="cost" label="费用" width="100">
<template #default="{ row }">
<span v-if="row.cost" class="font-semibold text-red-600">¥{{ formatPrice(row.cost) }}</span>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="client_ip" label="客户端IP" width="140">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.client_ip }}</span>
</template>
</el-table-column>
<el-table-column prop="start_at" label="调用时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.start_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.start_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="end_at" label="完成时间" width="160">
<template #default="{ row }">
<div v-if="row.end_at" class="text-sm">
<div class="text-gray-900">{{ formatDate(row.end_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.end_at) }}</div>
</div>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button
size="small"
type="primary"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- 分页 -->
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- API调用详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="API调用详情"
width="800px"
class="api-call-detail-dialog"
>
<div v-if="selectedApiCall" class="space-y-6">
<!-- 基本信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">交易ID</span>
<span class="info-value font-mono">{{ selectedApiCall?.transaction_id || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<el-tag :type="getStatusType(selectedApiCall?.status)" size="small">
{{ getStatusText(selectedApiCall?.status) }}
</el-tag>
</span>
</div>
<div class="info-item">
<span class="info-label">接口名称</span>
<span class="info-value">{{ selectedApiCall?.product_name || '未知接口' }}</span>
</div>
<div class="info-item">
<span class="info-label">费用</span>
<span class="info-value">
<span v-if="selectedApiCall?.cost" class="text-red-600 font-semibold">¥{{ formatPrice(selectedApiCall.cost) }}</span>
<span v-else class="text-gray-400">-</span>
</span>
</div>
<div class="info-item">
<span class="info-label">客户端IP</span>
<span class="info-value font-mono">{{ selectedApiCall?.client_ip || '-' }}</span>
</div>
<div class="info-item" v-if="!singleUserMode">
<span class="info-label">企业名称</span>
<span class="info-value">{{ selectedApiCall?.company_name || '未知企业' }}</span>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">时间信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">调用时间</span>
<span class="info-value">{{ formatDateTime(selectedApiCall?.start_at) }}</span>
</div>
<div class="info-item">
<span class="info-label">完成时间</span>
<span class="info-value">
<span v-if="selectedApiCall?.end_at">{{ formatDateTime(selectedApiCall.end_at) }}</span>
<span v-else class="text-gray-400">-</span>
</span>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="selectedApiCall?.translated_error_msg" class="error-info">
<h4 class="error-title">错误信息</h4>
<div class="error-content">
<div class="error-message">
<div class="translated-error">
{{ selectedApiCall.translated_error_msg }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex justify-center items-center py-8">
<el-loading size="large" />
</div>
</el-dialog>
</template>
</ListPageLayout>
<!-- 导出弹窗 -->
<ExportDialog
v-model="exportDialogVisible"
title="导出API调用记录"
:loading="exportLoading"
:show-company-select="true"
:show-product-select="true"
:show-recharge-type-select="false"
:show-status-select="false"
:show-date-range="true"
@confirm="handleExport"
/>
</template>
<script setup>
import { apiCallApi, userApi } from '@/api'
import ExportDialog from '@/components/common/ExportDialog.vue'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { Back, Close, Download, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 响应式数据
const loading = ref(false)
const apiCalls = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const detailDialogVisible = ref(false)
const selectedApiCall = ref(null)
const dateRange = ref([])
// 单用户模式
const singleUserMode = ref(false)
const currentUser = ref(null)
// 导出相关
const exportDialogVisible = ref(false)
const exportLoading = ref(false)
// 筛选条件
const filters = reactive({
company_name: '',
product_name: '',
transaction_id: '',
status: '',
start_time: '',
end_time: ''
})
// 初始化
onMounted(async () => {
await checkSingleUserMode()
await loadApiCalls()
})
// 检查单用户模式
const checkSingleUserMode = async () => {
const userId = route.query.user_id
if (userId) {
singleUserMode.value = true
await loadUserInfo(userId)
}
}
// 加载用户信息
const loadUserInfo = async (userId) => {
try {
const response = await userApi.getUserDetail(userId)
currentUser.value = response.data
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败')
}
}
// 加载API调用记录
const loadApiCalls = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
// 单用户模式添加用户ID筛选
if (singleUserMode.value && currentUser.value?.id) {
params.user_id = currentUser.value.id
}
const response = await apiCallApi.getAdminApiCalls(params)
apiCalls.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载API调用记录失败:', error)
ElMessage.error('加载API调用记录失败')
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 格式化日期时间
const formatDateTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 获取状态类型
const getStatusType = (status) => {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'danger'
case 'pending':
return 'warning'
default:
return 'info'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'pending':
return '处理中'
default:
return '未知'
}
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadApiCalls()
}
// 处理时间范围变化
const handleTimeRangeChange = (range) => {
if (range && range.length === 2) {
filters.start_time = range[0]
filters.end_time = range[1]
} else {
filters.start_time = ''
filters.end_time = ''
}
currentPage.value = 1
loadApiCalls()
}
// 重置筛选
const resetFilters = () => {
Object.keys(filters).forEach(key => {
filters[key] = ''
})
dateRange.value = []
currentPage.value = 1
loadApiCalls()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadApiCalls()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadApiCalls()
}
// 退出单用户模式
const exitSingleUserMode = () => {
singleUserMode.value = false
currentUser.value = null
router.replace({ name: 'AdminUsage' })
loadApiCalls()
}
// 返回用户管理
const goBackToUsers = () => {
router.push({ name: 'AdminUsers' })
}
// 查看详情
const handleViewDetail = (apiCall) => {
selectedApiCall.value = apiCall
detailDialogVisible.value = true
}
// 监听路由变化
watch(() => route.query.user_id, async (newUserId) => {
if (newUserId) {
singleUserMode.value = true
await loadUserInfo(newUserId)
} else {
singleUserMode.value = false
currentUser.value = null
}
await loadApiCalls()
})
// 导出相关方法
const showExportDialog = () => {
exportDialogVisible.value = true
}
const handleExport = async (options) => {
try {
exportLoading.value = true
// 构建导出参数
const params = {
format: options.format
}
// 添加企业筛选
if (options.companyIds.length > 0) {
params.user_ids = options.companyIds.join(',')
}
// 添加产品筛选
if (options.productIds.length > 0) {
params.product_ids = options.productIds.join(',')
}
// 添加时间范围筛选
if (options.dateRange && options.dateRange.length === 2) {
params.start_time = options.dateRange[0]
params.end_time = options.dateRange[1]
}
// 调用导出API
const response = await apiCallApi.exportAdminApiCalls(params)
// 创建下载链接
const blob = new Blob([response], {
type: options.format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv;charset=utf-8'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `API调用记录.${options.format === 'excel' ? 'xlsx' : 'csv'}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
exportDialogVisible.value = false
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败,请稍后重试')
} finally {
exportLoading.value = false
}
}
</script>
<style scoped>
.info-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.info-label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
min-width: 100px;
}
.info-value {
font-size: 0.875rem;
color: #111827;
flex: 1;
}
/* 错误信息样式 */
.error-info {
background: rgba(254, 242, 242, 0.8);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 16px;
}
.error-title {
font-size: 16px;
font-weight: 600;
color: #dc2626;
margin-bottom: 12px;
}
.error-content {
space-y: 2;
}
.error-message {
font-size: 14px;
color: #7f1d1d;
line-height: 1.5;
}
.translated-error {
font-weight: 500;
color: #dc2626;
margin-bottom: 4px;
}
.error-info-cell {
max-width: 200px;
}
.error-info-cell .translated-error {
font-weight: 500;
color: #dc2626;
margin-bottom: 4px;
font-size: 13px;
line-height: 1.4;
}
.api-call-detail-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.api-call-detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.api-call-detail-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.api-call-detail-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.info-label {
min-width: auto;
}
}
</style>

File diff suppressed because it is too large Load Diff